3c69d09c658f

feat: replace Kotlin Noise with shared Rust JNI core (v2.4.0)

time
2026-07-26 06:19 UTC · 3 months ago
machine
koh2
parent
5e73e5d32f84
19 files VERSION+1 −1app/build.gradle+16 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+24 −20app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+3 −0app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+11 −1app/src/main/java/com/peachpasswords/sync/NoiseKK.kt+49 −206app/src/main/java/com/peachpasswords/sync/NoiseNK.kt+75 −186app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+4 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+29 −25app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+35 −31app/src/main/java/com/peachpasswords/sync/RustNoiseNative.kt+104 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+10 −1app/src/main/jniLibs/arm64-v8a/libpeach_noise_core.sobinaryapp/src/main/jniLibs/armeabi-v7a/libpeach_noise_core.sobinaryapp/src/main/jniLibs/x86/libpeach_noise_core.sobinaryapp/src/main/jniLibs/x86_64/libpeach_noise_core.sobinaryapp/src/test/java/com/peachpasswords/sync/NoiseKKTest.kt+2 −2build.gradle+9 −0scripts/build-noise-core.sh+34 −0
VERSION+1 −1
@@ -1 +1 @@1-2.3.681+2.4.0

view file

app/build.gradle+16 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23790020-        versionName "2.3.79"19+        versionCode 24000020+        versionName "2.4.0"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {@@ -94,6 +94,20 @@9494                 "META-INF/LICENSE*",9595                 "META-INF/NOTICE*",9696             ]97+        }98+    }99+100+    testOptions {101+        unitTests.all {102+            def coreDir = rootProject.file("../peach-browser/crypto-core")103+            def nativeLibrary = new File(104+                coreDir,105+                "target/release/libpeach_noise_core.dylib",106+            )107+            if (coreDir.exists()) {108+                dependsOn rootProject.tasks.named("buildRustNoiseHost")109+                systemProperty "peach.noise.library.path", nativeLibrary.absolutePath110+            }97111         }98112     }99113 }

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+24 −20
@@ -215,27 +215,31 @@215215         socket::send,216216         socket::receive,217217     )218-    val theirIdentity = parsePairIdentityFrame(219-        session.decrypt(socket.receive()),220-    )221-    require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {222-        "peer identity does not match authenticated KK key"223-    }224-    socket.send(session.encrypt(encodePairIdentityFrame(localIdentity)))218+    try {219+        val theirIdentity = parsePairIdentityFrame(220+            session.decrypt(socket.receive()),221+        )222+        require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {223+            "peer identity does not match authenticated KK key"224+        }225+        socket.send(session.encrypt(encodePairIdentityFrame(localIdentity)))225226 226-    val vault = vaultFactory() ?: return227-    vault.bindAuthenticatedPeer(known)228-    val outcome = runChirpSyncResponder(229-        ChirpSyncSession(230-            remoteStaticPublicKey = initiatorKey.copyOf(),231-            send = { plaintext -> socket.send(session.encrypt(plaintext)) },232-            receive = { session.decrypt(socket.receive()) },233-            close = { socket.close() },234-        ),235-        vault,236-    )237-    if (outcome.status == SyncOutcomeStatus.COMPLETED) {238-        onSyncComplete()227+        val vault = vaultFactory() ?: return228+        vault.bindAuthenticatedPeer(known)229+        val outcome = runChirpSyncResponder(230+            ChirpSyncSession(231+                remoteStaticPublicKey = initiatorKey.copyOf(),232+                send = { plaintext -> socket.send(session.encrypt(plaintext)) },233+                receive = { session.decrypt(socket.receive()) },234+                close = { socket.close() },235+            ),236+            vault,237+        )238+        if (outcome.status == SyncOutcomeStatus.COMPLETED) {239+            onSyncComplete()240+        }241+    } finally {242+        session.close()239243     }240244 }241245 

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+3 −0
@@ -170,6 +170,7 @@170170         adoptionSourceDeps: AdoptionSourceDeps?,171171     ): PairingEvent = withContext(Dispatchers.IO) {172172         val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())173+        var noiseSession: NoiseNKSession? = null173174         try {174175             if (!ws.accept()) {175176                 ws.closeSilently()@@ -219,6 +220,7 @@219220                 { frame -> ws.send(frame) },220221                 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },221222             )223+            noiseSession = session222224 223225             if (flow == "adoption") {224226                 val deps = adoptionSourceDeps@@ -284,6 +286,7 @@284286             ws.closeSilently()285287             PairingEvent.Rejected(e.message ?: "pairing_failed")286288         } finally {289+            noiseSession?.close()287290             runCatching { client.close() }288291         }289292     }

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+11 −1
@@ -116,6 +116,7 @@116116             output = socket.getOutputStream(),117117             onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },118118         )119+        var noiseSession: NoiseNKSession? = null119120         return try {120121             val selector = JSONObject(String(transport.receive(), Charsets.UTF_8))121122             require(selector.optString("type") == "PEACH_RECONNECT") { "expected PEACH_RECONNECT" }@@ -133,6 +134,7 @@133134                 transport::send,134135                 transport::receive,135136             )137+            noiseSession = session136138 137139             // 2. PAIR_IDENTITY exchange (responder reads first, then answers) —138140             //    mirrors ChirpPairing.runPairingResponder ordering.@@ -159,6 +161,8 @@159161         } catch (e: Throwable) {160162             transport.close()161163             PairingServerEvent.Error(e.message ?: "pairing_server_error")164+        } finally {165+            noiseSession?.close()162166         }163167     }164168 @@ -287,7 +291,13 @@287291         // SocketFrameIO.sendBlocking writes a frame synchronously.288292         send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },289293         receive = { session.decrypt(transport.receive()) },290-        close = { transport.close() },294+        close = {295+            try {296+                session.close()297+            } finally {298+                transport.close()299+            }300+        },291301     )292302 293303 private fun jsonEscape(value: String): String = buildString(value.length) {

