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.

168 lines • 9.38 kB
/** * vault-recovery-custody — the member recovery bundle * (`r402s-member-recovery-bundle/v1`), the exported sidecar that makes a * source recovery code work with NO run402 server. * * A human member under wrapper custody holds no keystore: their X25519 * member scalar exists only as sealed `swrap2_` wrappers in the platform * directory. A server-side wrapper row alone is NOT offline backup — this * bundle (the key identity + every ACTIVE wrapper ciphertext, exported via * `GET /agent/v1/source-access/recovery-bundle` or the console's download * button) kept in the member's own storage, together with the source * recovery code, is. `r402s-recover` opens the bundle's `recovery_code` * wrapper locally (see {@link unwrapMemberRecoveryBundle}) and recovers the * same repository a keystore-based recovery would. * * A raw WebAuthn PRF output is NOT a supported recovery input — PRF * evaluation is a live credential ceremony, not portable key material; the * supported no-server path for a human is the source recovery code * (vault-offline-recovery spec, as modified by vault-recovery-custody). * * Mirror sidecar convention: a member who wants their bundle to travel WITH * a vault mirror puts it under `member-recovery-bundles/<name>.json` in the * mirrored prefix — an explicitly member-initiated, out-of-band versioned * sidecar, never an r402s object (recovery/keyless-verify list that prefix * as unverified availability hints; nothing ever writes it automatically). */ import { LocalError } from "../errors.js"; import { buildSourceWrapperContext, bytesToHex, ekFingerprint, fromBase64url, normalizeSourceRecoveryCode, openSourceWrapper, } from "../namespaces/vault.crypto.js"; import { x25519 } from "@noble/curves/ed25519.js"; import { utf8ToBytes } from "@noble/hashes/utils.js"; function fail(code, message, context, details, nextActions) { throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) }); } export const VAULT_MEMBER_BUNDLE_FORMAT = "r402s-member-recovery-bundle/v1"; /** The mirrored-prefix sidecar directory recovery/verify scan for bundles. */ export const VAULT_MEMBER_BUNDLE_MIRROR_PREFIX = "member-recovery-bundles/"; /** * The seal-time ceremony host every wrapper in existence was sealed under * (`location.hostname` of the sealing console page). Used only when neither * the bundle nor the caller names one — the swrap2 context binds it, so a * wrong value fails closed as `WRAPPER_DID_NOT_OPEN`, never a silent * wrong-key open. */ export const VAULT_DEFAULT_SOURCE_WRAPPER_RP_ID = "console.run402.com"; /** Parse + shape-check a member recovery bundle (a JSON string or an already-parsed value). Typed refusal, never a silent partial. */ export function parseMemberRecoveryBundle(input, context = "parsing member recovery bundle") { let value = input; if (typeof value === "string") { try { value = JSON.parse(value); } catch (e) { fail("RECOVERY_BUNDLE_INVALID", `not JSON: ${e instanceof Error ? e.message : String(e)}`, context); } } const b = value; if (!b || typeof b !== "object") fail("RECOVERY_BUNDLE_INVALID", "bundle is not an object", context); if (b.format !== VAULT_MEMBER_BUNDLE_FORMAT) { fail("RECOVERY_BUNDLE_INVALID", `unsupported bundle format ${JSON.stringify(b.format ?? null)} — expected ${VAULT_MEMBER_BUNDLE_FORMAT}`, context, { format: b.format ?? null }); } for (const field of ["principal_id", "encryption_key_id", "ek_fingerprint", "public_key"]) { if (typeof b[field] !== "string" || b[field].length === 0) fail("RECOVERY_BUNDLE_INVALID", `bundle is missing ${field}`, context, { field }); } if (!Array.isArray(b.wrappers)) fail("RECOVERY_BUNDLE_INVALID", "bundle is missing wrappers[]", context, { field: "wrappers" }); for (const w of b.wrappers) { if (!w || typeof w !== "object" || typeof w.wrapper_id !== "string" || typeof w.wrapper_ciphertext !== "string") { fail("RECOVERY_BUNDLE_INVALID", "bundle wrappers[] entry is malformed (needs wrapper_id + wrapper_ciphertext)", context); } } return b; } /** * Open a member recovery bundle with the source recovery code, offline. * * Tries every `recovery_code` wrapper in the bundle (the AEAD authenticates, * so a wrong open fails closed — nothing is ever trusted un-verified), then * proves validity at use: derives the FULL public key from the recovered * scalar and compares it byte-for-byte against the bundle's published * `public_key` (and the `ek_` fingerprint). `webauthn_prf` wrappers are * deliberately not an input here — a raw PRF output is not portable key * material. */ export function unwrapMemberRecoveryBundle(input) { const context = "opening member recovery bundle"; const bundle = input.bundle; const rpId = input.rp_id ?? bundle.rp_id ?? VAULT_DEFAULT_SOURCE_WRAPPER_RP_ID; const core = normalizeSourceRecoveryCode(input.source_recovery_code); // throws RECOVERY_CODE_CHECKSUM_INVALID on a local typo const ikm = utf8ToBytes(core); const publishedPublicKey = fromBase64url(bundle.public_key, "bundle public_key"); if (publishedPublicKey.length !== 32) fail("RECOVERY_BUNDLE_INVALID", "bundle public_key is not a raw 32-byte X25519 key", context); const codeWrappers = bundle.wrappers.filter((w) => w.kind === "recovery_code"); if (codeWrappers.length === 0) { fail("RECOVERY_BUNDLE_MISSING", "this bundle carries no recovery_code wrapper — the source recovery code has nothing to open. A raw WebAuthn PRF output is not a supported recovery input; enroll a recovery code at console.run402.com/account and re-export the bundle.", context, { wrapper_kinds: bundle.wrappers.map((w) => w.kind) }); } let lastError = null; for (const w of codeWrappers) { const wrapperContext = buildSourceWrapperContext({ rp_id: rpId, principal_id: bundle.principal_id, encryption_key_id: bundle.encryption_key_id, wrapper_id: w.wrapper_id, kind: "recovery_code", credential_subject: null, member_public_key: publishedPublicKey, }); let scalar; try { scalar = openSourceWrapper({ kind: "recovery_code", ikm, blob: w.wrapper_ciphertext, context: wrapperContext }); } catch (e) { lastError = e; continue; } // Validity is proven at use: FULL public key comparison, never the // truncated fingerprint alone (round-1 finding 2). const derived = x25519.getPublicKey(scalar); if (bytesToHex(derived) !== bytesToHex(publishedPublicKey)) { fail("RECOVERY_BUNDLE_INVALID", `wrapper ${w.wrapper_id} opened but its scalar does not derive the bundle's published member public key — the bundle is internally inconsistent`, context, { wrapper_id: w.wrapper_id }); } const fingerprint = ekFingerprint(derived); if (fingerprint !== bundle.ek_fingerprint) { fail("RECOVERY_BUNDLE_INVALID", `derived fingerprint ${fingerprint} does not match the bundle's ${bundle.ek_fingerprint}`, context, { wrapper_id: w.wrapper_id }); } return { private_key_hex: bytesToHex(scalar), public_key: derived, fingerprint, wrapper_id: w.wrapper_id, rp_id_used: rpId }; } fail("WRAPPER_DID_NOT_OPEN", `none of the bundle's ${codeWrappers.length} recovery_code wrapper(s) opened with that code — a wrong/typo'd code (compare against your saved copy), a corrupt bundle, or a different seal-time host (tried rp_id ${JSON.stringify(rpId)}; pass --rp-id if the wrapper was sealed elsewhere).`, context, { tried_wrapper_ids: codeWrappers.map((w) => w.wrapper_id), rp_id_tried: rpId, cause: lastError instanceof Error ? lastError.message : String(lastError) }); } /** * List `member-recovery-bundles/*.json` sidecars in a mirrored prefix, * best-effort — the keyless-verify "availability hints" and recovery's * bundle auto-discovery both read this. Never throws: an unreadable or * malformed sidecar is reported with its parse error, not skipped silently. */ export async function discoverMemberBundles(backend) { let keys; try { keys = (await backend.list(VAULT_MEMBER_BUNDLE_MIRROR_PREFIX)).filter((k) => k.endsWith(".json")); } catch { return []; } const out = []; for (const key of keys.sort()) { const bytes = await backend.get(key); if (!bytes) continue; try { const bundle = parseMemberRecoveryBundle(new TextDecoder().decode(bytes), `parsing mirrored bundle ${key}`); out.push({ key, ek_fingerprint: bundle.ek_fingerprint, principal_id: bundle.principal_id, wrapper_kinds: [...new Set(bundle.wrappers.map((w) => w.kind))].sort(), parse_error: null, bundle, }); } catch (e) { out.push({ key, ek_fingerprint: null, principal_id: null, wrapper_kinds: [], parse_error: e instanceof Error ? e.message : String(e), bundle: null }); } } return out; } //# sourceMappingURL=vault-member-bundle.js.map