/** * OS keychain backend -- credentials stored in the native secret store. * * Uses `cross-keychain` for cross-platform support: * - macOS: Keychain (Security.framework) * - Linux: Secret Service (GNOME Keyring / KWallet) * - Windows: Credential Manager * * Each credential is stored as a JSON-serialized CredentialEntry * under a service/account pair. */ import type { BackendStatus, CredentialBackend, CredentialEntry, KeychainBackendConfig, } from "../types.js"; const DEFAULT_SERVICE = "pi-credential-vault"; /** * Prefix for the index entry that tracks which providers have stored credentials. * The keychain has no "list all accounts for a service" API on all platforms, * so we maintain a small index entry. */ const INDEX_ACCOUNT = "__provider_index__"; export class KeychainBackend implements CredentialBackend { readonly name = "keychain"; private readonly service: string; constructor(config?: KeychainBackendConfig) { this.service = config?.service ?? DEFAULT_SERVICE; } private async loadKeychain(): Promise { return import("cross-keychain"); } // ----------------------------------------------------------------------- // Index management // ----------------------------------------------------------------------- private async getIndex(): Promise { const kc = await this.loadKeychain(); const raw = await kc.getPassword(this.service, INDEX_ACCOUNT); if (!raw) { return []; } const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) { return []; } return parsed.filter( (item: unknown): item is string => typeof item === "string", ); } private async setIndex(providers: string[]): Promise { const kc = await this.loadKeychain(); await kc.setPassword( this.service, INDEX_ACCOUNT, JSON.stringify(providers), ); } private async addToIndex(provider: string): Promise { const index = await this.getIndex(); if (!index.includes(provider)) { index.push(provider); await this.setIndex(index); } } private async removeFromIndex(provider: string): Promise { const index = await this.getIndex(); const filtered = index.filter((p) => p !== provider); if (filtered.length !== index.length) { await this.setIndex(filtered); } } // ----------------------------------------------------------------------- // CredentialBackend implementation // ----------------------------------------------------------------------- async get(provider: string): Promise { const kc = await this.loadKeychain(); const raw = await kc.getPassword(this.service, provider); if (!raw) { return undefined; } const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return undefined; } return parsed as CredentialEntry; } async set(provider: string, entry: CredentialEntry): Promise { const kc = await this.loadKeychain(); await kc.setPassword(this.service, provider, JSON.stringify(entry)); await this.addToIndex(provider); } async remove(provider: string): Promise { const kc = await this.loadKeychain(); await kc.deletePassword(this.service, provider); await this.removeFromIndex(provider); } async list(): Promise { return this.getIndex(); } async check(): Promise { try { const kc = await this.loadKeychain(); // Probe: attempt a read to verify keychain access await kc.getPassword(this.service, "__health_check__"); return { available: true, detail: `service: ${this.service}` }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { available: false, error: "Keychain not accessible", detail: message, }; } } }