Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 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
Expand Down
8 changes: 8 additions & 0 deletions benchmarks/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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:

- `MEMORY_BUDGET baselineBytes=7437064 usedBytes=11430280 steadyStateBytes=3993216`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -152,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ 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
import java.math.BigInteger
import java.security.MessageDigest
import java.security.SecureRandom
Expand Down Expand Up @@ -50,7 +50,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.publicKeyFromClampedPrivate(privateKey)
return X25519KeyPair(privateKey = privateKey, publicKey = publicKey)
}

Expand All @@ -59,7 +59,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 {
Expand Down Expand Up @@ -195,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 {
Expand Down Expand Up @@ -245,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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,17 +11,34 @@ 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()
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

Expand All @@ -31,71 +47,101 @@ 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)
multiplyByA24Into(a, c, 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) {
scalar[0] = (scalar[0].toInt() and 248).toByte()
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 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): 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
Expand Down
Loading
Loading