b7a2b0329555

feat: require mutual KK reconnect auth (v2.3.74)

time
2026-07-26 03:43 UTC · 3 months ago
machine
koh2
parent
123cd1686b6b
17 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+2 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncSession.kt+2 −3app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+13 −7app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+28 −25app/src/main/java/com/peachpasswords/sync/NoiseKK.kt+237 −0app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+89 −17app/src/main/java/com/peachpasswords/sync/RelayFrames.kt+2 −2app/src/main/java/com/peachpasswords/sync/RelayHub.kt+1 −1app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+28 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+42 −16app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+1 −1app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+31 −19app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt+34 −10app/src/test/java/com/peachpasswords/sync/NoiseKKTest.kt+129 −0app/src/test/java/com/peachpasswords/sync/RendezvousTest.kt+28 −0app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+130 −21
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23730020-        versionName "2.3.73"19+        versionCode 23740020+        versionName "2.3.74"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+2 −2
@@ -9,8 +9,8 @@99  * leaves to "the mobile-side coordinator that calls this same protocol the1010  * other way".1111  *12- * After Noise NK is open and the responder's static key is verified against the13- * paired-device registry, the two sides drive:12+ * After reconnect Noise KK authenticates both locally-paired static keys, the13+ * two sides drive:1414  *1515  *   ->  SYNC_HELLO { vaultId, deviceId, version, deviceTime }1616  *   <-  SYNC_HELLO                                  (mismatch → vault_mismatch)

view file

