diff --git a/design/ens-integration.md b/design/ens-integration.md deleted file mode 100644 index 66bbef93..00000000 --- a/design/ens-integration.md +++ /dev/null @@ -1,615 +0,0 @@ -# ENS integration - -**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..9256634e --- /dev/null +++ b/specs/ens-integration.md @@ -0,0 +1,598 @@ +--- +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: 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 + 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. + +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 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. + +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. 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. +- 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 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 + +- 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'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. +- 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. Depends on the Gateway's handle + 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. +- 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 (§11). + +## 5. Names + +```text +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 +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-]+`, 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 + 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: 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 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 + +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. + +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 `_` + 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. + 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 + 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-]+`, 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 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. + +## 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 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, + 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. `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. +- 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. +- 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 + +### 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 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 + +- 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 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; 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, 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 + 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, Handle Labels + 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 + 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. + +- 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): + 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 + 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 + 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. + +### 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`, 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. + 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`, 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 + 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: + 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. The + owner key MUST be held cold: offline, and brought online only to sign an + owner transaction. + + | 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 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 + resolver's address and not to a chain, so one Signer may serve resolvers + on several chains only while their addresses differ. + +## 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; 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 + callback rejects an expired answer, one past the ceiling, an untrusted + 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 + 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 + `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. +- 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. +- 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 + +- A compromised Signer key can answer with any address, and for a system + 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. +- 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 + 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. +- 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. +- 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. +- 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. + +## 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-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) | +| 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 + +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 217c0b5d..886aa604 100644 --- a/specs/libid.md +++ b/specs/libid.md @@ -45,6 +45,14 @@ These chapters are normative browser/service boundaries. TypeScript APIs, build tooling, UI projections, dependency pins, and qualification evidence belong to the implementation documentation, not this specification. +## Names + +- [ENS integration](ens-integration.md) defines how a binding is read as an + 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. + ## System model and specification ownership libID turns an identity-platform authorization into a proof that a Consumer @@ -99,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 @@ -127,6 +141,7 @@ root and verifier. | Public ceremony configuration and callback ingress | [OAuth Bridge](oauth-bridge.md) | | Static response policies, aggregate Callback artifact, immutable asset publication | [CCDP Distribution](ccdp-distribution.md) | | Package APIs, UI projections, build tooling, and qualification evidence | implementation documentation (non-normative) | +| ENS names, the handle-to-label transform, the Handle Resolver, and the Gateway | [ENS integration](ens-integration.md) | | Transaction dispatch and author authentication | Consumer protocol | | Verification dispatch, replay recording, trust roots, and version governance | [Common ceremony rules](ceremony-common.md) |