Skip to content

feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) - #399

Merged
LamaSu merged 7 commits into
masterfrom
feat/evidence-principal-ids
Oct 3, 2026
Merged

LamaSu merged 7 commits into
masterfrom
feat/evidence-principal-ids

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Summary

This pins the two FinalMilestonePackageV2 producer fields that public PCC had no concept of. The 24h objection window has passed: gateway and the steward had no objection, and nobody else objected.

id form bound to
operatorPrincipalId eip155:<chainId>:0x<40 lowercase hex> (CAIP-10) the D1 (secp256k1 EIP-712) signer's address
devicePrincipalId ed25519:0x<64 lowercase hex> the D2 (ed25519) signer; the kernel registry must hold it for the producing kernel
  • Lowercase-pinned, so the package digest never drifts on hex case. Parsing accepts only the exact form.
  • operatorPrincipalMatchesSigner / devicePrincipalMatchesSigner implement the D1/D2 bindings. The signer may be given in any case.
  • principalFromRegistry is the one way to compare a registry signer. The registry stores secp256k1 addresses EIP-55 checksummed, and this helper lowercases them. The CAIP-10 form is the owner-address form key bindings use (N2).
  • COMPROMISED_DEVICE_PUBLIC_KEYS holds the pcc-node key pair committed to the public repository (N35). It uses the same fingerprint as pcc-node's denylist on fix(pcc-node): verify_signature fails closed; never default to a key file in the checkout (N35b) #374. That key can never be formatted as a device principal, bound to D2, or read out of the registry.

Consumers, which follow separately:

Test plan

🤖 Generated with Claude Code

https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS

…a checked signature (pcc.evidence.principal-id.v1)

Pinned after the 24h objection window: gateway and the steward had no
objection, and nobody else objected.

- operatorPrincipalId = "eip155:<chainId>:0x<40 lowercase hex>" (CAIP-10).
  Its address must equal the D1 (secp256k1 EIP-712) signer.
- devicePrincipalId = "ed25519:0x<64 lowercase hex>". Its key must equal the
  D2 (ed25519) signer, and the kernel registry must hold that key for the
  producing kernel.
- Both are lowercase-pinned, so the package digest never drifts on case.
  Parsing accepts only the exact form: no checksum case, leading-zero
  chains, whitespace or missing 0x.
- operatorPrincipalMatchesSigner / devicePrincipalMatchesSigner implement the
  D1/D2 bindings, taking the signer in any hex case.
- principalFromRegistry is the single way to compare a registry signer. It
  lowercases the registry's EIP-55 addresses, as gateway asked, so nobody
  compares raw. The CAIP-10 form is the same owner-address form key bindings
  use (N2): one form, not two.
- COMPROMISED_DEVICE_PUBLIC_KEYS holds the pcc-node key pair committed to the
  public repository (N35), pinned by the same fingerprint as pcc-node's
  denylist. It can never be formatted, bound to D2, or read out of the
  registry as a device principal.

