Compare commits

...
29 Commits
Author SHA1 Message Date
Alain Brenzikofer 6275e7ee5e iterate /goal 2026-09-23 11:50:37 +02:00
Alain Brenzikofer c1e062ba12 cut comments 2026-09-23 11:42:03 +02:00
Alain Brenzikofer 21164fce3c retry different relay upon failure 2026-09-23 10:57:17 +02:00
Alain Brenzikofer b974dbd8c8 add ownLastBlockTs also in case no names were found 2026-09-23 10:36:54 +02:00
Alain Brenzikofer 78ebf1cb2e reused existing types 2026-09-23 09:51:32 +02:00
Alain Brenzikofer 4c8dc9f7d8 cut some comments 2026-09-23 08:54:35 +02:00
Alain Brenzikofer 1d82c0815d iterate /goal 2026-09-23 07:58:53 +02:00
Alain Brenzikofer 861e9890c6 RFC and fixes 2026-09-22 16:59:50 +02:00
Alain Brenzikofer adcdb54c02 use chain time, not host time 2026-09-22 16:50:19 +02:00
Alain Brenzikofer 2ffec2aad6 shrink diff 2026-09-22 16:40:59 +02:00
Alain Brenzikofer 3ae71f06b4 implement scanning names by owner 2026-09-22 13:18:30 +02:00
Alain BrenzikoferandClaude Opus 5 f53a6f3364 names: ROWN, the names an address owns
A recovered key has to be scanned for the accounts it already uses, which
needs a question the protocol could not ask: what does this address own.

ROWN carries one address and a page offset and is answered with ROWND,
from nameOwnedSMPVersion. The agent returns the relay it used so a scan
can ask the next account elsewhere: sending every account to one relay
would tell it they belong to one wallet.

inUse is the answer the scan acts on. Holding a name is only one way for
an account to be in use, so the resolver reports the nonce and balance it
is derived from; a scan that reads names alone hands out an account its
owner is already using.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WHaVjENni2mwBUL77SwFvr
2026-09-22 09:19:47 +00:00
Alain BrenzikoferandClaude Opus 5 5dc64f1cf4 resolver: owned-by endpoint for recovery scans
/v2/owned-by/<address> lists the names an address holds, read off the
registrar's ERC-721 index so a name acquired by transfer counts too, and
reports whether the account has been used at all.

inUse is what a recovery scan asks: holding a name is only one way to be
in use, so the nonce and balance it is derived from are reported too. A
scan that gets this wrong hands out an account its owner already uses.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WHaVjENni2mwBUL77SwFvr
2026-09-22 08:53:59 +00:00
Alain Brenzikofer 0d19ff3108 streamline 2026-09-22 09:29:13 +02:00
Alain Brenzikofer 7dcd89d30b cut comments 2026-09-22 08:57:30 +02:00
Alain Brenzikofer 31f0a74310 cut to the min 2026-09-22 08:40:14 +02:00
Alain Brenzikofer d2128507cc next review round 2026-09-22 08:00:36 +02:00
Alain Brenzikofer 33e8ef018e fix review findings iterate self-reviews with /goal 2026-09-20 20:03:54 +02:00
Alain Brenzikofer 86ac7fdad0 review fixes 2026-09-20 15:28:49 +02:00
Evgeny @ SimpleX Chat ae7a312af7 Merge branch 'master' into ab/eth-crypto 2026-09-19 15:16:22 +00:00
Alain BrenzikoferandClaude Opus 5 e6597f28b0 core: embed the BIP-39 wordlist as the upstream file
The list was transcoded into Haskell, which can only be audited by reading
2048 words against the source. The upstream english.txt is vendored instead
and embedded at compile time, so auditing it is one sha256sum, and a test
pins that checksum so the copy cannot drift.

file-embed moves into the library, where it was already a dependency of the
executables and the test suite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
2026-09-10 16:58:30 +00:00
Alain BrenzikoferandClaude Opus 5 2298704687 core: ethereumPath takes the address index
The BIP-44 path is the same for every address under an account, so the
caller that wants one names it, rather than rebuilding the path around the
one this hard-coded.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
2026-09-10 15:19:45 +00:00
Alain Brenzikofer a23da20fb5 Merge remote-tracking branch 'origin/master' into ab/eth-crypto
# Conflicts:
#	simplexmq.cabal
2026-09-08 15:40:56 +00:00
Alain Brenzikofer 088fc4ea81 streamline hex encoding 2026-09-04 11:25:49 +02:00
Alain Brenzikofer b23d3a3265 add string encoding 2026-08-18 17:58:07 +02:00
Alain Brenzikofer 2bbc55619f expose what's necessary for stealth addresses too" 2026-08-18 13:47:16 +02:00
Alain Brenzikofer 3ab14f5a93 plan update 2026-08-18 13:47:16 +02:00
Alain Brenzikofer d9953b30da tiny fixes 2026-08-18 13:47:16 +02:00
Claude 2d4c060c6d crypto: Ethereum primitives for SimpleX names
secp256k1 (vendored libsecp256k1 v0.8.0, recoverable ECDSA), BIP-39
mnemonics, BIP-32 derivation, Keccak-256, EIP-55 addresses and EIP-712
typed data hashing.

Client-side signing only: no RLP, no transaction construction, no chain
writes. The resolver path stays read-only.

