Repository navigation
feat(gateway): port the FinalMilestonePackageV2 producer and fix its signature domain (item 8, not yet wired) - #358
Merged
Merged
Conversation
The oracle's ingestion binds raw.packageHash === packageDigestV2 and the gateway
receipt binds receipt.packageDigest to the same value. Both already existed and
were tested; nothing in the repo PRODUCED the value they bind to. This is that
producer.
packageDigestV2 = SHA-256(JCS({body, signatures: canonicalSignatures(sigs)})),
canonicalSignatures = dedup-by-signer (first wins) + sort by lowercased signerId.
Reuses @pcc/spec canonicalize rather than hand-rolling JCS — two canonicalizers
in one money path is how byte-level disagreements are born.
Fails closed on values the shared canonicalizer is not RFC-8785-safe for
(non-integer numbers, bigint) rather than emitting a digest the oracle may not
reproduce. Returns 0x-hex, not @pcc/spec's sha256: evidence framing.
Two items are deliberately NOT guessed and are flagged to the oracle:
the literal signatures key name, and whether signerId case is normalized in the
canonical form (currently BOUND per their literal spec). 17 tests pass; the
byte-exact golden is todo pending the oracle's vector.
agent: gateway-lane 0600b204
(cherry picked from commit ac0063a)
Ported by implementer-item8 from lamasu/feat/g2-package-digest-v2 onto
feat/g2-package-digest-v2-wired (must-close item 8). Applied cleanly: no
conflicts, no changes to the upstream patch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…e #1395
The oracle owns ingestion and answered both items I refused to guess:
1. PackageSignature is {signer, scheme, sig} — not my placeholder
{signerId, signature}. Signer SET is {operator, kernel} per evidence's
frozen profile (D1 operator secp256k1-EIP712, D2 kernel ed25519-raw32).
2. signer is CASE-INSENSITIVE. Verbatim: 'Do NOT depend on case; changing a
signer id's case MUST be a no-op.' canonicalSignatures now LOWERCASES the
emitted signer, not just the dedup/sort key.
(2) is the one that mattered. The previous commit implemented the literal
spec — sort BY lowercase, emit as-given — which bound the SPELLING of an
address into the digest. Signer ids are EIP-55-checksummed in some paths and
lowercase in others, so identical evidence assembled by two services would
have hashed differently and failed both the packageHash bind and the
receipt.packageDigest bind at first live mint. The gateway already shipped
one EIP-55 casing bug on this seam (#286); this is the guard against a second.
Flagging it rather than silently shipping the literal spec is what surfaced it.
18 tests pass. The byte-exact golden stays a todo: the oracle's input vector
has not landed and the authoritative body schema is evidence's to give. The
BODY fixture is a placeholder for invariants, not a schema claim.
agent: gateway-lane 0600b204
(cherry picked from commit 74422ac)
Ported by implementer-item8 from lamasu/feat/g2-package-digest-v2 onto
feat/g2-package-digest-v2-wired (must-close item 8). Applied cleanly: no
conflicts, no changes to the upstream patch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…G2 Step B) Builds to evidence's pinned wire contract (c25c8f97, shared/vnext-finalmilestonepackage-v2-body-schema.md, 2026-08-26), which answered gateway #1148. Signer set RATIFIED D1/D2 by sol. packageBodyHash = SHA-256(raw32(SIG_DOMAIN_V2) || u64be(len) || JCS(body)) is a DIFFERENT hash from packageDigestV2 with a different pre-image. The operator (secp256k1-eip712) and kernel (ed25519-raw32) sign the former; raw.packageHash must equal the latter. Signing the wrong one produces signatures that verify against nothing, at mint, with funds in escrow — so the two are separate modules with a test asserting they never collide. Validator FAILS CLOSED on every deviation and names the offending path. Notably it REJECTS a JS number wherever the schema says decimal string: those values ride alongside uint256-derived data, JS numbers lose precision above 2^53, and the shared canonicalizer's number serialization is not RFC 8785. Coercing would yield a digest the oracle cannot reproduce. The length prefix is UTF-8 BYTE length, not JS string length. A producer using .length agrees with the oracle on ASCII and diverges silently the first time a principalId carries an accent — there is a test for exactly that pair. isInterimNonce() makes evidence schema §4's open item (challengeBinding.nonce awaits the durable T_lo challenge, gateway's 2nd increment, not built) queryable in code rather than living only in a doc. 35 tests pass across both G2 modules. Goldens remain todo: evidence is re-aligning 3 drifted goldens onto this single body before re-cross-confirming with oracle. agent: gateway-lane 0600b204 (cherry picked from commit 8b6efe3) Ported by implementer-item8 from lamasu/feat/g2-package-digest-v2 onto feat/g2-package-digest-v2-wired (must-close item 8). Applied cleanly: no conflicts, no changes to the upstream patch. Verified on this base (Spark, after the three picks): package-digest-v2 18 passed + 1 todo, final-milestone-package-v2 17 passed + 1 todo (35 passed, 2 todo); gateway tsc --noEmit exit 0 after building the gateway workspace deps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…ce-package-sig:v1), pin evidence's golden The ported producer hashed "PCC:vnext:evidence-package-sig:v2" (0x1e98b1f8...). That value was drift: evidence #1202 / oracle #1414 corrected the wire contract to ":v1" (0x74101076...), and evidence's pinned body schema section 3 now marks :v1 AUTHORITATIVE. The "V2" is the raw32 framing, not the suffix. The correction landed about two hours after the gateway lane's last G2 commit and never reached lamasu/feat/g2-package-digest-v2, so the three cherry-picks carried it. Operator (D1) and kernel (D2) sign packageBodyHash, so a producer on :v2 yields package signatures that verify against nothing. Evidence: over evidence's published body (#1202), the ported computePackageBodyHash returned 0x3896f784..., the exact drift value #1202 names. The same raw32 || u64be(byteLen) || JCS framing with the :v1 domain gives the golden 0x94a48c16..., so only the domain constant was wrong. packageDigestV2 already matched the golden 0xf78103a1... (no domain in it). Tests: the two it.todo goldens are now real, from evidence's integrated settlement vector (settlement-vector-golden-mirror.cjs @ 974b3ff, lamasu/feat/v3-evidence-signing), stored with provenance in __tests__/fixtures/g2-settlement-vector-golden.json: - JCS(body) via @pcc/spec canonicalize is byte-identical to evidence's; - packageBodyHash == 0x94a48c16...; - packageDigestV2 == 0xf78103a1..., also with the signatures reordered and duplicated. The SIG_DOMAIN_V2 test claimed to pin the domain but checked only its hex shape, which is how :v2 passed; it now pins the value and its preimage. Mutation checks: with ":v2" restored, the domain pin and the packageBodyHash golden fail (2 red). With SIGNATURES_KEY = "sigs", the three digest goldens fail while every pre-existing test stays green, so before this commit the suite could not detect a wrong signatures key (the open question the original producer commit flagged). Counts (Spark): package-digest-v2 20/20, final-milestone-package-v2 20/20, gateway tsc --noEmit exit 0. Kept separate from the three cherry-picks so it can be dropped or reworded on its own. No change to D1/D2 schemes or message spaces: both still sign raw32(packageBodyHash); only the domain inside packageBodyHash is corrected to the frozen value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…t-time guard
The evidence schema owner's calls on the ported producer's findings F3-F7:
- Hex in the body is lowercase only. An EIP-55 escrow address otherwise
produced a different packageBodyHash and packageDigestV2 for the same unit.
- validatePackageBody refuses unknown keys at every level instead of silently
dropping them (the digest covered a different object than the caller held),
and refuses empty principal ids, kernelId, tChallengeRef and time bounds.
- assertMintablePackage(body, sigs), the guard a mint must pass before any
digest exists: a valid body, not the interim challenge nonce, and exactly the
frozen signer set -- one D1 (secp256k1, 0x+40-hex address, 65-byte sig) and
one D2 (ed25519, 0x+64-hex key in the registry's form, 64-byte sig), each
with exactly {signer, scheme, sig}. No duplicate, extra, relabelled or
foreign-scheme entry can reach the digest.
canonicalSignatures stays the oracle's canonicalization contract (#1368,
#1395), and every published golden is unchanged (the golden's sample
signature set is rightly not mintable: its ed25519 entry has a 40-hex signer).
packageFormat stays "2" pending the escrow/oracle answer on F2.
Tests: package files 48/48 (8 new); gateway tsc exit 0; full gateway suite
3043 passed / 6 skipped / 1 failed -- the failure is a 5s timeout in
completion-real-tier.test.ts while another suite ran in parallel; that file
passes 3/3 in isolation twice and imports nothing from settlement/. Nine
mutants (hex case, address case, unknown keys, empty ids, interim nonce,
signature count, entry keys, duplicate scheme, D2 signer form) each turn a
test red.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
Owner
Author
|
Added 6e156be. It is the evidence schema owner's call on F3–F7:
Tests: package files 48/48; |
4 tasks done
…binding) into #358 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…the mint guard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…D2 and the kernel registry (pcc.evidence.principal-id.v1) assertMintablePackage now also requires: - operatorPrincipalId = eip155:<unitBinding.chainId>:<the D1 signer's address>, in the pinned lowercase form; - devicePrincipalId = ed25519:<the D2 signer's key>, never a key whose secret is public (the committed pcc-node key); - and that device key is the one the kernel registry holds for producer.kernelId. This is a new required argument, registeredDeviceSigner, compared through principalFromRegistry, so the registry's EIP-55 or hex case never matters. A package can no longer name a principal that its signatures and the registry don't stand behind. Ingestion (validatePackageBody) stays form-agnostic, so the digest goldens are unchanged. The guard has no production caller yet, so the mint path must pass the registry row when it lands. Tests: final-milestone-package-v2 32/32 (4 new; the free-text, wrong-signer, wrong-chain, checksum-case, not-in-registry and leaked-key principals are all refused). Gateway 3065 passed / 6 skipped; tsc clean; spec 868/868. 4 mutants killed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…bels (secp256k1-eip712, ed25519-raw32) The scheme label sits inside packageDigestV2's hashed signature entries, so the producer and every verifier need one string (oracle #3101 item 3). Evidence schema §3 and the packageDigestV2 golden already use "secp256k1-eip712" and "ed25519-raw32". The mint guard required bare "secp256k1" / "ed25519", which do not say raw, EIP-191 or EIP-712. The guard now requires the full labels, and the bare ones are refused as foreign schemes. The principal-id bindings look up D1 and D2 by the full labels. The digest itself is unchanged (it hashes whatever labels it is given), so the goldens are unchanged too. Tests: final-milestone-package-v2 + package-digest-v2 52/52. The accept path uses the full labels; the bare labels, and a D1 label on the D2 key, are refused. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
This was referenced Sep 24, 2026
…cimal Unix seconds, or that run backwards
evidenceTimeBounds.start and .end must be decimal strings of Unix
seconds, as the canonical settlement-vector golden carries them
("1699999500" / "1700000000"; ruling #3567). No sign, no leading zeros,
no fraction or exponent, no RFC 3339, no JSON number, and a safe
integer. start must not be after end. The strings are kept byte-for-byte,
so both goldens and the unit fixture digest exactly as before; the
golden's JCS, body hash and packageDigestV2 tests pass unchanged.
Previously any non-empty string was accepted.
The grammar is the same as spec parseEvidenceTimeBound (#438). gateway
3066 passed and 6 skipped (+1), tsc clean. The mutants that drop the
grammar check or the order check are each killed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the current subject binding Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…ner normalization, kind guard from cross-family E6) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…o the body module PackageSignature, InvalidSignatureEntryError and canonicalSignatures move verbatim from package-digest-v2.ts into final-milestone-package-v2.ts, and the digest module re-exports them, so every existing importer is unchanged. This is preparation for "packageDigestV2 calls validatePackageBody first": the body module already imported canonicalSignatures from the digest module, so that call would have made the two modules import each other. With the helpers on the body module's side the dependency is one way (digest -> body). No behavior change: the 53 tests in the two V2 test files pass unchanged, tsc is clean, and every line removed from the digest module is present in the body module. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…hex by rejection (F5) canonicalSignatures now refuses any signer that is not 0x followed by 40 or 64 lowercase hex digits (an address, or an ed25519 key): uppercase or EIP-55 mixed case, a 0X prefix, no prefix, another width, free text, a non-string. Before, any non-empty string was accepted and quietly lowercased, so the digest depended on a spelling the caller chose and on a repair the evidence mirror does not make. One accepted spelling per signer means one digest per package. The mint guard already pinned each form to its scheme (40 for D1, 64 for D2); this puts the spelling rule on the digest path as well. Scope note: the per-scheme width (D2 = exactly 64) is NOT enforced on the digest path. The published golden's sample signature set labels a 40-digit signer "ed25519", and the golden digest 0xf78103a1... must stay byte-identical, so the digest-path check allows either pinned width for either entry. The mint guard still pins them per scheme. Tests: BAD_SIGNERS (17 spellings) are refused by canonicalSignatures, a re-cased signer is refused by packageDigestV2 instead of being a no-op, and the golden tests are unchanged and green. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…; it refuses, so it agrees with the evidence mirror (F6) Before, canonicalSignatures lowercased every signer it emitted, while evidence's mirror (settlement-vector-golden-mirror.cjs) dedups on the lowercased signer but keeps the entry's own case. On a mixed-case signer the two produced different pre-images, so the producer's digest disagreed with the mirror's for an input the producer accepted. With F5 the producer refuses every signer that is not 0x + lowercase hex, so the lowercasing is the identity on everything it accepts. This removes it: the signer is deduplicated, sorted and emitted exactly as given. Producer, oracle and mirror then agree on every input the producer accepts: the oracle lowercases on its side, the mirror keeps the case it is given, and both see the same lowercase string. The package-signature and digest-module docs say so. The mirror is not touched (it lives in another worktree). Tests: a replica of the mirror's canonicalSigs (dedup on the lowercased signer, entry kept as given) is compared byte for byte, and as a digest pre-image, on every accepted input; the inputs where a lowercasing producer would have diverged from it (a signer whose case the mirror keeps) are the ones now refused. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…efusal: exact keys, no dedup, exactly two entries (F4)
canonicalSignatures used to repair its input, and each repair was a hole:
- an extra key on a signature entry was kept, so it moved the digest without
changing a fact;
- dedup was "first occurrence wins", so a forged duplicate placed ahead of the
real entry was the one that won, silently;
- any number of entries with any schemes was accepted.
It now accepts exactly one shape and only sorts:
- an array of exactly two entries (one per role of the signer set);
- each entry has EXACTLY the keys signer, scheme, sig, all strings (the open
index signature on PackageSignature is gone too);
- no two entries share a signer, and no two share a scheme (the role): a
duplicate is refused wherever it sits, never deduplicated.
Reordering stays a no-op (sorted by signer). The doc in package-digest-v2.ts now
states the contract: the producer refuses what the oracle and the mirror repair,
so on every input it accepts all three agree.
NOT enforced on the digest path, and recorded as such in code and as it.todo: the
scheme NAMES (secp256k1-eip712 / ed25519-raw32) and the signer width per scheme.
The published golden's sample set is labelled "secp256k1" / "ed25519" with a
40-digit "ed25519" signer, and its digest 0xf78103a1... must stay byte-identical.
The mint guard enforces both.
Behavior change worth reviewing: the golden test that fed a duplicated entry and
expected the golden digest now expects a refusal (the mirror's own check still
dedups; the producer accepts a subset of what it accepts and agrees there).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…ase hex is refused instead of binding (F3) validatePackageBody already refuses a checksummed or uppercase hex field, but computePackageBodyHash, packageBodyJcs and packageDigestV2 hashed whatever they were handed. At a189cc6 an EIP-55 escrow address moved both hashes: packageBodyHash 0x94a48c16... -> 0x0a13c9be... packageDigestV2 0xf78103a1... -> 0x70dce8b5... One accepted spelling is what gives one body one digest. Now each of the three (and the digest pre-image) calls validatePackageBody FIRST and hashes only the validated copy. Any hex field that is not 0x + lowercase hex of its exact width (uppercase, EIP-55 mixed case, a 0X prefix, no prefix, the wrong width) is refused with a PackageBodyValidationError that names the field; nothing is lowercased for the caller. The same call closes the digest-side halves of two F7 items: packageDigestV2 no longer hashes a body it never validated (unknown key, a JS number, a missing field), and refuses empty principal ids. The parameter types widen to unknown because validation is now the function's own job. assertCanonicalizable stays as a tripwire on the validated object and is exported so it stays testable. Canonical input hashes exactly as before: the goldens (packageBodyHash 0x94a48c16..., packageDigestV2 0xf78103a1...) are unchanged and the fixture file is untouched. Tests: all 8 hex fields x 4 bad spellings x 3 hashing functions, the EIP-55 case through both hashes, non-conforming bodies through every hasher, and the digest test file now uses a conforming package body instead of a placeholder object. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…and pin the F7 input gaps with tests (F7) F7 listed four input gaps. Reproduced at a189cc6 and handled as follows. 1. validatePackageBody drops unknown keys: NOT reproduced. It already refuses them at every level (6e156be); the existing test stays and the digest and hash paths are now pinned too. 2. packageDigestV2 does not validate its body: reproduced, closed by the previous commit (F3), which makes it call validatePackageBody first. Digest-side tests for an unknown key, a JS number, a missing field and empty ids are there. 3. Empty principal ids are accepted: reproduced on the hash and digest paths (validatePackageBody already refused them; the hashers never called it). Closed by F3. Beyond empty: producer.kernelId must now satisfy #399's isValidKernelId (1-128 printable ASCII, no space, not eip155:/ed25519: in any case), the rule principalTupleWord("kernel", id) hashes under, so every kernel id a package names can sit in a funded authorizedTuples triple. The golden's "kernel-golden-01" satisfies it. 4. isInterimNonce is only a flag: NOT reproduced. assertMintablePackage already refuses the all-zero interim nonce (6e156be) and fails closed until the durable challenge exists. The test is strengthened so it can only pass for that reason (the same body mints with a real nonce, and the message is asserted), and the doc on isInterimNonce says so. NOT applied, and recorded in code and as it.todo: pinning operatorPrincipalId and devicePrincipalId through parseOperatorPrincipalId / parseDevicePrincipalId inside validatePackageBody. The published golden's body carries the free-text ids "op-golden" and "dev-golden" and its hashes (0x94a48c16..., 0xf78103a1...) must stay byte-identical. assertMintablePackage already pins and binds them. Local-environment note: tsc resolves @pcc/spec through its built dist, and a dist built before #399's kernel-id grammar lacks isValidKernelId, so `tsc --noEmit -p packages/gateway` reports TS2724 against such a dist. CI builds every package before it type checks, and gateway typechecks clean against spec's source. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…sh only the copies (read-once) At a189cc6, validatePackageBody already read each of its 24 body fields exactly once (not reproduced; it is now pinned by a test). packageDigestV2 and assertMintablePackage did not: - packageDigestV2 walked the body to validate it (1 read per field) and then canonicalized the same object (2 more), so a field that answered differently the second time moved the digest while the validation had seen the first answer: digest 0xe0551dc9... became 0x67e69e3a... over flipping getters. Each body field was read 3 times, each signer 4 times. - assertMintablePackage read each signature entry's scheme 6-7 times, its signer 7 times and its sig 3 times, the list's length 7 times and its indices 3 and 4 times, and then canonicalized the original objects, so what it validated, what it bound to the principal ids and what it returned could be three different answers. The registry signer's algorithm was read twice for a secp256k1 key, inside #399's normalizer. Now every input is read once into a local copy and only the copies are checked and hashed: - the body goes through validatePackageBody's returned copy (packageDigestV2 and assertMintablePackage from the F3 and F4 commits on, the mint guard here); - one shared reader, copySignatureEntries, reads the list's length once, each index once and each entry's three fields once, and both canonicalSignatures and the mint guard use it (each raising its own error type); - copyRegisteredSigner reads the registry signer's fields once before they reach #399's normalizer, which is not modified. One adjacent defect found while reading, fail-closed already but untyped: the mint guard looked the scheme up in a plain object, so a scheme named "constructor" crashed with a TypeError instead of a PackageNotMintableError. The lookup now uses own keys only. Tests: exact read counts per field for validatePackageBody, the two body hashers, packageDigestV2, canonicalSignatures and assertMintablePackage (so a test cannot pass by never reading a field), getters that answer differently after the first read must give the first answers' result, the signature list's length and index reads through a Proxy, the returned copies must not follow later changes to the caller's inputs, and Object.prototype-named schemes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…roducer
Comments only; no code change (every changed line is a comment).
- package-digest-v2.ts: the divergence note named a function that does not
exist (assertNoFloats); it is assertCanonicalizable, and the note now says a
validated body is all strings so the divergence cannot be reached today.
- final-milestone-package-v2.ts: the signature-entry section header said the
code was "moved unchanged"; it has been rewritten since.
- final-milestone-package-v2.ts: the mint-profile doc said the
packageDigestV2 golden uses the self-describing scheme labels. It does not:
the golden's sample set is labelled "secp256k1" / "ed25519" (with a 40-digit
"ed25519" signer), which is why the profile is enforced by the mint guard and
not by the digest.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…t body fields before hashing (F5) operatorPrincipalId, devicePrincipalId and tChallengeRef were checked only for non-emptiness. The shared canonicalizer's JSON.stringify happily escapes a lone UTF-16 surrogate as "\udXXX" and hashes it, but the private Oracle's jcs() refuses lone surrogates — a gateway-mintable string could be unhashable by the Oracle (cross-family E9, finding 5). Add isWellFormedUnicode() (native String.prototype.isWellFormed with a lone-surrogate regex fallback for Node < 20) and route the three free-text fields through a new freeText() validator that rejects before hashing. Every hashing function already calls validatePackageBody first, so this is enforced everywhere with no other call-site changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…ackageBodyHash); D1 recovery STOPPED (F1) assertMintablePackage checked only scheme-specific regexes for signer and signature length, then returned the entries as mintable — no D2 ed25519 verification occurred, so repeated-byte fakes passed as "real" signatures (cross-family E9, finding 1). D2 is now cryptographically verified: verifyEd25519Signature (reused from gateway/src/auth/ed25519.ts) checks the kernel's signature against raw32(computePackageBodyHash(valid)) — the raw 32 bytes, never the hex string and never packageDigestV2 (which would be circular, since that digest embeds this very signature). Negatives added: random bytes, a real signature over the wrong body, a real signature by the wrong key, and a real signature over the ASCII-hex string instead of raw32. D1 (the operator's EIP-712 signature) is explicitly NOT touched: verifying it needs the authoritative EIP-712 domain + typed-data struct, and a search of public PCC (evidence schema docs, the V-next escrow Solidity, escrow's #367 TS ABI, and the #270 mirror) found only a prose description (domain name/version/chainId/verifyingContract, struct name, field list) — not a byte-exact per-field type definition. Inventing one here would verify against a type space only this file made up, not what the real operator signer or the private Oracle use, which is worse than the honest gap this documents. D1 stays shape-only until the struct is published in public PCC; see the STOP note above MINT_SIGNER_PROFILE and triage-E9-358-fixer-tango.md. Also corrects the doc comment at the old line 545-547: D2 signs raw32(packageBodyHash), not "the package digest". Test fixtures: the 0x22-repeated D2 placeholder is replaced with a real ed25519 signature from a deterministic seed (packages/gateway/src/auth/ ed25519.ts's PKCS8 derivation, test-only). One existing read-once assertion that hardcoded signer sort order ([D1r, D2r]) now computes the expected order instead, since D2r's signer is a real derived key that may sort either side of D1's fake address. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…keyed by producer.kernelId (F2) registeredDeviceSigner was a bare, caller-asserted value with no kernel id and no authenticated registry provenance: assertMintablePackage never used valid.producer.kernelId to perform or authenticate a lookup, so changing the kernelId to a victim's and presenting the attacker's own D2 key as "the registry's answer" still minted (cross-family E9, finding 2). Replace the bare argument with an injected KernelRegistryReader (signerForKernel(kernelId) -> signer | null). The guard now looks up the VALIDATED producer.kernelId and requires the D2 key to equal what comes back; null (no registered signer for this kernel) is refused, never treated as "no binding required". The lookup is async (a real registry is an I/O read), so assertMintablePackage is now async too. New tests: a kernelId renamed to "kernel-victim" is refused when the registry maps it to a different key than the attacker's D2 (the reviewer's exact reproduction); an empty registry (no entry for the claimed kernelId) is refused. All existing assertMintablePackage call sites are converted to await + the new reader shape; behavior and failure points are otherwise unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…n issued record, not just nonzero (F4)
The guard rejected only the all-zero interim placeholder nonce
(isInterimNonce). challengeBinding.nonce is documented as gateway-issued,
but any attacker-chosen NONZERO nonce passed with no issued-challenge
record anywhere (cross-family E9, finding 4).
Add an injected ChallengeReader (recordFor(unitBinding) -> {nonce,
tChallengeRef, state} | null). After the interim-nonce and registry
checks, the guard now requires an ISSUED record for this unit whose nonce
and tChallengeRef equal the package's; "consumed", any other state string,
or no record at all are all refused. The durable challenge issuer is not
built yet (evidence schema §4), so every real caller's recordFor returns
null today and every non-interim package is refused here — correct,
fail-closed behavior for an unbuilt freshness gate, documented as such on
the ChallengeReader interface.
New tests: a nonzero never-issued nonce is refused; a "consumed" record is
refused; a nonce mismatch and a tChallengeRef mismatch against an issued
record are each refused. The read-once suite is extended to cover the
challenge record's fields (one read each, resistant to a getter that
answers differently on a second read), matching the existing discipline
for the body, signatures and registry signer.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…to the money-bound digest (F3)
packageDigestV2 (the lenient, sample-compatible digest) could be called
directly on any conforming body/signature set, with nothing stopping that
set from being one assertMintablePackage would have refused. The digest
path and the mint guard had no enforced seam (cross-family E9, finding
3).
Rename packageDigestV2 -> packageDigestV2Unchecked (the wire VALUE is
still called packageDigestV2 per the oracle's contract; "Unchecked" names
this lenient IMPLEMENTATION, kept for golden/vector tests). Wrap
assertMintablePackage's checks (F1 D2 verification, F2 registry, F4
challenge freshness, body/signature-set validation) in a new
MintablePackage class with a private #brand field and a private
constructor: TypeScript compares classes with a private member nominally,
so a hand-built {body, signatures} object is never assignable to
MintablePackage even though its public shape matches, and nothing outside
the class can construct one at runtime either. assertMintablePackage is
now a thin async wrapper around the class's static assert() method, so
its name and call signature are unchanged for existing callers.
Add mintablePackageDigest(MintablePackage): Hex in package-digest-v2.ts —
the ONLY money-bound digest, gated by an instanceof check backed by the
brand. A structural fake, or a MINTABLE_BODY passed directly, is refused
with PackageNotMintableError.
New tests: mintablePackageDigest hashes a real MintablePackage identically
to packageDigestV2Unchecked over the same body/signatures; it refuses a
hand-built {body, signatures} object (cast past TypeScript) and
null/undefined/plain-object impostors. The full gateway suite (190 files)
passes with no regressions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…ted verifier D1 (the operator's EIP-712 signature) was checked for shape only -- the struct for FinalMilestonePackageV2 is still not pinned anywhere in public PCC, so recovering it here would verify against a type space this file invented. Instead of leaving the shape-only gap open, add an injected OperatorSignatureVerifier (mirrors tango's KernelRegistryReader / ChallengeReader pattern): assertMintablePackage/MintablePackage.assert take it as a new required parameter, called last, after D1/D2 shape, the self-consistency binds, D2's crypto check, F2's registry and F4's challenge freshness all pass. The verifier is trusted for exactly one outcome: - absent (undefined/null): refused, with a message naming the pinned-struct blocker (evidence schema doc Sec.2); - any return other than exactly `true` (false, a truthy non-boolean): refused, nothing coerced; - a thrown/rejected verifier: refused, converted to PackageNotMintableError, never lets the verifier's own error type escape. Today no production verifier exists, so every package is refused -- fails closed by construction. The follow-up is to pin the struct and implement EIP-712 recovery as the verifier, a one-parameter change, not a guard rewrite. Updated every caller (25+ call sites across both test files) and added 8 new tests pinning the fail-closed rules, including that the verifier receives the VALIDATED packageBodyHash/unitBinding/operatorPrincipalId, not anything caller-suppliable. Scope files: 105 passed + 3 todo (was 97+3). Typecheck clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…358 mint guard (round 3) Three findings from the evidence lane's own review of tango's and xray's work (not astra), written directly into this brief after xray correctly declined to act on unverified mid-task messages claiming to be from "the coordinator." All three reproduced against 39f958f first (scratch test file, deleted after use; output saved to /mnt/sparkbulk/tmp/evidence-358-seam-repro-39f958f2.txt, 5/5 assertions failed as predicted), then fixed. 1. The brand check was forgeable. mintablePackageDigest checked `mintable instanceof MintablePackage`, and two things passed it without the private constructor ever running: Object.create(MintablePackage. prototype) carrying its own body/signatures, and Object.defineProperty(MintablePackage, Symbol.hasInstance, { value: () => true }). Fixed with a static MintablePackage.isMintable(x): x is MintablePackage using the ES2022 ergonomic brand check, `#brand in x` -- it does not walk a prototype chain and does not consult Symbol.hasInstance, so neither forgery route reaches it. mintablePackageDigest now calls isMintable, never instanceof. 2. The freeze was shallow. The constructor did Object.freeze(body) and Object.freeze(signatures) -- top level only. body.unitBinding, body.producer, and each signature entry stayed mutable after a successful assert. Added a deepFreeze helper over Object.freeze / Object.isFrozen / Object.keys captured at MODULE LOAD, so a caller that monkey-patches those globals into no-ops after load cannot turn this module's freeze into one; the constructor now deep-freezes both body and signatures through it. 3. The validated body was handed to injected readers, and awaited on, before it was frozen. MintablePackage.assert passed valid.unitBinding to challenges.recordFor(...) and to the D1 verifier's input, then awaited the registry, the challenge reader and the verifier before the constructor ever froze anything -- a TOCTOU window where either reader, or anything racing their awaits, could change what was validated. Fixed by deep-freezing `valid` immediately after validatePackageBody returns, before the interim-nonce check and before any of the three injected calls. Added 9 permanent regression tests (3 for finding 1 in package-digest-v2.test.ts; 4 for finding 2 + 2 for finding 3 in final-milestone-package-v2.test.ts), including one that sabotages the global Object.freeze mid-test to confirm the captured-intrinsics design actually matters, and one proving the frozen unitBinding reaches the D1 verifier too (the brief asked to pass the frozen value "to the readers and the verifier"). Scope files (final-milestone-package-v2.test.ts + package-digest-v2.test.ts): 105 passed + 3 todo -> 114 passed + 3 todo (117 total), 0 failures. Whole gateway suite: 3118 passed -> 3126 passed / 6 skipped / 3 todo, 0 failures attributable to this change; one unrelated test (completion-real-tier.test.ts, a different subsystem) timed out once under full 190-file load and passed cleanly 3/3 times in isolation (<500ms each) -- a load flake per the brief's own rerun protocol, not a regression. tsc --noEmit -p packages/gateway clean. All three findings' source edits landed in one commit: the isMintable method, the class doc rewrite, the deepFreeze helper, and the early-freeze call in assert() are interleaved in the same class body, and splitting them after the fact would have meant reconstructing hunks rather than reviewing an honest diff. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…, and a challenge mints once (E9b CRITICAL + HIGH) Astra's E9b (on 425c0d3) was DO-NOT-SHIP. Both findings were reproduced at 425c0d3 before any change: 5 failing tests, /mnt/sparkbulk/tmp/evidence-358-e9b-repro-425c0d39.txt. CRITICAL: the mint seam could be manufactured. - `private constructor` is compile-time only: Reflect.construct and `new (MintablePackage as any)` built a genuinely #brand-ed instance with no checks, and mintablePackageDigest accepted it. - The static isMintable could be replaced. - Now: - the constructor refuses unless handed a module-private symbol token; - assert adds each package it returns to a module-private WeakSet; - isMintablePackage (what the digest calls) asks only that set, through WeakSet methods captured at load; - the class and its prototype are frozen. - The guarded digest returns its own branded type, MintablePackageDigest. The sample-compatible packageDigestV2Unchecked moves to package-digest-v2-vectors.ts, and a source scan fails the build if any production module imports it. HIGH: the challenge was checked but never consumed, so two assertions could both mint with it. - ChallengeReader gains consumeIssued: an atomic, one-use compare-and-set from "issued" to "consumed". - The guard calls it LAST, after every other check, so a package refused for another reason never burns the challenge. Only an exact `true` mints; false, a non-true value, a throw, or a missing method all refuse. Tests: - astra's reproductions (Reflect.construct, `new`, a replaced isMintable, concurrent and sequential replay); - with a one-use store, exactly one of two concurrent assertions mints; - non-true or missing consumes refuse; - the consume comes last (a D1 refusal does not consume); - minted packages are in the registry, and the class is frozen; - the source scan. Test readers that are about other properties get a consume that succeeds. Gateway scope tests: 124 passed, 3 todo. tsc is clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
…master (steward #6167) A plain merge: the tree is exactly git merge-tree of 5b2be32 and cd9d877, with no other change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu
marked this pull request as ready for review
October 4, 2026 02:22
LamaSu
added a commit
that referenced
this pull request
Oct 4, 2026
…ster, for a fresh full CI run (steward #6167) A plain merge: its tree equals git merge-tree of 2a3be66 (E13e SHIP) and master. #555 was stacked on #358 and now targets master. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
LamaSu
added a commit
that referenced
this pull request
Oct 6, 2026
feat(gateway): the production D1 (operator) EIP-712 verifier for the FinalMilestonePackageV2 mint guard (stacked on #358)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Update 2026-10-02 @adfa2695: the known producer gaps (F3-F7) are closed before the first cross-family review
0x+ lowercase hex.packageDigestV2validates its body first. Empty principal ids are refused.producer.kernelIdfollows feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) #399'sisValidKernelId.packageDigestV2andassertMintablePackageread each input once and hash only the copies.assertMintablePackage) enforces all three before anything is minted.0x94a48c16…and0xf78103a1…. A differential over 600 canonical packages gives identical hashes, and all 1,800 non-canonical variants are refused.🤖 Generated with Claude Code
https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst
Update 2026-09-24 @05a4c3af
0a4836a6) and feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) #399 (the pinned principal ids,c4cc8f8e).assertMintablePackage(body, sigs, registeredDeviceSigner)now requires:operatorPrincipalId=eip155:<unitBinding.chainId>:<the D1 signer>;devicePrincipalId=ed25519:<the D2 signer>, never a key whose secret is public;producer.kernelId. The registry signer is a required argument, compared throughprincipalFromRegistry.secp256k1-eip712(D1) anded25519-raw32(D2) that schema §3 uses. (Correction 2026-10-02: the digest golden does NOT use them. It is evidence's sample vector, with baresecp256k1/ed25519labels; see the update above.) Bare labels are refused. The digest and the goldens are unchanged.What this is (technical pack §3, must-close 8 — PARTIAL)
This ports the FinalMilestonePackageV2 /
packageDigestV2producer onto current master by cherry-pick, not merge. The source isfeat/g2-package-digest-v2(3 ahead / 86 behind):ac0063a3→cc217a7f,74422ac6→f679f208,8b6efe39→52d17a7c. There were no conflicts; the patches are byte-identical to upstream.It also fixes a money-path bug found while porting (
0e6b3cb5):SIG_DOMAIN_V2hashed"PCC:vnext:evidence-package-sig:v2"(0x1e98b1f8…). The wire contract is:v1(0x74101076…), as corrected by the evidence and oracle lanes before this branch's last commit.:v2makes operator and kernel signatures that verify against nothing.:v1,packageBodyHashreproduces evidence's golden0x94a48c16andpackageDigestV2reproduces0xf78103a1byte-exact.:v2got through. The value is now pinned.Why it is not wired yet
Wiring needs one call site where every body field is available. The field census is at
/mnt/sparkbulk/pcc-reconciliation/returns/pcc-evidence-work/item8-g2-census.md(field →file:line). Of 18 fields, 12 exist nowhere in public PCC:escrow,settlementUnitId,milestoneIndex,stepId,compositionRoot,compositionSchemaVersion, fundedacceptedEnvelopeHash(the V-next escrow is not deployed or read anywhere)operatorPrincipalId,devicePrincipalId(no principal-id concept)challengeBinding(the durable T_lo challenge isn't built)evidenceBlockHash(no producer on master)Also missing: D1/D2 package signers, a production receipt signer (key custody is still open), and a
/settleclient. The only oracle client isPOST /verify, and it has nopackageHash.Under the fail-closed rule, nothing is minted with invented values.
Open findings (for the owning lanes)
EVIDENCE_PACKAGE_FORMAT_V1 = 1(VNextSettlementLib.sol:169), while the evidence schema usespackageFormat = "2". If the attester mirrors2, every V2 release revertsEvidenceBundleMismatch.0x-hex ed25519 key.validatePackageBodydrops unknown keys;packageDigestV2doesn't validate its body;isInterimNonceis only a flag.Signing profiles
The package D2 kernel signature is ed25519 over the raw 32 bytes of the
0xpackage digest. That is a frozen profile and a different message space from evidence digests, which use the 71-byte UTF-8 ofsha256:<hex>(LO-EV-1, #338). Their lengths differ, so they can't collide. This PR does not unify them.Tests
tsc --noEmit: exit 0.:v2turns 2 red;SIGNATURES_KEY = "sigs"turns 3 red.Stacked on #341. Ported by implementer-item8, reviewed by the evidence lane.
🤖 Generated with Claude Code
https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS