File size: 5,595 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | /**
* Encrypted file-based credential store.
* @see .omo/plans/zcode-proxy.md Task 14
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
import type { Credential } from "./types.js";
const STORE_DIR = join(homedir(), ".zcode-proxy");
const STORE_FILE = join(STORE_DIR, "credentials.json");
const ENV_SECRET = "ZCODE_PROXY_CREDENTIAL_SECRET";
/**
* Derive the AES-GCM key as SHA-256(seed) (audit R2-13). The previous XOR-fold
* construction was a pseudo-KDF: a seed shorter than 32 bytes left zero blocks
* in the key. Scope note: on default machine-derived seeds the security gain
* is ~0 (any same-user process can re-derive the seed either way, 0o600 only
* stops other users) β the motivation is structural: env-secret deployments
* (`ZCODE_PROXY_CREDENTIAL_SECRET`) get real 32-byte diffusion, and the
* misleading "KDF" is gone.
*/
function getEncryptionKey(): Uint8Array {
const seed = process.env[ENV_SECRET] ?? `${homedir()}-${process.platform}-${process.arch}`;
return new Uint8Array(createHash("sha256").update(seed, "utf-8").digest());
}
/**
* Legacy XOR-fold key (pre-SHA-256 store format). Kept ONLY for the one-shot
* migration decrypt in {@link loadCredential} β never used for new writes.
*/
function getLegacyEncryptionKey(): Uint8Array {
const hash = new Uint8Array(new ArrayBuffer(32));
const encoder = new TextEncoder();
const seed = process.env[ENV_SECRET] ?? `${homedir()}-${process.platform}-${process.arch}`;
const seedBytes = encoder.encode(seed);
for (let i = 0; i < seedBytes.length; i++) {
hash[i % 32] ^= seedBytes[i];
}
return hash;
}
/** Atomic store write: temp file (0o600) + rename over the target. */
function atomicWriteStore(contents: string): void {
const tmp = `${STORE_FILE}.tmp-${process.pid}-${Date.now()}`;
writeFileSync(tmp, contents, { mode: 0o600 });
renameSync(tmp, STORE_FILE);
}
async function importAesKey(raw: Uint8Array): Promise<CryptoKey> {
// Copy into a plain ArrayBuffer: bun-types types Uint8Array as
// ArrayBufferLike, which is not assignable to BufferSource.
const ab = new ArrayBuffer(raw.byteLength);
new Uint8Array(ab).set(raw);
return crypto.subtle.importKey(
"raw",
ab,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
}
async function encryptWith(key: Uint8Array, plaintext: string): Promise<string> {
const aesKey = await importAesKey(key);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
encoder.encode(plaintext),
);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), iv.length);
return Buffer.from(combined).toString("base64");
}
async function decryptWith(key: Uint8Array, ciphertext: string): Promise<string> {
const aesKey = await importAesKey(key);
const combined = Buffer.from(ciphertext, "base64");
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
data,
);
return new TextDecoder().decode(decrypted);
}
async function encrypt(plaintext: string): Promise<string> {
return encryptWith(getEncryptionKey(), plaintext);
}
export async function saveCredential(cred: Credential): Promise<void> {
mkdirSync(dirname(STORE_FILE), { recursive: true });
const json = JSON.stringify(cred);
const encrypted = await encrypt(json);
atomicWriteStore(JSON.stringify({ encrypted }));
}
export async function loadCredential(): Promise<Credential | null> {
if (!existsSync(STORE_FILE)) return null;
const raw = readFileSync(STORE_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (!parsed.encrypted) return null;
let json: string;
try {
json = await decryptWith(getEncryptionKey(), parsed.encrypted);
} catch {
// Not decryptable under the new SHA-256 KDF β try the legacy XOR-fold key
// (one-shot migration), then transparently re-store under the new format.
try {
json = await decryptWith(getLegacyEncryptionKey(), parsed.encrypted);
} catch (e) {
// Stale/corrupt credential file β key derivation is machine-specific
// ({homedir}-{platform}-{arch}), so cross-machine copies or OS reinstalls
// produce undecryptable ciphertext. Silently treat as "not logged in".
console.warn(`Ignoring corrupted or stale credentials at ${STORE_FILE}: ${(e as Error).message}`);
return null;
}
// Re-store under the new KDF. Best-effort by design: the credential is
// already decrypted in memory, so a failed re-write (read-only dir, AV
// lock on Windows, ...) must NOT fail this load β it retries next boot.
try {
atomicWriteStore(JSON.stringify({ encrypted: await encrypt(json) }));
} catch (e) {
console.warn(`Credential re-encryption under the new key derivation failed (will retry on next load): ${(e as Error).message}`);
}
}
try {
return JSON.parse(json) as Credential;
} catch (e) {
console.warn(`Ignoring corrupted credentials at ${STORE_FILE}: ${(e as Error).message}`);
return null;
}
}
export function clearCredential(): void {
if (existsSync(STORE_FILE)) {
unlinkSync(STORE_FILE);
}
}
export function getStorePath(): string {
return STORE_FILE;
}
|