Skip to content

Commit 2bbc556

Browse files
committed
expose what's necessary for stealth addresses too"
1 parent 3ab14f5 commit 2bbc556

6 files changed

Lines changed: 341 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ Crypto:
66
Keccak-256, EIP-55 addresses and EIP-712 typed data hashing. Client-side
77
signing only - no transaction construction and no chain writes; the resolver
88
path remains read-only. See `plans/2026-08-05-eth-crypto-bindings.md`.
9+
- ERC-5564 stealth addresses (`Simplex.Messaging.Eth.Stealth`): a recipient
10+
publishes a spend/view meta-address, a sender derives a one-time address from
11+
it non-interactively, and only the recipient can find or spend from it. Adds
12+
`publicKeyTweakMul` and `publicKeyTweakAdd` to the secp256k1 bindings.
913

1014
# 6.5.1
1115

plans/2026-08-05-eth-crypto-bindings.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,51 @@ published vectors rather than our own output:
275275
The EIP-712 and BIP-44 expectations were additionally reproduced by an
276276
independent pure-Python secp256k1 reference written for the purpose, so they are
277277
not just our implementation agreeing with itself.
278+
279+
## Addendum: ERC-5564 stealth addresses
280+
281+
`Simplex.Messaging.Eth.Stealth`, added for the names v2 gifting flow (rc3 §7.4).
282+
A recipient publishes a meta-address — a spending public key and a viewing
283+
public key — and a sender derives a one-time destination from it with no
284+
handshake. Only the viewing key finds those destinations; only the spending key
285+
spends from them.
286+
287+
### Why not `secp256k1_ecdh`
288+
289+
The ECDH module hashes the shared secret point with SHA-256 and offers no way to
290+
substitute a hash without a C callback. ERC-5564 hashes with keccak256. So the
291+
module stays disabled and the two core-API point operations are bound instead:
292+
293+
- `secp256k1_ec_pubkey_tweak_mul` → `publicKeyTweakMul`, for `r · P_view`
294+
- `secp256k1_ec_pubkey_tweak_add` → `publicKeyTweakAdd`, for `P_spend + s_h · G`
295+
296+
Both are in `secp256k1.h`, so no build flag changed. The recipient's key,
297+
`p_spend + s_h`, reuses the existing `privateKeyTweakAdd`.
298+
299+
### The parts the EIP does not specify
300+
301+
ERC-5564 fixes the algebra but not the encoding, and getting either wrong
302+
produces a wallet that is self-consistent and interoperable with nothing. From
303+
the EIP author's reference implementation
304+
(`Nerolation/EIP-Stealth-Address-ERC`, `minimal_poc.ipynb`):
305+
306+
- the shared secret point is serialized **uncompressed with the SEC1 prefix
307+
removed**, `x || y`, 64 bytes;
308+
- it is hashed with **keccak256**;
309+
- the **view tag is the first byte** of that hash.
310+
311+
That is the same encoding Ethereum uses to turn a public key into an address, so
312+
`addressFromPublicKey` performs the final step unchanged.
313+
314+
### Tests
315+
316+
13 examples in `CoreTests.EthCryptoTests`, 111 in the module overall. Beyond the
317+
round-trip and negative cases, two carry the weight:
318+
319+
- **Batch scanning.** A recipient scans 512 announcements addressed to someone
320+
else; about two pass the one-byte view tag by chance and none yields an address
321+
they control. The complementary test confirms they find all 64 of their own.
322+
This exercises the scan loop rather than a single derivation.
323+
- **Independent agreement.** The pinned vector was reproduced by a from-scratch
324+
pure-Python secp256k1 implementing the reference algorithm directly, sharing no
325+
code with libsecp256k1. Without that, a pin only records our own output.

simplexmq.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ library
152152
Simplex.Messaging.Eth.Address
153153
Simplex.Messaging.Eth.EIP712
154154
Simplex.Messaging.Eth.Keccak
155+
Simplex.Messaging.Eth.Stealth
155156
Simplex.Messaging.Names.Record
156157
Simplex.Messaging.Notifications.Client
157158
Simplex.Messaging.Notifications.Protocol

src/Simplex/Messaging/Crypto/Secp256k1.hs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ module Simplex.Messaging.Crypto.Secp256k1
2828
parsePublicKey,
2929
serializePublicKey,
3030
privateKeyTweakAdd,
31+
publicKeyTweakMul,
32+
publicKeyTweakAdd,
3133
signRecoverable,
3234
recoverPublicKey,
3335
isLowS,
@@ -147,6 +149,11 @@ foreign import ccall "secp256k1_ec_pubkey_serialize"
147149
foreign import ccall "secp256k1_ec_seckey_tweak_add"
148150
c_ec_seckey_tweak_add :: Ptr Ctx -> Ptr Word8 -> Ptr Word8 -> IO CInt
149151