app/src/main/java/com/peachpasswords/sync/ChirpSyncSession.kt+2 −3
@@ -3,9 +3,8 @@33 /**44  * Transport-neutral session consumed by the sync coordinator.55  *6- * Live transports use Noise NK. The dialer pins the responder's static key;7- * responder sessions leave [remoteStaticPublicKey] empty until initiator8- * proof-of-possession is available.6+ * Live reconnect transports use Noise KK, so both locally-paired static keys7+ * are authenticated before this session reaches the coordinator.98  */109 data class ChirpSyncSession(1110     val remoteStaticPublicKey: ByteArray,

view file

app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+13 −7
@@ -33,14 +33,20 @@3333         inFlight = scope.launch {3434             try {3535                 val identity = buildLocalIdentity(context, master)36-                peers.forEach { peer ->37-                    runCatching {38-                        discoveryClient.syncWithKnownDevice(39-                            deviceId = peer.deviceId,40-                            localIdentity = identity,41-                            vaultFactory = ::buildAdapter,42-                        )36+                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)37+                try {38+                    peers.forEach { peer ->39+                        runCatching {40+                            discoveryClient.syncWithKnownDevice(41+                                deviceId = peer.deviceId,42+                                localIdentity = identity,43+                                localStaticPrivateKey = localKeys.secretKey,44+                                vaultFactory = ::buildAdapter,45+                            )46+                        }4347                     }48+                } finally {49+                    VaultCrypto.zero(localKeys.secretKey)4450                 }4551             } finally {4652                 VaultCrypto.zero(master)

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+28 −25
@@ -13,6 +13,7 @@1313 import kotlinx.coroutines.isActive1414 import kotlinx.coroutines.launch1515 import kotlinx.coroutines.withContext16+import org.json.JSONObject1617 import java.net.ServerSocket1718 import java.net.Socket1819 import java.nio.charset.StandardCharsets@@ -20,13 +21,13 @@2021 import javax.inject.Singleton2122 2223 /**23- * C7 TCP + mDNS pairing/sync SERVER (responder side).24+ * C7 TCP + mDNS reconnect SERVER (responder side).2425  *2526  * Listens on an OS-assigned TCP port, advertises itself over mDNS as2627  * `_peach._tcp`, and for each inbound connection runs the REAL responder2728  * pipeline:28- *   1. Noise NK responder handshake ([noiseNKResponder]) over length-prefixed29- *      frames ([SocketFrameIO]).29+ *   1. Resolve PEACH_RECONNECT against paired local state, then run Noise KK30+ *      over length-prefixed frames ([SocketFrameIO]).3031  *   2. PAIR_IDENTITY frame exchange — encoded/decoded with the SAME private3132  *      helpers ChirpPairing uses; since those are file-private there, this draft3233  *      re-derives the identity frame via [LocalChirpIdentity] and a minimal@@ -36,17 +37,9 @@3637  *   3. Encrypted vault sync via the REAL [runChirpSyncResponder] over a3738  *      [ChirpSyncSession] backed by the Noise session.3839  *39- * ── Reconciliation with the brief ──────────────────────────────────────────40- *  - The brief calls `noiseNK.acceptHandshake(stream, out)` returning an object41- *    with `.sendCipher/.recvCipher/.handshakeHash`. NO SUCH API EXISTS. The real42- *    handshake is the suspend free function `noiseNKResponder(ourPriv, ourPub,43- *    send, receive)` returning a single [NoiseNKSession] (encrypt/decrypt/44- *    handshakeHash) — one bidirectional cipher, not split send/recv ciphers.45- *  - NK is a responder-static / no-initiator-static pattern: the responder needs46- *    its OWN static keypair, and the initiator must already know the responder's47- *    static public key. We use the device's long-term sync identity48- *    ([deriveSyncIdentityKeypair]) as that static key and publish its public half49- *    in the mDNS TXT "pk" record so initiators can pin it.40+ * Reconnect is strictly KK. The opaque selector capability is resolved against41+ * local paired state before the handshake, both static keys are authenticated42+ * by the named pattern, and failure never falls back to first-pair NK.5043  *  - The brief calls `syncAdapter.buildResponse/applyRequest`. NO SUCH METHODS.5144  *    The real per-round protocol is [runChirpSyncResponder]; the adapter is a5245  *    [ChirpSyncVaultAdapter] ([VaultSyncAdapter]) with getEncryptedEntries/@@ -64,8 +57,8 @@6457     /**6558      * Start listening + advertise + accept. Caller supplies:6659      *  - [ourStaticPrivateKey]/[ourStaticPublicKey]: the device's long-term sync67-     *    identity (from [deriveSyncIdentityKeypair]) — the Noise NK responder68-     *    static key, also published in mDNS TXT "pk".60+     *    identity (from [deriveSyncIdentityKeypair]) — the KK responder static61+     *    key, also published in mDNS TXT "pk".6962      *  - [vaultFactory]: builds a fresh [ChirpSyncVaultAdapter] per connection7063      *    (the vault may lock/unlock between connections).7164      *  - [localIdentity]: our PAIR_IDENTITY payload.@@ -124,10 +117,19 @@124117             onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },125118         )126119         return try {127-            // 1. Noise NK responder handshake — real free function, single session.128-            val session = noiseNKResponder(120+            val selector = JSONObject(String(transport.receive(), Charsets.UTF_8))121+            require(selector.optString("type") == "PEACH_RECONNECT") { "expected PEACH_RECONNECT" }122+            val rendezvousId = selector.optString("rendezvous_id")123+            val known = Rendezvous.resolvePeer(deviceStore.getAll(), rendezvousId)124+                ?: throw IllegalArgumentException("unknown reconnect peer")125+            val initiatorKeyHex = known.longTermPublicKeyHex126+                ?: throw IllegalArgumentException("known peer missing static key")127+            val initiatorKey = hexToPubkey(initiatorKeyHex)128+129+            val session = noiseKKResponder(129130                 ourStaticPrivateKey,130131                 ourStaticPublicKey,132+                initiatorKey,131133                 transport::send,132134                 transport::receive,133135             )@@ -135,24 +137,25 @@135137             // 2. PAIR_IDENTITY exchange (responder reads first, then answers) —136138             //    mirrors ChirpPairing.runPairingResponder ordering.137139             val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))140+            require(theirIdentity.deviceId == known.deviceId) { "peer device id changed after KK" }141+            require(142+                theirIdentity.longTermPublicKeyHex.equals(initiatorKeyHex, ignoreCase = true),143+            ) { "peer identity does not match authenticated KK key" }138144             transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))139145 140146             val sas = SASDerivation.derive(session.handshakeHash)141-            val device = pairIdentityToPairedDevice(theirIdentity)142-            deviceStore.upsert(device)143-144147             // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.145148             val vault = vaultFactory()146149             if (vault != null) {147-                vault.bindAuthenticatedPeer(device)148-                val syncSession = chirpSessionOver(session, transport)150+                vault.bindAuthenticatedPeer(known)151+                val syncSession = chirpSessionOver(session, transport, initiatorKey)149152                 val outcome = runChirpSyncResponder(syncSession, vault)150153                 if (outcome.status == SyncOutcomeStatus.COMPLETED) {151-                    deviceStore.updateLastSync(device.deviceId)154+                    deviceStore.updateLastSync(known.deviceId)152155                 }153156             }154157             transport.close()155-            PairingServerEvent.SyncComplete(device.deviceId, sas)158+            PairingServerEvent.SyncComplete(known.deviceId, sas)156159         } catch (e: Throwable) {157160             transport.close()158161             PairingServerEvent.Error(e.message ?: "pairing_server_error")

view file

app/src/main/java/com/peachpasswords/sync/NoiseKK.kt+237 −0
@@ -0,0 +1,237 @@1+package com.peachpasswords.sync2+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+16+internal data class NoiseKKKeyPair(17+    val publicKey: ByteArray,18+    val secretKey: ByteArray,19+)20+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+74+/**75+ * Standard Noise KK reconnect initiator.76+ * Pre-messages: -> s, <- s; messages: -> e,es,ss then <- e,ee,se.77+ */78+internal suspend fun noiseKKInitiator(79+    ourStaticPrivateKey: ByteArray,80+    ourStaticPublicKey: ByteArray,81+    responderStaticPublicKey: ByteArray,82+    send: suspend (ByteArray) -> Unit,83+    receive: suspend () -> ByteArray,84+    ephemeral: NoiseKKKeyPair = freshKKKeyPair(),85+): NoiseNKSession {86+    requireKKKeys(87+        ourStaticPrivateKey,88+        ourStaticPublicKey,89+        responderStaticPublicKey,90+        ephemeral.secretKey,91+        ephemeral.publicKey,92+    )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"132+    }133+    hash = mixKKHash(hash, encrypted2)134+135+    val transportKeys = kkHkdf2(chainingKey, ByteArray(0))136+    return kkTransportSession(transportKeys.first, transportKeys.second, hash)137+}138+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+ */144+internal suspend fun noiseKKResponder(145+    ourStaticPrivateKey: ByteArray,146+    ourStaticPublicKey: ByteArray,147+    expectedInitiatorStaticPublicKey: ByteArray,148+    send: suspend (ByteArray) -> Unit,149+    receive: suspend () -> ByteArray,150+    ephemeral: NoiseKKKeyPair = freshKKKeyPair(),151+): NoiseNKSession {152+    requireKKKeys(153+        ourStaticPrivateKey,154+        ourStaticPublicKey,155+        expectedInitiatorStaticPublicKey,156+        ephemeral.secretKey,157+        ephemeral.publicKey,158+    )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"182+    }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)203+}204+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/PairingDiscoveryClient.kt+89 −17
@@ -12,6 +12,7 @@1212 import kotlinx.coroutines.flow.first1313 import kotlinx.coroutines.withContext1414 import kotlinx.coroutines.withTimeoutOrNull15+import org.json.JSONObject1516 import java.net.InetSocketAddress1617 import java.net.Socket1718 import java.net.SocketTimeoutException@@ -19,10 +20,11 @@1920 import javax.inject.Singleton2021 2122 /**22- * C7 TCP + mDNS pairing/sync CLIENT (initiator side). Discovers `_peach._tcp`23+ * C7 TCP + mDNS reconnect CLIENT (initiator side). Discovers `_peach._tcp`2324  * peers, dials one, and runs the REAL initiator pipeline:24- *   1. Noise NK initiator handshake ([noiseNKInitiator]) — pins the responder's25- *      static public key from the mDNS TXT "pk" record.25+ *   1. An opaque, rotating PEACH_RECONNECT capability lets the responder choose26+ *      an already-paired local record, then Noise KK authenticates both stored27+ *      static keys.2628  *   2. PAIR_IDENTITY exchange (initiator writes first, then reads), matching2729  *      ChirpPairing.runPairingInitiator ordering.2830  *   3. SAS via the REAL [SASDerivation.derive] over the Noise handshake hash.@@ -30,9 +32,9 @@3032  *      [ChirpSyncSession].3133  *3234  * ── Reconciliation with the brief ──────────────────────────────────────────33- *  - `noiseNK.initiateHandshake(stream, out, remotePublicKey)` does NOT exist.34- *    Real: `noiseNKInitiator(responderStaticPublicKey, send, receive)` →35- *    [NoiseNKSession]. One bidirectional cipher; no `.sendCipher/.recvCipher`.35+ *  - Reconnect is strictly KK: both local static keys are inputs and a failure36+ *    never negotiates or retries NK. The returned [NoiseNKSession] interface is37+ *    transport-neutral legacy naming, not the active handshake pattern.3638  *  - `syncAdapter.buildRequest/applyResponse` do NOT exist. Real round driver is3739  *    [runChirpSyncInitiator] against a [ChirpSyncVaultAdapter].3840  *  - `peer.serviceInfo.host/.port`: on API < 34, [NsdServiceInfo.getHost] is@@ -88,16 +90,18 @@8890     }8991 9092     /**91-     * Dial [peer], run the Noise NK initiator handshake, exchange identities and93+     * Dial [peer], run the Noise KK initiator handshake, exchange identities and9294      * derive the SAS, then run one initiator sync round.9395      *94-     * Caller supplies the local static identity (Noise initiator does not have a95-     * static key in NK, but our PAIR_IDENTITY + the sync layer still carry our96-     * long-term identity) and a vault adapter factory.96+     * Caller supplies the local static identity and private key required by KK,97+     * plus a vault adapter factory. The pre-handshake selector is an opaque98+     * rendezvous capability; it never discloses either stored public key.9799      */98-    suspend fun connectAndSync(100+    private suspend fun connectAndSync(99101         peer: DiscoveredPeer,100102         localIdentity: LocalChirpIdentity,103+        localStaticPrivateKey: ByteArray,104+        rendezvousId: String,101105         vaultFactory: () -> ChirpSyncVaultAdapter?,102106         connectTimeoutMs: Int = 15_000,103107     ): PairingResult = withContext(Dispatchers.IO) {@@ -112,8 +116,22 @@112116                 onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },113117             )114118 119+            val localPublicKey = hexToPubkey(localIdentity.longTermPublicKeyHex)120+            transport.send(121+                JSONObject()122+                    .put("type", "PEACH_RECONNECT")123+                    .put("rendezvous_id", rendezvousId)124+                    .toString()125+                    .toByteArray(Charsets.UTF_8),126+            )115127             val session = try {116-                noiseNKInitiator(peer.remoteStaticPublicKey, transport::send, transport::receive)128+                noiseKKInitiator(129+                    localStaticPrivateKey,130+                    localPublicKey,131+                    peer.remoteStaticPublicKey,132+                    transport::send,133+                    transport::receive,134+                )117135             } catch (e: Throwable) {118136                 transport.close()119137                 return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)@@ -123,6 +141,13 @@123141             // ChirpPairing.runPairingInitiator).124142             transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))125143             val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))144+            require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }145+            require(146+                theirIdentity.longTermPublicKeyHex.equals(147+                    pubkeyToHex(peer.remoteStaticPublicKey),148+                    ignoreCase = true,149+                ),150+            ) { "peer identity does not match authenticated KK key" }126151 127152             val sas = SASDerivation.derive(session.handshakeHash)128153             val incoming = pairIdentityToPairedDevice(theirIdentity)@@ -172,19 +197,51 @@172197     suspend fun syncWithKnownDevice(173198         deviceId: String,174199         localIdentity: LocalChirpIdentity,200+        localStaticPrivateKey: ByteArray,175201         vaultFactory: () -> ChirpSyncVaultAdapter?,176202         discoveryTimeoutMs: Long = 5_000L,177203         cached: DiscoveredPeer? = null,178204         cachedTimeoutMs: Int = 2_000,179205     ): PairingResult {206+        val paired = deviceStore.get(deviceId)207+            ?: return PairingResult.Error(PairingError.NOT_FOUND)208+        val secret = paired.rendezvousSecretHex209+            ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }210+            ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)211+        val rendezvousIds = Rendezvous.dialingIds(212+            secret,213+            System.currentTimeMillis() / 1000L,214+        )180215         if (cached != null) {181-            val viaCache = connectAndSync(cached, localIdentity, vaultFactory, connectTimeoutMs = cachedTimeoutMs)182-            if (viaCache is PairingResult.Success) return viaCache216+            val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)217+            for (rendezvousId in rendezvousIds) {218+                val viaCache = connectAndSync(219+                    pinnedCached,220+                    localIdentity,221+                    localStaticPrivateKey,222+                    rendezvousId,223+                    vaultFactory,224+                    connectTimeoutMs = cachedTimeoutMs,225+                )226+                if (viaCache is PairingResult.Success) return viaCache227+            }183228         }184-        val peer = withTimeoutOrNull(discoveryTimeoutMs) {229+        val discovered = withTimeoutOrNull(discoveryTimeoutMs) {185230             discoverPeers().first { it.deviceId == deviceId }186231         } ?: return PairingResult.Error(PairingError.NOT_FOUND)187-        return connectAndSync(peer, localIdentity, vaultFactory)232+        val peer = pinDiscoveredPeerToPairedState(paired, discovered)233+        var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)234+        for (rendezvousId in rendezvousIds) {235+            last = connectAndSync(236+                peer,237+                localIdentity,238+                localStaticPrivateKey,239+                rendezvousId,240+                vaultFactory,241+            )242+            if (last is PairingResult.Success) return last243+        }244+        return last188245     }189246 190247     private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {@@ -213,9 +270,24 @@213270     val deviceName: String,214271     val host: String,215272     val port: Int,216-    /** Noise NK responder static public key, parsed from TXT "pk". */273+    /** Paired responder static public key, parsed from TXT "pk" and pinned in KK. */217274     val remoteStaticPublicKey: ByteArray,218275 )276+277+/**278+ * Discovery supplies reachability only. KK's responder key always comes from279+ * paired local state; a spoofed mDNS TXT record can redirect or deny service,280+ * but cannot choose the key the initiator authenticates.281+ */282+internal fun pinDiscoveredPeerToPairedState(283+    paired: PairedDevice,284+    discovered: DiscoveredPeer,285+): DiscoveredPeer {286+    require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }287+    val expectedKey = paired.longTermPublicKeyHex288+        ?: throw IllegalArgumentException("paired peer missing static key")289+    return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))290+}219291 220292 sealed class PairingResult {221293     data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()

view file

app/src/main/java/com/peachpasswords/sync/RelayFrames.kt+2 −2
@@ -12,7 +12,7 @@1212  *1313  * Two browser extensions can never connect to each other: neither can listen.1414  * So a phone already paired with both forwards between them. The phone is a15- * pipe, not a peer — Noise NK for relayed traffic is established end-to-end15+ * pipe, not a peer — Noise KK for relayed reconnect traffic is end-to-end1616  * between the two laptops, and [Payload.payload] is opaque here by design.1717  *1818  * These frames ride the SAME encrypted channel as ordinary sync traffic: each@@ -38,7 +38,7 @@3838 @Serializable3939 sealed class RelayMessage {4040     /**41-     * [targetDeviceId] is the destination's Noise NK static public key as41+     * [targetDeviceId] is the destination's Noise static public key as4242      * lowercase hex — the design doc's field name, but keyed on the public key4343      * rather than the `deviceId` string, since the key is what both the phone's4444      * registry and the requester can name unambiguously.

view file

app/src/main/java/com/peachpasswords/sync/RelayHub.kt+1 −1
@@ -41,7 +41,7 @@4141  * paired with both forwards between them. Frames pass through verbatim: the4242  * relay decrypts only the per-hop link encryption it is a party to, sees a4343  * RELAY_FRAME wrapper, and re-encrypts the same bytes onto the other link. The44- * inner Noise NK session belongs to the two laptops and terminates there.44+ * inner Noise KK session belongs to the two laptops and terminates there.4545  *4646  * The hub holds every live authenticated connection keyed by the peer's static4747  * public key, which is the addressing layer the design calls for — nothing else

view file

app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+28 −0
@@ -5,6 +5,7 @@55 import org.bouncycastle.crypto.params.HKDFParameters66 import java.net.URI77 import java.nio.ByteBuffer8+import java.security.MessageDigest89 import java.security.SecureRandom910 import java.util.Base641011 @@ -49,6 +50,33 @@4950         return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {5051             deriveId(secret, it)5152         }53+    }54+55+    /**56+     * Resolve an opaque rotating capability to paired local state without57+     * exposing public keys in a pre-handshake selector. Invalid/missing legacy58+     * secrets are ignored; callers must fail closed rather than retry NK.59+     */60+    fun resolvePeer(61+        devices: List<PairedDevice>,62+        rendezvousId: String,63+        unixSeconds: Long = System.currentTimeMillis() / 1000L,64+    ): PairedDevice? {65+        if (!rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) return null66+        val presented = rendezvousId.toByteArray(Charsets.US_ASCII)67+        var match: PairedDevice? = null68+        for (device in devices) {69+            val secret = device.rendezvousSecretHex70+                ?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) }71+                ?.let(::secretFromHex)72+                ?: continue73+            for (candidate in waitingIds(secret, unixSeconds)) {74+                if (MessageDigest.isEqual(candidate.toByteArray(Charsets.US_ASCII), presented)) {75+                    match = device76+                }77+            }78+        }79+        return match5280     }5381 5482     fun newPairingToken(random: SecureRandom = SecureRandom()): String {

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+42 −16
@@ -5,8 +5,10 @@55 import kotlinx.coroutines.isActive66 import kotlinx.coroutines.launch77 import kotlinx.coroutines.withContext8+import org.json.JSONObject89 import java.net.ServerSocket910 import java.net.Socket11+import java.security.MessageDigest1012 import javax.inject.Inject1113 import javax.inject.Singleton1214 @@ -19,12 +21,10 @@1921  * phone is not reachable, two devices simply never sync and neither shows an2022  * error, because "no connection" and "nothing to sync" look identical.2123  *22- * Trust here is NOT the pairing token — there is none. Noise NK authenticates23- * the responder (this phone) to the dialer, not the reverse, so after the24- * handshake the peer must present a PAIR_IDENTITY whose device is already in25- * [PairedDeviceRegistry]. An unknown peer is dropped without a reply, the same26- * silence a bad pairing token gets: an unpaired caller learns nothing about27- * whether this is a Peach device.24+ * Reconnect uses Noise KK. The plaintext PEACH_RECONNECT selector is an opaque,25+ * rotating capability used only to find an already-paired local record before26+ * KK; it reveals no public identity and grants no trust. An unknown selector is27+ * dropped, and a KK failure never retries the weaker first-pair NK pattern.2828  *2929  * The port is fixed rather than OS-assigned because the browser reconnects to a3030  * cached address; an ephemeral port would be stale the moment the service@@ -96,9 +96,22 @@9696         try {9797             if (!ws.accept()) return@withContext9898 99-            val session = noiseNKResponder(99+            val selectorBytes = (ws.receive() as? WsMessage.Binary)?.bytes100+                ?: return@withContext101+            val selector = runCatching {102+                JSONObject(String(selectorBytes, Charsets.UTF_8))103+            }.getOrNull() ?: return@withContext104+            if (selector.optString("type") != "PEACH_RECONNECT") return@withContext105+            val rendezvousId = selector.optString("rendezvous_id")106+            val known = Rendezvous.resolvePeer(deviceStore.getAll(), rendezvousId)107+                ?: return@withContext108+            val initiatorKeyHex = known.longTermPublicKeyHex ?: return@withContext109+            val initiatorKey = hexToPubkey(initiatorKeyHex)110+111+            val session = noiseKKResponder(100112                 ourStaticPrivateKey,101113                 ourStaticPublicKey,114+                initiatorKey,102115                 { frame -> ws.send(frame) },103116                 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },104117             )@@ -109,10 +122,7 @@109122                 ),110123             )111124 112-            // The whole authentication step. Without it this port would sync113-            // the vault with anyone on the LAN who completed a handshake.114-            val known = deviceStore.get(theirIdentity.deviceId)115-            if (known == null || !keysMatch(known, theirIdentity)) return@withContext125+            if (!identityMatches(known, theirIdentity, initiatorKey)) return@withContext116126 117127             ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))118128 @@ -132,7 +142,7 @@132142                 ws.send(session.encrypt(plaintext))133143             }134144             try {135-                dispatch(link, session, ws, known, vaultFactory)145+                dispatch(link, session, ws, known, initiatorKey, vaultFactory)136146             } finally {137147                 relayHub.unregister(link)138148             }@@ -155,6 +165,7 @@155165         session: NoiseNKSession,156166         ws: WebSocketConnection,157167         authenticatedPeer: PairedDevice,168+        authenticatedPeerKey: ByteArray,158169         vaultFactory: () -> ChirpSyncVaultAdapter?,159170     ) {160171         while (true) {@@ -168,7 +179,15 @@168179                     try {169180                         val vault = vaultFactory() ?: return170181                         vault.bindAuthenticatedPeer(authenticatedPeer)171-                        runChirpSyncResponder(webSocketSyncSession(session, ws, pushback = frame), vault)182+                        runChirpSyncResponder(183+                            webSocketSyncSession(184+                                session,185+                                ws,186+                                authenticatedPeerKey,187+                                pushback = frame,188+                            ),189+                            vault,190+                        )172191                     } finally {173192                         relayHub.endSync(link)174193                     }@@ -188,9 +207,15 @@188207      * to become one. Either way it is not this peer, and re-pairing is the189208      * only correct route back.190209      */191-    private fun keysMatch(known: PairedDevice, presented: PairIdentity): Boolean {210+    private fun identityMatches(211+        known: PairedDevice,212+        presented: PairIdentity,213+        authenticatedKey: ByteArray,214+    ): Boolean {192215         val expected = known.longTermPublicKeyHex ?: return false193-        return expected.equals(presented.longTermPublicKeyHex, ignoreCase = true)216+        return known.deviceId == presented.deviceId &&217+            expected.equals(presented.longTermPublicKeyHex, ignoreCase = true) &&218+            MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))194219     }195220 }196221 @@ -204,11 +229,12 @@204229 internal fun webSocketSyncSession(205230     session: NoiseNKSession,206231     ws: WebSocketConnection,232+    remoteStaticPublicKey: ByteArray,207233     pushback: ByteArray? = null,208234 ): ChirpSyncSession {209235     var pending = pushback210236     return ChirpSyncSession(211-        remoteStaticPublicKey = ByteArray(0),237+        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),212238         send = { plaintext -> ws.send(session.encrypt(plaintext)) },213239         receive = {214240             pending?.also { pending = null } ?: session.decrypt(

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+1 −1
@@ -26,7 +26,7 @@2626  * into another — [applyEntries] rejects any envelope whose decrypted id does2727  * not match the map key it arrived under. Both paired devices hold the same2828  * syncMasterKey (same vault root key), so keys match without negotiation; the29- * Noise NK session is a second, independent encryption layer on top.29+ * Noise KK reconnect session is a second, independent encryption layer on top.3030  */3131 class VaultSyncAdapter(3232     private val repo: VaultRepository,

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+31 −19
@@ -17,6 +17,7 @@1717 import com.peachpasswords.sync.SyncStatus1818 import com.peachpasswords.sync.VaultSyncAdapter1919 import com.peachpasswords.sync.buildLocalIdentity20+import com.peachpasswords.sync.deriveLocalSyncKeypair2021 import com.peachpasswords.sync.getOrCreateDeviceId2122 import com.peachpasswords.vault.VaultCrypto2223 import com.peachpasswords.vault.VaultRepository@@ -40,7 +41,7 @@4041  *   4. mDNS peer discovery — a paired device appears on the LAN.4142  *4243  * Discovery, the WebSocket server role, and the paired-device store are wired43- * through [PairingDiscoveryClient], the single live Noise NK dial path.44+ * through [PairingDiscoveryClient], the live Noise KK reconnect dial path.4445  */4546 @HiltViewModel4647 class SyncViewModel @Inject constructor(@@ -153,25 +154,36 @@153154         viewModelScope.launch {154155             try {155156                 val identity = buildLocalIdentity(context, master)156-                peers.forEach { dev ->157-                    // The listener refreshes lastKnownIp on every inbound158-                    // reconnect, so this is usually still valid and saves a159-                    // discovery round — and works when mDNS does not.160-                    val cached = dev.lastKnownIp?.let { ip ->161-                        DiscoveredPeer(162-                            deviceId = dev.deviceId,163-                            deviceName = dev.deviceName ?: dev.deviceId,164-                            host = ip,165-                            port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,166-                            remoteStaticPublicKey = dev.longTermPublicKeyHex167-                                ?.takeIf { it.length == 64 }168-                                ?.let(::hexToPubkey)169-                                ?: return@forEach,170-                        )157+                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)158+                try {159+                    peers.forEach { dev ->160+                        // The listener refreshes lastKnownIp on every inbound161+                        // reconnect, so this is usually still valid and saves a162+                        // discovery round — and works when mDNS does not.163+                        val cached = dev.lastKnownIp?.let { ip ->164+                            DiscoveredPeer(165+                                deviceId = dev.deviceId,166+                                deviceName = dev.deviceName ?: dev.deviceId,167+                                host = ip,168+                                port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,169+                                remoteStaticPublicKey = dev.longTermPublicKeyHex170+                                    ?.takeIf { it.length == 64 }171+                                    ?.let(::hexToPubkey)172+                                    ?: return@forEach,173+                            )174+                        }175+                        runCatching {176+                            discoveryClient.syncWithKnownDevice(177+                                deviceId = dev.deviceId,178+                                localIdentity = identity,179+                                localStaticPrivateKey = localKeys.secretKey,180+                                vaultFactory = ::buildAdapter,181+                                cached = cached,182+                            )183+                        }171184                     }172-                    runCatching {173-                        discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter, cached = cached)174-                    }185+                } finally {186+                    VaultCrypto.zero(localKeys.secretKey)175187                 }176188                 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())177189             } catch (e: Exception) {

view file

app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt+34 −10
@@ -9,6 +9,7 @@99 import org.junit.Assert.assertArrayEquals1010 import org.junit.Assert.assertEquals1111 import org.junit.Assert.assertNotNull12+import org.junit.Assert.assertThrows1213 import org.junit.Assert.assertTrue1314 import org.junit.Test1415 import java.io.PipedInputStream@@ -21,9 +22,9 @@2122  * Context (EncryptedSharedPreferences), so its persistence is exercised by the2223  * orchestrator's instrumentation tests; here we test the pure-JVM serialization2324  * layer the store round-trips through (test 1) plus its upsert/replace semantics24- * against an in-memory list (test 2), the frame codec (test 3), the full25- * Noise-NK-over-frames exchange the server/client share (test 4), and the26- * timeout/error path (test 5).25+ * against an in-memory list (test 2), the frame codec (test 3), the first-pair26+ * Noise-NK primitive over frames (tests 4–5), and the local-state key pin used27+ * by the live KK reconnect discovery path (test 6).2728  */2829 class C7ChirpTransportTest {2930 @@ -85,10 +86,9 @@8586     }8687 8788     /**88-     * Test 4: a full Noise NK handshake + identity frame exchange over a Piped89-     * socket pair — the exact wire the server (responder) and client (initiator)90-     * share. Proves the handshake completes, both sides derive the SAME SAS, and91-     * an encrypted identity frame decrypts correctly. No real network.89+     * Test 4: the first-pair Noise NK primitive + identity frame exchange over90+     * piped frames. Live reconnect uses KK; this pins the separate first-pair91+     * pattern that remains valid when the initiator is not known yet.9292      */9393     @Test9494     fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {@@ -138,9 +138,7 @@138138     }139139 140140     /**141-     * Test 5: a wrong responder static key fails the initiator handshake — the142-     * NK auth tag rejects a MITM / mismatched key, which connectAndSync surfaces143-     * as PairingError.HANDSHAKE_FAILED.141+     * Test 5: a wrong responder static key fails the first-pair NK primitive.144142      */145143     @Test146144     fun `handshake fails when responder static key is wrong`() = runBlocking {@@ -175,5 +173,31 @@175173         initiatorJob.join()176174         responderJob.join()177175         assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)176+    }177+178+    @Test179+    fun `reconnect discovery cannot replace the locally paired responder key`() {180+        val (_, storedPublic) = newKeyPair()181+        val (_, advertisedPublic) = newKeyPair()182+        val paired = device("phone").copy(183+            longTermPublicKeyHex = pubkeyToHex(storedPublic),184+        )185+        val advertised = DiscoveredPeer(186+            deviceId = paired.deviceId,187+            deviceName = "Spoofed endpoint",188+            host = "192.0.2.1",189+            port = 49142,190+            remoteStaticPublicKey = advertisedPublic,191+        )192+193+        val pinned = pinDiscoveredPeerToPairedState(paired, advertised)194+195+        assertArrayEquals(storedPublic, pinned.remoteStaticPublicKey)196+        assertThrows(IllegalArgumentException::class.java) {197+            pinDiscoveredPeerToPairedState(198+                paired,199+                advertised.copy(deviceId = "different-device"),200+            )201+        }178202     }179203 }

view file

app/src/test/java/com/peachpasswords/sync/NoiseKKTest.kt+129 −0
@@ -0,0 +1,129 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters7+import org.junit.Assert.assertArrayEquals8+import org.junit.Assert.assertEquals9+import org.junit.Assert.assertTrue10+import org.junit.Test11+import java.security.SecureRandom12+13+class NoiseKKTest {14+    private fun keys(seed: Int? = null): NoiseKKKeyPair {15+        val privateKey = if (seed == null) {16+            X25519PrivateKeyParameters(SecureRandom())17+        } else {18+            X25519PrivateKeyParameters(ByteArray(32) { seed.toByte() }, 0)19+        }20+        return NoiseKKKeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)21+    }22+23+    @Test24+    fun `KK mutually authenticates and carries transport frames`() = runBlocking {25+        val initiator = keys()26+        val responder = keys()27+        val aToB = Channel<ByteArray>(Channel.UNLIMITED)28+        val bToA = Channel<ByteArray>(Channel.UNLIMITED)29+        val initiatorHandshake = async {30+            noiseKKInitiator(31+                initiator.secretKey,32+                initiator.publicKey,33+                responder.publicKey,34+                { aToB.send(it) },35+                { bToA.receive() },36+            )37+        }38+        val responderHandshake = async {39+            noiseKKResponder(40+                responder.secretKey,41+                responder.publicKey,42+                initiator.publicKey,43+                { bToA.send(it) },44+                { aToB.receive() },45+            )46+        }47+        val a = initiatorHandshake.await()48+        val b = responderHandshake.await()49+50+        assertArrayEquals(a.handshakeHash, b.handshakeHash)51+        assertEquals("one", String(b.decrypt(a.encrypt("one".encodeToByteArray()))))52+        assertEquals("two", String(a.decrypt(b.encrypt("two".encodeToByteArray()))))53+    }54+55+    @Test56+    fun `KK handshake hash matches browser cross-platform vector`() = runBlocking {57+        val initiator = keys(0x01)58+        val responder = keys(0x02)59+        val initiatorEphemeral = keys(0x03)60+        val responderEphemeral = keys(0x04)61+        val aToB = Channel<ByteArray>(Channel.UNLIMITED)62+        val bToA = Channel<ByteArray>(Channel.UNLIMITED)63+64+        val initiatorHandshake = async {65+            noiseKKInitiator(66+                initiator.secretKey,67+                initiator.publicKey,68+                responder.publicKey,69+                { aToB.send(it) },70+                { bToA.receive() },71+                initiatorEphemeral,72+            )73+        }74+        val responderHandshake = async {75+            noiseKKResponder(76+                responder.secretKey,77+                responder.publicKey,78+                initiator.publicKey,79+                { bToA.send(it) },80+                { aToB.receive() },81+                responderEphemeral,82+            )83+        }84+85+        val a = initiatorHandshake.await()86+        val b = responderHandshake.await()87+        assertEquals(88+            "e92392c530684df3eed06a76bd18da5c7a1887e88f5fdee758f9870dadd28182",89+            pubkeyToHex(a.handshakeHash),90+        )91+        assertArrayEquals(a.handshakeHash, b.handshakeHash)92+    }93+94+    @Test95+    fun `KK rejects an initiator key mismatch without fallback`() = runBlocking {96+        val initiator = keys()97+        val attacker = keys()98+        val responder = keys()99+        val aToB = Channel<ByteArray>(Channel.UNLIMITED)100+        val bToA = Channel<ByteArray>(Channel.UNLIMITED)101+102+        val initiatorHandshake = async {103+            runCatching {104+                noiseKKInitiator(105+                    initiator.secretKey,106+                    initiator.publicKey,107+                    responder.publicKey,108+                    { aToB.send(it) },109+                    { bToA.receive() },110+                )111+            }112+        }113+        val responderHandshake = async {114+            runCatching {115+                noiseKKResponder(116+                    responder.secretKey,117+                    responder.publicKey,118+                    attacker.publicKey,119+                    { bToA.send(it) },120+                    { aToB.receive() },121+                )122+            }123+        }124+125+        val failure = responderHandshake.await().exceptionOrNull()126+        assertTrue(failure?.message?.contains("Noise KK: msg1 auth failed") == true)127+        initiatorHandshake.cancel()128+    }129+}

view file

app/src/test/java/com/peachpasswords/sync/RendezvousTest.kt+28 −0
@@ -1,6 +1,8 @@11 package com.peachpasswords.sync22 33 import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Assert.assertSame46 import org.junit.Assert.assertThrows57 import org.junit.Test68 import java.security.SecureRandom@@ -40,6 +42,32 @@4042             ),4143             Rendezvous.dialingIds(secret, now),4244         )45+    }46+47+    @Test fun `opaque capabilities resolve current and previous windows only`() {48+        val secretHex = "42".repeat(32)49+        val peer = PairedDevice(50+            deviceId = "browser-1",51+            longTermPublicKeyHex = "ab".repeat(32),52+            pairedAt = 1L,53+            rendezvousSecretHex = secretHex,54+        )55+        val legacy = PairedDevice(56+            deviceId = "legacy",57+            longTermPublicKeyHex = "cd".repeat(32),58+            pairedAt = 1L,59+        )60+        val secret = Rendezvous.secretFromHex(secretHex)61+        val now = 500_000L * 3600 + 359962+        val current = Rendezvous.deriveId(secret, 500_000)63+        val previous = Rendezvous.deriveId(secret, 499_999)64+        val next = Rendezvous.deriveId(secret, 500_001)65+66+        assertSame(peer, Rendezvous.resolvePeer(listOf(legacy, peer), current, now))67+        assertSame(peer, Rendezvous.resolvePeer(listOf(legacy, peer), previous, now))68+        assertNull(Rendezvous.resolvePeer(listOf(legacy, peer), next, now))69+        assertNull(Rendezvous.resolvePeer(listOf(legacy, peer), "Z".repeat(43), now))70+        assertNull(Rendezvous.resolvePeer(listOf(legacy), current, now))4371     }4472 4573     @Test fun `first-pair token is 128-bit base64url`() {

view file

app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+130 −21
@@ -15,6 +15,7 @@1515 import org.bouncycastle.crypto.params.X25519PrivateKeyParameters1616 import org.junit.Assert.assertArrayEquals1717 import org.junit.Assert.assertEquals18+import org.junit.Assert.assertTrue1819 import org.junit.Test1920 import java.security.SecureRandom2021 import java.util.concurrent.CompletableFuture@@ -51,8 +52,17 @@5152         signingPublicKeyHex = "11".repeat(32),5253     )5354 54-    /** A browser-side peer: dials, completes NK, presents its identity. */55-    private class Laptop(private val identity: LocalChirpIdentity) {55+    private fun keys(): NoiseKKKeyPair {56+        val privateKey = X25519PrivateKeyParameters(SecureRandom())57+        return NoiseKKKeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)58+    }59+60+    /** A browser-side peer: selects paired state, completes KK, presents identity. */61+    private class Laptop(62+        private val identity: LocalChirpIdentity,63+        private val keys: NoiseKKKeyPair,64+        private val rendezvousSecretHex: String,65+    ) {5666         val inbox = LinkedBlockingQueue<ByteArray>()5767         private val opened = CompletableFuture<Boolean>()5868         @Volatile var trouble: String? = null@@ -71,7 +81,18 @@7181                 },7282             )7383             opened.get(10, TimeUnit.SECONDS)74-            session = noiseNKInitiator(84+            val rendezvousId = Rendezvous.dialingIds(85+                Rendezvous.secretFromHex(rendezvousSecretHex),86+                System.currentTimeMillis() / 1000L,87+            ).first()88+            ws.send(89+                """{"type":"PEACH_RECONNECT","rendezvous_id":"$rendezvousId"}"""90+                    .toByteArray()91+                    .toByteString(),92+            )93+            session = noiseKKInitiator(94+                keys.secretKey,95+                keys.publicKey,7596                 serverPublicKey,7697                 send = { ws.send(it.toByteString()) },7798                 receive = { take() },@@ -96,12 +117,26 @@96117     @Test fun `one laptop is relayed to another and frames cross untouched`() = runBlocking {97118         val priv = X25519PrivateKeyParameters(SecureRandom())98119         val serverPub = priv.generatePublicKey().encoded99-        val keyA = "aa".repeat(32)100-        val keyB = "bb".repeat(32)120+        val keysA = keys()121+        val keysB = keys()122+        val keyA = pubkeyToHex(keysA.publicKey)123+        val keyB = pubkeyToHex(keysB.publicKey)124+        val secretA = "01".repeat(32)125+        val secretB = "02".repeat(32)101126         val registry = FakeRegistry(102127             listOf(103-                PairedDevice(deviceId = "A", longTermPublicKeyHex = keyA, pairedAt = 0),104-                PairedDevice(deviceId = "B", longTermPublicKeyHex = keyB, pairedAt = 0),128+                PairedDevice(129+                    deviceId = "A",130+                    longTermPublicKeyHex = keyA,131+                    pairedAt = 0,132+                    rendezvousSecretHex = secretA,133+                ),134+                PairedDevice(135+                    deviceId = "B",136+                    longTermPublicKeyHex = keyB,137+                    pairedAt = 0,138+                    rendezvousSecretHex = secretB,139+                ),105140             ),106141         )107142         val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true })@@ -109,11 +144,11 @@109144         val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)110145 111146         try {112-            listener.start(scope, priv.encoded, serverPub, identity("phone", "cc".repeat(32)), port = 0) { null }147+            listener.start(scope, priv.encoded, serverPub, identity("phone", pubkeyToHex(serverPub)), port = 0) { null }113148             val port = awaitBound(listener)114149 115-            val a = Laptop(identity("A", keyA))116-            val b = Laptop(identity("B", keyB))150+            val a = Laptop(identity("A", keyA), keysA, secretA)151+            val b = Laptop(identity("B", keyB), keysB, secretB)117152             a.connect(port, serverPub)118153             b.connect(port, serverPub)119154 @@ -139,17 +174,28 @@139174     @Test fun `a request for a laptop that is not connected is answered as unavailable`() = runBlocking {140175         val priv = X25519PrivateKeyParameters(SecureRandom())141176         val serverPub = priv.generatePublicKey().encoded142-        val keyA = "aa".repeat(32)143-        val registry = FakeRegistry(listOf(PairedDevice(deviceId = "A", longTermPublicKeyHex = keyA, pairedAt = 0)))177+        val keysA = keys()178+        val keyA = pubkeyToHex(keysA.publicKey)179+        val secretA = "03".repeat(32)180+        val registry = FakeRegistry(181+            listOf(182+                PairedDevice(183+                    deviceId = "A",184+                    longTermPublicKeyHex = keyA,185+                    pairedAt = 0,186+                    rendezvousSecretHex = secretA,187+                ),188+            ),189+        )144190         val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true })145191         val listener = SyncReconnectListener(registry, hub)146192         val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)147193 148194         try {149-            listener.start(scope, priv.encoded, serverPub, identity("phone", "cc".repeat(32)), port = 0) { null }195+            listener.start(scope, priv.encoded, serverPub, identity("phone", pubkeyToHex(serverPub)), port = 0) { null }150196             val port = awaitBound(listener)151197 152-            val a = Laptop(identity("A", keyA))198+            val a = Laptop(identity("A", keyA), keysA, secretA)153199             a.connect(port, serverPub)154200             a.send(RelayMessage.Request("bb".repeat(32)))155201 @@ -163,12 +209,26 @@163209     @Test fun `relaying switched off is answered as disabled so the laptop tries another relay`() = runBlocking {164210         val priv = X25519PrivateKeyParameters(SecureRandom())165211         val serverPub = priv.generatePublicKey().encoded166-        val keyA = "aa".repeat(32)167-        val keyB = "bb".repeat(32)212+        val keysA = keys()213+        val keysB = keys()214+        val keyA = pubkeyToHex(keysA.publicKey)215+        val keyB = pubkeyToHex(keysB.publicKey)216+        val secretA = "04".repeat(32)217+        val secretB = "05".repeat(32)168218         val registry = FakeRegistry(169219             listOf(170-                PairedDevice(deviceId = "A", longTermPublicKeyHex = keyA, pairedAt = 0),171-                PairedDevice(deviceId = "B", longTermPublicKeyHex = keyB, pairedAt = 0),220+                PairedDevice(221+                    deviceId = "A",222+                    longTermPublicKeyHex = keyA,223+                    pairedAt = 0,224+                    rendezvousSecretHex = secretA,225+                ),226+                PairedDevice(227+                    deviceId = "B",228+                    longTermPublicKeyHex = keyB,229+                    pairedAt = 0,230+                    rendezvousSecretHex = secretB,231+                ),172232             ),173233         )174234         val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = false })@@ -176,16 +236,65 @@176236         val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)177237 178238         try {179-            listener.start(scope, priv.encoded, serverPub, identity("phone", "cc".repeat(32)), port = 0) { null }239+            listener.start(scope, priv.encoded, serverPub, identity("phone", pubkeyToHex(serverPub)), port = 0) { null }180240             val port = awaitBound(listener)181241 182-            val a = Laptop(identity("A", keyA))183-            val b = Laptop(identity("B", keyB))242+            val a = Laptop(identity("A", keyA), keysA, secretA)243+            val b = Laptop(identity("B", keyB), keysB, secretB)184244             a.connect(port, serverPub)185245             b.connect(port, serverPub)186246 187247             a.send(RelayMessage.Request(keyB))188248             assertEquals(RelayMessage.Disabled, a.next())249+        } finally {250+            listener.stop()251+            scope.cancel()252+        }253+    }254+255+    @Test fun `an unknown reconnect selector is dropped with no NK fallback`() = runBlocking {256+        val phonePrivate = X25519PrivateKeyParameters(SecureRandom())257+        val phonePublic = phonePrivate.generatePublicKey().encoded258+        val listener = SyncReconnectListener(259+            FakeRegistry(emptyList()),260+            RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true }),261+        )262+        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)263+        val opened = CompletableFuture<Boolean>()264+        val closed = CompletableFuture<Boolean>()265+        val received = LinkedBlockingQueue<ByteArray>()266+        try {267+            listener.start(268+                scope,269+                phonePrivate.encoded,270+                phonePublic,271+                identity("phone", pubkeyToHex(phonePublic)),272+                port = 0,273+            ) { null }274+            val socket = OkHttpClient().newWebSocket(275+                Request.Builder().url("ws://127.0.0.1:${awaitBound(listener)}").build(),276+                object : WebSocketListener() {277+                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }278+                    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {279+                        received.add(bytes.toByteArray())280+                    }281+                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {282+                        closed.complete(true)283+                    }284+                    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {285+                        closed.complete(true)286+                    }287+                },288+            )289+            opened.get(10, TimeUnit.SECONDS)290+            socket.send(291+                """{"type":"PEACH_RECONNECT","rendezvous_id":"${"Z".repeat(43)}"}"""292+                    .toByteArray()293+                    .toByteString(),294+            )295+296+            assertTrue("unknown local state must close", closed.get(5, TimeUnit.SECONDS))297+            assertTrue("listener must send no KK or NK response", received.isEmpty())189298         } finally {190299             listener.stop()191300             scope.cancel()

view file