Skip to content

feat(gateway): port the FinalMilestonePackageV2 producer and fix its signature domain (item 8, not yet wired) - #358

Merged
LamaSu merged 31 commits into
masterfrom
feat/g2-package-digest-v2-wired
Oct 4, 2026
Merged

LamaSu merged 31 commits into
masterfrom
feat/g2-package-digest-v2-wired

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Update 2026-10-02 @adfa2695: the known producer gaps (F3-F7) are closed before the first cross-family review

  • F3: hex binds in exactly one spelling. Non-lowercase hex is refused (typed), never lowercased.
  • F4: a signature set is exactly 2 entries with exact keys. Duplicates are refused (no more first-wins dedup), and no two entries share a role.
  • F5: a signer is 0x + lowercase hex.
  • F6: the producer refuses non-canonical signers instead of lowercasing them, so it agrees with the mirror on every input it accepts.
  • F7: packageDigestV2 validates its body first. Empty principal ids are refused. producer.kernelId follows feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) #399's isValidKernelId.
  • Read-once: packageDigestV2 and assertMintablePackage read each input once and hash only the copies.
  • Boundary, stated on purpose. The digest function stays compatible with evidence's SAMPLE golden. That golden uses bare scheme labels, a 40-hex "ed25519" signer and free-text principal ids. So the digest path does not enforce scheme names, the per-scheme signer width, or principal-id forms. The mint guard (assertMintablePackage) enforces all three before anything is minted.
  • Goldens byte-identical: 0x94a48c16… and 0xf78103a1…. A differential over 600 canonical packages gives identical hashes, and all 1,800 non-canonical variants are refused.
  • Tests: gateway 3096 passed / 6 skipped / 3 todo; 22 mutants killed.
  • Base: this branch merges feat(evidence): LO-EV-9 bind device evidence to the accepted job and kernel before settlement #341 @799cea1d and feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) #399 @c029f994.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VGNHoFFhbAdeNBc4BWigst


Update 2026-09-24 @05a4c3af

  • Stack kept current. It merges feat(evidence): LO-EV-9 bind device evidence to the accepted job and kernel before settlement #341 (now 0a4836a6) and feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) #399 (the pinned principal ids, c4cc8f8e).
  • Principal binding in the mint guard. 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;
    • that key to be the one the kernel registry holds for producer.kernelId. The registry signer is a required argument, compared through principalFromRegistry.
  • Scheme labels (oracle #3101 item 3). The mint guard requires the self-describing secp256k1-eip712 (D1) and ed25519-raw32 (D2) that schema §3 uses. (Correction 2026-10-02: the digest golden does NOT use them. It is evidence's sample vector, with bare secp256k1/ed25519 labels; see the update above.) Bare labels are refused. The digest and the goldens are unchanged.
  • Tests: final-milestone-package-v2 plus package-digest-v2 pass 52/52 (8 new). Gateway 3065 passed / 6 skipped; tsc clean; spec 872/872. 4 principal mutants killed.

What this is (technical pack §3, must-close 8 — PARTIAL)

This ports the FinalMilestonePackageV2 / packageDigestV2 producer onto current master by cherry-pick, not merge. The source is feat/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_V2 hashed "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.
  • A producer on :v2 makes operator and kernel signatures that verify against nothing.
  • With :v1, packageBodyHash reproduces evidence's golden 0x94a48c16 and packageDigestV2 reproduces 0xf78103a1 byte-exact.
  • The old test checked only the format of the hex value, which is how :v2 got 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:

Missing Owner
escrow, settlementUnitId, milestoneIndex, stepId, compositionRoot, compositionSchemaVersion, funded acceptedEnvelopeHash (the V-next escrow is not deployed or read anywhere) escrow + gateway
operatorPrincipalId, devicePrincipalId (no principal-id concept) evidence (format) + gateway
challengeBinding (the durable T_lo challenge isn't built) gateway
evidenceBlockHash (no producer on master) evidence

Also missing: D1/D2 package signers, a production receipt signer (key custody is still open), and a /settle client. The only oracle client is POST /verify, and it has no packageHash.

Under the fail-closed rule, nothing is minted with invented values.

Open findings (for the owning lanes)

  • F2, money path. The escrow commits EVIDENCE_PACKAGE_FORMAT_V1 = 1 (VNextSettlementLib.sol:169), while the evidence schema uses packageFormat = "2". If the attester mirrors 2, every V2 release reverts EvidenceBundleMismatch.
  • F3. Mixed-case hex binds. An EIP-55 escrow address, for example, moves both hashes.
  • F4. The malleability closure is partial:
    • an extra key on a signature entry moves the digest;
    • a duplicate placed before the real entry wins under first-wins dedup;
    • the signer set and scheme names are unenforced.
  • F5. Lowercasing is only safe for hex signer ids. The D2 signer should be pinned as a 0x-hex ed25519 key.
  • F6. The evidence mirror keeps signer case while the producer lowercases it.
  • F7. Several input gaps:
    • validatePackageBody drops unknown keys;
    • packageDigestV2 doesn't validate its body;
    • empty principal ids are accepted;
    • isInterimNonce is only a flag.

Signing profiles

The package D2 kernel signature is ed25519 over the raw 32 bytes of the 0x package digest. That is a frozen profile and a different message space from evidence digests, which use the 71-byte UTF-8 of sha256:<hex> (LO-EV-1, #338). Their lengths differ, so they can't collide. This PR does not unify them.

Tests

  • Ported files: 40/40 (previously 35 + 2 todo).
  • Full gateway suite: 190 files, 3036 passed / 6 skipped / 0 failed.
  • Gateway tsc --noEmit: exit 0.
  • Mutations: restoring :v2 turns 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

LamaSu and others added 4 commits September 24, 2026 02:59
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
@LamaSu

LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Added 6e156be. It is the evidence schema owner's call on F3–F7:

  • lowercase-only hex in the body;
  • unknown keys refused at every level;
  • empty identifiers refused;
  • a new assertMintablePackage(body, sigs) guard. It refuses the interim nonce and anything but exactly one D1 (secp256k1, 0x + 40-hex address) and one D2 (ed25519, 0x + 64-hex key) signature with exactly {signer, scheme, sig}.

canonicalSignatures and every published golden are unchanged. packageFormat stays "2" pending F2.

Tests: package files 48/48; tsc 0; full gateway 3043/6 skipped/1 timeout (a contention flake in completion-real-tier.test.ts, which passes 3/3 in isolation). Nine mutants, all red.

LamaSu and others added 6 commits September 24, 2026 15:44
…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
LamaSu and others added 14 commits September 28, 2026 20:41
…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
LamaSu and others added 5 commits October 2, 2026 19:58
…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
LamaSu marked this pull request as ready for review October 4, 2026 02:22
@LamaSu
LamaSu merged commit 52239f4 into master Oct 4, 2026
9 checks passed
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant