Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,31 @@ jobs:
echo "All "$attempts" attempts failed."
exit 1
fi

# =============================
# 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.

resolver-test:
name: "resolver (python)"
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v3

- name: Set up Python
# Matches the runtime stage of scripts/resolver/service/Dockerfile.
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install resolver dependencies
# The only runtime dependency; declared in
# scripts/resolver/service/pyproject.toml.
run: python -m pip install "eth-hash[pycryptodome]>=0.7"

- name: Test
run: python -m unittest discover -s scripts/resolver/service -v
33 changes: 33 additions & 0 deletions scripts/resolver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,39 @@ text record; the resolver splits/trims/drops-empties. Address encodings are
canonical per chain (EIP-55 / bech32 / SS58 / Monero-base58). Subnames work
identically (`bar.foobar.testing`).

### 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:

```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. It
learns which name you meant only if it guesses the label and hashes it.

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.

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.

This hides your interest in a name, and nothing more. The registration itself
is public, and the controller's commit-reveal protects that step.

### Status codes

| Status | Meaning |
Expand Down
44 changes: 43 additions & 1 deletion scripts/resolver/service/snrc-resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
./snrc-resolve.py # serve on :8000

curl -s http://127.0.0.1:8000/resolve/foobar.testing | jq .
curl -s 'http://127.0.0.1:8000/resolve/[<64-hex labelhash>].testing' | jq .
curl -s http://127.0.0.1:8000/health

Environment:
Expand Down Expand Up @@ -123,6 +124,47 @@ def namehash(name: str) -> bytes:
return node


# ENS writes a label whose preimage it does not know as `[<64 hex>]`, and that
# is the form reused here for a label the caller deliberately withholds. The
# brackets are what keep the two forms apart: `[` and `]` are outside the
# normalised character set, so no registrable name can take this shape, and the
# ecosystem already reads it back as a hash (ensjs `isEncodedLabelhash`; the
# subgraph refuses any real label containing a bracket). A bare `0x…` label
# would not be safe this way - that is an ordinary, registrable name, kept from
# clashing only by a registrar length cap that its owner can raise.
ENCODED_LABELHASH_LEN = 66 # "[" + 64 hex + "]"


def is_encoded_labelhash(label: str) -> bool:
return (
len(label) == ENCODED_LABELHASH_LEN
and label.startswith("[")
and label.endswith("]")
and all(c in "0123456789abcdef" for c in label[1:-1])
)


def node_of(name: str) -> bytes:
"""namehash, accepting an encoded labelhash in place of a 2LD's label.

A client checking whether a name is free is usually about to register it,
so the question itself is worth front-running. namehash is defined as
keccak(parent || keccak(label)), so a caller who supplies keccak(label)
reaches the same node having never sent the label.

Only 2LDs may be queried this way: that is the name a registration is
bought for, so the only one worth hiding. Subnames of any depth are
excluded - a subname is created by the 2LD's owner, nobody can race a
caller for one, so there is nothing to front-run. A label in `[<64 hex>]`
form there is hashed literally, not decoded; as brackets cannot occur in a
real registration, such a query names a node nobody can own.
"""
labels = name.split(".")
if len(labels) == 2 and is_encoded_labelhash(labels[0]):
return keccak(namehash(labels[1]) + bytes.fromhex(labels[0][1:-1]))
return namehash(name)


def selector(signature: str) -> str:
return "0x" + keccak(signature.encode())[:4].hex()

Expand Down Expand Up @@ -401,7 +443,7 @@ def resolve(name: str):
"configured_tlds": configured,
}

node = namehash(name)
node = node_of(name)
node_hex = node.hex()

resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex)
Expand Down
81 changes: 81 additions & 0 deletions scripts/resolver/service/test_snrc_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,86 @@ def test_order_is_preserved(self):
)


class EncodedLabelhashTests(unittest.TestCase):
"""Querying by labelhash instead of by label.
A client asking whether a name is free is usually about to register it, so
the question itself is worth front-running by whoever runs the resolver.
namehash is keccak(parent || keccak(label)), so supplying keccak(label)
yields the same node and the same answer, having never sent the label.
The encoding is ENS's own `[<64 hex>]`, which cannot collide with a real
name: brackets are outside the normalised character set."""

# keccak-256("alice") = 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501
# - written out in full wherever a test needs a real labelhash.

def test_the_encoded_form_is_recognised(self):
self.assertTrue(
snrc.is_encoded_labelhash(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
)
)

def test_an_ordinary_label_is_not(self):
self.assertFalse(snrc.is_encoded_labelhash("alice"))
self.assertFalse(snrc.is_encoded_labelhash("[alice]"))
self.assertFalse(snrc.is_encoded_labelhash("9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501"))

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
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):
self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 63 + "]"))
self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 65 + "]"))

def test_hash_and_label_reach_the_same_node(self):
self.assertEqual(
snrc.node_of("alice.testing"),
snrc.node_of(
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
),
)

def test_a_plain_name_is_unaffected(self):
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]"
".alice.testing"
),
snrc.namehash("alice.alice.testing"),
)
self.assertNotEqual(
snrc.node_of(
"alice."
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
".testing"
),
snrc.namehash("alice.alice.testing"),
)

def test_a_0x_prefixed_label_is_taken_literally(self):
"""`0x<64 hex>` is a registrable name, not a hash - the brackets are
what make the hashed form unambiguous."""
name = "0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501.testing"
self.assertEqual(snrc.node_of(name), snrc.namehash(name))
self.assertNotEqual(snrc.node_of(name), snrc.node_of("alice.testing"))

def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self):
name = "[nothex].testing"
self.assertEqual(snrc.node_of(name), snrc.namehash(name))


if __name__ == "__main__":
unittest.main()
Loading