mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-12 11:17:50 +00:00
implement resolving 2LD names by labelhash
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -129,6 +129,36 @@ 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`).
|
||||
|
||||
### Asking without naming the name
|
||||
|
||||
A client checking whether a name is free is usually about to register it, so
|
||||
the question itself is worth front-running. Substitute the label's keccak hash,
|
||||
written in ENS's `[<64 hex>]` form, and the answer is identical:
|
||||
|
||||
```sh
|
||||
# instead of /resolve/acme.testing
|
||||
curl -s "http://127.0.0.1:8000/resolve/[$(printf acme | keccak-256sum | cut -d' ' -f1)].testing"
|
||||
```
|
||||
|
||||
namehash is defined as `keccak(parent || keccak(label))`, so supplying
|
||||
`keccak(label)` reaches the same node: the resolver reads exactly the record it
|
||||
would have read, and learns which name you meant only if it already guessed it.
|
||||
|
||||
The brackets are what keep the two forms apart — `[` and `]` cannot occur in a
|
||||
normalised name, so no registrable label can take this shape, and it is the
|
||||
same encoding ENS itself uses for a label whose preimage is unknown. A bare
|
||||
`0x…` label would not do: that is an ordinary, registrable name.
|
||||
|
||||
Only 2LDs may be queried by hash: 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 you 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.
|
||||
|
||||
Registration itself stays public: this hides the *interest*, and the
|
||||
commit-reveal in the controller is what protects the registration.
|
||||
|
||||
### Status codes
|
||||
|
||||
| Status | Meaning |
|
||||
|
||||
@@ -29,6 +29,7 @@ Usage:
|
||||
./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:
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -82,5 +82,84 @@ class SplitLinksTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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 + "]"))
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user