Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
[submodule "cbits/blst"]
path = cbits/blst
url = https://github.com/supranational/blst.git
[submodule "cbits/libsecp256k1"]
path = cbits/libsecp256k1
url = https://github.com/bitcoin-core/secp256k1.git
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# 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.

# 6.5.1

Version 6.5.1.0
Expand Down
1 change: 1 addition & 0 deletions cbits/libsecp256k1
Submodule libsecp256k1 added at 6e2c8b
325 changes: 325 additions & 0 deletions plans/2026-08-05-eth-crypto-bindings.md

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion simplexmq.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ extra-source-files:
cbits/blst/**/*.asm
cbits/libbbs/**/*.c
cbits/libbbs/**/*.h
cbits/libsecp256k1/**/*.c
cbits/libsecp256k1/**/*.h
apps/common/Web/static/index.html
apps/common/Web/static/link.html
apps/common/Web/static/media/apk_icon.png
Expand Down Expand Up @@ -135,6 +137,10 @@ library
Simplex.Messaging.Crypto.Lazy
Simplex.Messaging.Crypto.Ratchet
Simplex.Messaging.Crypto.BBS
Simplex.Messaging.Crypto.BIP32
Simplex.Messaging.Crypto.BIP39
Simplex.Messaging.Crypto.BIP39.English
Simplex.Messaging.Crypto.Secp256k1
Simplex.Messaging.Crypto.SNTRUP761
Simplex.Messaging.Crypto.SNTRUP761.Bindings
Simplex.Messaging.Crypto.SNTRUP761.Bindings.Defines
Expand All @@ -143,6 +149,10 @@ library
Simplex.Messaging.Crypto.ShortLink
Simplex.Messaging.Encoding
Simplex.Messaging.Encoding.String
Simplex.Messaging.Eth.Address
Simplex.Messaging.Eth.EIP712
Simplex.Messaging.Eth.Keccak
Simplex.Messaging.Eth.Stealth
Simplex.Messaging.Names.Record
Simplex.Messaging.Notifications.Client
Simplex.Messaging.Notifications.Protocol
Expand Down Expand Up @@ -323,7 +333,13 @@ library
cbits/blst/src
cbits/libbbs/include
cbits/libbbs/src
cc-options: -D__BLST_PORTABLE__
cbits/libsecp256k1
cbits/libsecp256k1/include
cbits/libsecp256k1/src
-- libsecp256k1 is built without its autotools config header: every knob it
-- needs has an #ifndef default, and the checked-in precomputed tables match
-- those defaults. Only the recovery module has to be switched on explicitly.
cc-options: -D__BLST_PORTABLE__ -DENABLE_MODULE_RECOVERY=1
if flag(commoncrypto)
cc-options: -DBBS_CRYPTO_CC
frameworks: Security
Expand All @@ -337,6 +353,9 @@ library
cbits/libbbs/src/compat-string.c
cbits/libbbs/src/sha256.c
cbits/libbbs/src/shake256.c
cbits/libsecp256k1/src/secp256k1.c
cbits/libsecp256k1/src/precomputed_ecmult.c
cbits/libsecp256k1/src/precomputed_ecmult_gen.c
asm-sources:
cbits/blst/build/assembly.S
extra-libraries:
Expand Down Expand Up @@ -541,6 +560,7 @@ test-suite simplexmq-test
CoreTests.CryptoFileTests
CoreTests.CryptoTests
CoreTests.EncodingTests
CoreTests.EthCryptoTests
CoreTests.MsgStoreTests
CoreTests.RetryIntervalTests
CoreTests.SOCKSSettings
Expand Down
145 changes: 145 additions & 0 deletions src/Simplex/Messaging/Crypto/BIP32.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | 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.
module Simplex.Messaging.Crypto.BIP32
( ExtendedKey (..),
masterKey,
deriveChild,
derivePath,
parsePath,
renderPath,
hardenedOffset,
hardened,
isHardened,
chainCodeSize,
)
where

import qualified Crypto.Hash as H
import qualified Crypto.MAC.HMAC as HMAC
import qualified Data.ByteArray as BA
import Data.Bits (shiftR, (.&.))
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.List (intercalate)
import Data.Word (Word32)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S

