fix: keyring in container
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user