/** * Cross-platform OS keychain adapter (ADR-306) — macOS Keychain, Windows * Credential Manager (DPAPI), Linux Secret Service (libsecret), backed by * `@napi-rs/keyring` (prebuilt N-API binaries per platform, wraps the Rust * `keyring-rs` crate — no node-gyp compile step, same "optional native * binding" shape this monorepo already tolerates for `ruvector`). * * `keytar` was considered and rejected: archived by the Electron team, * compiles from source via node-gyp at install time. * * ADR-306's refresh-token storage rule: OS keychain only, never plain-text * config. When no keychain backend is reachable (typical headless Linux — * the binding can be present but no D-Bus Secret Service running), the CLI * falls back to session-only tokens (never persisted) rather than writing * the secret to disk unencrypted — a deliberate usability cost, not a bug. * `SessionOnlyKeychainAdapter` is that fallback, made explicit rather than * silently degrading. * * @module v3/security/keychain-adapter */ export interface KeychainAdapter { /** * A real write+read+delete canary probe against a throwaway entry — not * just "did the native module load". A headless box can have the binding * present but no reachable Secret Service, which only a real round-trip * reveals. */ isAvailable(): Promise; setSecret(service: string, account: string, secret: string): Promise; getSecret(service: string, account: string): Promise; deleteSecret(service: string, account: string): Promise; } const CANARY_SERVICE = 'ruflo-keychain-canary'; const CANARY_ACCOUNT = 'probe'; /** In-memory-only fallback — the ADR-306-mandated degrade path, not an error state. */ export class SessionOnlyKeychainAdapter implements KeychainAdapter { private readonly store = new Map(); private key(service: string, account: string): string { return `${service}${account}`; } async isAvailable(): Promise { return true; // in-memory storage is always "available", by definition } async setSecret(service: string, account: string, secret: string): Promise { this.store.set(this.key(service, account), secret); } async getSecret(service: string, account: string): Promise { return this.store.get(this.key(service, account)) ?? null; } async deleteSecret(service: string, account: string): Promise { this.store.delete(this.key(service, account)); } } interface KeyringEntryCtor { new (service: string, account: string): { setPassword(secret: string): void; getPassword(): string; deletePassword(): void; }; } class NativeKeychainAdapter implements KeychainAdapter { private entryCtor: KeyringEntryCtor | null | undefined; // undefined = not yet resolved private async resolveEntryCtor(): Promise { if (this.entryCtor !== undefined) return this.entryCtor; try { const mod = (await import('@napi-rs/keyring')) as unknown as { Entry: KeyringEntryCtor }; this.entryCtor = mod.Entry; } catch { this.entryCtor = null; // module not installed / no prebuilt binary for this platform } return this.entryCtor; } async isAvailable(): Promise { const Entry = await this.resolveEntryCtor(); if (!Entry) return false; try { const entry = new Entry(CANARY_SERVICE, CANARY_ACCOUNT); entry.setPassword('canary'); const readBack = entry.getPassword(); entry.deletePassword(); return readBack === 'canary'; } catch { return false; // binding loaded, but no reachable backend (e.g. headless Linux, no D-Bus) } } async setSecret(service: string, account: string, secret: string): Promise { const Entry = await this.resolveEntryCtor(); if (!Entry) throw new Error('native keychain backend unavailable'); new Entry(service, account).setPassword(secret); } async getSecret(service: string, account: string): Promise { const Entry = await this.resolveEntryCtor(); if (!Entry) return null; try { return new Entry(service, account).getPassword(); } catch { return null; // no matching entry, or backend unavailable } } async deleteSecret(service: string, account: string): Promise { const Entry = await this.resolveEntryCtor(); if (!Entry) return; try { new Entry(service, account).deletePassword(); } catch { // already absent — deleting a non-existent secret is a no-op, not an error } } } /** * Selects a real OS keychain when reachable, falling back to * `SessionOnlyKeychainAdapter` otherwise. Always resolves — never throws — * so callers can treat "no keychain" as a normal, expected outcome. */ export async function createKeychainAdapter(): Promise { const native = new NativeKeychainAdapter(); if (await native.isAvailable()) return native; return new SessionOnlyKeychainAdapter(); }