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