From 7dcd89d30bdf06913b03f3884bad987b75a6744a Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 08:57:30 +0200 Subject: [PATCH] cut comments --- CHANGELOG.md | 14 +++----- src/Simplex/Messaging/Crypto/BIP32.hs | 19 +++-------- src/Simplex/Messaging/Crypto/BIP39.hs | 29 ++++------------- src/Simplex/Messaging/Crypto/BIP39/English.hs | 6 +--- src/Simplex/Messaging/Crypto/Secp256k1.hs | 32 ++++--------------- src/Simplex/Messaging/Eth/Address.hs | 23 ++++--------- src/Simplex/Messaging/Eth/Keccak.hs | 8 +---- tests/CoreTests/EthCryptoTests.hs | 13 ++------ 8 files changed, 33 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac679b053..d336c92c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,11 @@ # Unreleased Crypto: -- Ethereum primitives for SimpleX names: secp256k1 with public key - recovery (vendored libsecp256k1), BIP-39 mnemonics, BIP-32 key derivation, - 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. +- Ethereum primitives for SimpleX names: secp256k1 keys (vendored + libsecp256k1), BIP-39 mnemonics, BIP-32 key derivation, Keccak-256 and EIP-55 + addresses. Key derivation only - no signing, no transaction construction and + no chain writes; the resolver path remains read-only. See + `plans/2026-08-05-eth-crypto-bindings.md`. # 6.5.1 diff --git a/src/Simplex/Messaging/Crypto/BIP32.hs b/src/Simplex/Messaging/Crypto/BIP32.hs index 91d2e6788..86b091610 100644 --- a/src/Simplex/Messaging/Crypto/BIP32.hs +++ b/src/Simplex/Messaging/Crypto/BIP32.hs @@ -1,11 +1,6 @@ {-# LANGUAGE OverloadedStrings #-} --- | BIP-32 hierarchical deterministic key derivation over secp256k1. --- --- Private derivation only: we always hold the seed, so the neutered/extended --- public key half of BIP-32 (CKDpub, xpub serialization, fingerprints) is not --- implemented. Non-hardened child derivation is supported, because BIP-44 paths --- end in non-hardened components. +-- | BIP-32 HD derivation over secp256k1, private only: we hold the seed, so CKDpub, xpub and fingerprints are not implemented. module Simplex.Messaging.Crypto.BIP32 ( ExtendedKey (..), masterKey, @@ -28,10 +23,7 @@ import Data.Word (Word32) import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding (smpEncode) --- | An extended private key: the key plus its chain code. --- --- 'Show' is redacting - the chain code plus one child key is enough to derive --- siblings, so it is secret material too. +-- | An extended private key: the key plus its chain code, secret too, as it and one child derive the siblings. data ExtendedKey = ExtendedKey { xkKey :: S.Secp256k1PrivateKey, xkChainCode :: ByteString @@ -45,8 +37,7 @@ instance Show ExtendedKey where hardenedOffset :: Word32 hardenedOffset = 0x80000000 --- | @hardened 44 == 44'@. Indexes at or above 'hardenedOffset' are returned --- unchanged, so @hardened . hardened@ is idempotent rather than overflowing. +-- | @hardened 44 == 44'@. An index at or above 'hardenedOffset' is returned unchanged, so this is idempotent rather than overflowing. hardened :: Word32 -> Word32 hardened i | isHardened i = i @@ -69,9 +60,7 @@ masterKey seed il = B.take 32 i ir = B.drop 32 i --- | CKDpriv. 'Left' only in the negligible case BIP-32 defines as "proceed with --- the next index"; callers deriving a fixed path should surface it rather than --- silently skipping, since it never happens for real seeds. +-- | CKDpriv. 'Left' only in the negligible case BIP-32 calls "proceed with the next index", which no real seed reaches. deriveChild :: ExtendedKey -> Word32 -> Either String ExtendedKey deriveChild xk i = case S.privateKeyTweakAdd (xkKey xk) il of diff --git a/src/Simplex/Messaging/Crypto/BIP39.hs b/src/Simplex/Messaging/Crypto/BIP39.hs index 9f45803f3..941e2e31b 100644 --- a/src/Simplex/Messaging/Crypto/BIP39.hs +++ b/src/Simplex/Messaging/Crypto/BIP39.hs @@ -1,15 +1,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} --- | BIP-39 mnemonics over the English wordlist. --- --- Scope is deliberately narrow: English only. Every English word is ASCII, so --- the Unicode NFKD normalization BIP-39 mandates is a no-op on the mnemonic --- side and this module needs no normalization dependency. A passphrase, if --- used, is taken as bytes and is the caller's responsibility to normalize. --- --- A mnemonic is the root secret for name ownership, so 'Mnemonic' has a --- redacting 'Show'. +-- | BIP-39 mnemonics, English only: every word is ASCII, so BIP-39's NFKD normalization is a no-op; a passphrase is bytes the caller normalizes. module Simplex.Messaging.Crypto.BIP39 ( Mnemonic, MnemonicStrength (..), @@ -92,8 +84,7 @@ indexByWord :: Map ByteString Int indexByWord = M.fromList $ zip englishWordList [0 ..] {-# NOINLINE indexByWord #-} --- | The mnemonic as one space-separated phrase - the exact bytes BIP-39 feeds --- to PBKDF2. +-- | The mnemonic as one space-separated phrase, the exact bytes BIP-39 feeds to PBKDF2. mnemonicPhrase :: Mnemonic -> ByteString mnemonicPhrase = BC.unwords . mnemonicWords @@ -106,8 +97,7 @@ entropyToMnemonic ent where entLen = B.length ent --- | Entropy to 11-bit word indexes. Assumes a validated entropy length; every --- result is masked to 11 bits, so all indexes are in @[0, 2047]@. +-- | Entropy to 11-bit word indexes: assumes a validated length, and masks every result into @[0, 2047]@. entropyToIndexes :: ByteString -> [Int] entropyToIndexes ent = [fromIntegral ((combined `shiftR` (11 * (n - 1 - i))) .&. 0x7FF) | i <- [0 .. n - 1]] @@ -129,12 +119,7 @@ mnemonicToEntropy m = i2ospOf_ (entBits `div` 8) (combined `shiftR` csBits) csBits = totalBits - entBits combined = foldl' (\acc i -> acc `shiftL` 11 .|. fromIntegral i) (0 :: Integer) idxs --- | Parse and fully validate a phrase: word count, wordlist membership, and the --- BIP-39 checksum. Words may be separated by any whitespace, and input is --- lower-cased first, so a user retyping their recovery key does not get an --- unhelpful failure for capitalising a word. This does not change the derived --- seed: 'mnemonicPhrase' always rebuilds the canonical lowercase sentence from --- the wordlist, and that is what 'mnemonicToSeed' hashes. +-- | Parse and fully validate a phrase: word count, wordlist membership, checksum. Any whitespace separates, and lower-casing does not change the seed. parseMnemonic :: ByteString -> Either String Mnemonic parseMnemonic = parseAll mnemonicP @@ -147,8 +132,7 @@ mnemonicP = do else do idxs <- traverse lookupWord ws let m = mnemonicFromIndexes idxs - -- Stripping the checksum bits and recomputing them is the checksum check: - -- if the supplied bits were wrong, the round trip cannot reproduce them. + -- stripping the checksum bits and recomputing them is the check: wrong bits cannot survive the round trip if entropyToIndexes (mnemonicToEntropy m) == idxs then pure m else fail "mnemonic: checksum mismatch" @@ -156,8 +140,7 @@ mnemonicP = do wordP = BC.map toLower <$> A.takeWhile1 (not . isSpace) lookupWord w = maybe (fail $ "mnemonic: not in wordlist: " <> BC.unpack w) pure $ M.lookup w indexByWord --- | PBKDF2-HMAC-SHA512, 2048 iterations, salt @\"mnemonic\" <> passphrase@. --- Pass an empty passphrase for the common case. +-- | PBKDF2-HMAC-SHA512, 2048 iterations, salt @\"mnemonic\" <> passphrase@; pass an empty passphrase for the common case. mnemonicToSeed :: Mnemonic -> ByteString -> ByteString mnemonicToSeed m passphrase = PBKDF2.generate diff --git a/src/Simplex/Messaging/Crypto/BIP39/English.hs b/src/Simplex/Messaging/Crypto/BIP39/English.hs index 6aab4734e..73bc635b7 100644 --- a/src/Simplex/Messaging/Crypto/BIP39/English.hs +++ b/src/Simplex/Messaging/Crypto/BIP39/English.hs @@ -1,10 +1,6 @@ {-# LANGUAGE TemplateHaskell #-} --- | The BIP-39 English wordlist, embedded verbatim from --- . --- --- The file is the upstream one, so it is audited by checksumming it. The tests --- pin that checksum, so this copy cannot drift. +-- | The BIP-39 English wordlist, embedded verbatim from , pinned by a checksum test. module Simplex.Messaging.Crypto.BIP39.English (englishWordList) where import Data.ByteString.Char8 (ByteString) diff --git a/src/Simplex/Messaging/Crypto/Secp256k1.hs b/src/Simplex/Messaging/Crypto/Secp256k1.hs index 0a2391d86..6f301ae56 100644 --- a/src/Simplex/Messaging/Crypto/Secp256k1.hs +++ b/src/Simplex/Messaging/Crypto/Secp256k1.hs @@ -3,11 +3,7 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE ScopedTypeVariables #-} --- | FFI bindings to libsecp256k1. --- --- Key validation, public key derivation and serialization, and scalar addition --- for BIP-32 child derivation. The pure API over 'unsafePerformIO' is safe --- because every operation is a pure function of its arguments. +-- | FFI bindings to libsecp256k1. Every operation is a pure function of its arguments, hence the pure API over 'unsafePerformIO'. module Simplex.Messaging.Crypto.Secp256k1 ( Secp256k1PrivateKey, Secp256k1PublicKey, @@ -52,18 +48,13 @@ pubKeyInternalSize = 64 -- Types --- | A validated secp256k1 private key: 32 bytes, in @[1, n-1]@. --- --- There is no 'Show' instance, so the key cannot be logged by accident, and --- 'Eq' is constant-time: this key authorises transfers of assets with monetary --- value. +-- | A validated private key, 32 bytes in @[1, n-1]@: no 'Show', so it cannot be logged by accident, and constant-time 'Eq'. newtype Secp256k1PrivateKey = Secp256k1PrivateKey ByteString instance Eq Secp256k1PrivateKey where Secp256k1PrivateKey a == Secp256k1PrivateKey b = BA.constEq a b --- | A parsed public key, held in libsecp256k1's opaque 64-byte internal form. --- Use 'serializePublicKey' to get the SEC1 bytes. +-- | A public key in libsecp256k1's opaque 64-byte form; 'serializePublicKey' gives the SEC1 bytes. newtype Secp256k1PublicKey = Secp256k1PublicKey ByteString deriving newtype (Eq, Show) @@ -99,11 +90,7 @@ foreign import ccall "secp256k1_ec_seckey_tweak_add" contextNone :: CUInt contextNone = 1 --- | The process-wide context, created and blinded once. --- --- Randomization is a side-channel countermeasure only: it does not affect any --- output, and signing does not mutate the context, so sharing one context --- across threads is safe and the pure API below is sound. +-- | The process-wide context, created and blinded once. Blinding affects no output and nothing mutates it, so sharing it across threads is safe. secp256k1Ctx :: Ptr Ctx secp256k1Ctx = unsafePerformIO $ do ctx <- c_context_create contextNone @@ -130,8 +117,7 @@ withPubKeyRaw (Secp256k1PublicKey bs) f = withBS bs $ f . castPtr -- Public API --- | Validate 32 bytes as a private key. Rejects zero and anything at or above --- the group order, which is what makes 'secp256k1PublicKey' total. +-- | Reject zero and anything at or above the group order, which is what makes 'secp256k1PublicKey' total. mkPrivateKey :: ByteString -> Either String Secp256k1PrivateKey mkPrivateKey bs | B.length bs /= privateKeySize = Left $ "private key: expected 32 bytes, got " <> show (B.length bs) @@ -164,16 +150,12 @@ serializePublicKey fmt pk = unsafePerformIO $ written <- peek lenPtr packPtr outPtr (fromIntegral written) where - -- SECP256K1_EC_COMPRESSED = SECP256K1_FLAGS_TYPE_COMPRESSION | SECP256K1_FLAGS_BIT_COMPRESSION - -- SECP256K1_EC_UNCOMPRESSED = SECP256K1_FLAGS_TYPE_COMPRESSION + -- SECP256K1_EC_COMPRESSED = FLAGS_TYPE_COMPRESSION | FLAGS_BIT_COMPRESSION, SECP256K1_EC_UNCOMPRESSED = FLAGS_TYPE_COMPRESSION (flag, outLen) = case fmt of Compressed -> (2 .|. 256, compressedSize) Uncompressed -> (2, uncompressedSize) --- | @sk + tweak mod n@, as BIP-32 child derivation needs. --- --- 'Nothing' when the result is zero or the tweak is out of range - BIP-32 --- requires the caller to skip to the next child index in that case. +-- | @sk + tweak mod n@, as BIP-32 child derivation needs. 'Nothing' when the result is zero or the tweak is out of range. privateKeyTweakAdd :: Secp256k1PrivateKey -> ByteString -> Maybe Secp256k1PrivateKey privateKeyTweakAdd (Secp256k1PrivateKey sk) tweak | B.length tweak /= privateKeySize = Nothing diff --git a/src/Simplex/Messaging/Eth/Address.hs b/src/Simplex/Messaging/Eth/Address.hs index 569af7050..f0d9a5ef9 100644 --- a/src/Simplex/Messaging/Eth/Address.hs +++ b/src/Simplex/Messaging/Eth/Address.hs @@ -1,7 +1,6 @@ {-# LANGUAGE OverloadedStrings #-} --- | Ethereum addresses: derivation from a public key, and EIP-55 mixed-case --- checksum encoding. +-- | Ethereum addresses: derivation from a public key, and EIP-55 mixed-case checksum encoding. module Simplex.Messaging.Eth.Address ( Address, addressFromPrivateKey, @@ -23,16 +22,14 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String import Simplex.Messaging.Eth.Keccak (keccak256) --- | A 20-byte Ethereum address. 'Show' renders the EIP-55 checksummed form, --- which is what a user would paste into a block explorer. +-- | A 20-byte Ethereum address. 'Show' renders the EIP-55 checksummed form, as pasted into a block explorer. newtype Address = Address ByteString deriving (Eq, Ord) instance Show Address where show = BC.unpack . checksumAddress --- | EIP-55 checksummed hex, the form shown in explorers and pasted by users. --- Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum. +-- | EIP-55 checksummed hex. Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum. instance StrEncoding Address where strEncode = checksumAddress strP = do @@ -54,8 +51,7 @@ instance StrEncoding Address where addressSize :: Int addressSize = 20 --- | The low 20 bytes of @keccak256@ of the uncompressed public key with its --- @0x04@ SEC1 prefix removed. +-- | The low 20 bytes of @keccak256@ of the uncompressed public key with its @0x04@ SEC1 prefix removed. addressFromPublicKey :: S.Secp256k1PublicKey -> Address addressFromPublicKey pk = Address . B.drop 12 . keccak256 . B.drop 1 $ S.serializePublicKey S.Uncompressed pk @@ -63,8 +59,7 @@ addressFromPublicKey pk = addressFromPrivateKey :: S.Secp256k1PrivateKey -> Address addressFromPrivateKey = addressFromPublicKey . S.secp256k1PublicKey --- | EIP-55: @0x@ followed by 40 hex digits whose case encodes a checksum over --- the lowercase hex form. +-- | EIP-55: @0x@ and 40 hex digits whose case encodes a checksum over the lowercase hex form. checksumAddress :: Address -> ByteString checksumAddress (Address bs) = "0x" <> B.pack (zipWith adjust [0 ..] (B.unpack lowerHex)) where @@ -81,15 +76,11 @@ checksumAddress (Address bs) = "0x" <> B.pack (zipWith adjust [0 ..] (B.unpack l upper c = c - 0x20 --- | BIP-44 path for Ethereum account @i@, address @k@: @m\/44'\/60'\/i'\/0\/k@. --- The account must be below @hardenedOffset@, as 'hardened' returns anything --- at or above it unchanged and the path would be another account's. +-- | BIP-44 path for Ethereum account @i@, address @k@: @m\/44'\/60'\/i'\/0\/k@. The account must be below @hardenedOffset@. ethereumPath :: Word32 -> Word32 -> [Word32] ethereumPath account address = [hardened 44, hardened 60, hardened account, 0, address] --- Hex via memory's Base16, which this package already depends on. Base16 emits --- lowercase, which is what EIP-55 needs as its starting point - 'checksumAddress' --- is what introduces case. +-- hex via memory's Base16, which emits the lowercase form EIP-55 starts from; 'checksumAddress' is what introduces case toHex :: ByteString -> ByteString toHex = BAE.convertToBase BAE.Base16 diff --git a/src/Simplex/Messaging/Eth/Keccak.hs b/src/Simplex/Messaging/Eth/Keccak.hs index a81c58031..41c100134 100644 --- a/src/Simplex/Messaging/Eth/Keccak.hs +++ b/src/Simplex/Messaging/Eth/Keccak.hs @@ -1,10 +1,4 @@ --- | Keccak-256 - the hash Ethereum uses everywhere. --- --- This is *not* SHA3-256. The two differ only in the padding byte (0x01 vs --- 0x06) and produce completely different digests, and crypton exposes both as --- @Keccak_256@ and @SHA3_256@. Confusing them is the classic way to write an --- Ethereum implementation that is wrong in a way nothing catches until a --- signature is rejected on-chain. +-- | Keccak-256, the hash Ethereum uses. Not SHA3-256: they differ in the padding byte and produce entirely different digests. module Simplex.Messaging.Eth.Keccak ( keccak256, ) diff --git a/tests/CoreTests/EthCryptoTests.hs b/tests/CoreTests/EthCryptoTests.hs index fb1b4336f..8e844e0ca 100644 --- a/tests/CoreTests/EthCryptoTests.hs +++ b/tests/CoreTests/EthCryptoTests.hs @@ -1,14 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} --- | Tests for the Ethereum crypto primitives: secp256k1, BIP-39, BIP-32, --- Keccak-256, EIP-55 and EIP-712. --- --- Everything here is checked against published vectors rather than against our --- own output: the official BIP-39 English vectors, BIP-32 spec test vectors 1 --- and 2 (expected private keys and chain codes decoded from the published --- @xprv@ strings), the EIP-55 spec addresses, and the @Mail@ example from the --- EIP-712 spec. +-- | Tests for secp256k1, BIP-39, BIP-32, Keccak-256 and EIP-55, checked against published vectors rather than against our own output. module CoreTests.EthCryptoTests (ethCryptoTests) where import Control.Concurrent.STM (atomically) @@ -241,9 +234,7 @@ eip55Tests = do it "rejects non-hex characters" $ strDecode @Address "0xZaAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" `shouldBe` Left "Failed reading: address: expected 40 hex digits, got 0" - -- Between them these cover every byte value 0x00..0xff going out through the - -- hex encoder, and every hex digit coming back through the decoder - which - -- the four spec vectors above do not. + -- between them these cover every byte value out through the hex encoder and every hex digit back through the decoder it "round-trips every byte value through the checksummed form" $ forM_ everyByteAddresses $ \a -> strDecode (strEncode a) `shouldBe` Right a