mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-29 07:48:25 +00:00
crypto: Ethereum primitives for SimpleX names
secp256k1 (vendored libsecp256k1 v0.8.0, recoverable ECDSA), BIP-39 mnemonics, BIP-32 derivation, Keccak-256, EIP-55 addresses and EIP-712 typed data hashing. Client-side signing only: no RLP, no transaction construction, no chain writes. The resolver path stays read-only. Verified against published vectors — the 24 official BIP-39 English vectors, BIP-32 spec vectors 1 and 2, the EIP-55 spec addresses and the EIP-712 Mail example. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
# 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`.
|
||||
|
||||
# 6.5.1
|
||||
|
||||
Version 6.5.1.0
|
||||
|
||||
Submodule
+1
Submodule cbits/libsecp256k1 added at 6e2c8bc4ec
@@ -0,0 +1,243 @@
|
||||
# Ethereum crypto primitives for simplexmq
|
||||
|
||||
Client-side crypto for SimpleX names: enough to derive an Ethereum key from a
|
||||
recovery phrase and sign EIP-712 typed data. General-purpose — these modules
|
||||
know nothing about names, registrars or relayers.
|
||||
|
||||
This is Workstream B of the SimpleX names v2 plan. The design it serves: names
|
||||
are owned by a plain EOA derived per chat profile from one BIP-39 seed, and
|
||||
every post-registration action (transfer, record edit) is a one-shot EIP-712
|
||||
intent signed by that key and relayed by SimpleX, which pays the gas.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
- **No RLP encoder, and no transaction building.** RLP is only needed to
|
||||
construct raw transactions or EIP-7702 authorizations. The client does
|
||||
neither: it signs EIP-712 typed data and hands the signature to the relayer.
|
||||
The client never reads a nonce, estimates gas or broadcasts anything, so the
|
||||
`RSLV` resolver path in this repo stays strictly read-only.
|
||||
- **No low-s normalization.** libsecp256k1 already emits the canonical low-`s`
|
||||
form EIP-2 requires. `isLowS` exists so tests assert that rather than assume
|
||||
it. There is deliberately no normalization entry point: we never accept a
|
||||
foreign signature, we only produce our own.
|
||||
- **No BIP-32 public derivation.** We always hold the seed, so CKDpub, xpub
|
||||
serialization and fingerprints are not implemented. Non-hardened *private*
|
||||
derivation is, because BIP-44 paths end in non-hardened components.
|
||||
- **No EIP-712 schema encoder.** The caller supplies the canonical type string.
|
||||
Our structs are a handful of fixed shapes agreed with the contracts, and a
|
||||
hand-written string checked against Solidity in a test is easier to audit than
|
||||
a schema encoder whose output nobody reads.
|
||||
- **English wordlist only.** Every English BIP-39 word is ASCII, so the NFKD
|
||||
normalization BIP-39 mandates is a no-op on the mnemonic side and no
|
||||
normalization dependency is needed.
|
||||
|
||||
## Modules
|
||||
|
||||
```
|
||||
Simplex.Messaging.Crypto.Secp256k1 FFI to libsecp256k1
|
||||
Simplex.Messaging.Crypto.BIP39 mnemonics
|
||||
Simplex.Messaging.Crypto.BIP39.English generated 2048-word list
|
||||
Simplex.Messaging.Crypto.BIP32 HD derivation
|
||||
Simplex.Messaging.Eth.Keccak Keccak-256
|
||||
Simplex.Messaging.Eth.Address addresses, EIP-55
|
||||
Simplex.Messaging.Eth.EIP712 typed data hashing
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
```haskell
|
||||
newtype PrivateKey -- 32 bytes, validated in [1, n-1]
|
||||
newtype PublicKey -- libsecp256k1's opaque 64-byte form
|
||||
data RecoverableSignature = RecoverableSignature {rsCompact :: ByteString, rsRecId :: Int}
|
||||
data PubKeyFormat = Compressed | Uncompressed
|
||||
|
||||
data Mnemonic -- validated indexes + words, always consistent
|
||||
data MnemonicStrength = MS128 | MS160 | MS192 | MS224 | MS256
|
||||
|
||||
data ExtendedKey = ExtendedKey {xkKey :: PrivateKey, xkChainCode :: ByteString}
|
||||
|
||||
newtype Address -- 20 bytes; Show renders the EIP-55 form
|
||||
data Eip712Domain = Eip712Domain {edName, edVersion :: ByteString, edChainId :: Integer, edVerifyingContract :: Address}
|
||||
data Value = VUint Integer | VInt Integer | VBool Bool | VAddress Address
|
||||
| VFixedBytes ByteString | VBytes ByteString | VString ByteString
|
||||
| VArray [Value] | VStruct ByteString
|
||||
```
|
||||
|
||||
`PrivateKey`, `Mnemonic` and `ExtendedKey` have **redacting `Show` instances**,
|
||||
and `PrivateKey` compares with `constEq`. These keys authorise transfers of
|
||||
assets with monetary value: a derived `Show` would put one in a log the first
|
||||
time anything is traced. A chain code is secret too — it plus one child key
|
||||
derives siblings.
|
||||
|
||||
## Functions
|
||||
|
||||
```haskell
|
||||
-- Secp256k1
|
||||
mkPrivateKey :: ByteString -> Either String PrivateKey
|
||||
publicKey :: PrivateKey -> PublicKey -- total: key is validated
|
||||
parsePublicKey :: ByteString -> Either String PublicKey
|
||||
serializePublicKey :: PubKeyFormat -> PublicKey -> ByteString
|
||||
privateKeyTweakAdd :: PrivateKey -> ByteString -> Maybe PrivateKey
|
||||
signRecoverable :: PrivateKey -> ByteString -> Either String RecoverableSignature
|
||||
recoverPublicKey :: RecoverableSignature -> ByteString -> Either String PublicKey
|
||||
isLowS :: RecoverableSignature -> Bool
|
||||
|
||||
-- BIP39
|
||||
entropyToMnemonic :: ByteString -> Either String Mnemonic
|
||||
mnemonicToEntropy :: Mnemonic -> ByteString -- total
|
||||
parseMnemonic :: ByteString -> Either String Mnemonic
|
||||
mnemonicToSeed :: Mnemonic -> ByteString -> ByteString
|
||||
randomMnemonic :: MnemonicStrength -> TVar ChaChaDRG -> STM Mnemonic
|
||||
|
||||
-- BIP32
|
||||
masterKey :: ByteString -> Either String ExtendedKey
|
||||
deriveChild :: ExtendedKey -> Word32 -> Either String ExtendedKey
|
||||
derivePath :: ExtendedKey -> [Word32] -> Either String ExtendedKey
|
||||
parsePath :: ByteString -> Either String [Word32]
|
||||
renderPath :: [Word32] -> ByteString
|
||||
|
||||
-- Eth
|
||||
keccak256 :: ByteString -> ByteString
|
||||
addressFromPrivateKey :: PrivateKey -> Address
|
||||
checksumAddress :: Address -> ByteString
|
||||
parseAddress :: ByteString -> Either String Address
|
||||
ethereumPath :: Word32 -> [Word32] -- m/44'/60'/i'/0/0
|
||||
typeHash :: ByteString -> ByteString
|
||||
hashStruct :: ByteString -> [Value] -> Either String ByteString
|
||||
domainSeparator :: Eip712Domain -> Either String ByteString
|
||||
hashTypedData :: Eip712Domain -> ByteString -> [Value] -> Either String ByteString
|
||||
```
|
||||
|
||||
`randomMnemonic` is shaped like `Simplex.Messaging.Crypto.randomBytes` so it
|
||||
composes with the agent's DRG instead of reaching for system entropy.
|
||||
|
||||
`parseMnemonic` lower-cases and splits on any whitespace, so a user retyping
|
||||
their recovery key is not rejected 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.
|
||||
|
||||
## How applications use it
|
||||
|
||||
An application defines the derivation path and the EIP-712 type strings. For
|
||||
SimpleX names, one seed per chat database and one key per chat profile —
|
||||
see `Simplex.Chat.Names.Wallet` in simplex-chat:
|
||||
|
||||
```haskell
|
||||
m <- either fail pure $ parseMnemonic phrase
|
||||
mk <- either fail pure $ masterKey (mnemonicToSeed m "")
|
||||
xk <- either fail pure $ derivePath mk (ethereumPath userId)
|
||||
let addr = addressFromPrivateKey (xkKey xk)
|
||||
```
|
||||
|
||||
Signing a transfer intent — the type string must match the contract's exactly,
|
||||
including EIP-712 canonical form (no spaces after commas, referenced struct
|
||||
types appended in alphabetical order):
|
||||
|
||||
```haskell
|
||||
digest <- either fail pure $ hashTypedData domain
|
||||
"TransferName(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline)"
|
||||
[VAddress from, VAddress to, VUint tokenId, VUint nonce, VUint deadline]
|
||||
sig <- either fail pure $ signRecoverable (xkKey xk) digest
|
||||
-- Ethereum's v is rsRecId + 27
|
||||
```
|
||||
|
||||
Nested structs go in as `VStruct` holding an already-computed `hashStruct`;
|
||||
arrays as `VArray`, which hashes the concatenation of its members.
|
||||
|
||||
## libsecp256k1 C API mapping
|
||||
|
||||
```c
|
||||
secp256k1_context_create(SECP256K1_CONTEXT_NONE) /* once, then _randomize */
|
||||
secp256k1_ec_seckey_verify(ctx, seckey)
|
||||
secp256k1_ec_pubkey_create(ctx, pubkey, seckey)
|
||||
secp256k1_ec_pubkey_parse(ctx, pubkey, input, inputlen)
|
||||
secp256k1_ec_pubkey_serialize(ctx, output, outputlen, pubkey, flags)
|
||||
secp256k1_ec_seckey_tweak_add(ctx, seckey, tweak)
|
||||
secp256k1_ecdsa_sign_recoverable(ctx, sig, msghash32, seckey, NULL, NULL)
|
||||
secp256k1_ecdsa_recoverable_signature_serialize_compact(ctx, output64, recid, sig)
|
||||
secp256k1_ecdsa_recoverable_signature_parse_compact(ctx, sig, input64, recid)
|
||||
secp256k1_ecdsa_recover(ctx, pubkey, sig, msghash32)
|
||||
```
|
||||
|
||||
Passing `NULL` for the nonce function selects RFC-6979, so signing is a
|
||||
deterministic pure function of (key, digest) — which is why the module exposes a
|
||||
pure API over `unsafePerformIO`. The context is created and blinded once at
|
||||
first use; randomization is a side-channel countermeasure that affects no
|
||||
output, and signing does not mutate the context, so one shared context is safe
|
||||
across threads.
|
||||
|
||||
`secp256k1_ec_seckey_tweak_add` returns 0 exactly when BIP-32 says "proceed with
|
||||
the next index" (tweak out of range, or a zero result), which is why
|
||||
`privateKeyTweakAdd` returns `Maybe` and `deriveChild` can surface it.
|
||||
|
||||
libsecp256k1 never reads OS entropy — RFC-6979 nonces are derived from the key
|
||||
and digest, and the context blinding seed is supplied by the caller. So unlike
|
||||
libbbs it raises no `getentropy` / ITMS-90338 concern on iOS, and needs no
|
||||
equivalent of the `commoncrypto` flag.
|
||||
|
||||
## Build
|
||||
|
||||
Submodule in `cbits/`, same pattern as blst and libbbs:
|
||||
`cbits/libsecp256k1` — https://github.com/bitcoin-core/secp256k1, pinned to
|
||||
**v0.8.0**.
|
||||
|
||||
```
|
||||
c-sources: cbits/libsecp256k1/src/{secp256k1,precomputed_ecmult,precomputed_ecmult_gen}.c
|
||||
include-dirs: cbits/libsecp256k1{,/include,/src}
|
||||
cc-options: -DENABLE_MODULE_RECOVERY=1
|
||||
```
|
||||
|
||||
Built **without** its autotools config header. Every knob has an `#ifndef`
|
||||
default in the headers, and the checked-in precomputed tables are generated for
|
||||
those defaults, so only the recovery module has to be switched on. The recovery
|
||||
module is `#include`d from `secp256k1.c`, so it needs no extra `c-sources`
|
||||
entry. `secp256k1.c` defines `SECP256K1_BUILD` itself, so that needs no `-D`
|
||||
either.
|
||||
|
||||
32-bit targets (armv7a-android, i686 musl) are covered by libsecp256k1's own
|
||||
fallback: `src/util.h` selects `SECP256K1_WIDEMUL_INT64` with the 10x26 field
|
||||
and 8x32 scalar backends when `__SIZEOF_INT128__` is absent.
|
||||
|
||||
`include-dirs` order matters: libsecp256k1's directories come last, after
|
||||
libbbs and blst. There are no filename collisions between the three (checked),
|
||||
and C quoted includes prefer the including file's own directory anyway, but the
|
||||
ordering keeps it that way if any library later adds a generically-named header.
|
||||
|
||||
`-DENABLE_MODULE_RECOVERY=1` lands on the shared `cc-options`, so it also
|
||||
reaches blst, libbbs and sntrup761 — harmless, none of them use the macro, and
|
||||
symmetrically `-D__BLST_PORTABLE__` reaches libsecp256k1.
|
||||
|
||||
No `flake.nix` change is needed in simplex-chat: the per-platform overrides
|
||||
there only force `packages.simplexmq.components.library.libs` (external
|
||||
libraries, i.e. openssl for `extra-libraries: crypto`) and flags. Vendored
|
||||
`c-sources` need no nix entry, which is why blst and libbbs have none either.
|
||||
|
||||
**Cross-compilation is unproven.** This has been built and tested on x86-64
|
||||
Linux only. iOS arm64 and the simulator, the Android ABIs, and the Windows
|
||||
desktop build all still need proving, and that is where the remaining risk in
|
||||
this workstream sits.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/CoreTests/EthCryptoTests.hs`, 100 examples. Everything is checked against
|
||||
published vectors rather than our own output:
|
||||
|
||||
- **BIP-39** — all 24 official English vectors from
|
||||
`trezor/python-mnemonic/vectors.json`, entropy → mnemonic → entropy and
|
||||
mnemonic → seed with the `TREZOR` passphrase.
|
||||
- **BIP-32** — spec test vectors 1 (all six chains) and 2. Expected private keys
|
||||
and chain codes were decoded from the published `xprv` base58 strings, since
|
||||
we do not implement xprv serialization.
|
||||
- **EIP-55** — the four addresses from the EIP-55 spec, round-tripped.
|
||||
- **EIP-712** — the `Mail` example from the spec: domain separator, `hashStruct`
|
||||
and the final digest.
|
||||
- **BIP-44** — the well-known `0x9858EfFD232B4033E47d90003D41EC34EcaEda94` for
|
||||
the `abandon … about` mnemonic at `m/44'/60'/0'/0/0`, plus accounts 1 and 2.
|
||||
- Keccak-256 against SHA3-256, so the padding-byte confusion cannot pass.
|
||||
- Negative cases: zero and out-of-range private keys, wrong digest length,
|
||||
malformed public keys, bad BIP-39 checksums and word counts, out-of-range
|
||||
seeds, bad EIP-55 checksums, and every EIP-712 range and length check.
|
||||
|
||||
The EIP-712 and BIP-44 expectations were additionally reproduced by an
|
||||
independent pure-Python secp256k1 reference written for the purpose, so they are
|
||||
not just our implementation agreeing with itself.
|
||||
+20
-1
@@ -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
|
||||
@@ -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
|
||||
@@ -143,6 +149,9 @@ 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.Names.Record
|
||||
Simplex.Messaging.Notifications.Client
|
||||
Simplex.Messaging.Notifications.Protocol
|
||||
@@ -323,7 +332,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
|
||||
@@ -337,6 +352,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:
|
||||
@@ -541,6 +559,7 @@ test-suite simplexmq-test
|
||||
CoreTests.CryptoFileTests
|
||||
CoreTests.CryptoTests
|
||||
CoreTests.EncodingTests
|
||||
CoreTests.EthCryptoTests
|
||||
CoreTests.MsgStoreTests
|
||||
CoreTests.RetryIntervalTests
|
||||
CoreTests.SOCKSSettings
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
@@ -0,0 +1,198 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | 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'.
|
||||
module Simplex.Messaging.Crypto.BIP39
|
||||
( Mnemonic,
|
||||
MnemonicStrength (..),
|
||||
mnemonicIndexes,
|
||||
mnemonicWords,
|
||||
mnemonicPhrase,
|
||||
entropyToMnemonic,
|
||||
mnemonicToEntropy,
|
||||
parseMnemonic,
|
||||
mnemonicToSeed,
|
||||
randomMnemonic,
|
||||
strengthBytes,
|
||||
strengthWordCount,
|
||||
seedSize,
|
||||
wordListSize,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Concurrent.STM
|
||||
import Crypto.Hash (Digest, SHA256, SHA512 (..), hash)
|
||||
import qualified Crypto.KDF.PBKDF2 as PBKDF2
|
||||
import Crypto.Random (ChaChaDRG, randomBytesGenerate)
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.Bits (shiftL, shiftR, (.&.), (.|.))
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import qualified Data.ByteString.Char8 as BC
|
||||
import Data.Char (toLower)
|
||||
import Data.IntMap.Strict (IntMap)
|
||||
import qualified Data.IntMap.Strict as IM
|
||||
import Data.List (foldl')
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Simplex.Messaging.Crypto.BIP39.English (englishWordList)
|
||||
|
||||
-- | A validated BIP-39 mnemonic. Indexes and words are always consistent
|
||||
-- because the constructor is private and every index is in @[0, 2047]@.
|
||||
data Mnemonic = Mnemonic
|
||||
{ mnemonicIndexes :: [Int],
|
||||
mnemonicWords :: [ByteString]
|
||||
}
|
||||
deriving (Eq)
|
||||
|
||||
instance Show Mnemonic where
|
||||
show m = "Mnemonic <" <> show (length (mnemonicIndexes m)) <> " words, redacted>"
|
||||
|
||||
-- | Entropy size, named by bit length as BIP-39 does.
|
||||
data MnemonicStrength = MS128 | MS160 | MS192 | MS224 | MS256
|
||||
deriving (Eq, Show, Bounded, Enum)
|
||||
|
||||
strengthBytes :: MnemonicStrength -> Int
|
||||
strengthBytes = \case
|
||||
MS128 -> 16
|
||||
MS160 -> 20
|
||||
MS192 -> 24
|
||||
MS224 -> 28
|
||||
MS256 -> 32
|
||||
|
||||
-- | 12, 15, 18, 21 or 24.
|
||||
strengthWordCount :: MnemonicStrength -> Int
|
||||
strengthWordCount s = (entBits + entBits `div` 32) `div` 11
|
||||
where
|
||||
entBits = strengthBytes s * 8
|
||||
|
||||
-- | BIP-39 seeds are always 64 bytes, whatever the entropy size.
|
||||
seedSize :: Int
|
||||
seedSize = 64
|
||||
|
||||
wordListSize :: Int
|
||||
wordListSize = 2048
|
||||
|
||||
validEntropySizes :: [Int]
|
||||
validEntropySizes = [16, 20, 24, 28, 32]
|
||||
|
||||
validWordCounts :: [Int]
|
||||
validWordCounts = [12, 15, 18, 21, 24]
|
||||
|
||||
-- Wordlist indexes
|
||||
|
||||
wordByIndex :: IntMap ByteString
|
||||
wordByIndex = IM.fromList $ zip [0 ..] englishWordList
|
||||
{-# NOINLINE wordByIndex #-}
|
||||
|
||||
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.
|
||||
mnemonicPhrase :: Mnemonic -> ByteString
|
||||
mnemonicPhrase = BC.unwords . mnemonicWords
|
||||
|
||||
-- | Build a mnemonic from raw entropy of 16, 20, 24, 28 or 32 bytes.
|
||||
entropyToMnemonic :: ByteString -> Either String Mnemonic
|
||||
entropyToMnemonic ent
|
||||
| entLen `notElem` validEntropySizes =
|
||||
Left $ "entropy: expected 16, 20, 24, 28 or 32 bytes, got " <> show entLen
|
||||
| otherwise = Right $ mnemonicFromIndexes $ entropyToIndexes 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]@.
|
||||
entropyToIndexes :: ByteString -> [Int]
|
||||
entropyToIndexes ent =
|
||||
[fromIntegral ((combined `shiftR` (11 * (n - 1 - i))) .&. 0x7FF) | i <- [0 .. n - 1]]
|
||||
where
|
||||
entBits = B.length ent * 8
|
||||
csBits = entBits `div` 32
|
||||
-- csBits is at most 8 (256/32), so the first checksum byte always suffices.
|
||||
csByte = B.head (sha256 ent)
|
||||
combined = beToInteger ent `shiftL` csBits .|. fromIntegral (csByte `shiftR` (8 - csBits))
|
||||
n = (entBits + csBits) `div` 11
|
||||
|
||||
-- | Total: a 'Mnemonic' can only hold a valid word count and valid indexes.
|
||||
mnemonicToEntropy :: Mnemonic -> ByteString
|
||||
mnemonicToEntropy m = integerToBE (entBits `div` 8) (combined `shiftR` csBits)
|
||||
where
|
||||
idxs = mnemonicIndexes m
|
||||
totalBits = length idxs * 11
|
||||
entBits = totalBits * 32 `div` 33
|
||||
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.
|
||||
parseMnemonic :: ByteString -> Either String Mnemonic
|
||||
parseMnemonic phrase
|
||||
| n `notElem` validWordCounts =
|
||||
Left $ "mnemonic: expected 12, 15, 18, 21 or 24 words, got " <> show n
|
||||
| otherwise = 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.
|
||||
if entropyToIndexes (mnemonicToEntropy m) == idxs
|
||||
then Right m
|
||||
else Left "mnemonic: checksum mismatch"
|
||||
where
|
||||
ws = BC.words $ BC.map toLower phrase
|
||||
n = length ws
|
||||
lookupWord w =
|
||||
maybe (Left $ "mnemonic: not in wordlist: " <> BC.unpack w) Right $ M.lookup w indexByWord
|
||||
|
||||
-- | 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
|
||||
(PBKDF2.prfHMAC SHA512)
|
||||
PBKDF2.Parameters {PBKDF2.iterCounts = 2048, PBKDF2.outputLength = seedSize}
|
||||
(mnemonicPhrase m)
|
||||
("mnemonic" <> passphrase :: ByteString)
|
||||
|
||||
-- | Generate a fresh mnemonic. Shaped like 'Simplex.Messaging.Crypto.randomBytes'
|
||||
-- so it composes with the agent's DRG instead of reaching for system entropy.
|
||||
randomMnemonic :: MnemonicStrength -> TVar ChaChaDRG -> STM Mnemonic
|
||||
randomMnemonic s gVar = do
|
||||
ent <- stateTVar gVar $ randomBytesGenerate (strengthBytes s)
|
||||
pure $ mnemonicFromIndexes $ entropyToIndexes ent
|
||||
|
||||
-- Internal
|
||||
|
||||
-- | Indexes must be in @[0, 2047]@; both call paths guarantee that (an 11-bit
|
||||
-- mask, or a lookup in the wordlist itself). The default is the empty word so
|
||||
-- that a violation would fail loudly downstream rather than silently produce a
|
||||
-- different valid mnemonic.
|
||||
mnemonicFromIndexes :: [Int] -> Mnemonic
|
||||
mnemonicFromIndexes idxs =
|
||||
Mnemonic {mnemonicIndexes = idxs, mnemonicWords = map wordAt idxs}
|
||||
where
|
||||
wordAt i = IM.findWithDefault "" i wordByIndex
|
||||
|
||||
sha256 :: ByteString -> ByteString
|
||||
sha256 bs = BA.convert (hash bs :: Digest SHA256)
|
||||
|
||||
beToInteger :: ByteString -> Integer
|
||||
beToInteger = B.foldl' (\acc w -> acc `shiftL` 8 .|. fromIntegral w) 0
|
||||
|
||||
integerToBE :: Int -> Integer -> ByteString
|
||||
integerToBE n x = B.pack [fromIntegral (x `shiftR` (8 * (n - 1 - i))) | i <- [0 .. n - 1]]
|
||||
@@ -0,0 +1,359 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
-- | The BIP-39 English wordlist (2048 words), verbatim from
|
||||
-- <https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt>.
|
||||
-- SHA-256 of the source file: 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda
|
||||
--
|
||||
-- This module is generated data; do not edit by hand. Every word is ASCII, and
|
||||
-- the first four bytes of each word are unique, which is what makes BIP-39
|
||||
-- mnemonics unambiguous under four-letter truncation.
|
||||
module Simplex.Messaging.Crypto.BIP39.English (englishWordList) where
|
||||
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
|
||||
-- | All 2048 words, in BIP-39 index order (index 0 is @abandon@).
|
||||
englishWordList :: [ByteString]
|
||||
englishWordList =
|
||||
[ "abandon", "ability", "able", "about", "above", "absent"
|
||||
, "absorb", "abstract", "absurd", "abuse", "access", "accident"
|
||||
, "account", "accuse", "achieve", "acid", "acoustic", "acquire"
|
||||
, "across", "act", "action", "actor", "actress", "actual"
|
||||
, "adapt", "add", "addict", "address", "adjust", "admit"
|
||||
, "adult", "advance", "advice", "aerobic", "affair", "afford"
|
||||
, "afraid", "again", "age", "agent", "agree", "ahead"
|
||||
, "aim", "air", "airport", "aisle", "alarm", "album"
|
||||
, "alcohol", "alert", "alien", "all", "alley", "allow"
|
||||
, "almost", "alone", "alpha", "already", "also", "alter"
|
||||
, "always", "amateur", "amazing", "among", "amount", "amused"
|
||||
, "analyst", "anchor", "ancient", "anger", "angle", "angry"
|
||||
, "animal", "ankle", "announce", "annual", "another", "answer"
|
||||
, "antenna", "antique", "anxiety", "any", "apart", "apology"
|
||||
, "appear", "apple", "approve", "april", "arch", "arctic"
|
||||
, "area", "arena", "argue", "arm", "armed", "armor"
|
||||
, "army", "around", "arrange", "arrest", "arrive", "arrow"
|
||||
, "art", "artefact", "artist", "artwork", "ask", "aspect"
|
||||
, "assault", "asset", "assist", "assume", "asthma", "athlete"
|
||||
, "atom", "attack", "attend", "attitude", "attract", "auction"
|
||||
, "audit", "august", "aunt", "author", "auto", "autumn"
|
||||
, "average", "avocado", "avoid", "awake", "aware", "away"
|
||||
, "awesome", "awful", "awkward", "axis", "baby", "bachelor"
|
||||
, "bacon", "badge", "bag", "balance", "balcony", "ball"
|
||||
, "bamboo", "banana", "banner", "bar", "barely", "bargain"
|
||||
, "barrel", "base", "basic", "basket", "battle", "beach"
|
||||
, "bean", "beauty", "because", "become", "beef", "before"
|
||||
, "begin", "behave", "behind", "believe", "below", "belt"
|
||||
, "bench", "benefit", "best", "betray", "better", "between"
|
||||
, "beyond", "bicycle", "bid", "bike", "bind", "biology"
|
||||
, "bird", "birth", "bitter", "black", "blade", "blame"
|
||||
, "blanket", "blast", "bleak", "bless", "blind", "blood"
|
||||
, "blossom", "blouse", "blue", "blur", "blush", "board"
|
||||
, "boat", "body", "boil", "bomb", "bone", "bonus"
|
||||
, "book", "boost", "border", "boring", "borrow", "boss"
|
||||
, "bottom", "bounce", "box", "boy", "bracket", "brain"
|
||||
, "brand", "brass", "brave", "bread", "breeze", "brick"
|
||||
, "bridge", "brief", "bright", "bring", "brisk", "broccoli"
|
||||
, "broken", "bronze", "broom", "brother", "brown", "brush"
|
||||
, "bubble", "buddy", "budget", "buffalo", "build", "bulb"
|
||||
, "bulk", "bullet", "bundle", "bunker", "burden", "burger"
|
||||
, "burst", "bus", "business", "busy", "butter", "buyer"
|
||||
, "buzz", "cabbage", "cabin", "cable", "cactus", "cage"
|
||||
, "cake", "call", "calm", "camera", "camp", "can"
|
||||
, "canal", "cancel", "candy", "cannon", "canoe", "canvas"
|
||||
, "canyon", "capable", "capital", "captain", "car", "carbon"
|
||||
, "card", "cargo", "carpet", "carry", "cart", "case"
|
||||
, "cash", "casino", "castle", "casual", "cat", "catalog"
|
||||
, "catch", "category", "cattle", "caught", "cause", "caution"
|
||||
, "cave", "ceiling", "celery", "cement", "census", "century"
|
||||
, "cereal", "certain", "chair", "chalk", "champion", "change"
|
||||
, "chaos", "chapter", "charge", "chase", "chat", "cheap"
|
||||
, "check", "cheese", "chef", "cherry", "chest", "chicken"
|
||||
, "chief", "child", "chimney", "choice", "choose", "chronic"
|
||||
, "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle"
|
||||
, "citizen", "city", "civil", "claim", "clap", "clarify"
|
||||
, "claw", "clay", "clean", "clerk", "clever", "click"
|
||||
, "client", "cliff", "climb", "clinic", "clip", "clock"
|
||||
, "clog", "close", "cloth", "cloud", "clown", "club"
|
||||
, "clump", "cluster", "clutch", "coach", "coast", "coconut"
|
||||
, "code", "coffee", "coil", "coin", "collect", "color"
|
||||
, "column", "combine", "come", "comfort", "comic", "common"
|
||||
, "company", "concert", "conduct", "confirm", "congress", "connect"
|
||||
, "consider", "control", "convince", "cook", "cool", "copper"
|
||||
, "copy", "coral", "core", "corn", "correct", "cost"
|
||||
, "cotton", "couch", "country", "couple", "course", "cousin"
|
||||
, "cover", "coyote", "crack", "cradle", "craft", "cram"
|
||||
, "crane", "crash", "crater", "crawl", "crazy", "cream"
|
||||
, "credit", "creek", "crew", "cricket", "crime", "crisp"
|
||||
, "critic", "crop", "cross", "crouch", "crowd", "crucial"
|
||||
, "cruel", "cruise", "crumble", "crunch", "crush", "cry"
|
||||
, "crystal", "cube", "culture", "cup", "cupboard", "curious"
|
||||
, "current", "curtain", "curve", "cushion", "custom", "cute"
|
||||
, "cycle", "dad", "damage", "damp", "dance", "danger"
|
||||
, "daring", "dash", "daughter", "dawn", "day", "deal"
|
||||
, "debate", "debris", "decade", "december", "decide", "decline"
|
||||
, "decorate", "decrease", "deer", "defense", "define", "defy"
|
||||
, "degree", "delay", "deliver", "demand", "demise", "denial"
|
||||
, "dentist", "deny", "depart", "depend", "deposit", "depth"
|
||||
, "deputy", "derive", "describe", "desert", "design", "desk"
|
||||
, "despair", "destroy", "detail", "detect", "develop", "device"
|
||||
, "devote", "diagram", "dial", "diamond", "diary", "dice"
|
||||
, "diesel", "diet", "differ", "digital", "dignity", "dilemma"
|
||||
, "dinner", "dinosaur", "direct", "dirt", "disagree", "discover"
|
||||
, "disease", "dish", "dismiss", "disorder", "display", "distance"
|
||||
, "divert", "divide", "divorce", "dizzy", "doctor", "document"
|
||||
, "dog", "doll", "dolphin", "domain", "donate", "donkey"
|
||||
, "donor", "door", "dose", "double", "dove", "draft"
|
||||
, "dragon", "drama", "drastic", "draw", "dream", "dress"
|
||||
, "drift", "drill", "drink", "drip", "drive", "drop"
|
||||
, "drum", "dry", "duck", "dumb", "dune", "during"
|
||||
, "dust", "dutch", "duty", "dwarf", "dynamic", "eager"
|
||||
, "eagle", "early", "earn", "earth", "easily", "east"
|
||||
, "easy", "echo", "ecology", "economy", "edge", "edit"
|
||||
, "educate", "effort", "egg", "eight", "either", "elbow"
|
||||
, "elder", "electric", "elegant", "element", "elephant", "elevator"
|
||||
, "elite", "else", "embark", "embody", "embrace", "emerge"
|
||||
, "emotion", "employ", "empower", "empty", "enable", "enact"
|
||||
, "end", "endless", "endorse", "enemy", "energy", "enforce"
|
||||
, "engage", "engine", "enhance", "enjoy", "enlist", "enough"
|
||||
, "enrich", "enroll", "ensure", "enter", "entire", "entry"
|
||||
, "envelope", "episode", "equal", "equip", "era", "erase"
|
||||
, "erode", "erosion", "error", "erupt", "escape", "essay"
|
||||
, "essence", "estate", "eternal", "ethics", "evidence", "evil"
|
||||
, "evoke", "evolve", "exact", "example", "excess", "exchange"
|
||||
, "excite", "exclude", "excuse", "execute", "exercise", "exhaust"
|
||||
, "exhibit", "exile", "exist", "exit", "exotic", "expand"
|
||||
, "expect", "expire", "explain", "expose", "express", "extend"
|
||||
, "extra", "eye", "eyebrow", "fabric", "face", "faculty"
|
||||
, "fade", "faint", "faith", "fall", "false", "fame"
|
||||
, "family", "famous", "fan", "fancy", "fantasy", "farm"
|
||||
, "fashion", "fat", "fatal", "father", "fatigue", "fault"
|
||||
, "favorite", "feature", "february", "federal", "fee", "feed"
|
||||
, "feel", "female", "fence", "festival", "fetch", "fever"
|
||||
, "few", "fiber", "fiction", "field", "figure", "file"
|
||||
, "film", "filter", "final", "find", "fine", "finger"
|
||||
, "finish", "fire", "firm", "first", "fiscal", "fish"
|
||||
, "fit", "fitness", "fix", "flag", "flame", "flash"
|
||||
, "flat", "flavor", "flee", "flight", "flip", "float"
|
||||
, "flock", "floor", "flower", "fluid", "flush", "fly"
|
||||
, "foam", "focus", "fog", "foil", "fold", "follow"
|
||||
, "food", "foot", "force", "forest", "forget", "fork"
|
||||
, "fortune", "forum", "forward", "fossil", "foster", "found"
|
||||
, "fox", "fragile", "frame", "frequent", "fresh", "friend"
|
||||
, "fringe", "frog", "front", "frost", "frown", "frozen"
|
||||
, "fruit", "fuel", "fun", "funny", "furnace", "fury"
|
||||
, "future", "gadget", "gain", "galaxy", "gallery", "game"
|
||||
, "gap", "garage", "garbage", "garden", "garlic", "garment"
|
||||
, "gas", "gasp", "gate", "gather", "gauge", "gaze"
|
||||
, "general", "genius", "genre", "gentle", "genuine", "gesture"
|
||||
, "ghost", "giant", "gift", "giggle", "ginger", "giraffe"
|
||||
, "girl", "give", "glad", "glance", "glare", "glass"
|
||||
, "glide", "glimpse", "globe", "gloom", "glory", "glove"
|
||||
, "glow", "glue", "goat", "goddess", "gold", "good"
|
||||
, "goose", "gorilla", "gospel", "gossip", "govern", "gown"
|
||||
, "grab", "grace", "grain", "grant", "grape", "grass"
|
||||
, "gravity", "great", "green", "grid", "grief", "grit"
|
||||
, "grocery", "group", "grow", "grunt", "guard", "guess"
|
||||
, "guide", "guilt", "guitar", "gun", "gym", "habit"
|
||||
, "hair", "half", "hammer", "hamster", "hand", "happy"
|
||||
, "harbor", "hard", "harsh", "harvest", "hat", "have"
|
||||
, "hawk", "hazard", "head", "health", "heart", "heavy"
|
||||
, "hedgehog", "height", "hello", "helmet", "help", "hen"
|
||||
, "hero", "hidden", "high", "hill", "hint", "hip"
|
||||
, "hire", "history", "hobby", "hockey", "hold", "hole"
|
||||
, "holiday", "hollow", "home", "honey", "hood", "hope"
|
||||
, "horn", "horror", "horse", "hospital", "host", "hotel"
|
||||
, "hour", "hover", "hub", "huge", "human", "humble"
|
||||
, "humor", "hundred", "hungry", "hunt", "hurdle", "hurry"
|
||||
, "hurt", "husband", "hybrid", "ice", "icon", "idea"
|
||||
, "identify", "idle", "ignore", "ill", "illegal", "illness"
|
||||
, "image", "imitate", "immense", "immune", "impact", "impose"
|
||||
, "improve", "impulse", "inch", "include", "income", "increase"
|
||||
, "index", "indicate", "indoor", "industry", "infant", "inflict"
|
||||
, "inform", "inhale", "inherit", "initial", "inject", "injury"
|
||||
, "inmate", "inner", "innocent", "input", "inquiry", "insane"
|
||||
, "insect", "inside", "inspire", "install", "intact", "interest"
|
||||
, "into", "invest", "invite", "involve", "iron", "island"
|
||||
, "isolate", "issue", "item", "ivory", "jacket", "jaguar"
|
||||
, "jar", "jazz", "jealous", "jeans", "jelly", "jewel"
|
||||
, "job", "join", "joke", "journey", "joy", "judge"
|
||||
, "juice", "jump", "jungle", "junior", "junk", "just"
|
||||
, "kangaroo", "keen", "keep", "ketchup", "key", "kick"
|
||||
, "kid", "kidney", "kind", "kingdom", "kiss", "kit"
|
||||
, "kitchen", "kite", "kitten", "kiwi", "knee", "knife"
|
||||
, "knock", "know", "lab", "label", "labor", "ladder"
|
||||
, "lady", "lake", "lamp", "language", "laptop", "large"
|
||||
, "later", "latin", "laugh", "laundry", "lava", "law"
|
||||
, "lawn", "lawsuit", "layer", "lazy", "leader", "leaf"
|
||||
, "learn", "leave", "lecture", "left", "leg", "legal"
|
||||
, "legend", "leisure", "lemon", "lend", "length", "lens"
|
||||
, "leopard", "lesson", "letter", "level", "liar", "liberty"
|
||||
, "library", "license", "life", "lift", "light", "like"
|
||||
, "limb", "limit", "link", "lion", "liquid", "list"
|
||||
, "little", "live", "lizard", "load", "loan", "lobster"
|
||||
, "local", "lock", "logic", "lonely", "long", "loop"
|
||||
, "lottery", "loud", "lounge", "love", "loyal", "lucky"
|
||||
, "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics"
|
||||
, "machine", "mad", "magic", "magnet", "maid", "mail"
|
||||
, "main", "major", "make", "mammal", "man", "manage"
|
||||
, "mandate", "mango", "mansion", "manual", "maple", "marble"
|
||||
, "march", "margin", "marine", "market", "marriage", "mask"
|
||||
, "mass", "master", "match", "material", "math", "matrix"
|
||||
, "matter", "maximum", "maze", "meadow", "mean", "measure"
|
||||
, "meat", "mechanic", "medal", "media", "melody", "melt"
|
||||
, "member", "memory", "mention", "menu", "mercy", "merge"
|
||||
, "merit", "merry", "mesh", "message", "metal", "method"
|
||||
, "middle", "midnight", "milk", "million", "mimic", "mind"
|
||||
, "minimum", "minor", "minute", "miracle", "mirror", "misery"
|
||||
, "miss", "mistake", "mix", "mixed", "mixture", "mobile"
|
||||
, "model", "modify", "mom", "moment", "monitor", "monkey"
|
||||
, "monster", "month", "moon", "moral", "more", "morning"
|
||||
, "mosquito", "mother", "motion", "motor", "mountain", "mouse"
|
||||
, "move", "movie", "much", "muffin", "mule", "multiply"
|
||||
, "muscle", "museum", "mushroom", "music", "must", "mutual"
|
||||
, "myself", "mystery", "myth", "naive", "name", "napkin"
|
||||
, "narrow", "nasty", "nation", "nature", "near", "neck"
|
||||
, "need", "negative", "neglect", "neither", "nephew", "nerve"
|
||||
, "nest", "net", "network", "neutral", "never", "news"
|
||||
, "next", "nice", "night", "noble", "noise", "nominee"
|
||||
, "noodle", "normal", "north", "nose", "notable", "note"
|
||||
, "nothing", "notice", "novel", "now", "nuclear", "number"
|
||||
, "nurse", "nut", "oak", "obey", "object", "oblige"
|
||||
, "obscure", "observe", "obtain", "obvious", "occur", "ocean"
|
||||
, "october", "odor", "off", "offer", "office", "often"
|
||||
, "oil", "okay", "old", "olive", "olympic", "omit"
|
||||
, "once", "one", "onion", "online", "only", "open"
|
||||
, "opera", "opinion", "oppose", "option", "orange", "orbit"
|
||||
, "orchard", "order", "ordinary", "organ", "orient", "original"
|
||||
, "orphan", "ostrich", "other", "outdoor", "outer", "output"
|
||||
, "outside", "oval", "oven", "over", "own", "owner"
|
||||
, "oxygen", "oyster", "ozone", "pact", "paddle", "page"
|
||||
, "pair", "palace", "palm", "panda", "panel", "panic"
|
||||
, "panther", "paper", "parade", "parent", "park", "parrot"
|
||||
, "party", "pass", "patch", "path", "patient", "patrol"
|
||||
, "pattern", "pause", "pave", "payment", "peace", "peanut"
|
||||
, "pear", "peasant", "pelican", "pen", "penalty", "pencil"
|
||||
, "people", "pepper", "perfect", "permit", "person", "pet"
|
||||
, "phone", "photo", "phrase", "physical", "piano", "picnic"
|
||||
, "picture", "piece", "pig", "pigeon", "pill", "pilot"
|
||||
, "pink", "pioneer", "pipe", "pistol", "pitch", "pizza"
|
||||
, "place", "planet", "plastic", "plate", "play", "please"
|
||||
, "pledge", "pluck", "plug", "plunge", "poem", "poet"
|
||||
, "point", "polar", "pole", "police", "pond", "pony"
|
||||
, "pool", "popular", "portion", "position", "possible", "post"
|
||||
, "potato", "pottery", "poverty", "powder", "power", "practice"
|
||||
, "praise", "predict", "prefer", "prepare", "present", "pretty"
|
||||
, "prevent", "price", "pride", "primary", "print", "priority"
|
||||
, "prison", "private", "prize", "problem", "process", "produce"
|
||||
, "profit", "program", "project", "promote", "proof", "property"
|
||||
, "prosper", "protect", "proud", "provide", "public", "pudding"
|
||||
, "pull", "pulp", "pulse", "pumpkin", "punch", "pupil"
|
||||
, "puppy", "purchase", "purity", "purpose", "purse", "push"
|
||||
, "put", "puzzle", "pyramid", "quality", "quantum", "quarter"
|
||||
, "question", "quick", "quit", "quiz", "quote", "rabbit"
|
||||
, "raccoon", "race", "rack", "radar", "radio", "rail"
|
||||
, "rain", "raise", "rally", "ramp", "ranch", "random"
|
||||
, "range", "rapid", "rare", "rate", "rather", "raven"
|
||||
, "raw", "razor", "ready", "real", "reason", "rebel"
|
||||
, "rebuild", "recall", "receive", "recipe", "record", "recycle"
|
||||
, "reduce", "reflect", "reform", "refuse", "region", "regret"
|
||||
, "regular", "reject", "relax", "release", "relief", "rely"
|
||||
, "remain", "remember", "remind", "remove", "render", "renew"
|
||||
, "rent", "reopen", "repair", "repeat", "replace", "report"
|
||||
, "require", "rescue", "resemble", "resist", "resource", "response"
|
||||
, "result", "retire", "retreat", "return", "reunion", "reveal"
|
||||
, "review", "reward", "rhythm", "rib", "ribbon", "rice"
|
||||
, "rich", "ride", "ridge", "rifle", "right", "rigid"
|
||||
, "ring", "riot", "ripple", "risk", "ritual", "rival"
|
||||
, "river", "road", "roast", "robot", "robust", "rocket"
|
||||
, "romance", "roof", "rookie", "room", "rose", "rotate"
|
||||
, "rough", "round", "route", "royal", "rubber", "rude"
|
||||
, "rug", "rule", "run", "runway", "rural", "sad"
|
||||
, "saddle", "sadness", "safe", "sail", "salad", "salmon"
|
||||
, "salon", "salt", "salute", "same", "sample", "sand"
|
||||
, "satisfy", "satoshi", "sauce", "sausage", "save", "say"
|
||||
, "scale", "scan", "scare", "scatter", "scene", "scheme"
|
||||
, "school", "science", "scissors", "scorpion", "scout", "scrap"
|
||||
, "screen", "script", "scrub", "sea", "search", "season"
|
||||
, "seat", "second", "secret", "section", "security", "seed"
|
||||
, "seek", "segment", "select", "sell", "seminar", "senior"
|
||||
, "sense", "sentence", "series", "service", "session", "settle"
|
||||
, "setup", "seven", "shadow", "shaft", "shallow", "share"
|
||||
, "shed", "shell", "sheriff", "shield", "shift", "shine"
|
||||
, "ship", "shiver", "shock", "shoe", "shoot", "shop"
|
||||
, "short", "shoulder", "shove", "shrimp", "shrug", "shuffle"
|
||||
, "shy", "sibling", "sick", "side", "siege", "sight"
|
||||
, "sign", "silent", "silk", "silly", "silver", "similar"
|
||||
, "simple", "since", "sing", "siren", "sister", "situate"
|
||||
, "six", "size", "skate", "sketch", "ski", "skill"
|
||||
, "skin", "skirt", "skull", "slab", "slam", "sleep"
|
||||
, "slender", "slice", "slide", "slight", "slim", "slogan"
|
||||
, "slot", "slow", "slush", "small", "smart", "smile"
|
||||
, "smoke", "smooth", "snack", "snake", "snap", "sniff"
|
||||
, "snow", "soap", "soccer", "social", "sock", "soda"
|
||||
, "soft", "solar", "soldier", "solid", "solution", "solve"
|
||||
, "someone", "song", "soon", "sorry", "sort", "soul"
|
||||
, "sound", "soup", "source", "south", "space", "spare"
|
||||
, "spatial", "spawn", "speak", "special", "speed", "spell"
|
||||
, "spend", "sphere", "spice", "spider", "spike", "spin"
|
||||
, "spirit", "split", "spoil", "sponsor", "spoon", "sport"
|
||||
, "spot", "spray", "spread", "spring", "spy", "square"
|
||||
, "squeeze", "squirrel", "stable", "stadium", "staff", "stage"
|
||||
, "stairs", "stamp", "stand", "start", "state", "stay"
|
||||
, "steak", "steel", "stem", "step", "stereo", "stick"
|
||||
, "still", "sting", "stock", "stomach", "stone", "stool"
|
||||
, "story", "stove", "strategy", "street", "strike", "strong"
|
||||
, "struggle", "student", "stuff", "stumble", "style", "subject"
|
||||
, "submit", "subway", "success", "such", "sudden", "suffer"
|
||||
, "sugar", "suggest", "suit", "summer", "sun", "sunny"
|
||||
, "sunset", "super", "supply", "supreme", "sure", "surface"
|
||||
, "surge", "surprise", "surround", "survey", "suspect", "sustain"
|
||||
, "swallow", "swamp", "swap", "swarm", "swear", "sweet"
|
||||
, "swift", "swim", "swing", "switch", "sword", "symbol"
|
||||
, "symptom", "syrup", "system", "table", "tackle", "tag"
|
||||
, "tail", "talent", "talk", "tank", "tape", "target"
|
||||
, "task", "taste", "tattoo", "taxi", "teach", "team"
|
||||
, "tell", "ten", "tenant", "tennis", "tent", "term"
|
||||
, "test", "text", "thank", "that", "theme", "then"
|
||||
, "theory", "there", "they", "thing", "this", "thought"
|
||||
, "three", "thrive", "throw", "thumb", "thunder", "ticket"
|
||||
, "tide", "tiger", "tilt", "timber", "time", "tiny"
|
||||
, "tip", "tired", "tissue", "title", "toast", "tobacco"
|
||||
, "today", "toddler", "toe", "together", "toilet", "token"
|
||||
, "tomato", "tomorrow", "tone", "tongue", "tonight", "tool"
|
||||
, "tooth", "top", "topic", "topple", "torch", "tornado"
|
||||
, "tortoise", "toss", "total", "tourist", "toward", "tower"
|
||||
, "town", "toy", "track", "trade", "traffic", "tragic"
|
||||
, "train", "transfer", "trap", "trash", "travel", "tray"
|
||||
, "treat", "tree", "trend", "trial", "tribe", "trick"
|
||||
, "trigger", "trim", "trip", "trophy", "trouble", "truck"
|
||||
, "true", "truly", "trumpet", "trust", "truth", "try"
|
||||
, "tube", "tuition", "tumble", "tuna", "tunnel", "turkey"
|
||||
, "turn", "turtle", "twelve", "twenty", "twice", "twin"
|
||||
, "twist", "two", "type", "typical", "ugly", "umbrella"
|
||||
, "unable", "unaware", "uncle", "uncover", "under", "undo"
|
||||
, "unfair", "unfold", "unhappy", "uniform", "unique", "unit"
|
||||
, "universe", "unknown", "unlock", "until", "unusual", "unveil"
|
||||
, "update", "upgrade", "uphold", "upon", "upper", "upset"
|
||||
, "urban", "urge", "usage", "use", "used", "useful"
|
||||
, "useless", "usual", "utility", "vacant", "vacuum", "vague"
|
||||
, "valid", "valley", "valve", "van", "vanish", "vapor"
|
||||
, "various", "vast", "vault", "vehicle", "velvet", "vendor"
|
||||
, "venture", "venue", "verb", "verify", "version", "very"
|
||||
, "vessel", "veteran", "viable", "vibrant", "vicious", "victory"
|
||||
, "video", "view", "village", "vintage", "violin", "virtual"
|
||||
, "virus", "visa", "visit", "visual", "vital", "vivid"
|
||||
, "vocal", "voice", "void", "volcano", "volume", "vote"
|
||||
, "voyage", "wage", "wagon", "wait", "walk", "wall"
|
||||
, "walnut", "want", "warfare", "warm", "warrior", "wash"
|
||||
, "wasp", "waste", "water", "wave", "way", "wealth"
|
||||
, "weapon", "wear", "weasel", "weather", "web", "wedding"
|
||||
, "weekend", "weird", "welcome", "west", "wet", "whale"
|
||||
, "what", "wheat", "wheel", "when", "where", "whip"
|
||||
, "whisper", "wide", "width", "wife", "wild", "will"
|
||||
, "win", "window", "wine", "wing", "wink", "winner"
|
||||
, "winter", "wire", "wisdom", "wise", "wish", "witness"
|
||||
, "wolf", "woman", "wonder", "wood", "wool", "word"
|
||||
, "work", "world", "worry", "worth", "wrap", "wreck"
|
||||
, "wrestle", "wrist", "write", "wrong", "yard", "year"
|
||||
, "yellow", "you", "young", "youth", "zebra", "zero"
|
||||
, "zone", "zoo"
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
{-# LANGUAGE DerivingStrategies #-}
|
||||
{-# LANGUAGE ForeignFunctionInterface #-}
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | FFI bindings to libsecp256k1 (ECDSA over secp256k1 with public key recovery).
|
||||
--
|
||||
-- Only what Ethereum signing needs: key validation, public key derivation and
|
||||
-- serialization, scalar addition (for BIP-32 child derivation), recoverable
|
||||
-- signing and recovery.
|
||||
--
|
||||
-- Signatures are produced with libsecp256k1's default RFC-6979 deterministic
|
||||
-- nonce, so signing is a pure function of (key, digest) — which is why this
|
||||
-- module exposes a pure API over 'unsafePerformIO'. libsecp256k1 also always
|
||||
-- emits the low-@s@ form, so every signature from 'signRecoverable' is already
|
||||
-- EIP-2 compliant; 'isLowS' is provided so callers can assert that rather than
|
||||
-- trust it. Note there is deliberately no normalization entry point: we never
|
||||
-- accept a foreign signature, we only produce our own.
|
||||
module Simplex.Messaging.Crypto.Secp256k1
|
||||
( PrivateKey,
|
||||
PublicKey,
|
||||
RecoverableSignature (..),
|
||||
PubKeyFormat (..),
|
||||
mkPrivateKey,
|
||||
unPrivateKey,
|
||||
publicKey,
|
||||
parsePublicKey,
|
||||
serializePublicKey,
|
||||
privateKeyTweakAdd,
|
||||
signRecoverable,
|
||||
recoverPublicKey,
|
||||
isLowS,
|
||||
privateKeySize,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
digestSize,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (when)
|
||||
import Crypto.Random (drgNew, randomBytesGenerate)
|
||||
import qualified Data.ByteArray as BA
|
||||
import qualified Data.ByteArray.Encoding as BAE
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import qualified Data.ByteString.Char8 as BC
|
||||
import qualified Data.ByteString.Unsafe as BU
|
||||
import Foreign
|
||||
import Foreign.C
|
||||
import System.IO.Unsafe (unsafePerformIO)
|
||||
|
||||
-- Sizes
|
||||
|
||||
-- | A secp256k1 scalar is 32 bytes, big-endian.
|
||||
privateKeySize :: Int
|
||||
privateKeySize = 32
|
||||
|
||||
-- | SEC1 compressed point: @0x02@/@0x03@ prefix and the x coordinate.
|
||||
compressedSize :: Int
|
||||
compressedSize = 33
|
||||
|
||||
-- | SEC1 uncompressed point: @0x04@ prefix, x, y.
|
||||
uncompressedSize :: Int
|
||||
uncompressedSize = 65
|
||||
|
||||
-- | ECDSA signs a 32-byte digest, never a message.
|
||||
digestSize :: Int
|
||||
digestSize = 32
|
||||
|
||||
-- | Internal size of @secp256k1_pubkey@ (opaque, not a serialization).
|
||||
pubKeyInternalSize :: Int
|
||||
pubKeyInternalSize = 64
|
||||
|
||||
-- | Internal size of @secp256k1_ecdsa_recoverable_signature@.
|
||||
recSigInternalSize :: Int
|
||||
recSigInternalSize = 65
|
||||
|
||||
compactSize :: Int
|
||||
compactSize = 64
|
||||
|
||||
-- Types
|
||||
|
||||
-- | A validated secp256k1 private key: 32 bytes, in @[1, n-1]@.
|
||||
--
|
||||
-- 'Show' is redacting and 'Eq' is constant-time, both deliberately: this key
|
||||
-- authorises transfers of assets with monetary value, so it must not reach a
|
||||
-- log through a derived 'Show' and must not leak through comparison timing.
|
||||
newtype PrivateKey = PrivateKey ByteString
|
||||
|
||||
instance Show PrivateKey where
|
||||
show _ = "PrivateKey <redacted>"
|
||||
|
||||
instance Eq PrivateKey where
|
||||
PrivateKey a == PrivateKey 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.
|
||||
newtype PublicKey = PublicKey ByteString
|
||||
deriving newtype (Eq)
|
||||
|
||||
instance Show PublicKey where
|
||||
show pk = "PublicKey " <> BC.unpack (hex $ serializePublicKey Compressed pk)
|
||||
|
||||
hex :: ByteString -> ByteString
|
||||
hex = BAE.convertToBase BAE.Base16
|
||||
|
||||
-- | SEC1 output format for 'serializePublicKey'.
|
||||
data PubKeyFormat = Compressed | Uncompressed
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | A signature plus the recovery id needed to recover the signing key.
|
||||
-- @rsCompact@ is @r || s@, 64 bytes big-endian; @rsRecId@ is in @[0, 3]@.
|
||||
-- Ethereum's @v@ is @rsRecId + 27@ (or @+ 35 + 2 * chainId@ for EIP-155).
|
||||
data RecoverableSignature = RecoverableSignature
|
||||
{ rsCompact :: ByteString,
|
||||
rsRecId :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- FFI
|
||||
|
||||
data Ctx
|
||||
|
||||
data PubKeyRaw
|
||||
|
||||
data RecSigRaw
|
||||
|
||||
foreign import ccall "secp256k1_context_create"
|
||||
c_context_create :: CUInt -> IO (Ptr Ctx)
|
||||
|
||||
foreign import ccall "secp256k1_context_randomize"
|
||||
c_context_randomize :: Ptr Ctx -> Ptr Word8 -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ec_seckey_verify"
|
||||
c_ec_seckey_verify :: Ptr Ctx -> Ptr Word8 -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ec_pubkey_create"
|
||||
c_ec_pubkey_create :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ec_pubkey_parse"
|
||||
c_ec_pubkey_parse :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr Word8 -> CSize -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ec_pubkey_serialize"
|
||||
c_ec_pubkey_serialize :: Ptr Ctx -> Ptr Word8 -> Ptr CSize -> Ptr PubKeyRaw -> CUInt -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ec_seckey_tweak_add"
|
||||
c_ec_seckey_tweak_add :: Ptr Ctx -> Ptr Word8 -> Ptr Word8 -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_sign_recoverable"
|
||||
c_ecdsa_sign_recoverable :: Ptr Ctx -> Ptr RecSigRaw -> Ptr Word8 -> Ptr Word8 -> Ptr () -> Ptr () -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_recoverable_signature_serialize_compact"
|
||||
c_recsig_serialize_compact :: Ptr Ctx -> Ptr Word8 -> Ptr CInt -> Ptr RecSigRaw -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_recoverable_signature_parse_compact"
|
||||
c_recsig_parse_compact :: Ptr Ctx -> Ptr RecSigRaw -> Ptr Word8 -> CInt -> IO CInt
|
||||
|
||||
foreign import ccall "secp256k1_ecdsa_recover"
|
||||
c_ecdsa_recover :: Ptr Ctx -> Ptr PubKeyRaw -> Ptr RecSigRaw -> Ptr Word8 -> IO CInt
|
||||
|
||||
-- SECP256K1_CONTEXT_NONE = SECP256K1_FLAGS_TYPE_CONTEXT
|
||||
contextNone :: CUInt
|
||||
contextNone = 1
|
||||
|
||||
-- SECP256K1_EC_COMPRESSED = SECP256K1_FLAGS_TYPE_COMPRESSION | SECP256K1_FLAGS_BIT_COMPRESSION
|
||||
-- SECP256K1_EC_UNCOMPRESSED = SECP256K1_FLAGS_TYPE_COMPRESSION
|
||||
formatFlag :: PubKeyFormat -> CUInt
|
||||
formatFlag = \case
|
||||
Compressed -> 2 .|. 256
|
||||
Uncompressed -> 2
|
||||
|
||||
-- | 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.
|
||||
secp256k1Ctx :: Ptr Ctx
|
||||
secp256k1Ctx = unsafePerformIO $ do
|
||||
ctx <- c_context_create contextNone
|
||||
when (ctx == nullPtr) $ ioError (userError "secp256k1_context_create failed")
|
||||
drg <- drgNew
|
||||
let (seed :: ByteString, _) = randomBytesGenerate 32 drg
|
||||
rc <- BU.unsafeUseAsCString seed $ \p -> c_context_randomize ctx (castPtr p)
|
||||
when (rc /= 1) $ ioError (userError "secp256k1_context_randomize failed")
|
||||
pure ctx
|
||||
{-# NOINLINE secp256k1Ctx #-}
|
||||
|
||||
-- Helpers
|
||||
|
||||
withBS :: ByteString -> (Ptr Word8 -> IO a) -> IO a
|
||||
withBS bs f = BU.unsafeUseAsCString bs $ f . castPtr
|
||||
|
||||
packPtr :: Ptr Word8 -> Int -> IO ByteString
|
||||
packPtr p n = B.packCStringLen (castPtr p, n)
|
||||
|
||||
-- | Marshal a 'PublicKey' back into its opaque C representation.
|
||||
withPubKeyRaw :: PublicKey -> (Ptr PubKeyRaw -> IO a) -> IO a
|
||||
withPubKeyRaw (PublicKey bs) f = withBS bs $ f . castPtr
|
||||
|
||||
-- | Marshal a 'RecoverableSignature' into the opaque C representation, failing
|
||||
-- if libsecp256k1 rejects it.
|
||||
withRecSigRaw :: RecoverableSignature -> (Ptr RecSigRaw -> IO (Either String a)) -> IO (Either String a)
|
||||
withRecSigRaw (RecoverableSignature compact recId) f
|
||||
| B.length compact /= compactSize = pure $ Left "signature: expected 64 bytes"
|
||||
| recId < 0 || recId > 3 = pure $ Left "signature: recovery id out of range"
|
||||
| otherwise =
|
||||
allocaBytes recSigInternalSize $ \sigPtr ->
|
||||
withBS compact $ \cPtr -> do
|
||||
rc <- c_recsig_parse_compact secp256k1Ctx sigPtr cPtr (fromIntegral recId)
|
||||
if rc /= 1 then pure $ Left "signature: malformed" else f sigPtr
|
||||
|
||||
-- Public API
|
||||
|
||||
-- | Validate 32 bytes as a private key. Rejects zero and anything at or above
|
||||
-- the group order, which is what makes 'publicKey' and 'signRecoverable' total.
|
||||
mkPrivateKey :: ByteString -> Either String PrivateKey
|
||||
mkPrivateKey bs
|
||||
| B.length bs /= privateKeySize = Left $ "private key: expected 32 bytes, got " <> show (B.length bs)
|
||||
| otherwise = unsafePerformIO $ withBS bs $ \p -> do
|
||||
rc <- c_ec_seckey_verify secp256k1Ctx p
|
||||
pure $ if rc == 1 then Right (PrivateKey bs) else Left "private key: not in [1, n-1]"
|
||||
|
||||
unPrivateKey :: PrivateKey -> ByteString
|
||||
unPrivateKey (PrivateKey bs) = bs
|
||||
|
||||
-- | Derive the public key. Total, because 'PrivateKey' is validated.
|
||||
publicKey :: PrivateKey -> PublicKey
|
||||
publicKey (PrivateKey sk) = unsafePerformIO $
|
||||
allocaBytes pubKeyInternalSize $ \pkPtr ->
|
||||
withBS sk $ \skPtr -> do
|
||||
rc <- c_ec_pubkey_create secp256k1Ctx pkPtr skPtr
|
||||
-- Cannot fail: the key was verified by mkPrivateKey.
|
||||
when (rc /= 1) $ ioError (userError "secp256k1_ec_pubkey_create failed on a validated key")
|
||||
PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
|
||||
-- | Parse a SEC1 point, compressed (33 bytes) or uncompressed (65 bytes).
|
||||
parsePublicKey :: ByteString -> Either String PublicKey
|
||||
parsePublicKey bs
|
||||
| len /= compressedSize && len /= uncompressedSize =
|
||||
Left $ "public key: expected 33 or 65 bytes, got " <> show len
|
||||
| otherwise = unsafePerformIO $
|
||||
allocaBytes pubKeyInternalSize $ \pkPtr ->
|
||||
withBS bs $ \inPtr -> do
|
||||
rc <- c_ec_pubkey_parse secp256k1Ctx pkPtr inPtr (fromIntegral len)
|
||||
if rc == 1
|
||||
then Right . PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
else pure $ Left "public key: not a valid curve point"
|
||||
where
|
||||
len = B.length bs
|
||||
|
||||
serializePublicKey :: PubKeyFormat -> PublicKey -> ByteString
|
||||
serializePublicKey fmt pk = unsafePerformIO $
|
||||
allocaBytes outLen $ \outPtr ->
|
||||
alloca $ \lenPtr ->
|
||||
withPubKeyRaw pk $ \pkPtr -> do
|
||||
poke lenPtr (fromIntegral outLen)
|
||||
rc <- c_ec_pubkey_serialize secp256k1Ctx outPtr lenPtr pkPtr (formatFlag fmt)
|
||||
when (rc /= 1) $ ioError (userError "secp256k1_ec_pubkey_serialize failed")
|
||||
written <- peek lenPtr
|
||||
packPtr outPtr (fromIntegral written)
|
||||
where
|
||||
outLen = case fmt of
|
||||
Compressed -> compressedSize
|
||||
Uncompressed -> 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.
|
||||
privateKeyTweakAdd :: PrivateKey -> ByteString -> Maybe PrivateKey
|
||||
privateKeyTweakAdd (PrivateKey sk) tweak
|
||||
| B.length tweak /= privateKeySize = Nothing
|
||||
| otherwise = unsafePerformIO $
|
||||
allocaBytes privateKeySize $ \skPtr ->
|
||||
withBS tweak $ \twPtr -> do
|
||||
withBS sk $ \src -> copyBytes skPtr src privateKeySize
|
||||
rc <- c_ec_seckey_tweak_add secp256k1Ctx skPtr twPtr
|
||||
if rc == 1
|
||||
then Just . PrivateKey <$> packPtr skPtr privateKeySize
|
||||
else pure Nothing
|
||||
|
||||
-- | Sign a 32-byte digest. Deterministic (RFC 6979) and always low-@s@.
|
||||
signRecoverable :: PrivateKey -> ByteString -> Either String RecoverableSignature
|
||||
signRecoverable (PrivateKey sk) digest
|
||||
| B.length digest /= digestSize =
|
||||
Left $ "digest: expected 32 bytes, got " <> show (B.length digest)
|
||||
| otherwise = unsafePerformIO $
|
||||
allocaBytes recSigInternalSize $ \sigPtr ->
|
||||
withBS digest $ \msgPtr ->
|
||||
withBS sk $ \skPtr -> do
|
||||
rc <- c_ecdsa_sign_recoverable secp256k1Ctx sigPtr msgPtr skPtr nullPtr nullPtr
|
||||
if rc /= 1
|
||||
then pure $ Left "secp256k1_ecdsa_sign_recoverable failed"
|
||||
else allocaBytes compactSize $ \outPtr ->
|
||||
alloca $ \recIdPtr -> do
|
||||
rc' <- c_recsig_serialize_compact secp256k1Ctx outPtr recIdPtr sigPtr
|
||||
if rc' /= 1
|
||||
then pure $ Left "secp256k1_ecdsa_recoverable_signature_serialize_compact failed"
|
||||
else do
|
||||
compact <- packPtr outPtr compactSize
|
||||
recId <- peek recIdPtr
|
||||
pure $ Right RecoverableSignature {rsCompact = compact, rsRecId = fromIntegral recId}
|
||||
|
||||
-- | Recover the signing public key from a signature and the digest it signed.
|
||||
recoverPublicKey :: RecoverableSignature -> ByteString -> Either String PublicKey
|
||||
recoverPublicKey sig digest
|
||||
| B.length digest /= digestSize =
|
||||
Left $ "digest: expected 32 bytes, got " <> show (B.length digest)
|
||||
| otherwise = unsafePerformIO $
|
||||
withRecSigRaw sig $ \sigPtr ->
|
||||
allocaBytes pubKeyInternalSize $ \pkPtr ->
|
||||
withBS digest $ \msgPtr -> do
|
||||
rc <- c_ecdsa_recover secp256k1Ctx pkPtr sigPtr msgPtr
|
||||
if rc == 1
|
||||
then Right . PublicKey <$> packPtr (castPtr pkPtr) pubKeyInternalSize
|
||||
else pure $ Left "secp256k1_ecdsa_recover failed"
|
||||
|
||||
-- | Whether @s <= n/2@, i.e. the signature is in the canonical form EIP-2
|
||||
-- requires. libsecp256k1 guarantees this for anything it signs; this exists so
|
||||
-- tests can assert it rather than assume it.
|
||||
isLowS :: RecoverableSignature -> Bool
|
||||
isLowS (RecoverableSignature compact _) =
|
||||
B.length compact == compactSize && beToInteger (B.drop 32 compact) <= halfOrder
|
||||
where
|
||||
halfOrder :: Integer
|
||||
halfOrder = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
|
||||
|
||||
beToInteger :: ByteString -> Integer
|
||||
beToInteger = B.foldl' (\acc w -> acc * 256 + fromIntegral w) 0
|
||||
@@ -0,0 +1,114 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | Ethereum addresses: derivation from a public key, and EIP-55 mixed-case
|
||||
-- checksum encoding.
|
||||
module Simplex.Messaging.Eth.Address
|
||||
( Address,
|
||||
unAddress,
|
||||
mkAddress,
|
||||
addressFromPublicKey,
|
||||
addressFromPrivateKey,
|
||||
checksumAddress,
|
||||
parseAddress,
|
||||
addressSize,
|
||||
ethereumPath,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Bits (shiftR, (.&.))
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import qualified Data.ByteString.Char8 as BC
|
||||
import Data.Char (isDigit, isHexDigit, isLower, isUpper, toLower)
|
||||
import Data.Word (Word32, Word8)
|
||||
import Simplex.Messaging.Crypto.BIP32 (hardened)
|
||||
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
|
||||
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.
|
||||
newtype Address = Address ByteString
|
||||
deriving (Eq, Ord)
|
||||
|
||||
instance Show Address where
|
||||
show = BC.unpack . checksumAddress
|
||||
|
||||
addressSize :: Int
|
||||
addressSize = 20
|
||||
|
||||
unAddress :: Address -> ByteString
|
||||
unAddress (Address bs) = bs
|
||||
|
||||
mkAddress :: ByteString -> Either String Address
|
||||
mkAddress bs
|
||||
| B.length bs /= addressSize = Left $ "address: expected 20 bytes, got " <> show (B.length bs)
|
||||
| otherwise = Right (Address bs)
|
||||
|
||||
-- | The low 20 bytes of @keccak256@ of the uncompressed public key with its
|
||||
-- @0x04@ SEC1 prefix removed.
|
||||
addressFromPublicKey :: S.PublicKey -> Address
|
||||
addressFromPublicKey pk =
|
||||
Address . B.drop 12 . keccak256 . B.drop 1 $ S.serializePublicKey S.Uncompressed pk
|
||||
|
||||
addressFromPrivateKey :: S.PrivateKey -> Address
|
||||
addressFromPrivateKey = addressFromPublicKey . S.publicKey
|
||||
|
||||
-- | EIP-55: @0x@ followed by 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 ..] lowerHex)
|
||||
where
|
||||
lowerHex = B.unpack (toHex bs)
|
||||
hashed = keccak256 (B.pack lowerHex)
|
||||
adjust :: Int -> Word8 -> Word8
|
||||
adjust i c
|
||||
| isHexLetter c && nibbleAt i >= 8 = upper c
|
||||
| otherwise = c
|
||||
nibbleAt i =
|
||||
let byte = B.index hashed (i `div` 2)
|
||||
in if even i then byte `shiftR` 4 else byte .&. 0x0F
|
||||
isHexLetter c = c >= 0x61 && c <= 0x66 -- 'a'..'f'
|
||||
upper c = c - 0x20
|
||||
|
||||
-- | Parse @0x@-prefixed or bare hex. A mixed-case address is checked against
|
||||
-- its EIP-55 checksum; an all-lowercase or all-uppercase one carries no
|
||||
-- checksum and is accepted as-is, which is what every Ethereum client does.
|
||||
parseAddress :: ByteString -> Either String Address
|
||||
parseAddress s
|
||||
| B.length body /= 40 = Left $ "address: expected 40 hex digits, got " <> show (B.length body)
|
||||
| not (BC.all isHexDigit body) = Left "address: not hexadecimal"
|
||||
| mixedCase && checksumAddress addr /= "0x" <> body = Left "address: EIP-55 checksum mismatch"
|
||||
| otherwise = Right addr
|
||||
where
|
||||
body = if "0x" `B.isPrefixOf` s || "0X" `B.isPrefixOf` s then B.drop 2 s else s
|
||||
bodyC = BC.unpack body
|
||||
letters = filter (not . isDigit) bodyC
|
||||
mixedCase = any isUpper letters && any isLower letters
|
||||
addr = Address (fromHex (BC.map toLower body))
|
||||
|
||||
-- | BIP-44 path for Ethereum account @i@: @m\/44'\/60'\/i'\/0\/0@.
|
||||
ethereumPath :: Word32 -> [Word32]
|
||||
ethereumPath account = [hardened 44, hardened 60, hardened account, 0, 0]
|
||||
|
||||
-- Hex helpers, local so that Address does not depend on a base16 package and
|
||||
-- the case handling stays explicit (EIP-55 is entirely about case).
|
||||
|
||||
toHex :: ByteString -> ByteString
|
||||
toHex = B.concatMap (\w -> B.pack [hexDigit (w `shiftR` 4), hexDigit (w .&. 0x0F)])
|
||||
where
|
||||
hexDigit n
|
||||
| n < 10 = 0x30 + n
|
||||
| otherwise = 0x57 + n -- 'a' - 10
|
||||
|
||||
-- | Assumes a validated even-length lowercase hex string.
|
||||
fromHex :: ByteString -> ByteString
|
||||
fromHex bs = B.pack $ go (B.unpack bs)
|
||||
where
|
||||
go (h : l : rest) = (nibble h * 16 + nibble l) : go rest
|
||||
go _ = []
|
||||
nibble w
|
||||
| w >= 0x30 && w <= 0x39 = w - 0x30
|
||||
| w >= 0x61 && w <= 0x66 = w - 0x57
|
||||
| w >= 0x41 && w <= 0x46 = w - 0x37
|
||||
| otherwise = 0
|
||||
@@ -0,0 +1,122 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | EIP-712 typed structured data hashing.
|
||||
--
|
||||
-- This implements the hashing half of EIP-712 — @typeHash@, @encodeData@,
|
||||
-- @hashStruct@ and the final @0x19 0x01@ digest — over an explicit list of
|
||||
-- member values. It deliberately does *not* derive the canonical type string
|
||||
-- from a schema: the caller supplies it. Our structs are a handful of fixed
|
||||
-- shapes agreed with the contracts, and a hand-written type string that is
|
||||
-- checked against Solidity in a test is both simpler and easier to audit than a
|
||||
-- schema encoder whose output nobody reads.
|
||||
--
|
||||
-- The type string must be the EIP-712 canonical encoding: no spaces after
|
||||
-- commas, member type and name separated by one space, and any referenced
|
||||
-- struct types appended in alphabetical order. For example:
|
||||
--
|
||||
-- > "TransferName(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline)"
|
||||
module Simplex.Messaging.Eth.EIP712
|
||||
( Eip712Domain (..),
|
||||
Value (..),
|
||||
typeHash,
|
||||
encodeValue,
|
||||
encodeData,
|
||||
hashStruct,
|
||||
domainSeparator,
|
||||
hashTypedData,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Bits (shiftR, (.&.))
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import Simplex.Messaging.Eth.Address (Address, unAddress)
|
||||
import Simplex.Messaging.Eth.Keccak (keccak256)
|
||||
|
||||
-- | The standard EIP-712 domain. All four fields are used; the spec allows
|
||||
-- omitting any of them, but every contract in this project includes all four,
|
||||
-- and fixing the shape keeps 'domainSeparator' total.
|
||||
data Eip712Domain = Eip712Domain
|
||||
{ edName :: ByteString,
|
||||
edVersion :: ByteString,
|
||||
edChainId :: Integer,
|
||||
edVerifyingContract :: Address
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | A struct member value, in the EIP-712 sense.
|
||||
--
|
||||
-- 'VStruct' takes an already-computed 'hashStruct' result, which is how nested
|
||||
-- structs are encoded; 'VArray' hashes the concatenation of its members.
|
||||
data Value
|
||||
= VUint Integer
|
||||
| VInt Integer
|
||||
| VBool Bool
|
||||
| VAddress Address
|
||||
| -- | @bytesN@ for @N@ in @[1, 32]@, left-aligned and zero-padded.
|
||||
VFixedBytes ByteString
|
||||
| -- | Dynamic @bytes@.
|
||||
VBytes ByteString
|
||||
| -- | @string@; the caller supplies UTF-8 bytes.
|
||||
VString ByteString
|
||||
| VArray [Value]
|
||||
| -- | A nested struct, given as its 32-byte @hashStruct@.
|
||||
VStruct ByteString
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | @keccak256@ of the canonical type string.
|
||||
typeHash :: ByteString -> ByteString
|
||||
typeHash = keccak256
|
||||
|
||||
-- | Encode one member as exactly 32 bytes.
|
||||
encodeValue :: Value -> Either String ByteString
|
||||
encodeValue = \case
|
||||
VUint n
|
||||
| n < 0 || n >= two256 -> Left $ "eip712: uint256 out of range: " <> show n
|
||||
| otherwise -> Right (word256 n)
|
||||
VInt n
|
||||
| n < negate two255 || n >= two255 -> Left $ "eip712: int256 out of range: " <> show n
|
||||
| otherwise -> Right (word256 (if n < 0 then n + two256 else n))
|
||||
VBool b -> Right (word256 (if b then 1 else 0))
|
||||
VAddress a -> Right (B.replicate 12 0 <> unAddress a)
|
||||
VFixedBytes bs
|
||||
| B.null bs || B.length bs > 32 -> Left $ "eip712: bytesN length " <> show (B.length bs)
|
||||
| otherwise -> Right (bs <> B.replicate (32 - B.length bs) 0)
|
||||
VBytes bs -> Right (keccak256 bs)
|
||||
VString bs -> Right (keccak256 bs)
|
||||
VArray vs -> keccak256 . B.concat <$> traverse encodeValue vs
|
||||
VStruct h
|
||||
| B.length h /= 32 -> Left $ "eip712: struct hash must be 32 bytes, got " <> show (B.length h)
|
||||
| otherwise -> Right h
|
||||
where
|
||||
two256 = 2 ^ (256 :: Int) :: Integer
|
||||
two255 = 2 ^ (255 :: Int) :: Integer
|
||||
|
||||
encodeData :: [Value] -> Either String ByteString
|
||||
encodeData vs = B.concat <$> traverse encodeValue vs
|
||||
|
||||
-- | @keccak256(typeHash ‖ encodeData(members))@.
|
||||
hashStruct :: ByteString -> [Value] -> Either String ByteString
|
||||
hashStruct typeString members = keccak256 . (typeHash typeString <>) <$> encodeData members
|
||||
|
||||
domainSeparator :: Eip712Domain -> Either String ByteString
|
||||
domainSeparator d =
|
||||
hashStruct
|
||||
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
|
||||
[ VString (edName d),
|
||||
VString (edVersion d),
|
||||
VUint (edChainId d),
|
||||
VAddress (edVerifyingContract d)
|
||||
]
|
||||
|
||||
-- | The final digest to sign: @keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct)@.
|
||||
hashTypedData :: Eip712Domain -> ByteString -> [Value] -> Either String ByteString
|
||||
hashTypedData d typeString members = do
|
||||
ds <- domainSeparator d
|
||||
hs <- hashStruct typeString members
|
||||
pure $ keccak256 (B.pack [0x19, 0x01] <> ds <> hs)
|
||||
|
||||
word256 :: Integer -> ByteString
|
||||
word256 x = B.pack [fromIntegral ((x `shiftR` (8 * (31 - i))) .&. 0xFF) | i <- [0 .. 31]]
|
||||
@@ -0,0 +1,23 @@
|
||||
-- | 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, so every Ethereum hash in this codebase goes
|
||||
-- through this module rather than reaching for @Crypto.Hash@ directly.
|
||||
module Simplex.Messaging.Eth.Keccak
|
||||
( keccak256,
|
||||
keccak256Size,
|
||||
)
|
||||
where
|
||||
|
||||
import Crypto.Hash (Digest, Keccak_256, hash)
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.ByteString (ByteString)
|
||||
|
||||
keccak256 :: ByteString -> ByteString
|
||||
keccak256 bs = BA.convert (hash bs :: Digest Keccak_256)
|
||||
|
||||
keccak256Size :: Int
|
||||
keccak256Size = 32
|
||||
@@ -0,0 +1,398 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
-- | 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.
|
||||
module CoreTests.EthCryptoTests (ethCryptoTests) where
|
||||
|
||||
import Control.Concurrent.STM (atomically)
|
||||
import Control.Monad (forM_)
|
||||
import qualified Data.ByteArray.Encoding as BAE
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import qualified Data.ByteString.Char8 as BC
|
||||
import Data.Either (isLeft)
|
||||
import Data.Word (Word32)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import qualified Simplex.Messaging.Crypto.BIP32 as B32
|
||||
import qualified Simplex.Messaging.Crypto.BIP39 as B39
|
||||
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
|
||||
import Simplex.Messaging.Eth.Address
|
||||
import Simplex.Messaging.Eth.EIP712
|
||||
import Simplex.Messaging.Eth.Keccak (keccak256)
|
||||
import Test.Hspec hiding (fit, it)
|
||||
import Util
|
||||
|
||||
ethCryptoTests :: Spec
|
||||
ethCryptoTests = do
|
||||
describe "Keccak-256" keccakTests
|
||||
describe "secp256k1" secp256k1Tests
|
||||
describe "BIP-39" bip39Tests
|
||||
describe "BIP-32" bip32Tests
|
||||
describe "BIP-44 derivation" derivationTests
|
||||
describe "EIP-55 addresses" eip55Tests
|
||||
describe "EIP-712 typed data" eip712Tests
|
||||
|
||||
-- helpers
|
||||
|
||||
hx :: ByteString -> ByteString
|
||||
hx s = either (const $ error $ "bad hex literal: " <> BC.unpack s) id $ BAE.convertFromBase BAE.Base16 s
|
||||
|
||||
toHex :: ByteString -> ByteString
|
||||
toHex = BAE.convertToBase BAE.Base16
|
||||
|
||||
right :: Either String a -> a
|
||||
right = either (error . ("unexpected Left: " <>)) id
|
||||
|
||||
hardened' :: Word32 -> Word32
|
||||
hardened' = B32.hardened
|
||||
|
||||
keccakTests :: Spec
|
||||
keccakTests = do
|
||||
it "hashes the empty string" $
|
||||
toHex (keccak256 "") `shouldBe` "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
|
||||
it "hashes abc" $
|
||||
toHex (keccak256 "abc") `shouldBe` "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"
|
||||
it "is Keccak-256, not SHA3-256" $
|
||||
-- SHA3-256 of the empty string, which differs only in the padding byte
|
||||
toHex (keccak256 "") `shouldNotBe` "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"
|
||||
|
||||
secp256k1Tests :: Spec
|
||||
secp256k1Tests = do
|
||||
it "derives the known address for a known key" $
|
||||
show (addressFromPrivateKey testKey) `shouldBe` "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23"
|
||||
it "signs deterministically (RFC 6979)" $
|
||||
right (S.signRecoverable testKey testDigest) `shouldBe` testSig
|
||||
it "produces low-s signatures (EIP-2)" $
|
||||
S.isLowS testSig `shouldBe` True
|
||||
it "recovers the signing key" $
|
||||
right (S.recoverPublicKey testSig testDigest) `shouldBe` S.publicKey testKey
|
||||
it "does not recover the signing key from another digest" $
|
||||
S.recoverPublicKey testSig (keccak256 "SimpleX names ") `shouldNotBe` Right (S.publicKey testKey)
|
||||
it "round-trips a compressed public key" $ do
|
||||
let pk = S.publicKey testKey
|
||||
ser = S.serializePublicKey S.Compressed pk
|
||||
B.length ser `shouldBe` 33
|
||||
S.parsePublicKey ser `shouldBe` Right pk
|
||||
it "round-trips an uncompressed public key" $ do
|
||||
let pk = S.publicKey testKey
|
||||
ser = S.serializePublicKey S.Uncompressed pk
|
||||
B.length ser `shouldBe` 65
|
||||
S.parsePublicKey ser `shouldBe` Right pk
|
||||
it "rejects a zero private key" $
|
||||
S.mkPrivateKey (B.replicate 32 0) `shouldSatisfy` isLeft
|
||||
it "rejects a private key at the group order" $
|
||||
S.mkPrivateKey (hx "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") `shouldSatisfy` isLeft
|
||||
it "rejects a short private key" $
|
||||
S.mkPrivateKey (B.replicate 31 1) `shouldSatisfy` isLeft
|
||||
it "rejects a digest that is not 32 bytes" $
|
||||
S.signRecoverable testKey (B.replicate 31 0) `shouldSatisfy` isLeft
|
||||
it "rejects a malformed public key" $
|
||||
S.parsePublicKey (B.replicate 33 0) `shouldSatisfy` isLeft
|
||||
it "redacts the private key in Show" $
|
||||
show testKey `shouldBe` "PrivateKey <redacted>"
|
||||
it "adds a tweak to a private key" $
|
||||
(toHex . S.unPrivateKey <$> S.privateKeyTweakAdd testKey (B.replicate 31 0 <> B.singleton 1))
|
||||
`shouldBe` Just "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362319"
|
||||
where
|
||||
testKey = right $ S.mkPrivateKey (hx "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318")
|
||||
testDigest = keccak256 "SimpleX names"
|
||||
testSig = right $ S.signRecoverable testKey testDigest
|
||||
|
||||
bip39Tests :: Spec
|
||||
bip39Tests = do
|
||||
describe "official English vectors" $
|
||||
forM_ (zip [0 :: Int ..] bip39Vectors) $ \(i, (entHex, phrase, seedHex)) ->
|
||||
it ("vector " <> show i) $ do
|
||||
let m = right $ B39.entropyToMnemonic (hx entHex)
|
||||
p = right $ B39.parseMnemonic phrase
|
||||
B39.mnemonicPhrase m `shouldBe` phrase
|
||||
toHex (B39.mnemonicToEntropy p) `shouldBe` entHex
|
||||
toHex (B39.mnemonicToSeed p "TREZOR") `shouldBe` seedHex
|
||||
it "has a 2048-word list" $
|
||||
B39.wordListSize `shouldBe` 2048
|
||||
it "maps strengths to word counts" $
|
||||
map B39.strengthWordCount [minBound .. maxBound] `shouldBe` [12, 15, 18, 21, 24]
|
||||
it "rejects a bad checksum" $
|
||||
B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
|
||||
`shouldSatisfy` isLeft
|
||||
it "rejects a word outside the list" $
|
||||
B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon simplex"
|
||||
`shouldSatisfy` isLeft
|
||||
it "rejects a wrong word count" $
|
||||
B39.parseMnemonic "abandon abandon about" `shouldSatisfy` isLeft
|
||||
it "accepts a capitalised phrase and normalises it" $
|
||||
(B39.mnemonicPhrase <$> B39.parseMnemonic "Abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon abandon About")
|
||||
`shouldBe` Right canonicalPhrase
|
||||
it "accepts extra whitespace" $
|
||||
B39.parseMnemonic " abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about "
|
||||
`shouldBe` B39.parseMnemonic canonicalPhrase
|
||||
it "rejects an invalid entropy size" $
|
||||
B39.entropyToMnemonic (B.replicate 17 0) `shouldSatisfy` isLeft
|
||||
it "generates mnemonics that parse back" $ do
|
||||
g <- C.newRandom
|
||||
forM_ [minBound .. maxBound] $ \s -> do
|
||||
m <- atomically $ B39.randomMnemonic s g
|
||||
length (B39.mnemonicWords m) `shouldBe` B39.strengthWordCount s
|
||||
B39.parseMnemonic (B39.mnemonicPhrase m) `shouldBe` Right m
|
||||
it "redacts the mnemonic in Show" $ do
|
||||
g <- C.newRandom
|
||||
m <- atomically $ B39.randomMnemonic B39.MS128 g
|
||||
show m `shouldBe` "Mnemonic <12 words, redacted>"
|
||||
|
||||
canonicalPhrase :: ByteString
|
||||
canonicalPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
bip32Tests :: Spec
|
||||
bip32Tests = do
|
||||
describe "spec test vector 1" $
|
||||
forM_ vector1 $ \(name, path, keyHex, ccHex) ->
|
||||
it name $ do
|
||||
let xk = right $ B32.derivePath master1 path
|
||||
toHex (S.unPrivateKey $ B32.xkKey xk) `shouldBe` keyHex
|
||||
toHex (B32.xkChainCode xk) `shouldBe` ccHex
|
||||
it "spec test vector 2, chain m" $ do
|
||||
toHex (S.unPrivateKey $ B32.xkKey master2) `shouldBe` "4b03d6fc340455b363f51020ad3ecca4f0850280cf436c70c727923f6db46c3e"
|
||||
toHex (B32.xkChainCode master2) `shouldBe` "60499f801b896d83179a4374aeb7822aaeaceaa0db1f85ee3e904c4defbd9689"
|
||||
it "rejects a seed shorter than 16 bytes" $
|
||||
B32.masterKey (B.replicate 15 1) `shouldSatisfy` isLeft
|
||||
it "rejects a seed longer than 64 bytes" $
|
||||
B32.masterKey (B.replicate 65 1) `shouldSatisfy` isLeft
|
||||
it "redacts the extended key in Show" $
|
||||
show master2 `shouldBe` "ExtendedKey <redacted>"
|
||||
describe "path parsing" $ do
|
||||
it "parses a BIP-44 path" $
|
||||
B32.parsePath "m/44'/60'/0'/0/0" `shouldBe` Right [hardened' 44, hardened' 60, hardened' 0, 0, 0]
|
||||
it "accepts h as the hardened marker" $
|
||||
B32.parsePath "m/44h/60h/2h/0/1" `shouldBe` Right [hardened' 44, hardened' 60, hardened' 2, 0, 1]
|
||||
it "accepts a path without the leading m" $
|
||||
B32.parsePath "44'/60'" `shouldBe` Right [hardened' 44, hardened' 60]
|
||||
it "renders a path" $
|
||||
B32.renderPath [hardened' 44, hardened' 60, hardened' 0, 0, 0] `shouldBe` "m/44'/60'/0'/0/0"
|
||||
it "round-trips render and parse" $
|
||||
B32.parsePath (B32.renderPath (ethereumPath 7)) `shouldBe` Right (ethereumPath 7)
|
||||
it "rejects a non-numeric component" $
|
||||
B32.parsePath "m/44x/60" `shouldSatisfy` isLeft
|
||||
it "rejects an index at the hardened boundary" $
|
||||
B32.parsePath "m/2147483648" `shouldSatisfy` isLeft
|
||||
where
|
||||
master1 = right $ B32.masterKey (hx "000102030405060708090a0b0c0d0e0f")
|
||||
master2 =
|
||||
right . B32.masterKey $
|
||||
hx "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"
|
||||
vector1 =
|
||||
[ ( "chain m",
|
||||
[],
|
||||
"e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35",
|
||||
"873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508"
|
||||
),
|
||||
( "chain m/0'",
|
||||
[hardened' 0],
|
||||
"edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea",
|
||||
"47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141"
|
||||
),
|
||||
( "chain m/0'/1",
|
||||
[hardened' 0, 1],
|
||||
"3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368",
|
||||
"2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19"
|
||||
),
|
||||
( "chain m/0'/1/2'",
|
||||
[hardened' 0, 1, hardened' 2],
|
||||
"cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca",
|
||||
"04466b9cc8e161e966409ca52986c584f07e9dc81f735db683c3ff6ec7b1503f"
|
||||
),
|
||||
( "chain m/0'/1/2'/2",
|
||||
[hardened' 0, 1, hardened' 2, 2],
|
||||
"0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4",
|
||||
"cfb71883f01676f587d023cc53a35bc7f88f724b1f8c2892ac1275ac822a3edd"
|
||||
),
|
||||
( "chain m/0'/1/2'/2/1000000000",
|
||||
[hardened' 0, 1, hardened' 2, 2, 1000000000],
|
||||
"471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8",
|
||||
"c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e"
|
||||
)
|
||||
]
|
||||
|
||||
derivationTests :: Spec
|
||||
derivationTests = do
|
||||
it "derives the standard BIP-39 seed" $
|
||||
toHex seed
|
||||
`shouldBe` "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
|
||||
it "derives the well-known account 0 address" $
|
||||
show (addrAt 0) `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
|
||||
it "derives account 1" $
|
||||
show (addrAt 1) `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
|
||||
it "derives account 2" $
|
||||
show (addrAt 2) `shouldBe` "0x07B5FdfEB4E11826D233403Fe8Db0611CCF4c231"
|
||||
it "gives each chat profile a distinct address" $
|
||||
map addrAt [0 .. 4] `shouldSatisfy` \as -> length as == length (foldr dedup [] as)
|
||||
where
|
||||
m = right $ B39.parseMnemonic canonicalPhrase
|
||||
seed = B39.mnemonicToSeed m ""
|
||||
master = right $ B32.masterKey seed
|
||||
addrAt i = addressFromPrivateKey . B32.xkKey . right $ B32.derivePath master (ethereumPath i)
|
||||
dedup a as = if a `elem` as then as else a : as
|
||||
|
||||
eip55Tests :: Spec
|
||||
eip55Tests = do
|
||||
describe "spec vectors round-trip" $
|
||||
forM_ specAddresses $ \a ->
|
||||
it (BC.unpack a) $
|
||||
BC.pack (show . right $ parseAddress a) `shouldBe` a
|
||||
it "accepts an all-lowercase address" $
|
||||
parseAddress "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight'
|
||||
it "accepts an all-uppercase address" $
|
||||
parseAddress "0x5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED" `shouldSatisfy` isRight'
|
||||
it "accepts an address without the 0x prefix" $
|
||||
parseAddress "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" `shouldSatisfy` isRight'
|
||||
it "rejects a bad EIP-55 checksum" $
|
||||
parseAddress "0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed" `shouldSatisfy` isLeft
|
||||
it "rejects the wrong length" $
|
||||
parseAddress "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAe" `shouldSatisfy` isLeft
|
||||
it "rejects non-hex characters" $
|
||||
parseAddress "0xZaAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" `shouldSatisfy` isLeft
|
||||
it "rejects raw bytes of the wrong length" $
|
||||
mkAddress (B.replicate 19 0) `shouldSatisfy` isLeft
|
||||
where
|
||||
isRight' = either (const False) (const True)
|
||||
specAddresses =
|
||||
[ "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
|
||||
"0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",
|
||||
"0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB",
|
||||
"0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
|
||||
]
|
||||
|
||||
eip712Tests :: Spec
|
||||
eip712Tests = do
|
||||
it "computes the spec domain separator" $
|
||||
toHex (right $ domainSeparator domain) `shouldBe` "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"
|
||||
it "computes hashStruct for the Mail example" $
|
||||
toHex mailHash `shouldBe` "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"
|
||||
it "computes the final signing digest" $
|
||||
toHex (right $ hashTypedData domain mailType mailMembers)
|
||||
`shouldBe` "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"
|
||||
it "encodes a bool" $
|
||||
toHex (right $ encodeValue (VBool True)) `shouldBe` "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
it "encodes a negative int as two's complement" $
|
||||
toHex (right $ encodeValue (VInt (-1))) `shouldBe` "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
it "left-aligns bytesN" $
|
||||
toHex (right $ encodeValue (VFixedBytes "\x01\x02")) `shouldBe` "0102000000000000000000000000000000000000000000000000000000000000"
|
||||
it "right-aligns an address" $
|
||||
toHex (right . encodeValue . VAddress . right $ parseAddress "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC")
|
||||
`shouldBe` "000000000000000000000000cccccccccccccccccccccccccccccccccccccccc"
|
||||
it "hashes an array to a single word" $
|
||||
B.length (right $ encodeValue (VArray [VUint 1, VUint 2])) `shouldBe` 32
|
||||
it "rejects a uint above 2^256" $
|
||||
encodeValue (VUint (2 ^ (256 :: Int))) `shouldSatisfy` isLeft
|
||||
it "rejects a negative uint" $
|
||||
encodeValue (VUint (-1)) `shouldSatisfy` isLeft
|
||||
it "rejects an int outside int256" $
|
||||
encodeValue (VInt (2 ^ (255 :: Int))) `shouldSatisfy` isLeft
|
||||
it "rejects bytesN longer than 32" $
|
||||
encodeValue (VFixedBytes (B.replicate 33 0)) `shouldSatisfy` isLeft
|
||||
it "rejects empty bytesN" $
|
||||
encodeValue (VFixedBytes "") `shouldSatisfy` isLeft
|
||||
it "rejects a struct hash that is not 32 bytes" $
|
||||
encodeValue (VStruct "short") `shouldSatisfy` isLeft
|
||||
where
|
||||
domain =
|
||||
Eip712Domain
|
||||
{ edName = "Ether Mail",
|
||||
edVersion = "1",
|
||||
edChainId = 1,
|
||||
edVerifyingContract = right $ parseAddress "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
|
||||
}
|
||||
personType = "Person(string name,address wallet)"
|
||||
mailType = "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
|
||||
person n w = right $ hashStruct personType [VString n, VAddress (right $ parseAddress w)]
|
||||
mailMembers =
|
||||
[ VStruct $ person "Cow" "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
|
||||
VStruct $ person "Bob" "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB",
|
||||
VString "Hello, Bob!"
|
||||
]
|
||||
mailHash = right $ hashStruct mailType mailMembers
|
||||
|
||||
-- | Official BIP-39 English test vectors from
|
||||
-- <https://github.com/trezor/python-mnemonic/blob/master/vectors.json>,
|
||||
-- all generated with the passphrase @TREZOR@: (entropy, mnemonic, seed).
|
||||
bip39Vectors :: [(ByteString, ByteString, ByteString)]
|
||||
bip39Vectors =
|
||||
[ ( "00000000000000000000000000000000",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" )
|
||||
, ( "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow",
|
||||
"2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607" )
|
||||
, ( "80808080808080808080808080808080",
|
||||
"letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
|
||||
"d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8" )
|
||||
, ( "ffffffffffffffffffffffffffffffff",
|
||||
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
||||
"ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069" )
|
||||
, ( "000000000000000000000000000000000000000000000000",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent",
|
||||
"035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa" )
|
||||
, ( "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
|
||||
"f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd" )
|
||||
, ( "808080808080808080808080808080808080808080808080",
|
||||
"letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always",
|
||||
"107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65" )
|
||||
, ( "ffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when",
|
||||
"0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528" )
|
||||
, ( "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
|
||||
"bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8" )
|
||||
, ( "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title",
|
||||
"bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87" )
|
||||
, ( "8080808080808080808080808080808080808080808080808080808080808080",
|
||||
"letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless",
|
||||
"c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f" )
|
||||
, ( "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
|
||||
"dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad" )
|
||||
, ( "9e885d952ad362caeb4efe34a8e91bd2",
|
||||
"ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic",
|
||||
"274ddc525802f7c828d8ef7ddbcdc5304e87ac3535913611fbbfa986d0c9e5476c91689f9c8a54fd55bd38606aa6a8595ad213d4c9c9f9aca3fb217069a41028" )
|
||||
, ( "6610b25967cdcca9d59875f5cb50b0ea75433311869e930b",
|
||||
"gravity machine north sort system female filter attitude volume fold club stay feature office ecology stable narrow fog",
|
||||
"628c3827a8823298ee685db84f55caa34b5cc195a778e52d45f59bcf75aba68e4d7590e101dc414bc1bbd5737666fbbef35d1f1903953b66624f910feef245ac" )
|
||||
, ( "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c",
|
||||
"hamster diagram private dutch cause delay private meat slide toddler razor book happy fancy gospel tennis maple dilemma loan word shrug inflict delay length",
|
||||
"64c87cde7e12ecf6704ab95bb1408bef047c22db4cc7491c4271d170a1b213d20b385bc1588d9c7b38f1b39d415665b8a9030c9ec653d75e65f847d8fc1fc440" )
|
||||
, ( "c0ba5a8e914111210f2bd131f3d5e08d",
|
||||
"scheme spot photo card baby mountain device kick cradle pact join borrow",
|
||||
"ea725895aaae8d4c1cf682c1bfd2d358d52ed9f0f0591131b559e2724bb234fca05aa9c02c57407e04ee9dc3b454aa63fbff483a8b11de949624b9f1831a9612" )
|
||||
, ( "6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3",
|
||||
"horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap uncle crack brave",
|
||||
"fd579828af3da1d32544ce4db5c73d53fc8acc4ddb1e3b251a31179cdb71e853c56d2fcb11aed39898ce6c34b10b5382772db8796e52837b54468aeb312cfc3d" )
|
||||
, ( "9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863",
|
||||
"panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol speed nation flash devote level hobby quick inner drive ghost inside",
|
||||
"72be8e052fc4919d2adf28d5306b5474b0069df35b02303de8c1729c9538dbb6fc2d731d5f832193cd9fb6aeecbc469594a70e3dd50811b5067f3b88b28c3e8d" )
|
||||
, ( "23db8160a31d3e0dca3688ed941adbf3",
|
||||
"cat swing flag economy stadium alone churn speed unique patch report train",
|
||||
"deb5f45449e615feff5640f2e49f933ff51895de3b4381832b3139941c57b59205a42480c52175b6efcffaa58a2503887c1e8b363a707256bdd2b587b46541f5" )
|
||||
, ( "8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0",
|
||||
"light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart sustain crack supply proud access",
|
||||
"4cbdff1ca2db800fd61cae72a57475fdc6bab03e441fd63f96dabd1f183ef5b782925f00105f318309a7e9c3ea6967c7801e46c8a58082674c860a37b93eda02" )
|
||||
, ( "066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad",
|
||||
"all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform",
|
||||
"26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d" )
|
||||
, ( "f30f8c1da665478f49b001d94c5fc452",
|
||||
"vessel ladder alter error federal sibling chat ability sun glass valve picture",
|
||||
"2aaa9242daafcee6aa9d7269f17d4efe271e1b9a529178d7dc139cd18747090bf9d60295d0ce74309a78852a9caadf0af48aae1c6253839624076224374bc63f" )
|
||||
, ( "c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05",
|
||||
"scissors invite lock maple supreme raw rapid void congress muscle digital elegant little brisk hair mango congress clump",
|
||||
"7b4a10be9d98e6cba265566db7f136718e1398c71cb581e1b2f464cac1ceedf4f3e274dc270003c670ad8d02c4558b2f8e39edea2775c9e232c7cb798b069e88" )
|
||||
, ( "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f",
|
||||
"void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
|
||||
"01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998" )
|
||||
]
|
||||
@@ -11,6 +11,7 @@ import CoreTests.BatchingTests
|
||||
import CoreTests.CryptoFileTests
|
||||
import CoreTests.CryptoTests
|
||||
import CoreTests.EncodingTests
|
||||
import CoreTests.EthCryptoTests
|
||||
import CoreTests.MsgStoreTests
|
||||
import CoreTests.RetryIntervalTests
|
||||
import CoreTests.SOCKSSettings
|
||||
@@ -87,6 +88,7 @@ main = do
|
||||
describe "Encoding tests" encodingTests
|
||||
describe "Version range" versionRangeTests
|
||||
describe "Encryption tests" cryptoTests
|
||||
describe "Ethereum crypto tests" ethCryptoTests
|
||||
describe "Encrypted files tests" cryptoFileTests
|
||||
describe "Message store tests" msgStoreTests
|
||||
describe "Retry interval tests" retryIntervalTests
|
||||
|
||||
Reference in New Issue
Block a user