secp256k1

Pure Haskell Schnorr, ECDSA on the elliptic curve secp256k1 (docs.ppad.tech/secp256k1).
git clone git://git.ppad.tech/secp256k1.git
Log | Files | Refs | README | LICENSE

commit 971bb01cef00e44a7fdb63b3408edf88c53815c1
parent 079a416155060950576ee931bec17cc9ea49d615
Author: Jared Tobin <jared@jtobin.io>
Date:   Sat,  1 Aug 2026 11:26:19 -0230

lib: reject the point at infinity as a public key

With P = O the term carrying the challenge (schnorr) or the key
(ecdsa) vanishes from the verification equation, and an attacker can
satisfy what remains for any message: (x(s * G), s) verifies as a
schnorr signature for every s, and picking s freely with
r = x(e / s * G) verifies as ecdsa.

parse_point cannot produce the identity, since 7 is a quadratic
non-residue mod p and valid rejects (0, 0) on the uncompressed path.
It is reachable whenever a Pub is built with add, mul or the
Projective constructor, as taproot tweaking, BIP32 derivation and
MuSig-style key aggregation all do. ecdh already checked for it.

Test z rather than the affine coordinates; affine maps the identity to
(0, 0), which is why even_y_vartime accepted it.

Diffstat:
Mlib/Crypto/Curve/Secp256k1.hs | 14++++++++++++++
Mtest/Main.hs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 77 insertions(+), 3 deletions(-)

diff --git a/lib/Crypto/Curve/Secp256k1.hs b/lib/Crypto/Curve/Secp256k1.hs @@ -424,6 +424,14 @@ weierstrass x = C.sqr x * x + _CURVE_Bm valid :: Projective -> Bool valid (affine -> Affine x y) = C.eq_vartime (C.sqr y) (weierstrass x) +-- Point is the identity, i.e. the point at infinity. +-- +-- Note that 'affine' maps the identity to (0, 0), so a point must be +-- tested here, and not via its affine coordinates. +is_inf :: Projective -> Bool +is_inf (Projective _ _ z) = CT.decide (C.eq z 0) +{-# INLINE is_inf #-} + -- (bip0340) return point with x coordinate == x and with even y coordinate -- -- conceptually: @@ -1090,6 +1098,9 @@ _verify_schnorr -> Bool _verify_schnorr _mul m p sig | BS.length sig /= 64 = False + -- e * P vanishes for the identity, dropping the challenge from the + -- verification equation entirely + | is_inf p = False | otherwise = M.isJust $ do let capP = even_y_vartime p (unsafe_roll32 -> r, unsafe_roll32 -> s) = BS.splitAt 32 sig @@ -1406,6 +1417,9 @@ _verify_ecdsa_unrestricted _mul hf m p (ECDSA r0 s0) = M.isJust $ do let h = case hf of Hash -> SHA256.hash m NoHash -> m + -- u2 * P vanishes for the identity, leaving an equation an attacker + -- can satisfy by choosing s freely + guard (not (is_inf p)) guard (ge r0 && ge s0) let r = S.to r0 s = S.to s0 diff --git a/test/Main.hs b/test/Main.hs @@ -5,10 +5,12 @@ module Main where import Crypto.Curve.Secp256k1 +import qualified Crypto.Hash.SHA256 as SHA256 import qualified Data.Aeson as A import qualified Data.Attoparsec.ByteString as AT import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 +import qualified Numeric.Montgomery.Secp256k1.Scalar as S import Test.Tasty import Test.Tasty.HUnit import qualified Data.Text.IO as TIO @@ -48,7 +50,7 @@ main = do case pen of Nothing -> error "couldn't parse wycheproof vectors" Just (w0, w1, w2, no, ip) -> defaultMain $ testGroup "ppad-secp256k1" [ - units + units tex , wycheproof_ecdsa_verify_tests tex "(ecdsa, sha256)" Unrestricted w0 , wycheproof_ecdsa_verify_tests tex "(ecdsa, sha256, low-s)" LowS w1 , wycheproof_ecdh_tests "(ecdh)" w2 @@ -67,14 +69,72 @@ wycheproof_ecdh_tests msg WE.Wycheproof {..} = testGroup ("wycheproof vectors " <> msg) $ fmap (WE.execute_group) wp_testGroups -units :: TestTree -units = testGroup "unit tests" [ +units :: Context -> TestTree +units tex = testGroup "unit tests" [ parse_point_tests , serialize_point_tests , add_tests , dub_tests + , identity_pubkey_tests tex ] +-- A public key at infinity annihilates the term carrying the challenge +-- (schnorr) or the key (ecdsa), leaving a verification equation an +-- attacker can satisfy for any message. +identity_pubkey_tests :: Context -> TestTree +identity_pubkey_tests tex = testGroup "identity public key" [ + schnorr_identity_test tex + , ecdsa_identity_test tex + ] + +forgery_msg :: BS.ByteString +forgery_msg = "arbitrary attacker-chosen message" + +-- The recovered point is s * G - e * O = s * G for every challenge e, +-- so (x(s * G), s) satisfies the equation whenever s * G has even y. +schnorr_forgery :: Maybe (Projective, BS.ByteString) +schnorr_forgery = do + let sec_bytes = decodeLenient + "b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef" + sec <- parse_int256 sec_bytes + pub <- derive_pub sec + pure (pub, BS.drop 1 (serialize_point pub) <> sec_bytes) + +schnorr_identity_test :: Context -> TestTree +schnorr_identity_test tex = + testCase "schnorr verification rejects the identity" $ + case schnorr_forgery of + Nothing -> assertFailure "couldn't construct forgery" + Just (pub, sig) -> do + assertEqual "forgery is well-formed (even y)" + "\002" (BS.take 1 (serialize_point pub)) + assertBool mempty (not (verify_schnorr forgery_msg _CURVE_ZERO sig)) + assertBool mempty + (not (verify_schnorr' tex forgery_msg _CURVE_ZERO sig)) + +-- Verification reduces to x(u1 * G) == r for u1 = e / s, so choosing +-- u1 freely and setting s = e / u1 satisfies it for any message. +ecdsa_forgery :: Maybe ECDSA +ecdsa_forgery = do + e <- parse_int256 (SHA256.hash forgery_msg) + u1 <- parse_int256 (BS.replicate 32 0x22) + cap <- derive_pub u1 + r <- parse_int256 (BS.drop 1 (serialize_point cap)) + pure (ECDSA r (S.retr (S.to e * S.inv (S.to u1)))) + +ecdsa_identity_test :: Context -> TestTree +ecdsa_identity_test tex = + testCase "ecdsa verification rejects the identity" $ + case ecdsa_forgery of + Nothing -> assertFailure "couldn't construct forgery" + Just sig@(ECDSA r s) -> do + assertBool "forgery is well-formed (r in range)" (ge r) + assertBool "forgery is well-formed (s in range)" (ge s) + assertBool mempty + (not (verify_ecdsa_unrestricted forgery_msg _CURVE_ZERO sig)) + assertBool mempty + (not (verify_ecdsa_unrestricted' tex forgery_msg _CURVE_ZERO sig)) + parse_point_tests :: TestTree parse_point_tests = testGroup "parse_point tests" [ parse_point_test_p