cut to the min

This commit is contained in:
Alain Brenzikofer
2026-09-22 08:40:14 +02:00
parent d2128507cc
commit 31f0a74310
9 changed files with 72 additions and 851 deletions
+43 -144
View File
@@ -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
View File
@@ -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
-27
View File
@@ -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)
-2
View File
@@ -20,9 +20,7 @@ module Simplex.Messaging.Crypto.BIP39
parseMnemonic,
mnemonicToSeed,
randomMnemonic,
strengthBytes,
strengthWordCount,
seedSize,
)
where
+5 -167
View File
@@ -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
-27
View File
@@ -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
-120
View File
@@ -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
-143
View File
@@ -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
+23 -218
View File
@@ -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