mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-12 02:22:45 +00:00
cut to the min
This commit is contained in:
@@ -1,32 +1,24 @@
|
||||
# Ethereum crypto primitives for simplexmq
|
||||
|
||||
Client-side crypto for SimpleX names: enough to derive an Ethereum key from a
|
||||
recovery phrase and sign EIP-712 typed data. General-purpose — these modules
|
||||
know nothing about names, registrars or relayers.
|
||||
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 Workstream B of the SimpleX names v2 plan. The design it serves: names
|
||||
are owned by a plain EOA derived per chat profile from one BIP-39 seed, and
|
||||
every post-registration action (transfer, record edit) is a one-shot EIP-712
|
||||
intent signed by that key and relayed by SimpleX, which pays the gas.
|
||||
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: it signs EIP-712 typed data and hands the signature to the relayer.
|
||||
The client never reads a nonce, estimates gas or broadcasts anything, so the
|
||||
`RSLV` resolver path in this repo stays strictly read-only.
|
||||
- **No low-s normalization.** libsecp256k1 already emits the canonical low-`s`
|
||||
form EIP-2 requires. `isLowS` exists so tests assert that rather than assume
|
||||
it. There is deliberately no normalization entry point: we never accept a
|
||||
foreign signature, we only produce our own.
|
||||
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.
|
||||
- **No EIP-712 schema encoder.** The caller supplies the canonical type string.
|
||||
Our structs are a handful of fixed shapes agreed with the contracts, and a
|
||||
hand-written string checked against Solidity in a test is easier to audit than
|
||||
a schema encoder whose output nobody reads.
|
||||
- **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.
|
||||
@@ -40,15 +32,13 @@ 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
|
||||
Simplex.Messaging.Eth.EIP712 typed data hashing
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
```haskell
|
||||
newtype PrivateKey -- 32 bytes, validated in [1, n-1]
|
||||
newtype PublicKey -- libsecp256k1's opaque 64-byte form
|
||||
data RecoverableSignature = RecoverableSignature {rsCompact :: ByteString, rsRecId :: Int}
|
||||
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
|
||||
@@ -57,10 +47,6 @@ data MnemonicStrength = MS128 | MS160 | MS192 | MS224 | MS256
|
||||
data ExtendedKey = ExtendedKey {xkKey :: PrivateKey, xkChainCode :: ByteString}
|
||||
|
||||
newtype Address -- 20 bytes; Show renders the EIP-55 form
|
||||
data Eip712Domain = Eip712Domain {edName, edVersion :: ByteString, edChainId :: Integer, edVerifyingContract :: Address}
|
||||
data Value = VUint Integer | VInt Integer | VBool Bool | VAddress Address
|
||||
| VFixedBytes ByteString | VBytes ByteString | VString ByteString
|
||||
| VArray [Value] | VStruct ByteString
|
||||
```
|
||||
|
||||
`PrivateKey`, `Mnemonic` and `ExtendedKey` have **redacting `Show` instances**,
|
||||
@@ -73,14 +59,11 @@ derives siblings.
|
||||
|
||||
```haskell
|
||||
-- Secp256k1
|
||||
mkPrivateKey :: ByteString -> Either String PrivateKey
|
||||
publicKey :: PrivateKey -> PublicKey -- total: key is validated
|
||||
parsePublicKey :: ByteString -> Either String PublicKey
|
||||
serializePublicKey :: PubKeyFormat -> PublicKey -> ByteString
|
||||
privateKeyTweakAdd :: PrivateKey -> ByteString -> Maybe PrivateKey
|
||||
signRecoverable :: PrivateKey -> ByteString -> Either String RecoverableSignature
|
||||
recoverPublicKey :: RecoverableSignature -> ByteString -> Either String PublicKey
|
||||
isLowS :: RecoverableSignature -> Bool
|
||||
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
|
||||
@@ -91,23 +74,19 @@ randomMnemonic :: MnemonicStrength -> TVar ChaChaDRG -> STM Mnemonic
|
||||
|
||||
-- BIP32
|
||||
masterKey :: ByteString -> Either String ExtendedKey
|
||||
deriveChild :: ExtendedKey -> Word32 -> Either String ExtendedKey
|
||||
derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
|
||||
parsePath :: ByteString -> Either String [Word32]
|
||||
renderPath :: [Word32] -> ByteString
|
||||
|
||||
-- Eth
|
||||
keccak256 :: ByteString -> ByteString
|
||||
addressFromPrivateKey :: PrivateKey -> Address
|
||||
checksumAddress :: Address -> ByteString
|
||||
parseAddress :: ByteString -> Either String Address
|
||||
ethereumPath :: Word32 -> [Word32] -- m/44'/60'/i'/0/0
|
||||
typeHash :: ByteString -> ByteString
|
||||
hashStruct :: ByteString -> [Value] -> Either String ByteString
|
||||
domainSeparator :: Eip712Domain -> Either String ByteString
|
||||
hashTypedData :: Eip712Domain -> ByteString -> [Value] -> Either String 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.
|
||||
|
||||
@@ -118,60 +97,38 @@ sentence from the wordlist, and that is what `mnemonicToSeed` hashes.
|
||||
|
||||
## How applications use it
|
||||
|
||||
An application defines the derivation path and the EIP-712 type strings. For
|
||||
SimpleX names, one seed per chat database and one key per chat profile —
|
||||
see `Simplex.Chat.Names.Wallet` in simplex-chat:
|
||||
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 userId)
|
||||
xk <- either fail pure $ derivePath mk (ethereumPath account 0)
|
||||
let addr = addressFromPrivateKey (xkKey xk)
|
||||
```
|
||||
|
||||
Signing a transfer intent — the type string must match the contract's exactly,
|
||||
including EIP-712 canonical form (no spaces after commas, referenced struct
|
||||
types appended in alphabetical order):
|
||||
|
||||
```haskell
|
||||
digest <- either fail pure $ hashTypedData domain
|
||||
"TransferName(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline)"
|
||||
[VAddress from, VAddress to, VUint tokenId, VUint nonce, VUint deadline]
|
||||
sig <- either fail pure $ signRecoverable (xkKey xk) digest
|
||||
-- Ethereum's v is rsRecId + 27
|
||||
```
|
||||
|
||||
Nested structs go in as `VStruct` holding an already-computed `hashStruct`;
|
||||
arrays as `VArray`, which hashes the concatenation of its members.
|
||||
|
||||
## 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_parse(ctx, pubkey, input, inputlen)
|
||||
secp256k1_ec_pubkey_serialize(ctx, output, outputlen, pubkey, flags)
|
||||
secp256k1_ec_seckey_tweak_add(ctx, seckey, tweak)
|
||||
secp256k1_ecdsa_sign_recoverable(ctx, sig, msghash32, seckey, NULL, NULL)
|
||||
secp256k1_ecdsa_recoverable_signature_serialize_compact(ctx, output64, recid, sig)
|
||||
secp256k1_ecdsa_recoverable_signature_parse_compact(ctx, sig, input64, recid)
|
||||
secp256k1_ecdsa_recover(ctx, pubkey, sig, msghash32)
|
||||
```
|
||||
|
||||
Passing `NULL` for the nonce function selects RFC-6979, so signing is a
|
||||
deterministic pure function of (key, digest) — which is why the module exposes a
|
||||
pure API over `unsafePerformIO`. The context is created and blinded once at
|
||||
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 signing does not mutate the context, so one shared context is safe
|
||||
across threads.
|
||||
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 `deriveChild` can surface it.
|
||||
`privateKeyTweakAdd` returns `Maybe` and `derivePath` can surface it.
|
||||
|
||||
libsecp256k1 never reads OS entropy — RFC-6979 nonces are derived from the key
|
||||
and digest, and the context blinding seed is supplied by the caller. So unlike
|
||||
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.
|
||||
|
||||
@@ -184,15 +141,12 @@ Submodule in `cbits/`, same pattern as blst and libbbs:
|
||||
```
|
||||
c-sources: cbits/libsecp256k1/src/{secp256k1,precomputed_ecmult,precomputed_ecmult_gen}.c
|
||||
include-dirs: cbits/libsecp256k1{,/include,/src}
|
||||
cc-options: -DENABLE_MODULE_RECOVERY=1
|
||||
```
|
||||
|
||||
Built **without** its autotools config header. Every knob has an `#ifndef`
|
||||
default in the headers, and the checked-in precomputed tables are generated for
|
||||
those defaults, so only the recovery module has to be switched on. The recovery
|
||||
module is `#include`d from `secp256k1.c`, so it needs no extra `c-sources`
|
||||
entry. `secp256k1.c` defines `SECP256K1_BUILD` itself, so that needs no `-D`
|
||||
either.
|
||||
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
|
||||
@@ -203,10 +157,6 @@ 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.
|
||||
|
||||
`-DENABLE_MODULE_RECOVERY=1` lands on the shared `cc-options`, so it also
|
||||
reaches blst, libbbs and sntrup761 — harmless, none of them use the macro, and
|
||||
symmetrically `-D__BLST_PORTABLE__` reaches libsecp256k1.
|
||||
|
||||
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
|
||||
@@ -253,7 +203,7 @@ entirely.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/CoreTests/EthCryptoTests.hs`, 98 examples. Everything is checked against
|
||||
`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
|
||||
@@ -263,63 +213,12 @@ published vectors rather than our own output:
|
||||
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.
|
||||
- **EIP-712** — the `Mail` example from the spec: domain separator, `hashStruct`
|
||||
and the final digest.
|
||||
- **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 and out-of-range private keys, wrong digest length,
|
||||
malformed public keys, bad BIP-39 checksums and word counts, out-of-range
|
||||
seeds, bad EIP-55 checksums, and every EIP-712 range and length check.
|
||||
- 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 EIP-712 and 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.
|
||||
|
||||
## Addendum: ERC-5564 stealth addresses
|
||||
|
||||
`Simplex.Messaging.Eth.Stealth`, added for the names v2 gifting flow (rc3 §7.4).
|
||||
A recipient publishes a meta-address — a spending public key and a viewing
|
||||
public key — and a sender derives a one-time destination from it with no
|
||||
handshake. Only the viewing key finds those destinations; only the spending key
|
||||
spends from them.
|
||||
|
||||
### Why not `secp256k1_ecdh`
|
||||
|
||||
The ECDH module hashes the shared secret point with SHA-256 and offers no way to
|
||||
substitute a hash without a C callback. ERC-5564 hashes with keccak256. So the
|
||||
module stays disabled and the two core-API point operations are bound instead:
|
||||
|
||||
- `secp256k1_ec_pubkey_tweak_mul` → `publicKeyTweakMul`, for `r · P_view`
|
||||
- `secp256k1_ec_pubkey_tweak_add` → `publicKeyTweakAdd`, for `P_spend + s_h · G`
|
||||
|
||||
Both are in `secp256k1.h`, so no build flag changed. The recipient's key,
|
||||
`p_spend + s_h`, reuses the existing `privateKeyTweakAdd`.
|
||||
|
||||
### The parts the EIP does not specify
|
||||
|
||||
ERC-5564 fixes the algebra but not the encoding, and getting either wrong
|
||||
produces a wallet that is self-consistent and interoperable with nothing. From
|
||||
the EIP author's reference implementation
|
||||
(`Nerolation/EIP-Stealth-Address-ERC`, `minimal_poc.ipynb`):
|
||||
|
||||
- the shared secret point is serialized **uncompressed with the SEC1 prefix
|
||||
removed**, `x || y`, 64 bytes;
|
||||
- it is hashed with **keccak256**;
|
||||
- the **view tag is the first byte** of that hash.
|
||||
|
||||
That is the same encoding Ethereum uses to turn a public key into an address, so
|
||||
`addressFromPublicKey` performs the final step unchanged.
|
||||
|
||||
### Tests
|
||||
|
||||
13 examples in `CoreTests.EthCryptoTests`, 111 in the module overall. Beyond the
|
||||
round-trip and negative cases, two carry the weight:
|
||||
|
||||
- **Batch scanning.** A recipient scans 512 announcements addressed to someone
|
||||
else; about two pass the one-byte view tag by chance and none yields an address
|
||||
they control. The complementary test confirms they find all 64 of their own.
|
||||
This exercises the scan loop rather than a single derivation.
|
||||
- **Independent agreement.** The pinned vector was reproduced by a from-scratch
|
||||
pure-Python secp256k1 implementing the reference algorithm directly, sharing no
|
||||
code with libsecp256k1. Without that, a pin only records our own output.
|
||||
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.
|
||||
|
||||
+1
-3
@@ -152,9 +152,7 @@ library
|
||||
Simplex.Messaging.Encoding
|
||||
Simplex.Messaging.Encoding.String
|
||||
Simplex.Messaging.Eth.Address
|
||||
Simplex.Messaging.Eth.EIP712
|
||||
Simplex.Messaging.Eth.Keccak
|
||||
Simplex.Messaging.Eth.Stealth
|
||||
Simplex.Messaging.Names.Record
|
||||
Simplex.Messaging.Notifications.Client
|
||||
Simplex.Messaging.Notifications.Protocol
|
||||
@@ -343,7 +341,7 @@ library
|
||||
-- libsecp256k1 is built without its autotools config header: every knob it
|
||||
-- needs has an #ifndef default, and the checked-in precomputed tables match
|
||||
-- those defaults. Only the recovery module has to be switched on explicitly.
|
||||
cc-options: -D__BLST_PORTABLE__ -DENABLE_MODULE_RECOVERY=1
|
||||
cc-options: -D__BLST_PORTABLE__
|
||||
if flag(commoncrypto)
|
||||
cc-options: -DBBS_CRYPTO_CC
|
||||
frameworks: Security
|
||||
|
||||
@@ -9,21 +9,16 @@
|
||||
module Simplex.Messaging.Crypto.BIP32
|
||||
( ExtendedKey (..),
|
||||
masterKey,
|
||||
deriveChild,
|
||||
derivePath,
|
||||
parsePath,
|
||||
renderPath,
|
||||
hardenedOffset,
|
||||
hardened,
|
||||
isHardened,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Control.Monad (foldM)
|
||||
import qualified Crypto.Hash as H
|
||||
import qualified Crypto.MAC.HMAC as HMAC
|
||||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
@@ -32,7 +27,6 @@ import Data.List (intercalate)
|
||||
import Data.Word (Word32)
|
||||
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
|
||||
import Simplex.Messaging.Encoding (smpEncode)
|
||||
import Simplex.Messaging.Parsers (parseAll)
|
||||
|
||||
-- | An extended private key: the key plus its chain code.
|
||||
--
|
||||
@@ -94,28 +88,7 @@ deriveChild xk i =
|
||||
derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
|
||||
derivePath = foldM deriveChild
|
||||
|
||||
-- | Parse a path such as @m\/44'\/60'\/0'\/0\/0@. A leading @m@ or @M@ is
|
||||
-- optional, as is a leading @\/@; @'@, @h@ and @H@ all mark a hardened index.
|
||||
parsePath :: ByteString -> Either String [Word32]
|
||||
parsePath = parseAll pathP
|
||||
|
||||
pathP :: A.Parser [Word32]
|
||||
pathP = do
|
||||
cs <- BC.split '/' <$> A.takeByteString
|
||||
case cs of
|
||||
c : cs' | c == "m" || c == "M" || B.null c -> traverse indexOf cs'
|
||||
_ -> traverse indexOf cs
|
||||
where
|
||||
-- each component is validated on its own, so a bad one names itself
|
||||
indexOf c
|
||||
| B.null c = fail "path: empty component"
|
||||
| otherwise = case A.parseOnly (indexP <* A.endOfInput) c of
|
||||
Left _ -> fail $ "path: bad component " <> BC.unpack c
|
||||
Right (n, hardenedIx)
|
||||
| n >= toInteger hardenedOffset -> fail $ "path: index out of range: " <> show n
|
||||
| otherwise -> pure $ fromInteger n + if hardenedIx then hardenedOffset else 0
|
||||
indexP = (,) <$> (A.decimal :: A.Parser Integer) <*> hardenedP
|
||||
hardenedP = (True <$ A.satisfy (\c -> c == '\'' || c == 'h' || c == 'H')) <|> pure False
|
||||
|
||||
renderPath :: [Word32] -> ByteString
|
||||
renderPath is = BC.pack $ intercalate "/" ("m" : map component is)
|
||||
|
||||
@@ -20,9 +20,7 @@ module Simplex.Messaging.Crypto.BIP39
|
||||
parseMnemonic,
|
||||
mnemonicToSeed,
|
||||
randomMnemonic,
|
||||
strengthBytes,
|
||||
strengthWordCount,
|
||||
seedSize,
|
||||
)
|
||||
where
|
||||
|
||||
|
||||
@@ -3,46 +3,24 @@
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | FFI bindings to libsecp256k1 (ECDSA over secp256k1 with public key recovery).
|
||||
-- | FFI bindings to libsecp256k1.
|
||||
--
|
||||
-- Only what Ethereum signing needs: key validation, public key derivation and
|
||||
-- serialization, scalar addition (for BIP-32 child derivation), point addition
|
||||
-- and multiplication (for ERC-5564 stealth addresses), recoverable signing and
|
||||
-- recovery.
|
||||
--
|
||||
-- Signatures are produced with libsecp256k1's default RFC-6979 deterministic
|
||||
-- nonce, so signing is a pure function of (key, digest) - which is why this
|
||||
-- module exposes a pure API over 'unsafePerformIO'. libsecp256k1 also always
|
||||
-- emits the low-@s@ form, so every signature from 'signRecoverable' is already
|
||||
-- EIP-2 compliant; 'isLowS' is provided so callers can assert that rather than
|
||||
-- trust it. A foreign signature can reach 'recoverPublicKey', which rejects a
|
||||
-- malformed one; there is no normalization entry point, as recovery does not
|
||||
-- need low-s.
|
||||
-- Key validation, public key derivation and serialization, and scalar addition
|
||||
-- for BIP-32 child derivation. The pure API over 'unsafePerformIO' is safe
|
||||
-- because every operation is a pure function of its arguments.
|
||||
module Simplex.Messaging.Crypto.Secp256k1
|
||||
( Secp256k1PrivateKey,
|
||||
Secp256k1PublicKey,
|
||||
RecoverableSignature (..),
|
||||
PubKeyFormat (..),
|
||||
mkPrivateKey,
|
||||
unPrivateKey,
|
||||
secp256k1PublicKey,
|
||||
parsePublicKey,
|
||||
serializePublicKey,
|
||||
privateKeyTweakAdd,
|
||||
publicKeyTweakMul,
|
||||
publicKeyTweakAdd,
|
||||
signRecoverable,
|
||||
recoverPublicKey,
|
||||
isLowS,
|
||||
privateKeySize,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
digestSize,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (when)
|
||||
import Crypto.Number.Serialize (os2ip)
|
||||
import Crypto.Random (drgNew, randomBytesGenerate)
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.ByteString (ByteString)
|
||||
@@ -66,20 +44,11 @@ compressedSize = 33
|
||||
uncompressedSize :: Int
|
||||
uncompressedSize = 65
|
||||
|
||||
-- | ECDSA signs a 32-byte digest, never a message.
|
||||
digestSize :: Int
|
||||
digestSize = 32
|
||||
|
||||
-- | Internal size of @secp256k1_pubkey@ (opaque, not a serialization).
|
||||
pubKeyInternalSize :: Int
|
||||
pubKeyInternalSize = 64
|
||||
|
||||
-- | Internal size of @secp256k1_ecdsa_recoverable_signature@.
|
||||
recSigInternalSize :: Int
|
||||
recSigInternalSize = 65
|
||||
|
||||
compactSize :: Int
|
||||
compactSize = 64
|
||||
|
||||
-- Types
|
||||
|
||||
@@ -102,23 +71,12 @@ newtype Secp256k1PublicKey = Secp256k1PublicKey ByteString
|
||||
data PubKeyFormat = Compressed | Uncompressed
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | A signature plus the recovery id needed to recover the signing key.
|
||||
-- @rsCompact@ is @r || s@, 64 bytes big-endian; @rsRecId@ is in @[0, 3]@.
|
||||
-- Ethereum's @v@ is @rsRecId + 27@ (or @+ 35 + 2 * chainId@ for EIP-155).
|
||||
data RecoverableSignature = RecoverableSignature
|
||||
{ rsCompact :: ByteString,
|
||||
rsRecId :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- FFI
|
||||
|
||||
data Ctx
|
||||
|
||||
data PubKeyRaw
|
||||
|
||||
data RecSigRaw
|
||||
|
||||
foreign import ccall "secp256k1_context_create"
|
||||
c_context_create :: CUInt -> IO (Ptr Ctx)
|
||||
|
||||
@@ -131,33 +89,12 @@ foreign import ccall "secp256k1_ec_seckey_verify"
|
||||
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_parse"
|
||||
c_ec_pubkey_parse :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> CSize -> 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
|
||||
|
||||
foreign import ccall "secp256k1_ec_pubkey_tweak_mul"
|
||||
c_ec_pubkey_tweak_mul :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ec_pubkey_tweak_add"
|
||||
c_ec_pubkey_tweak_add :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_sign_recoverable"
|
||||
c_ecdsa_sign_recoverable :: Ptr Ctx -> Ptr RecSigRaw -> Ptr Word8 -> Ptr Word8 -> Ptr () -> Ptr () -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_recoverable_signature_serialize_compact"
|
||||
c_recsig_serialize_compact :: Ptr Ctx -> Ptr Word8 -> Ptr CInt -> Ptr RecSigRaw -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_recoverable_signature_parse_compact"
|
||||
c_recsig_parse_compact :: Ptr Ctx -> Ptr RecSigRaw -> Ptr Word8 -> CInt -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_recover"
|
||||
c_ecdsa_recover :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr RecSigRaw -> Ptr Word8 -> IO CInt
|
||||
|
||||
-- SECP256K1_CONTEXT_NONE = SECP256K1_FLAGS_TYPE_CONTEXT
|
||||
contextNone :: CUInt
|
||||
contextNone = 1
|
||||
@@ -190,22 +127,11 @@ packPtr p n = B.packCStringLen (castPtr p, n)
|
||||
withPubKeyRaw :: Secp256k1PublicKey -> (Ptr PubKeyRaw -> IO a) -> IO a
|
||||
withPubKeyRaw (Secp256k1PublicKey bs) f = withBS bs $ f . castPtr
|
||||
|
||||
-- | Marshal a 'RecoverableSignature' into the opaque C representation, failing
|
||||
-- if libsecp256k1 rejects it.
|
||||
withRecSigRaw :: RecoverableSignature -> (Ptr RecSigRaw -> IO (Either String a)) -> IO (Either String a)
|
||||
withRecSigRaw (RecoverableSignature compact recId) f
|
||||
| B.length compact /= compactSize = pure $ Left "signature: expected 64 bytes"
|
||||
| recId < 0 || recId > 3 = pure $ Left "signature: recovery id out of range"
|
||||
| otherwise =
|
||||
allocaBytes recSigInternalSize $ \sigPtr ->
|
||||
withBS compact $ \cPtr -> do
|
||||
rc <- c_recsig_parse_compact secp256k1Ctx sigPtr cPtr (fromIntegral recId)
|
||||
if rc /= 1 then pure $ Left "signature: malformed" else f sigPtr
|
||||
|
||||
-- Public API
|
||||
|
||||
-- | Validate 32 bytes as a private key. Rejects zero and anything at or above
|
||||
-- the group order, which is what makes 'secp256k1PublicKey' and 'signRecoverable' total.
|
||||
-- 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)
|
||||
@@ -226,20 +152,6 @@ secp256k1PublicKey (Secp256k1PrivateKey sk) = unsafePerformIO $
|
||||
when (rc /= 1) $ ioError (userError "secp256k1_ec_pubkey_create failed on a validated key")
|
||||
Secp256k1PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
|
||||
-- | Parse a SEC1 point, compressed (33 bytes) or uncompressed (65 bytes).
|
||||
parsePublicKey :: ByteString -> Either String Secp256k1PublicKey
|
||||
parsePublicKey bs
|
||||
| len /= compressedSize && len /= uncompressedSize =
|
||||
Left $ "public key: expected 33 or 65 bytes, got " <> show len
|
||||
| otherwise = unsafePerformIO $
|
||||
allocaBytes pubKeyInternalSize $ \pkPtr ->
|
||||
withBS bs $ \inPtr -> do
|
||||
rc <- c_ec_pubkey_parse secp256k1Ctx pkPtr inPtr (fromIntegral len)
|
||||
if rc == 1
|
||||
then Right . Secp256k1PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
else pure $ Left "public key: not a valid curve point"
|
||||
where
|
||||
len = B.length bs
|
||||
|
||||
serializePublicKey :: PubKeyFormat -> Secp256k1PublicKey -> ByteString
|
||||
serializePublicKey fmt pk = unsafePerformIO $
|
||||
@@ -274,78 +186,4 @@ privateKeyTweakAdd (Secp256k1PrivateKey sk) tweak
|
||||
then Just . Secp256k1PrivateKey <$> packPtr skPtr privateKeySize
|
||||
else pure Nothing
|
||||
|
||||
-- | @tweak * P@. The scalar multiplication behind an ECDH shared secret.
|
||||
--
|
||||
-- Deliberately exposed instead of @secp256k1_ecdh@: that function hashes the
|
||||
-- resulting point with SHA-256, while ERC-5564 hashes it with keccak256 over
|
||||
-- the uncompressed coordinates. Returning the point leaves the hash to the
|
||||
-- caller.
|
||||
--
|
||||
-- 'Nothing' when the tweak is zero or out of range.
|
||||
publicKeyTweakMul :: Secp256k1PublicKey -> ByteString -> Maybe Secp256k1PublicKey
|
||||
publicKeyTweakMul = tweakPubKey c_ec_pubkey_tweak_mul
|
||||
|
||||
-- | @P + tweak * G@, the point addition stealth address derivation needs.
|
||||
--
|
||||
-- 'Nothing' when the tweak is out of range or the result is the point at
|
||||
-- infinity.
|
||||
publicKeyTweakAdd :: Secp256k1PublicKey -> ByteString -> Maybe Secp256k1PublicKey
|
||||
publicKeyTweakAdd = tweakPubKey c_ec_pubkey_tweak_add
|
||||
|
||||
tweakPubKey :: (Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt) -> Secp256k1PublicKey -> ByteString -> Maybe Secp256k1PublicKey
|
||||
tweakPubKey f pk tweak
|
||||
| B.length tweak /= privateKeySize = Nothing
|
||||
| otherwise = unsafePerformIO $
|
||||
allocaBytes pubKeyInternalSize $ \pkPtr ->
|
||||
withBS tweak $ \twPtr -> do
|
||||
withPubKeyRaw pk $ \src -> copyBytes (castPtr pkPtr) (castPtr src) pubKeyInternalSize
|
||||
rc <- f secp256k1Ctx pkPtr twPtr
|
||||
if rc == 1
|
||||
then Just . Secp256k1PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
else pure Nothing
|
||||
|
||||
-- | Sign a 32-byte digest. Deterministic (RFC 6979) and always low-@s@.
|
||||
signRecoverable :: Secp256k1PrivateKey -> ByteString -> Either String RecoverableSignature
|
||||
signRecoverable (Secp256k1PrivateKey sk) digest
|
||||
| B.length digest /= digestSize =
|
||||
Left $ "digest: expected 32 bytes, got " <> show (B.length digest)
|
||||
| otherwise = unsafePerformIO $
|
||||
allocaBytes recSigInternalSize $ \sigPtr ->
|
||||
withBS digest $ \msgPtr ->
|
||||
withBS sk $ \skPtr -> do
|
||||
rc <- c_ecdsa_sign_recoverable secp256k1Ctx sigPtr msgPtr skPtr nullPtr nullPtr
|
||||
if rc /= 1
|
||||
then pure $ Left "secp256k1_ecdsa_sign_recoverable failed"
|
||||
else allocaBytes compactSize $ \outPtr ->
|
||||
alloca $ \recIdPtr -> do
|
||||
rc' <- c_recsig_serialize_compact secp256k1Ctx outPtr recIdPtr sigPtr
|
||||
if rc' /= 1
|
||||
then pure $ Left "secp256k1_ecdsa_recoverable_signature_serialize_compact failed"
|
||||
else do
|
||||
compact <- packPtr outPtr compactSize
|
||||
recId <- peek recIdPtr
|
||||
pure $ Right RecoverableSignature {rsCompact = compact, rsRecId = fromIntegral recId}
|
||||
|
||||
-- | Recover the signing public key from a signature and the digest it signed.
|
||||
recoverPublicKey :: RecoverableSignature -> ByteString -> Either String Secp256k1PublicKey
|
||||
recoverPublicKey sig digest
|
||||
| B.length digest /= digestSize =
|
||||
Left $ "digest: expected 32 bytes, got " <> show (B.length digest)
|
||||
| otherwise = unsafePerformIO $
|
||||
withRecSigRaw sig $ \sigPtr ->
|
||||
allocaBytes pubKeyInternalSize $ \pkPtr ->
|
||||
withBS digest $ \msgPtr -> do
|
||||
rc <- c_ecdsa_recover secp256k1Ctx pkPtr sigPtr msgPtr
|
||||
if rc == 1
|
||||
then Right . Secp256k1PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
else pure $ Left "secp256k1_ecdsa_recover failed"
|
||||
|
||||
-- | Whether @s <= n/2@, i.e. the signature is in the canonical form EIP-2
|
||||
-- requires. libsecp256k1 guarantees this for anything it signs; this exists so
|
||||
-- tests can assert it rather than assume it.
|
||||
isLowS :: RecoverableSignature -> Bool
|
||||
isLowS (RecoverableSignature compact _) =
|
||||
B.length compact == compactSize && os2ip (B.drop privateKeySize compact) <= halfOrder
|
||||
where
|
||||
halfOrder :: Integer
|
||||
halfOrder = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
|
||||
|
||||
@@ -4,19 +4,12 @@
|
||||
-- checksum encoding.
|
||||
module Simplex.Messaging.Eth.Address
|
||||
( Address,
|
||||
unAddress,
|
||||
mkAddress,
|
||||
addressFromPublicKey,
|
||||
addressFromPrivateKey,
|
||||
checksumAddress,
|
||||
parseAddress,
|
||||
addressSize,
|
||||
ethereumPath,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Applicative (optional, (<|>))
|
||||
import Data.Aeson (FromJSON (..), ToJSON (..))
|
||||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||
import Data.Bits (shiftR, (.&.))
|
||||
import qualified Data.ByteArray.Encoding as BAE
|
||||
@@ -58,24 +51,9 @@ instance StrEncoding Address where
|
||||
where
|
||||
letters = BC.filter (not . isDigit) body
|
||||
|
||||
instance ToJSON Address where
|
||||
toEncoding = strToJEncoding
|
||||
toJSON = strToJSON
|
||||
|
||||
instance FromJSON Address where
|
||||
parseJSON = strParseJSON "Address"
|
||||
|
||||
addressSize :: Int
|
||||
addressSize = 20
|
||||
|
||||
unAddress :: Address -> ByteString
|
||||
unAddress (Address bs) = bs
|
||||
|
||||
mkAddress :: ByteString -> Either String Address
|
||||
mkAddress bs
|
||||
| B.length bs /= addressSize = Left $ "address: expected 20 bytes, got " <> show (B.length bs)
|
||||
| otherwise = Right (Address bs)
|
||||
|
||||
-- | The low 20 bytes of @keccak256@ of the uncompressed public key with its
|
||||
-- @0x04@ SEC1 prefix removed.
|
||||
addressFromPublicKey :: S.Secp256k1PublicKey -> Address
|
||||
@@ -102,11 +80,6 @@ checksumAddress (Address bs) = "0x" <> B.pack (zipWith adjust [0 ..] (B.unpack l
|
||||
isHexLetter c = c >= 0x61 && c <= 0x66 -- 'a'..'f'
|
||||
upper c = c - 0x20
|
||||
|
||||
-- | Parse @0x@-prefixed or bare hex. A mixed-case address is checked against
|
||||
-- its EIP-55 checksum; an all-lowercase or all-uppercase one carries no
|
||||
-- checksum and is accepted as-is, which is what every Ethereum client does.
|
||||
parseAddress :: ByteString -> Either String Address
|
||||
parseAddress = strDecode
|
||||
|
||||
-- | BIP-44 path for Ethereum account @i@, address @k@: @m\/44'\/60'\/i'\/0\/k@.
|
||||
-- The account must be below @hardenedOffset@, as 'hardened' returns anything
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
-- | EIP-712 typed structured data hashing.
|
||||
--
|
||||
-- This implements the hashing half of EIP-712 - @typeHash@, @encodeData@,
|
||||
-- @hashStruct@ and the final @0x19 0x01@ digest - over an explicit list of
|
||||
-- member values. It deliberately does *not* derive the canonical type string
|
||||
-- from a schema: the caller supplies it. Our structs are a handful of fixed
|
||||
-- shapes agreed with the contracts, and a hand-written type string that is
|
||||
-- checked against Solidity in a test is both simpler and easier to audit than a
|
||||
-- schema encoder whose output nobody reads.
|
||||
--
|
||||
-- The type string must be the EIP-712 canonical encoding: no spaces after
|
||||
-- commas, member type and name separated by one space, and any referenced
|
||||
-- struct types appended in alphabetical order. For example:
|
||||
--
|
||||
-- > "TransferName(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline)"
|
||||
module Simplex.Messaging.Eth.EIP712
|
||||
( Eip712Domain (..),
|
||||
Eip712Value (..),
|
||||
typeHash,
|
||||
encodeValue,
|
||||
encodeData,
|
||||
hashStruct,
|
||||
domainSeparator,
|
||||
hashTypedData,
|
||||
)
|
||||
where
|
||||
|
||||
import Crypto.Number.Serialize (i2ospOf_)
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import Simplex.Messaging.Eth.Address (Address, unAddress)
|
||||
import Simplex.Messaging.Eth.Keccak (keccak256)
|
||||
|
||||
-- | The standard EIP-712 domain. The spec allows omitting any of the four
|
||||
-- fields, but every contract in this project includes all four.
|
||||
data Eip712Domain = Eip712Domain
|
||||
{ edName :: ByteString,
|
||||
edVersion :: ByteString,
|
||||
edChainId :: Integer,
|
||||
edVerifyingContract :: Address
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | A struct member value, in the EIP-712 sense.
|
||||
--
|
||||
-- 'VStruct' takes an already-computed 'hashStruct' result, which is how nested
|
||||
-- structs are encoded; 'VArray' hashes the concatenation of its members.
|
||||
data Eip712Value
|
||||
= VUint Integer
|
||||
| VInt Integer
|
||||
| VBool Bool
|
||||
| VAddress Address
|
||||
| -- | @bytesN@ for @N@ in @[1, 32]@, left-aligned and zero-padded.
|
||||
VFixedBytes ByteString
|
||||
| -- | Dynamic @bytes@.
|
||||
VBytes ByteString
|
||||
| -- | @string@; the caller supplies UTF-8 bytes.
|
||||
VString ByteString
|
||||
| VArray [Eip712Value]
|
||||
| -- | A nested struct, given as its 32-byte @hashStruct@.
|
||||
VStruct ByteString
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | @keccak256@ of the canonical type string.
|
||||
typeHash :: ByteString -> ByteString
|
||||
typeHash = keccak256
|
||||
|
||||
-- | Encode one member as exactly 32 bytes.
|
||||
encodeValue :: Eip712Value -> Either String ByteString
|
||||
encodeValue = \case
|
||||
VUint n
|
||||
| n < 0 || n >= two256 -> Left $ "eip712: uint256 out of range: " <> show n
|
||||
| otherwise -> Right (word256 n)
|
||||
VInt n
|
||||
| n < negate two255 || n >= two255 -> Left $ "eip712: int256 out of range: " <> show n
|
||||
| otherwise -> Right (word256 (if n < 0 then n + two256 else n))
|
||||
VBool b -> Right (word256 (if b then 1 else 0))
|
||||
VAddress a -> Right (B.replicate 12 0 <> unAddress a)
|
||||
VFixedBytes bs
|
||||
| B.null bs || B.length bs > 32 -> Left $ "eip712: bytesN length " <> show (B.length bs)
|
||||
| otherwise -> Right (bs <> B.replicate (32 - B.length bs) 0)
|
||||
VBytes bs -> Right (keccak256 bs)
|
||||
VString bs -> Right (keccak256 bs)
|
||||
VArray vs -> keccak256 <$> encodeData vs
|
||||
VStruct h
|
||||
| B.length h /= 32 -> Left $ "eip712: struct hash must be 32 bytes, got " <> show (B.length h)
|
||||
| otherwise -> Right h
|
||||
where
|
||||
two256 = 2 ^ (256 :: Int) :: Integer
|
||||
two255 = 2 ^ (255 :: Int) :: Integer
|
||||
|
||||
encodeData :: [Eip712Value] -> Either String ByteString
|
||||
encodeData vs = B.concat <$> traverse encodeValue vs
|
||||
|
||||
-- | @keccak256(typeHash || encodeData(members))@.
|
||||
hashStruct :: ByteString -> [Eip712Value] -> Either String ByteString
|
||||
hashStruct typeString members = keccak256 . (typeHash typeString <>) <$> encodeData members
|
||||
|
||||
domainSeparator :: Eip712Domain -> Either String ByteString
|
||||
domainSeparator d =
|
||||
hashStruct
|
||||
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
|
||||
[ VString (edName d),
|
||||
VString (edVersion d),
|
||||
VUint (edChainId d),
|
||||
VAddress (edVerifyingContract d)
|
||||
]
|
||||
|
||||
-- | The final digest to sign: @keccak256(0x19 || 0x01 || domainSeparator || hashStruct)@.
|
||||
hashTypedData :: Eip712Domain -> ByteString -> [Eip712Value] -> Either String ByteString
|
||||
hashTypedData d typeString members = do
|
||||
ds <- domainSeparator d
|
||||
hs <- hashStruct typeString members
|
||||
pure $ keccak256 (B.pack [0x19, 0x01] <> ds <> hs)
|
||||
|
||||
word256 :: Integer -> ByteString
|
||||
word256 = i2ospOf_ 32
|
||||
@@ -1,143 +0,0 @@
|
||||
-- | ERC-5564 stealth addresses on secp256k1, scheme id 1 ("with view tags").
|
||||
--
|
||||
-- A recipient publishes a __meta-address__: two public keys, spending and
|
||||
-- viewing. A sender picks a random ephemeral key, derives a one-time address
|
||||
-- from it and the meta-address, and publishes the ephemeral public key. Only
|
||||
-- the recipient - who holds the viewing key - can tell which one-time addresses
|
||||
-- are theirs, and only they can spend from them.
|
||||
--
|
||||
-- The meta-address is not an address and never appears on chain, so publishing
|
||||
-- it discloses nothing beyond the ability to send to its owner.
|
||||
--
|
||||
-- == Interoperability
|
||||
--
|
||||
-- ERC-5564 specifies the algebra but /not/ how the shared-secret point is
|
||||
-- serialized before hashing, nor which hash is used. Those come from the EIP
|
||||
-- author's reference implementation
|
||||
-- (<https://github.com/Nerolation/EIP-Stealth-Address-ERC> @minimal_poc.ipynb@):
|
||||
--
|
||||
-- * the shared secret point is serialized __uncompressed with no SEC1 prefix__,
|
||||
-- as @x || y@, 64 bytes;
|
||||
-- * it is hashed with __keccak256__, not SHA-256 - which is why this module
|
||||
-- multiplies points directly rather than calling @secp256k1_ecdh@, whose
|
||||
-- built-in hash is SHA-256;
|
||||
-- * the __view tag is the first byte__ of that hash.
|
||||
--
|
||||
-- Encoding the point the same way an Ethereum address encodes a public key is
|
||||
-- not a coincidence, and it means "Simplex.Messaging.Eth.Address" already
|
||||
-- performs the last step unchanged.
|
||||
module Simplex.Messaging.Eth.Stealth
|
||||
( StealthMetaAddress (..),
|
||||
ViewTag,
|
||||
StealthDestination (..),
|
||||
metaAddress,
|
||||
metaAddressBytes,
|
||||
parseMetaAddress,
|
||||
metaAddressSize,
|
||||
stealthDestination,
|
||||
stealthMatch,
|
||||
stealthPrivateKey,
|
||||
sharedSecretHash,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import Data.Word (Word8)
|
||||
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
|
||||
import Simplex.Messaging.Eth.Address (Address, addressFromPublicKey)
|
||||
import Simplex.Messaging.Eth.Keccak (keccak256)
|
||||
|
||||
-- | A recipient's published key pair: spending key, then viewing key.
|
||||
data StealthMetaAddress = StealthMetaAddress
|
||||
{ smaSpend :: S.Secp256k1PublicKey,
|
||||
smaView :: S.Secp256k1PublicKey
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | The first byte of the hashed shared secret. Lets a recipient discard about
|
||||
-- 255 announcements in 256 with one point multiplication and one hash, instead
|
||||
-- of also deriving an address for each.
|
||||
type ViewTag = Word8
|
||||
|
||||
-- | What a sender produces and publishes.
|
||||
data StealthDestination = StealthDestination
|
||||
{ -- | Where to send. Unlinkable to the meta-address it came from.
|
||||
sdAddress :: Address,
|
||||
-- | The ephemeral public key, compressed. Must reach the recipient, either
|
||||
-- in an announcement event or a message, or the destination is
|
||||
-- undiscoverable.
|
||||
sdEphemeralPubKey :: ByteString,
|
||||
sdViewTag :: ViewTag
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
metaAddress :: S.Secp256k1PrivateKey -> S.Secp256k1PrivateKey -> StealthMetaAddress
|
||||
metaAddress spend view =
|
||||
StealthMetaAddress {smaSpend = S.secp256k1PublicKey spend, smaView = S.secp256k1PublicKey view}
|
||||
|
||||
metaAddressSize :: Int
|
||||
metaAddressSize = 2 * S.compressedSize
|
||||
|
||||
-- | Spending key then viewing key, both compressed. 66 bytes.
|
||||
metaAddressBytes :: StealthMetaAddress -> ByteString
|
||||
metaAddressBytes ma = pub (smaSpend ma) <> pub (smaView ma)
|
||||
where
|
||||
pub = S.serializePublicKey S.Compressed
|
||||
|
||||
parseMetaAddress :: ByteString -> Either String StealthMetaAddress
|
||||
parseMetaAddress bs
|
||||
| B.length bs /= metaAddressSize =
|
||||
Left $ "meta-address: expected " <> show metaAddressSize <> " bytes, got " <> show (B.length bs)
|
||||
| otherwise = do
|
||||
let (spend, view) = B.splitAt S.compressedSize bs
|
||||
StealthMetaAddress <$> S.parsePublicKey spend <*> S.parsePublicKey view
|
||||
|
||||
-- | @keccak256(x || y)@ of @sk * P@ - the value both sides arrive at, the
|
||||
-- sender from the ephemeral key and the recipient from the viewing key.
|
||||
sharedSecretHash :: S.Secp256k1PrivateKey -> S.Secp256k1PublicKey -> Either String ByteString
|
||||
sharedSecretHash sk pk =
|
||||
maybe (Left "stealth: shared secret is not a valid point") (Right . keccak256 . B.drop 1 . S.serializePublicKey S.Uncompressed) $
|
||||
S.publicKeyTweakMul pk (S.unPrivateKey sk)
|
||||
|
||||
-- | Sender side. @ephemeral@ must be freshly random and used once: reusing it
|
||||
-- across recipients lets them link the destinations, and reusing it for one
|
||||
-- recipient produces the same address twice.
|
||||
stealthDestination :: S.Secp256k1PrivateKey -> StealthMetaAddress -> Either String StealthDestination
|
||||
stealthDestination ephemeral ma = do
|
||||
sh <- sharedSecretHash ephemeral (smaView ma)
|
||||
stealthPub <- tweakSpend (smaSpend ma) sh
|
||||
pure
|
||||
StealthDestination
|
||||
{ sdAddress = addressFromPublicKey stealthPub,
|
||||
sdEphemeralPubKey = S.serializePublicKey S.Compressed (S.secp256k1PublicKey ephemeral),
|
||||
sdViewTag = B.head sh
|
||||
}
|
||||
|
||||
-- | Recipient side. Returns the address this announcement would pay us at,
|
||||
-- when its view tag matches. The tag is one byte, so one announcement in 256
|
||||
-- matches by chance: the caller must compare the result with the announced
|
||||
-- address to know the announcement is theirs.
|
||||
--
|
||||
-- The view tag is checked before the point addition, which is the whole reason
|
||||
-- it exists - a non-match costs one multiplication and one hash.
|
||||
stealthMatch :: S.Secp256k1PrivateKey -> S.Secp256k1PublicKey -> ByteString -> ViewTag -> Either String (Maybe Address)
|
||||
stealthMatch view spend ephemeralPub tag = do
|
||||
eph <- S.parsePublicKey ephemeralPub
|
||||
sh <- sharedSecretHash view eph
|
||||
if B.head sh /= tag
|
||||
then pure Nothing
|
||||
else Just . addressFromPublicKey <$> tweakSpend spend sh
|
||||
|
||||
-- | Recipient side. The key that controls a matched destination: @p_spend + s_h@.
|
||||
--
|
||||
-- Needs the spending key, which is why a viewing key can be delegated for
|
||||
-- scanning without granting the ability to spend.
|
||||
stealthPrivateKey :: S.Secp256k1PrivateKey -> S.Secp256k1PrivateKey -> ByteString -> Either String S.Secp256k1PrivateKey
|
||||
stealthPrivateKey spend view ephemeralPub = do
|
||||
eph <- S.parsePublicKey ephemeralPub
|
||||
sh <- sharedSecretHash view eph
|
||||
maybe (Left "stealth: derived key out of range") Right $ S.privateKeyTweakAdd spend sh
|
||||
|
||||
tweakSpend :: S.Secp256k1PublicKey -> ByteString -> Either String S.Secp256k1PublicKey
|
||||
tweakSpend spend sh = maybe (Left "stealth: derived point out of range") Right $ S.publicKeyTweakAdd spend sh
|
||||
@@ -1,4 +1,5 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
-- | Tests for the Ethereum crypto primitives: secp256k1, BIP-39, BIP-32,
|
||||
-- Keccak-256, EIP-55 and EIP-712.
|
||||
@@ -26,9 +27,8 @@ 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.Eth.EIP712
|
||||
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
|
||||
import Simplex.Messaging.Eth.Keccak (keccak256)
|
||||
import Simplex.Messaging.Eth.Stealth
|
||||
import Test.Hspec hiding (fit, it)
|
||||
import Util
|
||||
|
||||
@@ -40,8 +40,6 @@ ethCryptoTests = do
|
||||
describe "BIP-32" bip32Tests
|
||||
describe "BIP-44 derivation" derivationTests
|
||||
describe "EIP-55 addresses" eip55Tests
|
||||
describe "EIP-712 typed data" eip712Tests
|
||||
describe "ERC-5564 stealth addresses" stealthTests
|
||||
|
||||
-- helpers
|
||||
|
||||
@@ -71,45 +69,28 @@ secp256k1Tests :: Spec
|
||||
secp256k1Tests = do
|
||||
it "derives the known address for a known key" $
|
||||
show (addressFromPrivateKey testKey) `shouldBe` "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23"
|
||||
-- cross-checked against an independent RFC-6979 implementation, so this pins
|
||||
-- interoperability rather than our own output
|
||||
it "signs deterministically (RFC 6979)" $ do
|
||||
toHex (S.rsCompact testSig)
|
||||
`shouldBe` "51a4302323b42bae74eab7dc05d46141492eb44e37d24b5bd1f922225da8fc512bbf35fea4a63077fe5220fcdcadc56016c402462f5021d5b80bacd8b84bbdec"
|
||||
S.rsRecId testSig `shouldBe` 0
|
||||
it "produces low-s signatures (EIP-2)" $
|
||||
S.isLowS testSig `shouldBe` True
|
||||
it "recovers the signing key" $
|
||||
right (S.recoverPublicKey testSig testDigest) `shouldBe` S.secp256k1PublicKey testKey
|
||||
it "does not recover the signing key from another digest" $
|
||||
S.recoverPublicKey testSig (keccak256 "SimpleX names ") `shouldNotBe` Right (S.secp256k1PublicKey testKey)
|
||||
it "round-trips a compressed public key" $ do
|
||||
it "serializes a public key in both SEC1 forms" $ do
|
||||
let pk = S.secp256k1PublicKey testKey
|
||||
ser = S.serializePublicKey S.Compressed pk
|
||||
B.length ser `shouldBe` 33
|
||||
S.parsePublicKey ser `shouldBe` Right pk
|
||||
it "round-trips an uncompressed public key" $ do
|
||||
let pk = S.secp256k1PublicKey testKey
|
||||
ser = S.serializePublicKey S.Uncompressed pk
|
||||
B.length ser `shouldBe` 65
|
||||
S.parsePublicKey ser `shouldBe` Right pk
|
||||
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 "rejects a digest that is not 32 bytes" $
|
||||
S.signRecoverable testKey (B.replicate 31 0) `shouldSatisfy` isLeft
|
||||
it "rejects a malformed public key" $
|
||||
S.parsePublicKey (B.replicate 33 0) `shouldSatisfy` isLeft
|
||||
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")
|
||||
testDigest = keccak256 "SimpleX names"
|
||||
testSig = right $ S.signRecoverable testKey testDigest
|
||||
|
||||
bip39Tests :: Spec
|
||||
bip39Tests = do
|
||||
@@ -179,29 +160,11 @@ bip32Tests = do
|
||||
B32.masterKey (B.replicate 65 1) `shouldSatisfy` isLeft
|
||||
it "redacts the extended key in Show" $
|
||||
show master2 `shouldBe` "ExtendedKey <redacted>"
|
||||
describe "path parsing" $ do
|
||||
it "parses a BIP-44 path" $
|
||||
B32.parsePath "m/44'/60'/0'/0/0" `shouldBe` Right [hardened' 44, hardened' 60, hardened' 0, 0, 0]
|
||||
it "accepts h as the hardened marker" $
|
||||
B32.parsePath "m/44h/60h/2h/0/1" `shouldBe` Right [hardened' 44, hardened' 60, hardened' 2, 0, 1]
|
||||
it "accepts a path without the leading m" $
|
||||
B32.parsePath "44'/60'" `shouldBe` Right [hardened' 44, hardened' 60]
|
||||
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 "round-trips render and parse" $
|
||||
B32.parsePath (B32.renderPath (ethereumPath 7 3)) `shouldBe` Right (ethereumPath 7 3)
|
||||
it "renders an Ethereum path for an account and address" $
|
||||
B32.renderPath (ethereumPath 7 3) `shouldBe` "m/44'/60'/7'/0/3"
|
||||
it "rejects a non-numeric component" $
|
||||
B32.parsePath "m/44x/60" `shouldSatisfy` isLeft
|
||||
it "rejects an index at the hardened boundary, saying so" $
|
||||
B32.parsePath "m/2147483648" `shouldBe` Left "Failed reading: path: index out of range: 2147483648"
|
||||
it "rejects a path that ends at the separator" $
|
||||
B32.parsePath "m/" `shouldBe` Left "Failed reading: path: empty component"
|
||||
it "rejects an index glued to the leading m" $
|
||||
B32.parsePath "m44" `shouldBe` Left "Failed reading: path: bad component m44"
|
||||
it "accepts a leading slash" $
|
||||
B32.parsePath "/44'/60" `shouldBe` Right [hardened' 44, 60]
|
||||
where
|
||||
master1 = right $ B32.masterKey (hx "000102030405060708090a0b0c0d0e0f")
|
||||
master2 =
|
||||
@@ -264,35 +227,33 @@ eip55Tests = do
|
||||
describe "spec vectors round-trip" $
|
||||
forM_ specAddresses $ \a ->
|
||||
it (BC.unpack a) $
|
||||
BC.pack (show . right $ parseAddress a) `shouldBe` a
|
||||
BC.pack (show . right $ strDecode @Address a) `shouldBe` a
|
||||
it "accepts an all-lowercase address" $
|
||||
parseAddress "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight
|
||||
strDecode @Address "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight
|
||||
it "accepts an all-uppercase address" $
|
||||
parseAddress "0x5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED" `shouldSatisfy` isRight
|
||||
strDecode @Address "0x5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED" `shouldSatisfy` isRight
|
||||
it "accepts an address without the 0x prefix" $
|
||||
parseAddress "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight
|
||||
strDecode @Address "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight
|
||||
it "rejects a bad EIP-55 checksum" $
|
||||
parseAddress "0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed" `shouldSatisfy` isLeft
|
||||
strDecode @Address "0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed" `shouldSatisfy` isLeft
|
||||
it "rejects the wrong length" $
|
||||
parseAddress "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAe" `shouldSatisfy` isLeft
|
||||
strDecode @Address "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAe" `shouldSatisfy` isLeft
|
||||
it "rejects non-hex characters" $
|
||||
parseAddress "0xZaAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
||||
strDecode @Address "0xZaAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
||||
`shouldBe` Left "Failed reading: address: expected 40 hex digits, got 0"
|
||||
it "rejects raw bytes of the wrong length" $
|
||||
mkAddress (B.replicate 19 0) `shouldSatisfy` isLeft
|
||||
-- Between them these cover every byte value 0x00..0xff going out through the
|
||||
-- hex encoder, and every hex digit coming back through the decoder - which
|
||||
-- the four spec vectors above do not.
|
||||
it "round-trips every byte value through the checksummed form" $
|
||||
forM_ everyByteAddresses $ \a ->
|
||||
parseAddress (checksumAddress a) `shouldBe` Right a
|
||||
strDecode (strEncode a) `shouldBe` Right a
|
||||
it "round-trips every byte value through the lowercase form" $
|
||||
forM_ everyByteAddresses $ \a ->
|
||||
parseAddress (BC.map toLower (checksumAddress a)) `shouldBe` Right 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 . mkAddress . B.pack $
|
||||
[ right . strDecode @Address . ("0x" <>) . toHex . B.pack $
|
||||
[fromIntegral ((i * 20 + j) `mod` 256) | j <- [0 .. 19 :: Int]]
|
||||
| i <- [0 .. 12 :: Int]
|
||||
]
|
||||
@@ -303,60 +264,6 @@ eip55Tests = do
|
||||
"0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
|
||||
]
|
||||
|
||||
eip712Tests :: Spec
|
||||
eip712Tests = do
|
||||
it "computes the spec domain separator" $
|
||||
toHex (right $ domainSeparator domain) `shouldBe` "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"
|
||||
it "computes hashStruct for the Mail example" $
|
||||
toHex mailHash `shouldBe` "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"
|
||||
it "computes the final signing digest" $
|
||||
toHex (right $ hashTypedData domain mailType mailMembers)
|
||||
`shouldBe` "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"
|
||||
it "encodes a bool" $
|
||||
toHex (right $ encodeValue (VBool True)) `shouldBe` "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
it "encodes a negative int as two's complement" $
|
||||
toHex (right $ encodeValue (VInt (-1))) `shouldBe` "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
it "left-aligns bytesN" $
|
||||
toHex (right $ encodeValue (VFixedBytes "\x01\x02")) `shouldBe` "0102000000000000000000000000000000000000000000000000000000000000"
|
||||
it "right-aligns an address" $
|
||||
toHex (right . encodeValue . VAddress . right $ parseAddress "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC")
|
||||
`shouldBe` "000000000000000000000000cccccccccccccccccccccccccccccccccccccccc"
|
||||
it "hashes an array to a single word" $
|
||||
toHex (right $ encodeValue (VArray [VUint 1, VUint 2]))
|
||||
`shouldBe` "e90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0"
|
||||
it "rejects a uint above 2^256" $
|
||||
encodeValue (VUint (2 ^ (256 :: Int))) `shouldSatisfy` isLeft
|
||||
it "rejects a negative uint" $
|
||||
encodeValue (VUint (-1)) `shouldSatisfy` isLeft
|
||||
it "rejects an int outside int256" $
|
||||
encodeValue (VInt (2 ^ (255 :: Int))) `shouldSatisfy` isLeft
|
||||
it "rejects bytesN longer than 32" $
|
||||
encodeValue (VFixedBytes (B.replicate 33 0)) `shouldSatisfy` isLeft
|
||||
it "rejects empty bytesN" $
|
||||
encodeValue (VFixedBytes "") `shouldSatisfy` isLeft
|
||||
it "rejects a struct hash that is not 32 bytes" $
|
||||
encodeValue (VStruct "short") `shouldSatisfy` isLeft
|
||||
where
|
||||
domain =
|
||||
Eip712Domain
|
||||
{ edName = "Ether Mail",
|
||||
edVersion = "1",
|
||||
edChainId = 1,
|
||||
edVerifyingContract = right $ parseAddress "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
|
||||
}
|
||||
personType = "Person(string name,address wallet)"
|
||||
mailType = "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
|
||||
person n w = right $ hashStruct personType [VString n, VAddress (right $ parseAddress w)]
|
||||
mailMembers =
|
||||
[ VStruct $ person "Cow" "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
|
||||
VStruct $ person "Bob" "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB",
|
||||
VString "Hello, Bob!"
|
||||
]
|
||||
mailHash = right $ hashStruct mailType mailMembers
|
||||
|
||||
-- | Official BIP-39 English test vectors from
|
||||
-- <https://github.com/trezor/python-mnemonic/blob/master/vectors.json>,
|
||||
-- all generated with the passphrase @TREZOR@: (entropy, mnemonic, seed).
|
||||
bip39Vectors :: [(ByteString, ByteString, ByteString)]
|
||||
bip39Vectors =
|
||||
[ ( "00000000000000000000000000000000",
|
||||
@@ -432,105 +339,3 @@ bip39Vectors =
|
||||
"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" )
|
||||
]
|
||||
|
||||
-- ERC-5564 stealth addresses.
|
||||
--
|
||||
-- The EIP fixes the algebra but not the serialization or the hash, so the
|
||||
-- pinned vector below is the interoperability contract: it follows the EIP
|
||||
-- author's reference implementation (keccak256 over the shared secret point as
|
||||
-- x||y, view tag = first byte). Anything that changes it breaks compatibility
|
||||
-- with every other ERC-5564 wallet, which is why it is pinned rather than
|
||||
-- computed.
|
||||
stealthTests :: Spec
|
||||
stealthTests = do
|
||||
it "sender and recipient derive the same address" $ do
|
||||
let d = right $ stealthDestination ephemeralKey aliceMeta
|
||||
right (stealthMatch aliceView (smaSpend aliceMeta) (sdEphemeralPubKey d) (sdViewTag d))
|
||||
`shouldBe` Just (sdAddress d)
|
||||
|
||||
it "the recipient's derived key controls that address" $ do
|
||||
let d = right $ stealthDestination ephemeralKey aliceMeta
|
||||
sk = right $ stealthPrivateKey aliceSpend aliceView (sdEphemeralPubKey d)
|
||||
addressFromPrivateKey sk `shouldBe` sdAddress d
|
||||
|
||||
it "the derived key actually signs for it" $ do
|
||||
let d = right $ stealthDestination ephemeralKey aliceMeta
|
||||
sk = right $ stealthPrivateKey aliceSpend aliceView (sdEphemeralPubKey d)
|
||||
digest = keccak256 "transfer"
|
||||
sig = right $ S.signRecoverable sk digest
|
||||
addressFromPublicKey (right $ S.recoverPublicKey sig digest) `shouldBe` sdAddress d
|
||||
|
||||
it "the view tag is the first byte of the hashed shared secret" $ do
|
||||
let d = right $ stealthDestination ephemeralKey aliceMeta
|
||||
sh = right $ sharedSecretHash aliceView (right . S.parsePublicKey $ sdEphemeralPubKey d)
|
||||
sdViewTag d `shouldBe` B.head sh
|
||||
|
||||
it "a different ephemeral key gives an unrelated address" $ do
|
||||
let d1 = right $ stealthDestination ephemeralKey aliceMeta
|
||||
d2 = right $ stealthDestination ephemeralKey2 aliceMeta
|
||||
sdAddress d1 `shouldNotBe` sdAddress d2
|
||||
|
||||
it "the viewing key alone does not spend" $ do
|
||||
-- Using the viewing key where the spending key belongs must not produce the
|
||||
-- address: this is what makes delegated scanning safe.
|
||||
let d = right $ stealthDestination ephemeralKey aliceMeta
|
||||
wrong = right $ stealthPrivateKey aliceView aliceView (sdEphemeralPubKey d)
|
||||
addressFromPrivateKey wrong `shouldNotBe` sdAddress d
|
||||
|
||||
it "another recipient never matches, over a batch of announcements" $ do
|
||||
-- Bob scans 512 announcements addressed to Alice. About two will pass the
|
||||
-- one-byte view tag by chance; none may yield an address Bob controls.
|
||||
let ds = [right $ stealthDestination (ephemeralN i) aliceMeta | i <- [1 .. 512 :: Int]]
|
||||
matches =
|
||||
[ a
|
||||
| d <- ds,
|
||||
Just a <- [right $ stealthMatch bobView (smaSpend bobMeta) (sdEphemeralPubKey d) (sdViewTag d)]
|
||||
]
|
||||
filter (`elem` map sdAddress ds) matches `shouldBe` []
|
||||
|
||||
it "the recipient finds their own in the same batch" $ do
|
||||
let ds = [right $ stealthDestination (ephemeralN i) aliceMeta | i <- [1 .. 64 :: Int]]
|
||||
found =
|
||||
[ a
|
||||
| d <- ds,
|
||||
Just a <- [right $ stealthMatch aliceView (smaSpend aliceMeta) (sdEphemeralPubKey d) (sdViewTag d)]
|
||||
]
|
||||
found `shouldBe` map sdAddress ds
|
||||
|
||||
it "agrees with an independent implementation of the scheme" $ do
|
||||
-- Cross-checked against a from-scratch pure-Python secp256k1 implementing
|
||||
-- the reference algorithm directly (scratchpad @stealth_ref.py@), sharing
|
||||
-- no code with libsecp256k1. Agreement here is what makes this an
|
||||
-- interoperability vector rather than a record of our own output.
|
||||
let d = right $ stealthDestination ephemeralKey aliceMeta
|
||||
checksumAddress (sdAddress d) `shouldBe` "0xbC287a4f0345cD7Fea8d523fBa25Aec4f0B29a6c"
|
||||
toHex (sdEphemeralPubKey d) `shouldBe` "029ac20335eb38768d2052be1dbbc3c8f6178407458e51e6b4ad22f1d91758895b"
|
||||
sdViewTag d `shouldBe` 224
|
||||
|
||||
describe "meta-address encoding" $ do
|
||||
it "round-trips" $
|
||||
parseMetaAddress (metaAddressBytes aliceMeta) `shouldBe` Right aliceMeta
|
||||
it "is 66 bytes, spending key first" $ do
|
||||
let bs = metaAddressBytes aliceMeta
|
||||
B.length bs `shouldBe` 66
|
||||
B.take 33 bs `shouldBe` S.serializePublicKey S.Compressed (smaSpend aliceMeta)
|
||||
it "rejects a wrong length" $
|
||||
parseMetaAddress (B.take 65 $ metaAddressBytes aliceMeta) `shouldSatisfy` isLeft
|
||||
it "rejects points not on the curve" $
|
||||
parseMetaAddress (B.replicate 66 0xAA) `shouldSatisfy` isLeft
|
||||
|
||||
aliceSpend, aliceView, bobSpend, bobView, ephemeralKey, ephemeralKey2 :: S.Secp256k1PrivateKey
|
||||
aliceSpend = right $ S.mkPrivateKey (hx "1111111111111111111111111111111111111111111111111111111111111111")
|
||||
aliceView = right $ S.mkPrivateKey (hx "2222222222222222222222222222222222222222222222222222222222222222")
|
||||
bobSpend = right $ S.mkPrivateKey (hx "3333333333333333333333333333333333333333333333333333333333333333")
|
||||
bobView = right $ S.mkPrivateKey (hx "4444444444444444444444444444444444444444444444444444444444444444")
|
||||
ephemeralKey = right $ S.mkPrivateKey (hx "5555555555555555555555555555555555555555555555555555555555555555")
|
||||
ephemeralKey2 = right $ S.mkPrivateKey (hx "6666666666666666666666666666666666666666666666666666666666666666")
|
||||
|
||||
aliceMeta, bobMeta :: StealthMetaAddress
|
||||
aliceMeta = metaAddress aliceSpend aliceView
|
||||
bobMeta = metaAddress bobSpend bobView
|
||||
|
||||
-- Distinct ephemeral keys for batch tests.
|
||||
ephemeralN :: Int -> S.Secp256k1PrivateKey
|
||||
ephemeralN i = right . S.mkPrivateKey . keccak256 . BC.pack $ "ephemeral " <> show i
|
||||
|
||||
Reference in New Issue
Block a user