-- | 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.
data ExtendedKey = ExtendedKey
{ xkKey :: S.PrivateKey,
xkChainCode :: ByteString
}
deriving (Eq)

instance Show ExtendedKey where
show _ = "ExtendedKey <redacted>"

chainCodeSize :: Int
chainCodeSize = 32

-- | Child indexes at or above this are hardened.
hardenedOffset :: Word32
hardenedOffset = 0x80000000

-- | @hardened 44 == 44'@. Indexes at or above 'hardenedOffset' are returned
-- unchanged, so @hardened . hardened@ is idempotent rather than overflowing.
hardened :: Word32 -> Word32
hardened i
| i >= hardenedOffset = i
| otherwise = i + hardenedOffset

isHardened :: Word32 -> Bool
isHardened i = i >= hardenedOffset

-- | Derive the master key from a BIP-39 seed (BIP-32 allows 16 to 64 bytes).
masterKey :: ByteString -> Either String ExtendedKey
masterKey seed
| seedLen < 16 || seedLen > 64 =
Left $ "seed: expected 16 to 64 bytes, got " <> show seedLen
| otherwise = do
k <- either (const $ Left "seed: invalid master key, use a different seed") Right $ S.mkPrivateKey il
Right ExtendedKey {xkKey = k, xkChainCode = ir}
where
seedLen = B.length seed
i = hmacSHA512 "Bitcoin seed" 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.
deriveChild :: ExtendedKey -> Word32 -> Either String ExtendedKey
deriveChild xk i =
case S.privateKeyTweakAdd (xkKey xk) il of
Nothing -> Left $ "derivation: invalid child at index " <> show i <> ", use the next index"
Just k -> Right ExtendedKey {xkKey = k, xkChainCode = ir}
where
dat
| isHardened i = B.singleton 0 <> S.unPrivateKey (xkKey xk) <> ser32 i
| otherwise = S.serializePublicKey S.Compressed (S.publicKey (xkKey xk)) <> ser32 i
hm = hmacSHA512 (xkChainCode xk) dat
il = B.take 32 hm
ir = B.drop 32 hm

derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
derivePath = foldl (\acc i -> acc >>= (`deriveChild` i)) . Right

-- | Parse a path such as @m\/44'\/60'\/0'\/0\/0@. A leading @m@ or @M@ is
-- optional; both @'@ and @h@ mark a hardened index.
parsePath :: ByteString -> Either String [Word32]
parsePath s = case BC.split '/' (BC.filter (/= ' ') s) of
[] -> Right []
(h : rest)
| h == "m" || h == "M" || B.null h -> traverse element rest
| otherwise -> traverse element (h : rest)
where
element e
| B.null e = Left "path: empty component"
| otherwise =
let (digits, suffix) = BC.span (`elem` ("0123456789" :: String)) e
mark
| suffix == "'" || suffix == "h" || suffix == "H" = Right True
| B.null suffix = Right False
| otherwise = Left $ "path: bad component " <> BC.unpack e
in if B.null digits
then Left $ "path: bad component " <> BC.unpack e
else do
h' <- mark
n <- readIndex digits
if h' then Right (n + hardenedOffset) else Right n
readIndex digits =
let n = BC.foldl' (\acc c -> acc * 10 + toInteger (fromEnum c - fromEnum '0')) 0 digits
in if n >= toInteger hardenedOffset
then Left $ "path: index out of range: " <> BC.unpack digits
else Right (fromInteger n)

renderPath :: [Word32] -> ByteString
renderPath is = BC.pack $ intercalate "/" ("m" : map component is)
where
component i
| isHardened i = show (i - hardenedOffset) <> "'"
| otherwise = show i

hmacSHA512 :: ByteString -> ByteString -> ByteString
hmacSHA512 key msg = BA.convert (HMAC.hmac key msg :: HMAC.HMAC H.SHA512)

ser32 :: Word32 -> ByteString
ser32 i =
B.pack
[ fromIntegral (i `shiftR` 24),
fromIntegral ((i `shiftR` 16) .&. 0xFF),
fromIntegral ((i `shiftR` 8) .&. 0xFF),
fromIntegral (i .&. 0xFF)
]
Loading
Loading