mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-12 02:22:45 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6275e7ee5e | ||
|
|
c1e062ba12 | ||
|
|
21164fce3c | ||
|
|
b974dbd8c8 | ||
|
|
78ebf1cb2e | ||
|
|
4c8dc9f7d8 | ||
|
|
1d82c0815d | ||
|
|
861e9890c6 | ||
|
|
adcdb54c02 | ||
|
|
2ffec2aad6 | ||
|
|
3ae71f06b4 | ||
|
|
f53a6f3364 | ||
|
|
5dc64f1cf4 | ||
|
|
0d19ff3108 | ||
|
|
7dcd89d30b | ||
|
|
31f0a74310 | ||
|
|
d2128507cc | ||
|
|
33e8ef018e | ||
|
|
86ac7fdad0 | ||
|
|
ae7a312af7 | ||
|
|
e6597f28b0 | ||
|
|
2298704687 | ||
|
|
a23da20fb5 | ||
|
|
088fc4ea81 | ||
|
|
b23d3a3265 | ||
|
|
2bbc55619f | ||
|
|
3ab14f5a93 | ||
|
|
d9953b30da | ||
|
|
2d4c060c6d |
@@ -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
|
||||
|
||||
@@ -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
+1
Submodule cbits/libsecp256k1 added at 6e2c8bc4ec
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'')
|
||||
|
||||
@@ -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 ())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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" )
|
||||
]
|
||||
@@ -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
@@ -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) $
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user