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.

1,426 lines • 81.7 kB
/**
 * vault — `r402s/v0` crypto core (protocol rev 40, §1 + §2; task 5.1).
 *
 * Pure, isomorphic (WebCrypto + noble + `@hpke/core`), no I/O. Everything
 * byte-level the protocol pins lives here ONCE and is replayed against the
 * frozen vector set (`vault.crypto.test.ts`):
 *
 *   - JCS (RFC 8785) for the no-JSON-numbers profile + the strict-parse profile
 *   - the two hash rules: stored bytes (JCS INCLUDING `signature`) and the
 *     signature preimage `"r402s/v0/" + object_kind + "\n" + JCS(object minus signature)`
 *   - Ed25519 strict RFC 8032 — noble with `zip215: false` pinned; a
 *     ZIP215-only signature is REJECTED (vector `zip215-002`)
 *   - `lp` / `lp_opt` length prefixes + the open-id / open-binding preimages
 *   - HKDF-SHA-256 `k_obj` / `K_digest` derivations and HMAC keyed commitments
 *   - the XChaCha20-Poly1305 frame (`"R402S0"` ‖ suite byte ‖ 24-byte nonce ‖ ct‖tag)
 *     with its seven-member JCS AAD
 *   - the `key_envelope` HPKE seal/open (Base mode, DHKEM(X25519,HKDF-SHA256) /
 *     HKDF-SHA256 / ChaCha20-Poly1305) with the EXACT D188 `info` / AAD bytes
 *   - genesis key-binding + recovery-receipt checks
 *
 * Design rules carried from the protocol: HPKE is a NAMED implementation
 * (`@hpke/core` + `@hpke/chacha20poly1305`), never assembled from primitives
 * (D38); an envelope's signature is verified BEFORE any open; plain hashes of
 * plaintext content never leave the client (every server-comparable digest is
 * keyed); object ids are single-use key labels — callers MUST NOT re-encrypt
 * under an id (retries read-and-compare).
 */
import { parse, evaluate } from "@humanwhocodes/momoa";
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { hmac } from "@noble/hashes/hmac.js";
import { bytesToHex, hexToBytes, utf8ToBytes, concatBytes } from "@noble/hashes/utils.js";
import { base64urlnopad } from "@scure/base";
import { canonicalize } from "json-canonicalize";
import { CipherSuite, DhkemX25519HkdfSha256, HkdfSha256 } from "@hpke/core";
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
import { LocalError } from "../errors.js";
// ─── Constants ───────────────────────────────────────────────────────────────
/** Wire format tag (retained by decision D181 across the product rename). */
export const VAULT_FORMAT = "r402s/v0";
/** The one V0 cryptographic suite. */
export const VAULT_SUITE = "r402s-1";
/** Frame magic, bytes 0–5 of every encrypted object. */
export const VAULT_FRAME_MAGIC = "R402S0";
/** Frame suite byte (byte 6) and its two-char AAD spelling. */
export const VAULT_FRAME_SUITE_BYTE = 0x01;
export const VAULT_FRAME_SUITE_ID = "01";
/** Protocol §2: max plaintext per encrypted object (256 MiB). */
export const VAULT_MAX_PLAINTEXT_BYTES = 256 * 1024 * 1024;
/** The genesis generation / epoch constants (schema consts). */
export const VAULT_GENESIS_GENERATION = "0000000000000000";
export const VAULT_GENESIS_EPOCH = "0000000000000001";
/** HPKE `info` label for `key_envelope` (D188). */
export const VAULT_ENVELOPE_INFO_LABEL = "r402s/v0/envelope";
/**
 * The V0-A terminal-loss sentence — protocol §0 / client-surface spec. Doctor
 * and status surfaces MUST state it verbatim while V0-A is current. Do not
 * paraphrase; the wording is a reviewed product commitment.
 */
export const VAULT_TERMINAL_LOSS_STATEMENT = "whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship";
/**
 * The protocol's durability sentence — the keystore-qualified half of the
 * terminal-loss statement, reusable on its own. It is TRUE regardless of how
 * many principals cover a vault (unlike {@link VAULT_TERMINAL_LOSS_STATEMENT},
 * which is specifically the single-principal V0-A claim), so a caller that has
 * locally proven a vault carries >= 2 covering recipients prints this sentence
 * instead of the terminal-loss one — see `Repos.status()`'s `covering_recipients`
 * field. Never paraphrase; the wording is a reviewed product commitment.
 */
export const VAULT_DURABILITY_STATEMENT = "The vault protects source history from host-side loss while a principal keystore survives.";
/** Protocol §0 sentence in full, as `doctor`/`status` print it. */
export const VAULT_TERMINAL_LOSS_DOCTOR_TEXT = "In V0-A, whole-machine or whole-keystore loss is terminal for vault history (VAULT_UNRECOVERABLE) until human envelopes ship. " +
    `${VAULT_DURABILITY_STATEMENT} ` +
    "The remaining paths are the platform's custodial restore of deployed artifacts (the deploy lane's CAS) and org/infra recovery; " +
    "a recovery receipt authenticates a genesis — it cannot decrypt anything.";
/**
 * vault-mirror-and-recover (design D8) — the two honesty statements. Both
 * appear VERBATIM wherever mirror status or recovery success is shown (mirror
 * `status`/`sync`, `recover`, keyless `mirror verify`), same voice and
 * mechanism as {@link VAULT_TERMINAL_LOSS_STATEMENT} above. Never
 * paraphrased — the §6.4 honesty posture is a reviewed product commitment.
 */
export const VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT = "this recovery proves validity, never freshness — a mirror (or the vault itself) can only tell you the newest generation it happens to hold, never that a newer one does not exist elsewhere";
/** The mirror does not change the V0 terminal-loss sentence: it is durability, not a second key. */
export const VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT = "a mirror without the principal keystore (or an equivalent key) recovers nothing — mirroring ciphertext does not create a second key, and the V0 terminal-loss sentence is unchanged";
/**
 * vault-mirror-default — the recommended-default framing, stated the same
 * way everywhere (the copy gate pins the doc surfaces to it). "Replicated"
 * describes the platform's storage substrate (S3 multi-AZ redundancy), never
 * a second run402 service; the customer-owned mirror is the only copy outside
 * run402's custody, and — per the two honesty statements above — it proves
 * validity, never freshness, and recovers nothing without the keystore.
 */
export const VAULT_MIRROR_THREE_COPIES_STATEMENT = "the recommended shape is three copies: your working clone, the platform's replicated vault, and a mirror in storage you own";
/**
 * vault-byo-primary-bucket (design D4) — the degraded-read mechanism
 * sentence: the invariant part of the ONE stderr line a degraded chain/
 * payload read prints (`formatVaultDegradedReadNote`, `vault-node-
 * degraded-read.ts`). Mechanism-only, per the copy rules — never a
 * confidentiality claim: it names WHY (run402 is unreachable), restates
 * validity-not-freshness in brief, and states that a write still needs the
 * gateway (D194 — admission is irreducibly live-server, so writes are never
 * rerouted). The caller prepends the fallback's own destination (never a
 * credential); that is data, not part of this claim, so it lives outside
 * the constant. Never paraphrase — same reviewed-product-commitment voice
 * as the other statements in this file.
 */
export const VAULT_DEGRADED_READ_STATEMENT = "run402 is unreachable — this read is served from your mirror; it proves validity, not freshness; a later push still requires the gateway";
/**
 * vault-mirror-default — the `vault_unmirrored` standing finding, worded to
 * stay true in BOTH pre-clear states (no mirror configured at all, and a
 * configured mirror with no successful write/sync yet — the finding clears on
 * the first successful mirror write or sync, never on configuration alone).
 * Informational, never blocking; computed client-side only, so the gateway
 * never learns whether a mirror exists.
 */
export const VAULT_UNMIRRORED_FINDING_STATEMENT = `this vault has no customer-held mirror copy yet — ${VAULT_MIRROR_THREE_COPIES_STATEMENT}; until a first mirror write or sync succeeds, the third copy does not exist`;
/**
 * vault-mirror-default — the one-liner `repos create` prints beside the
 * recovery receipt: the two things worth doing in the first minute, stated in
 * the first minute. Deliberately custody-scoped ("stays in your custody"),
 * never a recoverability claim — the terminal-loss statement printed beside it
 * carries the key half.
 */
export const VAULT_MIRROR_SETUP_HINT = "recommended: 'run402 repos mirror <destination>' (an s3:// bucket or a local directory) starts the customer-owned mirror — every later capture is mirrored to it automatically, and it is the copy that stays in your custody";
/**
 * vault-byo-primary-bucket (proposal, the ratified D + chain-copy rung-3
 * headline; design D10 — verified from network topology once the client's
 * BYO write path ships, not by this sentence alone). Structurally scoped to
 * `storage_profile:"byo"` ONLY — the sentence is FALSE for a managed vault
 * (payload ciphertext IS client-written to run402's own bucket there), so
 * every caller MUST gate it behind a BYO-profile check before printing it
 * and MUST NEVER use it as a blanket claim describing vault in general.
 * INERT as of this fold (Phase 1, protocol coordination only) — nothing
 * emits it yet; Phase 2/3 wire the allocation/doctor/`repos view` surfaces
 * that print it once `storage_profile:"byo"` is a real, allocatable vault
 * state. Never paraphrase — the wording is a reviewed product commitment.
 */
export const VAULT_BYO_HEADLINE_STATEMENT = "your source ciphertext never touches our infrastructure — not even encrypted";
/**
 * vault-byo-primary-bucket (design D7) — the allocation-time no-payload-copy
 * disclosure a BYO `repos create` prints, unconditionally and independent of
 * mirror status: unlike a managed vault (where the platform's own bucket is
 * an extra copy beside the customer's clone), a BYO vault's primary bucket is
 * the ONLY payload copy from the moment it is allocated — run402 holds the
 * small signed chain only, never the payload. Distinct from
 * {@link VAULT_BYO_UNMIRRORED_REMEDY_STATEMENT} below, which is about a
 * missing SECOND customer-held copy on top of this primary one. INERT as of
 * this fold — Phase 2/3 wire the surfaces that print it. Never paraphrase.
 */