view file

app/src/main/java/com/peachpasswords/sync/NoiseKK.kt+49 −206
@@ -1,79 +1,14 @@11 package com.peachpasswords.sync22 3-import org.bouncycastle.crypto.digests.Blake2sDigest4-import org.bouncycastle.crypto.generators.HKDFBytesGenerator5-import org.bouncycastle.crypto.modes.ChaCha20Poly13056-import org.bouncycastle.crypto.params.AEADParameters7-import org.bouncycastle.crypto.params.HKDFParameters8-import org.bouncycastle.crypto.params.KeyParameter9-import org.bouncycastle.crypto.params.X25519PrivateKeyParameters10-import java.nio.ByteBuffer11-import java.nio.ByteOrder12-import java.security.SecureRandom13-14-private const val NOISE_KK_PROTOCOL_NAME = "Noise_KK_25519_ChaChaPoly_BLAKE2s"15-163 internal data class NoiseKKKeyPair(174     val publicKey: ByteArray,185     val secretKey: ByteArray,196 )207 21-private fun initKKHash(): ByteArray {22-    val name = NOISE_KK_PROTOCOL_NAME.encodeToByteArray()23-    if (name.size <= 32) return ByteArray(32).also { name.copyInto(it) }24-    return Blake2sDigest(256).run {25-        update(name, 0, name.size)26-        ByteArray(32).also { doFinal(it, 0) }27-    }28-}29-30-private fun mixKKHash(hash: ByteArray, data: ByteArray): ByteArray =31-    Blake2sDigest(256).run {32-        update(hash, 0, hash.size)33-        update(data, 0, data.size)34-        ByteArray(32).also { doFinal(it, 0) }35-    }36-37-private fun kkHkdf2(chainingKey: ByteArray, input: ByteArray): Pair<ByteArray, ByteArray> {38-    val generator = HKDFBytesGenerator(Blake2sDigest(256))39-    generator.init(HKDFParameters(input, chainingKey, ByteArray(0)))40-    val output = ByteArray(64)41-    generator.generateBytes(output, 0, output.size)42-    return output.copyOfRange(0, 32) to output.copyOfRange(32, 64)43-}44-45-private fun kkNonce(value: Long): ByteArray =46-    ByteArray(12).also {47-        ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putLong(4, value)48-    }49-50-private fun kkSeal(key: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {51-    val cipher = ChaCha20Poly1305()52-    cipher.init(true, AEADParameters(KeyParameter(key), 128, ByteArray(12), aad))53-    val output = ByteArray(cipher.getOutputSize(plaintext.size))54-    var size = cipher.processBytes(plaintext, 0, plaintext.size, output, 0)55-    size += cipher.doFinal(output, size)56-    return output.copyOf(size)57-}58-59-private fun kkOpen(key: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray? =60-    runCatching {61-        val cipher = ChaCha20Poly1305()62-        cipher.init(false, AEADParameters(KeyParameter(key), 128, ByteArray(12), aad))63-        val output = ByteArray(cipher.getOutputSize(ciphertext.size))64-        var size = cipher.processBytes(ciphertext, 0, ciphertext.size, output, 0)65-        size += cipher.doFinal(output, size)66-        output.copyOf(size)67-    }.getOrNull()68-69-private fun freshKKKeyPair(): NoiseKKKeyPair {70-    val privateKey = X25519PrivateKeyParameters(SecureRandom())71-    return NoiseKKKeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)72-}73-748 /**75- * Standard Noise KK reconnect initiator.76- * Pre-messages: -> s, <- s; messages: -> e,es,ss then <- e,ee,se.9+ * Standard unmodified Noise KK reconnect through the canonical Rust core.10+ * Both static keys are supplied from durable local pairing state; there is no11+ * NK downgrade or peer-controlled key selection.7712  */7813 internal suspend fun noiseKKInitiator(7914     ourStaticPrivateKey: ByteArray,@@ -81,157 +16,65 @@8116     responderStaticPublicKey: ByteArray,8217     send: suspend (ByteArray) -> Unit,8318     receive: suspend () -> ByteArray,84-    ephemeral: NoiseKKKeyPair = freshKKKeyPair(),19+    ephemeral: NoiseKKKeyPair? = null,8520 ): NoiseNKSession {86-    requireKKKeys(87-        ourStaticPrivateKey,88-        ourStaticPublicKey,89-        responderStaticPublicKey,90-        ephemeral.secretKey,91-        ephemeral.publicKey,21+    val native = RustNoiseSession(22+        if (ephemeral == null) {23+            RustNoiseNative.nativeCreateKkInitiator(24+                ourStaticPrivateKey,25+                ourStaticPublicKey,26+                responderStaticPublicKey,27+            )28+        } else {29+            RustNoiseNative.nativeCreateKkInitiatorForTest(30+                ourStaticPrivateKey,31+                ourStaticPublicKey,32+                responderStaticPublicKey,33+                ephemeral.secretKey,34+            )35+        },9236     )93-    var hash = initKKHash()94-    var chainingKey = hash.copyOf()95-    hash = mixKKHash(hash, ourStaticPublicKey)96-    hash = mixKKHash(hash, responderStaticPublicKey)97-98-    // -> e, es, ss99-    hash = mixKKHash(hash, ephemeral.publicKey)100-    var keys = kkHkdf2(101-        chainingKey,102-        x25519SharedKey(ephemeral.secretKey, responderStaticPublicKey),103-    )104-    chainingKey = keys.first105-    keys = kkHkdf2(106-        chainingKey,107-        x25519SharedKey(ourStaticPrivateKey, responderStaticPublicKey),108-    )109-    chainingKey = keys.first110-    val encrypted1 = kkSeal(keys.second, ByteArray(0), hash)111-    hash = mixKKHash(hash, encrypted1)112-    send(ephemeral.publicKey + encrypted1)113-114-    // <- e, ee, se115-    val message2 = receive()116-    require(message2.size >= 48) { "Noise KK: msg2 too short" }117-    val responderEphemeral = message2.copyOfRange(0, 32)118-    val encrypted2 = message2.copyOfRange(32, message2.size)119-    hash = mixKKHash(hash, responderEphemeral)120-    keys = kkHkdf2(121-        chainingKey,122-        x25519SharedKey(ephemeral.secretKey, responderEphemeral),123-    )124-    chainingKey = keys.first125-    keys = kkHkdf2(126-        chainingKey,127-        x25519SharedKey(ourStaticPrivateKey, responderEphemeral),128-    )129-    chainingKey = keys.first130-    requireNotNull(kkOpen(keys.second, encrypted2, hash)) {131-        "Noise KK: msg2 auth failed — wrong peer key or MITM"37+    return try {38+        send(native.writeHandshake())39+        native.readHandshake(receive())40+        native41+    } catch (error: Throwable) {42+        native.close()43+        throw error13244     }133-    hash = mixKKHash(hash, encrypted2)134-135-    val transportKeys = kkHkdf2(chainingKey, ByteArray(0))136-    return kkTransportSession(transportKeys.first, transportKeys.second, hash)13745 }13846 139-/**140- * Standard Noise KK responder. [expectedInitiatorStaticPublicKey] must be141- * selected from local paired state before the handshake; it is never learned142- * from a peer capability or accepted via fallback.143- */14447 internal suspend fun noiseKKResponder(14548     ourStaticPrivateKey: ByteArray,14649     ourStaticPublicKey: ByteArray,14750     expectedInitiatorStaticPublicKey: ByteArray,14851     send: suspend (ByteArray) -> Unit,14952     receive: suspend () -> ByteArray,150-    ephemeral: NoiseKKKeyPair = freshKKKeyPair(),53+    ephemeral: NoiseKKKeyPair? = null,15154 ): NoiseNKSession {152-    requireKKKeys(153-        ourStaticPrivateKey,154-        ourStaticPublicKey,155-        expectedInitiatorStaticPublicKey,156-        ephemeral.secretKey,157-        ephemeral.publicKey,55+    val native = RustNoiseSession(56+        if (ephemeral == null) {57+            RustNoiseNative.nativeCreateKkResponder(58+                ourStaticPrivateKey,59+                ourStaticPublicKey,60+                expectedInitiatorStaticPublicKey,61+            )62+        } else {63+            RustNoiseNative.nativeCreateKkResponderForTest(64+                ourStaticPrivateKey,65+                ourStaticPublicKey,66+                expectedInitiatorStaticPublicKey,67+                ephemeral.secretKey,68+            )69+        },15870     )159-    var hash = initKKHash()160-    var chainingKey = hash.copyOf()161-    hash = mixKKHash(hash, expectedInitiatorStaticPublicKey)162-    hash = mixKKHash(hash, ourStaticPublicKey)163-164-    // -> e, es, ss165-    val message1 = receive()166-    require(message1.size >= 48) { "Noise KK: msg1 too short" }167-    val initiatorEphemeral = message1.copyOfRange(0, 32)168-    val encrypted1 = message1.copyOfRange(32, message1.size)169-    hash = mixKKHash(hash, initiatorEphemeral)170-    var keys = kkHkdf2(171-        chainingKey,172-        x25519SharedKey(ourStaticPrivateKey, initiatorEphemeral),173-    )174-    chainingKey = keys.first175-    keys = kkHkdf2(176-        chainingKey,177-        x25519SharedKey(ourStaticPrivateKey, expectedInitiatorStaticPublicKey),178-    )179-    chainingKey = keys.first180-    requireNotNull(kkOpen(keys.second, encrypted1, hash)) {181-        "Noise KK: msg1 auth failed — wrong peer key or MITM"71+    return try {72+        native.readHandshake(receive())73+        send(native.writeHandshake())74+        native75+    } catch (error: Throwable) {76+        native.close()77+        throw error18278     }183-    hash = mixKKHash(hash, encrypted1)184-185-    // <- e, ee, se186-    hash = mixKKHash(hash, ephemeral.publicKey)187-    keys = kkHkdf2(188-        chainingKey,189-        x25519SharedKey(ephemeral.secretKey, initiatorEphemeral),190-    )191-    chainingKey = keys.first192-    keys = kkHkdf2(193-        chainingKey,194-        x25519SharedKey(ephemeral.secretKey, expectedInitiatorStaticPublicKey),195-    )196-    chainingKey = keys.first197-    val encrypted2 = kkSeal(keys.second, ByteArray(0), hash)198-    hash = mixKKHash(hash, encrypted2)199-    send(ephemeral.publicKey + encrypted2)200-201-    val transportKeys = kkHkdf2(chainingKey, ByteArray(0))202-    return kkTransportSession(transportKeys.second, transportKeys.first, hash)20379 }20480 205-private fun requireKKKeys(vararg keys: ByteArray) {206-    require(keys.all { it.size == 32 }) { "Noise KK keys must be 32 bytes" }207-}208-209-private fun kkTransportSession(210-    sendKey: ByteArray,211-    receiveKey: ByteArray,212-    hash: ByteArray,213-): NoiseNKSession {214-    var sendNonce = 0L215-    var receiveNonce = 0L216-    return object : NoiseNKSession {217-        override fun encrypt(plaintext: ByteArray): ByteArray {218-            val cipher = ChaCha20Poly1305()219-            cipher.init(true, AEADParameters(KeyParameter(sendKey), 128, kkNonce(sendNonce++), ByteArray(0)))220-            val output = ByteArray(cipher.getOutputSize(plaintext.size))221-            var size = cipher.processBytes(plaintext, 0, plaintext.size, output, 0)222-            size += cipher.doFinal(output, size)223-            return output.copyOf(size)224-        }225-226-        override fun decrypt(ciphertext: ByteArray): ByteArray {227-            val cipher = ChaCha20Poly1305()228-            cipher.init(false, AEADParameters(KeyParameter(receiveKey), 128, kkNonce(receiveNonce++), ByteArray(0)))229-            val output = ByteArray(cipher.getOutputSize(ciphertext.size))230-            var size = cipher.processBytes(ciphertext, 0, ciphertext.size, output, 0)231-            size += cipher.doFinal(output, size)232-            return output.copyOf(size)233-        }234-235-        override val handshakeHash: ByteArray = hash.copyOf()236-    }237-}

view file

app/src/main/java/com/peachpasswords/sync/NoiseNK.kt+75 −186
@@ -2,171 +2,88 @@22 33 import org.bouncycastle.crypto.digests.Blake2sDigest44 import org.bouncycastle.crypto.generators.HKDFBytesGenerator5-import org.bouncycastle.crypto.modes.ChaCha20Poly13056-import org.bouncycastle.crypto.params.AEADParameters75 import org.bouncycastle.crypto.params.HKDFParameters8-import org.bouncycastle.crypto.params.KeyParameter96 import org.bouncycastle.crypto.params.X25519PrivateKeyParameters107 import org.bouncycastle.crypto.params.X25519PublicKeyParameters11-import java.nio.ByteBuffer12-import java.nio.ByteOrder13-import java.security.SecureRandom148 159 private const val NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s"1610 17-interface NoiseNKSession {11+interface NoiseNKSession : AutoCloseable {1812     fun encrypt(plaintext: ByteArray): ByteArray1913     fun decrypt(ciphertext: ByteArray): ByteArray2014     val handshakeHash: ByteArray21-}2215 23-private data class NoiseNKKeyPair(24-    val publicKey: ByteArray,25-    val secretKey: ByteArray,26-)16+    fun rekeyOutgoing() {17+        throw UnsupportedOperationException("Noise session does not support rekeying")18+    }2719 20+    fun rekeyIncoming() {21+        throw UnsupportedOperationException("Noise session does not support rekeying")22+    }23+24+    override fun close() = Unit25+}26+2827 private fun initHash(): ByteArray {2928     val nameBytes = NOISE_NK_PROTOCOL_NAME.encodeToByteArray()3029     if (nameBytes.size <= 32) {3130         return ByteArray(32).also { nameBytes.copyInto(it) }3231     }33-    // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash34-    // MUST use it too (Noise spec §5.2), not SHA-256. Coordinated wire change with35-    // peach-browser noise-js.ts.36-    val blake = Blake2sDigest(256)37-    blake.update(nameBytes, 0, nameBytes.size)38-    val out = ByteArray(32)39-    blake.doFinal(out, 0)40-    return out32+    return Blake2sDigest(256).run {33+        update(nameBytes, 0, nameBytes.size)34+        ByteArray(32).also { doFinal(it, 0) }35+    }4136 }4237 43-private fun mixHash(h: ByteArray, data: ByteArray): ByteArray {44-    val blake = Blake2sDigest(256)45-    blake.update(h, 0, h.size)46-    blake.update(data, 0, data.size)47-    val out = ByteArray(32)48-    blake.doFinal(out, 0)49-    return out50-}51-52-private fun hkdf2(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> {53-    // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.38+private fun hkdf2(chainingKey: ByteArray, input: ByteArray): Pair<ByteArray, ByteArray> {5439     val generator = HKDFBytesGenerator(Blake2sDigest(256))55-    generator.init(HKDFParameters(ikm, ck, ByteArray(0)))56-    val out = ByteArray(64)57-    generator.generateBytes(out, 0, out.size)58-    return out.copyOfRange(0, 32) to out.copyOfRange(32, 64)40+    generator.init(HKDFParameters(input, chainingKey, ByteArray(0)))41+    val output = ByteArray(64)42+    generator.generateBytes(output, 0, output.size)43+    return output.copyOfRange(0, 32) to output.copyOfRange(32, 64)5944 }6045 61-// PCH-01-002: test-only seams for the cross-platform BLAKE2s vectors pinned in62-// CrossPlatformKATTest.kt (must stay byte-identical to peach-browser noise-js.ts).46+// Primitive-only compatibility seams. The production Noise transcript and47+// transport state are exclusively owned by the shared Rust core.6348 internal fun noiseNKInitHashForTest(): ByteArray = initHash()64-internal fun noiseNKHkdf2ForTest(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> = hkdf2(ck, ikm)49+internal fun noiseNKHkdf2ForTest(50+    chainingKey: ByteArray,51+    input: ByteArray,52+): Pair<ByteArray, ByteArray> = hkdf2(chainingKey, input)6553 66-private fun nonceBytes(n: Long): ByteArray {67-    val buf = ByteArray(12)68-    ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putLong(4, n)69-    return buf70-}71-72-private fun generateKeyPair(): NoiseNKKeyPair {73-    val priv = X25519PrivateKeyParameters(SecureRandom())74-    return NoiseNKKeyPair(75-        publicKey = priv.generatePublicKey().encoded,76-        secretKey = priv.encoded,77-    )78-}79-8054 internal fun x25519SharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {8155     require(privateKey.size == 32 && publicKey.size == 32) { "X25519 keys must be 32 bytes" }82-    val priv = X25519PrivateKeyParameters(privateKey, 0)83-    val pub = X25519PublicKeyParameters(publicKey, 0)84-    val out = ByteArray(32)85-    priv.generateSecret(pub, out, 0)86-    require(out.any { it.toInt() != 0 }) { "X25519 low-order point rejected" }87-    return out56+    val private = X25519PrivateKeyParameters(privateKey, 0)57+    val public = X25519PublicKeyParameters(publicKey, 0)58+    val output = ByteArray(32)59+    private.generateSecret(public, output, 0)60+    require(output.any { it.toInt() != 0 }) { "X25519 low-order point rejected" }61+    return output8862 }8963 90-private fun chachaSeal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {91-    val cipher = ChaCha20Poly1305()92-    cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))93-    val out = ByteArray(cipher.getOutputSize(plaintext.size))94-    var len = cipher.processBytes(plaintext, 0, plaintext.size, out, 0)95-    len += cipher.doFinal(out, len)96-    return out.copyOf(len)97-}98-99-private fun chachaOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray? {100-    return try {101-        val cipher = ChaCha20Poly1305()102-        cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))103-        val out = ByteArray(cipher.getOutputSize(ciphertext.size))104-        var len = cipher.processBytes(ciphertext, 0, ciphertext.size, out, 0)105-        len += cipher.doFinal(out, len)106-        out.copyOf(len)107-    } catch (_: Throwable) {108-        null109-    }110-}111-11264 suspend fun noiseNKInitiator(11365     responderStaticPublicKey: ByteArray,11466     send: suspend (ByteArray) -> Unit,11567     receive: suspend () -> ByteArray,68+    ephemeralPrivateKeyForTest: ByteArray? = null,11669 ): NoiseNKSession {117-    var h = initHash()118-    var ck = h.copyOf()119-120-    h = mixHash(h, responderStaticPublicKey)121-122-    val ephemeral = generateKeyPair()123-    h = mixHash(h, ephemeral.publicKey)124-    val es = x25519SharedKey(ephemeral.secretKey, responderStaticPublicKey)125-    val (ck1, k1) = hkdf2(ck, es)126-    ck = ck1127-128-    val cipher1 = ChaCha20Poly1305()129-    cipher1.init(true, AEADParameters(KeyParameter(k1), 128, nonceBytes(0L), h))130-    val encrypted1 = ByteArray(cipher1.getOutputSize(0))131-    cipher1.doFinal(encrypted1, 0)132-    h = mixHash(h, encrypted1)133-134-    val msg1 = ByteArray(32 + encrypted1.size)135-    ephemeral.publicKey.copyInto(msg1, 0)136-    encrypted1.copyInto(msg1, 32)137-    send(msg1)138-139-    val msg2 = receive()140-    require(msg2.size >= 48) { "Noise NK: msg2 too short" }141-    val remoteEphemeralPublic = msg2.copyOfRange(0, 32)142-    val encrypted2 = msg2.copyOfRange(32, msg2.size)143-144-    h = mixHash(h, remoteEphemeralPublic)145-    val ee = x25519SharedKey(ephemeral.secretKey, remoteEphemeralPublic)146-    val (ck2, k2) = hkdf2(ck, ee)147-    ck = ck2148-149-    val decrypted2 = chachaOpen(k2, nonceBytes(0L), encrypted2, h)150-    requireNotNull(decrypted2) { "Noise NK: msg2 auth failed — wrong responder key or MITM" }151-    h = mixHash(h, encrypted2)152-153-    val (sendKey, recvKey) = hkdf2(ck, ByteArray(0))154-    val sessionHash = h.copyOf()155-156-    var sendNonce = 0L157-    var recvNonce = 0L158-159-    return object : NoiseNKSession {160-        override fun encrypt(plaintext: ByteArray): ByteArray {161-            return chachaSeal(sendKey, nonceBytes(sendNonce++), plaintext, ByteArray(0))162-        }163-164-        override fun decrypt(ciphertext: ByteArray): ByteArray {165-            return chachaOpen(recvKey, nonceBytes(recvNonce++), ciphertext, ByteArray(0))166-                ?: throw IllegalStateException("Noise NK: decryption failed")167-        }168-169-        override val handshakeHash: ByteArray = sessionHash70+    val native = RustNoiseSession(71+        if (ephemeralPrivateKeyForTest == null) {72+            RustNoiseNative.nativeCreateNkInitiator(responderStaticPublicKey)73+        } else {74+            RustNoiseNative.nativeCreateNkInitiatorForTest(75+                responderStaticPublicKey,76+                ephemeralPrivateKeyForTest,77+            )78+        },79+    )80+    return try {81+        send(native.writeHandshake())82+        native.readHandshake(receive())83+        native84+    } catch (error: Throwable) {85+        native.close()86+        throw error17087     }17188 }17289 @@ -175,60 +92,32 @@17592     ourStaticPublicKey: ByteArray,17693     send: suspend (ByteArray) -> Unit,17794     receive: suspend () -> ByteArray,95+    ephemeralPrivateKeyForTest: ByteArray? = null,17896 ): NoiseNKSession {179-    var h = initHash()180-    var ck = h.copyOf()181-182-    h = mixHash(h, ourStaticPublicKey)183-184-    val msg1 = receive()185-    require(msg1.size >= 48) { "Noise NK: msg1 too short" }186-    val remoteEphemeralPublic = msg1.copyOfRange(0, 32)187-    val encrypted1 = msg1.copyOfRange(32, msg1.size)188-189-    h = mixHash(h, remoteEphemeralPublic)190-    val es = x25519SharedKey(ourStaticPrivateKey, remoteEphemeralPublic)191-    val (ck1, k1) = hkdf2(ck, es)192-    ck = ck1193-194-    val decrypted1 = chachaOpen(k1, nonceBytes(0L), encrypted1, h)195-    requireNotNull(decrypted1) { "Noise NK: msg1 auth failed — wrong static key or MITM" }196-    h = mixHash(h, encrypted1)197-198-    val ephemeral = generateKeyPair()199-    h = mixHash(h, ephemeral.publicKey)200-    val ee = x25519SharedKey(ephemeral.secretKey, remoteEphemeralPublic)201-    val (ck2, k2) = hkdf2(ck, ee)202-    ck = ck2203-204-    val encrypted2 = chachaSeal(k2, nonceBytes(0L), ByteArray(0), h)205-    h = mixHash(h, encrypted2)206-207-    val msg2 = ByteArray(32 + encrypted2.size)208-    ephemeral.publicKey.copyInto(msg2, 0)209-    encrypted2.copyInto(msg2, 32)210-    send(msg2)211-212-    val (initiatorSendKey, responderSendKey) = hkdf2(ck, ByteArray(0))213-    val sessionHash = h.copyOf()214-215-    var sendNonce = 0L216-    var recvNonce = 0L217-218-    return object : NoiseNKSession {219-        override fun encrypt(plaintext: ByteArray): ByteArray {220-            return chachaSeal(responderSendKey, nonceBytes(sendNonce++), plaintext, ByteArray(0))221-        }222-223-        override fun decrypt(ciphertext: ByteArray): ByteArray {224-            return chachaOpen(initiatorSendKey, nonceBytes(recvNonce++), ciphertext, ByteArray(0))225-                ?: throw IllegalStateException("Noise NK: decryption failed")226-        }227-228-        override val handshakeHash: ByteArray = sessionHash97+    val native = RustNoiseSession(98+        if (ephemeralPrivateKeyForTest == null) {99+            RustNoiseNative.nativeCreateNkResponder(100+                ourStaticPrivateKey,101+                ourStaticPublicKey,102+            )103+        } else {104+            RustNoiseNative.nativeCreateNkResponderForTest(105+                ourStaticPrivateKey,106+                ourStaticPublicKey,107+                ephemeralPrivateKeyForTest,108+            )109+        },110+    )111+    return try {112+        native.readHandshake(receive())113+        send(native.writeHandshake())114+        native115+    } catch (error: Throwable) {116+        native.close()117+        throw error229118     }230119 }231120 232-fun derivePairingSAS(handshakeHash: ByteArray): String {233-    return SASDerivation.derive(handshakeHash)234-}121+fun derivePairingSAS(handshakeHash: ByteArray): String =122+    SASDerivation.derive(handshakeHash)123+

view file

app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+4 −0
@@ -106,6 +106,7 @@106106         connectTimeoutMs: Int = 15_000,107107     ): PairingResult = withContext(Dispatchers.IO) {108108         val socket = Socket()109+        var noiseSession: NoiseNKSession? = null109110         try {110111             socket.connect(InetSocketAddress(peer.host, peer.port), connectTimeoutMs)111112             socket.soTimeout = connectTimeoutMs@@ -136,6 +137,7 @@136137                 transport.close()137138                 return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)138139             }140+            noiseSession = session139141 140142             // PAIR_IDENTITY: initiator writes first, then reads (mirrors141143             // ChirpPairing.runPairingInitiator).@@ -178,6 +180,8 @@178180         } catch (e: Throwable) {179181             runCatching { socket.close() }180182             PairingResult.Error(PairingError.IO_ERROR)183+        } finally {184+            noiseSession?.close()181185         }182186     }183187 

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+29 −25
@@ -151,33 +151,37 @@151151         socket::send,152152         socket::receive,153153     )154-    val channel = object : PairingTransport {155-        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))156-        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())157-        override suspend fun close() = socket.close()158-    }154+    try {155+        val channel = object : PairingTransport {156+            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))157+            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())158+            override suspend fun close() = socket.close()159+        }159160 160-    val request = receiveFrame(channel)161-    require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }162-    request as AdoptionFrame.Request163-    if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null161+        val request = receiveFrame(channel)162+        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }163+        request as AdoptionFrame.Request164+        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null164165 165-    val pairState = AdoptionPairState(166-        rendezvousSecretHex = Rendezvous.bytesToHex(167-            Rendezvous.deriveSecret(noise.handshakeHash),168-        ),169-        relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),170-    )171-    val boundDeps = object : AdoptionSourceDeps {172-        override val sourceIdentity: LocalChirpIdentity = localIdentity173-        override val pairState: AdoptionPairState = pairState174-        override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()175-        override suspend fun vaultId(): String = sourceDeps.vaultId()176-        override suspend fun license(): String? = sourceDeps.license()177-        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true178-        override suspend fun upsertPairedDevice(device: PairedDevice) {179-            sourceDeps.upsertPairedDevice(device)166+        val pairState = AdoptionPairState(167+            rendezvousSecretHex = Rendezvous.bytesToHex(168+                Rendezvous.deriveSecret(noise.handshakeHash),169+            ),170+            relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),171+        )172+        val boundDeps = object : AdoptionSourceDeps {173+            override val sourceIdentity: LocalChirpIdentity = localIdentity174+            override val pairState: AdoptionPairState = pairState175+            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()176+            override suspend fun vaultId(): String = sourceDeps.vaultId()177+            override suspend fun license(): String? = sourceDeps.license()178+            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true179+            override suspend fun upsertPairedDevice(device: PairedDevice) {180+                sourceDeps.upsertPairedDevice(device)181+            }180182         }183+        return grantAdoption(channel, request, boundDeps)184+    } finally {185+        noise.close()181186     }182-    return grantAdoption(channel, request, boundDeps)183187 }

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+35 −31
@@ -69,42 +69,46 @@6969     } finally {7070         sourceStatic.fill(0)7171     }72-    val channel = object : PairingTransport {73-        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))74-        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())75-        override suspend fun close() = socket.close()76-    }77-    val pairState = AdoptionPairState(78-        rendezvousSecretHex = Rendezvous.bytesToHex(79-            Rendezvous.deriveSecret(noise.handshakeHash),80-        ),81-        relayUrl = invitation.relayUrl,82-    )83-    val boundDeps = object : AdoptionReceiverDeps {84-        override val deviceName = receiverDeps.deviceName85-        override val deviceType = receiverDeps.deviceType86-        override val pairState = pairState87-        override fun generateDeviceId() = receiverDeps.generateDeviceId()88-        override suspend fun confirmSas(): Boolean =89-            confirm(SASDerivation.derive(noise.handshakeHash))72+    try {73+        val channel = object : PairingTransport {74+            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))75+            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())76+            override suspend fun close() = socket.close()77+        }78+        val pairState = AdoptionPairState(79+            rendezvousSecretHex = Rendezvous.bytesToHex(80+                Rendezvous.deriveSecret(noise.handshakeHash),81+            ),82+            relayUrl = invitation.relayUrl,83+        )84+        val boundDeps = object : AdoptionReceiverDeps {85+            override val deviceName = receiverDeps.deviceName86+            override val deviceType = receiverDeps.deviceType87+            override val pairState = pairState88+            override fun generateDeviceId() = receiverDeps.generateDeviceId()89+            override suspend fun confirmSas(): Boolean =90+                confirm(SASDerivation.derive(noise.handshakeHash))9091 91-        override suspend fun createVaultFromRootKey(rootKeyHex: String): String =92-            receiverDeps.createVaultFromRootKey(rootKeyHex)92+            override suspend fun createVaultFromRootKey(rootKeyHex: String): String =93+                receiverDeps.createVaultFromRootKey(rootKeyHex)9394 94-        override suspend fun deriveLocalIdentity(): LocalChirpIdentity =95-            receiverDeps.deriveLocalIdentity()95+            override suspend fun deriveLocalIdentity(): LocalChirpIdentity =96+                receiverDeps.deriveLocalIdentity()9697 97-        override suspend fun upsertPairedDevice(device: PairedDevice) {98-            receiverDeps.upsertPairedDevice(device)99-        }98+            override suspend fun upsertPairedDevice(device: PairedDevice) {99+                receiverDeps.upsertPairedDevice(device)100+            }100101 101-        override suspend fun applyLicense(license: String) {102-            receiverDeps.applyLicense(license)103-        }102+            override suspend fun applyLicense(license: String) {103+                receiverDeps.applyLicense(license)104+            }104105 105-        override fun onError(error: String) {106-            receiverDeps.onError(error)106+            override fun onError(error: String) {107+                receiverDeps.onError(error)108+            }107109         }110+        return requestAdoption(channel, boundDeps)111+    } finally {112+        noise.close()108113     }109-    return requestAdoption(channel, boundDeps)110114 }

