fix: keyring in container

This commit is contained in:
Shotaro Nakamura
2026-06-20 23:10:42 +09:00
parent 46f5856230
commit 30639c5ae4
5 changed files with 139 additions and 4 deletions
+3
View File
@@ -5,6 +5,9 @@ PORT=8787
CSBIE_SERVER_PORT=8787 CSBIE_SERVER_PORT=8787
CSBIE_UI_DEV_PORT=5173 CSBIE_UI_DEV_PORT=5173
CSBIE_DATABASE_PATH=./data/csbie.sqlite 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_ORIGIN=http://127.0.0.1:5173
CSBIE_CORS_ORIGIN=http://127.0.0.1:5173 CSBIE_CORS_ORIGIN=http://127.0.0.1:5173
CSBIE_SESSION_COOKIE=csbie_session CSBIE_SESSION_COOKIE=csbie_session
+3
View File
@@ -16,6 +16,9 @@ Before running, setup .env, refering `.env.example`.
#### With docker #### 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: docker-compose.yml:
```yaml ```yaml
+126 -4
View File
@@ -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 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) => { 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 <T>(account: string): Promise<T> => { export const readSecret = async <T>(account: string): Promise<T> => {
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}`) if (!secret) throw new Error(`secret not found: ${account}`)
return JSON.parse(secret) as T return JSON.parse(secret) as T
} }
export const deleteSecret = async (account: string) => { 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)
} }
+2
View File
@@ -14,6 +14,8 @@ RUN bun --filter @repo/csbie build
FROM oven/bun:1-slim AS runtime FROM oven/bun:1-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV CSBIE_DATABASE_PATH=/app/data/csbie.sqlite 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 WORKDIR /app
COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist
COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist
+5
View File
@@ -3,6 +3,7 @@ const requiredForSetup = ['CSBIE_SETUP_PASSWORD', 'CSBIE_SETUP_PASSWORD_HASH'] a
const runtimeDefaults = { const runtimeDefaults = {
PORT: '8787', PORT: '8787',
CSBIE_DATABASE_PATH: './data/csbie.sqlite', CSBIE_DATABASE_PATH: './data/csbie.sqlite',
CSBIE_KEYRING_BACKEND: 'platform',
CSBIE_ORIGIN: 'http://127.0.0.1:5173', CSBIE_ORIGIN: 'http://127.0.0.1:5173',
CSBIE_RP_ID: '127.0.0.1', 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')) { 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') messages.push('Set SBI_AUTH_BASE_URL and SBI_MTS_BASE_URL before connecting an SBI session')
} }