@opengeni/vaults-protonpass 0.0.0-stage → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.ts ADDED
@@ -0,0 +1,578 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ accessSync,
4
+ chmodSync,
5
+ constants as fsConstants,
6
+ createReadStream,
7
+ lstatSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ realpathSync,
11
+ rmSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
16
+ import { VaultProviderError, type VaultProviderAuthority } from "./contract";
17
+ import {
18
+ NOT_AUTHENTICATED,
19
+ SESSION_INVALIDATED,
20
+ isDefiniteMutationFailure,
21
+ loginFailure,
22
+ mapProtonFailure,
23
+ outcomeUnknown,
24
+ } from "./errors";
25
+ import { type ProcessResult, type ProcessRunner, runProcess } from "./runner";
26
+ import { InfoOutput } from "./schema";
27
+
28
+ /** The only pass-cli release whose argv, JSON output and errors this adapter was built against. */
29
+ export const PROTON_PASS_CLI_VERSION = "2.4.2";
30
+ /** A PAT login session is treated as stale before Proton's 2 h session lifetime. */
31
+ export const PROTON_PASS_SESSION_MAX_AGE_MS = 105 * 60_000;
32
+ export const PROTON_PASS_IDLE_CLOSE_MS = 15 * 60_000;
33
+ export const PROTON_PASS_COMMAND_TIMEOUT_MS = 60_000;
34
+ export const PROTON_PASS_MAX_STDOUT_BYTES = 32 * 1024 * 1024;
35
+ /** pass/src/monitor MAX_REASON_LENGTH. */
36
+ export const PROTON_PASS_MAX_REASON_CHARS = 300;
37
+ /** pass-cli processes allowed at once across every Proton connection in this process. */
38
+ export const PROTON_PASS_MAX_PROCESSES = 4;
39
+
40
+ const MAX_STDERR_BYTES = 16 * 1024;
41
+ const SMALL_STDOUT_BYTES = 256 * 1024;
42
+ const PASSTHROUGH_ENV = [
43
+ "HTTPS_PROXY",
44
+ "HTTP_PROXY",
45
+ "NO_PROXY",
46
+ "https_proxy",
47
+ "http_proxy",
48
+ "no_proxy",
49
+ "SSL_CERT_FILE",
50
+ "SSL_CERT_DIR",
51
+ ] as const;
52
+
53
+ export interface ProtonCliOptions {
54
+ /** Absolute path to an operator-installed pass-cli; defaults to `pass-cli` on PATH. */
55
+ binaryPath?: string;
56
+ /** Exact `pass-cli --version` semver to accept (default 2.4.2). */
57
+ expectedVersion?: string;
58
+ /** Optional SHA-256 (hex) the binary must match. */
59
+ expectedSha256?: string;
60
+ /** Parent of per-connection state directories (default `<tmpdir>/opengeni-protonpass`). */
61
+ stateRoot?: string;
62
+ commandTimeoutMs?: number;
63
+ maxStdoutBytes?: number;
64
+ sessionMaxAgeMs?: number;
65
+ idleCloseMs?: number;
66
+ now?: () => number;
67
+ /** Process runner (tests). Defaults to a no-shell, bounded `spawn`. */
68
+ runner?: ProcessRunner;
69
+ /** Process environment used only to resolve PATH and proxy settings (tests). */
70
+ hostEnv?: Readonly<Record<string, string | undefined>>;
71
+ }
72
+
73
+ export interface ProtonCommand {
74
+ args: readonly string[];
75
+ stdin?: string;
76
+ /** Short action name for errors and the agent reason, e.g. "list items". */
77
+ subject: string;
78
+ authority?: VaultProviderAuthority;
79
+ signal?: AbortSignal;
80
+ /** Set for writes: failures are definite only with this context and a server verdict. */
81
+ mutationContext?: RegExp;
82
+ /** Error code when stdout exceeds its bound (list: inventory incomplete). */
83
+ overflowCode?: "provider_inventory_incomplete" | "provider_unavailable";
84
+ maxStdoutBytes?: number;
85
+ }
86
+
87
+ export interface ProtonSessionInfo {
88
+ tokenName: string | null;
89
+ agent: boolean;
90
+ }
91
+
92
+ // --- process-wide bookkeeping ------------------------------------------------
93
+
94
+ export interface SlotSemaphore {
95
+ /** Resolves with a release function once a slot is held; rejects if `signal` aborts first. */
96
+ acquire(signal?: AbortSignal): Promise<() => void>;
97
+ /** Slots held, including ones handed to a waiter that has not resumed yet. */
98
+ active(): number;
99
+ waiting(): number;
100
+ }
101
+
102
+ /**
103
+ * A FIFO counting semaphore. A released slot is handed straight to the next
104
+ * waiter (the count never drops in between), so a caller arriving while the
105
+ * waiter resumes cannot take the same slot and exceed `limit`.
106
+ */
107
+ export function createSlotSemaphore(limit: number): SlotSemaphore {
108
+ let active = 0;
109
+ const waiters: Array<() => void> = [];
110
+ function releaser(): () => void {
111
+ let released = false;
112
+ return () => {
113
+ if (released) return;
114
+ released = true;
115
+ const next = waiters.shift();
116
+ if (next) next();
117
+ else active -= 1;
118
+ };
119
+ }
120
+ return {
121
+ async acquire(signal) {
122
+ if (active < limit) {
123
+ active += 1;
124
+ return releaser();
125
+ }
126
+ await new Promise<void>((resolveSlot, reject) => {
127
+ if (signal?.aborted) {
128
+ reject(signal.reason ?? new Error("aborted"));
129
+ return;
130
+ }
131
+ const ready = () => {
132
+ signal?.removeEventListener("abort", onAbort);
133
+ resolveSlot();
134
+ };
135
+ const onAbort = () => {
136
+ const index = waiters.indexOf(ready);
137
+ if (index >= 0) waiters.splice(index, 1);
138
+ reject(signal?.reason ?? new Error("aborted"));
139
+ };
140
+ signal?.addEventListener("abort", onAbort, { once: true });
141
+ waiters.push(ready);
142
+ });
143
+ // The releasing caller transferred its slot; `active` already counts it.
144
+ return releaser();
145
+ },
146
+ active: () => active,
147
+ waiting: () => waiters.length,
148
+ };
149
+ }
150
+
151
+ const processSlots = createSlotSemaphore(PROTON_PASS_MAX_PROCESSES);
152
+ function acquireProcessSlot(signal?: AbortSignal): Promise<() => void> {
153
+ return processSlots.acquire(signal);
154
+ }
155
+
156
+ const liveStateDirs = new Set<string>();
157
+ let exitHookInstalled = false;
158
+ function trackStateDir(dir: string) {
159
+ liveStateDirs.add(dir);
160
+ if (exitHookInstalled) return;
161
+ exitHookInstalled = true;
162
+ process.once("exit", () => {
163
+ for (const path of liveStateDirs) {
164
+ try {
165
+ rmSync(path, { recursive: true, force: true });
166
+ } catch {
167
+ // Best effort at exit.
168
+ }
169
+ }
170
+ });
171
+ }
172
+
173
+ // --- helpers -------------------------------------------------------------------
174
+
175
+ function unavailable(message: string): VaultProviderError {
176
+ return new VaultProviderError("provider_unavailable", message);
177
+ }
178
+
179
+ function findOnPath(command: string, pathValue: string | undefined): string | null {
180
+ for (const dir of (pathValue ?? "").split(delimiter)) {
181
+ if (!dir || !isAbsolute(dir)) continue;
182
+ const candidate = join(dir, command);
183
+ try {
184
+ accessSync(candidate, fsConstants.X_OK);
185
+ if (statSync(candidate).isFile()) return candidate;
186
+ } catch {
187
+ // Not here.
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ async function sha256File(path: string): Promise<string> {
194
+ const hash = createHash("sha256");
195
+ for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer);
196
+ return hash.digest("hex");
197
+ }
198
+
199
+ /** Creates (or checks) a private directory owned by this process's user. */
200
+ export function ensurePrivateDirectory(path: string): void {
201
+ mkdirSync(path, { recursive: true, mode: 0o700 });
202
+ const stat = lstatSync(path);
203
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
204
+ throw unavailable("The Proton Pass state directory is not a private directory");
205
+ }
206
+ const uid = process.getuid?.();
207
+ if (uid !== undefined && stat.uid !== uid) {
208
+ throw unavailable("The Proton Pass state directory is owned by another user");
209
+ }
210
+ if ((stat.mode & 0o077) !== 0) chmodSync(path, 0o700);
211
+ }
212
+
213
+ /** A reason for Proton's agent audit log, built only from trusted identifiers. */
214
+ export function agentReason(subject: string, authority?: VaultProviderAuthority): string {
215
+ const clean = (value: string) => value.replace(/[^A-Za-z0-9:._-]/g, "").slice(0, 64);
216
+ const parts = [`Opengeni ${subject.replace(/[^A-Za-z0-9 ._-]/g, "")}`];
217
+ if (authority) {
218
+ parts.push(`workspace ${clean(authority.workspaceId)}`);
219
+ if (authority.sessionId) parts.push(`session ${clean(authority.sessionId)}`);
220
+ if (authority.turnId) parts.push(`turn ${clean(authority.turnId)}`);
221
+ if (authority.subjectId) parts.push(`subject ${clean(authority.subjectId)}`);
222
+ }
223
+ return parts.join("; ").slice(0, PROTON_PASS_MAX_REASON_CHARS);
224
+ }
225
+
226
+ function succeeded(result: ProcessResult): boolean {
227
+ return (
228
+ result.exitCode === 0 &&
229
+ result.signal === null &&
230
+ !result.timedOut &&
231
+ !result.aborted &&
232
+ !result.stdoutOverflow &&
233
+ result.spawnError === null
234
+ );
235
+ }
236
+
237
+ /**
238
+ * One logged-in pass-cli state for one personal access token. Every call is
239
+ * serialized (pass-cli keeps a single SQLite session store per directory), the
240
+ * session is renewed before Proton's 2 h limit, and the directory with its
241
+ * encrypted session is wiped after idling or on close.
242
+ */
243
+ export class ProtonCliSession {
244
+ private readonly runner: ProcessRunner;
245
+ private readonly now: () => number;
246
+ private readonly hostEnv: Readonly<Record<string, string | undefined>>;
247
+ private readonly encryptionKey = randomBytes(32).toString("hex");
248
+ private binary: Promise<string> | null = null;
249
+ private dir: string | null = null;
250
+ private loggedInAt: number | null = null;
251
+ private info: ProtonSessionInfo | null = null;
252
+ private queue: Promise<unknown> = Promise.resolve();
253
+ private pending = 0;
254
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
255
+ private closed = false;
256
+
257
+ constructor(
258
+ private readonly token: string,
259
+ private readonly options: ProtonCliOptions = {},
260
+ ) {
261
+ this.runner = options.runner ?? runProcess;
262
+ this.now = options.now ?? Date.now;
263
+ this.hostEnv = options.hostEnv ?? process.env;
264
+ }
265
+
266
+ /** The state directory while a session is open (tests and diagnostics). */
267
+ get stateDirectory(): string | null {
268
+ return this.dir;
269
+ }
270
+
271
+ get sessionInfo(): ProtonSessionInfo | null {
272
+ return this.info;
273
+ }
274
+
275
+ /** Runs one pass-cli command after ensuring a fresh login; returns stdout. */
276
+ run(command: ProtonCommand): Promise<string> {
277
+ return this.serialize(async () => (await this.execute(command)).stdout);
278
+ }
279
+
280
+ /** Login state for `validate`: logs in when needed and returns the token identity. */
281
+ ensureSession(signal?: AbortSignal): Promise<ProtonSessionInfo> {
282
+ return this.serialize(async () => {
283
+ await this.ensureLogin(signal);
284
+ return this.info!;
285
+ });
286
+ }
287
+
288
+ /** Allocates a private directory inside the session state for one download. */
289
+ privateScratchDir(): string {
290
+ const dir = this.requireDir();
291
+ return mkdtempSync(join(dir, "dl-"));
292
+ }
293
+
294
+ /** Logs out (best effort) and wipes the state directory. */
295
+ async close(): Promise<void> {
296
+ this.closed = true;
297
+ if (this.idleTimer) clearTimeout(this.idleTimer);
298
+ this.idleTimer = null;
299
+ await this.queue.catch(() => {});
300
+ await this.teardown();
301
+ }
302
+
303
+ private serialize<T>(work: () => Promise<T>): Promise<T> {
304
+ if (this.closed) return Promise.reject(unavailable("The Proton Pass connection is closed"));
305
+ this.pending += 1;
306
+ if (this.idleTimer) clearTimeout(this.idleTimer);
307
+ this.idleTimer = null;
308
+ const run = this.queue.then(work, work);
309
+ this.queue = run.then(
310
+ () => undefined,
311
+ () => undefined,
312
+ );
313
+ return run.finally(() => {
314
+ this.pending -= 1;
315
+ if (this.pending === 0 && !this.closed) this.armIdleClose();
316
+ });
317
+ }
318
+
319
+ private armIdleClose() {
320
+ if (this.dir === null) return;
321
+ const idleMs = this.options.idleCloseMs ?? PROTON_PASS_IDLE_CLOSE_MS;
322
+ this.idleTimer = setTimeout(() => {
323
+ this.idleTimer = null;
324
+ if (this.pending > 0 || this.closed) return;
325
+ void this.serialize(() => this.teardown()).catch(() => {});
326
+ }, idleMs);
327
+ this.idleTimer.unref?.();
328
+ }
329
+
330
+ private async teardown(): Promise<void> {
331
+ const dir = this.dir;
332
+ if (!dir) return;
333
+ if (this.loggedInAt !== null) {
334
+ try {
335
+ await this.spawn({ args: ["logout"], subject: "sign out" }, false, 10_000);
336
+ } catch {
337
+ // The directory wipe below removes the session either way.
338
+ }
339
+ }
340
+ this.loggedInAt = null;
341
+ this.info = null;
342
+ this.dir = null;
343
+ liveStateDirs.delete(dir);
344
+ rmSync(dir, { recursive: true, force: true });
345
+ }
346
+
347
+ private requireDir(): string {
348
+ if (this.dir) return this.dir;
349
+ const root = resolve(this.options.stateRoot ?? join(tmpdir(), "opengeni-protonpass"));
350
+ ensurePrivateDirectory(root);
351
+ const dir = mkdtempSync(join(root, "pp-"));
352
+ ensurePrivateDirectory(dir);
353
+ trackStateDir(dir);
354
+ this.dir = dir;
355
+ return dir;
356
+ }
357
+
358
+ private sessionDir(): string {
359
+ return join(this.requireDir(), "session");
360
+ }
361
+
362
+ private baseEnv(): Record<string, string> {
363
+ const dir = this.requireDir();
364
+ const env: Record<string, string> = {
365
+ HOME: dir,
366
+ TZ: "UTC",
367
+ LANG: "C.UTF-8",
368
+ LC_ALL: "C.UTF-8",
369
+ PATH: "/usr/bin:/bin",
370
+ PROTON_PASS_SESSION_DIR: this.sessionDir(),
371
+ PROTON_PASS_KEY_PROVIDER: "env",
372
+ PROTON_PASS_ENCRYPTION_KEY: this.encryptionKey,
373
+ PROTON_PASS_DISABLE_TELEMETRY: "1",
374
+ PROTON_PASS_NO_UPDATE_CHECK: "1",
375
+ PASS_LOG_LEVEL: "off",
376
+ };
377
+ for (const name of PASSTHROUGH_ENV) {
378
+ const value = this.hostEnv[name];
379
+ if (value) env[name] = value;
380
+ }
381
+ return env;
382
+ }
383
+
384
+ private async resolveBinary(): Promise<string> {
385
+ this.binary ??= (async () => {
386
+ const configured = this.options.binaryPath;
387
+ if (configured !== undefined && !isAbsolute(configured)) {
388
+ throw unavailable("The pass-cli path must be absolute");
389
+ }
390
+ const found = configured ?? findOnPath("pass-cli", this.hostEnv.PATH);
391
+ if (!found) throw unavailable("pass-cli is not installed on this Opengeni server");
392
+ let path: string;
393
+ try {
394
+ path = realpathSync(found);
395
+ const stat = statSync(path);
396
+ if (!stat.isFile()) throw new Error("not a file");
397
+ if ((stat.mode & 0o002) !== 0) {
398
+ throw unavailable("pass-cli is world-writable; refusing to run it");
399
+ }
400
+ accessSync(path, fsConstants.X_OK);
401
+ } catch (error) {
402
+ if (error instanceof VaultProviderError) throw error;
403
+ throw unavailable("pass-cli is not an executable file");
404
+ }
405
+ const expectedSha = this.options.expectedSha256?.toLowerCase();
406
+ if (expectedSha && (await sha256File(path)) !== expectedSha) {
407
+ throw unavailable("pass-cli does not match the configured SHA-256");
408
+ }
409
+ const expected = this.options.expectedVersion ?? PROTON_PASS_CLI_VERSION;
410
+ const result = await this.runner({
411
+ file: path,
412
+ args: ["--version"],
413
+ env: { HOME: this.requireDir(), PATH: "/usr/bin:/bin", LANG: "C.UTF-8" },
414
+ cwd: this.requireDir(),
415
+ timeoutMs: 10_000,
416
+ maxStdoutBytes: 4096,
417
+ maxStderrBytes: 4096,
418
+ });
419
+ const version = /\b(\d+\.\d+\.\d+)\b/.exec(result.stdout)?.[1];
420
+ if (!succeeded(result) || !version) {
421
+ throw unavailable("pass-cli --version failed");
422
+ }
423
+ if (version !== expected) {
424
+ throw unavailable(
425
+ `pass-cli ${version} is not supported; install pass-cli ${expected} for this Opengeni release`,
426
+ );
427
+ }
428
+ return path;
429
+ })();
430
+ this.binary.catch(() => {
431
+ this.binary = null;
432
+ });
433
+ return this.binary;
434
+ }
435
+
436
+ private async spawn(
437
+ command: Pick<
438
+ ProtonCommand,
439
+ "args" | "stdin" | "subject" | "authority" | "signal" | "maxStdoutBytes"
440
+ >,
441
+ withToken: boolean,
442
+ timeoutMs?: number,
443
+ ): Promise<ProcessResult> {
444
+ const file = await this.resolveBinary();
445
+ const env = this.baseEnv();
446
+ if (withToken) env.PROTON_PASS_PERSONAL_ACCESS_TOKEN = this.token;
447
+ else env.PROTON_PASS_AGENT_REASON = agentReason(command.subject, command.authority);
448
+ const release = await acquireProcessSlot(command.signal);
449
+ try {
450
+ return await this.runner({
451
+ file,
452
+ args: command.args,
453
+ env,
454
+ cwd: this.requireDir(),
455
+ ...(command.stdin !== undefined ? { stdin: command.stdin } : {}),
456
+ timeoutMs: timeoutMs ?? this.options.commandTimeoutMs ?? PROTON_PASS_COMMAND_TIMEOUT_MS,
457
+ maxStdoutBytes:
458
+ command.maxStdoutBytes ?? this.options.maxStdoutBytes ?? PROTON_PASS_MAX_STDOUT_BYTES,
459
+ maxStderrBytes: MAX_STDERR_BYTES,
460
+ ...(command.signal ? { signal: command.signal } : {}),
461
+ });
462
+ } finally {
463
+ release();
464
+ }
465
+ }
466
+
467
+ private sessionFresh(): boolean {
468
+ if (this.loggedInAt === null || this.info === null) return false;
469
+ const maxAge = this.options.sessionMaxAgeMs ?? PROTON_PASS_SESSION_MAX_AGE_MS;
470
+ return this.now() - this.loggedInAt < maxAge;
471
+ }
472
+
473
+ private async ensureLogin(signal?: AbortSignal): Promise<void> {
474
+ if (this.sessionFresh()) return;
475
+ await this.login(signal);
476
+ }
477
+
478
+ private async login(signal?: AbortSignal): Promise<void> {
479
+ this.loggedInAt = null;
480
+ this.info = null;
481
+ // A fresh login replaces any earlier session state in this directory.
482
+ const sessionDir = this.sessionDir();
483
+ rmSync(sessionDir, { recursive: true, force: true });
484
+ mkdirSync(sessionDir, { mode: 0o700 });
485
+ const result = await this.spawn(
486
+ {
487
+ args: ["login"],
488
+ subject: "sign in",
489
+ maxStdoutBytes: SMALL_STDOUT_BYTES,
490
+ ...(signal ? { signal } : {}),
491
+ },
492
+ true,
493
+ );
494
+ if (!succeeded(result)) {
495
+ signal?.throwIfAborted();
496
+ throw loginFailure(result);
497
+ }
498
+ this.loggedInAt = this.now();
499
+ const infoResult = await this.spawn(
500
+ {
501
+ args: ["info", "--output", "json"],
502
+ subject: "read session info",
503
+ maxStdoutBytes: SMALL_STDOUT_BYTES,
504
+ ...(signal ? { signal } : {}),
505
+ },
506
+ false,
507
+ );
508
+ if (!succeeded(infoResult)) {
509
+ this.loggedInAt = null;
510
+ signal?.throwIfAborted();
511
+ throw mapProtonFailure(infoResult, "read session info");
512
+ }
513
+ const parsed = InfoOutput.safeParse(parseJson(infoResult.stdout));
514
+ if (!parsed.success) {
515
+ this.loggedInAt = null;
516
+ throw unavailable("pass-cli returned unexpected session info");
517
+ }
518
+ if (parsed.data.username !== undefined) {
519
+ // A personal access token login never reports a user name.
520
+ this.loggedInAt = null;
521
+ throw new VaultProviderError(
522
+ "provider_auth_invalid",
523
+ "The Proton Pass session is not a personal access token session",
524
+ );
525
+ }
526
+ const tokenName = parsed.data.personal_access_token_name ?? null;
527
+ this.info = {
528
+ tokenName: tokenName?.replace(/^\[Agent\] /, "") ?? null,
529
+ agent: tokenName?.startsWith("[Agent] ") ?? false,
530
+ };
531
+ }
532
+
533
+ private async execute(command: ProtonCommand): Promise<ProcessResult> {
534
+ command.signal?.throwIfAborted();
535
+ await this.ensureLogin(command.signal);
536
+ let result = await this.spawn(command, false);
537
+ if (!succeeded(result) && !result.timedOut && !result.aborted) {
538
+ if (NOT_AUTHENTICATED.test(result.stderr)) {
539
+ // Pre-flight: nothing reached Proton, so one relogin and retry is safe for writes too.
540
+ await this.login(command.signal);
541
+ result = await this.spawn(command, false);
542
+ } else if (SESSION_INVALIDATED.test(result.stderr)) {
543
+ this.loggedInAt = null;
544
+ this.info = null;
545
+ if (command.mutationContext) throw outcomeUnknown(command.subject);
546
+ await this.login(command.signal);
547
+ result = await this.spawn(command, false);
548
+ }
549
+ }
550
+ if (succeeded(result)) return result;
551
+ if (SESSION_INVALIDATED.test(result.stderr) || NOT_AUTHENTICATED.test(result.stderr)) {
552
+ this.loggedInAt = null;
553
+ this.info = null;
554
+ }
555
+ if (command.mutationContext) {
556
+ if (!isDefiniteMutationFailure(result, command.mutationContext)) {
557
+ throw outcomeUnknown(command.subject);
558
+ }
559
+ throw mapProtonFailure(result, command.subject);
560
+ }
561
+ if (result.aborted) command.signal?.throwIfAborted();
562
+ if (result.stdoutOverflow) {
563
+ throw new VaultProviderError(
564
+ command.overflowCode ?? "provider_unavailable",
565
+ `Proton Pass returned more data than Opengeni reads at once (${command.subject})`,
566
+ );
567
+ }
568
+ throw mapProtonFailure(result, command.subject);
569
+ }
570
+ }
571
+
572
+ export function parseJson(text: string): unknown {
573
+ try {
574
+ return JSON.parse(text);
575
+ } catch {
576
+ return undefined;
577
+ }
578
+ }