view file

app/src/main/java/com/peachpasswords/sync/RustNoiseNative.kt+104 −0
@@ -0,0 +1,104 @@1+package com.peachpasswords.sync2+3+/**4+ * Thin JNI boundary over the canonical Rust `snow` state machine.5+ *6+ * No transcript, key schedule, cipher, or nonce logic belongs in Kotlin.7+ */8+internal object RustNoiseNative {9+    init {10+        val explicitPath = System.getProperty("peach.noise.library.path")11+        if (explicitPath.isNullOrBlank()) {12+            System.loadLibrary("peach_noise_core")13+        } else {14+            System.load(explicitPath)15+        }16+    }17+18+    external fun nativeCreateNkInitiator(remotePublicKey: ByteArray): Long19+    external fun nativeCreateNkResponder(20+        localPrivateKey: ByteArray,21+        localPublicKey: ByteArray,22+    ): Long23+    external fun nativeCreateNkInitiatorForTest(24+        remotePublicKey: ByteArray,25+        ephemeralPrivateKey: ByteArray,26+    ): Long27+    external fun nativeCreateNkResponderForTest(28+        localPrivateKey: ByteArray,29+        localPublicKey: ByteArray,30+        ephemeralPrivateKey: ByteArray,31+    ): Long32+33+    external fun nativeCreateKkInitiator(34+        localPrivateKey: ByteArray,35+        localPublicKey: ByteArray,36+        remotePublicKey: ByteArray,37+    ): Long38+    external fun nativeCreateKkResponder(39+        localPrivateKey: ByteArray,40+        localPublicKey: ByteArray,41+        remotePublicKey: ByteArray,42+    ): Long43+    external fun nativeCreateKkInitiatorForTest(44+        localPrivateKey: ByteArray,45+        localPublicKey: ByteArray,46+        remotePublicKey: ByteArray,47+        ephemeralPrivateKey: ByteArray,48+    ): Long49+    external fun nativeCreateKkResponderForTest(50+        localPrivateKey: ByteArray,51+        localPublicKey: ByteArray,52+        remotePublicKey: ByteArray,53+        ephemeralPrivateKey: ByteArray,54+    ): Long55+56+    external fun nativeWriteHandshake(handle: Long, payload: ByteArray): ByteArray57+    external fun nativeReadHandshake(handle: Long, message: ByteArray): ByteArray58+    external fun nativeEncrypt(handle: Long, plaintext: ByteArray): ByteArray59+    external fun nativeDecrypt(handle: Long, ciphertext: ByteArray): ByteArray60+    external fun nativeHandshakeHash(handle: Long): ByteArray61+    external fun nativeRekeyOutgoing(handle: Long)62+    external fun nativeRekeyIncoming(handle: Long)63+    external fun nativeClose(handle: Long)64+}65+66+internal class RustNoiseSession(67+    private var handle: Long,68+) : NoiseNKSession {69+    private fun openHandle(): Long {70+        check(handle != 0L) { "Noise session is closed" }71+        return handle72+    }73+74+    fun writeHandshake(payload: ByteArray = ByteArray(0)): ByteArray =75+        RustNoiseNative.nativeWriteHandshake(openHandle(), payload)76+77+    fun readHandshake(message: ByteArray): ByteArray =78+        RustNoiseNative.nativeReadHandshake(openHandle(), message)79+80+    override fun encrypt(plaintext: ByteArray): ByteArray =81+        RustNoiseNative.nativeEncrypt(openHandle(), plaintext)82+83+    override fun decrypt(ciphertext: ByteArray): ByteArray =84+        RustNoiseNative.nativeDecrypt(openHandle(), ciphertext)85+86+    override val handshakeHash: ByteArray87+        get() = RustNoiseNative.nativeHandshakeHash(openHandle())88+89+    override fun rekeyOutgoing() {90+        RustNoiseNative.nativeRekeyOutgoing(openHandle())91+    }92+93+    override fun rekeyIncoming() {94+        RustNoiseNative.nativeRekeyIncoming(openHandle())95+    }96+97+    override fun close() {98+        val value = handle99+        if (value == 0L) return100+        handle = 0L101+        RustNoiseNative.nativeClose(value)102+    }103+}104+

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+10 −1
@@ -93,6 +93,7 @@9393         vaultFactory: () -> ChirpSyncVaultAdapter?,9494     ) = withContext(Dispatchers.IO) {9595         val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())96+        var noiseSession: NoiseNKSession? = null9697         try {9798             if (!ws.accept()) return@withContext9899 @@ -115,6 +116,7 @@115116                 { frame -> ws.send(frame) },116117                 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },117118             )119+            noiseSession = session118120 119121             val theirIdentity = parsePairIdentityFrame(120122                 session.decrypt(@@ -149,6 +151,7 @@149151                 relayHub.unregister(link)150152             }151153         } finally {154+            noiseSession?.close()152155             runCatching { client.close() }153156         }154157     }@@ -244,6 +247,12 @@244247                 (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),245248             )246249         },247-        close = { ws.closeSilently() },250+        close = {251+            try {252+                session.close()253+            } finally {254+                ws.closeSilently()255+            }256+        },248257     )249258 }

