/** * Age encryption backend -- credentials stored in an encrypted JSON file. * * Uses the `age-encryption` npm package (typage) for pure JS encryption. * No external CLI tools required. * * Vault file format: age-encrypted JSON with the structure: * { "anthropic": { "type": "oauth", "access": "...", ... }, ... } * * The vault file is safe to commit to git -- it contains only ciphertext. */ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import type { BackendStatus, CredentialBackend, CredentialEntry, AgeBackendConfig, } from "../types.js"; const DEFAULT_VAULT_PATH = join(homedir(), ".pi", "agent", "vault.age.json"); const DEFAULT_IDENTITY_PATH = join( homedir(), ".config", "pi-vault", "age.txt", ); interface VaultData { [provider: string]: CredentialEntry; } export class AgeBackend implements CredentialBackend { readonly name = "age"; private readonly vaultPath: string; private readonly identityPath: string; private readonly extraRecipients: readonly string[]; /** In-memory cache; cleared on write. */ private cache: VaultData | undefined; constructor(config?: AgeBackendConfig) { this.vaultPath = config?.vaultPath ?? DEFAULT_VAULT_PATH; this.identityPath = config?.identityPath ?? DEFAULT_IDENTITY_PATH; this.extraRecipients = config?.recipients ?? []; } // ----------------------------------------------------------------------- // Identity management // ----------------------------------------------------------------------- private getIdentityKey(): string | undefined { // Environment variable takes precedence const envKey = process.env.PI_VAULT_AGE_KEY; if (envKey) { return envKey; } if (!existsSync(this.identityPath)) { return undefined; } const content = readFileSync(this.identityPath, "utf-8"); // Extract the secret key line (skip comments) for (const line of content.split("\n")) { const trimmed = line.trim(); if (trimmed.startsWith("AGE-SECRET-KEY-")) { return trimmed; } } return undefined; } private getRecipientFromIdentity(): string | undefined { if (!existsSync(this.identityPath)) { return undefined; } const content = readFileSync(this.identityPath, "utf-8"); // The public key is in a comment line: # public key: age1... for (const line of content.split("\n")) { const match = line.match(/^#\s*public key:\s*(age1\S+)/); if (match?.[1]) { return match[1]; } } return undefined; } private getAllRecipients(): string[] { const recipients: string[] = []; const local = this.getRecipientFromIdentity(); if (local) { recipients.push(local); } for (const r of this.extraRecipients) { if (!recipients.includes(r)) { recipients.push(r); } } return recipients; } // ----------------------------------------------------------------------- // Encryption / decryption // ----------------------------------------------------------------------- private async decrypt(ciphertext: Uint8Array): Promise { const { Decrypter } = await import("age-encryption"); const d = new Decrypter(); const identityKey = this.getIdentityKey(); if (!identityKey) { throw new Error( `Age identity not found. Expected at ${this.identityPath} or PI_VAULT_AGE_KEY env var.`, ); } d.addIdentity(identityKey); const plaintext = await d.decrypt(ciphertext, "text"); return plaintext; } private async encrypt(plaintext: string): Promise { const { Encrypter } = await import("age-encryption"); const e = new Encrypter(); const recipients = this.getAllRecipients(); if (recipients.length === 0) { throw new Error( `No age recipients configured. Generate an identity with /vault setup.`, ); } for (const recipient of recipients) { e.addRecipient(recipient); } return e.encrypt(plaintext); } // ----------------------------------------------------------------------- // Vault file I/O // ----------------------------------------------------------------------- private async readVault(): Promise { if (this.cache) { return this.cache; } if (!existsSync(this.vaultPath)) { this.cache = {}; return this.cache; } const ciphertext = readFileSync(this.vaultPath); const plaintext = await this.decrypt(new Uint8Array(ciphertext)); const parsed: unknown = JSON.parse(plaintext); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { this.cache = {}; return this.cache; } this.cache = parsed as VaultData; return this.cache; } private async writeVault(data: VaultData): Promise { const plaintext = JSON.stringify(data, null, 2); const ciphertext = await this.encrypt(plaintext); const dir = dirname(this.vaultPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); } writeFileSync(this.vaultPath, ciphertext); chmodSync(this.vaultPath, 0o600); // Invalidate cache so next read re-decrypts this.cache = data; } // ----------------------------------------------------------------------- // CredentialBackend implementation // ----------------------------------------------------------------------- async get(provider: string): Promise { const vault = await this.readVault(); return vault[provider]; } async set(provider: string, entry: CredentialEntry): Promise { const vault = await this.readVault(); vault[provider] = entry; await this.writeVault(vault); } async remove(provider: string): Promise { const vault = await this.readVault(); delete vault[provider]; await this.writeVault(vault); } async list(): Promise { const vault = await this.readVault(); return Object.keys(vault); } async check(): Promise { const identityKey = this.getIdentityKey(); if (!identityKey) { return { available: false, error: "No age identity found", detail: `Expected at ${this.identityPath} or PI_VAULT_AGE_KEY env var. Run /vault setup to generate one.`, }; } const recipients = this.getAllRecipients(); if (recipients.length === 0) { return { available: false, error: "No age recipients configured", detail: "The identity file may be malformed (missing public key comment).", }; } // If vault file exists, verify we can decrypt it if (existsSync(this.vaultPath)) { try { await this.readVault(); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { available: false, error: "Cannot decrypt vault file", detail: message, }; } } return { available: true, detail: `${recipients.length} recipient(s), vault at ${this.vaultPath}`, }; } } // --------------------------------------------------------------------------- // Identity generation utility // --------------------------------------------------------------------------- /** * Generate a new age identity and write it to the configured path. * Returns the public key (recipient) string. */ export async function generateAgeIdentity( identityPath: string = DEFAULT_IDENTITY_PATH, ): Promise { const { generateIdentity, identityToRecipient } = await import( "age-encryption" ); const identity = await generateIdentity(); const recipient = await identityToRecipient(identity); const dir = dirname(identityPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); } const content = [ `# created: ${new Date().toISOString()}`, `# public key: ${recipient}`, identity, "", ].join("\n"); writeFileSync(identityPath, content, "utf-8"); chmodSync(identityPath, 0o600); return recipient; }