Verified against published vectors — the 24 official BIP-39 English
vectors, BIP-32 spec vectors 1 and 2, the EIP-55 spec addresses and the
EIP-712 Mail example.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 13:47:16 +02:00
32 changed files with 3873 additions and 42 deletions
+3
View File
@@ -4,3 +4,6 @@
[submodule "cbits/blst"]
path = cbits/blst
url = https://github.com/supranational/blst.git
[submodule "cbits/libsecp256k1"]
path = cbits/libsecp256k1
url = https://github.com/bitcoin-core/secp256k1.git
+14
View File
@@ -1,3 +1,17 @@
# Unreleased
Crypto:
- Ethereum primitives for SimpleX names: secp256k1 keys (vendored
libsecp256k1), BIP-39 mnemonics, BIP-32 key derivation, Keccak-256 and EIP-55
addresses. Key derivation only - no signing, no transaction construction and
no chain writes; the resolver path remains read-only. See
`plans/2026-08-05-eth-crypto-bindings.md`.
SMP protocol:
- `ROWN` command (v23): the names an address owns, and whether the account has
been used at all, so a recovered wallet seed can be scanned for the accounts
already in use. See `plans/2026-09-22-names-owned-by.md`.
# 6.5.1
Version 6.5.1.0
Submodule cbits/libsecp256k1 added at 6e2c8bc4ec
+224
View File
@@ -0,0 +1,224 @@
# Ethereum crypto primitives for simplexmq
Client-side crypto for SimpleX names: enough to derive an Ethereum key and its
address from a recovery phrase. General-purpose — these modules know nothing
about names, registrars or relayers.
This is the first part of Workstream B of the SimpleX names v2 plan. The design
it serves: names are owned by a plain EOA derived per name from one BIP-39 seed
held by the device. Signing with those keys (EIP-712 typed data) and stealth addresses
land in later changes, with the code that needs them.
## What is deliberately absent
- **No RLP encoder, and no transaction building.** RLP is only needed to
construct raw transactions or EIP-7702 authorizations. The client does
neither, so the `RSLV` resolver path in this repo stays strictly read-only.
- **No signing.** This change derives keys and addresses. Signing, and the
EIP-712 typed-data hashing it needs, land with the first code that signs.
- **No BIP-32 public derivation.** We always hold the seed, so CKDpub, xpub
serialization and fingerprints are not implemented. Non-hardened *private*
derivation is, because BIP-44 paths end in non-hardened components.
- **English wordlist only.** Every English BIP-39 word is ASCII, so the NFKD
normalization BIP-39 mandates is a no-op on the mnemonic side and no
normalization dependency is needed.
## Modules
```
Simplex.Messaging.Crypto.Secp256k1 FFI to libsecp256k1
Simplex.Messaging.Crypto.BIP39 mnemonics
Simplex.Messaging.Crypto.BIP39.English generated 2048-word list
Simplex.Messaging.Crypto.BIP32 HD derivation
Simplex.Messaging.Eth.Keccak Keccak-256
Simplex.Messaging.Eth.Address addresses, EIP-55
```
## Types
```haskell
newtype Secp256k1PrivateKey -- 32 bytes, validated in [1, n-1]
newtype Secp256k1PublicKey -- libsecp256k1's opaque 64-byte form
data PubKeyFormat = Compressed | Uncompressed
data Mnemonic -- validated indexes + words, always consistent
data MnemonicStrength = MS128 | MS160 | MS192 | MS224 | MS256
data ExtendedKey = ExtendedKey {xkKey :: PrivateKey, xkChainCode :: ByteString}
newtype Address -- 20 bytes; Show renders the EIP-55 form
```
`PrivateKey`, `Mnemonic` and `ExtendedKey` have **redacting `Show` instances**,
and `PrivateKey` compares with `constEq`. These keys authorise transfers of
assets with monetary value: a derived `Show` would put one in a log the first
time anything is traced. A chain code is secret too — it plus one child key
derives siblings.
## Functions
```haskell
-- Secp256k1
mkPrivateKey :: ByteString -> Either String Secp256k1PrivateKey
unPrivateKey :: Secp256k1PrivateKey -> ByteString
secp256k1PublicKey :: Secp256k1PrivateKey -> Secp256k1PublicKey -- total: key is validated
serializePublicKey :: PubKeyFormat -> Secp256k1PublicKey -> ByteString
privateKeyTweakAdd :: Secp256k1PrivateKey -> ByteString -> Maybe Secp256k1PrivateKey
-- BIP39
entropyToMnemonic :: ByteString -> Either String Mnemonic
mnemonicToEntropy :: Mnemonic -> ByteString -- total
parseMnemonic :: ByteString -> Either String Mnemonic
mnemonicToSeed :: Mnemonic -> ByteString -> ByteString
randomMnemonic :: MnemonicStrength -> TVar ChaChaDRG -> STM Mnemonic
-- BIP32
masterKey :: ByteString -> Either String ExtendedKey
derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
renderPath :: [Word32] -> ByteString
-- Eth
keccak256 :: ByteString -> ByteString
addressFromPrivateKey :: Secp256k1PrivateKey -> Address
ethereumPath :: Word32 -> Word32 -> [Word32] -- m/44'/60'/account'/0/address
```
`Address` has a `StrEncoding` instance: `strEncode` is the EIP-55 checksummed
form and `strP` accepts bare or `0x`-prefixed hex, rejecting a bad mixed-case
checksum.
`randomMnemonic` is shaped like `Simplex.Messaging.Crypto.randomBytes` so it
composes with the agent's DRG instead of reaching for system entropy.
`parseMnemonic` lower-cases and splits on any whitespace, so a user retyping
their recovery key is not rejected for capitalising a word. This does not change
the derived seed: `mnemonicPhrase` always rebuilds the canonical lowercase
sentence from the wordlist, and that is what `mnemonicToSeed` hashes.
## How applications use it
An application defines the derivation path. For SimpleX names, one seed per
device and one account per name — see `Simplex.Chat.Wallet` in simplex-chat:
```haskell
m <- either fail pure $ parseMnemonic phrase
mk <- either fail pure $ masterKey (mnemonicToSeed m "")
xk <- either fail pure $ derivePath mk (ethereumPath account 0)
let addr = addressFromPrivateKey (xkKey xk)
```
## libsecp256k1 C API mapping
```c
secp256k1_context_create(SECP256K1_CONTEXT_NONE) /* once, then _randomize */
secp256k1_ec_seckey_verify(ctx, seckey)
secp256k1_ec_pubkey_create(ctx, pubkey, seckey)
secp256k1_ec_pubkey_serialize(ctx, output, outputlen, pubkey, flags)
secp256k1_ec_seckey_tweak_add(ctx, seckey, tweak)
```
Every call is a pure function of its arguments, which is why the module exposes
a pure API over `unsafePerformIO`. The context is created and blinded once at
first use; randomization is a side-channel countermeasure that affects no
output and mutates nothing afterwards, so one shared context is safe across
threads.
`secp256k1_ec_seckey_tweak_add` returns 0 exactly when BIP-32 says "proceed with
the next index" (tweak out of range, or a zero result), which is why
`privateKeyTweakAdd` returns `Maybe` and `derivePath` can surface it.
libsecp256k1 never reads OS entropy — the context blinding seed is supplied by
the caller. So unlike
libbbs it raises no `getentropy` / ITMS-90338 concern on iOS, and needs no
equivalent of the `commoncrypto` flag.
## Build
Submodule in `cbits/`, same pattern as blst and libbbs:
`cbits/libsecp256k1` — https://github.com/bitcoin-core/secp256k1, pinned to
**v0.8.0**.
```
c-sources: cbits/libsecp256k1/src/{secp256k1,precomputed_ecmult,precomputed_ecmult_gen}.c
include-dirs: cbits/libsecp256k1{,/include,/src}
```
Built **without** its autotools config header, and with no `-D` of our own.
Every knob has an `#ifndef` default in the headers, and the checked-in
precomputed tables are generated for those defaults. `secp256k1.c` defines
`SECP256K1_BUILD` itself.
32-bit targets (armv7a-android, i686 musl) are covered by libsecp256k1's own
fallback: `src/util.h` selects `SECP256K1_WIDEMUL_INT64` with the 10x26 field
and 8x32 scalar backends when `__SIZEOF_INT128__` is absent.
`include-dirs` order matters: libsecp256k1's directories come last, after
libbbs and blst. There are no filename collisions between the three (checked),
and C quoted includes prefer the including file's own directory anyway, but the
ordering keeps it that way if any library later adds a generically-named header.
No `flake.nix` change is needed in simplex-chat: the per-platform overrides
there only force `packages.simplexmq.components.library.libs` (external
libraries, i.e. openssl for `extra-libraries: crypto`) and flags. Vendored
`c-sources` need no nix entry, which is why blst and libbbs have none either.
### Cross-compilation status
Verified by building simplex-chat through its flake:
| Target | Result |
|---|---|
| `x86_64-linux` (native, nix) | compiles and links |
| `aarch64-android` | **compiles and links** into the final shared object |
| `armv7a-android` | libsecp256k1 compiles; final link not reached (see below) |
| `x86_64-windows` (mingw) | blocked before our code — see below |
| `aarch64-darwin-ios` | not yet run (needs a darwin host) |
`aarch64-android` is the meaningful pass: it proves the C both cross-compiles
and links into the artifact the app actually ships.
`armv7a-android` gets far enough to prove the 32-bit path compiles — that is,
libsecp256k1's `SECP256K1_WIDEMUL_INT64` fallback builds under the NDK — but the
build then dies in simplex-chat's own `Simplex.Chat.Operators`, on the
`$(embedFile "PRIVACY.md")` splice. Cross-compiled Template Haskell runs the
splice on the target via `iserv-proxy` under `qemu-arm`, and that interpreter
fails to resolve `realpath` out of `libHSdirectory` and segfaults. It is
unrelated to this work: none of these modules use Template Haskell, and
simplexmq (which does) builds for armv7a fine. So 32-bit *linking* remains
unproven, though there is no plausible mechanism by which it would fail given
aarch64 links and the 32-bit objects compile.
`x86_64-windows` fails while bootstrapping the mingw cross-GHC, long before any
of our code is considered: haskell.nix applies
`ghc-9.6-fix-code-symbol-jumps.patch` to `rts/linker/PEi386.c` twice from the
same store path, and the second application aborts. That is a duplicate entry in
the patch list of the pinned haskell.nix branch
(`github:input-output-hk/haskell.nix/armv7a`), not something this change can
influence.
Both gaps can be closed without GHC by compiling the three C files with the
cross toolchain directly and linking a program that calls into both the core and
the recovery module — that isolates the C question from the Haskell build
entirely.
## Tests
`tests/CoreTests/EthCryptoTests.hs`, 75 examples. Everything is checked against
published vectors rather than our own output:
- **BIP-39** — all 24 official English vectors from
`trezor/python-mnemonic/vectors.json`, entropy → mnemonic → entropy and
mnemonic → seed with the `TREZOR` passphrase.
- **BIP-32** — spec test vectors 1 (all six chains) and 2. Expected private keys
and chain codes were decoded from the published `xprv` base58 strings, since
we do not implement xprv serialization.
- **EIP-55** — the four addresses from the EIP-55 spec, round-tripped.
- **BIP-44** — the well-known `0x9858EfFD232B4033E47d90003D41EC34EcaEda94` for
the `abandon … about` mnemonic at `m/44'/60'/0'/0/0`, plus accounts 1 and 2.
- Keccak-256 against SHA3-256, so the padding-byte confusion cannot pass.
- Negative cases: zero, short and out-of-range private keys, bad BIP-39
checksums and word counts, out-of-range seeds, and bad EIP-55 checksums.
The BIP-44 expectations were additionally reproduced by an independent
pure-Python secp256k1 reference written for the purpose, so they are not just
our implementation agreeing with itself.
+90
View File
@@ -0,0 +1,90 @@
# Owned-by: the names an address owns
A recovered seed does not say how many of its accounts have been used. Until it
does, a restored device cannot take a fresh account without risking one its
owner already has, and cannot find the names it already holds. This adds the
question the protocol could not ask: what does this address own.
This is Workstream B of the SimpleX names v2 plan, after the Ethereum crypto
primitives. The client consumer is the wallet recovery scan in simplex-chat.
## What this delivers
```
GET /v2/owned-by/<address>?offset=N resolver, JSON OwnedNames
ROWN <address> <offset> -> ROWND SMP v23 (nameOwnedSMPVersion)
ownedSimplexNames agent, over ownedNames in the server
```
The agent returns the relay it used alongside the answer, so a caller can pass
it back as used and ask the next account elsewhere. A relay that cannot answer
at all - too old for ROWN, unreachable, or with no resolver of its own behind it
- is not the end of the lookup: it asks another, so a scan does not fail
wholesale on the one relay it happened to draw during a version rollout. Sending every account of a
seed to one relay would tell that relay the accounts belong to one wallet; the
scan therefore asks one account per relay, and `getNextNameServer` avoids the
hosts already used where the configured set allows. `proxiedSMPRelayVersion` is
pinned to the current version so ROWN can be proxied at all — a scan that falls
back to a direct session hands the relay its address with its IP. The pin is
necessary but not sufficient: under the default `SPMUnknown` proxy mode a
configured names server is a known host, so the scan reaches it directly, as
RSLV already does.
Enumeration is read off the ERC-721 registrar (`balanceOf`,
`tokenOfOwnerByIndex`, `labelOf`), not from registration logs: the token is the
name, so a name acquired by transfer counts. Each name is then answered with the
`NameResponse` resolving it gives, so the client that scans can list the names
and act on them without asking again — the reason the per-name cost is worth
paying. The registrar does not maintain enumeration on expiry, so a name past
its grace is still enumerated; it answers as available, naming nothing the
account holds, so it is left out while still counting towards `inUse`.
## What `inUse` actually answers
`inUse` is `holds a name now, or nonce > 0, or balance > 0`, on the one chain
the resolver is configured with. Holding a name is only one way for an account
to be in use: an account that was funded, or ever sent a transaction, is in use
with no name, and a scan that reads names alone hands out an account its owner
is already using. Within that scope the signal is sound — an EOA's balance can
only fall through its own transaction, which bumps the nonce, so "funded then
drained" still reads as used, and an EIP-7702 delegation leaves the authority's
nonce incremented, so it needs no separate code check.
It does not see:
- **Accounts holding only other tokens.** An EOA can hold ERC-20s or NFTs with
nonce 0 and no ether. ERC-20 has no reverse index, so answering this needs
`eth_getLogs` over Transfer topics for all history, which providers cap, or an
external indexer. Out of scope here.
- **Accounts used on another chain.** The same key is the same address on every
L2 and sidechain; this chain's nonce and balance stay 0.
- **History, as opposed to current state.** `balanceOf` is present ownership. A
name registered for an account by a controller — which leaves the owner at
nonce 0 — and later transferred away, or lapsed and re-registered by someone
else, leaves no trace.
The cost is bounded. A name-holding account is always found, so no account that
holds a name is ever mislabelled. A missed account matters only as a gap-limit
reset: the scan can stop up to `scanGapLimit` indexes early and not reach a name
beyond that. That is recovery incompleteness, recoverable by scanning again on a
better signal, not loss. In the other direction a dust transfer marks an account
used; the indexes come from hardened derivation with no published xpub, so they
cannot be enumerated to be dusted.
## What is deliberately absent
- **No token or indexer integration**, per the limits above.
- **Nonce and balance are not carried over SMP.** The resolver reports them, and
`OwnedNames` keeps only the names, the flag and the cursor, so a client cannot
yet see why an account is in use.
- **No caching, and no batching.** One owned-by is a `balanceOf` per configured
TLD, then for each name the same reads resolving it takes - roughly fifteen
`eth_call`s - plus the nonce and balance, all issued one at a time, so it is
forked on
the server the same way RSLV is and nothing is memoised. The page size trades
against both the relay's response cap and its timeout, and it bounds names per
registrar rather than per page, so a second TLD doubles a full page.
- **Enumeration is not atomic.** `balanceOf` and each `tokenOfOwnerByIndex` are
separate calls at `latest`, so a transfer between them can duplicate or skip an
entry, and successive pages can straddle blocks. Pinning every call to one
block number is the fix if it ever matters for a recovery scan.
+51 -2
View File
@@ -70,6 +70,7 @@ Version 21, 2026-07-05
- [Resolver commands](#resolver-commands)
- [Resolve name command](#resolve-name-command)
- [Name record response](#name-record-response)
- [Names owned by an address command](#names-owned-by-an-address-command)
- [Transport connection with the SMP router](#transport-connection-with-the-SMP-router)
- [General transport protocol considerations](#general-transport-protocol-considerations)
- [TLS transport encryption](#tls-transport-encryption)
@@ -86,7 +87,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 22. Versions 1-5 are discontinued. The version history:
This document describes SMP protocol version 23. Versions 1-5 are discontinued. The version history:
- v1: binary protocol encoding
- v2: message flags (used to control notifications)
@@ -109,6 +110,7 @@ This document describes SMP protocol version 22. Versions 1-5 are discontinued.
- v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD
- v21: server public information in handshake
- v22: `RNAME` says whether a name can be registered, not only what it resolves to
- v23: the names an address owns (ROWN command, ROWND response) — direct or forwarded via PFWD
## Introduction
@@ -1453,7 +1455,8 @@ companion REST resolver process (`scripts/resolver/snrc-resolve.py`) that
queries the SNRC contract on Ethereum; alternative backings (different chains,
DHT, etc.) are valid as long as they expose the documented HTTP shape (`GET
/v2/resolve/<query>` returning a `NameRegistration` on 200 for every
registration shape, 400 for unknown TLDs, 502 for upstream failures) or
registration shape, 400 for unknown TLDs, 502 for upstream failures, and from
SMP v23 `GET /v2/owned-by/<address>?offset=N` returning an `ownedNames`) or
substitute a different transport returning the same JSON. The resolver API is
versioned separately from this protocol: `/v1/resolve/<name>` returns a bare
`NameRecord` and is what relays before v22 call as `/resolve/<name>`.
@@ -1623,6 +1626,52 @@ The names router caps the resolver response it will accept
stays within the SMP proxied transmission budget of 16224 bytes; a response over
the cap is `ERR NAME RESOLVER`.
#### Names owned by an address command
`ROWN` asks which names an address holds, and whether the account has been used
at all, so a device restoring a wallet seed can find the accounts already in
use. It is unauthenticated and accepted direct or forwarded, as `RSLV` is, from
v23.
```abnf
rown = %s"ROWN" SP address offset
address = length "0x" 40HEXDIG ; EIP-55 checksummed, length-prefixed
length = 1*1 OCTET ; 42
offset = 4*4 OCTET ; Word32, network byte order: where to resume listing
```
```abnf
rownd = %s"ROWND" SP ownedNames
```
`ownedNames` is a UTF-8 JSON object consuming the remainder of the transmission.
| Field | JSON type | Constraints |
|---|---|---|
| `lastBlockTs` | number | the oldest block any read behind this answer saw |
| `names` | array | the names the address holds, each the `nameResponse` resolving it answers with |
| `inUse` | boolean | whether the account has been used at all |
| `nextOffset` | number | cursor to resume from, absent when the listing is complete |
Each name is answered in full, as `RSLV` answers it, so a client can list the
names and act on them without resolving each one again. Enumeration is not
maintained on expiry, so the registrar still enumerates a name past its grace;
it answers as `available` and names nothing the account holds, so it is left out
of `names` while still counting towards `inUse`.
As with `RNAME`, a router reads through a node of its own, which can lag. Every
name carries the block it was read at, and `lastBlockTs` is the oldest of those
and of the enumeration's own block, so a client can tell a badly lagging router
even from an answer that carries no names — which is the answer a recovery scan
acts on.
`inUse` is what the registry could see on its own chain — the account's nonce,
its balance, and every name it holds, which is not only the names listed here:
a later page of a held account still reports `inUse` true with an empty `names`. Holding a name is only one way for an account
to be in use, and an account used only for other tokens, or on another chain,
reads as unused, so a client MUST NOT treat `inUse` as false meaning the account
has never been used.
## Transport connection with the SMP router
### General transport protocol considerations
+37 -2
View File
@@ -93,7 +93,7 @@ curl -s http://127.0.0.1:8000/v2/resolve/foobar.testing | jq
| reth p2p | `:30303` tcp/udp | Ethereum sync (open on firewall) |
| nimbus p2p | `:9000` tcp/udp | beacon sync (open on firewall) |
| nimbus REST | `127.0.0.1:5052` | beacon API |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/v2/resolve`, `/resolve`, `/health`) |
| **resolver** | `127.0.0.1:8000` | SNRC REST (`/v2/resolve`, `/v2/owned-by`, `/resolve`, `/health`) |
## Caveats
@@ -121,12 +121,13 @@ standalone for local dev (no Docker), via [`uv`](https://docs.astral.sh/uv/):
uv run scripts/resolver/service/snrc-resolve.py # defaults to local reth + mainnet .testing
```
Three routes, versioned separately from the protocol so each only changes when
Four routes, versioned separately from the protocol so each only changes when
its own shape does:
| Route | Called by | Answers |
|---|---|---|
| `/v2/resolve/<query>` | routers from SMP v22 | a `NameRegistration` |
| `/v2/owned-by/<address>` | routers from SMP v23 | the names an address owns |
| `/resolve/<name>` | routers before SMP v22 | a name record, flat |
| `/health` | anyone | readiness |
@@ -165,6 +166,35 @@ label, so a hashed query cannot be answered with a name. See
A subname reports the expiry and grace of the 2LD above it, since that is what
bounds its lifetime. A subname nobody created reports as not registered.
### v2: `/v2/owned-by/<address>?offset=N`
The body is the SMP protocol's `OwnedNames`: the names the address holds, each
the same `NameResponse` `/v2/resolve` answers with, and `inUse`, whether the
account has been used at all. The router decodes it as is and forwards it, so
the fields are specified with the wire, in the **Names owned by an address
command** section of
[`protocol/simplex-messaging.md`](../../protocol/simplex-messaging.md).
Answering each name in full is what lets a caller list and act on them without a
second request for each. `lastBlockTs` is the oldest block any of the reads saw,
so an answer with no names still says how far behind the node was. Enumeration comes off the registrar's ERC-721 index, so
a name acquired by transfer counts; one past its grace is still enumerated but
answers as available, so it is left out while still counting towards `inUse`.
`offset` is the cursor to resume from, and it counts per registrar, so a page
holds up to `SNRC_MAX_OWNED` names for each configured TLD. `nextOffset` is the
next cursor, or null when the listing is complete. `inUse` covers every name the
address holds, not only the page, so a later page reports it true with an empty
`names`.
| Status | Meaning |
|---|---|
| 200 | the names the address holds, with `inUse` |
| 400 | `badAddress`, `badOffset`, `noRegistrarConfigured` |
| 502 | `upstreamError` |
Error bodies carry `address`, a fixed `error` code and a `message`.
### v1: `/resolve/<name>`
What routers before SMP v22 call. Its shape is unrelated to v2's: the record is
@@ -374,3 +404,8 @@ here.
To override any of them, set `SNRC_REGISTRY_<TLD>`, `SNRC_REGISTRAR_<TLD>` or
`SNRC_CONTROLLER_<TLD>` on the `resolver` service in `docker-compose.yml`, or
as env vars when you run the script directly.
`SNRC_MAX_OWNED` (default 16) caps the names `/v2/owned-by` returns per
registrar per page. A router will not read a body over 16000 bytes, and a client
cannot ask for a smaller page, so keep it times the number of configured TLDs
well under that.
+109 -5
View File
@@ -44,6 +44,8 @@ Environment:
SNRC_CONTROLLER_<TLD> SimplexController (proxy) for the TLD; reservations,
and through its `prices()` oracle what registering costs
(default: mainnet for .testing, empty for .simplex)
SNRC_MAX_OWNED Names /v2/owned-by returns per registrar per page
(default: 16)
SNRC_PORT Listen port (default: 8000)
SNRC_BIND Bind address (default: 0.0.0.0)
@@ -69,7 +71,7 @@ import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
from urllib.parse import parse_qs, unquote, urlparse
from urllib.request import Request, urlopen
from eth_hash.auto import keccak
@@ -124,6 +126,9 @@ COIN_DOT = 354
ZERO_ADDR = "0x0000000000000000000000000000000000000000"
# Names per registrar per /owned-by page; times the configured TLDs, a full page of NameResponses must fit the 16000 bytes a relay reads.
MAX_OWNED = max(1, int(os.environ.get("SNRC_MAX_OWNED", "8")))
# The registry prices in attoUSD (1e-18 USD); the protocol carries US cents.
@@ -417,6 +422,14 @@ def encode_uint(value: int) -> str:
return value.to_bytes(32, "big").hex()
def encode_address(address: str) -> str:
return (12 * "00") + address[2:].lower()
def is_address(address: str) -> bool:
return len(address) == 42 and address.startswith("0x") and all(c in "0123456789abcdef" for c in address[2:].lower())
def encode_text_call(node: bytes, key: str) -> str:
sel = selector("text(bytes32,string)")
head = node.hex() + (0x40).to_bytes(32, "big").hex()
@@ -875,12 +888,89 @@ def resolve(name: str):
}
def owned_by(address: str, offset: int = 0):
"""Every name an address holds, across every configured TLD, with the
account's on-chain footprint.
Enumeration is read off the ERC-721 registrar, so a name acquired by
transfer counts. Each name is answered with the NameResponse /v2/resolve
gives for it, so a caller can list and act on them without asking again.
`lastBlockTs` is the oldest block any of those reads saw, so a caller can
tell a resolver lagging critically behind even when it holds no names.
`inUse` is what a recovery scan asks, derived from this chain's nonce and
balance and every name it holds, not only the page returned: an account
holding only other tokens is not seen, and neither is one used on another
chain.
"""
if not is_address(address):
return 400, {
"address": address,
"error": "badAddress",
"message": "expected a 0x-prefixed 20-byte address",
}
if offset < 0:
return 400, {
"address": address,
"error": "badOffset",
"message": "offset is a position in the listing",
}
configured = {t: r for t, r in REGISTRARS.items() if r}
if not configured:
return 400, {
"address": address,
"error": "noRegistrarConfigured",
"message": "no registrar is configured on this resolver",
"configuredTlds": [],
}
# the oldest block behind the answer: this one, or any name's own
last_block_ts = chain_now()
names, truncated, total_held = [], False, 0
for tld, registrar in configured.items():
held = decode_uint(eth_call(registrar, selector("balanceOf(address)") + encode_address(address)))
total_held += held
last = min(offset + MAX_OWNED, held)
if last < held:
truncated = True
for i in range(offset, last):
token = decode_uint(
eth_call(registrar, selector("tokenOfOwnerByIndex(address,uint256)") + encode_address(address) + encode_uint(i))
)
label = registered_label(registrar, token)
if not label:
continue
status, body = registration(label + "." + tld)
# one past its grace answers as available, naming nothing it holds
if status == 200 and body["registration"]["type"] == "registered":
names.append(body)
if body["lastBlockTs"] is not None:
last_block_ts = min(last_block_ts, body["lastBlockTs"])
nonce = decode_uint(rpc("eth_getTransactionCount", [address, "latest"]))
balance = decode_uint(rpc("eth_getBalance", [address, "latest"]))
names.sort(key=lambda n: n["registration"]["nameRecord"]["name"])
return 200, {
"address": address,
"lastBlockTs": last_block_ts,
"names": names,
"nonce": nonce,
"balance": str(balance),
# every name held, not just this page, or page two reads as owning nothing
"inUse": total_held > 0 or nonce > 0 or balance > 0,
"offset": offset,
"nextOffset": offset + MAX_OWNED if truncated else None,
"checkedTlds": sorted(configured),
}
# ---------- HTTP layer ----------
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - http.server contract
path = urlparse(self.path).path
parts = [unquote(p) for p in path.split("/") if p]
parsed = urlparse(self.path)
parts = [unquote(p) for p in parsed.path.split("/") if p]
if parts == ["health"]:
self._respond(200, {"ok": True, "rpc": RPC, "registries": REGISTRIES, **head_block()})
@@ -898,6 +988,20 @@ class Handler(BaseHTTPRequestHandler):
self._respond(status, body)
return
if len(parts) == 3 and parts[0] == "v2" and parts[1] == "owned-by":
address = parts[2].strip().lower()
try:
offset = int(parse_qs(parsed.query).get("offset", ["0"])[0])
except ValueError:
self._respond(400, {"address": address, "error": "badOffset", "message": "offset is a decimal position in the listing"})
return
try:
status, body = owned_by(address, offset)
except Exception as e: # surface upstream errors as 502
status, body = 502, upstream_error({"address": address}, e)
self._respond(status, body)
return
# /v1/resolve is an alias: relays before SMP v22 call /resolve
if parts[:2] == ["v1", "resolve"] and len(parts) == 3:
parts = ["resolve", parts[2]]
@@ -926,7 +1030,7 @@ class Handler(BaseHTTPRequestHandler):
{
"error": "noSuchRoute",
"message": "not found",
"routes": ["/health", "/v2/resolve/<query>", "/v1/resolve/<name>", "/resolve/<name>"],
"routes": ["/health", "/v2/resolve/<query>", "/v2/owned-by/<address>", "/v1/resolve/<name>", "/resolve/<name>"],
},
)
@@ -952,7 +1056,7 @@ def main():
)
for tld, addr in REGISTRIES.items():
sys.stderr.write(f" .{tld:<8s} = {addr or '(not configured)'}\n")
sys.stderr.write(" GET /v2/resolve/<name> GET /v1/resolve/<name> GET /health\n")
sys.stderr.write(" GET /v2/resolve/<name> GET /v2/owned-by/<address> GET /v1/resolve/<name> GET /health\n")
try:
server.serve_forever()
except KeyboardInterrupt:
@@ -1035,5 +1035,156 @@ class RegistrationV2Tests(unittest.TestCase):
self.assertEqual(res["lastBlockTs"], self.now)
self.assertEqual(res["registration"]["type"], "available")
class OwnedByTests(unittest.TestCase):
"""`/v2/owned-by` answers what a recovery scan asks: which names an account
holds, and whether the account has been used at all. Enumeration is read
off the registrar's ERC-721 index, so a name acquired by transfer counts
the same as one registered here."""
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
ADDR = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"
GRACE = 90 * 86400
# keccak-256("alice")
ALICE = 0x9C0257114EB9399A2985F8E75DAD7600C5D89FE3824FFA99EC1C3EB8BF3B0501
BLOCK_TS = 1813000000
def setUp(self):
self._saved = (snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration, snrc.chain_now)
snrc.REGISTRARS = {"testing": self.REGISTRAR, "simplex": ""}
snrc.rpc = lambda method, params: "0x0"
snrc.chain_now = lambda: self.BLOCK_TS
# RegistrationV2Tests covers resolving one; owned-by picks which
snrc.registration = lambda name: (200, self.response(name, "registered"))
def tearDown(self):
snrc.REGISTRARS, snrc.eth_call, snrc.rpc, snrc.registration, snrc.chain_now = self._saved
def response(self, name, type_, block_ts=None):
reg = {"type": type_}
if type_ == "registered":
reg["nameRecord"] = {"name": name}
return {"lastBlockTs": self.BLOCK_TS if block_ts is None else block_ts, "registration": reg}
def _chain(self, held, label=b"alice"):
def call(to, data):
sel = data[:10]
if sel == snrc.selector("balanceOf(address)"):
return snrc.encode_uint(held)
if sel == snrc.selector("tokenOfOwnerByIndex(address,uint256)"):
return snrc.encode_uint(self.ALICE)
if sel == snrc.selector("labelOf(uint256)"):
return "0x" + snrc.encode_uint(32) + snrc.encode_uint(len(label)) + label.hex() + "00" * ((-len(label)) % 32)
raise AssertionError("unexpected call " + sel)
return call
def names(self, body):
return [n["registration"]["nameRecord"]["name"] for n in body["names"]]
def test_a_held_name_is_answered_as_a_full_registration(self):
"""The caller acts on the names straight away, so each is the same
NameResponse resolving it would give."""
snrc.eth_call = self._chain(1)
status, body = snrc.owned_by(self.ADDR)
self.assertEqual(status, 200)
self.assertEqual(self.names(body), ["alice.testing"])
self.assertEqual(body["names"][0]["registration"]["type"], "registered")
self.assertIn("lastBlockTs", body["names"][0])
def test_a_name_past_its_grace_is_not_listed_as_held(self):
"""Enumeration keeps it until someone re-registers, but it answers as
available, which names nothing the account still holds."""
snrc.eth_call = self._chain(1)
snrc.registration = lambda name: (200, self.response(name, "available"))
_, body = snrc.owned_by(self.ADDR)
self.assertEqual(body["names"], [])
self.assertTrue(body["inUse"])
def test_holding_a_name_is_in_use(self):
snrc.eth_call = self._chain(1)
_, body = snrc.owned_by(self.ADDR)
self.assertTrue(body["inUse"])
def test_an_account_that_sent_a_transaction_is_in_use_with_no_name(self):
"""The case a names-only scan gets wrong: an account in use, holding
nothing, would be handed out again."""
snrc.eth_call = self._chain(0)
snrc.rpc = lambda method, params: "0x3" if method == "eth_getTransactionCount" else "0x0"
_, body = snrc.owned_by(self.ADDR)
self.assertEqual(body["names"], [])
self.assertEqual(body["nonce"], 3)
self.assertTrue(body["inUse"])
def test_a_funded_account_is_in_use_with_no_name_and_no_nonce(self):
snrc.eth_call = self._chain(0)
snrc.rpc = lambda method, params: "0x0" if method == "eth_getTransactionCount" else "0xde0b6b3a7640000"
_, body = snrc.owned_by(self.ADDR)
self.assertEqual(body["balance"], "1000000000000000000")
self.assertTrue(body["inUse"])
def test_an_untouched_account_is_not_in_use(self):
snrc.eth_call = self._chain(0)
_, body = snrc.owned_by(self.ADDR)
self.assertFalse(body["inUse"])
self.assertIsNone(body["nextOffset"])
def test_more_names_than_a_page_carry_the_cursor_to_resume_from(self):
snrc.eth_call = self._chain(snrc.MAX_OWNED + 1)
_, body = snrc.owned_by(self.ADDR)
self.assertEqual(body["nextOffset"], snrc.MAX_OWNED)
self.assertEqual(len(body["names"]), snrc.MAX_OWNED)
def test_a_malformed_address_is_refused(self):
status, body = snrc.owned_by("0xnothex")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badAddress")
def test_a_page_past_the_end_still_reports_the_account_in_use(self):
"""inUse is a property of the account, not of the page: an empty later
page must not read as an account that owns nothing."""
snrc.eth_call = self._chain(1)
_, body = snrc.owned_by(self.ADDR, snrc.MAX_OWNED)
self.assertEqual(body["names"], [])
self.assertTrue(body["inUse"])
def test_a_label_that_is_not_utf8_is_reported_not_fatal(self):
"""The registrar stores the label bytes unchecked, so one bad label
must not take down the whole listing."""
snrc.eth_call = self._chain(1, label=b"\xff\xfe")
status, body = snrc.owned_by(self.ADDR)
self.assertEqual(status, 200)
self.assertEqual(len(body["names"]), 1)
def test_an_account_with_no_names_still_reports_the_block_it_was_read_at(self):
"""inUse false is the answer a scan acts on, so it has to carry how
stale the registry it came from is."""
snrc.eth_call = self._chain(0)
_, body = snrc.owned_by(self.ADDR)
self.assertEqual(body["names"], [])
self.assertEqual(body["lastBlockTs"], self.BLOCK_TS)
def test_the_oldest_block_of_every_read_is_the_one_reported(self):
"""A name resolved against a node further behind is what the caller
has to judge the whole answer by."""
snrc.eth_call = self._chain(1)
snrc.registration = lambda name: (200, self.response(name, "registered", self.BLOCK_TS - 600))
_, body = snrc.owned_by(self.ADDR)
self.assertEqual(body["lastBlockTs"], self.BLOCK_TS - 600)
def test_a_negative_offset_is_refused(self):
snrc.eth_call = self._chain(1)
status, body = snrc.owned_by(self.ADDR, -1)
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badOffset")
def test_no_configured_registrar_is_an_error_not_an_empty_answer(self):
"""An empty list would read as "this key owns nothing", which is the
one answer a scan must not invent."""
snrc.REGISTRARS = {"testing": "", "simplex": ""}
status, body = snrc.owned_by(self.ADDR)
self.assertEqual(status, 400)
self.assertEqual(body["error"], "noRegistrarConfigured")
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -21,6 +21,7 @@ license-file: LICENSE
build-type: Simple
extra-source-files:
README.md
src/Simplex/Messaging/Crypto/BIP39/english.txt
CHANGELOG.md
cbits/sha512.h
cbits/sntrup761.h
@@ -31,6 +32,8 @@ extra-source-files:
cbits/blst/**/*.asm
cbits/libbbs/**/*.c
cbits/libbbs/**/*.h
cbits/libsecp256k1/**/*.c
cbits/libsecp256k1/**/*.h
apps/common/Web/static/index.html
apps/common/Web/static/link.html
apps/common/Web/static/media/apk_icon.png
@@ -135,7 +138,11 @@ library
Simplex.Messaging.Crypto.Lazy
Simplex.Messaging.Crypto.Ratchet
Simplex.Messaging.Crypto.BBS
Simplex.Messaging.Crypto.BIP32
Simplex.Messaging.Crypto.BIP39
Simplex.Messaging.Crypto.BIP39.English
Simplex.Messaging.Crypto.Entitlement
Simplex.Messaging.Crypto.Secp256k1
Simplex.Messaging.Crypto.SNTRUP761
Simplex.Messaging.Crypto.SNTRUP761.Bindings
Simplex.Messaging.Crypto.SNTRUP761.Bindings.Defines
@@ -144,6 +151,8 @@ library
Simplex.Messaging.Crypto.ShortLink
Simplex.Messaging.Encoding
Simplex.Messaging.Encoding.String
Simplex.Messaging.Eth.Address
Simplex.Messaging.Eth.Keccak
Simplex.Messaging.Names.Record
Simplex.Messaging.Notifications.Client
Simplex.Messaging.Notifications.Protocol
@@ -326,6 +335,10 @@ library
cbits/blst/src
cbits/libbbs/include
cbits/libbbs/src
cbits/libsecp256k1
cbits/libsecp256k1/include
cbits/libsecp256k1/src
-- libsecp256k1 is built without its autotools config header: every knob has an #ifndef default matching the checked-in tables
cc-options: -D__BLST_PORTABLE__
if flag(commoncrypto)
cc-options: -DBBS_CRYPTO_CC
@@ -340,6 +353,9 @@ library
cbits/libbbs/src/compat-string.c
cbits/libbbs/src/sha256.c
cbits/libbbs/src/shake256.c
cbits/libsecp256k1/src/secp256k1.c
cbits/libsecp256k1/src/precomputed_ecmult.c
cbits/libsecp256k1/src/precomputed_ecmult_gen.c
asm-sources:
cbits/blst/build/assembly.S
extra-libraries:
@@ -367,6 +383,7 @@ library
, cryptostore ==0.3.*
, data-default ==0.7.*
, directory ==1.3.*
, file-embed >=0.0.10 && <0.1
, filepath ==1.4.*
, hourglass ==0.2.*
, http-types ==0.12.*
@@ -544,6 +561,7 @@ test-suite simplexmq-test
CoreTests.CryptoFileTests
CoreTests.CryptoTests
CoreTests.EncodingTests
CoreTests.EthCryptoTests
CoreTests.MsgStoreTests
CoreTests.RetryIntervalTests
CoreTests.SOCKSSettings
+26 -2
View File
@@ -67,6 +67,7 @@ module Simplex.Messaging.Agent
deleteConnShortLink,
getConnShortLink,
resolveSimplexName,
ownedSimplexNames,
getConnLinkPrivKey,
deleteLocalInvShortLink,
changeConnectionUser,
@@ -219,13 +220,15 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
import qualified Simplex.Messaging.Crypto.ShortLink as SL
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Names.Record (OwnedNames)
import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode (NtfRegCode), NtfTknStatus (..), NtfTokenId, PNMessageData (..), pnMessagesP)
import Simplex.Messaging.Notifications.Types
import Simplex.Messaging.Parsers (defaultJSON, parse)
import Simplex.Messaging.Protocol
( BrokerMsg,
Cmd (..),
ErrorType (AUTH),
ErrorType (AUTH, NAME),
MsgBody,
MsgFlags (..),
NameResponse,
@@ -465,6 +468,11 @@ resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDoma
resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain
{-# INLINE resolveSimplexName #-}
-- | Names an address owns, with the relay used, so a scan can ask the next account elsewhere.
ownedSimplexNames :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AE (SMPServer, OwnedNames)
ownedSimplexNames c nm userId used addr offset = withAgentEnv c $ ownedSimplexNames' c nm userId used addr offset
{-# INLINE ownedSimplexNames #-}
getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519)
getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c
{-# INLINE getConnLinkPrivKey #-}
@@ -1272,9 +1280,25 @@ deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -
resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse
resolveSimplexName' c nm userId domain = do
resolverSrv <- getNextNameServer c userId
resolverSrv <- getNextNameServer c userId []
resolveName c nm userId resolverSrv domain
ownedSimplexNames' :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AM (SMPServer, OwnedNames)
ownedSimplexNames' c nm userId used addr offset = tryRelays ownedNamesRelays used
where
tryRelays attempts tried = do
srv <- getNextNameServer c userId tried
((srv,) <$> ownedNames c nm userId srv addr offset) `catchError` \e ->
if attempts > 1 && cannotAnswer e then tryRelays (attempts - 1) (srv : tried) else throwE e
-- cannot answer, as against answers: too old for ROWN, unreachable, or with no resolver
cannotAnswer e = temporaryOrHostError e || case e of
SMP _ (NAME _) -> True
_ -> False
-- | Relays one owned-names lookup asks before it gives up: during a version rollout the first one picked may have no ROWN.
ownedNamesRelays :: Int
ownedNamesRelays = 3
changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM ()
changeConnectionUser' c oldUserId connId newUserId = do
SomeConn _ conn <- withStore c (`getConn` connId)
+15 -3
View File
@@ -70,6 +70,7 @@ module Simplex.Messaging.Agent.Client
secureGetQueueLink,
getQueueLink,
resolveName,
ownedNames,
getNextNameServer,
enableQueueNotifications,
EnableQueueNtfReq (..),
@@ -258,6 +259,8 @@ import Simplex.Messaging.Crypto.BBS (BBSPresHeader (..), BBSPublicKey)
import Simplex.Messaging.Crypto.Entitlement (EntitlementCredential, EntitlementProof, generateEntitlementProof)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Names.Record (OwnedNames)
import Simplex.Messaging.Notifications.Client
import Simplex.Messaging.Notifications.Protocol
import Simplex.Messaging.Notifications.Transport (NTFVersion)
@@ -2029,14 +2032,23 @@ resolveName c nm userId server domain =
resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain
resolveDirectly smp = directResolveName smp nm domain
-- | Names an address owns, from one names-capable relay, proxied as resolveName is.
ownedNames :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> Address -> Word32 -> AM OwnedNames
ownedNames c nm userId server addr offset =
snd <$> sendOrProxySMPCommand c nm userId server "" "ROWN" NoEntity ownedViaProxy ownedDirectly
where
ownedViaProxy smp proxySess = proxyOwnedNames smp nm proxySess addr offset
ownedDirectly smp = directOwnedNames smp nm addr offset
-- | Pick a names-capable server for the user (the agent owns server selection,
-- accounting for the names role). nameSrvs is opt-in (a plain list); empty means
-- no server resolves names - a declared agent error, never a fallback.
getNextNameServer :: AgentClient -> UserId -> AM SMPServer
getNextNameServer c userId =
-- Used servers are avoided where the set allows, operator first: one operator asked about every account learns the wallet.
getNextNameServer :: AgentClient -> UserId -> [SMPServer] -> AM SMPServer
getNextNameServer c userId usedSrvs =
liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case
Just UserServers {nameSrvs} -> case L.nonEmpty nameSrvs of
Just srvs -> protoServer <$> pickServer srvs
Just srvs -> protoServer . snd <$> getNextServer_ srvs (usedOperatorsHosts srvs usedSrvs)
Nothing -> throwE NO_NAME_SERVERS
Nothing -> throwE $ INTERNAL "unknown userId - no user servers"
+24
View File
@@ -75,6 +75,8 @@ module Simplex.Messaging.Client
proxySMPMessage,
proxyResolveName,
directResolveName,
proxyOwnedNames,
directOwnedNames,
forwardSMPTransmission,
getSMPQueueInfo,
sendProtocolCommand,
@@ -154,6 +156,7 @@ import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime)
import Data.Word (Word32)
import qualified Data.X509 as X
import qualified Data.X509.Validation as XV
import Network.Socket (HostName, ServiceName)
@@ -162,6 +165,8 @@ import Numeric.Natural
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Names.Record (OwnedNames)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Protocol
import Simplex.Messaging.Protocol.Types
@@ -1076,6 +1081,25 @@ directResolveName c nm name
r -> throwE $ unexpectedResponse r
| otherwise = throwE $ PCETransportError TEVersion
-- | Names an address owns, via PFWD: a scan links accounts, so hiding the client IP matters more here than for one name.
proxyOwnedNames :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> Address -> Word32 -> ExceptT SMPClientError IO (Either ProxyClientError OwnedNames)
proxyOwnedNames c nm proxiedRelay addr offset
| prVersion proxiedRelay >= nameOwnedSMPVersion =
proxySMPCommand c nm proxiedRelay Nothing NoEntity (ROWN addr offset) >>= \case
Right (ROWND owned) -> pure $ Right owned
Right r -> throwE $ unexpectedResponse r
Left e -> pure $ Left e
| otherwise = throwE $ PCETransportError TEVersion
-- | Direct (non-PFWD) owned-names lookup, exposing the client IP.
directOwnedNames :: SMPClient -> NetworkRequestMode -> Address -> Word32 -> ExceptT SMPClientError IO OwnedNames
directOwnedNames c nm addr offset
| thVersion (thParams c) >= nameOwnedSMPVersion =
sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (ROWN addr offset)) >>= \case
ROWND owned -> pure owned
r -> throwE $ unexpectedResponse r
| otherwise = throwE $ PCETransportError TEVersion
resolvedNameOrNotFound :: SimplexDomain -> NameResponse -> Bool
resolvedNameOrNotFound d NameResponse {registration} = case registration of
NRRegistered {nameRecord} -> T.toLower (nrName nameRecord) == fullDomainName d
+90
View File
@@ -0,0 +1,90 @@
{-# LANGUAGE OverloadedStrings #-}
-- | BIP-32 HD derivation over secp256k1, private only: we hold the seed, so CKDpub, xpub and fingerprints are not implemented.
module Simplex.Messaging.Crypto.BIP32
( ExtendedKey (..),
masterKey,
derivePath,
renderPath,
hardenedOffset,
hardened,
)
where
import Control.Monad (foldM)
import qualified Crypto.Hash as H
import qualified Crypto.MAC.HMAC as HMAC
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.List (intercalate)
import Data.Word (Word32)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Encoding (smpEncode)
-- | An extended private key: the key plus its chain code, secret too, as it and one child derive the siblings.
data ExtendedKey = ExtendedKey
{ xkKey :: S.Secp256k1PrivateKey,
xkChainCode :: ByteString
}
deriving (Eq)
instance Show ExtendedKey where
show _ = "ExtendedKey <redacted>"
-- | Child indexes at or above this are hardened.
hardenedOffset :: Word32
hardenedOffset = 0x80000000
-- | @hardened 44 == 44'@. An index at or above 'hardenedOffset' is returned unchanged, so this is idempotent rather than overflowing.
hardened :: Word32 -> Word32
hardened i
| isHardened i = i
| otherwise = i + hardenedOffset
isHardened :: Word32 -> Bool
isHardened i = i >= hardenedOffset
-- | Derive the master key from a BIP-39 seed (BIP-32 allows 16 to 64 bytes).
masterKey :: ByteString -> Either String ExtendedKey
masterKey seed
| seedLen < 16 || seedLen > 64 =
Left $ "seed: expected 16 to 64 bytes, got " <> show seedLen
| otherwise = case S.mkPrivateKey il of
Left _ -> Left "seed: invalid master key, use a different seed"
Right k -> Right ExtendedKey {xkKey = k, xkChainCode = ir}
where
seedLen = B.length seed
i = hmacSHA512 "Bitcoin seed" seed
il = B.take 32 i
ir = B.drop 32 i
-- | CKDpriv. 'Left' only in the negligible case BIP-32 calls "proceed with the next index", which no real seed reaches.
deriveChild :: ExtendedKey -> Word32 -> Either String ExtendedKey
deriveChild xk i =
case S.privateKeyTweakAdd (xkKey xk) il of
Nothing -> Left $ "derivation: invalid child at index " <> show i <> ", use the next index"
Just k -> Right ExtendedKey {xkKey = k, xkChainCode = ir}
where
dat
| isHardened i = B.singleton 0 <> S.unPrivateKey (xkKey xk) <> smpEncode i
| otherwise = S.serializePublicKey S.Compressed (S.secp256k1PublicKey (xkKey xk)) <> smpEncode i
hm = hmacSHA512 (xkChainCode xk) dat
il = B.take 32 hm
ir = B.drop 32 hm
derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
derivePath = foldM deriveChild
renderPath :: [Word32] -> ByteString
renderPath is = BC.pack $ intercalate "/" ("m" : map component is)
where
component i
| isHardened i = show (i - hardenedOffset) <> "'"
| otherwise = show i
hmacSHA512 :: ByteString -> ByteString -> ByteString
hmacSHA512 key msg = BA.convert (HMAC.hmac key msg :: HMAC.HMAC H.SHA512)
+161
View File
@@ -0,0 +1,161 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | BIP-39 mnemonics, English only: every word is ASCII, so BIP-39's NFKD normalization is a no-op; a passphrase is bytes the caller normalizes.
module Simplex.Messaging.Crypto.BIP39
( Mnemonic,
MnemonicStrength (..),
mnemonicWords,
mnemonicPhrase,
entropyToMnemonic,
mnemonicToEntropy,
parseMnemonic,
mnemonicToSeed,
randomMnemonic,
strengthWordCount,
)
where
import Control.Concurrent.STM
import Crypto.Hash (SHA512 (..))
import qualified Crypto.KDF.PBKDF2 as PBKDF2
import Crypto.Number.Serialize (i2ospOf_, os2ip)
import Crypto.Random (ChaChaDRG)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bits (shiftL, shiftR, (.&.), (.|.))
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.Char (isSpace, toLower)
import Data.IntMap.Strict (IntMap)
import qualified Data.IntMap.Strict as IM
import Data.List (foldl')
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BIP39.English (englishWordList)
import Simplex.Messaging.Parsers (parseAll)
-- | A BIP-39 mnemonic: the word indexes and the words they name.
data Mnemonic = Mnemonic
{ mnemonicIndexes :: [Int],
mnemonicWords :: [ByteString]
}
deriving (Eq)
instance Show Mnemonic where
show m = "Mnemonic <" <> show (length (mnemonicIndexes m)) <> " words, redacted>"
-- | Entropy size, named by bit length as BIP-39 does.
data MnemonicStrength = MS128 | MS160 | MS192 | MS224 | MS256
deriving (Eq, Show, Bounded, Enum)
strengthBytes :: MnemonicStrength -> Int
strengthBytes = \case
MS128 -> 16
MS160 -> 20
MS192 -> 24
MS224 -> 28
MS256 -> 32
-- | 12, 15, 18, 21 or 24.
strengthWordCount :: MnemonicStrength -> Int
strengthWordCount s = (entBits + entBits `div` 32) `div` 11
where
entBits = strengthBytes s * 8
-- | BIP-39 seeds are always 64 bytes, whatever the entropy size.
seedSize :: Int
seedSize = 64
validEntropySizes :: [Int]
validEntropySizes = map strengthBytes [minBound .. maxBound]
validWordCounts :: [Int]
validWordCounts = map strengthWordCount [minBound .. maxBound]
-- Wordlist indexes
wordByIndex :: IntMap ByteString
wordByIndex = IM.fromList $ zip [0 ..] englishWordList
{-# NOINLINE wordByIndex #-}
indexByWord :: Map ByteString Int
indexByWord = M.fromList $ zip englishWordList [0 ..]
{-# NOINLINE indexByWord #-}
-- | The mnemonic as one space-separated phrase, the exact bytes BIP-39 feeds to PBKDF2.
mnemonicPhrase :: Mnemonic -> ByteString
mnemonicPhrase = BC.unwords . mnemonicWords
-- | Build a mnemonic from raw entropy of 16, 20, 24, 28 or 32 bytes.
entropyToMnemonic :: ByteString -> Either String Mnemonic
entropyToMnemonic ent
| entLen `notElem` validEntropySizes =
Left $ "entropy: expected 16, 20, 24, 28 or 32 bytes, got " <> show entLen
| otherwise = Right $ mnemonicFromIndexes $ entropyToIndexes ent
where
entLen = B.length ent
-- | Entropy to 11-bit word indexes: assumes a validated length, and masks every result into @[0, 2047]@.
entropyToIndexes :: ByteString -> [Int]
entropyToIndexes ent =
[fromIntegral ((combined `shiftR` (11 * (n - 1 - i))) .&. 0x7FF) | i <- [0 .. n - 1]]
where
entBits = B.length ent * 8
csBits = entBits `div` 32
-- csBits is at most 8 (256/32), so the first checksum byte always suffices.
csByte = B.head (C.sha256Hash ent)
combined = os2ip ent `shiftL` csBits .|. fromIntegral (csByte `shiftR` (8 - csBits))
n = (entBits + csBits) `div` 11
-- | The entropy the mnemonic encodes.
mnemonicToEntropy :: Mnemonic -> ByteString
mnemonicToEntropy m = i2ospOf_ (entBits `div` 8) (combined `shiftR` csBits)
where
idxs = mnemonicIndexes m
totalBits = length idxs * 11
entBits = totalBits * 32 `div` 33
csBits = totalBits - entBits
combined = foldl' (\acc i -> acc `shiftL` 11 .|. fromIntegral i) (0 :: Integer) idxs
-- | Parse and fully validate a phrase: word count, wordlist membership, checksum. Any whitespace separates, and lower-casing does not change the seed.
parseMnemonic :: ByteString -> Either String Mnemonic
parseMnemonic = parseAll mnemonicP
mnemonicP :: A.Parser Mnemonic
mnemonicP = do
ws <- A.skipWhile isSpace *> (wordP `A.sepBy'` A.takeWhile1 isSpace) <* A.skipWhile isSpace
let n = length ws
if n `notElem` validWordCounts
then fail $ "mnemonic: expected 12, 15, 18, 21 or 24 words, got " <> show n
else do
idxs <- traverse lookupWord ws
let m = mnemonicFromIndexes idxs
-- stripping the checksum bits and recomputing them is the check: wrong bits cannot survive the round trip
if entropyToIndexes (mnemonicToEntropy m) == idxs
then pure m
else fail "mnemonic: checksum mismatch"
where
wordP = BC.map toLower <$> A.takeWhile1 (not . isSpace)
lookupWord w = maybe (fail $ "mnemonic: not in wordlist: " <> BC.unpack w) pure $ M.lookup w indexByWord
-- | PBKDF2-HMAC-SHA512, 2048 iterations, salt @\"mnemonic\" <> passphrase@; pass an empty passphrase for the common case.
mnemonicToSeed :: Mnemonic -> ByteString -> ByteString
mnemonicToSeed m passphrase =
PBKDF2.generate
(PBKDF2.prfHMAC SHA512)
PBKDF2.Parameters {PBKDF2.iterCounts = 2048, PBKDF2.outputLength = seedSize}
(mnemonicPhrase m)
("mnemonic" <> passphrase :: ByteString)
-- | Generate a fresh mnemonic from the agent's DRG.
randomMnemonic :: MnemonicStrength -> TVar ChaChaDRG -> STM Mnemonic
randomMnemonic s gVar = mnemonicFromIndexes . entropyToIndexes <$> C.randomBytes (strengthBytes s) gVar
-- Internal
-- | Indexes are in @[0, 2047]@: an 11-bit mask, or a lookup in the wordlist itself.
mnemonicFromIndexes :: [Int] -> Mnemonic
mnemonicFromIndexes idxs =
Mnemonic {mnemonicIndexes = idxs, mnemonicWords = map (wordByIndex IM.!) idxs}
@@ -0,0 +1,12 @@
{-# LANGUAGE TemplateHaskell #-}
-- | The BIP-39 English wordlist, embedded verbatim from <https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt>, pinned by a checksum test.
module Simplex.Messaging.Crypto.BIP39.English (englishWordList) where
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as BC
import Data.FileEmbed (embedFile)
-- | All 2048 words, in BIP-39 index order (index 0 is @abandon@).
englishWordList :: [ByteString]
englishWordList = BC.lines $(embedFile "src/Simplex/Messaging/Crypto/BIP39/english.txt")
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE ForeignFunctionInterface #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | FFI bindings to libsecp256k1. Every operation is a pure function of its arguments, hence the pure API over 'unsafePerformIO'.
module Simplex.Messaging.Crypto.Secp256k1
( Secp256k1PrivateKey,
Secp256k1PublicKey,
PubKeyFormat (..),
mkPrivateKey,
unPrivateKey,
secp256k1PublicKey,
serializePublicKey,
privateKeyTweakAdd,
)
where
import Control.Monad (when)
import Crypto.Random (drgNew, randomBytesGenerate)
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Unsafe as BU
import Foreign
import Foreign.C
import System.IO.Unsafe (unsafePerformIO)
-- Sizes
-- | A secp256k1 scalar is 32 bytes, big-endian.
privateKeySize :: Int
privateKeySize = 32
-- | SEC1 compressed point: @0x02@/@0x03@ prefix and the x coordinate.
compressedSize :: Int
compressedSize = 33
-- | SEC1 uncompressed point: @0x04@ prefix, x, y.
uncompressedSize :: Int
uncompressedSize = 65
-- | Internal size of @secp256k1_pubkey@ (opaque, not a serialization).
pubKeyInternalSize :: Int
pubKeyInternalSize = 64
-- Types
-- | A validated private key, 32 bytes in @[1, n-1]@: no 'Show', so it cannot be logged by accident, and constant-time 'Eq'.
newtype Secp256k1PrivateKey = Secp256k1PrivateKey ByteString
instance Eq Secp256k1PrivateKey where
Secp256k1PrivateKey a == Secp256k1PrivateKey b = BA.constEq a b
-- | A public key in libsecp256k1's opaque 64-byte form; 'serializePublicKey' gives the SEC1 bytes.
newtype Secp256k1PublicKey = Secp256k1PublicKey ByteString
deriving newtype (Eq, Show)
-- | SEC1 output format for 'serializePublicKey'.
data PubKeyFormat = Compressed | Uncompressed
deriving (Eq, Show)
-- FFI
data Ctx
data PubKeyRaw
foreign import ccall "secp256k1_context_create"
c_context_create :: CUInt -> IO (Ptr Ctx)
foreign import ccall "secp256k1_context_randomize"
c_context_randomize :: Ptr Ctx -> Ptr Word8 -> IO CInt
foreign import ccall "secp256k1_ec_seckey_verify"
c_ec_seckey_verify :: Ptr Ctx -> Ptr Word8 -> IO CInt
foreign import ccall "secp256k1_ec_pubkey_create"
c_ec_pubkey_create :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt
foreign import ccall "secp256k1_ec_pubkey_serialize"
c_ec_pubkey_serialize :: Ptr Ctx -> Ptr Word8 -> Ptr CSize -> Ptr PubKeyRaw -> CUInt -> IO CInt
foreign import ccall "secp256k1_ec_seckey_tweak_add"
c_ec_seckey_tweak_add :: Ptr Ctx -> Ptr Word8 -> Ptr Word8 -> IO CInt
-- SECP256K1_CONTEXT_NONE = SECP256K1_FLAGS_TYPE_CONTEXT
contextNone :: CUInt
contextNone = 1
-- | The process-wide context, created and blinded once. Blinding affects no output and nothing mutates it, so sharing it across threads is safe.
secp256k1Ctx :: Ptr Ctx
secp256k1Ctx = unsafePerformIO $ do
ctx <- c_context_create contextNone
when (ctx == nullPtr) $ ioError (userError "secp256k1_context_create failed")
drg <- drgNew
let (seed :: ByteString, _) = randomBytesGenerate 32 drg
rc <- BU.unsafeUseAsCString seed $ \p -> c_context_randomize ctx (castPtr p)
when (rc /= 1) $ ioError (userError "secp256k1_context_randomize failed")
pure ctx
{-# NOINLINE secp256k1Ctx #-}
-- Helpers
withBS :: ByteString -> (Ptr Word8 -> IO a) -> IO a
withBS bs f = BU.unsafeUseAsCString bs $ f . castPtr
packPtr :: Ptr Word8 -> Int -> IO ByteString
packPtr p n = B.packCStringLen (castPtr p, n)
-- | Marshal a 'Secp256k1PublicKey' back into its opaque C representation.
withPubKeyRaw :: Secp256k1PublicKey -> (Ptr PubKeyRaw -> IO a) -> IO a
withPubKeyRaw (Secp256k1PublicKey bs) f = withBS bs $ f . castPtr
-- Public API
-- | Reject zero and anything at or above the group order, which is what makes 'secp256k1PublicKey' total.
mkPrivateKey :: ByteString -> Either String Secp256k1PrivateKey
mkPrivateKey bs
| B.length bs /= privateKeySize = Left $ "private key: expected 32 bytes, got " <> show (B.length bs)
| otherwise = unsafePerformIO $ withBS bs $ \p -> do
rc <- c_ec_seckey_verify secp256k1Ctx p
pure $ if rc == 1 then Right (Secp256k1PrivateKey bs) else Left "private key: not in [1, n-1]"
unPrivateKey :: Secp256k1PrivateKey -> ByteString
unPrivateKey (Secp256k1PrivateKey bs) = bs
-- | Derive the public key. Total, because 'Secp256k1PrivateKey' is validated.
secp256k1PublicKey :: Secp256k1PrivateKey -> Secp256k1PublicKey
secp256k1PublicKey (Secp256k1PrivateKey sk) = unsafePerformIO $
allocaBytes pubKeyInternalSize $ \pkPtr ->
withBS sk $ \skPtr -> do
rc <- c_ec_pubkey_create secp256k1Ctx pkPtr skPtr
-- Cannot fail: the key was verified by mkPrivateKey.
when (rc /= 1) $ ioError (userError "secp256k1_ec_pubkey_create failed on a validated key")
Secp256k1PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
serializePublicKey :: PubKeyFormat -> Secp256k1PublicKey -> ByteString
serializePublicKey fmt pk = unsafePerformIO $
allocaBytes outLen $ \outPtr ->
alloca $ \lenPtr ->
withPubKeyRaw pk $ \pkPtr -> do
poke lenPtr (fromIntegral outLen)
rc <- c_ec_pubkey_serialize secp256k1Ctx outPtr lenPtr pkPtr flag
when (rc /= 1) $ ioError (userError "secp256k1_ec_pubkey_serialize failed")
written <- peek lenPtr
packPtr outPtr (fromIntegral written)
where
-- SECP256K1_EC_COMPRESSED = FLAGS_TYPE_COMPRESSION | FLAGS_BIT_COMPRESSION, SECP256K1_EC_UNCOMPRESSED = FLAGS_TYPE_COMPRESSION
(flag, outLen) = case fmt of
Compressed -> (2 .|. 256, compressedSize)
Uncompressed -> (2, uncompressedSize)
-- | @sk + tweak mod n@, as BIP-32 child derivation needs. 'Nothing' when the result is zero or the tweak is out of range.
privateKeyTweakAdd :: Secp256k1PrivateKey -> ByteString -> Maybe Secp256k1PrivateKey
privateKeyTweakAdd (Secp256k1PrivateKey sk) tweak
| B.length tweak /= privateKeySize = Nothing
| otherwise = unsafePerformIO $
allocaBytes privateKeySize $ \skPtr ->
withBS tweak $ \twPtr -> do
withBS sk $ \src -> copyBytes skPtr src privateKeySize
rc <- c_ec_seckey_tweak_add secp256k1Ctx skPtr twPtr
if rc == 1
then Just . Secp256k1PrivateKey <$> packPtr skPtr privateKeySize
else pure Nothing
+97
View File
@@ -0,0 +1,97 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Ethereum addresses: derivation from a public key, and EIP-55 mixed-case checksum encoding.
module Simplex.Messaging.Eth.Address
( Address,
addressFromPrivateKey,
ethereumPath,
)
where
import Control.Applicative (optional, (<|>))
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bits (shiftR, (.&.))
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.Char (isDigit, isHexDigit, isLower, isUpper, toLower)
import Data.Word (Word32, Word8)
import Simplex.Messaging.Crypto.BIP32 (hardened)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Keccak (keccak256)
import Simplex.Messaging.Util ((<$?>))
-- | A 20-byte Ethereum address. 'Show' renders the EIP-55 checksummed form, as pasted into a block explorer.
newtype Address = Address ByteString
deriving (Eq, Ord)
instance Show Address where
show = BC.unpack . checksumAddress
-- | EIP-55 checksummed hex. Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum.
instance StrEncoding Address where
strEncode = checksumAddress
strP = do
_ <- optional $ A.string "0x" <|> A.string "0X"
body <- A.takeWhile isHexDigit
if B.length body /= addressSize * 2
then fail $ "address: expected 40 hex digits, got " <> show (B.length body)
else case fromHex (BC.map toLower body) of
Left _ -> fail "address: not hexadecimal"
Right bs
| mixedCase body && checksumAddress (Address bs) /= "0x" <> body ->
fail "address: EIP-55 checksum mismatch"
| otherwise -> pure $ Address bs
where
mixedCase body = BC.any isUpper letters && BC.any isLower letters
where
letters = BC.filter (not . isDigit) body
-- | The EIP-55 hex, length-prefixed, so a field following it is not swallowed by the hex parser.
instance Encoding Address where
smpEncode = smpEncode . strEncode
smpP = strDecode <$?> smpP
addressSize :: Int
addressSize = 20
-- | The low 20 bytes of @keccak256@ of the uncompressed public key with its @0x04@ SEC1 prefix removed.
addressFromPublicKey :: S.Secp256k1PublicKey -> Address
addressFromPublicKey pk =
Address . B.drop 12 . keccak256 . B.drop 1 $ S.serializePublicKey S.Uncompressed pk
addressFromPrivateKey :: S.Secp256k1PrivateKey -> Address
addressFromPrivateKey = addressFromPublicKey . S.secp256k1PublicKey
-- | EIP-55: @0x@ and 40 hex digits whose case encodes a checksum over the lowercase hex form.
checksumAddress :: Address -> ByteString
checksumAddress (Address bs) = "0x" <> B.pack (zipWith adjust [0 ..] (B.unpack lowerHex))
where
lowerHex = toHex bs
hashed = keccak256 lowerHex
adjust :: Int -> Word8 -> Word8
adjust i c
| isHexLetter c && nibbleAt i >= 8 = upper c
| otherwise = c
nibbleAt i =
let byte = B.index hashed (i `div` 2)
in if even i then byte `shiftR` 4 else byte .&. 0x0F
isHexLetter c = c >= 0x61 && c <= 0x66 -- 'a'..'f'
upper c = c - 0x20
-- | BIP-44 path for Ethereum account @i@, address @k@: @m\/44'\/60'\/i'\/0\/k@. The account must be below @hardenedOffset@.
ethereumPath :: Word32 -> Word32 -> [Word32]
ethereumPath account address = [hardened 44, hardened 60, hardened account, 0, address]
-- hex via memory's Base16, which emits the lowercase form EIP-55 starts from; 'checksumAddress' is what introduces case
toHex :: ByteString -> ByteString
toHex = BAE.convertToBase BAE.Base16
-- | Decodes base16; a non-hex or odd-length input is a Left.
fromHex :: ByteString -> Either String ByteString
fromHex = BAE.convertFromBase BAE.Base16
+12
View File
@@ -0,0 +1,12 @@
-- | Keccak-256, the hash Ethereum uses. Not SHA3-256: they differ in the padding byte and produce entirely different digests.
module Simplex.Messaging.Eth.Keccak
( keccak256,
)
where
import Crypto.Hash (Digest, Keccak_256, hash)
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
keccak256 :: ByteString -> ByteString
keccak256 bs = BA.convert (hash bs :: Digest Keccak_256)
+14
View File
@@ -13,6 +13,7 @@ module Simplex.Messaging.Names.Record
NamePricing (..),
USDCents (..),
NameReservedReason (..),
OwnedNames (..),
)
where
@@ -90,6 +91,17 @@ data NameRegistration
NRReserved {reservedReason :: NameReservedReason}
deriving (Eq, Show)
-- | What the registry holds for an address: the names it owns, each as the 'NameResponse' resolving it gives, and whether the account is in use
data OwnedNames = OwnedNames
{ -- | the oldest block any of the reads behind this answer saw, so a lagging registry shows even where no name does
ownLastBlockTs :: Maybe SystemSeconds,
ownNames :: [NameResponse],
ownInUse :: Bool,
-- | the cursor to resume from, absent when the listing is complete
ownNextOffset :: Maybe Int
}
deriving (Eq, Show)
-- | Enough to price the name locally, which the router cannot do behind a hash.
data NamePricing = NamePricing
{ -- | US cents per year, for the lengths the registry prices specially
@@ -142,3 +154,5 @@ $(JQ.deriveJSON defaultJSON ''NamePricing)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "NR") ''NameRegistration)
$(JQ.deriveJSON defaultJSON ''NameResponse)
$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "own"} ''OwnedNames)
+18 -1
View File
@@ -261,7 +261,7 @@ import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock.System (SystemTime (..), systemToUTCTime)
import Data.Type.Equality
import Data.Word (Word8, Word16)
import Data.Word (Word8, Word16, Word32)
import GHC.TypeLits (ErrorMessage (..), TypeError, type (+))
import qualified GHC.TypeLits as TE
import qualified GHC.TypeLits as Type
@@ -271,6 +271,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), FromField (..), ToField (.
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Names.Record
import Simplex.Messaging.Parsers
import Simplex.Messaging.Protocol.Types
@@ -610,6 +611,8 @@ data Command (p :: Party) where
RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay
-- Resolve SimpleX name.
RSLV :: NameQuery -> Command Resolver
-- Names an address owns, and whether the account has been used at all.
ROWN :: Address -> Word32 -> Command Resolver
deriving instance Show (Command p)
@@ -747,6 +750,8 @@ data BrokerMsg where
PONG :: BrokerMsg
-- What the router knows about a SimpleX name.
RNAME :: NameResponse -> BrokerMsg
-- What the router knows about the names an address owns.
ROWND :: OwnedNames -> BrokerMsg
deriving (Eq, Show)
data RcvMessage = RcvMessage
@@ -958,6 +963,7 @@ data CommandTag (p :: Party) where
NSUB_ :: CommandTag Notifier
NSUBS_ :: CommandTag NotifierService
RSLV_ :: CommandTag Resolver
ROWN_ :: CommandTag Resolver
data CmdTag = forall p. PartyI p => CT (SParty p) (CommandTag p)
@@ -985,6 +991,7 @@ data BrokerMsgTag
| ERR_
| PONG_
| RNAME_
| ROWND_
deriving (Show)
class ProtocolMsgTag t where
@@ -1022,6 +1029,7 @@ instance PartyI p => Encoding (CommandTag p) where
NSUB_ -> "NSUB"
NSUBS_ -> "NSUBS"
RSLV_ -> "RSLV"
ROWN_ -> "ROWN"
smpP = messageTagP
instance ProtocolMsgTag CmdTag where
@@ -1051,6 +1059,7 @@ instance ProtocolMsgTag CmdTag where
"NSUB" -> Just $ CT SNotifier NSUB_
"NSUBS" -> Just $ CT SNotifierService NSUBS_
"RSLV" -> Just $ CT SResolver RSLV_
"ROWN" -> Just $ CT SResolver ROWN_
_ -> Nothing
instance Encoding CmdTag where
@@ -1081,6 +1090,7 @@ instance Encoding BrokerMsgTag where
ERR_ -> "ERR"
PONG_ -> "PONG"
RNAME_ -> "RNAME"
ROWND_ -> "ROWND"
smpP = messageTagP
instance ProtocolMsgTag BrokerMsgTag where
@@ -1104,6 +1114,7 @@ instance ProtocolMsgTag BrokerMsgTag where
"ERR" -> Just ERR_
"PONG" -> Just PONG_
"RNAME" -> Just RNAME_
"ROWND" -> Just ROWND_
_ -> Nothing
-- | SMP message body format
@@ -1846,6 +1857,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where
PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s)
RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s)
RSLV q -> e (RSLV_, ' ', if v >= nameAvailSMPVersion then hashedQuery q else q)
ROWN addr offset -> e (ROWN_, ' ', addr, offset)
where
e :: Encoding a => a -> ByteString
e = smpEncode
@@ -1871,6 +1883,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where
PFWD {} -> entityCmd
RFWD _ -> noAuthCmd
RSLV _ -> noAuthCmd
ROWN _ _ -> noAuthCmd
SUB -> serviceCmd
NSUB -> serviceCmd
-- other client commands must have both signature and queue ID
@@ -1953,6 +1966,7 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where
| v >= rcvServiceSMPVersion -> Cmd SNotifierService <$> (NSUBS <$> _smpP <*> smpP)
| otherwise -> pure $ Cmd SNotifierService $ NSUBS (-1) mempty
CT SResolver RSLV_ -> Cmd SResolver . RSLV <$> _smpP
CT SResolver ROWN_ -> Cmd SResolver <$> (ROWN <$> _smpP <*> smpP)
fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg
{-# INLINE fromProtocolError #-}
@@ -1995,6 +2009,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where
| v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing}
_ -> err
PONG -> e PONG_
ROWND owned -> e (ROWND_, ' ', Tail $ LB.toStrict $ J.encode owned)
RNAME res
| v >= nameAvailSMPVersion -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode res)
| otherwise -> case registration res of
@@ -2046,6 +2061,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where
OK_ -> pure OK
ERR_ -> ERR <$> _smpP
PONG_ -> pure PONG
ROWND_ -> fmap ROWND . J.eitherDecodeStrict . unTail <$?> _smpP
RNAME_
| v >= nameAvailSMPVersion -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP
| otherwise -> fmap (RNAME . oldResponse) . J.eitherDecodeStrict . unTail <$?> _smpP
@@ -2074,6 +2090,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where
RRES _ -> noEntityMsg
ALLS -> noEntityMsg
RNAME {} -> noEntityMsg
ROWND {} -> noEntityMsg
-- other broker responses must have queue ID
_
| B.null entId -> Left $ CMD NO_ENTITY
+21 -5
View File
@@ -88,6 +88,7 @@ import Data.Time.Clock.System (SystemTime (..), getSystemTime)
import Data.Time.Format.ISO8601 (iso8601Show)
import Data.Type.Equality
import Data.Typeable (cast)
import Data.Word (Word32)
import qualified Data.X509 as X
import qualified Data.X509.Validation as XV
import GHC.Conc.Signal
@@ -103,13 +104,14 @@ import Simplex.Messaging.Client.Agent (OwnServer, SMPClientAgent (..), SMPClient
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Protocol
import Simplex.Messaging.Server.Control
import Simplex.Messaging.Server.Env.STM as Env
import Simplex.Messaging.Server.Expiration
import Simplex.Messaging.Server.MsgStore
import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages)
import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, resolveName)
import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, ownedNames, resolveName)
import Simplex.Messaging.Server.MsgStore.STM
import Simplex.Messaging.Server.MsgStore.Types
import Simplex.Messaging.Server.NtfStore
@@ -1276,6 +1278,7 @@ verifyQueueTransmission service thAuth (tAuth, authorized, (corrId, entId, comma
vc SProxiedClient _ = VRVerified Nothing
vc SProxyService (RFWD _) = VRVerified Nothing
vc SResolver (RSLV _) = VRVerified Nothing
vc SResolver (ROWN _ _) = VRVerified Nothing
checkRole = case (service, partyClientRole p) of
(Just THClientService {serviceRole}, Just role) -> serviceRole == role
_ -> True
@@ -1506,6 +1509,18 @@ client
answered = \case
NRRegistered {} -> True
_ -> v >= nameAvailSMPVersion
resolverMsg :: VersionSMP -> NamesEnv -> Command Resolver -> M s BrokerMsg
resolverMsg v nenv = \case
RSLV d -> resolveNameMsg v nenv d
ROWN addr offset -> ownedNamesMsg nenv addr offset
ownedNamesMsg :: NamesEnv -> Address -> Word32 -> M s BrokerMsg
ownedNamesMsg nenv addr offset = do
st <- asks (rslvStats . serverStats)
(selector, msg) <-
liftIO (ownedNames nenv addr offset) <&> \case
Right owned -> (rslvSucc, ROWND owned)
Left e -> (rslvResolverErrs, ERR $ NAME e)
incStat (selector st) $> msg
transportErr :: TransportError -> ErrorType
transportErr = PROXY . BROKER . TRANSPORT
mkIncProxyStats :: MonadIO m => ProxyStats -> ProxyStats -> OwnServer -> (ProxyStats -> IORef Int) -> m ()
@@ -1520,9 +1535,9 @@ client
SEND flags msgBody -> response <$> withQueue_ False err (sendMessage flags msgBody)
Cmd SIdleClient PING -> pure $ response (corrId, NoEntity, PONG)
Cmd SProxyService (RFWD encBlock) -> (response . (corrId, NoEntity,) =<<) <$> processForwardedCommand encBlock
Cmd SResolver (RSLV d) -> rslvNamesEnv >>= \case
Cmd SResolver command -> rslvNamesEnv >>= \case
Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER))
Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolveNameMsg (thVersion thParams') nenv d)
Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolverMsg (thVersion thParams') nenv command)
Cmd SSenderLink command -> case command of
LKEY k -> withQueue $ \q qr -> checkMode QMMessaging qr $ secureQueue_ q k $>> getQueueLink_ q qr
LGET -> withQueue $ \q qr -> checkContact qr $ getQueueLink_ q qr
@@ -2150,10 +2165,10 @@ client
-- rejectOrVerify filters allowed commands, no need to repeat it here.
Left r -> pure $ Just r
Right t''@(_, (corrId', entId', cmd')) -> case cmd' of
Cmd SResolver (RSLV d) -> lift $ rslvNamesEnv >>= \case
Cmd SResolver command -> lift $ rslvNamesEnv >>= \case
Nothing -> pure $ Just (corrId', entId', ERR (NAME NO_RESOLVER))
Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity $ do
msg <- resolveNameMsg (thVersion clntTHParams) nenv d
msg <- resolverMsg (thVersion clntTHParams) nenv command
either ERR id <$> runExceptT (encodeResp (corrId', entId', msg))
-- INTERNAL because processCommand never returns Nothing for sender commands;
-- `fst` drops the empty message only returned for SUB.
@@ -2175,6 +2190,7 @@ client
Cmd SSenderLink (LKEY _) -> True
Cmd SSenderLink LGET -> True
Cmd SResolver (RSLV _) -> True
Cmd SResolver (ROWN _ _) -> True
_ -> False
verified = \case
VRVerified q -> Right (q, t'')
+20 -3
View File
@@ -12,6 +12,7 @@ module Simplex.Messaging.Server.Names
closeNamesEnv,
pingEndpoint,
resolveName,
ownedNames,
)
where
@@ -21,7 +22,11 @@ import Data.Bifunctor (first)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1)
import Data.Word (Word32)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Names.Record (OwnedNames)
import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameResponse)
import Simplex.Messaging.Server.Names.HttpResolver
( ResolverEnv,
@@ -30,6 +35,7 @@ import Simplex.Messaging.Server.Names.HttpResolver
closeResolverEnv,
healthHttp,
newResolverEnv,
ownedByHttp,
resolveHttp,
)
import System.Timeout (timeout)
@@ -60,9 +66,12 @@ pingEndpoint NamesEnv {resolverEnv, config} =
fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv)
resolveName :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse)
resolveName env q = do
r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env q))
case r of
resolveName env q = resolverCall env $ fetch env q
-- | Bound the call by the configured timeout, and report a raised exception as an error, leaving async exceptions alone.
resolverCall :: NamesEnv -> IO (Either NameErrorType a) -> IO (Either NameErrorType a)
resolverCall env a =
E.try (timeout (resolverTimeoutMs (config env) * 1000) a) >>= \case
Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result)
Left e
| Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e
@@ -74,6 +83,14 @@ fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse)
fetch NamesEnv {resolverEnv} q =
first mapResolverError <$> resolveHttp resolverEnv (decodeLatin1 $ smpEncode q)
-- | The names an address owns. A resolver without the endpoint answers 404, which is a RESOLVER error, not an empty list.
ownedNames :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames)
ownedNames env addr offset = resolverCall env $ fetchOwned env addr offset
fetchOwned :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames)
fetchOwned NamesEnv {resolverEnv} addr offset =
first mapResolverError <$> ownedByHttp resolverEnv (decodeLatin1 $ strEncode addr) offset
mapResolverError :: ResolverError -> NameErrorType
mapResolverError = \case
HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code))
@@ -11,6 +11,7 @@
-- GET /v2/resolve/<query> -> 200 with a NameRegistration JSON document, for
-- all three registration shapes; 400 for unknown
-- TLDs, 502 for upstream RPC failures
-- GET /v2/owned-by/<addr> -> 200 with an OwnedNames JSON document
-- GET /v1/resolve/<name> -> 200 with a NameRecord, what relays before SMP
-- v22 call as /resolve
-- GET /health -> 200 when the resolver process is ready
@@ -29,6 +30,7 @@ module Simplex.Messaging.Server.Names.HttpResolver
newResolverEnv,
closeResolverEnv,
resolveHttp,
ownedByHttp,
healthHttp,
)
where
@@ -42,6 +44,7 @@ import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Word (Word32)
import Network.HTTP.Client
( HttpException,
Manager,
@@ -59,7 +62,7 @@ import qualified Network.HTTP.Client as HC
import Network.HTTP.Client.TLS (tlsManagerSettings)
import qualified Network.HTTP.Types as HT
import Network.HTTP.Types.URI (urlEncode)
import Simplex.Messaging.Names.Record (NameResponse)
import Simplex.Messaging.Names.Record (NameResponse, OwnedNames)
data RpcAuth = AuthBearer Text | AuthBasic Text Text
@@ -117,6 +120,12 @@ resolveHttp env q =
(>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict)
<$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q)))
-- | The address is encoded for the same reason the name is: nothing from a client reaches the path raw.
ownedByHttp :: ResolverEnv -> Text -> Word32 -> IO (Either ResolverError OwnedNames)
ownedByHttp env addr offset =
(>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict)
<$> httpGet env ("/v2/owned-by/" <> B.unpack (urlEncode True (encodeUtf8 addr)) <> "?offset=" <> show offset)
-- | 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 ())
+3 -3
View File
@@ -465,11 +465,11 @@ prometheusMetrics sm rtm ts =
in "# Names\n\
\# -----\n\
\\n\
\# HELP simplex_smp_names_reqs Total RSLV requests forwarded to this server.\n\
\# HELP simplex_smp_names_reqs Total resolver requests (RSLV and ROWN) forwarded to this server.\n\
\# TYPE simplex_smp_names_reqs counter\n\
\simplex_smp_names_reqs " <> mshow _rslvReqs <> "\n# rslvReqs\n\
\\n\
\# HELP simplex_smp_names_success NameRecord resolved, or availability answered.\n\
\# HELP simplex_smp_names_success NameRecord resolved, availability answered, or names owned listed.\n\
\# TYPE simplex_smp_names_success counter\n\
\simplex_smp_names_success " <> mshow _rslvSucc <> "\n# rslvSucc\n\
\\n\
@@ -481,7 +481,7 @@ prometheusMetrics sm rtm ts =
\# TYPE simplex_smp_names_resolver_errs counter\n\
\simplex_smp_names_resolver_errs " <> mshow _rslvResolverErrs <> "\n# rslvResolverErrs\n\
\\n\
\# HELP simplex_smp_names_disabled RSLV requests rejected because no resolver is configured (names role off).\n\
\# HELP simplex_smp_names_disabled Resolver requests rejected because no resolver is configured (names role off).\n\
\# TYPE simplex_smp_names_disabled counter\n\
\simplex_smp_names_disabled " <> mshow _rslvDisabled <> "\n# rslvDisabled\n\
\\n"
+10 -7
View File
@@ -54,6 +54,7 @@ module Simplex.Messaging.Transport
namesSMPVersion,
serverInfoSMPVersion,
nameAvailSMPVersion,
nameOwnedSMPVersion,
simplexMQVersion,
smpBlockSize,
TransportConfig (..),
@@ -177,6 +178,7 @@ smpBlockSize = 16384
-- 20 - public namespaces resolver, RSLV command (6/20/2026)
-- 21 - server public information in handshake (7/5/2026)
-- 22 - RNAME answers name availability as well as the record (7/25/2026)
-- 23 - ROWN command, the names an address owns (9/22/2026)
data SMPVersion
@@ -218,6 +220,10 @@ serverInfoSMPVersion = VersionSMP 21
nameAvailSMPVersion :: VersionSMP
nameAvailSMPVersion = VersionSMP 22
-- | ROWN lists the names an address owns; below this a server does not answer it, which is not the same as owning nothing.
nameOwnedSMPVersion :: VersionSMP
nameOwnedSMPVersion = VersionSMP 23
minClientSMPRelayVersion :: VersionSMP
minClientSMPRelayVersion = VersionSMP 14
@@ -225,20 +231,17 @@ minServerSMPRelayVersion :: VersionSMP
minServerSMPRelayVersion = VersionSMP 14
currentClientSMPRelayVersion :: VersionSMP
currentClientSMPRelayVersion = VersionSMP 22
currentClientSMPRelayVersion = VersionSMP 23
currentServerSMPRelayVersion :: VersionSMP
currentServerSMPRelayVersion = VersionSMP 22
currentServerSMPRelayVersion = VersionSMP 23
-- Max SMP protocol version to be used in e2e encrypted connection between
-- client and server, as defined by SMP proxy. Normally set below the current
-- version to prevent client version fingerprinting by the destination relays
-- when clients upgrade at different times. Pinned to the current version (22)
-- for this release because a proxied RSLV only carries availability from
-- nameAvailSMPVersion (22), so the one-version anti-fingerprinting buffer does
-- not apply yet; it reappears once the current version advances past 22.
-- when clients upgrade at different times. Pinned to the current version (23) because a proxied ROWN needs it, and the buffer returns once current passes 23.
proxiedSMPRelayVersion :: VersionSMP
proxiedSMPRelayVersion = VersionSMP 22
proxiedSMPRelayVersion = VersionSMP 23
-- minimal supported protocol version is 14
supportedClientSMPRelayVRange :: VersionRangeSMP
+28 -4
View File
@@ -15,6 +15,7 @@ module AgentTests.ResolveNameTests (resolveNameTests) where
import AgentTests.FunctionalAPITests (withAgent)
import Control.Monad.Except (runExceptT)
import qualified Data.ByteString.Lazy as LB
import Data.IORef (IORef, readIORef)
import Data.List (isInfixOf)
import Network.HTTP.Types (Status, status200, status404, status502)
import NamesResolverServer (memCfg, memCfg2, memProxyCfg, withNames)
@@ -22,7 +23,10 @@ import qualified NamesResolverServer as NRS
import SMPAgentClient
import SMPClient
import SMPNamesTests (availableBody, registeredBody, testNameRecord)
import Simplex.Messaging.Agent (resolveSimplexName)
import Data.Text (Text)
import Simplex.Messaging.Agent (ownedSimplexNames, resolveSimplexName)
import Simplex.Messaging.Encoding.String (strDecode)
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Agent.Client (AgentClient)
import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..), ServerCfg, ServerRoles (..), presetServerCfg)
import Simplex.Messaging.Agent.Protocol (AgentErrorType (..))
@@ -44,10 +48,14 @@ oneSrv :: ServerCfg 'SMP.PSMP -> InitialAgentServers
oneSrv cfg_ = (initAgentServersProxy_ SPMNever SPFProhibit) {smp = [(1, [cfg_])]}
withDirectResolver :: (Status, LB.ByteString) -> (AgentClient -> IO a) -> IO a
withDirectResolver (st, body) k =
NRS.withResolverServer (NRS.resolveResp st body) $ \port _ ->
withDirectResolver resp k = withDirectResolverReqs resp $ \c _ -> k c
-- | As 'withDirectResolver', with the requests the resolver was asked for.
withDirectResolverReqs :: (Status, LB.ByteString) -> (AgentClient -> IORef [[Text]] -> IO a) -> IO a
withDirectResolverReqs (st, body) k =
NRS.withResolverServer (NRS.resolveResp st body) $ \port reqs ->
withSmpServerConfigOn (transport @TLS) (withNames port memCfg) testPort $ \_ ->
withAgent 1 agentCfg (oneSrv (nameSrvCfg testSMPServer)) testDB k
withAgent 1 agentCfg (oneSrv (nameSrvCfg testSMPServer)) testDB $ \c -> k c reqs
withProxyAndResolver :: (Status, LB.ByteString) -> (AgentClient -> IO a) -> IO a
withProxyAndResolver (st, body) k =
@@ -87,6 +95,22 @@ resolveNameTests = do
it "returns NameRecord" testDirectSuccess
describe "name availability" $
it "an unregistered name answers as available" testAvailSuccess
describe "owned names" $
it "a relay that cannot answer is not the end of the lookup" testOwnedRetriesRelays
-- | A relay that cannot answer must not end a scan at the account it was asked about.
testOwnedRetriesRelays :: HasCallStack => IO ()
testOwnedRetriesRelays =
withDirectResolverReqs (status502, "{}") $ \c reqs -> do
r <- runExceptT $ ownedSimplexNames c NRMInteractive 1 [] testAddr 0
case r of
Left (SMP _ (SMP.NAME _)) -> pure ()
_ -> expectationFailure $ "expected Left (SMP _ (NAME ..)), got: " <> show r
asked <- readIORef reqs
length [q | q@("v2" : "owned-by" : _) <- asked] `shouldBe` 3
testAddr :: Address
testAddr = either error id $ strDecode "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
testAvailSuccess :: HasCallStack => IO ()
testAvailSuccess =
+332
View File
@@ -0,0 +1,332 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
-- | Tests for secp256k1, BIP-39, BIP-32, Keccak-256 and EIP-55, checked against published vectors rather than against our own output.
module CoreTests.EthCryptoTests (ethCryptoTests) where
import Control.Concurrent.STM (atomically)
import Control.Monad (forM_)
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.Char (toLower)
import Data.Either (isLeft, isRight)
import Data.List (nub)
import Data.Word (Word32)
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.BIP32 as B32
import qualified Simplex.Messaging.Crypto.BIP39 as B39
import Simplex.Messaging.Crypto.BIP39.English (englishWordList)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Eth.Address
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
import Simplex.Messaging.Eth.Keccak (keccak256)
import Test.Hspec hiding (fit, it)
import Util
ethCryptoTests :: Spec
ethCryptoTests = do
describe "Keccak-256" keccakTests
describe "secp256k1" secp256k1Tests
describe "BIP-39" bip39Tests
describe "BIP-32" bip32Tests
describe "BIP-44 derivation" derivationTests
describe "EIP-55 addresses" eip55Tests
-- helpers
hx :: ByteString -> ByteString
hx s = either (const $ error $ "bad hex literal: " <> BC.unpack s) id $ BAE.convertFromBase BAE.Base16 s
toHex :: ByteString -> ByteString
toHex = BAE.convertToBase BAE.Base16
right :: Either String a -> a
right = either (error . ("unexpected Left: " <>)) id
hardened' :: Word32 -> Word32
hardened' = B32.hardened
keccakTests :: Spec
keccakTests = do
it "hashes the empty string" $
toHex (keccak256 "") `shouldBe` "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
it "hashes abc" $
toHex (keccak256 "abc") `shouldBe` "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"
it "is Keccak-256, not SHA3-256" $
-- SHA3-256 of the empty string, which differs only in the padding byte
toHex (keccak256 "") `shouldNotBe` "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"
secp256k1Tests :: Spec
secp256k1Tests = do
it "derives the known address for a known key" $
show (addressFromPrivateKey testKey) `shouldBe` "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23"
it "serializes a public key in both SEC1 forms" $ do
let pk = S.secp256k1PublicKey testKey
comp = S.serializePublicKey S.Compressed pk
uncomp = S.serializePublicKey S.Uncompressed pk
B.length comp `shouldBe` 33
B.length uncomp `shouldBe` 65
B.head uncomp `shouldBe` 0x04
B.head comp `shouldSatisfy` (`elem` [0x02, 0x03])
-- both forms carry the same x, and the compressed prefix is y's parity
B.take 32 (B.drop 1 uncomp) `shouldBe` B.drop 1 comp
B.head comp `shouldBe` (if odd (B.last uncomp) then 0x03 else 0x02)
it "rejects a zero private key" $
isLeft (S.mkPrivateKey (B.replicate 32 0)) `shouldBe` True
it "rejects a private key at the group order" $
isLeft (S.mkPrivateKey (hx "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")) `shouldBe` True
it "rejects a short private key" $
isLeft (S.mkPrivateKey (B.replicate 31 1)) `shouldBe` True
it "adds a tweak to a private key" $
(toHex . S.unPrivateKey <$> S.privateKeyTweakAdd testKey (B.replicate 31 0 <> B.singleton 1))
`shouldBe` Just "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362319"
where
testKey = right $ S.mkPrivateKey (hx "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318")
bip39Tests :: Spec
bip39Tests = do
describe "official English vectors" $
forM_ (zip [0 :: Int ..] bip39Vectors) $ \(i, (entHex, phrase, seedHex)) ->
it ("vector " <> show i) $ do
let m = right $ B39.entropyToMnemonic (hx entHex)
p = right $ B39.parseMnemonic phrase
B39.mnemonicPhrase m `shouldBe` phrase
toHex (B39.mnemonicToEntropy p) `shouldBe` entHex
toHex (B39.mnemonicToSeed p "TREZOR") `shouldBe` seedHex
it "embeds the upstream wordlist, unchanged" $
-- sha256 of bitcoin/bips/bip-0039/english.txt
C.sha256Hash (BC.unlines englishWordList)
`shouldBe` hx "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda"
it "maps strengths to word counts" $
map B39.strengthWordCount [minBound .. maxBound] `shouldBe` [12, 15, 18, 21, 24]
it "rejects a bad checksum" $
B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
`shouldSatisfy` isLeft
it "rejects a word outside the list" $
B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon simplex"
`shouldSatisfy` isLeft
it "rejects an empty phrase, counting the words" $
B39.parseMnemonic "" `shouldBe` Left "Failed reading: mnemonic: expected 12, 15, 18, 21 or 24 words, got 0"
it "rejects a word with trailing punctuation, naming it" $
B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about."
`shouldBe` Left "Failed reading: mnemonic: not in wordlist: about."
it "rejects a wrong word count" $
B39.parseMnemonic "abandon abandon about" `shouldSatisfy` isLeft
it "accepts a capitalised phrase and normalises it" $
(B39.mnemonicPhrase <$> B39.parseMnemonic "Abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon abandon About")
`shouldBe` Right canonicalPhrase
it "accepts extra whitespace" $
B39.parseMnemonic " abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about "
`shouldBe` B39.parseMnemonic canonicalPhrase
it "rejects an invalid entropy size" $
B39.entropyToMnemonic (B.replicate 17 0) `shouldSatisfy` isLeft
it "generates mnemonics that parse back" $ do
g <- C.newRandom
forM_ [minBound .. maxBound] $ \s -> do
m <- atomically $ B39.randomMnemonic s g
length (B39.mnemonicWords m) `shouldBe` B39.strengthWordCount s
B39.parseMnemonic (B39.mnemonicPhrase m) `shouldBe` Right m
it "redacts the mnemonic in Show" $ do
g <- C.newRandom
m <- atomically $ B39.randomMnemonic B39.MS128 g
show m `shouldBe` "Mnemonic <12 words, redacted>"
canonicalPhrase :: ByteString
canonicalPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
bip32Tests :: Spec
bip32Tests = do
describe "spec test vector 1" $
forM_ vector1 $ \(name, path, keyHex, ccHex) ->
it name $ do
let xk = right $ B32.derivePath master1 path
toHex (S.unPrivateKey $ B32.xkKey xk) `shouldBe` keyHex
toHex (B32.xkChainCode xk) `shouldBe` ccHex
it "spec test vector 2, chain m" $ do
toHex (S.unPrivateKey $ B32.xkKey master2) `shouldBe` "4b03d6fc340455b363f51020ad3ecca4f0850280cf436c70c727923f6db46c3e"
toHex (B32.xkChainCode master2) `shouldBe` "60499f801b896d83179a4374aeb7822aaeaceaa0db1f85ee3e904c4defbd9689"
it "rejects a seed shorter than 16 bytes" $
B32.masterKey (B.replicate 15 1) `shouldSatisfy` isLeft
it "rejects a seed longer than 64 bytes" $
B32.masterKey (B.replicate 65 1) `shouldSatisfy` isLeft
it "redacts the extended key in Show" $
show master2 `shouldBe` "ExtendedKey <redacted>"
describe "path rendering" $ do
it "renders a path" $
B32.renderPath [hardened' 44, hardened' 60, hardened' 0, 0, 0] `shouldBe` "m/44'/60'/0'/0/0"
it "renders an Ethereum path for an account and address" $
B32.renderPath (ethereumPath 7 3) `shouldBe` "m/44'/60'/7'/0/3"
where
master1 = right $ B32.masterKey (hx "000102030405060708090a0b0c0d0e0f")
master2 =
right . B32.masterKey $
hx "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"
vector1 =
[ ( "chain m",
[],
"e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35",
"873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508"
),
( "chain m/0'",
[hardened' 0],
"edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea",
"47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141"
),
( "chain m/0'/1",
[hardened' 0, 1],
"3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368",
"2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19"
),
( "chain m/0'/1/2'",
[hardened' 0, 1, hardened' 2],
"cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca",
"04466b9cc8e161e966409ca52986c584f07e9dc81f735db683c3ff6ec7b1503f"
),
( "chain m/0'/1/2'/2",
[hardened' 0, 1, hardened' 2, 2],
"0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4",
"cfb71883f01676f587d023cc53a35bc7f88f724b1f8c2892ac1275ac822a3edd"
),
( "chain m/0'/1/2'/2/1000000000",
[hardened' 0, 1, hardened' 2, 2, 1000000000],
"471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8",
"c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e"
)
]
derivationTests :: Spec
derivationTests = do
it "derives the standard BIP-39 seed" $
toHex seed
`shouldBe` "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
it "derives the well-known account 0 address" $
show (addrAt 0) `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
it "derives account 1" $
show (addrAt 1) `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
it "derives account 2" $
show (addrAt 2) `shouldBe` "0x07B5FdfEB4E11826D233403Fe8Db0611CCF4c231"
it "gives each chat profile a distinct address" $
map addrAt [0 .. 4] `shouldSatisfy` \as -> length as == length (nub as)
where
m = right $ B39.parseMnemonic canonicalPhrase
seed = B39.mnemonicToSeed m ""
master = right $ B32.masterKey seed
addrAt i = addressFromPrivateKey . B32.xkKey . right $ B32.derivePath master (ethereumPath i 0)
eip55Tests :: Spec
eip55Tests = do
describe "spec vectors round-trip" $
forM_ specAddresses $ \a ->
it (BC.unpack a) $
BC.pack (show . right $ strDecode @Address a) `shouldBe` a
it "accepts an all-lowercase address" $
strDecode @Address "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight
it "accepts an all-uppercase address" $
strDecode @Address "0x5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED" `shouldSatisfy` isRight
it "accepts an address without the 0x prefix" $
strDecode @Address "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight
it "rejects a bad EIP-55 checksum" $
strDecode @Address "0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed" `shouldSatisfy` isLeft
it "rejects the wrong length" $
strDecode @Address "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAe" `shouldSatisfy` isLeft
it "rejects non-hex characters" $
strDecode @Address "0xZaAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
`shouldBe` Left "Failed reading: address: expected 40 hex digits, got 0"
-- between them these cover every byte value out through the hex encoder and every hex digit back through the decoder
it "round-trips every byte value through the checksummed form" $
forM_ everyByteAddresses $ \a ->
strDecode (strEncode a) `shouldBe` Right a
it "round-trips every byte value through the lowercase form" $
forM_ everyByteAddresses $ \a ->
strDecode (BC.map toLower (strEncode a)) `shouldBe` Right a
where
-- 13 x 20 = 260 bytes, so every value 0x00..0xff appears at least once
everyByteAddresses =
[ right . strDecode @Address . ("0x" <>) . toHex . B.pack $
[fromIntegral ((i * 20 + j) `mod` 256) | j <- [0 .. 19 :: Int]]
| i <- [0 .. 12 :: Int]
]
specAddresses =
[ "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
"0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",
"0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB",
"0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
]
bip39Vectors :: [(ByteString, ByteString, ByteString)]
bip39Vectors =
[ ( "00000000000000000000000000000000",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" )
, ( "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
"legal winner thank year wave sausage worth useful legal winner thank yellow",
"2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607" )
, ( "80808080808080808080808080808080",
"letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
"d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8" )
, ( "ffffffffffffffffffffffffffffffff",
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
"ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069" )
, ( "000000000000000000000000000000000000000000000000",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent",
"035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa" )
, ( "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
"f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd" )
, ( "808080808080808080808080808080808080808080808080",
"letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always",
"107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65" )
, ( "ffffffffffffffffffffffffffffffffffffffffffffffff",
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when",
"0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528" )
, ( "0000000000000000000000000000000000000000000000000000000000000000",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
"bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8" )
, ( "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title",
"bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87" )
, ( "8080808080808080808080808080808080808080808080808080808080808080",
"letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless",
"c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f" )
, ( "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
"dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad" )
, ( "9e885d952ad362caeb4efe34a8e91bd2",
"ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic",
"274ddc525802f7c828d8ef7ddbcdc5304e87ac3535913611fbbfa986d0c9e5476c91689f9c8a54fd55bd38606aa6a8595ad213d4c9c9f9aca3fb217069a41028" )
, ( "6610b25967cdcca9d59875f5cb50b0ea75433311869e930b",
"gravity machine north sort system female filter attitude volume fold club stay feature office ecology stable narrow fog",
"628c3827a8823298ee685db84f55caa34b5cc195a778e52d45f59bcf75aba68e4d7590e101dc414bc1bbd5737666fbbef35d1f1903953b66624f910feef245ac" )
, ( "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c",
"hamster diagram private dutch cause delay private meat slide toddler razor book happy fancy gospel tennis maple dilemma loan word shrug inflict delay length",
"64c87cde7e12ecf6704ab95bb1408bef047c22db4cc7491c4271d170a1b213d20b385bc1588d9c7b38f1b39d415665b8a9030c9ec653d75e65f847d8fc1fc440" )
, ( "c0ba5a8e914111210f2bd131f3d5e08d",
"scheme spot photo card baby mountain device kick cradle pact join borrow",
"ea725895aaae8d4c1cf682c1bfd2d358d52ed9f0f0591131b559e2724bb234fca05aa9c02c57407e04ee9dc3b454aa63fbff483a8b11de949624b9f1831a9612" )
, ( "6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3",
"horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap uncle crack brave",
"fd579828af3da1d32544ce4db5c73d53fc8acc4ddb1e3b251a31179cdb71e853c56d2fcb11aed39898ce6c34b10b5382772db8796e52837b54468aeb312cfc3d" )
, ( "9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863",
"panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol speed nation flash devote level hobby quick inner drive ghost inside",
"72be8e052fc4919d2adf28d5306b5474b0069df35b02303de8c1729c9538dbb6fc2d731d5f832193cd9fb6aeecbc469594a70e3dd50811b5067f3b88b28c3e8d" )
, ( "23db8160a31d3e0dca3688ed941adbf3",
"cat swing flag economy stadium alone churn speed unique patch report train",
"deb5f45449e615feff5640f2e49f933ff51895de3b4381832b3139941c57b59205a42480c52175b6efcffaa58a2503887c1e8b363a707256bdd2b587b46541f5" )
, ( "8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0",
"light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart sustain crack supply proud access",
"4cbdff1ca2db800fd61cae72a57475fdc6bab03e441fd63f96dabd1f183ef5b782925f00105f318309a7e9c3ea6967c7801e46c8a58082674c860a37b93eda02" )
, ( "066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad",
"all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform",
"26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d" )
, ( "f30f8c1da665478f49b001d94c5fc452",
"vessel ladder alter error federal sibling chat ability sun glass valve picture",
"2aaa9242daafcee6aa9d7269f17d4efe271e1b9a529178d7dc139cd18747090bf9d60295d0ce74309a78852a9caadf0af48aae1c6253839624076224374bc63f" )
, ( "c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05",
"scissors invite lock maple supreme raw rapid void congress muscle digital elegant little brisk hair mango congress clump",
"7b4a10be9d98e6cba265566db7f136718e1398c71cb581e1b2f464cac1ceedf4f3e274dc270003c670ad8d02c4558b2f8e39edea2775c9e232c7cb798b069e88" )
, ( "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f",
"void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
"01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998" )
]
+2 -2
View File
@@ -47,12 +47,12 @@ withResolverServerDelayed delayMs handler action = do
let (st, body) = handler (pathInfo req)
send $ responseLBS st [(hContentType, "application/json")] body
-- | The resolver API is versioned on its own: v2 answers with NameRegistration
-- JSON, which is the only shape the server asks for.
-- | The resolver API is versioned on its own: v2 answers a name with NameRegistration, an address with OwnedNames.
resolveResp :: Status -> LB.ByteString -> [Text] -> (Status, LB.ByteString)
resolveResp st body = \case
["health"] -> (ok200, "{}")
("v2" : "resolve" : _) -> (st, body)
("v2" : "owned-by" : _) -> (st, body)
_ -> (notFound404, "{}")
testNamesConfig :: Int -> NamesConfig
+59 -2
View File
@@ -27,6 +27,8 @@ import Simplex.Messaging.Client
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (strDecode)
import SMPNamesTests (availableBody, registeredBody, reservedBody, resolved, testNameRecord, testPricing)
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.Names.Record (OwnedNames (..))
import Simplex.Messaging.Protocol
( BrokerMsg (..),
Cmd (..),
@@ -72,6 +74,16 @@ withProxyAndResolver (st, body) runTest =
withSmpServerConfigOn (transport @TLS) memProxyCfg testPort $ \_ ->
withSmpServerConfigOn (transport @TLS) (withNames port memCfg2) testPort2 (const runTest)
sendRown :: Transport c => THandleSMP c 'TClient -> B.ByteString -> Address -> IO (Transmission (Either ErrorType BrokerMsg))
sendRown h@THandle {params} corrId addr = do
let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (ROWN addr 0))
[Right ()] <- tPut h (Right (Nothing, tToSend) :| [])
r :| _ <- tGetClient h
pure r
testAddr :: Address
testAddr = either error id $ strDecode "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
sendRslv :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg))
sendRslv h@THandle {params} corrId d = do
let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (NQDomain d)))
@@ -84,11 +96,14 @@ rslvTests = do
describe "RSLV direct (non-forwarded)" $ do
it "resolver without the v2 route (404) -> NAME RESOLVER, not NOT_FOUND" testRslvBackendNotFound
it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr
it "ROWN returns the names an address owns" testRownOwned
it "ROWN on a resolver without the endpoint is a resolver error, not an empty answer" testRownUnsupported
it "no names config -> NAME NO_RESOLVER" testRslvDisabled
it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion
describe "RSLV forwarded (PFWD)" $ do
it "PFWD-wrapped RSLV reaches resolver via proxy (PCEProtocolError (NAME RESOLVER))" testRslvForwarded
it "PFWD-wrapped RSLV success returns RNAME (record JSON frames over the proxy)" testRslvForwardedSuccess
it "PFWD-wrapped ROWN reaches the resolver, so a scan need not go direct" testRownForwarded
describe "RSLV success path (RNAME response)" $ do
it "returns RNAME with NameRecord" testRslvSuccess
describe "RSLV availability (RNAME response)" $ do
@@ -120,6 +135,36 @@ testRslvBackendHttpErr =
(_, _, resp) <- sendRslv h "rs05" (domain "alice.simplex")
resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 502")))
-- | The scan reads inUse, so in use with no names must not look like owning nothing.
testRownOwned :: IO ()
testRownOwned =
withResolverServer (status200, ownedBody) $
testSMPClient @TLS $ \h -> do
(corrId, _entId, resp) <- sendRown h "ro01" testAddr
corrId `shouldBe` CorrId "ro01"
case resp of
Right (ROWND owned) -> do
map ownedName (ownNames owned) `shouldBe` ["alice.simplex"]
ownInUse owned `shouldBe` True
r -> expectationFailure $ "unexpected " <> show r
-- 404 must reach the client as a resolver error; read as "owns nothing" it would end a scan early.
testRownUnsupported :: IO ()
testRownUnsupported =
withResolverServer (status404, "{}") $
testSMPClient @TLS $ \h -> do
(_, _, resp) <- sendRown h "ro02" testAddr
resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404")))
-- | Each owned name is the same NameResponse /v2/resolve answers with.
ownedBody :: LB.ByteString
ownedBody = "{\"lastBlockTs\":1813000000,\"names\":[" <> registeredBody testNameRecord <> "],\"inUse\":true,\"nextOffset\":null}"
ownedName :: NameResponse -> Text
ownedName = \case
NameResponse {registration = NRRegistered {nameRecord}} -> SMP.nrName nameRecord
r -> error $ "expected a registered name, got: " <> show r
testRslvDisabled :: IO ()
testRslvDisabled =
withSmpServerConfigOn (transport @TLS) memCfg testPort $ const $
@@ -142,7 +187,11 @@ testRslvVersion =
_ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r
forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResponse))
forwardedResolveAlice = do
forwardedResolveAlice = forwardedToRelay $ \pc sess -> proxyResolveName pc NRMInteractive sess (domain "alice.simplex")
-- | Run one proxied resolver command over a PFWD session to the second relay.
forwardedToRelay :: (SMPClient -> ProxiedRelay -> ExceptT SMPClientError IO a) -> IO (Either SMPClientError a)
forwardedToRelay proxiedCmd = do
g <- C.newRandom
ts <- getCurrentTime
let proxyServ = SMPServer testHost testPort testKeyHash
@@ -151,7 +200,7 @@ forwardedResolveAlice = do
pcE <- getProtocolClient g NRMInteractive (1, proxyServ, Nothing) cfg' [] Nothing ts (\_ -> pure ())
pc <- either (fail . show) pure pcE
sess <- runExceptT' (connectSMPProxiedRelay pc NRMInteractive relayServ Nothing)
runExceptT (proxyResolveName pc NRMInteractive sess (domain "alice.simplex"))
runExceptT (proxiedCmd pc sess)
testRslvForwarded :: IO ()
testRslvForwarded =
@@ -167,6 +216,14 @@ testRslvForwardedSuccess =
Right (Right NameResponse {registration = NRRegistered {nameRecord}}) -> nameRecord `shouldBe` testNameRecord
_ -> expectationFailure $ "expected Right (Right NRRegistered), got: " <> show r
-- | Without this a scan falls back to a direct session, handing the relay the address with the IP.
testRownForwarded :: IO ()
testRownForwarded =
withProxyAndResolver (status200, ownedBody) $
forwardedToRelay (\pc sess -> proxyOwnedNames pc NRMInteractive sess testAddr 0) >>= \r -> case r of
Right (Right owned) -> map ownedName (ownNames owned) `shouldBe` ["alice.simplex"]
_ -> expectationFailure $ "expected Right (Right OwnedNames), got: " <> show r
testRslvSuccess :: IO ()
testRslvSuccess =
withResolverServer (status200, registeredBody testNameRecord) $
+2
View File
@@ -11,6 +11,7 @@ import CoreTests.BatchingTests
import CoreTests.CryptoFileTests
import CoreTests.CryptoTests
import CoreTests.EncodingTests
import CoreTests.EthCryptoTests
import CoreTests.MsgStoreTests
import CoreTests.RetryIntervalTests
import CoreTests.SOCKSSettings
@@ -87,6 +88,7 @@ main = do
describe "Encoding tests" encodingTests
describe "Version range" versionRangeTests
describe "Encryption tests" cryptoTests
describe "Ethereum crypto tests" ethCryptoTests
describe "Encrypted files tests" cryptoFileTests
describe "Message store tests" msgStoreTests
describe "Retry interval tests" retryIntervalTests