export const VAULT_BYO_NO_PAYLOAD_COPY_STATEMENT = "run402 holds no payload copy of a BYO vault — only the small signed chain; your primary bucket is the sole copy of your source until you add a second customer-held location";
/**
 * vault-byo-primary-bucket (design D7) — the BYO variant of the
 * `vault_unmirrored` finding's remedy (vault-mirror-default): for a
 * managed vault the remedy is an ADDITIONAL mirror beside the platform's own
 * replicated copy ({@link VAULT_MIRROR_SETUP_HINT}); for a BYO vault the
 * platform holds no payload copy to fall back on
 * ({@link VAULT_BYO_NO_PAYLOAD_COPY_STATEMENT}), so the remedy names a
 * SECOND customer-held location as the only additional copy available — the
 * ordinary `repos mirror` machinery works unchanged against a different
 * destination (design D7). The `vault_unmirrored` finding itself still
 * applies to BYO vaults; only this remedy half is BYO-specific. INERT as of
 * this fold — Phase 2/3 wire the surfaces that print it. Never paraphrase.
 */
export const VAULT_BYO_UNMIRRORED_REMEDY_STATEMENT = "add a second customer-held location: 'run402 repos mirror <destination>' works unchanged against a different destination — for a BYO vault this is your only additional copy, since run402 holds no payload copy of its own";
const FRAME_NONCE_BYTES = 24;
const FRAME_HEADER_BYTES = 6 + 1 + FRAME_NONCE_BYTES;
const AEAD_TAG_BYTES = 16;
// ─── Scalar grammars (common.json) ───────────────────────────────────────────
export const VAULT_HEX16_RE = /^[0-9a-f]{16}$/;
export const VAULT_HEX32_RE = /^[0-9a-f]{32}$/;
export const VAULT_SHA256_RE = /^[0-9a-f]{64}$/;
export const VAULT_OID40_RE = /^[0-9a-f]{40}$/;
export const VAULT_VK_RE = /^vk_[0-9a-f]{32}$/;
export const VAULT_EK_RE = /^ek_[0-9a-f]{32}$/;
export const VAULT_SRC_RE = /^src_[0-9a-f]{32}$/;
export const VAULT_SERVICE_KEY_ID_RE = /^sk_[0-9a-z-]{4,64}$/;
export const VAULT_SIZE_BYTES_RE = /^(0|[1-9][0-9]{0,14})$/;
/** 32 bytes base64url: 43 chars, final char in the 16-value padding class. */
export const VAULT_B64U_32_RE = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
/** 64 bytes base64url: 86 chars, final char ∈ {A,Q,g,w}. */
export const VAULT_B64U_64_RE = /^[A-Za-z0-9_-]{85}[AQgw]$/;
/** The HPKE-sealed K_repo: 48 bytes = exactly 64 base64url chars. */
export const VAULT_HPKE_CT_RE = /^[A-Za-z0-9_-]{64}$/;
export const VAULT_TIMESTAMP_RE = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}Z$/;
function fail(code, message, context, details) {
    throw new LocalError(message, context, { code, details });
}
/** Canonical base64url (no padding, length mod 4 ≠ 1, zero trailing bits): decode→encode round-trips. */
export function isCanonicalBase64url(value) {
    if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1)
        return false;
    try {
        return base64urlnopad.encode(base64urlnopad.decode(value)) === value;
    }
    catch {
        return false;
    }
}
/** Decode canonical base64url or throw (`VAULT_NONCANONICAL_BASE64URL`). */
export function fromBase64url(value, field = "value") {
    if (!isCanonicalBase64url(value)) {
        fail("VAULT_NONCANONICAL_BASE64URL", `${field} is not canonical base64url`, "decoding r402s/v0 scalar", { field });
    }
    return base64urlnopad.decode(value);
}
export function toBase64url(bytes) {
    return base64urlnopad.encode(bytes);
}
/** RFC 3339 UTC ms `Z` with semantic calendar validation (D187: `2026-02-31` and a non-leap Feb 29 are rejects). */
export function isValidVaultTimestamp(value) {
    if (!VAULT_TIMESTAMP_RE.test(value))
        return false;
    const year = Number(value.slice(0, 4));
    const month = Number(value.slice(5, 7));
    const day = Number(value.slice(8, 10));
    const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
    const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
    return day <= daysInMonth;
}
/** Format a Date as the protocol timestamp grammar (RFC 3339 UTC, milliseconds, `Z`). */
export function formatVaultTimestamp(date = new Date()) {
    return date.toISOString();
}
// ─── Encoding helpers ────────────────────────────────────────────────────────
/** `lp(x)` = 4-byte big-endian UTF-8 byte length ‖ UTF-8 bytes (protocol §1). */
export function lp(value) {
    const bytes = utf8ToBytes(value);
    const out = new Uint8Array(4 + bytes.length);
    new DataView(out.buffer).setUint32(0, bytes.length, false);
    out.set(bytes, 4);
    return out;
}
/** `lp_opt(null)` = `0x00`; `lp_opt(x)` = `0x01` ‖ `lp(x)`. */
export function lpOpt(value) {
    return value === null ? new Uint8Array([0]) : concatBytes(new Uint8Array([1]), lp(value));
}
let hashBackend = null;
/**
 * SDK-INTERNAL (underscore-exported so the byte-equality suite can swap
 * backends). Installing `null` restores the `@noble` default.
 */
export function _setVaultHashBackend(backend) {
    hashBackend = backend;
}
/** SDK-INTERNAL: which hash backend is live. The isomorphic-entry test asserts `null`. */
export function _vaultHashBackend() {
    return hashBackend;
}
export function sha256Hex(bytes) {
    if (hashBackend)
        return bytesToHex(hashBackend.sha256(bytes));
    return bytesToHex(sha256(bytes));
}
/** CSPRNG bytes from the platform WebCrypto (protocol §2: all keys/nonces/ephemerals from the OS CSPRNG). */
export function randomBytes(length) {
    const out = new Uint8Array(length);
    globalThis.crypto.getRandomValues(out);
    return out;
}
/** A fresh `<prefix>_<32 lowercase hex>` id (protocol §1: ids are CSPRNG). */
export function newVaultId(prefix) {
    return `${prefix}_${bytesToHex(randomBytes(16))}`;
}
/** A fresh 32-lowercase-hex scalar (client_creation_id, capture_id, …). */
export function newHex32() {
    return bytesToHex(randomBytes(16));
}
// ─── JCS + strict parse ──────────────────────────────────────────────────────
function assertProfileValue(value, path) {
    if (value === null || typeof value === "string" || typeof value === "boolean")
        return;
    if (typeof value === "number" || typeof value === "bigint") {
        fail("VAULT_JSON_NUMBER", `r402s/v0 profile forbids JSON numbers (at ${path})`, "canonicalizing r402s/v0 object", { path });
    }
    if (Array.isArray(value)) {
        value.forEach((entry, i) => assertProfileValue(entry, `${path}[${i}]`));
        return;
    }
    if (typeof value === "object") {
        for (const [key, entry] of Object.entries(value)) {
            if (entry === undefined) {
                fail("VAULT_JSON_UNDEFINED", `undefined member at ${path}.${key}`, "canonicalizing r402s/v0 object", { path: `${path}.${key}` });
            }
            assertProfileValue(entry, `${path}.${key}`);
        }
        return;
    }
    fail("VAULT_JSON_UNSUPPORTED", `unsupported value at ${path}`, "canonicalizing r402s/v0 object", { path });
}
/** RFC 8785 canonical JSON (no-numbers profile) as UTF-8 bytes. */
export function jcs(value) {
    assertProfileValue(value, "$");
    return utf8ToBytes(canonicalize(value));
}
/** RFC 8785 canonical JSON as a string. */
export function jcsString(value) {
    assertProfileValue(value, "$");
    return canonicalize(value);
}
function inspectStrict(node, path) {
    switch (node.type) {
        case "Document":
            return inspectStrict(node.body, path);
        case "Number":
            fail("VAULT_STRICT_PARSE", `JSON number at ${path}`, "strict-parsing r402s/v0 object", { reason: "json-number", path });
        // eslint-disable-next-line no-fallthrough
        case "Object": {
            const seen = new Set();
            for (const member of node.members) {
                const name = member.name.type === "String" ? member.name.value : member.name.name;
                if (seen.has(name)) {
                    fail("VAULT_STRICT_PARSE", `duplicate member ${name} at ${path}`, "strict-parsing r402s/v0 object", { reason: "duplicate-member", path: `${path}.${name}` });
                }
                seen.add(name);
                inspectStrict(member.value, `${path}.${name}`);
            }
            return;
        }
        case "Array":
            node.elements.forEach((entry, i) => inspectStrict(entry.value, `${path}[${i}]`));
            return;
        default:
            return;
    }
}
/**
 * The §1 strict-parse profile: reject ANY JSON number, duplicate members,
 * invalid JSON, and any text that is not byte-for-byte the JCS of its value
 * (member order, whitespace, escapes). Schema-awareness (unknown members,
 * hex case, scalar lengths) is the caller's job.
 */
export function parseVaultStrict(text) {
    let ast;
    try {
        ast = parse(text, { mode: "json", allowTrailingCommas: false });
    }
    catch {
        fail("VAULT_STRICT_PARSE", "invalid JSON", "strict-parsing r402s/v0 object", { reason: "invalid-json" });
    }
    inspectStrict(ast, "$");
    const value = evaluate(ast);
    if (jcsString(value) !== text) {
        fail("VAULT_STRICT_PARSE", "text is not canonical JCS", "strict-parsing r402s/v0 object", { reason: "noncanonical-encoding" });
    }
    return value;
}
/** Classify a strict-parse failure (for vector replay + diagnostics). */
export function vaultStrictParseReason(error) {
    if (error instanceof LocalError && error.code === "VAULT_STRICT_PARSE") {
        const details = error.details;
        return details?.reason ?? null;
    }
    return null;
}
// ─── Hash rules: stored bytes + signature preimage ───────────────────────────
/** Stored bytes of a signed object = JCS of the COMPLETE object including `signature`. */
export function storedBytes(object) {
    return jcs(object);
}
/** SHA-256 over stored bytes — what every `*_sha256` naming a signed object means. */
export function storedBytesSha256(object) {
    return sha256Hex(storedBytes(object));
}
function withoutSignature(object) {
    const { signature: _signature, ...rest } = object;
    void _signature;
    return rest;
}
/**
 * Exported so the rotation producer (D195) can build the SAME
 * signature-stripped shape `rotation_id`'s own derivation needs
 * (`sha256(JCS(descriptor minus signature))`) without reaching into this
 * module's private helper twice under two different names.
 */