view file

app/src/main/jniLibs/arm64-v8a/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/main/jniLibs/armeabi-v7a/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/main/jniLibs/x86/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/main/jniLibs/x86_64/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/test/java/com/peachpasswords/sync/NoiseKKTest.kt+2 −2
@@ -85,7 +85,7 @@8585         val a = initiatorHandshake.await()8686         val b = responderHandshake.await()8787         assertEquals(88-            "e92392c530684df3eed06a76bd18da5c7a1887e88f5fdee758f9870dadd28182",88+            "ac3dd2e4c40290fab2a0192b0b33b37bb8c31396ffec23271ffa2a99ef3a0f75",8989             pubkeyToHex(a.handshakeHash),9090         )9191         assertArrayEquals(a.handshakeHash, b.handshakeHash)@@ -123,7 +123,7 @@123123         }124124 125125         val failure = responderHandshake.await().exceptionOrNull()126-        assertTrue(failure?.message?.contains("Noise KK: msg1 auth failed") == true)126+        assertTrue(failure?.message?.contains("decrypt", ignoreCase = true) == true)127127         initiatorHandshake.cancel()128128     }129129 }

view file

build.gradle+9 −0
@@ -6,3 +6,12 @@66     id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false77     id "com.google.dagger.hilt.android" version "2.51.1" apply false88 }9+10+tasks.register("buildRustNoiseHost", Exec) {11+    def coreDir = file("../peach-browser/crypto-core")12+    onlyIf {13+        coreDir.exists()14+    }15+    workingDir coreDir16+    commandLine "cargo", "build", "--locked", "--release"17+}

