diff --git a/plans/2026-08-05-eth-crypto-bindings.md b/plans/2026-08-05-eth-crypto-bindings.md index 35bc7679f..88c3a7d69 100644 --- a/plans/2026-08-05-eth-crypto-bindings.md +++ b/plans/2026-08-05-eth-crypto-bindings.md @@ -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. diff --git a/simplexmq.cabal b/simplexmq.cabal index 66070c875..2649dba4c 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -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 diff --git a/src/Simplex/Messaging/Crypto/BIP32.hs b/src/Simplex/Messaging/Crypto/BIP32.hs index f68ede201..91d2e6788 100644 --- a/src/Simplex/Messaging/Crypto/BIP32.hs +++ b/src/Simplex/Messaging/Crypto/BIP32.hs @@ -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) diff --git a/src/Simplex/Messaging/Crypto/BIP39.hs b/src/Simplex/Messaging/Crypto/BIP39.hs index 4cd7f3f10..9f45803f3 100644 --- a/src/Simplex/Messaging/Crypto/BIP39.hs +++ b/src/Simplex/Messaging/Crypto/BIP39.hs @@ -20,9 +20,7 @@ module Simplex.Messaging.Crypto.BIP39 parseMnemonic, mnemonicToSeed, randomMnemonic, - strengthBytes, strengthWordCount, - seedSize, ) where diff --git a/src/Simplex/Messaging/Crypto/Secp256k1.hs b/src/Simplex/Messaging/Crypto/Secp256k1.hs index 33ca218b4..0a2391d86 100644 --- a/src/Simplex/Messaging/Crypto/Secp256k1.hs +++ b/src/Simplex/Messaging/Crypto/Secp256k1.hs @@ -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 diff --git a/src/Simplex/Messaging/Eth/Address.hs b/src/Simplex/Messaging/Eth/Address.hs index 9f34536db..569af7050 100644 --- a/src/Simplex/Messaging/Eth/Address.hs +++ b/src/Simplex/Messaging/Eth/Address.hs @@ -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 diff --git a/src/Simplex/Messaging/Eth/EIP712.hs b/src/Simplex/Messaging/Eth/EIP712.hs deleted file mode 100644 index 82161f855..000000000 --- a/src/Simplex/Messaging/Eth/EIP712.hs +++ /dev/null @@ -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 diff --git a/src/Simplex/Messaging/Eth/Stealth.hs b/src/Simplex/Messaging/Eth/Stealth.hs deleted file mode 100644 index a4da47b47..000000000 --- a/src/Simplex/Messaging/Eth/Stealth.hs +++ /dev/null @@ -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 --- ( @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 diff --git a/tests/CoreTests/EthCryptoTests.hs b/tests/CoreTests/EthCryptoTests.hs index 174739533..fb1b4336f 100644 --- a/tests/CoreTests/EthCryptoTests.hs +++ b/tests/CoreTests/EthCryptoTests.hs @@ -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 " - 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 --- , --- 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