export function vaultWithoutSignature(object) {
    return withoutSignature(object);
}
/**
 * Signature preimage = `"r402s/v0/" + domain + "\n" + JCS(object without its
 * single top-level signature)`. `domain` is USUALLY a `VaultObjectKind`
 * (an actual `object_kind`-carrying signed object), but the type accepts any
 * string: vault-multi-writer rev 47's `writer_acceptance.statement` reuses
 * this exact byte-construction under the ad-hoc domain
 * `"handoff-writer-accept/v1"` for a sub-object that is NOT itself a
 * `VaultSignedObject` (no `object_kind`/`signature` fields of its own —
 * both signatures over it live on the PARENT `writer_acceptance`). Matches
 * the gateway's own `signaturePreimage(objectKind: string, ...)`, which was
 * never narrower than `string` to begin with.
 */
export function signaturePreimage(domain, objectWithoutSignature) {
    if ("signature" in objectWithoutSignature) {
        fail("VAULT_PREIMAGE_HAS_SIGNATURE", "preimage input must not carry a signature member", "building r402s/v0 signature preimage");
    }
    return concatBytes(utf8ToBytes(`${VAULT_FORMAT}/${domain}\n`), jcs(objectWithoutSignature));
}
// ─── Ed25519 strict (RFC 8032, zip215:false) ─────────────────────────────────
export function ed25519PublicKey(seed) {
    return ed25519.getPublicKey(seed);
}
export function ed25519Sign(message, seed) {
    return ed25519.sign(message, seed);
}
/**
 * Strict RFC 8032 verification: `zip215: false` is PINNED. Non-canonical R or A
 * encodings, non-canonical S, and wrong-length inputs are all `false` — never
 * an exception a caller could mistake for a transport error.
 */
export function ed25519VerifyStrict(signature, message, publicKey) {
    if (signature.length !== 64 || publicKey.length !== 32)
        return false;
    try {
        return ed25519.verify(signature, message, publicKey, { zip215: false });
    }
    catch {
        return false;
    }
}
/** Sign an object under its `object_kind` domain; returns the complete signed object. */
export function signVaultObject(objectWithoutSignature, seed) {
    const preimage = signaturePreimage(objectWithoutSignature.object_kind, objectWithoutSignature);
    return { ...objectWithoutSignature, signature: toBase64url(ed25519Sign(preimage, seed)) };
}
/**
 * Verify a signed object against a raw public key. Exact scalar lengths are
 * checked FIRST (an 85-char signature or a 42-char key is a schema reject,
 * never a decode-time surprise), then strict Ed25519 under the object's domain.
 */
export function verifyVaultObject(object, publicKey) {
    if (typeof object.signature !== "string" || !VAULT_B64U_64_RE.test(object.signature) || !isCanonicalBase64url(object.signature))
        return false;
    let pub;
    if (typeof publicKey === "string") {
        if (!VAULT_B64U_32_RE.test(publicKey) || !isCanonicalBase64url(publicKey))
            return false;
        pub = fromBase64url(publicKey);
    }
    else {
        pub = publicKey;
    }
    const preimage = signaturePreimage(object.object_kind, withoutSignature(object));
    return ed25519VerifyStrict(fromBase64url(object.signature), preimage, pub);
}
/** Verify or throw `VAULT_SIGNATURE_INVALID`. */
export function assertVaultSignature(object, publicKey, context = "verifying r402s/v0 signature") {
    if (!verifyVaultObject(object, publicKey)) {
        fail("VAULT_SIGNATURE_INVALID", `${object.object_kind} signature does not verify (strict RFC 8032)`, context, { object_kind: object.object_kind });
    }
}
// ─── Key fingerprints + keypairs ─────────────────────────────────────────────
/** First 16 bytes of SHA-256(raw pubkey), lowercase hex (32 chars). */
export function keyFingerprintHex(rawPublicKey) {
    return sha256Hex(rawPublicKey).slice(0, 32);
}
/** `vk_` fingerprint of a raw Ed25519 public key. */
export function vkFingerprint(rawPublicKey) {
    return `vk_${keyFingerprintHex(rawPublicKey)}`;
}
/** `ek_` fingerprint of a raw X25519 public key. */
export function ekFingerprint(rawPublicKey) {
    return `ek_${keyFingerprintHex(rawPublicKey)}`;
}
export function generateSigningKeypair(seed = randomBytes(32)) {
    if (seed.length !== 32)
        fail("VAULT_BAD_SEED", "Ed25519 seed must be 32 bytes", "generating signing keypair");
    return { seed, public_key: ed25519PublicKey(seed) };
}
export function generateEncryptionKeypair(privateKey = randomBytes(32)) {
    if (privateKey.length !== 32)
        fail("VAULT_BAD_SEED", "X25519 private key must be 32 bytes", "generating encryption keypair");
    return { private_key: privateKey, public_key: x25519.getPublicKey(privateKey) };
}
// ─── HKDF: k_obj / K_digest, keyed commitments ──────────────────────────────
/** `k_obj` info = lp("r402s/v0") ‖ lp(suite) ‖ lp(repo_id) ‖ lp(epoch) ‖ lp(object_kind) ‖ lp(object_id) ‖ lp("L=32"). */
export function objectKeyInfo(repoId, epoch, objectKind, objectId) {
    return concatBytes(lp(VAULT_FORMAT), lp(VAULT_SUITE), lp(repoId), lp(epoch), lp(objectKind), lp(objectId), lp("L=32"));
}
/** `k_obj = HKDF-SHA-256(ikm=K_repo, salt=∅, info, L=32)`. */
export function deriveObjectKey(kRepo, repoId, epoch, objectKind, objectId) {
    return hkdf(sha256, kRepo, undefined, objectKeyInfo(repoId, epoch, objectKind, objectId), 32);
}
/** `K_digest` info = lp("r402s/v0") ‖ lp("r402s-1") ‖ lp(repo_id) ‖ lp(epoch) ‖ lp("digest") ‖ lp(label) ‖ lp("L=32"). */
export function digestKeyInfo(repoId, epoch, label) {
    return concatBytes(lp(VAULT_FORMAT), lp(VAULT_SUITE), lp(repoId), lp(epoch), lp("digest"), lp(label), lp("L=32"));
}
export function deriveDigestKey(kRepo, repoId, epoch, label) {
    return deriveDigestKeyFrom(kRepo, repoId, epoch, label);
}
/**
 * The general form `deriveDigestKey` is a fixed-`ikm=K_repo` specialization
 * of (D195/D198/D200, rev 42): every rev-42 rotation label is keyed by the
 * SAMPLED epoch key (`K_e` for a rotation, `K_1` for genesis) instead of
 * `K_repo` — "the value being committed IS the key being distributed"
 * (protocol-v0.md S1). Same HKDF info construction either way.
 */
export function deriveDigestKeyFrom(ikm, repoId, epoch, label) {
    return hkdf(sha256, ikm, undefined, digestKeyInfo(repoId, epoch, label), 32);
}
/** Commitment = HMAC-SHA-256(K_digest(label), JCS(content)), lowercase hex. */
export function keyedCommitment(kDigest, content) {
    return bytesToHex(hmac(sha256, kDigest, jcs(content)));
}
/** The `"objectset"` content shape: `{oids:[sorted unique lowercase 40-hex]}`. */
export function objectsetContent(oids) {
    const unique = [...new Set(oids)];
    for (const oid of unique) {
        if (!VAULT_OID40_RE.test(oid))
            fail("VAULT_BAD_OID", `not a lowercase 40-hex oid: ${oid}`, "building objectset content");
    }
    return { oids: unique.sort() };
}
/** True iff an `objectset` content is already in canonical (sorted-unique) order. */
export function isCanonicalObjectset(content) {
    const canonical = objectsetContent(content.oids).oids;
    return canonical.length === content.oids.length && canonical.every((oid, i) => oid === content.oids[i]);
}
/** The `"snapshot_oid"` content shape. */
export function snapshotOidContent(oid) {
    if (!VAULT_OID40_RE.test(oid))
        fail("VAULT_BAD_OID", `not a lowercase 40-hex oid: ${oid}`, "building snapshot_oid content");
    return { oid, format: "sha1" };
}
// ─── Golden preimages (open-id / open-binding) ──────────────────────────────
/** `"r402s/v0/open-id" ‖ lp(org_id) ‖ lp(repo_id) ‖ lp(client_open_id)`. */
export function openIdPreimage(orgId, repoId, clientOpenId) {
    return concatBytes(utf8ToBytes("r402s/v0/open-id"), lp(orgId), lp(repoId), lp(clientOpenId));
}
/** `"r402s/v0/open-binding" ‖ lp(client_open_id) ‖ lp(base_head_sha256) ‖ lp_opt(prior) ‖ lp(requested_r2_cap_size_bytes)`. */
export function openBindingPreimage(clientOpenId, baseHeadSha256, priorCheckpointClaimSetSha256, requestedR2CapSizeBytes) {
    return concatBytes(utf8ToBytes("r402s/v0/open-binding"), lp(clientOpenId), lp(baseHeadSha256), lpOpt(priorCheckpointClaimSetSha256), lp(requestedR2CapSizeBytes));
}
// ─── AEAD frame (XChaCha20-Poly1305) ────────────────────────────────────────
/** The seven-member frame AAD, as the object JCS serializes. */
export function frameAad(repoId, objectKind, objectId, epoch) {
    return { repo_id: repoId, object_kind: objectKind, object_id: objectId, epoch, suite: VAULT_SUITE, magic: VAULT_FRAME_MAGIC, suite_id: VAULT_FRAME_SUITE_ID };
}
let aeadBackend = null;
/**
 * SDK-INTERNAL (underscore-exported so the byte-equality suite can swap
 * backends). Installing `null` restores the `@noble` default — which is what
 * every runtime without a registration already has.
 */
export function _setVaultAeadBackend(backend) {
    aeadBackend = backend;
}
/** SDK-INTERNAL: which backend is live. The verifier-lineage test asserts `null`. */
export function _vaultAeadBackend() {
    return aeadBackend;
}
function aeadSeal(key32, nonce24, aad, plaintext) {
    if (aeadBackend)
        return aeadBackend.seal(key32, nonce24, aad, plaintext);
    return xchacha20poly1305(key32, nonce24, aad).encrypt(plaintext);
}
/**
 * Returns the plaintext, or `null` for ANY failure the caller must render as
 * `VAULT_AEAD_AUTH_FAILURE` (D4). A backend that throws is treated as an
 * auth failure rather than surfacing a second error shape from the same input
 * space — the native path is an optimization, never a new failure mode.
 */
function aeadOpen(key32, nonce24, aad, ctAndTag) {
    try {
        if (aeadBackend)
            return aeadBackend.open(key32, nonce24, aad, ctAndTag);
        return xchacha20poly1305(key32, nonce24, aad).decrypt(ctAndTag);
    }
    catch {
        return null;
    }
}
/**
 * Seal a plaintext into the §2 frame: `"R402S0"` ‖ `0x01` ‖ nonce(24) ‖ ct‖tag.
 * `ciphertext_sha256` covers the COMPLETE frame. Callers own the single-use
 * rule: one object id, one seal — never re-encrypt under an id.
 */
