From a239dba96154d43b4c4261e61bd384e8b43ff326 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 16:14:09 +0300 Subject: [PATCH 01/17] docs: publish the ENS integration design on the docs site Move design/ens-integration.md to docs/pages, where the Starlight site publishes it at /docs/ens-integration/. The heading becomes the page's title frontmatter; the text is unchanged. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- {design => docs/pages}/ens-integration.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) rename {design => docs/pages}/ens-integration.md (99%) diff --git a/design/ens-integration.md b/docs/pages/ens-integration.md similarity index 99% rename from design/ens-integration.md rename to docs/pages/ens-integration.md index 66bbef93..d25770d1 100644 --- a/design/ens-integration.md +++ b/docs/pages/ens-integration.md @@ -1,4 +1,7 @@ -# ENS integration +--- +title: ENS integration +description: How libID handles resolve as ENS names in wallets. +--- **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; the parts From 5ec4c6cf93147479d22f2917bd9b35a1a01ea041 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 16:30:22 +0300 Subject: [PATCH 02/17] docs(specs): specify the ENS integration Rewrite the ENS integration design as a normative spec chapter, with assumptions, security properties, requirements and conformance vectors, and link it from the specification index. Where the design and the deployed resolver and gateway disagree, the spec follows the code: an EVM chain the gateway does not index gets an unsigned 503 rather than a signed null, the resolver also announces ERC-165, and the platform id is the registry's for the platform key. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- docs/pages/ens-integration.md | 618 ---------------------------------- specs/ens-integration.md | 397 ++++++++++++++++++++++ specs/libid.md | 8 + 3 files changed, 405 insertions(+), 618 deletions(-) delete mode 100644 docs/pages/ens-integration.md create mode 100644 specs/ens-integration.md diff --git a/docs/pages/ens-integration.md b/docs/pages/ens-integration.md deleted file mode 100644 index d25770d1..00000000 --- a/docs/pages/ens-integration.md +++ /dev/null @@ -1,618 +0,0 @@ ---- -title: ENS integration -description: How libID handles resolve as ENS names in wallets. ---- - -**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; the parts -that carry trust assumptions graduate into a service spec under `specs/` if the -integration is approved. - -## The thing that must work - -Alice puts a name in her X bio. Bob — who has never heard of libID — pastes it -into MetaMask and sends her funds. - -Everything below follows from that sentence. A name that resolves only inside our -own product needs no ENS at all: we would call `resolveHandle` and be done. ENS -earns its keep exactly when the name works in wallets we do not control. - -## Why this is cheap - -ENS documents three ways to issue subnames: on-chain, on an L2, and offchain via -CCIP-Read. None describes us, and the difference is in our favour: **we are not -issuing names, we are projecting state that already exists.** -`IdentityRegistry.resolveHandle(platformId, handle)` already answers the question an -ENS resolver asks, so a name needs no registry entry, no NFT, no mint, and no -storage of its own. - -Coinbase runs this shape in production for over eleven million `*.cb.id` names: - -``` -registry.resolver(cb.id) = 0x1934FC75… supportsInterface(0x9061b923) = true -registry.resolver(jesse.cb.id) = 0x0000… no per-name entry at all -``` - -One wildcard resolver at the apex. Nothing per user. - -## Namespace - -**`handles.link`, imported on-chain** via `DNSRegistrar`. The import is one -gas-heavy transaction, never repeated; it produces an ordinary ENS registry entry -that a wildcard resolver then serves. The `.link` TLD is already wired into ENS -(`registry.resolver(link)` is set), so nothing exotic is required. - -**ENSv2 changes none of this.** Its root registry gives every DNS TLD one -`DNSTLDResolver`, whose first step is to look the name up in the v1 registry and -hand the query to whatever resolver it finds there, full name included. A DNS -name imported as above therefore keeps resolving through the same entry and the -same resolver after v2 ships; only `.eth` names have a migration. - -A `.eth` second-level name was the alternative, and it is rejected. It costs an -annual renewal whose expiry would kill every name beneath it at once — a recurring -dependency taken on for nothing, since the DNS domain already exists and is -already managed. - -DNS-rooted names resolve in the wallets that matter. ENS resolution is -namehash-based and TLD-agnostic, so `.eth` is one TLD among the others; MetaMask -resolves `jesse.cb.id` and labels it ENS. That name is served offchain — with -CCIP-Read disabled it does not resolve at all — so it evidences the whole path, -not merely the choice of TLD. - -**Gasless DNSSEC (ENSIP-17) is not the route**, for a reason belonging to the -standard rather than to any wallet: the resolver a TXT record names must -implement `addr` and must not initiate a CCIP-Read request, which is exactly what -a gateway needs. Subnames require the on-chain import. - -**MetaMask Snaps** (`endowment:name-lookup`) can teach a wallet a namespace it -does not know, but the snap must be installed by the **sender** — the participant -we control least. They solve nothing here. - -## Name shape - -``` -alice.x.handles.link the short form -alice.x.base.handles.link with an explicit chain -``` - -The platform label is not decoration: our keyspace is per platform, so `alice` on -X and `alice` on GitHub are different names that may have different holders. - -**The chain belongs to the hierarchy**, as a label of its own, rather than being -left to the coin type alone. That is settled, and it is part of the name shape -rather than an extension held in reserve. - -The label may be omitted, and the two forms say different things: - -- **With a chain** the name **narrows**: it is answered only when the caller asks - for that same chain. This is how a payment is directed explicitly — in an - invoice, a message, an integration. -- **Without one** the name is answered by the caller's coin type under the rule - below: an address for a chain that holds a binding, null for every other. - -So a chain label never overrides and never widens. Safety comes from the -coin-type rule in both forms; the label adds intent. - -The grammar is ours and nothing external constrains it. Two rules keep it -unambiguous: platform names and chain names are closed sets that never overlap, -and the parse runs right to left, since a Gmail local part contributes a variable -number of labels. - -## How resolution works - -**ENSIP-10 (wildcard).** The client walks up: it asks the registry for a resolver -for the full name and, on a miss, strips the leftmost label and repeats. The -resolver it finds receives the **original, complete name**: - -```solidity -interface ExtendedResolver { - function resolve(bytes calldata name, bytes calldata data) - external view returns (bytes); -} -// supportsInterface: 0x9061b923 -``` - -One resolver at the apex therefore covers arbitrary depth. We never create -`x.handles.link` as a name. - -**ERC-3668 (CCIP-Read).** The resolver holds no data, so it reverts: - -```solidity -error OffchainLookup(address sender, string[] urls, bytes callData, - bytes4 callbackFunction, bytes extraData); -``` - -**Three steps, and the address comes from the third.** The gateway's answer is an -opaque blob; it becomes an address only after a second on-chain call. - -``` -1. eth_call resolve(name, addr(node, coinType)) - on the mainnet registry, 0x0000…2e1e — the fixed point. - The walk-up finds the resolver set on handles.link. - ↳ revert OffchainLookup(sender, urls, callData, callback, extraData) - -2. HTTP GET urls[0] → { "data": "0x…" } - A signed blob. No address yet. - -3. eth_call callback(response, extraData) on our resolver. - It verifies the signature and RETURNS the address. -``` - -Both calls are `eth_call`. Nothing is sent, no gas is spent, and no state changes. - -**Nothing is registered with any wallet.** The endpoint travels inside the revert, -per query, so the contract tells the client where to look. `urls` is a list, so -several endpoints give redundancy and the client tries them in order. - -In practice a client reaches the resolver through the ENS `UniversalResolver`, -which performs the walk-up, so the revert surfaces from `resolveWithGateways` -rather than from our contract directly. The flow is unchanged; only the trace -looks different. - -**Supporting ENS is not enough.** A wallet needs three things together: ENSIP-10, -or the walk-up never reaches a resolver at all; ERC-3668, or the revert is -reported as an error; and ENSIP-11, or it asks only for mainnet. A wallet holding -all three resolves our names with nothing installed and nothing registered. One -holding fewer either shows an error or receives the mainnet answer, and both are -safe. - -## Which chain, and which address - -**ENSIP-11** assigns EVM chains a coin type of `0x80000000 | chainId`, resolved -through `addr(node, coinType)`. Bare `addr(node)` is coin type 60 — Ethereum -mainnet, a specific chain rather than an unknown one. - -`IdentityRegistry` sits at one CREATE3 address on every chain with its own state, so -the coin type says which chain's contract to read. The rule is one line with no -exceptions: - -> Answer with an address only for a chain where the binding exists. For every -> other chain, including mainnet, answer null. - -- Deployed on Base, sender on Base → resolves -- Sender on Arbitrum → refused -- Sender on mainnet where there is no binding → refused - -MetaMask queries per chain and does **not** fall back to bare `addr(node)` when -the answer is null: `jesse.base.eth` resolves on Base and is refused elsewhere -with "address resolution for this name not found". Sending on the wrong chain is -therefore not merely unlikely but impossible for wallets implementing ENSIP-11. -One that only calls bare `addr(node)` receives the mainnet answer or a refusal, -and both are safe. - -No home chain is designated and nothing is guessed on the user's behalf. - -## Where the boundary runs - -The line between what we decide and what we accept falls at the gateway call. - -**Before it — the client's, not ours.** The walk-up, the registry lookups, whether -the client follows CCIP-Read at all, which coin type it asks with, how deep a name -it will resolve, and **which chain the transaction is finally sent on**. A wallet -that balks at a name never calls us and we never learn of it. - -**After it — entirely ours.** The name grammar, the parse, which chain's contract -to read, what policy to apply, and what to refuse. - -This is why a chain label cannot make a wallet send on that chain: by the time we -answer, the wallet has chosen its network. What the label does is make us withhold -the address when the caller's coin type disagrees, so nothing is sent at all. - -## Several chains, one identity - -A user represented on several chains needs no separation in the name: the coin -type separates them. One name gives a different answer per chain, which is -correct — the name denotes a person, the address is a per-chain detail. - -A binding on a chain therefore means **"funds can reach me here"**, and users -should be told so plainly. A person believes they are proving an identity; they -are also opening an account. - -What does not exist, and does not appear on its own, is **cross-chain freshness**. -The `observedAt` watermark orders proofs within a chain; nothing orders them -across chains. A chain where nobody has re-proved in two years answers -confidently about two years ago. - -The sharp case is handle recycling, which this system permits by design. Alice -binds `@alice` on Base, loses the account, the platform reassigns the handle, and -its new holder binds `@alice` on Arbitrum: - -``` -alice.x.handles.link on Base → Alice -alice.x.handles.link on Arbitrum → somebody else -``` - -Both answers are honest to their own chain. One name, two people, told apart only -by which network the sender was on. - -**The gateway is where this can be addressed**, because it reads every chain at -once and no on-chain resolver can. `handleBinding(handleNode)` exposes `observedAt` -publicly, so the options are open: expose the age and let the consumer decide; -answer only for the freshest chain and refuse elsewhere; or refuse entirely when -chains disagree. - -The first is cheap and honest. The others are **policy** — the gateway begins -deciding rather than reporting, which is a different thing for a system that -describes itself as trust-minimized, and a choice to make deliberately. - -## What to implement - -**0. Import `handles.link`** via `DNSRegistrar`. - -DNSSEC is a precondition, not a preference: the oracle verifies the signature -chain in the EVM, so an unsigned zone cannot be imported at all. `handles.link` is -registered and signed, and `.link` is already an ENS node owned by the registrar, -so what remains is the TXT record and one transaction. - -``` -TXT _ens.handles.link a=0x - -DNSRegistrar.proveAndClaimWithResolver(name, input, resolver, addr) -``` - -- `name` — the domain in DNS **wire format**, not a string: - `\x07handles\x04link\x00`. The same encoding ENSIP-10 uses. -- `input` — the DNSSEC chain as `{bytes rrset; bytes sig}` pairs, root downward, - ending with the TXT RRset. The oracle validates each step against the root key - it pins. This is what makes the transaction gas-heavy. -- `resolver` — our wildcard resolver, set in the same transaction. May be zero and - set later with `setResolver`. -- `addr` — must be **zero** for us. It would write `addr(node)` on the resolver, - and ours holds no records. A non-zero `addr` with a zero `resolver` reverts. - -**No gateway URL appears anywhere in DNS** — the record carries an address and -nothing else. Only the rejected ENSIP-17 path puts a resolver in a TXT record. - -`proveAndClaimWithResolver` requires `msg.sender` to be the address in the TXT -record. Plain `proveAndClaim(name, input)` has no such check — anyone may submit -the proof, and ownership goes to whatever the record names. - -That is the shape of the dependency: **the ENS name follows the DNS domain.** A -later valid proof overwrites the owner, so whoever controls the registrar account -and the DNSSEC keys controls the namespace. Those credentials belong apart from -the gateway's signing key. - -**1. The resolver contract** — mainnet, small, stateless. `resolve(name, data)` -reverting `OffchainLookup`; the callback verifying the response; -`supportsInterface(0x9061b923)` and no other id — ERC-7996 in particular, see -**What the gateway signs for**; owner-managed gateway URLs and signer set. It -holds no names. - -**2. The gateway** — below. It reads the indexed model `usernames-indexer` -keeps of every chain, and nothing else: no RPC, no per-chain configuration. -The chains it serves are the chains indexers have written; a new indexer is -served the first time it commits. - -**3. Nothing on the write path.** `IdentityRegistry` is untouched: no new call, no -migration, no per-user transaction. - -## The backend - -A **read-only CCIP-Read gateway** with no state of its own: it reads the -Postgres mirror `usernames-indexer` keeps of every chain. No queue, no write -path, one signing key. - -``` -request → GET /{sender}/{data}.json (ERC-3668) - -work → 1. decode → (DNS-encoded name, record calldata) - 2. parse right to left → handle, platform, optional chain - 3. platformId = keccak256(platform domain) - 4. match coinType forward against the chains the store holds; - a label naming another chain answers null - 5. look the binding up in that chain's indexed model - 6. sign (resolver, expires, keccak(callData), keccak(result)) - -response → { "data": "0x…" } → the resolver's callback verifies -``` - -- **Stateless** — scale horizontally, restart freely, nothing to back up. -- **Read-only** — a compromised gateway cannot write a binding; the worst it does - is answer wrongly. -- **One secret**, the signing key, pinned by the resolver. Rotation is an owner - transaction. -- **Cacheable** — answers are `IdentityRegistry` reads and carry an expiry the - resolver enforces. - -**What the gateway signs for.** The `resolver` in the digest is the address the -gateway is configured to serve, never the `{sender}` in the path. Today the two -agree, but only because of the route: a wallet reaches the resolver through the -ENS `UniversalResolver`, which forwards the `OffchainLookup` to a batch gateway -with the original sender intact (ENSIP-21). A resolver announcing ERC-7996 is -instead called directly, and the universal resolver raises the lookup again -under its own address (ENSIP-22), so `{sender}` names the universal resolver. -The reference `offchain-resolver` gateway signs for the path and would fail on -that route; that, and not taste, is why the resolver announces ENSIP-10 and -nothing else. Signing for the configured address costs one setting and holds on -both routes. `{sender}` never enters the digest; the gateway may refuse a -mismatch with a terminal 400, and logs it either way. The comparison is -case-insensitive — viem lowercases it, so a checksum check refuses every -request — and that refusal is the first thing to relax before any ERC-7996 -deployment. One instance serves one resolver; another network or a -replacement resolver gets its own. - -**CORS, on every response.** The mainnet universal resolver lists its gateways -as `["https://ccip-v3.ens.xyz", "x-batch-gateway:true"]`, and the second entry -tells viem to run the batch gateway inside the page. The browser therefore -fetches this gateway directly from the wallet's origin, and without -`Access-Control-Allow-Origin: *` the script never sees the answer and the name -does not resolve. The header goes on every response, errors included. `GET` is -the only method: the resolver's `urls` carry `{data}`, the deployment workflow -refuses a template without it, and ERC-3668 uses its `POST` form only when -`{data}` is absent — so no `POST` route and no preflight. It belongs in the -binary rather than in a proxy in front of it, so no deployment can lose it. `*` -is right: the answers are public and signed, and nothing is sent with -credentials. - -**Direct invocation, later.** Once the gateway signs for a configured address -and can answer a `multicall(bytes[])` inside `resolve`, a new resolver -deployment may announce ERC-7996 with `eth.ens.resolver.extended.multicall`: -one signed answer then carries a whole profile, and the ENS batch gateway drops -out of the path. Not before the v2 contracts are final, and never by editing -the deployed resolver, which is immutable — replacing it is one `setResolver`. - -It belongs in its own repository, following the pattern of `notary` and -`identity-backend`: a small Rust binary shipped as a container. It must not live -inside `identity-backend`, which is the write path — OAuth, proofs, claiming. -Different failure modes, different blast radius. Being stateless it also suits a -serverless deployment. - -**Testing note.** A client without ERC-3668 reports failure for names that work, -and a client with CCIP-Read enabled follows the lookup silently, which makes an -offchain resolver look like an on-chain one. Telling the two apart means disabling -CCIP-Read deliberately — in viem that is a client option, and passing it to the -action instead is silently ignored. A test through the mainnet -`UniversalResolver`, with the batch gateway played by the test, covers the route -a wallet actually takes; the resolver's own tests do not. - -## What ENSIP-15 allows - -ENSIP-15 is the normative transform, and it is **not** UTS-46 — it diverges, so -UTS-46 is not a substitute for reading it. The reference implementation is -`@adraffy/ens-normalize`, which every wallet reaches through ethers or viem, so a -name it refuses is a name no wallet will resolve. - -After `HandleNormalizer` a handle is lowercase ASCII drawn from `[a-z0-9._-]`. -Most of the specification — combining marks, confusable scripts, fenced -characters — cannot apply to that set. What remains is the requirement that a -label is not empty, which our own rules already enforce, and two rules from the -**Validate** section: - -``` -5F (_) LOW LINE can only occur at the start. - Must match /^_*[^_]*$/ - valid: "___", "__abc" invalid: "abc__", "_abc_" - -The 3rd and 4th characters must not both be 2D (-) HYPHEN-MINUS. - Must not match /^..--/ - valid: "ab-c", "---a" invalid: "xn--", "----" -``` - -The hyphen rule reserves the punycode prefix `xn--`, so it is positional rather -than a ban on the character: `a--b` passes, `ab--cd` does not. - -Note what is NOT restricted, because it shapes the mappings below. A leading or -trailing hyphen is legal in ENS, though DNS forbids it — `-alice` and `alice-` -both normalize. A leading underscore is legal, and it is the only position where -one is. Both facts are load-bearing: the first is why the `_` → `-` substitution -works, the second is what makes a marker label possible. - -## Turning a handle into a name - -The transform takes what `HandleNormalizer` produced, never the raw text, so it -never sees case, padding or a leading at-sign. It returns an ordered list of -labels, or it refuses. A refusal means the account has no name: it stays -reachable by address, and by nothing else. - -``` - . [ . ] . handles . link -``` - -Only the handle labels are transformed. The platform label and the chain label -come from closed sets that never overlap, and the chain is chosen by whoever -writes the name rather than derived from anything — see **Name shape**. - -**X** — substitute, then check one position. - -``` -label = handle, every "_" replaced by "-" -refuse when the 3rd and 4th characters of handle are both "_" -``` - -X's alphabet is `[a-z0-9_]` and contains no hyphen, so the substitution is a -bijection onto its image and reverses by replacing every `-` with `_`. The refusal -is the `/^..--/` rule and nothing more: `a__b` gives `a--b` and passes, `ab__cd` -gives `ab--cd` and does not, so only a doubled underscore at exactly those two -positions is lost. - -**GitHub** — nothing to do. - -``` -label = handle -``` - -Our rules forbid a doubled hyphen anywhere, so `/^..--/` cannot fire, and GitHub -issues no underscores. No handle is ever refused. - -**Gmail** — split the local part. - -``` -local, domain = handle, split at its single "@" -refuse unless domain is "gmail.com" -refuse unless local matches ^[a-z0-9]+(\.[a-z0-9]+)*$ -labels = local, split at every "." -``` - -Reverses by joining the labels with dots and appending the domain the platform -label implies. Our email rules also admit `+`, `-` and `_`, which Gmail does not -issue; an address carrying one is refused rather than mapped, because a mapping -for characters no account can hold would be untested code on a payment path. - -**Workspace** — the domain travels in the labels. - -``` -labels = local split at "." ++ ["_at"] ++ domain split at "." -``` - -The `_at` marker cannot collide with a handle: a leading underscore is the only -position ENS permits one, and no handle-derived label contains an underscore at -all — X's are `[a-z0-9-]`, GitHub's are `[a-z0-9-]`, Gmail's are `[a-z0-9]`. - -**There is no fallback, and an earlier draft was wrong to promise one.** It named -an id-derived form, `._id.handles.link`. Sixty-four -characters is one past the DNS label ceiling of RFC 1035, so no client can encode -it — ethers' `dnsEncode` refuses above 63. It could not have covered anything. -Splitting the node across two labels or encoding it in a shorter alphabet would -both work, but neither is worth a name nobody would use: the accounts it was meant -to rescue are better served by the Workspace form above. - -Three properties hold, and each is work the gateway does not have to do: - -- **Injective** — no two accounts reach one name. Across platforms the platform - label separates them; within one, the substitutions are bijections. -- **Reversible** — the labels carry the handle itself, so the gateway holds no - mapping and needs no database. -- **Closed** — it reads no state and calls nothing, so one function serves the - gateway, a browser and a Rust client alike. - -## Which handles become names - -Both sides normalize. The ENS client applies **ENSIP-15** before hashing, so the -resolver receives already-normalized labels; `HandleNormalizer` then applies ours. -On the subset ENS accepts, the two agree, so a mismatch never produces a wrong -address — it produces a name that does not exist, rejected by the client before -the request reaches us. Two handles cannot collapse onto one name either: after -our normalization a handle is lowercase ASCII, and ENSIP-15 is injective on that -set. - -**GitHub — all of them, unchanged.** - -**X — all but a rounding error.** `@some_handle` becomes -`some-handle.x.handles.link`, which anyone recognises. What is lost is a doubled -underscore at the third and fourth characters, and nothing else. - -**Gmail — all of them, with no escape at all.** Gmail permits only `[a-z0-9.]`, -with no leading, trailing or doubled dot, which is already a legal chain of ENS -labels. Letting the platform label imply the domain removes any ambiguity: - -``` -alice@gmail.com → alice.google.handles.link -alice.smith@gmail.com → alice.smith.google.handles.link -``` - -A `+tag` never appears — it is a delivery alias, and the `email` claim carries the -canonical address. - -**Google Workspace — every address whose parts are label-legal.** A Workspace -address carries its own domain, and joining the parts with dots is ambiguous -(`a.b@c.com` and `a@b.c.com` both give `a.b.c.com`); the `_at` marker resolves it: - -``` -alice@company.com → alice._at.company.com.google.handles.link -``` - -Six labels with a marker in the middle is closer to a machine string than to a -name, and a resolving name publishes where someone works. Both were once reason -enough to leave the form unexposed. They are not, because the alternative turned -out to be no name at all: without it a Workspace account is unreachable by name, -and the fallback that was supposed to catch it does not exist. A long name beats -none. **The privacy consequence stands and is now a product decision rather than -a technical one**: publishing a Workspace name discloses an employer, so whether -these names are minted by default belongs in the binding flow, not here. - -**What still has no name.** An address holding `_` or `+`. Both are legal in a -Google address as proved, neither is legal in an ENS label, and no substitution is -available: unlike X, where `_` maps to `-` because X forbids `-`, a Google address -may hold both, so the map would not be reversible. An irreversible map on a -payment path is worse than no name. These accounts are reachable by address. - -## The trust model - -**The gateway signs, and that is the model.** The resolver pins a signer, the -gateway signs each answer, and the callback verifies the signature. It is what -most CCIP-Read deployments do, and it is the decision here rather than a stage on -the way to something else. - -The cost is stated plainly and accepted: **a compromised signing key can answer -with any address.** For a system that routes payments that is the whole risk in -one sentence. - -The signer is a keypair of its own, pinned in the resolver's storage. It is not -the ENS name owner and not the address in the DNS record — three separate keys, -which must be held apart: - -| key | what it grants | where it lives | -|---|---|---| -| registrar account + DNSSEC | the DNS domain, and therefore the ENS name | DNS provider | -| name and resolver owner | sets the resolver, the gateway URLs, the signer set | cold | -| gateway signer | signs answers | on the gateway host | - -Seizing a gateway URL without the signing key buys nothing: the callback rejects -the answer. Seizing the DNS takes the whole namespace. So the URLs are the least -sensitive of the three and the DNS credentials the most, which is the opposite of -where attention usually goes. - -What a signature covers is `(resolver, expires, keccak(callData), keccak(result))`. -An answer is therefore bound to that resolver and that query, and expires — it -cannot be replayed for another name or after its deadline. The `resolver` is the -gateway's own configuration, not the request's `{sender}`, for the reason given -under **The backend**. - -A storage proof against an L2 state root posted on L1 would replace the signature, -and it is not the plan. It also would not generalize: it needs a chain that posts -state roots to L1 and a canonical way to prove storage against them, which not -every chain we target provides. - -## What we deliberately do not do - -**Issue subnames as NFTs**, on L1 or through an L2 framework such as Durin. Durin -is right when a name is an asset in its own right. Here a name is derived from a -proof, and minting it separately creates a **second source of truth that can -disagree with the first**: a user rebinds `alice` to a new holder in -`IdentityRegistry` while the subname NFT still records the old owner, and nothing -decides which is correct. - -The consequence to accept: the user has **no on-chain claim to the ENS name -itself**. It resolves while we run the gateway and keep the resolver pointed at -it. The authoritative record is the binding in `IdentityRegistry`, which survives -independently and is readable without us. ENS is a display layer, not storage, -and users should be told so. - -## What this buys that ordinary subnames do not - -Because the name is derived rather than registered: - -- **A rename follows automatically.** Alice re-proves as `@alice2`, - `alice2.x.handles.link` starts resolving and `alice.x.handles.link` stops — no - ENS transaction, because the handle retirement already happened in - `IdentityRegistry`. -- **A wallet move follows automatically.** Alice re-proves from a new wallet and - the same name resolves to it. With a registered subname this would be a - transfer; here there is nothing to transfer. - -## Open decisions - -1. **Gateway policy when chains disagree** — the most consequential, because it - is where the gateway stops being a pure projection. -2. **Whether `_` → `-` is surfaced in our own UI** or applied only in the - gateway, so a user sees the name they will paste into a bio. -3. **Whether a Workspace name is minted by default**, given that it discloses an - employer, or offered as a choice at binding time. -4. **Name depth.** `jesse.cb.id` is one label under the domain and resolves. - Gmail names reach three and a chain label adds a fourth. ENSIP-10's walk-up is - depth-agnostic by specification, but depth is decided by the client, so it is - the first thing to measure after the import. - -## Sources - -- [ENSIP-10: Wildcard Resolution](https://docs.ens.domains/ensip/10/) -- [ENSIP-11: EVM Chain Address Resolution](https://docs.ens.domains/ensip/11/) -- [ENSIP-15: Name Normalization](https://docs.ens.domains/ensip/15/) -- [ENSIP-17: Gasless DNS Resolution](https://docs.ens.domains/ensip/17/) -- [ERC-3668: CCIP Read](https://eips.ethereum.org/EIPS/eip-3668) -- [Offchain / L2 Resolvers](https://docs.ens.domains/resolvers/ccip-read/) -- [ens-normalize: the ENSIP-15 reference implementation](https://github.com/adraffy/ens-normalize.js) -- [Subnames: issuance models](https://docs.ens.domains/web/subdomains/) -- [DNS Registrar](https://docs.ens.domains/registry/dns/) -- [MetaMask: custom name resolution](https://docs.metamask.io/snaps/features/custom-name-resolution/) -- [Durin](https://github.com/resolverworks/durin) diff --git a/specs/ens-integration.md b/specs/ens-integration.md new file mode 100644 index 00000000..10f7b606 --- /dev/null +++ b/specs/ens-integration.md @@ -0,0 +1,397 @@ +--- +title: ENS integration +sidebar: + order: 5 +--- + +# ENS integration + +Part of the [libID protocol specification](libid.md). + +## 1. Scope + +This document is the normative owner of how a libID binding is read as an ENS +name: the name grammar under the Parent Name, the transform between a handle +and its labels, the Handle Resolver contract on the ENS chain, and the Gateway +that answers the resolver's offchain lookups. + +The integration projects state that already exists. A name is derived from a +binding in the `IdentityRegistry` of a Consumer Chain; nothing is registered, +minted, or stored per name, and nothing here writes to any `IdentityRegistry`. +The authoritative record is the binding. An ENS name is a way to read it. + +It does not own the binding itself, handle normalization (`HandleNormalizer`, +under the platform's rules), the indexer that mirrors each chain, the DNS +zone's operation, or wallet behavior before the Gateway is called. + +## 2. Terminology + +Parent Name: `handles.link`, a DNS name imported into the ENS registry on the + ENS chain through the ENS DNS registrar. + +ENS Chain: Ethereum mainnet for a production deployment; a test deployment + uses an Ethereum test network that hosts the ENS registry. + +Handle Resolver: The contract set as the resolver of the Parent Name. It holds + no names. + +Gateway: The HTTP service named in the Handle Resolver's URL list. It reads + the Indexed Store and signs answers. + +Indexed Store: The mirror of every indexed Consumer Chain's `IdentityRegistry` + that `usernames-indexer` writes, with each chain's index position and + indexer report. + +Platform Label: One of `x`, `github`, `google`. It names the platform whose + Platform ID the `IdentityRegistry` registers for that key. + +Chain Label: A label naming one Consumer Chain. The set of Chain Labels is + deployment data in the Indexed Store. + +Handle Labels: The labels a handle becomes under §6. + +Coin Type: The ENSIP-11 coin type a client asks with. `60` is Ethereum + mainnet; an EVM chain `c` is `0x80000000 | c`. + +Signer: An address the Handle Resolver trusts to sign Gateway answers. + +Signed Null: A signed answer that the queried record does not exist. + +Refusal: An unsigned HTTP error. It asserts nothing about any binding. + +## 3. Assumptions + +- ASM-ENS-01: + A client resolving a name implements ENSIP-10 wildcard resolution, ERC-3668 + CCIP-Read, and ENSIP-11 chain address resolution. A client lacking one of + them reports an error or asks only for coin type 60; it never receives an + address for a chain it did not ask about. +- ASM-ENS-02: + A client applies ENSIP-15 normalization before hashing a name, so the + Handle Resolver and the Gateway receive normalized labels. +- ASM-ENS-03: + The Indexed Store reflects each indexed chain's `IdentityRegistry` up to + the index position it records, and the indexer report it carries is + current while unexpired. +- ASM-ENS-04: + Each Signer's private key is known only to the Gateway operator. +- ASM-ENS-05: + Whoever controls the Parent Name's DNS registration and DNSSEC keys + controls the Parent Name in ENS: a later valid DNSSEC proof overwrites its + owner. + +## 4. Security properties + +- SP-ENS-01: + The Handle Resolver returns an answer only when a current Signer signed + that exact answer, for that exact query, for that resolver, and the answer + has not expired. An answer cannot be replayed for another name, another + record, another resolver, or after its deadline. Depends on ASM-ENS-04. +- SP-ENS-02: + A signed address is the holder the Indexed Store records for the handle on + the one chain the Coin Type names. Depends on ASM-ENS-03. +- SP-ENS-03: + The Gateway never signs absence it does not know: a chain it does not + index, a Coin Type two indexed chains share, and an index too far behind + each get a Refusal, never a Signed Null. +- SP-ENS-04: + No two handles reach one name, and a name carries its handle: the Gateway + reads a name with no mapping table. +- SP-ENS-05: + A name never widens to another chain. A Chain Label only narrows the Coin + Type's answer. + +These properties do not survive compromise of a Signer key, which can answer +with any address (§10). + +## 5. Names + +```text +name = handleLabels "." platformLabel [ "." chainLabel ] ".handles.link" +``` + +```text +alice.x.handles.link short form +alice.x.base.handles.link with a Chain Label +alice.smith.google.handles.link Gmail +alice._at.company.com.google.handles.link + Google Workspace +``` + +- REQ-ENS-NAME-01 (upholds SP-ENS-04): + Platform Labels and Chain Labels MUST be disjoint sets. A Chain Label MUST + match `[a-z0-9-]+`. Necessity: the parse below tells the two apart only by + membership. +- REQ-ENS-NAME-02 (upholds SP-ENS-04): + A name MUST be parsed right to left. After `handles.link`, the last label + is the Platform Label if it is one; otherwise it is the Chain Label and the + label before it is the Platform Label. Every remaining label is a Handle + Label. Necessity: a Gmail or Workspace handle contributes a variable number + of labels, so only the right end has fixed positions. +- REQ-ENS-NAME-03 (upholds SP-ENS-05): + A name with a Chain Label MUST be answered only when the Coin Type names + the chain that label names. A name without one is answered for the chain + the Coin Type names. +- REQ-ENS-NAME-04: + A name MUST be DNS wire format with every label 1 to 63 bytes. A label + containing an uppercase letter or a byte at or below `0x20` is not a name + any handle produces. + +## 6. Handle labels + +The transform takes the handle `HandleNormalizer` produced under the +platform's rules, never raw input. It returns Handle Labels or refuses. A +refused handle has no name; its binding stays readable through the +`IdentityRegistry` and by address. + +ENSIP-15 accepts every label below except where a rule refuses, and two of +its rules shape them: `_` may appear only at the start of a label, and a +label's third and fourth characters must not both be `-`. + +- REQ-ENS-LABEL-01 (X): + The Handle Labels of an X handle are one label: the handle with every `_` + replaced by `-`. The transform MUST refuse a handle whose third and fourth + characters are both `_`. Necessity: X issues no `-`, so the substitution + reverses exactly; the refusal is ENSIP-15's `/^..--/` rule and nothing + more. +- REQ-ENS-LABEL-02 (GitHub): + The Handle Labels of a GitHub handle are one label: the handle unchanged. + Necessity: GitHub's rules admit neither `_` nor a doubled `-`, so no + ENSIP-15 rule can fire. +- REQ-ENS-LABEL-03 (Gmail): + For a Google handle whose domain is `gmail.com`, the Handle Labels are the + local part split at every `.`. The transform MUST refuse a local part not + matching `^[a-z0-9]+(\.[a-z0-9]+)*$`. Necessity: Gmail issues only that + alphabet; a mapping for characters no account can hold would be untested + code on a payment path. +- REQ-ENS-LABEL-04 (Google Workspace): + For any other Google handle, the Handle Labels are the local part split at + `.`, then `_at`, then the domain split at `.`. Every label other than + `_at` MUST be non-empty and match `[a-z0-9-]+`; the transform MUST refuse + otherwise. Necessity: joining local part and domain with dots alone is + ambiguous (`a.b@c.com`, `a@b.c.com`); `_at` cannot collide with a Handle + Label, because a leading `_` is the only one ENSIP-15 admits and no other + Handle Label holds one. +- REQ-ENS-LABEL-05 (upholds SP-ENS-04): + The Gateway MUST read Handle Labels by the exact inverse of + REQ-ENS-LABEL-01 to -04, run the result through the chain's handle rules, + and answer a Signed Null when either step fails. It SHOULD answer a Signed + Null for labels outside the transform's image, such as an X label with + `--` at the third and fourth characters, or `_at` followed by `gmail.com`. + Necessity: the labels carry the handle, so no table maps names to handles; + the inverse is the only reading. + +## 7. Handle Resolver + +- REQ-ENS-RES-01: + The Handle Resolver MUST be the resolver of the Parent Name and MUST hold + no per-name state. It MUST answer `supportsInterface` true for ENSIP-10 + `IExtendedResolver` (`0x9061b923`) and ERC-165 (`0x01ffc9a7`) only. + Necessity: announcing ERC-7996 makes the ENS Universal Resolver raise the + lookup under its own address (ENSIP-22), which the Gateway refuses under + REQ-ENS-GW-08. +- REQ-ENS-RES-02: + `resolve(bytes name, bytes data)` MUST revert with ERC-3668 + `OffchainLookup(address(this), urls, callData, resolveWithProof.selector, + callData)`, where `callData` is the `resolve` call as received. It MUST + revert `NoUrls()` when the URL list is empty. +- REQ-ENS-RES-03 (upholds SP-ENS-01): + `resolveWithProof(bytes response, bytes extraData)` MUST decode + `(bytes result, uint64 expires, bytes signature)` from `response` and + return `result` only if `expires >= block.timestamp`, + `expires <= block.timestamp + 3600`, and the address recovered from + `signature` over the digest below is a Signer: + + ```text + digest = keccak256(0x19 ‖ 0x00 ‖ resolver ‖ U64BE(expires) + ‖ keccak256(extraData) ‖ keccak256(result)) + ``` + + where `resolver` is the Handle Resolver's 20-byte address. It reverts + `SignatureExpired`, `DeadlineTooFar`, or `UntrustedSigner` otherwise. + Necessity: the resolver address binds the answer to one resolver, the + request hash to one query, the result hash to one answer, and the ceiling + stops a leaked answer living indefinitely. +- REQ-ENS-RES-04: + Only the owner MAY replace the URL list or add or remove a Signer. + Ownership MUST transfer in two steps and MUST NOT be renounceable. The + Handle Resolver is not upgradeable; it is replaced by setting another + resolver on the Parent Name. + +## 8. Gateway + +### 8.1 Requests + +- REQ-ENS-GW-01: + The Gateway MUST serve `GET {base}/{sender}/{data}`, where `{data}` is the + hex `callData` of REQ-ENS-RES-02, with or without `0x` and a `.json` + suffix. A `{data}` that is not hex, not a `resolve` call, or not decodable + MUST get HTTP 400. +- REQ-ENS-GW-02: + The Gateway MUST answer HTTP 400 for a name outside `handles.link`, a name + violating REQ-ENS-NAME-04's wire rules, and a record call whose node is not + the namehash of the name. Necessity: these are malformed queries, not + names; and a node that differs from the name means the client and the + Gateway normalized differently, which must not be signed. + +### 8.2 Choosing the chain + +- REQ-ENS-GW-03 (upholds SP-ENS-02, SP-ENS-03): + The Gateway MUST match the Coin Type forward against the chain IDs in the + Indexed Store: chain `c` matches `0x80000000 | c`, and chain 1 also + matches `60`. It MUST NOT decode a Coin Type into a chain ID. Exactly one + match selects that chain. Necessity: `0x80000000 | c` is not injective for + `c >= 2^31`, so decoding would answer for a different chain. +- REQ-ENS-GW-04 (upholds SP-ENS-03): + With no match, the Gateway MUST return a Refusal (HTTP 503) when the Coin + Type names an EVM chain (`60`, or bit 31 set), and a Signed Null otherwise. + With two or more matches it MUST return a Refusal (HTTP 503). Necessity: + the resolver has one URL list for every query, and ERC-3668 has the client + walk it until one succeeds. A Signed Null is a success that ends the walk, + so signing absence for a chain this Gateway does not hold would deny a + binding a later Gateway in the list could serve. No libID binding is ever + a non-EVM address, so that absence is known. +- REQ-ENS-GW-05 (upholds SP-ENS-05): + Once a chain is selected, a name the Gateway cannot parse, a Handle Label + outside REQ-ENS-LABEL-05, and a Chain Label not naming the selected chain + MUST each get a Signed Null. +- REQ-ENS-GW-06 (upholds SP-ENS-03): + Before answering from a chain, the Gateway MUST return a Refusal (HTTP 503) + when that chain's index is more than the deployment's maximum lag behind, + or its indexer report has expired or is unknown. Necessity: a stale index + can deny a binding that already exists. + +### 8.3 Answers + +- REQ-ENS-GW-07 (upholds SP-ENS-02): + The answer is the holder the Indexed Store records for the handle node on + the selected chain, encoded per record: + + | record | answer | none | + |---|---|---| + | `addr(bytes32)` | ABI `address` | the zero address | + | `addr(bytes32,uint256)` | ABI `bytes`, 20 bytes | empty bytes | + | any other selector | empty result | — | + + An `addr` call whose arguments do not decode gets the empty result. +- REQ-ENS-GW-08 (upholds SP-ENS-01): + The Gateway MUST sign the REQ-ENS-RES-03 digest with `resolver` set to the + Handle Resolver address it is configured to serve, never the `{sender}` of + the request, and with `expires` at most 3300 seconds after signing. It MAY + refuse a `{sender}` that is not that address with HTTP 400, comparing + addresses case-insensitively. Necessity: on the Universal Resolver's + direct-call route `{sender}` names the Universal Resolver, and the 300 s + below the resolver's ceiling absorbs clock skew between Gateway and chain. +- REQ-ENS-GW-09: + A successful response MUST be HTTP 200 with body + `{"data": "0x" ‖ hex(abi.encode(bytes result, uint64 expires, bytes signature))}`. + A Refusal or error MUST carry no signature. +- REQ-ENS-GW-10: + Every response, errors included, MUST carry + `Access-Control-Allow-Origin: *`. Necessity: the mainnet Universal + Resolver's `x-batch-gateway:true` entry makes the client fetch the Gateway + from the wallet's page; without the header the page never sees the answer. + The answers are public and signed, and no credentials are sent. + +## 9. Keys + +- REQ-ENS-KEY-01: + The DNS registrar account with the DNSSEC keys, the owner of the Parent + Name and the Handle Resolver, and each Signer MUST be distinct keys. + + | key | grants | lives | + |---|---|---| + | DNS registrar account and DNSSEC keys | the Parent Name (ASM-ENS-05) | DNS provider | + | Parent Name and Handle Resolver owner | resolver, URL list, Signers | cold | + | Signer | answers | Gateway host | + + Necessity: a URL without a Signer key buys nothing, because the callback + rejects its answer; a Signer key answers anything; the DNS credentials take + the whole namespace. Held together, the least guarded one decides all + three. +- REQ-ENS-KEY-02 (upholds SP-ENS-01): + A Signer MUST NOT be trusted by Handle Resolvers at one address on two + chains. Necessity: the digest does not cover a chain ID, so such an answer + verifies on both. + +## 10. Conformance + +- TEST-ENS-01 (exercises REQ-ENS-LABEL-01 to -05): + Forward and inverse vectors for each platform round-trip; `a__b` maps to + `a--b` and `ab__cd` is refused; a Gmail local part with `+`, `-` or `_` is + refused; `a.b@c.com` and `a@b.c.com` give different names. +- TEST-ENS-02 (exercises REQ-ENS-RES-03, REQ-ENS-GW-08): + One digest vector reproduces in the Handle Resolver and the Gateway; the + callback rejects an expired answer, one past the ceiling, an untrusted + Signer, and an answer signed for another resolver address. +- TEST-ENS-03 (exercises REQ-ENS-GW-03 to -06): + An unindexed EVM Coin Type, coin type 60 without chain 1 indexed, two + chains sharing one Coin Type, and a stale index each get a Refusal; a + non-EVM Coin Type, an unknown Handle Label, and a mismatched Chain Label + each get a Signed Null. +- TEST-ENS-04 (exercises REQ-ENS-GW-07, REQ-ENS-GW-09, REQ-ENS-GW-10): + Both `addr` shapes answer a bound and an unbound handle; a `text` call gets + the signed empty result; a 200 and a Refusal both carry + `Access-Control-Allow-Origin: *`. +- TEST-ENS-05 (exercises SP-ENS-01, SP-ENS-02 end to end): + A name resolves through the ENS Universal Resolver on the ENS Chain, with + the batch gateway played by the test. The same name fails with CCIP-Read + disabled on the client, which shows the answer came offchain. + +## 11. Security Considerations + +- A compromised Signer key can answer with any address, and for a system + routing payments that is the whole risk. Removing the Signer is one owner + transaction; answers already signed stay valid until they expire, at most + an hour. +- A user has no onchain claim to the name. It resolves while the Gateway + runs and the Parent Name points at the Handle Resolver. The binding in the + `IdentityRegistry` survives either, and is readable without them. +- Nothing orders bindings across chains. A handle the platform recycled can + be bound to different holders on different chains, and one name then + resolves to different people depending on the chain asked. Each answer is + correct for its chain. A Gateway that answered only for the freshest chain + would be deciding rather than reporting; this specification does not. +- A binding on a chain means funds sent to the name on that chain reach its + holder. A Workspace name publishes the employer's domain. +- The chain a transaction is finally sent on is the client's choice. A Chain + Label cannot make a wallet send on that chain; it makes the Gateway + withhold the address when the Coin Type disagrees. + +## 12. Not specified + +- Gateway policy when chains disagree about one handle. +- Whether an application shows a user the name derived under §6. +- Whether a Workspace name is offered by default, given that it discloses an + employer. +- The deepest name clients resolve. ENSIP-10 is depth-agnostic; a Gmail name + with a Chain Label has six labels. + +## 13. Provenance + +| Requirement | Source | +|---|---| +| REQ-ENS-RES-01 to RES-04 | `libid-contracts` `solidity/contracts/ens/HandleResolver.sol` | +| REQ-ENS-NAME-01 to NAME-04, REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs` | +| REQ-ENS-GW-01 to GW-10 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs` | +| REQ-ENS-LABEL-01 to LABEL-04 | no implementation yet | + +## 14. References + +Normative: + +- [ENSIP-10] Wildcard Resolution (). +- [ENSIP-11] EVM Chain Address Resolution (). +- [ENSIP-15] Name Normalization (). +- [ERC-3668] CCIP Read (). +- [EIP-191] Signed Data Standard (). +- [RFC1035] Domain Names (). +- [RFC2119] Key words for use in RFCs to Indicate Requirement Levels + (), as clarified by [RFC8174] + (). + +Informative: + +- [ENSIP-21] Batch Gateway Offchain Lookup Protocol (). +- [ENSIP-22] ENS Universal Resolver (). +- [DNS Registrar] . diff --git a/specs/libid.md b/specs/libid.md index a7fa9a6c..0b0f43b2 100644 --- a/specs/libid.md +++ b/specs/libid.md @@ -32,6 +32,13 @@ specifications. protocols cite it instead of restating opener, isolation, and continuity mechanics. +## Names + +- [ENS integration](ens-integration.md) defines how a binding is read as an + ENS name under `handles.link`: the name grammar, the handle-to-label + transform, the Handle Resolver contract, and the Gateway that signs its + answers. + ## System model and specification ownership libID turns an identity-platform authorization into a proof that a Consumer @@ -104,6 +111,7 @@ root and verifier. | Chain ID, Transaction Author, Block Time, and transaction-data encoding | [Chain profiles](chain-profiles.md), with the Consumer's protocol fixing each transaction kind's arguments | | Platform endpoints, fields, trust roots, and proof projections | [Identity-platform ceremonies](platform-ceremonies.md) | | Popup origin allowlists, message model, delivery, navigation, closure, and continuity guarantees | [Popup transport](popup-transport.md) | +| ENS names, the handle-to-label transform, the Handle Resolver, and the Gateway | [ENS integration](ens-integration.md) | | Redirect transport, interruption behavior, and UI control flow | browser architecture | | Transaction dispatch and author authentication | Consumer protocol | | Verification dispatch, replay recording, trust roots, and version governance | [Common ceremony rules](ceremony-common.md) | From 77f9be3df39005967a7266bc906bc00aa783d5b0 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 13:09:01 +0300 Subject: [PATCH 03/17] docs(specs): let one ENS signer serve several chains Drop REQ-ENS-KEY-02. The digest covers no chain ID, so an answer replays across resolvers at one address that trust the same signer; Security Considerations states when that replay is wrong. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 10f7b606..299e697d 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -309,10 +309,6 @@ label's third and fourth characters must not both be `-`. rejects its answer; a Signer key answers anything; the DNS credentials take the whole namespace. Held together, the least guarded one decides all three. -- REQ-ENS-KEY-02 (upholds SP-ENS-01): - A Signer MUST NOT be trusted by Handle Resolvers at one address on two - chains. Necessity: the digest does not cover a chain ID, so such an answer - verifies on both. ## 10. Conformance @@ -344,6 +340,12 @@ label's third and fourth characters must not both be `-`. routing payments that is the whole risk. Removing the Signer is one owner transaction; answers already signed stay valid until they expire, at most an hour. +- The digest covers no chain ID, so one Signer may serve Handle Resolvers on + several chains. An answer signed for a resolver address then also verifies + on a resolver at the same address on another chain that trusts the same + Signer, until it expires. That replay returns the answer the Gateway gave, + which is wrong only where the two resolvers' Gateways read different + Indexed Stores. - A user has no onchain claim to the name. It resolves while the Gateway runs and the Parent Name points at the Handle Resolver. The binding in the `IdentityRegistry` survives either, and is readable without them. From 3ae0186acf668d94c63aa57fb38260afb5513fcc Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:32:02 +0300 Subject: [PATCH 04/17] docs(specs): a shared ENS signer needs resolvers at different addresses The digest binds an answer to its resolver's address, not its chain, so one Signer may serve several chains while their resolvers' addresses differ (REQ-ENS-KEY-02); SP-ENS-01 says so. Removing a Signer takes effect at once on each chain, since the callback checks it when it runs. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 299e697d..48f38e6e 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -84,9 +84,10 @@ Refusal: An unsigned HTTP error. It asserts nothing about any binding. - SP-ENS-01: The Handle Resolver returns an answer only when a current Signer signed - that exact answer, for that exact query, for that resolver, and the answer - has not expired. An answer cannot be replayed for another name, another - record, another resolver, or after its deadline. Depends on ASM-ENS-04. + that exact answer, for that exact query, for that resolver's address, and + the answer has not expired. An answer cannot be replayed for another name, + another record, a resolver at another address, or after its deadline. + Depends on ASM-ENS-04 and REQ-ENS-KEY-02. - SP-ENS-02: A signed address is the holder the Indexed Store records for the handle on the one chain the Coin Type names. Depends on ASM-ENS-03. @@ -309,6 +310,11 @@ label's third and fourth characters must not both be `-`. rejects its answer; a Signer key answers anything; the DNS credentials take the whole namespace. Held together, the least guarded one decides all three. +- REQ-ENS-KEY-02 (upholds SP-ENS-01): + Handle Resolvers that trust one Signer MUST sit at different addresses, + whichever chains they are on. Necessity: the digest binds an answer to its + resolver's address and not to a chain, so one Signer may serve resolvers + on several chains only while their addresses differ. ## 10. Conformance @@ -337,15 +343,13 @@ label's third and fourth characters must not both be `-`. ## 11. Security Considerations - A compromised Signer key can answer with any address, and for a system - routing payments that is the whole risk. Removing the Signer is one owner - transaction; answers already signed stay valid until they expire, at most - an hour. -- The digest covers no chain ID, so one Signer may serve Handle Resolvers on - several chains. An answer signed for a resolver address then also verifies - on a resolver at the same address on another chain that trusts the same - Signer, until it expires. That replay returns the answer the Gateway gave, - which is wrong only where the two resolvers' Gateways read different - Indexed Stores. + routing payments that is the whole risk. Removing it takes one owner + transaction on each chain whose resolver trusts it. The callback checks the + Signer when it runs, so the answers it signed stop verifying from that + block on. +- One Signer may serve Handle Resolvers on several chains. The digest covers + no chain ID, but it covers the resolver's address, and REQ-ENS-KEY-02 keeps + those apart, so an answer verifies only at the resolver it was signed for. - A user has no onchain claim to the name. It resolves while the Gateway runs and the Parent Name points at the Handle Resolver. The binding in the `IdentityRegistry` survives either, and is readable without them. From e60ee7504b0b8db16f88f99648d2a29b7f2ecf44 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:45:32 +0300 Subject: [PATCH 05/17] docs(specs): coin type 60 is the ENS Chain's coin Coin type 60 matches the chain whose ENS registry holds the Parent Name, which the Gateway is configured with, not always chain 1. A coin type at or above 2^64 names no EVM chain and gets a Signed Null. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 48f38e6e..a3465dd8 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -29,8 +29,10 @@ zone's operation, or wallet behavior before the Gateway is called. Parent Name: `handles.link`, a DNS name imported into the ENS registry on the ENS chain through the ENS DNS registrar. -ENS Chain: Ethereum mainnet for a production deployment; a test deployment - uses an Ethereum test network that hosts the ENS registry. +ENS Chain: The chain whose ENS registry holds the Parent Name: Ethereum + mainnet for a production deployment, an Ethereum test network that hosts + the ENS registry for a test deployment. The Gateway is configured with its + chain ID. Handle Resolver: The contract set as the resolver of the Parent Name. It holds no names. @@ -50,8 +52,9 @@ Chain Label: A label naming one Consumer Chain. The set of Chain Labels is Handle Labels: The labels a handle becomes under §6. -Coin Type: The ENSIP-11 coin type a client asks with. `60` is Ethereum - mainnet; an EVM chain `c` is `0x80000000 | c`. +Coin Type: The ENSIP-11 coin type a client asks with. `60` is the coin of + the ENS Chain, and `addr(bytes32)` asks with `60`; an EVM chain `c` is + `0x80000000 | c`. Signer: An address the Handle Resolver trusts to sign Gateway answers. @@ -239,14 +242,19 @@ label's third and fourth characters must not both be `-`. - REQ-ENS-GW-03 (upholds SP-ENS-02, SP-ENS-03): The Gateway MUST match the Coin Type forward against the chain IDs in the - Indexed Store: chain `c` matches `0x80000000 | c`, and chain 1 also + Indexed Store: chain `c` matches `0x80000000 | c`, and the ENS Chain also matches `60`. It MUST NOT decode a Coin Type into a chain ID. Exactly one match selects that chain. Necessity: `0x80000000 | c` is not injective for - `c >= 2^31`, so decoding would answer for a different chain. + `c >= 2^31`, so decoding would answer for a different chain; and a client + that asks with `60` sends on the chain whose registry it asked, so `60` + through a test network's registry is that test network, never chain 1. - REQ-ENS-GW-04 (upholds SP-ENS-03): With no match, the Gateway MUST return a Refusal (HTTP 503) when the Coin - Type names an EVM chain (`60`, or bit 31 set), and a Signed Null otherwise. - With two or more matches it MUST return a Refusal (HTTP 503). Necessity: + Type names an EVM chain, and a Signed Null otherwise. A Coin Type names an + EVM chain when it is below 2^64 and is `60` or has bit 31 set; every Coin + Type at or above 2^64 is non-EVM, and no indexed chain matches one, because + the Indexed Store holds chain IDs below 2^64 only. With two or more matches + the Gateway MUST return a Refusal (HTTP 503). Necessity: the resolver has one URL list for every query, and ERC-3668 has the client walk it until one succeeds. A Signed Null is a success that ends the walk, so signing absence for a chain this Gateway does not hold would deny a @@ -327,10 +335,12 @@ label's third and fourth characters must not both be `-`. callback rejects an expired answer, one past the ceiling, an untrusted Signer, and an answer signed for another resolver address. - TEST-ENS-03 (exercises REQ-ENS-GW-03 to -06): - An unindexed EVM Coin Type, coin type 60 without chain 1 indexed, two - chains sharing one Coin Type, and a stale index each get a Refusal; a - non-EVM Coin Type, an unknown Handle Label, and a mismatched Chain Label - each get a Signed Null. + An unindexed EVM Coin Type, coin type 60 without the ENS Chain indexed, + two chains sharing one Coin Type, and a stale index each get a Refusal; a + non-EVM Coin Type (`0`, and `2^64 | 0x80000000`), an unknown Handle Label, + and a mismatched Chain Label each get a Signed Null. With the ENS Chain set + to Sepolia (11155111) and both Sepolia and chain 1 indexed, coin type 60 + selects Sepolia. - TEST-ENS-04 (exercises REQ-ENS-GW-07, REQ-ENS-GW-09, REQ-ENS-GW-10): Both `addr` shapes answer a bound and an unbound handle; a `text` call gets the signed empty result; a 200 and a Refusal both carry From 8f10f5e36fea177c543018f2e1a4dd7cea7479cf Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:45:43 +0300 Subject: [PATCH 06/17] docs(specs): make the Parent Name a deployment parameter The Gateway answers under a configured domain: handles.link in production, a name under it such as testnet.handles.link for a test deployment. The grammar, the right-to-left parse and the foreign-name 400 refer to the Parent Name rather than the literal handles.link, and TEST-ENS-06 covers a subname Parent Name. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index a3465dd8..fa0cd19a 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -26,8 +26,11 @@ zone's operation, or wallet behavior before the Gateway is called. ## 2. Terminology -Parent Name: `handles.link`, a DNS name imported into the ENS registry on the - ENS chain through the ENS DNS registrar. +Parent Name: The name a deployment's names sit under, a deployment + parameter: `handles.link` in production, which is a DNS name imported into + the ENS registry on the ENS Chain through the ENS DNS registrar. A test + deployment may use a name under it, such as `testnet.handles.link`. Every + label of the Parent Name matches `[a-z0-9-]+`. ENS Chain: The chain whose ENS registry holds the Parent Name: Ethereum mainnet for a production deployment, an Ethereum test network that hosts @@ -79,9 +82,9 @@ Refusal: An unsigned HTTP error. It asserts nothing about any binding. - ASM-ENS-04: Each Signer's private key is known only to the Gateway operator. - ASM-ENS-05: - Whoever controls the Parent Name's DNS registration and DNSSEC keys - controls the Parent Name in ENS: a later valid DNSSEC proof overwrites its - owner. + Whoever controls the DNS registration and DNSSEC keys of `handles.link` + controls it in ENS, and with it every Parent Name under it: a later valid + DNSSEC proof overwrites its owner. ## 4. Security properties @@ -111,9 +114,11 @@ with any address (§10). ## 5. Names ```text -name = handleLabels "." platformLabel [ "." chainLabel ] ".handles.link" +name = handleLabels "." platformLabel [ "." chainLabel ] "." parentName ``` +With the production Parent Name `handles.link`: + ```text alice.x.handles.link short form alice.x.base.handles.link with a Chain Label @@ -127,8 +132,8 @@ alice._at.company.com.google.handles.link match `[a-z0-9-]+`. Necessity: the parse below tells the two apart only by membership. - REQ-ENS-NAME-02 (upholds SP-ENS-04): - A name MUST be parsed right to left. After `handles.link`, the last label - is the Platform Label if it is one; otherwise it is the Chain Label and the + A name MUST be parsed right to left. After the Parent Name's labels, the + last label is the Platform Label if it is one; otherwise it is the Chain Label and the label before it is the Platform Label. Every remaining label is a Handle Label. Necessity: a Gmail or Workspace handle contributes a variable number of labels, so only the right end has fixed positions. @@ -232,7 +237,7 @@ label's third and fourth characters must not both be `-`. suffix. A `{data}` that is not hex, not a `resolve` call, or not decodable MUST get HTTP 400. - REQ-ENS-GW-02: - The Gateway MUST answer HTTP 400 for a name outside `handles.link`, a name + The Gateway MUST answer HTTP 400 for a name outside its Parent Name, a name violating REQ-ENS-NAME-04's wire rules, and a record call whose node is not the namehash of the name. Necessity: these are malformed queries, not names; and a node that differs from the name means the client and the @@ -350,6 +355,12 @@ label's third and fourth characters must not both be `-`. the batch gateway played by the test. The same name fails with CCIP-Read disabled on the client, which shows the answer came offchain. +- TEST-ENS-06 (exercises REQ-ENS-NAME-02, REQ-ENS-GW-02): + With the Parent Name `testnet.handles.link`, `alice.x.testnet.handles.link` + reads as the X handle `alice` with no Chain Label, + `alice.x.sepolia.testnet.handles.link` carries the Chain Label `sepolia`, + and `alice.x.handles.link` gets HTTP 400. + ## 11. Security Considerations - A compromised Signer key can answer with any address, and for a system From b1778554240318ccf76a299ea4a4a2f3d0cde96b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:46:18 +0300 Subject: [PATCH 07/17] docs(specs): state what the Gateway's handle reading does The Gateway checks an inverted handle against the rules built into it, the same for every chain, so a binding admitted only under wider rules on a chain gets a Signed Null; SP-ENS-03 now depends on the rules agreeing. A Workspace label with '-' at its third and fourth characters, IDNA domain labels among them, is refused, since ENSIP-15 refuses it. The Security Considerations say plainly that the Gateway reads names outside the transform's image, among them _at followed by gmail.com. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 53 +++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index fa0cd19a..d56fad79 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -100,7 +100,8 @@ Refusal: An unsigned HTTP error. It asserts nothing about any binding. - SP-ENS-03: The Gateway never signs absence it does not know: a chain it does not index, a Coin Type two indexed chains share, and an index too far behind - each get a Refusal, never a Signed Null. + each get a Refusal, never a Signed Null. Depends on the Gateway's handle + rules admitting every handle an indexed chain binds (§11). - SP-ENS-04: No two handles reach one name, and a name carries its handle: the Gateway reads a name with no mapping table. @@ -153,9 +154,12 @@ platform's rules, never raw input. It returns Handle Labels or refuses. A refused handle has no name; its binding stays readable through the `IdentityRegistry` and by address. -ENSIP-15 accepts every label below except where a rule refuses, and two of -its rules shape them: `_` may appear only at the start of a label, and a -label's third and fourth characters must not both be `-`. +Every Handle Label also satisfies REQ-ENS-NAME-04, so the transform refuses +a handle that would give a label longer than 63 bytes. Two ENSIP-15 rules +shape the labels below: `_` may appear only at the start of a label, and a +label's third and fourth characters must not both be `-`. The rules below +keep every Handle Label within both, so ENSIP-15 leaves every name they +produce unchanged. - REQ-ENS-LABEL-01 (X): The Handle Labels of an X handle are one label: the handle with every `_` @@ -176,17 +180,22 @@ label's third and fourth characters must not both be `-`. - REQ-ENS-LABEL-04 (Google Workspace): For any other Google handle, the Handle Labels are the local part split at `.`, then `_at`, then the domain split at `.`. Every label other than - `_at` MUST be non-empty and match `[a-z0-9-]+`; the transform MUST refuse - otherwise. Necessity: joining local part and domain with dots alone is - ambiguous (`a.b@c.com`, `a@b.c.com`); `_at` cannot collide with a Handle - Label, because a leading `_` is the only one ENSIP-15 admits and no other - Handle Label holds one. + `_at` MUST be non-empty and match `[a-z0-9-]+`, and the transform MUST + refuse a handle with a label whose third and fourth characters are both + `-`, such as the local part `ab--x` or the IDNA domain label + `xn--bcher-kva`. Necessity: joining local part and domain with dots alone + is ambiguous (`a.b@c.com`, `a@b.c.com`); `_at` cannot collide with a + Handle Label, because a leading `_` is the only one ENSIP-15 admits and no + other Handle Label holds one; and ENSIP-15 refuses a label matching + `/^..--/`, so no client could send such a name. - REQ-ENS-LABEL-05 (upholds SP-ENS-04): The Gateway MUST read Handle Labels by the exact inverse of - REQ-ENS-LABEL-01 to -04, run the result through the chain's handle rules, - and answer a Signed Null when either step fails. It SHOULD answer a Signed - Null for labels outside the transform's image, such as an X label with - `--` at the third and fourth characters, or `_at` followed by `gmail.com`. + REQ-ENS-LABEL-01 to -04, run the result through the platform's handle + rules built into the Gateway, which are the same for every chain, and + answer a Signed Null when either step fails. It SHOULD answer a Signed Null + for labels outside the transform's image, such as an X or Workspace label + with `--` at the third and fourth characters, or `_at` followed by + `gmail.com`. Necessity: the labels carry the handle, so no table maps names to handles; the inverse is the only reading. @@ -334,7 +343,9 @@ label's third and fourth characters must not both be `-`. - TEST-ENS-01 (exercises REQ-ENS-LABEL-01 to -05): Forward and inverse vectors for each platform round-trip; `a__b` maps to `a--b` and `ab__cd` is refused; a Gmail local part with `+`, `-` or `_` is - refused; `a.b@c.com` and `a@b.c.com` give different names. + refused; `a.b@c.com` and `a@b.c.com` give different names; + `ab--x@company.com` and `alice@xn--bcher-kva.example` have no name; a + handle giving a 64-byte label has no name. - TEST-ENS-02 (exercises REQ-ENS-RES-03, REQ-ENS-GW-08): One digest vector reproduces in the Handle Resolver and the Gateway; the callback rejects an expired answer, one past the ceiling, an untrusted @@ -371,6 +382,20 @@ label's third and fourth characters must not both be `-`. - One Signer may serve Handle Resolvers on several chains. The digest covers no chain ID, but it covers the resolver's address, and REQ-ENS-KEY-02 keeps those apart, so an answer verifies only at the resolver it was signed for. +- The Gateway applies the handle rules it was built with to every chain. A + chain whose `IdentityRegistry` admits a platform's handles under wider + rules than those can hold a binding the Gateway's rules refuse; that + binding's name gets a Signed Null although the binding exists. A change to + a platform's rules on any indexed chain needs a Gateway built with the + same rules before such bindings resolve. +- The Gateway does not implement REQ-ENS-LABEL-05's SHOULD. It reads + `alice._at.gmail.com.google.handles.link` as `alice@gmail.com`, so every + Gmail handle has a second name that resolves to the same binding, and + that form takes a local part outside the Gmail alphabet of + REQ-ENS-LABEL-03, such as one with `-`, on to the handle rules and the + lookup. It likewise reads an X or Workspace label with `--` at the third + and fourth characters, which no ENSIP-15 client sends. Each such name + resolves to the binding of the handle the Gateway reads from it. - A user has no onchain claim to the name. It resolves while the Gateway runs and the Parent Name points at the Handle Resolver. The binding in the `IdentityRegistry` survives either, and is readable without them. From 4b48c4cfb8acbf55422844103365ddd1f7730f57 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:47:06 +0300 Subject: [PATCH 08/17] docs(specs): specify the order of the Gateway's checks The node/name comparison applies to addr calls only; any other record gets the signed empty result once the name is wire-valid and under the Parent Name. A label that is not UTF-8 is a wire error and gets HTTP 400. REQ-ENS-GW-11 lists the steps in the order the Gateway takes them: the Signed Nulls of REQ-ENS-GW-05 come before the staleness check and the handle rules after it, and the consequence is stated. TEST-ENS-07 covers the order and the 400s. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 63 ++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index d56fad79..4994c135 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -143,9 +143,10 @@ alice._at.company.com.google.handles.link the chain that label names. A name without one is answered for the chain the Coin Type names. - REQ-ENS-NAME-04: - A name MUST be DNS wire format with every label 1 to 63 bytes. A label - containing an uppercase letter or a byte at or below `0x20` is not a name - any handle produces. + A name MUST be DNS wire format: labels of 1 to 63 bytes, each valid UTF-8, + ended by the root label with no bytes after it. A label containing an + uppercase letter or a byte at or below `0x20` is not a name any handle + produces; the Gateway answers it under REQ-ENS-GW-05. ## 6. Handle labels @@ -246,11 +247,15 @@ produce unchanged. suffix. A `{data}` that is not hex, not a `resolve` call, or not decodable MUST get HTTP 400. - REQ-ENS-GW-02: - The Gateway MUST answer HTTP 400 for a name outside its Parent Name, a name - violating REQ-ENS-NAME-04's wire rules, and a record call whose node is not - the namehash of the name. Necessity: these are malformed queries, not - names; and a node that differs from the name means the client and the - Gateway normalized differently, which must not be signed. + The Gateway MUST answer HTTP 400 for a name violating REQ-ENS-NAME-04's + wire rules, a label that is not UTF-8 among them, and for a name outside + its Parent Name, whatever the record. It MUST answer HTTP 400 for an `addr` + call whose node is not the namehash of the name. For any other record it + answers the signed empty result of REQ-ENS-GW-07 without comparing the + node. Necessity: the first are malformed queries, not names; and a node + that differs from the name means the client and the Gateway normalized + differently, which must not be signed as an address. The empty result + asserts nothing about the node. ### 8.2 Choosing the chain @@ -275,9 +280,9 @@ produce unchanged. binding a later Gateway in the list could serve. No libID binding is ever a non-EVM address, so that absence is known. - REQ-ENS-GW-05 (upholds SP-ENS-05): - Once a chain is selected, a name the Gateway cannot parse, a Handle Label - outside REQ-ENS-LABEL-05, and a Chain Label not naming the selected chain - MUST each get a Signed Null. + Once a chain is selected, a name the Gateway cannot parse, Handle Labels + the inverse of REQ-ENS-LABEL-05 refuses, and a Chain Label not naming the + selected chain MUST each get a Signed Null. - REQ-ENS-GW-06 (upholds SP-ENS-03): Before answering from a chain, the Gateway MUST return a Refusal (HTTP 503) when that chain's index is more than the deployment's maximum lag behind, @@ -316,6 +321,35 @@ produce unchanged. from the wallet's page; without the header the page never sees the answer. The answers are public and signed, and no credentials are sent. +### 8.4 Order + +- REQ-ENS-GW-11 (upholds SP-ENS-03): + The Gateway MUST take these steps in order, and the first that answers + ends the request: + + 1. `{sender}` (REQ-ENS-GW-08) and `{data}` (REQ-ENS-GW-01): HTTP 400. + 2. The name's wire rules and Parent Name (REQ-ENS-GW-02): HTTP 400. + 3. A record other than `addr`: the signed empty result. + 4. An `addr` node that is not the name's namehash: HTTP 400. + 5. Choosing the chain (REQ-ENS-GW-03, REQ-ENS-GW-04): a Refusal, or a + Signed Null for a non-EVM Coin Type. + 6. The Signed Nulls of REQ-ENS-GW-05. + 7. Staleness (REQ-ENS-GW-06): a Refusal. + 8. The handle rules of REQ-ENS-LABEL-05: a Signed Null. + 9. The holder (REQ-ENS-GW-07). + + Necessity: a chain the Gateway does not serve keeps its Refusal, so the + client walks on to a Gateway that may read the name; and no step before + staleness reads a binding. + + Consequence: a record other than `addr` gets its signed empty result + whatever the Coin Type and however stale the index, and the Signed Nulls of + REQ-ENS-GW-05 are signed while the selected chain's index is stale or its + report has expired or is unknown. Those answers depend on the name, the + Coin Type and the Chain Labels the Indexed Store holds when asked, never on + a binding. A handle the rules refuse, and every holder, is answered only + from an index that passes REQ-ENS-GW-06. + ## 9. Keys - REQ-ENS-KEY-01: @@ -365,12 +399,17 @@ produce unchanged. A name resolves through the ENS Universal Resolver on the ENS Chain, with the batch gateway played by the test. The same name fails with CCIP-Read disabled on the client, which shows the answer came offchain. - - TEST-ENS-06 (exercises REQ-ENS-NAME-02, REQ-ENS-GW-02): With the Parent Name `testnet.handles.link`, `alice.x.testnet.handles.link` reads as the X handle `alice` with no Chain Label, `alice.x.sepolia.testnet.handles.link` carries the Chain Label `sepolia`, and `alice.x.handles.link` gets HTTP 400. +- TEST-ENS-07 (exercises REQ-ENS-NAME-04, REQ-ENS-GW-02, REQ-ENS-GW-11): + A name with a label that is not UTF-8, and an `addr` call whose node is + not the name's namehash, each get HTTP 400; a `text` call whose node is not + the name's namehash gets the signed empty result; with the selected + chain's index stale, a mismatched Chain Label gets a Signed Null and a + readable handle gets a Refusal. ## 11. Security Considerations From a35d2f760897273df57c5c1ae1410a6fbb4c8a65 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:47:19 +0300 Subject: [PATCH 09/17] docs(specs): pin the signature encoding and the URL placeholders REQ-ENS-RES-03 names the encoding OpenZeppelin ECDSA.recover accepts and the other ways a malformed answer reverts; REQ-ENS-GW-08 requires the Gateway to sign the digest unprefixed in that encoding. REQ-ENS-RES-05 requires every resolver URL to carry {sender} and {data}, since a URL without {data} is fetched by POST and the Gateway serves GET only. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 4994c135..235eac38 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -226,8 +226,14 @@ produce unchanged. ‖ keccak256(extraData) ‖ keccak256(result)) ``` - where `resolver` is the Handle Resolver's 20-byte address. It reverts - `SignatureExpired`, `DeadlineTooFar`, or `UntrustedSigner` otherwise. + where `resolver` is the Handle Resolver's 20-byte address. `signature` is + the encoding OpenZeppelin `ECDSA.recover` accepts: 65 bytes `r ‖ s ‖ v`, + with `v` 27 or 28 and `s` at most half the secp256k1 group order. It + reverts `SignatureExpired`, `DeadlineTooFar`, or `UntrustedSigner` when a + check fails. A malformed answer reverts too: a `response` that does not + decode reverts in `abi.decode`, and a `signature` outside that encoding + reverts `ECDSAInvalidSignatureLength`, `ECDSAInvalidSignatureS`, or + `ECDSAInvalidSignature`. A client treats any revert as a rejected answer. Necessity: the resolver address binds the answer to one resolver, the request hash to one query, the result hash to one answer, and the ceiling stops a leaked answer living indefinitely. @@ -236,6 +242,11 @@ produce unchanged. Ownership MUST transfer in two steps and MUST NOT be renounceable. The Handle Resolver is not upgradeable; it is replaced by setting another resolver on the Parent Name. +- REQ-ENS-RES-05: + Every URL in the Handle Resolver's list MUST contain both `{sender}` and + `{data}`, in the `{base}/{sender}/{data}` form of REQ-ENS-GW-01. + Necessity: an ERC-3668 client sends a POST to a URL without `{data}`, and + the Gateway serves GET only, so such a URL answers nothing. ## 8. Gateway @@ -305,7 +316,9 @@ produce unchanged. - REQ-ENS-GW-08 (upholds SP-ENS-01): The Gateway MUST sign the REQ-ENS-RES-03 digest with `resolver` set to the Handle Resolver address it is configured to serve, never the `{sender}` of - the request, and with `expires` at most 3300 seconds after signing. It MAY + the request, and with `expires` at most 3300 seconds after signing. It + MUST sign the digest itself, with no further prefix, and encode the + signature as REQ-ENS-RES-03 requires. It MAY refuse a `{sender}` that is not that address with HTTP 400, comparing addresses case-insensitively. Necessity: on the Universal Resolver's direct-call route `{sender}` names the Universal Resolver, and the 300 s @@ -383,7 +396,8 @@ produce unchanged. - TEST-ENS-02 (exercises REQ-ENS-RES-03, REQ-ENS-GW-08): One digest vector reproduces in the Handle Resolver and the Gateway; the callback rejects an expired answer, one past the ceiling, an untrusted - Signer, and an answer signed for another resolver address. + Signer, an answer signed for another resolver address, a 64-byte + signature, a signature with `v` 0 or 1, and one with a high `s`. - TEST-ENS-03 (exercises REQ-ENS-GW-03 to -06): An unindexed EVM Coin Type, coin type 60 without the ENS Chain indexed, two chains sharing one Coin Type, and a stale index each get a Refusal; a From bdf9a84b2492a7dc69d8a05dd2bcc5db5e4afd19 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:47:33 +0300 Subject: [PATCH 10/17] docs(specs): say what is implemented and where the keys fall short The current deployment holds the Handle Resolver's owner, the deployer and the Parent Name's owner as one key, so it does not meet REQ-ENS-KEY-01; Security Considerations say so. Provenance records the forward transform in @libid/ens (libID PR #109, unmerged) and what it lacks, the unimplemented SHOULD of REQ-ENS-LABEL-05, where REQ-ENS-RES-05 is enforced, and the new REQ-ENS-GW-11. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 235eac38..1507717c 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -432,6 +432,11 @@ produce unchanged. transaction on each chain whose resolver trusts it. The callback checks the Signer when it runs, so the answers it signed stop verifying from that block on. +- The current deployment does not meet REQ-ENS-KEY-01. One key, the KMS + key the contracts' deploy workflow signs with, deploys the Handle + Resolver, owns it, and owns the Parent Name in the ENS registry. Whoever + can use that key can trust a Signer of their own and point the URL list at + their own endpoint, with no Signer key involved. - One Signer may serve Handle Resolvers on several chains. The digest covers no chain ID, but it covers the resolver's address, and REQ-ENS-KEY-02 keeps those apart, so an answer verifies only at the resolver it was signed for. @@ -477,9 +482,12 @@ produce unchanged. | Requirement | Source | |---|---| | REQ-ENS-RES-01 to RES-04 | `libid-contracts` `solidity/contracts/ens/HandleResolver.sol` | -| REQ-ENS-NAME-01 to NAME-04, REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs` | -| REQ-ENS-GW-01 to GW-10 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs` | -| REQ-ENS-LABEL-01 to LABEL-04 | no implementation yet | +| REQ-ENS-RES-05 | `libid-contracts` `scripts/setup-ens-resolver.sh` refuses a URL without both placeholders; the contract does not check | +| REQ-ENS-NAME-01 to NAME-04 | `usernames-indexer` `crates/usernames-core/src/ens.rs` | +| REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform for the Parent Name `handles.link` only; it does not yet refuse a Workspace label with `--` at the third and fourth characters | +| REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs`, `crates/usernames-core/src/nodes.rs`: the MUST; the SHOULD is not implemented (§11) | +| REQ-ENS-GW-01 to GW-11 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs`; the TTL ceiling of REQ-ENS-GW-08 in `bin/usernames-api/src/lib.rs` | +| REQ-ENS-KEY-01 | not met by the current deployment (§11) | ## 14. References From 13f87027a634c8f0bf4eefc31b3c406bdb0f8368 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:48:17 +0300 Subject: [PATCH 11/17] docs(specs): place the staleness check where the order puts it REQ-ENS-GW-06 applies before the handle rules and the holder, not before every answer from the selected chain, matching REQ-ENS-GW-11. Reflows two paragraphs. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 1507717c..761ea731 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -284,9 +284,9 @@ produce unchanged. EVM chain when it is below 2^64 and is `60` or has bit 31 set; every Coin Type at or above 2^64 is non-EVM, and no indexed chain matches one, because the Indexed Store holds chain IDs below 2^64 only. With two or more matches - the Gateway MUST return a Refusal (HTTP 503). Necessity: - the resolver has one URL list for every query, and ERC-3668 has the client - walk it until one succeeds. A Signed Null is a success that ends the walk, + the Gateway MUST return a Refusal (HTTP 503). Necessity: the resolver has + one URL list for every query, and ERC-3668 has the client walk it until + one succeeds. A Signed Null is a success that ends the walk, so signing absence for a chain this Gateway does not hold would deny a binding a later Gateway in the list could serve. No libID binding is ever a non-EVM address, so that absence is known. @@ -295,9 +295,10 @@ produce unchanged. the inverse of REQ-ENS-LABEL-05 refuses, and a Chain Label not naming the selected chain MUST each get a Signed Null. - REQ-ENS-GW-06 (upholds SP-ENS-03): - Before answering from a chain, the Gateway MUST return a Refusal (HTTP 503) - when that chain's index is more than the deployment's maximum lag behind, - or its indexer report has expired or is unknown. Necessity: a stale index + Before applying the handle rules or reading a holder, the Gateway MUST + return a Refusal (HTTP 503) when the selected chain's index is more than + the deployment's maximum lag behind, or its indexer report has expired or + is unknown. Necessity: a stale index can deny a binding that already exists. ### 8.3 Answers @@ -318,9 +319,8 @@ produce unchanged. Handle Resolver address it is configured to serve, never the `{sender}` of the request, and with `expires` at most 3300 seconds after signing. It MUST sign the digest itself, with no further prefix, and encode the - signature as REQ-ENS-RES-03 requires. It MAY - refuse a `{sender}` that is not that address with HTTP 400, comparing - addresses case-insensitively. Necessity: on the Universal Resolver's + signature as REQ-ENS-RES-03 requires. It MAY refuse a `{sender}` that is + not that address with HTTP 400, comparing addresses case-insensitively. Necessity: on the Universal Resolver's direct-call route `{sender}` names the Universal Resolver, and the 300 s below the resolver's ceiling absorbs clock skew between Gateway and chain. - REQ-ENS-GW-09: From 91215f6ea2bb0d700a0ec5090cf6f4625cea2821 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:51:01 +0300 Subject: [PATCH 12/17] docs(specs): tighten wording on uppercase labels and the inverse An uppercase label reaches REQ-ENS-GW-05 only for an addr call. Drops a TEST-ENS-01 vector no normalized handle can produce: every platform's rules keep a handle under 63 bytes. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 761ea731..adc75771 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -146,7 +146,7 @@ alice._at.company.com.google.handles.link A name MUST be DNS wire format: labels of 1 to 63 bytes, each valid UTF-8, ended by the root label with no bytes after it. A label containing an uppercase letter or a byte at or below `0x20` is not a name any handle - produces; the Gateway answers it under REQ-ENS-GW-05. + produces; the Gateway answers an `addr` call for it under REQ-ENS-GW-05. ## 6. Handle labels @@ -292,7 +292,7 @@ produce unchanged. a non-EVM address, so that absence is known. - REQ-ENS-GW-05 (upholds SP-ENS-05): Once a chain is selected, a name the Gateway cannot parse, Handle Labels - the inverse of REQ-ENS-LABEL-05 refuses, and a Chain Label not naming the + the inverse in REQ-ENS-LABEL-05 refuses, and a Chain Label not naming the selected chain MUST each get a Signed Null. - REQ-ENS-GW-06 (upholds SP-ENS-03): Before applying the handle rules or reading a holder, the Gateway MUST @@ -391,8 +391,7 @@ produce unchanged. Forward and inverse vectors for each platform round-trip; `a__b` maps to `a--b` and `ab__cd` is refused; a Gmail local part with `+`, `-` or `_` is refused; `a.b@c.com` and `a@b.c.com` give different names; - `ab--x@company.com` and `alice@xn--bcher-kva.example` have no name; a - handle giving a 64-byte label has no name. + `ab--x@company.com` and `alice@xn--bcher-kva.example` have no name. - TEST-ENS-02 (exercises REQ-ENS-RES-03, REQ-ENS-GW-08): One digest vector reproduces in the Handle Resolver and the Gateway; the callback rejects an expired answer, one past the ceiling, an untrusted @@ -484,7 +483,7 @@ produce unchanged. | REQ-ENS-RES-01 to RES-04 | `libid-contracts` `solidity/contracts/ens/HandleResolver.sol` | | REQ-ENS-RES-05 | `libid-contracts` `scripts/setup-ens-resolver.sh` refuses a URL without both placeholders; the contract does not check | | REQ-ENS-NAME-01 to NAME-04 | `usernames-indexer` `crates/usernames-core/src/ens.rs` | -| REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform for the Parent Name `handles.link` only; it does not yet refuse a Workspace label with `--` at the third and fourth characters | +| REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform for the Parent Name `handles.link` only; it does not refuse a Workspace label with `--` at the third and fourth characters | | REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs`, `crates/usernames-core/src/nodes.rs`: the MUST; the SHOULD is not implemented (§11) | | REQ-ENS-GW-01 to GW-11 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs`; the TTL ceiling of REQ-ENS-GW-08 in `bin/usernames-api/src/lib.rs` | | REQ-ENS-KEY-01 | not met by the current deployment (§11) | From 0e34ba1a18450025b09a8f69034a665ce700acb4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:29:47 +0300 Subject: [PATCH 13/17] docs(specs): refuse X and GitHub handles a widened rule would break An X handle containing '-' has no name, since the '_' to '-' substitution reverses only while no handle holds '-'; a GitHub handle containing '_' or '--' at its third and fourth characters has no name. Both match @libid/ens. A Chain Label is at most 63 bytes and has no '--' at its third and fourth characters; Provenance records that the indexer's ChainName::parse does not check either. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index adc75771..c075a9d8 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -130,8 +130,10 @@ alice._at.company.com.google.handles.link - REQ-ENS-NAME-01 (upholds SP-ENS-04): Platform Labels and Chain Labels MUST be disjoint sets. A Chain Label MUST - match `[a-z0-9-]+`. Necessity: the parse below tells the two apart only by - membership. + match `[a-z0-9-]+`, be at most 63 bytes, and not have `-` as both its third + and fourth characters. Necessity: the parse below tells the two apart only + by membership, and a Chain Label outside DNS or ENSIP-15 is one no client + sends. - REQ-ENS-NAME-02 (upholds SP-ENS-04): A name MUST be parsed right to left. After the Parent Name's labels, the last label is the Platform Label if it is one; otherwise it is the Chain Label and the @@ -164,14 +166,19 @@ produce unchanged. - REQ-ENS-LABEL-01 (X): The Handle Labels of an X handle are one label: the handle with every `_` - replaced by `-`. The transform MUST refuse a handle whose third and fourth - characters are both `_`. Necessity: X issues no `-`, so the substitution - reverses exactly; the refusal is ENSIP-15's `/^..--/` rule and nothing - more. + replaced by `-`. The transform MUST refuse a handle containing `-`, and a + handle whose third and fourth characters are both `_`. Necessity: the + substitution reverses exactly only while no handle holds `-`. X issues + none, but a chain's rules can be widened to admit it, and the refusal keeps + every name reversible then; the second refusal is ENSIP-15's `/^..--/` + rule. - REQ-ENS-LABEL-02 (GitHub): The Handle Labels of a GitHub handle are one label: the handle unchanged. - Necessity: GitHub's rules admit neither `_` nor a doubled `-`, so no - ENSIP-15 rule can fire. + The transform MUST refuse a handle containing `_`, and a handle whose + third and fourth characters are both `-`. Necessity: GitHub issues + neither, so no ENSIP-15 rule fires on a GitHub handle; a chain's rules can + be widened to admit them, and the refusals keep every name within ENSIP-15 + then. - REQ-ENS-LABEL-03 (Gmail): For a Google handle whose domain is `gmail.com`, the Handle Labels are the local part split at every `.`. The transform MUST refuse a local part not @@ -390,7 +397,8 @@ produce unchanged. - TEST-ENS-01 (exercises REQ-ENS-LABEL-01 to -05): Forward and inverse vectors for each platform round-trip; `a__b` maps to `a--b` and `ab__cd` is refused; a Gmail local part with `+`, `-` or `_` is - refused; `a.b@c.com` and `a@b.c.com` give different names; + refused; the X handle `a-b`, and the GitHub handles `a_b` and `ab--c`, + have no name; `a.b@c.com` and `a@b.c.com` give different names; `ab--x@company.com` and `alice@xn--bcher-kva.example` have no name. - TEST-ENS-02 (exercises REQ-ENS-RES-03, REQ-ENS-GW-08): One digest vector reproduces in the Handle Resolver and the Gateway; the @@ -482,7 +490,7 @@ produce unchanged. |---|---| | REQ-ENS-RES-01 to RES-04 | `libid-contracts` `solidity/contracts/ens/HandleResolver.sol` | | REQ-ENS-RES-05 | `libid-contracts` `scripts/setup-ens-resolver.sh` refuses a URL without both placeholders; the contract does not check | -| REQ-ENS-NAME-01 to NAME-04 | `usernames-indexer` `crates/usernames-core/src/ens.rs` | +| REQ-ENS-NAME-01 to NAME-04 | `usernames-indexer` `crates/usernames-core/src/ens.rs`; its `ChainName::parse` accepts a Chain Label longer than 63 bytes or with `--` at the third and fourth characters, which REQ-ENS-NAME-01 refuses | | REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform for the Parent Name `handles.link` only; it does not refuse a Workspace label with `--` at the third and fourth characters | | REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs`, `crates/usernames-core/src/nodes.rs`: the MUST; the SHOULD is not implemented (§11) | | REQ-ENS-GW-01 to GW-11 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs`; the TTL ceiling of REQ-ENS-GW-08 in `bin/usernames-api/src/lib.rs` | From a7965ad4753647b406559609683aa438988812ad Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:30:22 +0300 Subject: [PATCH 14/17] docs(specs): state the deployment rules a Gateway depends on REQ-ENS-NAME-05 keeps production Chain Labels off the first label of a deployment under handles.link, such as testnet. REQ-ENS-GW-12 requires the ENS Chain to be indexed for coin type 60 to answer, and REQ-ENS-GW-13 requires Gateways in one URL list to map Chain Labels alike, since an unmapped label gets a Signed Null. ASM-ENS-01 no longer claims a client never receives another chain's address; Security Considerations describe MetaMask's fallback to addr(bytes32) and how a Chain Label defeats it. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 43 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index c075a9d8..2240f918 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -70,8 +70,8 @@ Refusal: An unsigned HTTP error. It asserts nothing about any binding. - ASM-ENS-01: A client resolving a name implements ENSIP-10 wildcard resolution, ERC-3668 CCIP-Read, and ENSIP-11 chain address resolution. A client lacking one of - them reports an error or asks only for coin type 60; it never receives an - address for a chain it did not ask about. + them reports an error or asks only for coin type 60. A client may also + fall back from one Coin Type to another (§11). - ASM-ENS-02: A client applies ENSIP-15 normalization before hashing a name, so the Handle Resolver and the Gateway receive normalized labels. @@ -149,6 +149,14 @@ alice._at.company.com.google.handles.link ended by the root label with no bytes after it. A label containing an uppercase letter or a byte at or below `0x20` is not a name any handle produces; the Gateway answers an `addr` call for it under REQ-ENS-GW-05. +- REQ-ENS-NAME-05: + A Chain Label of the production deployment MUST NOT equal the first label + of another deployment's Parent Name under `handles.link`, such as + `testnet`. Necessity: ENSIP-10 resolves a name through its nearest + ancestor that has a resolver, so where `testnet.handles.link` has its own + resolver, `alice.x.testnet.handles.link` reaches that deployment; a + production Chain Label `testnet` would be unreachable there and would + read as that deployment's names. ## 6. Handle labels @@ -308,6 +316,19 @@ produce unchanged. is unknown. Necessity: a stale index can deny a binding that already exists. +- REQ-ENS-GW-12: + A deployment that answers Coin Type `60` MUST index the ENS Chain, which + is then a Consumer Chain. Necessity: `60` matches only the ENS Chain + (REQ-ENS-GW-03), so a Gateway that does not index it returns a Refusal for + every `60` query, and `addr(bytes32)`, the default query of most clients, + asks with `60`. +- REQ-ENS-GW-13 (upholds SP-ENS-03): + The Gateways in one Handle Resolver's URL list MUST map the same Chain + Labels to the same chains. Necessity: a Gateway answers a Chain Label its + Indexed Store does not map with a Signed Null (REQ-ENS-GW-05), which ends + the client's walk; a Gateway missing a label that another in the list maps + to the selected chain would deny a binding the other could serve. + ### 8.3 Answers - REQ-ENS-GW-07 (upholds SP-ENS-02): @@ -471,6 +492,23 @@ produce unchanged. would be deciding rather than reporting; this specification does not. - A binding on a chain means funds sent to the name on that chain reach its holder. A Workspace name publishes the employer's domain. +- A Gateway signs a Signed Null for a Chain Label its Indexed Store does not + map, whether or not another Gateway in the URL list maps it. REQ-ENS-GW-13 + is a deployment rule; nothing checks it, and a Gateway that breaks it + denies bindings under that label. +- A deployment that does not index the ENS Chain answers every Coin Type + `60` query, including every `addr(bytes32)`, with a Refusal + (REQ-ENS-GW-12). Its names resolve only for clients that ask with a + chain's ENSIP-11 Coin Type. +- MetaMask's ENS resolution (`@metamask/ens-resolver-snap`) was observed to + ask with the current chain's Coin Type first and, when that answer is + empty, to ask `addr(bytes32)` (Coin Type `60`, the ENS Chain), using that + address unless it has code on the current chain. A name without a Chain + Label can therefore give the ENS Chain's holder for a payment on a chain + where the handle is unbound or bound to someone else. A name with a Chain + Label naming a chain other than the ENS Chain defeats the fallback: the + `60` query names the ENS Chain, the label names another, and the answer is + a Signed Null. - The chain a transaction is finally sent on is the client's choice. A Chain Label cannot make a wallet send on that chain; it makes the Gateway withhold the address when the Coin Type disagrees. @@ -494,6 +532,7 @@ produce unchanged. | REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform for the Parent Name `handles.link` only; it does not refuse a Workspace label with `--` at the third and fourth characters | | REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs`, `crates/usernames-core/src/nodes.rs`: the MUST; the SHOULD is not implemented (§11) | | REQ-ENS-GW-01 to GW-11 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs`; the TTL ceiling of REQ-ENS-GW-08 in `bin/usernames-api/src/lib.rs` | +| REQ-ENS-NAME-05, REQ-ENS-GW-12, REQ-ENS-GW-13 | deployment rules; nothing enforces them. `usernames-indexer` `bin/usernames-api/src/ens.rs` documents REQ-ENS-GW-12 | | REQ-ENS-KEY-01 | not met by the current deployment (§11) | ## 14. References From f1c01a8550d41c992ebf8a1981082c4e631bd66a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:30:38 +0300 Subject: [PATCH 15/17] docs(specs): test the resolver, the request parsing and the deployment rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST-ENS-08 to -12 cover the interface set, the OffchainLookup shape and NoUrls, two-step non-renounceable ownership, the URL placeholders, and {data} parsing. TEST-ENS-13 is a deployment check for REQ-ENS-KEY-02 and the Chain Label, ENS Chain and shared-label rules. The ERC-7996 rationale of REQ-ENS-RES-01 rests on the deployed Gateway refusing a mismatched {sender}, which REQ-ENS-GW-08 permits. Step 3 of REQ-ENS-GW-11 includes an addr call whose arguments do not decode. §4 points at §11. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 42 ++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 2240f918..752509cb 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -110,7 +110,7 @@ Refusal: An unsigned HTTP error. It asserts nothing about any binding. Type's answer. These properties do not survive compromise of a Signer key, which can answer -with any address (§10). +with any address (§11). ## 5. Names @@ -221,9 +221,11 @@ produce unchanged. The Handle Resolver MUST be the resolver of the Parent Name and MUST hold no per-name state. It MUST answer `supportsInterface` true for ENSIP-10 `IExtendedResolver` (`0x9061b923`) and ERC-165 (`0x01ffc9a7`) only. - Necessity: announcing ERC-7996 makes the ENS Universal Resolver raise the - lookup under its own address (ENSIP-22), which the Gateway refuses under - REQ-ENS-GW-08. + Necessity: announcing ERC-7996 lets the ENS Universal Resolver call the + resolver directly and raise the lookup under its own address (ENSIP-22), + so `{sender}` names the Universal Resolver. REQ-ENS-GW-08 permits a + Gateway to refuse that `{sender}` with HTTP 400, the deployed Gateway does + (step 1 of REQ-ENS-GW-11), and a 4xx ends the client's lookup. - REQ-ENS-RES-02: `resolve(bytes name, bytes data)` MUST revert with ERC-3668 `OffchainLookup(address(this), urls, callData, resolveWithProof.selector, @@ -370,7 +372,8 @@ produce unchanged. 1. `{sender}` (REQ-ENS-GW-08) and `{data}` (REQ-ENS-GW-01): HTTP 400. 2. The name's wire rules and Parent Name (REQ-ENS-GW-02): HTTP 400. - 3. A record other than `addr`: the signed empty result. + 3. A record other than `addr`, or an `addr` call whose arguments do not + decode: the signed empty result. 4. An `addr` node that is not the name's namehash: HTTP 400. 5. Choosing the chain (REQ-ENS-GW-03, REQ-ENS-GW-04): a Refusal, or a Signed Null for a non-EVM Coin Type. @@ -383,7 +386,8 @@ produce unchanged. client walks on to a Gateway that may read the name; and no step before staleness reads a binding. - Consequence: a record other than `addr` gets its signed empty result + Consequence: a record other than `addr`, and an `addr` call whose + arguments do not decode, get the signed empty result whatever the Coin Type and however stale the index, and the Signed Nulls of REQ-ENS-GW-05 are signed while the selected chain's index is stale or its report has expired or is unknown. Those answers depend on the name, the @@ -452,6 +456,32 @@ produce unchanged. the name's namehash gets the signed empty result; with the selected chain's index stale, a mismatched Chain Label gets a Signed Null and a readable handle gets a Refusal. +- TEST-ENS-08 (exercises REQ-ENS-RES-01): + `supportsInterface` is true for `0x9061b923` and `0x01ffc9a7`, and false + for ERC-7996 (`0x582de3e7`), `0xffffffff`, and any other identifier. +- TEST-ENS-09 (exercises REQ-ENS-RES-02): + `resolve` reverts `OffchainLookup` whose sender is the Handle Resolver, + whose URL list is the stored one, whose `callData` and `extraData` both + equal the ABI-encoded `resolve(name, data)` call, and whose callback is + the `resolveWithProof` selector; with the URL list empty it reverts + `NoUrls()`. +- TEST-ENS-10 (exercises REQ-ENS-RES-04): + A non-owner calling `setUrls` or `setSigner` reverts; ownership moves only + when the new owner accepts it; `renounceOwnership` reverts. +- TEST-ENS-11 (exercises REQ-ENS-RES-05): + Every deploy path refuses a URL lacking `{sender}` or `{data}`, and every + URL a deployed Handle Resolver stores carries both. +- TEST-ENS-12 (exercises REQ-ENS-GW-01): + One `{data}` with and without `0x`, and with and without `.json`, gets one + answer; a `{data}` that is not hex, a call that is not `resolve`, and + `resolve` arguments that do not decode each get HTTP 400. +- TEST-ENS-13 (exercises REQ-ENS-KEY-02, REQ-ENS-NAME-05, REQ-ENS-GW-12, + REQ-ENS-GW-13): + A deployment check over every Handle Resolver and Gateway of a deployment + finds that resolvers trusting one Signer have different addresses, that + no production Chain Label is `testnet` or another deployment's first + label, that the ENS Chain is indexed, and that the Gateways in one URL + list map the same Chain Labels to the same chains. ## 11. Security Considerations From 3dcc5938b86dcdb1a8a13c2960d4d5d3b0f36e33 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:31:12 +0300 Subject: [PATCH 16/17] docs(specs): hold the owner key cold and record what enforces the keys REQ-ENS-KEY-01 requires the owner key to be held cold, so the online KMS deployer key that owns the current deployment visibly fails it. Its necessity no longer says a URL buys nothing: a URL holder can serve a signed answer until it expires and end lookups, which Security Considerations now describe. Provenance records that nothing enforces REQ-ENS-KEY-02 and that @libid/ens carries the refusals and a parent option. libid.md names the Parent Name as a deployment parameter and lists the ENS principals and trust roots. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 33 ++++++++++++++++++++++----------- specs/libid.md | 13 ++++++++++--- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index 752509cb..f9f1d423 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -399,7 +399,9 @@ produce unchanged. - REQ-ENS-KEY-01: The DNS registrar account with the DNSSEC keys, the owner of the Parent - Name and the Handle Resolver, and each Signer MUST be distinct keys. + Name and the Handle Resolver, and each Signer MUST be distinct keys. The + owner key MUST be held cold: offline, and brought online only to sign an + owner transaction. | key | grants | lives | |---|---|---| @@ -407,10 +409,11 @@ produce unchanged. | Parent Name and Handle Resolver owner | resolver, URL list, Signers | cold | | Signer | answers | Gateway host | - Necessity: a URL without a Signer key buys nothing, because the callback - rejects its answer; a Signer key answers anything; the DNS credentials take - the whole namespace. Held together, the least guarded one decides all - three. + Necessity: a URL without a Signer key cannot forge an answer, though it + can replay a signed answer until it expires and end lookups (§11); a + Signer key answers anything; the owner key replaces both; the DNS + credentials take the whole namespace. Held together, the least guarded + one decides all of them. - REQ-ENS-KEY-02 (upholds SP-ENS-01): Handle Resolvers that trust one Signer MUST sit at different addresses, whichever chains they are on. Necessity: the digest binds an answer to its @@ -490,11 +493,18 @@ produce unchanged. transaction on each chain whose resolver trusts it. The callback checks the Signer when it runs, so the answers it signed stop verifying from that block on. -- The current deployment does not meet REQ-ENS-KEY-01. One key, the KMS - key the contracts' deploy workflow signs with, deploys the Handle - Resolver, owns it, and owns the Parent Name in the ENS registry. Whoever - can use that key can trust a Signer of their own and point the URL list at - their own endpoint, with no Signer key involved. +- The current deployment does not meet REQ-ENS-KEY-01. In each + environment one AWS KMS key, which the contracts' deploy workflow signs + with from CI, deploys the Handle Resolver, owns it, and owns the Parent + Name in the ENS registry. That key is online, not cold. Whoever can use it + can trust a Signer of their own and point the URL list at their own + endpoint, with no Signer key involved. +- Whoever controls a host in the URL list, without a Signer key, cannot + forge an answer. It can serve any signed answer it has seen for the same + request until that answer expires, at most an hour (REQ-ENS-RES-03), so a + binding that moved can resolve to its previous holder for that long. It + can also end every lookup that reaches it with a 4xx, from which a client + does not walk on. Removing the URL is an owner transaction. - One Signer may serve Handle Resolvers on several chains. The digest covers no chain ID, but it covers the resolver's address, and REQ-ENS-KEY-02 keeps those apart, so an answer verifies only at the resolver it was signed for. @@ -559,11 +569,12 @@ produce unchanged. | REQ-ENS-RES-01 to RES-04 | `libid-contracts` `solidity/contracts/ens/HandleResolver.sol` | | REQ-ENS-RES-05 | `libid-contracts` `scripts/setup-ens-resolver.sh` refuses a URL without both placeholders; the contract does not check | | REQ-ENS-NAME-01 to NAME-04 | `usernames-indexer` `crates/usernames-core/src/ens.rs`; its `ChainName::parse` accepts a Chain Label longer than 63 bytes or with `--` at the third and fourth characters, which REQ-ENS-NAME-01 refuses | -| REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform for the Parent Name `handles.link` only; it does not refuse a Workspace label with `--` at the third and fourth characters | +| REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform with its refusals, and a `parent` option for the Parent Name | | REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs`, `crates/usernames-core/src/nodes.rs`: the MUST; the SHOULD is not implemented (§11) | | REQ-ENS-GW-01 to GW-11 | `usernames-indexer` `bin/usernames-api/src/ens.rs`, `crates/usernames-core/src/ens.rs`; the TTL ceiling of REQ-ENS-GW-08 in `bin/usernames-api/src/lib.rs` | | REQ-ENS-NAME-05, REQ-ENS-GW-12, REQ-ENS-GW-13 | deployment rules; nothing enforces them. `usernames-indexer` `bin/usernames-api/src/ens.rs` documents REQ-ENS-GW-12 | | REQ-ENS-KEY-01 | not met by the current deployment (§11) | +| REQ-ENS-KEY-02 | nothing enforces it: both deploy paths, `libid-contracts` `.github/workflows/deploy.yml` and `scripts/setup-ens-resolver.sh`, deploy with nonce-based `forge create`, and `deploy.yml` picks the deployer key per environment | ## 14. References diff --git a/specs/libid.md b/specs/libid.md index 022543ca..886aa604 100644 --- a/specs/libid.md +++ b/specs/libid.md @@ -48,7 +48,8 @@ the implementation documentation, not this specification. ## Names - [ENS integration](ens-integration.md) defines how a binding is read as an - ENS name under `handles.link`: the name grammar, the handle-to-label + ENS name under the Parent Name, a deployment parameter that is + `handles.link` in production: the name grammar, the handle-to-label transform, the Handle Resolver contract, and the Gateway that signs its answers. @@ -106,12 +107,18 @@ authenticates the Transaction Author and supplies its Chain ID and Block Time. | Identity-platform operator | authenticates accounts and issues signed or TLS-authenticated responses | the `ASM-PROV-*` behavior the selected profile cites | the proof-bound transaction or Transaction Author | | Notary operator | operates the X/GitHub attestation key and observes sessions | `ASM-NOTARY-01` | user intent or transaction authorization | | Verifier governance administrator | activates verifier artifacts, trust roots, and the Supported Version Set | correct authority lifecycle | user consent | +| ENS Gateway operator | holds the ENS Signer keys; reads the Indexed Store and signs ENS answers | answers that report the Indexed Store, and refusals where it cannot know | bindings, proofs, or anything a Consumer accepts | +| Handle Resolver and Parent Name owner | sets the Parent Name's resolver, the Handle Resolver's URL list, and its Signers | pointing ENS names at honest Gateways and Signers | bindings or proofs | +| DNS registrar account and DNSSEC key holder | controls `handles.link` in DNS and, through DNSSEC proofs, its owner in ENS | keeping the Parent Name with its owner | bindings or proofs | The principal trust roots are Google's active signing moduli, the active X/GitHub notary keys, the selected proof-verifier artifacts, the Proof Verifier that dispatches to them, the Platform Verifiers it selects, Verifier governance, -and Consumer Chain consensus. The Proof Verifier is the most concentrated of -these: every Consumer takes its accept-or-reject decision, operation domain, +and Consumer Chain consensus. For ENS names only, the ENS Signer keys, the +Handle Resolver and Parent Name owner key, and the DNS registrar account and +DNSSEC keys are trust roots as well: none of them reaches a binding, but each +decides where funds sent to a name go. The Proof Verifier is the most +concentrated of these: every Consumer takes its accept-or-reject decision, operation domain, and Authorized Transaction Data from that one component, so its compromise authorizes arbitrary transactions at every Consumer at once. A compromised Platform Verifier does the same for one platform and version, because it is the From 4d778353dc8a03414231253504629f35da539635 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:32:58 +0300 Subject: [PATCH 17/17] docs(specs): align SP-ENS-03 with its rules and describe the deploy keys SP-ENS-03 also depends on REQ-ENS-GW-13. The KEY-01 consideration describes what the deploy workflow does per environment, and the REQ-ENS-RES-05 Provenance row names the workflow's placeholder check beside the script's. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- specs/ens-integration.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/specs/ens-integration.md b/specs/ens-integration.md index f9f1d423..9256634e 100644 --- a/specs/ens-integration.md +++ b/specs/ens-integration.md @@ -101,7 +101,8 @@ Refusal: An unsigned HTTP error. It asserts nothing about any binding. The Gateway never signs absence it does not know: a chain it does not index, a Coin Type two indexed chains share, and an index too far behind each get a Refusal, never a Signed Null. Depends on the Gateway's handle - rules admitting every handle an indexed chain binds (§11). + rules admitting every handle an indexed chain binds (§11), and on + REQ-ENS-GW-13. - SP-ENS-04: No two handles reach one name, and a name carries its handle: the Gateway reads a name with no mapping table. @@ -493,10 +494,10 @@ produce unchanged. transaction on each chain whose resolver trusts it. The callback checks the Signer when it runs, so the answers it signed stop verifying from that block on. -- The current deployment does not meet REQ-ENS-KEY-01. In each - environment one AWS KMS key, which the contracts' deploy workflow signs - with from CI, deploys the Handle Resolver, owns it, and owns the Parent - Name in the ENS registry. That key is online, not cold. Whoever can use it +- The current deployment does not meet REQ-ENS-KEY-01. The contracts' + deploy workflow gives each environment one AWS KMS key that signs from + CI, deploys the Handle Resolver, owns it, and owns that environment's + Parent Name in the ENS registry. That key is online, not cold. Whoever can use it can trust a Signer of their own and point the URL list at their own endpoint, with no Signer key involved. - Whoever controls a host in the URL list, without a Signer key, cannot @@ -567,7 +568,7 @@ produce unchanged. | Requirement | Source | |---|---| | REQ-ENS-RES-01 to RES-04 | `libid-contracts` `solidity/contracts/ens/HandleResolver.sol` | -| REQ-ENS-RES-05 | `libid-contracts` `scripts/setup-ens-resolver.sh` refuses a URL without both placeholders; the contract does not check | +| REQ-ENS-RES-05 | `libid-contracts` `.github/workflows/deploy.yml` and `scripts/setup-ens-resolver.sh` refuse a URL without both placeholders; the contract does not check | | REQ-ENS-NAME-01 to NAME-04 | `usernames-indexer` `crates/usernames-core/src/ens.rs`; its `ChainName::parse` accepts a Chain Label longer than 63 bytes or with `--` at the third and fourth characters, which REQ-ENS-NAME-01 refuses | | REQ-ENS-LABEL-01 to LABEL-04 | `libID` `ts/packages/ens` (`@libid/ens`, libID PR #109, unmerged): the forward transform with its refusals, and a `parent` option for the Parent Name | | REQ-ENS-LABEL-05 | `usernames-indexer` `crates/usernames-core/src/ens.rs`, `crates/usernames-core/src/nodes.rs`: the MUST; the SHOULD is not implemented (§11) |