From f155091db84784dabe13574851fc2582738276af Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 15 Jul 2026 20:53:10 +0200 Subject: [PATCH] refactor(meshlink): split Ed25519 fallback into field and point arithmetic units Implements #125 by extracting the RFC 8032/TweetNaCl radix-2^16 field arithmetic and Edwards point arithmetic into dedicated internal classes: - Curve25519FieldArithmetic - Curve25519PointArithmetic (+ Point/PointScratch) Ed25519Fallback now stays focused on signing/verification/key generation, composing the two extracted units while preserving: - exact constant-time vs variable-time path distinctions - byte-for-byte signing/verification behavior - ExpandedPrivateKey WeakHashMap cache semantics keyed by ByteArray identity No algorithmic changes were made: arithmetic code was moved verbatim. Regenerated detekt baseline for androidMain only. This is re-keying from file extraction (MagicNumber/TooManyFunctions/VariableNaming/ UnsafeCallOnNullableType IDs moved from Ed25519Fallback.kt to the new files) plus removal of stale L2capFrameBuffer baseline IDs no longer present in current code. Verification: - allTests: 1239/1239 pass (unchanged) - Ed25519/Wycheproof tests unchanged: Ed25519FallbackTest 6/6, WycheproofRegressionTest 5/5 on both JVM+AndroidHost, AndroidCryptoPolicyConformanceTest 1/1, MeshRuntimeAndroidCryptoTest 1/1 - apiCheck: zero .api diff - detektAll: clean - ktfmtFormat: clean - clean rebuild re-check: rm -rf meshlink/build && allTests+apiCheck+detektAll pass --- .../config/detekt/baseline-androidMain.xml | 65 +- .../crypto/Curve25519FieldArithmetic.kt | 366 ++++++++++ .../crypto/Curve25519PointArithmetic.kt | 345 +++++++++ .../android/crypto/Ed25519Fallback.kt | 662 +----------------- 4 files changed, 755 insertions(+), 683 deletions(-) create mode 100644 meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519FieldArithmetic.kt create mode 100644 meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519PointArithmetic.kt diff --git a/meshlink/config/detekt/baseline-androidMain.xml b/meshlink/config/detekt/baseline-androidMain.xml index a0ec0556..540e6e62 100644 --- a/meshlink/config/detekt/baseline-androidMain.xml +++ b/meshlink/config/detekt/baseline-androidMain.xml @@ -66,35 +66,40 @@ MagicNumber:BleTransportAdapterL2capSupport.kt$L2capLink$6 MagicNumber:BleTransportAdapterL2capSupport.kt$L2capLink$8 MagicNumber:BleTransportAdapterScanSupport.kt$6 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$0x7fffL + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$0x7fffL + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$0xFF + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$0xffL + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$0xffedL + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$0xffffL + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$10 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$100 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$14 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$15 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$16 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$20 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$3 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$37L + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$38L + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$5 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$50 + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$65535L + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$65536L + MagicNumber:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$8 + MagicNumber:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$0xFF + MagicNumber:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$31 + MagicNumber:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$4 + MagicNumber:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$7 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$0xFF - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$0xffL - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$0xffedL - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$0xffffL - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$10 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$100 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$12 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$127 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$128L - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$14 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$15 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$16 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$16L - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$20 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$248 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$255L - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$3 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$31 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$37L - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$38L MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$4 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$5 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$50 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$63 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$64 - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$65535L - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$65536L - MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$7 MagicNumber:Ed25519Fallback.kt$Ed25519Fallback$8 MagicNumber:GattNotifyClient.kt$GattNotifyClient$6 MagicNumber:GattNotifyClient.kt$GattNotifyClient.<no name provided>$517 @@ -108,11 +113,6 @@ MagicNumber:JcaCapabilityProbe.kt$JcaCapabilityProbe$0x45 MagicNumber:JcaCapabilityProbe.kt$JcaCapabilityProbe$12 MagicNumber:JcaCapabilityProbe.kt$JcaCapabilityProbe$32 - MagicNumber:L2capFrameBuffer.kt$L2capFrameBuffer$0xFF - MagicNumber:L2capFrameBuffer.kt$L2capFrameBuffer$16 - MagicNumber:L2capFrameBuffer.kt$L2capFrameBuffer$24 - MagicNumber:L2capFrameBuffer.kt$L2capFrameBuffer$3 - MagicNumber:L2capFrameBuffer.kt$L2capFrameBuffer$8 MagicNumber:L2capSendSupport.kt$6 MagicNumber:PreferredGattSendSupport.kt$25 MagicNumber:PreferredGattSendSupport.kt$6 @@ -201,12 +201,13 @@ TooManyFunctions:BleTransportAdapterL2capSupport.kt$ch.trancee.meshlink.platform.android.l2cap.BleTransportAdapterL2capSupport.kt TooManyFunctions:BleTransportDiscoveryLifecycle.kt$BleTransportDiscoveryLifecycle TooManyFunctions:BleTransportLinkRegistry.kt$BleTransportLinkRegistry<T> + TooManyFunctions:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic + TooManyFunctions:Curve25519PointArithmetic.kt$Curve25519PointArithmetic TooManyFunctions:Ed25519Fallback.kt$Ed25519Fallback TooManyFunctions:GattNotifyClient.kt$GattNotifyClient TooManyFunctions:GattNotifySessionAdapter.kt$BluetoothGattNotifySession : GattNotifySession TooManyFunctions:GattNotifySessionAdapter.kt$GattNotifySession TooManyFunctions:JcaCryptoProvider.kt$JcaCryptoProvider : CryptoProvider - TooManyFunctions:L2capFrameBuffer.kt$L2capFrameBuffer TopLevelPropertyNaming:ScanResultSupport.kt$private const val L2capSupportedSdkInt: Int = 34 UnnecessaryAbstractClass:GattNotifyCallbackSupport.kt$BaseGattNotifyCallback$BaseGattNotifyCallback UnreachableCode:BleTransportAdapterLifecycleSupport.kt$advertiser = try { adapter.bluetoothLeAdvertiser } catch (exception: SecurityException) { throw androidPermissionDenied(exception) } @@ -232,14 +233,14 @@ UnreachableCode:BleTransportAdapterLifecycleSupport.kt$val gattNotifyServer = BluetoothGattNotifyServer( context = context, peerBindings = peerBindings, onUnknownPeerFrame = ::registerProvisionalGattPeer, onClaimedPeerIdentity = ::registerClaimedGattPeer, onFrameReceived = ::enqueueInboundFrame, log = ::log, ) UnreachableCode:BleTransportAdapterLifecycleSupport.kt$val l2capServerSupported = supportsL2capServerSockets() UnreachableCode:BleTransportAdapterLifecycleSupport.kt$val serverSocket = if (l2capServerSupported) { runCatching { L2capSocketFactory.listenInsecure(adapter) } .onFailure { error -> log("L2CAP server socket unavailable: ${error.message.orEmpty()}") } .getOrNull() } else { log("L2CAP server socket unavailable: runtime capability probe returned false") null } - UnsafeCallOnNullableType:Ed25519Fallback.kt$Ed25519Fallback$row[digit - 1]!! - UnsafeCallOnNullableType:Ed25519Fallback.kt$Ed25519Fallback$table[digit - 1]!! - UnsafeCallOnNullableType:Ed25519Fallback.kt$Ed25519Fallback$table[digit]!! + UnsafeCallOnNullableType:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$row[digit - 1]!! + UnsafeCallOnNullableType:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$table[digit - 1]!! + UnsafeCallOnNullableType:Curve25519PointArithmetic.kt$Curve25519PointArithmetic$table[digit]!! UnusedParameter:L2capSendSupport.kt$context: L2capSendContext - VariableNaming:Ed25519Fallback.kt$Ed25519Fallback$val z2_100_0 = fieldElement() - VariableNaming:Ed25519Fallback.kt$Ed25519Fallback$val z2_10_0 = fieldElement() - VariableNaming:Ed25519Fallback.kt$Ed25519Fallback$val z2_20_0 = fieldElement() - VariableNaming:Ed25519Fallback.kt$Ed25519Fallback$val z2_50_0 = fieldElement() - VariableNaming:Ed25519Fallback.kt$Ed25519Fallback$val z2_5_0 = fieldElement() + VariableNaming:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$val z2_100_0 = fieldElement() + VariableNaming:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$val z2_10_0 = fieldElement() + VariableNaming:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$val z2_20_0 = fieldElement() + VariableNaming:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$val z2_50_0 = fieldElement() + VariableNaming:Curve25519FieldArithmetic.kt$Curve25519FieldArithmetic$val z2_5_0 = fieldElement() diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519FieldArithmetic.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519FieldArithmetic.kt new file mode 100644 index 00000000..ecab703d --- /dev/null +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519FieldArithmetic.kt @@ -0,0 +1,366 @@ +package ch.trancee.meshlink.platform.android.crypto + +/** + * RFC 8032/TweetNaCl-style radix-2^16 field arithmetic over GF(2^255-19). + * + * This is extracted verbatim from [Ed25519Fallback] so the signing API can stay focused on key + * handling/signature assembly while field operations remain isolated and independently auditable. + * + * IMPORTANT: This class intentionally preserves the exact arithmetic, carry strategy, and helper + * structure from the original fallback implementation. It is a structural extraction only. + */ +internal class Curve25519FieldArithmetic { + internal fun add(output: LongArray, left: LongArray, right: LongArray): Unit { + for (index in 0 until FIELD_ELEMENT_SIZE) { + output[index] = left[index] + right[index] + } + } + + internal fun subtract(output: LongArray, left: LongArray, right: LongArray): Unit { + for (index in 0 until FIELD_ELEMENT_SIZE) { + output[index] = left[index] - right[index] + } + } + + internal fun multiply( + output: LongArray, + left: LongArray, + right: LongArray, + temp: LongArray, + ): Unit { + temp.fill(0) + for (leftIndex in 0 until FIELD_ELEMENT_SIZE) { + val leftValue = left[leftIndex] + for (rightIndex in 0 until FIELD_ELEMENT_SIZE) { + temp[leftIndex + rightIndex] += leftValue * right[rightIndex] + } + } + for (index in 0 until FIELD_ELEMENT_SIZE - 1) { + temp[index] += 38L * temp[index + FIELD_ELEMENT_SIZE] + } + for (index in 0 until FIELD_ELEMENT_SIZE) { + output[index] = temp[index] + } + carry(output) + carry(output) + } + + internal fun square(output: LongArray, input: LongArray, temp: LongArray): Unit { + // Schoolbook squaring skips recomputing symmetric cross terms (input[i]*input[j] == + // input[j]*input[i]) and doubles them once instead, roughly halving the number of + // limb multiplications compared to calling the general multiply(input, input). + temp.fill(0) + for (leftIndex in 0 until FIELD_ELEMENT_SIZE) { + val leftValue = input[leftIndex] + temp[leftIndex * 2] += leftValue * leftValue + for (rightIndex in leftIndex + 1 until FIELD_ELEMENT_SIZE) { + temp[leftIndex + rightIndex] += 2L * leftValue * input[rightIndex] + } + } + for (index in 0 until FIELD_ELEMENT_SIZE - 1) { + temp[index] += 38L * temp[index + FIELD_ELEMENT_SIZE] + } + for (index in 0 until FIELD_ELEMENT_SIZE) { + output[index] = temp[index] + } + carry(output) + carry(output) + } + + /** + * Computes input^-1 mod p via Fermat's little theorem (input^(p-2)), but instead of the naive + * one-bit-at-a-time square-and-multiply (254 squarings + 251 multiplications), this uses the + * standard curve25519/ed25519 addition chain for the exponent p-2 = 2^255-21 (as used by + * ref10/curve25519-donna): it builds up runs of consecutive 1 bits (2^k-1 patterns) via + * repeated squaring plus a single multiply per run, cutting the multiplication count from 251 + * down to 11 while keeping the same 254 squarings. + */ + internal fun invert(output: LongArray, input: LongArray, temp: LongArray): Unit { + val z2 = fieldElement() + val z9 = fieldElement() + val z11 = fieldElement() + val z2_5_0 = fieldElement() + val z2_10_0 = fieldElement() + val z2_20_0 = fieldElement() + val z2_50_0 = fieldElement() + val z2_100_0 = fieldElement() + val t0 = fieldElement() + val t1 = fieldElement() + + square(z2, input, temp) // 2 + square(t0, z2, temp) // 4 + square(t0, t0, temp) // 8 + multiply(z9, t0, input, temp) // 9 + multiply(z11, z9, z2, temp) // 11 + square(t0, z11, temp) // 22 + multiply(z2_5_0, t0, z9, temp) // 2^5 - 2^0 = 31 + + square(t0, z2_5_0, temp) + for (index in 1 until 5) square(t0, t0, temp) // 2^10 - 2^5 + multiply(z2_10_0, t0, z2_5_0, temp) // 2^10 - 2^0 + + square(t0, z2_10_0, temp) + for (index in 1 until 10) square(t0, t0, temp) // 2^20 - 2^10 + multiply(z2_20_0, t0, z2_10_0, temp) // 2^20 - 2^0 + + square(t0, z2_20_0, temp) + for (index in 1 until 20) square(t0, t0, temp) // 2^40 - 2^20 + multiply(t1, t0, z2_20_0, temp) // 2^40 - 2^0 + + square(t0, t1, temp) + for (index in 1 until 10) square(t0, t0, temp) // 2^50 - 2^10 + multiply(z2_50_0, t0, z2_10_0, temp) // 2^50 - 2^0 + + square(t0, z2_50_0, temp) + for (index in 1 until 50) square(t0, t0, temp) // 2^100 - 2^50 + multiply(z2_100_0, t0, z2_50_0, temp) // 2^100 - 2^0 + + square(t0, z2_100_0, temp) + for (index in 1 until 100) square(t0, t0, temp) // 2^200 - 2^100 + multiply(t1, t0, z2_100_0, temp) // 2^200 - 2^0 + + square(t0, t1, temp) + for (index in 1 until 50) square(t0, t0, temp) // 2^250 - 2^50 + multiply(t0, t0, z2_50_0, temp) // 2^250 - 2^0 + + square(t0, t0, temp) // 2^251 - 2^1 + square(t0, t0, temp) // 2^252 - 2^2 + square(t0, t0, temp) // 2^253 - 2^3 + square(t0, t0, temp) // 2^254 - 2^4 + square(t0, t0, temp) // 2^255 - 2^5 + multiply(output, t0, z11, temp) // 2^255 - 21 + } + + /** + * Computes input^((p-5)/8) mod p = input^(2^252-3), used to compute a candidate square root + * during point decompression. Uses the same style of addition chain as [invert] (ref10's + * fe_pow22523), reducing the multiplication count from 249 down to 9 while keeping the same 250 + * squarings as the naive one-bit-at-a-time approach. + */ + internal fun power2523(output: LongArray, input: LongArray, temp: LongArray): Unit { + val z2 = fieldElement() + val z9 = fieldElement() + val z11 = fieldElement() + val z2_5_0 = fieldElement() + val z2_10_0 = fieldElement() + val z2_20_0 = fieldElement() + val z2_50_0 = fieldElement() + val z2_100_0 = fieldElement() + val t0 = fieldElement() + val t1 = fieldElement() + + square(z2, input, temp) // 2 + square(t0, z2, temp) // 4 + square(t0, t0, temp) // 8 + multiply(z9, t0, input, temp) // 9 + multiply(z11, z9, z2, temp) // 11 + square(t0, z11, temp) // 22 + multiply(z2_5_0, t0, z9, temp) // 2^5 - 2^0 = 31 + + square(t0, z2_5_0, temp) + for (index in 1 until 5) square(t0, t0, temp) + multiply(z2_10_0, t0, z2_5_0, temp) // 2^10 - 2^0 + + square(t0, z2_10_0, temp) + for (index in 1 until 10) square(t0, t0, temp) + multiply(z2_20_0, t0, z2_10_0, temp) // 2^20 - 2^0 + + square(t0, z2_20_0, temp) + for (index in 1 until 20) square(t0, t0, temp) + multiply(t1, t0, z2_20_0, temp) // 2^40 - 2^0 + + square(t0, t1, temp) + for (index in 1 until 10) square(t0, t0, temp) + multiply(z2_50_0, t0, z2_10_0, temp) // 2^50 - 2^0 + + square(t0, z2_50_0, temp) + for (index in 1 until 50) square(t0, t0, temp) + multiply(z2_100_0, t0, z2_50_0, temp) // 2^100 - 2^0 + + square(t0, z2_100_0, temp) + for (index in 1 until 100) square(t0, t0, temp) + multiply(t1, t0, z2_100_0, temp) // 2^200 - 2^0 + + square(t0, t1, temp) + for (index in 1 until 50) square(t0, t0, temp) + multiply(t0, t0, z2_50_0, temp) // 2^250 - 2^0 + + square(t0, t0, temp) // 2^251 - 2^1 + square(t0, t0, temp) // 2^252 - 2^2 + multiply(output, t0, input, temp) // 2^252 - 3 + } + + internal fun carry(output: LongArray): Unit { + var carry = 1L + for (index in 0 until FIELD_ELEMENT_SIZE) { + val value = output[index] + carry + 65535L + carry = value / 65536L + output[index] = value - (carry * 65536L) + } + output[0] += (carry - 1L) + (37L * (carry - 1L)) + } + + internal fun select(first: LongArray, second: LongArray, bit: Int): Unit { + val mask = -bit.toLong() + for (index in 0 until FIELD_ELEMENT_SIZE) { + val xor = first[index] xor second[index] + val delta = mask and xor + first[index] = first[index] xor delta + second[index] = second[index] xor delta + } + } + + internal fun pack25519(output: ByteArray, input: LongArray): Unit { + val reduced = input.copyOf() + val candidate = fieldElement() + repeat(3) { carry(reduced) } + repeat(2) { + candidate[0] = reduced[0] - 0xffedL + for (index in 1 until FIELD_ELEMENT_SIZE - 1) { + candidate[index] = reduced[index] - 0xffffL - ((candidate[index - 1] shr 16) and 1L) + candidate[index - 1] = candidate[index - 1] and 0xffffL + } + candidate[15] = reduced[15] - 0x7fffL - ((candidate[14] shr 16) and 1L) + val borrow = ((candidate[15] shr 16) and 1L).toInt() + candidate[14] = candidate[14] and 0xffffL + select(reduced, candidate, 1 - borrow) + } + for (index in 0 until FIELD_ELEMENT_SIZE) { + output[index * 2] = (reduced[index] and 0xffL).toByte() + output[(index * 2) + 1] = ((reduced[index] shr 8) and 0xffL).toByte() + } + } + + internal fun unpack25519(output: LongArray, input: ByteArray): Unit { + for (index in 0 until FIELD_ELEMENT_SIZE) { + output[index] = + input[index * 2].toUnsignedLong() + (input[(index * 2) + 1].toUnsignedLong() shl 8) + } + output[15] = output[15] and 0x7fffL + } + + internal fun zeroField(output: LongArray): Unit { + output.fill(0) + } + + internal fun copyField(output: LongArray, input: LongArray): Unit { + input.copyInto(output) + } + + private fun Byte.toUnsignedLong(): Long { + return (toInt() and 0xFF).toLong() + } + + internal companion object { + internal fun fieldElement(): LongArray = LongArray(FIELD_ELEMENT_SIZE) + + internal const val FIELD_ELEMENT_SIZE: Int = 16 + internal const val MULTIPLICATION_SCRATCH_SIZE: Int = 31 + + internal val FIELD_ZERO = + longArrayOf(0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L) + + internal val FIELD_ONE = + longArrayOf(1L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L) + + internal val D = + longArrayOf( + 0x78a3L, + 0x1359L, + 0x4dcaL, + 0x75ebL, + 0xd8abL, + 0x4141L, + 0x0a4dL, + 0x0070L, + 0xe898L, + 0x7779L, + 0x4079L, + 0x8cc7L, + 0xfe73L, + 0x2b6fL, + 0x6ceeL, + 0x5203L, + ) + + internal val D2 = + longArrayOf( + 0xf159L, + 0x26b2L, + 0x9b94L, + 0xebd6L, + 0xb156L, + 0x8283L, + 0x149aL, + 0x00e0L, + 0xd130L, + 0xeef3L, + 0x80f2L, + 0x198eL, + 0xfce7L, + 0x56dfL, + 0xd9dcL, + 0x2406L, + ) + + internal val BASE_X = + longArrayOf( + 0xd51aL, + 0x8f25L, + 0x2d60L, + 0xc956L, + 0xa7b2L, + 0x9525L, + 0xc760L, + 0x692cL, + 0xdc5cL, + 0xfdd6L, + 0xe231L, + 0xc0a4L, + 0x53feL, + 0xcd6eL, + 0x36d3L, + 0x2169L, + ) + + internal val BASE_Y = + longArrayOf( + 0x6658L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + 0x6666L, + ) + + internal val SQRT_MINUS_ONE = + longArrayOf( + 0xa0b0L, + 0x4a0eL, + 0x1b27L, + 0xc4eeL, + 0xe478L, + 0xad2fL, + 0x1806L, + 0x2f43L, + 0xd7a7L, + 0x3dfbL, + 0x0099L, + 0x2b4dL, + 0xdf0bL, + 0x4fc1L, + 0x2480L, + 0x2b83L, + ) + } +} diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519PointArithmetic.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519PointArithmetic.kt new file mode 100644 index 00000000..46649632 --- /dev/null +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Curve25519PointArithmetic.kt @@ -0,0 +1,345 @@ +package ch.trancee.meshlink.platform.android.crypto + +/** + * Edwards-curve point arithmetic used by [Ed25519Fallback]. + * + * This preserves the exact formulas and constant-time/variable-time distinctions from the original + * fallback implementation; the only change is extraction into a dedicated class to isolate this + * concern from signing/key-management logic. + */ +internal class Curve25519PointArithmetic(private val field: Curve25519FieldArithmetic) { + /** + * Precomputed radix-16 comb table for the fixed base point: `baseCombTable[block][digit]` = + * `digit * 16^block * B`. Built once (lazily, on first use) so every subsequent fixed-base + * scalar multiplication (key generation, signing, and half of verification) needs only 64 point + * additions and zero point doublings, instead of 256 doublings + 256 additions for a generic + * double-and-add ladder. This is the single biggest cost driver in the fallback + * signer/verifier, so trading ~64 * 15 precomputed points (~0.5 MB, built once per process) for + * that speedup is a deliberate priority-over-footprint tradeoff. + */ + private val baseCombTable: Array> by lazy { buildBaseCombTable() } + + /** + * Fixed-base scalar multiplication using the precomputed radix-16 comb table. Runs in constant + * time with respect to `scalar` (via masked table selection) because this is used for both + * secret scalars (nonce, private key during key generation/signing) and public ones + * (verification); the selection cost is negligible compared to the field multiplications it + * replaces, so there is no reason to special-case the public callers. + */ + internal fun scalarBase(output: Point, scalar: ByteArray): Unit { + require(scalar.size >= SCALAR_SIZE_BYTES) { "Ed25519 scalar must be at least 32 bytes" } + setIdentity(output) + val scratch = PointScratch() + val selected = Point() + for (block in 0 until COMB_BLOCK_COUNT) { + selectPoint(selected, baseCombTable[block], nibbleAt(scalar, block)) + add(output, selected, scratch) + } + } + + /** + * Variable-base windowed scalar multiplication (radix-16, MSB-first). Both `point` and `scalar` + * are public values at every call site (signature verification only), so this intentionally + * branches on scalar digits instead of using constant-time selection, trading side-channel + * resistance we don't need here for fewer point operations. + */ + internal fun windowedScalarMultiplyPublic( + output: Point, + point: Point, + scalar: ByteArray, + ): Unit { + require(scalar.size >= SCALAR_SIZE_BYTES) { "Ed25519 scalar must be at least 32 bytes" } + + val addScratch = PointScratch() + val table = arrayOfNulls(COMB_DIGIT_COUNT) + table[1] = point.copy() + for (digit in 2 until COMB_DIGIT_COUNT) { + val next = table[digit - 1]!!.copy() + add(next, point, addScratch) + table[digit] = next + } + + setIdentity(output) + val dblScratch = PointScratch() + for (block in COMB_BLOCK_COUNT - 1 downTo 0) { + repeat(WINDOW_BITS) { double(output, dblScratch) } + val digit = nibbleAt(scalar, block) + if (digit != 0) { + add(output, table[digit]!!, addScratch) + } + } + } + + internal fun add(point: Point, other: Point): Unit { + add(point, other, PointScratch()) + } + + internal fun pack(output: ByteArray, point: Point): Unit { + val tx = fieldElement() + val ty = fieldElement() + val zi = fieldElement() + val temp = LongArray(Curve25519FieldArithmetic.MULTIPLICATION_SCRATCH_SIZE) + field.invert(zi, point.z, temp) + field.multiply(tx, point.x, zi, temp) + field.multiply(ty, point.y, zi, temp) + field.pack25519(output, ty) + output[31] = (output[31].toInt() xor (fieldParity(tx) shl 7)).toByte() + } + + internal fun unpackNegative(output: Point, publicKey: ByteArray): Int { + val t = fieldElement() + val check = fieldElement() + val numerator = fieldElement() + val denominator = fieldElement() + val denominator2 = fieldElement() + val denominator4 = fieldElement() + val denominator6 = fieldElement() + val temp = LongArray(Curve25519FieldArithmetic.MULTIPLICATION_SCRATCH_SIZE) + + field.copyField(output.z, Curve25519FieldArithmetic.FIELD_ONE) + field.unpack25519(output.y, publicKey) + field.square(numerator, output.y, temp) + field.multiply(denominator, numerator, Curve25519FieldArithmetic.D, temp) + field.subtract(numerator, numerator, output.z) + field.add(denominator, output.z, denominator) + + field.square(denominator2, denominator, temp) + field.square(denominator4, denominator2, temp) + field.multiply(denominator6, denominator4, denominator2, temp) + field.multiply(t, denominator6, numerator, temp) + field.multiply(t, t, denominator, temp) + + field.power2523(t, t, temp) + field.multiply(t, t, numerator, temp) + field.multiply(t, t, denominator, temp) + field.multiply(t, t, denominator, temp) + field.multiply(output.x, t, denominator, temp) + + field.square(check, output.x, temp) + field.multiply(check, check, denominator, temp) + if (fieldNotEqual(check, numerator)) { + field.multiply(output.x, output.x, Curve25519FieldArithmetic.SQRT_MINUS_ONE, temp) + } + + field.square(check, output.x, temp) + field.multiply(check, check, denominator, temp) + if (fieldNotEqual(check, numerator)) { + return -1 + } + + if (fieldParity(output.x) == ((publicKey[31].toInt() and 0xFF) ushr 7)) { + field.subtract(output.x, Curve25519FieldArithmetic.FIELD_ZERO, output.x) + } + field.multiply(output.t, output.x, output.y, temp) + return 0 + } + + /** Extracts the 4-bit digit covering bits `[4 * block, 4 * block + 3]` of `scalar`. */ + private fun nibbleAt(scalar: ByteArray, block: Int): Int { + val byteValue = scalar[block ushr 1].toInt() + return if (block and 1 == 0) byteValue and 0x0F else (byteValue ushr 4) and 0x0F + } + + /** + * Builds the fixed-base comb table: `table[block][digit]` = `digit * 16^block * B`, for `block` + * in `0 until 64` and `digit` in `1 until 16` (digit 0 is never stored; selection treats it as + * the identity). This runs once per process (see `baseCombTable`). + */ + private fun buildBaseCombTable(): Array> { + val base = + Point().also { point -> + field.copyField(point.x, Curve25519FieldArithmetic.BASE_X) + field.copyField(point.y, Curve25519FieldArithmetic.BASE_Y) + field.copyField(point.z, Curve25519FieldArithmetic.FIELD_ONE) + field.multiply( + point.t, + Curve25519FieldArithmetic.BASE_X, + Curve25519FieldArithmetic.BASE_Y, + LongArray(Curve25519FieldArithmetic.MULTIPLICATION_SCRATCH_SIZE), + ) + } + + var blockBase = base + return Array(COMB_BLOCK_COUNT) { block -> + val addScratch = PointScratch() + val row = arrayOfNulls(COMB_DIGIT_COUNT) + row[1] = blockBase + for (digit in 2 until COMB_DIGIT_COUNT) { + val next = row[digit - 1]!!.copy() + add(next, blockBase, addScratch) + row[digit] = next + } + + if (block != COMB_BLOCK_COUNT - 1) { + val nextBlockBase = blockBase.copy() + val dblScratch = PointScratch() + repeat(WINDOW_BITS) { double(nextBlockBase, dblScratch) } + blockBase = nextBlockBase + } + + @Suppress("UNCHECKED_CAST") (row as Array) + } + } + + /** + * Constant-time selection of `candidates[digit]` into `output`, where `digit` is in `0 until + * 16` and `candidates[0]` is implicitly the identity point (not stored). Scans every candidate + * and masks in the match so execution time and memory access pattern do not depend on `digit`. + */ + private fun selectPoint(output: Point, candidates: Array, digit: Int): Unit { + setIdentity(output) + for (index in 1 until COMB_DIGIT_COUNT) { + val mask = maskEquals(digit, index) + conditionalCopy(output.x, candidates[index].x, mask) + conditionalCopy(output.y, candidates[index].y, mask) + conditionalCopy(output.z, candidates[index].z, mask) + conditionalCopy(output.t, candidates[index].t, mask) + } + } + + /** Returns an all-ones mask when `value == target`, otherwise an all-zeros mask. */ + private fun maskEquals(value: Int, target: Int): Long { + val diff = value xor target + // `diff` is in [0, 15], so `diff - 1` is -1 (all bits set) exactly when diff == 0, and a + // small non-negative number otherwise; sign-extending shift turns that into 0. + return ((diff - 1) shr 31).toLong() + } + + private fun conditionalCopy(destination: LongArray, source: LongArray, mask: Long): Unit { + for (index in 0 until Curve25519FieldArithmetic.FIELD_ELEMENT_SIZE) { + destination[index] = (destination[index] and mask.inv()) or (source[index] and mask) + } + } + + private fun setIdentity(point: Point): Unit { + field.zeroField(point.x) + field.copyField(point.y, Curve25519FieldArithmetic.FIELD_ONE) + field.copyField(point.z, Curve25519FieldArithmetic.FIELD_ONE) + field.zeroField(point.t) + } + + private fun add(point: Point, other: Point, scratch: PointScratch): Unit { + field.subtract(scratch.a, point.y, point.x) + field.subtract(scratch.t, other.y, other.x) + field.multiply(scratch.a, scratch.a, scratch.t, scratch.temp) + field.add(scratch.b, point.x, point.y) + field.add(scratch.t, other.x, other.y) + field.multiply(scratch.b, scratch.b, scratch.t, scratch.temp) + field.multiply(scratch.c, point.t, other.t, scratch.temp) + field.multiply(scratch.c, scratch.c, Curve25519FieldArithmetic.D2, scratch.temp) + field.multiply(scratch.d, point.z, other.z, scratch.temp) + field.add(scratch.d, scratch.d, scratch.d) + field.subtract(scratch.e, scratch.b, scratch.a) + field.subtract(scratch.f, scratch.d, scratch.c) + field.add(scratch.g, scratch.d, scratch.c) + field.add(scratch.h, scratch.b, scratch.a) + field.multiply(point.x, scratch.e, scratch.f, scratch.temp) + field.multiply(point.y, scratch.h, scratch.g, scratch.temp) + field.multiply(point.z, scratch.g, scratch.f, scratch.temp) + field.multiply(point.t, scratch.e, scratch.h, scratch.temp) + } + + /** + * Dedicated point doubling (dbl-2008-hwcd) for the twisted Edwards curve with a = -1. This is + * mathematically equivalent to `add(point, point, scratch)` but costs 4 squarings + 4 + * multiplications instead of the unified addition formula's 9 multiplications, which matters + * because every scalar-multiplication bit performs one doubling. + */ + private fun double(point: Point, scratch: PointScratch): Unit { + field.square(scratch.a, point.x, scratch.temp) + field.square(scratch.b, point.y, scratch.temp) + field.square(scratch.c, point.z, scratch.temp) + field.add(scratch.c, scratch.c, scratch.c) + field.add(scratch.h, point.x, point.y) + field.square(scratch.e, scratch.h, scratch.temp) + field.subtract(scratch.e, scratch.e, scratch.a) + field.subtract(scratch.e, scratch.e, scratch.b) + // d = a * A = -A since a = -1 + field.subtract(scratch.d, Curve25519FieldArithmetic.FIELD_ZERO, scratch.a) + field.add(scratch.g, scratch.d, scratch.b) + field.subtract(scratch.f, scratch.g, scratch.c) + field.subtract(scratch.h, scratch.d, scratch.b) + field.multiply(point.x, scratch.e, scratch.f, scratch.temp) + field.multiply(point.y, scratch.g, scratch.h, scratch.temp) + field.multiply(point.t, scratch.e, scratch.h, scratch.temp) + field.multiply(point.z, scratch.f, scratch.g, scratch.temp) + } + + private fun fieldParity(input: LongArray): Int { + val packed = ByteArray(PUBLIC_KEY_SIZE_BYTES) + field.pack25519(packed, input) + return packed[0].toInt() and 1 + } + + private fun fieldNotEqual(first: LongArray, second: LongArray): Boolean { + val packedFirst = ByteArray(PUBLIC_KEY_SIZE_BYTES) + val packedSecond = ByteArray(PUBLIC_KEY_SIZE_BYTES) + field.pack25519(packedFirst, first) + field.pack25519(packedSecond, second) + return !constantTimeEquals(packedFirst, 0, packedSecond, 0, PUBLIC_KEY_SIZE_BYTES) + } + + private fun constantTimeEquals( + first: ByteArray, + firstOffset: Int, + second: ByteArray, + secondOffset: Int, + length: Int, + ): Boolean { + var diff = 0 + for (index in 0 until length) { + diff = + diff or + ((first[firstOffset + index].toInt() and 0xFF) xor + (second[secondOffset + index].toInt() and 0xFF)) + } + return diff == 0 + } + + private fun fieldElement(): LongArray { + return Curve25519FieldArithmetic.fieldElement() + } + + private companion object { + private const val PUBLIC_KEY_SIZE_BYTES: Int = 32 + private const val SCALAR_SIZE_BYTES: Int = 32 + + /** Window width (bits) for the radix-16 comb/windowed scalar multiplications. */ + private const val WINDOW_BITS: Int = 4 + + /** Number of 4-bit windows covering a 256-bit scalar (32 bytes * 2 nibbles/byte). */ + private const val COMB_BLOCK_COUNT: Int = 64 + + /** Number of representable digit values per window (0 until 16, digit 0 is implicit). */ + private const val COMB_DIGIT_COUNT: Int = 16 + } +} + +internal class Point { + val x: LongArray = Curve25519FieldArithmetic.fieldElement() + val y: LongArray = Curve25519FieldArithmetic.fieldElement() + val z: LongArray = Curve25519FieldArithmetic.fieldElement() + val t: LongArray = Curve25519FieldArithmetic.fieldElement() + + fun copy(): Point { + return Point().also { point -> + x.copyInto(point.x) + y.copyInto(point.y) + z.copyInto(point.z) + t.copyInto(point.t) + } + } +} + +internal class PointScratch { + val a: LongArray = Curve25519FieldArithmetic.fieldElement() + val b: LongArray = Curve25519FieldArithmetic.fieldElement() + val c: LongArray = Curve25519FieldArithmetic.fieldElement() + val d: LongArray = Curve25519FieldArithmetic.fieldElement() + val e: LongArray = Curve25519FieldArithmetic.fieldElement() + val f: LongArray = Curve25519FieldArithmetic.fieldElement() + val g: LongArray = Curve25519FieldArithmetic.fieldElement() + val h: LongArray = Curve25519FieldArithmetic.fieldElement() + val t: LongArray = Curve25519FieldArithmetic.fieldElement() + val temp: LongArray = LongArray(Curve25519FieldArithmetic.MULTIPLICATION_SCRATCH_SIZE) +} diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Ed25519Fallback.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Ed25519Fallback.kt index 8dae5948..d823ff75 100644 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Ed25519Fallback.kt +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/crypto/Ed25519Fallback.kt @@ -24,16 +24,8 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr } } - /** - * Precomputed radix-16 comb table for the fixed base point: `baseCombTable[block][digit]` = - * `digit * 16^block * B`. Built once (lazily, on first use) so every subsequent fixed-base - * scalar multiplication (key generation, signing, and half of verification) needs only 64 point - * additions and zero point doublings, instead of 256 doublings + 256 additions for a generic - * double-and-add ladder. This is the single biggest cost driver in the fallback - * signer/verifier, so trading ~64 * 15 precomputed points (~0.5 MB, built once per process) for - * that speedup is a deliberate priority-over-footprint tradeoff. - */ - private val baseCombTable: Array> by lazy { buildBaseCombTable() } + private val fieldArithmetic = Curve25519FieldArithmetic() + private val pointArithmetic = Curve25519PointArithmetic(fieldArithmetic) internal fun generateKeyPair(): Ed25519KeyPair { val privateKey = @@ -61,9 +53,9 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr val expanded = expandedPrivateKey(privateKey) val reducedNonce = reduceScalar(sha512(expanded.prefix, message)) val noncePoint = Point() - scalarBase(noncePoint, reducedNonce) + pointArithmetic.scalarBase(noncePoint, reducedNonce) val encodedR = ByteArray(PUBLIC_KEY_SIZE_BYTES) - pack(encodedR, noncePoint) + pointArithmetic.pack(encodedR, noncePoint) val reducedChallenge = reduceScalar(sha512(encodedR, expanded.publicKey, message)) val s = ByteArray(SCALAR_SIZE_BYTES) @@ -95,7 +87,7 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr } val publicPoint = Point() - if (unpackNegative(publicPoint, publicKey) != 0) { + if (pointArithmetic.unpackNegative(publicPoint, publicKey) != 0) { return false } @@ -107,14 +99,14 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr // multiplication does not need to be constant-time; a plain windowed method is used // instead of the constant-time comb selection that fixed-base multiplication requires. val leftSide = Point() - windowedScalarMultiplyPublic(leftSide, publicPoint, reducedChallenge) + pointArithmetic.windowedScalarMultiplyPublic(leftSide, publicPoint, reducedChallenge) val rightSide = Point() - scalarBase(rightSide, scalarComponent) + pointArithmetic.scalarBase(rightSide, scalarComponent) - add(leftSide, rightSide, PointScratch()) + pointArithmetic.add(leftSide, rightSide) val encoded = ByteArray(PUBLIC_KEY_SIZE_BYTES) - pack(encoded, leftSide) + pointArithmetic.pack(encoded, leftSide) return constantTimeEquals(signature, 0, encoded, 0, PUBLIC_KEY_SIZE_BYTES) } @@ -135,9 +127,9 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr val scalar = hash.copyOfRange(0, SCALAR_SIZE_BYTES).also(::clampScalar) val prefix = hash.copyOfRange(SCALAR_SIZE_BYTES, HASH_SIZE_BYTES) val publicPoint = Point() - scalarBase(publicPoint, scalar) + pointArithmetic.scalarBase(publicPoint, scalar) val publicKey = ByteArray(PUBLIC_KEY_SIZE_BYTES) - pack(publicKey, publicPoint) + pointArithmetic.pack(publicKey, publicPoint) val expanded = ExpandedPrivateKey(scalar = scalar, prefix = prefix, publicKey = publicKey) synchronized(expandedPrivateKeys) { expandedPrivateKeys[seed] = expanded } @@ -163,482 +155,6 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr return digest.digest() } - /** - * Fixed-base scalar multiplication using the precomputed radix-16 comb table. Runs in constant - * time with respect to `scalar` (via masked table selection) because this is used for both - * secret scalars (nonce, private key during key generation/signing) and public ones - * (verification); the selection cost is negligible compared to the field multiplications it - * replaces, so there is no reason to special-case the public callers. - */ - private fun scalarBase(output: Point, scalar: ByteArray): Unit { - require(scalar.size >= SCALAR_SIZE_BYTES) { "Ed25519 scalar must be at least 32 bytes" } - setIdentity(output) - val scratch = PointScratch() - val selected = Point() - for (block in 0 until COMB_BLOCK_COUNT) { - selectPoint(selected, baseCombTable[block], nibbleAt(scalar, block)) - add(output, selected, scratch) - } - } - - /** - * Variable-base windowed scalar multiplication (radix-16, MSB-first). Both `point` and `scalar` - * are public values at every call site (signature verification only), so this intentionally - * branches on scalar digits instead of using constant-time selection, trading side-channel - * resistance we don't need here for fewer point operations. - */ - private fun windowedScalarMultiplyPublic(output: Point, point: Point, scalar: ByteArray): Unit { - require(scalar.size >= SCALAR_SIZE_BYTES) { "Ed25519 scalar must be at least 32 bytes" } - - val addScratch = PointScratch() - val table = arrayOfNulls(COMB_DIGIT_COUNT) - table[1] = point.copy() - for (digit in 2 until COMB_DIGIT_COUNT) { - val next = table[digit - 1]!!.copy() - add(next, point, addScratch) - table[digit] = next - } - - setIdentity(output) - val dblScratch = PointScratch() - for (block in COMB_BLOCK_COUNT - 1 downTo 0) { - repeat(WINDOW_BITS) { double(output, dblScratch) } - val digit = nibbleAt(scalar, block) - if (digit != 0) { - add(output, table[digit]!!, addScratch) - } - } - } - - /** Extracts the 4-bit digit covering bits `[4 * block, 4 * block + 3]` of `scalar`. */ - private fun nibbleAt(scalar: ByteArray, block: Int): Int { - val byteValue = scalar[block ushr 1].toInt() - return if (block and 1 == 0) byteValue and 0x0F else (byteValue ushr 4) and 0x0F - } - - /** - * Builds the fixed-base comb table: `table[block][digit]` = `digit * 16^block * B`, for `block` - * in `0 until 64` and `digit` in `1 until 16` (digit 0 is never stored; selection treats it as - * the identity). This runs once per process (see `baseCombTable`). - */ - private fun buildBaseCombTable(): Array> { - val base = - Point().also { point -> - copyField(point.x, BASE_X) - copyField(point.y, BASE_Y) - copyField(point.z, FIELD_ONE) - multiply(point.t, BASE_X, BASE_Y, LongArray(MULTIPLICATION_SCRATCH_SIZE)) - } - - var blockBase = base - return Array(COMB_BLOCK_COUNT) { block -> - val addScratch = PointScratch() - val row = arrayOfNulls(COMB_DIGIT_COUNT) - row[1] = blockBase - for (digit in 2 until COMB_DIGIT_COUNT) { - val next = row[digit - 1]!!.copy() - add(next, blockBase, addScratch) - row[digit] = next - } - - if (block != COMB_BLOCK_COUNT - 1) { - val nextBlockBase = blockBase.copy() - val dblScratch = PointScratch() - repeat(WINDOW_BITS) { double(nextBlockBase, dblScratch) } - blockBase = nextBlockBase - } - - @Suppress("UNCHECKED_CAST") (row as Array) - } - } - - /** - * Constant-time selection of `candidates[digit]` into `output`, where `digit` is in `0 until - * 16` and `candidates[0]` is implicitly the identity point (not stored). Scans every candidate - * and masks in the match so execution time and memory access pattern do not depend on `digit`. - */ - private fun selectPoint(output: Point, candidates: Array, digit: Int): Unit { - setIdentity(output) - for (index in 1 until COMB_DIGIT_COUNT) { - val mask = maskEquals(digit, index) - conditionalCopy(output.x, candidates[index].x, mask) - conditionalCopy(output.y, candidates[index].y, mask) - conditionalCopy(output.z, candidates[index].z, mask) - conditionalCopy(output.t, candidates[index].t, mask) - } - } - - /** Returns an all-ones mask when `value == target`, otherwise an all-zeros mask. */ - private fun maskEquals(value: Int, target: Int): Long { - val diff = value xor target - // `diff` is in [0, 15], so `diff - 1` is -1 (all bits set) exactly when diff == 0, and a - // small non-negative number otherwise; sign-extending shift turns that into 0. - return ((diff - 1) shr 31).toLong() - } - - private fun conditionalCopy(destination: LongArray, source: LongArray, mask: Long): Unit { - for (index in 0 until FIELD_ELEMENT_SIZE) { - destination[index] = (destination[index] and mask.inv()) or (source[index] and mask) - } - } - - private fun setIdentity(point: Point): Unit { - zeroField(point.x) - copyField(point.y, FIELD_ONE) - copyField(point.z, FIELD_ONE) - zeroField(point.t) - } - - private fun add(point: Point, other: Point, scratch: PointScratch): Unit { - subtract(scratch.a, point.y, point.x) - subtract(scratch.t, other.y, other.x) - multiply(scratch.a, scratch.a, scratch.t, scratch.temp) - add(scratch.b, point.x, point.y) - add(scratch.t, other.x, other.y) - multiply(scratch.b, scratch.b, scratch.t, scratch.temp) - multiply(scratch.c, point.t, other.t, scratch.temp) - multiply(scratch.c, scratch.c, D2, scratch.temp) - multiply(scratch.d, point.z, other.z, scratch.temp) - add(scratch.d, scratch.d, scratch.d) - subtract(scratch.e, scratch.b, scratch.a) - subtract(scratch.f, scratch.d, scratch.c) - add(scratch.g, scratch.d, scratch.c) - add(scratch.h, scratch.b, scratch.a) - multiply(point.x, scratch.e, scratch.f, scratch.temp) - multiply(point.y, scratch.h, scratch.g, scratch.temp) - multiply(point.z, scratch.g, scratch.f, scratch.temp) - multiply(point.t, scratch.e, scratch.h, scratch.temp) - } - - /** - * Dedicated point doubling (dbl-2008-hwcd) for the twisted Edwards curve with a = -1. This is - * mathematically equivalent to `add(point, point, scratch)` but costs 4 squarings + 4 - * multiplications instead of the unified addition formula's 9 multiplications, which matters - * because every scalar-multiplication bit performs one doubling. - */ - private fun double(point: Point, scratch: PointScratch): Unit { - square(scratch.a, point.x, scratch.temp) - square(scratch.b, point.y, scratch.temp) - square(scratch.c, point.z, scratch.temp) - add(scratch.c, scratch.c, scratch.c) - add(scratch.h, point.x, point.y) - square(scratch.e, scratch.h, scratch.temp) - subtract(scratch.e, scratch.e, scratch.a) - subtract(scratch.e, scratch.e, scratch.b) - // d = a * A = -A since a = -1 - subtract(scratch.d, FIELD_ZERO, scratch.a) - add(scratch.g, scratch.d, scratch.b) - subtract(scratch.f, scratch.g, scratch.c) - subtract(scratch.h, scratch.d, scratch.b) - multiply(point.x, scratch.e, scratch.f, scratch.temp) - multiply(point.y, scratch.g, scratch.h, scratch.temp) - multiply(point.t, scratch.e, scratch.h, scratch.temp) - multiply(point.z, scratch.f, scratch.g, scratch.temp) - } - - private fun pack(output: ByteArray, point: Point): Unit { - val tx = fieldElement() - val ty = fieldElement() - val zi = fieldElement() - val temp = LongArray(MULTIPLICATION_SCRATCH_SIZE) - invert(zi, point.z, temp) - multiply(tx, point.x, zi, temp) - multiply(ty, point.y, zi, temp) - pack25519(output, ty) - output[31] = (output[31].toInt() xor (fieldParity(tx) shl 7)).toByte() - } - - private fun unpackNegative(output: Point, publicKey: ByteArray): Int { - val t = fieldElement() - val check = fieldElement() - val numerator = fieldElement() - val denominator = fieldElement() - val denominator2 = fieldElement() - val denominator4 = fieldElement() - val denominator6 = fieldElement() - val temp = LongArray(MULTIPLICATION_SCRATCH_SIZE) - - copyField(output.z, FIELD_ONE) - unpack25519(output.y, publicKey) - square(numerator, output.y, temp) - multiply(denominator, numerator, D, temp) - subtract(numerator, numerator, output.z) - add(denominator, output.z, denominator) - - square(denominator2, denominator, temp) - square(denominator4, denominator2, temp) - multiply(denominator6, denominator4, denominator2, temp) - multiply(t, denominator6, numerator, temp) - multiply(t, t, denominator, temp) - - power2523(t, t, temp) - multiply(t, t, numerator, temp) - multiply(t, t, denominator, temp) - multiply(t, t, denominator, temp) - multiply(output.x, t, denominator, temp) - - square(check, output.x, temp) - multiply(check, check, denominator, temp) - if (fieldNotEqual(check, numerator)) { - multiply(output.x, output.x, SQRT_MINUS_ONE, temp) - } - - square(check, output.x, temp) - multiply(check, check, denominator, temp) - if (fieldNotEqual(check, numerator)) { - return -1 - } - - if (fieldParity(output.x) == ((publicKey[31].toInt() and 0xFF) ushr 7)) { - subtract(output.x, FIELD_ZERO, output.x) - } - multiply(output.t, output.x, output.y, temp) - return 0 - } - - private fun add(output: LongArray, left: LongArray, right: LongArray): Unit { - for (index in 0 until FIELD_ELEMENT_SIZE) { - output[index] = left[index] + right[index] - } - } - - private fun subtract(output: LongArray, left: LongArray, right: LongArray): Unit { - for (index in 0 until FIELD_ELEMENT_SIZE) { - output[index] = left[index] - right[index] - } - } - - private fun multiply( - output: LongArray, - left: LongArray, - right: LongArray, - temp: LongArray, - ): Unit { - temp.fill(0) - for (leftIndex in 0 until FIELD_ELEMENT_SIZE) { - val leftValue = left[leftIndex] - for (rightIndex in 0 until FIELD_ELEMENT_SIZE) { - temp[leftIndex + rightIndex] += leftValue * right[rightIndex] - } - } - for (index in 0 until FIELD_ELEMENT_SIZE - 1) { - temp[index] += 38L * temp[index + FIELD_ELEMENT_SIZE] - } - for (index in 0 until FIELD_ELEMENT_SIZE) { - output[index] = temp[index] - } - carry(output) - carry(output) - } - - private fun square(output: LongArray, input: LongArray, temp: LongArray): Unit { - // Schoolbook squaring skips recomputing symmetric cross terms (input[i]*input[j] == - // input[j]*input[i]) and doubles them once instead, roughly halving the number of - // limb multiplications compared to calling the general multiply(input, input). - temp.fill(0) - for (leftIndex in 0 until FIELD_ELEMENT_SIZE) { - val leftValue = input[leftIndex] - temp[leftIndex * 2] += leftValue * leftValue - for (rightIndex in leftIndex + 1 until FIELD_ELEMENT_SIZE) { - temp[leftIndex + rightIndex] += 2L * leftValue * input[rightIndex] - } - } - for (index in 0 until FIELD_ELEMENT_SIZE - 1) { - temp[index] += 38L * temp[index + FIELD_ELEMENT_SIZE] - } - for (index in 0 until FIELD_ELEMENT_SIZE) { - output[index] = temp[index] - } - carry(output) - carry(output) - } - - /** - * Computes input^-1 mod p via Fermat's little theorem (input^(p-2)), but instead of the naive - * one-bit-at-a-time square-and-multiply (254 squarings + 251 multiplications), this uses the - * standard curve25519/ed25519 addition chain for the exponent p-2 = 2^255-21 (as used by - * ref10/curve25519-donna): it builds up runs of consecutive 1 bits (2^k-1 patterns) via - * repeated squaring plus a single multiply per run, cutting the multiplication count from 251 - * down to 11 while keeping the same 254 squarings. - */ - private fun invert(output: LongArray, input: LongArray, temp: LongArray): Unit { - val z2 = fieldElement() - val z9 = fieldElement() - val z11 = fieldElement() - val z2_5_0 = fieldElement() - val z2_10_0 = fieldElement() - val z2_20_0 = fieldElement() - val z2_50_0 = fieldElement() - val z2_100_0 = fieldElement() - val t0 = fieldElement() - val t1 = fieldElement() - - square(z2, input, temp) // 2 - square(t0, z2, temp) // 4 - square(t0, t0, temp) // 8 - multiply(z9, t0, input, temp) // 9 - multiply(z11, z9, z2, temp) // 11 - square(t0, z11, temp) // 22 - multiply(z2_5_0, t0, z9, temp) // 2^5 - 2^0 = 31 - - square(t0, z2_5_0, temp) - for (index in 1 until 5) square(t0, t0, temp) // 2^10 - 2^5 - multiply(z2_10_0, t0, z2_5_0, temp) // 2^10 - 2^0 - - square(t0, z2_10_0, temp) - for (index in 1 until 10) square(t0, t0, temp) // 2^20 - 2^10 - multiply(z2_20_0, t0, z2_10_0, temp) // 2^20 - 2^0 - - square(t0, z2_20_0, temp) - for (index in 1 until 20) square(t0, t0, temp) // 2^40 - 2^20 - multiply(t1, t0, z2_20_0, temp) // 2^40 - 2^0 - - square(t0, t1, temp) - for (index in 1 until 10) square(t0, t0, temp) // 2^50 - 2^10 - multiply(z2_50_0, t0, z2_10_0, temp) // 2^50 - 2^0 - - square(t0, z2_50_0, temp) - for (index in 1 until 50) square(t0, t0, temp) // 2^100 - 2^50 - multiply(z2_100_0, t0, z2_50_0, temp) // 2^100 - 2^0 - - square(t0, z2_100_0, temp) - for (index in 1 until 100) square(t0, t0, temp) // 2^200 - 2^100 - multiply(t1, t0, z2_100_0, temp) // 2^200 - 2^0 - - square(t0, t1, temp) - for (index in 1 until 50) square(t0, t0, temp) // 2^250 - 2^50 - multiply(t0, t0, z2_50_0, temp) // 2^250 - 2^0 - - square(t0, t0, temp) // 2^251 - 2^1 - square(t0, t0, temp) // 2^252 - 2^2 - square(t0, t0, temp) // 2^253 - 2^3 - square(t0, t0, temp) // 2^254 - 2^4 - square(t0, t0, temp) // 2^255 - 2^5 - multiply(output, t0, z11, temp) // 2^255 - 21 - } - - /** - * Computes input^((p-5)/8) mod p = input^(2^252-3), used to compute a candidate square root - * during point decompression. Uses the same style of addition chain as [invert] (ref10's - * fe_pow22523), reducing the multiplication count from 249 down to 9 while keeping the same 250 - * squarings as the naive one-bit-at-a-time approach. - */ - private fun power2523(output: LongArray, input: LongArray, temp: LongArray): Unit { - val z2 = fieldElement() - val z9 = fieldElement() - val z11 = fieldElement() - val z2_5_0 = fieldElement() - val z2_10_0 = fieldElement() - val z2_20_0 = fieldElement() - val z2_50_0 = fieldElement() - val z2_100_0 = fieldElement() - val t0 = fieldElement() - val t1 = fieldElement() - - square(z2, input, temp) // 2 - square(t0, z2, temp) // 4 - square(t0, t0, temp) // 8 - multiply(z9, t0, input, temp) // 9 - multiply(z11, z9, z2, temp) // 11 - square(t0, z11, temp) // 22 - multiply(z2_5_0, t0, z9, temp) // 2^5 - 2^0 = 31 - - square(t0, z2_5_0, temp) - for (index in 1 until 5) square(t0, t0, temp) - multiply(z2_10_0, t0, z2_5_0, temp) // 2^10 - 2^0 - - square(t0, z2_10_0, temp) - for (index in 1 until 10) square(t0, t0, temp) - multiply(z2_20_0, t0, z2_10_0, temp) // 2^20 - 2^0 - - square(t0, z2_20_0, temp) - for (index in 1 until 20) square(t0, t0, temp) - multiply(t1, t0, z2_20_0, temp) // 2^40 - 2^0 - - square(t0, t1, temp) - for (index in 1 until 10) square(t0, t0, temp) - multiply(z2_50_0, t0, z2_10_0, temp) // 2^50 - 2^0 - - square(t0, z2_50_0, temp) - for (index in 1 until 50) square(t0, t0, temp) - multiply(z2_100_0, t0, z2_50_0, temp) // 2^100 - 2^0 - - square(t0, z2_100_0, temp) - for (index in 1 until 100) square(t0, t0, temp) - multiply(t1, t0, z2_100_0, temp) // 2^200 - 2^0 - - square(t0, t1, temp) - for (index in 1 until 50) square(t0, t0, temp) - multiply(t0, t0, z2_50_0, temp) // 2^250 - 2^0 - - square(t0, t0, temp) // 2^251 - 2^1 - square(t0, t0, temp) // 2^252 - 2^2 - multiply(output, t0, input, temp) // 2^252 - 3 - } - - private fun carry(output: LongArray): Unit { - var carry = 1L - for (index in 0 until FIELD_ELEMENT_SIZE) { - val value = output[index] + carry + 65535L - carry = value / 65536L - output[index] = value - (carry * 65536L) - } - output[0] += (carry - 1L) + (37L * (carry - 1L)) - } - - private fun select(first: LongArray, second: LongArray, bit: Int): Unit { - val mask = -bit.toLong() - for (index in 0 until FIELD_ELEMENT_SIZE) { - val xor = first[index] xor second[index] - val delta = mask and xor - first[index] = first[index] xor delta - second[index] = second[index] xor delta - } - } - - private fun pack25519(output: ByteArray, input: LongArray): Unit { - val reduced = input.copyOf() - val candidate = fieldElement() - repeat(3) { carry(reduced) } - repeat(2) { - candidate[0] = reduced[0] - 0xffedL - for (index in 1 until FIELD_ELEMENT_SIZE - 1) { - candidate[index] = reduced[index] - 0xffffL - ((candidate[index - 1] shr 16) and 1L) - candidate[index - 1] = candidate[index - 1] and 0xffffL - } - candidate[15] = reduced[15] - 0x7fffL - ((candidate[14] shr 16) and 1L) - val borrow = ((candidate[15] shr 16) and 1L).toInt() - candidate[14] = candidate[14] and 0xffffL - select(reduced, candidate, 1 - borrow) - } - for (index in 0 until FIELD_ELEMENT_SIZE) { - output[index * 2] = (reduced[index] and 0xffL).toByte() - output[(index * 2) + 1] = ((reduced[index] shr 8) and 0xffL).toByte() - } - } - - private fun unpack25519(output: LongArray, input: ByteArray): Unit { - for (index in 0 until FIELD_ELEMENT_SIZE) { - output[index] = - input[index * 2].toUnsignedLong() + (input[(index * 2) + 1].toUnsignedLong() shl 8) - } - output[15] = output[15] and 0x7fffL - } - - private fun fieldParity(input: LongArray): Int { - val packed = ByteArray(PUBLIC_KEY_SIZE_BYTES) - pack25519(packed, input) - return packed[0].toInt() and 1 - } - - private fun fieldNotEqual(first: LongArray, second: LongArray): Boolean { - val packedFirst = ByteArray(PUBLIC_KEY_SIZE_BYTES) - val packedSecond = ByteArray(PUBLIC_KEY_SIZE_BYTES) - pack25519(packedFirst, first) - pack25519(packedSecond, second) - return !constantTimeEquals(packedFirst, 0, packedSecond, 0, PUBLIC_KEY_SIZE_BYTES) - } - private fun reduce(input: ByteArray): Unit { val expanded = LongArray(64) { index -> input[index].toUnsignedLong() } input.fill(0) @@ -711,14 +227,6 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr return diff == 0 } - private fun zeroField(output: LongArray): Unit { - output.fill(0) - } - - private fun copyField(output: LongArray, input: LongArray): Unit { - input.copyInto(output) - } - private fun requireSized(value: ByteArray, expectedSize: Int, label: String): ByteArray { if (value.size != expectedSize) { throw MeshLinkException.CryptoFailure("$label key must be $expectedSize bytes") @@ -736,161 +244,13 @@ internal class Ed25519Fallback(private val randomBytesProvider: (Int) -> ByteArr val publicKey: ByteArray = publicKey.copyOf() } - private class Point { - val x: LongArray = fieldElement() - val y: LongArray = fieldElement() - val z: LongArray = fieldElement() - val t: LongArray = fieldElement() - - fun copy(): Point { - return Point().also { point -> - x.copyInto(point.x) - y.copyInto(point.y) - z.copyInto(point.z) - t.copyInto(point.t) - } - } - } - - private class PointScratch { - val a: LongArray = fieldElement() - val b: LongArray = fieldElement() - val c: LongArray = fieldElement() - val d: LongArray = fieldElement() - val e: LongArray = fieldElement() - val f: LongArray = fieldElement() - val g: LongArray = fieldElement() - val h: LongArray = fieldElement() - val t: LongArray = fieldElement() - val temp: LongArray = LongArray(MULTIPLICATION_SCRATCH_SIZE) - } - private companion object { - private fun fieldElement(): LongArray = LongArray(FIELD_ELEMENT_SIZE) - - private const val FIELD_ELEMENT_SIZE: Int = 16 private const val HASH_SIZE_BYTES: Int = 64 - private const val MULTIPLICATION_SCRATCH_SIZE: Int = 31 private const val PRIVATE_KEY_SIZE_BYTES: Int = 32 private const val PUBLIC_KEY_SIZE_BYTES: Int = 32 private const val SCALAR_SIZE_BYTES: Int = 32 private const val SIGNATURE_SIZE_BYTES: Int = 64 - /** Window width (bits) for the radix-16 comb/windowed scalar multiplications. */ - private const val WINDOW_BITS: Int = 4 - - /** Number of 4-bit windows covering a 256-bit scalar (32 bytes * 2 nibbles/byte). */ - private const val COMB_BLOCK_COUNT: Int = 64 - - /** Number of representable digit values per window (0 until 16, digit 0 is implicit). */ - private const val COMB_DIGIT_COUNT: Int = 16 - - private val FIELD_ZERO = - longArrayOf(0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L) - - private val FIELD_ONE = - longArrayOf(1L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L) - - private val D = - longArrayOf( - 0x78a3L, - 0x1359L, - 0x4dcaL, - 0x75ebL, - 0xd8abL, - 0x4141L, - 0x0a4dL, - 0x0070L, - 0xe898L, - 0x7779L, - 0x4079L, - 0x8cc7L, - 0xfe73L, - 0x2b6fL, - 0x6ceeL, - 0x5203L, - ) - - private val D2 = - longArrayOf( - 0xf159L, - 0x26b2L, - 0x9b94L, - 0xebd6L, - 0xb156L, - 0x8283L, - 0x149aL, - 0x00e0L, - 0xd130L, - 0xeef3L, - 0x80f2L, - 0x198eL, - 0xfce7L, - 0x56dfL, - 0xd9dcL, - 0x2406L, - ) - - private val BASE_X = - longArrayOf( - 0xd51aL, - 0x8f25L, - 0x2d60L, - 0xc956L, - 0xa7b2L, - 0x9525L, - 0xc760L, - 0x692cL, - 0xdc5cL, - 0xfdd6L, - 0xe231L, - 0xc0a4L, - 0x53feL, - 0xcd6eL, - 0x36d3L, - 0x2169L, - ) - - private val BASE_Y = - longArrayOf( - 0x6658L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - 0x6666L, - ) - - private val SQRT_MINUS_ONE = - longArrayOf( - 0xa0b0L, - 0x4a0eL, - 0x1b27L, - 0xc4eeL, - 0xe478L, - 0xad2fL, - 0x1806L, - 0x2f43L, - 0xd7a7L, - 0x3dfbL, - 0x0099L, - 0x2b4dL, - 0xdf0bL, - 0x4fc1L, - 0x2480L, - 0x2b83L, - ) - private val GROUP_ORDER = longArrayOf( 0xedL,