Skip to content

feat!: key identities by circuit-proved hashes, never plaintext - #87

Open
SupremaLex wants to merge 34 commits into
mainfrom
feat/hashed-identities
Open

SupremaLex wants to merge 34 commits into
mainfrom
feat/hashed-identities

Conversation

@SupremaLex

@SupremaLex SupremaLex commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

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) and handleNode = SHA256("libid.<p>.handle" || fold(handle)). The circuits output these. The registry stores no preimages, and IdentityBound carries none.

X and GitHub verifiers

The identity response reveals only the anchors around the id and the handle. TlsNotaryVerifierBase finds each value as the one commitment its anchors frame: requireFramedCommitment, or requireFramedInteger for GitHub's bare integer id. It then verifies the platform's circuit over 72 public inputs:

Index Value
0-31 token commitment, one byte per field
32-63 identity commitment, one byte per field
64-65 id commitment, 16-byte halves
66-67 handle commitment
68-69 idNode
70-71 handleNode

Google verifier

Reads both nodes from oidc-google's 57 public inputs: 0-31 Authorization Digest, 32-33 SHA256(aud), 34-35 idNode, 36-37 handleNode, 38 exp, 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

  • The bind payload may carry a handle. The Platform Verifier normalizes it, requires sha256(tag || handle) to equal the proof's handle node (HandleNotProved), and returns it normalized.
  • The registry takes the handle from the Platform Verifier, as it takes the nodes, and stores it as the wallet's name (HandlePublished).
  • publish(platformId, handle) discloses later, held to a node the caller holds (NotYourHandle). unpublish clears the name; the disclosing transaction stays public.
  • A handle the rules reject is UnusableHandle(problem), from publish, handleNodeOf and the verifiers. HandleNormalizer owns the error and its Problem enum.

One rule table

