From 4c3b31fe8ebecc696c7e5d79f9e6206a59c0af2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 04:47:19 +0000 Subject: [PATCH] feat: replace fr.acinq.secp256k1 with pure-Kotlin secp256k1 implementation Implements all secp256k1 operations used by Secp256k1Instance in pure Kotlin, eliminating the dependency on native secp256k1 bindings (fr.acinq.secp256k1-kmp). Implementation: - Field.kt: 256-bit unsigned integer arithmetic, field mod p, scalar mod n - Point.kt: EC point operations (Jacobian coords), point parsing/serialization - Secp256k1.kt: Public API - pubkeyCreate, pubKeyCompress, secKeyVerify, signSchnorr/verifySchnorr (BIP-340), privKeyTweakAdd, pubKeyTweakMul Tests (36 total): - All 19 BIP-340 test vectors (signing vectors 0-3, 15-18; verify vectors 4-14) - ACINQ test vectors for key creation, compression, secKeyVerify, privKeyTweakAdd, pubKeyTweakMul - ECDH symmetry and sign/verify round-trip tests Secp256k1Instance is now a concrete object in commonMain instead of expect/actual, delegating to the pure-Kotlin implementation. All existing NIP-44 and BIP-32 tests pass with the new implementation. https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg --- .../quartz/utils/Secp256k1Instance.android.kt | 59 -- .../quartz/utils/Secp256k1Instance.kt | 20 +- .../quartz/utils/secp256k1/Field.kt | 559 ++++++++++++++++++ .../quartz/utils/secp256k1/Point.kt | 293 +++++++++ .../quartz/utils/secp256k1/Secp256k1.kt | 247 ++++++++ .../quartz/utils/secp256k1/SchnorrTest.kt | 382 ++++++++++++ .../quartz/utils/secp256k1/Secp256k1Test.kt | 241 ++++++++ .../quartz/utils/Secp256k1Instance.jvm.kt | 59 -- .../quartz/utils/Secp256k1Instance.native.kt | 59 -- 9 files changed, 1734 insertions(+), 185 deletions(-) delete mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Field.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/SchnorrTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Test.kt delete mode 100644 quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt delete mode 100644 quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt deleted file mode 100644 index 789fe2404..000000000 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 - -import fr.acinq.secp256k1.Secp256k1 - -actual object Secp256k1Instance { - private val h02 = Hex.decode("02") - private val secp256k1 = Secp256k1.get() - - actual fun compressedPubKeyFor(privKey: ByteArray) = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)) - - actual fun isPrivateKeyValid(il: ByteArray): Boolean = secp256k1.secKeyVerify(il) - - actual fun signSchnorr( - data: ByteArray, - privKey: ByteArray, - nonce: ByteArray?, - ): ByteArray = secp256k1.signSchnorr(data, privKey, nonce) - - actual fun signSchnorr( - data: ByteArray, - privKey: ByteArray, - ): ByteArray = secp256k1.signSchnorr(data, privKey, null) - - actual fun verifySchnorr( - signature: ByteArray, - hash: ByteArray, - pubKey: ByteArray, - ): Boolean = secp256k1.verifySchnorr(signature, hash, pubKey) - - actual fun privateKeyAdd( - first: ByteArray, - second: ByteArray, - ): ByteArray = secp256k1.privKeyTweakAdd(first, second) - - actual fun pubKeyTweakMulCompact( - pubKey: ByteArray, - privateKey: ByteArray, - ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index 594b025f9..5067c4d5b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -20,35 +20,39 @@ */ package com.vitorpamplona.quartz.utils -expect object Secp256k1Instance { - fun compressedPubKeyFor(privKey: ByteArray): ByteArray +import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 - fun isPrivateKeyValid(il: ByteArray): Boolean +object Secp256k1Instance { + private val h02 = Hex.decode("02") + + fun compressedPubKeyFor(privKey: ByteArray) = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privKey)) + + fun isPrivateKeyValid(il: ByteArray): Boolean = Secp256k1.secKeyVerify(il) fun signSchnorr( data: ByteArray, privKey: ByteArray, nonce: ByteArray? = RandomInstance.bytes(32), - ): ByteArray + ): ByteArray = Secp256k1.signSchnorr(data, privKey, nonce) fun signSchnorr( data: ByteArray, privKey: ByteArray, - ): ByteArray + ): ByteArray = Secp256k1.signSchnorr(data, privKey, null) fun verifySchnorr( signature: ByteArray, hash: ByteArray, pubKey: ByteArray, - ): Boolean + ): Boolean = Secp256k1.verifySchnorr(signature, hash, pubKey) fun privateKeyAdd( first: ByteArray, second: ByteArray, - ): ByteArray + ): ByteArray = Secp256k1.privKeyTweakAdd(first, second) fun pubKeyTweakMulCompact( pubKey: ByteArray, privateKey: ByteArray, - ): ByteArray + ): ByteArray = Secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Field.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Field.kt new file mode 100644 index 000000000..773007476 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Field.kt @@ -0,0 +1,559 @@ +/* + * 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 + +/** + * 256-bit unsigned integer arithmetic for secp256k1 field and scalar operations. + * + * Numbers are represented as IntArray(8) in little-endian order where each element + * holds 32 bits (treated as unsigned). Element [0] is the least significant limb. + */ +internal object U256 { + val ZERO = IntArray(8) + + fun isZero(a: IntArray): Boolean { + for (i in 0 until 8) if (a[i] != 0) return false + return true + } + + /** Compare: returns negative if a < b, 0 if equal, positive if a > b */ + fun cmp( + a: IntArray, + b: IntArray, + ): Int { + for (i in 7 downTo 0) { + val ai = a[i].toLong() and 0xFFFFFFFFL + val bi = b[i].toLong() and 0xFFFFFFFFL + if (ai < bi) return -1 + if (ai > bi) return 1 + } + return 0 + } + + /** a + b, returns (result, carry) where carry is 0 or 1 */ + fun addCarry( + a: IntArray, + b: IntArray, + ): Pair { + val r = IntArray(8) + var carry = 0L + for (i in 0 until 8) { + carry += (a[i].toLong() and 0xFFFFFFFFL) + (b[i].toLong() and 0xFFFFFFFFL) + r[i] = carry.toInt() + carry = carry ushr 32 + } + return Pair(r, carry.toInt()) + } + + /** a - b, returns (result, borrow) where borrow is 0 or 1 */ + fun subBorrow( + a: IntArray, + b: IntArray, + ): Pair { + val r = IntArray(8) + var borrow = 0L + for (i in 0 until 8) { + val diff = (a[i].toLong() and 0xFFFFFFFFL) - (b[i].toLong() and 0xFFFFFFFFL) - borrow + r[i] = diff.toInt() + borrow = if (diff < 0) 1L else 0L + } + return Pair(r, borrow.toInt()) + } + + /** Full 256x256 -> 512 bit multiplication. Result is IntArray(16). */ + fun mulWide( + a: IntArray, + b: IntArray, + ): IntArray { + val r = IntArray(16) + for (i in 0 until 8) { + var carry = 0L + val ai = a[i].toLong() and 0xFFFFFFFFL + for (j in 0 until 8) { + val prod = ai * (b[j].toLong() and 0xFFFFFFFFL) + (r[i + j].toLong() and 0xFFFFFFFFL) + carry + r[i + j] = prod.toInt() + carry = prod ushr 32 + } + r[i + 8] = carry.toInt() + } + return r + } + + /** Multiply 256-bit number by a small (fits in Long) constant. Result is IntArray(9). */ + fun mulSmall( + a: IntArray, + b: Long, + ): IntArray { + val r = IntArray(9) + var carry = 0L + for (i in 0 until 8) { + carry += (a[i].toLong() and 0xFFFFFFFFL) * b + r[i] = carry.toInt() + carry = carry ushr 32 + } + r[8] = carry.toInt() + return r + } + + /** Convert big-endian 32-byte array to IntArray(8) little-endian limbs */ + fun fromBytes(bytes: ByteArray): IntArray { + require(bytes.size == 32) { "Expected 32 bytes, got ${bytes.size}" } + val r = IntArray(8) + for (i in 0 until 8) { + val offset = 28 - i * 4 + r[i] = ((bytes[offset].toInt() and 0xFF) shl 24) or + ((bytes[offset + 1].toInt() and 0xFF) shl 16) or + ((bytes[offset + 2].toInt() and 0xFF) shl 8) or + (bytes[offset + 3].toInt() and 0xFF) + } + return r + } + + /** Convert IntArray(8) little-endian limbs to big-endian 32-byte array */ + fun toBytes(a: IntArray): ByteArray { + val r = ByteArray(32) + for (i in 0 until 8) { + val offset = 28 - i * 4 + r[offset] = (a[i] ushr 24).toByte() + r[offset + 1] = (a[i] ushr 16).toByte() + r[offset + 2] = (a[i] ushr 8).toByte() + r[offset + 3] = a[i].toByte() + } + return r + } + + /** Check if bit at position pos is set (pos 0 = LSB) */ + fun testBit( + a: IntArray, + pos: Int, + ): Boolean { + val limb = pos / 32 + val bit = pos % 32 + return (a[limb] ushr bit) and 1 == 1 + } + + /** XOR two 256-bit values */ + fun xor( + a: IntArray, + b: IntArray, + ): IntArray { + val r = IntArray(8) + for (i in 0 until 8) r[i] = a[i] xor b[i] + return r + } + + fun clone(a: IntArray): IntArray = a.copyOf() +} + +/** + * Field arithmetic modulo p = 2^256 - 2^32 - 977 (= 2^256 - 4294968273). + * This is the base field of the secp256k1 curve. + */ +internal object FieldP { + // p = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F + val P = + intArrayOf( + 0xFFFFFC2F.toInt(), + 0xFFFFFFFE.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + ) + + // 2^256 mod p = 2^32 + 977 = 0x1000003D1 (fits in 33 bits) + // As limbs: [977, 1, 0, 0, 0, 0, 0, 0] + private val P_COMPLEMENT_LIMBS = intArrayOf(977, 1, 0, 0, 0, 0, 0, 0) + + /** Reduce a value that might be >= p (but < 2p) */ + fun reduce(a: IntArray): IntArray = + if (U256.cmp(a, P) >= 0) { + U256.subBorrow(a, P).first + } else { + a + } + + /** Reduce a wide 512-bit product mod p */ + fun reduceWide(w: IntArray): IntArray { + // w = hi * 2^256 + lo + // ≡ lo + hi * (2^32 + 977) (mod p) + // + // Split: hi * (2^32 + 977) = (hi << 32) + hi * 977 + // hi * 977: each limb product fits in 42 bits, no overflow + // hi << 32: shift limbs by one position + + val lo = IntArray(8) + val hi = IntArray(8) + for (i in 0 until 8) { + lo[i] = w[i] + hi[i] = w[i + 8] + } + + // Compute hi * 977 (result fits in 266 bits = 9 limbs) + val hiTimes977 = U256.mulSmall(hi, 977L) + + // Compute lo + hi * 977 + (hi << 32) + // hi << 32 means: [0, hi[0], hi[1], ..., hi[7]] (9 limbs) + val result = IntArray(8) + var carry = 0L + for (i in 0 until 8) { + carry += (lo[i].toLong() and 0xFFFFFFFFL) + + (hiTimes977[i].toLong() and 0xFFFFFFFFL) + + if (i > 0) (hi[i - 1].toLong() and 0xFFFFFFFFL) else 0L + result[i] = carry.toInt() + carry = carry ushr 32 + } + // Remaining overflow: carry + hiTimes977[8] + hi[7] + var overflow = + carry + + (hiTimes977[8].toLong() and 0xFFFFFFFFL) + + (hi[7].toLong() and 0xFFFFFFFFL) + + // Second round: overflow * (2^32 + 977) + // overflow is at most ~35 bits, so overflow * 977 fits in Long easily + if (overflow > 0) { + val ov977 = overflow * 977L + var c2 = 0L + // Add ov977 to result[0..1], and overflow to result[1..2] (the <<32 part) + for (i in 0 until 8) { + c2 += (result[i].toLong() and 0xFFFFFFFFL) + if (i == 0) c2 += (ov977 and 0xFFFFFFFFL) + if (i == 1) c2 += (ov977 ushr 32) + (overflow and 0xFFFFFFFFL) + if (i == 2) c2 += (overflow ushr 32) + result[i] = c2.toInt() + c2 = c2 ushr 32 + } + // c2 should be 0 or very small; if > 0, do a third tiny round + if (c2 > 0) { + val tiny = c2 * 977L + var c3 = 0L + for (i in 0 until 3) { + c3 += (result[i].toLong() and 0xFFFFFFFFL) + if (i == 0) c3 += (tiny and 0xFFFFFFFFL) + if (i == 1) c3 += (tiny ushr 32) + (c2 and 0xFFFFFFFFL) + if (i == 2) c3 += (c2 ushr 32) + result[i] = c3.toInt() + c3 = c3 ushr 32 + } + } + } + + // Final reduction: result might still be >= p + return reduce(result) + } + + fun add( + a: IntArray, + b: IntArray, + ): IntArray { + val (sum, carry) = U256.addCarry(a, b) + return if (carry != 0) { + // sum + 2^256 ≡ sum + (2^32 + 977) (mod p) + // carry is always 1 here, so just add the constant + val (r2, c2) = U256.addCarry(sum, P_COMPLEMENT_LIMBS) + if (c2 != 0) reduce(U256.addCarry(r2, P_COMPLEMENT_LIMBS).first) else reduce(r2) + } else { + reduce(sum) + } + } + + fun sub( + a: IntArray, + b: IntArray, + ): IntArray { + val (diff, borrow) = U256.subBorrow(a, b) + return if (borrow != 0) { + // Add p back + U256.addCarry(diff, P).first + } else { + diff + } + } + + fun mul( + a: IntArray, + b: IntArray, + ): IntArray = reduceWide(U256.mulWide(a, b)) + + fun sqr(a: IntArray): IntArray = mul(a, a) + + fun neg(a: IntArray): IntArray = if (U256.isZero(a)) IntArray(8) else U256.subBorrow(P, a).first + + /** Modular inverse using Fermat's little theorem: a^(p-2) mod p */ + fun inv(a: IntArray): IntArray { + require(!U256.isZero(a)) { "Cannot invert zero" } + // p - 2 = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2D + // Use square-and-multiply with an optimized addition chain for secp256k1 + return powModP(a, P_MINUS_2) + } + + // p - 2 + private val P_MINUS_2 = + intArrayOf( + 0xFFFFFC2D.toInt(), + 0xFFFFFFFE.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + ) + + // (p + 1) / 4 = 3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFBFFFFF0C + // Used for computing square roots since p ≡ 3 (mod 4) + private val P_PLUS_1_DIV_4 = + intArrayOf( + 0xBFFFFF0C.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0x3FFFFFFF, + ) + + /** Square root mod p. Returns null if a is not a quadratic residue. */ + fun sqrt(a: IntArray): IntArray? { + // Since p ≡ 3 (mod 4), sqrt(a) = a^((p+1)/4) mod p + val r = powModP(a, P_PLUS_1_DIV_4) + // Verify: r^2 == a (mod p) + return if (U256.cmp(mul(r, r), reduce(a)) == 0) r else null + } + + /** Generic modular exponentiation mod p using square-and-multiply */ + private fun powModP( + base: IntArray, + exp: IntArray, + ): IntArray { + var result = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) // 1 + var b = base.copyOf() + // Find highest set bit + var highBit = 255 + while (highBit >= 0 && !U256.testBit(exp, highBit)) highBit-- + for (i in 0..highBit) { + if (U256.testBit(exp, i)) { + result = mul(result, b) + } + if (i < highBit) { + b = sqr(b) + } + } + return result + } +} + +/** + * Scalar arithmetic modulo n (the order of the secp256k1 group). + * n = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + */ +internal object ScalarN { + val N = + intArrayOf( + 0xD0364141.toInt(), + 0xBFD25E8C.toInt(), + 0xAF48A03B.toInt(), + 0xBAAEDCE6.toInt(), + 0xFFFFFFFE.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + ) + + /** Check if 0 < a < n */ + fun isValid(a: IntArray): Boolean { + if (U256.isZero(a)) return false + return U256.cmp(a, N) < 0 + } + + /** Reduce mod n: for values up to 2n */ + fun reduce(a: IntArray): IntArray = + if (U256.cmp(a, N) >= 0) { + U256.subBorrow(a, N).first + } else { + a + } + + /** Reduce a wide 512-bit value mod n */ + fun reduceWide(w: IntArray): IntArray { + // Barrett-like reduction using schoolbook division + // For simplicity, we do repeated subtraction with shifts + // Since n is close to 2^256, the quotient is at most ~2^256. + // We use a simpler approach: reduce by subtracting n * (hi_estimate) + // + // Actually, we'll use the same approach as field reduction but + // for n which doesn't have a nice sparse form. + // n = 2^256 - nComplement where nComplement = 2^256 - n + // = 0x14551231950B75FC4402DA1732FC9BEBF + + val lo = IntArray(8) + val hi = IntArray(8) + for (i in 0 until 8) { + lo[i] = w[i] + hi[i] = w[i + 8] + } + + if (U256.isZero(hi)) return reduce(lo) + + // 2^256 mod n = N_COMPLEMENT + // hi * 2^256 ≡ hi * N_COMPLEMENT (mod n) + val hiTimesNC = U256.mulWide(hi, N_COMPLEMENT) + // This is at most 384 bits. Add lo. + val sum = IntArray(16) + var carry = 0L + for (i in 0 until 16) { + carry += (hiTimesNC[i].toLong() and 0xFFFFFFFFL) + + if (i < 8) (lo[i].toLong() and 0xFFFFFFFFL) else 0L + sum[i] = carry.toInt() + carry = carry ushr 32 + } + + // Now sum might be up to ~384 bits. Repeat reduction. + val lo2 = IntArray(8) + val hi2 = IntArray(8) + for (i in 0 until 8) { + lo2[i] = sum[i] + hi2[i] = sum[i + 8] + } + + if (U256.isZero(hi2)) return reduce(lo2) + + // Another round + val hi2TimesNC = U256.mulWide(hi2, N_COMPLEMENT) + var carry2 = 0L + val result = IntArray(8) + for (i in 0 until 8) { + carry2 += (lo2[i].toLong() and 0xFFFFFFFFL) + (hi2TimesNC[i].toLong() and 0xFFFFFFFFL) + result[i] = carry2.toInt() + carry2 = carry2 ushr 32 + } + // Handle any remaining overflow + var overflow = carry2 + for (i in 8 until 16) { + overflow += (hi2TimesNC[i].toLong() and 0xFFFFFFFFL) + } + // overflow * 2^256 ≡ overflow * N_COMPLEMENT (mod n) + if (overflow > 0) { + val corr = U256.mulSmall(N_COMPLEMENT, overflow) + var c3 = 0L + for (i in 0 until 8) { + c3 += (result[i].toLong() and 0xFFFFFFFFL) + (corr[i].toLong() and 0xFFFFFFFFL) + result[i] = c3.toInt() + c3 = c3 ushr 32 + } + } + + // Final reductions + var r = result + while (U256.cmp(r, N) >= 0) { + r = U256.subBorrow(r, N).first + } + return r + } + + // 2^256 - n = 14551231950B75FC4402DA1732FC9BEBF + // In little-endian limbs: + private val N_COMPLEMENT = + intArrayOf( + 0x2FC9BEBF.toInt(), + 0x402DA173.toInt(), + 0x50B75FC4.toInt(), + 0x45512319.toInt(), + 0x00000001, + 0, + 0, + 0, + ) + + fun add( + a: IntArray, + b: IntArray, + ): IntArray { + val (sum, carry) = U256.addCarry(a, b) + return if (carry != 0) { + // sum + 2^256 ≡ sum + N_COMPLEMENT (mod n) + val (r2, _) = U256.addCarry(sum, N_COMPLEMENT) + reduce(r2) + } else { + reduce(sum) + } + } + + fun sub( + a: IntArray, + b: IntArray, + ): IntArray { + val (diff, borrow) = U256.subBorrow(a, b) + return if (borrow != 0) { + U256.addCarry(diff, N).first + } else { + diff + } + } + + fun mul( + a: IntArray, + b: IntArray, + ): IntArray = reduceWide(U256.mulWide(a, b)) + + fun neg(a: IntArray): IntArray = if (U256.isZero(a)) IntArray(8) else U256.subBorrow(N, a).first + + /** Modular inverse using Fermat's little theorem: a^(n-2) mod n */ + fun inv(a: IntArray): IntArray { + require(!U256.isZero(a)) { "Cannot invert zero" } + return powModN(a, N_MINUS_2) + } + + // n - 2 + private val N_MINUS_2 = + intArrayOf( + 0xD036413F.toInt(), + 0xBFD25E8C.toInt(), + 0xAF48A03B.toInt(), + 0xBAAEDCE6.toInt(), + 0xFFFFFFFE.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + 0xFFFFFFFF.toInt(), + ) + + private fun powModN( + base: IntArray, + exp: IntArray, + ): IntArray { + var result = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) + var b = base.copyOf() + var highBit = 255 + while (highBit >= 0 && !U256.testBit(exp, highBit)) highBit-- + for (i in 0..highBit) { + if (U256.testBit(exp, i)) { + result = mul(result, b) + } + if (i < highBit) { + b = mul(b, b) + } + } + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt new file mode 100644 index 000000000..2d00f66dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt @@ -0,0 +1,293 @@ +/* + * 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 + +/** + * Elliptic curve point operations on the secp256k1 curve: y² = x³ + 7 (mod p). + * Uses Jacobian coordinates (X, Y, Z) where affine (x, y) = (X/Z², Y/Z³). + * The point at infinity is represented by Z = 0. + */ +internal class JPoint( + val x: IntArray, + val y: IntArray, + val z: IntArray, +) { + companion object { + val INFINITY = JPoint(IntArray(8), intArrayOf(1, 0, 0, 0, 0, 0, 0, 0), IntArray(8)) + } + + fun isInfinity(): Boolean = U256.isZero(z) +} + +internal object ECPoint { + // Generator point G + val GX = + intArrayOf( + 0x16F81798.toInt(), + 0x59F2815B.toInt(), + 0x2DCE28D9.toInt(), + 0x029BFCDB.toInt(), + 0xCE870B07.toInt(), + 0x55A06295.toInt(), + 0xF9DCBBAC.toInt(), + 0x79BE667E.toInt(), + ) + val GY = + intArrayOf( + 0xFB10D4B8.toInt(), + 0x9C47D08F.toInt(), + 0xA6855419.toInt(), + 0xFD17B448.toInt(), + 0x0E1108A8.toInt(), + 0x5DA4FBFC.toInt(), + 0x26A3C465.toInt(), + 0x483ADA77.toInt(), + ) + + val G = JPoint(GX.copyOf(), GY.copyOf(), intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)) + + // Curve constant b = 7 + private val B = intArrayOf(7, 0, 0, 0, 0, 0, 0, 0) + + /** + * Point doubling in Jacobian coordinates. + * Formula from https://hyperelliptic.org/EFD/g1p/auto-shortw-jacobian-0.html#doubling-dbl-2009-l + */ + fun double(p: JPoint): JPoint { + if (p.isInfinity()) return JPoint.INFINITY + + val x = p.x + val y = p.y + val z = p.z + + val a = FieldP.sqr(x) // A = X1² + val b = FieldP.sqr(y) // B = Y1² + val c = FieldP.sqr(b) // C = B² + val xPlusB = FieldP.add(x, b) + val d = + FieldP.sub( + FieldP.sub(FieldP.sqr(xPlusB), a), + c, + ) + val d2 = FieldP.add(d, d) // D = 2*((X1+B)²-A-C) + val e = FieldP.add(FieldP.add(a, a), a) // E = 3*A + val f = FieldP.sqr(e) // F = E² + + val x3 = FieldP.sub(f, FieldP.add(d2, d2)) // X3 = F - 2*D + val c8 = + FieldP.add( + FieldP.add(FieldP.add(c, c), FieldP.add(c, c)), + FieldP.add(FieldP.add(c, c), FieldP.add(c, c)), + ) // 8*C + val y3 = FieldP.sub(FieldP.mul(e, FieldP.sub(d2, x3)), c8) // Y3 = E*(D-X3) - 8*C + val z3 = + FieldP.sub( + FieldP.sub(FieldP.sqr(FieldP.add(y, z)), FieldP.sqr(y)), + FieldP.sqr(z), + ) // Z3 = (Y1+Z1)² - B - Z1² ... but B = Y1² so this = 2*Y1*Z1 + + return JPoint(x3, y3, z3) + } + + /** + * Point addition in Jacobian coordinates. + * Mixed addition when q.z = 1 (affine point) for efficiency. + */ + fun add( + p: JPoint, + q: JPoint, + ): JPoint { + if (p.isInfinity()) return q + if (q.isInfinity()) return p + + val z1sq = FieldP.sqr(p.z) + val z2sq = FieldP.sqr(q.z) + + val u1 = FieldP.mul(p.x, z2sq) // U1 = X1*Z2² + val u2 = FieldP.mul(q.x, z1sq) // U2 = X2*Z1² + val s1 = FieldP.mul(p.y, FieldP.mul(q.z, z2sq)) // S1 = Y1*Z2³ + val s2 = FieldP.mul(q.y, FieldP.mul(p.z, z1sq)) // S2 = Y2*Z1³ + + if (U256.cmp(u1, u2) == 0) { + return if (U256.cmp(s1, s2) == 0) { + double(p) // Same point + } else { + JPoint.INFINITY // Inverse points + } + } + + val h = FieldP.sub(u2, u1) // H = U2 - U1 + val i = FieldP.sqr(FieldP.add(h, h)) // I = (2*H)² + val j = FieldP.mul(h, i) // J = H*I + val r = + FieldP.add( + FieldP.sub(s2, s1), + FieldP.sub(s2, s1), + ) // r = 2*(S2-S1) + val v = FieldP.mul(u1, i) // V = U1*I + + val x3 = + FieldP.sub( + FieldP.sub(FieldP.sqr(r), j), + FieldP.add(v, v), + ) // X3 = r² - J - 2*V + val y3 = + FieldP.sub( + FieldP.mul(r, FieldP.sub(v, x3)), + FieldP.add(FieldP.mul(s1, j), FieldP.mul(s1, j)), + ) // Y3 = r*(V-X3) - 2*S1*J + val z3 = + FieldP.mul( + FieldP.sub( + FieldP.sub( + FieldP.sqr(FieldP.add(p.z, q.z)), + z1sq, + ), + z2sq, + ), + h, + ) // Z3 = ((Z1+Z2)²-Z1²-Z2²)*H + + return JPoint(x3, y3, z3) + } + + /** + * Scalar multiplication using double-and-add (left-to-right). + */ + fun mul( + p: JPoint, + scalar: IntArray, + ): JPoint { + if (U256.isZero(scalar) || p.isInfinity()) return JPoint.INFINITY + + var result = JPoint.INFINITY + // Find highest set bit + var highBit = 255 + while (highBit >= 0 && !U256.testBit(scalar, highBit)) highBit-- + + for (i in highBit downTo 0) { + result = double(result) + if (U256.testBit(scalar, i)) { + result = add(result, p) + } + } + return result + } + + /** + * Convert Jacobian point to affine coordinates. + * Returns null if the point is at infinity. + */ + fun toAffine(p: JPoint): Pair? { + if (p.isInfinity()) return null + val zInv = FieldP.inv(p.z) + val zInv2 = FieldP.sqr(zInv) + val zInv3 = FieldP.mul(zInv2, zInv) + val x = FieldP.mul(p.x, zInv2) + val y = FieldP.mul(p.y, zInv3) + return Pair(x, y) + } + + /** + * Lift x-coordinate to a point on the curve. + * Returns the point with even y if it exists, null otherwise. + * Used by BIP-340 for x-only public keys. + */ + fun liftX(x: IntArray): Pair? { + // Check x < p + if (U256.cmp(x, FieldP.P) >= 0) return null + + // y² = x³ + 7 + val x3 = FieldP.mul(FieldP.sqr(x), x) + val y2 = FieldP.add(x3, B) + val y = FieldP.sqrt(y2) ?: return null + + // Return the even-y variant + val yBytes = U256.toBytes(y) + return if (yBytes[31].toInt() and 1 == 0) { + Pair(x, y) + } else { + Pair(x, FieldP.neg(y)) + } + } + + /** Check if y coordinate is even */ + fun hasEvenY(y: IntArray): Boolean { + // y is even if the least significant bit is 0 + return y[0] and 1 == 0 + } + + /** + * Parse a serialized public key (33 bytes compressed or 65 bytes uncompressed). + * Returns affine (x, y) or null on failure. + */ + fun parsePublicKey(pubkey: ByteArray): Pair? { + return when { + pubkey.size == 33 && (pubkey[0] == 0x02.toByte() || pubkey[0] == 0x03.toByte()) -> { + val x = U256.fromBytes(pubkey.copyOfRange(1, 33)) + if (U256.cmp(x, FieldP.P) >= 0) return null + val x3 = FieldP.mul(FieldP.sqr(x), x) + val y2 = FieldP.add(x3, B) + val y = FieldP.sqrt(y2) ?: return null + val isOdd = y[0] and 1 == 1 + val wantOdd = pubkey[0] == 0x03.toByte() + if (isOdd != wantOdd) Pair(x, FieldP.neg(y)) else Pair(x, y) + } + + pubkey.size == 65 && pubkey[0] == 0x04.toByte() -> { + val x = U256.fromBytes(pubkey.copyOfRange(1, 33)) + val y = U256.fromBytes(pubkey.copyOfRange(33, 65)) + // Verify point is on curve: y² = x³ + 7 + val y2 = FieldP.sqr(y) + val x3p7 = FieldP.add(FieldP.mul(FieldP.sqr(x), x), B) + if (U256.cmp(y2, x3p7) != 0) return null + Pair(x, y) + } + + else -> { + null + } + } + } + + /** Serialize affine point as 65-byte uncompressed key (04 || x || y) */ + fun serializeUncompressed( + x: IntArray, + y: IntArray, + ): ByteArray { + val result = ByteArray(65) + result[0] = 0x04 + U256.toBytes(x).copyInto(result, 1) + U256.toBytes(y).copyInto(result, 33) + return result + } + + /** Serialize affine point as 33-byte compressed key (02/03 || x) */ + fun serializeCompressed( + x: IntArray, + y: IntArray, + ): ByteArray { + val result = ByteArray(33) + result[0] = if (hasEvenY(y)) 0x02 else 0x03 + U256.toBytes(x).copyInto(result, 1) + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt new file mode 100644 index 000000000..8e58a1d6d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt @@ -0,0 +1,247 @@ +/* + * 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 com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Pure Kotlin implementation of secp256k1 elliptic curve operations. + * Provides the same functionality as fr.acinq.secp256k1.Secp256k1 but + * without requiring native bindings. + * + * Implements only the operations used by Secp256k1Instance: + * - pubkeyCreate / pubKeyCompress + * - secKeyVerify + * - signSchnorr / verifySchnorr (BIP-340) + * - privKeyTweakAdd + * - pubKeyTweakMul + */ +object Secp256k1 { + /** + * Create a public key from a secret key. + * @param seckey 32-byte secret key + * @return 65-byte uncompressed public key (04 || x || y) + */ + fun pubkeyCreate(seckey: ByteArray): ByteArray { + require(seckey.size == 32) { "Secret key must be 32 bytes" } + val scalar = U256.fromBytes(seckey) + require(ScalarN.isValid(scalar)) { "Invalid secret key" } + + val point = ECPoint.mul(ECPoint.G, scalar) + val (x, y) = ECPoint.toAffine(point) ?: error("Unexpected infinity") + return ECPoint.serializeUncompressed(x, y) + } + + /** + * Compress a public key. + * @param pubkey 65-byte uncompressed public key or 33-byte compressed + * @return 33-byte compressed public key (02/03 || x) + */ + fun pubKeyCompress(pubkey: ByteArray): ByteArray { + val (x, y) = ECPoint.parsePublicKey(pubkey) ?: error("Invalid public key") + return ECPoint.serializeCompressed(x, y) + } + + /** + * Verify that a secret key is valid (0 < key < n). + * @param seckey secret key bytes + * @return true if valid + */ + fun secKeyVerify(seckey: ByteArray): Boolean { + if (seckey.size != 32) return false + val scalar = U256.fromBytes(seckey) + return ScalarN.isValid(scalar) + } + + /** + * Create a Schnorr signature per BIP-340. + * @param data 32-byte message hash + * @param seckey 32-byte secret key + * @param auxrand optional 32-byte auxiliary randomness (null for deterministic) + * @return 64-byte signature + */ + fun signSchnorr( + data: ByteArray, + seckey: ByteArray, + auxrand: ByteArray?, + ): ByteArray { + require(seckey.size == 32) { "Secret key must be 32 bytes" } + + // Step 1-3: Compute keypair, negate secret key if needed + val d0 = U256.fromBytes(seckey) + require(ScalarN.isValid(d0)) { "Invalid secret key" } + + val pubPoint = ECPoint.mul(ECPoint.G, d0) + val (px, py) = ECPoint.toAffine(pubPoint) ?: error("Unexpected infinity") + + val d = if (ECPoint.hasEvenY(py)) d0 else ScalarN.neg(d0) + val dBytes = U256.toBytes(d) + val pBytes = U256.toBytes(px) // x-only public key + + // Step 4: Compute t = xor(d, tagged_hash("BIP0340/aux", auxrand)) + val t = + if (auxrand != null) { + require(auxrand.size == 32) { "Aux randomness must be 32 bytes" } + val auxHash = taggedHash("BIP0340/aux", auxrand) + val tArray = U256.fromBytes(dBytes) + val auxArray = U256.fromBytes(auxHash) + U256.toBytes(U256.xor(tArray, auxArray)) + } else { + dBytes + } + + // Step 5: rand = tagged_hash("BIP0340/nonce", t || pBytes || data) + val nonceInput = t + pBytes + data + val rand = taggedHash("BIP0340/nonce", nonceInput) + + // Step 6: k' = int(rand) mod n + val k0 = ScalarN.reduce(U256.fromBytes(rand)) + require(!U256.isZero(k0)) { "Nonce is zero" } + + // Step 7: R = k'·G + val rPoint = ECPoint.mul(ECPoint.G, k0) + val (rx, ry) = ECPoint.toAffine(rPoint) ?: error("Unexpected infinity") + + // Step 8: k = k' if has_even_y(R), else n - k' + val k = if (ECPoint.hasEvenY(ry)) k0 else ScalarN.neg(k0) + + // Step 9: e = int(tagged_hash("BIP0340/challenge", bytes(R) || bytes(P) || msg)) mod n + val rBytes = U256.toBytes(rx) + val challengeInput = rBytes + pBytes + data + val eHash = taggedHash("BIP0340/challenge", challengeInput) + val e = ScalarN.reduce(U256.fromBytes(eHash)) + + // Step 10: sig = bytes(R) || bytes((k + e*d) mod n) + val s = ScalarN.add(k, ScalarN.mul(e, d)) + val sig = rBytes + U256.toBytes(s) + + // Step 11: Verify (optional safety check - can be removed for performance) + require(verifySchnorr(sig, data, pBytes)) { "Signature verification failed" } + + return sig + } + + /** + * Verify a Schnorr signature per BIP-340. + * @param signature 64-byte signature + * @param data 32-byte message hash + * @param pub 32-byte x-only public key + * @return true if the signature is valid + */ + fun verifySchnorr( + signature: ByteArray, + data: ByteArray, + pub: ByteArray, + ): Boolean { + if (signature.size != 64) return false + if (pub.size != 32) return false + + // Step 1: P = lift_x(int(pk)) + val px = U256.fromBytes(pub) + val (pxCoord, pyCoord) = ECPoint.liftX(px) ?: return false + + // Step 2: r = int(sig[0:32]) + val rBytes = signature.copyOfRange(0, 32) + val r = U256.fromBytes(rBytes) + if (U256.cmp(r, FieldP.P) >= 0) return false + + // Step 3: s = int(sig[32:64]) + val s = U256.fromBytes(signature.copyOfRange(32, 64)) + if (U256.cmp(s, ScalarN.N) >= 0) return false + + // Step 4: e = int(tagged_hash("BIP0340/challenge", bytes(r) || bytes(P) || msg)) mod n + val challengeInput = rBytes + pub + data + val eHash = taggedHash("BIP0340/challenge", challengeInput) + val e = ScalarN.reduce(U256.fromBytes(eHash)) + + // Step 5: R = s·G - e·P + val sG = ECPoint.mul(ECPoint.G, s) + val negE = ScalarN.neg(e) + val pJac = JPoint(pxCoord, pyCoord, intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)) + val ePneg = ECPoint.mul(pJac, negE) + val rPoint = ECPoint.add(sG, ePneg) + + // Step 6: Fail if R is infinity, or if R has odd y, or if x(R) != r + if (rPoint.isInfinity()) return false + val (rx, ry) = ECPoint.toAffine(rPoint) ?: return false + if (!ECPoint.hasEvenY(ry)) return false + if (U256.cmp(rx, r) != 0) return false + + return true + } + + /** + * Add a tweak to a private key: (seckey + tweak) mod n. + * @param seckey 32-byte secret key + * @param tweak 32-byte tweak + * @return 32-byte tweaked secret key + */ + fun privKeyTweakAdd( + seckey: ByteArray, + tweak: ByteArray, + ): ByteArray { + require(seckey.size == 32) { "Secret key must be 32 bytes" } + require(tweak.size == 32) { "Tweak must be 32 bytes" } + val a = U256.fromBytes(seckey) + val b = U256.fromBytes(tweak) + val result = ScalarN.add(a, b) + require(!U256.isZero(result)) { "Result is zero" } + require(U256.cmp(result, ScalarN.N) < 0) { "Result >= n" } + return U256.toBytes(result) + } + + /** + * Multiply a public key by a tweak (scalar multiplication). + * @param pubkey 33-byte compressed or 65-byte uncompressed public key + * @param tweak 32-byte tweak/scalar + * @return public key in the same format as input + */ + fun pubKeyTweakMul( + pubkey: ByteArray, + tweak: ByteArray, + ): ByteArray { + require(tweak.size == 32) { "Tweak must be 32 bytes" } + val (x, y) = ECPoint.parsePublicKey(pubkey) ?: error("Invalid public key") + val scalar = U256.fromBytes(tweak) + require(ScalarN.isValid(scalar)) { "Invalid tweak" } + + val point = JPoint(x, y, intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)) + val result = ECPoint.mul(point, scalar) + val (rx, ry) = ECPoint.toAffine(result) ?: error("Result is infinity") + + return if (pubkey.size == 33) { + ECPoint.serializeCompressed(rx, ry) + } else { + ECPoint.serializeUncompressed(rx, ry) + } + } + + // ============ Tagged Hash (BIP-340) ============ + + /** BIP-340 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || msg) */ + internal fun taggedHash( + tag: String, + msg: ByteArray, + ): ByteArray { + val tagHash = sha256(tag.encodeToByteArray()) + return sha256(tagHash + tagHash + msg) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/SchnorrTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/SchnorrTest.kt new file mode 100644 index 000000000..d4469ea0f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/SchnorrTest.kt @@ -0,0 +1,382 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Full BIP-340 test vectors from + * https://github.com/bitcoin/bips/blob/master/bip-0340/test-vectors.csv + */ +class SchnorrTest { + // ============================================================ + // BIP-340 signing tests (vectors with secret keys) + // ============================================================ + + @Test + fun bip340Vector0Sign() { + val sig = + Secp256k1.signSchnorr( + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000003".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + ) + assertEquals( + "e907831f80848d1069a5371b402410364bdf1c5f8307b0084c55f1ce2dca8215" + + "25f66a4a85ea8b71e482a74f382d2ce5ebeee8fdb2172f477df4900d310536c0", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector0Verify() { + assertTrue( + Secp256k1.verifySchnorr( + ( + "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA8215" + + "25F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0" + ).hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector1Sign() { + val sig = + Secp256k1.signSchnorr( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray(), + ) + assertEquals( + "6896bd60eeae296db48a229ff71dfe071bde413e6d43f917dc8dcf8c78de3341" + + "8906d11ac976abccb20b091292bff4ea897efcb639ea871cfa95f6de339e4b0a", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector1Verify() { + assertTrue( + Secp256k1.verifySchnorr( + ( + "6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE3341" + + "8906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector2Sign() { + val sig = + Secp256k1.signSchnorr( + "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C".hexToByteArray(), + "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9".hexToByteArray(), + "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906".hexToByteArray(), + ) + assertEquals( + "5831aaeed7b44bb74e5eab94ba9d4294c49bcf2a60728d8b4c200f50dd313c1b" + + "ab745879a5ad954a72c45a91c3a51d3c7adea98d82f8481e0e1e03674a6f3fb7", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector3Sign() { + val sig = + Secp256k1.signSchnorr( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".hexToByteArray(), + "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710".hexToByteArray(), + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".hexToByteArray(), + ) + assertEquals( + "7eb0509757e246f19449885651611cb965ecc1a187dd51b64fda1edc9637d5ec" + + "97582b9cb13db3933705b32ba982af5af25fd78881ebb32771fc5922efc66ea3", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector15Sign() { + // Empty message + val sig = + Secp256k1.signSchnorr( + ByteArray(0), + "0340034003400340034003400340034003400340034003400340034003400340".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + ) + assertEquals( + "71535db165ecd9fbbc046e5ffaea61186bb6ad436732fccc25291a55895464cf" + + "6069ce26bf03466228f19a3a62db8a649f2d560fac652827d1af0574e427ab63", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector16Sign() { + // 1-byte message + val sig = + Secp256k1.signSchnorr( + "11".hexToByteArray(), + "0340034003400340034003400340034003400340034003400340034003400340".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + ) + assertEquals( + "08a20a0afef64124649232e0693c583ab1b9934ae63b4c3511f3ae1134c6a303" + + "ea3173bfea6683bd101fa5aa5dbc1996fe7cacfc5a577d33ec14564cec2bacbf", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector17Sign() { + // 17-byte message + val sig = + Secp256k1.signSchnorr( + "0102030405060708090A0B0C0D0E0F1011".hexToByteArray(), + "0340034003400340034003400340034003400340034003400340034003400340".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + ) + assertEquals( + "5130f39a4059b43bc7cac09a19ece52b5d8699d1a71e3c52da9afdb6b50ac370" + + "c4a482b77bf960f8681540e25b6771ece1e5a37fd80e5a51897c5566a97ea5a5", + sig.toHexKey(), + ) + } + + @Test + fun bip340Vector18Sign() { + // 100-byte message + val sig = + Secp256k1.signSchnorr( + ( + "9999999999999999999999999999999999999999" + + "9999999999999999999999999999999999999999" + + "9999999999999999999999999999999999999999" + + "9999999999999999999999999999999999999999" + + "9999999999999999999999999999999999999999" + ).hexToByteArray(), + "0340034003400340034003400340034003400340034003400340034003400340".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + ) + assertEquals( + "403b12b0d8555a344175ea7ec746566303321e5dbfa8be6f091635163eca79a8" + + "585ed3e3170807e7c03b720fc54c7b23897fcba0e9d0b4a06894cfd249f22367", + sig.toHexKey(), + ) + } + + // ============================================================ + // BIP-340 verify-only tests (vectors 4-14) + // ============================================================ + + @Test + fun bip340Vector4Verify() { + assertTrue( + Secp256k1.verifySchnorr( + ( + "00000000000000000000003B78CE563F89A0ED9414F5AA28AD0D96D6795F9C63" + + "76AFB1548AF603B3EB45C9F8207DEE1060CB71C04E80F593060B07D28308D7F4" + ).hexToByteArray(), + "4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703".hexToByteArray(), + "D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector5VerifyFail() { + // public key not on the curve + assertFalse( + Secp256k1.verifySchnorr( + ( + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector6VerifyFail() { + // has_even_y(R) is false + assertFalse( + Secp256k1.verifySchnorr( + ( + "FFF97BD5755EEEA420453A14355235D382F6472F8568A18B2F057A1460297556" + + "3CC27944640AC607CD107AE10923D9EF7A73C643E166BE5EBEAFA34B1AC553E2" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector7VerifyFail() { + // negated message + assertFalse( + Secp256k1.verifySchnorr( + ( + "1FA62E331EDBC21C394792D2AB1100A7B432B013DF3F6FF4F99FCB33E0E1515F" + + "28890B3EDB6E7189B630448B515CE4F8622A954CFE545735AAEA5134FCCDB2BD" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector8VerifyFail() { + // negated s value + assertFalse( + Secp256k1.verifySchnorr( + ( + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + + "961764B3AA9B2FFCB6EF947B6887A226E8D7C93E00C5ED0C1834FF0D0C2E6DA6" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector9VerifyFail() { + // sG - eP is infinite (x(inf) as 0) + assertFalse( + Secp256k1.verifySchnorr( + ( + "0000000000000000000000000000000000000000000000000000000000000000" + + "123DDA8328AF9C23A94C1FEECFD123BA4FB73476F0D594DCB65C6425BD186051" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector10VerifyFail() { + // sG - eP is infinite (x(inf) as 1) + assertFalse( + Secp256k1.verifySchnorr( + ( + "0000000000000000000000000000000000000000000000000000000000000001" + + "7615FBAF5AE28864013C099742DEADB4DBA87F11AC6754F93780D5A1837CF197" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector11VerifyFail() { + // sig[0:32] is not an X coordinate on the curve + assertFalse( + Secp256k1.verifySchnorr( + ( + "4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D" + + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector12VerifyFail() { + // sig[0:32] is equal to field size + assertFalse( + Secp256k1.verifySchnorr( + ( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F" + + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector13VerifyFail() { + // sig[32:64] is equal to curve order + assertFalse( + Secp256k1.verifySchnorr( + ( + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659".hexToByteArray(), + ), + ) + } + + @Test + fun bip340Vector14VerifyFail() { + // public key exceeds field size + assertFalse( + Secp256k1.verifySchnorr( + ( + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B" + ).hexToByteArray(), + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray(), + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30".hexToByteArray(), + ), + ) + } + + // ============================================================ + // Sign then verify round-trip + // ============================================================ + + @Test + fun signVerifyRoundTrip() { + val seckey = + "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" + .hexToByteArray() + val msg = ByteArray(32) { 0x42 } + val sig = Secp256k1.signSchnorr(msg, seckey, null) + val pubkey = Secp256k1.pubkeyCreate(seckey) + val compressed = Secp256k1.pubKeyCompress(pubkey) + // x-only pubkey is compressed[1..33] + val xonly = compressed.copyOfRange(1, 33) + assertTrue(Secp256k1.verifySchnorr(sig, msg, xonly)) + } +} 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 new file mode 100644 index 000000000..e6a941afe --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Test.kt @@ -0,0 +1,241 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class Secp256k1Test { + // ============================================================ + // secKeyVerify + // ============================================================ + + @Test + fun verifyValidPrivateKey() { + assertTrue( + Secp256k1.secKeyVerify( + "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" + .hexToByteArray(), + ), + ) + } + + @Test + fun verifyInvalidPrivateKeyWrongSize() { + assertFalse( + Secp256k1.secKeyVerify( + "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A106353001" + .hexToByteArray(), + ), + ) + } + + @Test + fun verifyInvalidPrivateKeyCurveOrder() { + assertFalse( + Secp256k1.secKeyVerify( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" + .hexToByteArray(), + ), + ) + } + + @Test + fun verifyInvalidPrivateKeyZero() { + assertFalse( + Secp256k1.secKeyVerify( + "0000000000000000000000000000000000000000000000000000000000000000" + .hexToByteArray(), + ), + ) + } + + // ============================================================ + // pubkeyCreate + // ============================================================ + + @Test + fun createValidPublicKey() { + val pubkey = + Secp256k1.pubkeyCreate( + "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" + .hexToByteArray(), + ) + assertEquals( + "04c591a8ff19ac9c4e4e5793673b83123437e975285e7b442f4ee2654dffca5e2d" + + "2103ed494718c697ac9aebcfd19612e224db46661011863ed2fc54e71861e2a6", + pubkey.toHexKey(), + ) + } + + @Test + fun createPublicKeyFromKeyOne() { + val pubkey = + Secp256k1.pubkeyCreate( + "0000000000000000000000000000000000000000000000000000000000000001" + .hexToByteArray(), + ) + // G itself + assertEquals( + "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", + pubkey.toHexKey(), + ) + } + + @Test + fun createPublicKeyFromKeyThree() { + val pubkey = + Secp256k1.pubkeyCreate( + "0000000000000000000000000000000000000000000000000000000000000003" + .hexToByteArray(), + ) + val compressed = Secp256k1.pubKeyCompress(pubkey) + // BIP-340 test vector 0 public key (x-only) + assertEquals( + "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + compressed.toHexKey(), + ) + } + + // ============================================================ + // pubKeyCompress + // ============================================================ + + @Test + fun compressPublicKey() { + val compressed = + Secp256k1.pubKeyCompress( + ( + "04C591A8FF19AC9C4E4E5793673B83123437E975285E7B442F4EE2654DFFCA5E2D" + + "2103ED494718C697AC9AEBCFD19612E224DB46661011863ED2FC54E71861E2A6" + ).hexToByteArray(), + ) + assertEquals( + "02c591a8ff19ac9c4e4e5793673b83123437e975285e7b442f4ee2654dffca5e2d", + compressed.toHexKey(), + ) + } + + @Test + fun compressPublicKeyWithOddY() { + // From existing Amethyst test: key with 03 prefix + val privKey = + "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a" + .hexToByteArray() + val pubkey = Secp256k1.pubkeyCreate(privKey) + val compressed = Secp256k1.pubKeyCompress(pubkey) + assertEquals( + "033dcef7585efbdb68747d919152bd481e21f5e952aaaef5a19604fbd096a93dd5", + compressed.toHexKey(), + ) + } + + @Test + fun compressPublicKeyWithEvenY() { + val privKey = + "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0" + .hexToByteArray() + val pubkey = Secp256k1.pubkeyCreate(privKey) + val compressed = Secp256k1.pubKeyCompress(pubkey) + assertEquals( + "029fa4ce8c87ca546b196e6518db80a6780e1bd5552b61f9f17bafee5d4e34e09b", + compressed.toHexKey(), + ) + } + + // ============================================================ + // privKeyTweakAdd + // ============================================================ + + @Test + fun privateKeyTweakAdd() { + val result = + Secp256k1.privKeyTweakAdd( + "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530" + .hexToByteArray(), + "3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3" + .hexToByteArray(), + ) + assertEquals( + "a168571e189e6f9a7e2d657a4b53ae99b909f7e712d1c23ced28093cd57c88f3", + result.toHexKey(), + ) + } + + // ============================================================ + // pubKeyTweakMul + // ============================================================ + + @Test + fun publicKeyTweakMul() { + val result = + Secp256k1.pubKeyTweakMul( + ( + "040A629506E1B65CD9D2E0BA9C75DF9C4FED0DB16DC9625ED14397F0AFC836FAE5" + + "95DC53F8B0EFE61E703075BD9B143BAC75EC0E19F82A2208CAEB32BE53414C40" + ).hexToByteArray(), + "3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3" + .hexToByteArray(), + ) + assertEquals( + "04e0fe6fe55ebca626b98a807f6caf654139e14e5e3698f01a9a658e21dc1d2791" + + "ec060d4f412a794d5370f672bc94b722640b5f76914151cfca6e712ca48cc589", + result.toHexKey(), + ) + } + + @Test + fun publicKeyTweakMulCompressed() { + // Test the pattern used by Secp256k1Instance.pubKeyTweakMulCompact + val pubKey = + "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133" + .hexToByteArray() + val privKey = + "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268" + .hexToByteArray() + val h02 = byteArrayOf(0x02) + val result = Secp256k1.pubKeyTweakMul(h02 + pubKey, privKey) + // Should return 33-byte compressed key; take bytes 1..33 for x-only + assertEquals(33, result.size) + } + + @Test + fun ecdhSymmetry() { + // Shared secret A->B should equal B->A + val privA = + "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268" + .hexToByteArray() + val privB = + "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e" + .hexToByteArray() + val pubA = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privA)) + val pubB = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privB)) + + val secretAB = Secp256k1.pubKeyTweakMul(pubB, privA) + val secretBA = Secp256k1.pubKeyTweakMul(pubA, privB) + assertEquals(secretAB.toHexKey(), secretBA.toHexKey()) + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt deleted file mode 100644 index 789fe2404..000000000 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 - -import fr.acinq.secp256k1.Secp256k1 - -actual object Secp256k1Instance { - private val h02 = Hex.decode("02") - private val secp256k1 = Secp256k1.get() - - actual fun compressedPubKeyFor(privKey: ByteArray) = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)) - - actual fun isPrivateKeyValid(il: ByteArray): Boolean = secp256k1.secKeyVerify(il) - - actual fun signSchnorr( - data: ByteArray, - privKey: ByteArray, - nonce: ByteArray?, - ): ByteArray = secp256k1.signSchnorr(data, privKey, nonce) - - actual fun signSchnorr( - data: ByteArray, - privKey: ByteArray, - ): ByteArray = secp256k1.signSchnorr(data, privKey, null) - - actual fun verifySchnorr( - signature: ByteArray, - hash: ByteArray, - pubKey: ByteArray, - ): Boolean = secp256k1.verifySchnorr(signature, hash, pubKey) - - actual fun privateKeyAdd( - first: ByteArray, - second: ByteArray, - ): ByteArray = secp256k1.privKeyTweakAdd(first, second) - - actual fun pubKeyTweakMulCompact( - pubKey: ByteArray, - privateKey: ByteArray, - ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) -} diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt deleted file mode 100644 index 771c35916..000000000 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 - -import fr.acinq.secp256k1.Secp256k1 - -actual object Secp256k1Instance { - private val h02 = Hex.decode("02") - private val secp256k1Ref = Secp256k1.get() - - actual fun compressedPubKeyFor(privKey: ByteArray): ByteArray = secp256k1Ref.pubKeyCompress(secp256k1Ref.pubkeyCreate(privKey)) - - actual fun isPrivateKeyValid(il: ByteArray): Boolean = secp256k1Ref.secKeyVerify(il) - - actual fun signSchnorr( - data: ByteArray, - privKey: ByteArray, - nonce: ByteArray?, - ): ByteArray = secp256k1Ref.signSchnorr(data, privKey, nonce) - - actual fun signSchnorr( - data: ByteArray, - privKey: ByteArray, - ): ByteArray = secp256k1Ref.signSchnorr(data, privKey, null) - - actual fun verifySchnorr( - signature: ByteArray, - hash: ByteArray, - pubKey: ByteArray, - ): Boolean = secp256k1Ref.verifySchnorr(signature, hash, pubKey) - - actual fun privateKeyAdd( - first: ByteArray, - second: ByteArray, - ): ByteArray = secp256k1Ref.privKeyTweakAdd(first, second) - - actual fun pubKeyTweakMulCompact( - pubKey: ByteArray, - privateKey: ByteArray, - ): ByteArray = secp256k1Ref.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) -}