KeePassXC as a secret provider - pull secrets from a .kdbx via keepassxc-cli, bulk or inline refs

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:34:37 +02:00
1 parent 674d80b178
commit fa1987add6
6 files changed
+457 -4

No files matched your search

@@ -0,0 +1,16 @@
<script lang="ts">
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
</script>
<!-- KeePassXC brand mark, monochrome (currentColor). -->
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class={className}
fill="currentColor"
aria-label="KeePassXC"
>
<path
d="M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24m0 .8A11 11 0 0 1 23.2 12 11 11 0 0 1 12 23.2 11 11 0 0 1 .8 12 11 11 0 0 1 12 .8m0 1.9-1.7.1A1.7 1.7 0 0 0 12 5a1.7 1.7 0 0 0 1.7-2.2zM7.2 4a9 9 0 0 0-4.5 8 9.3 9.3 0 0 0 18.6 0 9 9 0 0 0-4.5-8 5 5 0 0 1-2.7 5.4l-.1 2 1.5 1.5-1.5 1.5 1 1-1 1v1.8l-2 2.1-2.1-2v-9A5 5 0 0 1 7.2 4m3.4 6.5v7h.7v-7z"
/>
</svg>
@@ -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
}
+3 -1
View File
@@ -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<string, SecretProvider> = {
[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. */
+399
View File
@@ -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/<pid>/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 <entry> -q -s -a <FIELD>`, 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<Buffer> {
const stdoutChunks: Buffer[] = [];
try {
return await new Promise<Buffer>((resolve, reject) => {
let stdoutBytes = 0;
let stderrBytes = 0;
let failure: KeePassCliError | undefined;
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | 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<KeePassConfig> = {
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<TestConnectionResult> {
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<Map<string, string>> {
const { path, flags } = dbArgs(config);
const unique = [...new Set(refs)];
const resolved = new Map<string, string>();
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 <db> <entry> -s -a <field>` 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<Record<string, string>> {
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<string, string>;
} finally {
listOut?.fill(0);
}
const result = Object.create(null) as Record<string, string>;
const seen = new Set<string>();
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));
}
}
};
+13 -2
View File
@@ -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
@@ -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=<entry password>. Leave blank to inject only inline keepass:// references.'
}
};
</script>
@@ -449,7 +460,7 @@
</div>
{/each}
</div>
{#if formType === 'bitwarden' || formType === 'proton'}
{#if formType === 'bitwarden' || formType === 'proton' || formType === 'keepass'}
<!-- Fixed min-height so switching between the bitwarden (shorter) and proton
(taller) external-CLI notes doesn't jump the dialog's vertical size. -->
<div class="min-h-16">
@@ -462,6 +473,17 @@
<code>DOCKHAND_BWS_PATH</code> process override).
</span>
</p>
{:else if formType === 'keepass'}
<p class="flex items-start gap-2 text-xs text-muted-foreground">
<Info class="h-3.5 w-3.5 shrink-0 mt-0.5 text-amber-500" />
<span>
KeePassXC requires the official <code>keepassxc-cli</code> client installed in the
container (or an absolute <code>DOCKHAND_KEEPASSXC_CLI_PATH</code> process override),
and the <code>.kdbx</code> database bind-mounted into the container read-only at the
path above. Supports both a bulk group pull and inline <code>keepass://</code>
references.
</span>
</p>
{:else}
<p class="flex items-start gap-2 text-xs text-muted-foreground">
<Info class="h-3.5 w-3.5 shrink-0 mt-0.5 text-amber-500" />