view file

scripts/build-noise-core.sh+34 −0
@@ -0,0 +1,34 @@1+#!/bin/sh2+set -eu3+4+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)5+ANDROID_REPOSITORY=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)6+CORE_REPOSITORY=${PEACH_NOISE_CORE_DIR:-"$ANDROID_REPOSITORY/../peach-browser/crypto-core"}7+NDK_HOME=${ANDROID_NDK_HOME:-/opt/homebrew/share/android-commandlinetools/ndk/27.3.13750724}8+9+if [ ! -f "$CORE_REPOSITORY/Cargo.toml" ]; then10+  echo "Canonical Peach Noise core not found: $CORE_REPOSITORY" >&211+  exit 112+fi13+14+if [ ! -d "$NDK_HOME" ]; then15+  echo "Android NDK not found: $NDK_HOME" >&216+  exit 117+fi18+19+cd "$CORE_REPOSITORY"20+cargo fmt --check21+cargo test --locked22+cargo build --locked --release23+24+ANDROID_NDK_HOME="$NDK_HOME" cargo ndk \25+  --target arm64-v8a \26+  --target armeabi-v7a \27+  --target x86 \28+  --target x86_64 \29+  --platform 26 \30+  --output-dir "$ANDROID_REPOSITORY/app/src/main/jniLibs" \31+  build --locked --release32+33+shasum -a 256 "$ANDROID_REPOSITORY"/app/src/main/jniLibs/*/libpeach_noise_core.so34+

view file