78bdfd653a67
refactor: remove retired sync transport paths (v2.3.71)
- time
- 2026-07-26 02:46 UTC · 3 months ago
- machine
- koh2
- agent
- codex
- session
- peach relay sync implementation
- parent
- c6504350b98c
↓ peach-android-78bdfd65.tar.gzatlas at this save
files changed16 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+1 −1app/src/main/java/com/peachpasswords/sync/ChirpSyncSession.kt+15 −0app/src/main/java/com/peachpasswords/sync/ChirpSyncTransport.kt+0 −267app/src/main/java/com/peachpasswords/sync/ChirpTypes.kt+0 −2app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+2 −2app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+0 −1app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+1 −2app/src/main/java/com/peachpasswords/sync/NoiseXX.kt+0 −348app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+1 −1app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+1 −2app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+2 −48app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+2 −23app/src/test/java/com/peachpasswords/sync/ChirpSyncTransportTest.kt+0 −99app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+4 −4app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23700020- versionName "2.3.70"19+ versionCode 23710020+ versionName "2.3.71"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+1 −1
@@ -9,7 +9,7 @@99 * leaves to "the mobile-side coordinator that calls this same protocol the1010 * other way".1111 *12- * After Noise XX is open and the peer's static key is verified against the12+ * After Noise NK is open and the responder's static key is verified against the1313 * paired-device registry, the two sides drive:1414 *1515 * -> SYNC_HELLO { vaultId, deviceId, version, deviceTime }app/src/main/java/com/peachpasswords/sync/ChirpSyncSession.kt+15 −0
@@ -0,0 +1,15 @@1+package com.peachpasswords.sync2+3+/**4+ * Transport-neutral session consumed by the sync coordinator.5+ *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.9+ */10+data class ChirpSyncSession(11+ val remoteStaticPublicKey: ByteArray,12+ val send: (ByteArray) -> Unit,13+ val receive: suspend () -> ByteArray,14+ val close: suspend () -> Unit,15+)app/src/main/java/com/peachpasswords/sync/ChirpSyncTransport.kt+0 −267
@@ -1,267 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.async4-import kotlinx.coroutines.awaitAll5-import kotlinx.coroutines.coroutineScope6-import kotlinx.coroutines.ExperimentalCoroutinesApi7-import kotlinx.coroutines.channels.Channel8-import kotlinx.coroutines.suspendCancellableCoroutine9-import kotlinx.coroutines.withTimeout10-import okhttp3.OkHttpClient11-import okhttp3.Request12-import okhttp3.Response13-import okhttp3.WebSocket14-import okhttp3.WebSocketListener15-import okio.ByteString.Companion.toByteString16-import java.io.IOException17-import java.util.concurrent.TimeUnit18-import java.util.concurrent.atomic.AtomicBoolean19-import kotlin.coroutines.resume20-import kotlin.coroutines.resumeWithException21-22-const val DEFAULT_PROBE_TIMEOUT_MS: Long = 50023-const val DEFAULT_PROBE_BATCH: Int = 1624-const val DEFAULT_CONNECT_TIMEOUT_MS: Long = 4_00025-const val SUBNET_PROBE_COOLDOWN_MS: Long = 60_00026-27-data class ChirpSyncSession(28- val remoteStaticPublicKey: ByteArray,29- val send: (ByteArray) -> Unit,30- val receive: suspend () -> ByteArray,31- val close: suspend () -> Unit,32-)33-34-data class SyncEndpoint(35- val ip: String,36- val port: Int,37-)38-39-private val lastProbeAt = mutableMapOf<String, Long>()40-private val webSocketClient = OkHttpClient.Builder().build()41-42-suspend fun openChirpSyncSession(43- endpoint: SyncEndpoint,44- ourLongTermPrivateKey: ByteArray,45- ourLongTermPublicKey: ByteArray,46- expectedRemotePubkeyHex: String,47- connectTimeoutMs: Long = DEFAULT_CONNECT_TIMEOUT_MS,48-): ChirpSyncSession {49- val connection = connectWebSocket(endpoint, connectTimeoutMs)50-51- val session = try {52- noiseXXInitiator(53- ourLongTermPrivateKey,54- ourLongTermPublicKey,55- send = connection::send,56- receive = connection::receive,57- )58- } catch (err: Throwable) {59- connection.close()60- throw err61- }62-63- val remoteHex = pubkeyToHex(session.remoteStaticPublicKey)64- if (remoteHex != expectedRemotePubkeyHex) {65- connection.close()66- throw IllegalStateException(67- "paired-device static key mismatch (got ${remoteHex.take(16)}…, expected ${expectedRemotePubkeyHex.take(16)}…)",68- )69- }70- voidHex(expectedRemotePubkeyHex)71-72- return ChirpSyncSession(73- remoteStaticPublicKey = session.remoteStaticPublicKey,74- send = { plaintext -> connection.send(session.encrypt(plaintext)) },75- receive = { session.decrypt(connection.receive()) },76- close = { connection.close() },77- )78-}79-80-fun subnetProbeOnCooldown(81- expectedRemotePubkeyHex: String,82- now: Long = System.currentTimeMillis(),83- cooldownMs: Long = SUBNET_PROBE_COOLDOWN_MS,84-): Boolean {85- val last = lastProbeAt[expectedRemotePubkeyHex]86- return last != null && now - last < cooldownMs87-}88-89-fun resetSubnetProbeCooldown() {90- lastProbeAt.clear()91-}92-93-suspend fun probeSubnetForPeer(94- subnet: String,95- port: Int,96- ourLongTermPrivateKey: ByteArray,97- ourLongTermPublicKey: ByteArray,98- expectedRemotePubkeyHex: String,99- batch: Int = DEFAULT_PROBE_BATCH,100- timeoutMs: Long = DEFAULT_PROBE_TIMEOUT_MS,101- cooldownMs: Long = SUBNET_PROBE_COOLDOWN_MS,102- now: Long = System.currentTimeMillis(),103-): SyncEndpoint? {104- if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {105- return null106- }107- lastProbeAt[expectedRemotePubkeyHex] = now108-109- for (start in 1..254 step batch) {110- val end = minOf(start + batch - 1, 254)111- val results = coroutineScope {112- (start..end).map { i ->113- val ip = "$subnet.$i"114- async {115- probeOne(116- endpoint = SyncEndpoint(ip = ip, port = port),117- ourLongTermPrivateKey = ourLongTermPrivateKey,118- ourLongTermPublicKey = ourLongTermPublicKey,119- expectedRemotePubkeyHex = expectedRemotePubkeyHex,120- timeoutMs = timeoutMs,121- )122- }123- }.awaitAll()124- }125- val hit = results.firstOrNull { it != null }126- if (hit != null) return hit127- }128- return null129-}130-131-private suspend fun probeOne(132- endpoint: SyncEndpoint,133- ourLongTermPrivateKey: ByteArray,134- ourLongTermPublicKey: ByteArray,135- expectedRemotePubkeyHex: String,136- timeoutMs: Long,137-): SyncEndpoint? {138- return try {139- val session = withTimeout(timeoutMs) {140- openChirpSyncSession(141- endpoint = endpoint,142- ourLongTermPrivateKey = ourLongTermPrivateKey,143- ourLongTermPublicKey = ourLongTermPublicKey,144- expectedRemotePubkeyHex = expectedRemotePubkeyHex,145- connectTimeoutMs = timeoutMs,146- )147- }148- session.close()149- endpoint150- } catch (_: Throwable) {151- null152- }153-}154-155-private suspend fun connectWebSocket(156- endpoint: SyncEndpoint,157- timeoutMs: Long,158-): RawWebSocketConnection {159- return withTimeout(timeoutMs) {160- suspendCancellableCoroutine { cont ->161- val request = Request.Builder()162- .url("ws://${endpoint.ip}:${endpoint.port}/")163- .build()164- val connection = RawWebSocketConnection()165- val listener = object : WebSocketListener() {166- override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) {167- if (bytes.size > MAX_SYNC_FRAME_BYTES) {168- webSocket.close(1009, "sync frame too large")169- connection.onClosed(170- SyncFrameTooLargeException(171- "sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes",172- ),173- )174- return175- }176- connection.onMessage(bytes)177- }178-179- override fun onOpen(webSocket: WebSocket, response: Response) {180- connection.install(webSocket)181- if (cont.isActive) cont.resume(connection) else webSocket.cancel()182- }183-184- override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {185- connection.onClosed(186- if (code == 1009) SyncFrameTooLargeException()187- else IOException("WebSocket closed"),188- )189- }190-191- override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {192- connection.onFailure(IOException("WebSocket connect to ${endpoint.ip}:${endpoint.port} errored", t))193- if (cont.isActive) {194- cont.resumeWithException(IOException("WebSocket connect to ${endpoint.ip}:${endpoint.port} errored", t))195- }196- }197- }198- val ws = webSocketClient.newWebSocket(request, listener)199- connection.install(ws)200- cont.invokeOnCancellation {201- try {202- ws.cancel()203- } catch (_: Throwable) {204- // ignore205- }206- }207- }208- }209-}210-211-private class RawWebSocketConnection {212- private val inbox = Channel<ByteArray>(Channel.UNLIMITED)213- private val closed = AtomicBoolean(false)214- private var ws: WebSocket? = null215-216- fun install(webSocket: WebSocket) {217- ws = webSocket218- }219-220- fun onMessage(bytes: okio.ByteString) {221- inbox.trySend(bytes.toByteArray())222- }223-224- @OptIn(ExperimentalCoroutinesApi::class)225- fun onClosed(cause: Throwable) {226- if (closed.compareAndSet(false, true)) {227- inbox.close(cause)228- }229- }230-231- fun onFailure(cause: Throwable) {232- onClosed(cause)233- }234-235- fun send(bytes: ByteArray) {236- requireSyncFrameSize(bytes.size)237- val socket = ws ?: throw IOException("WebSocket not open")238- if (!socket.send(bytes.toByteString())) {239- throw IOException("WebSocket send failed")240- }241- }242-243- suspend fun receive(): ByteArray {244- if (closed.get() && inbox.isEmpty) {245- throw IOException("WebSocket closed before frame")246- }247- return try {248- inbox.receive()249- } catch (err: kotlinx.coroutines.channels.ClosedReceiveChannelException) {250- throw IOException("WebSocket closed before frame", err)251- }252- }253-254- fun close() {255- try {256- onClosed(IOException("WebSocket closed"))257- ws?.close(1000, null)258- } catch (_: Throwable) {259- // ignore260- }261- }262-}263-264-private fun voidHex(value: String) {265- // Keep the expected pubkey source pinned in the call graph.266- hexToPubkey(value)267-}app/src/main/java/com/peachpasswords/sync/ChirpTypes.kt+0 −2
@@ -4,7 +4,6 @@44 EXTENSION("extension", 0x01),55 ANDROID("android", 0x02),66 IOS("ios", 0x03),7- DESKTOP("desktop", 0x04),87 }98 109 val CHIRP_MAGIC_BYTES: ByteArray = byteArrayOf(0x50, 0x45, 0x41, 0x43)@@ -15,7 +14,6 @@1514 0x01 -> DeviceType.EXTENSION1615 0x02 -> DeviceType.ANDROID1716 0x03 -> DeviceType.IOS18- 0x04 -> DeviceType.DESKTOP1917 else -> throw IllegalArgumentException("unknown device type byte 0x${(b.toInt() and 0xff).toString(16)}")2018 }2119 app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+2 −2
@@ -64,7 +64,7 @@6464 fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {6565 val id = deviceId.takeIf { it.isNotBlank() } ?: return null6666 val name = deviceName?.takeIf { it.isNotBlank() } ?: return null67- val type = deviceType?.takeIf { it in setOf("extension", "android", "ios", "desktop") } ?: return null67+ val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null6868 val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null6969 val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null7070 return ManifestDeviceEntry(@@ -80,7 +80,7 @@8080 8181 private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {8282 if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null83- if (entry.deviceType !in setOf("extension", "android", "ios", "desktop")) return null83+ if (entry.deviceType !in setOf("extension", "android", "ios")) return null8484 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null8585 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null8686 return entry.copy(app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+0 −1
@@ -67,7 +67,6 @@6767 null,6868 "android",6969 "ios",70- "desktop",7170 "extension" -> true7271 else -> false7372 }app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+1 −2
@@ -237,7 +237,6 @@237237 "extension" -> DeviceType.EXTENSION238238 "android" -> DeviceType.ANDROID239239 "ios" -> DeviceType.IOS240- "desktop" -> DeviceType.DESKTOP241240 else -> error("device_type missing")242241 }243242 val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")@@ -277,7 +276,7 @@277276 ): ChirpSyncSession =278277 ChirpSyncSession(279278 remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),280- // ChirpSyncSession.send is non-suspending (matches openChirpSyncSession);279+ // ChirpSyncSession.send is non-suspending;281280 // SocketFrameIO.sendBlocking writes a frame synchronously.282281 send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },283282 receive = { session.decrypt(transport.receive()) },app/src/main/java/com/peachpasswords/sync/NoiseXX.kt+0 −348
@@ -1,348 +0,0 @@1-package com.peachpasswords.sync2-3-import org.bouncycastle.crypto.InvalidCipherTextException4-import org.bouncycastle.crypto.digests.Blake2sDigest5-import org.bouncycastle.crypto.generators.HKDFBytesGenerator6-import org.bouncycastle.crypto.modes.ChaCha20Poly13057-import org.bouncycastle.crypto.params.AEADParameters8-import org.bouncycastle.crypto.params.HKDFParameters9-import org.bouncycastle.crypto.params.KeyParameter10-import org.bouncycastle.crypto.params.X25519PrivateKeyParameters11-import org.bouncycastle.crypto.params.X25519PublicKeyParameters12-import java.nio.ByteBuffer13-import java.nio.ByteOrder14-import java.security.SecureRandom15-16-private const val PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s"17-18-private fun initHash(): ByteArray {19- val nameBytes = PROTOCOL_NAME.encodeToByteArray()20- if (nameBytes.size <= 32) {21- return ByteArray(32).also { nameBytes.copyInto(it) }22- }23- // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash24- // MUST use it too (Noise spec §5.2), not SHA-256. Coordinated wire change with25- // peach-browser noise-xx.ts.26- val blake = Blake2sDigest(256)27- blake.update(nameBytes, 0, nameBytes.size)28- val out = ByteArray(32)29- blake.doFinal(out, 0)30- return out31-}32-33-private fun mixHash(h: ByteArray, data: ByteArray): ByteArray {34- val blake = Blake2sDigest(256)35- blake.update(h, 0, h.size)36- blake.update(data, 0, data.size)37- val out = ByteArray(32)38- blake.doFinal(out, 0)39- return out40-}41-42-private fun hkdf2(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> {43- // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.44- val generator = HKDFBytesGenerator(Blake2sDigest(256))45- generator.init(HKDFParameters(ikm, ck, ByteArray(0)))46- val out = ByteArray(64)47- generator.generateBytes(out, 0, out.size)48- return out.copyOfRange(0, 32) to out.copyOfRange(32, 64)49-}50-51-// PCH-01-002: test-only seams for the cross-platform BLAKE2s vectors pinned in52-// CrossPlatformKATTest.kt (must stay byte-identical to peach-browser noise-xx.ts).53-internal fun noiseXXInitHashForTest(): ByteArray = initHash()54-internal fun noiseXXHkdf2ForTest(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> = hkdf2(ck, ikm)55-56-private fun nonceBytes(n: Long): ByteArray {57- val buf = ByteArray(12)58- ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putLong(4, n)59- return buf60-}61-62-interface NoiseXXSession {63- fun encrypt(plaintext: ByteArray): ByteArray64- fun decrypt(ciphertext: ByteArray): ByteArray65- val remoteStaticPublicKey: ByteArray66- val handshakeHash: ByteArray67-}68-69-private data class X25519KeyPair(70- val publicKey: ByteArray,71- val secretKey: ByteArray,72-)73-74-private fun generateKeyPair(): X25519KeyPair {75- val priv = X25519PrivateKeyParameters(SecureRandom())76- return X25519KeyPair(77- publicKey = priv.generatePublicKey().encoded,78- secretKey = priv.encoded,79- )80-}81-82-private fun keyPairFromSeed(seed: ByteArray): X25519KeyPair {83- val priv = X25519PrivateKeyParameters(seed, 0)84- return X25519KeyPair(85- publicKey = priv.generatePublicKey().encoded,86- secretKey = priv.encoded,87- )88-}89-90-private fun sharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {91- val priv = X25519PrivateKeyParameters(privateKey, 0)92- val pub = X25519PublicKeyParameters(publicKey, 0)93- val out = ByteArray(32)94- priv.generateSecret(pub, out, 0)95- return out96-}97-98-private fun chachaSeal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {99- val cipher = ChaCha20Poly1305()100- cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))101- val out = ByteArray(cipher.getOutputSize(plaintext.size))102- var len = cipher.processBytes(plaintext, 0, plaintext.size, out, 0)103- len += cipher.doFinal(out, len)104- return out.copyOf(len)105-}106-107-private fun chachaOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray? {108- return try {109- val cipher = ChaCha20Poly1305()110- cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))111- val out = ByteArray(cipher.getOutputSize(ciphertext.size))112- var len = cipher.processBytes(ciphertext, 0, ciphertext.size, out, 0)113- len += cipher.doFinal(out, len)114- out.copyOf(len)115- } catch (_: InvalidCipherTextException) {116- null117- }118-}119-120-internal fun noiseXXDeterministicHandshakeHashForTest(): ByteArray {121- val initiatorStatic = keyPairFromSeed(ByteArray(32) { 0xa1.toByte() })122- val responderStatic = keyPairFromSeed(ByteArray(32) { 0xb2.toByte() })123- val initiatorEphemeral = keyPairFromSeed(ByteArray(32) { 0x01 })124- val responderEphemeral = keyPairFromSeed(ByteArray(32) { 0x02 })125-126- var hI = initHash()127- var ckI = hI.copyOf()128- var hR = initHash()129- var ckR = hR.copyOf()130-131- // Message 1: -> e132- val msg1 = initiatorEphemeral.publicKey133- hI = mixHash(hI, msg1)134- hI = mixHash(hI, ByteArray(0))135- hR = mixHash(hR, msg1)136- hR = mixHash(hR, ByteArray(0))137-138- // Message 2: <- e, ee, s, es139- hR = mixHash(hR, responderEphemeral.publicKey)140- val eeR = sharedKey(responderEphemeral.secretKey, msg1)141- val (ckR1, kR1) = hkdf2(ckR, eeR)142- ckR = ckR1143- val encryptedResponderStatic = chachaSeal(kR1, nonceBytes(0L), responderStatic.publicKey, hR)144- hR = mixHash(hR, encryptedResponderStatic)145-146- val esR = sharedKey(responderStatic.secretKey, msg1)147- val (ckR2, kR2) = hkdf2(ckR, esR)148- ckR = ckR2149- val encryptedPayload = chachaSeal(kR2, nonceBytes(0L), ByteArray(0), hR)150- hR = mixHash(hR, encryptedPayload)151-152- val remoteEphemeral = responderEphemeral.publicKey153- hI = mixHash(hI, remoteEphemeral)154- val eeI = sharedKey(initiatorEphemeral.secretKey, remoteEphemeral)155- val (ckI1, kI1) = hkdf2(ckI, eeI)156- ckI = ckI1157- val remoteStaticPlain = chachaOpen(kI1, nonceBytes(0L), encryptedResponderStatic, hI)158- ?: throw IllegalStateException("Noise XX KAT: msg2 static auth failed")159- hI = mixHash(hI, encryptedResponderStatic)160-161- val esI = sharedKey(initiatorEphemeral.secretKey, remoteStaticPlain)162- val (ckI2, kI2) = hkdf2(ckI, esI)163- ckI = ckI2164- chachaOpen(kI2, nonceBytes(0L), encryptedPayload, hI)165- ?: throw IllegalStateException("Noise XX KAT: msg2 payload auth failed")166- hI = mixHash(hI, encryptedPayload)167-168- // Message 3: -> s, se169- val encryptedInitiatorStatic = chachaSeal(kI2, nonceBytes(1L), initiatorStatic.publicKey, hI)170- hI = mixHash(hI, encryptedInitiatorStatic)171- val seI = sharedKey(initiatorStatic.secretKey, remoteEphemeral)172- val (ckI3, kI3) = hkdf2(ckI, seI)173- ckI = ckI3174- val encryptedFinalPayload = chachaSeal(kI3, nonceBytes(0L), ByteArray(0), hI)175- hI = mixHash(hI, encryptedFinalPayload)176-177- val initiatorStaticPlain = chachaOpen(kR2, nonceBytes(1L), encryptedInitiatorStatic, hR)178- ?: throw IllegalStateException("Noise XX KAT: msg3 static auth failed")179- hR = mixHash(hR, encryptedInitiatorStatic)180- val seR = sharedKey(responderEphemeral.secretKey, initiatorStaticPlain)181- val (ckR3, kR3) = hkdf2(ckR, seR)182- ckR = ckR3183- chachaOpen(kR3, nonceBytes(0L), encryptedFinalPayload, hR)184- ?: throw IllegalStateException("Noise XX KAT: msg3 final payload auth failed")185- hR = mixHash(hR, encryptedFinalPayload)186-187- check(hI.contentEquals(hR)) { "Noise XX KAT sides diverged" }188- check(ckI.contentEquals(ckR)) { "Noise XX KAT chaining keys diverged" }189- return hI190-}191-192-suspend fun noiseXXInitiator(193- ourStaticPrivateKey: ByteArray,194- ourStaticPublicKey: ByteArray,195- send: suspend (ByteArray) -> Unit,196- receive: suspend () -> ByteArray,197-): NoiseXXSession {198- var h = initHash()199- var ck = h.copyOf()200-201- val e = generateKeyPair()202- h = mixHash(h, e.publicKey)203- // PCH-02-002: canonical Noise still runs EncryptAndHash(empty) for msg1.204- // No bytes are added to the wire; the transcript hash moves in lockstep205- // with peach-browser.206- h = mixHash(h, ByteArray(0))207- send(e.publicKey)208-209- val msg2 = receive()210- require(msg2.size >= 32 + 32 + 16 + 16) { "Noise XX: msg2 too short" }211- val remoteEphemeral = msg2.copyOfRange(0, 32)212- val encryptedStatic = msg2.copyOfRange(32, 32 + 32 + 16)213- val encryptedPayload = msg2.copyOfRange(32 + 32 + 16, msg2.size)214-215- h = mixHash(h, remoteEphemeral)216- val ee = sharedKey(e.secretKey, remoteEphemeral)217- val (ck1, k1) = hkdf2(ck, ee)218- ck = ck1219-220- val remoteStaticPlain = chachaOpen(k1, nonceBytes(0L), encryptedStatic, h)221- ?: throw IllegalStateException("Noise XX: msg2 static auth failed")222- require(remoteStaticPlain.size == 32) { "Noise XX: msg2 static auth failed" }223- h = mixHash(h, encryptedStatic)224-225- val es = sharedKey(e.secretKey, remoteStaticPlain)226- val (ck2, k2) = hkdf2(ck, es)227- ck = ck2228-229- val payloadPlain = chachaOpen(k2, nonceBytes(0L), encryptedPayload, h)230- ?: throw IllegalStateException("Noise XX: msg2 payload auth failed")231- h = mixHash(h, encryptedPayload)232- @Suppress("UNUSED_VARIABLE")233- val _payloadPlain = payloadPlain234-235- val encryptedOurStatic = chachaSeal(k2, nonceBytes(1L), ourStaticPublicKey, h)236- h = mixHash(h, encryptedOurStatic)237-238- val se = sharedKey(ourStaticPrivateKey, remoteEphemeral)239- val (ck3, k3) = hkdf2(ck, se)240- ck = ck3241-242- val encryptedFinalPayload = chachaSeal(k3, nonceBytes(0L), ByteArray(0), h)243- h = mixHash(h, encryptedFinalPayload)244-245- val msg3 = ByteArray(encryptedOurStatic.size + encryptedFinalPayload.size)246- encryptedOurStatic.copyInto(msg3, 0)247- encryptedFinalPayload.copyInto(msg3, encryptedOurStatic.size)248- send(msg3)249-250- val (initSend, respSend) = hkdf2(ck, ByteArray(0))251- val sendCipher = initSend252- val recvCipher = respSend253- var sendNonce = 0L254- var recvNonce = 0L255-256- return object : NoiseXXSession {257- override fun encrypt(plaintext: ByteArray): ByteArray {258- return chachaSeal(sendCipher, nonceBytes(sendNonce++), plaintext, ByteArray(0))259- }260-261- override fun decrypt(ciphertext: ByteArray): ByteArray {262- return chachaOpen(recvCipher, nonceBytes(recvNonce++), ciphertext, ByteArray(0))263- ?: throw IllegalStateException("Noise XX: decryption failed")264- }265-266- override val remoteStaticPublicKey: ByteArray = remoteStaticPlain267- override val handshakeHash: ByteArray = h.copyOf()268- }269-}270-271-suspend fun noiseXXResponder(272- ourStaticPrivateKey: ByteArray,273- ourStaticPublicKey: ByteArray,274- send: suspend (ByteArray) -> Unit,275- receive: suspend () -> ByteArray,276-): NoiseXXSession {277- var h = initHash()278- var ck = h.copyOf()279-280- val msg1 = receive()281- require(msg1.size >= 32) { "Noise XX: msg1 too short" }282- val remoteEphemeral = msg1.copyOfRange(0, 32)283- h = mixHash(h, remoteEphemeral)284- // PCH-02-002: mirror the initiator's empty-payload MixHash for msg1.285- h = mixHash(h, ByteArray(0))286-287- val e = generateKeyPair()288- h = mixHash(h, e.publicKey)289- val ee = sharedKey(e.secretKey, remoteEphemeral)290- val (ck1, k1) = hkdf2(ck, ee)291- ck = ck1292-293- val encryptedOurStatic = chachaSeal(k1, nonceBytes(0L), ourStaticPublicKey, h)294- h = mixHash(h, encryptedOurStatic)295-296- val es = sharedKey(ourStaticPrivateKey, remoteEphemeral)297- val (ck2, k2) = hkdf2(ck, es)298- ck = ck2299-300- val encryptedPayload = chachaSeal(k2, nonceBytes(0L), ByteArray(0), h)301- h = mixHash(h, encryptedPayload)302-303- val msg2 = ByteArray(e.publicKey.size + encryptedOurStatic.size + encryptedPayload.size)304- e.publicKey.copyInto(msg2, 0)305- encryptedOurStatic.copyInto(msg2, e.publicKey.size)306- encryptedPayload.copyInto(msg2, e.publicKey.size + encryptedOurStatic.size)307- send(msg2)308-309- val msg3 = receive()310- require(msg3.size >= 32 + 16 + 16) { "Noise XX: msg3 too short" }311- val encryptedRemoteStatic = msg3.copyOfRange(0, 32 + 16)312- val encryptedFinalPayload = msg3.copyOfRange(32 + 16, msg3.size)313-314- val remoteStaticPlain = chachaOpen(k2, nonceBytes(1L), encryptedRemoteStatic, h)315- ?: throw IllegalStateException("Noise XX: msg3 static auth failed")316- require(remoteStaticPlain.size == 32) { "Noise XX: msg3 static auth failed" }317- h = mixHash(h, encryptedRemoteStatic)318-319- val se = sharedKey(e.secretKey, remoteStaticPlain)320- val (ck3, k3) = hkdf2(ck, se)321- ck = ck3322-323- val finalPayloadPlain = chachaOpen(k3, nonceBytes(0L), encryptedFinalPayload, h)324- ?: throw IllegalStateException("Noise XX: msg3 final payload auth failed")325- h = mixHash(h, encryptedFinalPayload)326- @Suppress("UNUSED_VARIABLE")327- val _finalPayloadPlain = finalPayloadPlain328-329- val (initSend, respSend) = hkdf2(ck, ByteArray(0))330- val sendCipher = respSend331- val recvCipher = initSend332- var sendNonce = 0L333- var recvNonce = 0L334-335- return object : NoiseXXSession {336- override fun encrypt(plaintext: ByteArray): ByteArray {337- return chachaSeal(sendCipher, nonceBytes(sendNonce++), plaintext, ByteArray(0))338- }339-340- override fun decrypt(ciphertext: ByteArray): ByteArray {341- return chachaOpen(recvCipher, nonceBytes(recvNonce++), ciphertext, ByteArray(0))342- ?: throw IllegalStateException("Noise XX: decryption failed")343- }344-345- override val remoteStaticPublicKey: ByteArray = remoteStaticPlain346- override val handshakeHash: ByteArray = h.copyOf()347- }348-}app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1
@@ -7,7 +7,7 @@77 /**88 * Chirp Sync wire protocol — exact port of the browser `ChirpSyncMessage`99 * union (shared/sync-types.ts). Each frame is JSON with a `type` discriminator,10- * sent over the Noise-XX-encrypted session. Entries travel as opaque base6410+ * sent over the Noise-NK-encrypted session. Entries travel as opaque base641111 * ciphertext envelopes; the coordinator never sees plaintext.1212 */1313 @Serializableapp/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 XX session is a second, independent encryption layer on top.29+ * Noise NK 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/settings/SettingsCardBodies.kt+1 −2
@@ -702,7 +702,7 @@702702 if (pairedDevices.isEmpty()) {703703 PeachEmptyOrEndState(704704 message = "No paired devices yet.",705- detail = "Pair a nearby phone, browser, or desktop helper when you're ready to sync.",705+ detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",706706 modifier = Modifier.padding(top = PeachSpacing.xs),707707 )708708 } else {@@ -812,7 +812,6 @@812812 "android" -> "Android"813813 "phone" -> "Phone"814814 "tablet" -> "Tablet"815- "desktop" -> "Desktop"816815 "browser" -> "Browser"817816 else -> "Device"818817 }app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+2 −48
@@ -13,16 +13,11 @@1313 import com.peachpasswords.sync.hexToPubkey1414 import com.peachpasswords.sync.PairingDiscoveryClient1515 import com.peachpasswords.sync.RelaySettingsStore16-import com.peachpasswords.sync.SyncEndpoint1716 import com.peachpasswords.sync.SyncAlertStore18-import com.peachpasswords.sync.SyncOutcomeStatus1917 import com.peachpasswords.sync.SyncStatus2018 import com.peachpasswords.sync.VaultSyncAdapter2119 import com.peachpasswords.sync.buildLocalIdentity22-import com.peachpasswords.sync.deriveSyncIdentityKeypair2320 import com.peachpasswords.sync.getOrCreateDeviceId24-import com.peachpasswords.sync.openChirpSyncSession25-import com.peachpasswords.sync.runChirpSyncInitiator2621 import com.peachpasswords.vault.VaultCrypto2722 import com.peachpasswords.vault.VaultRepository2823 import dagger.hilt.android.lifecycle.HiltViewModel@@ -44,9 +39,8 @@4439 * 3. Manual — the "Sync now" control on the Sync card.4540 * 4. mDNS peer discovery — a paired device appears on the LAN.4641 *47- * Discovery, the WebSocket server role, and the paired-device store land with48- * the pairing UI (Phase 10); [syncWith] already performs a real initiator round49- * against a known endpoint so those wirings have a finished target to call.42+ * Discovery, the WebSocket server role, and the paired-device store are wired43+ * through [PairingDiscoveryClient], the single live Noise NK dial path.5044 */5145 @HiltViewModel5246 class SyncViewModel @Inject constructor(@@ -181,44 +175,4 @@181175 */182176 fun triggerSync() = syncNow()183177 184- /** Run one initiator round against a known peer endpoint. */185- fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {186- val master = vault.syncMasterKey() ?: run {187- _status.value = SyncStatus.Error("vault_locked")188- return189- }190- val adapter = buildAdapter() ?: run {191- _status.value = SyncStatus.Error("vault_locked")192- master.fill(0)193- return194- }195- _status.value = SyncStatus.Syncing196- viewModelScope.launch {197- try {198- val deviceId = getOrCreateDeviceId(context)199- val identity = deriveSyncIdentityKeypair(master, deviceId)200- val session = openChirpSyncSession(201- endpoint = endpoint,202- ourLongTermPrivateKey = identity.secretKey,203- ourLongTermPublicKey = identity.publicKey,204- expectedRemotePubkeyHex = expectedRemotePubkeyHex,205- )206- val outcome = try {207- runChirpSyncInitiator(session, adapter)208- } finally {209- session.close()210- }211- _status.value = when (outcome.status) {212- SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())213- SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")214- SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")215- SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")216- }217- } catch (e: Exception) {218- _status.value = SyncStatus.Error(e.message ?: "sync_failed")219- } finally {220- VaultCrypto.zero(master)221- }222- }223- }224178 }app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+2 −23
@@ -2,11 +2,8 @@22 33 import com.peachpasswords.sync.SASDerivation44 import com.peachpasswords.sync.computeVaultId5-import com.peachpasswords.sync.noiseXXDeterministicHandshakeHashForTest65 import com.peachpasswords.sync.noiseNKHkdf2ForTest76 import com.peachpasswords.sync.noiseNKInitHashForTest8-import com.peachpasswords.sync.noiseXXHkdf2ForTest9-import com.peachpasswords.sync.noiseXXInitHashForTest107 import com.peachpasswords.vault.VaultCrypto118 import org.junit.Assert.assertArrayEquals129 import org.junit.Assert.assertEquals@@ -72,13 +69,6 @@7269 // pinned IDENTICALLY in peach-browser's noise-blake2s-kat.test.ts — a mismatch7370 // here means the two repos' Noise wire derivations have diverged (which would7471 // break cross-device pairing/sync). A SHA-256 revert on either side also fails.75- @Test fun noise_xx_init_hash_is_blake2s_vector() {76- assertEquals(77- "1ceedd81c5f458b225923dc2507787bf156f9251fc17c45af63263a929fc1ed2",78- hex(noiseXXInitHashForTest()),79- )80- }81-8272 @Test fun noise_nk_init_hash_is_blake2s_vector() {8373 assertEquals(8474 "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e",@@ -90,19 +80,8 @@9080 // ck = 0x00..0x1f, ikm = 0x20..0x3f.9181 val ck = ByteArray(32) { it.toByte() }9282 val ikm = ByteArray(32) { (it + 32).toByte() }93- val (xk1, xk2) = noiseXXHkdf2ForTest(ck, ikm)94- assertEquals("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0", hex(xk1))95- assertEquals("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5", hex(xk2))96- // hkdf2 is duplicated in NoiseXX.kt and NoiseNK.kt — they must agree.9783 val (nk1, nk2) = noiseNKHkdf2ForTest(ck, ikm)98- assertArrayEquals(xk1, nk1)99- assertArrayEquals(xk2, nk2)100- }101-102- @Test fun noise_xx_handshake_hash_includes_canonical_msg1_empty_payload_hash() {103- assertEquals(104- "505b6dcb59838e5d163f4fd4897cfff87c20ecca23aa993b416206aba25ee71c",105- hex(noiseXXDeterministicHandshakeHashForTest()),106- )84+ assertEquals("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0", hex(nk1))85+ assertEquals("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5", hex(nk2))10786 }10887 }app/src/test/java/com/peachpasswords/sync/ChirpSyncTransportTest.kt+0 −99
@@ -1,99 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.runBlocking4-import org.junit.Assert.assertFalse5-import org.junit.Assert.assertNull6-import org.junit.Assert.assertTrue7-import org.junit.Before8-import org.junit.Test9-10-class ChirpSyncTransportTest {11- private val peerPubkey = "ab".repeat(32)12-13- @Before14- fun setUp() {15- resetSubnetProbeCooldown()16- }17-18- @Test19- fun `subnetProbeOnCooldown returns false before any probe has happened`() {20- assertFalse(subnetProbeOnCooldown(peerPubkey))21- }22-23- @Test24- fun `subnetProbeOnCooldown returns true within the cooldown window after a probe`() = runBlocking {25- val now = 1_000_000L26- probeSubnetForPeer(27- subnet = "10.0.0",28- port = SyncReconnectListener.SYNC_PORT,29- ourLongTermPrivateKey = ByteArray(32),30- ourLongTermPublicKey = ByteArray(32),31- expectedRemotePubkeyHex = peerPubkey,32- batch = 254,33- timeoutMs = 1,34- now = now,35- )36- assertTrue(subnetProbeOnCooldown(peerPubkey, now + 1))37- assertTrue(subnetProbeOnCooldown(peerPubkey, now + SUBNET_PROBE_COOLDOWN_MS - 1))38- }39-40- @Test41- fun `subnetProbeOnCooldown releases after the window expires`() = runBlocking {42- val now = 1_000_000L43- probeSubnetForPeer(44- subnet = "10.0.0",45- port = SyncReconnectListener.SYNC_PORT,46- ourLongTermPrivateKey = ByteArray(32),47- ourLongTermPublicKey = ByteArray(32),48- expectedRemotePubkeyHex = peerPubkey,49- batch = 254,50- timeoutMs = 1,51- now = now,52- )53- assertFalse(subnetProbeOnCooldown(peerPubkey, now + SUBNET_PROBE_COOLDOWN_MS))54- }55-56- @Test57- fun `probeSubnetForPeer returns null and skips the scan when on cooldown`() = runBlocking {58- val now = 1_000_000L59- probeSubnetForPeer(60- subnet = "10.0.0",61- port = SyncReconnectListener.SYNC_PORT,62- ourLongTermPrivateKey = ByteArray(32),63- ourLongTermPublicKey = ByteArray(32),64- expectedRemotePubkeyHex = peerPubkey,65- batch = 254,66- timeoutMs = 1,67- now = now,68- )69- val result = probeSubnetForPeer(70- subnet = "10.0.0",71- port = SyncReconnectListener.SYNC_PORT,72- ourLongTermPrivateKey = ByteArray(32),73- ourLongTermPublicKey = ByteArray(32),74- expectedRemotePubkeyHex = peerPubkey,75- batch = 254,76- timeoutMs = 1,77- now = now + 1_000,78- )79- assertNull(result)80- }81-82- @Test83- fun `probeSubnetForPeer tracks cooldown independently per peer pubkey`() = runBlocking {84- val now = 1_000_000L85- val other = "cd".repeat(32)86- probeSubnetForPeer(87- subnet = "10.0.0",88- port = SyncReconnectListener.SYNC_PORT,89- ourLongTermPrivateKey = ByteArray(32),90- ourLongTermPublicKey = ByteArray(32),91- expectedRemotePubkeyHex = peerPubkey,92- batch = 254,93- timeoutMs = 1,94- now = now,95- )96- assertTrue(subnetProbeOnCooldown(peerPubkey, now + 1))97- assertFalse(subnetProbeOnCooldown(other, now + 1))98- }99-}app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+4 −4
@@ -43,9 +43,9 @@4343 4444 @Test fun `round-trips a freshly generated token`() {4545 val token = PairingCode.newToken()46- val parsed = PairingCode.parse(PairingCode.encode("10.0.0.7", 8765, token))!!46+ val parsed = PairingCode.parse(PairingCode.encode("10.0.0.7", 49142, token))!!4747 assertEquals("10.0.0.7", parsed.ip)48- assertEquals(8765, parsed.port)48+ assertEquals(49142, parsed.port)4949 assertEquals(token.joinToString("") { "%02x".format(it) }, parsed.tokenHex)5050 }5151 @@ -78,13 +78,13 @@7878 7979 @Test(expected = IllegalArgumentException::class)8080 fun `refuses a token shorter than the canonical size`() {81- PairingCode.encode("10.0.0.1", 8765, byteArrayOf(1, 2, 3))81+ PairingCode.encode("10.0.0.1", 49142, byteArrayOf(1, 2, 3))8282 }8383 8484 @Test(expected = IllegalArgumentException::class)8585 fun `refuses a token longer than the canonical size`() {8686 // The browser throws here too. A longer token encodes fine and quietly8787 // produces a six-group code, which is the regression to prevent.88- PairingCode.encode("10.0.0.1", 8765, ByteArray(8))88+ PairingCode.encode("10.0.0.1", 49142, ByteArray(8))8989 }9090 }