mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-17 10:15:51 +00:00
review fixes
This commit is contained in:
@@ -12,4 +12,3 @@ cabal.project.local~
|
||||
*.tix
|
||||
.coverage
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -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.
|
||||
`[<hash>].simplex` and `sub.[<hash>].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 <detail>` 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 <detail>` 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
|
||||
|
||||
|
||||
@@ -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_<TLD>` wherever `SNRC_REGISTRAR_<TLD>` 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.[<hash>].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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -66,6 +66,7 @@ Unrecognised payloads fall back to `0x`-prefixed raw hex.
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import unquote, urlparse
|
||||
@@ -170,12 +171,17 @@ 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. 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")
|
||||
|
||||
@@ -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):
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <baseUrl>/health; success = reachable with status < 400. The body is
|
||||
-- size-capped but NOT decoded — the probe only checks reachability.
|
||||
healthHttp :: ResolverEnv -> IO (Either ResolverError ())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+59
-1
@@ -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
|
||||
|
||||
+50
-3
@@ -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 "<html>gateway</html>" (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
|
||||
|
||||
Reference in New Issue
Block a user