diff --git a/.env.example b/.env.example index 0428b10..9079d89 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ PORT=8787 CSBIE_SERVER_PORT=8787 CSBIE_UI_DEV_PORT=5173 CSBIE_DATABASE_PATH=./data/csbie.sqlite +CSBIE_KEYRING_BACKEND=platform +CSBIE_KEYRING_SQLITE_PATH=./data/csbie.keyring.sqlite +CSBIE_KEYRING_SECRET= CSBIE_ORIGIN=http://127.0.0.1:5173 CSBIE_CORS_ORIGIN=http://127.0.0.1:5173 CSBIE_SESSION_COOKIE=csbie_session diff --git a/README.md b/README.md index 24ced37..6147f8e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ Before running, setup .env, refering `.env.example`. #### With docker +Docker uses `CSBIE_KEYRING_BACKEND=sqlite` because platform keyrings are not available in +headless containers. Set `CSBIE_KEYRING_SECRET` in `.env` before saving SBI passkeys. + docker-compose.yml: ```yaml diff --git a/apps/csbie-server/src/security/keyring.ts b/apps/csbie-server/src/security/keyring.ts index 6d49a5a..d56be44 100644 --- a/apps/csbie-server/src/security/keyring.ts +++ b/apps/csbie-server/src/security/keyring.ts @@ -1,17 +1,139 @@ -import { getPassword, setPassword, deletePassword } from '@napi-rs/keyring/keytar' +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { Database } from 'bun:sqlite' const SERVICE = 'csbie' +const SQLITE_BACKEND = 'sqlite' +const PLATFORM_BACKEND = 'platform' + +type KeyringBackend = typeof PLATFORM_BACKEND | typeof SQLITE_BACKEND + +type Keytar = typeof import('@napi-rs/keyring/keytar') + +let keytar: Keytar | undefined +let sqlite: Database | undefined + +const keyringBackend = (): KeyringBackend => { + const backend = process.env.CSBIE_KEYRING_BACKEND ?? PLATFORM_BACKEND + if (backend === PLATFORM_BACKEND || backend === SQLITE_BACKEND) return backend + throw new Error(`unsupported CSBIE_KEYRING_BACKEND: ${backend}`) +} + +const loadKeytar = async () => { + keytar ??= await import('@napi-rs/keyring/keytar') + return keytar +} + +const sqlitePath = () => + resolve( + process.env.CSBIE_KEYRING_SQLITE_PATH ?? + process.env.CSBIE_DATABASE_PATH?.replace(/\.sqlite$/u, '.keyring.sqlite') ?? + './data/csbie.keyring.sqlite', + ) + +const sqliteKey = () => { + const secret = process.env.CSBIE_KEYRING_SECRET + if (!secret) { + throw new Error('CSBIE_KEYRING_SECRET is required when CSBIE_KEYRING_BACKEND=sqlite') + } + return createHash('sha256').update(secret).digest() +} + +const keyringDb = () => { + if (sqlite) return sqlite + const path = sqlitePath() + mkdirSync(dirname(path), { recursive: true }) + sqlite = new Database(path, { create: true, strict: true }) + sqlite.run('PRAGMA journal_mode = WAL') + sqlite.run(` + CREATE TABLE IF NOT EXISTS keyring_secrets ( + service TEXT NOT NULL, + account TEXT NOT NULL, + nonce TEXT NOT NULL, + tag TEXT NOT NULL, + ciphertext TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (service, account) + ) + `) + return sqlite +} + +const encrypt = (plaintext: string) => { + const nonce = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', sqliteKey(), nonce) + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + return { + nonce: nonce.toString('base64url'), + tag: cipher.getAuthTag().toString('base64url'), + ciphertext: ciphertext.toString('base64url'), + } +} + +const decrypt = (row: { nonce: string; tag: string; ciphertext: string }) => { + const decipher = createDecipheriv('aes-256-gcm', sqliteKey(), Buffer.from(row.nonce, 'base64url')) + decipher.setAuthTag(Buffer.from(row.tag, 'base64url')) + return Buffer.concat([ + decipher.update(Buffer.from(row.ciphertext, 'base64url')), + decipher.final(), + ]).toString('utf8') +} export const saveSecret = async (account: string, secret: unknown) => { - await setPassword(SERVICE, account, JSON.stringify(secret)) + const payload = JSON.stringify(secret) + if (keyringBackend() === PLATFORM_BACKEND) { + const { setPassword } = await loadKeytar() + await setPassword(SERVICE, account, payload) + return + } + + const encrypted = encrypt(payload) + keyringDb() + .query( + ` + INSERT INTO keyring_secrets (service, account, nonce, tag, ciphertext, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(service, account) DO UPDATE SET + nonce = excluded.nonce, + tag = excluded.tag, + ciphertext = excluded.ciphertext, + updated_at = excluded.updated_at + `, + ) + .run(SERVICE, account, encrypted.nonce, encrypted.tag, encrypted.ciphertext, Date.now()) } export const readSecret = async (account: string): Promise => { - const secret = await getPassword(SERVICE, account) + if (keyringBackend() === PLATFORM_BACKEND) { + const { getPassword } = await loadKeytar() + const secret = await getPassword(SERVICE, account) + if (!secret) throw new Error(`secret not found: ${account}`) + return JSON.parse(secret) as T + } + + const row = keyringDb() + .query<{ nonce: string; tag: string; ciphertext: string }, [string, string]>( + ` + SELECT nonce, tag, ciphertext + FROM keyring_secrets + WHERE service = ? AND account = ? + `, + ) + .get(SERVICE, account) + const secret = row ? decrypt(row) : undefined if (!secret) throw new Error(`secret not found: ${account}`) return JSON.parse(secret) as T } export const deleteSecret = async (account: string) => { - await deletePassword(SERVICE, account) + if (keyringBackend() === PLATFORM_BACKEND) { + const { deletePassword } = await loadKeytar() + await deletePassword(SERVICE, account) + return + } + + keyringDb() + .query('DELETE FROM keyring_secrets WHERE service = ? AND account = ?') + .run(SERVICE, account) } diff --git a/apps/csbie/Dockerfile b/apps/csbie/Dockerfile index cf3f959..a1f9a35 100644 --- a/apps/csbie/Dockerfile +++ b/apps/csbie/Dockerfile @@ -14,6 +14,8 @@ RUN bun --filter @repo/csbie build FROM oven/bun:1-slim AS runtime ENV NODE_ENV=production ENV CSBIE_DATABASE_PATH=/app/data/csbie.sqlite +ENV CSBIE_KEYRING_BACKEND=sqlite +ENV CSBIE_KEYRING_SQLITE_PATH=/app/data/csbie.keyring.sqlite WORKDIR /app COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist diff --git a/scripts/check-env.ts b/scripts/check-env.ts index 82bc6c7..271b222 100644 --- a/scripts/check-env.ts +++ b/scripts/check-env.ts @@ -3,6 +3,7 @@ const requiredForSetup = ['CSBIE_SETUP_PASSWORD', 'CSBIE_SETUP_PASSWORD_HASH'] a const runtimeDefaults = { PORT: '8787', CSBIE_DATABASE_PATH: './data/csbie.sqlite', + CSBIE_KEYRING_BACKEND: 'platform', CSBIE_ORIGIN: 'http://127.0.0.1:5173', CSBIE_RP_ID: '127.0.0.1', } @@ -39,6 +40,10 @@ if (!requiredForSetup.some(present)) { ) } +if (process.env.CSBIE_KEYRING_BACKEND === 'sqlite' && !present('CSBIE_KEYRING_SECRET')) { + messages.push('Set CSBIE_KEYRING_SECRET when CSBIE_KEYRING_BACKEND=sqlite') +} + if (!present('SBI_AUTH_BASE_URL') || !present('SBI_MTS_BASE_URL')) { messages.push('Set SBI_AUTH_BASE_URL and SBI_MTS_BASE_URL before connecting an SBI session') }