secp256k1-haskell-2.0.0: test/Crypto/Secp256k1/RecoverySpec.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | ECDSA public key recovery. The module under test only exists when the
-- @recovery@ flag of the package is on, so the whole specification is behind
-- the same flag. This module avoids string gaps, because CPP would join
-- their lines.
module Crypto.Secp256k1.RecoverySpec (spec) where
import Test.Hspec
#ifdef RECOVERY
import Control.DeepSeq (deepseq)
import Control.Exception (evaluate)
import Control.Monad (forM_)
import Control.Monad.Par qualified as P
import Crypto.Secp256k1
import Crypto.Secp256k1.Fixtures
import Crypto.Secp256k1.Internal.Base (CompactSig (CompactSig), Msg (Msg), SecKey (SecKey))
import Crypto.Secp256k1.Internal.Recovery (RecSig (RecSig))
import Crypto.Secp256k1.Recovery
import Data.Bits (xor)
import Data.ByteString qualified as BS
import Data.Hashable (hash)
import Data.Maybe (fromJust, isJust)
import Data.String (fromString)
import Data.Word (Word8)
import Test.QuickCheck
import Text.Read (readMaybe)
spec :: Spec
spec = around withContext $ describe "public key recovery" $ do
signingSpec
recoverySpec
formulaSpec
compactSpec
valueSpec
rawValueSpec
instanceSpec
-- * Fixtures
--
-- As in "Crypto.Secp256k1.Fixtures", every expected value is a literal from
-- an independent source, recomputed with an independent implementation of
-- the curve.
-- Bitcoin Core key_tests.cpp: the SignCompact signature of coreKey over
-- coreMsg is 20 ++ coreR ++ coreS. The header byte 0x20 = 32 = 27 + 1 + 4
-- says recovery id 1 and a compressed key. coreDer is 30440220 ++ coreR ++
-- 0220 ++ coreS.
coreR, coreS :: String
coreR = "5dbbddda71772d95ce91cd2d14b592cfbc1dd0aabd6a394b6c2d377bbe59d31d"
coreS = "14ddda21494a4e221f0824f0b8b924c43fa43c0ad57dccdaa11f81a6bd4582f6"
coreRecId :: Word8
coreRecId = 1
-- The compressed public key of coreKey, and the key that recovery id 0 gives
-- for the same signature.
corePub, coreOtherPub :: String
corePub = "030b4c866585dd868a9d62348a9cd008d6a312937048fff31670e7e920cfc7a744"
coreOtherPub = "02818f8ef9ebd82871789a39ea0385398c267d3d6bb6f537166bd813135870c8ec"
-- Bitcoin Core util_tests.cpp, message_sign: the key of the address
-- 15CRxFdyRpGZLW9w8HnHvVduizdL5jKNbs signs the message "Trust no one".
messageKey :: SecKey
messageKey = "d97f5108f11cda6eeebaaa420fef0726b1f898060b98489fa3098463c0032866"
-- sha256d of the varstrings "Bitcoin Signed Message:\n" and "Trust no one".
messageHash :: Msg
messageHash = "aa8215d723ecd2f14867eeb7e19f192be7bc15a2352a24b991d4f5870cbaf6e8"
-- The expected signature, base64
-- IPojfrX2dfPnH26UegfbGQQLrdK844DlHq5157/P6h57WyuS/Qsl+h/WSVGDF4MUi4rWSswW38oimDYfNNUBUOk=
-- decoded: the header byte 0x20, recovery id 1 and a compressed key, then r
-- and s.
messageR, messageS :: String
messageR = "fa237eb5f675f3e71f6e947a07db19040badd2bce380e51eae75e7bfcfea1e7b"
messageS = "5b2b92fd0b25fa1fd64951831783148b8ad64acc16dfca2298361f34d50150e9"
-- The compressed public key of messageKey.
messagePub :: String
messagePub = "03d2be3dddb497c7c4388cfcb941e14a222cf3e26fe5e2cc260da1821e60d26022"
-- The RFC 6979 vector has recovery id 1. Recovery id 0 gives this other key.
satoshiOtherPub :: String
satoshiOtherPub = "0354d58835e07b996d6378eafa930ae9d2211e767381f1d52aedc67f48c8886f0b"
-- Key 1 signing sha256 "4" (highRDer): r, s and recovery id 0.
highRR, highRS :: String
highRR = "d479c93a086bf5f4ae1fed7eaf2604a4da2007d851c5409961c81dd7f0600194"
highRS = "1cd7a196454152f5b8d19865d49b4fa71e314ebff9c59e2f7b2295bf80331be8"
-- Values for the formula Q = r⁻¹(s·R − e·G).
two, five :: String
two = "0000000000000000000000000000000000000000000000000000000000000002"
-- 5³ + 7 = 132 is not a square modulo p, so no point has x = 5.
five = "0000000000000000000000000000000000000000000000000000000000000005"
-- x = 1 is on the curve: 1 + 7 = 8 is a square modulo p. pointOne is the
-- point with x = 1 and an even y, so its compressed encoding is 02 ++ one and
-- that of its negation is 03 ++ one.
pointOne, pointOneMinusG, twicePointOne :: String
pointOne = "02" ++ one
pointOneMinusG = "02871e0c836ec675e07395aef58d72b646f77e0e33ce1622dfaa0fb8e131f3fe12"
twicePointOne = "03c7ffffffffffffffffffffffffffffffffffffffffffffffffffffff37fffd03"
-- x = n + 2 is on the curve, x = n + 1 is not.
orderPlusTwo :: String
orderPlusTwo = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364143"
-- Half of the point with x = n + 2 and an even y.
halfPointOrderPlusTwo :: String
halfPointOrderPlusTwo = "02ad802f2145df548ce1fc418b851cb32669c5a6bb939e6caf744de90310f8f1ff"
-- p − n and p − n − 1. With the recovery id 2, the first gives x = p, which
-- is outside the field, and the second x = p − 1, which is not on the curve.
fieldMinusOrder, fieldMinusOrderMinusOne :: String
fieldMinusOrder = "000000000000000000000000000000014551231950b75fc4402da1722fc9baee"
fieldMinusOrderMinusOne = "000000000000000000000000000000014551231950b75fc4402da1722fc9baed"
-- The x coordinate of G, and the key −(x⁻¹)·G.
genX, genNegatedOverX :: String
genX = drop 2 genCompressed
genNegatedOverX = "028ff83d8cf12121491609c4939dc11c4aa35503508fe432dc5a5c1905608b9218"
-- * Signing
signingSpec :: SpecWith Ctx
signingSpec = describe "signing" $ do
it "produces Bitcoin Core's SignCompact signature of sha256d 'Very deterministic message'" $ \ctx ->
exportCompactRecSig ctx (signRecMsg ctx coreKey coreMsg)
`shouldBe` (fromString (coreR ++ coreS), coreRecId)
it "produces Bitcoin Core's signmessage signature of 'Trust no one'" $ \ctx ->
exportCompactRecSig ctx (signRecMsg ctx messageKey messageHash)
`shouldBe` (fromString (messageR ++ messageS), 1)
it "produces the RFC 6979 vector with recovery id 1" $ \ctx ->
exportCompactRecSig ctx (signRecMsg ctx keyOne satoshiMsg)
`shouldBe` (fromString (satoshiR ++ satoshiS), 1)
it "keeps a high r, as Bitcoin Core's message signing does" $ \ctx -> do
exportCompactRecSig ctx (signRecMsg ctx keyOne highRMsg)
`shouldBe` (fromString (highRR ++ highRS), 0)
convertRecSig ctx (signRecMsg ctx keyOne highRMsg) `shouldNotBe` signMsgLowR ctx keyOne highRMsg
it "signs with the r and s of signMsg" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) -> convertRecSig ctx (signRecMsg ctx k m) == signMsg ctx k m
it "is deterministic" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) -> signRecMsg ctx k m == signRecMsg ctx k m
it "gives a recovery id of 0 or 1" $ \ctx ->
-- The ids 2 and 3 need r below p - n, about one signature in 2^128.
property $ \(m :: Msg, k :: SecKey) ->
snd (exportCompactRecSig ctx (signRecMsg ctx k m)) `elem` [0, 1]
it "produces lower-S signatures" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) -> isLowS ctx (convertRecSig ctx (signRecMsg ctx k m))
it "signs and recovers in parallel" $ \ctx ->
property $ \(xs :: [(Msg, SecKey)]) -> P.runPar $ do
results <- mapM (P.spawnP . signsAndRecovers ctx) xs
and <$> mapM P.get results
where
signsAndRecovers ctx (m, k) =
recover ctx (signRecMsg ctx k m) m == Just (derivePubKey ctx k)
-- * Recovery
recoverySpec :: SpecWith Ctx
recoverySpec = describe "recovery" $ do
it "recovers the key of Bitcoin Core's SignCompact vector" $ \ctx ->
recovered ctx (coreR ++ coreS ++ "01") coreMsg `shouldBe` Just corePub
it "recovers another key with the other recovery id" $ \ctx ->
recovered ctx (coreR ++ coreS ++ "00") coreMsg `shouldBe` Just coreOtherPub
it "recovers the signer of Bitcoin Core's 'Trust no one' message" $ \ctx ->
recovered ctx (messageR ++ messageS ++ "01") messageHash `shouldBe` Just messagePub
it "recovers G from the RFC 6979 vector" $ \ctx -> do
recovered ctx (satoshiR ++ satoshiS ++ "01") satoshiMsg `shouldBe` Just genCompressed
recovered ctx (satoshiR ++ satoshiS ++ "00") satoshiMsg `shouldBe` Just satoshiOtherPub
it "recovers the signing key" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) ->
recover ctx (signRecMsg ctx k m) m == Just (derivePubKey ctx k)
it "recovers a different key for another message" $ \ctx ->
property $ \(m :: Msg, m' :: Msg, k :: SecKey) ->
m /= m' ==> recover ctx (signRecMsg ctx k m) m' /= Just (derivePubKey ctx k)
it "recovers the same key from the high-S form with the parity bit flipped" $ \ctx -> do
-- The RFC 6979 vector with s replaced by n - s and the id 1 turned into 0.
recovered ctx (satoshiR ++ satoshiHighS ++ "00") satoshiMsg `shouldBe` Just genCompressed
recovered ctx (satoshiR ++ satoshiHighS ++ "01") satoshiMsg `shouldNotBe` Just genCompressed
it "does not apply the lower-S rule" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) ->
let (compact, i) = exportCompactRecSig ctx (signRecMsg ctx k m)
(r, s) = BS.splitAt 32 compact.get
-- n - s, through the negation of a tweak.
highS = (fromJust (tweakNegate ctx (fromJust (tweak s)))).get
high = fromJust (importCompactRecSig ctx (fromJust (compactSig (r <> highS))) (i `xor` 1))
in recover ctx high m == Just (derivePubKey ctx k)
where
recovered ctx sigHex m = bytesToHex . exportPubKey ctx True <$> recover ctx (fromString sigHex) m
-- * The formula
formulaSpec :: SpecWith Ctx
formulaSpec = describe "the formula Q = r⁻¹(s·R − e·G)" $ do
it "gives R itself for r = 1, s = 1 and a zero hash" $ \ctx ->
recovered ctx (one ++ one ++ "00") zero `shouldBe` Just pointOne
it "takes the parity of y from the recovery id" $ \ctx ->
recovered ctx (one ++ one ++ "01") zero `shouldBe` Just ("03" ++ one)
it "reduces the hash modulo n" $ \ctx ->
recovered ctx (one ++ one ++ "00") order `shouldBe` Just pointOne
it "subtracts e·G" $ \ctx ->
recovered ctx (one ++ one ++ "00") one `shouldBe` Just pointOneMinusG
it "multiplies by s" $ \ctx -> do
recovered ctx (one ++ two ++ "00") zero `shouldBe` Just twicePointOne
recovered ctx (one ++ orderMinusOne ++ "00") zero `shouldBe` Just ("03" ++ one)
it "divides by r" $ \ctx ->
recovered ctx (two ++ one ++ "02") zero `shouldBe` Just halfPointOrderPlusTwo
it "uses r + n as the x coordinate for the ids 2 and 3" $ \ctx -> do
recovered ctx (two ++ two ++ "02") zero `shouldBe` Just ("02" ++ orderPlusTwo)
recovered ctx (two ++ two ++ "03") zero `shouldBe` Just ("03" ++ orderPlusTwo)
it "gives nothing when no point has the x coordinate" $ \ctx -> do
recovered ctx (five ++ one ++ "00") zero `shouldBe` Nothing
recovered ctx (one ++ one ++ "02") zero `shouldBe` Nothing
recovered ctx (fieldMinusOrderMinusOne ++ one ++ "02") zero `shouldBe` Nothing
it "gives nothing when r + n reaches the field size" $ \ctx ->
recovered ctx (fieldMinusOrder ++ one ++ "02") zero `shouldBe` Nothing
it "gives nothing when s·R − e·G is the point at infinity" $ \ctx -> do
-- R = G, s = 1 and e = 1: G - G.
recovered ctx (genX ++ one ++ "00") one `shouldBe` Nothing
-- With e = 2 instead: (G - 2G) / x = -(x⁻¹)·G.
recovered ctx (genX ++ one ++ "00") two `shouldBe` Just genNegatedOverX
where
recovered ctx sigHex msgHex =
bytesToHex . exportPubKey ctx True <$> recover ctx (fromString sigHex) (fromString msgHex)
-- * Compact encoding
compactSpec :: SpecWith Ctx
compactSpec = describe "compact encoding" $ do
it "imports and exports back the same signature" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) ->
let s = signRecMsg ctx k m
(compact, i) = exportCompactRecSig ctx s
in importCompactRecSig ctx compact i == Just s
it "accepts the recovery ids 0 to 3 and rejects 4 and 255" $ \ctx -> do
forM_ [0 .. 3] $ \i -> importCompactRecSig ctx (fromString (one ++ one)) i `shouldSatisfy` isJust
importCompactRecSig ctx (fromString (one ++ one)) 4 `shouldBe` Nothing
importCompactRecSig ctx (fromString (one ++ one)) 255 `shouldBe` Nothing
it "accepts components of 1 and n - 1 and rejects 0 and n" $ \ctx -> do
importCompactRecSig ctx (fromString (one ++ one)) 0 `shouldSatisfy` isJust
importCompactRecSig ctx (fromString (orderMinusOne ++ orderMinusOne)) 0 `shouldSatisfy` isJust
importCompactRecSig ctx (fromString (order ++ one)) 0 `shouldBe` Nothing
importCompactRecSig ctx (fromString (one ++ order)) 0 `shouldBe` Nothing
importCompactRecSig ctx (fromString (zero ++ one)) 0 `shouldBe` Nothing
importCompactRecSig ctx (fromString (one ++ zero)) 0 `shouldBe` Nothing
it "imports a high-S signature" $ \ctx ->
importCompactRecSig ctx (fromString (satoshiR ++ satoshiHighS)) 0 `shouldSatisfy` isJust
it "converts to the signature without the id" $ \ctx -> do
bytesToHex (exportSig ctx (convertRecSig ctx (fromString (coreR ++ coreS ++ "01")))) `shouldBe` coreDer
convertRecSig ctx (fromString (coreR ++ coreS ++ "00"))
`shouldBe` convertRecSig ctx (fromString (coreR ++ coreS ++ "01"))
it "converts to a signature that verifies" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) ->
verifySig ctx (derivePubKey ctx k) (convertRecSig ctx (signRecMsg ctx k m)) m
-- * Signature values
valueSpec :: SpecWith Ctx
valueSpec = describe "signature values" $ do
it "rebuild from their internal bytes" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) -> let s = signRecMsg ctx k m in recSig s.get == Just s
it "reject internal bytes that are not a signature" $ \_ -> do
recSig (BS.replicate 65 0) `shouldBe` Nothing
recSig (BS.replicate 65 0xff) `shouldBe` Nothing
recSig (BS.replicate 64 1) `shouldBe` Nothing
recSig (BS.replicate 66 1) `shouldBe` Nothing
it "reject an internal recovery id above 3" $ \ctx -> do
-- libsecp256k1 keeps the id in the last byte of the representation.
let bytes = (signRecMsg ctx keyOne satoshiMsg).get
recSig (BS.init bytes `BS.snoc` 4) `shouldBe` Nothing
it "show r, s and the recovery id in hex" $ \ctx ->
show (signRecMsg ctx coreKey coreMsg) `shouldBe` quoted (coreR ++ coreS ++ "01")
it "read that encoding" $ \ctx -> do
read (quoted (coreR ++ coreS ++ "01")) `shouldBe` signRecMsg ctx coreKey coreMsg
(fromString (coreR ++ coreS ++ "01") :: RecSig) `shouldBe` signRecMsg ctx coreKey coreMsg
it "show and read" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) -> showRead (signRecMsg ctx k m)
it "do not read an invalid signature" $ \_ -> do
(readMaybe (quoted (coreR ++ coreS)) :: Maybe RecSig) `shouldBe` Nothing
(readMaybe (quoted (coreR ++ coreS ++ "04")) :: Maybe RecSig) `shouldBe` Nothing
(readMaybe (quoted (zero ++ coreS ++ "01")) :: Maybe RecSig) `shouldBe` Nothing
(readMaybe (quoted (order ++ coreS ++ "01")) :: Maybe RecSig) `shouldBe` Nothing
(readMaybe (quoted (coreR ++ coreS ++ "0")) :: Maybe RecSig) `shouldBe` Nothing
evaluate (fromString (coreR ++ coreS) :: RecSig) `shouldThrow` anyErrorCall
-- * Values built with the raw constructors of the Internal modules
rawValueSpec :: SpecWith Ctx
rawValueSpec = describe "raw values from the Internal modules" $ do
it "a signature of 65 zero bytes recovers nothing, instead of aborting the process" $ \ctx ->
recover ctx (RecSig (BS.replicate 65 0)) satoshiMsg `shouldBe` Nothing
it "a zero secret key throws instead of signing" $ \ctx ->
evaluate (signRecMsg ctx (SecKey (BS.replicate 32 0)) satoshiMsg) `shouldThrow` anyErrorCall
it "values of the wrong length throw before reaching the library" $ \ctx -> do
evaluate (signRecMsg ctx (SecKey "a") satoshiMsg) `shouldThrow` anyErrorCall
evaluate (signRecMsg ctx keyOne (Msg "a")) `shouldThrow` anyErrorCall
evaluate (recover ctx (RecSig "a") satoshiMsg) `shouldThrow` anyErrorCall
evaluate (recover ctx (signRecMsg ctx keyOne satoshiMsg) (Msg "a")) `shouldThrow` anyErrorCall
evaluate (convertRecSig ctx (RecSig "a")) `shouldThrow` anyErrorCall
evaluate (exportCompactRecSig ctx (RecSig "a")) `shouldThrow` anyErrorCall
evaluate (importCompactRecSig ctx (CompactSig "a") 0) `shouldThrow` anyErrorCall
evaluate (length (show (RecSig "a"))) `shouldThrow` anyErrorCall
-- * Derived instances
instanceSpec :: SpecWith Ctx
instanceSpec = describe "derived instances" $ do
it "equal values hash equally, whichever way they were built" $ \ctx ->
property $ \(m :: Msg, k :: SecKey) ->
let s = signRecMsg ctx k m
(compact, i) = exportCompactRecSig ctx s
in hash s == hash (fromJust (importCompactRecSig ctx compact i))
&& hash s == hash (fromJust (recSig s.get))
it "can be fully evaluated" $ \ctx ->
(signRecMsg ctx keyOne satoshiMsg `deepseq` ()) `shouldBe` ()
#else
spec :: Spec
spec =
describe "public key recovery" $
it "needs the recovery flag of the package" $
pendingWith "the package was built with -f -recovery"
#endif