From fa1987add60cba3da1a16e44ecd23f4dda671361 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Thu, 27 Aug 2026 07:34:37 +0200 Subject: [PATCH] KeePassXC as a secret provider - pull secrets from a .kdbx via keepassxc-cli, bulk or inline refs --- .../provider-icons/KeePassXC.svelte | 16 + src/lib/components/provider-icons/index.ts | 3 + src/lib/server/secretproviders/index.ts | 4 +- src/lib/server/secretproviders/keepass.ts | 399 ++++++++++++++++++ src/lib/server/secretproviders/shared.ts | 15 +- .../settings/secrets/ProviderModal.svelte | 24 +- 6 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/provider-icons/KeePassXC.svelte create mode 100644 src/lib/server/secretproviders/keepass.ts diff --git a/src/lib/components/provider-icons/KeePassXC.svelte b/src/lib/components/provider-icons/KeePassXC.svelte new file mode 100644 index 0000000..0b5a543 --- /dev/null +++ b/src/lib/components/provider-icons/KeePassXC.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/src/lib/components/provider-icons/index.ts b/src/lib/components/provider-icons/index.ts index a0a2b6b..44ff527 100644 --- a/src/lib/components/provider-icons/index.ts +++ b/src/lib/components/provider-icons/index.ts @@ -7,6 +7,7 @@ import Doppler from './Doppler.svelte'; import Bitwarden from './Bitwarden.svelte'; import ProtonPass from './ProtonPass.svelte'; import AzureKeyVault from './AzureKeyVault.svelte'; +import KeePassXC from './KeePassXC.svelte'; /** Brand icon for a secret-provider type. Falls back to a generic key icon. */ export function getProviderIcon(type: string): Component { @@ -26,6 +27,8 @@ export function getProviderIcon(type: string): Component { return ProtonPass; case 'azure-kv': return AzureKeyVault; + case 'keepass': + return KeePassXC; default: return KeyRound; // any future provider until it has a brand icon } diff --git a/src/lib/server/secretproviders/index.ts b/src/lib/server/secretproviders/index.ts index 4e12b1e..a808c73 100644 --- a/src/lib/server/secretproviders/index.ts +++ b/src/lib/server/secretproviders/index.ts @@ -21,6 +21,7 @@ import { dopplerProvider } from './doppler'; import { bitwardenProvider } from './bitwarden'; import { protonProvider } from './proton'; import { azureKvProvider } from './azure-kv'; +import { keepassProvider } from './keepass'; // Registered providers. Adding a new backend means dropping a file in this // directory and registering it here; each implements the SecretProvider @@ -33,7 +34,8 @@ const providers: Record = { [dopplerProvider.type]: dopplerProvider as SecretProvider, [bitwardenProvider.type]: bitwardenProvider as SecretProvider, [protonProvider.type]: protonProvider as SecretProvider, - [azureKvProvider.type]: azureKvProvider as SecretProvider + [azureKvProvider.type]: azureKvProvider as SecretProvider, + [keepassProvider.type]: keepassProvider as SecretProvider }; /** Returns the provider for a stored type, or undefined if unknown. */ diff --git a/src/lib/server/secretproviders/keepass.ts b/src/lib/server/secretproviders/keepass.ts new file mode 100644 index 0000000..c6f45e7 --- /dev/null +++ b/src/lib/server/secretproviders/keepass.ts @@ -0,0 +1,399 @@ +/** + * KeePassXC provider. + * + * An adapter around an operator-installed `keepassxc-cli` executable reading a + * local `.kdbx` database (bind-mounted into the Dockhand container). Dockhand + * does not distribute the client and does not implement the KeePass format. + * + * keepassxc-cli is STATELESS per call: each command opens the database, reads, + * and exits. The master password is fed on STDIN (never on argv, so it is not + * exposed on the process's /proc//cmdline); an optional key file is passed + * by path. Two resolution modes: + * - Bulk pull: `ls -R -f` lists every entry path; each entry's Password becomes + * one env var keyed by the entry title (must be a valid env-var name). + * - Inline references: `keepass://GROUP/ENTRY/FIELD` - the last segment is the + * attribute (Password by default is what a bare title maps to; here FIELD is + * explicit), everything before it is the entry path (groups may nest). Resolved + * with `show -q -s -a `, which prints the bare value on stdout. + */ + +import { spawn } from 'node:child_process'; +import { isAbsolute } from 'node:path'; +import type { KeePassConfig, SecretProvider, TestConnectionResult } from './shared'; + +const DEFAULT_CLI_PATH = '/usr/bin/keepassxc-cli'; +const COMMAND_TIMEOUT_MS = 30_000; +const LIST_OUTPUT_LIMIT = 10 * 1024 * 1024; +const SHOW_OUTPUT_LIMIT = 1 * 1024 * 1024; +const STDERR_OUTPUT_LIMIT = 64 * 1024; +const KILL_GRACE_MS = 2_000; + +// keepass://GROUP/ENTRY/FIELD. Groups may nest (any number of "/"), so the entry +// path is everything up to the LAST segment, which is the attribute name. The ref +// is passed to keepassxc-cli as single argv elements (spawn shell:false), so spaces +// in a group/entry/field are safe; we only forbid control chars and empty segments. +const KEEPASS_PREFIX = 'keepass://'; +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +class KeePassCliError extends Error { + /** True for a FAILURE TO RUN the command (timeout, output-limit kill, spawn error) + * as opposed to the command running and exiting non-zero (bad group / password / + * entry). A bulk group listing may treat a non-operational error as "empty group", + * but an operational error must fail the deploy - it does NOT mean the group is + * empty, and swallowing it ships a stack with blank secrets. */ + readonly operational: boolean; + constructor(message: string, operational = false) { + super(message); + this.name = 'KeePassCliError'; + this.operational = operational; + } +} + +interface ParsedRef { + entry: string; + field: string; +} + +function executablePath(): string { + const override = process.env.DOCKHAND_KEEPASSXC_CLI_PATH?.trim(); + if (!override) return DEFAULT_CLI_PATH; + if (!isAbsolute(override)) { + // Operational: we can't even locate the executable, same class as ENOENT - it must + // fail the deploy, not be swallowed as an empty group. + throw new KeePassCliError('KeePassXC keepassxc-cli executable path must be absolute', true); + } + return override; +} + +function spawnFailure(error: unknown): KeePassCliError { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code === 'ENOENT') return new KeePassCliError('KeePassXC keepassxc-cli executable was not found', true); + if (code === 'EACCES') return new KeePassCliError('KeePassXC keepassxc-cli executable is not executable', true); + return new KeePassCliError('KeePassXC keepassxc-cli executable could not be started', true); +} + +/** + * keepassxc-cli exits 1 for EVERY error (no distinct codes) and we never retain its + * stderr (it can echo secret data), so we can't read the exact cause. But the COMMAND + * narrows the likely reason, which is far more useful than one generic "wrong + * password/entry" string that made a bad group selector look like a credentials error. + */ +function nonZeroExitMessage(command: string | undefined): string { + switch (command) { + case 'db-info': + return 'KeePassXC could not open the database (wrong password or key file, or the .kdbx path is unreadable)'; + case 'ls': + return 'KeePassXC could not list the group (the group name may not exist, or the database could not be opened)'; + case 'show': + return 'KeePassXC could not read the entry (the entry path may not exist)'; + default: + return 'KeePassXC keepassxc-cli command failed'; + } +} + +/** Path arguments (database, key file) must be absolute and free of NUL / control chars. */ +function safePath(value: string, label: string): string { + const p = typeof value === 'string' ? value.trim() : ''; + if (!p) throw new KeePassCliError(`KeePassXC ${label} is empty`); + if (!isAbsolute(p) || p.includes('\0') || /[\n\r\t]/.test(p) || p.startsWith('-')) { + throw new KeePassCliError(`KeePassXC ${label} is invalid`); + } + return p; +} + +/** + * Base argv for a database command: the .kdbx path plus the auth flags. Password (if any) + * goes on stdin, not here. `--no-password` is passed when no password is configured so a + * key-file-only database opens without prompting. + */ +function dbArgs(config: KeePassConfig): { path: string; keyFile?: string; hasPassword: boolean; flags: string[] } { + const path = safePath(config.databasePath, 'database path'); + const hasPassword = typeof config.password === 'string' && config.password.length > 0; + const keyFile = config.keyFilePath ? safePath(config.keyFilePath, 'key file path') : undefined; + if (!hasPassword && !keyFile) { + throw new KeePassCliError('KeePassXC needs a password or a key file'); + } + const flags: string[] = ['-q']; + if (keyFile) flags.push('-k', keyFile); + if (!hasPassword) flags.push('--no-password'); + return { path, keyFile, hasPassword, flags }; +} + +/** + * Run one keepassxc-cli process without a shell, feeding the master password on stdin, + * while bounding lifetime and output. stdout is captured; stderr byte count only. + */ +async function executeKeepass(args: string[], password: string | undefined, stdoutLimit: number): Promise { + const stdoutChunks: Buffer[] = []; + try { + return await new Promise((resolve, reject) => { + let stdoutBytes = 0; + let stderrBytes = 0; + let failure: KeePassCliError | undefined; + let settled = false; + let killTimer: ReturnType | undefined; + + const child = spawn(executablePath(), args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false + }); + + const terminate = (error: KeePassCliError) => { + if (failure) return; + failure = error; + try { + child.kill('SIGTERM'); + } catch { + // the close/error handler still settles the command + } + killTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // the close/error handler still settles the command + } + } + }, KILL_GRACE_MS); + }; + + const timeout = setTimeout( + () => terminate(new KeePassCliError('KeePassXC keepassxc-cli command timed out', true)), + COMMAND_TIMEOUT_MS + ); + + // The password (plus the newline keepassxc-cli waits for) is written to stdin and + // the stream is ended. A key-file-only db gets no password: stdin is just closed. + child.stdin?.on('error', () => { + /* a closed stdin (fast exit / no prompt) is not fatal on its own */ + }); + if (password) { + const buf = Buffer.from(`${password}\n`, 'utf8'); + child.stdin?.end(buf, () => buf.fill(0)); + } else { + child.stdin?.end(); + } + + child.stdout?.on('data', (chunk: Buffer | string) => { + if (failure) return; + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += data.length; + if (stdoutBytes > stdoutLimit) { + terminate(new KeePassCliError('KeePassXC keepassxc-cli command exceeded the stdout limit', true)); + return; + } + stdoutChunks.push(Buffer.from(data)); + }); + + // stderr is deliberately never retained: only its byte count is observed. + child.stderr?.on('data', (chunk: Buffer | string) => { + if (failure) return; + stderrBytes += Buffer.byteLength(chunk); + if (stderrBytes > STDERR_OUTPUT_LIMIT) { + terminate(new KeePassCliError('KeePassXC keepassxc-cli command exceeded the stderr limit', true)); + } + }); + + child.once('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (killTimer) clearTimeout(killTimer); + reject(failure ?? spawnFailure(error)); + }); + + child.once('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (killTimer) clearTimeout(killTimer); + if (failure) { + reject(failure); + return; + } + if (code !== 0) { + reject(new KeePassCliError(nonZeroExitMessage(args[0]))); + return; + } + resolve(Buffer.concat(stdoutChunks, stdoutBytes)); + }); + }); + } finally { + for (const chunk of stdoutChunks) chunk.fill(0); + } +} + +/** `keepass://GROUP/ENTRY/FIELD` -> `{ entry, field }`. The last path segment is the field. */ +function parseReference(value: string): ParsedRef { + const ref = value.trim(); + if (!ref.startsWith(KEEPASS_PREFIX)) { + throw new KeePassCliError('KeePassXC reference must start with keepass://'); + } + const rest = ref.slice(KEEPASS_PREFIX.length); + if (rest.includes('\0') || /[\n\r\t]/.test(rest)) { + throw new KeePassCliError('KeePassXC reference contains invalid characters'); + } + const segments = rest.split('/'); + if (segments.length < 2 || segments.some((s) => s.length === 0)) { + throw new KeePassCliError('KeePassXC reference must be keepass://GROUP/ENTRY/FIELD (a field is required)'); + } + const field = segments.pop() as string; + const entry = segments.join('/'); + // keepassxc-cli parses a leading "-" as a flag, not a positional argument, so an + // operator-controlled entry/field beginning with "-" would inject a flag. Reject it + // (a real KeePass title/attribute never starts with "-" in practice). + if (entry.startsWith('-') || field.startsWith('-')) { + throw new KeePassCliError('KeePassXC reference entry and field must not start with "-"'); + } + return { entry, field }; +} + +function sanitizedError(error: unknown): string { + return error instanceof KeePassCliError ? error.message : 'KeePassXC keepassxc-cli operation failed'; +} + +export const keepassProvider: SecretProvider = { + type: 'keepass', + label: 'KeePassXC', + supportsReferences: true, + supportsBulk: true, + + isReference(value: unknown): value is string { + return typeof value === 'string' && value.trim().startsWith(KEEPASS_PREFIX); + }, + + async testConnection(config: KeePassConfig): Promise { + try { + const { path, flags } = dbArgs(config); + // `db-info` opens the database and prints metadata; it fails on a bad + // password/key file, so a clean exit proves the credentials open the db. + const output = await executeKeepass(['db-info', ...flags, path], config.password, SHOW_OUTPUT_LIMIT); + output.fill(0); + return { ok: true }; + } catch (error: unknown) { + return { ok: false, error: sanitizedError(error) }; + } + }, + + async resolveSecretReferences( + config: KeePassConfig, + refs: string[], + logPrefix = '' + ): Promise> { + const { path, flags } = dbArgs(config); + const unique = [...new Set(refs)]; + const resolved = new Map(); + for (const raw of unique) { + let parsed: ParsedRef; + try { + parsed = parseReference(raw); + } catch { + continue; // malformed reference: leave the literal in place + } + let output: Buffer | undefined; + try { + // `show -s -a ` prints the bare attribute value. + output = await executeKeepass( + ['show', ...flags, path, parsed.entry, '-s', '-a', parsed.field], + config.password, + SHOW_OUTPUT_LIMIT + ); + const value = output.toString('utf8').replace(/\r?\n$/, ''); + if (value.includes('\0')) throw new KeePassCliError('field value is not valid text'); + resolved.set(raw, value); + } catch (error: unknown) { + console.warn(`${logPrefix}KeePassXC reference did not resolve: ${sanitizedError(error)}`); + } finally { + output?.fill(0); + } + } + return resolved; + }, + + async resolveBulk(config: KeePassConfig, selector: string): Promise> { + try { + const { path, flags } = dbArgs(config); + const group = typeof selector === 'string' ? selector.trim() : ''; + if (group.includes('\0') || /[\n\r\t]/.test(group) || group.startsWith('-')) { + throw new KeePassCliError('KeePassXC group selector is invalid'); + } + // `ls -R -f` prints every entry as a full path (groups end with "/", entries do not). + // An empty selector lists from the root group. `-f` gives flat, full paths. + const lsArgs = group ? ['ls', ...flags, '-R', '-f', path, group] : ['ls', ...flags, '-R', '-f', path]; + let listOut: Buffer | undefined; + let entryPaths: string[]; + try { + listOut = await executeKeepass(lsArgs, config.password, LIST_OUTPUT_LIMIT); + entryPaths = listOut + .toString('utf8') + .split('\n') + .map((l) => l.replace(/\r$/, '')) // strip only CR, not a legit trailing space in a title + .filter((l) => l.length > 0 && !l.endsWith('/')); // drop group lines + } catch (error: unknown) { + // An OPERATIONAL failure (timeout, output-limit kill, spawn error) does NOT + // mean the group is empty - swallowing it would deploy a stack with blank + // secrets while reporting success. Always propagate it (fail the deploy). + if (error instanceof KeePassCliError && error.operational) throw error; + // With a GROUP selector, a non-operational ls failure (the command ran and + // exited non-zero) is almost always "group not found" - a mistyped selector + // must NOT fail the whole deploy, so treat it as no secrets. With NO selector + // (whole db) it's a real db/credentials error and propagates to the outer catch. + if (!group) throw error; + console.warn(`KeePassXC bulk group "${group}" did not list (treated as empty): ${sanitizedError(error)}`); + return Object.create(null) as Record; + } finally { + listOut?.fill(0); + } + + const result = Object.create(null) as Record; + const seen = new Set(); + for (const entryPath of entryPaths) { + // keepassxc-cli reads a leading "-" as a flag; skip a path whose first + // segment starts with "-" so it can never inject one into `show`. + if (entryPath.startsWith('-')) { + console.warn(`KeePassXC bulk skipped entry "${entryPath}" (path starts with "-")`); + continue; + } + // Key the env var by the entry's leaf title; skip titles that are not valid + // env-var names rather than failing the whole pull. Warn so a title with a + // dash/space (e.g. "test-test") isn't silently dropped - a common surprise. + const title = entryPath.split('/').pop() ?? ''; + if (!ENV_NAME_RE.test(title) || DANGEROUS_KEYS.has(title)) { + console.warn(`KeePassXC bulk skipped entry "${entryPath}": title "${title}" is not a valid environment variable name`); + continue; + } + // The same title in different subgroups is common in KeePass and can't map to + // two env vars. Keep the FIRST resolved one and skip later duplicates with a + // warning, rather than failing the whole deploy. + if (seen.has(title)) { + console.warn(`KeePassXC bulk skipped a duplicate entry title "${title}" (${entryPath}); the first match is used`); + continue; + } + let showOut: Buffer | undefined; + try { + showOut = await executeKeepass( + ['show', ...flags, path, entryPath, '-s', '-a', 'Password'], + config.password, + SHOW_OUTPUT_LIMIT + ); + const value = showOut.toString('utf8').replace(/\r?\n$/, ''); + if (!value || value.includes('\0')) continue; // no usable password on this entry + seen.add(title); + result[title] = value; + } catch (error: unknown) { + // One unreadable entry (e.g. a title the flat ls path can't re-address) is + // skipped, not fatal - mirrors resolveSecretReferences. The db-open failure + // already surfaced on the ls above, so this is genuinely per-entry. + console.warn(`KeePassXC bulk entry did not resolve: ${sanitizedError(error)}`); + } finally { + showOut?.fill(0); + } + } + return result; + } catch (error: unknown) { + if (error instanceof KeePassCliError) throw error; + throw new KeePassCliError(sanitizedError(error)); + } + } +}; diff --git a/src/lib/server/secretproviders/shared.ts b/src/lib/server/secretproviders/shared.ts index ce9a1b5..2087bd0 100644 --- a/src/lib/server/secretproviders/shared.ts +++ b/src/lib/server/secretproviders/shared.ts @@ -34,6 +34,7 @@ export type SecretProviderType = | 'bitwarden' | 'proton' | 'azure-kv' + | 'keepass' // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | (string & {}); @@ -212,6 +213,15 @@ export interface AzureKvConfig { clientSecret: string; } +export interface KeePassConfig { + /** Absolute path to the `.kdbx` database, as seen inside the Dockhand container. */ + databasePath: string; + /** Master password for the database (encrypted at rest). Optional if a key file is set. */ + password?: string; + /** Absolute path to an optional key file, as seen inside the container. */ + keyFilePath?: string; +} + /** Persisted (encrypted) config, discriminated by the provider `type`. */ export type SecretProviderConfig = | ServiceAccountConfig @@ -221,7 +231,8 @@ export type SecretProviderConfig = | DopplerConfig | BitwardenConfig | ProtonConfig - | AzureKvConfig; + | AzureKvConfig + | KeePassConfig; /** * Config keys that hold a SECRET across every provider type. Only these are stripped @@ -230,7 +241,7 @@ export type SecretProviderConfig = * a non-secret coordinate the user needs to see and edit. Keep in sync with the * `type: 'password'` fields in ProviderModal.svelte's PROVIDER_FIELDS. */ -export const SECRET_CONFIG_KEYS = new Set(['token', 'clientSecret']); +export const SECRET_CONFIG_KEYS = new Set(['token', 'clientSecret', 'password']); /** * A masked secret key that only makes sense alongside a non-secret partner field, as a diff --git a/src/routes/settings/secrets/ProviderModal.svelte b/src/routes/settings/secrets/ProviderModal.svelte index 498e862..0664d70 100644 --- a/src/routes/settings/secrets/ProviderModal.svelte +++ b/src/routes/settings/secrets/ProviderModal.svelte @@ -30,6 +30,7 @@ { value: 'bitwarden', label: 'Bitwarden Secrets Manager' }, { value: 'proton', label: 'Proton Pass' }, { value: 'azure-kv', label: 'Azure Key Vault' }, + { value: 'keepass', label: 'KeePassXC' }, ]; // Config fields per provider type, matching the config shapes in @@ -78,6 +79,11 @@ { key: 'clientId', label: 'Client ID', type: 'text', required: true, placeholder: 'application (client) ID', hint: 'The service-principal (app registration) client ID.' }, { key: 'clientSecret', label: 'Client secret', type: 'password', required: true, placeholder: 'app registration client secret', hint: 'A client secret for the app registration, with Get/List secret permission on the vault.' }, ], + keepass: [ + { key: 'databasePath', label: 'Database path', type: 'text', required: true, placeholder: '/secrets/passwords.kdbx', hint: 'Absolute path to the .kdbx file as seen inside the Dockhand container (bind-mount it read-only). keepassxc-cli must be installed in the container.' }, + { key: 'password', label: 'Master password', type: 'password', required: false, requiredWhen: (c) => !(c.keyFilePath ?? '').trim(), placeholder: 'database master password', hint: 'The database master password. Optional if a key file is provided instead (or in addition).' }, + { key: 'keyFilePath', label: 'Key file path', type: 'text', required: false, placeholder: '/secrets/db.keyx', hint: 'Optional absolute path to the database key file, as seen inside the container.' }, + ], }; export function providerTypeLabel(type: string): string { @@ -119,6 +125,11 @@ label: 'Key Vault', placeholder: 'any value enables bulk pull', hint: 'Bulk-load every secret in the vault. Set any value to enable it; leave blank to inject only inline azurekv:// references.' + }, + 'keepass': { + label: 'Group', + placeholder: 'e.g. dockhand (leave blank for inline refs only)', + hint: 'Bulk-load every entry under this group as ENV=. Leave blank to inject only inline keepass:// references.' } }; @@ -449,7 +460,7 @@ {/each} - {#if formType === 'bitwarden' || formType === 'proton'} + {#if formType === 'bitwarden' || formType === 'proton' || formType === 'keepass'}
@@ -462,6 +473,17 @@ DOCKHAND_BWS_PATH process override).

+ {:else if formType === 'keepass'} +

+ + + KeePassXC requires the official keepassxc-cli client installed in the + container (or an absolute DOCKHAND_KEEPASSXC_CLI_PATH process override), + and the .kdbx database bind-mounted into the container read-only at the + path above. Supports both a bulk group pull and inline keepass:// + references. + +

{:else}