Tests: principal-id 10/10; spec 807/807; tsc clean. 7 mutants killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
LamaSu added a commit that referenced this pull request Sep 24, 2026
…the mint guard

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…cipal id (oracle #3101 item 4)

A funded authorizedTuples entry is an (operator, kernel, device) triple of
bytes32. Each word is keccak256 of the UTF-8 of the pinned id string:
- operator = keccak256("eip155:<chain>:0x<addr>");
- kernel = keccak256(kernelId);
- device = keccak256("ed25519:0x<key>").
The scheme prefix inside the hashed string keeps the kinds apart.

principalTupleWord refuses ids that are not the pinned forms, an empty
kernel id, and a device key whose secret is public. authorizedTuple builds
the triple in order. The module header now records that the device
principal's key is the kernel's registered Ed25519 key: the one that signs
the LO-EV-1 delegation (parentSignature) and D2.

Tests: principal-id 12/12, pinned to the well-known keccak256("a") vector
(so it is Keccak-256, not SHA3-256). Spec 809/809; tsc clean. 4 mutants
killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
…ror by value (J1)

The oracle mirrors principalTupleWord by value once the vectors are public
(#3190). principal-id.vectors.json pins:
- five words: two operators on different chains, two kernels (one is the
  keccak256("a") vector), one device;
- one full (operator, kernel, device) tuple;
- four refused ids: checksum case, a leading-zero chain, a missing 0x, an
  empty kernel.
principal-id.test.ts reproduces every entry.

Tests: principal-id 14/14; spec 811/811; tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS
LamaSu and others added 4 commits October 1, 2026 01:12
…pleWord (E6 F2)

principalTupleWord("kernel", id) hashed any non-empty string, so two things
broke the one-principal/one-word property on the funded authorizedTuples path:

- TextEncoder rewrites a lone UTF-16 surrogate to U+FFFD, so distinct kernel
  ids shared a word (a lone surrogate, U+D800, and U+FFFD hash to the same
  bytes).
- The kind is not in the hash preimage, so a kernel id equal to a valid
  operator or device id string produced that principal's word.

Reserve namespaces instead of changing the preimage. A kernel id must be 1-128
printable ASCII characters with no space (the id rule of the accepted deal's
parser, ID_PATTERN) and must not start with eip155: or ed25519:, compared ASCII
case-insensitively. ASCII makes UTF-8 injective, and the reserved prefixes make
a kernel string unequal to every operator or device id string, so the three
kinds' words cannot collide. principalTupleWord("kernel", id) throws
PrincipalIdError otherwise.

Every valid operator, device and kernel id keeps its word byte for byte, so no
contract version bump or re-mirror of the positive vectors is needed. Kernel-id
refusal vectors are added to principal-id.vectors.json (additions only); the
existing vectors test already consumes that list.

The funding caller must still authenticate the exact kernel registry row the id
names; principalTupleWord only hashes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
…e registry's normalization (E6 F3)

principalFromRegistry accepted a signer with a 0X prefix (the registry's
normalizeRegisteredSigner takes 0x, 0X or no prefix, in any hex case) and
produced a principal id, but the D1 and D2 binding functions read the same
signer with their own stricter regexes (lowercase 0x only for an address, 0x or
none for a key). The binding refused a signer the registry had accepted. An
unprefixed 40-hex address disagreed the same way for D1. The denylist predicate
had the same gap: a 0X spelling of the leaked key read as not compromised. That
one was still refused downstream (the formatter rejected the spelling as
malformed), so it failed closed rather than bypassing the denylist.

Every signer or key input in the module (D1, D2, the denylist lookup and both
formatters) now goes through one reader per key family, each a thin wrapper over
the registry's own normalizeRegisteredSigner, so the two cannot drift again.
registered-signer.ts is unchanged.

Principal ids stay lowercase-pinned: parse* still refuses 0X inside an id, and
formatted ids contain the lowercase form only. The denylist holds under the
shared reading: isCompromisedDevicePublicKey("0X" + leaked) is true, and
formatDevicePrincipalId("0X" + leaked) throws for the denylist reason.

Behaviour change to note: like the registry, formatOperatorPrincipalId and the
D1 binding now also accept an unprefixed 40-hex address and a 0X prefix. Their
output is unchanged and the same 20 address bytes are named either way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… the F2 and F3 fixes (E6)

F1 (pinned, did not reproduce). The reviewer read the end anchor as Perl or
Python do. In ECMAScript, without the m flag, $ matches only at the very end of
the input, so every anchored pattern already refuses a final LF, CR, CRLF,
U+2028 and U+2029. The tests pin that on every entry point: both parsers, both
formatters (the compromised key plus a terminator included), both D1/D2
bindings (terminator in the id and in the signer), the denylist predicate and
principalTupleWord for operator and device ids. No source change.

F2. The kernel id grammar equals the accepted deal's ID_PATTERN; the reserved
eip155: and ed25519: namespaces are the real id prefixes and are refused in any
ASCII case; no pinned operator or device id is a valid kernel id across chains,
addresses and keys; the reviewer's lone-surrogate collision is refused; each
grammar refusal (space, tab, terminators, NUL, DEL, non-ASCII, NFD, astral,
lone surrogates, U+FFFD, 129 characters) is covered; boundary cases and
look-alikes keep keccak256 of their UTF-8 byte for byte; every kernel id format
the repo actually uses (db seeds, gateway-minted shapes, fixtures) is still
accepted; and the vectors agree with the grammar.

F3. A 0X signer binds under D1 and D2 as the registry accepts it, on every
spelling (0x, 0X, no prefix, lower, upper, EIP-55), checked by hand-picked cases
and by 400 generated spellings per family: the registry accepts a signer exactly
when the binding does. Ids stay lowercase-pinned, and the denylist holds under
the shared normalization for every spelling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
… and device

Found by fixer-kilo while fixing E6, outside the verdict: a kind outside
the three skipped every check and hashed any string, so
principalTupleWord("bogus", operatorId) returned that operator's word.
TypeScript's union does not bind a JavaScript caller. One guard, one test
over eight bad kinds; the guard's mutant turns the test red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qb6kQhDYRwUDd6AVes3Fwx
LamaSu added a commit that referenced this pull request Oct 1, 2026
…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
LamaSu added a commit that referenced this pull request Oct 3, 2026
…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
LamaSu added a commit that referenced this pull request Oct 3, 2026
…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
LamaSu added a commit that referenced this pull request Oct 3, 2026
…d #5835)

#399 (MERGE NOW #13) and #336 both add one export after eligibility.js in
packages/spec/src/evidence/index.ts: kept both, #336's measurement-profile
then #399's principal-id (tsc: no export collides). #399's other three files
merge as they are.
@LamaSu
LamaSu marked this pull request as ready for review October 3, 2026 22:16
@LamaSu
LamaSu merged commit afcb6a8 into master Oct 3, 2026
8 checks passed
LamaSu added a commit that referenced this pull request Oct 7, 2026
…nce-principal-ids) has merged

Clean merge, no conflicts. Retargeted to master; E2e (SHIP @525b6851) reviewed #416's own changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116fiVRS9gDE1HrCECyjVR9
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