export function sealFrame(input) {
    if (input.k_obj.length !== 32)
        fail("VAULT_BAD_KEY", "k_obj must be 32 bytes", "sealing r402s/v0 frame");
    if (input.plaintext.length > VAULT_MAX_PLAINTEXT_BYTES) {
        fail("GIT_OBJECT_TOO_LARGE", "plaintext exceeds the 256 MiB per-object cap", "sealing r402s/v0 frame", { size_bytes: String(input.plaintext.length) });
    }
    const nonce = input.nonce ?? randomBytes(FRAME_NONCE_BYTES);
    if (nonce.length !== FRAME_NONCE_BYTES)
        fail("VAULT_BAD_NONCE", "frame nonce must be 24 bytes", "sealing r402s/v0 frame");
    const aad = jcs(frameAad(input.repo_id, input.object_kind, input.object_id, input.epoch));
    const ct = aeadSeal(input.k_obj, nonce, aad, input.plaintext);
    const frame = concatBytes(utf8ToBytes(VAULT_FRAME_MAGIC), new Uint8Array([VAULT_FRAME_SUITE_BYTE]), nonce, ct);
    return { frame, ciphertext_sha256: sha256Hex(frame), size_bytes: String(frame.length), nonce_hex: bytesToHex(nonce) };
}
/**
 * Open a §2 frame: header checked (magic, suite byte, length), receipt
 * compared when supplied, then the AEAD opened under the seven-member AAD.
 * No unverified streaming release — the whole plaintext or nothing.
 */
export function openFrame(input) {
    const { frame } = input;
    if (input.expected_ciphertext_sha256 !== undefined && sha256Hex(frame) !== input.expected_ciphertext_sha256) {
        fail("VAULT_RECEIPT_MISMATCH", "frame bytes do not hash to the receipt's ciphertext_sha256", "opening r402s/v0 frame", { object_id: input.object_id });
    }
    if (frame.length < FRAME_HEADER_BYTES + AEAD_TAG_BYTES) {
        fail("VAULT_FRAME_INVALID", "frame shorter than header + tag", "opening r402s/v0 frame", { object_id: input.object_id });
    }
    const magic = new TextDecoder().decode(frame.subarray(0, 6));
    if (magic !== VAULT_FRAME_MAGIC || frame[6] !== VAULT_FRAME_SUITE_BYTE) {
        fail("VAULT_FRAME_INVALID", "frame header magic/suite mismatch", "opening r402s/v0 frame", { object_id: input.object_id });
    }
    return openFrameWithAad(input.k_obj, frame, jcs(frameAad(input.repo_id, input.object_kind, input.object_id, input.epoch)), input.object_id);
}
/**
 * The AEAD half of {@link openFrame} with the AAD supplied verbatim — the
 * vector replay uses it to prove every one of the seven AAD members is
 * authenticated (including the constant `suite`/`magic`/`suite_id` members
 * that {@link openFrame}'s typed input cannot vary). Header already checked.
 */
export function openFrameWithAad(kObj, frame, aad, objectId = "?") {
    if (frame.length < FRAME_HEADER_BYTES + AEAD_TAG_BYTES) {
        fail("VAULT_FRAME_INVALID", "frame shorter than header + tag", "opening r402s/v0 frame", { object_id: objectId });
    }
    const nonce = frame.subarray(7, FRAME_HEADER_BYTES);
    const ct = frame.subarray(FRAME_HEADER_BYTES);
    const opened = aeadOpen(kObj, nonce, aad, ct);
    if (opened === null) {
        fail("VAULT_AEAD_AUTH_FAILURE", "frame failed AEAD authentication", "opening r402s/v0 frame", { object_id: objectId });
    }
    return opened;
}
// ─── HPKE (named implementation: @hpke/core + @hpke/chacha20poly1305) ───────
let hpkeSuite;
/** The one `r402s-1` HPKE suite: Base mode, DHKEM(X25519,HKDF-SHA256) 0x0020 / HKDF-SHA256 0x0001 / ChaCha20-Poly1305 0x0003. */
export function vaultHpkeSuite() {
    hpkeSuite ??= new CipherSuite({ kem: new DhkemX25519HkdfSha256(), kdf: new HkdfSha256(), aead: new Chacha20Poly1305() });
    return hpkeSuite;
}
function toArrayBuffer(bytes) {
    const copy = new Uint8Array(bytes.length);
    copy.set(bytes);
    return copy.buffer;
}
/** HPKE `info` (D188): lp("r402s/v0/envelope") ‖ lp("r402s-1") ‖ lp(lowerhex(SHA-256(recipient raw X25519 pubkey))) ‖ lp(created_by). */
export function envelopeInfo(recipientPublicKeyRaw, createdBy) {
    return concatBytes(lp(VAULT_ENVELOPE_INFO_LABEL), lp(VAULT_SUITE), lp(sha256Hex(recipientPublicKeyRaw)), lp(createdBy));
}
/**
 * HPKE AAD (D188, discriminated by `rotation_id` presence — D203, rev 42):
 * `rotation_id` ABSENT (genesis / ADD-workaround envelope) -> the rev-41
 * four-field form, BYTE-IDENTICAL to every existing golden HPKE digest;
 * `rotation_id` PRESENT (a rotation-attempt envelope) -> the new five-field
 * form. Field ORDER in the returned object does not matter (JCS sorts
 * object members), but `rotation_id` must never be included as an explicit
 * `null` — the two shapes are genuinely different objects, not one object
 * with an optional-null member (protocol-v0.md S2).
 */
export function envelopeAad(repoId, epoch, recipientFingerprint, rotationId) {
    if (rotationId) {
        return { repo_id: repoId, epoch, rotation_id: rotationId, recipient_kind: "principal", recipient_fingerprint: recipientFingerprint };
    }
    return { repo_id: repoId, epoch, recipient_kind: "principal", recipient_fingerprint: recipientFingerprint };
}
/** Single-shot HPKE seal (Base mode) returning `{enc, ct}`. */
export async function hpkeSeal(input) {
    const suite = vaultHpkeSuite();
    const recipientPublicKey = await suite.kem.deserializePublicKey(toArrayBuffer(input.recipient_public_key));
    const ekm = input.ikm_e ? await suite.kem.deriveKeyPair(toArrayBuffer(input.ikm_e)) : undefined;
    const sender = await suite.createSenderContext({ recipientPublicKey, info: toArrayBuffer(input.info), ...(ekm ? { ekm } : {}) });
    const ct = new Uint8Array(await sender.seal(toArrayBuffer(input.plaintext), toArrayBuffer(input.aad)));
    return { enc: new Uint8Array(sender.enc), ct };
}
/** Single-shot HPKE open; any failure is `VAULT_HPKE_OPEN_FAILED` (never garbage plaintext). */
export async function hpkeOpen(input) {
    const suite = vaultHpkeSuite();
    try {
        const recipientKey = await suite.kem.deserializePrivateKey(toArrayBuffer(input.recipient_private_key));
        const recipient = await suite.createRecipientContext({ recipientKey, enc: toArrayBuffer(input.enc), info: toArrayBuffer(input.info) });
        return new Uint8Array(await recipient.open(toArrayBuffer(input.ct), toArrayBuffer(input.aad)));
    }
    catch {
        fail("VAULT_HPKE_OPEN_FAILED", "HPKE open failed — the recipient recovers no key", "opening r402s/v0 key envelope");
    }
}
/** Seal + sign a `key_envelope` for one principal; returns the object with its stored-bytes identity + genesis receipt. */
export async function sealKeyEnvelope(input) {
    if (input.k_repo.length !== 32)
        fail("VAULT_BAD_KEY", "K_repo must be 32 bytes", "sealing r402s/v0 key envelope");
    if (!isValidVaultTimestamp(input.created_at))
        fail("VAULT_BAD_TIMESTAMP", "created_at is not an RFC 3339 UTC ms timestamp", "sealing r402s/v0 key envelope");
    const createdBy = vkFingerprint(input.signer.public_key);
    const recipientFingerprint = ekFingerprint(input.recipient_public_key);
    const rotationId = input.rotation_id ?? null;
    const { enc, ct } = await hpkeSeal({
        recipient_public_key: input.recipient_public_key,
        info: envelopeInfo(input.recipient_public_key, createdBy),
        aad: jcs(envelopeAad(input.repo_id, input.epoch, recipientFingerprint, rotationId)),
        plaintext: input.k_repo,
        ikm_e: input.ikm_e,
    });
    const unsigned = {
        format: VAULT_FORMAT,
        object_kind: "key_envelope",
        suite: VAULT_SUITE,
        repo_id: input.repo_id,
        epoch: input.epoch,
        recipient_kind: "principal",
        recipient_fingerprint: recipientFingerprint,
        enc: toBase64url(enc),
        ct: toBase64url(ct),
        created_by: createdBy,
        created_at: input.created_at,
        ...(rotationId ? { rotation_id: rotationId } : {}),
    };
    const envelope = signVaultObject(unsigned, input.signer.seed);
    const bytes = storedBytes(envelope);
    const hash = sha256Hex(bytes);
    return {
        envelope,
        stored_bytes: bytes,
        stored_bytes_sha256: hash,
        size_bytes: String(bytes.length),
        receipt: {
            object_kind: "key_envelope",
            epoch: input.epoch,
            recipient_fingerprint: recipientFingerprint,
            stored_bytes_sha256: hash,
            size_bytes: String(bytes.length),
            ...(rotationId ? { rotation_id: rotationId } : {}),
        },
    };
}
/**
 * Open a `key_envelope`: exact-scalar checks → signature (strict, domain
 * `key_envelope`) → path identity (recipient fingerprint = ours) → HPKE open
 * with the D188 info/AAD (widened to the five-field form when the envelope
 * itself carries `rotation_id` — D195/D203). Returns the raw 32-byte secret
 * (`K_repo`/`K_1` at genesis, `K_e` for a rotation).
 */
