diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 13141dd40..85b7522a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,8 +306,8 @@ jobs: # Resolver test job # ============================= -# The SNRC resolver is Python and stdlib-only apart from keccak, so it needs -# none of the Haskell toolchain above and runs independently of it. +# The SNRC resolver is Python, so this job needs none of the Haskell toolchain +# above and runs independently of it. resolver-test: name: "resolver (python)" @@ -323,8 +323,7 @@ jobs: python-version: "3.13" - name: Install resolver dependencies - # The only runtime dependency; declared in - # scripts/resolver/service/pyproject.toml. + # Must match scripts/resolver/service/pyproject.toml. run: python -m pip install "eth-hash[pycryptodome]>=0.7" - name: Test diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index f9bacc17e..8f5907375 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -170,21 +170,19 @@ needs, because a subname is only as good as the name it sits under. ### Querying by labelhash -A client that asks whether a name is free is usually about to register it. -Whoever runs the resolver sees that question and could register the name first. -To avoid that, send the keccak hash of the label instead of the label itself, -written in ENS's `[<64 hex>]` form. The answer is the same: +A client asking whether a name is free is usually about to register it, and +whoever runs the resolver could register it first. To avoid that, send the +keccak hash of the label in ENS's `[<64 hex>]` form instead of the label: ```sh # instead of /resolve/acme.testing curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing" ``` -This works because namehash is `keccak(parent || keccak(label))`. Passing -`keccak(label)` gives the same node, so the resolver reads the same record. The -registrar keys `nameExpires` and `reservedNames` on the labelhash as well, so -the status fields do not need the label either. The resolver learns which name -you meant only if it guesses the label and hashes it. +namehash is `keccak(parent || keccak(label))`, so this reaches the same node and +returns the same record. The registrar keys `nameExpires` and `reservedNames` on +the labelhash too, so the status fields do not need the label either. The +resolver learns the name only by guessing the label and hashing it. Read the answer from `status`. A name is free only when the body says `unregistered`, which comes with a 404. Every other status means somebody holds @@ -192,33 +190,27 @@ the name or held it recently. Watch out for `noResolver`: it is also a 404, but the name is taken. The hash must be keccak-256. `openssl dgst -sha3-256` and `sha3sum` compute -SHA3-256, which is a different function. They return 64 valid-looking hex -characters that point at the wrong node. +SHA3-256, a different function that returns 64 valid-looking hex characters +pointing at the wrong node. The resolver lowercases the query before matching, so uppercase hex works too. -HTTP clients that refuse raw brackets in a path can percent-encode them as -`%5B` and `%5D`. Both forms reach the same name. +Clients that refuse raw brackets in a path can percent-encode them as `%5B` and +`%5D`. -Brackets keep the two forms from colliding. `[` and `]` are not valid in a -normalised ENS name, and the dApp normalises before it registers, so no name -registered through it can look like this. Nothing on chain checks the character -set, but a `[<64 hex>]` label is 66 bytes and the registrar's `maxLabelLength` -is 63, so it cannot be registered directly either. ENS uses this same encoding -for a label whose preimage it does not know. A plain `0x…` label would not work -here, because that is an ordinary name anyone can register. +Brackets cannot collide with a real name: they are invalid in a normalised ENS +name, and a `[<64 hex>]` label is 66 bytes against the registrar's +`maxLabelLength` of 63. A plain `0x…` label is not treated as a hash, since that +is an ordinary, registrable name. -Only 2LDs can be queried by hash. A 2LD is what a registration buys, so it is -the only name worth hiding. Subnames are left out because nobody can race you -for one: the owner of the 2LD creates them. In a subname the resolver hashes a -`[<64 hex>]` label as written instead of decoding it, so such a query points at -a node nobody can own. ENS tooling accepts the bracketed form at any depth; -this resolver does not, on purpose. +Only 2LDs can be queried this way, as only a 2LD can be raced for: subnames are +created by the 2LD's owner. A bracket label in a subname is hashed as written, +so it points at a node nobody can own. ENS tooling accepts the bracketed form at +any depth; this resolver does not, on purpose. -This hides your interest in a name, and nothing more. The registration itself -is public, and the controller's commit-reveal protects that step. The hash is -also easy to guess for a short or well-known label, since an operator can hash -candidate labels and compare. And once you register, the reveal publishes the -labelhash, so an operator who logged your query can match it to the name. +This hides interest in a name and nothing else: the registration itself is +public, and commit-reveal covers that step. A short or well-known label is easy +to guess by hashing candidates, and the reveal publishes the labelhash, so an +operator who logged the query can match it to the name afterwards. ### Errors diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index aaf0048fe..e0cb3061d 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -157,16 +157,8 @@ def namehash(name: str) -> bytes: return node -# ENS writes a label whose preimage it does not know as `[<64 hex>]`, and this -# resolver reuses that form for a label the caller withholds on purpose. -# Brackets keep the two forms from colliding: `[` and `]` are not valid in a -# normalised ENS name, and the dApp normalises before it registers. Nothing on -# chain checks the character set, but a bracketed labelhash is 66 bytes and the -# registrar's maxLabelLength is 63, so it cannot be registered directly either. -# The ecosystem already reads this form back as a hash (ensjs -# `isEncodedLabelhash`; the subgraph rejects any real label containing a -# bracket). A plain `0x…` label would not work: that is an ordinary name anyone -# can register. +# ENS's encoding for a label whose preimage is unknown. Brackets are outside +# the normalised character set, so it cannot collide with a registrable name. ENCODED_LABELHASH_LEN = 66 # "[" + 64 hex + "]" @@ -182,16 +174,11 @@ def is_encoded_labelhash(label: str) -> bool: def node_of(name: str) -> bytes: """namehash, accepting an encoded labelhash in place of a 2LD's label. - A client that asks whether a name is free is usually about to register it, - and whoever runs the resolver could register it first. namehash is - keccak(parent || keccak(label)), so passing keccak(label) reaches the same - node without sending the label. - - Only 2LDs can be queried this way. A 2LD is what a registration buys, so it - is the only name worth hiding. Subnames are left out because nobody can - race a caller for one: the owner of the 2LD creates them. In a subname a - `[<64 hex>]` label is hashed as written instead of decoded, so such a query - points at a node nobody can own. + keccak(parent || keccak(label)) reaches the same node without the label, + so a caller can check a 2LD without disclosing which one they are about to + register. Subnames are excluded - only the 2LD's owner creates them, so + there is nothing to front-run - and a bracket label there is hashed as + written. """ labels = name.split(".") if len(labels) == 2 and is_encoded_labelhash(labels[0]): diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index f06e77543..6c90afd03 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -86,18 +86,11 @@ class SplitLinksTests(unittest.TestCase): class EncodedLabelhashTests(unittest.TestCase): - """Querying by labelhash instead of by label. + """`node_of` accepts a 2LD's label as an encoded labelhash `[<64 hex>]`, + reaching the same node as the label itself.""" - A client that asks whether a name is free is usually about to register it, - and whoever runs the resolver could register it first. namehash is - keccak(parent || keccak(label)), so supplying keccak(label) gives the same - node and the same answer without sending the label. - - The encoding is ENS's own `[<64 hex>]`. It cannot collide with a real name, - because brackets are not valid in a normalised ENS name.""" - - # keccak-256("alice") = 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 - # - written out in full wherever a test needs a real labelhash. + # keccak-256("alice"), written out in full wherever a test needs it. + # 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501 REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" GRACE = 90 * 86400 @@ -125,9 +118,8 @@ class EncodedLabelhashTests(unittest.TestCase): def test_non_hex_between_the_brackets_is_not(self): self.assertFalse(snrc.is_encoded_labelhash("[" + "z" * 64 + "]")) - # uppercase hex is not it either: the handler lowercases the whole name + # uppercase is rejected because the handler lowercases the whole name self.assertFalse(snrc.is_encoded_labelhash("[" + "A" * 64 + "]")) - # explicitly disallowed prefix self.assertFalse(snrc.is_encoded_labelhash("[0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]")) def test_the_wrong_length_is_not(self): @@ -147,9 +139,6 @@ class EncodedLabelhashTests(unittest.TestCase): self.assertEqual(snrc.node_of("alice.testing"), snrc.namehash("alice.testing")) def test_an_encoded_subname_is_not_the_name_it_would_decode_to(self): - """Only 2LDs are queried by hash. If a label in `[<64 hex>]` form were - decoded in a subname, that subname would silently be the name the hash - stands for - here `alice.alice.testing`.""" self.assertNotEqual( snrc.node_of( "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" @@ -167,8 +156,6 @@ class EncodedLabelhashTests(unittest.TestCase): ) def test_a_0x_prefixed_label_is_taken_literally(self): - """`0x<64 hex>` is a registrable name, not a hash. Only the bracket - form is read as a labelhash.""" name = "0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501.testing" self.assertEqual(snrc.node_of(name), snrc.namehash(name)) self.assertNotEqual(snrc.node_of(name), snrc.node_of("alice.testing"))