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
↓ peach-android-3c69d09c.tar.gzatlas at this save
files changed19 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 −0app/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 }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 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 }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) {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-}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+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 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 }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 }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+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 }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 }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+}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+