Repository navigation
feat(spec): pin FinalMilestonePackageV2 principal ids (pcc.evidence.principal-id.v1) - #399
Merged
Merged
Conversation
…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
LamaSu
added a commit
that referenced
this pull request
Sep 24, 2026
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
This was referenced Sep 24, 2026
feat(spec): kernel signing-key registry snapshot + ident.registered_key PrimitiveVerifier (N19)
#416
Draft
…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
marked this pull request as ready for review
October 3, 2026 22:16
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
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.
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.
operatorPrincipalIdeip155:<chainId>:0x<40 lowercase hex>(CAIP-10)devicePrincipalIded25519:0x<64 lowercase hex>operatorPrincipalMatchesSigner/devicePrincipalMatchesSignerimplement the D1/D2 bindings. The signer may be given in any case.principalFromRegistryis 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_KEYSholds 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:
assertMintablePackageapplies the D1/D2 bindings.Test plan
tscclean🤖 Generated with Claude Code
https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS