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
+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 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 <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}`)
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)
}
+2
View File
@@ -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