From ef6c22a6eafa430212d6278dca4444a4b5c93ae9 Mon Sep 17 00:00:00 2001 From: jarek Date: Mon, 17 Aug 2026 09:50:34 +0200 Subject: [PATCH] Proton Pass as a secret provider via an operator-installed pass-cli client --- .../provider-icons/ProtonPass.svelte | 84 ++++ src/lib/server/secretproviders/proton.ts | 449 ++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 src/lib/components/provider-icons/ProtonPass.svelte create mode 100644 src/lib/server/secretproviders/proton.ts diff --git a/src/lib/components/provider-icons/ProtonPass.svelte b/src/lib/components/provider-icons/ProtonPass.svelte new file mode 100644 index 0000000..2e79755 --- /dev/null +++ b/src/lib/components/provider-icons/ProtonPass.svelte @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/server/secretproviders/proton.ts b/src/lib/server/secretproviders/proton.ts new file mode 100644 index 0000000..d75a8f0 --- /dev/null +++ b/src/lib/server/secretproviders/proton.ts @@ -0,0 +1,449 @@ +/** + * Proton Pass provider. + * + * Like the Bitwarden provider, this is only an adapter around an + * operator-installed official `pass-cli` executable. Dockhand does not + * distribute the client and does not implement Proton's API or cryptography. + * + * pass-cli is STATEFUL: a session is created by `login`, used by subsequent + * commands, and torn down by `logout`. Each Dockhand call runs the whole + * login/operate/logout cycle inside a private, ephemeral session directory + * (PROTON_PASS_SESSION_DIR) so concurrent calls never share credentials and no + * session state survives the call. + * + * Two resolution modes: + * - Bulk pull: `item list --vault-name --output json --show-secrets`, + * mapping each item to one env var (title -> primary secret). + * - Inline references: `pass://SHARE_ID/ITEM_ID[/FIELD]`, resolved one field at + * a time via `item view --output json`. + * + * The access token is passed via the PROTON_PASS_PERSONAL_ACCESS_TOKEN + * environment variable (pass-cli's own env channel for the PAT), never on argv, + * so it is not exposed on the login process's /proc//cmdline. `login` with + * no --pat routes to PAT login when that env var is set. + */ + +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import type { ProtonConfig, SecretProvider, TestConnectionResult } from './shared'; + +const DEFAULT_PASS_CLI_PATH = '/usr/local/bin/pass-cli'; +const LOGIN_TIMEOUT_MS = 30_000; +const COMMAND_TIMEOUT_MS = 30_000; +const LOGOUT_TIMEOUT_MS = 10_000; +const KILL_GRACE_MS = 2_000; +const LIST_OUTPUT_LIMIT = 10 * 1024 * 1024; +const VIEW_OUTPUT_LIMIT = 1 * 1024 * 1024; +const LOGIN_OUTPUT_LIMIT = 64 * 1024; +const STDERR_OUTPUT_LIMIT = 64 * 1024; + +// pass://SHARE_ID/ITEM_ID/FIELD. Share/item ids are opaque base64url-ish tokens +// (Proton uses url-safe base64 with '=' padding). The FIELD segment is required: +// with a field, `item view` prints the bare field value, which is unambiguous; +// without one it would print the whole item and there is no single "the secret". +const PASS_REF_RE = /^pass:\/\/[A-Za-z0-9_=-]+\/[A-Za-z0-9_=-]+\/[^/\s]+$/; +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +const CHILD_ENV_ALLOWLIST = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR' +] as const; + +class PassCliError extends Error { + constructor(message: string) { + super(message); + this.name = 'PassCliError'; + } +} + +interface CommandLimits { + timeoutMs: number; + stdoutBytes: number; + stderrBytes: number; + /** Set only on the `login` call; passed to pass-cli via its own env channel. */ + accessToken?: string; +} + +function executablePath(): string { + const override = process.env.DOCKHAND_PASS_CLI_PATH?.trim(); + if (!override) return DEFAULT_PASS_CLI_PATH; + if (!isAbsolute(override)) { + throw new PassCliError('Proton Pass pass-cli executable path must be absolute'); + } + return override; +} + +function childEnvironment(sessionDir: string, accessToken?: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + HOME: sessionDir, + XDG_CONFIG_HOME: sessionDir, + PROTON_PASS_SESSION_DIR: sessionDir, + PROTON_PASS_NO_UPDATE_CHECK: '1' + }; + for (const key of CHILD_ENV_ALLOWLIST) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + if (accessToken !== undefined) env.PROTON_PASS_PERSONAL_ACCESS_TOKEN = accessToken; + return env; +} + +function spawnFailure(error: unknown): PassCliError { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code === 'ENOENT') return new PassCliError('Proton Pass pass-cli executable was not found'); + if (code === 'EACCES') + return new PassCliError('Proton Pass pass-cli executable is not executable'); + return new PassCliError('Proton Pass pass-cli executable could not be started'); +} + +/** Execute one pass-cli process without a shell while bounding lifetime and output. */ +async function executePassCli( + sessionDir: string, + args: string[], + limits: CommandLimits +): Promise { + const stdoutChunks: Buffer[] = []; + try { + return await new Promise((resolve, reject) => { + let stdoutBytes = 0; + let stderrBytes = 0; + let failure: PassCliError | undefined; + let settled = false; + let killTimer: ReturnType | undefined; + + const child = spawn(executablePath(), args, { + env: childEnvironment(sessionDir, limits.accessToken), + stdio: ['ignore', 'pipe', 'pipe'], + shell: false + }); + + const terminate = (error: PassCliError) => { + if (failure) return; + failure = error; + try { + child.kill('SIGTERM'); + } catch { + // The close/error handler below still settles the command. + } + killTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // The close/error handler below still settles the command. + } + } + }, KILL_GRACE_MS); + }; + + const timeout = setTimeout( + () => terminate(new PassCliError('Proton Pass pass-cli command timed out')), + limits.timeoutMs + ); + + child.stdout?.on('data', (chunk: Buffer | string) => { + if (failure) return; + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += data.length; + if (stdoutBytes > limits.stdoutBytes) { + terminate(new PassCliError('Proton Pass pass-cli command exceeded the stdout limit')); + 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 > limits.stderrBytes) { + terminate(new PassCliError('Proton Pass pass-cli command exceeded the stderr limit')); + } + }); + + 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 PassCliError('Proton Pass pass-cli command failed')); + return; + } + resolve(Buffer.concat(stdoutChunks, stdoutBytes)); + }); + }); + } finally { + for (const chunk of stdoutChunks) chunk.fill(0); + } +} + +/** + * Runs `fn` inside a fresh, private pass-cli session: login, then the callback, + * then an unconditional logout, then the session dir is removed. The PAT never + * survives the call. + */ +async function withSession( + token: string, + fn: (session: string) => Promise +): Promise { + let sessionDir: string | undefined; + try { + sessionDir = await mkdtemp(join(tmpdir(), 'dockhand-pass-')); + await chmod(sessionDir, 0o700); + } catch { + if (sessionDir) await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined); + throw new PassCliError('Proton Pass pass-cli session state could not be created'); + } + + try { + const loginOutput = await executePassCli(sessionDir, ['login'], { + timeoutMs: LOGIN_TIMEOUT_MS, + stdoutBytes: LOGIN_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT, + accessToken: token + }); + loginOutput.fill(0); + try { + return await fn(sessionDir); + } finally { + // Best-effort teardown of the remote session; a failure here must not + // mask the real result. The session dir is removed regardless below. + await executePassCli(sessionDir, ['logout', '--force'], { + timeoutMs: LOGOUT_TIMEOUT_MS, + stdoutBytes: LOGIN_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT + }).catch(() => undefined); + } + } finally { + await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +function accessToken(config: ProtonConfig): string { + const token = typeof config?.token === 'string' ? config.token.trim() : ''; + if (!token) throw new PassCliError('Proton Pass access token is empty'); + if (token.includes('\0') || /\s/.test(token)) { + throw new PassCliError('Proton Pass access token is malformed'); + } + return token; +} + +function vaultName(selector: string): string { + const name = typeof selector === 'string' ? selector.trim() : ''; + if (!name) throw new PassCliError('Proton Pass vault selector is empty'); + if (name.includes('\0') || name.startsWith('-')) { + throw new PassCliError('Proton Pass vault selector is invalid'); + } + return name; +} + +function passReference(value: string): string { + const ref = value.trim(); + if (!PASS_REF_RE.test(ref)) { + throw new PassCliError('Proton Pass reference must be pass://SHARE_ID/ITEM_ID[/FIELD]'); + } + return ref; +} + +function sanitizedError(error: unknown): string { + return error instanceof PassCliError ? error.message : 'Proton Pass pass-cli operation failed'; +} + +function parseJson(output: Buffer): unknown { + try { + return JSON.parse(output.toString('utf8')); + } catch { + throw new PassCliError('Proton Pass pass-cli returned invalid JSON'); + } +} + +function asObject(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; +} + +/** + * Reads the primary injectable secret from one item of `item list --show-secrets`. + * The item's payload is pass-cli's own `Item` serde (see pass-domain): the + * content lives under `content` (an ItemData), whose `content` is an + * externally-tagged ItemContent enum (`{ "Login": { password, ... } }`). A + * Login item yields its password; otherwise the item's note text is used, else + * the first hidden extra field. Items with no readable secret are reported as + * null so the caller can skip them. + */ +function primarySecret(item: Record): string | null { + const data = asObject(item.content); + if (!data) return null; + + const inner = asObject(data.content); + if (inner) { + const login = asObject(inner.Login); + if (login && typeof login.password === 'string' && login.password) { + return login.password.includes('\0') ? null : login.password; + } + } + + if (typeof data.note === 'string' && data.note && !data.note.includes('\0')) { + return data.note; + } + + const extras = Array.isArray(data.extra_fields) ? data.extra_fields : []; + for (const field of extras) { + const f = asObject(field); + const content = f && asObject(f.content); + const hidden = content?.Hidden; + if (typeof hidden === 'string' && hidden && !hidden.includes('\0')) return hidden; + } + return null; +} + +/** + * Maps a bulk `item list --show-secrets` payload to env vars. Each item + * contributes one entry keyed by its title (which must already be a valid + * env-var name; other items are skipped) with its primary secret as the value. + */ +function bulkRecord(payload: unknown): Record { + const wrapper = asObject(payload); + const items = wrapper && Array.isArray(wrapper.items) ? wrapper.items : null; + if (!items) throw new PassCliError('Proton Pass pass-cli returned an invalid item list'); + + const result = Object.create(null) as Record; + const seen = new Set(); + + for (const entry of items) { + const item = asObject(entry); + if (!item) throw new PassCliError('Proton Pass pass-cli returned an invalid item'); + const data = asObject(item.content); + const title = data && typeof data.title === 'string' ? data.title.trim() : ''; + if (!ENV_NAME_RE.test(title) || DANGEROUS_KEYS.has(title)) { + // A title that is not a valid env-var name cannot be injected; skip it + // rather than fail the whole pull. + continue; + } + const value = primarySecret(item); + if (value === null) continue; // no injectable secret on this item + if (seen.has(title)) { + throw new PassCliError('Proton Pass vault has duplicate item titles'); + } + seen.add(title); + result[title] = value; + } + + return result; +} + +export const protonProvider: SecretProvider = { + type: 'proton', + label: 'Proton Pass', + supportsReferences: true, + supportsBulk: true, + + isReference(value: unknown): value is string { + return typeof value === 'string' && value.trim().startsWith('pass://'); + }, + + async testConnection(config: ProtonConfig): Promise { + try { + const token = accessToken(config); + await withSession(token, async (session) => { + const output = await executePassCli(session, ['info', '--output', 'json'], { + timeoutMs: COMMAND_TIMEOUT_MS, + stdoutBytes: LOGIN_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT + }); + output.fill(0); + }); + return { ok: true }; + } catch (error: unknown) { + return { ok: false, error: sanitizedError(error) }; + } + }, + + async resolveSecretReferences( + config: ProtonConfig, + refs: string[], + logPrefix = '' + ): Promise> { + const token = accessToken(config); + const unique = [...new Set(refs)]; + return withSession(token, async (session) => { + const resolved = new Map(); + for (const raw of unique) { + let ref: string; + try { + ref = passReference(raw); + } catch { + continue; // malformed reference: leave the literal in place + } + let output: Buffer | undefined; + try { + // A field-targeted `item view` prints the bare field value on stdout + // (no JSON envelope); a trailing newline is the only decoration. + output = await executePassCli(session, ['item', 'view', ref], { + timeoutMs: COMMAND_TIMEOUT_MS, + stdoutBytes: VIEW_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT + }); + const value = output.toString('utf8').replace(/\r?\n$/, ''); + if (value.includes('\0')) throw new PassCliError('field value is not valid text'); + resolved.set(raw, value); + } catch (error: unknown) { + // A single lookup failure is logged and skipped; the caller keeps + // the literal. Transport-level failures still propagate below. + console.warn(`${logPrefix}Proton Pass reference did not resolve: ${sanitizedError(error)}`); + } finally { + output?.fill(0); + } + } + return resolved; + }); + }, + + async resolveBulk(config: ProtonConfig, selector: string): Promise> { + try { + const token = accessToken(config); + const vault = vaultName(selector); + return await withSession(token, async (session) => { + let output: Buffer | undefined; + try { + output = await executePassCli( + session, + ['item', 'list', '--vault-name', vault, '--output', 'json', '--show-secrets'], + { + timeoutMs: COMMAND_TIMEOUT_MS, + stdoutBytes: LIST_OUTPUT_LIMIT, + stderrBytes: STDERR_OUTPUT_LIMIT + } + ); + return bulkRecord(parseJson(output)); + } finally { + output?.fill(0); + } + }); + } catch (error: unknown) { + if (error instanceof PassCliError) throw error; + throw new PassCliError(sanitizedError(error)); + } + } +};