152+
foreign import ccall "secp256k1_ec_pubkey_tweak_mul"
153+
c_ec_pubkey_tweak_mul :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt
154+
foreign import ccall "secp256k1_ec_pubkey_tweak_add"
155+
c_ec_pubkey_tweak_add :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt
156+
150157
foreign import ccall "secp256k1_ecdsa_sign_recoverable"
151158
c_ecdsa_sign_recoverable :: Ptr Ctx -> Ptr RecSigRaw -> Ptr Word8 -> Ptr Word8 -> Ptr () -> Ptr () -> IO CInt
152159

@@ -280,6 +287,36 @@ privateKeyTweakAdd (PrivateKey sk) tweak
280287
then Just . PrivateKey <$> packPtr skPtr privateKeySize
281288
else pure Nothing
282289

290+
-- | @tweak * P@. The scalar multiplication behind an ECDH shared secret.
291+
--
292+
-- Deliberately exposed instead of @secp256k1_ecdh@: that function hashes the
293+
-- resulting point with SHA-256, while ERC-5564 hashes it with keccak256 over
294+
-- the uncompressed coordinates. Returning the point leaves the hash to the
295+
-- caller.
296+
--
297+
-- 'Nothing' when the tweak is zero or out of range.
298+
publicKeyTweakMul :: PublicKey -> ByteString -> Maybe PublicKey
299+
publicKeyTweakMul = tweakPubKey c_ec_pubkey_tweak_mul
300+
301+
-- | @P + tweak * G@, the point addition stealth address derivation needs.
302+
--
303+
-- 'Nothing' when the tweak is out of range or the result is the point at
304+
-- infinity.
305+
publicKeyTweakAdd :: PublicKey -> ByteString -> Maybe PublicKey
306+
publicKeyTweakAdd = tweakPubKey c_ec_pubkey_tweak_add
307+
308+
tweakPubKey :: (Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt) -> PublicKey -> ByteString -> Maybe PublicKey
309+
tweakPubKey f pk tweak
310+
| B.length tweak /= privateKeySize = Nothing
311+
| otherwise = unsafePerformIO $
312+
allocaBytes pubKeyInternalSize $ \pkPtr ->
313+
withBS tweak $ \twPtr -> do
314+
withPubKeyRaw pk $ \src -> copyBytes (castPtr pkPtr) (castPtr src) pubKeyInternalSize
315+
rc <- f secp256k1Ctx pkPtr twPtr
316+
if rc == 1
317+
then Just . PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
318+
else pure Nothing
319+
283320
-- | Sign a 32-byte digest. Deterministic (RFC 6979) and always low-@s@.
284321
signRecoverable :: PrivateKey -> ByteString -> Either String RecoverableSignature
285322
signRecoverable (PrivateKey sk) digest
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
3+
-- | ERC-5564 stealth addresses on secp256k1, scheme id 1 ("with view tags").
4+
--
5+
-- A recipient publishes a __meta-address__: two public keys, spending and
6+
-- viewing. A sender picks a random ephemeral key, derives a one-time address
7+
-- from it and the meta-address, and publishes the ephemeral public key. Only
8+
-- the recipient — who holds the viewing key — can tell which one-time addresses
9+
-- are theirs, and only they can spend from them.
10+
--
11+
-- The meta-address is not an address and never appears on chain, so publishing
12+
-- it discloses nothing beyond the ability to send to its owner.
13+
--
14+
-- == Interoperability
15+
--
16+
-- ERC-5564 specifies the algebra but /not/ how the shared-secret point is
17+
-- serialized before hashing, nor which hash is used. Those come from the EIP
18+
-- author's reference implementation
19+
-- (<https://github.com/Nerolation/EIP-Stealth-Address-ERC> @minimal_poc.ipynb@):
20+
--
21+
-- * the shared secret point is serialized __uncompressed with no SEC1 prefix__,
22+
-- as @x || y@, 64 bytes;
23+
-- * it is hashed with __keccak256__, not SHA-256 — which is why this module
24+
-- multiplies points directly rather than calling @secp256k1_ecdh@, whose
25+
-- built-in hash is SHA-256;
26+
-- * the __view tag is the first byte__ of that hash.
27+
--
28+
-- Encoding the point the same way an Ethereum address encodes a public key is
29+
-- not a coincidence, and it means 'Simplex.Messaging.Eth.Address' already
30+
-- performs the last step unchanged.
31+
module Simplex.Messaging.Eth.Stealth
32+
( StealthMetaAddress (..),
33+
ViewTag,
34+
StealthDestination (..),
35+
metaAddress,
36+
metaAddressBytes,
37+
parseMetaAddress,
38+
metaAddressSize,
39+
stealthDestination,
40+
stealthMatch,
41+
stealthPrivateKey,
42+
sharedSecretHash,
43+
)
44+
where
45+
46+
import Data.ByteString (ByteString)
47+
import qualified Data.ByteString as B
48+
import Data.Word (Word8)
49+
import Simplex.Messaging.Eth.Address (Address, addressFromPublicKey)
50+
import Simplex.Messaging.Eth.Keccak (keccak256)
51+
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
52+
53+
-- | A recipient's published key pair: spending key, then viewing key.
54+
data StealthMetaAddress = StealthMetaAddress
55+
{ smaSpend :: S.PublicKey,
56+
smaView :: S.PublicKey
57+
}
58+
deriving (Eq, Show)
59+
60+
-- | The first byte of the hashed shared secret. Lets a recipient discard about
61+
-- 255 announcements in 256 with one point multiplication and one hash, instead
62+
-- of also deriving an address for each.
63+
type ViewTag = Word8
64+
65+
-- | What a sender produces and publishes.
66+
data StealthDestination = StealthDestination
67+
{ -- | Where to send. Unlinkable to the meta-address it came from.
68+
sdAddress :: Address,
69+
-- | The ephemeral public key, compressed. Must reach the recipient, either
70+
-- in an announcement event or a message, or the destination is
71+
-- undiscoverable.
72+
sdEphemeralPubKey :: ByteString,
73+
sdViewTag :: ViewTag
74+
}
75+
deriving (Eq, Show)
76+
77+
metaAddress :: S.PrivateKey -> S.PrivateKey -> StealthMetaAddress
78+
metaAddress spend view =
79+
StealthMetaAddress {smaSpend = S.publicKey spend, smaView = S.publicKey view}
80+
81+
metaAddressSize :: Int
82+
metaAddressSize = 2 * S.compressedSize
83+
84+
-- | Spending key then viewing key, both compressed. 66 bytes.
85+
metaAddressBytes :: StealthMetaAddress -> ByteString
86+
metaAddressBytes ma = pub (smaSpend ma) <> pub (smaView ma)
87+
where
88+
pub = S.serializePublicKey S.Compressed
89+
90+
parseMetaAddress :: ByteString -> Either String StealthMetaAddress
91+
parseMetaAddress bs
92+
| B.length bs /= metaAddressSize =
93+
Left $ "meta-address: expected " <> show metaAddressSize <> " bytes, got " <> show (B.length bs)
94+
| otherwise = do
95+
let (spend, view) = B.splitAt S.compressedSize bs
96+
StealthMetaAddress <$> S.parsePublicKey spend <*> S.parsePublicKey view
97+
98+
-- | @keccak256(x || y)@ of @sk * P@ — the value both sides arrive at, the
99+
-- sender from the ephemeral key and the recipient from the viewing key.
100+
sharedSecretHash :: S.PrivateKey -> S.PublicKey -> Either String ByteString
101+
sharedSecretHash sk pk =
102+
case S.publicKeyTweakMul pk (S.unPrivateKey sk) of
103+
Nothing -> Left "stealth: shared secret is not a valid point"
104+
Just p -> Right . keccak256 . B.drop 1 $ S.serializePublicKey S.Uncompressed p
105+
106+
-- | Sender side. @ephemeral@ must be freshly random and used once: reusing it
107+
-- across recipients lets them link the destinations, and reusing it for one
108+
-- recipient produces the same address twice.
109+
stealthDestination :: S.PrivateKey -> StealthMetaAddress -> Either String StealthDestination
110+
stealthDestination ephemeral ma = do
111+
sh <- sharedSecretHash ephemeral (smaView ma)
112+
stealthPub <- tweakSpend (smaSpend ma) sh
113+
pure
114+
StealthDestination
115+
{ sdAddress = addressFromPublicKey stealthPub,
116+
sdEphemeralPubKey = S.serializePublicKey S.Compressed (S.publicKey ephemeral),
117+
sdViewTag = B.head sh
118+
}
119+
120+
-- | Recipient side. Returns the address when this announcement is ours.
121+
--
122+
-- The view tag is checked before the point addition, which is the whole reason
123+
-- it exists — a non-match costs one multiplication and one hash.
124+
stealthMatch :: S.PrivateKey -> S.PublicKey -> ByteString -> ViewTag -> Either String (Maybe Address)
125+
stealthMatch view spend ephemeralPub tag = do
126+
eph <- S.parsePublicKey ephemeralPub
127+
sh <- sharedSecretHash view eph
128+
if B.head sh /= tag
129+
then pure Nothing
130+
else Just . addressFromPublicKey <$> tweakSpend spend sh
131+
132+
-- | Recipient side. The key that controls a matched destination: @p_spend + s_h@.
133+
--
134+
-- Needs the spending key, which is why a viewing key can be delegated for
135+
-- scanning without granting the ability to spend.
136+
stealthPrivateKey :: S.PrivateKey -> S.PrivateKey -> ByteString -> Either String S.PrivateKey
137+
stealthPrivateKey spend view ephemeralPub = do
138+
eph <- S.parsePublicKey ephemeralPub
139+
sh <- sharedSecretHash view eph
140+
case S.privateKeyTweakAdd spend sh of
141+
Nothing -> Left "stealth: derived key out of range"
142+
Just sk -> Right sk
143+
144+
tweakSpend :: S.PublicKey -> ByteString -> Either String S.PublicKey
145+
tweakSpend spend sh = case S.publicKeyTweakAdd spend sh of
146+
Nothing -> Left "stealth: derived point out of range"
147+
Just p -> Right p

tests/CoreTests/EthCryptoTests.hs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S
2626
import Simplex.Messaging.Eth.Address
2727
import Simplex.Messaging.Eth.EIP712
2828
import Simplex.Messaging.Eth.Keccak (keccak256)
29+
import Simplex.Messaging.Eth.Stealth
2930
import Test.Hspec hiding (fit, it)
3031
import Util
3132

@@ -38,6 +39,7 @@ ethCryptoTests = do
3839
describe "BIP-44 derivation" derivationTests
3940
describe "EIP-55 addresses" eip55Tests
4041
describe "EIP-712 typed data" eip712Tests
42+
describe "ERC-5564 stealth addresses" stealthTests
4143

4244
-- helpers
4345

@@ -396,3 +398,105 @@ bip39Vectors =
396398
"void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
397399
"01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998" )
398400
]
401+
402+
-- ERC-5564 stealth addresses.
403+
--
404+
-- The EIP fixes the algebra but not the serialization or the hash, so the
405+
-- pinned vector below is the interoperability contract: it follows the EIP
406+
-- author's reference implementation (keccak256 over the shared secret point as
407+
-- x||y, view tag = first byte). Anything that changes it breaks compatibility
408+
-- with every other ERC-5564 wallet, which is why it is pinned rather than
409+
-- computed.
410+
stealthTests :: Spec
411+
stealthTests = do
412+
it "sender and recipient derive the same address" $ do
413+
let d = right $ stealthDestination ephemeralKey aliceMeta
414+
right (stealthMatch aliceView (smaSpend aliceMeta) (sdEphemeralPubKey d) (sdViewTag d))
415+
`shouldBe` Just (sdAddress d)
416+
417+
it "the recipient's derived key controls that address" $ do
418+
let d = right $ stealthDestination ephemeralKey aliceMeta
419+
sk = right $ stealthPrivateKey aliceSpend aliceView (sdEphemeralPubKey d)
420+
addressFromPrivateKey sk `shouldBe` sdAddress d
421+
422+
it "the derived key actually signs for it" $ do
423+
let d = right $ stealthDestination ephemeralKey aliceMeta
424+
sk = right $ stealthPrivateKey aliceSpend aliceView (sdEphemeralPubKey d)
425+
digest = keccak256 "transfer"
426+
sig = right $ S.signRecoverable sk digest
427+
addressFromPublicKey (right $ S.recoverPublicKey sig digest) `shouldBe` sdAddress d
428+
429+
it "the view tag is the first byte of the hashed shared secret" $ do
430+
let d = right $ stealthDestination ephemeralKey aliceMeta
431+
sh = right $ sharedSecretHash aliceView (right . S.parsePublicKey $ sdEphemeralPubKey d)
432+
sdViewTag d `shouldBe` B.head sh
433+
434+
it "a different ephemeral key gives an unrelated address" $ do
435+
let d1 = right $ stealthDestination ephemeralKey aliceMeta
436+
d2 = right $ stealthDestination ephemeralKey2 aliceMeta
437+
sdAddress d1 `shouldNotBe` sdAddress d2
438+
439+
it "the viewing key alone does not spend" $ do
440+
-- Using the viewing key where the spending key belongs must not produce the
441+
-- address: this is what makes delegated scanning safe.
442+
let d = right $ stealthDestination ephemeralKey aliceMeta
443+
wrong = right $ stealthPrivateKey aliceView aliceView (sdEphemeralPubKey d)
444+
addressFromPrivateKey wrong `shouldNotBe` sdAddress d
445+
446+
it "another recipient never matches, over a batch of announcements" $ do
447+
-- Bob scans 512 announcements addressed to Alice. About two will pass the
448+
-- one-byte view tag by chance; none may yield an address Bob controls.
449+
let ds = [right $ stealthDestination (ephemeralN i) aliceMeta | i <- [1 .. 512 :: Int]]
450+
matches =
451+
[ a
452+
| d <- ds,
453+
Just a <- [right $ stealthMatch bobView (smaSpend bobMeta) (sdEphemeralPubKey d) (sdViewTag d)]
454+
]
455+
filter (`elem` map sdAddress ds) matches `shouldBe` []
456+
457+
it "the recipient finds their own in the same batch" $ do
458+
let ds = [right $ stealthDestination (ephemeralN i) aliceMeta | i <- [1 .. 64 :: Int]]
459+
found =
460+
[ a
461+
| d <- ds,
462+
Just a <- [right $ stealthMatch aliceView (smaSpend aliceMeta) (sdEphemeralPubKey d) (sdViewTag d)]
463+
]
464+
found `shouldBe` map sdAddress ds
465+
466+
it "agrees with an independent implementation of the scheme" $ do
467+
-- Cross-checked against a from-scratch pure-Python secp256k1 implementing
468+
-- the reference algorithm directly (scratchpad @stealth_ref.py@), sharing
469+
-- no code with libsecp256k1. Agreement here is what makes this an
470+
-- interoperability vector rather than a record of our own output.
471+
let d = right $ stealthDestination ephemeralKey aliceMeta
472+
checksumAddress (sdAddress d) `shouldBe` "0xbC287a4f0345cD7Fea8d523fBa25Aec4f0B29a6c"
473+
toHex (sdEphemeralPubKey d) `shouldBe` "029ac20335eb38768d2052be1dbbc3c8f6178407458e51e6b4ad22f1d91758895b"
474+
sdViewTag d `shouldBe` 224
475+
476+
describe "meta-address encoding" $ do
477+
it "round-trips" $
478+
parseMetaAddress (metaAddressBytes aliceMeta) `shouldBe` Right aliceMeta
479+
it "is 66 bytes, spending key first" $ do
480+
let bs = metaAddressBytes aliceMeta
481+
B.length bs `shouldBe` 66
482+
B.take 33 bs `shouldBe` S.serializePublicKey S.Compressed (smaSpend aliceMeta)
483+
it "rejects a wrong length" $
484+
parseMetaAddress (B.take 65 $ metaAddressBytes aliceMeta) `shouldSatisfy` isLeft
485+
it "rejects points not on the curve" $
486+
parseMetaAddress (B.replicate 66 0xAA) `shouldSatisfy` isLeft
487+
488+
aliceSpend, aliceView, bobSpend, bobView, ephemeralKey, ephemeralKey2 :: S.PrivateKey
489+
aliceSpend = right $ S.mkPrivateKey (hx "1111111111111111111111111111111111111111111111111111111111111111")
490+
aliceView = right $ S.mkPrivateKey (hx "2222222222222222222222222222222222222222222222222222222222222222")
491+
bobSpend = right $ S.mkPrivateKey (hx "3333333333333333333333333333333333333333333333333333333333333333")
492+
bobView = right $ S.mkPrivateKey (hx "4444444444444444444444444444444444444444444444444444444444444444")
493+
ephemeralKey = right $ S.mkPrivateKey (hx "5555555555555555555555555555555555555555555555555555555555555555")
494+
ephemeralKey2 = right $ S.mkPrivateKey (hx "6666666666666666666666666666666666666666666666666666666666666666")
495+
496+
aliceMeta, bobMeta :: StealthMetaAddress
497+
aliceMeta = metaAddress aliceSpend aliceView
498+
bobMeta = metaAddress bobSpend bobView
499+
500+
-- Distinct ephemeral keys for batch tests.
501+
ephemeralN :: Int -> S.PrivateKey
502+
ephemeralN i = right . S.mkPrivateKey . keccak256 . BC.pack $ "ephemeral " <> show i

0 commit comments

Comments
 (0)