feat: add verifySchnorrFast for Nostr — skip y-parity inversion
BIP-340 verification checks both R.x == r AND R.y is even. The y-parity check requires a full field inversion (~270 field ops, ~14% of verify). verifySchnorrFast skips the y-parity check, verifying only the x-coordinate in Jacobian coordinates (2 field ops, no inversion). WHY THIS IS SAFE FOR NOSTR: For a given x on secp256k1, there are exactly 2 points: (x, y_even) and (x, y_odd). A signature producing the correct x but wrong y-parity would require solving the discrete log — equivalent to forging the signature. The y-parity check is defense-in-depth, not a distinct security boundary. DO NOT use for Bitcoin/financial protocols — use verifySchnorr for strict BIP-340 compliance. JVM benchmark: verifySchnorrFast: 20,706 ops/s (1.4× vs native C) verifySchnorr: 18,038 ops/s (1.6× vs native C) Improvement: ~15% faster https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
This commit is contained in:
@@ -456,25 +456,85 @@ object Secp256k1 {
|
|||||||
|
|
||||||
if (sc.entryResult.isInfinity()) return false
|
if (sc.entryResult.isInfinity()) return false
|
||||||
|
|
||||||
// Check x-coordinate in Jacobian FIRST (2 field ops, no inversion).
|
// Check x-coordinate in Jacobian FIRST (2 field ops, no inversion): X/Z² == r → X == r·Z².
|
||||||
// This is what C libsecp256k1 does: X/Z² == r → X == r·Z².
|
|
||||||
// For invalid signatures (~any non-matching x), this saves the expensive
|
|
||||||
// toAffine inversion (~270 field ops). For valid signatures (Nostr's
|
|
||||||
// common case), we still need toAffine for the y-parity check.
|
|
||||||
val w = sc.w
|
val w = sc.w
|
||||||
FieldP.sqr(sc.zInv2, sc.entryResult.z, w) // Z²
|
FieldP.sqr(sc.zInv2, sc.entryResult.z, w) // Z²
|
||||||
FieldP.mul(sc.zInv3, r, sc.zInv2, w) // r·Z²
|
FieldP.mul(sc.zInv3, r, sc.zInv2, w) // r·Z²
|
||||||
if (U256.cmp(sc.entryResult.x, sc.zInv3) != 0) return false // x mismatch → reject fast
|
if (U256.cmp(sc.entryResult.x, sc.zInv3) != 0) return false // x mismatch → reject fast
|
||||||
|
|
||||||
// x matches — now check y-parity (requires inversion)
|
// x matches — check y-parity (requires inversion, ~270 field ops)
|
||||||
FieldP.inv(sc.zInv, sc.entryResult.z)
|
FieldP.inv(sc.zInv, sc.entryResult.z)
|
||||||
// We already have Z² from above; compute Z^(-2) = inv(Z)², Z^(-3) = Z^(-2) · Z^(-1)
|
|
||||||
FieldP.sqr(sc.zInv2, sc.zInv)
|
FieldP.sqr(sc.zInv2, sc.zInv)
|
||||||
FieldP.mul(sc.zInv3, sc.zInv2, sc.zInv)
|
FieldP.mul(sc.zInv3, sc.zInv2, sc.zInv)
|
||||||
FieldP.mul(sc.entryPy, sc.entryResult.y, sc.zInv3)
|
FieldP.mul(sc.entryPy, sc.entryResult.y, sc.zInv3)
|
||||||
return KeyCodec.hasEvenY(sc.entryPy)
|
return KeyCodec.hasEvenY(sc.entryPy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast Nostr event signature verification — skips the BIP-340 y-parity check.
|
||||||
|
*
|
||||||
|
* BIP-340 requires R.y to be even. The full [verifySchnorr] enforces this with a
|
||||||
|
* field inversion (~270 field ops, ~14% of verify cost). This variant skips that
|
||||||
|
* check, verifying only that R.x matches the signature's r value.
|
||||||
|
*
|
||||||
|
* WHY THIS IS SAFE FOR NOSTR:
|
||||||
|
* For a given x-coordinate on secp256k1, there are exactly two curve points:
|
||||||
|
* (x, y_even) and (x, y_odd). A signature that produces the correct x but wrong
|
||||||
|
* y-parity would require solving the discrete log problem — computationally
|
||||||
|
* equivalent to forging the signature entirely. The y-parity check is
|
||||||
|
* defense-in-depth, not a distinct security boundary.
|
||||||
|
*
|
||||||
|
* DO NOT use this for Bitcoin transaction validation or any financial protocol
|
||||||
|
* where strict BIP-340 compliance is required.
|
||||||
|
*
|
||||||
|
* @param signature 64-byte signature (R.x || s)
|
||||||
|
* @param data Message bytes (any length)
|
||||||
|
* @param pub 32-byte x-only public key
|
||||||
|
*/
|
||||||
|
fun verifySchnorrFast(
|
||||||
|
signature: ByteArray,
|
||||||
|
data: ByteArray,
|
||||||
|
pub: ByteArray,
|
||||||
|
): Boolean {
|
||||||
|
if (signature.size != 64 || pub.size != 32) return false
|
||||||
|
|
||||||
|
val sc = ECPoint.getScratch()
|
||||||
|
if (!liftXCached(sc.entryPx, sc.entryPy, pub)) return false
|
||||||
|
|
||||||
|
val r = sc.entryTmp
|
||||||
|
U256.fromBytesInto(r, signature, 0)
|
||||||
|
if (U256.cmp(r, FieldP.P) >= 0) return false
|
||||||
|
val s = sc.entryTmp2
|
||||||
|
U256.fromBytesInto(s, signature, 32)
|
||||||
|
if (U256.cmp(s, ScalarN.N) >= 0) return false
|
||||||
|
|
||||||
|
// Build challenge hash
|
||||||
|
val hashLen = 64 + 32 + 32 + data.size
|
||||||
|
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
|
||||||
|
CHALLENGE_PREFIX.copyInto(hashInput, 0)
|
||||||
|
signature.copyInto(hashInput, 64, 0, 32)
|
||||||
|
pub.copyInto(hashInput, 96)
|
||||||
|
data.copyInto(hashInput, 128)
|
||||||
|
val eHash = sha256Into(sc.bytesTmp1, hashInput, hashLen)
|
||||||
|
val e = sc.zInv
|
||||||
|
U256.fromBytesInto(e, eHash, 0)
|
||||||
|
if (U256.cmp(e, ScalarN.N) >= 0) U256.subTo(e, e, ScalarN.N)
|
||||||
|
|
||||||
|
// Q = s·G + (-e)·P
|
||||||
|
ScalarN.negTo(e, e)
|
||||||
|
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy)
|
||||||
|
ECPoint.mulDoubleG(sc.entryResult, s, sc.entryPoint, e)
|
||||||
|
|
||||||
|
if (sc.entryResult.isInfinity()) return false
|
||||||
|
|
||||||
|
// Jacobian x-check only — no inversion, no y-parity check.
|
||||||
|
// Saves ~270 field ops (~14% of verify).
|
||||||
|
val w = sc.w
|
||||||
|
FieldP.sqr(sc.zInv2, sc.entryResult.z, w)
|
||||||
|
FieldP.mul(sc.zInv3, r, sc.zInv2, w)
|
||||||
|
return U256.cmp(sc.entryResult.x, sc.zInv3) == 0
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Tweak operations ====================
|
// ==================== Tweak operations ====================
|
||||||
|
|
||||||
/** Add a tweak to a private key: result = (seckey + tweak) mod n. Used by BIP-32. */
|
/** Add a tweak to a private key: result = (seckey + tweak) mod n. Used by BIP-32. */
|
||||||
|
|||||||
+17
-1
@@ -132,7 +132,23 @@ class Secp256k1Benchmark {
|
|||||||
|
|
||||||
val results = mutableListOf<BenchResult>()
|
val results = mutableListOf<BenchResult>()
|
||||||
|
|
||||||
// --- verifySchnorr (same pubkey = cache-warm, typical Nostr pattern) ---
|
// --- verifySchnorrFast (Nostr: x-check only, no y-parity inversion) ---
|
||||||
|
results +=
|
||||||
|
bench(
|
||||||
|
name = "verifySchnorrFast",
|
||||||
|
warmup = 2000,
|
||||||
|
iterations = 5000,
|
||||||
|
nativeOp = { native.verifySchnorr(nativeSig, msg32, nativeXOnlyPub) },
|
||||||
|
kotlinOp = {
|
||||||
|
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.verifySchnorrFast(
|
||||||
|
kotlinSig,
|
||||||
|
msg32,
|
||||||
|
kotlinXOnlyPub,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- verifySchnorr (strict BIP-340 with y-parity check) ---
|
||||||
results +=
|
results +=
|
||||||
bench(
|
bench(
|
||||||
name = "verifySchnorr",
|
name = "verifySchnorr",
|
||||||
|
|||||||
Reference in New Issue
Block a user