mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-11-12 09:28:29 +00:00
cut comments
This commit is contained in:
1 parent
31f0a74310
commit
7dcd89d30b
8 files changed
+33
-111
No files matched your search
+5
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user