From 69cce336759aebc90f7c58adc6ceee540a3b6434 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 5 Sep 2026 18:37:55 +0200 Subject: [PATCH] review fixes --- .gitignore | 1 - protocol/simplex-messaging.md | 54 ++++-- scripts/resolver/README.md | 20 ++- .../__pycache__/snrc-resolve.cpython-314.pyc | Bin 0 -> 40957 bytes .../test_snrc_resolve.cpython-314.pyc | Bin 0 -> 59621 bytes scripts/resolver/service/snrc-resolve.py | 56 ++++-- scripts/resolver/service/test_snrc_resolve.py | 169 ++++++++++-------- src/Simplex/Messaging/Client.hs | 29 ++- src/Simplex/Messaging/Server.hs | 11 +- src/Simplex/Messaging/Server/Names.hs | 41 +++-- .../Messaging/Server/Names/HttpResolver.hs | 48 +++-- src/Simplex/Messaging/SimplexName.hs | 55 ++++-- src/Simplex/Messaging/Transport.hs | 1 + tests/RSLVTests.hs | 60 ++++++- tests/SMPNamesTests.hs | 53 +++++- 15 files changed, 425 insertions(+), 173 deletions(-) create mode 100644 scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc create mode 100644 scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 9550e48c0..9d27c4ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ cabal.project.local~ *.tix .coverage -__pycache__/ diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index df3f4aa24..7f01b82fb 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -86,7 +86,7 @@ It's designed with the focus on communication security and integrity, under the It is designed as a low level protocol for other application protocols to solve the problem of secure and private message transmission, making [MITM attack][1] very difficult at any part of the message transmission system. -This document describes SMP protocol version 20. Versions 1-5 are discontinued. The version history: +This document describes SMP protocol version 22. Versions 1-5 are discontinued. The version history: - v1: binary protocol encoding - v2: message flags (used to control notifications) @@ -1469,18 +1469,31 @@ rslv = %s"RSLV" SP domain ; domain = canonical name as non-space bytes, consum explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to 253 bytes. -**Hashed labels.** A label MAY instead be given as `[` followed by 64 lowercase -hex characters and `]` — the keccak-256 hash of the label — so a client can ask -about a name without disclosing it to the names router. This is ENS's encoding +**Hashed labels.** The second-level label MAY instead be given as `[` followed +by 64 lowercase hex characters and `]` — the keccak-256 hash of that label — so +a router can answer about a name without being told it. This is ENS's encoding for a label whose preimage is unknown; the brackets are outside the name character set, so the form cannot collide with a registrable name, and the backing resolver uses the hash as the registry key rather than hashing the label again. A hashed label is 66 characters and is therefore exempt from the 63-byte -DNS label limit: it is a key into the registry, not a DNS label. A bare `0x` -hex string is NOT a hashed label — it is an ordinary label, and would be hashed -again, keying a different name. A router answering a hashed query cannot know -the name's length, and so cannot know its price or whether it meets a -minimum-length policy. +DNS label limit: it is a key into the registry, not a DNS label. + +**Only the second-level label.** It is the only label the registry is keyed on; +subname labels are needed as text to reach the record, so they are never hashed. +`[].simplex` and `sub.[].simplex` both reach the node their plain +names would, and a bracket label in any other position is an ordinary label, +hashed as written. Routers MUST reject a name whose hashed label is not the +second-level one, so that client and resolver cannot disagree about which node +was asked about. A bare `0x` hex string is likewise NOT a hashed label — it is an +ordinary label, and would be hashed again, keying a different name. + +**Clients send the hash.** From v22 a client MUST hash the second-level label of +every `RSLV` and `NAVL` it sends, so a registrable name never reaches a router in +the clear. Routers below v22 cannot parse the form, so a client on an older +session sends the name itself. The record returned for a hashed query names the +hash, because that is what was asked; the client restores the name it used. +A router answering a hashed query cannot know the name's length, and so cannot +know its price or whether it meets a minimum-length policy. **Server-side validation.** The names router parses `domain` as a fully-qualified name (TLD required — bare labels are rejected) and forwards it @@ -1591,7 +1604,7 @@ reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" | Answer | Condition | Client action | |---|---|---| | `AVAILABLE` | registrable at the ordinary price | offer it | -| `TAKEN` | held by someone until `expires`, or the router could not answer completely | do not offer it | +| `TAKEN` | held by someone until `expires` | do not offer it | | `GRACE` | lapsed, but renewable by its previous owner until `grace-ends` | do not offer it; it may free up then | | `AUCTION` | registrable by anyone, at `premium` above the ordinary price, decaying to nothing by `auction-ends` | offer it only with the premium shown | | `RESERVED` | held back by the registry for `reason` | do not offer it; explain `reason` | @@ -1608,9 +1621,15 @@ countdown; it MUST NOT treat either deadline as authorisation to register, which only the registry grants. A router that cannot obtain the payload for `GRACE` or `AUCTION` MUST answer -`TAKEN` rather than `AVAILABLE`. Quoting the ordinary price for a name that -carries a premium is the one materially harmful answer here, and withholding a -name the user could have had is the smaller error. +`TAKEN` with no `expires`, rather than `AVAILABLE`. Quoting the ordinary price +for a name that carries a premium is the one materially harmful answer here, and +withholding a name the user could have had is the smaller error. + +A router that cannot read the name's status at all MUST answer `ERR NAME +RESOLVER ` and MUST NOT answer `TAKEN`, which would assert a +registration nobody read, or `NOT_FOUND`, which a client may read as "no such +name, therefore free". This covers an unreachable chain, a TLD the backing +resolver has no registry for, and any status the router does not recognise. `RESERVED` carries a reason code rather than a sentence so the client can word it in the user's language. A client MUST treat a reason it does not recognise as @@ -1620,9 +1639,12 @@ it in the user's language. A client MUST treat a reason it does not recognise as resolver, `ERR NAME RESOLVER ` on a transient backing failure. It is gated on SMP v22 and MUST NOT be sent to a router that negotiated a lower version. Like `RSLV` it is unauthenticated and accepted directly or inside a -`PFWD` block, and clients SHOULD prefer the forwarded path for the same reason: -an availability query discloses the lookup key, and a hashed label protects the -name but not the client's IP. +`PFWD` block, and clients SHOULD prefer the forwarded path: a hashed label keeps +the name from the router, but only the proxy keeps the client's IP from it. A +client whose proxy cannot carry `NAVL` — every proxy below v22, since the proxy +caps the relay version at `proxiedSMPRelayVersion` — falls back to a direct send +if its network configuration allows one, so during rollout the names router sees +the client's IP alongside the hash, and never the name. ## Transport connection with the SMP router diff --git a/scripts/resolver/README.md b/scripts/resolver/README.md index 56c4aa5c2..13ef998d6 100644 --- a/scripts/resolver/README.md +++ b/scripts/resolver/README.md @@ -187,7 +187,18 @@ base price itself. The oracle is found through the controller's `prices()`, so no extra configuration is needed. Its window is read from the chain rather than assumed, because the owner can retune it; a window of zero days switches the auction off, -and every lapsed name then reports `expired` directly. +and every lapsed name then reports `expired` directly. The curve +(`startPremium`, `totalDays`, `endValue`) is cached for `AUCTION_PARAMS_TTL` +seconds, 5 minutes by default, since it changes only when the owner calls +`setPremium`; the decaying premium itself is read from the oracle on every +query. A retune is therefore visible within the TTL, not immediately. + +**Known gap.** When the auction cannot be read at all — no controller +configured, or the oracle unreachable — the name reports `expired`, which routers +map to "available at the ordinary price". A name still inside its auction would +then be quoted at list price while the registrar charges the premium. Configure +`SNRC_CONTROLLER_` wherever `SNRC_REGISTRAR_` is set, and upgrade this +service before the routers that query it. Upgrade the resolver before the router that queries it. A resolver without this status reports a name in its auction as plain `expired`, which reads as "free at @@ -231,6 +242,13 @@ 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. +Only the second-level label is a registry key, so only it is decoded — but it is +decoded wherever it sits, so `sub.[].testing` reaches the node +`sub.name.testing` does. Subname labels are needed as text to walk down to the +record and are never hashed; a bracket label to the left of the 2LD is an +ordinary label and is hashed as written. SMP routers from v22 send every 2LD +this way, so in normal operation a registrable name never reaches this service. + Read the answer from `status`. A name is free when the body says `unregistered` (a 404), and also when it says `expired` or `auction` (a 410) — though `auction` costs a premium on top. Every other status means somebody holds diff --git a/scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc b/scripts/resolver/service/__pycache__/snrc-resolve.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b33e989ed7a4ab780c329a41d5af62199676f55 GIT binary patch literal 40957 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 literal 0 HcmV?d00001 diff --git a/scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc b/scripts/resolver/service/__pycache__/test_snrc_resolve.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..025a0b6e52669c1ccd96291ff568dec977dc29b1 GIT binary patch literal 59621 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 bool: def node_of(name: str) -> bytes: - """namehash, accepting an encoded labelhash in place of a 2LD's label. In a - subname a bracket label is hashed as written, not decoded.""" + """namehash, accepting the 2LD's label as an encoded labelhash at any depth, + so `[hash].tld` and `sub.[hash].tld` both reach the node the name itself + would. Only that label is a registry key: a bracket label anywhere else is + hashed as written, which is what the routers also enforce.""" 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) + if len(labels) < 2 or not is_encoded_labelhash(labels[-2]): + return namehash(name) + node = keccak(namehash(labels[-1]) + bytes.fromhex(labels[-2][1:-1])) + for label in reversed(labels[:-2]): + node = keccak(node + keccak(label.encode())) + return node # ---------- Registration status ---------- @@ -213,23 +219,47 @@ def reservation_reason(tld: str, token: int) -> int: return decode_uint(raw) +# The oracle address and its curve change only when the owner retunes the +# auction, so they are read at most once per AUCTION_PARAMS_TTL seconds instead +# of on every lapsed-name query. The premium itself is never cached: it decays +# continuously and is read from the oracle each time. +AUCTION_PARAMS_TTL = 300 +_auction_params: dict = {} + + +def auction_params(tld: str): + """(oracle, startPremium, totalDays, endValue) for the TLD's controller, or + (ZERO_ADDR, 0, 0, 0) when no controller or no oracle is configured.""" + cached = _auction_params.get(tld) + if cached and time.time() - cached[0] < AUCTION_PARAMS_TTL: + return cached[1] + params = (ZERO_ADDR, 0, 0, 0) + controller = CONTROLLERS.get(tld) + if controller: + oracle = decode_address(eth_call(controller, selector("prices()"))) + if oracle != ZERO_ADDR: + params = ( + oracle, + decode_uint(eth_call(oracle, selector("startPremium()"))), + decode_uint(eth_call(oracle, selector("totalDays()"))), + decode_uint(eth_call(oracle, selector("endValue()"))), + ) + _auction_params[tld] = (time.time(), params) + return params + + def auction(tld: str, grace_ends: int, now: int): """Past its grace period a name is registrable again, but at a premium that decays to zero over the price oracle's auction window. Returns when the premium reaches zero and what it is now, in attoUSD, or (None, None) once prices are back to normal - which includes an auction switched off by setting totalDays to 0.""" - controller = CONTROLLERS.get(tld) - if not controller: - return None, None - oracle = decode_address(eth_call(controller, selector("prices()"))) + oracle, start, total_days, floor = auction_params(tld) if oracle == ZERO_ADDR: return None, None - ends = grace_ends + decode_uint(eth_call(oracle, selector("totalDays()"))) * 86400 + ends = grace_ends + total_days * 86400 if now >= ends: return None, None - start = decode_uint(eth_call(oracle, selector("startPremium()"))) - floor = decode_uint(eth_call(oracle, selector("endValue()"))) # decayedPremium is `pure`, so the premium quoted here is the oracle's own # arithmetic rather than a reimplementation of its decay curve. decayed = decode_uint( @@ -261,7 +291,7 @@ def name_status(name: str): # nameExpires and reservedNames are keyed on uint256(keccak(label)). # Decoded for a 2LD only, the same rule node_of applies to the node. label = labels[-2] - if len(labels) == 2 and is_encoded_labelhash(label): + if is_encoded_labelhash(label): token = int(label[1:-1], 16) else: token = int.from_bytes(keccak(label.encode()), "big") diff --git a/scripts/resolver/service/test_snrc_resolve.py b/scripts/resolver/service/test_snrc_resolve.py index 30ad640ae..d2a2296e4 100644 --- a/scripts/resolver/service/test_snrc_resolve.py +++ b/scripts/resolver/service/test_snrc_resolve.py @@ -97,6 +97,7 @@ class EncodedLabelhashTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) + snrc._auction_params.clear() def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved @@ -135,7 +136,9 @@ class EncodedLabelhashTests(unittest.TestCase): 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): + def test_a_bracket_subname_label_stays_literal(self): + """Only the 2LD is a registry key, so a bracket label to the left of it + is a name in its own right and is hashed as written.""" self.assertNotEqual( snrc.node_of( "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" @@ -143,13 +146,25 @@ class EncodedLabelhashTests(unittest.TestCase): ), snrc.namehash("alice.alice.testing"), ) - self.assertNotEqual( + + def test_a_hashed_2ld_under_a_subname_reaches_the_same_node(self): + """Clients hash the 2LD and leave subname labels as text, so + `sub.[hash].tld` must reach the node `sub.name.tld` does.""" + self.assertEqual( snrc.node_of( - "alice." + "sub." "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" ".testing" ), - snrc.namehash("alice.alice.testing"), + snrc.namehash("sub.alice.testing"), + ) + self.assertEqual( + snrc.node_of( + "a.b." + "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" + ".testing" + ), + snrc.namehash("a.b.alice.testing"), ) def test_a_0x_prefixed_label_is_taken_literally(self): @@ -221,6 +236,7 @@ class NameStatusTests(unittest.TestCase): # Expiry alone; ReservedTests covers a configured controller. snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) + snrc._auction_params.clear() def tearDown(self): ( @@ -297,6 +313,20 @@ class NameStatusTests(unittest.TestCase): # the token asked about is keccak("alice"), not keccak("x") self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex())) + def test_a_hashed_2ld_is_queried_by_its_hash_at_any_depth(self): + """Clients hash the 2LD and leave subname labels as text, so the token + must come from the hash, not from hashing the bracket text again.""" + seen = [] + + def eth_call(to, data): + seen.append(data) + return "0x" + snrc.encode_uint(0) + + snrc.eth_call = eth_call + hashed = "[" + snrc.keccak(b"alice").hex() + "]" + snrc.name_status("x." + hashed + ".testing") + self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex())) + def test_unconfigured_tld_is_unknown_rather_than_unregistered(self): snrc.REGISTRARS = {"testing": ""} snrc.eth_call = lambda *a: self.fail("must not reach the chain") @@ -333,6 +363,7 @@ class ReservedTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} snrc.chain_now = lambda: int(time.time()) + snrc._auction_params.clear() def tearDown(self): snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved @@ -400,6 +431,7 @@ class ReservedReasonTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": self.CONTROLLER} snrc.chain_now = lambda: int(time.time()) + snrc._auction_params.clear() def tearDown(self): ( @@ -422,6 +454,47 @@ class ReservedReasonTests(unittest.TestCase): return eth_call + def _reserved_as(self, code): + def eth_call(to, data): + if data.startswith(snrc.selector("reservedNames(bytes32)")): + return "0x" + snrc.encode_uint(code) + if data.startswith(snrc.selector("GRACE_PERIOD()")): + return "0x" + snrc.encode_uint(90 * 86400) + if data.startswith(snrc.selector("prices()")): + return "0x" + snrc.encode_uint(0) + return "0x" + snrc.encode_uint(0) + + return eth_call + + def test_every_enum_value_has_a_code_and_a_sentence(self): + for code, (name, sentence) in snrc.RESERVED_REASONS.items(): + snrc.eth_call = self._reserved_as(code) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "reserved", name) + self.assertEqual(reg["reasonCode"], name) + self.assertEqual(reg["reason"], sentence) + + def test_a_trademark_reservation_says_so(self): + snrc.eth_call = self._reserved_as(2) + _, body = snrc.resolve("acme.testing") + self.assertEqual(body["reasonCode"], "trademark") + + def test_a_controller_storing_a_bool_reads_as_unspecified(self): + """Before the enum, `reservedNames` was a bool; its `true` decodes as 1, + which is the value this table already describes as unspecified.""" + snrc.eth_call = self._reserved_as(1) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["reasonCode"], "unspecified") + self.assertEqual(reg["reason"], "reserved for a brand or public interest") + + def test_an_enum_value_this_resolver_predates_is_not_dropped(self): + """A controller upgraded with a new Reason still reports the name as + reserved; only the wording falls back.""" + snrc.eth_call = self._reserved_as(99) + reg = snrc.name_status("acme.testing") + self.assertEqual(reg["status"], "reserved") + self.assertEqual(reg["reasonCode"], "unspecified") + def test_a_reserved_name_carries_the_reason(self): snrc.eth_call = self._chain(0, True) status, body = snrc.resolve("acme.testing") @@ -484,6 +557,7 @@ class AuctionTests(unittest.TestCase): snrc.CONTROLLERS = {"testing": self.CONTROLLER} self.now = int(time.time()) snrc.chain_now = lambda: self.now + snrc._auction_params.clear() def tearDown(self): ( @@ -576,10 +650,17 @@ class AuctionTests(unittest.TestCase): self.assertEqual(snrc.name_status("acme.testing")["status"], "grace") self.assertEqual(self.oracle_calls, []) - def test_a_live_name_never_reaches_the_oracle(self): - snrc.eth_call = self._chain(self.now + 3600) - self.assertEqual(snrc.name_status("acme.testing")["status"], "registered") - self.assertEqual(self.oracle_calls, []) + def test_the_oracle_curve_is_read_once_not_per_query(self): + """The curve changes only when the owner retunes the auction, so only the + decaying premium is re-read; the rest would be four RPC calls per query.""" + snrc.eth_call = self._chain(self._lapsed(1)) + snrc.name_status("acme.testing") + seen_first = len(self.oracle_calls) + snrc.name_status("acme.testing") + self.assertEqual( + self.oracle_calls[seen_first:], + [snrc.selector("decayedPremium(uint256,uint256)")], + ) def test_a_reserved_lapsed_name_stays_reserved_rather_than_auctioned(self): snrc.eth_call = self._chain(self._lapsed(0), reserved=2) @@ -616,77 +697,6 @@ class AuctionTests(unittest.TestCase): self.assertIsNotNone(body["premium"]) -class ReasonCodeTests(unittest.TestCase): - """The reason a name is held back is the controller's `Reason` enum, so the - app can word it in the user's language instead of showing a server string.""" - - REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" - REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a" - CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714" - - def setUp(self): - self._saved = ( - snrc.REGISTRIES, - snrc.REGISTRARS, - snrc.CONTROLLERS, - snrc.eth_call, - snrc.chain_now, - ) - snrc.REGISTRIES = {"testing": self.REGISTRY} - snrc.REGISTRARS = {"testing": self.REGISTRAR} - snrc.CONTROLLERS = {"testing": self.CONTROLLER} - snrc.chain_now = lambda: int(time.time()) - - def tearDown(self): - ( - snrc.REGISTRIES, - snrc.REGISTRARS, - snrc.CONTROLLERS, - snrc.eth_call, - snrc.chain_now, - ) = self._saved - - def _reserved_as(self, code): - def eth_call(to, data): - if data.startswith(snrc.selector("reservedNames(bytes32)")): - return "0x" + snrc.encode_uint(code) - if data.startswith(snrc.selector("GRACE_PERIOD()")): - return "0x" + snrc.encode_uint(90 * 86400) - if data.startswith(snrc.selector("prices()")): - return "0x" + snrc.encode_uint(0) - return "0x" + snrc.encode_uint(0) - - return eth_call - - def test_every_enum_value_has_a_code_and_a_sentence(self): - for code, (name, sentence) in snrc.RESERVED_REASONS.items(): - snrc.eth_call = self._reserved_as(code) - reg = snrc.name_status("acme.testing") - self.assertEqual(reg["status"], "reserved", name) - self.assertEqual(reg["reasonCode"], name) - self.assertEqual(reg["reason"], sentence) - - def test_a_trademark_reservation_says_so(self): - snrc.eth_call = self._reserved_as(2) - _, body = snrc.resolve("acme.testing") - self.assertEqual(body["reasonCode"], "trademark") - - def test_a_controller_storing_a_bool_reads_as_unspecified(self): - """Before the enum, `reservedNames` was a bool; its `true` decodes as 1, - which is the value this table already describes as unspecified.""" - snrc.eth_call = self._reserved_as(1) - reg = snrc.name_status("acme.testing") - self.assertEqual(reg["reasonCode"], "unspecified") - self.assertEqual(reg["reason"], "reserved for a brand or public interest") - - def test_an_enum_value_this_resolver_predates_is_not_dropped(self): - """A controller upgraded with a new Reason still reports the name as - reserved; only the wording falls back.""" - snrc.eth_call = self._reserved_as(99) - reg = snrc.name_status("acme.testing") - self.assertEqual(reg["status"], "reserved") - self.assertEqual(reg["reasonCode"], "unspecified") - class ErrorCodeTests(unittest.TestCase): REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b" @@ -704,6 +714,7 @@ class ErrorCodeTests(unittest.TestCase): snrc.REGISTRARS = {"testing": self.REGISTRAR} snrc.CONTROLLERS = {"testing": ""} snrc.chain_now = lambda: int(time.time()) + snrc._auction_params.clear() def tearDown(self): ( diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 93c8a035d..5b553dacf 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -168,7 +168,7 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON import Simplex.Messaging.Protocol import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo -import Simplex.Messaging.SimplexName (SimplexDomain) +import Simplex.Messaging.SimplexName (SimplexDomain, fullDomainName, hashedDomain) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport @@ -1059,8 +1059,8 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRecord) proxyResolveName c nm proxiedRelay name | prVersion proxiedRelay >= namesSMPVersion = - proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV name) >>= \case - Right (RNAME nr) -> pure $ Right nr + proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain (prVersion proxiedRelay) name)) >>= \case + Right (RNAME nr) -> pure $ Right (namedFor name nr) Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1072,11 +1072,24 @@ proxyResolveName c nm proxiedRelay name -- encoder, so an old server never receives RSLV. directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRecord directResolveName c nm name - | thVersion (thParams c) >= namesSMPVersion = - sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV name)) >>= \case - RNAME nr -> pure nr + | v >= namesSMPVersion = + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case + RNAME nr -> pure (namedFor name nr) r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion + where + v = thVersion (thParams c) + +-- | How a name travels to the router. From `nameAvailSMPVersion` the +-- second-level label is replaced by its hash, so the router answers about the +-- name without being told it; an older router can only parse the name itself. +queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain +queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d + +-- | The record names whatever was asked for, which for a hashed query is the +-- hash, so the name the caller used is put back. +namedFor :: SimplexDomain -> NameRecord -> NameRecord +namedFor d nr = nr {nrName = fullDomainName d} -- | Ask whether a name can be registered, over PFWD. Availability is a second -- question about the same name rather than a variant of resolution, so it has @@ -1084,7 +1097,7 @@ directResolveName c nm name proxyNameAvailability :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameAvailability) proxyNameAvailability c nm proxiedRelay name | prVersion proxiedRelay >= nameAvailSMPVersion = - proxySMPCommand c nm proxiedRelay Nothing NoEntity (NAVL name) >>= \case + proxySMPCommand c nm proxiedRelay Nothing NoEntity (NAVL (hashedDomain name)) >>= \case Right (NAVAIL a) -> pure $ Right a Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e @@ -1095,7 +1108,7 @@ proxyNameAvailability c nm proxiedRelay name directNameAvailability :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameAvailability directNameAvailability c nm name | thVersion (thParams c) >= nameAvailSMPVersion = - sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (NAVL name)) >>= \case + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (NAVL (hashedDomain name))) >>= \case NAVAIL a -> pure a r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 3b375fa0f..0e7b6ba2f 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1496,10 +1496,13 @@ client -- Runs on a forked thread so RSLV does not block other commands; -- concurrency is limited by serverResolverConcurrency in forkCmd. nameAvailMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg - nameAvailMsg nenv d = - liftIO (nameAvailability nenv d) <&> \case - Right a -> NAVAIL a - Left e -> ERR $ NAME e + nameAvailMsg nenv d = do + st <- asks (rslvStats . serverStats) + (selector, msg) <- + liftIO (nameAvailability nenv d) <&> \case + Right a -> (rslvSucc, NAVAIL a) + Left e -> (rslvResolverErrs, ERR $ NAME e) + incStat (selector st) $> msg resolveNameMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg resolveNameMsg nenv d = do st <- asks (rslvStats . serverStats) diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index d69a1cac8..6e4e90540 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -18,7 +18,7 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Bifunctor (bimap, first) +import Data.Bifunctor (first) import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -88,23 +88,38 @@ nameAvailability env d = do fetchAvail :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameAvailability) fetchAvail NamesEnv {resolverEnv} d = - bimap mapResolverError mapAvailability <$> availabilityHttp resolverEnv (fullDomainName d) + either (Left . mapAvailError) mapAvailability <$> availabilityHttp resolverEnv (fullDomainName d) + +-- | NAVL answers whether a name can be registered, so a resolver failure must +-- never look like an answer about the name: NOT_FOUND, which 'mapResolverError' +-- returns for 404/410/400, would read as "no such name, therefore free". +mapAvailError :: ResolverError -> NameErrorType +mapAvailError = \case + HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) + e -> mapResolverError e -- | The resolver's own vocabulary. A lapsed registration past its grace period -- is available again; one still in grace belongs to its previous owner; one in --- the auction that follows grace is registrable, but not at the usual price. A --- status whose payload is missing is reported as taken - refusing a name the --- user could have had is a smaller harm than quoting the wrong price for it. -mapAvailability :: NameStatusResp -> NameAvailability +-- the auction that follows grace is registrable, but not at the usual price. +-- Only the statuses that describe the name are answers - anything else means the +-- resolver could not answer, and saying "taken" to that would assert a +-- registration that was never read. +mapAvailability :: NameStatusResp -> Either NameErrorType NameAvailability mapAvailability NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = case nsStatus of - "unregistered" -> NAVailable - "expired" -> NAVailable - "grace" -> maybe taken NAInGrace nsGraceEnds - "auction" -> fromMaybe taken (NAAuction <$> nsPremium <*> nsAuctionEnds) - "reserved" -> NAReserved (maybe NRUnspecified mapReason nsReasonCode) - _ -> taken + "unregistered" -> Right NAVailable + "expired" -> Right NAVailable + "grace" -> Right $ maybe lapsed NAInGrace nsGraceEnds + "auction" -> Right $ fromMaybe lapsed (NAAuction <$> nsPremium <*> nsAuctionEnds) + "reserved" -> Right $ NAReserved (maybe NRUnspecified mapReason nsReasonCode) + "registered" -> Right $ NATaken nsExpires + -- registered, but its records point nowhere + "noResolver" -> Right $ NATaken nsExpires + s -> Left (RESOLVER s) where - taken = NATaken nsExpires + -- A lapsed name missing the deadline or price that its status carries: + -- withholding it is safer than quoting the ordinary price, but its expiry is + -- in the past, so it is not "registered until" anything. + lapsed = NATaken Nothing -- | The controller's reservation reasons, as the resolver spells them. mapReason :: Text -> NameReservedReason diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 75a690b71..ccf1933a4 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -40,9 +40,11 @@ import qualified Data.Aeson.KeyMap as JKM import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) +import Data.Char (isDigit) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL import Data.Int (Int64) +import qualified Data.Text as T import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Client @@ -141,7 +143,7 @@ resolveHttp env name = -- 200 and "error" otherwise, alongside the deadline or price that status -- carries. availabilityHttp :: ResolverEnv -> Text -> IO (Either ResolverError NameStatusResp) -availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro} name = do +availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseBytes} name = do req0 <- parseRequest (baseUrl <> "/resolve/" <> B.unpack (urlEncode True (encodeUtf8 name))) let req = req0 @@ -152,22 +154,34 @@ availabilityHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro} name = do result <- E.try $ withResponse req manager $ \res -> do let status = HT.statusCode (responseStatus res) field = if status < 400 then "status" else "error" - bs <- brReadSome (responseBody res) statusBodyBytes - pure $ case J.decode bs of - Just (J.Object o) - | Just (J.String t) <- JKM.lookup field o -> - Right - NameStatusResp - { nsStatus = t, - nsExpires = jsonField o "expires", - nsGraceEnds = jsonField o "graceEnds", - nsAuctionEnds = jsonField o "auctionEnds", - nsPremium = jsonField o "premium", - nsReasonCode = jsonField o "reasonCode" - } - _ -> Left (HttpStatusErr status) + bs <- brReadSome (responseBody res) (maxResponseBytes + 1) + pure $ + if BL.length bs > fromIntegral maxResponseBytes + then Left BodyTooLarge + else case J.decode bs of + Just (J.Object o) + | Just (J.String t) <- JKM.lookup field o -> + Right + NameStatusResp + { nsStatus = t, + nsExpires = jsonField o "expires", + nsGraceEnds = jsonField o "graceEnds", + nsAuctionEnds = jsonField o "auctionEnds", + nsPremium = jsonField o "premium" >>= decimalPrice, + nsReasonCode = jsonField o "reasonCode" + } + _ -> Left (HttpStatusErr status) pure (either (Left . HttpFailure) id result) +-- | A price is a 256-bit integer written in decimal, so at most 78 digits. The +-- wire format prefixes it with a single length byte, which would wrap silently +-- on a longer string and leave the whole response unparseable, so anything else +-- is dropped rather than re-encoded. +decimalPrice :: Text -> Maybe Text +decimalPrice t + | not (T.null t) && T.length t <= 78 && T.all isDigit t = Just t + | otherwise = Nothing + -- | A field the resolver omits, or sends as null, for the statuses that do not -- carry it. jsonField :: J.FromJSON a => J.Object -> Key -> Maybe a @@ -175,10 +189,6 @@ jsonField o k = case J.fromJSON <$> JKM.lookup k o of Just (J.Success v) -> Just v _ -> Nothing --- | Enough of a body to reach the status field; the rest is not read. -statusBodyBytes :: Int -statusBodyBytes = 4096 - -- | GET /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 4622f987a..d07b74205 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -10,14 +10,18 @@ module Simplex.Messaging.SimplexName SimplexTLD (..), SimplexNameType (..), fullDomainName, + hashedDomain, shortNameInfoStr, ) where import Control.Applicative (optional, (<|>)) +import Crypto.Hash (Digest, hash) +import Crypto.Hash.Algorithms (Keccak_256) import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Attoparsec.Text as AT +import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isDigit) @@ -57,22 +61,12 @@ instance StrEncoding SimplexNameType where strP = A.char '#' $> NTPublicGroup <|> A.char '@' $> NTContact nameLabelP :: AT.Parser Text -nameLabelP = labelhashP <|> do +nameLabelP = do label <- T.intercalate "-" <$> AT.takeWhile1 (\c -> isNameLetter c || isDigit c) `AT.sepBy1` AT.char '-' -- DNS label limit: each dot-separated component is at most 63 bytes (labels -- are ASCII, so character count == byte count) if T.length label > 63 then fail "name label exceeds 63 bytes" else pure label where - -- A label given as its own keccak256 hash, so a client can ask whether a - -- name is taken without saying which name. ENS's encoding for a label whose - -- preimage is unknown: the brackets are outside the name character set, so - -- the form cannot collide with a registrable name, and the resolver reads - -- the hash as the registry key instead of hashing the label again. 66 - -- characters, so it is exempt from the DNS limit above: it is a key into the - -- registry, not a DNS label. - labelhashP = do - hex <- AT.char '[' *> AT.takeWhile1 (\c -> isDigit c || c >= 'a' && c <= 'f') <* AT.char ']' - if T.length hex == 64 then pure ("[" <> hex <> "]") else fail "labelhash: expected 64 hex digits" -- ASCII letters only. SNRC contracts hash byte sequences via keccak; ENS -- uses UTS-46 + Punycode for IDN, which we do not implement. Admitting -- Cyrillic / Greek / etc. via Data.Char.isAlpha would (a) make namehash @@ -80,6 +74,31 @@ nameLabelP = labelhashP <|> do -- (Cyrillic а vs ASCII a hash to different on-chain records). isNameLetter c = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +-- | A second-level label given as its own keccak256 hash, so a router never +-- learns the name it is asked about. ENS's encoding for a label whose preimage +-- is unknown: the brackets are outside the name character set, so the form +-- cannot collide with a registrable name, and the resolver reads the hash as the +-- registry key instead of hashing the label again. 66 characters, so it is +-- exempt from the DNS label limit: it is a key into the registry, not a label. +labelHashP :: AT.Parser Text +labelHashP = do + hex <- AT.char '[' *> AT.takeWhile1 (\c -> isDigit c || c >= 'a' && c <= 'f') <* AT.char ']' + if T.length hex == 64 then pure ("[" <> hex <> "]") else fail "labelhash: expected 64 hex digits" + +isLabelHash :: Text -> Bool +isLabelHash t = T.length t == 66 && T.head t == '[' && T.last t == ']' + +-- | The name with its second-level label replaced by that label's keccak256 +-- hash, which is what the registry is keyed on - so a router can answer about +-- the name without being told it. Subname labels are left as text, as reaching +-- the record needs them, and a web TLD has no registry to key into. +hashedDomain :: SimplexDomain -> SimplexDomain +hashedDomain d@SimplexDomain {nameTLD, domain} + | nameTLD == TLDWeb || isLabelHash domain = d + | otherwise = d {domain = "[" <> labelHash <> "]"} + where + labelHash = decodeLatin1 $ BAE.convertToBase BAE.Base16 (hash (encodeUtf8 domain) :: Digest Keccak_256) + -- | Cap the name at 253 bytes (DNS full-domain limit) boundedNonSpace :: A.Parser ByteString boundedNonSpace = do @@ -103,15 +122,21 @@ instance StrEncoding SimplexDomain where strEncode = encodeUtf8 . fullDomainName strP = parseDomain . safeDecodeUtf8 <$?> boundedNonSpace where - parseDomain s = AT.parseOnly (nameLabelP `AT.sepBy1` AT.char '.' <* AT.endOfInput) s >>= mkDomain + parseDomain s = AT.parseOnly ((labelHashP <|> nameLabelP) `AT.sepBy1` AT.char '.' <* AT.endOfInput) s >>= mkDomain mkDomain labels = case reverse lowered of [] -> Left "empty name" [_] -> Left "domain requires TLD" - "simplex" : name : sub -> Right (SimplexDomain TLDSimplex name sub) - "testing" : name : sub -> Right (SimplexDomain TLDTesting name sub) - _ -> Right (SimplexDomain TLDWeb (T.intercalate "." lowered) []) + "simplex" : name : sub -> registryDomain TLDSimplex name sub + "testing" : name : sub -> registryDomain TLDTesting name sub + _ + | any isLabelHash lowered -> Left "labelhash requires a registry TLD" + | otherwise -> Right (SimplexDomain TLDWeb (T.intercalate "." lowered) []) where lowered = map T.toLower labels + -- Only the second-level label is a registry key, so only it may be hashed. + registryDomain tld name sub + | any isLabelHash sub = Left "only the second-level label may be a labelhash" + | otherwise = Right (SimplexDomain tld name sub) instance Encoding SimplexDomain where smpEncode = strEncode diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 9c66998af..21edf16c1 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -172,6 +172,7 @@ smpBlockSize = 16384 -- 19 - service subscriptions to messages (10/20/2025) -- 20 - public namespaces resolver, RSLV command (6/20/2026) -- 21 - server public information in handshake (7/5/2026) +-- 22 - name availability (NAVL command, NAVAIL response) data SMPVersion diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 6faf8916e..83681cf19 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -15,6 +15,7 @@ import Control.Monad.Trans.Except (ExceptT, runExceptT) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB +import Data.IORef (IORef, readIORef) import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) @@ -59,6 +60,11 @@ withResolverServer (st, body) runTest = NRS.withResolverServer (NRS.resolveResp st body) $ \port _ -> withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort (const runTest) +withResolverServerReqs :: (Status, LB.ByteString) -> (IORef [[Text]] -> IO a) -> IO a +withResolverServerReqs (st, body) runTest = + NRS.withResolverServer (NRS.resolveResp st body) $ \port reqs -> + withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort (const (runTest reqs)) + withProxyAndResolver :: (Status, LB.ByteString) -> IO a -> IO a withProxyAndResolver (st, body) runTest = NRS.withResolverServer (NRS.resolveResp st body) $ \port _ -> @@ -99,6 +105,10 @@ rslvTests = do it "no names config -> NAME NO_RESOLVER" testNavlDisabled it "refuses to send NAVL on a session below nameAvailSMPVersion" testNavlVersion it "PFWD-wrapped NAVL reaches the resolver via the proxy" testNavlForwarded + describe "hashed lookups" $ do + it "RSLV sends the second-level label as its hash, never the name" testRslvSendsTheHash + it "NAVL sends the second-level label as its hash, never the name" testNavlSendsTheHash + it "a subname keeps its own labels as text, hashing only the 2LD" testSubnameKeepsItsLabels testRslvBackendNotFound :: IO () testRslvBackendNotFound = @@ -214,7 +224,9 @@ testNavlVersion = g <- C.newRandom ts <- getCurrentTime let srv = SMPServer testHost testPort testKeyHash - oldCfg = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion rcvServiceSMPVersion} + -- the version immediately below the gate: a range ending lower would + -- also pass for a gate at 20 or 21 and prove nothing about v22 + oldCfg = defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion serverInfoSMPVersion} pcE <- getProtocolClient g NRMInteractive (1, srv, Nothing) oldCfg [] Nothing ts (\_ -> pure ()) pc <- either (fail . show) pure pcE r <- runExceptT (directNameAvailability pc NRMInteractive (domain "alice.simplex")) @@ -242,5 +254,51 @@ testNavlForwarded = auctionBody :: LB.ByteString auctionBody = "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" +-- keccak-256("alice"), the key the registry is keyed on +aliceHash :: Text +aliceHash = "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]" + +-- | A client on a current session must never put a registrable name on the +-- wire: the router answers about the hash and learns only that. +resolvePaths :: IORef [[Text]] -> IO [[Text]] +resolvePaths reqs = filter isResolve <$> readIORef reqs + where + isResolve = \case ("resolve" : _) -> True; _ -> False + +currentClient :: IO SMPClient +currentClient = do + g <- C.newRandom + ts <- getCurrentTime + let srv = SMPServer testHost testPort testKeyHash + pcE <- getProtocolClient g NRMInteractive (1, srv, Nothing) defaultSMPClientConfig [] Nothing ts (\_ -> pure ()) + either (fail . show) pure pcE + +testRslvSendsTheHash :: IO () +testRslvSendsTheHash = + withResolverServerReqs (status200, J.encode echoed) $ \reqs -> do + pc <- currentClient + nr <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) + resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] + -- the record names what the caller asked for, not what went on the wire + SMP.nrName nr `shouldBe` "alice.simplex" + where + -- the resolver echoes the name it was asked about, which is the hash + echoed = testNameRecord {SMP.nrName = aliceHash <> ".simplex"} + +testNavlSendsTheHash :: IO () +testNavlSendsTheHash = + withResolverServerReqs (status404, "{\"error\":\"unregistered\"}") $ \reqs -> do + pc <- currentClient + a <- runExceptT' (directNameAvailability pc NRMInteractive (domain "alice.simplex")) + a `shouldBe` NAVailable + resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] + +testSubnameKeepsItsLabels :: IO () +testSubnameKeepsItsLabels = + withResolverServerReqs (status404, "{\"error\":\"unregistered\"}") $ \reqs -> do + pc <- currentClient + _ <- runExceptT' (directNameAvailability pc NRMInteractive (domain "x.alice.simplex")) + resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]] + runExceptT' :: Show e => ExceptT e IO a -> IO a runExceptT' a = runExceptT a >>= either (fail . show) pure diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index ac3d9a2fe..5ddc2e49d 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -28,7 +28,7 @@ import Simplex.Messaging.Server.Names resolveName, ) import Simplex.Messaging.Server.Names.HttpResolver (ResolverError (..)) -import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..)) +import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName, hashedDomain) import Test.Hspec testNameRecord :: NameRecord @@ -132,6 +132,29 @@ availabilitySpec = do answers status410 "{\"error\":\"grace\"}" (NATaken Nothing) it "an auction without its price is reported as taken" $ answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NATaken Nothing) + it "a registered name whose records point nowhere is still taken" $ + answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NATaken (Just 1811232000)) + -- a price is a 256-bit integer in decimal; the wire length-prefixes it with one + -- byte, so a longer or non-numeric string is dropped rather than re-encoded + it "a premium too long to encode is not quoted" $ + answers status410 (jsonBody ("{\"error\":\"auction\",\"premium\":\"" <> replicate 300 '9' <> "\",\"auctionEnds\":1798191621}")) (NATaken Nothing) + it "a premium that is not a decimal integer is not quoted" $ + answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NATaken Nothing) + -- a resolver that could not answer must not be reported as an answer: saying + -- TAKEN would assert a registration nobody read, and NOT_FOUND would read as + -- "no such name, therefore free" + it "an upstream RPC failure is a resolver error, not a taken name" $ + refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") + it "a TLD this resolver has no registry for is a resolver error" $ + refuses status400 "{\"error\":\"tldNotConfigured\"}" (RESOLVER "tldNotConfigured") + it "a TLD with no registrar, so status could not be read, is a resolver error" $ + refuses status200 "{\"status\":\"unknown\",\"expires\":null}" (RESOLVER "unknown") + it "a body that is not the resolver's JSON is never NOT_FOUND" $ + refuses status404 "gateway" (RESOLVER "HTTP 404") + it "a body past the configured cap is a resolver error" $ + withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do + env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200} + nameAvailability env navlDomain `shouldReturn` Left (RESOLVER "response too large") it "every answer survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) @@ -148,10 +171,13 @@ availabilitySpec = do NAReserved NRPremium ] where - answers st body expected = + jsonBody = LB.fromStrict . B.pack + answers st body expected = asks_ st body (Right expected) + refuses st body err = asks_ st body (Left err) + asks_ st body expected = withResolverServer (resolveResp st body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - nameAvailability env navlDomain `shouldReturn` Right expected + nameAvailability env navlDomain `shouldReturn` expected navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} parseNameSpec :: Spec @@ -169,6 +195,27 @@ parseNameSpec = do it "keeps the brackets, which are what the resolver reads as a hash" $ (strEncode <$> parseN ("[" <> T.replicate 64 "b" <> "].simplex")) `shouldBe` Right (encodeUtf8 ("[" <> T.replicate 64 "b" <> "].simplex")) + -- only the second-level label is a registry key, so only it may be hashed; + -- a subname label is needed as text to reach the record + it "accepts a hashed second-level label under a subname" $ + parseN ("x.[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight + it "refuses a hashed subname label" $ + parseN ("[" <> T.replicate 64 "b" <> "].alice.simplex") `shouldSatisfy` isLeft + it "refuses a labelhash under a web TLD, which has no registry" $ + parseN ("[" <> T.replicate 64 "b" <> "].com") `shouldSatisfy` isLeft + -- the hash the client sends must be the one the resolver keys on: this is + -- keccak-256("alice"), the same constant the resolver's own tests use + it "hashes the second-level label to the registry key" $ + (fullDomainName . hashedDomain <$> parseN "alice.simplex") + `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" + it "leaves subname labels as text" $ + (fullDomainName . hashedDomain <$> parseN "x.alice.simplex") + `shouldBe` Right "x.[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" + it "leaves a web name alone, as it has no registry to key into" $ + (fullDomainName . hashedDomain <$> parseN "example.com") `shouldBe` Right "example.com" + it "does not hash a name that is already a hash" $ + (fullDomainName . hashedDomain . hashedDomain <$> parseN "alice.simplex") + `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" it "accepts a valid simplex-TLD name" $ case parseN "privacy.simplex" of Right d -> do