diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt index 3029b5566..e21f31f86 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt @@ -43,3 +43,21 @@ actual fun sha256(data: ByteArray): ByteArray { return digest.toByteArray() } + +@OptIn(ExperimentalForeignApi::class) +actual fun sha256Into( + out: ByteArray, + data: ByteArray, + len: Int, +): ByteArray { + data.usePinned { inputPinned -> + out.asUByteArray().usePinned { digestPinned -> + CC_SHA256( + inputPinned.addressOf(0), + len.convert(), + digestPinned.addressOf(0), + ) + } + } + return out +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt index 4bf264f15..6f90ecfe5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt @@ -171,4 +171,22 @@ internal class PointScratch { @JvmField val entryTmp = LongArray(4) @JvmField val entryTmp2 = LongArray(4) + + // Pre-allocated byte buffers for sign/verify (eliminates ByteArray allocations). + // hashBuf: reusable buffer for BIP-340 tagged hash inputs (prefix(64) + fields). + // The max size is 64 + 32 + 32 + msgLen. For 32-byte messages (event IDs), that's 160. + // For larger messages, signSchnorrInternal must still allocate. + @JvmField val hashBuf = ByteArray(256) + + // 32-byte scratch for serialized field elements / scalars (avoids U256.toBytes allocs) + @JvmField val bytesTmp1 = ByteArray(32) + + @JvmField val bytesTmp2 = ByteArray(32) + + // Scratch LongArray(4) for intermediate scalar results (avoids ScalarN alloc) + @JvmField val scalarTmp1 = LongArray(4) + + @JvmField val scalarTmp2 = LongArray(4) + + @JvmField val scalarTmp3 = LongArray(4) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt index daf4bb107..7981f95f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt @@ -63,6 +63,18 @@ internal object ScalarN { a } + /** Allocation-free reduce: out = a mod n. Safe for out === a. */ + fun reduceTo( + out: LongArray, + a: LongArray, + ) { + if (U256.cmp(a, N) >= 0) { + U256.subTo(out, a, N) + } else if (out !== a) { + U256.copyInto(out, a) + } + } + fun add( a: LongArray, b: LongArray, 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 index 5ac2d9552..83c263ce3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.utils.secp256k1 import com.vitorpamplona.quartz.utils.sha256.sha256 +import com.vitorpamplona.quartz.utils.sha256.sha256Into /** * Pure Kotlin implementation of secp256k1 elliptic curve operations for Nostr. @@ -241,6 +242,7 @@ object Secp256k1 { auxrand: ByteArray?, ): ByteArray { require(seckey.size == 32) + // Allocate d0 separately — signSchnorrInternal uses all scalar scratch buffers. val d0 = U256.fromBytes(seckey) require(ScalarN.isValid(d0)) @@ -249,6 +251,8 @@ object Secp256k1 { ECPoint.mulG(sc.entryResult, d0) check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc)) + // Allocate xOnlyPub — signSchnorrInternal reuses bytesTmp1/2 internally. + // This 32-byte allocation is negligible next to the mulG cost (~100μs). val xOnlyPub = U256.toBytes(sc.entryPx) return signSchnorrInternal(data, d0, xOnlyPub, KeyCodec.hasEvenY(sc.entryPy), auxrand) } @@ -317,34 +321,46 @@ object Secp256k1 { auxrand: ByteArray?, ): ByteArray { val sc = ECPoint.getScratch() - val tmp = sc.entryTmp val d = if (pubKeyHasEvenY) { d0 } else { - ScalarN.negTo(tmp, d0) - tmp + ScalarN.negTo(sc.entryTmp, d0) + sc.entryTmp } - val dBytes = U256.toBytes(d) + // Serialize d into scratch byte buffer (avoids U256.toBytes allocation) + val dBytes = sc.bytesTmp1 + U256.toBytesInto(d, dBytes, 0) val tBytes: ByteArray if (auxrand != null) { require(auxrand.size == 32) - val auxHash = sha256(AUX_PREFIX + auxrand) - U256.xorTo(sc.entryTmp2, U256.fromBytes(dBytes), U256.fromBytes(auxHash)) - tBytes = U256.toBytes(sc.entryTmp2) + // Build AUX_PREFIX + auxrand in scratch hashBuf (avoids concatenation alloc) + AUX_PREFIX.copyInto(sc.hashBuf, 0) + auxrand.copyInto(sc.hashBuf, 64) + sha256Into(sc.bytesTmp2, sc.hashBuf, 96) + // XOR d with auxHash — reuse limb scratch + U256.fromBytesInto(sc.scalarTmp1, dBytes, 0) + U256.fromBytesInto(sc.scalarTmp2, sc.bytesTmp2, 0) + U256.xorTo(sc.scalarTmp3, sc.scalarTmp1, sc.scalarTmp2) + tBytes = sc.bytesTmp2 // reuse bytesTmp2 for tBytes + U256.toBytesInto(sc.scalarTmp3, tBytes, 0) } else { tBytes = dBytes } - val nonceInput = ByteArray(64 + 32 + 32 + data.size) + // Build nonce input. Reuse hashBuf if it fits. + val nonceLen = 64 + 32 + 32 + data.size + val nonceInput = if (nonceLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(nonceLen) NONCE_PREFIX.copyInto(nonceInput, 0) - tBytes.copyInto(nonceInput, 64) + tBytes.copyInto(nonceInput, 64, 0, 32) pBytes.copyInto(nonceInput, 96) data.copyInto(nonceInput, 128) - val rand = sha256(nonceInput) - val k0 = ScalarN.reduce(U256.fromBytes(rand)) + sha256Into(sc.bytesTmp2, nonceInput, nonceLen) // rand → bytesTmp2 + U256.fromBytesInto(sc.scalarTmp1, sc.bytesTmp2, 0) + ScalarN.reduceTo(sc.scalarTmp1, sc.scalarTmp1) + val k0 = sc.scalarTmp1 require(!U256.isZero(k0)) // R = k0·G @@ -353,22 +369,35 @@ object Secp256k1 { val ry = sc.entryPy check(ECPoint.toAffine(sc.entryResult, rx, ry, sc)) - val k = if (KeyCodec.hasEvenY(ry)) k0 else ScalarN.neg(k0) + val k = + if (KeyCodec.hasEvenY(ry)) { + k0 + } else { + ScalarN.negTo(sc.scalarTmp2, k0) + sc.scalarTmp2 + } - // Challenge: e = H(R || P || msg) - val chalInput = ByteArray(64 + 32 + 32 + data.size) + // Challenge: e = H(R || P || msg) — reuse hashBuf + val chalLen = 64 + 32 + 32 + data.size + val chalInput = if (chalLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(chalLen) CHALLENGE_PREFIX.copyInto(chalInput, 0) U256.toBytesInto(rx, chalInput, 64) pBytes.copyInto(chalInput, 96) data.copyInto(chalInput, 128) - val eHash = sha256(chalInput) - val e = ScalarN.reduce(U256.fromBytes(eHash)) + sha256Into(sc.bytesTmp1, chalInput, chalLen) // eHash → bytesTmp1 + U256.fromBytesInto(sc.scalarTmp3, sc.bytesTmp1, 0) + ScalarN.reduceTo(sc.scalarTmp3, sc.scalarTmp3) + val e = sc.scalarTmp3 // s = k + e·d mod n - val sScalar = ScalarN.add(k, ScalarN.mul(e, d)) + // Note: d may alias sc.entryTmp (when !pubKeyHasEvenY), so use splitK1 for mulTo output. + ScalarN.mulTo(sc.splitK1, e, d, sc.splitWide) // e·d → splitK1 + ScalarN.addTo(sc.entryTmp2, k, sc.splitK1) // k + e·d → entryTmp2 + + // Build output signature (the only required allocation) val sig = ByteArray(64) U256.toBytesInto(rx, sig, 0) - U256.toBytesInto(sScalar, sig, 32) + U256.toBytesInto(sc.entryTmp2, sig, 32) return sig } @@ -406,13 +435,15 @@ object Secp256k1 { U256.fromBytesInto(s, signature, 32) if (U256.cmp(s, ScalarN.N) >= 0) return false - // Build challenge hash input in a single array: prefix(64) + r(32) + pub(32) + data(N) - val hashInput = ByteArray(64 + 32 + 32 + data.size) + // Build challenge hash input. Reuse scratch byte buffer if message fits, + // otherwise allocate (rare for Nostr: event IDs are 32 bytes → total 160). + val hashLen = 64 + 32 + 32 + data.size + val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen) CHALLENGE_PREFIX.copyInto(hashInput, 0) signature.copyInto(hashInput, 64, 0, 32) // r bytes from signature pub.copyInto(hashInput, 96) data.copyInto(hashInput, 128) - val eHash = sha256(hashInput) + val eHash = sha256Into(sc.bytesTmp1, hashInput, hashLen) // Reuse zInv for e (safe: zInv not used until toAffine, which we skip here) val e = sc.zInv U256.fromBytesInto(e, eHash, 0) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt index ee944fddc..b9bbf9bf4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt @@ -21,3 +21,14 @@ package com.vitorpamplona.quartz.utils.sha256 expect fun sha256(data: ByteArray): ByteArray + +/** + * Allocation-free SHA-256: hash `data[0..len)` and write the 32-byte digest into `out`. + * Returns `out` for convenience. `out` must be at least 32 bytes. + * `data` may be longer than `len` (only the first `len` bytes are hashed). + */ +expect fun sha256Into( + out: ByteArray, + data: ByteArray, + len: Int = data.size, +): ByteArray diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index 0dc972d8f..b58969cf6 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -37,6 +37,17 @@ val threadLocalDigest = actual fun sha256(data: ByteArray): ByteArray = threadLocalDigest.get().digest(data) +actual fun sha256Into( + out: ByteArray, + data: ByteArray, + len: Int, +): ByteArray { + val md = threadLocalDigest.get() + md.update(data, 0, len) + md.digest(out, 0, 32) + return out +} + /** * Calculate SHA256 hash while counting bytes read from the stream. * Returns both the hash and the number of bytes processed. diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt index 91fb3205f..dd2e17c18 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.linux.kt @@ -28,3 +28,15 @@ actual fun sha256(data: ByteArray): ByteArray { val hasher = provider.get(SHA256).hasher() return hasher.hashBlocking(data) } + +actual fun sha256Into( + out: ByteArray, + data: ByteArray, + len: Int, +): ByteArray { + // Linux cryptography provider doesn't support writing into existing buffer. + // Fall back to allocating and copying. + val hash = sha256(if (len == data.size) data else data.copyOfRange(0, len)) + hash.copyInto(out) + return out +}