cut comments

This commit is contained in:
Alain Brenzikofer committed 2026-09-22 08:57:30 +02:00
1 parent 31f0a74310
commit 7dcd89d30b
8 files changed
+33 -111

No files matched your search

+5 -9
View File
@@ -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
+4 -15
View File
@@ -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
+6 -23
View File
@@ -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
@@ -1,10 +1,6 @@
{-# LANGUAGE TemplateHaskell #-}
-- | The BIP-39 English wordlist, embedded verbatim from
-- <https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt>.
--
-- 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 <https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt>, pinned by a checksum test.
module Simplex.Messaging.Crypto.BIP39.English (englishWordList) where
import Data.ByteString.Char8 (ByteString)
+7 -25
View File
@@ -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
+7 -16
View File
@@ -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
+1 -7
View File
@@ -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,
)
+2 -11
View File
@@ -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