export async function openKeyEnvelope(input) {
    const env = input.envelope;
    if (!VAULT_HPKE_CT_RE.test(env.ct) || !VAULT_B64U_32_RE.test(env.enc) || !isCanonicalBase64url(env.ct) || !isCanonicalBase64url(env.enc)) {
        fail("VAULT_SCHEMA_REJECT", "key_envelope enc/ct are not the exact scalar lengths", "opening r402s/v0 key envelope");
    }
    assertVaultSignature(env, input.signer_public_key, "opening r402s/v0 key envelope");
    const ours = ekFingerprint(input.recipient.public_key);
    if (env.recipient_fingerprint !== ours || env.recipient_kind !== "principal") {
        fail("VAULT_ENVELOPE_NOT_FOR_RECIPIENT", "key_envelope is addressed to another recipient", "opening r402s/v0 key envelope", { recipient_fingerprint: env.recipient_fingerprint });
    }
    const kRepo = await hpkeOpen({
        recipient_private_key: input.recipient.private_key,
        enc: fromBase64url(env.enc, "enc"),
        info: envelopeInfo(input.recipient.public_key, env.created_by),
        aad: jcs(envelopeAad(env.repo_id, env.epoch, env.recipient_fingerprint, env.rotation_id ?? null)),
        ct: fromBase64url(env.ct, "ct"),
    });
    if (kRepo.length !== 32)
        fail("VAULT_HPKE_OPEN_FAILED", "opened plaintext is not a 32-byte K_repo", "opening r402s/v0 key envelope");
    return kRepo;
}
// ─── Epoch rotation — reader side (D193-D203, rev 42) ───────────────────────
/**
 * Decode + validate one head's `transition.payload` as a `rotate_epoch_payload`
 * (D193-D203, rev 42): base64url-jcs decode, `payload_sha256` self-check,
 * strict JSON parse, and the payload's OWN `new_epoch` agreeing with the
 * carrying head's `epoch` field (a second, independent check over the SIGNED
 * payload bytes — separate from {@link checkChainLink}'s epoch-continuity
 * check on the head's wire `epoch` field itself, protocol-v0.md S4.3). Pure
 * and keyless: this never opens an envelope, so every reader can call it
 * (including the fully keyless chain walk) before any key material is needed.
 */
export function parseRotateEpochPayload(head) {
    const t = head.transition;
    if (!t || t.kind !== "rotate_epoch")
        fail("VAULT_NOT_A_ROTATION", `head ${head.generation} does not carry a rotate_epoch transition`, "parsing rotate_epoch_payload");
    if (t.payload_format !== "base64url-jcs")
        fail("CHAIN_BROKEN", `head ${head.generation}: unsupported transition payload_format "${t.payload_format}"`, "parsing rotate_epoch_payload");
    let raw;
    try {
        raw = fromBase64url(t.payload, "transition.payload");
    }
    catch {
        fail("CHAIN_BROKEN", `head ${head.generation}: transition.payload is not canonical base64url`, "parsing rotate_epoch_payload");
    }
    if (sha256Hex(raw) !== t.payload_sha256)
        fail("CHAIN_BROKEN", `head ${head.generation}: transition.payload_sha256 does not match the payload bytes`, "parsing rotate_epoch_payload");
    let payload;
    try {
        payload = parseVaultStrict(new TextDecoder().decode(raw));
    }
    catch {
        fail("CHAIN_BROKEN", `head ${head.generation}: transition.payload is not strict-parseable r402s/v0 JSON`, "parsing rotate_epoch_payload");
    }
    if (payload.new_epoch !== head.epoch) {
        fail("CHAIN_BROKEN", `head ${head.generation}: rotate_epoch_payload.new_epoch (${payload.new_epoch}) does not equal the carrying head's own epoch (${head.epoch})`, "parsing rotate_epoch_payload");
    }
    if (!VAULT_SHA256_RE.test(payload.rotation_id))
        fail("CHAIN_BROKEN", `head ${head.generation}: rotate_epoch_payload.rotation_id is not a 64-hex digest`, "parsing rotate_epoch_payload");
    if (!Array.isArray(payload.envelopes))
        fail("CHAIN_BROKEN", `head ${head.generation}: rotate_epoch_payload.envelopes is not an array`, "parsing rotate_epoch_payload");
    return payload;
}
/**
 * Decode + validate one head's `transition.payload` as an
 * `add_writer_key_payload` (vault-multi-writer rev 47, protocol §4.16):
 * base64url-jcs decode, `payload_sha256` self-check, strict JSON parse, and
 * the payload's OWN `repo_id` agreeing with the carrying head's — mirrors
 * {@link parseRotateEpochPayload} exactly, `repo_id` in place of `epoch`
 * (this transition never changes the epoch). Pure and keyless: the FULL
 * cryptographic + structural validation (signatures, base/next writer-set
 * commitments, handoff-grant binding) is `validateAddWriterKeyPayload`'s job
 * (`../node/vault-writer-state.js`) — this function only gets the bytes
 * off the wire safely, so every reader can call it before any key material
 * is needed.
 */
export function parseAddWriterKeyPayload(head) {
    const t = head.transition;
    if (!t || t.kind !== "add_writer_key")
        fail("VAULT_NOT_A_WRITER_ADD", `head ${head.generation} does not carry an add_writer_key transition`, "parsing add_writer_key_payload");
    if (t.payload_format !== "base64url-jcs")
        fail("CHAIN_BROKEN", `head ${head.generation}: unsupported transition payload_format "${t.payload_format}"`, "parsing add_writer_key_payload");
    let raw;
    try {
        raw = fromBase64url(t.payload, "transition.payload");
    }
    catch {
        fail("CHAIN_BROKEN", `head ${head.generation}: transition.payload is not canonical base64url`, "parsing add_writer_key_payload");
    }
    if (sha256Hex(raw) !== t.payload_sha256)
        fail("CHAIN_BROKEN", `head ${head.generation}: transition.payload_sha256 does not match the payload bytes`, "parsing add_writer_key_payload");
    let payload;
    try {
        payload = parseVaultStrict(new TextDecoder().decode(raw));
    }
    catch {
        fail("CHAIN_BROKEN", `head ${head.generation}: transition.payload is not strict-parseable r402s/v0 JSON`, "parsing add_writer_key_payload");
    }
    if (payload.schema !== "r402s.add-writer-key/v1")
        fail("CHAIN_BROKEN", `head ${head.generation}: add_writer_key_payload.schema is not r402s.add-writer-key/v1`, "parsing add_writer_key_payload");
    if (payload.repo_id !== head.repo_id) {
        fail("CHAIN_BROKEN", `head ${head.generation}: add_writer_key_payload.repo_id (${payload.repo_id}) does not equal the carrying head's own repo_id (${head.repo_id})`, "parsing add_writer_key_payload");
    }
    return payload;
}
/**
 * Open ONE `rotate_epoch` transition's own `key_envelope` for
 * `own_fingerprint` and return the derived `K_e`, after verifying it
 * reproduces the payload's `epoch_key_commitment` (D200's per-recipient
 * self-check — never a global set-coherence proof, §14ao/§2). Fails CLOSED
 * with `VAULT_EPOCH_NOT_OPENABLE` — never a bare `VAULT_AEAD_AUTH_FAILURE`
 * — either when this recipient is not among the rotation's `envelopes[]` at
 * all, or when the addressed envelope's stored bytes are absent or altered.
 *
 * Transport-agnostic by design (`get_envelope_bytes` + `envelope_path`
 * callbacks): this is the ONE place a reader opens a rotation envelope, and
 * both the live publication read path (`Vault.verifyToNewest`, over
 * the gateway transport) and the offline mirror recovery path
 * (`recoverVaultMirror`, over a `VaultMirrorBackend`) call it —
 * differing only in HOW they fetch bytes, never in the open/verify logic.
 */
export async function openEpochRotationForRecipient(input) {
    const { repo_id, payload, own_fingerprint, own_encryption_keypair, writer_signing_public_key, get_envelope_bytes, envelope_path } = input;
    const pair = payload.envelopes.find((p) => p.envelope.recipient_fingerprint === own_fingerprint);
    if (!pair) {
        fail("VAULT_EPOCH_NOT_OPENABLE", `this keystore (fingerprint ${own_fingerprint}) is not among epoch ${payload.new_epoch}'s included recipients (rotation ${payload.rotation_id}) — the vault rotated past an epoch this identity was never given a key for`, "opening an epoch-rotation key envelope", { epoch: payload.new_epoch, rotation_id: payload.rotation_id, recipient_fingerprint: own_fingerprint, reason: "not_included" });
    }
    const path = envelope_path(payload.new_epoch, own_fingerprint, payload.rotation_id);
    const bytes = await get_envelope_bytes(path);
    if (!bytes || sha256Hex(bytes) !== pair.envelope.stored_bytes_sha256) {
        fail("VAULT_EPOCH_NOT_OPENABLE", `the rotation-attempt key_envelope for epoch ${payload.new_epoch} (rotation ${payload.rotation_id}) is absent or altered — this keystore (fingerprint ${own_fingerprint}) cannot open this epoch`, "opening an epoch-rotation key envelope", { epoch: payload.new_epoch, rotation_id: payload.rotation_id, recipient_fingerprint: own_fingerprint, reason: "envelope_absent_or_altered" });
    }
    const envelope = parseVaultStrict(new TextDecoder().decode(bytes));
    const kE = await openKeyEnvelope({ envelope, recipient: own_encryption_keypair, signer_public_key: writer_signing_public_key });
    const includedFingerprints = payload.envelopes.map((p) => p.envelope.recipient_fingerprint);
    const recomputed = epochRotationKeyCommitment(kE, repo_id, payload.new_epoch, payload.rotation_id, includedFingerprints);
    if (recomputed !== payload.epoch_key_commitment) {
        fail("EPOCH_KEY_COMMITMENT_MISMATCH", `this keystore's own opened epoch key for epoch ${payload.new_epoch} (rotation ${payload.rotation_id}) does not reproduce the signed epoch_key_commitment — client-local diagnostic (protocol-v0.md S2/D200), never sent as an admission refusal`, "opening an epoch-rotation key envelope", { epoch: payload.new_epoch, rotation_id: payload.rotation_id, recipient_fingerprint: own_fingerprint });
    }
    return kE;
}
/** Build + sign a `vault_genesis` (generation 0, epoch 1, the creator's single envelope). */
export function buildVaultGenesis(input) {
    const unsigned = {
        format: VAULT_FORMAT,
        object_kind: "vault_genesis",
        suite: VAULT_SUITE,
        repo_id: input.repo_id,
        org_id: input.org_id,
        project_id: input.project_id,
        allocation_nonce: input.allocation_nonce,
        generation: VAULT_GENESIS_GENERATION,
        epoch: VAULT_GENESIS_EPOCH,
        git_object_format: "sha1",
        creator_signing_pubkey: toBase64url(input.creator_signing.public_key),
        creator_encryption_pubkey: toBase64url(input.creator_encryption_public_key),
        envelopes: [input.envelope_receipt],
        writer_key_id: vkFingerprint(input.creator_signing.public_key),
        created_at: input.created_at,
    };
    return signVaultObject(unsigned, input.creator_signing.seed);
}
/**
 * The genesis key bindings (round-10 A6): exactly one envelope at epoch 1 whose
 * `recipient_fingerprint` is the creator's `ek_`; `writer_key_id` is the
 * creator's `vk_`; and, when the stored envelope is supplied, the receipt's
 * hash/size name its stored bytes and its path identity + `created_by` equal
 * the genesis values. Schema-valid-but-protocol-invalid objects surface here.
 */
