diff --git a/plans/2026-08-05-eth-crypto-bindings.md b/plans/2026-08-05-eth-crypto-bindings.md index a35d9cf42..e12197830 100644 --- a/plans/2026-08-05-eth-crypto-bindings.md +++ b/plans/2026-08-05-eth-crypto-bindings.md @@ -10,12 +10,13 @@ held by the device. ## What is deliberately absent -- **No RLP encoder, and no transaction building.** RLP is only needed to - construct raw transactions or EIP-7702 authorizations. The client does - neither, so the `RSLV` resolver path in this repo stays strictly read-only. -- **No signing and no stealth addresses.** This change adds key and address - derivation only. Signing, the EIP-712 typed-data hashing it requires, and - stealth addresses are added with the first code that uses them. +- **No EIP-7702 authorizations.** RLP is implemented only as far as EIP-1559 + transactions need it, for registering names; the `RSLV` resolver path in + this repo stays strictly read-only. +- **No stealth addresses and no EIP-712.** Signing covers recoverable ECDSA + over a 32-byte digest and EIP-1559 transactions, which name registration + requires. EIP-712 typed-data hashing and stealth addresses are added with + the first code that uses them. - **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. @@ -37,6 +38,8 @@ Simplex.Messaging.Crypto.BIP39.English embedded upstream 2048-word list Simplex.Messaging.Crypto.BIP32 HD derivation, WalletMaster Simplex.Messaging.Crypto.BIP44 BIP-44 paths, AccountIndex Simplex.Messaging.Eth.Address addresses, EIP-55 +Simplex.Messaging.Eth.RLP Recursive Length Prefix encoding +Simplex.Messaging.Eth.Transaction signed EIP-1559 transactions ``` `keccak256` is added to `Simplex.Messaging.Crypto`, next to `sha3_256`. @@ -211,12 +214,14 @@ c-sources: cbits/libsecp256k1/src/{secp256k1,precomputed_ecmult,precomputed_e No `include-dirs` are needed: every libsecp256k1 include is quoted and relative to the including file. The embedded wordlist makes `file-embed` a library dependency; before this change only the `smp-server` and `xftp-server` -executables and the test suite used it. +executables and the test suite used it. `wide-word` provides `Word128` and +`Word256` for transaction fields, each as narrow as both geth and reth accept. -Built with no `-D` of our own. The table-size settings (`ECMULT_WINDOW_SIZE`, -`COMB_BLOCKS`, `COMB_TEETH`) have `#ifndef` defaults in the headers, and the -checked-in precomputed tables are generated for those defaults; every other -option, such as the optional modules and x86-64 assembly, is off unless a `-D` +Built with one `-D` of our own, `ENABLE_MODULE_RECOVERY=1`, for recoverable +signatures. The table-size settings (`ECMULT_WINDOW_SIZE`, `COMB_BLOCKS`, +`COMB_TEETH`) have `#ifndef` defaults in the headers, and the checked-in +precomputed tables are generated for those defaults; every other option, such +as the other optional modules and x86-64 assembly, is off unless a `-D` enables it. `secp256k1.c` defines `SECP256K1_BUILD` itself. 32-bit targets (armv7a-android, i686 musl) are covered by libsecp256k1's own @@ -269,7 +274,7 @@ the C code independently of the Haskell build. ## Tests -`tests/CoreTests/EthCryptoTests.hs`, 93 examples, with published vectors read +`tests/CoreTests/EthCryptoTests.hs`, 104 examples, with published vectors read from vendored upstream files in `tests/fixtures`, each pinned by a sha256 test: - **BIP-39**: all 24 English vectors of `trezor/python-mnemonic/vectors.json`: diff --git a/simplexmq.cabal b/simplexmq.cabal index 0a716f06a..fac127bb4 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -155,6 +155,8 @@ library Simplex.Messaging.Encoding Simplex.Messaging.Encoding.String Simplex.Messaging.Eth.Address + Simplex.Messaging.Eth.RLP + Simplex.Messaging.Eth.Transaction Simplex.Messaging.Names.Record Simplex.Messaging.Notifications.Client Simplex.Messaging.Notifications.Protocol @@ -339,8 +341,8 @@ library cbits/blst/src cbits/libbbs/include cbits/libbbs/src - -- libsecp256k1 requires no -D flags: every setting has an #ifndef default that matches the checked-in tables - cc-options: -D__BLST_PORTABLE__ + -- libsecp256k1's only -D flag enables its recovery module: every other setting has an #ifndef default that matches the checked-in tables + cc-options: -D__BLST_PORTABLE__ -DENABLE_MODULE_RECOVERY=1 if flag(commoncrypto) cc-options: -DBBS_CRYPTO_CC frameworks: Security @@ -408,6 +410,7 @@ library , transformers ==0.6.* , unliftio ==0.2.* , unliftio-core ==0.2.* + , wide-word ==0.1.6.* , yaml ==0.11.* , zstd ==0.1.3.* default-language: Haskell2010 diff --git a/src/Simplex/Messaging/Crypto/Secp256k1.hs b/src/Simplex/Messaging/Crypto/Secp256k1.hs index 3637e5b6a..27122c6e7 100644 --- a/src/Simplex/Messaging/Crypto/Secp256k1.hs +++ b/src/Simplex/Messaging/Crypto/Secp256k1.hs @@ -1,4 +1,5 @@ {-# LANGUAGE ForeignFunctionInterface #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE ScopedTypeVariables #-} -- | FFI bindings to libsecp256k1. @@ -7,6 +8,7 @@ module Simplex.Messaging.Crypto.Secp256k1 Secp256k1PublicKey, Secp256k1Context, PubKeyFormat (..), + RecoverableSignature (..), mkPrivateKey, unPrivateKey, withContext, @@ -14,6 +16,7 @@ module Simplex.Messaging.Crypto.Secp256k1 secp256k1PublicKey_, serializePublicKey, privateKeyTweakAdd, + signRecoverable, ) where @@ -26,6 +29,7 @@ import Crypto.Random (ChaChaDRG) import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) +import qualified Data.ByteString as B import Foreign hiding (void) import Foreign.C import qualified Simplex.Messaging.Crypto as C @@ -46,6 +50,16 @@ uncompressedSize = 65 pubKeyInternalSize :: Int pubKeyInternalSize = 64 +-- | Internal size of @secp256k1_ecdsa_recoverable_signature@. +recSigInternalSize :: Int +recSigInternalSize = 65 + +compactSigSize :: Int +compactSigSize = 64 + +digestSize :: Int +digestSize = 32 + -- Types newtype Secp256k1PrivateKey = Secp256k1PrivateKey ScrubbedBytes @@ -59,12 +73,22 @@ newtype Secp256k1Context = Secp256k1Context (Ptr Ctx) data PubKeyFormat = Compressed | Uncompressed deriving (Eq, Show) +-- | @r@ and @s@, 32 bytes big-endian each, and the recovery id, EIP-1559's @yParity@. +data RecoverableSignature = RecoverableSignature + { rsR :: ByteString, + rsS :: ByteString, + rsRecId :: Word8 + } + deriving (Eq, Show) + -- FFI data Ctx data PubKeyRaw +data RecSigRaw + foreign import ccall "secp256k1_context_create" c_context_create :: CUInt -> IO (Ptr Ctx) @@ -86,6 +110,12 @@ foreign import ccall "secp256k1_ec_pubkey_serialize" 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_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_ecdsa_recoverable_signature_serialize_compact :: Ptr Ctx -> Ptr Word8 -> Ptr CInt -> Ptr RecSigRaw -> IO CInt + -- SECP256K1_CONTEXT_NONE = SECP256K1_FLAGS_TYPE_CONTEXT contextNone :: CUInt contextNone = 1 @@ -146,3 +176,20 @@ privateKeyTweakAdd (Secp256k1PrivateKey sk) tweak BA.withByteArray tweak $ \twPtr -> do (rc, sk') <- BA.copyRet sk $ \skPtr -> c_ec_seckey_tweak_add staticContext skPtr twPtr pure $ if rc == 1 then Just (Secp256k1PrivateKey sk') else Nothing + +-- | Signs a 32-byte digest, deterministically (RFC 6979) and always low-@s@, as EIP-2 requires. +signRecoverable :: TVar ChaChaDRG -> Secp256k1PrivateKey -> ByteString -> IO (Either String RecoverableSignature) +signRecoverable g (Secp256k1PrivateKey sk) digest + | B.length digest /= digestSize = pure $ Left $ "digest: expected 32 bytes, got " <> show (B.length digest) + | otherwise = withContext g $ \(Secp256k1Context ctx) -> + allocaBytes recSigInternalSize $ \sigPtr -> do + rc <- BA.withByteArray digest $ \msgPtr -> BA.withByteArray sk $ \skPtr -> c_ecdsa_sign_recoverable ctx sigPtr msgPtr skPtr nullPtr nullPtr + if rc /= 1 + then pure $ Left "secp256k1_ecdsa_sign_recoverable failed on a validated key" + else do + (recId, compact) <- BA.allocRet compactSigSize $ \outPtr -> + alloca $ \recIdPtr -> do + void $ c_ecdsa_recoverable_signature_serialize_compact ctx outPtr recIdPtr sigPtr + peek recIdPtr + let (rsR, rsS) = B.splitAt (compactSigSize `div` 2) compact + pure $ Right RecoverableSignature {rsR, rsS, rsRecId = fromIntegral recId} diff --git a/src/Simplex/Messaging/Eth/Address.hs b/src/Simplex/Messaging/Eth/Address.hs index 6e6cf94dc..0945baea6 100644 --- a/src/Simplex/Messaging/Eth/Address.hs +++ b/src/Simplex/Messaging/Eth/Address.hs @@ -5,6 +5,7 @@ module Simplex.Messaging.Eth.Address ( Address, + unAddress, addressFromPublicKey, addressFromPrivateKey, deriveAddress, @@ -48,6 +49,9 @@ instance StrEncoding Address where addressSize :: Int addressSize = 20 +unAddress :: Address -> ByteString +unAddress (Address bs) = bs + -- | The last 20 bytes of @keccak256@ of the uncompressed public key with its @0x04@ SEC1 prefix removed. addressFromPublicKey :: S.Secp256k1PublicKey -> IO Address addressFromPublicKey pk = Address . B.drop 12 . keccak256 . B.drop 1 <$> S.serializePublicKey S.Uncompressed pk diff --git a/src/Simplex/Messaging/Eth/RLP.hs b/src/Simplex/Messaging/Eth/RLP.hs new file mode 100644 index 000000000..673c3d42f --- /dev/null +++ b/src/Simplex/Messaging/Eth/RLP.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE LambdaCase #-} + +-- | Recursive Length Prefix, the encoding of Ethereum transactions. +module Simplex.Messaging.Eth.RLP + ( RLPItem (..), + rlpEncode, + scalarItem, + ) +where + +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.Word (Word32) +import Simplex.Messaging.Encoding (smpEncode) + +data RLPItem = RLPBytes ByteString | RLPList [RLPItem] + +rlpEncode :: RLPItem -> ByteString +rlpEncode = \case + RLPBytes b + | B.length b == 1 && B.head b < 0x80 -> b + | otherwise -> lengthPrefixed 0x80 b + RLPList items -> lengthPrefixed 0xc0 $ B.concat $ map rlpEncode items + where + lengthPrefixed offset payload + | len < 56 = B.cons (offset + fromIntegral len) payload + | otherwise = B.cons (offset + 55 + fromIntegral (B.length lenBytes)) lenBytes <> payload + where + len = B.length payload + lenBytes = B.dropWhile (== 0) $ smpEncode (fromIntegral len :: Word32) + +-- | Big-endian with no leading zeros, and zero as the empty string. +scalarItem :: ByteString -> RLPItem +scalarItem = RLPBytes . B.dropWhile (== 0) diff --git a/src/Simplex/Messaging/Eth/Transaction.hs b/src/Simplex/Messaging/Eth/Transaction.hs new file mode 100644 index 000000000..f7b8e15c9 --- /dev/null +++ b/src/Simplex/Messaging/Eth/Transaction.hs @@ -0,0 +1,58 @@ +{-# LANGUAGE NamedFieldPuns #-} + +-- | EIP-1559 (type 2) transactions with an empty access list, signed for @eth_sendRawTransaction@. +module Simplex.Messaging.Eth.Transaction + ( Eip1559Tx (..), + signEip1559Tx, + ) +where + +import Control.Concurrent.STM (TVar) +import Crypto.Random (ChaChaDRG) +import Data.Bits (shiftR) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.WideWord.Word128 (Word128 (..)) +import Data.WideWord.Word256 (Word256 (..)) +import Data.Word (Word32, Word64) +import Simplex.Messaging.Crypto (keccak256) +import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature (..), Secp256k1PrivateKey, signRecoverable) +import Simplex.Messaging.Encoding (smpEncode) +import Simplex.Messaging.Eth.Address (Address, unAddress) +import Simplex.Messaging.Eth.RLP +import Simplex.Messaging.Util ((<$$>)) + +-- | Each width is the narrowest that both geth and reth accept. +data Eip1559Tx = Eip1559Tx + { txChainId :: Word64, + txNonce :: Word64, + txMaxPriorityFeePerGas :: Word128, + txMaxFeePerGas :: Word128, + txGasLimit :: Word64, + txTo :: Address, + txValue :: Word256, + txData :: ByteString + } + +-- | @0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, yParity, r, s])@, signing @keccak256@ of the same without the last three. +signEip1559Tx :: TVar ChaChaDRG -> Secp256k1PrivateKey -> Eip1559Tx -> IO (Either String ByteString) +signEip1559Tx g sk Eip1559Tx {txChainId, txNonce, txMaxPriorityFeePerGas, txMaxFeePerGas, txGasLimit, txTo, txValue, txData} = + signed <$$> signRecoverable g sk (keccak256 $ typed fields) + where + signed RecoverableSignature {rsR, rsS, rsRecId} = typed $ fields <> [scalarItem (B.singleton rsRecId), scalarItem rsR, scalarItem rsS] + fields = + [ w64 txChainId, + w64 txNonce, + w128 txMaxPriorityFeePerGas, + w128 txMaxFeePerGas, + w64 txGasLimit, + RLPBytes $ unAddress txTo, + w256 txValue, + RLPBytes txData, + RLPList [] + ] + w64 = scalarItem . be64 + w128 (Word128 hi lo) = scalarItem $ be64 hi <> be64 lo + w256 (Word256 a3 a2 a1 a0) = scalarItem $ be64 a3 <> be64 a2 <> be64 a1 <> be64 a0 + be64 n = smpEncode (fromIntegral (n `shiftR` 32) :: Word32) <> smpEncode (fromIntegral n :: Word32) + typed items = B.cons 0x02 $ rlpEncode $ RLPList items diff --git a/tests/CoreTests/EthCryptoTests.hs b/tests/CoreTests/EthCryptoTests.hs index 22b0e4cad..0f8df1f21 100644 --- a/tests/CoreTests/EthCryptoTests.hs +++ b/tests/CoreTests/EthCryptoTests.hs @@ -32,6 +32,9 @@ import Simplex.Messaging.Crypto.BIP44 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strDecode, strEncode) import Simplex.Messaging.Eth.Address +import Simplex.Messaging.Eth.RLP +import Simplex.Messaging.Eth.Transaction +import Simplex.Messaging.Util ((<$$>)) import Test.Hspec hiding (fit, it) import Util @@ -44,6 +47,9 @@ ethCryptoTests = do describe "BIP-32" $ bip32Tests g describe "BIP-44 derivation" $ derivationTests g describe "EIP-55 addresses" eip55Tests + describe "RLP" rlpTests + describe "recoverable signing" $ signingTests g + describe "EIP-1559 transactions" $ transactionTests g -- helpers @@ -307,3 +313,58 @@ eip55Tests = do "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb" ] + +rlpTests :: Spec +rlpTests = do + forM_ rlpVectors $ \(name, item, expected) -> + it ("encodes " <> name) $ toHex (rlpEncode item) `shouldBe` expected + it "encodes scalars without leading zeros" $ do + toHex (rlpEncode $ scalarItem $ B.replicate 32 0) `shouldBe` "80" + toHex (rlpEncode $ scalarItem $ hx "000f") `shouldBe` "0f" + toHex (rlpEncode $ scalarItem $ hx "000400") `shouldBe` "820400" + where + rlpVectors = + [ ("the empty string", RLPBytes "", "80"), + ("the empty list", RLPList [], "c0"), + ("dog", RLPBytes "dog", "83646f67"), + ("[cat, dog]", RLPList [RLPBytes "cat", RLPBytes "dog"], "c88363617483646f67"), + ("the set-theoretic three", RLPList [RLPList [], RLPList [RLPList []], RLPList [RLPList [], RLPList [RLPList []]]], "c7c0c1c0c3c0c1c0"), + ("a 56-byte string", RLPBytes (B.replicate 56 0x61), "b838" <> B.concat (replicate 56 "61")) + ] + +-- expected signatures and transactions are viem's for the same key and inputs +signingTests :: TVar ChaChaDRG -> Spec +signingTests g = do + it "derives the EIP-155 example address" $ + (strEncode <$> addressFromPrivateKey g txKey) `shouldReturn` "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F" + it "signs a digest as viem does" $ + S.signRecoverable g txKey (C.keccak256 "simplex") + `shouldReturn` Right + S.RecoverableSignature + { S.rsR = hx "8d5aa94d4288fb8297dd9ff2c9cc5026b09b5305ace1f0b48a864ab116f2d3b0", + S.rsS = hx "3eb3b2c117d918736df192c69cef499294f2a070aff74c2bf69776a179b7cdb1", + S.rsRecId = 1 + } + it "refuses a digest that is not 32 bytes" $ + (isLeft <$> S.signRecoverable g txKey "digest") `shouldReturn` True + +transactionTests :: TVar ChaChaDRG -> Spec +transactionTests g = do + it "signs a contract call as viem does" $ + (toHex <$$> signEip1559Tx g txKey contractCall) + `shouldReturn` Right "02f89383aa36a709843b9aca008506fc23ac00830186a094353535353535353535353535353535353535353580a4f14fcbc81111111111111111111111111111111111111111111111111111111111111111c001a08be78ee2e58a62763be161e3c4440c74c3b6ae56d667b143554e12239a6328e5a0790d86c0398a5295d981d12f4abc10d61b58917b7b7fd21215f7686457aafc19" + it "signs a value transfer with zero fields as viem does" $ + (toHex <$$> signEip1559Tx g txKey transfer) + `shouldReturn` Right "02f86a01808001825208943535353535353535353535353535353535353535880de0b6b3a764000080c001a0d04dbcdcfa4c64fe9deb8f0fea4d7a1f07407e80fd2b066b8f6d58a9baf6af0ba074c14688697464225bbe29301db1df7f0c1b9484a52eaf76162524baba2f2791" + it "encodes every word of fields wider than 64 bits" $ + (B.isInfixOf wideFields . toHex <$$> signEip1559Tx g txKey wide) + `shouldReturn` Right True + where + to = right $ strDecode "0x3535353535353535353535353535353535353535" + contractCall = Eip1559Tx {txChainId = 11155111, txNonce = 9, txMaxPriorityFeePerGas = 1000000000, txMaxFeePerGas = 30000000000, txGasLimit = 100000, txTo = to, txValue = 0, txData = hx "f14fcbc8" <> B.replicate 32 0x11} + transfer = Eip1559Tx {txChainId = 1, txNonce = 0, txMaxPriorityFeePerGas = 0, txMaxFeePerGas = 1, txGasLimit = 21000, txTo = to, txValue = 1000000000000000000, txData = ""} + wide = Eip1559Tx {txChainId = maxBound, txNonce = maxBound - 1, txMaxPriorityFeePerGas = 0x10000000000000000000000007, txMaxFeePerGas = 0x80000000000000010000000000000003, txGasLimit = 0x10000000001, txTo = to, txValue = 0x8000000000000001000000000000000100000000000000010000000000000009, txData = ""} + wideFields = "88ffffffffffffffff88fffffffffffffffe8d10000000000000000000000007908000000000000001000000000000000386010000000001943535353535353535353535353535353535353535a0800000000000000100000000000000010000000000000001000000000000000980c0" + +txKey :: S.Secp256k1PrivateKey +txKey = right $ S.mkPrivateKey $ BA.replicate 32 0x46