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.

209 lines • 12.8 kB
/** * Bearer-redeem key format — the pure, kind-agnostic primitives shared by * every bearer-addressed membership redeem key this SDK mints or parses * (add-room-invite design D2/D3, mirroring the gateway's own * `services/bearer-redeem-key.ts`). * * Through kygit-invite this lived inline in `vault-handoff.ts`: the key * registry (`HANDOFF_KEY_PREFIXES`), `uuidToBytes`, the generic * assemble/parse helpers, and the HKDF derivations for `auth_secret` / * `wrap_key`. add-room-invite adds a THIRD kind — `room` (`kri1_…`) — whose * redemption confers org membership rather than vault access and carries no * `wrap_key`/envelope/admission-seed at all (design D3). Rather than teach * the vault-shaped module a room-shaped kind, the pure key-format * primitives move HERE (a move, not a copy — `vault-handoff.ts` * re-exports every symbol it previously exported, under its existing name, * so nothing importing it changes) and gain the room row alongside them. * Everything that is genuinely vault-specific — the sealed envelope, the * writer-admission grant/acceptance, the Handoff/Invite Note — stays in * `vault-handoff.ts`, which imports the pieces it needs from here. * * `HANDOFF_KEY_PREFIXES` is a REGISTRY: `kgh1_` (handoff, `resume`), `kgi1_` * (invite, `repos join`), `kri1_` (room, `rooms join`). `parseRedeemKey` * refuses any other recognized-shape-but-wrong-kind prefix BY NAME, naming * the door it actually belongs at, rather than misreading it as a malformed * key of the kind the caller expected — never contacting the gateway. */ import { hkdf } from "@noble/hashes/hkdf.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { concatBytes, utf8ToBytes } from "@noble/hashes/utils.js"; import { LocalError } from "../errors.js"; import { fromBase64url, randomBytes, toBase64url, bytesToHex } from "../namespaces/vault.crypto.js"; function fail(code, message, context, details) { throw new LocalError(message, context, { code, details }); } /** * `kgh1_` is the first row (handoff); `kgi1_` the second (invite, * kygit-invite design D3); `kri1_` the third (room, add-room-invite design * D3). A future kind is a SIBLING row here, never a branch on an existing * one's parser. */ export const HANDOFF_KEY_PREFIXES = [ { prefix: "kgh1_", kind: "handoff", verb: "resume", errorPrefix: "HANDOFF", doorLabel: "run402 repos resume", envelopeKind: "kygit-handoff-envelope-v2", noteSchema: "kygit.handoff-note.v1", frameMagic: "KGH1" }, { prefix: "kgi1_", kind: "invite", verb: "join", errorPrefix: "INVITE", doorLabel: "run402 repos join", envelopeKind: "kygit-invite-envelope-v2", noteSchema: "kygit.invite-note.v1", frameMagic: "KGI1" }, { prefix: "kri1_", kind: "room", verb: "join", errorPrefix: "ROOM_INVITE", doorLabel: "run402 rooms join" }, ]; const HANDOFF_ENTRY = HANDOFF_KEY_PREFIXES[0]; const INVITE_ENTRY = HANDOFF_KEY_PREFIXES[1]; const ROOM_ENTRY = HANDOFF_KEY_PREFIXES[2]; function entryForKind(kind) { return kind === "handoff" ? HANDOFF_ENTRY : kind === "invite" ? INVITE_ENTRY : ROOM_ENTRY; } const CLAIM_ID_BYTES = 16; const MASTER_SECRET_BYTES = 32; const CLAIM_KEY_BODY_BYTES = CLAIM_ID_BYTES + MASTER_SECRET_BYTES; function bytesToUuid(bytes) { const h = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; } const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function uuidToBytes(uuid, field = "handoff_id") { if (!UUID_RE.test(uuid)) fail("HANDOFF_ID_INVALID", `${field} is not a canonical UUID`, "parsing handoff id", { field }); const hex = uuid.replace(/-/g, "").toLowerCase(); const out = new Uint8Array(16); for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } /** A fresh RFC 4122 v4 UUID — the client-generated key id every bearer-redeem kind mints with (design D3: `auth_secret` derives off it, so it cannot be gateway-assigned). */ export function randomRedeemId() { const b = randomBytes(16); b[6] = ((b[6] ?? 0) & 0x0f) | 0x40; b[8] = ((b[8] ?? 0) & 0x3f) | 0x80; return bytesToUuid(b); } /** Assemble a printed redeem key from a fresh 32-byte master secret and the (client-generated or gateway-minted) key id, for `entry`'s kind. */ function assembleClaimKey(entry, claimId, masterSecret) { if (masterSecret.length !== MASTER_SECRET_BYTES) fail(`${entry.errorPrefix}_KEY_INVALID`, "master_secret must be 32 bytes", `assembling ${entry.kind} key`); const idBytes = uuidToBytes(claimId, `${entry.kind}_id`); const body = concatBytes(idBytes, masterSecret); return { key: `${entry.prefix}${toBase64url(body)}`, id_bytes: idBytes, master_secret: masterSecret }; } /** * Assemble the printed key from a fresh 32-byte master secret and the * gateway-minted `handoff_id`. The gateway never sees `master_secret` — the * caller derives `auth_hash` (via {@link deriveHandoffSecrets}) and sends * ONLY that. */ export function assembleHandoffKey(handoffId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) { const { key, id_bytes, master_secret } = assembleClaimKey(HANDOFF_ENTRY, handoffId, masterSecret); return { key, handoff_id_bytes: id_bytes, master_secret }; } /** The invite-kind sibling of {@link assembleHandoffKey} (kygit-invite design D3). */ export function assembleInviteKey(inviteId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) { const { key, id_bytes, master_secret } = assembleClaimKey(INVITE_ENTRY, inviteId, masterSecret); return { key, invite_id_bytes: id_bytes, master_secret }; } /** * The room-kind sibling of {@link assembleHandoffKey}/{@link assembleInviteKey} * (add-room-invite design D3). `invite_id` is CLIENT-generated (unlike the * vault kinds, whose id the gateway mints) — the client sends it verbatim in * the mint request and the gateway uses it as the row id, refusing a * collision with `409 ROOM_INVITE_ID_CONFLICT`. */ export function assembleRoomInviteKey(inviteId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) { const { key, id_bytes, master_secret } = assembleClaimKey(ROOM_ENTRY, inviteId, masterSecret); return { key, invite_id_bytes: id_bytes, master_secret }; } /** * Parse ANY registered redeem-family key by its prefix registry against the * `expectedKind` the calling verb accepts. A recognized prefix of a * DIFFERENT kind (a `kgi1_` invite key handed to `resume`, a `kri1_` room * key handed to `repos join`/`resume`, or a `kgi1_`/`kgh1_` vault key handed * to `rooms join`) refuses BY NAME pointing at its own door — never misread * as a malformed key of the expected kind, and never contacting the gateway * (design D9 rule 4 / kygit-invite design D3 / add-room-invite design D3). */ export function parseRedeemKey(raw, expectedKind) { const expectedEntry = entryForKind(expectedKind); const trimmed = raw.trim(); const entry = HANDOFF_KEY_PREFIXES.find((e) => trimmed.startsWith(e.prefix)); if (!entry) { fail(`${expectedEntry.errorPrefix}_KEY_INVALID`, `not a recognized run402 redeem key (expected a key starting ${expectedEntry.prefix})`, "parsing redeem key"); } if (entry.kind !== expectedKind) { fail(`${expectedEntry.errorPrefix}_KEY_WRONG_KIND`, `this key is a ${entry.kind} key — use \`${entry.doorLabel}\` instead of \`${expectedEntry.doorLabel}\``, "parsing redeem key", { kind: entry.kind, verb: entry.verb }); } const body = trimmed.slice(entry.prefix.length); let bytes; try { bytes = fromBase64url(body, `${entry.kind} key body`); } catch { fail(`${entry.errorPrefix}_KEY_INVALID`, "the key body is not valid base64url", "parsing redeem key"); } if (bytes.length !== CLAIM_KEY_BODY_BYTES) { fail(`${entry.errorPrefix}_KEY_INVALID`, `the key decodes to ${bytes.length} bytes; expected ${CLAIM_KEY_BODY_BYTES}`, "parsing redeem key"); } const idBytes = bytes.subarray(0, CLAIM_ID_BYTES); const masterSecret = bytes.subarray(CLAIM_ID_BYTES); return { kind: entry.kind, id_bytes: idBytes, id: bytesToUuid(idBytes), master_secret: masterSecret }; } /** * Parse ANY registered redeem-family key by its prefix registry. A * recognized prefix of a DIFFERENT kind (an invite or room key handed to * `resume`) refuses BY NAME pointing at its own door — never misread as a * malformed handoff key (design D9 rule 4). Kind-bound alias of * {@link parseRedeemKey}. */ export function parseHandoffKey(raw) { const parsed = parseRedeemKey(raw, "handoff"); return { kind: "handoff", handoff_id_bytes: parsed.id_bytes, handoff_id: parsed.id, master_secret: parsed.master_secret }; } /** The invite-kind sibling of {@link parseHandoffKey} (kygit-invite design D3). */ export function parseInviteKey(raw) { const parsed = parseRedeemKey(raw, "invite"); return { kind: "invite", invite_id_bytes: parsed.id_bytes, invite_id: parsed.id, master_secret: parsed.master_secret }; } /** The room-kind sibling of {@link parseHandoffKey}/{@link parseInviteKey} (add-room-invite design D3). */ export function parseRoomInviteKey(raw) { const parsed = parseRedeemKey(raw, "room"); return { kind: "room", invite_id_bytes: parsed.id_bytes, invite_id: parsed.id, master_secret: parsed.master_secret }; } /** `HKDF-SHA256(ikm=master_secret, salt=id[16], info=…)` per D3, domain-separated by kind, for both `auth_secret` and `wrap_key`. Vault kinds only — `room` has no `wrap_key` and derives separately below. */ function deriveClaimSecrets(kind, idBytes, masterSecret) { const authInfo = `kygit/${kind}/auth/v1`; const wrapInfo = `kygit/${kind}/wrap/v1`; const authHashLabel = `kygit/${kind}/auth-hash/v1`; const authSecret = hkdf(sha256, masterSecret, idBytes, utf8ToBytes(authInfo), 32); const wrapKey = hkdf(sha256, masterSecret, idBytes, utf8ToBytes(wrapInfo), 32); const authHash = sha256(concatBytes(utf8ToBytes(authHashLabel), authSecret)); // ONE SHA-256 over (label ‖ auth_secret), hex-encoded — exactly what the // gateway recomputes at redeem (`computeAuthHash`). Through 4.68.1 this line // ran the digest through `sha256Hex` (which hashes its input again), so the // stored value was sha256(sha256(label ‖ secret)) and NO key minted by any // published client could ever verify. The cross-side vector in // vault-handoff.test.ts now recomputes the gateway's hash independently. return { auth_secret: authSecret, wrap_key: wrapKey, auth_hash_hex: bytesToHex(authHash) }; } /** `HKDF-SHA256(ikm=master_secret, salt=handoff_id[16], info=kygit/handoff/…)` per D3, for both `auth_secret` and `wrap_key`. */ export function deriveHandoffSecrets(handoffIdBytes, masterSecret) { return deriveClaimSecrets("handoff", handoffIdBytes, masterSecret); } /** The invite-kind sibling of {@link deriveHandoffSecrets}, domain-separated under `kygit/invite/…` info strings (kygit-invite design D3) — an invite secret never verifies as a handoff hash, or the reverse. */ export function deriveInviteSecrets(inviteIdBytes, masterSecret) { return deriveClaimSecrets("invite", inviteIdBytes, masterSecret); } // ─── HKDF derivation — room kind (add-room-invite design D3) ──────────────── // // A room invite derives EXACTLY ONE secret — no `wrap_key`, no envelope, no // admission seed exist for this kind (there is nothing sealed: the gateway // already reads every room message in plaintext, so there is no secret to // hide from it). The info string and hash label are their OWN // `run402/room-invite/…` namespace, never `kygit/room/…` — domain-separated // from every vault kind so a room secret never verifies as a handoff/invite // hash and neither of those ever verifies as a room hash, even for a row // whose id every parser recovers. const ROOM_INVITE_AUTH_INFO = "run402/room-invite/auth/v1"; const ROOM_INVITE_AUTH_HASH_LABEL = "run402/room-invite/auth-hash/v1"; /** `HKDF-SHA256(ikm=master_secret, salt=invite_id[16], info="run402/room-invite/auth/v1")` — the ONE secret a room invite derives (design D3). */ export function deriveRoomInviteAuthSecret(inviteIdBytes, masterSecret) { return hkdf(sha256, masterSecret, inviteIdBytes, utf8ToBytes(ROOM_INVITE_AUTH_INFO), 32); } /** `SHA-256("run402/room-invite/auth-hash/v1" ‖ auth_secret)`, lowercase hex — exactly what the gateway's `mintRoomInvite`/`claimRoomInvite` compute and store/compare (design D3). */ export function computeRoomInviteAuthHash(authSecret) { return bytesToHex(sha256(concatBytes(utf8ToBytes(ROOM_INVITE_AUTH_HASH_LABEL), authSecret))); } //# sourceMappingURL=bearer-redeem-key.js.map