import { getSecretStore } from '../../shared/secret-store' import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' const ZCODE_PLAN_API_KEY_FILE = 'zcode-plan-api-key.enc' export const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:' let cachedZcodePlanApiKey: string | null = null let warnedZcodePlanApiKeyStatusHardenFailure = false type ZcodePlanApiKeyEnvelope = { kind: 'encrypted' | 'plaintext' payload: Buffer } export function getZcodePlanApiKeyPath(): string { return join(homedir(), '.orca', ZCODE_PLAN_API_KEY_FILE) } function encodeApiKeyEnvelope(kind: ZcodePlanApiKeyEnvelope['kind'], payload: Buffer): string { return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` } function decodeApiKeyEnvelope(raw: Buffer): ZcodePlanApiKeyEnvelope { const text = raw.toString('utf8') if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) { throw new Error('GLM Coding Plan API key could not be decrypted') } const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length) const separator = rest.indexOf(':') if (separator === -1) { throw new Error('GLM Coding Plan API key could not be decrypted') } const kind = rest.slice(0, separator) if (kind !== 'encrypted' && kind !== 'plaintext') { throw new Error('GLM Coding Plan API key could not be decrypted') } return { kind, payload: Buffer.from(rest.slice(separator + 1), 'base64') } } function readEnvelope(envelope: ZcodePlanApiKeyEnvelope): string { if (envelope.kind === 'plaintext') { return envelope.payload.toString('utf8') } if (!getSecretStore().isEncryptionAvailable()) { throw new Error('GLM Coding Plan API key could not be decrypted') } return getSecretStore().decryptString(envelope.payload) } export function hasZcodePlanApiKey(): boolean { const keyPath = getZcodePlanApiKeyPath() if (!existsSync(keyPath)) { return false } try { hardenExistingSecureFile(keyPath) } catch (error) { if (!warnedZcodePlanApiKeyStatusHardenFailure) { warnedZcodePlanApiKeyStatusHardenFailure = true console.warn( '[zcode] Failed to harden GLM Coding Plan API key file while checking status', error ) } } return true } export function getZcodePlanApiKeyProtection(): SecretAtRestProtection | null { const keyPath = getZcodePlanApiKeyPath() if (!existsSync(keyPath)) { return null } try { return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed' } catch { return null } } function rollbackPlaintextEnvelope( keyPath: string, attemptedEnvelope: string, previousEnvelope: Buffer | null ): void { const attempted = Buffer.from(attemptedEnvelope, 'utf8') try { if (!readFileSync(keyPath).equals(attempted)) { return } if (previousEnvelope) { try { writeSecureFile(keyPath, previousEnvelope.toString('utf8'), { shouldPublish: () => readFileSync(keyPath).equals(attempted) }) } catch { // A failed restore may already have published the previous envelope. } } const currentEnvelope = readFileSync(keyPath) if ( currentEnvelope.equals(attempted) && (!previousEnvelope || !currentEnvelope.equals(previousEnvelope)) ) { rmSync(keyPath, { force: true }) } } catch { // Unreadable current bytes do not establish ownership of the rejected write. } } export function saveZcodePlanApiKey(key: string): void { const trimmed = key.trim() if (!trimmed) { throw new Error('GLM Coding Plan API key is required') } if (/[\r\n]/.test(trimmed)) { throw new Error('GLM Coding Plan API key must be a single line') } if (getSecretStore().isEncryptionAvailable()) { writeSecureFile( getZcodePlanApiKeyPath(), encodeApiKeyEnvelope('encrypted', getSecretStore().encryptString(trimmed)) ) cachedZcodePlanApiKey = trimmed return } console.warn( '[zcode] safeStorage encryption unavailable — storing GLM Coding Plan API key in plaintext' ) const keyPath = getZcodePlanApiKeyPath() // Capture old bytes before secure publication can replace them yet report restriction failure; rollback must preserve the working key. let previousEnvelope: Buffer | null = null if (existsSync(keyPath)) { try { previousEnvelope = readFileSync(keyPath) } catch { previousEnvelope = null } } const attemptedEnvelope = encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) const wroteRestricted = writeSecureFile(keyPath, attemptedEnvelope) // Unrestricted plaintext must never be acknowledged as saved. if (!wroteRestricted) { rollbackPlaintextEnvelope(keyPath, attemptedEnvelope, previousEnvelope) throw new Error('GLM Coding Plan API key could not be stored securely on this device') } cachedZcodePlanApiKey = trimmed } export function readZcodePlanApiKey(): string | null { if (cachedZcodePlanApiKey !== null) { return cachedZcodePlanApiKey } const keyPath = getZcodePlanApiKeyPath() if (!existsSync(keyPath)) { return null } // Separate hardening from decryption so ACL/chmod faults cannot masquerade as decrypt failures. try { hardenExistingSecureFile(keyPath) } catch (error) { console.warn('[zcode] Failed to harden GLM Coding Plan API key file while reading', error) } try { const raw = readFileSync(keyPath) const envelope = decodeApiKeyEnvelope(raw) cachedZcodePlanApiKey = readEnvelope(envelope) return cachedZcodePlanApiKey } catch (error) { console.error('[zcode] failed to decode/decrypt GLM Coding Plan API key', error) throw new Error('GLM Coding Plan API key could not be decrypted') } } export function clearZcodePlanApiKey(): void { cachedZcodePlanApiKey = null rmSync(getZcodePlanApiKeyPath(), { force: true }) }