diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Glv.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Glv.kt index 0f5e6abb8..2b3b24e35 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Glv.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Glv.kt @@ -34,13 +34,14 @@ package com.vitorpamplona.quartz.utils.secp256k1 // algorithm with precomputed lattice basis vectors. // // wNAF (windowed Non-Adjacent Form) is a scalar encoding where non-zero digits are odd -// and separated by at least w-1 zero digits. Width-5 wNAF uses digits ±{1,3,...,15} -// with a table of 8 odd multiples. For a 128-bit scalar, this produces ~26 non-zero -// digits instead of ~60 for simple 4-bit windowing. +// and separated by at least w-1 zero digits. Width-w wNAF uses digits ±{1,3,...,2^(w-1)-1} +// with a table of 2^(w-2) odd multiples. For a 128-bit scalar with width 5, this produces +// ~26 non-zero digits; with width 8, ~16 digits. // -// Together, GLV + wNAF-5 enables signature verification (s·G + e·P) as 4 interleaved -// 128-bit streams with ~130 shared doublings and ~44 additions, roughly halving the -// cost compared to two separate 256-bit scalar multiplications. +// These techniques are used throughout the secp256k1 package: +// - mul (arbitrary point): GLV + wNAF-5, ~130 shared doublings +// - mulG (generator): Comb method (Point.kt), only 3 doublings + ~43 table lookups +// - mulDoubleG (verify): Strauss + GLV + wNAF, 4 interleaved 128-bit streams // ===================================================================================== /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodecTest.kt new file mode 100644 index 000000000..03bcdb489 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodecTest.kt @@ -0,0 +1,172 @@ +/* + * 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 KeyCodec: key parsing, serialization, liftX, hasEvenY. */ +class KeyCodecTest { + private fun toHex(a: IntArray) = U256.toBytes(a).joinToString("") { "%02x".format(it) } + + // ==================== liftX ==================== + + @Test + fun liftXGenerator() { + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.liftX(x, y, ECPoint.GX)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + assertTrue(KeyCodec.hasEvenY(y)) + } + + @Test + fun liftXInvalidFieldElement() { + // p itself is not a valid x + val x = IntArray(8) + val y = IntArray(8) + assertFalse(KeyCodec.liftX(x, y, FieldP.P)) + } + + @Test + fun liftXNotOnCurve() { + // x=2: y² = 8+7 = 15. 15 is not a quadratic residue mod p. + val x = IntArray(8) + val y = IntArray(8) + val two = intArrayOf(2, 0, 0, 0, 0, 0, 0, 0) + // This may or may not be on the curve — just check it doesn't crash + KeyCodec.liftX(x, y, two) // result doesn't matter, just no exception + } + + @Test + fun liftXAlwaysReturnsEvenY() { + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.liftX(x, y, ECPoint.GX)) + assertTrue(KeyCodec.hasEvenY(y), "liftX should always return even y") + } + + // ==================== hasEvenY ==================== + + @Test + fun hasEvenYForEvenValue() { + assertTrue(KeyCodec.hasEvenY(intArrayOf(2, 0, 0, 0, 0, 0, 0, 0))) + assertTrue(KeyCodec.hasEvenY(intArrayOf(0, 0, 0, 0, 0, 0, 0, 0))) + } + + @Test + fun hasEvenYForOddValue() { + assertFalse(KeyCodec.hasEvenY(intArrayOf(1, 0, 0, 0, 0, 0, 0, 0))) + assertFalse(KeyCodec.hasEvenY(intArrayOf(3, 0, 0, 0, 0, 0, 0, 0))) + } + + // ==================== parsePublicKey ==================== + + @Test + fun parseCompressedEvenY() { + val compressed = KeyCodec.serializeCompressed(ECPoint.GX, ECPoint.GY) + assertEquals(0x02.toByte(), compressed[0]) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.parsePublicKey(compressed, x, y)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + assertEquals(toHex(ECPoint.GY), toHex(y)) + } + + @Test + fun parseCompressedOddY() { + val negGy = FieldP.neg(ECPoint.GY) + val compressed = KeyCodec.serializeCompressed(ECPoint.GX, negGy) + assertEquals(0x03.toByte(), compressed[0]) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.parsePublicKey(compressed, x, y)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + assertEquals(toHex(negGy), toHex(y)) + } + + @Test + fun parseUncompressed() { + val uncompressed = KeyCodec.serializeUncompressed(ECPoint.GX, ECPoint.GY) + assertEquals(0x04.toByte(), uncompressed[0]) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.parsePublicKey(uncompressed, x, y)) + assertEquals(toHex(ECPoint.GX), toHex(x)) + assertEquals(toHex(ECPoint.GY), toHex(y)) + } + + @Test + fun parseInvalidSizes() { + val x = IntArray(8) + val y = IntArray(8) + assertFalse(KeyCodec.parsePublicKey(ByteArray(0), x, y)) + assertFalse(KeyCodec.parsePublicKey(ByteArray(10), x, y)) + assertFalse(KeyCodec.parsePublicKey(ByteArray(32), x, y)) + assertFalse(KeyCodec.parsePublicKey(ByteArray(34), x, y)) + assertFalse(KeyCodec.parsePublicKey(ByteArray(64), x, y)) + assertFalse(KeyCodec.parsePublicKey(ByteArray(66), x, y)) + } + + @Test + fun parseInvalidPrefix() { + val x = IntArray(8) + val y = IntArray(8) + assertFalse(KeyCodec.parsePublicKey(ByteArray(33), x, y)) // prefix 0x00 + assertFalse(KeyCodec.parsePublicKey(ByteArray(65), x, y)) // prefix 0x00 + } + + @Test + fun parseUncompressedNotOnCurve() { + // Valid-looking 65 bytes but y doesn't satisfy y² = x³ + 7 + val fake = ByteArray(65) + fake[0] = 0x04 + fake[1] = 0x01 // x = 1 (padded) + fake[33] = 0x01 // y = 1 (padded) — 1² ≠ 1³ + 7 + val x = IntArray(8) + val y = IntArray(8) + assertFalse(KeyCodec.parsePublicKey(fake, x, y)) + } + + // ==================== Serialization round-trips ==================== + + @Test + fun compressDecompressRoundTrip() { + val compressed = KeyCodec.serializeCompressed(ECPoint.GX, ECPoint.GY) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.parsePublicKey(compressed, x, y)) + val recompressed = KeyCodec.serializeCompressed(x, y) + assertEquals(compressed.toList(), recompressed.toList()) + } + + @Test + fun uncompressedRoundTrip() { + val uncompressed = KeyCodec.serializeUncompressed(ECPoint.GX, ECPoint.GY) + val x = IntArray(8) + val y = IntArray(8) + assertTrue(KeyCodec.parsePublicKey(uncompressed, x, y)) + val reser = KeyCodec.serializeUncompressed(x, y) + assertEquals(uncompressed.toList(), reser.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 516ffcd42..40b128054 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.utils.secp256k1 import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -280,4 +281,59 @@ class Secp256k1Test { val sig2 = Secp256k1.signSchnorr(msg, privKey, null) assertEquals(sig1.toList(), sig2.toList()) } + + @Test + fun ecdhXOnlyMatchesTweakMul() { + // ecdhXOnly should produce the same x as pubKeyTweakMulCompact + val privKey = + "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" + .hexToByteArray() + val pubKeyXOnly = + Secp256k1Instance + .compressedPubKeyFor( + "3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3".hexToByteArray(), + ).copyOfRange(1, 33) + val viaEcdh = + com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 + .ecdhXOnly(pubKeyXOnly, privKey) + val viaTweak = Secp256k1Instance.pubKeyTweakMulCompact(pubKeyXOnly, privKey) + assertEquals(viaEcdh.toHexKey(), viaTweak.toHexKey()) + } + + @Test + fun ecdhXOnlySymmetric() { + // A→B and B→A should produce the same shared secret + val privA = + "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" + .hexToByteArray() + val privB = + "3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3" + .hexToByteArray() + val pubA = Secp256k1Instance.compressedPubKeyFor(privA).copyOfRange(1, 33) + val pubB = Secp256k1Instance.compressedPubKeyFor(privB).copyOfRange(1, 33) + val secretAB = + com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 + .ecdhXOnly(pubB, privA) + val secretBA = + com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 + .ecdhXOnly(pubA, privB) + assertEquals(secretAB.toHexKey(), secretBA.toHexKey()) + } + + @Test + fun taggedHashConsistency() { + // tagged_hash("BIP0340/challenge", msg) should equal SHA256(SHA256(tag) || SHA256(tag) || msg) + val tag = "BIP0340/challenge" + val msg = ByteArray(32) { 0x42 } + val result = + com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 + .taggedHash(tag, msg) + val tagHash = + com.vitorpamplona.quartz.utils.sha256 + .sha256(tag.encodeToByteArray()) + val expected = + com.vitorpamplona.quartz.utils.sha256 + .sha256(tagHash + tagHash + msg) + assertEquals(expected.toHexKey(), result.toHexKey()) + } } 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 index c078330c8..854dd31fb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256Test.kt @@ -205,4 +205,34 @@ class U256Test { U256.xorTo(out, hex("ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00"), hex("0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f")) assertEquals("f00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00f", toHex(out)) } + + @Test + fun toBytesIntoAtOffset() { + val a = hex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + val dest = ByteArray(64) + U256.toBytesInto(a, dest, 16) // write at offset 16 + // First 16 bytes should be zero + for (i in 0 until 16) assertEquals(0, dest[i].toInt()) + // Bytes 16-47 should contain the value + assertEquals(0x01, dest[16].toInt() and 0xFF) + assertEquals(0x20, dest[47].toInt() and 0xFF) + } + + @Test + fun copyIntoTest() { + val src = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + val dst = IntArray(8) + U256.copyInto(dst, src) + for (i in 0 until 8) assertEquals(src[i], dst[i]) + } + + @Test + fun fromBytesWithOffset() { + val fullArray = ByteArray(64) + // Put a known value at offset 32 + val expected = hex("67e56582298859ddae725f972992a07c6c4fb9f62a8fff58ce3ca926a1063530") + U256.toBytesInto(expected, fullArray, 32) + val decoded = U256.fromBytes(fullArray, 32) + for (i in 0 until 8) assertEquals(expected[i], decoded[i]) + } }