import type { H2ASignature } from "../../types.js"; /** * THE NONCE BRACKET — four bounds, each labelled with what it is FOR. * ================================================================= * * The general form, which is the correction to the first draft of this contract: * **a positive specification does not mean a single value — it means every * accepted-set boundary is stated, and each one says what it is for.** A floor * that protects strength and a ceiling that protects against blobs are different * parameters and must not be described in the same breath. The first attempt * specified `fixed ~43`, which pinned an issuer that DOES NOT EXIST YET to an * entropy choice made on its behalf: if the auth lane later picks 384 or 512 bits * — the *safer* choice — a fixed verifier turns their improvement into our * outage. * * 1. {@link H2A_ENROLLMENT_NONCE_PATTERN} — alphabet base64url. * **POSITIVE, SECURITY-BEARING.** * 2. {@link H2A_ENROLLMENT_NONCE_MIN_BITS} / {@link H2A_ENROLLMENT_NONCE_MIN_LENGTH} * — minimum 256 bits. **POSITIVE, SECURITY-BEARING.** * 3. {@link H2A_ENROLLMENT_NONCE_MAX_BITS} / {@link H2A_ENROLLMENT_NONCE_MAX_LENGTH} * — maximum 1024 bits. **SANITY CEILING, EXPLICITLY NOT A SECURITY * PARAMETER.** It means *"beyond this it is not a nonce"*. It must NEVER be * read as *"this much entropy is enough"* — the floor is the only bound that * speaks about strength. * 4. {@link H2A_ENROLLMENT_MAX_NONCE_LENGTH} — 4096 chars, pre-parse DoS guard * only. Not a definition of anything. */ /** * Minimum entropy a gateway nonce must carry. **POSITIVE, SECURITY-BEARING.** * Below this a "nonce" is guessable, and a guessable challenge is one an attacker * can pre-compute a proof for. */ export declare const H2A_ENROLLMENT_NONCE_MIN_BITS = 256; /** * Upper end of the bracket. **SANITY CEILING — NOT A SECURITY PARAMETER.** * * It says "beyond this it is not a nonce", nothing about sufficiency. * * Set at 1024 rather than 512 for HEADROOM — and the arithmetic has to be stated * correctly, because an earlier draft of this comment said "88 base64url * characters" and then drew a conclusion that only the wrong number supports. * Base64url packs 6 bits per character, so a perfectly reasonable 64-byte nonce is * 512 bits / **86** characters — and a 512-bit ceiling derives to `ceil(512 / 6) = * 86` as well. It would therefore land EXACTLY on such a nonce and ACCEPT it, not * reject it. So "512 would reject a 64-byte nonce" was never true; the real * objection is that it leaves ZERO margin. A bound never meant to bound entropy * must not sit flush against a legitimate value, where one more byte of issuer * entropy — or base64 padding the issuer does not strip — turns a sanity bound * into an outage. 1024 buys that margin. The corrected number does not weaken the * choice; it removes a justification that misreported its own arithmetic, which is * the defect class this module exists to argue against. */ export declare const H2A_ENROLLMENT_NONCE_MAX_BITS = 1024; /** * The nonce's accepted alphabet, stated POSITIVELY: base64url characters only. * **POSITIVE, SECURITY-BEARING** — it is what refuses free text, JSON, a URL, or * a message borrowed from some other protocol, rather than enumerating those. */ export declare const H2A_ENROLLMENT_NONCE_PATTERN: RegExp; /** * Minimum nonce length, DERIVED not asserted: base64url packs 6 bits per * character, so `ceil(256 / 6) = 43` characters is the 256-bit floor. * **POSITIVE, SECURITY-BEARING.** * * A MINIMUM, never an exact length — see the bracket note above for why pinning * an as-yet-unbuilt issuer to one value is the error this replaces. */ export declare const H2A_ENROLLMENT_NONCE_MIN_LENGTH: number; /** * Maximum nonce length, derived the same way: `ceil(1024 / 6) = 171` characters. * **SANITY CEILING, NOT A SECURITY PARAMETER** — see * {@link H2A_ENROLLMENT_NONCE_MAX_BITS}. */ export declare const H2A_ENROLLMENT_NONCE_MAX_LENGTH: number; /** * Cheap PRE-PARSE cap, and nothing more. * * A DoS sanity bound, not a definition of a nonce — the definition is the * alphabet plus the floor, with the ceiling as a separate sanity bound. Kept * distinct so none of the four is mistaken for another. */ export declare const H2A_ENROLLMENT_MAX_NONCE_LENGTH = 4096; /** * The ONLY keys a challenge document may carry. An ALLOWLIST, not a blocklist. * * The nonce was specified positively while the challenge *object* was left * specified negatively — a blocklist of one key, `principalSub`. That control did * not cover the harm it was written for: the harm is *"a principal id reaching an * agent process and context window"*, and `{ nonce, meta: { principalSub } }` or * `{ nonce, "__proto__": { principalSub } }` both do exactly that while passing a * top-level `"principalSub" in challenge` check. A blocklist of one key stops one * spelling of one field; nesting is a different spelling. * * So the same positive-specification move is applied one level up: only `nonce` * and `expiresAt` may appear, both must be strings, and anything else is refused * without needing to know what it means. No nesting is reachable, so no * deep scan for a forbidden name is needed — that would be the blocklist again, * one level deeper. */ export declare const H2A_ENROLLMENT_CHALLENGE_KEYS: readonly ["nonce", "expiresAt"]; /** * The domain-separation tag carried and signed by every proof — see * {@link H2AEnrollmentProof.type}. * * Versioned so a format change is distinguishable rather than silently * reinterpreted. Bump this and the verifier stops accepting the old shape, which * is the entire point of it being here. */ export declare const H2A_ENROLLMENT_PROOF_TYPE = "h2a-enrollment-proof-v1"; /** * A gateway-issued enrollment challenge, as the local agent receives it. * * Part B, flow step 2: *"Gateway issues a challenge: a random nonce, TTL-bound, * scoped to that `sub`."* The `sub`-scoping is a fact the GATEWAY holds and * re-checks at verification time (flow step 5a) and the agent never sees it — * see consequence 2 in the module header. */ export interface H2AEnrollmentChallenge { /** * The random nonce: base64url, ≥256 bits. It is one of the three signed * fields — see {@link enrollmentProofSignedPayload}. */ readonly nonce: string; /** * Optional ISO-8601 expiry, as told to the agent. **Advisory**: the gateway * remains the authority on the TTL (flow step 5a). Present, it lets the agent * refuse to spend a signature on a challenge it can already see is dead — * which can only ever narrow what happens, never widen it. */ readonly expiresAt?: string; } /** * The proof payload the agent returns to the gateway — Part B, flow step 4: * *"The agent returns `{ nonce, signature, publicKeyPem, instance }`."* * * Exactly those four fields, and nothing else. In particular: no private key * material, no filesystem path, no principal identifier, no bearer token, no * capability list. What the gateway does with it is Part B flow step 5. */ export interface H2AEnrollmentProof { /** * WHAT THIS MESSAGE IS — the domain-separation tag, signed like every other * carried field. * * The rule "a proof must attest to everything it CARRIES" is only * content-completeness. The other half: **a proof must also attest WHAT IT * IS.** Attesting content while leaving the message type unstated is exactly * how cross-protocol attacks work — signature valid, content honest, * *interpretation attacker-chosen*. * * That is precisely the oracle this contract already closed once: moving from a * signed string to a signed object fixed it, but bound the type only * ACCIDENTALLY, via a key set that happens to be unique among h2a's signing * sites today. "Safe because no other site currently signs this shape" is the * same negative property — satisfied by absence, expiring when someone adds the * colliding site. This field makes it positive. * * **VERSIONED deliberately.** A future format change must be * *distinguishable*, not silently reinterpreted; an unversioned tag only defers * the same problem to the next revision. {@link verifyEnrollmentProof} checks * this value, so a `-v2` proof cannot pass a v1 verifier. */ readonly type: typeof H2A_ENROLLMENT_PROOF_TYPE; /** The gateway's nonce, echoed verbatim. Signed. */ readonly nonce: string; /** * `signCanonical(enrollmentProofSignedPayload(proof), { by: instance, * privateKeyPem })` — an `H2ASignature` `{ by, alg: 'ed25519', value }` * (`types.ts` `H2ASignature`, reused unchanged per Part B's "Reuse vs. new" * table). * * `signature.by` is NOT part of the signed message; it is a routing label that * duplicates the signed `instance`. A verifier must read `instance`, never * `by`. */ readonly signature: H2ASignature; /** * The agent's CURRENT public key PEM (SPKI). Public material, not a secret. * Signed — so a payload cannot be re-pointed at another key and still verify * against this signature. */ readonly publicKeyPem: string; /** * The agent's CURRENT live instance id. Signed, so its PROVENANCE is * trustworthy. That is emphatically not permission: see SIGNED ≠ AUTHORIZED in * the module header. It maps to Part B's `agentInstanceIdAtBinding`, which the * contract already marks "provenance only — NEVER re-used as authority at read * time". */ readonly instance: string; } /** * The exact object the signature covers: **every field the proof carries except * the signature itself**. * * Single definition on purpose. The gateway's verification (Part B flow step 5b, * as amended) is `verifyCanonical(enrollmentProofSignedPayload(proof), * proof.signature, proof.publicKeyPem)`, and field ordering is irrelevant * because `canonicalize` normalizes it — so an independent implementation cannot * disagree about what was signed by guessing at key order. * * The rule is STRUCTURAL, not asserted. Coverage is a rest-spread that removes * exactly one field, so "every field except the signature" is what the code * *does* rather than a list that has to be kept in step — the same shape as * `envelope.ts` `envelopeSigningView` (`const { signatures: _omit, ...rest }`). * Three things follow, and the third is why this beats an enumeration: * * 1. `Omit<…, "signature">` on the parameter makes **tsc** reject a caller that * does not hold every non-signature field, so a new required field on * {@link H2AEnrollmentProof} is a compile error, not a test failure. * 2. A new field flows into the signature automatically. There is no second list * to forget. * 3. A field carried on the proof but NOT signed becomes impossible to emit: * signing sees the unsigned view, verification re-derives it from the finished * proof, so any extra field makes the two disagree and * {@link signEnrollmentChallenge}'s self-verification throws. The rule holds * even with every test deleted. * * The keys test is kept anyway — it documents the intent and costs nothing. */ export declare function enrollmentProofSignedPayload(proof: Omit & { readonly signature?: H2ASignature; }): Omit; /** * Verify a proof the way the gateway must: the signature over the whole * composite, checked against the key the proof ships. * * **This answers "did this key produce this payload", and NOTHING else.** It is * not an authorization check and must never be used as one: a `true` here on a * key with no active binding row still authorizes nothing (Part B, fail-closed * item 3 — "verifying the signature must never, by itself, cause any row to be * returned"). Provided so the two lanes verify the same bytes, not so a caller * can shortcut a binding lookup. */ export declare function verifyEnrollmentProof(proof: H2AEnrollmentProof): boolean; /** * Refuse to prove control of a key h2a itself does not list as ACTIVE. * * h2a-side key validity is *necessary but not sufficient* for exposure — the * binding governs that (Part B, fail-closed item 4) — but a key h2a considers * revoked must never be offered up for a NEW binding. * * This is DEFENCE IN DEPTH, and its call site is unreachable in practice: when * the live key has been revoked, `resolveLiveIdentity`'s reclaim proof * (`provesLocalKey`, which requires at least one active key) fails, so it MINTS * a fresh identity instead of returning the revoked one — verified by the * "a locally revoked key is never the key proved" test. The guard exists so that * remains a CHECKED fact rather than a relied-upon one, and it is exported so it * can be exercised directly instead of sitting untested behind a path that * cannot reach it. * * An empty key list is an ERROR here, never a silent pass: "no active keys" is * not the fact "this key is fine". */ export declare function assertKeyIsLocallyActive(input: { readonly instance: string; readonly publicKeyPem: string; readonly activeKeys: readonly string[]; }): void; /** The local key material the ceremony proves control of. */ export interface H2AEnrollmentIdentity { /** The live instance id, resolved at run time — never a recorded value. */ readonly instance: string; /** PKCS#8 PEM. Read from disk, used to sign, and NEVER returned outward. */ readonly privateKeyPem: string; /** SPKI PEM. Public material; this one does go on the wire. */ readonly publicKeyPem: string; /** * How live resolution arrived at this identity: `reclaim` re-proved an existing * local key, `mint` created a NEW one. Reported so a mint is never silent — a * mint the owner did not expect means an earlier keypair was unusable (see * {@link listUnusablePrivateKeys}) or the agent re-anchored. Optional so an * injected resolver need not synthesize a provenance it does not have. */ readonly identityAction?: "override" | "reclaim" | "mint"; } /** Where {@link buildEnrollmentProof} looks for the live agent identity. */ export interface ResolveEnrollmentIdentityInput { /** The h2a store root (`/keys/...`). */ readonly root: string; /** The provider host (`claude`, `codex`, ...) — an input to live resolution. */ readonly host: string; /** The working directory, for workspace resolution. */ readonly cwd: string; } /** * Seam for the identity resolution. Injected by tests; the default * ({@link resolveEnrollmentIdentity}) is the only production implementation. */ export type EnrollmentIdentityResolver = (input: ResolveEnrollmentIdentityInput) => H2AEnrollmentIdentity; /** * Resolve the CURRENT live identity and load its keypair. * * Why this resolves live, every single time, and takes no instance id: * **a memory that names an instance-id ROTS.** That is a documented failure in * this project (a stale recorded mapping sent a consultation to the wrong * instance), and it is the same failure the identity re-anchor of 2026-06-07 * created for enrollment: the stability unit moved from `(host, workspaceId)` * to the provider conversation UUID, so an agent enrolled before it now * presents a DIFFERENT instance handle and a DIFFERENT keypair (Part B, * "Re-enrollment of a post-re-anchor key"). The previously enrolled * `agentPubKey` is simply no longer produced by any live agent — which is why a * push today is rejected 401. * * So there is deliberately **no `instance` parameter anywhere in this module's * public surface**. The id cannot be passed in, therefore a stale one cannot be * used. `resolveLiveIdentity` is called without `explicitInstance` (which would * short-circuit before any keypair exists — `identity/live.ts`), and the key is * then read from the paths THAT resolution returned. * * WHAT ACTUALLY HAPPENS WHEN THE LOCAL KEY IS UNUSABLE — stated because the * obvious reading ("it fails closed") is wrong, and an overstated guard comment * is worse than none: * * - **Corrupt, truncated or passphrase-protected private key** → `provesLocalKey` * catches its own failure and returns `false`, so `resolveLiveIdentity` MINTS a * fresh identity. The ceremony then succeeds, with exit 0, proving control of a * BRAND-NEW key — the corrupt file is left on disk and the old instance stays * listed active. It does not fail, and it does not prove the damaged key. The * mint is the same self-healing mechanism as the revoked-key case, but here it * would MASK TAMPERING, so silence is the defect: the CLI names any unusable * key file on stderr before this runs (see {@link listUnusablePrivateKeys}), * and `identityAction` reports the mint. Pinned by an OBSERVED BEHAVIOUR test. * - **Unreadable private key (EACCES)** → `readKeypair` reads the same file * earlier, inside `resolveLiveIdentity` and outside any `try`, so the raw errno * escapes from there rather than from the guard below. That is why the whole * resolution call is wrapped: the failure is re-thrown with the root named, * instead of surfacing as a bare `EACCES` with no indication of which store it * came from. * - **The two guards below are NARROWING, not behaviour.** The * `privateKeyPath === undefined` branch exists because the type is optional * (`explicitInstance` is the only resolution path that omits the paths, and * this function never passes one), and the `readFileSync` branch is belt and * braces behind the earlier read. Neither is reachable on this path; they are * not the reason a broken key is safe. {@link assertKeyIsLocallyActive} is in * the same position and says so itself. * * DOCUMENTED LIMIT on what the returned `instance` contains: an instance id is * `: