|
| 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 |
0 commit comments