UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

865 lines • 42.3 kB
/** * vault — the principal keystore (protocol rev 41 §5.1; task 5.2). * * Layout (under the active profile's config dir, beside the project-key cache * `core/src/keystore.ts` manages — `RUN402_CONFIG_DIR` / `RUN402_WALLET` aware): * * <config>/vault/ * identity.json Ed25519 seed + X25519 private key (0600) * repos/<repo_id>.json K_repo, trust pin, dual pins, last ref transaction (0600) * repos/<repo_id>.lock/ per-repo lock (mkdir-atomic, 0700) * receipts/<repo_id>.recovery_receipt.json the recovery receipt (NOT a secret; more copies better) * journal/<client_creation_id>.json six-stage creation journals (task 5.3) * audit.log append-only JSON lines (0600) * * Discipline (spec: "Writes SHALL be atomic with per-repo locks, no-follow * creation, and permission audits"): directories 0700, files 0600, every * write = temp file (O_NOFOLLOW|O_EXCL) + fsync + rename + directory fsync, * and `auditKeystorePermissions()` runs at open so a world-readable identity * is a named finding, never silent. * * Transitions (protocol §5.1 partial-loss table) are DERIVED by * {@link VaultKeystore.assess} and acted on by explicit methods — the * keystore never guesses: * - repo file lost + identity intact → `restoreRepoFromEnvelope` (own envelope) * - signing key lost, K_repo held → `read_only` (clear report) * - stale pin → `stale_pin` (re-verify from genesis; 5.4) * - whole keystore lost → `VAULT_UNRECOVERABLE` (§0, verbatim) */ import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs"; import { dirname, join } from "node:path"; import { getConfigDir } from "../../core-dist/config.js"; import { LocalError } from "../errors.js"; import { VAULT_EK_RE, VAULT_SRC_RE, VAULT_TERMINAL_LOSS_DOCTOR_TEXT, VAULT_TERMINAL_LOSS_STATEMENT, VAULT_VK_RE, bytesToHex, checkGenesisKeyBindings, checkRecoveryReceipt, ekFingerprint, fromBase64url, formatVaultTimestamp, generateEncryptionKeypair, generateSigningKeypair, hexToBytes, openKeyEnvelope, storedBytesSha256, toBase64url, verifyVaultObject, vkFingerprint, } from "../namespaces/vault.crypto.js"; function fail(code, message, context, details) { throw new LocalError(message, context, { code, details }); } const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; const DIR_MODE = 0o700; const FILE_MODE = 0o600; /** The local object cache's recency window (design D3: "genesis + newest N=8 heads + newest carriers"). */ export const VAULT_OBJECT_CACHE_WINDOW = 8; /** vault-object-host-predial (design D1): the max persisted object-store origins per repo — small on purpose, this is a predial hint, not an inventory. */ export const VAULT_OBJECT_STORE_ORIGINS_CAP = 4; /** Bare hex16 generation comparison for cache eviction — deliberately NOT `generationToBigInt` (vault-publication.ts), which this lower-level module must not import (publication already imports FROM here). Malformed input sorts as "evict" rather than throwing: a corrupt cache entry should never block eviction of everything else. */ function genToBigInt(generation) { try { return /^[0-9a-f]{16}$/.test(generation) ? BigInt(`0x${generation}`) : -1n; } catch { return -1n; } } /** The keystore directory's earlier name under the same config dir. */ const FORMER_KEYSTORE_DIRNAME = "gitvault"; /** * Default keystore root: `<active profile config dir>/vault`. * * This is the one place the keystore path is first resolved, so it is also * where a keystore still at `<config>/gitvault` moves to `<config>/vault`: * one same-directory `rename`, which is atomic, and only when `vault` does * not exist yet. Nothing reads the earlier path after that. A move another * process already made is not an error; any other refusal throws * `VAULT_KEYSTORE_MOVE_FAILED` naming both paths, because answering with an * empty `vault` directory would make a live keystore look lost. */ export function getVaultKeystoreRoot() { const configDir = getConfigDir(); const root = join(configDir, "vault"); moveFormerKeystoreDir(join(configDir, FORMER_KEYSTORE_DIRNAME), root); return root; } function moveFormerKeystoreDir(former, root) { if (existsSync(root)) return; let stat; try { stat = lstatSync(former); } catch { return; // nothing to move } if (!stat.isDirectory() && !stat.isSymbolicLink()) return; try { renameSync(former, root); } catch (e) { const code = e.code; if (existsSync(root) || code === "ENOENT") return; // another process moved it first throw new LocalError(`could not move the vault keystore from ${former} to ${root}: ${e instanceof Error ? e.message : String(e)}`, "resolving the vault keystore", { code: "VAULT_KEYSTORE_MOVE_FAILED", details: { from: former, to: root }, cause: e }); } } function mkdir0700(path) { mkdirSync(path, { recursive: true, mode: DIR_MODE }); } function fsyncDir(path) { try { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } catch { /* some filesystems refuse directory fsync; the rename is still atomic */ } } /** * Atomic, no-follow, fsynced write: `O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW` on a * temp sibling, fsync, rename over the target, fsync the directory. */ export function writeFileAtomic0600(path, text) { const dir = dirname(path); mkdir0700(dir); if (existsSync(path) && lstatSync(path).isSymbolicLink()) { fail("VAULT_KEYSTORE_SYMLINK", `refusing to write through a symlink: ${path}`, "writing vault keystore file", { path }); } const tmp = join(dir, `.${String(process.pid)}.${bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(6)))}.tmp`); const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, FILE_MODE); try { writeSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); } renameSync(tmp, path); fsyncDir(dir); } /** No-follow read: a symlinked keystore file is a refusal, not a read. */ export function readFileNoFollow(path) { let st; try { st = lstatSync(path); } catch { return null; } if (st.isSymbolicLink()) { fail("VAULT_KEYSTORE_SYMLINK", `refusing to read through a symlink: ${path}`, "reading vault keystore file", { path }); } const fd = openSync(path, constants.O_RDONLY | O_NOFOLLOW); try { return readFileSync(fd, "utf8"); } finally { closeSync(fd); } } function auditPath(path, expectDir) { const findings = []; let st; try { st = lstatSync(path); } catch { return [{ path, problem: "missing" }]; } if (st.isSymbolicLink()) findings.push({ path, problem: "symlink" }); if (process.platform !== "win32") { const mode = st.mode & 0o777; const allowed = expectDir ? DIR_MODE : FILE_MODE; if ((mode & ~allowed) !== 0) findings.push({ path, problem: "world_or_group_accessible", mode: mode.toString(8) }); if (typeof process.getuid === "function" && st.uid !== process.getuid()) findings.push({ path, problem: "not_owned_by_user" }); } return findings; } // ─── The keystore ──────────────────────────────────────────────────────────── export class VaultKeystore { rootDir; now; strictPermissions; constructor(options = {}) { this.rootDir = options.rootDir ?? getVaultKeystoreRoot(); this.now = options.now ?? (() => new Date()); this.strictPermissions = options.strictPermissions ?? false; } /** Open (creating the directory skeleton) and run the permission audit. */ static open(options = {}) { const ks = new VaultKeystore(options); for (const dir of [ks.rootDir, ks.reposDir, ks.receiptsDir, ks.journalDir]) mkdir0700(dir); const findings = ks.auditPermissions(); ks.audit("keystore_opened", undefined, { findings: findings.length }); if (findings.length > 0 && ks.strictPermissions) { fail("VAULT_KEYSTORE_PERMISSIONS", "vault keystore permission audit failed", "opening vault keystore", { findings }); } return ks; } get identityPath() { return join(this.rootDir, "identity.json"); } get reposDir() { return join(this.rootDir, "repos"); } get receiptsDir() { return join(this.rootDir, "receipts"); } get journalDir() { return join(this.rootDir, "journal"); } get auditLogPath() { return join(this.rootDir, "audit.log"); } repoPath(repoId) { if (!VAULT_SRC_RE.test(repoId)) fail("VAULT_BAD_REPO_ID", `not a src_ id: ${repoId}`, "resolving vault repo path"); return join(this.reposDir, `${repoId}.json`); } recoveryReceiptPath(repoId) { if (!VAULT_SRC_RE.test(repoId)) fail("VAULT_BAD_REPO_ID", `not a src_ id: ${repoId}`, "resolving vault receipt path"); return join(this.receiptsDir, `${repoId}.recovery_receipt.json`); } // ── permission audit ── /** Audit every keystore path that exists; findings are recorded in `audit.log` and returned. */ auditPermissions() { const findings = []; const check = (path, dir) => { if (!existsSync(path) && !isSymlink(path)) return; findings.push(...auditPath(path, dir)); }; check(this.rootDir, true); check(this.identityPath, false); check(this.auditLogPath, false); for (const dir of [this.reposDir, this.journalDir]) { check(dir, true); if (existsSync(dir)) { for (const entry of readdirSync(dir)) { const p = join(dir, entry); if (entry.endsWith(".lock")) continue; check(p, statSafe(p)?.isDirectory() ?? false); } } } for (const f of findings) this.audit("permission_finding", undefined, { ...f }); return findings; } // ── audit log ── /** Append one JSON line to `audit.log` (0600, created no-follow). */ audit(event, repoId, details) { const entry = { at: formatVaultTimestamp(this.now()), event, ...(repoId ? { repo_id: repoId } : {}), ...(details ? { details } : {}) }; mkdir0700(this.rootDir); if (isSymlink(this.auditLogPath)) { fail("VAULT_KEYSTORE_SYMLINK", "refusing to append through a symlinked audit.log", "appending vault audit log"); } const fd = openSync(this.auditLogPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | O_NOFOLLOW, FILE_MODE); try { writeSync(fd, `${JSON.stringify(entry)}\n`); fsyncSync(fd); } finally { closeSync(fd); } } /** Read the audit log (newest last). */ readAuditLog() { const text = readFileNoFollow(this.auditLogPath); if (!text) return []; return text.split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); } // ── identity ── readIdentity() { const text = readFileNoFollow(this.identityPath); if (!text) return null; const parsed = JSON.parse(text); if (parsed.version !== 1 || typeof parsed.signing_pubkey !== "string" || typeof parsed.encryption_pubkey !== "string") { fail("VAULT_KEYSTORE_CORRUPT", "identity.json is not a version-1 vault identity", "reading vault identity"); } return parsed; } /** Create the identity if absent; never overwrites an existing one. */ ensureIdentity() { const existing = this.readIdentity(); if (existing) return existing; const signing = generateSigningKeypair(); const encryption = generateEncryptionKeypair(); const identity = { version: 1, signing_seed_hex: bytesToHex(signing.seed), signing_pubkey: toBase64url(signing.public_key), signing_fingerprint: vkFingerprint(signing.public_key), encryption_private_key_hex: bytesToHex(encryption.private_key), encryption_pubkey: toBase64url(encryption.public_key), encryption_fingerprint: ekFingerprint(encryption.public_key), created_at: formatVaultTimestamp(this.now()), }; if (existsSync(this.identityPath)) { fail("VAULT_KEYSTORE_CONFLICT", "identity.json appeared concurrently; refusing to overwrite", "creating vault identity"); } writeFileAtomic0600(this.identityPath, JSON.stringify(identity, null, 2)); this.audit("identity_created", undefined, { signing_fingerprint: identity.signing_fingerprint, encryption_fingerprint: identity.encryption_fingerprint }); return identity; } /** * Record (idempotently) which principal this identity enrolled as. A * DIFFERENT already-recorded principal is refused — a deliberate migration * deletes/replaces the identity, it never silently rebinds. */ bindIdentityPrincipal(principalId) { const identity = this.requireIdentity(); if (identity.enrolled_principal_id === principalId) return; if (identity.enrolled_principal_id) { fail("VAULT_IDENTITY_PROFILE_MISMATCH", `this keystore identity enrolled as principal ${identity.enrolled_principal_id}, not ${principalId} — refusing cross-profile reuse`, "binding vault identity", { enrolled_principal_id: identity.enrolled_principal_id, principal_id: principalId }); } writeFileAtomic0600(this.identityPath, JSON.stringify({ ...identity, enrolled_principal_id: principalId }, null, 2)); this.audit("identity_principal_bound", undefined, { principal_id: principalId }); } /** The signing keypair, or `null` when the signing seed is lost (read-only principal). */ signingKeypair(identity = this.requireIdentity()) { if (!identity.signing_seed_hex) return null; const kp = generateSigningKeypair(hexToBytes(identity.signing_seed_hex)); if (vkFingerprint(kp.public_key) !== identity.signing_fingerprint) { fail("VAULT_KEYSTORE_CORRUPT", "identity signing seed does not derive the recorded fingerprint", "loading vault signing key"); } return kp; } /** The encryption keypair, or `null` when the X25519 private key is lost. */ encryptionKeypair(identity = this.requireIdentity()) { if (!identity.encryption_private_key_hex) return null; const kp = generateEncryptionKeypair(hexToBytes(identity.encryption_private_key_hex)); if (ekFingerprint(kp.public_key) !== identity.encryption_fingerprint) { fail("VAULT_KEYSTORE_CORRUPT", "identity encryption key does not derive the recorded fingerprint", "loading vault encryption key"); } return kp; } requireIdentity() { const id = this.readIdentity(); if (!id) fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "loading vault identity", { statement: VAULT_TERMINAL_LOSS_STATEMENT }); return id; } // ── repo files ── readRepo(repoId) { const text = readFileNoFollow(this.repoPath(repoId)); if (!text) return null; const parsed = JSON.parse(text); if (parsed.version !== 1 || parsed.repo_id !== repoId || typeof parsed.k_repo_hex !== "string") { fail("VAULT_KEYSTORE_CORRUPT", `repos/${repoId}.json is not a version-1 vault repo file`, "reading vault repo file", { repo_id: repoId }); } return parsed; } listRepoIds() { if (!existsSync(this.reposDir)) return []; return readdirSync(this.reposDir).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5)).filter((id) => VAULT_SRC_RE.test(id)).sort(); } /** * vault-offline-clone-resolve (design D1/D2/D6, task 1.1): the * keystore's project→repo lookup that lets {@link resolveVaultAddress} * (`vault-address.ts`) answer an id-form address WITHOUT the network * `findVaultByProject` round trip. D2's completeness argument: decrypting * anything requires exactly this repo file (`k_repo_hex`), so "this * keystore holds a matching file" is exactly the set of clones that can * complete regardless of how resolution went — the network call was only * ever confirming a fact this machine already held. * * `orgId`, when supplied, is a HARD equality filter, never a hint: a * `project_id` match under a different `org_id` is a miss (D1, "mismatched * org → treat as miss, never guess") — an org transfer or an id collision * must never resolve to the wrong vault silently. Passing `undefined` * accepts any org for that project id. * * Multiple repo files can legitimately name the same `project_id` — most * commonly a re-vaulted project that left its old file behind under its * old `repo_id` on THIS machine. D3: the most-recently-modified file (by * filesystem mtime — D6 rules out a maintained on-disk index for v1) wins; * a wrong pick is indistinguishable from an ordinary stale pin and heals * through the same {@link recoverStaleVaultPin} first-use-refusal path. * * Cheap by construction (D6): one directory listing plus one JSON read per * candidate — no maintained project→repo index. NEVER throws: a corrupt, * unreadable, or concurrently-deleted repo file is skipped exactly as a * cache miss, because a project-id scan has no single `repo_id` to blame a * throw on (unlike {@link readRepo}'s single-id contract, where throwing on * corruption is the right, attributable failure). Returns `null` on no * match, an empty keystore, or a not-yet-created `repos/` directory. */ findRepoByProject(projectId, orgId) { let best = null; for (const repoId of this.listRepoIds()) { let repo; try { repo = this.readRepo(repoId); } catch { continue; // corrupt/unreadable file — skip silently, this is a scan, not a targeted read } if (!repo || repo.project_id !== projectId) continue; if (orgId !== undefined && repo.org_id !== orgId) continue; let mtimeMs; try { mtimeMs = statSync(this.repoPath(repoId)).mtimeMs; } catch { continue; // vanished between listing and stat — skip } if (!best || mtimeMs > best.mtimeMs) best = { repo, mtimeMs }; } return best?.repo ?? null; } /** Save (create or replace) a repo file under the per-repo lock. */ saveRepo(repo) { const full = { version: 1, ...repo, updated_at: formatVaultTimestamp(this.now()) }; this.withRepoLock(repo.repo_id, () => { writeFileAtomic0600(this.repoPath(repo.repo_id), JSON.stringify(full, null, 2)); }); this.audit("repo_saved", repo.repo_id, { provenance: repo.provenance, genesis_sha256: repo.genesis_sha256 }); return full; } /** Update the dual pins / last ref transaction without touching key material. */ updateRepo(repoId, patch) { return this.withRepoLock(repoId, () => { const existing = this.readRepo(repoId); if (!existing) fail("VAULT_REPO_STATE_MISSING", `no repo file for ${repoId}`, "updating vault repo file", { repo_id: repoId }); const full = { ...existing, ...patch, updated_at: formatVaultTimestamp(this.now()) }; writeFileAtomic0600(this.repoPath(repoId), JSON.stringify(full, null, 2)); return full; }); } /** * vault-object-host-predial (design D1/D4, task 1.2): record that * `origins` (already-normalized `scheme://host` strings) were observed * serving THIS repo's objects — the transport's write-through-on-change * hook. Most-recently-observed wins ties over anything already recorded; * deduped; capped at {@link VAULT_OBJECT_STORE_ORIGINS_CAP}. A true * no-op (no lock taken, no write, `updated_at` untouched) when the * resulting set is IDENTICAL — in order — to what is already on disk, so * the steady state (every session re-observes the same one or two * origins) costs nothing. Best-effort by contract: a missing repo file * (this call racing a not-yet-`saveRepo`'d creation) or any read/write * failure is swallowed — this is a latency hint, never load-bearing, and * must never surface into a caller's own object-read path. */ recordObjectStoreOrigins(repoId, origins) { if (origins.length === 0) return; try { const existing = this.readRepo(repoId); if (!existing) return; const current = existing.object_store_origins ?? []; const next = []; for (const o of origins) if (!next.includes(o)) next.push(o); for (const o of current) { if (next.length >= VAULT_OBJECT_STORE_ORIGINS_CAP) break; if (!next.includes(o)) next.push(o); } const capped = next.slice(0, VAULT_OBJECT_STORE_ORIGINS_CAP); if (capped.length === current.length && capped.every((o, i) => o === current[i])) return; this.updateRepo(repoId, { object_store_origins: capped }); } catch { /* best-effort learning only — never surfaces into an object read */ } } // ── local immutable-object cache (vault-client-round-trips design D3) ── // // `<rootDir>/<repo_id>/objects/` — beside the repo file above, NOT inside // it: genesis bytes, admitted head bytes, and carrier (ref_state / // retention_roots) ciphertext this principal has already fetched and // verified once. Holds ONLY ciphertext and signed public objects — never // decrypted plaintext, key material, or key envelopes. This class stores // and retrieves bytes alongside the sha256 they were verified against; it // does not itself re-verify anything — every caller (`Vault`) // re-checks a cache hit against the SAME pin/receipt it would check // network bytes against before trusting it, so a stale or tampered entry // can only ever be discarded and refetched, never silently trusted. A // missing or corrupt cache file reads back as `null` (a plain cache miss), // never a thrown error — the cache is a pure accelerator, and its absence // degrades to exactly today's network-fetch behavior. objectsRepoDir(repoId) { if (!VAULT_SRC_RE.test(repoId)) fail("VAULT_BAD_REPO_ID", `not a src_ id: ${repoId}`, "resolving vault object cache path"); return join(this.rootDir, repoId, "objects"); } objectsGenesisPath(repoId) { return join(this.objectsRepoDir(repoId), "genesis.json"); } objectsHeadsDir(repoId) { return join(this.objectsRepoDir(repoId), "heads"); } objectsCarriersDir(repoId) { return join(this.objectsRepoDir(repoId), "carriers"); } objectsHeadPath(repoId, generation) { return join(this.objectsHeadsDir(repoId), `${generation}.json`); } objectsCarrierPath(repoId, objectId) { return join(this.objectsCarriersDir(repoId), `${objectId}.json`); } readCachedFile(path) { const text = readFileNoFollow(path); if (!text) return null; try { const parsed = JSON.parse(text); return typeof parsed === "object" && parsed !== null ? parsed : null; } catch { return null; } } writeCachedFile(path, data) { writeFileAtomic0600(path, JSON.stringify(data)); } decodeCachedBytes(data) { if (!data || typeof data.sha256 !== "string" || typeof data.bytes_base64 !== "string") return null; try { return { sha256: data.sha256, bytes: new Uint8Array(Buffer.from(data.bytes_base64, "base64")) }; } catch { return null; } } /** Genesis is one small object per vault, immutable forever — cached and never evicted. */ readCachedGenesis(repoId) { return this.decodeCachedBytes(this.readCachedFile(this.objectsGenesisPath(repoId))); } writeCachedGenesis(repoId, sha256, bytes) { this.writeCachedFile(this.objectsGenesisPath(repoId), { sha256, bytes_base64: Buffer.from(bytes).toString("base64") }); } readCachedHead(repoId, generation) { return this.decodeCachedBytes(this.readCachedFile(this.objectsHeadPath(repoId, generation))); } /** Writes the head, then evicts anything more than {@link VAULT_OBJECT_CACHE_WINDOW} generations behind it. */ writeCachedHead(repoId, generation, sha256, bytes) { this.writeCachedFile(this.objectsHeadPath(repoId, generation), { sha256, bytes_base64: Buffer.from(bytes).toString("base64") }); this.evictObjectCache(repoId, generation); } readCachedCarrier(repoId, objectId) { const data = this.readCachedFile(this.objectsCarrierPath(repoId, objectId)); const decoded = this.decodeCachedBytes(data); if (!decoded || typeof data?.generation !== "string") return null; return { ...decoded, generation: data.generation }; } /** Writes the carrier (keyed by `object_id`, tagged with the generation that referenced it), then sweeps the window. */ writeCachedCarrier(repoId, objectId, generation, sha256, bytes) { this.writeCachedFile(this.objectsCarrierPath(repoId, objectId), { sha256, bytes_base64: Buffer.from(bytes).toString("base64"), generation }); this.evictObjectCache(repoId, generation); } /** * Drop heads and carriers more than {@link VAULT_OBJECT_CACHE_WINDOW} * generations behind `latestGeneration` — genesis is exempt (kept * forever). Runs inline on every cache write; also callable directly * (`sweepObjectCache`, wired into `repos gc`) as a periodic backstop that * catches any orphan a crashed write might have left behind. Best-effort: * an unlink failure (e.g. already gone) is silently ignored, matching the * cache's own "a miss just refetches" posture. */ evictObjectCache(repoId, latestGeneration) { const cutoff = genToBigInt(latestGeneration) - BigInt(VAULT_OBJECT_CACHE_WINDOW - 1); const pruneDir = (dir, generationOf) => { if (!existsSync(dir)) return; for (const entry of readdirSync(dir)) { if (!entry.endsWith(".json")) continue; const gen = generationOf(entry); if (gen === null || genToBigInt(gen) < cutoff) { try { unlinkSync(join(dir, entry)); } catch { /* already gone — fine */ } } } }; pruneDir(this.objectsHeadsDir(repoId), (entry) => { const gen = entry.slice(0, -".json".length); return /^[0-9a-f]{16}$/.test(gen) ? gen : null; }); pruneDir(this.objectsCarriersDir(repoId), (entry) => { const data = this.readCachedFile(join(this.objectsCarriersDir(repoId), entry)); return typeof data?.generation === "string" ? data.generation : null; }); } /** The `repos gc`-time sweep (task 4.2): re-applies the SAME eviction window as an inline write would, for every repo this keystore holds. Safe to call on a repo with no cache directory at all (a no-op). */ sweepObjectCache(repoId) { const repo = this.readRepo(repoId); const newest = repo?.materialized_pin?.generation ?? repo?.head_pin?.generation ?? null; if (newest) this.evictObjectCache(repoId, newest); } /** * Record a COMMITTED epoch rotation (D194, rev 42): advances the local * "current" pointer (`epoch` + `k_repo_hex`) to the new epoch's key AND * appends it to `epoch_keys` (never overwriting a prior entry — every key * this principal has ever held stays locally available for historical * decrypt / the D195 prior-key-inequality check on the NEXT rotation). * Audited distinctly from an ordinary `updateRepo` patch. */ recordEpochRotation(repoId, input) { return this.withRepoLock(repoId, () => { const existing = this.readRepo(repoId); if (!existing) fail("VAULT_REPO_STATE_MISSING", `no repo file for ${repoId}`, "recording vault epoch rotation", { repo_id: repoId }); const epochKeys = { ...(existing.epoch_keys ?? {}), [existing.epoch]: existing.k_repo_hex, [input.new_epoch]: input.new_k_repo_hex }; const full = { ...existing, epoch: input.new_epoch, k_repo_hex: input.new_k_repo_hex, epoch_keys: epochKeys, updated_at: formatVaultTimestamp(this.now()) }; writeFileAtomic0600(this.repoPath(repoId), JSON.stringify(full, null, 2)); this.audit("epoch_rotation_recorded", repoId, { new_epoch: input.new_epoch, known_epoch_count: Object.keys(epochKeys).length }); return full; }); } // ── recovery receipts ── saveRecoveryReceipt(receipt) { // Not a secret — but written with the same discipline so a partially written // receipt is never mistaken for a complete one. writeFileAtomic0600(this.recoveryReceiptPath(receipt.repo_id), JSON.stringify(receipt, null, 2)); this.audit("recovery_receipt_stored", receipt.repo_id, { genesis_sha256: receipt.genesis_sha256 }); } readRecoveryReceipt(repoId) { const text = readFileNoFollow(this.recoveryReceiptPath(repoId)); return text ? JSON.parse(text) : null; } // ── per-repo lock ── /** * Run `fn` holding the per-repo lock (`repos/<repo_id>.lock/`, mkdir-atomic). * A lock older than `staleAfterMs` whose recorded pid is dead is reclaimed * (audited). Re-entrant for the same process+repo. */ withRepoLock(repoId, fn, options = {}) { const lockDir = `${this.repoPath(repoId)}.lock`; if (this.held.has(lockDir)) return fn(); const staleAfterMs = options.staleAfterMs ?? 10 * 60 * 1000; const timeoutMs = options.timeoutMs ?? 30 * 1000; const deadline = Date.now() + timeoutMs; mkdir0700(this.reposDir); for (;;) { try { mkdirSync(lockDir, { mode: DIR_MODE }); break; } catch (e) { if (e.code !== "EEXIST") throw e; if (this.reclaimStaleLock(lockDir, repoId, staleAfterMs)) continue; if (Date.now() >= deadline) { fail("VAULT_KEYSTORE_LOCKED", `another process holds the lock for ${repoId}`, "acquiring vault repo lock", { repo_id: repoId, lock: lockDir }); } const until = Date.now() + 25; while (Date.now() < until) { /* spin */ } } } try { writeFileAtomic0600(join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, at: formatVaultTimestamp(this.now()) })); } catch { /* owner record is advisory */ } this.held.add(lockDir); this.audit("lock_acquired", repoId); try { return fn(); } finally { this.held.delete(lockDir); try { safeRemoveLock(lockDir); } catch { /* best-effort */ } this.audit("lock_released", repoId); } } held = new Set(); reclaimStaleLock(lockDir, repoId, staleAfterMs) { let ownerPid; let age = Number.POSITIVE_INFINITY; try { const st = statSync(lockDir); age = Date.now() - st.mtimeMs; const owner = readFileNoFollow(join(lockDir, "owner.json")); if (owner) ownerPid = JSON.parse(owner).pid; } catch { /* unreadable owner record → rely on age */ } const ownerDead = ownerPid !== undefined && ownerPid !== process.pid && !pidAlive(ownerPid); if (age > staleAfterMs || ownerDead) { try { safeRemoveLock(lockDir); this.audit("lock_stale_reclaimed", repoId, { owner_pid: ownerPid ?? null, age_ms: Math.round(age) }); return true; } catch { return false; } } return false; } // ── §5.1 transitions ── /** * Derive the partial-loss transition for one vault. `expectedGenesisSha256` * (from a freshly listed genesis, 5.4) turns a pin mismatch into `stale_pin`. */ assess(repoId, expectedGenesisSha256) { const identity = this.readIdentity(); const repo = this.readRepo(repoId); let state; if (!identity && !repo) { state = { state: "unrecoverable", code: "VAULT_UNRECOVERABLE", statement: VAULT_TERMINAL_LOSS_STATEMENT, doctor_text: VAULT_TERMINAL_LOSS_DOCTOR_TEXT }; } else if (!identity && repo) { // K_repo survives but no key can sign or open anything new: the history // stays READABLE (K_repo decrypts) but nothing can be published. state = { state: "read_only", repo, reason: "signing_key_lost" }; } else if (identity && !repo) { state = identity.encryption_private_key_hex ? { state: "repo_state_lost_identity_intact", next_action: "restore_from_envelope" } : { state: "unrecoverable", code: "VAULT_UNRECOVERABLE", statement: VAULT_TERMINAL_LOSS_STATEMENT, doctor_text: VAULT_TERMINAL_LOSS_DOCTOR_TEXT }; } else if (identity && repo && !identity.signing_seed_hex) { state = { state: "read_only", repo, reason: "signing_key_lost" }; } else if (expectedGenesisSha256 !== undefined && repo && repo.genesis_sha256 !== expectedGenesisSha256) { state = { state: "stale_pin", repo, next_action: "reverify_from_genesis" }; } else { state = { state: "ready", repo: repo }; } this.audit("transition_assessed", repoId, { state: state.state }); return state; } /** * §5.1 "repo file lost + identity intact → restore K_repo from own envelope". * The genesis is checked (signature + key bindings against the stored * envelope + the recovery receipt/pin when one survives) BEFORE the envelope * is opened; the restored file is marked `restored_from_envelope`. */ async restoreRepoFromEnvelope(input) { const identity = this.readIdentity(); if (!identity) fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "restoring vault repo state", { statement: VAULT_TERMINAL_LOSS_STATEMENT }); const recipient = this.encryptionKeypair(identity); if (!recipient) fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "restoring vault repo state", { statement: VAULT_TERMINAL_LOSS_STATEMENT }); const { genesis, envelope } = input; if (!verifyVaultObject(genesis, genesis.creator_signing_pubkey)) { fail("VAULT_SIGNATURE_INVALID", "vault_genesis signature does not verify", "restoring vault repo state", { repo_id: genesis.repo_id }); } // The creator's OWN genesis envelope is bound to genesis byte-for-byte // (hash + size + recipient). Any OTHER recipient's envelope (a member // wrapped post-genesis — vault-agent-envelopes D4's cold open) is // not in genesis at all: for it, check genesis's own structural bindings // plus the envelope's addressing (this vault, this epoch, written under // the registered writer key); `openKeyEnvelope` then verifies the // writer's signature over it before anything is opened. const creatorEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "creator_encryption_pubkey")); const isCreatorEnvelope = envelope.recipient_fingerprint === creatorEk; const bindings = isCreatorEnvelope ? checkGenesisKeyBindings(genesis, envelope) : checkGenesisKeyBindings(genesis); // The key that must have signed this envelope: the genesis writer, or — // rev 47, writers plural — the admitted writer `created_by` names. let envelopeSignerPubkey = genesis.creator_signing_pubkey; if (!isCreatorEnvelope) { if (envelope.repo_id !== genesis.repo_id) bindings.push("stored_envelope_repo_id"); if (envelope.epoch !== genesis.epoch) bindings.push("stored_envelope_epoch"); if (envelope.created_by !== genesis.writer_key_id) { const wrapper = (input.admitted_writers ?? []).find((w) => w.writer_key_id === envelope.created_by); if (!wrapper) bindings.push("stored_envelope_created_by"); else envelopeSignerPubkey = wrapper.signing_pubkey; } } if (bindings.length > 0) { fail("VAULT_CREATION_CONFLICT", "genesis key bindings do not hold for the stored envelope", "restoring vault repo state", { repo_id: genesis.repo_id, problems: bindings }); } const genesisSha = storedBytesSha256(genesis); // First-open pin (agent-envelopes D4): a genesis this keystore already // pinned for this repo_id — in a surviving repo file or the cached genesis // — must be the SAME genesis; a different one is a substituted vault. const priorRepo = this.readRepo(genesis.repo_id); const priorPin = priorRepo?.genesis_sha256 ?? this.readCachedGenesis(genesis.repo_id)?.sha256 ?? null; if (priorPin && priorPin !== genesisSha) { fail("VAULT_CREATION_CONFLICT", `the admitted genesis (${genesisSha}) is not the one this keystore pinned (${priorPin}); refusing a substituted vault`, "restoring vault repo state", { repo_id: genesis.repo_id, admitted: genesisSha, pinned: priorPin }); } const continuity = priorPin ? "pinned" : "first_seen"; const receipt = input.recovery_receipt ?? this.readRecoveryReceipt(genesis.repo_id); let trust = input.allocation_attested ? "platform_attested" : "unauthenticated_salvage"; if (receipt) { const problems = checkRecoveryReceipt(receipt, genesis); if (problems.length > 0) { fail("VAULT_CREATION_CONFLICT", "recovery receipt does not pin this genesis (substituted vault?)", "restoring vault repo state", { repo_id: genesis.repo_id, problems }); } trust = "receipt"; } if (envelope.recipient_fingerprint !== identity.encryption_fingerprint) { fail("VAULT_ENVELOPE_NOT_FOR_RECIPIENT", "the stored envelope is addressed to another principal", "restoring vault repo state", { recipient_fingerprint: envelope.recipient_fingerprint }); } const kRepo = await openKeyEnvelope({ envelope, recipient, signer_public_key: envelopeSignerPubkey }); const repo = this.saveRepo({ repo_id: genesis.repo_id, org_id: genesis.org_id, project_id: genesis.project_id, k_repo_hex: bytesToHex(kRepo), epoch: envelope.epoch, genesis_sha256: genesisSha, head_pin: null, last_ref_transaction: null, provenance: "restored_from_envelope", }); this.audit("repo_restored_from_envelope", genesis.repo_id, { trust, continuity }); return { repo, trust, continuity, independently_verified: trust === "receipt" }; } /** Identity fingerprints for display/doctor (never key material). */ identitySummary() { const id = this.readIdentity(); if (!id) return null; if (!VAULT_VK_RE.test(id.signing_fingerprint) || !VAULT_EK_RE.test(id.encryption_fingerprint)) { fail("VAULT_KEYSTORE_CORRUPT", "identity fingerprints are malformed", "reading vault identity"); } return { signing_fingerprint: id.signing_fingerprint, encryption_fingerprint: id.encryption_fingerprint, signing_key_present: Boolean(id.signing_seed_hex), encryption_key_present: Boolean(id.encryption_private_key_hex), }; } } function isSymlink(path) { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } } function statSafe(path) { try { return lstatSync(path); } catch { return null; } } function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } } function safeRemoveLock(lockDir) { const owner = join(lockDir, "owner.json"); // rmdir needs an empty directory; the owner record is the only file ever written there. if (existsSync(owner)) unlinkSync(owner); rmdirSync(lockDir); } //# sourceMappingURL=vault-keystore.js.map