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
16 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 −4
app/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 {

view file

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 }

view file

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+)

view file

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-}

view file at the parent

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 

view file

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(

view file

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         }

view file

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()) },

view file

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-}

view file at the parent

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 @Serializable

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 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,

view file

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 }

view file

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 }

view file

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 }

view file

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-}

view file at the parent

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 }

view file