solidity/contracts/handles/handles.json holds every platform's tags and rules. scripts/regen-identity-handles.py generates HandlePlatforms.sol (production) and HandleVectors.sol (tests only), the Rust and TypeScript tables, and libID-circuits' Noir table.nr and table_tests.nr. Each output carries the table's SHA-256, and each language's tests check it. --compare-noir and --compare-noir-tests check a circuits table; libID-circuits CI runs them at its contracts.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. initialize and setTrustRoots revert WrongCircuit(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's Initializer::call checks 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. initialize reverts RegistryLacks against a registry that does not take nodes.

Errors by name

Rust BindError::decode and TS bindErrorsAbi name 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, through IHonkVerifierErrors.sol. A test in each language holds the declared selectors to the vendored verifiers' *_SELECTOR constants.

Rust and TypeScript

  • Payload ABI: ICeremonyPayloads.sol declares TlsNotaryProof and GoogleProof. TS exports encodeTlsNotaryProof / encodeGoogleProof; Rust exports the structs. All three produce the same bytes for x-ceremony-payload.json.
  • Local nodes: libid-identity (node feature) has handle_node, id_node, check_id; @libid/contracts has handleNode, idNode, checkId.
  • One generated platform table serves both languages.

setPlatform removed

setPlatform, PlatformConfigured, PlatformFrozen, EmptyHandleTag and the platform storage are removed. Rules and tags are generated constants in HandlePlatforms, read by the registry and the verifiers, and the circuits compile the same constants. There is nothing per platform to set.

  • Which platforms exist is HandlePlatforms.knows. For any other id, bind, publish, unpublish, publishedHandleOf, rulesOf, handleTagOf, handleNodeOf, resolveHandle and resolveHandleAndId revert UnknownPlatform; acceptsBindings returns false; quoteBind reverts UnknownVersion at the Proof Verifier.
  • Whether a platform is live is whether its verifier is registered at the Proof Verifier. Retiring it withdraws the platform; its bindings stay resolvable.
  • The deploy script configures no platform.

Deployment

A fresh deployment under new canonical names, never an upgrade of the live proxies.

  • IdentityRegistry keeps the storage namespace libid.storage.IdentityRegistry, but its slots hold nodes and its layout drops a field. An upgraded proxy would read old entries as nodes. IdentityRegistry.storage-layout is re-recorded.
  • x, github and google stay at ceremony version 1, with this release's payload shapes. A client built for the live deployment is refused at decode.
  • HandleEscrow is redeployed against the new registry.
  • The new canonical names go into chain-configurations separately.

Boundaries

  • The circuit owns raw → node. Contracts never see the raw id or handle of a private bind.
  • The Platform Verifier owns framing, the public-input layout, the circuit pin and the disclosed-handle check. The registry gets a VerifiedClaim of nodes and an optional normalized handle.
  • The registry owns bindings and the name slot. publish checks a later disclosure itself, since no verifier is on that path.
  • handles.json is the one source of rules and tags; every other copy is generated and checked.
  • Layering: handles ← ceremony ← identity ← escrow. The escrow calls only IIdentityRegistry.handleBinding. The node formula lives once, in HandleNormalizer.node.

Privacy

  • Private means not disclosed, not unguessable. The tags are public, so anyone can hash a candidate and look it up.
  • Committed range lengths reveal value lengths.
  • GitHub ids are sequential, so a GitHub id node is in effect public: hashing every id up to the current maximum takes about a minute.
  • A disclosure stays in history.
  • A duplicate field behind a commitment cannot be detected; that rests on ASM-PROV-06.

Gas

solidity/snapshots/claim-verification.json at 2100a0d (FOUNDRY_PROFILE=gas):

Call Gas
XPlatformVerifier.verify 1,143,047
GitHubPlatformVerifier.verify 1,205,134
BearerLinkXHonkVerifier.verify, BearerLinkGithubHonkVerifier.verify 740,178
OidcGoogleHonkVerifier.verify 755,087

Breaking changes

  • Fresh deployment; the registry's storage layout is not upgrade-compatible.
  • ICeremony.VerifiedClaim carries idNode, handleNode and handle.
  • TlsNotaryProof and GoogleProof change shape.
  • IdentityRegistry: bind(platformId, version, payload); resolveId(bytes32); resolveHandleAndId takes an id node; identitiesOf returns nodes; IdentityBound carries no plaintext; DisclosureMismatch removed; setPlatform and its events and errors removed.
  • IIdentityRegistry declares only handleBinding.
  • EmptyHandle, HandleTooLong, BadCharacter and BadShape become UnusableHandle(uint8). Normalization refuses rather than trims.
  • HandleEscrow.deposit(handleNode, token, amount, refundTo); Deposited and Forwarded drop platformId; PlatformAcceptsNoBindings removed.
  • Platform Verifiers revert WrongCircuit, NotOneRequest and BytesAfterRequest.
  • The circuit verifiers are BearerLinkXHonkVerifier, BearerLinkGithubHonkVerifier and OidcGoogleHonkVerifier.
  • Rust: Initializer::call takes &Artifacts; Error::WrongCircuit; the per-flag rule constants give way to the generated table. TS: handleHash is replaced by handleNode, idNode and checkId.
  • Crate and npm versions are not bumped yet.

Verification

Ran

  • forge test at 2100a0d, locally with verifiers vendored by --local from 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.
  • Real proofs through the whole stack for X, GitHub and Google (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 reverts HandleNotProved.
  • Refusals: other or swapped nodes, substituted commitments, each bearer-link verifier refusing the other platform's proof, WrongCircuit on rotation.
  • Framing is fuzzed against a reference that rebuilds the transcript as a byte map.

Synthetic

  • X and GitHub sessions from libid-rs' 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.
  • No real X or GitHub capture under anchor framing; that needs the test accounts.

CI

  • Red until a circuits release is pinned. Solidity, Rust, TypeScript, Gas and Publish stop at the vendor step (circuits.json pins no release for: bearer-link-github bearer-link-x oidc-google) before any test runs. Locally, Rust's the_enum_matches_the_pin fails for the same reason. Generated tables, Version fields and DCO pass.
  • Merge order: a libID-circuits pre-release is tagged and pinned in circuits.json; CI here goes green and this merges; libID-circuits then moves contracts.ref to the merged commit.

Unverified

  • The fixtures copied from libid-rs record their generator command but not its revision; nothing checks them against libid-rs.

Open questions

  • On-chain normalizer. The hash match already proves canonical form. Marginal gas of a disclosed handle:

    Option X typical GitHub max Google max
    Full rules (current) 6,002 29,792 44,567
    Fold only 3,157 11,087 16,995
    Exact bytes ~885–1,378

    Keep the full rules, fold only, or require exact bytes?

  • setTrustRoots's honkVerifierCodehash_. 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.

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>
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
SupremaLex marked this pull request as ready for review October 9, 2026 14:57
@SupremaLex
SupremaLex requested a review from xgreenx October 9, 2026 14:57

This branch has not been deployed

No deployments
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