export function checkGenesisKeyBindings(genesis, storedEnvelope) {
    const problems = [];
    if (genesis.envelopes.length !== 1)
        problems.push("envelope_count");
    const receipt = genesis.envelopes[0];
    if (receipt && receipt.epoch !== VAULT_GENESIS_EPOCH)
        problems.push("envelope_epoch");
    const expectedEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "creator_encryption_pubkey"));
    if (receipt && receipt.recipient_fingerprint !== expectedEk)
        problems.push("envelope_recipient_fingerprint");
    const expectedVk = vkFingerprint(fromBase64url(genesis.creator_signing_pubkey, "creator_signing_pubkey"));
    if (genesis.writer_key_id !== expectedVk)
        problems.push("writer_key_id");
    if (storedEnvelope && receipt) {
        const bytes = storedBytes(storedEnvelope);
        if (sha256Hex(bytes) !== receipt.stored_bytes_sha256)
            problems.push("stored_envelope_hash");
        if (String(bytes.length) !== receipt.size_bytes)
            problems.push("stored_envelope_size");
        if (storedEnvelope.repo_id !== genesis.repo_id)
            problems.push("stored_envelope_repo_id");
        if (storedEnvelope.epoch !== genesis.epoch)
            problems.push("stored_envelope_epoch");
        if (storedEnvelope.recipient_fingerprint !== receipt.recipient_fingerprint)
            problems.push("stored_envelope_recipient_fingerprint");
        if (storedEnvelope.created_by !== genesis.writer_key_id)
            problems.push("stored_envelope_created_by");
    }
    return problems;
}
/** Build + sign the creator's `recovery_receipt` (emitted at ACTIVE; integrity data, not a secret). */
export function buildRecoveryReceipt(input) {
    const unsigned = {
        format: VAULT_FORMAT,
        object_kind: "recovery_receipt",
        suite: VAULT_SUITE,
        repo_id: input.repo_id,
        org_id: input.org_id,
        project_id: input.project_id,
        genesis_sha256: input.genesis_sha256,
        creator_signing_fingerprint: vkFingerprint(input.creator_signing.public_key),
        creator_encryption_fingerprint: ekFingerprint(input.creator_encryption_public_key),
    };
    return signVaultObject(unsigned, input.creator_signing.seed);
}
/**
 * Check a recovery receipt against a genesis it claims to pin: the receipt's
 * signature (under the genesis's creator key), the genesis stored-bytes hash,
 * and every identity field. A substituted, internally consistent vault fails
 * on `genesis_sha256` — exactly the fabricated-vault scenario.
 */
export function checkRecoveryReceipt(receipt, genesis) {
    const problems = [];
    if (!verifyVaultObject(receipt, genesis.creator_signing_pubkey))
        problems.push("signature");
    if (receipt.genesis_sha256 !== storedBytesSha256(genesis))
        problems.push("genesis_sha256");
    if (receipt.repo_id !== genesis.repo_id)
        problems.push("repo_id");
    if (receipt.org_id !== genesis.org_id)
        problems.push("org_id");
    if (receipt.project_id !== genesis.project_id)
        problems.push("project_id");
    if (receipt.creator_signing_fingerprint !== genesis.writer_key_id)
        problems.push("creator_signing_fingerprint");
    const expectedEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "creator_encryption_pubkey"));
    if (receipt.creator_encryption_fingerprint !== expectedEk)
        problems.push("creator_encryption_fingerprint");
    return problems;
}
/**
 * Field-level checks on an `allocation` for THIS creation attempt (the
 * control-plane signature itself is verified through the service-key registry,
 * which task 5.4 wires; pass `service_public_key` once it is pinned).
 *
 * `expected.org_id` / `expected.project_id` are OPTIONAL (repo-first-onramp
 * task 4.5's push-to-create addition): a push-to-create attempt does not know
 * its org/project ahead of the allocation response (only `org_slug`/
 * `repo_name`), so those two fields are trust-on-first-use — the caller skips
 * the comparison on the FIRST allocation of a fresh push-to-create journal and
 * pins whatever the (owner-authorized, signature-verified) allocation says,
 * then compares against that pin on every later call for the SAME journal.
 * Every other field is checked unconditionally, same as before.
 */
export function checkAllocation(allocation, expected, servicePublicKey) {
    const problems = [];
    if (allocation.format !== VAULT_FORMAT || allocation.object_kind !== "allocation" || allocation.suite !== VAULT_SUITE)
        problems.push("envelope");
    if (!VAULT_SRC_RE.test(allocation.repo_id))
        problems.push("repo_id");
    if (!VAULT_HEX32_RE.test(allocation.allocation_nonce))
        problems.push("allocation_nonce");
    if (!VAULT_HEX16_RE.test(allocation.allocation_generation))
        problems.push("allocation_generation");
    if (!VAULT_SERVICE_KEY_ID_RE.test(allocation.service_key_id))
        problems.push("service_key_id");
    if (allocation.client_creation_id !== expected.client_creation_id)
        problems.push("client_creation_id");
    if (allocation.creator_signing_fingerprint !== expected.creator_signing_fingerprint)
        problems.push("creator_signing_fingerprint");
    if (allocation.creator_encryption_fingerprint !== expected.creator_encryption_fingerprint)
        problems.push("creator_encryption_fingerprint");
    if (expected.org_id !== undefined && allocation.org_id !== expected.org_id)
        problems.push("org_id");
    if (expected.project_id !== undefined && allocation.project_id !== expected.project_id)
        problems.push("project_id");
    if (!isValidVaultTimestamp(allocation.issued_at) || !isValidVaultTimestamp(allocation.created_at))
        problems.push("timestamp");
    if (servicePublicKey !== undefined && !verifyVaultObject(allocation, servicePublicKey))
        problems.push("signature");
    return problems;
}
// ─── Epoch rotation (D193-D203, rev 42, change vault-human-envelopes) ───
//
// Pure, isomorphic math shared by the producer (Node-only, vault-
// publication.ts's `rotateEpoch`, which has the live desired-recipient
// state + local keystore this module cannot see) and by vector generation.
// Every formula here MIRRORS the gateway's own pure functions in
// `packages/gateway/src/services/vault/epoch-rotation.ts` byte-for-byte
// (same field order is irrelevant — JCS sorts — but the SAME field SET and
// the SAME preimage construction is load-bearing: a drift here would build
// a descriptor/payload the gateway's own re-derivation refuses).
/** 16-hex epochs compare/increment as unsigned integers (chain.ts's `nextGeneration`, mirrored). */
export function epochValue(hex16) {
    return BigInt("0x" + hex16);
}
export function nextEpoch(hex16) {
    return (epochValue(hex16) + 1n).toString(16).padStart(16, "0");
}
function sortedDistinct(xs) {
    return [...new Set(xs)].sort();
}
/**
 * D196: `target_partition_digest = SHA-256(JCS({recipient_state_version,
 * recipient_revocation_version, pin_manifest_version, pin_manifest_sha256,
 * included: [sorted {principal_id, ek_fingerprint}], excluded_keyless_principal_ids:
 * [sorted], excluded_unconfirmed_principal_ids: [sorted]}))`. The producer
 * calls this ONCE to build the value it embeds in both the descriptor
 * (D195) and the payload (D196); the gateway recomputes the SAME formula
 * twice server-side and refuses on any disagreement — so a bug here is a
 * `RECIPIENT_SET_MISMATCH` at admission time, not a silent forgery.
 */
export function computeTargetPartitionDigest(input) {
    const includedSorted = [...input.included].sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
    const body = {
        recipient_state_version: input.recipient_state_version,
        recipient_revocation_version: input.recipient_revocation_version,
        pin_manifest_version: input.pin_manifest_version,
        pin_manifest_sha256: input.pin_manifest_sha256,
        included: includedSorted.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
        excluded_keyless_principal_ids: sortedDistinct(input.excluded_keyless_principal_ids),
        excluded_unconfirmed_principal_ids: sortedDistinct(input.excluded_unconfirmed_principal_ids),
    };
    return sha256Hex(jcs(body));
}
/**
 * D195: `rotation_id = lowerhex(SHA-256(JCS(the complete descriptor object,
 * MINUS signature)))` — the full 64-hex digest, computed over the descriptor
 * INCLUDING `attempt_key_commitment` (a field ON the descriptor). Callers
 * pass the SIGNED descriptor (or the unsigned one — the signature member is
 * stripped either way, matching the gateway's `computeRotationId`).
 */
export function computeRotationId(descriptor) {
    return sha256Hex(jcs(vaultWithoutSignature(descriptor)));
}
/**
 * D195's `attempt_key_commitment` preimage: the descriptor's OWN fields
 * minus `attempt_key_commitment` itself (and minus `signature`, which does
 * not exist yet at this point in the build) — `rotation_id` is never a
 * member of the descriptor object at all (it is the object's derived path
 * key), so there is nothing to additionally strip for it. Noncircular by
 * construction: `attempt_key_commitment` must exist before `rotation_id`
 * can be computed FROM the complete descriptor.
 */
export function attemptKeyCommitmentPreimage(descriptorFields) {
    return { ...descriptorFields };
}
/**
 * D195/§1's `"epoch_rotation_attempt"` commitment: `HMAC-SHA-256(K_digest(
 * "epoch_rotation_attempt", repo_id, new_epoch), JCS(the descriptor's own
 * fields minus attempt_key_commitment), ikm=K_e)` — keyed by the SAMPLED
 * epoch key being distributed, not `K_repo` (D195/D200/§1: "every one keyed
 * by the SAMPLED epoch key... since the value being committed IS the key
 * being distributed"). `K_digest`'s own `epoch` parameter is `new_epoch`
 * (this rotation's OWN epoch) — the natural, and only internally-consistent,
 * reading of §1's generic formula for a value that is a property of the new
 * epoch's key; there is no gateway-side computation of this value to cross-
 * check against (the gateway never sees `K_e`), so this is the SDK's own
 * considered wire-shape decision, stated here rather than guessed silently.
 */
