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:
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