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.

626 lines • 42.4 kB
/** * vault-mirror-and-recover — `r402s-recover`, the offline recovery engine * (design D4/D5, tasks 3.1–3.5). * * Turns a mirrored `source/<repo_id>/` prefix (any {@link VaultMirrorBackend} * — S3 or a local directory) plus a pin (the keystore's recovery receipt, or * one supplied explicitly) into a working git repository, with NO SERVER * INVOLVED: everything here reads from the {@link VaultMirrorBackend} * alone. Only the decrypt/materialize step touches key material (design D5) * — discovery, chain verification, and closure/absence adjudication run * keylessly and are a first-class outcome on their own (`run402 repos fsck * --mirror`), not a degraded mode of the keyed path. * * Reuses the SAME pure protocol functions the live SDK verifies pushes with * ({@link checkChainLink}, {@link assertNoTransition}, {@link * checkClaimSetEquality}, {@link vaultPaths}) — this file adds no new * protocol semantics, exactly per the design doc's "recovery is the keyed * offline subset of machinery that already exists." */ import { createHash } from "node:crypto"; import { existsSync, mkdirSync } from "node:fs"; import { LocalError, isRun402Error } from "../errors.js"; import { VAULT_GENESIS_EPOCH, VAULT_GENESIS_GENERATION, VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, bytesToHex, checkGenesisKeyBindings, checkRecoveryReceipt, deriveDigestKey, deriveObjectKey, generateEncryptionKeypair, hexToBytes, keyedCommitment, objectsetContent, openEpochRotationForRecipient, openFrame, openKeyEnvelope, parseVaultStrict, parseRotateEpochPayload, verifyVaultObject, } from "../namespaces/vault.crypto.js"; import { assertNoTransition, checkChainLink, checkClaimSetEquality, vaultPaths, nextGeneration, reconcileRetainedTipRefs } from "./vault-publication.js"; import { VaultKeystore } from "./vault-keystore.js"; import { discoverMemberBundles, parseMemberRecoveryBundle, unwrapMemberRecoveryBundle, } from "./vault-member-bundle.js"; import { hardenedGit, hasObject } from "./vault-snapshot.js"; function fail(code, message, context, details, nextActions) { throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) }); } function sha256Hex(bytes) { return createHash("sha256").update(bytes).digest("hex"); } /** * List `head/`, pin-check the genesis, and walk `prev_sha256` from genesis to * the newest generation this mirror both HOLDS and can VERIFY. A signature or * chain-link failure at generation K stops the walk at K-1 (`chain_break` * names why); a generation simply absent from the mirror also stops the walk * there, with `chain_break: null` (nothing further was ever pushed here, or * hasn't been synced yet — not evidence of corruption). * * A PIN MISMATCH is the one hard, no-fallback refusal (`VAULT_CREATION_CONFLICT`): * a substituted vault must never be silently accepted at "whatever chain * verifies", because a substituted vault's own chain verifies perfectly * against ITS OWN (different) creator key. */ export async function discoverAndVerifyChain(options) { const { backend } = options; const genesisBytes = await backend.get(vaultPaths.head(VAULT_GENESIS_GENERATION)); if (!genesisBytes) fail("CHAIN_BROKEN", "the mirror holds no genesis (head/0000000000000000) — nothing to recover", "discovering vault from mirror", { destination: backend.describe() }); const genesis = parseVaultStrict(new TextDecoder().decode(genesisBytes)); if (genesis.object_kind !== "vault_genesis") fail("CHAIN_BROKEN", "head/0000000000000000 is not a vault_genesis object", "discovering vault from mirror"); if (!verifyVaultObject(genesis, genesis.creator_signing_pubkey)) { fail("VAULT_SIGNATURE_INVALID", "vault_genesis signature does not verify under its own claimed creator key", "discovering vault from mirror", { repo_id: genesis.repo_id }); } const genesisSha = sha256Hex(genesisBytes); const receipt = options.recovery_receipt ?? options.keystore?.readRecoveryReceipt(genesis.repo_id) ?? null; let pinTrust = "unauthenticated_salvage"; if (receipt) { const problems = checkRecoveryReceipt(receipt, genesis); if (problems.length > 0) { fail("VAULT_CREATION_CONFLICT", `the recovery receipt does not pin this mirror's genesis (${problems.join(", ")}) — this mirror may hold a SUBSTITUTED vault; refusing to recover from it`, "discovering vault from mirror", { repo_id: genesis.repo_id, problems, expected_genesis_sha256: receipt.genesis_sha256, observed_genesis_sha256: genesisSha }, [{ action: "verify the mirror destination and pin are the ones you intend — do not retry with a different receipt to make this pass" }]); } pinTrust = "receipt"; } const chain = []; let prevHash = genesisSha; let prevGen = VAULT_GENESIS_GENERATION; let prevEpoch = VAULT_GENESIS_EPOCH; let chainBreak = null; const rotations = []; for (;;) { const nextGen = nextGeneration(prevGen); const bytes = await backend.get(vaultPaths.head(nextGen)); if (!bytes) break; // natural end — the mirror holds nothing further at this generation let head; try { head = parseVaultStrict(new TextDecoder().decode(bytes)); checkChainLink({ head, stored_bytes: bytes, listed_sha256: sha256Hex(bytes), expected_generation: nextGen, prev_sha256: prevHash, repo_id: genesis.repo_id, writer_public_key: genesis.creator_signing_pubkey, writer_key_id: genesis.writer_key_id, prev_epoch: prevEpoch, }); // D193, rev 42: `rotate_epoch` is a NO-THROW pass here — the keyless // chain walk continues right through it (`checkChainLink`'s own D194 // epoch-continuity check already validated it structurally); the other // three transition kinds remain the genuine fail-closed stop. assertNoTransition(head); if (head.transition !== null && head.transition.kind === "rotate_epoch") { rotations.push({ generation: head.generation, epoch: head.epoch, payload: parseRotateEpochPayload(head) }); } } catch (e) { chainBreak = { generation: nextGen, reason: e instanceof Error ? e.message : String(e) }; break; } prevEpoch = head.epoch; const headSha = sha256Hex(bytes); chain.push({ generation: nextGen, head, head_sha256: headSha }); prevHash = headSha; prevGen = nextGen; } return { repo_id: genesis.repo_id, genesis, genesis_sha256: genesisSha, pin_trust: pinTrust, chain, newest_generation: prevGen, newest_head_sha256: prevHash, chain_break: chainBreak, rotations, }; } /** * The required object set for one generation, resolved KEYLESSLY: * `checkpoint_claim_set` is plaintext-structured (never encrypted, protocol * §4.7) precisely so ITS ordered pack receipts enumerate every checkpoint * pack + manifest id without decryption — closure resolution never needs a * key. Walks back from `head` to the nearest checkpoint-bearing ancestor (or * genesis) collecting each head's own carriers + WAL entries along the way. */ export async function closureForGeneration(backend, chain, targetGeneration) { const byGen = new Map(chain.map((e) => [e.generation, e])); const out = []; let gen = targetGeneration; for (;;) { if (gen === VAULT_GENESIS_GENERATION) break; const entry = byGen.get(gen); if (!entry) fail("CHAIN_BROKEN", `generation ${gen} is not in the verified chain`, "resolving vault recovery closure", { generation: gen }); out.push({ key: vaultPaths.refState(entry.head.ref_state.object_id), object_id: entry.head.ref_state.object_id, kind: "ref_state" }); out.push({ key: vaultPaths.retentionRoots(entry.head.retention_roots.object_id), object_id: entry.head.retention_roots.object_id, kind: "retention_roots" }); if (entry.head.checkpoint) { const claimSetId = entry.head.checkpoint.claim_set.object_id; out.push({ key: vaultPaths.claimSet(claimSetId), object_id: claimSetId, kind: "checkpoint_claim_set" }); // The claim set's OWN content (plaintext-structured — no decryption) // enumerates the manifest + every pack. Missing claim-set bytes stop // enumeration here; the caller's adjudication loop reports it, and // enumeration simply cannot go further without it (honest, not silent). const claimBytes = await backend.get(vaultPaths.claimSet(claimSetId)); if (claimBytes) { const claimSet = parseVaultStrict(new TextDecoder().decode(claimBytes)); out.push({ key: vaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), object_id: claimSet.manifest_receipt.object_id, kind: "checkpoint_manifest" }); for (const p of claimSet.ordered_pack_receipts) out.push({ key: vaultPaths.checkpointPack(p.object_id), object_id: p.object_id, kind: "checkpoint_pack" }); } break; // the checkpoint's recovery bundle is self-contained; no earlier WAL is needed } for (const w of entry.head.wal_entries) out.push({ key: vaultPaths.wal(w.object_id), object_id: w.object_id, kind: "wal_pack" }); gen = generationMinusOne(gen); } return out; } function generationMinusOne(generation) { return (BigInt(`0x${generation}`) - 1n).toString(16).padStart(16, "0"); } /** * Check presence of every required object; for each ABSENT one, check the * mirror's OWN stored `prune_intent` objects (plaintext-structured, keyless) * for a delete-set entry naming it. Never a silent skip: every absence is * adjudicated one way or the other, and the caller decides what to do with an * `unexplained_absence` (typically: fall back one generation, see {@link * recoverWithFallback}). */ export async function adjudicateAbsences(backend, required) { const missing = []; for (const r of required) { const meta = await backend.head(r.key); if (!meta) missing.push(r); } if (missing.length === 0) return []; const pruneKeys = (await backend.list("prune/")).filter((k) => k.endsWith(".intent.json")); const intents = []; for (const key of pruneKeys) { const bytes = await backend.get(key); if (!bytes) continue; try { const intent = parseVaultStrict(new TextDecoder().decode(bytes)); intents.push({ object_id: intent.object_id, delete_set: new Set((intent.core?.delete_set ?? []).map((d) => d.object_id)) }); } catch { /* an unreadable prune intent adjudicates nothing; the missing object stays unexplained */ } } return missing.map((m) => { const covering = intents.find((i) => i.delete_set.has(m.object_id)); return { object_id: m.object_id, key: m.key, adjudication: covering ? "intentionally_pruned" : "unexplained_absence", prune_intent_object_id: covering?.object_id ?? null, }; }); } /** * Discovery + chain verification + closure/absence adjudication, WITHOUT * touching any key material — `run402 repos fsck --mirror`. Reports the * recoverable generation and an inventory; never decrypts, never * materializes. A genuinely keyless integrity probe (design D5) — useful as a * CI check that never needs a secret. */ export async function verifyVaultMirror(backend, options = {}) { const discovery = await discoverAndVerifyChain({ backend, ...options }); const { generation, absences } = await resolveWithFallback(backend, discovery, { requireMaterializable: false }); const inventory = {}; if (generation !== VAULT_GENESIS_GENERATION) { const required = await closureForGeneration(backend, discovery.chain, generation); for (const r of required) inventory[r.kind] = (inventory[r.kind] ?? 0) + 1; } // Member recovery-bundle sidecars — reported keylessly as UNVERIFIED // availability hints (the bundle itself never becomes part of the chain // verdict; the parsed blobs are not carried on the report, only identity // hints — a keyless report should never embed ciphertext). const memberBundles = (await discoverMemberBundles(backend)).map(({ bundle: _b, ...hint }) => hint); return { repo_id: discovery.repo_id, genesis_sha256: discovery.genesis_sha256, pin_trust: discovery.pin_trust, recovered_generation: generation, chain_verified_to_generation: discovery.newest_generation, chain_break: discovery.chain_break, epoch_decrypt_failure: null, absences, data_loss_detected: absences.some((a) => a.adjudication === "unexplained_absence"), validity_not_freshness: VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, keystore_still_required: VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, mode: "keyless_verify", inventory, member_recovery_bundles: memberBundles, }; } /** * Try `options.start_generation` (default `discovery.newest_generation`), * then fall back one generation at a time on `unexplained_absence`, * recording every adjudication along the way (never a silent skip — task * 3.2). Request 4 (D193-D203, rev 42): the KEYED recovery path caps * `start_generation` at the epoch-decrypt boundary BEFORE calling this — * an unopenable epoch is adjudicated as its OWN named cause (`epoch_decrypt_failure` * on the report), never folded into this function's generic absence * vocabulary. */ async function resolveWithFallback(backend, discovery, options) { const allAbsences = []; let gen = options.start_generation ?? discovery.newest_generation; for (;;) { if (gen === VAULT_GENESIS_GENERATION) return { generation: gen, absences: allAbsences }; const required = await closureForGeneration(backend, discovery.chain, gen); const absences = await adjudicateAbsences(backend, required); allAbsences.push(...absences); const unexplained = absences.filter((a) => a.adjudication === "unexplained_absence"); if (unexplained.length === 0) return { generation: gen, absences: allAbsences }; if (!options.requireMaterializable) { // Keyless verify still names the generation it landed on as the OLDEST // one with no unexplained gap, same fallback the keyed path takes. gen = generationMinusOne(gen); continue; } gen = generationMinusOne(gen); } } /** Open the recipient's genesis key envelope and decrypt `K_1` — the FIRST step in this file that touches key material (a genesis's own envelope, `rotation_id` absent — D203). */ async function resolveGenesisEpochKey(backend, genesis, identity) { if (!identity.encryption_private_key_hex) { fail("VAULT_UNRECOVERABLE", "no encryption key available — recovery can verify and adjudicate but cannot decrypt or materialize anything", "materializing vault recovery", undefined, [ { action: "restore the principal keystore (identity.json) from backup, then re-run recover" }, { action: "run `run402 repos fsck --mirror` for the verify-only outcome" }, ]); } if (genesis.envelopes.length === 0) fail("VAULT_CREATION_CONFLICT", "genesis carries no key envelope", "materializing vault recovery"); // A real vault can carry MORE than one key_envelope ledger row — a // foreign recipient's envelope is the norm on a multi-recipient vault, // never an anomaly. A // genesis's own `envelopes[]` has always had exactly one entry so far (the // creator's, minted at the six-stage creation — later reconcile-added // envelopes are separate ledger rows, never appended to the SIGNED // genesis), which is why blindly reading index 0 happened to be correct. // SELECT BY FINGERPRINT EXPLICITLY instead of by position: this machine // can only ever decrypt ITS OWN envelope (the recipient-only read gate // means the mirror never even holds anyone else's — see // `keyEnvelopeRecipientFingerprintFromKey`/`skipped_foreign_recipient` in // `vault-mirror.ts`), so this function never looks for, and never fails // on, a FOREIGN envelope being absent — that is silent-OK. A missing OWN // envelope in genesis.envelopes (no entry matches our fingerprint at all) // is its own distinct, honest refusal, separate from "the mirror never // synced the bytes for an envelope genesis DOES name for us" below. const envelopeReceipt = genesis.envelopes.find((e) => e.recipient_fingerprint === identity.encryption_fingerprint); if (!envelopeReceipt) { fail("VAULT_ENVELOPE_NOT_FOR_RECIPIENT", "genesis carries no key envelope for this machine's identity — this keystore is not a recipient of this vault", "materializing vault recovery", { own_fingerprint: identity.encryption_fingerprint }); } // `key-envelopes/<epoch>/<recipient_fingerprint>.env` — the GATEWAY's real // §3 wire key (`upload-sessions.ts` `UPLOADABLE_KINDS.key_envelope.key()`, // mirrored by `storage-keys.ts` `objectKeyFor`), matching protocol §3 // exactly. The SDK's OWN `vault-creation-journal.ts` envelope `path` // string (`envelopes/<epoch>/<fp>`) is a DIFFERENT, client-local label used // only for the upload manifest ("never rides the wire") — the gateway // derives its own key independently and never echoes that string back, so // a mirror synced from the real objects listing never contains it. const envelopeBytes = await backend.get(`key-envelopes/${envelopeReceipt.epoch}/${envelopeReceipt.recipient_fingerprint}.env`); if (!envelopeBytes) fail("VAULT_UNRECOVERABLE", "the mirror holds no key envelope for this genesis — cannot derive K_repo", "materializing vault recovery"); const envelope = parseVaultStrict(new TextDecoder().decode(envelopeBytes)); const bindingProblems = checkGenesisKeyBindings(genesis, envelope); if (bindingProblems.length > 0) fail("VAULT_CREATION_CONFLICT", `genesis key bindings do not hold for the stored envelope (${bindingProblems.join(", ")})`, "materializing vault recovery", { problems: bindingProblems }); if (envelope.recipient_fingerprint !== identity.encryption_fingerprint) { fail("VAULT_ENVELOPE_NOT_FOR_RECIPIENT", "the stored envelope is addressed to another principal — this keystore cannot open it", "materializing vault recovery", { recipient_fingerprint: envelope.recipient_fingerprint }); } const kp = generateEncryptionKeypair(hexToBytes(identity.encryption_private_key_hex)); return openKeyEnvelope({ envelope, recipient: kp, signer_public_key: genesis.creator_signing_pubkey }); } /** The mirror's own `key-envelopes/<epoch>/<rotation_id>/<fingerprint>.env` wire key (protocol §3) — a rotation-attempt envelope's path, distinct from a genesis/ADD envelope's `key-envelopes/<epoch>/<fingerprint>.env`. */ /** * Parameter order MUST match {@link openEpochRotationForRecipient}'s own * `envelope_path` field type — `(epoch, recipient_fingerprint, rotation_id)` * — exactly, not the storage path's OWN left-to-right key-segment order * (`key-envelopes/<epoch>/<rotation_id>/<fingerprint>.env`, protocol §3). * Getting this backwards type-checks fine (it is a positional function * type, so TypeScript cannot catch the swap) but silently resolves every * rotation envelope to the WRONG mirror key — caught by * `vault-epoch-reader.test.ts`'s own offline-recovery test, which is * exactly why the parameter names spell it out here too. */ function mirrorRotationEnvelopePath(epoch, recipientFingerprint, rotationId) { return `key-envelopes/${epoch}/${rotationId}/${recipientFingerprint}.env`; } /** * Traverse every admitted `rotate_epoch` transition {@link discoverAndVerifyChain} * saw, opening THIS keystore's own rotation-attempt envelope for each new * epoch from the mirror (recipient-only rule: only this keystore's own * envelopes are ever present on a mirror synced under the recipient-only * read gate — see {@link resolveGenesisEpochKey}'s own comment on the * genesis case). NEVER throws: a rotation this keystore cannot open stops * traversal and is recorded as `failure` — the caller decides what that * means for `recovered_generation` (Request 4's honest fallback, named at * the epoch boundary rather than folded into a generic absence). */ async function resolveRotationEpochKeys(backend, genesis, rotations, identity) { const epochKeys = {}; if (!identity.encryption_private_key_hex) return { epoch_keys_hex: epochKeys, failure: null }; const ownKeypair = generateEncryptionKeypair(hexToBytes(identity.encryption_private_key_hex)); const ownFingerprint = identity.encryption_fingerprint; for (const rot of rotations) { try { const kE = await openEpochRotationForRecipient({ repo_id: genesis.repo_id, payload: rot.payload, own_fingerprint: ownFingerprint, own_encryption_keypair: ownKeypair, writer_signing_public_key: genesis.creator_signing_pubkey, get_envelope_bytes: (path) => backend.get(path), envelope_path: mirrorRotationEnvelopePath, }); epochKeys[rot.epoch] = bytesToHex(kE); } catch (e) { const code = isRun402Error(e) && e.code ? e.code : "VAULT_EPOCH_NOT_OPENABLE"; return { epoch_keys_hex: epochKeys, failure: { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id, code, message: e instanceof Error ? e.message : String(e) } }; } } return { epoch_keys_hex: epochKeys, failure: null }; } /** * Pure (Request 4): given every locally-openable epoch key, compute the * newest generation this keystore can actually DECRYPT — the last * generation before the first rotation whose epoch is missing from * `epochKeysHex`, or `newestGeneration` if every rotation opened. Mirrors * the live publication read path's identical boundary computation * (`Vault.materialize`) so both surfaces report the same concept * under the same name. */ function computeDecryptableBoundaryGeneration(rotations, epochKeysHex, newestGeneration) { let boundary = rotations.length > 0 ? generationMinusOne(rotations[0].generation) : newestGeneration; for (let i = 0; i < rotations.length; i++) { const rot = rotations[i]; if (!epochKeysHex[rot.epoch]) { return { generation: boundary, failure: { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id, code: "VAULT_EPOCH_NOT_OPENABLE", message: `epoch ${rot.epoch} (rotation ${rot.payload.rotation_id}) could not be opened by this keystore — recovery caps at the last generation under the prior, openable epoch` }, }; } boundary = i + 1 < rotations.length ? generationMinusOne(rotations[i + 1].generation) : newestGeneration; } return { generation: boundary, failure: null }; } async function openCarrier(backend, kRepo, repoId, epoch, kind, objectId, ciphertextSha256, key) { const frame = await backend.get(key); if (!frame) fail("CHAIN_UNUSABLE", `${kind} ${objectId} is absent from the mirror`, "materializing vault recovery", { object_id: objectId }); const plaintext = openFrame({ k_obj: deriveObjectKey(kRepo, repoId, epoch, kind, objectId), repo_id: repoId, object_kind: kind, object_id: objectId, epoch, frame, expected_ciphertext_sha256: ciphertextSha256 }); return parseVaultStrict(new TextDecoder().decode(plaintext)); } /** * Materialize the resolved generation into `out_dir` and run §4.7 restorer * acceptance: scratch restore, every covered ref at its EXACT oid, `git fsck` * full connectivity, recompute the three keyed commitments against the * checkpoint manifest when one covers this generation. Acceptance failure is * a typed non-zero exit (throws) — never a partial, silently-accepted repo. */ export async function recoverVaultMirror(options) { // `out_dir` is "where to materialize the recovered repository" — the CLI's own // documented example (`--out ./restored`) is a path that naturally does // NOT already exist, same expectation as `git clone <url> restored-dir`. // Create it FIRST, before any discovery/decrypt work, so a bad `out_dir` // fails fast with a truthful, typed error instead of the misleading // `GIT_UNAVAILABLE` the first `hardenedGit` call would throw (see that function's own // `GIT_CWD_MISSING` disambiguation in `vault-snapshot.ts` — this is the // belt; that is the suspenders). try { mkdirSync(options.out_dir, { recursive: true }); } catch (e) { fail("VAULT_RECOVER_OUT_DIR_UNWRITABLE", `could not create the recovery output directory: ${options.out_dir}`, "materializing vault recovery", { out_dir: options.out_dir, cause: e instanceof Error ? e.message : String(e) }); } const { backend } = options; const keystore = options.keystore ?? VaultKeystore.open(); const discovery = await discoverAndVerifyChain({ backend, ...(options.recovery_receipt ? { recovery_receipt: options.recovery_receipt } : {}), keystore }); const repoId = discovery.repo_id; // ── Request 4 (D193-D203, rev 42): resolve every locally-openable epoch // key, then cap the materializable range at the DECRYPT boundary BEFORE // running the (keyless) absence fallback — an unopenable epoch is its OWN // named cause, never folded into the generic "unexplained_absence" // vocabulary `resolveWithFallback` already owns for missing bytes. // // vault-recovery-custody: the decrypt identity comes from EITHER the // keystore (the original path) OR a member recovery bundle opened with the // source recovery code (the human-member path, no keystore involved). A // code with no bundle — explicit or discovered in the mirror's own // `member-recovery-bundles/` sidecars — is the truthful refusal by name: // a server-side wrapper row that was never exported is not offline backup. let identity; let memberRecovery = null; if (options.source_recovery_code != null || options.member_bundle != null) { if (options.source_recovery_code == null) { fail("SOURCE_RECOVERY_CODE_REQUIRED", "a member recovery bundle was supplied without its source recovery code — the bundle is ciphertext; the code is what opens it", "materializing vault recovery", undefined, [ { action: "re-run with the source recovery code (SRC1-…) you saved at enrollment" }, ]); } const code = options.source_recovery_code; if (options.member_bundle) { // Re-validate even the typed input — the CLI hands over whatever JSON the // file held, and a malformed bundle should refuse by name, not TypeError. const suppliedBundle = parseMemberRecoveryBundle(options.member_bundle); const member = unwrapMemberRecoveryBundle({ bundle: suppliedBundle, source_recovery_code: code, ...(options.rp_id ? { rp_id: options.rp_id } : {}) }); identity = { encryption_private_key_hex: member.private_key_hex, encryption_fingerprint: member.fingerprint }; memberRecovery = { bundle_key: null, wrapper_id: member.wrapper_id, rp_id_used: member.rp_id_used, ek_fingerprint: member.fingerprint }; } else { const discovered = (await discoverMemberBundles(backend)).filter((b) => b.bundle != null); if (discovered.length === 0) { fail("RECOVERY_BUNDLE_MISSING", "a source recovery code was supplied but no member recovery bundle is available — none was passed and this mirror carries no member-recovery-bundles/ sidecar. The code alone cannot recover anything: export the bundle (console.run402.com/account → Download recovery bundle, or `run402 repos recovery-bundle`) and keep it with the mirror.", "materializing vault recovery", undefined, [{ action: "re-run with --bundle <exported-bundle.json>" }]); } // Try each discovered bundle — the AEAD authenticates, so a foreign // member's bundle simply fails to open and the loop moves on. let opened = null; let lastErr = null; for (const d of discovered) { try { opened = { member: unwrapMemberRecoveryBundle({ bundle: d.bundle, source_recovery_code: code, ...(options.rp_id ? { rp_id: options.rp_id } : {}) }), bundle_key: d.key }; break; } catch (e) { if (isRun402Error(e) && e.code === "RECOVERY_CODE_CHECKSUM_INVALID") throw e; // a local typo is the same typo for every bundle lastErr = e; } } if (!opened) { fail("WRAPPER_DID_NOT_OPEN", `this mirror carries ${discovered.length} member recovery-bundle sidecar(s) but the supplied code opened none of them — a wrong code, or a bundle belonging to a different member`, "materializing vault recovery", { tried: discovered.map((d) => d.key), cause: lastErr instanceof Error ? lastErr.message : String(lastErr) }); } identity = { encryption_private_key_hex: opened.member.private_key_hex, encryption_fingerprint: opened.member.fingerprint }; memberRecovery = { bundle_key: opened.bundle_key, wrapper_id: opened.member.wrapper_id, rp_id_used: opened.member.rp_id_used, ek_fingerprint: opened.member.fingerprint }; } } else { const stored = keystore.readIdentity(); if (!stored?.encryption_private_key_hex) { fail("VAULT_UNRECOVERABLE", "no encryption key in this keystore — recovery can verify and adjudicate but cannot decrypt or materialize anything", "materializing vault recovery", undefined, [ { action: "restore the principal keystore (identity.json) from backup, then re-run recover" }, { action: "a human member under wrapper custody: re-run with --bundle <exported-bundle.json> + the source recovery code" }, { action: "run `run402 repos fsck --mirror` for the verify-only outcome" }, ]); } identity = stored; } const k1 = await resolveGenesisEpochKey(backend, discovery.genesis, identity); const rotationKeys = await resolveRotationEpochKeys(backend, discovery.genesis, discovery.rotations, identity); const epochKeysHex = { [VAULT_GENESIS_EPOCH]: bytesToHex(k1), ...rotationKeys.epoch_keys_hex }; const decryptBoundary = computeDecryptableBoundaryGeneration(discovery.rotations, epochKeysHex, discovery.newest_generation); // The two possible epoch-decrypt failure sources (an unopenable rotation // envelope, or the boundary walk simply never reaching one) agree by // construction — `resolveRotationEpochKeys` stops at the SAME rotation // `computeDecryptableBoundaryGeneration` would independently name; prefer // whichever is non-null (both name the identical rotation when both fire). const epochDecryptFailure = decryptBoundary.failure ?? rotationKeys.failure; const startGeneration = decryptBoundary.generation < discovery.newest_generation ? decryptBoundary.generation : discovery.newest_generation; const { generation, absences } = await resolveWithFallback(backend, discovery, { requireMaterializable: true, start_generation: startGeneration }); if (generation === VAULT_GENESIS_GENERATION) { fail("CHECKPOINT_INCOMPLETE", epochDecryptFailure && startGeneration === VAULT_GENESIS_GENERATION ? `no generation could be materialized — epoch ${epochDecryptFailure.epoch} (rotation ${epochDecryptFailure.rotation_id}) could not be opened by this keystore and the vault's history begins there` : "no generation could be fully materialized — every generation back to genesis has an unexplained missing object", "materializing vault recovery", { absences, epoch_decrypt_failure: epochDecryptFailure }); } const entry = discovery.chain.find((e) => e.generation === generation); const epoch = entry.head.epoch; const kRepo = hexToBytes(epochKeysHex[epoch]); // guaranteed present — `generation` never exceeds `decryptBoundary.generation` const refState = await openCarrier(backend, kRepo, repoId, epoch, "ref_state", entry.head.ref_state.object_id, entry.head.ref_state.ciphertext_sha256, vaultPaths.refState(entry.head.ref_state.object_id)); const roots = await openCarrier(backend, kRepo, repoId, epoch, "retention_roots", entry.head.retention_roots.object_id, entry.head.retention_roots.ciphertext_sha256, vaultPaths.retentionRoots(entry.head.retention_roots.object_id)); // ── restore into scratch: newest checkpoint's packs, then every later WAL pack ── const chainUpTo = discovery.chain.filter((e) => e.generation <= generation); const heads = []; for (let i = chainUpTo.length - 1; i >= 0; i--) { heads.unshift(chainUpTo[i]); if (chainUpTo[i].head.checkpoint) break; } // A fresh output directory becomes a bare repository. An EXISTING git // directory — the degraded read materializes straight into a checkout's // own `.git` (vault-byo-primary-bucket design D4), and a re-run lands // on a prior recovery — is used as it is: `git init --bare .` on a // checkout's git dir would rewrite `core.bare = true` and leave the // working tree unusable ("this operation must be run in a work tree"). const isGitDirAlready = existsSync(`${options.out_dir}/HEAD`) && existsSync(`${options.out_dir}/objects`); if (!isGitDirAlready) await hardenedGit(options.out_dir, ["init", "-q", "--bare", "--object-format=sha1", "."]); let manifest = null; const first = heads[0]; // Every object below is decrypted under ITS OWN carrying head's `epoch` // (D194) — a covered span crossing a rotation mixes epochs, exactly like // the live publication read path's `restoreObjectsInto`. `epochKeysHex` // is guaranteed to cover every epoch from genesis through `epoch` // (`decryptBoundary` capped `generation` at the last generation with a // fully-resolved epoch key). const kRepoForEpoch = (e) => hexToBytes(epochKeysHex[e]); if (first.head.checkpoint) { const claimBytes = await backend.get(vaultPaths.claimSet(first.head.checkpoint.claim_set.object_id)); if (!claimBytes) fail("CHECKPOINT_INCOMPLETE", "checkpoint claim set absent from the mirror", "materializing vault recovery"); const claimSet = parseVaultStrict(new TextDecoder().decode(claimBytes)); if (!verifyVaultObject(claimSet, discovery.genesis.creator_signing_pubkey)) fail("CHECKPOINT_INCOMPLETE", "checkpoint claim set signature fails", "materializing vault recovery"); manifest = await openCarrier(backend, kRepoForEpoch(first.head.epoch), repoId, first.head.epoch, "checkpoint_manifest", claimSet.manifest_receipt.object_id, claimSet.manifest_receipt.ciphertext_sha256, vaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id)); checkClaimSetEquality(claimSet, manifest, first.head.checkpoint.covers_through_generation); for (const p of manifest.packs) { const frame = await backend.get(vaultPaths.checkpointPack(p.object_id)); if (!frame) fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "materializing vault recovery"); const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(first.head.epoch), repoId, first.head.epoch, "checkpoint_pack", p.object_id), repo_id: repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: first.head.epoch, frame, expected_ciphertext_sha256: p.ciphertext_sha256 }); if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes) fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "materializing vault recovery"); await hardenedGit(options.out_dir, ["index-pack", "--stdin", "--strict"], { input: plain }); } } for (const h of heads) { for (const w of h.head.wal_entries) { const frame = await backend.get(vaultPaths.wal(w.object_id)); if (!frame) fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "materializing vault recovery", { object_id: w.object_id }); const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(h.head.epoch), repoId, h.head.epoch, "wal_pack", w.object_id), repo_id: repoId, object_kind: "wal_pack", object_id: w.object_id, epoch: h.head.epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 }); await hardenedGit(options.out_dir, ["index-pack", "--stdin", "--strict"], { input: plain }); } } // ── every covered ref at its EXACT oid + HEAD (task 3.4) ── const coverageTips = new Set(Object.values(refState.refs)); for (const r of roots.roots) coverageTips.add(r.oid); if (refState.head_target.kind === "detached") coverageTips.add(refState.head_target.oid); for (const tip of coverageTips) { if (!(await hasObject(options.out_dir, tip))) fail("CHAIN_UNUSABLE", `covered tip ${tip} does not resolve after restore`, "materializing vault recovery", { oid: tip }); } for (const [ref, oid] of Object.entries(refState.refs)) { await hardenedGit(options.out_dir, ["update-ref", ref, oid]); } if (refState.head_target.kind === "symref") await hardenedGit(options.out_dir, ["symbolic-ref", "HEAD", refState.head_target.ref]); else await hardenedGit(options.out_dir, ["update-ref", "--no-deref", "HEAD", refState.head_target.oid]); // ── git fsck --no-dangling, full connectivity (task 3.4) ── const fsck = await hardenedGit(options.out_dir, ["fsck", "--no-dangling", "--full", "--strict"], { okStatuses: [1, 2] }); if (fsck.status !== 0) fail("CHECKPOINT_INCOMPLETE", `fsck reports missing connectivity after restore: ${fsck.stderr.slice(0, 500)}`, "materializing vault recovery"); // clone-installs-retained-refs (D2): every retained tip this recovery just // proved connective above is now present in the bare repo — install/ // reconcile its refs/r402/retain/* ref so a disaster-drill `git fsck` // reports nothing dangling. Runs AFTER fsck (and thus after coverage // verification) so a reconcile never references an object the restore // itself failed to land — same ordering `restoreObjectsInto` (clone/fetch) // uses. const retained_refs = await reconcileRetainedTipRefs(options.out_dir, { refs: refState.refs, roots: roots.roots, head_target: refState.head_target }); // ── recompute the three keyed commitments against the covering manifest (§4.7) ── // Keyed under the CHECKPOINT-bearing head's OWN epoch (`first.head.epoch`) // — the commitments are checkpoint-build-time artifacts, sealed under // whatever epoch was current when the checkpoint was minted, which can // differ from the (later) target generation's own epoch across a rotation. if (manifest) { const kDigestRefmap = deriveDigestKey(kRepoForEpoch(first.head.epoch), repoId, first.head.epoch, "refmap"); const kDigestRootset = deriveDigestKey(kRepoForEpoch(first.head.epoch), repoId, first.head.epoch, "rootset"); const kDigestObjectset = deriveDigestKey(kRepoForEpoch(first.head.epoch), repoId, first.head.epoch, "objectset"); const { signature: _s1, ...refStateContent } = refState; const { signature: _s2, ...rootsContent } = roots; const refmapOk = keyedCommitment(kDigestRefmap, refStateContent) === manifest.ref_state_hmac; const rootsetOk = keyedCommitment(kDigestRootset, rootsContent) === manifest.retention_roots_hmac; const sortedTips = [...coverageTips].sort(); const restoredOids = sortedTips.length === 0 ? [] : (await hardenedGit(options.out_dir, ["rev-list", "--objects", "--no-object-names", ...sortedTips])).lines().map((l) => l.trim()); const objectsetOk = keyedCommitment(kDigestObjectset, objectsetContent(restoredOids)) === manifest.object_set_hmac; if (!refmapOk || !rootsetOk || !objectsetOk) { fail("CHECKPOINT_INCOMPLETE", `restored state does not match the checkpoint's keyed commitments (refmap ${refmapOk ? "ok" : "MISMATCH"}, rootset ${rootsetOk ? "ok" : "MISMATCH"}, objectset ${objectsetOk ? "ok" : "MISMATCH"})`, "materializing vault recovery", { refmapOk, rootsetOk, objectsetOk }); } } return { repo_id: repoId, genesis_sha256: discovery.genesis_sha256, pin_trust: discovery.pin_trust, recovered_generation: generation, chain_verified_to_generation: discovery.newest_generation, chain_break: discovery.chain_break, epoch_decrypt_failure: generation === decryptBoundary.generation ? epochDecryptFailure : null, absences, data_loss_detected: absences.some((a) => a.adjudication === "unexplained_absence"), validity_not_freshness: VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, keystore_still_required: VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, mode: "recovered", out_dir: options.out_dir, refs: refState.refs, head_target: refState.head_target, member_recovery: memberRecovery, retained_refs, layout: "bare", next_actions: [ { action: "the recovered repository is bare (no working files) — clone it to get a working tree", command: `git clone ${options.out_dir} ${options.out_dir}-worktree` }, ], }; } //# sourceMappingURL=vault-recover.js.map