export function attemptKeyCommitment(kE, repoId, newEpoch, descriptorFields) {
    const kDigest = deriveDigestKeyFrom(kE, repoId, newEpoch, "epoch_rotation_attempt");
    return keyedCommitment(kDigest, attemptKeyCommitmentPreimage(descriptorFields));
}
/**
 * D200/§1's `"epoch_rotation"` commitment: `HMAC-SHA-256(K_digest(
 * "epoch_rotation", repo_id, new_epoch), JCS({rotation_id, fingerprints:
 * [sorted pairwise-distinct]}), ikm=K_e)`. Proves a PER-RECIPIENT self-check
 * only (D200's narrowed claim) — never global set-coherence. A recipient
 * who opens their own envelope recovers `K_e` + `epoch` (== `new_epoch`)
 * directly from the envelope itself, so they can recompute this independent
 * of the descriptor/payload — confirming `new_epoch` is the right `K_digest`
 * epoch parameter (the same value both the producer and every recipient
 * arrive at without coordination).
 */
export function epochRotationKeyCommitment(kE, repoId, newEpoch, rotationId, fingerprints) {
    const kDigest = deriveDigestKeyFrom(kE, repoId, newEpoch, "epoch_rotation");
    return keyedCommitment(kDigest, { rotation_id: rotationId, fingerprints: sortedDistinct(fingerprints) });
}
/**
 * D198's genesis-specific `"vault_genesis_epoch_key"` commitment — the same
 * per-recipient-self-check discipline applied to an N-recipient genesis,
 * with its own noncircular preimage (no `rotation_id` exists at generation
 * zero). Not built by this SDK's genesis path today (single-recipient
 * genesis only); exported for vector generation / future N-recipient
 * genesis support.
 */
export function genesisEpochKeyCommitment(k1, repoId, fingerprints) {
    const kDigest = deriveDigestKeyFrom(k1, repoId, VAULT_GENESIS_EPOCH, "vault_genesis_epoch_key");
    return keyedCommitment(kDigest, { repo_id: repoId, generation: VAULT_GENESIS_GENERATION, epoch: VAULT_GENESIS_EPOCH, fingerprints: sortedDistinct(fingerprints) });
}
/**
 * D195's producer obligation the round-3 review's own decisive witness
 * forces: "a producer that cannot prove it is resuming its OWN prior
 * in-flight attempt... MUST mint a fresh `client_idempotency_key`... before
 * uploading any envelope." The corollary — and what actually PREVENTS the
 * complementary-path mosaic client-side, before the descriptor's create-only
 * CAS even gets a chance to catch it server-side — is that a fresh K_e must
 * never equal any epoch key this client has ever locally held (constant-time
 * comparison; a match is evidence of a broken CSPRNG or a reused seed, not a
 * legitimate resume, since a genuine resume reuses the SAME
 * client_idempotency_key AND therefore builds the identical descriptor, not
 * merely the same K_e in isolation).
 */
export function checkFreshEpochKeyAgainstPriorKeys(kE, priorKeys) {
    if (kE.length !== 32)
        fail("VAULT_BAD_KEY", "a freshly sampled K_e must be 32 bytes", "sampling a fresh epoch key");
    const fresh = bytesToHex(kE);
    for (const prior of priorKeys) {
        if (bytesToHex(prior) === fresh) {
            fail("VAULT_EPOCH_KEY_NOT_FRESH", "the freshly sampled K_e equals a prior epoch key already held locally — refusing to seal under it", "sampling a fresh epoch key");
        }
    }
}
/**
 * D196's pair-level bijection, mirrored client-side as a PRE-SUBMIT
 * self-check (the gateway's own recomputation under its live desired-state
 * lock remains the authoritative check — this exists so a client-side
 * partition bug surfaces as a clear local error instead of a round trip
 * that ends in `RECIPIENT_SET_MISMATCH`).
 */
export function checkHPartition(input) {
    const writerIncluded = (principalId) => input.survivingWriterPrincipalIds !== undefined && input.survivingWriterPrincipalIds.has(principalId) && !input.pinnedFingerprintOf.has(principalId);
    const includedIds = input.included.map((p) => p.principal_id);
    const includedFps = input.included.map((p) => p.ek_fingerprint);
    if (new Set(includedIds).size !== includedIds.length)
        return { ok: false, detail: "envelopes[].principal_id are not pairwise-distinct" };
    if (new Set(includedFps).size !== includedFps.length)
        return { ok: false, detail: "envelopes[].envelope.recipient_fingerprint are not pairwise-distinct" };
    if (new Set(input.excludedKeylessPrincipalIds).size !== input.excludedKeylessPrincipalIds.length)
        return { ok: false, detail: "excluded_keyless_principal_ids are not pairwise-distinct" };
    if (new Set(input.excludedUnconfirmedPrincipalIds).size !== input.excludedUnconfirmedPrincipalIds.length)
        return { ok: false, detail: "excluded_unconfirmed_principal_ids are not pairwise-distinct" };
    const partition = new Map();
    for (const id of includedIds) {
        if (partition.has(id))
            return { ok: false, detail: `principal ${id} appears more than once across the partition` };
        partition.set(id, "included");
    }
    for (const id of input.excludedKeylessPrincipalIds) {
        if (partition.has(id))
            return { ok: false, detail: `principal ${id} appears more than once across the partition` };
        partition.set(id, "keyless");
    }
    for (const id of input.excludedUnconfirmedPrincipalIds) {
        if (partition.has(id))
            return { ok: false, detail: `principal ${id} appears more than once across the partition` };
        partition.set(id, "unconfirmed");
    }
    if (partition.size !== input.desiredPrincipalIds.size)
        return { ok: false, detail: `partition covers ${partition.size} principals; H has ${input.desiredPrincipalIds.size}` };
    for (const id of partition.keys())
        if (!input.desiredPrincipalIds.has(id))
            return { ok: false, detail: `principal ${id} is not in the current desired-recipient set H` };
    for (const id of input.desiredPrincipalIds)
        if (!partition.has(id))
            return { ok: false, detail: `principal ${id} in H is named by neither envelopes[] nor either exclusion array` };
    for (const pair of input.included) {
        if (!input.keyedPrincipalIds.has(pair.principal_id))
            return { ok: false, detail: `included principal ${pair.principal_id} has no enrolled key` };
        const pinned = input.pinnedFingerprintOf.get(pair.principal_id);
        if (pinned === undefined) {
            if (!writerIncluded(pair.principal_id))
                return { ok: false, detail: `included principal ${pair.principal_id} has no live pin-manifest entry and is not a surviving writer` };
            const directory = input.directoryFingerprintOf?.get(pair.principal_id);
            if (directory === undefined || directory !== pair.ek_fingerprint)
                return { ok: false, detail: `included writer ${pair.principal_id}'s envelope fingerprint does not match the directory's current key` };
            continue;
        }
        if (pinned !== pair.ek_fingerprint)
            return { ok: false, detail: `included principal ${pair.principal_id}'s envelope fingerprint does not match the effective pin manifest's binding` };
    }
    for (const id of input.excludedKeylessPrincipalIds) {
        if (input.keyedPrincipalIds.has(id))
            return { ok: false, detail: `excluded-keyless principal ${id} actually has an enrolled key` };
    }
    for (const id of input.excludedUnconfirmedPrincipalIds) {
        if (!input.keyedPrincipalIds.has(id))
            return { ok: false, detail: `excluded-unconfirmed principal ${id} is keyless, not unconfirmed` };
        if (input.pinnedFingerprintOf.has(id))
            return { ok: false, detail: `excluded-unconfirmed principal ${id} actually has a live pin-manifest entry` };
        if (writerIncluded(id))
            return { ok: false, detail: `excluded-unconfirmed principal ${id} is a surviving writer and must be included` };
    }
    return { ok: true };
}
/** D197's full-map conservation rule: `next_map = prior_map + receipt-authorized additions/replacements`, deletion-by-omission forbidden. */
export function checkPinManifestConservation(prior, next) {
    const nextIds = new Set(next.map((p) => p.principal_id));
    for (const p of prior) {
        if (!nextIds.has(p.principal_id))
            return { ok: false, detail: `principal ${p.principal_id} present in the predecessor manifest is missing from the successor — deletion by omission is forbidden in V0` };
    }
    const seen = new Set();
    for (const p of next) {
        if (seen.has(p.principal_id))
            return { ok: false, detail: `principal ${p.principal_id} appears more than once in the manifest` };
        seen.add(p.principal_id);
    }
    return { ok: true };
}
/** The synthetic ledger id a path-addressed `key_envelope` gets — mirrors the gateway's `keyEnvelopeLedgerId` exactly (drift here breaks receipt pairing at upload finalize). */
export function keyEnvelopeLedgerId(epoch, fingerprint, rotationId) {
    return rotationId ? `key_envelope:${epoch}:${rotationId}:${fingerprint}` : `key_envelope:${epoch}:${fingerprint}`;
}
export function pinManifestLedgerId(pinManifestVersion) {
    return `recipient_pin_manifest:${pinManifestVersion}`;
}
// ─── Source-access wrapper custody — WrapperV1 / `swrap2` (DECRYPT SIDE) ─────
//
// vault-recovery-custody: a human member's X25519 private scalar is RANDOM
// and persisted only as sealed wrappers — each an XChaCha20-Poly1305 AEAD
// encryption of the 32-byte scalar under a KEK derived from a credential
// secret, bound to a canonical JCS context. This SDK implements only the
// OPEN path plus recovery-code normalization: sealing, PRF assertions, and
// enrollment ceremonies live in the console (browser WebAuthn); the offline
// tool `r402s-recover` opens `recovery_code` wrappers with no server.
//
// Every constant below is pinned byte-for-byte against the canonical module
// (private repo `apps/git/public/lib/r402s-crypto.js`, "Source-access wrapper
// custody" section, and vault-recovery-custody/design.md D1 addendum). If
// they ever disagree, both are wrong and need reconciling together. The prior
// `swrap1_` format never protected a real member key and is NOT decodable —
// it fails by name.
//
//   context = UTF-8("r402s/v0/source-wrapper-context/v2\n")
//           ‖ UTF-8(JCS({aead, credential_subject, encryption_key_id, format,
//                        kdf, kind, member_public_key_sha256, principal_id,
//                        rp_id, suite, wrapper_id}))
//   KEK  = HKDF-SHA256(ikm, salt = per-wrapper kdf_salt(32),
//                      info = UTF-8(<per-kind label>) ‖ context, L = 32)
//   blob = "swrap2_" + b64url(kdf_salt(32) ‖ nonce(24) ‖ ct‖tag); aad = context.
//
//   SOURCE recovery code v2 ("SRC1"): 160-bit Crockford-base32 core (32
//   chars, alphabet "0123456789ABCDEFGHJKMNPQRSTVWXYZ" — no I/L/O/U) plus
//   ONE check character alphabet[(Σ char values) mod 32]; the CORE's UTF-8
//   bytes are the ikm. Decrypt-only: the code never authenticates and never
//   satisfies step-up.
const SOURCE_WRAP_CONTEXT_PREFIX = "r402s/v0/source-wrapper-context/v2\n";
const SOURCE_WRAP_PRF_KEK_LABEL = "r402s/v0/source-wrapper-prf-kek/v2\n";
const SOURCE_WRAP_RC_KEK_LABEL = "r402s/v0/source-wrapper-recovery-code-kek/v2\n";
export const SOURCE_WRAP_BLOB_PREFIX = "swrap2_";
export const SOURCE_RC_DISPLAY_PREFIX = "SRC1";
const SOURCE_RC_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32 — no I/L/O/U
/** The pinned canonical context for one wrapper — BOTH the KEK HKDF-info suffix AND the AEAD AAD. */
export function buildSourceWrapperContext(fields) {
    if (fields.kind !== "webauthn_prf" && fields.kind !== "recovery_code") {
        fail("WRAPPER_FORMAT_UNSUPPORTED", `unknown wrapper kind: ${String(fields.kind)}`, "building source-wrapper context");
    }
    if (!(fields.member_public_key instanceof Uint8Array) || fields.member_public_key.length !== 32) {
        fail("WRAPPER_FORMAT_UNSUPPORTED", "member_public_key must be the raw 32-byte member public key", "building source-wrapper context");
    }
    return concatBytes(utf8ToBytes(SOURCE_WRAP_CONTEXT_PREFIX), utf8ToBytes(canonicalize({
        aead: "xchacha20poly1305",
        credential_subject: fields.kind === "webauthn_prf" ? String(fields.credential_subject) : null,
        encryption_key_id: String(fields.encryption_key_id),
        format: "swrap2",
        kdf: "hkdf-sha256",
        kind: fields.kind,
        member_public_key_sha256: bytesToHex(sha256(fields.member_public_key)),
        principal_id: String(fields.principal_id),
        rp_id: String(fields.rp_id),
        suite: VAULT_SUITE,
        wrapper_id: String(fields.wrapper_id),
    })));
}
function sourceWrapKek(kind, ikm, kdfSalt, context) {
    const label = kind === "webauthn_prf" ? SOURCE_WRAP_PRF_KEK_LABEL : SOURCE_WRAP_RC_KEK_LABEL;
    return hkdf(sha256, ikm, kdfSalt, concatBytes(utf8ToBytes(label), context), 32);
}
/**
 * Open a `swrap2_...` wrapper blob back into the 32-byte member scalar.
 * `ikm` is the UTF-8 bytes of the normalized code CORE (`recovery_code`) or
 * the raw PRF output (`webauthn_prf` — only ever exercised by browser
 * surfaces; offline recovery refuses raw PRF as an input by policy, at the
 * recover layer). A failed AEAD is `WRAPPER_DID_NOT_OPEN` — the truthful
 * cause-neutral error: wrong code, corrupt blob, and wrong context (rp_id
 * included) are indistinguishable here. The caller MUST compare the derived
 * FULL public key against the published one before trusting the scalar.
 */
