diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldPTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldPTest.kt index adec25fdb..d51a9ef2d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldPTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldPTest.kt @@ -262,4 +262,49 @@ class FieldPTest { FieldP.sqr(out, a) assertEquals(25, out[0]) // 5² = 25 } + + @Test + fun halfOfPMinus1() { + // half(p-1) should equal (p-1)/2 + val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") + val out = IntArray(8) + FieldP.half(out, pMinus1) + // Verify: 2 * half(p-1) = p-1 + val doubled = FieldP.add(out, out) + assertEquals(toHex(pMinus1), toHex(doubled)) + } + + @Test + fun invOfTwo() { + val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0) + val inv2 = FieldP.inv(two) + val product = FieldP.mul(two, inv2) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(product)) + } + + @Test + fun sqrtOfZero() { + val zero = IntArray(8) + val root = FieldP.sqrt(zero) + assertTrue(root != null && U256.isZero(root)) + } + + @Test + fun sqrtOfOne() { + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + val root = FieldP.sqrt(one)!! + assertEquals(toHex(one), toHex(root)) + } + + @Test + fun mulAliasingOutputEqualsInput() { + // mul(out, a, b) where out == a should work + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + val expected = FieldP.mul(a, b) + val aCopy = a.copyOf() + FieldP.mul(aCopy, aCopy, b) // out == a + assertEquals(toHex(expected), toHex(aCopy)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/GlvTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/GlvTest.kt index 73b27cd83..ff1178e9f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/GlvTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/GlvTest.kt @@ -22,122 +22,176 @@ package com.vitorpamplona.quartz.utils.secp256k1 import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue +/** Comprehensive tests for GLV endomorphism and wNAF encoding. */ class GlvTest { private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) } - @Suppress("ktlint:standard:property-naming") - private val LAMBDA = - intArrayOf( - 0x1B23BD72.toInt(), - 0xDF02967C.toInt(), - 0x20816678.toInt(), - 0x122E22EA.toInt(), - 0x8812645A.toInt(), - 0xA5261C02.toInt(), - 0xC05C30E0.toInt(), - 0x5363AD4C.toInt(), + private fun hex(s: String) = + U256.fromBytes( + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(), ) + @Suppress("ktlint:standard:property-naming") + private val LAMBDA = hex("5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72") + + // ==================== GLV Scalar Decomposition ==================== + @Test - fun scalarSplitReconstruction() { - val k = - U256.fromBytes( - "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" - .chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray(), - ) + fun splitScalarReconstruction() { + // k₁ + k₂·λ ≡ k (mod n) for a typical scalar + val k = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") val split = Glv.splitScalar(k) val k1 = if (split.negK1) ScalarN.neg(split.k1) else split.k1 val k2 = if (split.negK2) ScalarN.neg(split.k2) else split.k2 - val reconstructed = ScalarN.add(k1, ScalarN.mul(k2, LAMBDA)) - assertEquals(toHex(k), toHex(reconstructed), "k = k1 + k2*lambda mod n") + assertEquals(toHex(k), toHex(ScalarN.add(k1, ScalarN.mul(k2, LAMBDA)))) + } + + @Test + fun splitScalarZero() { + val split = Glv.splitScalar(IntArray(8)) + assertTrue(U256.isZero(split.k1) && U256.isZero(split.k2)) + } + + @Test + fun splitScalarNMinus1() { + // n-1 is the maximum valid scalar + val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140") + val split = Glv.splitScalar(nMinus1) + val k1 = if (split.negK1) ScalarN.neg(split.k1) else split.k1 + val k2 = if (split.negK2) ScalarN.neg(split.k2) else split.k2 + assertEquals(toHex(nMinus1), toHex(ScalarN.add(k1, ScalarN.mul(k2, LAMBDA)))) + } + + @Test + fun splitScalarHalvesAreSmall() { + // Both k₁ and k₂ should be ~128 bits (fit in 4 limbs) + val k = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val split = Glv.splitScalar(k) + // Upper 4 limbs should be zero for a proper 128-bit half-scalar + for (i in 4 until 8) { + assertEquals(0, split.k1[i], "k1 limb $i should be 0") + assertEquals(0, split.k2[i], "k2 limb $i should be 0") + } + } + + @Test + fun splitMultipleScalars() { + // Verify reconstruction for several different scalars + val scalars = + listOf( + hex("0000000000000000000000000000000000000000000000000000000000000001"), + hex("0000000000000000000000000000000000000000000000000000000000000003"), + hex("b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef"), + hex("c90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b14e5c9"), + hex("944946c56e0d133f326db0b0645544a04bfcc5a0f447ad6d0227958414d8ba73"), + ) + for (k in scalars) { + val split = Glv.splitScalar(k) + val k1 = if (split.negK1) ScalarN.neg(split.k1) else split.k1 + val k2 = if (split.negK2) ScalarN.neg(split.k2) else split.k2 + assertEquals( + toHex(k), + toHex(ScalarN.add(k1, ScalarN.mul(k2, LAMBDA))), + "Reconstruction failed for ${toHex(k)}", + ) + } + } + + // ==================== Endomorphism ==================== + + @Test + fun betaCubedIsOne() { + // β³ ≡ 1 (mod p) — the defining property of the cube root of unity + val b2 = FieldP.sqr(Glv.BETA) + val b3 = FieldP.mul(b2, Glv.BETA) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(b3)) } @Test fun endomorphismCorrectness() { - val beta = - intArrayOf( - 0x719501EE.toInt(), - 0xC1396C28.toInt(), - 0x12F58995.toInt(), - 0x9CF04975.toInt(), - 0xAC3434E9.toInt(), - 0x6E64479E.toInt(), - 0x657C0710.toInt(), - 0x7AE96A2B.toInt(), - ) - val betaGx = FieldP.mul(ECPoint.GX, beta) + // λ·G should equal (β·Gx, Gy) val result = MutablePoint() ECPoint.mulG(result, LAMBDA) val rx = IntArray(8) val ry = IntArray(8) ECPoint.toAffine(result, rx, ry) - assertEquals(toHex(betaGx), toHex(rx), "x should be beta*Gx") - assertEquals(toHex(ECPoint.GY), toHex(ry), "y should be Gy") + assertEquals(toHex(FieldP.mul(ECPoint.GX, Glv.BETA)), toHex(rx)) + assertEquals(toHex(ECPoint.GY), toHex(ry)) + } + + // ==================== wNAF Encoding ==================== + + @Test + fun wnafReconstructionSmall() { + // wNAF digits should reconstruct to the original scalar + val k = intArrayOf(17, 0, 0, 0, 0, 0, 0, 0) // 17 = 10001 in binary + val digits = Glv.wnaf(k, 5, 256) + assertEquals(k[0], reconstructWnaf(digits)[0]) } @Test - fun wnafK1GDirectly() { - // Compute |k1|*G via wNAF, then negate if needed. Compare with k1_signed*G via mulG. - val k = - U256.fromBytes( - "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" - .chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray(), - ) - val split = Glv.splitScalar(k) - val gOdd = Array(8) { ECPoint.gTable[it * 2] } - val digits = Glv.wnaf(split.k1, 5, 129) + fun wnafReconstructionLarge() { + val k = hex("e907831f80848d1069a5371b402410364bdf1c5f8307b0084c55f1ce2dca8215") + val digits = Glv.wnaf(k, 5, 256) + val reconstructed = reconstructWnaf(digits) + for (i in 0 until 8) assertEquals(k[i], reconstructed[i], "Limb $i mismatch") + } - var bits = digits.size - while (bits > 0 && digits[bits - 1] == 0) bits-- - val result = MutablePoint() - result.setInfinity() - val tmp = MutablePoint() - val negY = IntArray(8) - for (i in bits - 1 downTo 0) { - ECPoint.doublePoint(result, result) + @Test + fun wnafCarryOverflowBit255() { + // Regression: scalars with high bits set caused carries past bit 255 to be dropped. + // This scalar (negE from BIP-340 vector 0) triggers the carry at bit 256. + val k = hex("944946c56e0d133f326db0b0645544a04bfcc5a0f447ad6d0227958414d8ba73") + val digits = Glv.wnaf(k, 5, 256) + val reconstructed = reconstructWnaf(digits) + for (i in 0 until 8) assertEquals(k[i], reconstructed[i], "Limb $i mismatch for carry test") + } + + @Test + fun wnafDigitsAreOddAndBounded() { + val k = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val digits = Glv.wnaf(k, 5, 256) + for (i in digits.indices) { val d = digits[i] if (d != 0) { - val idx = (if (d > 0) d else -d) / 2 - val neg = (d < 0) xor split.negK1 - if (!neg) { - ECPoint.addMixed(tmp, result, gOdd[idx].x, gOdd[idx].y) - } else { - FieldP.neg(negY, gOdd[idx].y) - ECPoint.addMixed(tmp, result, gOdd[idx].x, negY) - } - result.copyFrom(tmp) + assertTrue(d % 2 != 0, "wNAF digit at $i should be odd, got $d") + assertTrue(d in -15..15, "wNAF digit at $i should be in [-15,15], got $d") } } - val rx = IntArray(8) - val ry = IntArray(8) - ECPoint.toAffine(result, rx, ry) - - // Expected: k1_signed * G - val k1Signed = if (split.negK1) ScalarN.neg(split.k1) else split.k1 - val direct = MutablePoint() - ECPoint.mulG(direct, k1Signed) - val dx = IntArray(8) - val dy = IntArray(8) - ECPoint.toAffine(direct, dx, dy) - - println("k1=${toHex(split.k1)} neg=${split.negK1} bits=$bits wnaf_x=${toHex(rx)} direct_x=${toHex(dx)}") - assertEquals(toHex(dx), toHex(rx), "k1 via wNAF+GLV sign should match direct") } + @Test + fun wnafZeroRunsBetweenDigits() { + // wNAF-5 guarantees at least 4 zeros between non-zero digits + val k = hex("b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef") + val digits = Glv.wnaf(k, 5, 256) + var lastNonZero = -5 + for (i in digits.indices) { + if (digits[i] != 0) { + assertTrue(i - lastNonZero >= 5, "Gap between digits at $lastNonZero and $i is ${i - lastNonZero}, expected ≥5") + lastNonZero = i + } + } + } + + @Test + fun wnafSmallMaxBits() { + // wNAF with maxBits=129 (used for GLV half-scalars) + val k = intArrayOf(0x12345678.toInt(), 0x9ABCDEF0.toInt(), 0x11111111, 0x22222222, 0, 0, 0, 0) + val digits = Glv.wnaf(k, 5, 129) + val reconstructed = reconstructWnaf(digits) + for (i in 0 until 4) assertEquals(k[i], reconstructed[i], "Limb $i mismatch for 129-bit wNAF") + } + + // ==================== Integration: GLV mulDoubleG ==================== + @Test fun mulDoubleGWithZeroE() { - val s = - U256.fromBytes( - "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" - .chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray(), - ) + // s·G + 0·P = s·G + val s = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") val p = MutablePoint() ECPoint.mulG(p, intArrayOf(2, 0, 0, 0, 0, 0, 0, 0)) val combined = MutablePoint() @@ -150,6 +204,40 @@ class GlvTest { val dx = IntArray(8) val dy = IntArray(8) ECPoint.toAffine(direct, dx, dy) - assertEquals(toHex(dx), toHex(cx), "s*G+0*P should equal s*G") + assertEquals(toHex(dx), toHex(cx)) + } + + // ==================== Helpers ==================== + + /** Reconstruct a scalar from wNAF digits using Horner's method. */ + private fun reconstructWnaf(digits: IntArray): IntArray { + var acc = IntArray(8) + for (bit in digits.size - 1 downTo 0) { + val doubled = IntArray(8) + var carry = 0L + for (j in 0 until 8) { + carry += (acc[j].toLong() and 0xFFFFFFFFL) * 2L + doubled[j] = carry.toInt() + carry = carry ushr 32 + } + acc = doubled + val d = digits[bit] + if (d > 0) { + var c = 0L + for (j in 0 until 8) { + c += (acc[j].toLong() and 0xFFFFFFFFL) + if (j == 0) d.toLong() else 0L + acc[j] = c.toInt() + c = c ushr 32 + } + } else if (d < 0) { + var b = 0L + for (j in 0 until 8) { + val diff = (acc[j].toLong() and 0xFFFFFFFFL) - (if (j == 0) (-d).toLong() else 0L) - b + acc[j] = diff.toInt() + b = if (diff < 0) 1L else 0L + } + } + } + return acc } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTest.kt index 0d1b47b2d..26018489f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTest.kt @@ -330,4 +330,50 @@ class PointTest { assertFalse(ECPoint.parsePublicKey(ByteArray(10), x, y)) assertFalse(ECPoint.parsePublicKey(ByteArray(33), x, y)) // wrong prefix (0x00) } + + @Test + fun addMixedEqualPoints() { + // addMixed with equal points should double + val p = MutablePoint() + p.setAffine(ECPoint.GX, ECPoint.GY) + val result = MutablePoint() + ECPoint.addMixed(result, p, ECPoint.GX, ECPoint.GY) + val rx = IntArray(8) + val ry = IntArray(8) + ECPoint.toAffine(result, rx, ry) + val doubled = MutablePoint() + ECPoint.doublePoint(doubled, p) + val dx = IntArray(8) + val dy = IntArray(8) + ECPoint.toAffine(doubled, dx, dy) + assertEquals(toHex(dx), toHex(rx)) + } + + @Test + fun addMixedInversePoints() { + val p = MutablePoint() + p.setAffine(ECPoint.GX, ECPoint.GY) + val negGy = FieldP.neg(ECPoint.GY) + val result = MutablePoint() + ECPoint.addMixed(result, p, ECPoint.GX, negGy) + assertTrue(result.isInfinity()) + } + + @Test + fun parseCompressedOddY() { + val privKeyBytes = + "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a" + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + val pubkey = Secp256k1.pubkeyCreate(privKeyBytes) + val compressed = Secp256k1.pubKeyCompress(pubkey) + assertEquals(0x03.toByte(), compressed[0]) // Odd y → 03 prefix + val x = IntArray(8) + val y = IntArray(8) + assertTrue(ECPoint.parsePublicKey(compressed, x, y)) + // Round-trip: compress again should give same result + val recompressed = ECPoint.serializeCompressed(x, y) + assertEquals(compressed.toList(), recompressed.toList()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Test.kt index e6a941afe..516ffcd42 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Test.kt @@ -238,4 +238,46 @@ class Secp256k1Test { val secretBA = Secp256k1.pubKeyTweakMul(pubA, privB) assertEquals(secretAB.toHexKey(), secretBA.toHexKey()) } + + @Test + fun verifySchnorrWrongMessage() { + val sig = + Secp256k1.signSchnorr( + ByteArray(32) { 0x42 }, + "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray(), + null, + ) + val pubkey = + Secp256k1.pubKeyCompress( + Secp256k1.pubkeyCreate("f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()), + ) + val xonly = pubkey.copyOfRange(1, 33) + // Correct message verifies + assertTrue(Secp256k1.verifySchnorr(sig, ByteArray(32) { 0x42 }, xonly)) + // Wrong message fails + assertFalse(Secp256k1.verifySchnorr(sig, ByteArray(32) { 0x43 }, xonly)) + } + + @Test + fun verifySchnorrCorruptedSignature() { + val privKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val msg = ByteArray(32) { 0x42 } + val sig = Secp256k1.signSchnorr(msg, privKey, null) + val pubkey = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privKey)) + val xonly = pubkey.copyOfRange(1, 33) + // Flip a bit in the signature + val corrupt = sig.copyOf() + corrupt[0] = (corrupt[0].toInt() xor 1).toByte() + assertFalse(Secp256k1.verifySchnorr(corrupt, msg, xonly)) + } + + @Test + fun signSchnorrDeterministic() { + // With null auxrand, signing should be deterministic + val privKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val msg = ByteArray(32) { 0x42 } + val sig1 = Secp256k1.signSchnorr(msg, privKey, null) + val sig2 = Secp256k1.signSchnorr(msg, privKey, null) + assertEquals(sig1.toList(), sig2.toList()) + } }