From a5089a562084c0d51a6ace2a64b97beb85880e0a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 22 Sep 2026 14:08:41 +0300 Subject: [PATCH 01/11] docs(design): a private mode for Gmail handles A design proposal, nothing built: how a Google binding can reach the chain as a hash of the normalized email instead of the address, with the address disclosed only when its owner sends it. The note maps where the address is published today, lays out the options on each axis (what the circuit exposes, where normalization runs, which hash, where the mode lives, defaults and transitions, downstream readers, spec changes), measures the gate cost of the two viable hashes on the pinned toolchain, recommends one combination, and lists what it does not protect against and the decisions it leaves open. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex --- design/private-gmail-handle.md | 496 +++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 design/private-gmail-handle.md diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md new file mode 100644 index 00000000..c5d67865 --- /dev/null +++ b/design/private-gmail-handle.md @@ -0,0 +1,496 @@ +# A private mode for Gmail handles + +**Status: design proposal.** Nothing here is built. It is not a protocol spec +in the sense of `specs/` and defines no `ASM-*`/`SP-*`/`REQ-*` identifiers; it +names the ones that would have to change, and the parts that carry trust +assumptions graduate into `specs/platform-ceremonies.md` and +`specs/ceremony-common.md` if the design is approved. + +## The thing that must work + +Alice binds `alice@gmail.com` to her wallet. Nobody who reads the chain, an +explorer, the indexer or its search learns that address. Bob, whom Alice told +her address, resolves it and pays her. Alice can make the address public +later; she can never make a public one private, because a published log line +is permanent. + +One decision is already taken and everything below rests on it: **private +means hidden but resolvable by the exact address.** An unsalted hash of the +normalized email reaches the chain. Whoever knows the address can compute the +hash and resolve it. Whoever does not cannot read it. The design that goes +further, a salted commitment nobody can resolve without a secret the owner +shares, is named at the end under what we deliberately do not do. + +## Who learns the email today + +A Google binding publishes the address in three places on chain and two off +it. + +| Where | What carries it | Reader | +|---|---|---| +| Claim calldata | `email_packed`, two field elements of raw bytes at public-input slots 35 and 36 | anyone with an archive node or an explorer | +| `IdentityBound` log | `string handle`, the normalized email, next to `string userId` | any log reader, every indexer | +| Contract storage | `published[owner][platformId]`, the plaintext, when the user asked to publish | `reverseOf`, `primaryOf`, wallets | +| usernames-indexer | `names.handles.handle TEXT`, with prefix and trigram indexes built for substring search; `/v1/search`, `/v1/resolve/*` | any API client, and the operator's access logs | +| ENS | `alice.google.handles.link` is the address with `@gmail.com` folded into the platform label | any wallet | + +The spec sanctions all of it: "the handle, the platform user identifier, and +the client identifier are published deliberately … the protocol treats none +of them as confidential" (`ceremony-common.md` §12). `unpublish` clears only +the storage string and says why: the log line "is already public and always +will be" (`IdentityNames.sol:678-686`). + +The storage keys are already hashes. `handleNode` is +`keccak256(abi.encode(HANDLE_NODE_V1, platformId, keccak256(normalizedHandle)))` +(`IdentityNodes.sol:37-41`), and every lookup, on chain and in the indexer, +goes through that node. The plaintext exists on chain only to be read back. +That is the whole opening: the system already resolves by hash; it just also +publishes the preimage. + +### What hidden-but-resolvable protects, and what it does not + +It stops passive collection: nobody scrapes Gmail addresses out of calldata, +logs, an explorer, or a substring search. It does not stop: + +- **Confirmation by guessing.** The hash is an unsalted keccak of a + lowercase string with little entropy. Anyone can test a list of addresses + against every `handleNode` on chain, offline, at hash speed. This is the + price of resolving by exact address and it cannot be paid down without a + salt. +- **Confirmation of a known `sub`.** Google's stable account id is one number + per account, the same at every relying party the person ever signed in to + with Google, so it is hidden with the handle, by decision. What remains is + the same test as for the address: a relying party that holds the `sub` + can hash it and confirm the binding. +- **ENS forward names.** `alice.google.handles.link` resolves for a private + binding as for a public one, by decision: the name is the address, and a + wallet that resolves it has confirmed it. +- **Access logs.** `GET /v1/resolve/handle/{platform}/{handle}` puts the + plaintext in the request line of every proxy and log on the way, and the + route stays, by decision: whoever runs the indexer is trusted with the + addresses people resolve, and retaining or dropping that path is an + operational rule, not a protocol one. +- **The fact of a binding.** That this wallet holds some Google identity, and + when it was observed, stays public. + +## Options + +Each axis below lists the options with what changes, what it protects, what +it costs, and a verdict. The recommended combination follows. + +### What the circuit exposes for the email + +**Keep the raw bytes as a public input and stop publishing in the contract.** +Rejected. The email would still sit in the transaction's calldata at slots +35 and 36 of `publicInputs`, where `GooglePlatformVerifier` reads it today +(`GooglePlatformVerifier.sol:237`). A contract that declines to emit what +every explorer can decode from the call protects nothing. + +**Replace `email_packed` with a hash of the normalized email.** The circuit +publishes `handle_hash: pub [Field; 2]`, the 32-byte digest packed sixteen +bytes per field, exactly as it already publishes `audience_hash` +(`oidc-google/src/main.nr:259-267`). The chain then does for the email +what the verifier does for the audience today: the payload carries the +plaintext, the digest is recomputed and compared +(`GooglePlatformVerifier.sol:206-210`, REQ-PLAT-19A), here by the Consumer, +which owns normalization. In private mode the payload carries no plaintext, +and the node is derived from the hash alone. The public-input count stays 56 and no +offset in the verifier moves; only the meaning of two slots changes. A new +verification key and a regenerated `OidcGoogleHonkVerifier.sol` follow +regardless, as they do for any circuit change. **Recommended.** + +**Expose both the raw bytes and the hash, with a mode bit.** Rejected. In +private mode the raw slots would have to be zero and the bit would say so, +which is the previous option with two dead field elements, and it is exactly +the "detached second representation of a claim" REQ-PLAT-16B forbids. + +**Two circuits, one per mode.** Rejected. Two artifacts, two verifiers, two +normalizers that must agree byte for byte or the same address lands on two +nodes. The plaintext-in-payload option gives the user the same choice with +one artifact. + +### Where normalization happens + +Once only a hash leaves the circuit, the contract can no longer normalize the +bytes itself, so the hash must be over bytes that are already normalized. +Three ways to get there. + +**Normalize in the circuit, mirroring the Consumer's rules.** After the +checks the circuit already makes on the email bytes (`main.nr:125-147`: +prefix match, byte equality with the payload, no interior quote, zero padding +past the length, closing quote, structural byte after it), it folds `A-Z` to +`a-z`, refuses any byte outside the email alphabet `HandleNormalizer._allowed` +admits, refuses a space rather than trimming it (Google never emits one, and +REQ-PLAT-08A forbids trimming), requires exactly one `@` with a nonempty side +on each end as `_hasEmailShape` does (`HandleNormalizer.sol:100-148`), and +hashes exactly `email_len` bytes of the folded buffer, never the 62-byte +padded one, so the digest equals `keccak256(bytes(normalizedHandle))` as the +contract computes it in public mode. This freezes the Google rules of +`handles.json` into the circuit: a rules edit without a circuit release makes +every public claim fail the equality check, which is the enforced form of the +invariant `IdentityNames.sol:106-112` states in prose, that the key a handle +hashes to cannot vary by version. **Recommended.** It amends REQ-PLAT-08A for +the one profile that exposes a digest instead of bytes. + +**Hash the raw bytes under a new node tag.** Rejected. `Alice@gmail.com` and +`alice@gmail.com` become two identities, and a public claim (normalized in +the contract) and a private claim (raw in the circuit) of the same address +land on two nodes. That is the split namespace the invariant exists to +prevent. + +**Hash the raw bytes and rely on Google emitting lowercase.** Rejected. +Nothing Google signs promises the case of `email`; one token with an +uppercase letter splits the namespace silently. + +### Which hash + +The choice decides whether Google's `handleNode` stays what it is today. + +**keccak256 of the normalized bytes.** The digest is the inner hash of +`handleNode` exactly (`IdentityNodes.sol:39-41`), so the node of a private +claim, a public claim, an old-version claim and an ENS lookup is one value. +The contract, the indexer's node arithmetic (`usernames-core/src/nodes.rs`), +the TypeScript resolver and the ENS gateway keep their derivation untouched. +The pinned Noir (`toolchain.env`: nargo 1.0.0-beta.25, bb 5.2.0) has no +keccak in its standard library; the `noir-lang/keccak256` library at v0.1.3 +compiles under it (v0.1.1, the version vendored locally today, does not: it +names a `keccakf1600` the pinned standard library no longer has). + +**SHA-256 of the normalized bytes.** Already in the circuit's dependencies +and cheaper. But the node's inner hash would then be SHA-256 for Google and +keccak for everything else, so `IdentityNodes`, the indexer's `nodes.rs`, the +TypeScript node helpers and the gateway all grow a per-platform case, and +every Google node changes. None of the networks in `chain-configurations` +deploys `IdentityNames` today, so there is no Google namespace to migrate, +but the case is permanent code and permanent audit surface. + +**Poseidon.** Cheapest in a circuit and no precedent anywhere in libID's +Solidity, Rust or TypeScript. The contract would compute Poseidon in `_write` +and `resolveHandle`, and so would the gateway. Not for a first version. + +Measured on a scratch copy of the circuit at the pinned toolchain, with the +fold-and-shape loop included in both variants (`nargo compile`, then +`bb gates --oracle_hash keccak`): + +| Circuit | ACIR opcodes | Honk gates | Delta | +|---|---|---|---| +| today | 44,612 | 179,443 | | +| fold + SHA-256 | 49,918 | 192,254 | +7.1% | +| fold + keccak256 (library v0.1.3) | 49,911 | 203,878 | +13.6% | + +Proving time was not measured; it grows roughly with the gate count. The +recommendation is keccak256: a seventh more proving work in the browser +against no change to any node, any reader, or any invariant. SHA-256 is the +fallback if the browser prover's time budget cannot take it, and then the +per-platform inner hash is written into the spec as a Google exception. + +### The account id, `sub` + +The same treatment, with the validation moving where the bytes are: +`sub_hash: pub [Field; 2]` replaces `sub_packed`, keccak256 over the signed +`sub` bytes exactly as signed, since the spec keeps the id case-sensitive and +untransformed (`platform-ceremonies.md` §2.1: "its exact 1–255 +case-sensitive ASCII bytes"), so the digest is the inner hash of `idNode` +(`IdentityNodes.sol:32-34`) and that node does not change either. + +Today the circuit checks the `sub` bytes against the payload, refuses an +interior quote and pins the padding to zero (`main.nr:177-192`), and leaves +emptiness to the verifier, which rejects an empty `userId` after unpacking +(`GooglePlatformVerifier.sol:234`). A verifier that receives a digest can +inspect nothing, so the circuit takes over the whole of the id's validation, +which REQ-PLAT-04 already states for every implementation: `sub_len` at +least 1; every byte within `sub_len` in `0x20` through `0x7e`, so no control +byte, no non-ASCII byte and, as today, no quote; the padding zero; and the +digest over exactly `sub_len` bytes. `SUB_MAX` stays a profile +constant: 31 today, a Google `sub` being 21 digits, and a `sub` longer than +the constant fails to prove rather than truncating. The spec's 255 is the +identity's general bound, not this profile's; raising `SUB_MAX` to it costs +a second keccak block and is a measurement away if a longer Google `sub` +ever appears. A 32-byte digest needs two field elements where the packed id +needed one, so the public-input count becomes 57 and the offsets after slot +34 move by one. Measured the same way as above, the two digests together +cost: + +| Circuit | ACIR opcodes | Honk gates | Delta | +|---|---|---|---| +| fold + keccak256 of the email, keccak256 of `sub` | 50,375 | 223,582 | +24.6% | + +A quarter more proving work than today, against a wallet that no longer +joins with every relying party's user table. + +### Where the mode lives + +**The presence of the plaintext in the payload.** `GoogleProof` gains +`bytes userId` and `bytes email` beside `clientIdentifier` +(`GooglePlatformVerifier.sol:76-84`), both present or both empty; empty means +private. The verifier passes the submitted bytes through as `userId` and +`handle`, possibly empty, and returns the two digests the proof bound as +new `VerifiedClaim.userIdHash` and `handleHash`; it checks nothing about the +bytes, because the check needs the handle normalized and normalization is +the Consumer's (REQ-PLAT-08B). `IdentityNames._write` is the one place the +equality holds: it derives both nodes from the digests in both modes; when +the plaintext is present it normalizes the handle, requires +`keccak256(bytes(userId)) == userIdHash` and `keccak256(bytes(normalized)) +== handleHash`, and only then stores or publishes the strings. +`publishName` with empty plaintext reverts. X and GitHub verifiers return +zero digests and keep the plaintext path they have. **Recommended:** two +optional payload fields, no change to the signature of `claim`, and the +user decides at submission time, after proving. It keeps REQ-PLAT-08B honest +in the form that matters: the Consumer still derives the key from a +proof-bound value and still refuses a caller-supplied key; the plaintext is +accepted only because the proof binds its digest. + +**An explicit flag on `claim`.** Rejected. Two sources of truth for one fact, +and nothing to do when they disagree. + +**A mode bit as a circuit public input.** Rejected. It bakes the disclosure +choice into the proof, costs an input, and stops the user from changing their +mind between proving and submitting. + +The event gains `bool disclosed` and keeps `string userId` and `string +handle`, both empty when private. `disclosed` is a fact about the event: +this event carries the plaintext. It says nothing about earlier events, and +it cannot, because a plaintext once emitted is public for good. An empty +string is unambiguous, since the normalizer rejects an empty handle, but the +bool is what an indexer reads without parsing. A separate indexed +`handleHash` would duplicate `handleNode`. + +### Default, and moving between modes + +**Google is private unless the user sends the plaintext.** That is the premise +of this note. Public by default with a private opt-in would leave §12's +rationale intact and is recorded here only as the alternative not taken. + +**X and GitHub stay as they are.** Their handles are public on the platform +by construction, their circuits reveal transcript bytes the notary attested, +and §12's reasoning holds for them. The zero `handleHash` leaves the door +open. + +**Private to public, later.** By decision a call, not a new claim, and the +contract already has its inverse, `unpublish`, so the call is +`publish(platformId, string userId, string handle)`: normalize the handle, +derive both nodes, require that the caller owns them and that the binding is +live, set `published`, emit `IdentityPublished(owner, platformId, idNode, +handleNode, userId, handle)`. No proof is needed; the preimages are the +proof. The address is public from that block on. + +A handle is retired when the same account claims again under another one: +the old node's owner is cleared and `HandleRetired` emitted +(`IdentityNames.sol:660-667`), and the old name is free for another account +to take. `publish` refuses a retired handle, by decision, however well the +caller knows its preimage. Accepting would have emitted the +plaintext of an address the wallet no longer holds and set `published` to +it, so `reverseOf`, which returns the stored string as it is (`:770`), would +name that address until the next claim; `primaryOf` already refuses a +published string whose node the wallet does not own (`:787-792`). So +`publish` requires the handle node to be the account's current one and the +wallet to own it, and the owner of a private binding can publish exactly +what they hold. + +The same scenario settles one more rule. A claim from a wallet that has +already published refreshes the published string to the handle just proved, +so a rename never leaves a stale name on display (`:626-633`). A private +claim carries no plaintext to refresh with, so it deletes the publication +instead; otherwise `reverseOf` keeps showing the old address after the +account moved on. + +Two facts therefore live apart. **Disclosure** is history: once any event +has carried an identity's plaintext, a private claim, a private refresh of +the same nodes, or `unpublish` afterwards, the plaintext is known and stays +known. **Publication** is state: whether the wallet currently displays the +name, set by `publishName` on a disclosed claim or by `publish`, cleared by +`unpublish` or by a private claim. The sequence private claim, `publish`, +private refresh ends with the plaintext known, the publication cleared, and +the last event saying `disclosed: false`, all three true at once. + +**Public to private.** Impossible, and the note should say so where users +read it. The log line exists. `unpublish` already documents this for the +storage string; the same sentence covers the event. + +### Downstream + +**usernames-indexer.** `names.handles.handle` and `names.ids.user_id` become +nullable and `names.published` stays plaintext, since only a disclosed +binding can be published. `resolve_handle` currently selects `WHERE h.handle = $3` +(`handle_lookup`, `usernames-core/src/db.rs:1186-1199`) and moves to the node: fold the query, +normalize, derive `handleNode` with the function the indexer already has +(`nodes.rs`), select by node. `/v1/search` excludes private bindings by +construction, since there is no text to match. The recompute check that +compares a re-derived node with the emitted topic (`db.rs:791-798`) skips the +handle and the id when the event carries none; `/v1/resolve/id` moves to +the node the same way. The indexer keeps the two facts apart as the +contract does: `IdentityPublished` fills a `names.ids` or `names.handles` +row whose plaintext was null and sets `names.published`; a later private +event never nulls a plaintext row, only `names.published` follows the +publication state. Responses carry `disclosed`, meaning the plaintext of +this identity is known, from any event that ever carried it, and +`published`, the current state; a private-only identity returns `handle: +null` and `userId: null`. The resolve routes keep the plaintext in the path, +by decision; a `GET /v1/resolve/node/{platform}/{handleNode}` where the +client hashes, so the server never sees the address, can be added later for +clients that want it, as the ENS gateway already works. + +**ENS gateway.** Because the node is the same in both modes, a private +binding resolves as `alice.google.handles.link` with no change at all: the +gateway derives the node from the labels and falls back to +`resolveHandle` over RPC (`bin/usernames-api/src/ens.rs:416`). That is +the decided behaviour; the `disclosed` field still tells an indexer which +bindings have a string to display. + +**TypeScript claim SDK.** The Google proof type carries `email` as a required +string; it becomes optional, absent for private. Client-side normalization +must produce the bytes the circuit hashes, and `handle.ts` already +reproduces the shared vector table, so the only new obligation is that the +circuit input builder feeds the circuit the raw bytes and expects the digest +of the folded ones. The local result may keep returning the email to the +caller; it never leaves the browser. + +**Demo.** The "publish the handle on chain" checkbox becomes a three-way +choice: private, public, public and published. + +### Spec changes + +- REQ-PLAT-08A: a carve-out for a profile whose circuit exposes a digest + instead of bytes. The circuit MAY apply the profile's published + normalization to the bytes it digests, and MUST apply exactly that + normalization, so the digest equals what the Consumer computes from the + plaintext. +- REQ-PLAT-08B: the Consumer MUST NOT accept a caller-supplied normalized + handle or pre-hashed key; it MAY accept caller-supplied raw bytes when the + proof binds their digest, and MUST derive the key from the digest. +- REQ-PLAT-16B: row three becomes "digest of the signed `sub`" and row four + "digest of the normalized `email`". The sentence forbidding a detached + second representation stays true, since the raw bytes are no longer + exposed at all. +- A new REQ-PLAT-16C: the Platform Verifier MUST return both digests to the + Consumer, and MUST pass any plaintext the Submission carries through + unchanged and unchecked; the equality check is the Consumer's, under + REQ-PLAT-08B, because it needs the handle normalized. +- REQ-PLAT-16D, the id's validation in the circuit: `sub` nonempty, + printable ASCII, no quote, digested over exactly its length; a `sub` + beyond the profile's buffer fails to prove. +- REQ-COMMON-05E: "the canonical `userId`, the raw handle bytes" becomes + "the canonical `userId` and the raw handle bytes, or neither when the + profile discloses digests, and both digests". +- §12: the handle and the user identifier are confidential by default for a + digest profile; the client identifier stays published. +- New assumption and property, in the skill's chain: an assumption that + keccak256 is preimage resistant, with the explicit note that it does not + protect a low-entropy preimage from enumeration; a property that an + undisclosed handle is recoverable from no chain artifact except by + guessing its exact preimage; the requirements above upholding it; and a + test, TEST-PLAT-20A, that the same account claimed public and private + lands on the same two nodes, and that a private claim's decoded calldata + and decoded events carry neither the email nor the account id. +- A new Google `ceremonyVersion`, since the public inputs change. The + platform's `rules` do not change, so the old version retires without a + namespace split. + +## The recommendation + +Hash in the circuit, normalize in the circuit, keccak256 over the email and +over `sub`, mode by presence of the plaintexts in the payload, private by +default for Google alone, `publish` in the first version, ENS forward names +resolving for private bindings as for public ones, and the resolve routes as +they are. This is the one combination where neither the email nor the +account id reaches calldata, where both nodes are identical across modes, +versions and readers, where the verifier reuses a pattern it already has for +the audience, and where the payload change is two optional fields. + +It does not protect against confirmation of a suspected address or account +id by whoever already holds it, the query plaintext in the indexer's access +logs, or the visibility of the binding itself. + +## What to implement, in order + +1. **Spec** (`libid`): the amendments above, and a note on the vector table + that the trim rows do not apply to the circuit, which refuses a space + instead. Done when a reader can follow the new property to its + requirements and its test. +2. **Circuit** (`libid-circuits`): the fold-and-shape loop, the keccak + library, `handle_hash` in place of `email_packed` and `sub_hash` in place + of `sub_packed`, a gate count in the commit. Done when `Alice@Gmail.com` + and `alice@gmail.com` prove the same digest, and a space, two `@`, an + empty local part and a garbage tail each fail to prove. +3. **Contracts** (`libid-contracts`): a new Google verifier version, `bytes + userId` and `bytes email` in the payload, `userIdHash` and `handleHash` + in `VerifiedClaim`, the equality checks and the hash-derived nodes in + `_write`, `disclosed` in the event, `publish`, the circuit pin. Done when + the same account claimed public and then private lands on the same two + nodes; when a private transaction, made with recognizable test values, + carries no plaintext email or account id in its decoded calldata or its + decoded events, the payload fields being empty and the event strings + empty, rather than a byte search over proof bytes that can contain + anything; and when `publishName` without plaintext reverts. +4. **Indexer, SDK, demo**: nullable handle and id, node-keyed resolve for + both, `IdentityPublished` filling the plaintext rows and the publication + state, optional `userId` and `email`, the three-way choice. Done when + resolve finds a private binding by exact address or account id, search + never returns it, `reverseOf` is empty for it, and the sequence private + claim, `publish`, private refresh reads back as known, unpublished, + last event undisclosed. The node-keyed lookups are proven against the + chain, not against a hand-computed hash: one test claims on a local + chain and checks the nodes the indexer recomputes against the + `IdentityBound` event the contract emitted, the check `db.rs:791-798` + already makes on every event. + +## What we deliberately do not do + +- **A salted commitment.** `verify_hash_commit` in the bearer-link circuit + already proves `SHA256(plaintext || blinder)` (`bearer-link/src/main.nr:51-70`) + and would apply to an email unchanged. It defeats guessing, and it defeats + resolving: nobody can find Alice without her blinder. That is a different + product, a stealth address, and it can be added later as a third mode with + its own node tag without disturbing this one. +- **Changing X and GitHub.** Their handles are public where they live. +- **Per-chain variants.** The node is chain-independent today and stays so. + +## Open decisions + +Decided: `alice.google.handles.link` resolves for a private binding, so the +name is the address and a wallet that resolves it has confirmed it, and with +it that confirming a suspected address by hashing it is accepted; `sub` is +hidden with the handle; private to public is a `publish` call in the first +version, not a new claim; the resolve routes keep the plaintext in the +request line, the indexer's operator being trusted with what people resolve; +and `publish` refuses a handle retired by a later claim of the same account, +as set out under "Default, and moving between modes". + +Nothing in this note is left open. + +The indexer and the deployer derive nodes and platform ids from the same +generated table the contract's constants come from (`libid-identity` +0.13, `usernames-indexer` `Cargo.toml:20-22`, `libid-deploy` +`platforms.rs:174-206`), and the indexer's pinned vectors reproduce the +contract's derivation (`nodes.rs:302-320`). Nothing needs aligning before +the node-keyed lookups; the test above keeps it that way. + +## Sources + +Line numbers are against each repository's `origin/main` on 2026-09-22 +unless a revision is named; spec references are against `libid` +`origin/main` at `002c201`. + +- `specs/platform-ceremonies.md`: REQ-PLAT-08A/08B/08C and TEST-PLAT-20 + (134-166), REQ-PLAT-16B (298-313), REQ-PLAT-19A (322-328), REQ-PLAT-20. +- `specs/ceremony-common.md`: REQ-COMMON-05E (553-561), §12 privacy + statement (1497-1500). +- `libid-circuits/circuits/oidc-google/src/main.nr`: public inputs (87-97), + email checks (125-147), audience hash (259-267), email packing (290-307); + `circuits/bearer-link/src/main.nr:51-70` (`verify_hash_commit`); + `toolchain.env`. +- `libid-contracts/solidity/contracts/ceremony/GooglePlatformVerifier.sol`: + offsets (43-50), `GoogleProof` (76-84), audience check (206-210), handle + (237); `ceremony/ICeremony.sol:66-84` (`VerifiedClaim`); + `identity/IdentityNames.sol`: invariant (106-112), event (267-277), + `_write` (600-640), `unpublish` (678-686); `identity/IdentityNodes.sol` + (25-41); `identity/HandleNormalizer.sol` (100-148); + `identity/handles.json` (google, 52-73). +- `usernames-indexer` at `origin/main` `bec6765`, `crates/usernames-core`: + `migrations/001_schema.sql` (47, 73, 83-91), `src/db.rs` (791-798, + 1091-1100, 1133, 1186-1199), `src/api/mod.rs` (79-84), `src/ens.rs` + (500-542), `src/nodes.rs` (241-243, 302-320); `bin/usernames-api/src/ens.rs` + (416). `chain-configurations` at `origin/main` `f15832a`: + `bin/libid-deploy/src/platforms.rs` (174-206). +- Gate counts: `nargo info` and `bb gates` on scratch copies of the circuit + at nargo 1.0.0-beta.25 and bb 5.2.0, with `noir-lang/keccak256` v0.1.3. From 328b7d8686ce37b96686c705f395b6610cb887c3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 22 Sep 2026 17:35:47 +0300 Subject: [PATCH 02/11] docs(specs): identity digests and disclosure for the Google profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A digest profile's Proving Circuit exposes the handle and the canonical userId as keccak256 digests instead of bytes, and the Consumer keys the binding on them, so an identity resolves for whoever knows it and is published only when its owner sends the plaintext. Google is the launch digest profile, at Platform Ceremony Version 2. platform-ceremonies.md: REQ-PLAT-08A and 08B admit a circuit that normalizes what it digests and a Consumer that keys on digests; §2.1b adds REQ-PLAT-08D to 08F (keys from the digests, plaintext accepted only when it hashes to them, the disclosure call and what it refuses, the event's disclosed flag) with TEST-PLAT-20A; REQ-PLAT-16B lists the two digests as public inputs; REQ-PLAT-16C returns them and passes plaintext through unchecked; REQ-PLAT-16D moves the sub and email validation into the circuit; TEST-PLAT-06A exercises the three; §9 states what the digests protect and what they do not. ceremony-common.md: ASM-HASH-01 (preimage resistance, with the enumeration caveat), SP-PRIV-01, REQ-COMMON-05E returns digests where a profile exposes them, and §12 replaces the statement that every handle is published deliberately with the digest profile's confidentiality and its limits. libid.md names the guarantee among the enforceable ones. The design note carries the rationale and now points at this text. Assisted-by: Claude Fable 5.1 Signed-off-by: SupremaLex --- design/private-gmail-handle.md | 63 +++++--------- specs/ceremony-common.md | 47 ++++++++-- specs/libid.md | 6 +- specs/platform-ceremonies.md | 154 +++++++++++++++++++++++++++++---- 4 files changed, 202 insertions(+), 68 deletions(-) diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md index c5d67865..c90474fe 100644 --- a/design/private-gmail-handle.md +++ b/design/private-gmail-handle.md @@ -1,10 +1,10 @@ # A private mode for Gmail handles -**Status: design proposal.** Nothing here is built. It is not a protocol spec -in the sense of `specs/` and defines no `ASM-*`/`SP-*`/`REQ-*` identifiers; it -names the ones that would have to change, and the parts that carry trust -assumptions graduate into `specs/platform-ceremonies.md` and -`specs/ceremony-common.md` if the design is approved. +**Status: design proposal, with its specification written.** Nothing here +is built. This note is the rationale; the normative text is in +`specs/platform-ceremonies.md` (§2.1b, REQ-PLAT-08D to 08F, REQ-PLAT-16C +and 16D, TEST-PLAT-06A and 20A, Google version 2) and +`specs/ceremony-common.md` (ASM-HASH-01, SP-PRIV-01, REQ-COMMON-05E, §12). ## The thing that must work @@ -350,41 +350,24 @@ choice: private, public, public and published. ### Spec changes -- REQ-PLAT-08A: a carve-out for a profile whose circuit exposes a digest - instead of bytes. The circuit MAY apply the profile's published - normalization to the bytes it digests, and MUST apply exactly that - normalization, so the digest equals what the Consumer computes from the - plaintext. -- REQ-PLAT-08B: the Consumer MUST NOT accept a caller-supplied normalized - handle or pre-hashed key; it MAY accept caller-supplied raw bytes when the - proof binds their digest, and MUST derive the key from the digest. -- REQ-PLAT-16B: row three becomes "digest of the signed `sub`" and row four - "digest of the normalized `email`". The sentence forbidding a detached - second representation stays true, since the raw bytes are no longer - exposed at all. -- A new REQ-PLAT-16C: the Platform Verifier MUST return both digests to the - Consumer, and MUST pass any plaintext the Submission carries through - unchanged and unchecked; the equality check is the Consumer's, under - REQ-PLAT-08B, because it needs the handle normalized. -- REQ-PLAT-16D, the id's validation in the circuit: `sub` nonempty, - printable ASCII, no quote, digested over exactly its length; a `sub` - beyond the profile's buffer fails to prove. -- REQ-COMMON-05E: "the canonical `userId`, the raw handle bytes" becomes - "the canonical `userId` and the raw handle bytes, or neither when the - profile discloses digests, and both digests". -- §12: the handle and the user identifier are confidential by default for a - digest profile; the client identifier stays published. -- New assumption and property, in the skill's chain: an assumption that - keccak256 is preimage resistant, with the explicit note that it does not - protect a low-entropy preimage from enumeration; a property that an - undisclosed handle is recoverable from no chain artifact except by - guessing its exact preimage; the requirements above upholding it; and a - test, TEST-PLAT-20A, that the same account claimed public and private - lands on the same two nodes, and that a private claim's decoded calldata - and decoded events carry neither the email nor the account id. -- A new Google `ceremonyVersion`, since the public inputs change. The - platform's `rules` do not change, so the old version retires without a - namespace split. +Written, on this branch. The map, for a reader coming from the specs: + +- `platform-ceremonies.md`: §2.1a's lead-in and REQ-PLAT-08A/08B admit a + circuit that normalizes what it digests and a Consumer that keys on + digests; a new §2.1b defines the digest profile with REQ-PLAT-08D (keys + from the digests, plaintext accepted only when it hashes to them, both or + neither), 08E (the disclosure call, what it refuses, the publication a + private claim clears), 08F (the event's disclosed flag) and + TEST-PLAT-20A; REQ-PLAT-16B lists the two digests as public inputs; + REQ-PLAT-16C has the verifier return them and pass plaintext through + unchecked; REQ-PLAT-16D moves the `sub` and `email` validation into the + circuit; TEST-PLAT-06A exercises the three; Google is Platform Ceremony + Version 2; §9 states what the digests protect and what they do not. +- `ceremony-common.md`: ASM-HASH-01, SP-PRIV-01, REQ-COMMON-05E returns + the digests where a profile exposes them, §12 replaces "published + deliberately" for the handle and user identifier with the digest + profile's confidentiality and its limits. +- `libid.md`: one sentence among the enforceable guarantees. ## The recommendation diff --git a/specs/ceremony-common.md b/specs/ceremony-common.md index dcba2081..644646ee 100644 --- a/specs/ceremony-common.md +++ b/specs/ceremony-common.md @@ -224,6 +224,11 @@ Attestation Count: The number of entries in the closed attestation list a - ASM-BROWSER-01: The Canonical Runtime executes unmodified, and the user agent enforces the same-origin policy over authorization responses. +- ASM-HASH-01: + keccak256 is preimage resistant: given a digest, no party recovers a + preimage except by hashing candidates and comparing. A digest is not a + secret: a low-entropy preimage, such as an email address, is recovered by + hashing guesses, and this assumption gives no protection against that. ## 4. Security properties @@ -267,6 +272,12 @@ on it. Within one Consumer deployment, one ceremony authorizes at most one authoritative effect. Depends on ASM-CHAIN-01, ASM-CHAIN-02. Evidence: checked invariant in the Consumer. +- SP-PRIV-01: + An identity a Consumer holds undisclosed is recoverable from no chain + artifact, whether Submission calldata, event, or storage, except by hashing + a candidate handle or user identifier and comparing it with the identity's + keys. Depends on ASM-HASH-01, ASM-PROOF-01. Evidence: conformance tests + (supporting, not proving) plus the preimage resistance of keccak256. ## 5. Authorization digest @@ -550,12 +561,14 @@ an identity session — so one Submission on either path pays two fees. Platform Verifier MUST treat each of those decisions as final. The Platform Verifier MUST NOT call the Notary Service where its Platform Profile requires no attestation. -- REQ-COMMON-05E (upholds SP-CLIENT-01): +- REQ-COMMON-05E (upholds SP-CLIENT-01, SP-PRIV-01): The Platform Verifier MUST return its verified fields: the Authorization Digest it recomputed, the operation domain and Authorized Transaction Data it decoded, the Platform Ceremony Version it implements, the client - identifier, the canonical `userId`, the raw handle bytes, and - `metadataObservedAt`. Necessity: an authenticated `userId`, handle, and + identifier, the canonical `userId` and the raw handle bytes where its + Platform Profile exposes them, the `userId` digest and the handle digest + where its Platform Profile exposes those instead (platform REQ-PLAT-16C), + and `metadataObservedAt`. Necessity: an authenticated `userId`, handle, and observation time are what the ceremony exists to produce; the digest is the Consumer's replay nullifier, which it cannot recompute without reading the payload. The Consumer trusts these fields as it trusts the Platform Verifier @@ -1452,7 +1465,7 @@ the constructions that role implements. ## 12. Security Considerations This document enforces SP-BIND-01, SP-CLIENT-01, SP-EXCHANGE-01, -SP-FRESH-01, and SP-REPLAY-01 under the assumptions of §3. +SP-FRESH-01, SP-REPLAY-01, and SP-PRIV-01 under the assumptions of §3. Replay within one Consumer deployment is prevented by `authorizationNonce` and REQ-COMMON-03. Replay across Consumer Chains whose Chain Profiles use @@ -1494,11 +1507,27 @@ liveness dependency only: they cannot forge evidence, but an unbounded fee stops every ceremony for the platforms whose profiles carry attestations, and leaves a profile with no attestation unaffected. -The handle, the platform user identifier, and the client identifier are -published deliberately. A binding exists to be read, and each of these values -is already discoverable from the identity platform, so the protocol treats -none of them as confidential. Only the bearer, the client secret, and the -transcript bytes outside a profile's revealed ranges stay withheld for good. +The client identifier is published deliberately: a binding exists to be +read, and the client is discoverable from the identity platform. The handle +and the platform user identifier are published where a Platform Profile +exposes them as bytes, X and GitHub at launch, whose handles are public on +the platform itself; a Platform Profile that exposes them as digests, Google +at launch, keeps both confidential until their owner discloses them +(platform §2.1b), and that confidentiality is SP-PRIV-01. The bearer, the +client secret, and the transcript bytes outside a profile's revealed ranges +stay withheld for good. + +SP-PRIV-01 is a statement about the chain's artifacts, not about the +identity. The keys an undisclosed identity is stored under are unsalted +digests of its normalized handle and its user identifier, so that whoever +already knows an address can resolve it; by the same arithmetic, whoever +suspects an address, or holds the user identifier from another relying +party, confirms the binding by hashing it (ASM-HASH-01). The property does +not hide that a Transaction Author holds some identity on the platform, nor +when it was observed, and it says nothing about the query a resolver +receives off chain: a resolver that logs the handles it is asked for holds +what the chain does not. A salted commitment would refuse the guess and the +honest resolver alike; this protocol does not offer one. For a PKCE profile, the raw `authorizationNonce` is withheld until the token exchange completes, per REQ-COMMON-14. The Submission publishes it afterwards as the same nonce already required to recompute the Authorization Digest, diff --git a/specs/libid.md b/specs/libid.md index 645b57db..2f07155a 100644 --- a/specs/libid.md +++ b/specs/libid.md @@ -114,7 +114,11 @@ GitHub; the Proving Circuit proves only what cannot be read from that evidence, which is Google's signature relation and, on X and GitHub, that one hidden bearer opens both sessions' commitments. The Consumer enforces replay rejection by recording every Authorization Digest it accepts before applying -an effect (REQ-COMMON-03, REQ-COMMON-03A). The Canonical Runtime +an effect (REQ-COMMON-03, REQ-COMMON-03A). For a profile that exposes identity +digests, Google at launch, the Consumer keys the binding on the digests and +holds the handle and user identifier undisclosed until their owner +publishes them (SP-PRIV-01); it does not prevent confirmation of a guessed +identity by hashing. The Canonical Runtime locally enforces the selected OAuth client and redirect profile. The protocol assumes the named identity-platform parser, PKCE, delivery, notary, browser, verifier-soundness, and chain behaviors. It diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index 5bd27682..c9ac05b9 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -130,19 +130,28 @@ replace the immutable `userId`. ### 2.1a Handle normalization -Proof layers work with raw bytes; normalization is a consumption-time -derivation, layered strictly: +Proof layers work with raw bytes and normalization is a consumption-time +derivation, layered strictly, with one exception: in a digest profile +(§2.1b) the Proving Circuit applies that same normalization before it +digests, because the Consumer receives no bytes to normalize. - REQ-PLAT-08A (upholds SP-BIND-01): - The Proving Circuit and the Notary Service MUST NOT case-fold, trim, or - otherwise transform identity bytes. The Consumer MUST receive the handle as - the raw authenticated bytes of its platform source. + The Notary Service MUST NOT case-fold, trim, or otherwise transform + identity bytes. The Proving Circuit of a profile that exposes identity + bytes MUST NOT transform them. The Consumer MUST receive the handle of + such a profile as the raw authenticated bytes of its platform source. The + Proving Circuit of a profile that exposes identity digests (§2.1b) MUST + apply exactly the profile's published normalization to the handle bytes + it digests, and no other transform, so that the digest equals the one the + Consumer derives from the normalized handle. - REQ-PLAT-08B (upholds SP-BIND-01): - The Consumer MUST derive the normalized handle from the - proof-verified raw bytes on its own write path. The Consumer MUST - NOT accept a caller-supplied normalized handle or pre-hashed handle key. - Necessity: the handle arrives inside a proof; a caller supplying the - derived key could name any handle it liked. + The Consumer MUST derive the normalized handle from the proof-verified raw + bytes on its own write path, or the handle key from the proof-verified + handle digest where the profile exposes digests. The Consumer MUST NOT + accept a caller-supplied normalized handle or pre-hashed handle key. The + Consumer MAY accept caller-supplied raw bytes only where the proof binds + their digest, under REQ-PLAT-08D. Necessity: the handle arrives inside a proof; + a caller supplying the derived key could name any handle it liked. - REQ-PLAT-08C: A browser-side normalization exists only for display and local checks. No proof statement or Consumer behavior may rely on it. Necessity: a check @@ -163,7 +172,64 @@ such table is ineligible. Every implementation reproduces the shared handle vector table byte for byte; a caller-supplied normalized handle or pre-hashed key is rejected; and identity bytes transformed before derivation by the Consumer - fail conformance. + fail conformance. For a profile that exposes digests, the Proving Circuit + reproduces the table's case-folding, character-set, and shape rows and + refuses the inputs of its trimming rows, which a signed claim never + carries. + +### 2.1b Identity disclosure + +A digest profile is a Platform Profile whose Proving Circuit exposes the +handle and the canonical `userId` as keccak256 digests rather than bytes; +Google is one (§3.3). The Consumer keys every identity on those digests, +so an identity is resolvable by whoever knows its handle or `userId` +whether or not the bytes were ever published. What a digest profile adds +is the choice of publishing them. + +- REQ-PLAT-08D (upholds SP-BIND-01, SP-PRIV-01): + For a digest profile, the Consumer MUST derive the identity's keys from + the `userId` digest and the handle digest the Platform Verifier returns, + in every Submission. The Transaction Author MAY include the plaintext + `userId` and handle in a Submission, both or neither. Where a Submission + carries them, the Consumer MUST normalize the handle under §2.1a. The + Consumer MUST reject a Submission whose plaintext does not hash, as + `keccak256` of the `userId` bytes and of the normalized handle, to the + two digests. The Consumer MUST accept + a Submission that carries neither as an undisclosed binding. The Consumer + MUST reject a request to publish a name for an undisclosed binding. +- REQ-PLAT-08E (upholds SP-PRIV-01): + The Consumer MUST offer the owner of an undisclosed binding a disclosure + call that takes the plaintext `userId` and handle. The Consumer MUST + accept a disclosure only when the keys derived from it are the ones the + caller's binding currently holds. The Consumer MUST refuse a disclosure of + a key retired by a later claim of the same account. The Consumer MUST NOT + require a new proof for a disclosure; the preimages are the evidence. The + Consumer MUST clear any name the Transaction Author had published for the + platform when it accepts a Submission that carries no plaintext. + Necessity: a published name is + read as the wallet's current handle, and a wallet must not display, or + disclose under its name, a handle it no longer holds. +- REQ-PLAT-08F (upholds SP-PRIV-01): + The Consumer MUST state in every binding or disclosure event it emits + whether the event carries plaintext. The Consumer MUST put the plaintext + `userId` and handle in an event only when the Submission or disclosure + carried them. Necessity: an event that + carried plaintext is public for good; a reader distinguishes an identity + whose plaintext is known from one currently displaying a name only if the + events say which is which. A private Submission after a disclosure leaves + the plaintext known and the name unpublished, and its event says + undisclosed; all three hold at once. +- TEST-PLAT-20A (exercises REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F): + The same Google account submitted with and without its plaintext lands on + the same two keys. A Submission without plaintext, made with recognizable + test values, carries neither the email nor the `userId` in its decoded + payload or its decoded events. A Submission whose plaintext does not hash + to the digests is rejected. A request to publish a name for an undisclosed + binding is rejected. A disclosure of a handle retired by a later claim of + the same account is rejected, and a disclosure of the current one + publishes it. A Submission without plaintext after a disclosure leaves the + binding resolvable by its handle, its name unpublished, and its event + marked undisclosed. ### 2.2 Metadata ordering and validity ceilings @@ -203,9 +269,13 @@ block an otherwise valid authority operation. ```text identityPlatform = "google" -platformCeremonyVersion = 1 +platformCeremonyVersion = 2 ``` +Version 1 exposes the raw `sub` and `email` bytes as public inputs; version +2 exposes their digests (REQ-PLAT-16B). A Consumer that implements §2.1b +accepts version 2 only. + Google uses direct authentication-only OIDC and has no server-side token exchange. Identity evidence is the signed ID Token delivered in the redirect fragment. @@ -303,14 +373,39 @@ require a verifier that dispatches on the header `alg`; none exists here. |---|---| | Authorization Digest | signed `nonce`, decoded as exactly 32 bytes | | client-identifier digest | `SHA256` of the signed `aud` | - | canonical `userId` | signed `sub` | - | raw `email` bytes | signed `email`; the Consumer derives the normalized handle | + | `userId` digest | `keccak256` of the signed `sub`, exactly as signed (REQ-PLAT-16D) | + | handle digest | `keccak256` of the signed `email` after the normalization of §2.1a (REQ-PLAT-16D) | | evidence timestamp | signed `exp`; used for both `metadataObservedAt` and `proofValidUntil` | | RSA modulus | exact `n` that verified the JWS; `e = 65537` is profile-fixed | The Proving Circuit MUST NOT expose a detached second representation of a - claim. Proofs are over raw bytes; normalization, such as lowercasing the - handle, is the Consumer's decision at consumption time. + claim: the `sub` and `email` bytes appear in no public input, only their + digests do, and each digest is the inner hash of the Consumer's key for + that identity, so a claim disclosed in plaintext and one left undisclosed + key the same binding. +- REQ-PLAT-16C (upholds SP-BIND-01, SP-PRIV-01): + The Platform Verifier MUST return the `userId` digest and the handle + digest as the digests of common REQ-COMMON-05E. Where the Submission + carries the plaintext `sub` and `email`, the Platform Verifier MUST pass + them to the Consumer unchanged and unchecked. The Platform Verifier MUST + NOT derive a normalized handle or a key from them; the check that they + hash to the digests is the Consumer's under REQ-PLAT-08D, because it + needs the normalization of §2.1a. Necessity: one party owns the equality, and it is + the one that owns the normalization. +- REQ-PLAT-16D (upholds SP-BIND-01, SP-PRIV-01): + The Proving Circuit MUST admit a `sub` only under REQ-PLAT-04 and + REQ-PLAT-05, nonempty and of bytes `0x20` through `0x7e`. The Proving + Circuit MUST digest exactly the signed `sub` bytes. The Proving Circuit + MUST admit an `email` only when the normalization of §2.1a admits it. The + Proving Circuit MUST refuse an `email` that normalization would trim. The + Proving Circuit MUST digest exactly the normalized `email` bytes. The + Proving Circuit MUST fail to prove, rather than truncate, a value longer + than its buffer for it; the buffer lengths are constants of the Platform + Ceremony Version. A `sub` or `email` whose JSON encoding escapes a byte + cannot satisfy the byte comparison of common REQ-COMMON-19 and + REQ-COMMON-19B, which excludes the closing delimiter, and so fails to + prove. Necessity: a verifier that receives a digest inspects nothing, + so every check the bytes need happens where the bytes are. - REQ-PLAT-17 (upholds SP-BIND-01): The Proving Circuit MUST prove the signed `iss` equals `https://accounts.google.com`. @@ -1141,6 +1236,16 @@ Platform Verifier, Notary Service, Consumer. whose supplied `aud` bytes do not hash to the audience public input is rejected, and an accepted one returns those exact bytes as the client identifier. +- TEST-PLAT-06A (exercises REQ-PLAT-16B, REQ-PLAT-16C, REQ-PLAT-16D): + `Alice@Gmail.com` and `alice@gmail.com` prove the same handle digest, and + it equals `keccak256` of the normalized handle the Consumer derives from + the plaintext. An `email` with a space, two `@`, an empty local part, a + byte outside the normalization's alphabet, or bytes past its signed length + cannot satisfy the circuit; neither can an empty `sub`, a `sub` byte + outside `0x20` through `0x7e`, or a `sub` or `email` longer than its + buffer. The public inputs carry no `sub` or `email` byte. The Platform + Verifier returns both digests, returns plaintext the Submission carried + byte for byte, and returns none where the Submission carried none. - TEST-PLAT-07 (exercises REQ-PLAT-22, REQ-PLAT-09, REQ-PLAT-09A): A proof at or after `proofValidUntil`, and a token-attestation creation time more than `maxFutureAttestationSkew` ahead of Block Time, are rejected. An @@ -1288,8 +1393,8 @@ Platform Verifier, Notary Service, Consumer. ## 9. Security Considerations This document enforces SP-BIND-01, SP-CLIENT-01, SP-EXCHANGE-01, and -SP-FRESH-01 for the launch platforms, under the assumptions of -[common §3](ceremony-common.md#3-assumptions). +SP-FRESH-01 for the launch platforms, and SP-PRIV-01 for Google, under the +assumptions of [common §3](ceremony-common.md#3-assumptions). Google is the only platform whose evidence is a bearer artifact: an ID Token is complete evidence to whoever holds it. Its delivery is therefore @@ -1345,6 +1450,19 @@ Google's JWKS rotation makes the trusted modulus set a liveness dependency (REQ-PLAT-24): every Google ceremony fails closed while Google signs with an untrusted modulus. +Google is the launch digest profile (§2.1b): its `sub` and `email` reach the +chain only as keccak256 digests, and the Consumer keys the binding on them. +The confidentiality this buys is stated in +[common §12](ceremony-common.md#12-security-considerations) together with +what it does not buy: a guessed address or a `sub` held by another relying +party confirms the binding by hashing, the binding's existence and +observation time stay public, and a name that resolves through an ENS +gateway or an off-chain resolver is the address itself. The normalization +of §2.1a now runs inside the Proving Circuit for this profile +(REQ-PLAT-16D), which fixes the profile's handle rules into the Platform +Ceremony Version: a change to those rules is a new version, never an edit, +or a disclosed claim stops hashing to its own key. + ## 10. References Normative: [RFC6749], [RFC7636], [RFC7515], [RFC7517], [RFC7518], [RFC7519], From 3e83a649f8de923af4becd9f89e1fce50c79ff6f Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 16:27:20 +0300 Subject: [PATCH 03/11] docs(specs): the Google digest profile is version 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing is released, so the digest statement replaces version 1 in place rather than arriving as version 2 beside it: platformCeremonyVersion stays 1, and the prose on version 1 exposing raw bytes and on a Consumer accepting version 2 only is gone. §9 says the handle rules are part of the profile's proof statement instead of prescribing a new version for every rules change. The design note drops its version-2 references and the old-version claim. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- design/private-gmail-handle.md | 15 ++++++++------- specs/platform-ceremonies.md | 14 +++++--------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md index c90474fe..69555f33 100644 --- a/design/private-gmail-handle.md +++ b/design/private-gmail-handle.md @@ -3,7 +3,7 @@ **Status: design proposal, with its specification written.** Nothing here is built. This note is the rationale; the normative text is in `specs/platform-ceremonies.md` (§2.1b, REQ-PLAT-08D to 08F, REQ-PLAT-16C -and 16D, TEST-PLAT-06A and 20A, Google version 2) and +and 16D, TEST-PLAT-06A and 20A) and `specs/ceremony-common.md` (ASM-HASH-01, SP-PRIV-01, REQ-COMMON-05E, §12). ## The thing that must work @@ -148,7 +148,7 @@ The choice decides whether Google's `handleNode` stays what it is today. **keccak256 of the normalized bytes.** The digest is the inner hash of `handleNode` exactly (`IdentityNodes.sol:39-41`), so the node of a private -claim, a public claim, an old-version claim and an ENS lookup is one value. +claim, a public claim and an ENS lookup is one value. The contract, the indexer's node arithmetic (`usernames-core/src/nodes.rs`), the TypeScript resolver and the ENS gateway keep their derivation untouched. The pinned Noir (`toolchain.env`: nargo 1.0.0-beta.25, bb 5.2.0) has no @@ -361,8 +361,9 @@ Written, on this branch. The map, for a reader coming from the specs: TEST-PLAT-20A; REQ-PLAT-16B lists the two digests as public inputs; REQ-PLAT-16C has the verifier return them and pass plaintext through unchecked; REQ-PLAT-16D moves the `sub` and `email` validation into the - circuit; TEST-PLAT-06A exercises the three; Google is Platform Ceremony - Version 2; §9 states what the digests protect and what they do not. + circuit; TEST-PLAT-06A exercises the three; §9 states what the digests + protect and what they do not. The Google profile stays Platform Ceremony + Version 1: nothing is released, so its statement is edited in place. - `ceremony-common.md`: ASM-HASH-01, SP-PRIV-01, REQ-COMMON-05E returns the digests where a profile exposes them, §12 replaces "published deliberately" for the handle and user identifier with the digest @@ -376,8 +377,8 @@ over `sub`, mode by presence of the plaintexts in the payload, private by default for Google alone, `publish` in the first version, ENS forward names resolving for private bindings as for public ones, and the resolve routes as they are. This is the one combination where neither the email nor the -account id reaches calldata, where both nodes are identical across modes, -versions and readers, where the verifier reuses a pattern it already has for +account id reaches calldata, where both nodes are identical across modes +and readers, where the verifier reuses a pattern it already has for the audience, and where the payload change is two optional fields. It does not protect against confirmation of a suspected address or account @@ -395,7 +396,7 @@ logs, or the visibility of the binding itself. of `sub_packed`, a gate count in the commit. Done when `Alice@Gmail.com` and `alice@gmail.com` prove the same digest, and a space, two `@`, an empty local part and a garbage tail each fail to prove. -3. **Contracts** (`libid-contracts`): a new Google verifier version, `bytes +3. **Contracts** (`libid-contracts`): the Google verifier regenerated, `bytes userId` and `bytes email` in the payload, `userIdHash` and `handleHash` in `VerifiedClaim`, the equality checks and the hash-derived nodes in `_write`, `disclosed` in the event, `publish`, the circuit pin. Done when diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index c9ac05b9..eb6b047e 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -269,13 +269,9 @@ block an otherwise valid authority operation. ```text identityPlatform = "google" -platformCeremonyVersion = 2 +platformCeremonyVersion = 1 ``` -Version 1 exposes the raw `sub` and `email` bytes as public inputs; version -2 exposes their digests (REQ-PLAT-16B). A Consumer that implements §2.1b -accepts version 2 only. - Google uses direct authentication-only OIDC and has no server-side token exchange. Identity evidence is the signed ID Token delivered in the redirect fragment. @@ -1458,10 +1454,10 @@ what it does not buy: a guessed address or a `sub` held by another relying party confirms the binding by hashing, the binding's existence and observation time stay public, and a name that resolves through an ENS gateway or an off-chain resolver is the address itself. The normalization -of §2.1a now runs inside the Proving Circuit for this profile -(REQ-PLAT-16D), which fixes the profile's handle rules into the Platform -Ceremony Version: a change to those rules is a new version, never an edit, -or a disclosed claim stops hashing to its own key. +of §2.1a runs inside the Proving Circuit for this profile (REQ-PLAT-16D), so +the profile's handle rules are part of its proof statement: rules that the +Consumer and the circuit do not share make a disclosed claim stop hashing to +its own key. ## 10. References From 0395742e4b80021b812ac607b11f00b5b7e0ff89 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 16:34:56 +0300 Subject: [PATCH 04/11] docs(specs): disclosure is history, publication is state, and the user chooses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2.1b defines the two facts it had used without defining: an identity is disclosed once any transaction has carried its plaintext, and a name is published while the Consumer holds one to display. - REQ-PLAT-08D: the Consumer's Authorized Transaction Data for a binding carries whether the Submission discloses, so the choice is the user's and committed in the digest; a Submission carrying one of the two plaintexts, or plaintext its data does not authorize, is rejected; a private Submission keeps the published name when it re-proves the same handle and clears it otherwise (moved here from 08E, and narrowed). - REQ-PLAT-08E: the disclosure call publishes, and requires the identity and handle keys to be each other's current pair, in both directions, and the caller to own both, which refuses a handle another account of the same wallet took over; its necessity says a refusal protects the display, not the plaintext. - REQ-PLAT-08F: events carry the normalized handle. - REQ-PLAT-08A/08B: the digesting circuit refuses where the table trims; the Consumer normalizes whatever handle bytes it receives, taking none as already normalized. - TEST-PLAT-20A exercises each new rule. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/platform-ceremonies.md | 113 ++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 40 deletions(-) diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index eb6b047e..42b8139b 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -141,17 +141,21 @@ digests, because the Consumer receives no bytes to normalize. bytes MUST NOT transform them. The Consumer MUST receive the handle of such a profile as the raw authenticated bytes of its platform source. The Proving Circuit of a profile that exposes identity digests (§2.1b) MUST - apply exactly the profile's published normalization to the handle bytes - it digests, and no other transform, so that the digest equals the one the - Consumer derives from the normalized handle. + apply the profile's published normalization to the handle bytes it + digests, refusing any input that normalization would trim rather than + trimming it (REQ-PLAT-16D), and no other transform, so that the digest + equals the one the Consumer derives from the normalized handle. - REQ-PLAT-08B (upholds SP-BIND-01): The Consumer MUST derive the normalized handle from the proof-verified raw bytes on its own write path, or the handle key from the proof-verified handle digest where the profile exposes digests. The Consumer MUST NOT - accept a caller-supplied normalized handle or pre-hashed handle key. The - Consumer MAY accept caller-supplied raw bytes only where the proof binds - their digest, under REQ-PLAT-08D. Necessity: the handle arrives inside a proof; - a caller supplying the derived key could name any handle it liked. + accept a caller-supplied pre-hashed handle key. The Consumer MUST + normalize whatever handle bytes it receives, taking none as already + normalized, and store and emit the normalized form. Where the profile exposes digests, the Consumer MAY accept + caller-supplied handle bytes only under REQ-PLAT-08D or REQ-PLAT-08E, + whose digest or key comparison binds them to a proof. Necessity: the + handle arrives inside a proof; a caller supplying the derived key could + name any handle it liked. - REQ-PLAT-08C: A browser-side normalization exists only for display and local checks. No proof statement or Consumer behavior may rely on it. Necessity: a check @@ -186,50 +190,79 @@ so an identity is resolvable by whoever knows its handle or `userId` whether or not the bytes were ever published. What a digest profile adds is the choice of publishing them. +Two facts about such an identity are kept apart. An identity is +**disclosed** once any transaction has carried its plaintext `userId` and +handle to the Consumer Chain; disclosure is history and nothing undoes it. +A wallet's name on a platform is **published** while the Consumer holds a +handle for that wallet and platform to display; publication is Consumer +state, set and cleared as below. + - REQ-PLAT-08D (upholds SP-BIND-01, SP-PRIV-01): For a digest profile, the Consumer MUST derive the identity's keys from the `userId` digest and the handle digest the Platform Verifier returns, - in every Submission. The Transaction Author MAY include the plaintext - `userId` and handle in a Submission, both or neither. Where a Submission - carries them, the Consumer MUST normalize the handle under §2.1a. The - Consumer MUST reject a Submission whose plaintext does not hash, as - `keccak256` of the `userId` bytes and of the normalized handle, to the - two digests. The Consumer MUST accept - a Submission that carries neither as an undisclosed binding. The Consumer - MUST reject a request to publish a name for an undisclosed binding. -- REQ-PLAT-08E (upholds SP-PRIV-01): - The Consumer MUST offer the owner of an undisclosed binding a disclosure - call that takes the plaintext `userId` and handle. The Consumer MUST - accept a disclosure only when the keys derived from it are the ones the - caller's binding currently holds. The Consumer MUST refuse a disclosure of - a key retired by a later claim of the same account. The Consumer MUST NOT - require a new proof for a disclosure; the preimages are the evidence. The - Consumer MUST clear any name the Transaction Author had published for the - platform when it accepts a Submission that carries no plaintext. - Necessity: a published name is - read as the wallet's current handle, and a wallet must not display, or - disclose under its name, a handle it no longer holds. + in every Submission. The Consumer's Authorized Transaction Data for a + binding MUST carry whether the Submission discloses the identity. The + Consumer MUST reject a Submission that carries one of the plaintext + `userId` and handle without the other. The Consumer MUST reject a + Submission that carries plaintext its Authorized Transaction Data says + it does not disclose, or carries none where that data says it does. + Where a Submission carries plaintext, the Consumer MUST normalize the + handle under §2.1a. The Consumer MUST reject such a Submission unless + `keccak256` of the `userId` bytes equals the `userId` digest and + `keccak256` of the normalized handle equals the handle digest. The + Consumer MUST reject a Submission that asks to publish a name and + carries no plaintext. On accepting a Submission that carries no + plaintext, the Consumer MUST keep the name the Transaction Author + published for the platform when that name's handle key equals the + Submission's handle key. The Consumer MUST clear that name when the keys + differ. Necessity: the choice to disclose is the user's, so it is + committed in the Authorization Digest with the rest of the operation; and + a published name is read as the wallet's current handle, which a private + re-proof of the same handle leaves true and a claim of another handle + makes false. +- REQ-PLAT-08E (upholds SP-BIND-01): + For a digest profile, the Consumer MUST offer a disclosure call that + takes a platform, a plaintext `userId`, and a handle. The Consumer MUST + normalize the handle under §2.1a, derive the identity key and the handle + key from the plaintext, and accept the call only when the caller owns + both keys, the identity key's current handle key is that handle key, and + that handle key's current identity key is that identity key. On + acceptance the Consumer MUST publish the normalized handle as the + caller's name for the platform. The Consumer MUST NOT require a new proof + for a disclosure; the preimages are the evidence. Necessity: testing the + pairing in both directions is what refuses a handle retired by a later + claim of the same account, and a handle one of the caller's accounts took + from another of them, so a wallet displays only a handle it holds. A + refused call protects the display, not the plaintext: its calldata is + public whether or not the Consumer accepts it. - REQ-PLAT-08F (upholds SP-PRIV-01): The Consumer MUST state in every binding or disclosure event it emits whether the event carries plaintext. The Consumer MUST put the plaintext `userId` and handle in an event only when the Submission or disclosure - carried them. Necessity: an event that - carried plaintext is public for good; a reader distinguishes an identity - whose plaintext is known from one currently displaying a name only if the - events say which is which. A private Submission after a disclosure leaves - the plaintext known and the name unpublished, and its event says - undisclosed; all three hold at once. + call carried them. The Consumer MUST put the normalized handle in an + event, never the bytes as submitted. Necessity: an event that carried plaintext is public + for good, and a reader distinguishes a disclosed identity from one + currently published only if the events say which is which. A Submission + without plaintext after a disclosure leaves the identity disclosed, its + name published or cleared under REQ-PLAT-08D, and its own event without + plaintext. - TEST-PLAT-20A (exercises REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F): The same Google account submitted with and without its plaintext lands on the same two keys. A Submission without plaintext, made with recognizable test values, carries neither the email nor the `userId` in its decoded - payload or its decoded events. A Submission whose plaintext does not hash - to the digests is rejected. A request to publish a name for an undisclosed - binding is rejected. A disclosure of a handle retired by a later claim of - the same account is rejected, and a disclosure of the current one - publishes it. A Submission without plaintext after a disclosure leaves the - binding resolvable by its handle, its name unpublished, and its event - marked undisclosed. + payload or its decoded events. A Submission is rejected when its + plaintext does not hash to the digests, when it carries only one of the + two, when it carries plaintext its Authorized Transaction Data says it + does not disclose, when it carries none where that data says it does, + and when it asks to publish a name without plaintext. A private re-proof + of the published handle keeps the name; a private claim of another handle + clears it. A disclosure call is rejected for a handle retired by a later + claim of the same account, for a handle another account of the same + wallet took over, and from a caller that does not own both keys; a + disclosure of the current pair publishes it, and its event carries the + normalized handle. A Submission without plaintext after a disclosure + leaves the binding resolvable by its handle and its event without + plaintext. ### 2.2 Metadata ordering and validity ceilings From b5472c0a58632bc122e76815be6920554f0e2d05 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 16:35:52 +0300 Subject: [PATCH 05/11] docs(specs): who may trust the plaintext, and what SP-PRIV-01 depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REQ-COMMON-05E returns the plaintext a Submission carried, marked unverified; the Consumer trusts it only once it hashes to the digests, and the Proof Verifier forwards it with the rest under REQ-COMMON-06. - REQ-PLAT-03: for a digest profile the Canonical Runtime derives the local fields from the signed ID Token, places them in the Submission exactly when the authorized operation discloses them, and never returns them to the application otherwise. - SP-PRIV-01 says what it holds: plaintext reaches the chain only in a Submission whose digest commits its disclosure or in a disclosure call, and a transaction carrying plaintext publishes it when sent, accepted or not. It depends on the new ASM-ZK-01, and REQ-COMMON-45 has governance select only zero-knowledge artifacts for a digest profile. - Common §12 and libid.md follow. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ceremony-common.md | 56 ++++++++++++++++++++++++++---------- specs/libid.md | 7 +++-- specs/platform-ceremonies.md | 15 ++++++++-- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/specs/ceremony-common.md b/specs/ceremony-common.md index 644646ee..ccf7bfbe 100644 --- a/specs/ceremony-common.md +++ b/specs/ceremony-common.md @@ -229,6 +229,10 @@ Attestation Count: The number of entries in the closed attestation list a preimage except by hashing candidates and comparing. A digest is not a secret: a low-entropy preimage, such as an email address, is recovered by hashing guesses, and this assumption gives no protection against that. +- ASM-ZK-01: + The proof system of a digest profile is zero-knowledge: a proof that the + verifier artifact selected for it accepts reveals nothing of the witness + beyond the proof's public inputs. ## 4. Security properties @@ -273,11 +277,15 @@ on it. one authoritative effect. Depends on ASM-CHAIN-01, ASM-CHAIN-02. Evidence: checked invariant in the Consumer. - SP-PRIV-01: - An identity a Consumer holds undisclosed is recoverable from no chain - artifact, whether Submission calldata, event, or storage, except by hashing - a candidate handle or user identifier and comparing it with the identity's - keys. Depends on ASM-HASH-01, ASM-PROOF-01. Evidence: conformance tests - (supporting, not proving) plus the preimage resistance of keccak256. + For a digest profile, an identity's plaintext `userId` and handle reach + the Consumer Chain only in a Submission whose Authorization Digest commits + their disclosure, or in a disclosure call; the Canonical Runtime releases + them to no other party. Short of such a transaction, no chain artifact, + whether calldata, proof bytes, event, or storage, yields them except by + hashing a candidate handle or user identifier and comparing it with the + identity's keys. Depends on ASM-HASH-01, ASM-PROOF-01, ASM-ZK-01, + ASM-BROWSER-01. Evidence: conformance tests (supporting, not proving) + plus the preimage resistance of keccak256. ## 5. Authorization digest @@ -568,18 +576,25 @@ an identity session — so one Submission on either path pays two fees. identifier, the canonical `userId` and the raw handle bytes where its Platform Profile exposes them, the `userId` digest and the handle digest where its Platform Profile exposes those instead (platform REQ-PLAT-16C), - and `metadataObservedAt`. Necessity: an authenticated `userId`, handle, and - observation time are what the ceremony exists to produce; the digest is the - Consumer's replay nullifier, which it cannot recompute without reading the - payload. The Consumer trusts these fields as it trusts the Platform Verifier - the Verifier Governance Process installed. -- REQ-COMMON-45 (upholds SP-BIND-01, SP-EXCHANGE-01): + and `metadataObservedAt`. Where its Platform Profile exposes digests and + the Submission carries the plaintext `userId` and handle, the Platform + Verifier MUST also return those bytes, marked unverified. Necessity: an + authenticated `userId`, handle, and observation time are what the ceremony + exists to produce; the digest is the Consumer's replay nullifier, which it + cannot recompute without reading the payload. The Consumer trusts the + verified fields as it trusts the Platform Verifier the Verifier Governance + Process installed. The Consumer trusts the unverified plaintext only once + it hashes to the digests (platform REQ-PLAT-08D); a Consumer that reads it + otherwise binds whatever identity a Submission names. +- REQ-COMMON-45 (upholds SP-BIND-01, SP-EXCHANGE-01, SP-PRIV-01): The Platform Verifier MUST verify the proof carried in the Submission Payload under the exact verifier artifact the Verifier Governance Process selected for it. The Verifier Governance Process MAY select a different artifact for each Consumer Chain and each Verifier Version. The Verifier Governance Process MUST select only artifacts that enforce the proof - statement of the Platform Ceremony Version the verifier implements. The Platform Verifier + statement of the Platform Ceremony Version the verifier implements. For a + digest profile, the Verifier Governance Process MUST select only artifacts + that accept zero-knowledge proofs alone (ASM-ZK-01). The Platform Verifier MUST reject a Submission whose proof does not verify under that artifact. The Platform Verifier MUST NOT accept a caller-supplied artifact, verifying key, or externally computed verification result. Necessity: @@ -1452,8 +1467,10 @@ the constructions that role implements. A Submission whose proof does not verify under the artifact selected for the Platform Verifier registered under its identity platform and Verifier Version is rejected; a proof verifying only under another platform's or - another ceremony version's artifact is rejected; and a caller-supplied artifact, verifying key, or precomputed - verification result changes no decision. + another ceremony version's artifact is rejected; a caller-supplied artifact, verifying key, or precomputed + verification result changes no decision; and for a digest profile, a + proof of the right statement made without the zero-knowledge option is + rejected. - TEST-COMMON-23 (exercises REQ-COMMON-02, REQ-COMMON-46): The Platform Verifier recomputes the digest from the operation domain, nonce and transaction data it decoded, the ceremony version it implements, @@ -1518,7 +1535,16 @@ client secret, and the transcript bytes outside a profile's revealed ranges stay withheld for good. SP-PRIV-01 is a statement about the chain's artifacts, not about the -identity. The keys an undisclosed identity is stored under are unsalted +identity. Whether a Submission discloses is part of the operation the user +authorizes, committed in its Authorization Digest like the fee (platform +REQ-PLAT-08D), and the Canonical Runtime hands the plaintext to the +application only when that operation discloses it (platform REQ-PLAT-03); +an application operator that never receives the plaintext cannot attach it, +and one that asks for a disclosing operation is the consent-screen case +below. A transaction that carries plaintext publishes it when it is sent: +a Submission or disclosure call the Consumer refuses still leaves its +calldata on chain. The proof bytes hide the witness only because the proof +system is zero-knowledge (ASM-ZK-01, REQ-COMMON-45). The keys an undisclosed identity is stored under are unsalted digests of its normalized handle and its user identifier, so that whoever already knows an address can resolve it; by the same arithmetic, whoever suspects an address, or holds the user identifier from another relying diff --git a/specs/libid.md b/specs/libid.md index 2f07155a..cf717134 100644 --- a/specs/libid.md +++ b/specs/libid.md @@ -115,9 +115,10 @@ evidence, which is Google's signature relation and, on X and GitHub, that one hidden bearer opens both sessions' commitments. The Consumer enforces replay rejection by recording every Authorization Digest it accepts before applying an effect (REQ-COMMON-03, REQ-COMMON-03A). For a profile that exposes identity -digests, Google at launch, the Consumer keys the binding on the digests and -holds the handle and user identifier undisclosed until their owner -publishes them (SP-PRIV-01); it does not prevent confirmation of a guessed +digests, Google at launch, the Consumer keys the binding on the digests, and +the handle and user identifier reach the chain only in a transaction that +discloses them, by the user's authorized choice or their owner's +disclosure call (SP-PRIV-01); it does not prevent confirmation of a guessed identity by hashing. The Canonical Runtime locally enforces the selected OAuth client and redirect profile. The protocol assumes the named identity-platform parser, diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index 42b8139b..708d1d6e 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -81,7 +81,14 @@ REQ-COMMON-15A. Platform Verifier extracts, using the same canonical extraction and normalization rules. The Canonical Runtime MUST reject a detached proof output, sidecar value, or caller value that supplies or overrides `userId`, - handle, or `metadataObservedAt`. + handle, or `metadataObservedAt`. For a digest profile (§2.1b), the + Canonical Runtime MUST derive the local `userId` and handle from the + signed ID Token whose digests the Submission's proof carries, the `sub` + exactly and the `email` as signed. The Canonical Runtime MUST place them + in the Submission as its plaintext, byte for byte, exactly when the + Authorized Transaction Data says the Submission discloses them. The + Canonical Runtime MUST NOT return them to the application for a + Submission that does not disclose them. This is a data-source invariant, not a browser-flow requirement. It defines the identity fields returned to callers and used by any composition-owned UI; @@ -246,7 +253,7 @@ state, set and cleared as below. without plaintext after a disclosure leaves the identity disclosed, its name published or cleared under REQ-PLAT-08D, and its own event without plaintext. -- TEST-PLAT-20A (exercises REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F): +- TEST-PLAT-20A (exercises REQ-PLAT-03, REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F): The same Google account submitted with and without its plaintext lands on the same two keys. A Submission without plaintext, made with recognizable test values, carries neither the email nor the `userId` in its decoded @@ -262,7 +269,9 @@ state, set and cleared as below. disclosure of the current pair publishes it, and its event carries the normalized handle. A Submission without plaintext after a disclosure leaves the binding resolvable by its handle and its event without - plaintext. + plaintext. The Canonical Runtime returns no `sub` or `email` to the + application for a Submission that does not disclose, and places the + signed `sub` and `email` byte for byte in one that does. ### 2.2 Metadata ordering and validity ceilings From 0e09f303a7bcecee0706641e5962bcc7af60b08a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 16:37:05 +0300 Subject: [PATCH 06/11] docs(specs): the Google circuit's buffers, no escaped byte, fixed rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REQ-PLAT-04 and 16D refuse a backslash in `sub`: every JSON escape begins with one, so no value the circuit digests holds an escaped byte; the email alphabet already lacks it. The old claim that an escaped value fails the byte comparison was false for `sub`. - REQ-PLAT-16D states the Google buffers, 31 bytes of `sub` and 62 of `email` (the handle rules' own maximum), so TEST-PLAT-06A's over-length cases are writable. - REQ-PLAT-08G: a digest profile's Consumer holds its handle rules equal to the circuit's and refuses to change them once it has bound an identity, since it cannot re-key digests; §9 points to it. - REQ-PLAT-16B lists SP-PRIV-01. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/platform-ceremonies.md | 60 +++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index 708d1d6e..340fdfc5 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -104,9 +104,12 @@ it does not create a ceremony-owned confirmation page. - REQ-PLAT-04: The Implementation MUST accept a Google `sub` of bytes `0x20` through `0x7e` - only. The Implementation MUST reject empty, control, non-ASCII, and - over-255-byte values. Necessity: identity compatibility across - implementations. + only, other than `"` and `\`. The Implementation MUST reject empty, + control, non-ASCII, and over-255-byte values. Necessity: identity + compatibility across implementations; a value holding a backslash is one + whose JSON encoding may escape a byte, and an implementation that + decodes the escape and one that reads the signed bytes would key it + differently. - REQ-PLAT-05: The Implementation MUST NOT trim or case-convert a Google `sub`. Necessity: identity compatibility. @@ -253,7 +256,14 @@ state, set and cleared as below. without plaintext after a disclosure leaves the identity disclosed, its name published or cleared under REQ-PLAT-08D, and its own event without plaintext. -- TEST-PLAT-20A (exercises REQ-PLAT-03, REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F): +- REQ-PLAT-08G (upholds SP-BIND-01): + For a digest profile, the Consumer's handle rules for the platform MUST be + the normalization the profile's Proving Circuit applies. The Consumer MUST + refuse a change to those rules once it has bound any identity of that + platform. Necessity: the Consumer holds only digests, so it cannot re-key + a binding under new rules, and a disclosed handle normalized under rules + the circuit does not apply stops hashing to its own key. +- TEST-PLAT-20A (exercises REQ-PLAT-03, REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F, REQ-PLAT-08G): The same Google account submitted with and without its plaintext lands on the same two keys. A Submission without plaintext, made with recognizable test values, carries neither the email nor the `userId` in its decoded @@ -271,7 +281,8 @@ state, set and cleared as below. leaves the binding resolvable by its handle and its event without plaintext. The Canonical Runtime returns no `sub` or `email` to the application for a Submission that does not disclose, and places the - signed `sub` and `email` byte for byte in one that does. + signed `sub` and `email` byte for byte in one that does. A change to + Google's handle rules is refused once a Google identity is bound. ### 2.2 Metadata ordering and validity ceilings @@ -403,7 +414,7 @@ require a verifier that dispatches on the header `alg`; none exists here. REQ-PLAT-23. JWK decoding and canonical-encoding validation happen where a modulus is admitted to the trusted set, per REQ-PLAT-24; the JWK encoding appears in no signed artifact, so proving it would add nothing. -- REQ-PLAT-16B (upholds SP-BIND-01, SP-CLIENT-01, SP-FRESH-01): +- REQ-PLAT-16B (upholds SP-BIND-01, SP-CLIENT-01, SP-FRESH-01, SP-PRIV-01): The Proving Circuit MUST expose exactly the following Google public inputs, each derived from the signed payload or verified signing key: @@ -431,19 +442,20 @@ require a verifier that dispatches on the header `alg`; none exists here. needs the normalization of §2.1a. Necessity: one party owns the equality, and it is the one that owns the normalization. - REQ-PLAT-16D (upholds SP-BIND-01, SP-PRIV-01): - The Proving Circuit MUST admit a `sub` only under REQ-PLAT-04 and - REQ-PLAT-05, nonempty and of bytes `0x20` through `0x7e`. The Proving - Circuit MUST digest exactly the signed `sub` bytes. The Proving Circuit - MUST admit an `email` only when the normalization of §2.1a admits it. The - Proving Circuit MUST refuse an `email` that normalization would trim. The - Proving Circuit MUST digest exactly the normalized `email` bytes. The - Proving Circuit MUST fail to prove, rather than truncate, a value longer - than its buffer for it; the buffer lengths are constants of the Platform - Ceremony Version. A `sub` or `email` whose JSON encoding escapes a byte - cannot satisfy the byte comparison of common REQ-COMMON-19 and - REQ-COMMON-19B, which excludes the closing delimiter, and so fails to - prove. Necessity: a verifier that receives a digest inspects nothing, - so every check the bytes need happens where the bytes are. + The Proving Circuit MUST admit a `sub` only as REQ-PLAT-04 and + REQ-PLAT-05 admit one, nonempty, of bytes `0x20` through `0x7e`, and + holding neither `"` nor `\`. The Proving Circuit MUST digest exactly the signed + `sub` bytes. The Proving Circuit MUST admit an `email` only when the + normalization of §2.1a admits it. The Proving Circuit MUST refuse an + `email` that normalization would trim. The Proving Circuit MUST digest + exactly the normalized `email` bytes. The Proving Circuit MUST fail to + prove, rather than truncate, a `sub` longer than 31 bytes or an `email` + longer than 62 bytes; these are the Google profile's buffer lengths, and + 62 is the handle rules' own maximum. No value the circuit digests holds + an escaped byte: every JSON escape begins with `\`, which the `sub` rule + refuses and the `email` alphabet does not contain. Necessity: a verifier + that receives a digest inspects nothing, so every check the bytes need + happens where the bytes are. - REQ-PLAT-17 (upholds SP-BIND-01): The Proving Circuit MUST prove the signed `iss` equals `https://accounts.google.com`. @@ -1280,8 +1292,8 @@ Platform Verifier, Notary Service, Consumer. the plaintext. An `email` with a space, two `@`, an empty local part, a byte outside the normalization's alphabet, or bytes past its signed length cannot satisfy the circuit; neither can an empty `sub`, a `sub` byte - outside `0x20` through `0x7e`, or a `sub` or `email` longer than its - buffer. The public inputs carry no `sub` or `email` byte. The Platform + outside `0x20` through `0x7e`, a `sub` holding `\` (as `12\/3` does), a + 32-byte `sub`, or a 63-byte `email`. The public inputs carry no `sub` or `email` byte. The Platform Verifier returns both digests, returns plaintext the Submission carried byte for byte, and returns none where the Submission carried none. - TEST-PLAT-07 (exercises REQ-PLAT-22, REQ-PLAT-09, REQ-PLAT-09A): @@ -1497,9 +1509,9 @@ party confirms the binding by hashing, the binding's existence and observation time stay public, and a name that resolves through an ENS gateway or an off-chain resolver is the address itself. The normalization of §2.1a runs inside the Proving Circuit for this profile (REQ-PLAT-16D), so -the profile's handle rules are part of its proof statement: rules that the -Consumer and the circuit do not share make a disclosed claim stop hashing to -its own key. +the profile's handle rules are part of its proof statement, and the +Consumer holds them fixed once it has bound a Google identity +(REQ-PLAT-08G). ## 10. References From e6b73f92e6df0c0c0818b1c506efba102514e221 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 16:39:07 +0300 Subject: [PATCH 07/11] docs(design): the note follows the spec's disclosure and trust rules - The mode lives in the operation the user authorizes: `disclose` joins the claim's Authorized Transaction Data, the Canonical Runtime hands the email to the application only for a disclosing claim, and choosing by plaintext presence alone is recorded as the rejected alternative. - `publish` checks the pairing in both directions, with the two-account case that needs it; a private claim of the published handle keeps the publication; a refused `publish` still publishes its calldata. - The ENS gateway reads the indexer's store, keyed by the handle string, so private names resolve only once that lookup is keyed by node; the earlier "no change at all" and the RPC fallback it named were wrong. - The public-input count is stated once, per digest; the status line points at the spec map instead of repeating it; the map, the implementation steps and the decided list carry the new rules. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- design/private-gmail-handle.md | 195 +++++++++++++++++++++------------ 1 file changed, 122 insertions(+), 73 deletions(-) diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md index 69555f33..2baf4ce8 100644 --- a/design/private-gmail-handle.md +++ b/design/private-gmail-handle.md @@ -1,10 +1,8 @@ # A private mode for Gmail handles **Status: design proposal, with its specification written.** Nothing here -is built. This note is the rationale; the normative text is in -`specs/platform-ceremonies.md` (§2.1b, REQ-PLAT-08D to 08F, REQ-PLAT-16C -and 16D, TEST-PLAT-06A and 20A) and -`specs/ceremony-common.md` (ASM-HASH-01, SP-PRIV-01, REQ-COMMON-05E, §12). +is built. This note is the rationale; the normative text is on this branch, +and "Spec changes" below maps it. ## The thing that must work @@ -94,8 +92,9 @@ what the verifier does for the audience today: the payload carries the plaintext, the digest is recomputed and compared (`GooglePlatformVerifier.sol:206-210`, REQ-PLAT-19A), here by the Consumer, which owns normalization. In private mode the payload carries no plaintext, -and the node is derived from the hash alone. The public-input count stays 56 and no -offset in the verifier moves; only the meaning of two slots changes. A new +and the node is derived from the hash alone. The digest takes the two slots +the email's bytes held, so the email alone moves no offset; the `sub` +digest below adds the one slot that does. A new verification key and a regenerated `OidcGoogleHonkVerifier.sol` follow regardless, as they do for any circuit change. **Recommended.** @@ -166,7 +165,7 @@ but the case is permanent code and permanent audit surface. **Poseidon.** Cheapest in a circuit and no precedent anywhere in libID's Solidity, Rust or TypeScript. The contract would compute Poseidon in `_write` -and `resolveHandle`, and so would the gateway. Not for a first version. +and `resolveHandle`, and so would the gateway. Not for this design. Measured on a scratch copy of the circuit at the pinned toolchain, with the fold-and-shape loop included in both variants (`nargo compile`, then @@ -220,32 +219,46 @@ joins with every relying party's user table. ### Where the mode lives -**The presence of the plaintext in the payload.** `GoogleProof` gains +**In the operation the user authorizes, carried out by the presence of the +plaintext.** The Consumer's Authorized Transaction Data for a claim is the +triple `(target, feeAmount, feeReceiver)` (`IdentityNames.sol:481-491`); it +becomes `(target, feeAmount, feeReceiver, disclose)`, so the choice is +committed in the Authorization Digest the way the fee is, and the user sees +it when they consent. A platform that exposes bytes has only one true +answer, and its claims carry `disclose = true`. `GoogleProof` gains `bytes userId` and `bytes email` beside `clientIdentifier` -(`GooglePlatformVerifier.sol:76-84`), both present or both empty; empty means -private. The verifier passes the submitted bytes through as `userId` and -`handle`, possibly empty, and returns the two digests the proof bound as -new `VerifiedClaim.userIdHash` and `handleHash`; it checks nothing about the -bytes, because the check needs the handle normalized and normalization is -the Consumer's (REQ-PLAT-08B). `IdentityNames._write` is the one place the -equality holds: it derives both nodes from the digests in both modes; when -the plaintext is present it normalizes the handle, requires -`keccak256(bytes(userId)) == userIdHash` and `keccak256(bytes(normalized)) -== handleHash`, and only then stores or publishes the strings. -`publishName` with empty plaintext reverts. X and GitHub verifiers return -zero digests and keep the plaintext path they have. **Recommended:** two -optional payload fields, no change to the signature of `claim`, and the -user decides at submission time, after proving. It keeps REQ-PLAT-08B honest -in the form that matters: the Consumer still derives the key from a -proof-bound value and still refuses a caller-supplied key; the plaintext is -accepted only because the proof binds its digest. - -**An explicit flag on `claim`.** Rejected. Two sources of truth for one fact, -and nothing to do when they disagree. - -**A mode bit as a circuit public input.** Rejected. It bakes the disclosure -choice into the proof, costs an input, and stops the user from changing their -mind between proving and submitting. +(`GooglePlatformVerifier.sol:76-84`), both present exactly when `disclose` +says so. The Canonical Runtime fills them from the ID Token it verified, +and hands the email to the application only for a disclosing claim, so an +application that asked for a private one has nothing to attach. + +The verifier passes the submitted bytes through, marked unverified, and +returns the two digests the proof bound as new `VerifiedClaim.userIdHash` +and `handleHash`; it checks nothing about the bytes, because the check +needs the handle normalized and normalization is the Consumer's +(REQ-PLAT-08B). `IdentityNames._write` is the one place the equality holds: +it derives both nodes from the digests in both modes; it refuses plaintext +that `disclose` does not authorize, a missing plaintext it does, and one of +the two without the other; when the plaintext is present it normalizes the +handle, requires `keccak256(bytes(userId)) == userIdHash` and +`keccak256(bytes(normalized)) == handleHash`, and only then stores or +emits the strings, the handle normalized. `publishName` without plaintext +reverts. X and GitHub verifiers return zero digests and keep the plaintext +path they have. **Recommended.** It keeps REQ-PLAT-08B honest in the form +that matters: the Consumer still derives the key from a proof-bound value +and still refuses a caller-supplied key; the plaintext is accepted only +because the proof binds its digest and the user authorized sending it. + +**The presence of the plaintext alone, chosen at submission time.** +Rejected. The application assembles the transaction, and whoever assembles +it would decide whether the address is published; the application operator +is trusted with no identity field (`libid.md`, roles). The cost of the +recommendation is that the choice is fixed before proving; making a +private binding public later is the `publish` call below, and nothing +turns a public one private anyway. + +**A mode bit as a circuit public input.** Rejected. It costs an input and +adds nothing the Authorization Digest does not already commit. The event gains `bool disclosed` and keeps `string userId` and `string handle`, both empty when private. `disclosed` is a fact about the event: @@ -272,7 +285,9 @@ contract already has its inverse, `unpublish`, so the call is derive both nodes, require that the caller owns them and that the binding is live, set `published`, emit `IdentityPublished(owner, platformId, idNode, handleNode, userId, handle)`. No proof is needed; the preimages are the -proof. The address is public from that block on. +proof. The address is public from the block the call is sent in, whether +or not it is accepted: a refused `publish` still leaves its calldata on +chain. A handle is retired when the same account claims again under another one: the old node's owner is cleared and `HandleRetired` emitted @@ -283,23 +298,31 @@ plaintext of an address the wallet no longer holds and set `published` to it, so `reverseOf`, which returns the stored string as it is (`:770`), would name that address until the next claim; `primaryOf` already refuses a published string whose node the wallet does not own (`:787-792`). So -`publish` requires the handle node to be the account's current one and the -wallet to own it, and the owner of a private binding can publish exactly -what they hold. +`publish` requires the pairing in both directions, `handleOfId[idNode] == +handleNode` and `idOfHandle[handleNode] == idNode`, and the wallet to own +both nodes. One direction is not enough: a wallet holding two Google +accounts, whose first account's handle was reassigned to its second, owns +both of the first account's nodes while `handleOfId` still names the +retired handle, and only `idOfHandle` says the handle is the second +account's now. The owner of a private binding can publish exactly what +they hold. The same scenario settles one more rule. A claim from a wallet that has already published refreshes the published string to the handle just proved, so a rename never leaves a stale name on display (`:626-633`). A private -claim carries no plaintext to refresh with, so it deletes the publication -instead; otherwise `reverseOf` keeps showing the old address after the -account moved on. +claim carries no plaintext to refresh with. When it re-proves the handle +already published, whose normalized string hashes to the claim's +`handleHash`, the publication stays, as the contract already keeps a +display a `publishName: false` claim still vouches for; when it proves +another handle, it deletes the publication, or `reverseOf` would keep +showing the old address after the account moved on. Two facts therefore live apart. **Disclosure** is history: once any event has carried an identity's plaintext, a private claim, a private refresh of the same nodes, or `unpublish` afterwards, the plaintext is known and stays known. **Publication** is state: whether the wallet currently displays the name, set by `publishName` on a disclosed claim or by `publish`, cleared by -`unpublish` or by a private claim. The sequence private claim, `publish`, +`unpublish` or by a private claim of another handle. The sequence private claim, `publish`, private refresh ends with the plaintext known, the publication cleared, and the last event saying `disclosed: false`, all three true at once. @@ -330,51 +353,67 @@ by decision; a `GET /v1/resolve/node/{platform}/{handleNode}` where the client hashes, so the server never sees the address, can be added later for clients that want it, as the ENS gateway already works. -**ENS gateway.** Because the node is the same in both modes, a private -binding resolves as `alice.google.handles.link` with no change at all: the -gateway derives the node from the labels and falls back to -`resolveHandle` over RPC (`bin/usernames-api/src/ens.rs:416`). That is -the decided behaviour; the `disclosed` field still tells an indexer which -bindings have a string to display. +**ENS gateway.** The node is the same in both modes, so a private binding +can resolve as `alice.google.handles.link`, and that is the decided +behaviour. It does not follow without work: the gateway reads the indexer's +store, not the chain (`bin/usernames-api/src/ens.rs:409-420` calls the +store's `resolve_handle`), and that lookup selects by the handle string +(`db.rs:1186-1199`), which a private binding does not have. The gateway +resolves private bindings once `resolve_handle` selects by node, the same +change the resolve route needs above, and not before; the rollout ships +the two together. **TypeScript claim SDK.** The Google proof type carries `email` as a required string; it becomes optional, absent for private. Client-side normalization must produce the bytes the circuit hashes, and `handle.ts` already reproduces the shared vector table, so the only new obligation is that the circuit input builder feeds the circuit the raw bytes and expects the digest -of the folded ones. The local result may keep returning the email to the -caller; it never leaves the browser. +of the folded ones. The local result carries the email only for a +disclosing claim (REQ-PLAT-03); for a private one the application receives +the digests, and the email stays inside the Canonical Runtime. **Demo.** The "publish the handle on chain" checkbox becomes a three-way -choice: private, public, public and published. +choice made before the ceremony starts, since `disclose` is in the digest: +private, public, public and published. ### Spec changes Written, on this branch. The map, for a reader coming from the specs: - `platform-ceremonies.md`: §2.1a's lead-in and REQ-PLAT-08A/08B admit a - circuit that normalizes what it digests and a Consumer that keys on - digests; a new §2.1b defines the digest profile with REQ-PLAT-08D (keys - from the digests, plaintext accepted only when it hashes to them, both or - neither), 08E (the disclosure call, what it refuses, the publication a - private claim clears), 08F (the event's disclosed flag) and - TEST-PLAT-20A; REQ-PLAT-16B lists the two digests as public inputs; - REQ-PLAT-16C has the verifier return them and pass plaintext through - unchecked; REQ-PLAT-16D moves the `sub` and `email` validation into the - circuit; TEST-PLAT-06A exercises the three; §9 states what the digests - protect and what they do not. The Google profile stays Platform Ceremony - Version 1: nothing is released, so its statement is edited in place. -- `ceremony-common.md`: ASM-HASH-01, SP-PRIV-01, REQ-COMMON-05E returns - the digests where a profile exposes them, §12 replaces "published - deliberately" for the handle and user identifier with the digest - profile's confidentiality and its limits. + circuit that normalizes what it digests, refusing where the table trims, + and a Consumer that keys on digests and normalizes every handle it + receives. A new §2.1b defines disclosure (history) and publication + (state), and holds REQ-PLAT-08D (keys from the digests, the disclosure + choice in the Authorized Transaction Data, plaintext only when authorized + and only when it hashes to the digests, both or neither, a private claim + keeping or clearing the publication), 08E (the disclosure call, its + two-way pairing check, and what a refusal does not protect), 08F (the + event's disclosed flag, the normalized handle), 08G (rules fixed once a + digest platform has bound anything) and TEST-PLAT-20A. REQ-PLAT-03 has + the Canonical Runtime release the plaintext only for a disclosing claim; + REQ-PLAT-04 and 16D refuse a backslash in `sub`; REQ-PLAT-16B lists the + two digests as public inputs; 16C has the verifier return them and pass + plaintext through unchecked; 16D moves the `sub` and `email` validation + into the circuit and states the 31- and 62-byte buffers; TEST-PLAT-06A + exercises the three; §9 states what the digests protect and what they do + not. The Google profile stays Platform Ceremony Version 1: nothing is + released, so its statement is edited in place. +- `ceremony-common.md`: ASM-HASH-01, ASM-ZK-01, SP-PRIV-01 (plaintext + reaches the chain only in a transaction that discloses it), REQ-COMMON-05E + returns the digests, and the plaintext marked unverified, where a profile + exposes digests; REQ-COMMON-45 selects only zero-knowledge artifacts for + a digest profile; §12 replaces "published deliberately" for the handle + and user identifier with the digest profile's confidentiality and its + limits. - `libid.md`: one sentence among the enforceable guarantees. ## The recommendation Hash in the circuit, normalize in the circuit, keccak256 over the email and -over `sub`, mode by presence of the plaintexts in the payload, private by -default for Google alone, `publish` in the first version, ENS forward names +over `sub`, the mode chosen by the user in the authorized operation and +carried out by the presence of the plaintexts, private by default for +Google alone, `publish` to go public later, ENS forward names resolving for private bindings as for public ones, and the resolve routes as they are. This is the one combination where neither the email nor the account id reaches calldata, where both nodes are identical across modes @@ -398,14 +437,19 @@ logs, or the visibility of the binding itself. empty local part and a garbage tail each fail to prove. 3. **Contracts** (`libid-contracts`): the Google verifier regenerated, `bytes userId` and `bytes email` in the payload, `userIdHash` and `handleHash` - in `VerifiedClaim`, the equality checks and the hash-derived nodes in - `_write`, `disclosed` in the event, `publish`, the circuit pin. Done when + in `VerifiedClaim`, `disclose` in the claim's Authorized Transaction + Data, the equality checks and the hash-derived nodes in `_write`, + `disclosed` in the event, `publish` with its two-way pairing check, + `setPlatform` refusing a Google rules change once Google has bound + anything, the circuit pin. Done when the same account claimed public and then private lands on the same two nodes; when a private transaction, made with recognizable test values, carries no plaintext email or account id in its decoded calldata or its decoded events, the payload fields being empty and the event strings empty, rather than a byte search over proof bytes that can contain - anything; and when `publishName` without plaintext reverts. + anything; and when `publishName` without plaintext, plaintext `disclose` + does not authorize, and a `publish` of a pair either direction of the + mapping disputes all revert. 4. **Indexer, SDK, demo**: nullable handle and id, node-keyed resolve for both, `IdentityPublished` filling the plaintext rows and the publication state, optional `userId` and `email`, the three-way choice. Done when @@ -434,11 +478,16 @@ logs, or the visibility of the binding itself. Decided: `alice.google.handles.link` resolves for a private binding, so the name is the address and a wallet that resolves it has confirmed it, and with it that confirming a suspected address by hashing it is accepted; `sub` is -hidden with the handle; private to public is a `publish` call in the first -version, not a new claim; the resolve routes keep the plaintext in the +hidden with the handle; private to public is a `publish` call, not a new +claim; the resolve routes keep the plaintext in the request line, the indexer's operator being trusted with what people resolve; -and `publish` refuses a handle retired by a later claim of the same account, -as set out under "Default, and moving between modes". +`publish` refuses a handle retired by a later claim of the same account, or +taken over by another account of the same wallet, as set out under +"Default, and moving between modes"; the user, not the application, +chooses disclosure, in the Authorized Transaction Data; a private claim of +the published handle keeps the publication; Google's handle rules are +fixed once Google has bound anything; and the verifier artifact is +zero-knowledge, which the privacy rests on as much as on the hash. Nothing in this note is left open. From c041f310fd050c7abbd1549373017cc695ca07dd Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 16:41:43 +0300 Subject: [PATCH 08/11] docs(specs): a bytes profile always discloses; the note names the backslash - REQ-PLAT-08D: the claim's Authorized Transaction Data carries the disclosure choice for every platform, one encoding per transaction kind, so a profile that exposes bytes rejects a Submission that does not disclose; TEST-PLAT-20A exercises it. - REQ-COMMON-45: governance selects artifacts that verify zero-knowledge proofs. - The design note lists the backslash refusal among the circuit's `sub` checks and in its done criteria: today's circuit accepts one. The digest vectors use opaque transactionData, so the new field leaves them as they are. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- design/private-gmail-handle.md | 8 ++++++-- specs/ceremony-common.md | 2 +- specs/platform-ceremonies.md | 11 ++++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md index 2baf4ce8..94e2e9e4 100644 --- a/design/private-gmail-handle.md +++ b/design/private-gmail-handle.md @@ -199,7 +199,10 @@ emptiness to the verifier, which rejects an empty `userId` after unpacking inspect nothing, so the circuit takes over the whole of the id's validation, which REQ-PLAT-04 already states for every implementation: `sub_len` at least 1; every byte within `sub_len` in `0x20` through `0x7e`, so no control -byte, no non-ASCII byte and, as today, no quote; the padding zero; and the +byte, no non-ASCII byte and, as today, no quote; no backslash, which today's +circuit accepts and REQ-PLAT-04 now refuses, because every JSON escape +begins with one and an escaped `sub` would digest its escaped form; the +padding zero; and the digest over exactly `sub_len` bytes. `SUB_MAX` stays a profile constant: 31 today, a Google `sub` being 21 digits, and a `sub` longer than the constant fails to prove rather than truncating. The spec's 255 is the @@ -434,7 +437,8 @@ logs, or the visibility of the binding itself. library, `handle_hash` in place of `email_packed` and `sub_hash` in place of `sub_packed`, a gate count in the commit. Done when `Alice@Gmail.com` and `alice@gmail.com` prove the same digest, and a space, two `@`, an - empty local part and a garbage tail each fail to prove. + empty local part and a garbage tail each fail to prove, as does a `sub` + holding a backslash. 3. **Contracts** (`libid-contracts`): the Google verifier regenerated, `bytes userId` and `bytes email` in the payload, `userIdHash` and `handleHash` in `VerifiedClaim`, `disclose` in the claim's Authorized Transaction diff --git a/specs/ceremony-common.md b/specs/ceremony-common.md index ccf7bfbe..2136f3ba 100644 --- a/specs/ceremony-common.md +++ b/specs/ceremony-common.md @@ -594,7 +594,7 @@ an identity session — so one Submission on either path pays two fees. Governance Process MUST select only artifacts that enforce the proof statement of the Platform Ceremony Version the verifier implements. For a digest profile, the Verifier Governance Process MUST select only artifacts - that accept zero-knowledge proofs alone (ASM-ZK-01). The Platform Verifier + that verify zero-knowledge proofs (ASM-ZK-01). The Platform Verifier MUST reject a Submission whose proof does not verify under that artifact. The Platform Verifier MUST NOT accept a caller-supplied artifact, verifying key, or externally computed verification result. Necessity: diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index 340fdfc5..f61ea810 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -211,8 +211,11 @@ state, set and cleared as below. For a digest profile, the Consumer MUST derive the identity's keys from the `userId` digest and the handle digest the Platform Verifier returns, in every Submission. The Consumer's Authorized Transaction Data for a - binding MUST carry whether the Submission discloses the identity. The - Consumer MUST reject a Submission that carries one of the plaintext + binding MUST carry whether the Submission discloses the identity. For a + profile that exposes identity bytes, the Consumer MUST reject a + Submission whose Authorized Transaction Data does not disclose, since + the bytes it carries are the disclosure. The Consumer MUST reject a + Submission that carries one of the plaintext `userId` and handle without the other. The Consumer MUST reject a Submission that carries plaintext its Authorized Transaction Data says it does not disclose, or carries none where that data says it does. @@ -271,7 +274,9 @@ state, set and cleared as below. plaintext does not hash to the digests, when it carries only one of the two, when it carries plaintext its Authorized Transaction Data says it does not disclose, when it carries none where that data says it does, - and when it asks to publish a name without plaintext. A private re-proof + and when it asks to publish a name without plaintext; an X or GitHub + Submission whose Authorized Transaction Data does not disclose is + rejected. A private re-proof of the published handle keeps the name; a private claim of another handle clears it. A disclosure call is rejected for a handle retired by a later claim of the same account, for a handle another account of the same From 71584781e3fb910279b4b241ec4e72d29a2b7b05 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 17:25:29 +0300 Subject: [PATCH 09/11] docs(specs): the application chooses, and only the handle is ever sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second review showed the user-authorized disclosure could not be enforced: the ID Token lands on the application's origin, no trusted screen shows the choice, and Authorized Transaction Data is opaque to the Canonical Runtime. So `disclose` leaves the Authorized Transaction Data, X and GitHub claims keep their encoding, and SP-PRIV-01 says plainly that it does not survive a malicious application operator; it bounds what the Consumer and the Proof Verifier put on chain. - The `sub` is never sent: a Google Submission may carry the handle alone, and the disclosure call takes the handle and finds the identity through the handle key, so showing an address no longer publishes the cross-site account id. Both-or-neither and the lone-field drop go with it. - "Disclosed" counts accepted transactions; a refused one still publishes its calldata (common §12). - REQ-PLAT-08D's keep-or-clear compares with the handle the identity holds once the Submission is applied, so an older proof clears nothing. - REQ-PLAT-08F requires the event of a disclosing Submission or disclosure call to carry the normalized handle, and forbids the `sub` in any event. - REQ-PLAT-08G fixes the Consumer's normalization to the profile's published handle table, and says how a Consumer knows a digest profile. - ASM-ZK-01 is a property of the zero-knowledge proving mode, and the new REQ-COMMON-45A has the Canonical Runtime prove in it with fresh randomness; TEST-COMMON-22 checks two proofs of one witness differ. - REQ-PLAT-03 and TEST-PLAT-17 name the ID Token as a digest profile's local source, the local handle normalized; REQ-PLAT-04 and the §2.1 table bound a Google `sub` at the circuit's 31 bytes. - §12's pointer to the consent-screen case is gone with the text it served. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ceremony-common.md | 77 ++++++++----- specs/libid.md | 10 +- specs/platform-ceremonies.md | 209 +++++++++++++++++------------------ 3 files changed, 156 insertions(+), 140 deletions(-) diff --git a/specs/ceremony-common.md b/specs/ceremony-common.md index 2136f3ba..0e51354d 100644 --- a/specs/ceremony-common.md +++ b/specs/ceremony-common.md @@ -230,14 +230,22 @@ Attestation Count: The number of entries in the closed attestation list a secret: a low-entropy preimage, such as an email address, is recovered by hashing guesses, and this assumption gives no protection against that. - ASM-ZK-01: - The proof system of a digest profile is zero-knowledge: a proof that the - verifier artifact selected for it accepts reveals nothing of the witness - beyond the proof's public inputs. + The proof system of a digest profile is zero-knowledge in its + zero-knowledge proving mode: a proof made in that mode with fresh prover + randomness reveals nothing of the witness beyond the proof's public + inputs. No verifier can tell what randomness a proof was made with, so + this holds only for proofs the Canonical Runtime makes as REQ-COMMON-45A + requires. ## 4. Security properties The properties below survive a malicious application operator under their -cited assumptions. They assume an unmodified Canonical Runtime, the selected +cited assumptions, except SP-PRIV-01. A Google ID Token reaches the +application's own origin, so an application operator holds the handle and +the `sub` in plaintext and can send them anywhere, the chain included. +Against such an operator SP-PRIV-01 still bounds the Consumer and the Proof +Verifier, which put no plaintext on chain that a transaction did not carry; +against an honest application it bounds everyone who reads the chain. They assume an unmodified Canonical Runtime, the selected verifier artifact, the Consumer, and verifier configuration. Compromise of the applicable identity-platform signing root, notary key, Platform Verifier, verifier governance, @@ -277,14 +285,13 @@ on it. one authoritative effect. Depends on ASM-CHAIN-01, ASM-CHAIN-02. Evidence: checked invariant in the Consumer. - SP-PRIV-01: - For a digest profile, an identity's plaintext `userId` and handle reach - the Consumer Chain only in a Submission whose Authorization Digest commits - their disclosure, or in a disclosure call; the Canonical Runtime releases - them to no other party. Short of such a transaction, no chain artifact, - whether calldata, proof bytes, event, or storage, yields them except by - hashing a candidate handle or user identifier and comparing it with the - identity's keys. Depends on ASM-HASH-01, ASM-PROOF-01, ASM-ZK-01, - ASM-BROWSER-01. Evidence: conformance tests (supporting, not proving) + For a digest profile, the Consumer and the Proof Verifier put an + identity's handle on the Consumer Chain in plaintext only from an + accepted Submission or disclosure call that carried it, and its `userId` + never. No chain artifact, whether calldata, proof bytes, event, or + storage, yields a handle or `userId` that no transaction carried, except + by hashing a candidate and comparing it with the identity's keys. Depends + on ASM-HASH-01, ASM-PROOF-01, ASM-ZK-01, ASM-BROWSER-01. Evidence: conformance tests (supporting, not proving) plus the preimage resistance of keccak256. ## 5. Authorization digest @@ -577,14 +584,14 @@ an identity session — so one Submission on either path pays two fees. Platform Profile exposes them, the `userId` digest and the handle digest where its Platform Profile exposes those instead (platform REQ-PLAT-16C), and `metadataObservedAt`. Where its Platform Profile exposes digests and - the Submission carries the plaintext `userId` and handle, the Platform - Verifier MUST also return those bytes, marked unverified. Necessity: an + the Submission carries the plaintext handle, the Platform Verifier MUST + also return those bytes, marked unverified. Necessity: an authenticated `userId`, handle, and observation time are what the ceremony exists to produce; the digest is the Consumer's replay nullifier, which it cannot recompute without reading the payload. The Consumer trusts the verified fields as it trusts the Platform Verifier the Verifier Governance - Process installed. The Consumer trusts the unverified plaintext only once - it hashes to the digests (platform REQ-PLAT-08D); a Consumer that reads it + Process installed. The Consumer trusts the unverified handle only once it + hashes to the handle digest (platform REQ-PLAT-08D); a Consumer that reads it otherwise binds whatever identity a Submission names. - REQ-COMMON-45 (upholds SP-BIND-01, SP-EXCHANGE-01, SP-PRIV-01): The Platform Verifier MUST verify the proof carried in the Submission @@ -594,7 +601,7 @@ an identity session — so one Submission on either path pays two fees. Governance Process MUST select only artifacts that enforce the proof statement of the Platform Ceremony Version the verifier implements. For a digest profile, the Verifier Governance Process MUST select only artifacts - that verify zero-knowledge proofs (ASM-ZK-01). The Platform Verifier + that accept only proofs in the zero-knowledge proving mode (ASM-ZK-01). The Platform Verifier MUST reject a Submission whose proof does not verify under that artifact. The Platform Verifier MUST NOT accept a caller-supplied artifact, verifying key, or externally computed verification result. Necessity: @@ -602,6 +609,13 @@ an identity session — so one Submission on either path pays two fees. role performed the acceptance; with no rule placing that work anywhere, no role is obliged to run it, and every public input the surrounding rules compare is then a number the caller wrote down. +- REQ-COMMON-45A (upholds SP-PRIV-01): + For a digest profile, the Canonical Runtime MUST make every proof in the + proof system's zero-knowledge proving mode, with prover randomness drawn + fresh for that proof. Necessity: the proof bytes sit in calldata beside + the public inputs, and whether they reveal the email and `sub` is decided + when they are made; the verifier accepts a proof whatever randomness it + was made with (ASM-ZK-01). - REQ-COMMON-46 (upholds SP-BIND-01): The Proof Verifier MUST pass the Submission Payload and the native value to the Platform Verifier it selected without decoding either. The Platform @@ -1463,14 +1477,15 @@ the constructions that role implements. Notary Service; a Submission on a two-count profile is quoted and charged exactly two fees; and a Submission whose second attestation verification rejects leaves no fee delivered for the first. -- TEST-COMMON-22 (exercises REQ-COMMON-45): +- TEST-COMMON-22 (exercises REQ-COMMON-45, REQ-COMMON-45A): A Submission whose proof does not verify under the artifact selected for the Platform Verifier registered under its identity platform and Verifier Version is rejected; a proof verifying only under another platform's or another ceremony version's artifact is rejected; a caller-supplied artifact, verifying key, or precomputed verification result changes no decision; and for a digest profile, a - proof of the right statement made without the zero-knowledge option is - rejected. + proof of the right statement made outside the zero-knowledge proving mode + is rejected, and two proofs the Canonical Runtime makes of one witness + differ. - TEST-COMMON-23 (exercises REQ-COMMON-02, REQ-COMMON-46): The Platform Verifier recomputes the digest from the operation domain, nonce and transaction data it decoded, the ceremony version it implements, @@ -1535,16 +1550,18 @@ client secret, and the transcript bytes outside a profile's revealed ranges stay withheld for good. SP-PRIV-01 is a statement about the chain's artifacts, not about the -identity. Whether a Submission discloses is part of the operation the user -authorizes, committed in its Authorization Digest like the fee (platform -REQ-PLAT-08D), and the Canonical Runtime hands the plaintext to the -application only when that operation discloses it (platform REQ-PLAT-03); -an application operator that never receives the plaintext cannot attach it, -and one that asks for a disclosing operation is the consent-screen case -below. A transaction that carries plaintext publishes it when it is sent: -a Submission or disclosure call the Consumer refuses still leaves its -calldata on chain. The proof bytes hide the witness only because the proof -system is zero-knowledge (ASM-ZK-01, REQ-COMMON-45). The keys an undisclosed identity is stored under are unsalted +identity, and it does not survive a malicious application operator (§4). +The ID Token, and the email and `sub` in it, reach the application's +origin, so whether a Submission carries the handle is the application's +choice, made for the user; an operator that wants the address public can +send it, and no Consumer check can tell that from the user's wish. What +the property does bound is the chain: the Consumer and the Proof Verifier +emit and store nothing a transaction did not carry, and a transaction that +carries a handle publishes it when it is sent, since a Submission or +disclosure call the Consumer refuses still leaves its calldata on chain. +The proof bytes hide the witness only because the Canonical Runtime proves +in the zero-knowledge mode with fresh randomness (ASM-ZK-01, +REQ-COMMON-45A). The keys an undisclosed identity is stored under are unsalted digests of its normalized handle and its user identifier, so that whoever already knows an address can resolve it; by the same arithmetic, whoever suspects an address, or holds the user identifier from another relying diff --git a/specs/libid.md b/specs/libid.md index cf717134..c1d8d6bd 100644 --- a/specs/libid.md +++ b/specs/libid.md @@ -115,11 +115,11 @@ evidence, which is Google's signature relation and, on X and GitHub, that one hidden bearer opens both sessions' commitments. The Consumer enforces replay rejection by recording every Authorization Digest it accepts before applying an effect (REQ-COMMON-03, REQ-COMMON-03A). For a profile that exposes identity -digests, Google at launch, the Consumer keys the binding on the digests, and -the handle and user identifier reach the chain only in a transaction that -discloses them, by the user's authorized choice or their owner's -disclosure call (SP-PRIV-01); it does not prevent confirmation of a guessed -identity by hashing. The Canonical Runtime +digests, Google at launch, the Consumer keys the binding on the digests and +puts the handle on chain only from a transaction that carried it, and the +user identifier never (SP-PRIV-01); it does not prevent confirmation of a +guessed identity by hashing, nor an application operator, who receives the +ID Token, from sending the handle itself. The Canonical Runtime locally enforces the selected OAuth client and redirect profile. The protocol assumes the named identity-platform parser, PKCE, delivery, notary, browser, verifier-soundness, and chain behaviors. It diff --git a/specs/platform-ceremonies.md b/specs/platform-ceremonies.md index f61ea810..3870cecd 100644 --- a/specs/platform-ceremonies.md +++ b/specs/platform-ceremonies.md @@ -74,21 +74,21 @@ REQ-COMMON-15A. interoperability between the Canonical Runtime build and server deployment. - REQ-PLAT-03 (upholds SP-CLIENT-01): The Canonical Runtime MUST derive the local identity fields exclusively from - the Platform Profile's canonical source in the exact Submission it - returns. + the Platform Profile's canonical source: for X and GitHub, in the exact + Submission it returns; for a digest profile, in the signed ID Token whose + digests that Submission's proof carries. Those fields are not an authority decision; only the Consumer's acceptance of that exact Submission is. For X and GitHub, the Canonical Runtime MUST parse the exact revealed identity-response bytes that the Platform Verifier extracts, using the same canonical extraction and normalization rules. The Canonical Runtime MUST reject a detached proof output, sidecar value, or caller value that supplies or overrides `userId`, handle, or `metadataObservedAt`. For a digest profile (§2.1b), the - Canonical Runtime MUST derive the local `userId` and handle from the - signed ID Token whose digests the Submission's proof carries, the `sub` - exactly and the `email` as signed. The Canonical Runtime MUST place them - in the Submission as its plaintext, byte for byte, exactly when the - Authorized Transaction Data says the Submission discloses them. The - Canonical Runtime MUST NOT return them to the application for a - Submission that does not disclose them. + Canonical Runtime MUST derive the local `userId` from the signed `sub` + exactly, and the local handle from the signed `email` normalized under + §2.1a, of the ID Token it verified under §3.2. The Canonical Runtime MUST + place the signed `email` bytes in the Submission as its plaintext handle + only when its caller asks to disclose the handle. The Canonical Runtime + MUST NOT place the `sub` in any Submission. This is a data-source invariant, not a browser-flow requirement. It defines the identity fields returned to callers and used by any composition-owned UI; @@ -98,15 +98,16 @@ it does not create a ceremony-owned confirmation page. | Identity platform | Authenticated source | Canonical `userId` | Mutable handle | |---|---|---|---| -| Google | signed ID-Token `sub` | its exact 1–255 case-sensitive ASCII bytes | normalized email | +| Google | signed ID-Token `sub` | its exact 1–31 case-sensitive ASCII bytes | normalized email | | X | `/2/users/me.data.id` JSON string | canonical nonzero unsigned 64-bit decimal | normalized `username` | | GitHub | `/user.id` JSON integer token | canonical nonzero unsigned 64-bit decimal | normalized `login` | - REQ-PLAT-04: The Implementation MUST accept a Google `sub` of bytes `0x20` through `0x7e` only, other than `"` and `\`. The Implementation MUST reject empty, - control, non-ASCII, and over-255-byte values. Necessity: identity - compatibility across implementations; a value holding a backslash is one + control, non-ASCII, and over-31-byte values. Necessity: identity + compatibility across implementations; 31 bytes is the Proving Circuit's + buffer (REQ-PLAT-16D), and a Google `sub` is 21 digits; a value holding a backslash is one whose JSON encoding may escape a byte, and an implementation that decodes the escape and one that reads the signed bytes would key it differently. @@ -195,99 +196,93 @@ such table is ineligible. A digest profile is a Platform Profile whose Proving Circuit exposes the handle and the canonical `userId` as keccak256 digests rather than bytes; -Google is one (§3.3). The Consumer keys every identity on those digests, -so an identity is resolvable by whoever knows its handle or `userId` -whether or not the bytes were ever published. What a digest profile adds -is the choice of publishing them. - -Two facts about such an identity are kept apart. An identity is -**disclosed** once any transaction has carried its plaintext `userId` and -handle to the Consumer Chain; disclosure is history and nothing undoes it. -A wallet's name on a platform is **published** while the Consumer holds a -handle for that wallet and platform to display; publication is Consumer -state, set and cleared as below. +Google is one (§3.3). A Consumer knows a platform's profile is one because +its Platform Verifier returns digests where another returns bytes (common +REQ-COMMON-05E). The Consumer keys every identity on those digests, so an +identity is resolvable by whoever knows its handle or `userId` whether or +not either was ever sent in plaintext. What a digest profile adds is the +choice of sending the handle. Its `userId` is never sent: nothing the +Consumer or a reader does needs it as text, and it is the one value that +also names the account at every other relying party. + +Two facts about such an identity are kept apart. A handle is **disclosed** +once a Submission or disclosure call the Consumer accepted has carried it; +disclosure is history and nothing undoes it. A refused transaction leaves +no disclosure on record, although its calldata is public all the same +(common §12). A wallet's name on a platform is **published** while the +Consumer holds a handle for that wallet and platform to display; +publication is Consumer state, set and cleared as below. - REQ-PLAT-08D (upholds SP-BIND-01, SP-PRIV-01): For a digest profile, the Consumer MUST derive the identity's keys from the `userId` digest and the handle digest the Platform Verifier returns, - in every Submission. The Consumer's Authorized Transaction Data for a - binding MUST carry whether the Submission discloses the identity. For a - profile that exposes identity bytes, the Consumer MUST reject a - Submission whose Authorized Transaction Data does not disclose, since - the bytes it carries are the disclosure. The Consumer MUST reject a - Submission that carries one of the plaintext - `userId` and handle without the other. The Consumer MUST reject a - Submission that carries plaintext its Authorized Transaction Data says - it does not disclose, or carries none where that data says it does. - Where a Submission carries plaintext, the Consumer MUST normalize the - handle under §2.1a. The Consumer MUST reject such a Submission unless - `keccak256` of the `userId` bytes equals the `userId` digest and - `keccak256` of the normalized handle equals the handle digest. The - Consumer MUST reject a Submission that asks to publish a name and - carries no plaintext. On accepting a Submission that carries no - plaintext, the Consumer MUST keep the name the Transaction Author - published for the platform when that name's handle key equals the - Submission's handle key. The Consumer MUST clear that name when the keys - differ. Necessity: the choice to disclose is the user's, so it is - committed in the Authorization Digest with the rest of the operation; and - a published name is read as the wallet's current handle, which a private - re-proof of the same handle leaves true and a claim of another handle - makes false. + in every Submission. Where a Submission carries the plaintext handle, the + Consumer MUST normalize it under §2.1a. The Consumer MUST reject such a + Submission unless `keccak256` of the normalized handle equals the handle + digest. The Consumer MUST reject a Submission that asks to publish a name + and carries no handle. On accepting a Submission that carries no handle, + the Consumer MUST keep the name the Transaction Author published for the + platform when that name's handle key is the handle key the identity holds + once the Submission is applied. The Consumer MUST clear that name when + the keys differ. Necessity: a published name is read as the wallet's + current handle, which a private re-proof of the same handle leaves true, + a proof of another handle makes false, and an older proof that leaves the + identity's handle where it was does not change. - REQ-PLAT-08E (upholds SP-BIND-01): For a digest profile, the Consumer MUST offer a disclosure call that - takes a platform, a plaintext `userId`, and a handle. The Consumer MUST - normalize the handle under §2.1a, derive the identity key and the handle - key from the plaintext, and accept the call only when the caller owns - both keys, the identity key's current handle key is that handle key, and - that handle key's current identity key is that identity key. On - acceptance the Consumer MUST publish the normalized handle as the - caller's name for the platform. The Consumer MUST NOT require a new proof - for a disclosure; the preimages are the evidence. Necessity: testing the - pairing in both directions is what refuses a handle retired by a later - claim of the same account, and a handle one of the caller's accounts took - from another of them, so a wallet displays only a handle it holds. A - refused call protects the display, not the plaintext: its calldata is - public whether or not the Consumer accepts it. + takes a platform and a plaintext handle. The Consumer MUST normalize the + handle under §2.1a and derive its handle key. The Consumer MUST accept + the call only when that handle key's current identity key names an + identity whose current handle key is that handle key, and the caller owns + both keys. On acceptance the Consumer MUST publish the normalized handle + as the caller's name for the platform. The Consumer MUST NOT require a + new proof or the `userId` for a disclosure; the handle's preimage is the + evidence, and the identity key follows from the handle key. Necessity: + testing the pairing in both directions is what refuses a handle retired + by a later claim of the same account, and a handle one of the caller's + accounts took from another of them, so a wallet displays only a handle it + holds. A refused call protects the display, not the handle: its calldata + is public whether or not the Consumer accepts it. - REQ-PLAT-08F (upholds SP-PRIV-01): The Consumer MUST state in every binding or disclosure event it emits - whether the event carries plaintext. The Consumer MUST put the plaintext - `userId` and handle in an event only when the Submission or disclosure - call carried them. The Consumer MUST put the normalized handle in an - event, never the bytes as submitted. Necessity: an event that carried plaintext is public - for good, and a reader distinguishes a disclosed identity from one - currently published only if the events say which is which. A Submission - without plaintext after a disclosure leaves the identity disclosed, its - name published or cleared under REQ-PLAT-08D, and its own event without - plaintext. + whether the event carries the handle. The Consumer MUST put the + normalized handle in the event of every accepted Submission that carried + it and of every accepted disclosure call. The Consumer MUST NOT put a + handle in any other event of a digest profile. The Consumer MUST NOT put + the `userId` of a digest profile in any event. Necessity: an event that + carried a handle is public for good, and a reader distinguishes a + disclosed handle from a currently published name only if the events say + which is which. A Submission without the handle after a disclosure leaves + the handle disclosed, its name published or cleared under REQ-PLAT-08D, + and its own event without the handle. - REQ-PLAT-08G (upholds SP-BIND-01): - For a digest profile, the Consumer's handle rules for the platform MUST be - the normalization the profile's Proving Circuit applies. The Consumer MUST - refuse a change to those rules once it has bound any identity of that - platform. Necessity: the Consumer holds only digests, so it cannot re-key - a binding under new rules, and a disclosed handle normalized under rules - the circuit does not apply stops hashing to its own key. + For a digest profile, the Consumer MUST normalize the platform's handles + with the handle table the profile publishes (§2.1a), whose case-folding, + character-set, and shape rows the Proving Circuit reproduces + (TEST-PLAT-20). The Consumer MUST refuse a change to that normalization + once it has bound any identity of that platform. Necessity: the Consumer + holds only digests, so it cannot re-key a binding under new rules, and a + disclosed handle normalized under rules the circuit does not apply stops + hashing to its own key. - TEST-PLAT-20A (exercises REQ-PLAT-03, REQ-PLAT-08D, REQ-PLAT-08E, REQ-PLAT-08F, REQ-PLAT-08G): - The same Google account submitted with and without its plaintext lands on - the same two keys. A Submission without plaintext, made with recognizable - test values, carries neither the email nor the `userId` in its decoded - payload or its decoded events. A Submission is rejected when its - plaintext does not hash to the digests, when it carries only one of the - two, when it carries plaintext its Authorized Transaction Data says it - does not disclose, when it carries none where that data says it does, - and when it asks to publish a name without plaintext; an X or GitHub - Submission whose Authorized Transaction Data does not disclose is - rejected. A private re-proof - of the published handle keeps the name; a private claim of another handle - clears it. A disclosure call is rejected for a handle retired by a later - claim of the same account, for a handle another account of the same - wallet took over, and from a caller that does not own both keys; a - disclosure of the current pair publishes it, and its event carries the - normalized handle. A Submission without plaintext after a disclosure - leaves the binding resolvable by its handle and its event without - plaintext. The Canonical Runtime returns no `sub` or `email` to the - application for a Submission that does not disclose, and places the - signed `sub` and `email` byte for byte in one that does. A change to - Google's handle rules is refused once a Google identity is bound. + The same Google account submitted with and without its handle lands on + the same two keys. A Submission without the handle, made with + recognizable test values, carries neither the email nor the `sub` in its + decoded payload or its decoded events, and a Submission with the handle + carries no `sub` in either. A Submission is rejected when its handle does + not hash to the handle digest, and when it asks to publish a name without + the handle. A private re-proof of the published handle keeps the name; a + private claim of another handle clears it; an older private proof + accepted without moving the identity's handle leaves the name. A + disclosure call is rejected for a handle retired by a later claim of the + same account, for a handle another account of the same wallet took over, + and from a caller that does not own both keys; a disclosure of the + current handle publishes it, and its event carries the normalized handle. + A Submission without the handle after a disclosure leaves the binding + resolvable by its handle and its event without the handle. The Canonical + Runtime places the signed `email` in a Submission only when asked to + disclose and places no `sub` in any. A change to Google's handle + normalization is refused once a Google identity is bound. ### 2.2 Metadata ordering and validity ceilings @@ -435,16 +430,18 @@ require a verifier that dispatches on the header `alg`; none exists here. The Proving Circuit MUST NOT expose a detached second representation of a claim: the `sub` and `email` bytes appear in no public input, only their digests do, and each digest is the inner hash of the Consumer's key for - that identity, so a claim disclosed in plaintext and one left undisclosed + that identity, so a claim that carries its handle and one that does not key the same binding. - REQ-PLAT-16C (upholds SP-BIND-01, SP-PRIV-01): The Platform Verifier MUST return the `userId` digest and the handle digest as the digests of common REQ-COMMON-05E. Where the Submission - carries the plaintext `sub` and `email`, the Platform Verifier MUST pass - them to the Consumer unchanged and unchecked. The Platform Verifier MUST - NOT derive a normalized handle or a key from them; the check that they - hash to the digests is the Consumer's under REQ-PLAT-08D, because it - needs the normalization of §2.1a. Necessity: one party owns the equality, and it is + carries the plaintext `email`, the Platform Verifier MUST pass those bytes + to the Consumer unchanged and unchecked, as the unverified handle of + REQ-COMMON-05E. The Google Submission Payload MUST carry no plaintext + `sub`. The Platform Verifier MUST NOT derive a normalized handle or a key + from the `email`; the check that it hashes to the handle digest is the + Consumer's under REQ-PLAT-08D, because it needs the normalization of + §2.1a. Necessity: one party owns the equality, and it is the one that owns the normalization. - REQ-PLAT-16D (upholds SP-BIND-01, SP-PRIV-01): The Proving Circuit MUST admit a `sub` only as REQ-PLAT-04 and @@ -1299,8 +1296,9 @@ Platform Verifier, Notary Service, Consumer. cannot satisfy the circuit; neither can an empty `sub`, a `sub` byte outside `0x20` through `0x7e`, a `sub` holding `\` (as `12\/3` does), a 32-byte `sub`, or a 63-byte `email`. The public inputs carry no `sub` or `email` byte. The Platform - Verifier returns both digests, returns plaintext the Submission carried - byte for byte, and returns none where the Submission carried none. + Verifier returns both digests, returns the `email` a Submission carried + byte for byte, and returns none where the Submission carried none; the + payload has no field for a `sub`. - TEST-PLAT-07 (exercises REQ-PLAT-22, REQ-PLAT-09, REQ-PLAT-09A): A proof at or after `proofValidUntil`, and a token-attestation creation time more than `maxFutureAttestationSkew` ahead of Block Time, are rejected. An @@ -1405,8 +1403,9 @@ Platform Verifier, Notary Service, Consumer. and semantic public inputs across two chains using different conforming verifier artifacts. A destination chain does not support the pair without a conforming artifact or, for a TLSNotary profile, a compatible Notary Service. - No local identity field originates outside proof public inputs and the exact - revealed attestation bytes carried by its Submission. Only the Consumer's + No local identity field originates outside proof public inputs, the exact + revealed attestation bytes carried by its Submission, and, for a digest + profile, the signed ID Token whose digests its proof carries. Only the Consumer's acceptance of that exact Submission makes the claim authoritative. - TEST-PLAT-17A (exercises REQ-PLAT-03, REQ-PLAT-31A, REQ-PLAT-51A): Pair authenticated X or GitHub identity-response bytes for account B with a From 01db38318fe917550c8b1997713f1f02affdb2df Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 17:27:06 +0300 Subject: [PATCH 10/11] docs(design): the note follows the application-chosen, handle-only design - The mode is the email's presence in the payload; `GoogleProof` gains `bytes email` alone and never a `sub`. The Authorized-Transaction-Data flag moves to the rejected options with its reason: no trusted screen shows it, the Canonical Runtime cannot decode it, and the ID Token has already reached the application. - `publish(platformId, handle)` finds the identity through the handle's node; keep-or-clear compares with the handle held after the write. - Disclosure counts accepted transactions, the indexer's `disclosed` says so, and the private-refresh sequence reads back as published, matching REQ-PLAT-08D; the step-4 criteria follow. - eden-testnet does deploy IdentityNames and the Google verifier; the sentence saying no network did is corrected, pre-release test data. - The SDK keeps returning the email and `sub`; the prover's zero-knowledge mode is named as the requirement it now is. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- design/private-gmail-handle.md | 290 ++++++++++++++++++--------------- 1 file changed, 157 insertions(+), 133 deletions(-) diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md index 94e2e9e4..04fae482 100644 --- a/design/private-gmail-handle.md +++ b/design/private-gmail-handle.md @@ -159,9 +159,12 @@ names a `keccakf1600` the pinned standard library no longer has). and cheaper. But the node's inner hash would then be SHA-256 for Google and keccak for everything else, so `IdentityNodes`, the indexer's `nodes.rs`, the TypeScript node helpers and the gateway all grow a per-platform case, and -every Google node changes. None of the networks in `chain-configurations` -deploys `IdentityNames` today, so there is no Google namespace to migrate, -but the case is permanent code and permanent audit surface. +every Google node changes. One network does deploy `IdentityNames` and the +Google verifier, eden-testnet (`chain-configurations` +`networks/eden-testnet.toml:66,74`), but nothing is released, so its +Google bindings are test data redeployed with the new statement rather than +a namespace to migrate; the case would still be permanent code and +permanent audit surface. **Poseidon.** Cheapest in a circuit and no precedent anywhere in libID's Solidity, Rust or TypeScript. The contract would compute Poseidon in `_write` @@ -222,58 +225,62 @@ joins with every relying party's user table. ### Where the mode lives -**In the operation the user authorizes, carried out by the presence of the -plaintext.** The Consumer's Authorized Transaction Data for a claim is the -triple `(target, feeAmount, feeReceiver)` (`IdentityNames.sol:481-491`); it -becomes `(target, feeAmount, feeReceiver, disclose)`, so the choice is -committed in the Authorization Digest the way the fee is, and the user sees -it when they consent. A platform that exposes bytes has only one true -answer, and its claims carry `disclose = true`. `GoogleProof` gains -`bytes userId` and `bytes email` beside `clientIdentifier` -(`GooglePlatformVerifier.sol:76-84`), both present exactly when `disclose` -says so. The Canonical Runtime fills them from the ID Token it verified, -and hands the email to the application only for a disclosing claim, so an -application that asked for a private one has nothing to attach. - -The verifier passes the submitted bytes through, marked unverified, and -returns the two digests the proof bound as new `VerifiedClaim.userIdHash` -and `handleHash`; it checks nothing about the bytes, because the check -needs the handle normalized and normalization is the Consumer's -(REQ-PLAT-08B). `IdentityNames._write` is the one place the equality holds: -it derives both nodes from the digests in both modes; it refuses plaintext -that `disclose` does not authorize, a missing plaintext it does, and one of -the two without the other; when the plaintext is present it normalizes the -handle, requires `keccak256(bytes(userId)) == userIdHash` and +**The presence of the handle in the payload.** `GoogleProof` gains one +optional field, `bytes email`, beside `clientIdentifier` +(`GooglePlatformVerifier.sol:76-84`); empty means private. It never gains +a `userId` field: the `sub` is never sent, since nothing on chain or in the +indexer reads it as text (`resolveId` and `resolvePair` hash what the +caller supplies), and it is the one value that also names the account at +every other relying party. The verifier passes the email bytes through, +marked unverified, and returns the two digests the proof bound as new +`VerifiedClaim.userIdHash` and `handleHash`; it checks nothing about the +bytes, because the check needs the handle normalized and normalization is +the Consumer's (REQ-PLAT-08B). `IdentityNames._write` is the one place the +equality holds: it derives both nodes from the digests in both modes; when +the email is present it normalizes it, requires `keccak256(bytes(normalized)) == handleHash`, and only then stores or -emits the strings, the handle normalized. `publishName` without plaintext -reverts. X and GitHub verifiers return zero digests and keep the plaintext -path they have. **Recommended.** It keeps REQ-PLAT-08B honest in the form -that matters: the Consumer still derives the key from a proof-bound value -and still refuses a caller-supplied key; the plaintext is accepted only -because the proof binds its digest and the user authorized sending it. - -**The presence of the plaintext alone, chosen at submission time.** -Rejected. The application assembles the transaction, and whoever assembles -it would decide whether the address is published; the application operator -is trusted with no identity field (`libid.md`, roles). The cost of the -recommendation is that the choice is fixed before proving; making a -private binding public later is the `publish` call below, and nothing -turns a public one private anyway. - -**A mode bit as a circuit public input.** Rejected. It costs an input and -adds nothing the Authorization Digest does not already commit. - -The event gains `bool disclosed` and keeps `string userId` and `string -handle`, both empty when private. `disclosed` is a fact about the event: -this event carries the plaintext. It says nothing about earlier events, and -it cannot, because a plaintext once emitted is public for good. An empty -string is unambiguous, since the normalizer rejects an empty handle, but the -bool is what an indexer reads without parsing. A separate indexed -`handleHash` would duplicate `handleNode`. +emits the handle, normalized. `publishName` without the email reverts. X +and GitHub verifiers return zero digests and keep the plaintext path they +have. **Recommended:** one optional payload field, no change to the +signature of `claim` or to any claim's Authorized Transaction Data. It +keeps REQ-PLAT-08B honest in the form that matters: the Consumer still +derives the key from a proof-bound value and still refuses a +caller-supplied key; the email is accepted only because the proof binds +its digest. + +Whoever assembles the transaction decides whether it carries the email, +and that is the application. The ID Token lands on the application's +redirect page and the SDK hands it to the application's page, so an +application operator holds the email and the `sub` whatever any field +says. The privacy this design buys is therefore against readers of the +chain, not against a malicious application, and the spec says so +(SP-PRIV-01 and §4 of `ceremony-common.md`). + +**The choice in the claim's Authorized Transaction Data.** Rejected. It +would commit a `disclose` flag in the Authorization Digest like the fee, +but no trusted screen shows it to the user, the Canonical Runtime cannot +decode Authorized Transaction Data to honour it, and the token has +already reached the application; a field that enforces nothing only +changes every X and GitHub claim's encoding. + +**An explicit flag on `claim`.** Rejected. Two sources of truth for one +fact, and nothing to do when they disagree. + +**A mode bit as a circuit public input.** Rejected. It bakes the disclosure +choice into the proof, costs an input, and stops the user from changing +their mind between proving and submitting. + +The event gains `bool disclosed` and keeps `string handle`, empty when +private, and leaves `string userId` empty for Google always. `disclosed` +is a fact about the event: this event carries the handle. It says nothing +about earlier events, and it cannot, because a handle once emitted is +public for good. An empty string is unambiguous, since the normalizer +rejects an empty handle, but the bool is what an indexer reads without +parsing. A separate indexed `handleHash` would duplicate `handleNode`. ### Default, and moving between modes -**Google is private unless the user sends the plaintext.** That is the premise +**Google is private unless the application sends the email.** That is the premise of this note. Public by default with a private opt-in would leave §12's rationale intact and is recorded here only as the alternative not taken. @@ -284,11 +291,12 @@ open. **Private to public, later.** By decision a call, not a new claim, and the contract already has its inverse, `unpublish`, so the call is -`publish(platformId, string userId, string handle)`: normalize the handle, -derive both nodes, require that the caller owns them and that the binding is -live, set `published`, emit `IdentityPublished(owner, platformId, idNode, -handleNode, userId, handle)`. No proof is needed; the preimages are the -proof. The address is public from the block the call is sent in, whether +`publish(platformId, string handle)`: normalize the handle, derive its +node, find the identity through `idOfHandle`, require the pairing below and +that the caller owns both nodes, set `published`, emit +`IdentityPublished(owner, platformId, idNode, handleNode, handle)`. No +proof is needed and no `sub`: the handle's preimage is the proof, and the +identity follows from the handle's node. The address is public from the block the call is sent in, whether or not it is accepted: a refused `publish` still leaves its calldata on chain. @@ -301,9 +309,9 @@ plaintext of an address the wallet no longer holds and set `published` to it, so `reverseOf`, which returns the stored string as it is (`:770`), would name that address until the next claim; `primaryOf` already refuses a published string whose node the wallet does not own (`:787-792`). So -`publish` requires the pairing in both directions, `handleOfId[idNode] == -handleNode` and `idOfHandle[handleNode] == idNode`, and the wallet to own -both nodes. One direction is not enough: a wallet holding two Google +`publish` requires the pairing in both directions, `idOfHandle[handleNode]` +naming an `idNode` with `handleOfId[idNode] == handleNode`, and the wallet +to own both nodes. One direction is not enough: a wallet holding two Google accounts, whose first account's handle was reassigned to its second, owns both of the first account's nodes while `handleOfId` still names the retired handle, and only `idOfHandle` says the handle is the second @@ -313,21 +321,26 @@ they hold. The same scenario settles one more rule. A claim from a wallet that has already published refreshes the published string to the handle just proved, so a rename never leaves a stale name on display (`:626-633`). A private -claim carries no plaintext to refresh with. When it re-proves the handle -already published, whose normalized string hashes to the claim's -`handleHash`, the publication stays, as the contract already keeps a -display a `publishName: false` claim still vouches for; when it proves -another handle, it deletes the publication, or `reverseOf` would keep -showing the old address after the account moved on. - -Two facts therefore live apart. **Disclosure** is history: once any event -has carried an identity's plaintext, a private claim, a private refresh of -the same nodes, or `unpublish` afterwards, the plaintext is known and stays -known. **Publication** is state: whether the wallet currently displays the -name, set by `publishName` on a disclosed claim or by `publish`, cleared by -`unpublish` or by a private claim of another handle. The sequence private claim, `publish`, -private refresh ends with the plaintext known, the publication cleared, and -the last event saying `disclosed: false`, all three true at once. +claim carries no email to refresh with. The publication stays when the +published string hashes to the handle the identity holds once the claim is +written, as the contract already keeps a display a `publishName: false` +claim still vouches for; otherwise it is deleted, or `reverseOf` would keep +showing the old address after the account moved on. Comparing with the +handle held after the write, not with the claim's own `handleHash`, is +what keeps an older proof accepted without moving the handle from clearing +a correct name. + +Two facts therefore live apart. **Disclosure** is history: once an +accepted claim or `publish` has carried a handle, the handle is known and +stays known, whatever private claim or `unpublish` follows. A refused +transaction discloses nothing on record, but its calldata is public all +the same; no event marks it. **Publication** is state: whether the wallet +currently displays the name, set by `publishName` on a claim carrying the +email or by `publish`, cleared by `unpublish` or by a private claim that +moves the identity to another handle. The sequence private claim, +`publish`, private refresh of the same handle ends with the handle known, +the publication kept, and the last event saying `disclosed: false`, all +three true at once. **Public to private.** Impossible, and the note should say so where users read it. The log line exists. `unpublish` already documents this for the @@ -335,9 +348,9 @@ storage string; the same sentence covers the event. ### Downstream -**usernames-indexer.** `names.handles.handle` and `names.ids.user_id` become -nullable and `names.published` stays plaintext, since only a disclosed -binding can be published. `resolve_handle` currently selects `WHERE h.handle = $3` +**usernames-indexer.** `names.handles.handle` becomes nullable, and +`names.ids.user_id` is null for every Google identity; `names.published` +stays plaintext, since only a disclosed handle can be published. `resolve_handle` currently selects `WHERE h.handle = $3` (`handle_lookup`, `usernames-core/src/db.rs:1186-1199`) and moves to the node: fold the query, normalize, derive `handleNode` with the function the indexer already has (`nodes.rs`), select by node. `/v1/search` excludes private bindings by @@ -345,13 +358,14 @@ construction, since there is no text to match. The recompute check that compares a re-derived node with the emitted topic (`db.rs:791-798`) skips the handle and the id when the event carries none; `/v1/resolve/id` moves to the node the same way. The indexer keeps the two facts apart as the -contract does: `IdentityPublished` fills a `names.ids` or `names.handles` -row whose plaintext was null and sets `names.published`; a later private -event never nulls a plaintext row, only `names.published` follows the -publication state. Responses carry `disclosed`, meaning the plaintext of -this identity is known, from any event that ever carried it, and -`published`, the current state; a private-only identity returns `handle: -null` and `userId: null`. The resolve routes keep the plaintext in the path, +contract does: `IdentityPublished` fills a `names.handles` row whose +plaintext was null and sets `names.published`; a later private event never +nulls a plaintext row, only `names.published` follows the publication +state. Responses carry `disclosed`, meaning an accepted event carried this +handle, and `published`, the current state; an undisclosed identity +returns `handle: null`, and a Google identity `userId: null`. `disclosed: +false` does not promise the handle never reached the chain: a refused +transaction's calldata is not an event. The resolve routes keep the plaintext in the path, by decision; a `GET /v1/resolve/node/{platform}/{handleNode}` where the client hashes, so the server never sees the address, can be added later for clients that want it, as the ENS gateway already works. @@ -367,17 +381,19 @@ change the resolve route needs above, and not before; the rollout ships the two together. **TypeScript claim SDK.** The Google proof type carries `email` as a required -string; it becomes optional, absent for private. Client-side normalization +string; it becomes optional, absent for private, and the proof type carries +no `sub` at all. Client-side normalization must produce the bytes the circuit hashes, and `handle.ts` already reproduces the shared vector table, so the only new obligation is that the circuit input builder feeds the circuit the raw bytes and expects the digest -of the folded ones. The local result carries the email only for a -disclosing claim (REQ-PLAT-03); for a private one the application receives -the digests, and the email stays inside the Canonical Runtime. +of the folded ones. The local result keeps returning the normalized email +and the `sub` to the application, which holds the token anyway; the +prover must run in the zero-knowledge mode with fresh randomness +(REQ-COMMON-45A), which today depends on `prove.worker.ts` setting +`verifierTarget: 'evm'` and is written down as a requirement instead. **Demo.** The "publish the handle on chain" checkbox becomes a three-way -choice made before the ceremony starts, since `disclose` is in the digest: -private, public, public and published. +choice, made at submission: private, public, public and published. ### Spec changes @@ -386,46 +402,50 @@ Written, on this branch. The map, for a reader coming from the specs: - `platform-ceremonies.md`: §2.1a's lead-in and REQ-PLAT-08A/08B admit a circuit that normalizes what it digests, refusing where the table trims, and a Consumer that keys on digests and normalizes every handle it - receives. A new §2.1b defines disclosure (history) and publication - (state), and holds REQ-PLAT-08D (keys from the digests, the disclosure - choice in the Authorized Transaction Data, plaintext only when authorized - and only when it hashes to the digests, both or neither, a private claim - keeping or clearing the publication), 08E (the disclosure call, its - two-way pairing check, and what a refusal does not protect), 08F (the - event's disclosed flag, the normalized handle), 08G (rules fixed once a - digest platform has bound anything) and TEST-PLAT-20A. REQ-PLAT-03 has - the Canonical Runtime release the plaintext only for a disclosing claim; - REQ-PLAT-04 and 16D refuse a backslash in `sub`; REQ-PLAT-16B lists the - two digests as public inputs; 16C has the verifier return them and pass - plaintext through unchecked; 16D moves the `sub` and `email` validation + receives. A new §2.1b defines the digest profile, how a Consumer knows + one, that its `userId` is never sent, disclosure (history of accepted + transactions) and publication (state), and holds REQ-PLAT-08D (keys from + the digests, a handle accepted only when it hashes to its digest, keep or + clear the publication against the handle held after the write), 08E (the + disclosure call on the handle alone, its two-way pairing check, and what + a refusal does not protect), 08F (the event's flag, the normalized handle + where one was carried, never the `sub`), 08G (the published handle table, + fixed once a digest platform has bound anything) and TEST-PLAT-20A. + REQ-PLAT-03 and TEST-PLAT-17 name the ID Token as a digest profile's + local source; REQ-PLAT-04 and the §2.1 table bound a Google `sub` at 31 + bytes and refuse a backslash; REQ-PLAT-16B lists the two digests as + public inputs; 16C has the verifier return them, pass the email through + unchecked, and carry no `sub`; 16D moves the `sub` and `email` validation into the circuit and states the 31- and 62-byte buffers; TEST-PLAT-06A exercises the three; §9 states what the digests protect and what they do not. The Google profile stays Platform Ceremony Version 1: nothing is released, so its statement is edited in place. -- `ceremony-common.md`: ASM-HASH-01, ASM-ZK-01, SP-PRIV-01 (plaintext - reaches the chain only in a transaction that discloses it), REQ-COMMON-05E - returns the digests, and the plaintext marked unverified, where a profile - exposes digests; REQ-COMMON-45 selects only zero-knowledge artifacts for - a digest profile; §12 replaces "published deliberately" for the handle - and user identifier with the digest profile's confidentiality and its - limits. +- `ceremony-common.md`: ASM-HASH-01, ASM-ZK-01 (the zero-knowledge proving + mode), SP-PRIV-01 (the Consumer puts on chain only what a transaction + carried, and no `sub`), with §4 stating that it does not survive a + malicious application operator; REQ-COMMON-05E returns the digests, and + the handle marked unverified, where a profile exposes digests; + REQ-COMMON-45 and 45A have governance select zero-knowledge artifacts and + the Canonical Runtime prove in that mode with fresh randomness; §12 + replaces "published deliberately" for the handle and user identifier with + the digest profile's confidentiality and its limits. - `libid.md`: one sentence among the enforceable guarantees. ## The recommendation Hash in the circuit, normalize in the circuit, keccak256 over the email and -over `sub`, the mode chosen by the user in the authorized operation and -carried out by the presence of the plaintexts, private by default for -Google alone, `publish` to go public later, ENS forward names +over `sub`, the mode set by the presence of the email in the payload and +the `sub` never sent, private by default for Google alone, `publish` to go public later, ENS forward names resolving for private bindings as for public ones, and the resolve routes as they are. This is the one combination where neither the email nor the account id reaches calldata, where both nodes are identical across modes and readers, where the verifier reuses a pattern it already has for -the audience, and where the payload change is two optional fields. +the audience, and where the payload change is one optional field. It does not protect against confirmation of a suspected address or account id by whoever already holds it, the query plaintext in the indexer's access -logs, or the visibility of the binding itself. +logs, the visibility of the binding itself, or an application operator, +which receives the ID Token and can send the address itself. ## What to implement, in order @@ -440,10 +460,10 @@ logs, or the visibility of the binding itself. empty local part and a garbage tail each fail to prove, as does a `sub` holding a backslash. 3. **Contracts** (`libid-contracts`): the Google verifier regenerated, `bytes - userId` and `bytes email` in the payload, `userIdHash` and `handleHash` - in `VerifiedClaim`, `disclose` in the claim's Authorized Transaction - Data, the equality checks and the hash-derived nodes in `_write`, - `disclosed` in the event, `publish` with its two-way pairing check, + email` in the payload, `userIdHash` and `handleHash` in `VerifiedClaim`, + the equality check and the hash-derived nodes in `_write`, keep-or-clear + against the handle held after the write, `disclosed` in the event, + `publish(platformId, handle)` with its two-way pairing check, `setPlatform` refusing a Google rules change once Google has bound anything, the circuit pin. Done when the same account claimed public and then private lands on the same two @@ -451,16 +471,17 @@ logs, or the visibility of the binding itself. carries no plaintext email or account id in its decoded calldata or its decoded events, the payload fields being empty and the event strings empty, rather than a byte search over proof bytes that can contain - anything; and when `publishName` without plaintext, plaintext `disclose` - does not authorize, and a `publish` of a pair either direction of the - mapping disputes all revert. + anything; when no transaction, private or not, carries the `sub`; and + when `publishName` without the email, and a `publish` of a handle either + direction of the mapping disputes, both revert. 4. **Indexer, SDK, demo**: nullable handle and id, node-keyed resolve for - both, `IdentityPublished` filling the plaintext rows and the publication - state, optional `userId` and `email`, the three-way choice. Done when - resolve finds a private binding by exact address or account id, search - never returns it, `reverseOf` is empty for it, and the sequence private - claim, `publish`, private refresh reads back as known, unpublished, - last event undisclosed. The node-keyed lookups are proven against the + both, the ENS gateway on the node-keyed lookup, `IdentityPublished` + filling the handle row and the publication state, optional `email` and + no `sub`, the three-way choice. Done when resolve and the gateway find a + private binding by exact address or account id, search never returns + it, `reverseOf` is empty for it, and the sequence private claim, + `publish`, private refresh of the same handle reads back as known, + published, last event undisclosed. The node-keyed lookups are proven against the chain, not against a hand-computed hash: one test claims on a local chain and checks the nodes the indexer recomputes against the `IdentityBound` event the contract emitted, the check `db.rs:791-798` @@ -482,16 +503,19 @@ logs, or the visibility of the binding itself. Decided: `alice.google.handles.link` resolves for a private binding, so the name is the address and a wallet that resolves it has confirmed it, and with it that confirming a suspected address by hashing it is accepted; `sub` is -hidden with the handle; private to public is a `publish` call, not a new +hidden with the handle and never sent, not even when the handle is; +private to public is a `publish` call on the handle alone, not a new claim; the resolve routes keep the plaintext in the request line, the indexer's operator being trusted with what people resolve; `publish` refuses a handle retired by a later claim of the same account, or taken over by another account of the same wallet, as set out under -"Default, and moving between modes"; the user, not the application, -chooses disclosure, in the Authorized Transaction Data; a private claim of -the published handle keeps the publication; Google's handle rules are -fixed once Google has bound anything; and the verifier artifact is -zero-knowledge, which the privacy rests on as much as on the hash. +"Default, and moving between modes"; the application chooses whether a +claim carries the email, and the privacy is against readers of the chain, +not against a malicious application; a private claim keeps the publication +while the identity still holds the published handle; Google's handle rules +are fixed once Google has bound anything; and the proof is made in the +zero-knowledge mode with fresh randomness, which the privacy rests on as +much as on the hash. Nothing in this note is left open. From 32d167e2a69312fb0f0a00dddd6497a811da5bb8 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 23 Sep 2026 18:18:39 +0300 Subject: [PATCH 11/11] docs(design): a neutral Google client, as an option beside the digest profile A second note asks whether the application can be kept from the address too, and answers with what was checked on 2026-09-23: one Google client operated by libID, its redirect on the Distribution origin, the ID Token verified and proved there, and only the proof, the digests and, on the user's yes, the email returned to the application. - Feasible: Google's policies allow a shared broker client, MetaMask Embedded Wallets ship one, `openid email` needs no app verification, and a top-level redirect avoids the opener and storage-partitioning problems; FedCM does not fit, and a popup rests on Google's COOP staying report-only. - The new risk: consent to one shared client is global, so a hostile site could bind a victim's account to its own wallet; the runtime must show its own confirmation screen, ideally with the wallet signing for the target. That screen would also make a user-authorized disclosure enforceable again. - The cost: libID gains silent re-authentication over every user of every application, the Distribution operator holds every Google token, one client carries every application's quota, and the consent screen names libID. What would change in the specs is listed, not written. `private-gmail-handle.md` points to it from what it deliberately does not do. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- design/neutral-google-client.md | 333 ++++++++++++++++++++++++++++++++ design/private-gmail-handle.md | 5 + 2 files changed, 338 insertions(+) create mode 100644 design/neutral-google-client.md diff --git a/design/neutral-google-client.md b/design/neutral-google-client.md new file mode 100644 index 00000000..1b580b6d --- /dev/null +++ b/design/neutral-google-client.md @@ -0,0 +1,333 @@ +# A neutral Google client, so the application never sees the address + +**Status: design option, not specified.** Nothing here is built, and no +requirement text is written for it. It layers on the digest profile of +`private-gmail-handle.md` and does not replace it: without the digests the +proof itself carries the address. It changes the OAuth Bridge, the CCDP +Distribution and the SDK, and would be specified in a PR of its own. + +## The thing that must work + +Alice binds `alice@gmail.com` to her wallet through an application she does +not trust with her address. The application gets a proof it can submit and +two digests, and learns the address only if Alice, on a screen the +application does not control, chooses to show it. Bob, whom Alice told her +address, still resolves it. + +`private-gmail-handle.md` hides the address from everyone who reads the +chain. It says plainly that it does not hide it from the application +operator: the ID Token reaches the application's origin, so the operator +holds the email and decides whether a claim carries it (SP-PRIV-01 and §4 +of `ceremony-common.md`). This note asks what it would take to close that +last gap, and whether it can be closed at all. + +## Who learns the address today + +Two browser flows exist. The SDK on `main` (`ts/packages/claim`) has the +application open Google in a popup and receive the raw `id_token` on its own +origin over the `libid_link` channel (`google/claim.ts:53-91`, +`channel.ts:11`, `apps/demo/src/relay.ts:61-111`). The application then +holds the token, the email, the `sub` and the proof. + +The CCDP rebuild (libid PR #28 at `08c3f3d`, specified in PR #13 at +`374035c`, neither merged) goes further. The Callback page on the Bridge's +`/auth/callback` copies the return into memory, clears it, and moves it to +the CCDP Prover page on the Distribution origin, which verifies the token +and proves there; the Application receives the result over the popup's +message channel, never the token (`ccdp.md:762`: "keep the return private +from Application"). Two things still hand the address to the application: + +- **The result.** Prover returns `identity` with `userId` and `userName` + beside the proof (`ts/packages/ceremony/src/platforms/index.ts:89-126`), + and the Google proof of the byte profile carries the email in its public + inputs anyway. +- **The OAuth client is the application's.** Every registration's + `redirect_uri` is the Bridge's `/auth/callback` (`oauth-bridge.md:59-61`), + the Bridge "owns OAuth registrations" (`ccdp.md:65`), and the application + operator "controls its frontend, redirect deployment, OAuth clients" + (`libid.md:56`). The Bridge writes the Callback document's CSP itself, so + the hash pinning of the Distribution's code does not bind a malicious + Bridge operator (`ccdp.md:594-595`: "A compromised Bridge or Distribution + can replace browser code and observe or withhold credentials"). And an + operator that owns a Google client can recover a returning user's email + with no consent screen at all, `prompt=none` or One Tap's `auto_select`, + outside any libID ceremony + (developers.google.com/identity/openid-connect/openid-connect, + developers.google.com/identity/gsi/web/reference/js-reference). + +The second point is the one no amount of care inside the ceremony fixes: +while the client is the application's, the application can ask Google +directly. + +## The idea + +One Google OAuth client, registered and operated by libID, whose only +redirect URI is on the Distribution origin. The token lands in the +Canonical Runtime there; the runtime verifies it, proves the digest +statement, and returns to the application the proof, the two digests and, +only if the user chose it on the runtime's own screen, the email. The +application never holds the token and owns no Google client that could +ask for it. + +## Is it feasible + +Checked on 2026-09-23 against Google's published policy and the browsers' +documented behaviour; the sources are listed at the end. + +### Google + +- **A shared broker client is allowed.** Google's OAuth policies require + separate projects for testing and production and nothing like one project + per application; the rule that matters is that the consent screen + "accurately represent the identity of the application", and here the + application that receives the token is libID, so the screen naming libID + is the accurate one. A broker that showed an integrating application's + brand under its own client would break that rule. +- **Precedent.** MetaMask Embedded Wallets (formerly Web3Auth) ship a shared + client by default: "The Google consent screen identifies the OAuth + application managed by Embedded Wallets, not your dapp". Aptos Connect + appears to do the same for Keyless across dApps; that is inferred, not + confirmed. Privy, Clerk and Auth0 offer shared credentials for + development only and ask for your own in production, for branding and + control, not because Google forbids sharing. Sui zkLogin and plain Aptos + Keyless use per-dApp clients because the client id is part of the address + derivation, which libID's is not (REQ-COMMON-17C). +- **The redirect must be libID's.** Redirect domains must be ones the + project owner owns or is licensed to use, and verification covers every + redirect domain. So "each application registers libID's redirect under its + own client" is not an option, and would not help: the application would + still own a client it could ask directly. +- **Verification.** `openid email` are non-sensitive scopes; no app + verification is required. Showing libID's name and logo requires brand + verification: the domains verified in Search Console, a public homepage + and a privacy policy on the same domain, and the privacy policy must say + that libID passes the proof and digests to the application the user is + using. +- **The endpoint.** Google's discovery document still lists + `response_type=id_token` with `fragment` and `form_post`, and the OIDC + guide documents the implicit flow with a required `nonce` and no + deprecation notice. Google's browser pages do call direct implicit use + "provided only for legacy support". Google Identity Services in redirect + mode, which also takes a `nonce`, is the supported alternative if the + endpoint is ever retired. + +### Browsers + +- **Top-level redirects work everywhere.** The application navigates the + whole tab to the Distribution origin; that page stores the ceremony in its + own first-party storage keyed by `state`, sends the tab to Google, receives + the fragment on its own callback, proves, and navigates back to the + application with the result in the fragment or a form post. No opener has + to survive, and no storage is partitioned. The result is tens of + kilobytes; Chrome's URL limit is 2 MB, and a form post has none that + matters. +- **A popup probably works, with no guarantee.** Google's sign-in pages sent + `Cross-Origin-Opener-Policy: same-origin` only as report-only on + 2026-09-23 (signed out; signed-in pages not measured), so today the + opener survives. Google is collecting reports, and an enforced header + would cut the popup off from the page that opened it. The CCDP popup flow + depends on the opener already, so the risk is not new, but a design that + hinges on privacy should not rest on a report-only header. +- **An iframe of the Distribution inside the application cannot talk to a + top-level Distribution window through storage.** Chrome (since 115), + Firefox (since 103) and Safari partition BroadcastChannel and storage by + top-level site. A popup opened by that iframe keeps it as `opener`, which + should work; untested. +- **FedCM does not fit.** Google supports FedCM only for same-site iframes + ("All other cases like different domains are unsupported"), so a libID + iframe inside `app.example` is excluded, and FedCM is Chrome and Edge only; + Firefox paused its implementation. + +Verdict: feasible, with the top-level redirect as the flow to rely on and +the popup as a convenience where it keeps working. + +## The one new risk, and the screen it needs + +With per-application clients, a hostile site that wants Alice's Google +account bound to the attacker's wallet has to get Alice through a Google +consent screen naming the hostile site's own client. With one shared +client, Alice consents to libID once, and every later ceremony, started by +any site, meets at most an account chooser. A hostile site can open the +neutral ceremony with an Authorized Transaction Data whose `target` is the +attacker's wallet, and Alice, recognizing the libID flow, signs in. The +proof binds her account to the attacker. Consent-screen phishing is already +outside protocol enforcement (`ceremony-common.md` §12), but a shared client +turns a per-site consent into a global one. + +So the neutral runtime must show a screen of its own before it sends Alice +to Google, and continue only on her click there: the site that asked (the +return origin, which the runtime reads from the navigation it received and +will send the result to), the operation, the wallet the identity binds to, +the service fee, and the disclosure choice. The Distribution origin serves +that page top-level with `frame-ancestors 'none'`, so the site cannot frame +or overlay it. + +A wallet address is not something people check by eye. The stronger form +has the runtime ask the wallet in Alice's browser to sign for the `target` +before the ceremony proceeds: a hostile site's `target` is a wallet Alice's +browser does not control, so the ceremony stops there. Injected wallets +work on any top-level origin; embedded and smart-account wallets need their +own connection flow on the Distribution origin, and that is the cost. + +This screen is also the enforcement point the second review of +`private-gmail-handle.md` found missing. There, a disclosure choice in the +Authorized Transaction Data was rejected because no trusted screen showed +it and the runtime could not read it. Under a neutral client the runtime +owns a screen, shows the choice, and holds the email until the user says +yes, so a user-authorized disclosure becomes enforceable. This note does not +reintroduce it; it records that the option would reopen. + +## What the application can still do + +- **Confirm a suspected address.** It receives the handle digest, which is + an unsalted hash of the normalized address. An application that already + suspects Alice's address confirms it by hashing it (ASM-HASH-01), as any + reader of the chain can. The neutral client stops the application from + being told; it does not stop it from guessing. +- **Run its own client beside the neutral one.** Nothing stops an + application from also registering a Google client and asking Alice to + sign in to it. The neutral client removes libID's part in handing over the + address; it cannot remove the application's ability to ask. Alice sees the + difference, since that consent screen names the application, and the + protocol can say no more than that. +- **Withhold or delay.** It can refuse to submit the proof. That is + availability, not privacy. + +## Where the trust goes + +Trust moves; it does not shrink. + +- **libID gains silent re-authentication over every user of every + application.** The same `prompt=none` that lets an application recover an + email today lets whoever operates the neutral client recover it for + anyone who ever consented to libID. That is the concentration this design + buys, and it has to be named in the privacy policy and the threat model. +- **The Distribution operator can change the code the token lands in.** + `ccdp.md:594-595` already says a compromised Distribution can observe + credentials; under a neutral client every Google ceremony runs there. + Content-addressed releases, published hashes and reproducible builds let + others check what was served; they do not stop a malicious operator from + serving something else to someone. +- **One client carries every application's quota and reputation.** Google + applies a new-user authorization rate limit to every client, set by + "application history, developer reputation, and riskiness". Abuse through + one integration throttles all of them, and a suspension stops every + Google ceremony at once. +- **The consent screen says libID.** Applications lose their own branding on + Google sign-in, which is the price of the application not being the party + Google releases the token to. + +## Options + +**Neutral client, top-level redirect, runtime-owned confirmation, digest +profile.** The design above. **Recommended as the option to pursue**, for +Google only, if hiding the address from applications is a goal. + +**Neutral client with a popup.** The flow the CCDP rebuild already uses, +with the redirect moved to the Distribution origin. Less disruptive for the +application, but it rests on Google's COOP staying report-only. Acceptable +as a convenience beside the redirect flow, not as the only path. + +**Neutral client through FedCM.** Rejected: Google refuses cross-site +iframes, and only Chromium ships it. + +**Per-application clients with libID's redirect registered under them.** +Rejected: Google allows it only with a licence to libID's domain, and the +application still owns a client it can ask directly. + +**Per-application clients, CCDP as specified.** What PR #13 describes. It +keeps the token out of the application's page but not out of the +application's reach, for the two reasons in "Who learns the address +today". This is the status quo against which the option is measured. + +## What would change + +Listed, not written; each is a requirement change for its own PR. + +- **Roles.** `libid.md`'s roles table: the application operator no longer + configures a Google OAuth client; libID operates one, and the Distribution + operator is trusted with Google tokens. `ccdp.md:65` and + `oauth-bridge.md:22` stop giving the Bridge the Google registration. +- **Redirect.** Google's `redirect_uri` moves from `/auth/callback` + (`oauth-bridge.md:59-61`, `ccdp.md:127`, + `ts/packages/ceremony/src/ccdp/client/config.ts:57`) to a fixed path on the + Distribution origin. The Bridge keeps X and GitHub. +- **SP-CLIENT-01 and REQ-COMMON-17.** They become trivial for Google: every + deployment carries the same client. +- **SP-DELIVERY-01, REQ-COMMON-29 and 30.** Their meaning moves from "an + authorization response reaches only an origin registered to that client" + to "the Canonical Runtime returns a result only to the return origin the + user confirmed". The consent-phishing paragraph of common §12 gains the + global-consent case and the confirmation screen. +- **Attribution.** `IdentityNames.CeremonyBound` carries the client + identifier so an operator can tell which application produced a binding + (`IdentityNames.sol:279-294`); with one client it tells them apart no + longer. A replacement would be the return origin the user confirmed, + reported by the runtime. The Consumer cannot check that value, so either + it is committed where the proof binds it, which changes the Authorization + Digest's fields, or it is recorded as unauthenticated. +- **REQ-PLAT-03.** Unchanged in substance: the runtime still derives local + fields from the verified token; what changes is who receives them. +- **The result.** The runtime returns the proof, the digests and, on the + user's yes, the email; never `userId` in plaintext, which the digest + profile already stops sending. +- **SDK and Distribution.** A ceremony entry page and a Google callback on + the Distribution origin, the confirmation screen, first-party ceremony + state keyed by `state`, and a return navigation; the application side + loses its Google client configuration and gains a return route. + +## Open decisions + +- Whether hiding the address from applications is a goal worth moving this + much trust to libID. The design is feasible; whether it is wanted is a + product question. +- Whether the confirmation screen asks the wallet to sign for the `target`, + and what that means for embedded and smart-account wallets. +- Whether the confirmed return origin replaces the client identifier as the + attribution field, and whether it is committed in the Authorization + Digest. +- Whether a user-authorized disclosure choice returns once the screen exists. + +## Sources + +Code and specification, against each repository's `origin/main` on +2026-09-23 unless a revision is named: + +- `libid` `ts/packages/claim`: `src/google/claim.ts:53-91`, + `src/channel.ts:11`, `src/prover/prove.worker.ts:72-75`; + `ts/apps/demo/src/relay.ts:61-111`. +- `libid` PR #28 at `08c3f3d`: `ts/packages/ceremony/src/platforms/index.ts:89-126`, + `src/ccdp/client/config.ts:57`. PR #13 at `374035c`: `specs/ccdp.md` + (65-69, 97-99, 127, 594-595, 762), `specs/oauth-bridge.md` (20-24, 59-61). +- `libid-server-rs` at `origin/main`: `src/routes/mod.rs:101-119`, + `src/routes/callback.rs:84-96`, `src/artifact/mod.rs:92-107`. +- `specs/libid.md:56,67`; `specs/ceremony-common.md` SP-CLIENT-01, + SP-DELIVERY-01, REQ-COMMON-17/17C/29/30, §12. +- `libid-contracts` `identity/IdentityNames.sol:279-294`, + `ceremony/GooglePlatformVerifier.sol:208-210,239`. + +Google and browsers, fetched 2026-09-23: + +- Google OAuth policies and compliance: + developers.google.com/identity/protocols/oauth2/policies, + developers.google.com/identity/protocols/oauth2/production-readiness/policy-compliance, + developers.google.com/identity/protocols/oauth2/production-readiness/brand-verification, + developers.google.com/terms/api-services-user-data-policy, + support.google.com/cloud/answer/13463073, /15549049, /7454865, /9028764. +- Endpoint: accounts.google.com/.well-known/openid-configuration, + developers.google.com/identity/openid-connect/openid-connect, + developers.google.com/identity/protocols/oauth2/javascript-implicit-flow, + developers.google.com/identity/gsi/web/reference/js-reference. +- FedCM: developers.google.com/identity/gsi/web/guides/fedcm-migration, + developer.mozilla.org/en-US/docs/Web/API/FedCM_API, + github.com/mozilla/standards-positions/issues/618. +- Partitioning: privacysandbox.google.com/cookies/storage-partitioning, + developer.mozilla.org/en-US/docs/Web/Privacy/Guides/State_Partitioning, + bugs.webkit.org/show_bug.cgi?id=229814. +- Precedent: docs.metamask.io/embedded-wallets/authentication/social-logins/google/, + aptos.dev/build/guides/aptos-keyless/introduction, docs.sui.io/sui-stack/zklogin-integration/, + docs.privy.io/basics/get-started/dashboard/configure-login-methods, + clerk.com/docs/guides/configure/auth-strategies/social-connections/google, + auth0.com/docs/authenticate/identity-providers/social-identity-providers/devkeys. +- COOP: `curl -sI` of Google's authorization endpoint and sign-in page, + signed out, 2026-09-23 (reported by the research; not re-measured). diff --git a/design/private-gmail-handle.md b/design/private-gmail-handle.md index 04fae482..19d4680c 100644 --- a/design/private-gmail-handle.md +++ b/design/private-gmail-handle.md @@ -497,6 +497,11 @@ which receives the ID Token and can send the address itself. its own node tag without disturbing this one. - **Changing X and GitHub.** Their handles are public where they live. - **Per-chain variants.** The node is chain-independent today and stays so. +- **Hiding the address from the application.** Out of reach while the + application owns the Google client: the ID Token lands where it controls, + and it can ask Google for the email directly. `neutral-google-client.md` + checks whether one libID-operated client, with a confirmation screen the + runtime owns, could close that gap, and what it would cost. ## Open decisions