diff --git a/plugins/credential-sharing/src/engine.ts b/plugins/credential-sharing/src/engine.ts index 5796743..fdc4bd9 100644 --- a/plugins/credential-sharing/src/engine.ts +++ b/plugins/credential-sharing/src/engine.ts @@ -197,7 +197,7 @@ export class CredentialEngine { const runId = this.id("cred_run"); if (reversible && !dryRun && target.readValues) { const preImage = await target.readValues(plan.to, [...upsertKeys, ...deleteKeys]); - this.store.saveVault(runId, preImage); + await this.store.saveVault(runId, preImage); } const writeResults = await target.write({ endpoint: plan.to, upserts, deletes: deleteKeys, dryRun }); @@ -272,7 +272,7 @@ export class CredentialEngine { if (!run.reversible) { throw new Error(`Run ${runId} was not reversible (no pre-image captured). Rollbacks require a value-readable target.`); } - const preImage = this.store.getVault(runId); + const preImage = await this.store.getVault(runId); if (!preImage) { throw new Error(`No rollback pre-image found for run ${runId}.`); } @@ -334,7 +334,7 @@ export class CredentialEngine { return {}; } if (plan.rollbackOfRunId) { - const preImage = this.store.getVault(plan.rollbackOfRunId); + const preImage = await this.store.getVault(plan.rollbackOfRunId); if (!preImage) { throw new Error(`Rollback plan ${plan.id} references missing vault for run ${plan.rollbackOfRunId}.`); } diff --git a/plugins/credential-sharing/src/store.ts b/plugins/credential-sharing/src/store.ts index c14b38c..88fad36 100644 --- a/plugins/credential-sharing/src/store.ts +++ b/plugins/credential-sharing/src/store.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; -import { logicsrcHome } from "./identity.js"; +import { identityPath, loadOrCreateIdentity, logicsrcHome, readIdentity } from "./identity.js"; +import { decryptValue, encryptValue, generateVaultKey, unwrapVaultKey, wrapVaultKey, type SealedValue } from "./crypto.js"; import type { CredentialSyncPlan, CredentialSyncRun, CredentialAuditEvent, CredentialValueBag } from "./types.js"; /** @@ -11,13 +12,15 @@ import type { CredentialSyncPlan, CredentialSyncRun, CredentialAuditEvent, Crede * plans/.json redacted sync plans (fingerprints only) * runs/.json run records (fingerprints only) * audit/.json audit events (fingerprints only) - * vault/.json rollback pre-image — RAW prior target values, mode 0600 + * vault/.json rollback pre-image — sealed to this machine's identity, mode 0600 * - * The vault is the only place raw values touch disk, and only to make rollback - * possible. It is written 0600 and lives in the user's config dir, outside any - * project — so there is nothing for a caller to gitignore, and nothing that - * lands in a repo because the CLI was run from inside one. Audit and plan - * records never contain raw values. + * The vault is the only place prior values touch disk, and only to make + * rollback possible. It is encrypted at rest — sealed to this machine's + * identity key, so the file opens with the secret key in identity.json and + * nothing else — written 0600, and kept in the user's config dir, outside any + * project. Mode 0600 stops another user on the box; the sealing stops a + * backup, a synced home directory or a lifted disk. Audit and plan records + * never contain raw values at all. */ export interface CredentialStore { baseDir: string; @@ -27,8 +30,29 @@ export interface CredentialStore { getRun(id: string): CredentialSyncRun | undefined; saveAudit(runId: string, events: CredentialAuditEvent[]): void; getAudit(runId: string): CredentialAuditEvent[]; - saveVault(runId: string, preImage: CredentialValueBag): void; - getVault(runId: string): CredentialValueBag | undefined; + saveVault(runId: string, preImage: CredentialValueBag): Promise; + getVault(runId: string): Promise; +} + +/** + * A vault file, sealed to this machine's identity key. + * + * The DEK is fresh per write and sealed to the identity public key, so the + * file is openable by the secret key in `identity.json` and nothing else — + * mode 0600 stops another user on the box reading it, and this stops a backup, + * a synced home directory, or a stolen disk from doing the same. + * + * `version` is what tells a sealed file from the plaintext ones written before + * this existed. Those are still readable; see getVault. + */ +interface SealedVaultFile { + version: 2; + wrappedKey: string; + sealed: SealedValue; +} + +function isSealed(value: unknown): value is SealedVaultFile { + return typeof value === "object" && value !== null && (value as { version?: unknown }).version === 2; } /** @@ -95,12 +119,41 @@ export function createFileCredentialStore(baseDir = defaultCredentialHome()): Cr getAudit(runId) { return readJson(join(dirs.audit, `${runId}.json`)) ?? []; }, - saveVault(runId, preImage) { + async saveVault(runId, preImage) { ensure(dirs.vault, 0o700); - writeFileSync(join(dirs.vault, `${runId}.json`), JSON.stringify(preImage, null, 2), { mode: 0o600 }); + // A fresh DEK per run, sealed to this machine's identity. Reusing one key + // across runs would make a single compromise open every rollback ever + // captured, and there is no reason to: the DEK travels with the file. + const identity = await loadOrCreateIdentity(); + const dek = await generateVaultKey(); + const file: SealedVaultFile = { + version: 2, + wrappedKey: await wrapVaultKey(dek, identity.keys.publicKey), + sealed: await encryptValue(JSON.stringify(preImage), dek) + }; + writeFileSync(join(dirs.vault, `${runId}.json`), JSON.stringify(file, null, 2), { mode: 0o600 }); }, - getVault(runId) { - return readJson(join(dirs.vault, `${runId}.json`)); + async getVault(runId) { + const raw = readJson(join(dirs.vault, `${runId}.json`)); + if (!raw) { + return undefined; + } + // Plaintext files written before vaults were sealed still open. Refusing + // them would strand the rollback data they exist to hold — the point of + // the vault is that a bad rotation can be undone, and a reader that + // cannot read yesterday's vault takes that away. + if (!isSealed(raw)) { + return raw as CredentialValueBag; + } + const identity = readIdentity(); + if (!identity?.keys?.secretKey) { + throw new Error( + `Vault for run ${runId} is sealed to this machine's identity, which is missing. ` + + `Restore ${identityPath()} to roll this run back.` + ); + } + const dek = await unwrapVaultKey(raw.wrappedKey, identity.keys); + return JSON.parse(await decryptValue(raw.sealed, dek)) as CredentialValueBag; } }; } @@ -119,8 +172,8 @@ export function createMemoryCredentialStore(): CredentialStore { getRun: (id) => runs.get(id), saveAudit: (runId, events) => void audit.set(runId, events), getAudit: (runId) => audit.get(runId) ?? [], - saveVault: (runId, preImage) => void vault.set(runId, preImage), - getVault: (runId) => vault.get(runId) + saveVault: async (runId, preImage) => void vault.set(runId, preImage), + getVault: async (runId) => vault.get(runId) }; } diff --git a/plugins/credential-sharing/src/vault-encryption.test.ts b/plugins/credential-sharing/src/vault-encryption.test.ts new file mode 100644 index 0000000..5d2c60e --- /dev/null +++ b/plugins/credential-sharing/src/vault-encryption.test.ts @@ -0,0 +1,107 @@ +// The vault, at rest. +// +// The vault holds the target's prior values so a bad rotation can be undone — +// the one place raw credentials touch disk. It was written as plain JSON at +// mode 0600, which is a permission bit and nothing more: it stops another user +// on the same box and does nothing about a backup, a synced home directory, a +// stolen laptop, or anything that reads the file as its owner. +// +// It is now sealed to this machine's identity key. These tests exist to fail +// the moment a secret is legible on disk again. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createFileCredentialStore } from "./store.js"; +import { loadOrCreateIdentity, identityPath } from "./identity.js"; + +const ENV_KEYS = ["LOGICSRC_HOME", "XDG_CONFIG_HOME", "HOME", "LOGICSRC_CREDENTIAL_HOME", "LOGICSRC_IDENTITY_FILE"] as const; + +const SECRET = "sk-live-do-not-write-me-in-the-clear"; +const BAG = { API_KEY: SECRET, OTHER: "second-value-also-secret" }; + +let saved: Record; +let sandbox: string; + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + sandbox = mkdtempSync(join(tmpdir(), "logicsrc-vault-")); + for (const k of ENV_KEYS) delete process.env[k]; + process.env.HOME = sandbox; +}); + +afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(sandbox, { recursive: true, force: true }); +}); + +const vaultFile = (store: { baseDir: string }, runId: string) => join(store.baseDir, "vault", `${runId}.json`); + +describe("vault encryption at rest", () => { + it("does not write the secret to disk in the clear", async () => { + const store = createFileCredentialStore(); + await store.saveVault("run_1", BAG); + + const onDisk = readFileSync(vaultFile(store, "run_1"), "utf8"); + expect(onDisk).not.toContain(SECRET); + expect(onDisk).not.toContain("second-value-also-secret"); + // The key names are secrets too — knowing an endpoint holds STRIPE_LIVE_KEY + // is worth something on its own. + expect(onDisk).not.toContain("API_KEY"); + }); + + it("round-trips through the seal", async () => { + const store = createFileCredentialStore(); + await store.saveVault("run_2", BAG); + expect(await store.getVault("run_2")).toEqual(BAG); + }); + + it("seals each run under its own key", async () => { + // One DEK across every run would make a single compromise open every + // rollback ever captured. + const store = createFileCredentialStore(); + await store.saveVault("run_3", BAG); + await store.saveVault("run_4", BAG); + + const a = JSON.parse(readFileSync(vaultFile(store, "run_3"), "utf8")); + const b = JSON.parse(readFileSync(vaultFile(store, "run_4"), "utf8")); + expect(a.wrappedKey).not.toBe(b.wrappedKey); + expect(a.sealed.ciphertext).not.toBe(b.sealed.ciphertext); + }); + + it("writes the file 0600", async () => { + const store = createFileCredentialStore(); + await store.saveVault("run_5", BAG); + const { mode } = await import("node:fs").then((fs) => fs.statSync(vaultFile(store, "run_5"))); + expect(mode & 0o777).toBe(0o600); + }); + + it("still reads a plaintext vault written before this existed", async () => { + // Refusing them would strand the rollback data they exist to hold. + const store = createFileCredentialStore(); + mkdirSync(join(store.baseDir, "vault"), { recursive: true }); + writeFileSync(vaultFile(store, "legacy"), JSON.stringify(BAG), { mode: 0o600 }); + + expect(await store.getVault("legacy")).toEqual(BAG); + }); + + it("says what is wrong when the identity is gone", async () => { + const store = createFileCredentialStore(); + await store.saveVault("run_6", BAG); + await loadOrCreateIdentity(); + rmSync(identityPath(), { force: true }); + + // Not a decode error out of libsodium: the operator needs to know the + // rollback is recoverable, and by restoring what. + await expect(store.getVault("run_6")).rejects.toThrow(/identity/i); + }); + + it("returns undefined for a run with no vault, as before", async () => { + const store = createFileCredentialStore(); + expect(await store.getVault("never-happened")).toBeUndefined(); + }); +});