expose what's necessary for stealth addresses too"

This commit is contained in:
Alain Brenzikofer
2026-08-18 13:47:16 +02:00
committed by Alain Brenzikofer
parent 3ab14f5a93
commit 2bbc55619f
6 changed files with 341 additions and 0 deletions
+4
View File
@@ -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
+48
View File
@@ -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.
+1
View File
@@ -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
+37
View File
@@ -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
+147
View File
@@ -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
-- (<https://github.com/Nerolation/EIP-Stealth-Address-ERC> @minimal_poc.ipynb@):
--
-- * the shared secret point is serialized __uncompressed with no SEC1 prefix__,
-- as @x || y@, 64 bytes;
-- * it is hashed with __keccak256__, not SHA-256 — which is why this module
-- multiplies points directly rather than calling @secp256k1_ecdh@, whose
-- built-in hash is SHA-256;
-- * the __view tag is the first byte__ of that hash.
--
-- Encoding the point the same way an Ethereum address encodes a public key is
-- not a coincidence, and it means 'Simplex.Messaging.Eth.Address' already
-- performs the last step unchanged.
module Simplex.Messaging.Eth.Stealth
( StealthMetaAddress (..),
ViewTag,
StealthDestination (..),
metaAddress,
metaAddressBytes,
parseMetaAddress,
metaAddressSize,
stealthDestination,
stealthMatch,
stealthPrivateKey,
sharedSecretHash,
)
where
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Word (Word8)
import 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
+104
View File
@@ -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