Repository navigation
feat!: key identities by circuit-proved hashes, never plaintext - #87
Open
SupremaLex wants to merge 34 commits into
Open
SupremaLex wants to merge 34 commits into
SupremaLex wants to merge 34 commits into
Conversation
A binding no longer puts its user id, handle or email on chain. Each
platform's circuit outputs two keys, and the registry stores those:
idNode = SHA256("libid.<platform>.user-id" || id)
handleNode = SHA256("libid.<platform>.handle" || fold(handle))
Verifiers. VerifiedClaim carries idNode and handleNode instead of strings.
TlsNotaryVerifierBase no longer reads the id and handle: the identity
response reveals only their anchors, and the verifier locates each value's
commitment by them (requireFramedCommitment; requireFramedInteger for
GitHub's bare id), then verifies the per-platform circuit over 72 public
inputs -- the bearer commitments, the id and handle commitments, and the
two nodes. GooglePlatformVerifier reads both nodes from oidc-google's public
inputs. A payload may carry a handle to disclose: the Platform Verifier
normalizes it with the platform's rules, requires it to hash to the node its
proof bound (HandleNotProved), and returns it normalized.
Registry. Keyed by the verifiers' nodes, with no preimages stored.
IdentityBound carries no plaintext. A disclosed handle becomes the wallet's
name (HandlePublished); publish(platformId, handle) discloses later, held to
a node the caller holds (NotYourHandle); unpublish clears the name. bind
takes (platformId, version, payload). A platform's rules and handle tag are
set by setPlatform and frozen at its first binding (PlatformFrozen).
resolveId takes an id node; resolveHandleAndId agrees only on the handle's
platform.
Rules. handles.json gains per-platform tags, id rules, and handle and id
nodes computed independently with hashlib. Normalization refuses rather
than trims: no space trimming, no @ stripping. The generator emits Solidity,
Rust, TypeScript and, with --noir-out, the circuits' Noir table, each with
the table's SHA-256, and refuses a stored node it cannot reproduce.
libid-identity gains id rules and, behind the `node` feature, id and handle
node helpers; @libid/contracts computes the same nodes locally.
Escrow. deposit(platformId, handleNode, ...) takes the node itself.
Circuits. One Honk verifier per platform: BearerLinkX, BearerLinkGithub,
OidcGoogle. vendor-circuit-verifiers.sh --local takes them from an
unreleased libid-circuits build, refusing one built from another
handles.json; circuits.json pins no release for the two new circuits yet.
Fixtures are libid-rs' ceremony_fixtures records (X handle Alice_1, GitHub
login OctoCat), signed over the registry's transaction triple, with real bb
proofs of their witnesses. The *-ceremony-real.json captures still reveal
the values and their tests are skipped until a recapture.
BREAKING CHANGE: VerifiedClaim, the TLSNotary and Google payloads,
IdentityRegistry's bind, setPlatform, resolveId, resolveHandleAndId,
identitiesOf and IdentityBound, HandleEscrow.deposit, HandleNormalizer.Rules
and the circuit verifiers all change shape. A fresh deployment; the storage
layout is not upgrade-compatible.
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The circuits that key a binding carry their platform's handle rules and tags, so the registry no longer holds a copy an owner could set. Which platforms exist, and their rules and tags, are HandleVectors' generated constants, the same handles.json the circuits are built from. Registry: - setPlatform, PlatformConfigured, the Platform struct and the platforms mapping are removed, and with them PlatformFrozen and EmptyHandleTag. A platform is known when HandleVectors.knows(platformId); every entry point reverts UnknownPlatform for one it does not know. - Enabling a platform is registering its verifier at the Proof Verifier; withdrawing one is retiring it. Resolvers keep answering UnknownPlatform for a known platform that has never bound and cannot verify now. - rulesOf, handleTagOf and handleNodeOf are pure. - The storage namespace stays libid.storage.IdentityRegistry, but the layout drops a field, so this ships as a fresh deployment; the layout snapshot is rewritten for it. Escrow: deposit(handleNode, token, amount, refundTo). The node names its platform through its tag, so a deposit names no platform of its own; Deposited and Forwarded lose platformId, PlatformAcceptsNoBindings is gone, and initialize probes only handleBinding. A node on a platform that cannot bind escrows and is refundable. Handles: the rules move to contracts/handles. The generator emits knows(), refuses overlapping tags, and checks a circuits release's handles-table.nr (--compare-noir), which vendor-circuit-verifiers.sh now requires; circuits.json pins no release yet. Verifiers check a disclosed handle before the proof. Deploy.s.sol configures no platform. The Rust and TypeScript bindings, the anvil suite and the docs follow; libid-identity's README examples run as doctests. The claim-verification gas snapshot moves with the disclosure check (FOUNDRY_PROFILE=gas, osaka). BREAKING CHANGE: IdentityRegistry.setPlatform, PlatformConfigured, PlatformFrozen and EmptyHandleTag are removed; HandleEscrow.deposit drops its platformId argument and its events drop platformId; the registry's storage layout changes and needs a fresh deployment. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
No verifier reads an id or handle out of a JSON body any more: the framing checks locate a commitment by its anchors instead. So tryNormalizedJsonString, tryNormalizedJsonInteger, the Found enum, its _findUnique helper and the BadIntegerTerminator and NoncanonicalInteger errors go. normalizeJsonBytes and occurrences stay, for CeremonyAttestation's framing; AmbiguousField and FieldNotFound stay, for the form reader. The JSON whitespace tests now assert normalizeJsonBytes' output and the occurrence count the framing reads, instead of going through the removed readers. The readers' differential tests and their reference copies go with them. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…erently The reference requireFramedCommitment and requireFramedInteger were the production code in byte loops: the same walk over the range list, the same normalize-then-compare-the-tail. A differential test of two copies of one algorithm cannot catch the algorithm being wrong. The reference now rebuilds the transcript as a byte map -- each offset revealed (with its byte and range), committed or unknown -- and reads the rule off the map: the anchor range found by walking back from the commitment's start, the suffix as revealed bytes after its end, the integer terminator by a forward scan of the range that starts there, and the prefix count by a matcher of its own. Its JSON normalizer streams, holding a whitespace run until the next byte decides it, where production looks ahead. test_theFramingGeneratorsAcceptOften walks 300 seeds and asserts each framing and each whole identity session is accepted often enough that the fuzzing compares acceptances, not only two refusals: 224 framed commitments, 149 framed integers, 189 framed handles, 67 X and 56 GitHub identity sessions. Two production mutants (comparing the anchor without removing whitespace; taking `]` as an integer terminator) each fail the framing fuzz within ten runs. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…egistry RealProofBind covered X only. The checks move to an abstract base each platform fills with its fixture, the plaintext it proves and the nodes Python's hashlib computes for them, so GitHub (github-ceremony-session through the vendored bearer-link-github verifier) and Google (google-ceremony-proof through oidc-google, under a root list trusting the fixture's modulus) run the same ones as X: - a private bind leaves no id, handle or address bytes in the payload, the logs, or any storage slot it wrote; - a handle disclosed in the payload is stored folded and announced; - a disclosure the proof did not bind reverts HandleNotProved. Each also checks that the nodes it names are the proof's own outputs, and Google that a private payload naming another handle node fails the Honk verifier's sumcheck. Bind gas under cancun: X 1,421,087, GitHub 1,484,345, Google 1,054,456. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
A bind reverts with whatever the contract on its route that refused it raised, and the Platform Verifiers' errors were in no binding. Rust: the TLSNotary and Google Platform Verifier bindings bind every error `verify` can raise -- HandleNotProved, NoFramedCommitment, AmbiguousFraming, the normalizer's EmptyHandle, HandleTooLong, BadCharacter and BadShape, and the rest -- and are held to the drift check against the X, GitHub and Google artifacts. One interface serves X and GitHub, so the check takes the items only a sibling has (WrongGrantType is X's alone). TypeScript: codegen emits bindErrorsAbi, the errors of the registry, the Proof Verifier, the three Platform Verifiers and the Notary Service, each signature once, read from their artifacts. The Honk verifiers are not in it: bb's generated code reverts from assembly with selectors no ABI declares, so their failures (SumcheckFailed and the like) still do not decode by name. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The README described the plaintext registry: a four-argument bind with a publish flag, a normalizer that trims and strips `@`, handleHash, and identities listed with their id and handle. It now shows bind(platformId, version, payload) with the handle disclosed in the payload, decoding a refused bind with bindErrorsAbi, normalize refusing ' @Alice_1 ', handleNode(platformKey, raw), idNode and checkId, and identitiesOf returning nodes. The local examples were run against the package. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
… source repo; decodable unknown platform Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…nown platforms everywhere bind hashes the handle a Platform Verifier returns under the platform's tag and refuses one that does not name the returned handle node (DisclosureMismatch), before the name slot is written. The registry owns that slot, so it does not rest on the verifier's own check. unpublish and publishedHandleOf revert UnknownPlatform for a platform handles.json does not name, like every other entry point that takes a platform. publishedHandleOf returned "" for an unknown platform with nothing published and reverted with HandleVectors' error otherwise. IIdentityRegistry declares only handleBinding, the one call HandleEscrow makes. handleNodeOf, acceptsBindings, UnknownPlatform and UnusableHandle stay on IdentityRegistry. The escrow test registry drops the two views. The deploy-wiring test expected HandleVectors' old string revert; it now expects the UnknownPlatform error HandleVectors raises. BREAKING CHANGE: IIdentityRegistry no longer declares handleNodeOf, acceptsBindings, UnknownPlatform or UnusableHandle; use IdentityRegistry. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ed once requireFramedCommitment and requireFramedInteger differed only in what ends the commitment, and each joined and normalized every revealed byte to count its prefix. Both are now one private lookup with a terminator mode (an exact suffix, or the `,`/`}` that closes a bare JSON integer). Overloads take the normalized join, so _identityTranscript builds it once for the id and the handle reads. The public signatures and refusals are unchanged. Gas (FOUNDRY_PROFILE=gas, osaka): XPlatformVerifier.verify 1,142,586 -> 1,139,114 (-3,472) GitHubPlatformVerifier.verify 1,205,420 -> 1,199,261 (-6,159) Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The X and GitHub verifiers, their shared base and the generated profile constants describe the identity response as revealed anchors around two commitments the platform's circuit opens, not as values read out of it. The circuit is what separates X from GitHub: both share one public-input layout, so setTrustRoots says the owner must pin the platform's own circuit, and that the code hash check cannot tell the two apart. HandleNormalizer.Rules and its Rust and TypeScript mirrors say a platform's rules are generated handles.json constants, and a new platform is an entry there plus a circuit. README and IdentityRegistry state that this stack ships only as a new deployment under new canonical names: the registry keeps its storage namespace with node-keyed slots, and x, github and google stay at ceremony version 1 with this release's payload shapes. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…nd TypeScript bind carries a payload as opaque bytes, so no ABI named the structs the Platform Verifiers decode and every client re-declared them. ICeremonyPayloads takes TlsNotaryProof and GoogleProof in two functions nothing calls, which puts their tuple types into its artifact. TypeScript: codegen emits ceremonyPayloadsAbi, and encodeTlsNotaryProof, encodeGoogleProof and their decoders read the struct types from it. Rust: bindings::ceremony carries TlsNotaryProof, GoogleProof and Attestation, held to the interface's artifact by the drift check; a payload is the struct's SolValue::abi_encode. One vector for all three: x-ceremony-payload.json names the fields the X session fixture does not (the nodes, checked against the proof's outputs, and the disclosed handle) and pins the length and keccak256 of solc's abi.encode of the payload. The forge test, the vitest and the cargo test each encode the fixture and compare; cast abi-encode agrees. Both encoders also round-trip through the struct ABI. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ircuit X's and GitHub's circuits share one public-input layout, so a TLSNotary Platform Verifier accepts either circuit's Honk verifier, and the wrong one keys bindings under the other platform's tags. setTrustRoots checks the code hash it is given against the address, which proves which artifact is wired and not that it is this platform's. Initializer::call now takes the Artifacts and requires the code hash at honk_verifier to be keccak256 of the vendored runtime code of PlatformVerifier::circuit(). Otherwise it returns Error::WrongCircuit, naming the contract, the address, the expected circuit and the circuit found there (None for code that is no vendored verifier). deploy_platform_verifier checks the same before any transaction, and PlatformVerifier::circuit_codehash_at hands out the code hash a setTrustRoots rotation takes under the same check. vendor-artifacts.sh keeps deployedBytecode.object; Artifacts gains deployed_bytecode_named, and Circuit gains runtime_codehash and with_runtime_codehash. The anvil suite checks each deployed verifier's code hash is its circuit's, and that X wired to GitHub's verifier, and GitHub wired to a non-verifier, are refused by name. BREAKING CHANGE: Initializer::call takes `&Artifacts`; Error gains the WrongCircuit variant. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
BindError::decode tries the error sets of every contract on the bind route, in the order the call reaches them: IdentityRegistry, CeremonyProofVerifier, the TLSNotary and Google Platform Verifiers, and the Notary Service. It returns the typed error under the contract whose set declared it, with name() and signature(); a selector none declares, such as bb's SumcheckFailed(), decodes to None. TypeScript's bindErrorsAbi carries the same sets. The NotaryService and CeremonyProofVerifier bindings now declare their errors and are held to their artifacts by the drift check. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The identity session carries exactly one HTTP request, as the token session does. CeremonyAttestation.requireBearerHeaderRequest now also requires, over the revealed request bytes joined, exactly one head end (`\r\n\r\n`), nothing revealed after it -- a GET has no body -- and the last revealed range ending at the signed transcript length. Every header the verifier reads, the authorization line included, is then a header of that one request. Refusals decode as NotOneRequest(heads) and BytesAfterRequest(count). headBoundaries is the one head-end count both sessions read: the token request's _tokenBody calls it and keeps its NoHeadBoundary error. The synthetic identity requests in the tests carried a blank line before their authorization line; they now carry one head, as the libid-rs session fixtures and the real captures do. The reference model applies the same rule, and the identity-request generator also ends a request with a second request after it. The Rust TlsNotaryPlatformVerifier binding declares the two errors. Gas (FOUNDRY_PROFILE=gas): XPlatformVerifier.verify 1139114 -> 1143022, GitHubPlatformVerifier.verify 1199261 -> 1205167. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
HandleEscrow keys deposits by handle node, and the registry before nodes answers handleBinding in the same shape under other keys, so the one call the escrow makes could not tell the two apart. _requireAnswers now also probes resolveId(bytes32), which only the node-keyed registry has, by a low-level staticcall: the escrow never calls it, so it stays off IIdentityRegistry. A registry without it reverts RegistryLacks(registry, resolveId selector). Tested against a mock shaped like the earlier registry (handleBinding alike, resolveId(bytes32,string)); the expected selector is taken from IdentityRegistry.resolveId, so the probe cannot drift from the function it names. The root README's deploying section says the escrow is redeployed against the new registry. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…oblem) publish, handleNodeOf and a Platform Verifier's disclosure check now refuse a handle the platform's rules reject the same way: UnusableHandle(HandleNormalizer.Problem). The error moves into HandleNormalizer, which owns Problem; normalize reverts it, and the four per-reason errors (EmptyHandle, HandleTooLong, BadCharacter, BadShape) are gone. IdentityRegistry no longer declares its own copy; its ABI and the Platform Verifiers' carry the library's, selector unchanged (0xeb1fffd9). The Rust IdentityRegistry, TlsNotaryPlatformVerifier and GooglePlatformVerifier bindings declare UnusableHandle(uint8) in place of the four; the TypeScript README says how a local HandleError kind maps onto the on-chain problem (kind + 1). BREAKING CHANGE: EmptyHandle(), HandleTooLong(), BadCharacter() and BadShape() no longer exist; decode UnusableHandle(uint8) instead. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…or tests The generator's Solidity output is two files. HandlePlatforms.sol holds what production code reads: the platform ids, MAX_LENGTH_*, the user-id and handle tags, knows, userIdTagFor, handleTagFor, rulesFor, TABLE_SHA256 and UnknownPlatform. HandleVectors.sol keeps the shared vector table (Vector, ERROR_*, COUNT, all) and only tests import it, so the deployed contracts no longer import a test table. IdentityRegistry and PlatformVerifierBase import HandlePlatforms; the tests import whichever they read. Regenerated; --check passes and --compare-noir against the circuits' table.nr still matches. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
TlsNotaryProof and GoogleProof move out of TlsNotaryVerifierBase and GooglePlatformVerifier into ceremony/CeremonyPayloads.sol, as file-level structs. ICeremonyPayloads imports that file and no verifier, so the payload ABI a client encodes against no longer depends on the verifier implementations; the verifiers import the same structs to decode. Attestation stays in ICeremony. The tuple types are unchanged, so the encoded payload bytes are too: PayloadEncoding.t.sol's cross-language fixture still matches. Only the ABI's internalType strings change (struct TlsNotaryProof rather than struct TlsNotaryVerifierBase.TlsNotaryProof); nothing compares them. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
keysOf looked the platform key up on a plain object, so a name
Object.prototype carries ('toString', 'constructor', '__proto__')
returned an inherited value instead of refusing, and idNode on such a
key did not throw "unknown platform". keysOf now checks Object.hasOwn
and throws for anything the table does not name. rulesFor compares keys
by equality and needs no change.
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…rate The Honk verifier binding names the three vendored verifiers (BearerLinkX, BearerLinkGithub, OidcGoogle) instead of a BearerLinkHonkVerifier that no longer exists. libid-identity's description covers the id rules and the `node` feature's SHA-256 nodes, not only the handle transform. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
bb's verifiers revert from assembly with selectors held in *_SELECTOR constants and declare no error, so a refused proof came back out of bind undecodable. circuits/IHonkVerifierErrors.sol declares the ten under bb's own names (SumcheckFailed, ShpleminiFailed, PublicInputsLengthWrong, ProofLengthWrongWithLogN(uint256,uint256,uint256), MODEXP_FAILED, ...); nothing implements it. Rust: bindings::circuits::IHonkVerifierErrors, held to that artifact by the drift check, and BindError::HonkVerifier, tried last in decode. TypeScript: honkVerifierErrorsAbi, and bindErrorsAbi includes it. Both packages have a drift test that reads the vendored verifiers' *_SELECTOR constants and requires the declared selectors to be exactly that set, so an error bb adds or drops fails a test rather than decoding to nothing. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
vendor-circuit-verifiers.sh --local wrote the absolute local artifacts path into each vendored verifier's banner. The banner now names the circuit and, when the build left a `commit` file at the artifacts root, the libid-circuits commit it was built from; otherwise "a local libid-circuits build". The verifiers stay gitignored. Re-vendored locally from a fresh build of the current libid-circuits (its shipped table matches handles.json): the three verifiers' sources equal the previous build's byte for byte, and their compiled runtime code hashes are unchanged. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…n chain X's and GitHub's circuits share one public-input layout, so a Platform Verifier could be wired to the other platform's Honk verifier and only the Rust initializer refused it. Each verifier now knows its own circuit's verifier by runtime code hash and refuses any other: _setTrustRoots, under initialize and setTrustRoots alike, reverts WrongCircuit(expected, found) when the code at the address is not circuitCodehash(). The hashes are generated constants in contracts/circuits/ CircuitCodehashes.sol. scripts/vendor-circuit-verifiers.sh writes the verifiers, compiles only them under foundry.toml (legacy pipeline, no CBOR metadata, so the bytes depend on source and settings alone), hashes each deployedBytecode as EXTCODEHASH reports it (bb's verifier has no immutables) and writes the file. It is committed, since the contracts import it; compiling only the verifiers means a stale file never blocks writing a new one. HonkVerifiers.t.sol asserts each deployed vendored verifier's code hash equals its constant, so forge test fails until the file and the vendored verifiers agree, and that every Platform Verifier refuses the other circuits' verifiers by name. Tests that wired a stand-in Honk verifier now deploy the platform's real vendored verifier and mock its verify (HonkStub), since nothing else can be wired. The Rust bindings declare circuitCodehash() and WrongCircuit; the crate's off-chain check in Initializer::call stays and its docs no longer say the contract cannot tell. Gas (FOUNDRY_PROFILE=gas): XPlatformVerifier.verify 1143022 -> 1143084, GitHubPlatformVerifier.verify 1205167 -> 1205189 (dispatch only). BREAKING CHANGE: a Platform Verifier accepts only the Honk verifier whose runtime code hash it was compiled with; moving to another circuit release is a new implementation, not a setTrustRoots rotation. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…uest The real-capture tests skipped wholesale because the captured identity responses reveal the id and handle, which the anchor-only framing refuses. Only that part is skipped now. _identityTranscript is _identityRequest then _identityResponse, so the request half is callable alone; the reference model splits the same way. XPlatformVerifier.t.sol and GitHubPlatformVerifier.t.sol authenticate both real attestations through the suite's NotaryService, check their authorities, and run the token transcript and the identity request (the one-request rule included) under the capture's own digest and nonce. TranscriptEquivalence.t.sol holds both implementations to the same answers for those two parts of x-ceremony-real.json and github-ceremony-real.json. The whole-record and identity-response tests remain, skipped with that reason. Both real identity requests hold exactly one head end, at the end of the revealed bytes, and end at the signed transcript length. Gas (FOUNDRY_PROFILE=gas): XPlatformVerifier.verify 1143084 -> 1143047, GitHubPlatformVerifier.verify 1205189 -> 1205134. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…node_halves
gen_noir now writes only the constants the circuits compile against and a
release ships as handles-table.nr. The vector tests move to a sibling
table_tests.nr, written by --noir-out next to table.nr.
Each test asserts `node_halves(tag, value, len) == [high, low]`, two Field
literals, instead of `tagged_hash::<A,B,TOTAL>` against a 32-byte array,
and builds its witness with the testing module's `padded("...".as_bytes())`.
A byte array remains only for an input a string literal cannot spell
plainly: a backslash or a byte above 0x7e. A too-long input is the
buffer's worth of the string with the true length, as before.
--compare-noir compares the constants; --compare-noir-tests compares the
tests. noir_shape keeps string literals byte for byte, so whitespace inside
a vector's input still counts. --check with --noir-out covers both files.
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…ript regen-identity-handles.py emits each platform once, as a `Platform` record (key, tags, handle rules, id rules) and a `PLATFORMS` table with a `platform(key)` lookup, in place of a constant per flag. The Rust `Rules::X`/`IdRules::X`/`rules_for`/`id_rules_for`/`tags_for` and the TypeScript `RULES_X`/`rulesFor`/`handleNode`/`checkId`/`idNode` read it; their signatures and results are unchanged. The per-flag constants (MAX_LENGTH_*, IS_EMAIL_*, ALLOW_*, USER_ID_TAG_*, HANDLE_TAG_*, MAX_ID_LENGTH_*, ID_DECIMAL_*, ID_LEADING_ZERO_*) are gone from the generated Rust and TypeScript modules; PLATFORM_<P>_KEY, the error kinds and the vectors stay. TypeScript gains the `IdRules` type. Solidity: HandlePlatforms no longer emits MAX_LENGTH_*, which only a test read; that test now compares rulesFor with handles.json itself. It gains `handleNodeOf(platformId, raw)` and `tryHandleNodeOf`, over one platform lookup that yields the rules and the tag together, and HandleNormalizer gains `tryNodeOf`. PlatformVerifierBase._disclosed, IdentityRegistry.publish and the registry's resolvers call them instead of pairing rulesFor with handleTagFor; _tryNodeOf is gone. Errors and their order are unchanged. Gas: the claim-verification snapshot is unchanged. A disclosing bind with a real proof costs less (RealProofBind: X 1752306 -> 1751857 test gas, GitHub 1822646 -> 1821950, Google 1360559 -> 1359663); a private bind is unchanged. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The real-proof blocks of XPlatformVerifier.t.sol and GitHubPlatformVerifier.t.sol move to RealTlsNotaryProofTest, modelled on RealProofBind's RealTlsNotaryBind: each suite supplies its verifier, its notary, its fixtures and the other platform's session; the base wires the circuit's verifier and runs the refusals. - The node-mutation tests are one per verifier: another id node, another handle node, the two swapped. - The substituted-commitment tests are one loop over the handle, id, identity-bearer and token-bearer commitments. GitHub's suite now covers the token bearer too. - The cross-circuit tests (a GitHub proof under the X circuit and the reverse) are dropped: HonkVerifiers.t.sol's test_eachBearerLinkVerifierRefusesTheOtherPlatformsProof refuses each proof under the other key with its own public inputs. - test_aGitHubVerifierRefusesAnXCeremony is dropped: GitHub's test_rejectsTheExchangeFromTheApiHost covers the same token-authority check. test_anXVerifierRefusesAGitHubCeremony stays, as the only test of X's token-session authority; test_rejectsAnotherCircuitsVerifier stays, as the only test of the rotation path's WrongCircuit and of circuitCodehash(). AttestationBuilder gains sign, fixtureSession, nodeAt, indexOf and contains, which replace the private copies in the X, GitHub, Decoy, LayoutForgery, PlantedRangeForgery, GoogleJwtRoots, PayloadEncoding and ClaimVerificationGas suites. Tests: X 116 -> 109, GitHub 76 -> 71. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
- One IGoogleJwtRoots stub, TrustingJwtRoots, for the Google verifier,
upgrade-safety and real-proof suites.
- TestNodes.handleNode hashes through HandleNormalizer.node, and
StubPlatformVerifier normalizes and hashes through
HandlePlatforms.handleNodeOf and TestNodes, instead of their own copies.
- The node literals written as `sha256("libid.<p>.handle...")` go through
TestNodes. The hashlib vectors, RealProofBind's and the verifier suites'
ID_NODE/HANDLE_NODE constants stay as written: they are the independent
reference.
- One test_theNodesAreTheProofsOutputs, in RealProofBindBase: each platform
names where its id node sits among the public inputs.
- One privacy scan, PrivacyScan, for IdentityRegistry.t.sol and
RealProofBind.t.sol: the logs' topics and data, and every storage word
written, checked for the id, the handle and the folded handle.
- Dropped as covered elsewhere: test_anUnknownPlatformStillReverts
(test_everyEntryPointRefusesAnUnknownPlatform asserts the same revert on
resolveHandle), test_bindingWithoutDisclosingNeverPublishes
(test_aPrivateBindPublishesNothing, and test_aBindWithoutDisclosureLeaves
TheNameAlone for renames), and RealProofBind's test_unpublishClearsTheName
(test_aPublishedHandleCanBeWithdrawn and test_withdrawingAPublishedHandle
KeepsTheBinding; unpublish reads no proof).
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
forge-build runs `git diff --exit-code` on CircuitCodehashes.sol right after the vendor step. The vendor step rewrites that file from the verifiers it just wrote, so without this the drift test in HonkVerifiers.t.sol compares the file with itself. vendor-circuit-verifiers.sh reads the pin's circuits once into arrays, where four loops and the unreleased check each ran jq over circuits.json; its comment names HonkVerifiers.t.sol, which holds the drift test, not CircuitCodehashes.t.sol. A --local run from the current circuits build writes the verifiers and CircuitCodehashes.sol byte for byte as committed. regen-identity-handles.py checks the handle and id nodes in one loop, and drops the branch that skipped an output whose package directory was absent: both packages exist. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
name() is the signature up to its parenthesis, instead of a second match over every error set's name_by_selector. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…er uses The shared TrustingJwtRoots stub replaced the suite's own; `forge lint -D notes` flagged the import it left behind. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…fier The Platform Verifier already normalizes a disclosed handle and refuses one that does not hash to the proof's handle node (HandleNotProved). The registry takes that handle as it takes the verifier's id and handle nodes, and no longer hashes it a second time. BREAKING CHANGE: IdentityRegistry.DisclosureMismatch is removed from the ABI and from the Rust and TS error decoders. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
This was referenced Oct 9, 2026
Trim the comments this branch added or changed to what the code does, plus a line of reason where it is not obvious. Rationale, threat narratives and spec retellings live in the commits and the PR. Comments only: the compiled bytecode, ABIs and storage layouts are unchanged, and the generated Solidity outputs were regenerated from the shortened generator text. The Noir outputs are byte for byte the same. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex <georglutsenko@gmail.com>
SupremaLex
marked this pull request as ready for review
October 9, 2026 14:57
This branch has not been deployed
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.
Keys every identity by circuit-proved hashes, so a bind puts no plaintext user id, handle or email on chain. The Platform Verifiers frame the committed id and handle by their revealed anchors and pass the nodes the circuits output. A handle is disclosed only when the payload carries it, checked against the proved node. This ships as a fresh deployment under new canonical names. Companion PRs: libid-org/libID-rs#35 and libid-org/libID-circuits#17.
What changes
Keys
Bindings are keyed by
idNode = SHA256("libid.<p>.user-id" || id)andhandleNode = SHA256("libid.<p>.handle" || fold(handle)). The circuits output these. The registry stores no preimages, andIdentityBoundcarries none.X and GitHub verifiers
The identity response reveals only the anchors around the id and the handle.
TlsNotaryVerifierBasefinds each value as the one commitment its anchors frame:requireFramedCommitment, orrequireFramedIntegerfor GitHub's bare integer id. It then verifies the platform's circuit over 72 public inputs:idNodehandleNodeGoogle verifier
Reads both nodes from oidc-google's 57 public inputs: 0-31 Authorization Digest, 32-33
SHA256(aud), 34-35idNode, 36-37handleNode, 38exp, 39-56 modulus.Identity session: one bodiless request
The identity session's sent direction must be one HTTP request with no body: exactly one head end (
\r\n\r\n), nothing revealed after it, and the last revealed range ending at the signed transcript length. Every header the verifier reads is then a header of that request. Refusals:NotOneRequest(heads),BytesAfterRequest(count). The token session follows the same head-end rule; both use one count,headBoundaries.Disclosure
sha256(tag || handle)to equal the proof's handle node (HandleNotProved), and returns it normalized.HandlePublished).publish(platformId, handle)discloses later, held to a node the caller holds (NotYourHandle).unpublishclears the name; the disclosing transaction stays public.UnusableHandle(problem), frompublish,handleNodeOfand the verifiers.HandleNormalizerowns the error and itsProblemenum.One rule table
solidity/contracts/handles/handles.jsonholds every platform's tags and rules.scripts/regen-identity-handles.pygeneratesHandlePlatforms.sol(production) andHandleVectors.sol(tests only), the Rust and TypeScript tables, and libID-circuits' Noirtable.nrandtable_tests.nr. Each output carries the table's SHA-256, and each language's tests check it.--compare-noirand--compare-noir-testscheck a circuits table; libID-circuits CI runs them at itscontracts.ref. The node vectors are computed independently with hashlib.Circuit pin on chain
Each Platform Verifier knows its circuit's Honk verifier by runtime code hash, a generated constant in
CircuitCodehashes.sol.initializeandsetTrustRootsrevertWrongCircuit(expected, found)for any other code. X and GitHub share one public-input layout, so the pin keeps one platform's proofs out of the other's verifier. A new circuit release is a Platform Verifier upgrade, not a trust-root rotation. Rust'sInitializer::callchecks the same before any transaction.Escrow
deposit(handleNode, token, amount, refundTo). The node's tag carries its platform, so a deposit names no platform. A node on a platform that cannot bind is escrowed and refundable.initializerevertsRegistryLacksagainst a registry that does not take nodes.Errors by name
Rust
BindError::decodeand TSbindErrorsAbiname a refused bind's error across the registry, the Proof Verifier, the Platform Verifiers and the Notary Service. Honk verifier refusals (SumcheckFailed,ShpleminiFailed, …) decode too, throughIHonkVerifierErrors.sol. A test in each language holds the declared selectors to the vendored verifiers'*_SELECTORconstants.Rust and TypeScript
ICeremonyPayloads.soldeclaresTlsNotaryProofandGoogleProof. TS exportsencodeTlsNotaryProof/encodeGoogleProof; Rust exports the structs. All three produce the same bytes forx-ceremony-payload.json.nodefeature) hashandle_node,id_node,check_id; @libid/contracts hashandleNode,idNode,checkId.setPlatformremovedsetPlatform,PlatformConfigured,PlatformFrozen,EmptyHandleTagand the platform storage are removed. Rules and tags are generated constants inHandlePlatforms, read by the registry and the verifiers, and the circuits compile the same constants. There is nothing per platform to set.HandlePlatforms.knows. For any other id,bind,publish,unpublish,publishedHandleOf,rulesOf,handleTagOf,handleNodeOf,resolveHandleandresolveHandleAndIdrevertUnknownPlatform;acceptsBindingsreturnsfalse;quoteBindrevertsUnknownVersionat the Proof Verifier.Deployment
A fresh deployment under new canonical names, never an upgrade of the live proxies.
IdentityRegistrykeeps the storage namespacelibid.storage.IdentityRegistry, but its slots hold nodes and its layout drops a field. An upgraded proxy would read old entries as nodes.IdentityRegistry.storage-layoutis re-recorded.HandleEscrowis redeployed against the new registry.Boundaries
VerifiedClaimof nodes and an optional normalized handle.publishchecks a later disclosure itself, since no verifier is on that path.handles.jsonis the one source of rules and tags; every other copy is generated and checked.IIdentityRegistry.handleBinding. The node formula lives once, inHandleNormalizer.node.Privacy
Gas
solidity/snapshots/claim-verification.jsonat 2100a0d (FOUNDRY_PROFILE=gas):XPlatformVerifier.verifyGitHubPlatformVerifier.verifyBearerLinkXHonkVerifier.verify,BearerLinkGithubHonkVerifier.verifyOidcGoogleHonkVerifier.verifyBreaking changes
ICeremony.VerifiedClaimcarriesidNode,handleNodeandhandle.TlsNotaryProofandGoogleProofchange shape.IdentityRegistry:bind(platformId, version, payload);resolveId(bytes32);resolveHandleAndIdtakes an id node;identitiesOfreturns nodes;IdentityBoundcarries no plaintext;DisclosureMismatchremoved;setPlatformand its events and errors removed.IIdentityRegistrydeclares onlyhandleBinding.EmptyHandle,HandleTooLong,BadCharacterandBadShapebecomeUnusableHandle(uint8). Normalization refuses rather than trims.HandleEscrow.deposit(handleNode, token, amount, refundTo);DepositedandForwardeddropplatformId;PlatformAcceptsNoBindingsremoved.WrongCircuit,NotOneRequestandBytesAfterRequest.BearerLinkXHonkVerifier,BearerLinkGithubHonkVerifierandOidcGoogleHonkVerifier.Initializer::calltakes&Artifacts;Error::WrongCircuit; the per-flag rule constants give way to the generated table. TS:handleHashis replaced byhandleNode,idNodeandcheckId.Verification
Ran
forge testat 2100a0d, locally with verifiers vendored by--localfrom a libID-circuits#17 build: 736 passed, 0 failed, 3 skipped. The skips are the real-capture identity-response and whole-record tests; those captures reveal the id and handle, which anchor framing refuses. Their token session and identity request run.RealProofBind.t.sol): a private bind leaves no id, handle or address bytes in the payload, logs or written storage; a disclosure is stored folded; a wrong one revertsHandleNotProved.WrongCircuiton rotation.Synthetic
ceremony_fixtures, with real bb proofs of their witnesses. Google signed by a seeded test key.Not run
cargo test, vitest, fmt and lint at this head.CI
circuits.json pins no release for: bearer-link-github bearer-link-x oidc-google) before any test runs. Locally, Rust'sthe_enum_matches_the_pinfails for the same reason. Generated tables, Version fields and DCO pass.circuits.json; CI here goes green and this merges; libID-circuits then movescontracts.refto the merged commit.Unverified
Open questions
On-chain normalizer. The hash match already proves canonical form. Marginal gas of a disclosed handle:
Keep the full rules, fold only, or require exact bytes?
setTrustRoots'shonkVerifierCodehash_. With the circuit pinned on chain, the parameter is redundant. Remove it?Identity-request head scans. Merge them into one pass, about 20k gas?
Per-platform profile lookups. Generate them, about 180 lines of hand-written code?
Required headers. Precompute them, about 11k gas?
Google address rules. Only
[a-z0-9.+-_@]after folding, at most 62 bytes. Workspace addresses with an apostrophe or over 62 bytes cannot bind. Widening later moves no node but needs a new circuit and verifier. Accept for this release?Stack
#88 is stacked on this PR. It moves the bearer-link verifiers to 12 public inputs and the tightened circuits, and checks the circuit pin at verify time.