From 2bbc55619f336a39a7e569f8b87fb5974f12c698 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 7 Aug 2026 19:04:22 +0200 Subject: [PATCH] expose what's necessary for stealth addresses too" --- CHANGELOG.md | 4 + plans/2026-08-05-eth-crypto-bindings.md | 48 +++++++ simplexmq.cabal | 1 + src/Simplex/Messaging/Crypto/Secp256k1.hs | 37 ++++++ src/Simplex/Messaging/Eth/Stealth.hs | 147 ++++++++++++++++++++++ tests/CoreTests/EthCryptoTests.hs | 104 +++++++++++++++ 6 files changed, 341 insertions(+) create mode 100644 src/Simplex/Messaging/Eth/Stealth.hs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ee358167..ac679b053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Crypto: Keccak-256, EIP-55 addresses and EIP-712 typed data hashing. Client-side signing only - no transaction construction and no chain writes; the resolver path remains read-only. See `plans/2026-08-05-eth-crypto-bindings.md`. +- ERC-5564 stealth addresses (`Simplex.Messaging.Eth.Stealth`): a recipient + publishes a spend/view meta-address, a sender derives a one-time address from + it non-interactively, and only the recipient can find or spend from it. Adds + `publicKeyTweakMul` and `publicKeyTweakAdd` to the secp256k1 bindings. # 6.5.1 diff --git a/plans/2026-08-05-eth-crypto-bindings.md b/plans/2026-08-05-eth-crypto-bindings.md index 218610202..35bc7679f 100644 --- a/plans/2026-08-05-eth-crypto-bindings.md +++ b/plans/2026-08-05-eth-crypto-bindings.md @@ -275,3 +275,51 @@ published vectors rather than our own output: 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. diff --git a/simplexmq.cabal b/simplexmq.cabal index ef93887ec..87caa269a 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -152,6 +152,7 @@ library 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 diff --git a/src/Simplex/Messaging/Crypto/Secp256k1.hs b/src/Simplex/Messaging/Crypto/Secp256k1.hs index d82540e73..dcdff54b4 100644 --- a/src/Simplex/Messaging/Crypto/Secp256k1.hs +++ b/src/Simplex/Messaging/Crypto/Secp256k1.hs @@ -28,6 +28,8 @@ module Simplex.Messaging.Crypto.Secp256k1 parsePublicKey, serializePublicKey, privateKeyTweakAdd, + publicKeyTweakMul, + publicKeyTweakAdd, signRecoverable, recoverPublicKey, isLowS, @@ -147,6 +149,11 @@ 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_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 @@ -280,6 +287,36 @@ privateKeyTweakAdd (PrivateKey sk) tweak then Just . PrivateKey <$> 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 :: PublicKey -> ByteString -> Maybe PublicKey +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 :: PublicKey -> ByteString -> Maybe PublicKey +publicKeyTweakAdd = tweakPubKey c_ec_pubkey_tweak_add + +tweakPubKey :: (Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt) -> PublicKey -> ByteString -> Maybe PublicKey +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 . PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize + else pure Nothing + -- | Sign a 32-byte digest. Deterministic (RFC 6979) and always low-@s@. signRecoverable :: PrivateKey -> ByteString -> Either String RecoverableSignature signRecoverable (PrivateKey sk) digest diff --git a/src/Simplex/Messaging/Eth/Stealth.hs b/src/Simplex/Messaging/Eth/Stealth.hs new file mode 100644 index 000000000..5e896803d --- /dev/null +++ b/src/Simplex/Messaging/Eth/Stealth.hs @@ -0,0 +1,147 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | 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 Simplex.Messaging.Eth.Address (Address, addressFromPublicKey) +import Simplex.Messaging.Eth.Keccak (keccak256) +import qualified Simplex.Messaging.Crypto.Secp256k1 as S + +-- | A recipient's published key pair: spending key, then viewing key. +data StealthMetaAddress = StealthMetaAddress + { smaSpend :: S.PublicKey, + smaView :: S.PublicKey + } + 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.PrivateKey -> S.PrivateKey -> StealthMetaAddress +metaAddress spend view = + StealthMetaAddress {smaSpend = S.publicKey spend, smaView = S.publicKey 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.PrivateKey -> S.PublicKey -> Either String ByteString +sharedSecretHash sk pk = + case S.publicKeyTweakMul pk (S.unPrivateKey sk) of + Nothing -> Left "stealth: shared secret is not a valid point" + Just p -> Right . keccak256 . B.drop 1 $ S.serializePublicKey S.Uncompressed p + +-- | 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.PrivateKey -> 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.publicKey ephemeral), + sdViewTag = B.head sh + } + +-- | Recipient side. Returns the address when this announcement is ours. +-- +-- 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.PrivateKey -> S.PublicKey -> 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.PrivateKey -> S.PrivateKey -> ByteString -> Either String S.PrivateKey +stealthPrivateKey spend view ephemeralPub = do + eph <- S.parsePublicKey ephemeralPub + sh <- sharedSecretHash view eph + case S.privateKeyTweakAdd spend sh of + Nothing -> Left "stealth: derived key out of range" + Just sk -> Right sk + +tweakSpend :: S.PublicKey -> ByteString -> Either String S.PublicKey +tweakSpend spend sh = case S.publicKeyTweakAdd spend sh of + Nothing -> Left "stealth: derived point out of range" + Just p -> Right p diff --git a/tests/CoreTests/EthCryptoTests.hs b/tests/CoreTests/EthCryptoTests.hs index f828cbf7a..7f3841be2 100644 --- a/tests/CoreTests/EthCryptoTests.hs +++ b/tests/CoreTests/EthCryptoTests.hs @@ -26,6 +26,7 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Eth.Address import Simplex.Messaging.Eth.EIP712 import Simplex.Messaging.Eth.Keccak (keccak256) +import Simplex.Messaging.Eth.Stealth import Test.Hspec hiding (fit, it) import Util @@ -38,6 +39,7 @@ ethCryptoTests = do describe "BIP-44 derivation" derivationTests describe "EIP-55 addresses" eip55Tests describe "EIP-712 typed data" eip712Tests + describe "ERC-5564 stealth addresses" stealthTests -- helpers @@ -396,3 +398,105 @@ 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.PrivateKey +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.PrivateKey +ephemeralN i = right . S.mkPrivateKey . keccak256 . BC.pack $ "ephemeral " <> show i