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
JavaScript
/**
* 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