459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
/**
|
|
* 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 <selector> --output json --show-secrets`,
|
|
* mapping each item to one env var (title -> primary secret).
|
|
* - Inline references: `pass://VAULT/ITEM/FIELD` (id or name), resolved one field at
|
|
* a time via `item view <uri> --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/<pid>/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';
|
|
import { stripSurroundingQuotes } 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://VAULT/ITEM/FIELD. VAULT and ITEM may be an opaque share/item id OR a
|
|
// human-readable name (pass-cli resolves names to ids itself); names can contain
|
|
// spaces, so those two segments allow any char except a path separator or a control
|
|
// char (newline/tab). The FIELD segment is required and kept tight (no spaces): 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". The ref is passed
|
|
// to pass-cli as a single argv element (spawn shell:false), so a space is safe.
|
|
const PASS_REF_RE = /^pass:\/\/[^/\n\r\t]+\/[^/\n\r\t]+\/[^/\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',
|
|
// Dockhand runs headless in a container with no system keyring / D-Bus, where
|
|
// pass-cli's default key provider fails with NoStorageAccess before any auth.
|
|
// The filesystem provider stores the key under our isolated sessionDir instead.
|
|
// An operator with a real keyring can override via the container env (#1440).
|
|
PROTON_PASS_KEY_PROVIDER: process.env.PROTON_PASS_KEY_PROVIDER?.trim() || 'fs'
|
|
};
|
|
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<Buffer> {
|
|
const stdoutChunks: Buffer[] = [];
|
|
try {
|
|
return await new Promise<Buffer>((resolve, reject) => {
|
|
let stdoutBytes = 0;
|
|
let stderrBytes = 0;
|
|
let failure: PassCliError | undefined;
|
|
let settled = false;
|
|
let killTimer: ReturnType<typeof setTimeout> | 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<T>(
|
|
token: string,
|
|
fn: (session: string) => Promise<T>
|
|
): Promise<T> {
|
|
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://VAULT/ITEM/FIELD (id or name)');
|
|
}
|
|
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<string, unknown> | null {
|
|
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : 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, unknown>): 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<string, string> {
|
|
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<string, string>;
|
|
const seen = new Set<string>();
|
|
|
|
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<ProtonConfig> = {
|
|
type: 'proton',
|
|
label: 'Proton Pass',
|
|
supportsReferences: true,
|
|
supportsBulk: true,
|
|
|
|
isReference(value: unknown): value is string {
|
|
return typeof value === 'string' && stripSurroundingQuotes(value).startsWith('pass://');
|
|
},
|
|
|
|
async testConnection(config: ProtonConfig): Promise<TestConnectionResult> {
|
|
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<Map<string, string>> {
|
|
const token = accessToken(config);
|
|
const unique = [...new Set(refs)];
|
|
return withSession(token, async (session) => {
|
|
const resolved = new Map<string, string>();
|
|
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<Record<string, string>> {
|
|
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));
|
|
}
|
|
}
|
|
};
|