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 new file mode 100644 index 000000000..adec25fdb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldPTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.secp256k1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Tests for field arithmetic modulo p. */ +class FieldPTest { + private fun hex(s: String) = + U256.fromBytes( + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(), + ) + + private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) } + + // ==================== Basic identities ==================== + + @Test + fun addZeroIdentity() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val zero = IntArray(8) + assertEquals(toHex(a), toHex(FieldP.add(a, zero))) + } + + @Test + fun subSelfIsZero() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val result = FieldP.sub(a, a) + assertTrue(U256.isZero(result)) + } + + @Test + fun addThenSubRoundTrips() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + val sum = FieldP.add(a, b) + val back = FieldP.sub(sum, b) + assertEquals(toHex(a), toHex(back)) + } + + @Test + fun mulOneIdentity() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(a), toHex(FieldP.mul(a, one))) + } + + // ==================== Reduction near p ==================== + + @Test + fun addNearP() { + // (p - 1) + 1 = p ≡ 0 (mod p) + val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + val result = FieldP.add(pMinus1, one) + assertTrue(U256.isZero(result)) + } + + @Test + fun addNearPOverflow() { + // (p - 1) + (p - 1) = 2p - 2 ≡ p - 2 (mod p) + val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") + val result = FieldP.add(pMinus1, pMinus1) + val expected = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2d") // p-2 + assertEquals(toHex(expected), toHex(result)) + } + + @Test + fun subUnderflow() { + // 0 - 1 ≡ p - 1 (mod p) + val zero = IntArray(8) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + val result = FieldP.sub(zero, one) + val expected = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") // p-1 + assertEquals(toHex(expected), toHex(result)) + } + + // ==================== Negation ==================== + + @Test + fun negTwiceIsIdentity() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + assertEquals(toHex(a), toHex(FieldP.neg(FieldP.neg(a)))) + } + + @Test + fun negZeroIsZero() { + assertTrue(U256.isZero(FieldP.neg(IntArray(8)))) + } + + @Test + fun addNegIsZero() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val result = FieldP.add(a, FieldP.neg(a)) + assertTrue(U256.isZero(result)) + } + + // ==================== Multiplication ==================== + + @Test + fun mulCommutative() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + assertEquals(toHex(FieldP.mul(a, b)), toHex(FieldP.mul(b, a))) + } + + @Test + fun sqrMatchesMul() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + assertEquals(toHex(FieldP.mul(a, a)), toHex(FieldP.sqr(a))) + } + + @Test + fun mulDistributive() { + // a * (b + c) = a*b + a*c + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + val c = hex("0000000000000000000000000000000000000000000000000000000000000007") + val lhs = FieldP.mul(a, FieldP.add(b, c)) + val rhs = FieldP.add(FieldP.mul(a, b), FieldP.mul(a, c)) + assertEquals(toHex(lhs), toHex(rhs)) + } + + // ==================== Inversion ==================== + + @Test + fun invMulIsOne() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val aInv = FieldP.inv(a) + val product = FieldP.mul(a, aInv) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(product)) + } + + @Test + fun invOfOne() { + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(FieldP.inv(one))) + } + + @Test + fun invOfPMinus1() { + // (p-1)^(-1) = p-1 because (p-1)^2 = 1 mod p + val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") + assertEquals(toHex(pMinus1), toHex(FieldP.inv(pMinus1))) + } + + // ==================== Half ==================== + + @Test + fun halfOfEven() { + val out = IntArray(8) + val four = intArrayOf(4, 0, 0, 0, 0, 0, 0, 0) + FieldP.half(out, four) + assertEquals(2, out[0]) + for (i in 1 until 8) assertEquals(0, out[i]) + } + + @Test + fun halfOfOdd() { + // half(1) = (1 + p) / 2 = (p + 1) / 2 + val out = IntArray(8) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + FieldP.half(out, one) + // Verify: 2 * half(1) = 1 mod p + val doubled = FieldP.add(out, out) + assertEquals(1, doubled[0]) + for (i in 1 until 8) assertEquals(0, doubled[i]) + } + + @Test + fun halfThenDoubleRoundTrips() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val out = IntArray(8) + FieldP.half(out, a) + val doubled = FieldP.add(out, out) + assertEquals(toHex(a), toHex(doubled)) + } + + // ==================== Square root ==================== + + @Test + fun sqrtOfSquare() { + // sqrt(a²) should return a or p-a + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val aSq = FieldP.sqr(a) + val root = FieldP.sqrt(aSq)!! + // root² should equal a² + assertEquals(toHex(aSq), toHex(FieldP.sqr(root))) + } + + @Test + fun sqrtOfNonResidue() { + // 3 is not a quadratic residue mod p (for secp256k1's p) + val three = intArrayOf(3, 0, 0, 0, 0, 0, 0, 0) + assertNull(FieldP.sqrt(three)) + } + + @Test + fun sqrtOfSecp256k1Generator() { + // G.y² = G.x³ + 7. sqrt(G.x³ + 7) should give G.y or -G.y + val gx = ECPoint.GX + val gy = ECPoint.GY + val x3 = FieldP.mul(FieldP.sqr(gx), gx) + val y2 = FieldP.add(x3, intArrayOf(7, 0, 0, 0, 0, 0, 0, 0)) + val root = FieldP.sqrt(y2)!! + // root should be gy or -gy + val isGy = U256.cmp(root, gy) == 0 + val isNegGy = U256.cmp(root, FieldP.neg(gy)) == 0 + assertTrue(isGy || isNegGy) + } + + // ==================== Reduction edge cases ==================== + + @Test + fun reduceWideWithMaxValues() { + // Multiply two values near p and verify result is < p + val pMinus1 = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2e") + val result = FieldP.mul(pMinus1, pMinus1) + assertTrue(U256.cmp(result, FieldP.P) < 0, "Result should be < p") + // (p-1)² ≡ 1 (mod p) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(result)) + } + + // ==================== In-place operations ==================== + + @Test + fun inPlaceAdd() { + val a = hex("0000000000000000000000000000000000000000000000000000000000000005") + val b = hex("0000000000000000000000000000000000000000000000000000000000000003") + val out = IntArray(8) + FieldP.add(out, a, b) + assertEquals(8, out[0]) + } + + @Test + fun inPlaceSqr() { + val a = hex("0000000000000000000000000000000000000000000000000000000000000005") + val out = IntArray(8) + FieldP.sqr(out, a) + assertEquals(25, out[0]) // 5² = 25 + } +} 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 new file mode 100644 index 000000000..0d1b47b2d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTest.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.secp256k1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Tests for elliptic curve point operations. */ +class PointTest { + private fun hex(s: String) = + U256.fromBytes( + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(), + ) + + private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) } + + // ==================== Generator point ==================== + + @Test + fun generatorIsOnCurve() { + // y² = x³ + 7 + val x3 = FieldP.mul(FieldP.sqr(ECPoint.GX), ECPoint.GX) + val y2expected = FieldP.add(x3, intArrayOf(7, 0, 0, 0, 0, 0, 0, 0)) + val y2actual = FieldP.sqr(ECPoint.GY) + assertEquals(toHex(y2expected), toHex(y2actual)) + } + + // ==================== Point doubling ==================== + + @Test + fun doubleGMatchesTwoG() { + // 2·G via doubling + val p = MutablePoint() + p.setAffine(ECPoint.GX, ECPoint.GY) + val doubled = MutablePoint() + ECPoint.doublePoint(doubled, p) + val dx = IntArray(8) + val dy = IntArray(8) + ECPoint.toAffine(doubled, dx, dy) + + // 2·G via scalar multiplication + val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0) + val mulResult = MutablePoint() + ECPoint.mulG(mulResult, two) + val mx = IntArray(8) + val my = IntArray(8) + ECPoint.toAffine(mulResult, mx, my) + + assertEquals(toHex(mx), toHex(dx)) + assertEquals(toHex(my), toHex(dy)) + } + + @Test + fun doubleInPlace() { + // doublePoint(out, out) should work correctly + val p = MutablePoint() + p.setAffine(ECPoint.GX, ECPoint.GY) + ECPoint.doublePoint(p, p) + val x = IntArray(8) + val y = IntArray(8) + ECPoint.toAffine(p, x, y) + + val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0) + val expected = MutablePoint() + ECPoint.mulG(expected, two) + val ex = IntArray(8) + val ey = IntArray(8) + ECPoint.toAffine(expected, ex, ey) + + assertEquals(toHex(ex), toHex(x)) + } + + @Test + fun doubleInfinityIsInfinity() { + val inf = MutablePoint() + inf.setInfinity() + val result = MutablePoint() + ECPoint.doublePoint(result, inf) + assertTrue(result.isInfinity()) + } + + // ==================== Point addition ==================== + + @Test + fun addGPlusGEqualsDoubleG() { + val g = MutablePoint() + g.setAffine(ECPoint.GX, ECPoint.GY) + val sum = MutablePoint() + ECPoint.addPoints(sum, g, g) + val sx = IntArray(8) + val sy = IntArray(8) + ECPoint.toAffine(sum, sx, sy) + + val doubled = MutablePoint() + ECPoint.doublePoint(doubled, g) + val dx = IntArray(8) + val dy = IntArray(8) + ECPoint.toAffine(doubled, dx, dy) + + assertEquals(toHex(dx), toHex(sx)) + assertEquals(toHex(dy), toHex(sy)) + } + + @Test + fun addInfinityIdentity() { + val g = MutablePoint() + g.setAffine(ECPoint.GX, ECPoint.GY) + val inf = MutablePoint() + inf.setInfinity() + + val result = MutablePoint() + ECPoint.addPoints(result, g, inf) + val rx = IntArray(8) + val ry = IntArray(8) + ECPoint.toAffine(result, rx, ry) + assertEquals(toHex(ECPoint.GX), toHex(rx)) + + val result2 = MutablePoint() + ECPoint.addPoints(result2, inf, g) + val r2x = IntArray(8) + val r2y = IntArray(8) + ECPoint.toAffine(result2, r2x, r2y) + assertEquals(toHex(ECPoint.GX), toHex(r2x)) + } + + @Test + fun addInverseIsInfinity() { + // G + (-G) = infinity + val g = MutablePoint() + g.setAffine(ECPoint.GX, ECPoint.GY) + val negG = MutablePoint() + negG.setAffine(ECPoint.GX, FieldP.neg(ECPoint.GY)) + + val result = MutablePoint() + ECPoint.addPoints(result, g, negG) + assertTrue(result.isInfinity()) + } + + // ==================== Mixed addition ==================== + + @Test + fun addMixedMatchesFull() { + // addMixed should produce the same result as addPoints when q is affine + val three = intArrayOf(3, 0, 0, 0, 0, 0, 0, 0) + val p = MutablePoint() + ECPoint.mulG(p, three) // 3G in Jacobian (z ≠ 1) + + // Add G as affine + val mixed = MutablePoint() + ECPoint.addMixed(mixed, p, ECPoint.GX, ECPoint.GY) + val mx = IntArray(8) + val my = IntArray(8) + ECPoint.toAffine(mixed, mx, my) + + // Add G as Jacobian + val gJac = MutablePoint() + gJac.setAffine(ECPoint.GX, ECPoint.GY) + val full = MutablePoint() + ECPoint.addPoints(full, p, gJac) + val fx = IntArray(8) + val fy = IntArray(8) + ECPoint.toAffine(full, fx, fy) + + assertEquals(toHex(fx), toHex(mx)) + assertEquals(toHex(fy), toHex(my)) + } + + @Test + fun addMixedInfinityInput() { + val inf = MutablePoint() + inf.setInfinity() + val result = MutablePoint() + ECPoint.addMixed(result, inf, ECPoint.GX, ECPoint.GY) + val rx = IntArray(8) + val ry = IntArray(8) + ECPoint.toAffine(result, rx, ry) + assertEquals(toHex(ECPoint.GX), toHex(rx)) + } + + // ==================== Scalar multiplication ==================== + + @Test + fun mulGByOne() { + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + val result = MutablePoint() + ECPoint.mulG(result, one) + val rx = IntArray(8) + val ry = IntArray(8) + ECPoint.toAffine(result, rx, ry) + assertEquals(toHex(ECPoint.GX), toHex(rx)) + assertEquals(toHex(ECPoint.GY), toHex(ry)) + } + + @Test + fun mulGByZeroIsInfinity() { + val zero = IntArray(8) + val result = MutablePoint() + ECPoint.mulG(result, zero) + assertTrue(result.isInfinity()) + } + + @Test + fun mulGByNIsInfinity() { + // n·G = infinity (the group order) + val result = MutablePoint() + ECPoint.mulG(result, ScalarN.N) + assertTrue(result.isInfinity()) + } + + @Test + fun mulGMatchesMul() { + // mulG(k) should equal mul(G, k) + val k = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val gResult = MutablePoint() + ECPoint.mulG(gResult, k) + val gx = IntArray(8) + val gy = IntArray(8) + ECPoint.toAffine(gResult, gx, gy) + + val g = MutablePoint() + g.setAffine(ECPoint.GX, ECPoint.GY) + val mResult = MutablePoint() + ECPoint.mul(mResult, g, k) + val mx = IntArray(8) + val my = IntArray(8) + ECPoint.toAffine(mResult, mx, my) + + assertEquals(toHex(mx), toHex(gx)) + assertEquals(toHex(my), toHex(gy)) + } + + @Test + fun mulDoubleGSeparateVsCombined() { + // mulDoubleG(s, P, e) should equal mulG(s) + mul(P, e) + val s = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val e = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + + val p = MutablePoint() + val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0) + ECPoint.mulG(p, two) // P = 2·G + + // Combined + val combined = MutablePoint() + ECPoint.mulDoubleG(combined, s, p, e) + val cx = IntArray(8) + val cy = IntArray(8) + ECPoint.toAffine(combined, cx, cy) + + // Separate + val sG = MutablePoint() + ECPoint.mulG(sG, s) + val eP = MutablePoint() + ECPoint.mul(eP, p, e) + val sep = MutablePoint() + ECPoint.addPoints(sep, sG, eP) + val sx = IntArray(8) + val sy = IntArray(8) + ECPoint.toAffine(sep, sx, sy) + + assertEquals(toHex(sx), toHex(cx)) + assertEquals(toHex(sy), toHex(cy)) + } + + // ==================== liftX ==================== + + @Test + fun liftXGenerator() { + val x = IntArray(8) + val y = IntArray(8) + assertTrue(ECPoint.liftX(x, y, ECPoint.GX)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + // liftX returns even y + assertTrue(ECPoint.hasEvenY(y)) + } + + @Test + fun liftXInvalidX() { + // p itself is not a valid x coordinate + val x = IntArray(8) + val y = IntArray(8) + assertFalse(ECPoint.liftX(x, y, FieldP.P)) + } + + // ==================== Serialization round-trips ==================== + + @Test + fun compressDecompressRoundTrip() { + val compressed = ECPoint.serializeCompressed(ECPoint.GX, ECPoint.GY) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(ECPoint.parsePublicKey(compressed, x, y)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + assertEquals(toHex(ECPoint.GY), toHex(y)) + } + + @Test + fun uncompressedRoundTrip() { + val uncompressed = ECPoint.serializeUncompressed(ECPoint.GX, ECPoint.GY) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(ECPoint.parsePublicKey(uncompressed, x, y)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + assertEquals(toHex(ECPoint.GY), toHex(y)) + } + + @Test + fun parseInvalidKey() { + val x = IntArray(8) + val y = IntArray(8) + assertFalse(ECPoint.parsePublicKey(ByteArray(10), x, y)) + assertFalse(ECPoint.parsePublicKey(ByteArray(33), x, y)) // wrong prefix (0x00) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarNTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarNTest.kt new file mode 100644 index 000000000..60524ce00 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarNTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.secp256k1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Tests for scalar arithmetic modulo n. */ +class ScalarNTest { + private fun hex(s: String) = + U256.fromBytes( + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(), + ) + + private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) } + + // ==================== isValid ==================== + + @Test + fun isValidNormal() { + assertTrue(ScalarN.isValid(hex("0000000000000000000000000000000000000000000000000000000000000001"))) + } + + @Test + fun isValidZero() { + assertFalse(ScalarN.isValid(IntArray(8))) + } + + @Test + fun isValidN() { + // n itself is not valid (must be < n) + assertFalse(ScalarN.isValid(ScalarN.N)) + } + + @Test + fun isValidNMinus1() { + val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140") + assertTrue(ScalarN.isValid(nMinus1)) + } + + // ==================== Arithmetic identities ==================== + + @Test + fun addZeroIdentity() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + assertEquals(toHex(a), toHex(ScalarN.add(a, IntArray(8)))) + } + + @Test + fun subSelfIsZero() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + assertTrue(U256.isZero(ScalarN.sub(a, a))) + } + + @Test + fun addThenSubRoundTrips() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + assertEquals(toHex(a), toHex(ScalarN.sub(ScalarN.add(a, b), b))) + } + + @Test + fun negTwiceIsIdentity() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + assertEquals(toHex(a), toHex(ScalarN.neg(ScalarN.neg(a)))) + } + + @Test + fun addNegIsZero() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + assertTrue(U256.isZero(ScalarN.add(a, ScalarN.neg(a)))) + } + + // ==================== Multiplication ==================== + + @Test + fun mulOneIdentity() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(a), toHex(ScalarN.mul(a, one))) + } + + @Test + fun mulCommutative() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + assertEquals(toHex(ScalarN.mul(a, b)), toHex(ScalarN.mul(b, a))) + } + + @Test + fun mulDistributive() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val b = hex("3982f19bef1615bccfbb05e321c10e1d4cba3df0e841c2e41eeb6016347653c3") + val c = hex("0000000000000000000000000000000000000000000000000000000000000007") + val lhs = ScalarN.mul(a, ScalarN.add(b, c)) + val rhs = ScalarN.add(ScalarN.mul(a, b), ScalarN.mul(a, c)) + assertEquals(toHex(lhs), toHex(rhs)) + } + + // ==================== Inversion ==================== + + @Test + fun invMulIsOne() { + val a = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val aInv = ScalarN.inv(a) + val product = ScalarN.mul(a, aInv) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(product)) + } + + // ==================== Reduction near n ==================== + + @Test + fun addNearN() { + // (n-1) + 1 should wrap to 0 + val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140") + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertTrue(U256.isZero(ScalarN.add(nMinus1, one))) + } + + @Test + fun addNearNWrap() { + // (n-1) + 2 should give 1 + val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140") + val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0) + val result = ScalarN.add(nMinus1, two) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(result)) + } + + @Test + fun mulLargeScalars() { + // (n-1) * (n-1) ≡ 1 mod n (since (n-1) ≡ -1 and (-1)² = 1) + val nMinus1 = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140") + val result = ScalarN.mul(nMinus1, nMinus1) + val one = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + assertEquals(toHex(one), toHex(result)) + } + + @Test + fun negOfZeroIsZero() { + assertTrue(U256.isZero(ScalarN.neg(IntArray(8)))) + } + + @Test + fun reduceOfN() { + val result = ScalarN.reduce(ScalarN.N.copyOf()) + assertTrue(U256.isZero(result)) + } + + @Test + fun reduceOfSmall() { + val a = hex("0000000000000000000000000000000000000000000000000000000000000005") + val result = ScalarN.reduce(a) + assertEquals(toHex(a), toHex(result)) // No change — already < n + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256Test.kt new file mode 100644 index 000000000..c078330c8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256Test.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.secp256k1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Tests for 256-bit unsigned integer arithmetic. */ +class U256Test { + private fun hex(s: String) = + U256.fromBytes( + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray(), + ) + + private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) } + + // ==================== isZero / cmp ==================== + + @Test + fun isZeroTrue() = assertTrue(U256.isZero(IntArray(8))) + + @Test + fun isZeroFalse() = assertFalse(U256.isZero(intArrayOf(1, 0, 0, 0, 0, 0, 0, 0))) + + @Test + fun isZeroHighBit() = assertFalse(U256.isZero(intArrayOf(0, 0, 0, 0, 0, 0, 0, 1))) + + @Test + fun cmpEqual() = assertEquals(0, U256.cmp(hex("0000000000000000000000000000000000000000000000000000000000000001"), hex("0000000000000000000000000000000000000000000000000000000000000001"))) + + @Test + fun cmpLessThan() = assertEquals(-1, U256.cmp(hex("0000000000000000000000000000000000000000000000000000000000000001"), hex("0000000000000000000000000000000000000000000000000000000000000002"))) + + @Test + fun cmpGreaterThan() = assertEquals(1, U256.cmp(hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), hex("0000000000000000000000000000000000000000000000000000000000000001"))) + + // ==================== add / sub ==================== + + @Test + fun addSimple() { + val out = IntArray(8) + val carry = U256.addTo(out, hex("0000000000000000000000000000000000000000000000000000000000000001"), hex("0000000000000000000000000000000000000000000000000000000000000002")) + assertEquals("0000000000000000000000000000000000000000000000000000000000000003", toHex(out)) + assertEquals(0, carry) + } + + @Test + fun addOverflow() { + val out = IntArray(8) + val carry = U256.addTo(out, hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), hex("0000000000000000000000000000000000000000000000000000000000000001")) + assertEquals("0000000000000000000000000000000000000000000000000000000000000000", toHex(out)) + assertEquals(1, carry) + } + + @Test + fun addLimbCarry() { + val out = IntArray(8) + U256.addTo(out, hex("00000000000000000000000000000000000000000000000000000000ffffffff"), hex("0000000000000000000000000000000000000000000000000000000000000001")) + assertEquals("0000000000000000000000000000000000000000000000000000000100000000", toHex(out)) + } + + @Test + fun subSimple() { + val out = IntArray(8) + val borrow = U256.subTo(out, hex("0000000000000000000000000000000000000000000000000000000000000003"), hex("0000000000000000000000000000000000000000000000000000000000000001")) + assertEquals("0000000000000000000000000000000000000000000000000000000000000002", toHex(out)) + assertEquals(0, borrow) + } + + @Test + fun subUnderflow() { + val out = IntArray(8) + val borrow = U256.subTo(out, hex("0000000000000000000000000000000000000000000000000000000000000000"), hex("0000000000000000000000000000000000000000000000000000000000000001")) + assertEquals("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", toHex(out)) + assertEquals(1, borrow) + } + + // ==================== mulWide / sqrWide ==================== + + @Test + fun mulWideSmall() { + val out = IntArray(16) + U256.mulWide(out, hex("0000000000000000000000000000000000000000000000000000000000000003"), hex("0000000000000000000000000000000000000000000000000000000000000007")) + // 3 * 7 = 21 = 0x15 + assertEquals(0x15, out[0]) + for (i in 1 until 16) assertEquals(0, out[i]) + } + + @Test + fun mulWideLarge() { + // (2^128 - 1)² consistency: mulWide and sqrWide should match + val out1 = IntArray(16) + val out2 = IntArray(16) + val maxHalf = hex("00000000000000000000000000000000ffffffffffffffffffffffffffffffff") + U256.mulWide(out1, maxHalf, maxHalf) + U256.sqrWide(out2, maxHalf) + for (i in 0 until 16) assertEquals(out1[i], out2[i], "Limb $i mismatch") + // Lowest limb is 1 (from +1 in (2^128-1)² = 2^256 - 2^129 + 1) + assertEquals(1, out1[0]) + } + + @Test + fun sqrWideMatchesMulWide() { + // sqrWide(a) should produce the same result as mulWide(a, a) + val a = hex("67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530") + val mulResult = IntArray(16) + U256.mulWide(mulResult, a, a) + val sqrResult = IntArray(16) + U256.sqrWide(sqrResult, a) + for (i in 0 until 16) { + assertEquals(mulResult[i], sqrResult[i], "Limb $i mismatch") + } + } + + @Test + fun sqrWideMaxValue() { + // (2^256 - 1)^2 should match mulWide + val maxVal = hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + val mulResult = IntArray(16) + U256.mulWide(mulResult, maxVal, maxVal) + val sqrResult = IntArray(16) + U256.sqrWide(sqrResult, maxVal) + for (i in 0 until 16) { + assertEquals(mulResult[i], sqrResult[i], "Limb $i mismatch for max value sqr") + } + } + + // ==================== fromBytes / toBytes ==================== + + @Test + fun bytesRoundTrip() { + val hex = "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" + val bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val limbs = U256.fromBytes(bytes) + val back = U256.toBytes(limbs) + assertEquals(hex.lowercase(), back.joinToString("") { "%02x".format(it) }) + } + + @Test + fun bytesZero() { + val limbs = U256.fromBytes(ByteArray(32)) + assertTrue(U256.isZero(limbs)) + } + + // ==================== getNibble ==================== + + @Test + fun getNibbleValues() { + // 0xAB at the lowest byte = limb[0] = 0x...AB + val a = hex("00000000000000000000000000000000000000000000000000000000000000ab") + assertEquals(0xB, U256.getNibble(a, 0)) // lowest nibble + assertEquals(0xA, U256.getNibble(a, 1)) // second nibble + assertEquals(0, U256.getNibble(a, 2)) // third nibble + } + + @Test + fun getNibbleHighBits() { + val a = hex("f000000000000000000000000000000000000000000000000000000000000000") + assertEquals(0xF, U256.getNibble(a, 63)) // highest nibble + assertEquals(0, U256.getNibble(a, 62)) + } + + // ==================== testBit ==================== + + @Test + fun testBitLow() { + val a = hex("0000000000000000000000000000000000000000000000000000000000000005") // bits 0 and 2 + assertTrue(U256.testBit(a, 0)) + assertFalse(U256.testBit(a, 1)) + assertTrue(U256.testBit(a, 2)) + } + + @Test + fun testBitHigh() { + val a = hex("8000000000000000000000000000000000000000000000000000000000000000") // bit 255 + assertTrue(U256.testBit(a, 255)) + assertFalse(U256.testBit(a, 254)) + } + + // ==================== xor ==================== + + @Test + fun xorBasic() { + val out = IntArray(8) + U256.xorTo(out, hex("ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00"), hex("0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f")) + assertEquals("f00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00f", toHex(out)) + } +}