merge ab/resolver-labelhash-query

# Conflicts:
#	scripts/resolver/README.md
#	scripts/resolver/service/snrc-resolve.py
#	scripts/resolver/service/test_snrc_resolve.py
This commit is contained in:
sh
2026-09-04 14:52:37 +00:00
4 changed files with 38 additions and 73 deletions
+23 -31
View File
@@ -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
+7 -20
View File
@@ -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]):
+5 -18
View File
@@ -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"))