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.

529 lines • 31 kB
/** * vault — the Handoff Key and the Invite Key, one kind-parameterized * module (kygit-handoff design D3/D6/D10; kygit-invite design D3). * * Both keys are bearer bridges, never a K_repo derivation of their own: * `kgh1_<base64url(handoff_id[16] || master_secret[32])>` / * `kgi1_<base64url(invite_id[16] || master_secret[32])>` (69 chars each). * Two HKDF-SHA-256 derivations off `master_secret` (salt = the 16 raw id * bytes) produce `auth_secret` (what the gateway hashes and compares) and * `wrap_key` (what seals/opens the small envelope carrying the vault's * epoch key `k_e` directly — the gateway never sees either). The HKDF info * strings and the auth-hash label are DOMAIN-SEPARATED by kind * (`kygit/<kind>/auth/v1`, `kygit/<kind>/wrap/v1`, * `kygit/<kind>/auth-hash/v1`) so a handoff-derived secret can never verify * as an invite secret nor the reverse, even for a row whose id both parsers * would recover. * * The prefix is a REGISTRY (design D9 rule 4 / kygit-invite D3): `kgh1_` is * the first row, `kgi1_` the second. `parseRedeemKey` refuses any other * recognized-shape-but-wrong-kind prefix BY NAME, pointing at its own verb, * rather than misreading it as a malformed key of the kind it expected. * `parseHandoffKey`/`parseInviteKey` are thin kind-bound aliases over the * one parser — every existing handoff export and test stays byte-identical. * * Reuses ONLY existing primitives — `jcs`/HKDF/HMAC/SHA-256 from * `@noble/hashes`, the SAME `_vaultAeadBackend()` XChaCha20-Poly1305 * seam `sealFrame`/`openFrame` use — never a second crypto path. The wire * envelope here is a DIFFERENT, smaller frame than `r402s/v0`'s seven-member * object frame (design D3: "the sealed envelope lives in the gateway ROW, * never in the vault object store — the frozen r402s/v0 protocol is * untouched"), so it gets its own tiny header/AAD rather than reusing * `frameAad`'s object-store-shaped seven fields. The invite envelope is a * DIFFERENT, deliberately simpler format again than the `KGH1` handoff- * bridge frame is from `r402s/v0` — same idea, one level down: `KGI1` is its * own frame magic, never reused across kinds. */ import { hkdf } from "@noble/hashes/hkdf.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { concatBytes, utf8ToBytes } from "@noble/hashes/utils.js"; import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; import { base64 } from "@scure/base"; import { LocalError } from "../errors.js"; import { _vaultAeadBackend, fromBase64url, randomBytes, toBase64url, VAULT_FORMAT, VAULT_SUITE, VAULT_HEX16_RE, VAULT_SHA256_RE, signaturePreimage, ed25519Sign, ed25519PublicKey, ed25519VerifyStrict, vkFingerprint, ekFingerprint, } from "../namespaces/vault.crypto.js"; // add-room-invite design D2: the pure key-format primitives (the registry, // uuidToBytes, assemble/parse, the HKDF derive) moved to // `bearer-redeem-key.ts` — a move, not a copy — so the room kind (`kri1_`) // can share them without teaching THIS vault-shaped module a room-shaped // kind. Every symbol this module previously exported is re-exported here // under its existing name, so nothing importing it changes. import { HANDOFF_KEY_PREFIXES, uuidToBytes, assembleHandoffKey, assembleInviteKey, parseRedeemKey, parseHandoffKey, parseInviteKey, deriveHandoffSecrets, deriveInviteSecrets, } from "./bearer-redeem-key.js"; export { HANDOFF_KEY_PREFIXES, uuidToBytes, assembleHandoffKey, assembleInviteKey, parseRedeemKey, parseHandoffKey, parseInviteKey, deriveHandoffSecrets, deriveInviteSecrets, }; function fail(code, message, context, details) { throw new LocalError(message, context, { code, details }); } const HANDOFF_ENTRY = HANDOFF_KEY_PREFIXES[0]; const INVITE_ENTRY = HANDOFF_KEY_PREFIXES[1]; // ─── Writer-admission grant + acceptance (vault-multi-writer D4/§4.17) ─── // // The bearer-completable path for `add_writer_key{authorization.kind: // "handoff"}` — an unknown-at-mint, possibly-sender-gone recipient becomes a // vault WRITER (not just a K_repo holder) via a sender-signed grant plus the // redeemer's own two-signature acceptance. Reuses ONLY existing primitives: // `signaturePreimage`/`ed25519Sign`/`ed25519VerifyStrict`/`vkFingerprint`/ // `ekFingerprint` from `vault.crypto.ts` — the SAME "r402s/v0/<kind>\n" + // JCS(object minus signature) preimage rule every other chain object uses // (protocol §1), NOT a bespoke construction. Verified against the gateway's // OWN `services/vault/claims.ts` (not just the protocol prose, which // describes the acceptance statement's preimage as `lp(domain) ‖ // JCS(statement)` — that text is stale; the shipped gateway code calls its // own `signaturePreimage("handoff-writer-accept/v1", acceptance.statement)`, // the identical convention used here). /** * Third HKDF output of a redeem key's `master_secret` (design D4, protocol * §4.17; kind-parameterized by kygit-invite design D3): the one-use * admission Ed25519 seed. Derived directly from `master_secret` — * deliberately NEVER from `auth_secret` or `wrap_key` — so the gateway, * which receives `auth_secret` at redeem, stays computationally unable to * derive this seed and manufacture a different redeemer completion. The info * string is domain-separated by kind (`kygit/<kind>/writer-admission/v1`), * so an invite's admission seed never matches a handoff's for the same id * and master secret. */ export function deriveRedeemWriterAdmissionSeed(kind, idBytes, masterSecret) { return hkdf(sha256, masterSecret, idBytes, utf8ToBytes(`kygit/${kind}/writer-admission/v1`), 32); } /** Handoff-bound alias of {@link deriveRedeemWriterAdmissionSeed} — `kygit/handoff/writer-admission/v1`. */ export function deriveWriterAdmissionSeed(handoffIdBytes, masterSecret) { return deriveRedeemWriterAdmissionSeed("handoff", handoffIdBytes, masterSecret); } /** The invite-kind sibling of {@link deriveWriterAdmissionSeed} — `kygit/invite/writer-admission/v1` (kygit-invite design D3). */ export function deriveInviteWriterAdmissionSeed(inviteIdBytes, masterSecret) { return deriveRedeemWriterAdmissionSeed("invite", inviteIdBytes, masterSecret); } const WRITER_MINTED_ROLES = ["owner", "admin", "developer", "billing", "viewer"]; /** Mint side (D4): builds + signs `writer_admission_grant`. The gateway's own mint-time validation (grantor active + self-consistent, field equality) is server-side; this function only builds a well-formed, correctly-signed object. */ export function buildWriterAdmissionGrant(input) { if (!VAULT_SHA256_RE.test(input.auth_hash)) fail("VALIDATION_FAILED", "auth_hash must be 64 lowercase hex", "building writer_admission_grant", { field: "auth_hash" }); if (!VAULT_HEX16_RE.test(input.checkpoint_generation)) fail("VALIDATION_FAILED", "checkpoint_generation must be 16 lowercase hex", "building writer_admission_grant", { field: "checkpoint_generation" }); if (!VAULT_SHA256_RE.test(input.checkpoint_head_sha256)) fail("VALIDATION_FAILED", "checkpoint_head_sha256 must be 64 lowercase hex", "building writer_admission_grant", { field: "checkpoint_head_sha256" }); if (!WRITER_MINTED_ROLES.includes(input.minted_role)) fail("VALIDATION_FAILED", "minted_role is not a recognized role", "building writer_admission_grant", { field: "minted_role" }); const withoutSignature = { format: VAULT_FORMAT, object_kind: "writer_admission_grant", suite: VAULT_SUITE, repo_id: input.repo_id, handoff_id: input.handoff_id, auth_hash: input.auth_hash, checkpoint_generation: input.checkpoint_generation, checkpoint_head_sha256: input.checkpoint_head_sha256, grantor_writer_key_id: vkFingerprint(ed25519PublicKey(input.grantor_signing_seed)), handoff_admission_pubkey: toBase64url(input.handoff_admission_pubkey), minted_role: input.minted_role, claim_not_after: input.claim_not_after, created_at: input.created_at ?? new Date().toISOString(), }; const signature = toBase64url(ed25519Sign(signaturePreimage("writer_admission_grant", withoutSignature), input.grantor_signing_seed)); return { ...withoutSignature, signature }; } /** * Structural + signature verification of a `writer_admission_grant` against * the grantor's writer signing pubkey (raw bytes) — does NOT check chain * state (grantor active in the predecessor writer state, `handoff_id` * unconsumed). That is the caller's job: gateway-side at admission, or a * client-side pre-check before spending a grant it did not itself mint. */ export function verifyWriterAdmissionGrant(grant, grantorSigningPubkeyRaw) { if (grant.format !== VAULT_FORMAT || grant.object_kind !== "writer_admission_grant" || grant.suite !== VAULT_SUITE) return false; if (grant.grantor_writer_key_id !== vkFingerprint(grantorSigningPubkeyRaw)) return false; const { signature, ...withoutSignature } = grant; let sigBytes; try { sigBytes = fromBase64url(signature, "writer_admission_grant.signature"); } catch { return false; } return ed25519VerifyStrict(sigBytes, signaturePreimage("writer_admission_grant", withoutSignature), grantorSigningPubkeyRaw); } /** The statement `writer_acceptance` signs TWICE (design D225's fold of the consultation's separate `writer_key_proof` into a second signature over this same statement). */ export const HANDOFF_WRITER_ACCEPT_DOMAIN = "r402s/v0/handoff-writer-accept/v1"; const HANDOFF_WRITER_ACCEPT_PREIMAGE_DOMAIN = "handoff-writer-accept/v1"; /** * Redeem side (D4): the redeemer can construct BOTH signatures before ever * seeing the stored grant, since it already knows `handoff_id`, `auth_hash` * (recomputed exactly as the existing redeem flow already does via * {@link deriveHandoffSecrets}), and its own keys. */ export function buildWriterAcceptance(input) { if (!VAULT_SHA256_RE.test(input.auth_hash)) fail("VALIDATION_FAILED", "auth_hash must be 64 lowercase hex", "building writer_acceptance", { field: "auth_hash" }); const signingPubkeyRaw = ed25519PublicKey(input.redeemer_signing_seed); const statement = { domain: HANDOFF_WRITER_ACCEPT_DOMAIN, handoff_id: input.handoff_id, auth_hash: input.auth_hash, writer_key_id: vkFingerprint(signingPubkeyRaw), signing_pubkey: toBase64url(signingPubkeyRaw), encryption_pubkey: toBase64url(input.redeemer_encryption_pubkey_raw), encryption_fingerprint: ekFingerprint(input.redeemer_encryption_pubkey_raw), }; const preimage = signaturePreimage(HANDOFF_WRITER_ACCEPT_PREIMAGE_DOMAIN, statement); return { statement, acceptance_signature: toBase64url(ed25519Sign(preimage, input.admission_seed)), possession_signature: toBase64url(ed25519Sign(preimage, input.redeemer_signing_seed)), }; } /** * Verifies BOTH signatures over the SAME statement: `acceptance_signature` * under the grant's `handoff_admission_pubkey` (proves B legitimately * redeemed THIS handoff), `possession_signature` under the statement's own * `signing_pubkey` (proves `writer_key_id`/`signing_pubkey` really belong * to whoever is submitting it, not a replayed statement about someone * else's key). Does not check `handoff_id`/`auth_hash` binding to a stored * grant, or single-use — those are the caller's job. */ export function verifyWriterAcceptance(acceptance, admissionPubkeyRaw) { if (acceptance.statement.domain !== HANDOFF_WRITER_ACCEPT_DOMAIN) return false; let acceptanceSig; let possessionSig; let signingPubkey; try { acceptanceSig = fromBase64url(acceptance.acceptance_signature, "writer_acceptance.acceptance_signature"); possessionSig = fromBase64url(acceptance.possession_signature, "writer_acceptance.possession_signature"); signingPubkey = fromBase64url(acceptance.statement.signing_pubkey, "writer_acceptance.statement.signing_pubkey"); } catch { return false; } if (acceptance.statement.writer_key_id !== vkFingerprint(signingPubkey)) return false; const preimage = signaturePreimage(HANDOFF_WRITER_ACCEPT_PREIMAGE_DOMAIN, acceptance.statement); return ed25519VerifyStrict(acceptanceSig, preimage, admissionPubkeyRaw) && ed25519VerifyStrict(possessionSig, preimage, signingPubkey); } /** Frame tag for the handoff envelope format — DISTINCT from `r402s/v0`'s `"R402S0"` object frame (design D3: never stored as a vault object). The LEGACY v1 tag: nothing seals it any more, but a pre-vault-multi-writer handoff row still carries one and {@link openHandoffEnvelope} still opens it. */ export const HANDOFF_ENVELOPE_KIND = "kygit-handoff-envelope-v1"; const FRAME_MAGIC_BYTES = 4; const FRAME_VERSION_BYTE = 0x01; const CLAIM_NONCE_BYTES = 24; const CLAIM_FRAME_HEADER_BYTES = FRAME_MAGIC_BYTES + 1 + CLAIM_NONCE_BYTES; function redeemAeadSeal(key32, nonce24, aad, plaintext) { const backend = _vaultAeadBackend(); if (backend) return backend.seal(key32, nonce24, aad, plaintext); return xchacha20poly1305(key32, nonce24, aad).encrypt(plaintext); } function redeemAeadOpen(key32, nonce24, aad, ctAndTag) { try { const backend = _vaultAeadBackend(); if (backend) return backend.open(key32, nonce24, aad, ctAndTag); return xchacha20poly1305(key32, nonce24, aad).decrypt(ctAndTag); } catch { return null; } } /** AAD = `id[16] ‖ envelopeKind` (design D3) — `envelopeKind` is this envelope format's own tag, UTF-8. */ function redeemEnvelopeAad(idBytes, envelopeKind) { return concatBytes(idBytes, utf8ToBytes(envelopeKind)); } /** * `sealed_envelope` on the wire is STANDARD base64 — openapi spells it * `format: byte` in both the mint body and the redeem response, and the * gateway echoes the stored bytes with `Buffer#toString("base64")` (`+`, * `/`, `=` padding). Decoding it with the canonical-base64url-only * `fromBase64url`, which refuses every `+`/`/`/`=`, takes a 200 from the * gateway and dies client-side with `*_ENVELOPE_INVALID`. This decoder * reads the documented form and, for tolerance, the base64url form other * clients mint; padding is optional either way. Anything outside the two * alphabets is a refusal (`null` — the caller raises the typed * `*_ENVELOPE_INVALID`), never a silent partial decode. Shared by both * redeem kinds: the invite routes encode exactly as the handoff routes do, * byte for byte, rather than "fixing" the alphabet on one side only * (kygit-invite design D3's cross-side trap). */ function decodeSealedEnvelope(value) { const trimmed = value.trim(); if (trimmed.length === 0 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(trimmed)) return null; const unpadded = trimmed.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/"); if (unpadded.length % 4 === 1) return null; try { return base64.decode(unpadded + "=".repeat((4 - (unpadded.length % 4)) % 4)); } catch { return null; } } /** * Seal `payload` under `wrap_key` for `entry`'s kind, tagged `envelopeKind`. * Returns the standard-base64 wire form (openapi `format: byte`) + the tag. * * Plain JSON, NOT the r402s/v0 no-JSON-numbers JCS profile: this envelope is * never a vault object and only this SDK ever encodes/decodes it, so it * needs deterministic round-tripping, not cross-implementation * byte-canonical determinism (design D3's "the frozen protocol is * untouched" — reusing the object-frame profile here would be exactly the * kind of accidental protocol coupling that note warns against). Shared by * every redeem kind and every envelope version (v1, v2, …) so the frame * byte-layout lives in exactly one place — a kind or version divergence here * would be invisible to either side until a cross-open failed. */ function sealRedeemEnvelope(entry, idBytes, wrapKey, payload, envelopeKind, nonce) { const plaintext = utf8ToBytes(JSON.stringify(payload)); const n = nonce ?? randomBytes(CLAIM_NONCE_BYTES); if (n.length !== CLAIM_NONCE_BYTES) fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "nonce must be 24 bytes", `sealing ${entry.kind} envelope`); const aad = redeemEnvelopeAad(idBytes, envelopeKind); const ct = redeemAeadSeal(wrapKey, n, aad, plaintext); const frame = concatBytes(utf8ToBytes(entry.frameMagic), new Uint8Array([FRAME_VERSION_BYTE]), n, ct); return { sealed_envelope: base64.encode(frame), envelope_kind: envelopeKind }; } /** * Decodes, header-checks, AEAD-opens, and JSON-parses a sealed envelope for * `entry`'s kind, returning the RAW parsed payload with no shape validation * (mirrors {@link sealRedeemEnvelope} on the seal side) so the frame * byte-layout lives in exactly one place. Each envelope kind/version's own * `open*` function applies its OWN shape check on the result; this function * deliberately does not, since a shape check IS the version discriminator (a * v1 opener must reject a `v:2` payload, not silently accept it — that is * what makes `v` meaningful at all). Throws `${errorPrefix}_ENVELOPE_INVALID` * on any header mismatch and `${errorPrefix}_AEAD_AUTH_FAILURE` on a bad * key/AAD/ciphertext. */ function openRedeemEnvelope(entry, idBytes, wrapKey, sealedEnvelope, envelopeKind) { const frame = decodeSealedEnvelope(sealedEnvelope); if (frame === null) { fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope is not valid base64", `opening ${entry.kind} envelope`); } if (frame.length < CLAIM_FRAME_HEADER_BYTES + 16) { fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope is shorter than header + AEAD tag", `opening ${entry.kind} envelope`); } const magic = new TextDecoder().decode(frame.subarray(0, FRAME_MAGIC_BYTES)); if (magic !== entry.frameMagic || frame[FRAME_MAGIC_BYTES] !== FRAME_VERSION_BYTE) { fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope header magic/version mismatch", `opening ${entry.kind} envelope`); } const nonce = frame.subarray(FRAME_MAGIC_BYTES + 1, CLAIM_FRAME_HEADER_BYTES); const ct = frame.subarray(CLAIM_FRAME_HEADER_BYTES); const aad = redeemEnvelopeAad(idBytes, envelopeKind); const opened = redeemAeadOpen(wrapKey, nonce, aad, ct); if (opened === null) fail(`${entry.errorPrefix}_AEAD_AUTH_FAILURE`, "the sealed envelope failed AEAD authentication under this key", `opening ${entry.kind} envelope`); try { return JSON.parse(new TextDecoder().decode(opened)); } catch { fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "opened envelope is not valid JSON", `opening ${entry.kind} envelope`); } } /** Seal the handoff payload under `wrap_key` as a LEGACY v1 envelope. Returns the standard-base64 wire form (openapi `format: byte`) + its declared kind tag. Nothing in the shipped flow calls this any more — {@link sealHandoffEnvelopeV2} is what `handoff` seals — but the v1 shape stays openable and sealable for the conformance vectors. */ export function sealHandoffEnvelope(handoffIdBytes, wrapKey, payload, nonce) { return sealRedeemEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_KIND, nonce); } /** Open a sealed handoff envelope under `wrap_key` (standard base64 as the redeem response carries it, or base64url). Throws `HANDOFF_ENVELOPE_INVALID` on any header mismatch, a `v:2` (or otherwise non-v1) payload shape, and `HANDOFF_AEAD_AUTH_FAILURE` on a bad key/AAD/ciphertext. */ export function openHandoffEnvelope(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind = HANDOFF_ENVELOPE_KIND) { const p = openRedeemEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind); if (p.v !== 1 || p.kind !== "handoff" || typeof p.repo_id !== "string" || typeof p.epoch !== "string" || typeof p.k_e_hex !== "string" || !p.checkpoint || p.note_schema !== "kygit.handoff-note.v1") { fail("HANDOFF_ENVELOPE_INVALID", "opened envelope does not match the kygit.handoff-note.v1 payload shape", "opening handoff envelope"); } return p; } // ─── Envelope v2 (vault-multi-writer D4/§4.17; kygit-invite design D3) ─── // // `writer_admission_grant_sha256` is what makes v2 v2 — design D4's "no hash // cycle: grant first, then seal": the minter builds + signs // {@link WriterAdmissionGrant} FIRST, hashes its stored bytes SECOND, and // only THEN seals this envelope carrying that hash. The redeemer // cross-checks it against the grant the gateway independently returns at // redeem, an integrity binding entirely independent of anything the gateway // could tamper with (the envelope's AEAD authenticity comes from `wrap_key`, // which the gateway never holds). BOTH redeem kinds seal v2 — an invite has // no v1 at all, and a handoff's v1 survives only as a legacy opener. /** The v2 envelope kind the handoff mint seals. */ export const HANDOFF_ENVELOPE_V2_KIND = "kygit-handoff-envelope-v2"; /** The v2 envelope kind the invite mint seals (kygit-invite design D3) — its own tag, never confusable with the handoff one even for the same id. */ export const INVITE_ENVELOPE_V2_KIND = "kygit-invite-envelope-v2"; /** Seal a v2 handoff envelope — identical framing to {@link sealHandoffEnvelope}, tagged `kygit-handoff-envelope-v2` so its AAD (and therefore its ciphertext) is never confusable with a v1 envelope of the same handoff. */ export function sealHandoffEnvelopeV2(handoffIdBytes, wrapKey, payload, nonce) { if (!VAULT_SHA256_RE.test(payload.writer_admission_grant_sha256)) { fail("VALIDATION_FAILED", "writer_admission_grant_sha256 must be 64 lowercase hex", "sealing handoff envelope v2", { field: "writer_admission_grant_sha256" }); } return sealRedeemEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_V2_KIND, nonce); } /** The invite-kind sibling of {@link sealHandoffEnvelopeV2} — the ONLY invite envelope there is (kygit-invite design D3). */ export function sealInviteEnvelope(inviteIdBytes, wrapKey, payload, nonce) { if (!VAULT_SHA256_RE.test(payload.writer_admission_grant_sha256)) { fail("VALIDATION_FAILED", "writer_admission_grant_sha256 must be 64 lowercase hex", "sealing invite envelope", { field: "writer_admission_grant_sha256" }); } return sealRedeemEnvelope(INVITE_ENTRY, inviteIdBytes, wrapKey, payload, INVITE_ENVELOPE_V2_KIND, nonce); } /** * Open a v2 envelope for a writer-activation flow. Refuses a v1 (or any * other non-v2) `envelope_kind` LOCALLY with `HANDOFF_ENVELOPE_UNSUPPORTED` * BEFORE attempting to decrypt anything — post-vault-multi-writer, a * writer-activation-aware `resume` needs `writer_admission_grant_sha256` to * cross-check the returned grant, and a pre-rev-47 v1 envelope structurally * has none. Shares ONLY the frame/AEAD-opening step with * {@link openHandoffEnvelope} ({@link openRedeemEnvelope}) — NOT that * function itself, since its `v !== 1` shape check would (correctly) refuse * a v2 payload; this function applies its own `v !== 2` shape check * instead. Callers that only need K_repo/checkpoint delivery, with no * writer activation, keep using {@link openHandoffEnvelope} against a v1 * envelope as before — v1 and v2 are NOT interchangeable at the shape * layer, by design. */ export function openHandoffEnvelopeV2(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind) { if (envelopeKind !== HANDOFF_ENVELOPE_V2_KIND) { fail("HANDOFF_ENVELOPE_UNSUPPORTED", `this handoff's envelope is ${envelopeKind ? `\`${envelopeKind}\`` : "of an unrecognized kind"}, not \`${HANDOFF_ENVELOPE_V2_KIND}\` — writer activation needs the v2 envelope's writer_admission_grant_sha256, which a pre-vault-multi-writer handoff never carries`, "opening handoff envelope for writer activation", { received: envelopeKind, required: HANDOFF_ENVELOPE_V2_KIND }); } const p = openRedeemEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind); if (p.v !== 2 || p.kind !== "handoff" || typeof p.repo_id !== "string" || typeof p.epoch !== "string" || typeof p.k_e_hex !== "string" || !p.checkpoint || p.note_schema !== "kygit.handoff-note.v1" || typeof p.writer_admission_grant_sha256 !== "string" || !VAULT_SHA256_RE.test(p.writer_admission_grant_sha256)) { fail("HANDOFF_ENVELOPE_INVALID", "opened envelope does not match the v2 kygit.handoff-note.v1 payload shape", "opening handoff envelope for writer activation"); } return p; } /** The invite-kind sibling of {@link openHandoffEnvelopeV2} — throws `INVITE_ENVELOPE_UNSUPPORTED` / `INVITE_ENVELOPE_INVALID` / `INVITE_AEAD_AUTH_FAILURE` (kygit-invite design D3). */ export function openInviteEnvelope(inviteIdBytes, wrapKey, sealedEnvelope, envelopeKind = INVITE_ENVELOPE_V2_KIND) { if (envelopeKind !== INVITE_ENVELOPE_V2_KIND) { fail("INVITE_ENVELOPE_UNSUPPORTED", `this invite's envelope is ${envelopeKind ? `\`${envelopeKind}\`` : "of an unrecognized kind"}, not \`${INVITE_ENVELOPE_V2_KIND}\``, "opening invite envelope", { received: envelopeKind, required: INVITE_ENVELOPE_V2_KIND }); } const p = openRedeemEnvelope(INVITE_ENTRY, inviteIdBytes, wrapKey, sealedEnvelope, envelopeKind); if (p.v !== 2 || p.kind !== "invite" || typeof p.repo_id !== "string" || typeof p.epoch !== "string" || typeof p.k_e_hex !== "string" || !p.checkpoint || p.note_schema !== "kygit.invite-note.v1" || typeof p.writer_admission_grant_sha256 !== "string" || !VAULT_SHA256_RE.test(p.writer_admission_grant_sha256)) { fail("INVITE_ENVELOPE_INVALID", "opened envelope does not match the v2 kygit.invite-note.v1 payload shape", "opening invite envelope"); } return p; } export function isKygitHandoffNote(value) { if (!value || typeof value !== "object") return false; const v = value; return v.schema === "kygit.handoff-note.v1" && typeof v.summary === "string" && typeof v.created_at === "string" && !!v.from && !!v.capture; } export function isKygitInviteNote(value) { if (!value || typeof value !== "object") return false; const v = value; return v.schema === "kygit.invite-note.v1" && typeof v.summary === "string" && typeof v.created_at === "string" && !!v.from && !!v.capture; } // ─── Client-side secret scan (design D10 — no override flag) ──────────────── const HANDOFF_NOTE_SECRET_PREFIXES = [ /\bsk-[A-Za-z0-9]/, /\bghp_[A-Za-z0-9]/, /\bgho_[A-Za-z0-9]/, /\bxox[abp]-[A-Za-z0-9]/, /\bAKIA[A-Z0-9]{4,}/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/, /-----BEGIN [A-Z ]*CERTIFICATE-----/, /\bkgh1_[A-Za-z0-9_-]/, // kygit-invite design D3: the invite key joins the scan's known-prefix // list so a pasted kgi1_ reads as a bare secret in EITHER note kind, not // just its own. /\bkgi1_[A-Za-z0-9_-]/, ]; /** Shannon entropy in bits/char over `s`. */ function shannonEntropy(s) { const counts = new Map(); for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1); let entropy = 0; for (const count of counts.values()) { const p = count / s.length; entropy -= p * Math.log2(p); } return entropy; } /** A run of ≥32 token-ish chars (letters/digits/`+/=_-`) with high per-char entropy reads as a bare secret, not prose. */ function hasHighEntropyToken(text) { const tokenRe = /[A-Za-z0-9+/_=-]{32,}/g; let m; while ((m = tokenRe.exec(text))) { const token = m[0]; if (shannonEntropy(token) >= 3.5) return token; } return null; } /** Every string leaf in a note, `path` = dotted field name for the refusal. */ function* noteStringLeaves(note) { yield { path: "summary", text: note.summary }; for (const [group, arr] of [ ["completed", note.completed], ["in_progress", note.in_progress], ["failing", note.failing], ["tried", note.tried], ["next_steps", note.next_steps], ["decisions", note.decisions], ["open_questions", note.open_questions], ]) { for (const [i, text] of (arr ?? []).entries()) yield { path: `${group}[${i}]`, text }; } if (note.commands) { for (const key of ["test", "build", "run"]) { const v = note.commands[key]; if (v) yield { path: `commands.${key}`, text: v }; } } } /** * Refuse a note that carries a bare secret — no override flag exists * (design D10): a note is read by another agent, and secrets have the * secrets API. `null` when the note is clean. Shared implementation for * every note kind. */ function scanNoteForSecrets(note) { for (const { path, text } of noteStringLeaves(note)) { for (const re of HANDOFF_NOTE_SECRET_PREFIXES) { if (re.test(text)) return { field: path, reason: `matches a known secret prefix (${re.source})` }; } const token = hasHighEntropyToken(text); if (token) return { field: path, reason: `contains a high-entropy token (${token.length} chars) that reads as a bare secret` }; } return null; } /** * Refuse a note that carries a bare secret — no override flag exists * (design D10): a note is read by another agent, and secrets have the * secrets API. `null` when the note is clean. */ export function scanHandoffNoteForSecrets(note) { return scanNoteForSecrets(note); } /** The invite-kind sibling of {@link scanHandoffNoteForSecrets} (kygit-invite design D3). */ export function scanInviteNoteForSecrets(note) { return scanNoteForSecrets(note); } /** Throws `HANDOFF_NOTE_CONTAINS_SECRET` naming the field — call before the handoff commit is written. */ export function assertHandoffNoteHasNoSecret(note) { const finding = scanHandoffNoteForSecrets(note); if (finding) { fail("HANDOFF_NOTE_CONTAINS_SECRET", `the handoff note's \`${finding.field}\` ${finding.reason} — edit the note; secrets belong in the secrets API, never in a note another agent will read`, "scanning handoff note", { field: finding.field }); } } /** Throws `INVITE_NOTE_CONTAINS_SECRET` naming the field — call before the invite commit is written (kygit-invite design D3). */ export function assertInviteNoteHasNoSecret(note) { const finding = scanInviteNoteForSecrets(note); if (finding) { fail("INVITE_NOTE_CONTAINS_SECRET", `the invite note's \`${finding.field}\` ${finding.reason} — edit the note; secrets belong in the secrets API, never in a note another agent will read`, "scanning invite note", { field: finding.field }); } } //# sourceMappingURL=vault-handoff.js.map