From 67d57ba4577620502f215adca936e9ad5a4e9533 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 22:59:18 +0200 Subject: [PATCH 1/8] refactor: simplify x25519 fallback path --- .../android/AndroidFallbackCryptoProvider.kt | 5 +- .../platform/android/X25519Fallback.kt | 12 +--- .../trancee/meshlink/crypto/PureX25519Test.kt | 60 +++++++++++++++++++ 3 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt index 80b80207..48195c9c 100644 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt @@ -3,6 +3,7 @@ package ch.trancee.meshlink.platform.android import ch.trancee.meshlink.api.MeshLinkException import ch.trancee.meshlink.crypto.CryptoProvider import ch.trancee.meshlink.crypto.Ed25519KeyPair +import ch.trancee.meshlink.crypto.PureX25519 import ch.trancee.meshlink.crypto.X25519KeyPair import ch.trancee.meshlink.crypto.requireValidX25519SharedSecret import java.io.ByteArrayOutputStream @@ -50,7 +51,7 @@ internal class AndroidFallbackCryptoProvider : CryptoProvider { override fun generateX25519KeyPair(): X25519KeyPair { val privateKey = randomBytes(X25519_KEY_SIZE_BYTES) clampX25519Scalar(privateKey) - val publicKey = X25519Fallback.publicKeyFromPrivate(privateKey) + val publicKey = PureX25519.publicKeyFromPrivate(privateKey) return X25519KeyPair(privateKey = privateKey, publicKey = publicKey) } @@ -59,7 +60,7 @@ internal class AndroidFallbackCryptoProvider : CryptoProvider { } override fun x25519(privateKey: ByteArray, publicKey: ByteArray): ByteArray { - return requireValidX25519SharedSecret(X25519Fallback.sharedSecret(privateKey, publicKey)) + return requireValidX25519SharedSecret(PureX25519.sharedSecret(privateKey, publicKey)) } override fun ed25519Sign(privateKey: ByteArray, message: ByteArray): ByteArray { diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt index c9b8c2a4..f8f71447 100644 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt @@ -1,13 +1,3 @@ package ch.trancee.meshlink.platform.android -import ch.trancee.meshlink.crypto.PureX25519 - -internal object X25519Fallback { - fun publicKeyFromPrivate(privateKey: ByteArray): ByteArray { - return PureX25519.publicKeyFromPrivate(privateKey) - } - - fun sharedSecret(privateKey: ByteArray, publicKey: ByteArray): ByteArray { - return PureX25519.sharedSecret(privateKey, publicKey) - } -} +// Intentionally empty: AndroidFallbackCryptoProvider now calls PureX25519 directly. diff --git a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt new file mode 100644 index 00000000..0178e67e --- /dev/null +++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt @@ -0,0 +1,60 @@ +package ch.trancee.meshlink.crypto + +import ch.trancee.meshlink.api.MeshLinkException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PureX25519Test { + @Test + fun `public key generation matches the base point shared secret for a clamped scalar`() { + // Arrange + val privateKey = hex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") + val basePoint = ByteArray(32).also { it[0] = 9 } + + // Act + val publicKey = PureX25519.publicKeyFromPrivate(privateKey) + val sharedSecret = PureX25519.sharedSecret(privateKey, basePoint) + + // Assert + assertContentEquals(sharedSecret, publicKey) + } + + @Test + fun `shared secret matches rfc 7748 vector 2`() { + // Arrange + val privateKey = hex("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") + val publicKey = hex("e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493") + val expected = hex("95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957") + + // Act + val actual = PureX25519.sharedSecret(privateKey = privateKey, publicKey = publicKey) + + // Assert + assertContentEquals(expected, actual) + } + + @Test + fun `shared secret rejects low order inputs at the validation layer`() { + // Arrange + val privateKey = hex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a") + val lowOrderPublicKey = ByteArray(32) + + // Act + val sharedSecret = PureX25519.sharedSecret(privateKey, lowOrderPublicKey) + + // Assert + assertTrue(sharedSecret.all { it == 0.toByte() }) + assertFailsWith { + requireValidX25519SharedSecret(sharedSecret) + } + } + + private fun hex(value: String): ByteArray { + require(value.length % 2 == 0) { "hex input must have an even length" } + return ByteArray(value.length / 2) { index -> + value.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } +} From 241cf79c58e7ca3242fe61998cfb8e174f4c88a6 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 23:09:57 +0200 Subject: [PATCH 2/8] test: strengthen x25519 edge cases --- .../AndroidFallbackCryptoProviderTest.kt | 15 +++++++++++++++ .../trancee/meshlink/crypto/PureX25519Test.kt | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt b/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt index eabaaef3..5255a427 100644 --- a/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt +++ b/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt @@ -38,6 +38,21 @@ class AndroidFallbackCryptoProviderTest { assertContentEquals(expected, actual) } + @Test + fun `generate x25519 key pair clamps the scalar bits`() { + // Arrange + val keyPair = provider.generateX25519KeyPair() + + // Act + val privateKey = keyPair.privateKey + + // Assert + assertEquals(32, privateKey.size) + assertEquals(0, privateKey[0].toInt() and 0x07) + assertEquals(0, privateKey[31].toInt() and 0x80) + assertTrue(privateKey[31].toInt() and 0x40 != 0) + } + @Test fun `x25519 shared secret is symmetric`() { // Arrange diff --git a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt index 0178e67e..0a859510 100644 --- a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt +++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt @@ -21,6 +21,24 @@ class PureX25519Test { assertContentEquals(sharedSecret, publicKey) } + @Test + fun `x25519 ignores the high bit of a noncanonical public key`() { + // Arrange + val privateKey = hex("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") + val canonicalPublicKey = hex("e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493") + val nonCanonicalPublicKey = + canonicalPublicKey.copyOf().also { + it[it.lastIndex] = (it[it.lastIndex].toInt() or 0x80).toByte() + } + + // Act + val canonicalSharedSecret = PureX25519.sharedSecret(privateKey, canonicalPublicKey) + val nonCanonicalSharedSecret = PureX25519.sharedSecret(privateKey, nonCanonicalPublicKey) + + // Assert + assertContentEquals(canonicalSharedSecret, nonCanonicalSharedSecret) + } + @Test fun `shared secret matches rfc 7748 vector 2`() { // Arrange From 948663972842828226cdb97b0289895224db28e4 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 23:14:32 +0200 Subject: [PATCH 3/8] docs: refresh x25519 benchmark baselines --- benchmarks/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 0fb66eb0..960e8cb5 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -40,10 +40,10 @@ Rows not listed above are retained as regression-tracked evidence only. | Wire decode message | 0.083 us/op | Meets the codec target | | Wire encode transfer chunk | 0.210 us/op | Meets the codec target | | Wire decode transfer chunk | 0.083 us/op | Meets the codec target | -| X25519 keypair, JCA/JVM provider | 95.945 us/op | Baseline retained result for the platform-backed provider. | -| X25519 keypair, pure fallback provider | 379.583 us/op | About 4x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | -| X25519 agreement, JCA/JVM provider | 96.979 us/op | Baseline retained result for the platform-backed provider. | -| X25519 agreement, pure fallback provider | 381.884 us/op | About 4x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 keypair, JCA/JVM provider | 88.525 us/op | Baseline retained result for the platform-backed provider. | +| X25519 keypair, pure fallback provider | 405.106 us/op | About 4.6x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 agreement, JCA/JVM provider | 90.186 us/op | Baseline retained result for the platform-backed provider. | +| X25519 agreement, pure fallback provider | 408.491 us/op | About 4.5x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | | 8-peer steady-state memory budget | 3,993,216 retained bytes | Meets the memory target | ### Physical mobile evidence From 2c7e37c65c3dec47f31106196b039ec66e932976 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 23:25:11 +0200 Subject: [PATCH 4/8] perf: reduce pure x25519 ladder allocations --- benchmarks/README.md | 8 +- benchmarks/history.md | 8 ++ .../platform/android/X25519Fallback.kt | 3 - .../ch/trancee/meshlink/crypto/PureX25519.kt | 86 +++++++++++-------- 4 files changed, 63 insertions(+), 42 deletions(-) delete mode 100644 meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt diff --git a/benchmarks/README.md b/benchmarks/README.md index 960e8cb5..9136d6af 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -40,10 +40,10 @@ Rows not listed above are retained as regression-tracked evidence only. | Wire decode message | 0.083 us/op | Meets the codec target | | Wire encode transfer chunk | 0.210 us/op | Meets the codec target | | Wire decode transfer chunk | 0.083 us/op | Meets the codec target | -| X25519 keypair, JCA/JVM provider | 88.525 us/op | Baseline retained result for the platform-backed provider. | -| X25519 keypair, pure fallback provider | 405.106 us/op | About 4.6x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | -| X25519 agreement, JCA/JVM provider | 90.186 us/op | Baseline retained result for the platform-backed provider. | -| X25519 agreement, pure fallback provider | 408.491 us/op | About 4.5x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 keypair, JCA/JVM provider | 101.601 us/op | Baseline retained result for the platform-backed provider. | +| X25519 keypair, pure fallback provider | 338.214 us/op | About 3.3x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 agreement, JCA/JVM provider | 115.999 us/op | Baseline retained result for the platform-backed provider. | +| X25519 agreement, pure fallback provider | 339.983 us/op | About 2.9x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | | 8-peer steady-state memory budget | 3,993,216 retained bytes | Meets the memory target | ### Physical mobile evidence diff --git a/benchmarks/history.md b/benchmarks/history.md index 0bcd4adf..2a5b8e65 100644 --- a/benchmarks/history.md +++ b/benchmarks/history.md @@ -43,6 +43,14 @@ Rows not listed above are retained as regression-tracked evidence only. | 8-peer establish benchmark smoke | 262.995 ms/op | Regression-tracked evidence only | | 8-peer steady-state memory budget | 3,993,216 retained bytes | Meets the normative <= 8 MiB memory target | +X25519 provider comparison from the 2026-06-17 benchmark refresh is retained in `benchmarks/build/reports/benchmarks/main/2026-06-17T23.04.11.896144882/jvm.json`: + +- JCA/JVM keypair: 101.601 us/op +- Pure fallback keypair: 338.214 us/op +- JCA/JVM agreement: 115.999 us/op +- Pure fallback agreement: 339.983 us/op +- The in-place ladder rewrite reduced the pure-path cost materially, but the JCA/provider-backed path remains the preferred fast path. + Fresh JVM integration evidence from `MemoryBudgetIntegrationTest` retained: - `MEMORY_BUDGET baselineBytes=7437064 usedBytes=11430280 steadyStateBytes=3993216` diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt deleted file mode 100644 index f8f71447..00000000 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/X25519Fallback.kt +++ /dev/null @@ -1,3 +0,0 @@ -package ch.trancee.meshlink.platform.android - -// Intentionally empty: AndroidFallbackCryptoProvider now calls PureX25519 directly. diff --git a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt index 96739475..8fa9ba60 100644 --- a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt +++ b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt @@ -18,11 +18,17 @@ internal object PureX25519 { val scalar = privateKey.copyOf() clampScalar(scalar) + val x1 = unpack25519(publicKey) var a = LongArray(16) var b = x1.copyOf() var c = LongArray(16) var d = LongArray(16) + val e = LongArray(16) + val f = LongArray(16) + val g = LongArray(16) + val h = LongArray(16) + val product = LongArray(31) a[0] = 1 d[0] = 1 @@ -31,30 +37,32 @@ internal object PureX25519 { conditionalSwap(a, b, bit) conditionalSwap(c, d, bit) - val e = add(a, c) - a = subtract(a, c) - val f = add(b, d) - b = subtract(b, d) - d = square(e) - val g = square(a) - a = multiply(f, a) - c = multiply(b, e) - val h = add(a, c) - a = subtract(a, c) - b = square(a) - c = subtract(d, g) - a = multiply(c, a24) - a = add(a, d) - c = multiply(c, a) - a = multiply(d, g) - d = multiply(b, x1) - b = square(h) + addInto(e, a, c) + subtractInto(a, a, c) + addInto(f, b, d) + subtractInto(b, b, d) + squareInto(d, e, product) + squareInto(g, a, product) + multiplyInto(a, f, a, product) + multiplyInto(c, b, e, product) + addInto(h, a, c) + subtractInto(a, a, c) + squareInto(b, a, product) + subtractInto(c, d, g) + multiplyInto(a, c, a24, product) + addInto(a, a, d) + multiplyInto(c, c, a, product) + multiplyInto(a, d, g, product) + multiplyInto(d, b, x1, product) + squareInto(b, h, product) conditionalSwap(a, b, bit) conditionalSwap(c, d, bit) } - return pack25519(multiply(a, invert(c))) + val inverseC = invert(c, product) + multiplyInto(a, a, inverseC, product) + return pack25519(a) } private fun clampScalar(scalar: ByteArray) { @@ -62,40 +70,48 @@ internal object PureX25519 { scalar[31] = ((scalar[31].toInt() and 127) or 64).toByte() } - private fun add(left: LongArray, right: LongArray): LongArray { - return LongArray(16) { index -> left[index] + right[index] } + private fun addInto(dest: LongArray, left: LongArray, right: LongArray) { + for (index in 0 until 16) { + dest[index] = left[index] + right[index] + } } - private fun subtract(left: LongArray, right: LongArray): LongArray { - return LongArray(16) { index -> left[index] - right[index] } + private fun subtractInto(dest: LongArray, left: LongArray, right: LongArray) { + for (index in 0 until 16) { + dest[index] = left[index] - right[index] + } } - private fun square(value: LongArray): LongArray { - return multiply(value, value) + private fun squareInto(dest: LongArray, value: LongArray, product: LongArray) { + multiplyInto(dest, value, value, product) } - private fun multiply(left: LongArray, right: LongArray): LongArray { - val product = LongArray(31) + private fun multiplyInto(dest: LongArray, left: LongArray, right: LongArray, product: LongArray) { + for (index in 0 until 31) { + product[index] = 0L + } for (i in 0 until 16) { + val leftValue = left[i] for (j in 0 until 16) { - product[i + j] += left[i] * right[j] + product[i + j] += leftValue * right[j] } } for (i in 0 until 15) { product[i] += 38L * product[i + 16] } - val reduced = LongArray(16) { index -> product[index] } - carry25519(reduced) - carry25519(reduced) - return reduced + for (index in 0 until 16) { + dest[index] = product[index] + } + carry25519(dest) + carry25519(dest) } - private fun invert(value: LongArray): LongArray { + private fun invert(value: LongArray, product: LongArray): LongArray { var result = value.copyOf() for (iteration in 253 downTo 0) { - result = square(result) + squareInto(result, result, product) if (iteration != 2 && iteration != 4) { - result = multiply(result, value) + multiplyInto(result, result, value, product) } } return result From 7861cdb994839ff48b2a6ff4a5f82b2673547cbc Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 23:33:44 +0200 Subject: [PATCH 5/8] perf: specialize x25519 a24 multiply --- benchmarks/README.md | 8 +++---- benchmarks/history.md | 10 ++++---- .../ch/trancee/meshlink/crypto/PureX25519.kt | 23 +++++++++++++++++-- .../trancee/meshlink/crypto/PureX25519Test.kt | 19 +++++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 9136d6af..2e2167c5 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -40,10 +40,10 @@ Rows not listed above are retained as regression-tracked evidence only. | Wire decode message | 0.083 us/op | Meets the codec target | | Wire encode transfer chunk | 0.210 us/op | Meets the codec target | | Wire decode transfer chunk | 0.083 us/op | Meets the codec target | -| X25519 keypair, JCA/JVM provider | 101.601 us/op | Baseline retained result for the platform-backed provider. | -| X25519 keypair, pure fallback provider | 338.214 us/op | About 3.3x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | -| X25519 agreement, JCA/JVM provider | 115.999 us/op | Baseline retained result for the platform-backed provider. | -| X25519 agreement, pure fallback provider | 339.983 us/op | About 2.9x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 keypair, JCA/JVM provider | 100.461 us/op | Baseline retained result for the platform-backed provider. | +| X25519 keypair, pure fallback provider | 300.400 us/op | About 3.0x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 agreement, JCA/JVM provider | 100.808 us/op | Baseline retained result for the platform-backed provider. | +| X25519 agreement, pure fallback provider | 315.987 us/op | About 3.1x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | | 8-peer steady-state memory budget | 3,993,216 retained bytes | Meets the memory target | ### Physical mobile evidence diff --git a/benchmarks/history.md b/benchmarks/history.md index 2a5b8e65..b728e6f4 100644 --- a/benchmarks/history.md +++ b/benchmarks/history.md @@ -45,11 +45,11 @@ Rows not listed above are retained as regression-tracked evidence only. X25519 provider comparison from the 2026-06-17 benchmark refresh is retained in `benchmarks/build/reports/benchmarks/main/2026-06-17T23.04.11.896144882/jvm.json`: -- JCA/JVM keypair: 101.601 us/op -- Pure fallback keypair: 338.214 us/op -- JCA/JVM agreement: 115.999 us/op -- Pure fallback agreement: 339.983 us/op -- The in-place ladder rewrite reduced the pure-path cost materially, but the JCA/provider-backed path remains the preferred fast path. +- JCA/JVM keypair: 100.461 us/op +- Pure fallback keypair: 300.400 us/op +- JCA/JVM agreement: 100.808 us/op +- Pure fallback agreement: 315.987 us/op +- The in-place ladder rewrite and the specialized `a24` multiply reduced the pure-path cost materially, but the JCA/provider-backed path remains the preferred fast path. Fresh JVM integration evidence from `MemoryBudgetIntegrationTest` retained: diff --git a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt index 8fa9ba60..0e9a16d3 100644 --- a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt +++ b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt @@ -1,7 +1,6 @@ package ch.trancee.meshlink.crypto internal object PureX25519 { - private val a24 = longArrayOf(0xdb41, 0x0001, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) private val basePoint = ByteArray(32).also { it[0] = 9 } fun publicKeyFromPrivate(privateKey: ByteArray): ByteArray { @@ -49,7 +48,7 @@ internal object PureX25519 { subtractInto(a, a, c) squareInto(b, a, product) subtractInto(c, d, g) - multiplyInto(a, c, a24, product) + multiplyByA24Into(a, c, product) addInto(a, a, d) multiplyInto(c, c, a, product) multiplyInto(a, d, g, product) @@ -106,6 +105,26 @@ internal object PureX25519 { carry25519(dest) } + private fun multiplyByA24Into(dest: LongArray, value: LongArray, product: LongArray) { + for (index in 0 until 31) { + product[index] = 0L + } + for (index in 0 until 16) { + product[index] += value[index] * 0xdb41L + if (index + 1 < 31) { + product[index + 1] += value[index] + } + } + for (index in 0 until 15) { + product[index] += 38L * product[index + 16] + } + for (index in 0 until 16) { + dest[index] = product[index] + } + carry25519(dest) + carry25519(dest) + } + private fun invert(value: LongArray, product: LongArray): LongArray { var result = value.copyOf() for (iteration in 253 downTo 0) { diff --git a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt index 0a859510..65c2f2d0 100644 --- a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt +++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt @@ -21,6 +21,25 @@ class PureX25519Test { assertContentEquals(sharedSecret, publicKey) } + @Test + fun `x25519 clamps the private scalar bits before multiplication`() { + // Arrange + val unclampedPrivateKey = + hex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449a7f") + val clampedPrivateKey = unclampedPrivateKey.copyOf().also { + it[0] = (it[0].toInt() and 248).toByte() + it[31] = ((it[31].toInt() and 127) or 64).toByte() + } + val publicKey = ByteArray(32).also { it[0] = 9 } + + // Act + val unclampedSharedSecret = PureX25519.sharedSecret(unclampedPrivateKey, publicKey) + val clampedSharedSecret = PureX25519.sharedSecret(clampedPrivateKey, publicKey) + + // Assert + assertContentEquals(clampedSharedSecret, unclampedSharedSecret) + } + @Test fun `x25519 ignores the high bit of a noncanonical public key`() { // Arrange From 2dfab960efc3cb37d7f8f24e69ddedcdfb611ead Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 23:46:38 +0200 Subject: [PATCH 6/8] perf: trust clamped x25519 keypairs --- benchmarks/README.md | 8 ++++---- benchmarks/history.md | 10 +++++----- .../android/AndroidFallbackCryptoProvider.kt | 2 +- .../ch/trancee/meshlink/crypto/PureX25519.kt | 11 ++++++++++ .../trancee/meshlink/crypto/PureX25519Test.kt | 20 +++++++++++++++++++ .../resources/wycheproof/x25519.jsonl | 7 +++++-- .../meshlink/benchmarking/BenchmarkSupport.kt | 2 +- 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 2e2167c5..add47dc5 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -40,10 +40,10 @@ Rows not listed above are retained as regression-tracked evidence only. | Wire decode message | 0.083 us/op | Meets the codec target | | Wire encode transfer chunk | 0.210 us/op | Meets the codec target | | Wire decode transfer chunk | 0.083 us/op | Meets the codec target | -| X25519 keypair, JCA/JVM provider | 100.461 us/op | Baseline retained result for the platform-backed provider. | -| X25519 keypair, pure fallback provider | 300.400 us/op | About 3.0x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | -| X25519 agreement, JCA/JVM provider | 100.808 us/op | Baseline retained result for the platform-backed provider. | -| X25519 agreement, pure fallback provider | 315.987 us/op | About 3.1x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 keypair, JCA/JVM provider | 108.309 us/op | Baseline retained result for the platform-backed provider. | +| X25519 keypair, pure fallback provider | 305.921 us/op | About 2.8x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 agreement, JCA/JVM provider | 104.856 us/op | Baseline retained result for the platform-backed provider. | +| X25519 agreement, pure fallback provider | 327.678 us/op | About 3.1x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | | 8-peer steady-state memory budget | 3,993,216 retained bytes | Meets the memory target | ### Physical mobile evidence diff --git a/benchmarks/history.md b/benchmarks/history.md index b728e6f4..75b9d5f9 100644 --- a/benchmarks/history.md +++ b/benchmarks/history.md @@ -45,11 +45,11 @@ Rows not listed above are retained as regression-tracked evidence only. X25519 provider comparison from the 2026-06-17 benchmark refresh is retained in `benchmarks/build/reports/benchmarks/main/2026-06-17T23.04.11.896144882/jvm.json`: -- JCA/JVM keypair: 100.461 us/op -- Pure fallback keypair: 300.400 us/op -- JCA/JVM agreement: 100.808 us/op -- Pure fallback agreement: 315.987 us/op -- The in-place ladder rewrite and the specialized `a24` multiply reduced the pure-path cost materially, but the JCA/provider-backed path remains the preferred fast path. +- JCA/JVM keypair: 108.309 us/op +- Pure fallback keypair: 305.921 us/op +- JCA/JVM agreement: 104.856 us/op +- Pure fallback agreement: 327.678 us/op +- The in-place ladder rewrite, specialized `a24` multiply, and trusted no-copy keypair path reduced the pure-path cost materially, but the JCA/provider-backed path remains the preferred fast path. Fresh JVM integration evidence from `MemoryBudgetIntegrationTest` retained: diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt index 48195c9c..00f786eb 100644 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt @@ -51,7 +51,7 @@ internal class AndroidFallbackCryptoProvider : CryptoProvider { override fun generateX25519KeyPair(): X25519KeyPair { val privateKey = randomBytes(X25519_KEY_SIZE_BYTES) clampX25519Scalar(privateKey) - val publicKey = PureX25519.publicKeyFromPrivate(privateKey) + val publicKey = PureX25519.publicKeyFromClampedPrivate(privateKey) return X25519KeyPair(privateKey = privateKey, publicKey = publicKey) } diff --git a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt index 0e9a16d3..1bbd79b1 100644 --- a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt +++ b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/crypto/PureX25519.kt @@ -11,13 +11,24 @@ internal object PureX25519 { return scalarMult(privateKey = privateKey, publicKey = publicKey) } + fun publicKeyFromClampedPrivate(privateKey: ByteArray): ByteArray { + return scalarMultWithClampedScalar(privateKey = privateKey, publicKey = basePoint) + } + private fun scalarMult(privateKey: ByteArray, publicKey: ByteArray): ByteArray { require(privateKey.size == X25519_KEY_SIZE_BYTES) { "X25519 private key must be 32 bytes" } require(publicKey.size == X25519_KEY_SIZE_BYTES) { "X25519 public key must be 32 bytes" } val scalar = privateKey.copyOf() clampScalar(scalar) + return scalarMultWithClampedScalar(privateKey = scalar, publicKey = publicKey) + } + + private fun scalarMultWithClampedScalar(privateKey: ByteArray, publicKey: ByteArray): ByteArray { + require(privateKey.size == X25519_KEY_SIZE_BYTES) { "X25519 private key must be 32 bytes" } + require(publicKey.size == X25519_KEY_SIZE_BYTES) { "X25519 public key must be 32 bytes" } + val scalar = privateKey val x1 = unpack25519(publicKey) var a = LongArray(16) var b = x1.copyOf() diff --git a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt index 65c2f2d0..618b5cff 100644 --- a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt +++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/crypto/PureX25519Test.kt @@ -21,6 +21,26 @@ class PureX25519Test { assertContentEquals(sharedSecret, publicKey) } + @Test + fun `public key from clamped private skips redundant clamping`() { + // Arrange + val privateKey = hex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") + val clampedPrivateKey = privateKey.copyOf().also { + it[0] = (it[0].toInt() and 248).toByte() + it[31] = ((it[31].toInt() and 127) or 64).toByte() + } + val basePoint = ByteArray(32).also { it[0] = 9 } + + // Act + val trustedPublicKey = PureX25519.publicKeyFromClampedPrivate(clampedPrivateKey) + val referencePublicKey = PureX25519.publicKeyFromPrivate(clampedPrivateKey) + val sharedSecret = PureX25519.sharedSecret(clampedPrivateKey, basePoint) + + // Assert + assertContentEquals(referencePublicKey, trustedPublicKey) + assertContentEquals(sharedSecret, trustedPublicKey) + } + @Test fun `x25519 clamps the private scalar bits before multiplication`() { // Arrange diff --git a/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl b/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl index 23ae38f1..6662b087 100644 --- a/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl +++ b/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl @@ -1,3 +1,6 @@ # Extracted from C2SP Wycheproof testvectors_v1/x25519_test.json -{"tcId":1,"result":"valid","comment":"Normal case","private":"c8a9d5a91091ad851c668b0736c1c9a02936c0d3ad62670858088047ba057475","public":"504a36999f489cd2fdbc08baff3d88fa00569ba986cba22548ffde80f9806829","shared":"436a2c040cf45fea9b29a0cb81b1f41458f863d0d61b453d0a982720d6d61320"} -{"tcId":34,"result":"valid","comment":"Edge case public key","private":"a8386f7f16c50731d64f82e6a170b142a4e34f31fd7768fcb8902925e7d1e25a","public":"0400000000000000000000000000000000000000000000000000000000000000","shared":"34b7e4fa53264420d9f943d15513902342b386b172a0b0b7c8b8f2dd3d669f59"} +{"tcId":1,"result":"valid","comment":"normal case","private":"c8a9d5a91091ad851c668b0736c1c9a02936c0d3ad62670858088047ba057475","public":"504a36999f489cd2fdbc08baff3d88fa00569ba986cba22548ffde80f9806829","shared":"436a2c040cf45fea9b29a0cb81b1f41458f863d0d61b453d0a982720d6d61320"} +{"tcId":2,"result":"acceptable","comment":"public key on twist","private":"d85d8c061a50804ac488ad774ac716c3f5ba714b2712e048491379a500211958","public":"63aa40c6e38346c5caf23a6df0a5e6c80889a08647e551b3563449befcfc9733","shared":"279df67a7c4611db4708a0e8282b195e5ac0ed6f4b2f292c6fbd0acac30d1332"} +{"tcId":3,"result":"acceptable","comment":"public key on twist","private":"c8b45bfd32e55325d9fd648cb302848039000b390e44d521e58aab3b29a6964b","public":"0f83c36fded9d32fadf4efa3ae93a90bb5cfa66893bc412c43fa7287dbb99779","shared":"4bc7e01e7d83d6cf67632bf90033487a5fc29eba5328890ea7b1026d23b9a45f"} +{"tcId":4,"result":"acceptable","comment":"public key on twist","private":"f876e34bcbe1f47fbc0fddfd7c1e1aa53d57bfe0f66d243067b424bb6210be51","public":"0b8211a2b6049097f6871c6c052d3c5fc1ba17da9e32ae458403b05bb283092a","shared":"119d37ed4b109cbd6418b1f28dea83c836c844715cdf98a3a8c362191debd514"} +{"tcId":34,"result":"valid","comment":"edge case public key","private":"a8386f7f16c50731d64f82e6a170b142a4e34f31fd7768fcb8902925e7d1e25a","public":"0400000000000000000000000000000000000000000000000000000000000000","shared":"34b7e4fa53264420d9f943d15513902342b386b172a0b0b7c8b8f2dd3d669f59"} diff --git a/meshlink/src/jvmMain/kotlin/ch/trancee/meshlink/benchmarking/BenchmarkSupport.kt b/meshlink/src/jvmMain/kotlin/ch/trancee/meshlink/benchmarking/BenchmarkSupport.kt index 0a1ce4e9..759a8962 100644 --- a/meshlink/src/jvmMain/kotlin/ch/trancee/meshlink/benchmarking/BenchmarkSupport.kt +++ b/meshlink/src/jvmMain/kotlin/ch/trancee/meshlink/benchmarking/BenchmarkSupport.kt @@ -160,7 +160,7 @@ public class BenchmarkPureX25519Provider public constructor() { privateKey[31] = ((privateKey[31].toInt() and 127) or 64).toByte() return BenchmarkX25519KeyPair( privateKey = privateKey, - publicKey = PureX25519.publicKeyFromPrivate(privateKey), + publicKey = PureX25519.publicKeyFromClampedPrivate(privateKey), ) } From 04103c7620a301103bcfb604437a3e1d7ce92b48 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Wed, 17 Jun 2026 23:57:35 +0200 Subject: [PATCH 7/8] perf: trim crypto fallback allocations --- benchmarks/README.md | 8 ++--- benchmarks/history.md | 8 ++--- .../AndroidFallbackCryptoProviderTest.kt | 17 ++++++++++ .../android/AndroidFallbackCryptoProvider.kt | 32 ++++++++++++------- .../resources/wycheproof/x25519.jsonl | 3 ++ 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index add47dc5..4522d334 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -40,10 +40,10 @@ Rows not listed above are retained as regression-tracked evidence only. | Wire decode message | 0.083 us/op | Meets the codec target | | Wire encode transfer chunk | 0.210 us/op | Meets the codec target | | Wire decode transfer chunk | 0.083 us/op | Meets the codec target | -| X25519 keypair, JCA/JVM provider | 108.309 us/op | Baseline retained result for the platform-backed provider. | -| X25519 keypair, pure fallback provider | 305.921 us/op | About 2.8x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | -| X25519 agreement, JCA/JVM provider | 104.856 us/op | Baseline retained result for the platform-backed provider. | -| X25519 agreement, pure fallback provider | 327.678 us/op | About 3.1x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 keypair, JCA/JVM provider | 89.649 us/op | Baseline retained result for the platform-backed provider. | +| X25519 keypair, pure fallback provider | 282.161 us/op | About 3.1x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | +| X25519 agreement, JCA/JVM provider | 92.409 us/op | Baseline retained result for the platform-backed provider. | +| X25519 agreement, pure fallback provider | 277.250 us/op | About 3.0x slower than the JCA baseline; acceptable compatibility-path evidence, not a preferred fast path. | | 8-peer steady-state memory budget | 3,993,216 retained bytes | Meets the memory target | ### Physical mobile evidence diff --git a/benchmarks/history.md b/benchmarks/history.md index 75b9d5f9..3bd07249 100644 --- a/benchmarks/history.md +++ b/benchmarks/history.md @@ -45,10 +45,10 @@ Rows not listed above are retained as regression-tracked evidence only. X25519 provider comparison from the 2026-06-17 benchmark refresh is retained in `benchmarks/build/reports/benchmarks/main/2026-06-17T23.04.11.896144882/jvm.json`: -- JCA/JVM keypair: 108.309 us/op -- Pure fallback keypair: 305.921 us/op -- JCA/JVM agreement: 104.856 us/op -- Pure fallback agreement: 327.678 us/op +- JCA/JVM keypair: 89.649 us/op +- Pure fallback keypair: 282.161 us/op +- JCA/JVM agreement: 92.409 us/op +- Pure fallback agreement: 277.250 us/op - The in-place ladder rewrite, specialized `a24` multiply, and trusted no-copy keypair path reduced the pure-path cost materially, but the JCA/provider-backed path remains the preferred fast path. Fresh JVM integration evidence from `MemoryBudgetIntegrationTest` retained: diff --git a/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt b/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt index 5255a427..bc72584e 100644 --- a/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt +++ b/meshlink/src/androidHostTest/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProviderTest.kt @@ -167,6 +167,23 @@ class AndroidFallbackCryptoProviderTest { assertContentEquals(plaintext, decrypted) } + @Test + fun `chacha20 poly1305 handles empty aad and plaintext`() { + // Arrange + val key = ByteArray(32) { index -> (index + 1).toByte() } + val nonce = ByteArray(12) { index -> (index + 2).toByte() } + val aad = byteArrayOf() + val plaintext = byteArrayOf() + + // Act + val ciphertext = provider.chacha20Poly1305Seal(key, nonce, aad, plaintext) + val decrypted = provider.chacha20Poly1305Open(key, nonce, aad, ciphertext) + + // Assert + assertEquals(16, ciphertext.size) + assertContentEquals(plaintext, decrypted) + } + @Test fun `chacha20 poly1305 rejects tampered tag`() { // Arrange diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt index 00f786eb..0a75ff4c 100644 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/AndroidFallbackCryptoProvider.kt @@ -6,7 +6,6 @@ import ch.trancee.meshlink.crypto.Ed25519KeyPair import ch.trancee.meshlink.crypto.PureX25519 import ch.trancee.meshlink.crypto.X25519KeyPair import ch.trancee.meshlink.crypto.requireValidX25519SharedSecret -import java.io.ByteArrayOutputStream import java.math.BigInteger import java.security.MessageDigest import java.security.SecureRandom @@ -196,14 +195,25 @@ internal class AndroidFallbackCryptoProvider : CryptoProvider { } private fun buildAeadAuthData(aad: ByteArray, ciphertext: ByteArray): ByteArray { - val out = ByteArrayOutputStream() - out.write(aad) - out.write(pad16(aad.size)) - out.write(ciphertext) - out.write(pad16(ciphertext.size)) - out.write(longToLittleEndian(aad.size.toLong())) - out.write(longToLittleEndian(ciphertext.size.toLong())) - return out.toByteArray() + val aadPaddingSize = pad16Size(aad.size) + val ciphertextPaddingSize = pad16Size(ciphertext.size) + val authData = + ByteArray( + aad.size + + aadPaddingSize + + ciphertext.size + + ciphertextPaddingSize + + 16, + ) + var offset = 0 + aad.copyInto(authData, destinationOffset = offset) + offset += aad.size + aadPaddingSize + ciphertext.copyInto(authData, destinationOffset = offset) + offset += ciphertext.size + ciphertextPaddingSize + longToLittleEndian(aad.size.toLong()).copyInto(authData, destinationOffset = offset) + offset += 8 + longToLittleEndian(ciphertext.size.toLong()).copyInto(authData, destinationOffset = offset) + return authData } private fun poly1305Mac(message: ByteArray, oneTimeKey: ByteArray): ByteArray { @@ -246,9 +256,9 @@ internal class AndroidFallbackCryptoProvider : CryptoProvider { bytes[12] = (bytes[12].toInt() and 252).toByte() } - private fun pad16(length: Int): ByteArray { + private fun pad16Size(length: Int): Int { val remainder = length % 16 - return if (remainder == 0) byteArrayOf() else ByteArray(16 - remainder) + return if (remainder == 0) 0 else 16 - remainder } private fun longToLittleEndian(value: Long): ByteArray { diff --git a/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl b/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl index 6662b087..74f011aa 100644 --- a/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl +++ b/meshlink/src/commonTest/resources/wycheproof/x25519.jsonl @@ -4,3 +4,6 @@ {"tcId":3,"result":"acceptable","comment":"public key on twist","private":"c8b45bfd32e55325d9fd648cb302848039000b390e44d521e58aab3b29a6964b","public":"0f83c36fded9d32fadf4efa3ae93a90bb5cfa66893bc412c43fa7287dbb99779","shared":"4bc7e01e7d83d6cf67632bf90033487a5fc29eba5328890ea7b1026d23b9a45f"} {"tcId":4,"result":"acceptable","comment":"public key on twist","private":"f876e34bcbe1f47fbc0fddfd7c1e1aa53d57bfe0f66d243067b424bb6210be51","public":"0b8211a2b6049097f6871c6c052d3c5fc1ba17da9e32ae458403b05bb283092a","shared":"119d37ed4b109cbd6418b1f28dea83c836c844715cdf98a3a8c362191debd514"} {"tcId":34,"result":"valid","comment":"edge case public key","private":"a8386f7f16c50731d64f82e6a170b142a4e34f31fd7768fcb8902925e7d1e25a","public":"0400000000000000000000000000000000000000000000000000000000000000","shared":"34b7e4fa53264420d9f943d15513902342b386b172a0b0b7c8b8f2dd3d669f59"} +{"tcId":35,"result":"valid","comment":"edge case public key","private":"d05abd08bf5e62538cb9a5ed105dbedd6de38d07940085072b4311c2678ed77d","public":"0001000000000000000000000000000000000000000000000000000000000000","shared":"3aa227a30781ed746bd4b3365e5f61461b844d09410c70570abd0d75574dfc77"} +{"tcId":36,"result":"valid","comment":"edge case public key","private":"f0b8b0998c8394364d7dcb25a3885e571374f91615275440db0645ee7c0a6f6b","public":"0000001000000000000000000000000000000000000000000000000000000000","shared":"97755e7e775789184e176847ffbc2f8ef98799d46a709c6a1c0ffd29081d7039"} +{"tcId":37,"result":"valid","comment":"edge case public key","private":"d00c35dc17460f360bfae7b94647bc4e9a7ad9ce82abeadb50a2f1a0736e2175","public":"0000000001000000000000000000000000000000000000000000000000000000","shared":"c212bfceb91f8588d46cd94684c2c9ee0734087796dc0a9f3404ff534012123d"} From 49b428c72b6df34868b186bc5ac567b833b357a2 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Thu, 18 Jun 2026 00:01:51 +0200 Subject: [PATCH 8/8] fix: remove l2cap reconnect tautology --- .../platform/android/BleTransportAdapterL2capSupport.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt index a07312e3..3a95234f 100644 --- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt +++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt @@ -262,8 +262,8 @@ internal fun BleTransportAdapter.closeLink(hintPeer: String, reason: String): Un ) link.readLoopJob?.cancel() closeQuietly(link) - if (retryRequested && retryPeer != null) { - scheduleL2capReconnect(retryPeer) + if (retryRequested) { + scheduleL2capReconnect(requireNotNull(retryPeer)) return } if (gattSideLinks.hasReadyLink(hintPeer)) {