export function openSourceWrapper(input) {
    const { kind, ikm, blob, context } = input;
    if (typeof blob !== "string" || !blob.startsWith(SOURCE_WRAP_BLOB_PREFIX)) {
        fail("WRAPPER_FORMAT_UNSUPPORTED", "not a source-access wrapper blob (expected swrap2_ prefix)", "opening source-access wrapper");
    }
    let raw;
    try {
        raw = base64urlnopad.decode(blob.slice(SOURCE_WRAP_BLOB_PREFIX.length));
    }
    catch {
        fail("WRAPPER_FORMAT_UNSUPPORTED", "malformed source-access wrapper blob (not canonical base64url)", "opening source-access wrapper");
    }
    if (raw.length < 32 + 24 + 16 + 32)
        fail("WRAPPER_FORMAT_UNSUPPORTED", "malformed source-access wrapper blob (too short)", "opening source-access wrapper");
    const kdfSalt = raw.subarray(0, 32);
    const nonce = raw.subarray(32, 56);
    const ctWithTag = raw.subarray(56);
    const kek = sourceWrapKek(kind, ikm, kdfSalt, context);
    let scalar;
    try {
        scalar = xchacha20poly1305(kek, nonce, context).decrypt(ctWithTag);
    }
    catch {
        fail("WRAPPER_DID_NOT_OPEN", "The wrapper did not open — wrong code/credential, a corrupt stored blob, or mismatched wrapper metadata (rp_id included).", "opening source-access wrapper");
    }
    if (scalar.length !== 32)
        fail("WRAPPER_DID_NOT_OPEN", "source-access wrapper did not contain a 32-byte scalar", "opening source-access wrapper");
    return scalar;
}
function sourceRcCheckChar(core) {
    let sum = 0;
    for (const ch of core)
        sum += SOURCE_RC_ALPHABET.indexOf(ch);
    return SOURCE_RC_ALPHABET[sum % 32];
}
/**
 * Pinned normalization (ONE canonical accepted form): uppercase; strip every
 * char outside [0-9A-Z]; map I→1, L→1, O→0; drop a leading "SRC1" when the
 * result is 37 chars; require exactly 33 chars; validate the check character
 * (`RECOVERY_CODE_CHECKSUM_INVALID` — a local typo, caught before any KEK
 * derivation or wrapper read). Returns the 32-char CORE (the KEK ikm).
 */
export function normalizeSourceRecoveryCode(input) {
    if (typeof input !== "string")
        fail("RECOVERY_CODE_CHECKSUM_INVALID", "recovery code must be a string", "normalizing source recovery code");
    let stripped = input.toUpperCase().replace(/[^0-9A-Z]/g, "").replace(/I/g, "1").replace(/L/g, "1").replace(/O/g, "0");
    if (stripped.length === 37 && stripped.startsWith(SOURCE_RC_DISPLAY_PREFIX)) {
        stripped = stripped.slice(SOURCE_RC_DISPLAY_PREFIX.length);
    }
    if (stripped.length !== 33) {
        fail("RECOVERY_CODE_CHECKSUM_INVALID", `recovery code must be 33 characters after normalization — the 32-char code plus its final check character (got ${stripped.length})`, "normalizing source recovery code");
    }
    for (const ch of stripped) {
        if (!SOURCE_RC_ALPHABET.includes(ch)) {
            fail("RECOVERY_CODE_CHECKSUM_INVALID", `recovery code contains an invalid character: ${ch}`, "normalizing source recovery code");
        }
    }
    const core = stripped.slice(0, 32);
    if (sourceRcCheckChar(core) !== stripped[32]) {
        fail("RECOVERY_CODE_CHECKSUM_INVALID", "That code has a typo — its check character doesn't match. Compare against your saved copy (0/O and 1/I/L are interchangeable; dashes and case don't matter).", "normalizing source recovery code");
    }
    return core;
}
export { bytesToHex, hexToBytes };
// ── vault-agent-envelopes: keystore-key possession proof ──────────────────
/**
 * The client half of the gateway's ECDH key-confirmation challenge for a
 * `keystore_v1` enrollment (`POST /agent/v1/whoami/encryption-key` →
 * `activation.{challenge_id, epk}`; `POST …/encryption-key/activate`
 * `{challenge_id, proof}`). The server minted an ephemeral X25519 pair,
 * computed HMAC-SHA256(ECDH(esk, our public key), message) and stored only
 * its SHA-256; a holder of the keystore's private scalar reproduces the HMAC
 * from `epk`. Byte-identical to the gateway's `keyPossessionMessage` /
 * `mintKeyPossessionChallenge` (services/principal-encryption-keys.ts) —
 * the prefix + JCS message are pinned here so the two lineages cannot drift.
 * Client convention, not r402s/v0 wire protocol.
 */
export const VAULT_KEYSTORE_POSSESSION_PREFIX = "r402s/v0/keystore-key-possession/v1\n";
export function computeKeystorePossessionProof(input) {
    const shared = x25519.getSharedSecret(input.private_key, fromBase64url(input.epk_b64u, "activation.epk"));
    // Sorted-key JSON.stringify IS RFC 8785 here: three plain string fields.
    const jcs = JSON.stringify({ challenge_id: input.challenge_id, encryption_key_id: input.encryption_key_id, public_key: input.public_key_b64u });
    const message = concatBytes(utf8ToBytes(VAULT_KEYSTORE_POSSESSION_PREFIX), utf8ToBytes(jcs));
    return toBase64url(hmac(sha256, shared, message));
}
/**
 * vault-multi-writer rev 47 (design D9) — the writer-signing-key
 * possession message. Byte-identical to the gateway's own
 * `signingKeyPossessionMessage` (services/principal-encryption-keys.ts) —
 * `JSON.stringify({domain, principal_id, signing_pubkey, encryption_pubkey})`
 * in EXACTLY this field order, verified by reading that function's source
 * directly rather than trusting its own comment (which claims "sorted
 * keys" — this order is NOT alphabetically sorted; only the byte-for-byte
 * match to what the gateway actually re-derives at verify time matters, and
 * `JSON.stringify` is deterministic for a fixed insertion order regardless
 * of whether that order happens to be alphabetical). Deliberately plain
 * `JSON.stringify`, not `jcs()` — the RFC 8785 canonicalizer would SORT
 * these four keys into a DIFFERENT order (`domain, encryption_pubkey,
 * principal_id, signing_pubkey`) and silently produce a signature that
 * never verifies.
 */
export const VAULT_SIGNING_KEY_POSSESSION_DOMAIN = "r402s/v0/signing-key-possession/v1";
export function signingKeyPossessionMessage(fields) {
    const json = JSON.stringify({ domain: VAULT_SIGNING_KEY_POSSESSION_DOMAIN, principal_id: fields.principal_id, signing_pubkey: fields.signing_pubkey, encryption_pubkey: fields.encryption_pubkey });
    return utf8ToBytes(json);
}
/** Ed25519 signature over {@link signingKeyPossessionMessage} — proves possession of the signing half's private key over this specific (principal, signing key, encryption key) binding. */
export function computeSigningKeyPossessionSignature(input) {
    return toBase64url(ed25519Sign(signingKeyPossessionMessage(input), input.signing_seed));
}
//# sourceMappingURL=vault.crypto.js.map