b7a2b0329555
feat: require mutual KK reconnect auth (v2.3.74)
- time
- 2026-07-26 03:43 UTC · 3 months ago
- machine
- koh2
- parent
- 123cd1686b6b
↓ peach-android-b7a2b032.tar.gzatlas at this save
files changed17 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 −21app/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 {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)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,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)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")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+}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()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.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 elseapp/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 {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(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,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) {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 }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+}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`() {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()