5d453c6020e8

verify the invitation pinned static before Noise in the phone-source direction and bump the invitation prefix to v2 (PB-26-103, v2.14.89)

time
2026-09-02 06:27 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
ec591698c3b3
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+9 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+15 −0app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+4 −4app/src/test/java/com/peachpasswords/sync/RelayAdoptionInitiatorSourceTest.kt+153 −1app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+200 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.881+2.14.89

view file

app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+9 −1
@@ -5,7 +5,7 @@55 import kotlinx.serialization.encodeToString66 import kotlinx.serialization.json.Json77 8-private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:"8+private const val PAIRING_INVITATION_PREFIX = "peach-pair-v2:"99 private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")1010 private val NoiseStaticPattern = Regex("^[0-9a-f]{64}$")1111 @@ -20,6 +20,14 @@2020  */2121 enum class VaultStatus { EMPTY, POPULATED }2222 23+/**24+ * PB-26-103: `static` is mandatory in practice — both adoption directions now25+ * refuse an invitation that pins no Noise static, in either direction of the26+ * transfer. That is a behaviour change under the v1 prefix rather than a format27+ * change, so the prefix moves to v2: a mismatched pair fails at the scan with an28+ * unrecognised-invitation error instead of mid-handshake with a pin error.29+ * Both clients must be updated together; there is no interop with v1.30+ */2331 data class PairingInvitation(2432     val relayUrl: String,2533     val token: String,

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+15 −0
@@ -324,7 +324,22 @@324324     require(ack.optString("type") == "PEACH_HELLO_ACK") {325325         "expected PEACH_HELLO_ACK"326326     }327+    // PB-26-103: authenticate the cleartext ACK against the static pinned in the328+    // invitation before running Noise. This is the root-key-bearing direction —329+    // the phone grants its vault here — so trusting the ACK lets a relay330+    // terminate both sessions, present its own key to each side, and collect331+    // ADOPT_GRANT.root_key_hex. The mirror check on the receiver path is at332+    // runRelayAdoptionReceiverSession.327333     val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))334+    val invitedStatic = invitation.noiseStaticPublicKeyHex335+        ?.let(::hexToPubkey)336+        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")337+    val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)338+    invitedStatic.fill(0)339+    if (!staticMatches) {340+        responderStatic.fill(0)341+        throw IllegalArgumentException("pairing_static_key_mismatch")342+    }328343     val noise = try {329344         noiseNKInitiator(330345             responderStatic,

view file

app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+4 −4
@@ -10,9 +10,9 @@1010     private val relay = "wss://relay.peachpasswords.com"1111     private val staticKey = "01".repeat(32)1212     private val kat =13-        "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoicG9wdWxhdGVkIn0"13+        "peach-pair-v2:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoicG9wdWxhdGVkIn0"1414     private val emptyKat =15-        "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoiZW1wdHkifQ"15+        "peach-pair-v2:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoiZW1wdHkifQ"1616 1717     @Test fun `matches browser invitation KAT with default populated vault`() {1818         assertEquals(kat, createPairingInvitation("$relay/", token))@@ -73,12 +73,12 @@7373 7474     @Test fun `rejects malformed insecure and short-token invitations`() {7575         assertNull(parsePairingInvitation("not-an-invitation"))76-        assertNull(parsePairingInvitation("peach-pair-v1:%%%"))76+        assertNull(parsePairingInvitation("peach-pair-v2:%%%"))7777         assertNull(parsePairingInvitation(raw("""{"relay":"ws://relay.test","token":"$token"}""")))7878         assertNull(parsePairingInvitation(raw("""{"relay":"$relay","token":"short"}""")))7979     }8080 8181     private fun raw(json: String): String =82-        "peach-pair-v1:" + java.util.Base64.getUrlEncoder().withoutPadding()82+        "peach-pair-v2:" + java.util.Base64.getUrlEncoder().withoutPadding()8383             .encodeToString(json.toByteArray())8484 }

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionInitiatorSourceTest.kt+153 −1
@@ -1,6 +1,7 @@11 package com.peachpasswords.sync22 33 import kotlinx.coroutines.async4+import kotlinx.coroutines.withTimeoutOrNull45 import kotlinx.coroutines.runBlocking56 import org.junit.Assert.assertEquals67 import org.junit.Assert.assertFalse@@ -19,6 +20,14 @@1920  */2021 class RelayAdoptionInitiatorSourceTest {2122 23+    /**24+     * Time-boxed so these adversarial tests fail rather than hang. Without the25+     * pin check the phone completes a handshake against the substituted key and26+     * then waits on a protocol step the attacker never performs, so a plain27+     * await() would never return.28+     */29+    private val HANDSHAKE_TIMEOUT_MS = 5_000L30+2231     private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(2332         deviceId = "$name-id",2433         deviceName = name,@@ -68,7 +77,12 @@6877         val phoneJob = async {6978             runRelayAdoptionInitiatorSourceSession(7079                 socket = phoneSocket,71-                invitation = PairingInvitation(relay, token, VaultStatus.EMPTY),80+                invitation = PairingInvitation(81+                    relay,82+                    token,83+                    VaultStatus.EMPTY,84+                    Rendezvous.bytesToHex(responderKeys.publicKey),85+                ),7286                 sourceDeps = object : AdoptionSourceDeps {7387                     override val sourceIdentity = phoneIdentity7488                     override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)@@ -149,6 +163,143 @@149163         assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)150164     }151165 166+    // PB-26-103: this is the root-key-bearing direction. The phone grants its167+    // vault to an empty browser, so if the relay can substitute the responder's168+    // static in the cleartext ACK the phone completes an NK handshake with the169+    // attacker and hands over the root key. The invitation pin is the only170+    // machine-checkable defence — the SAS on the source side is a human step171+    // the attack assumes is skipped.172+    /**173+     * The attacker's half of the adoption protocol once it has substituted its174+     * static: complete Noise as responder, ask for the vault, and read whatever175+     * the phone sends back. Fire-and-forget — with the pin check in place the176+     * phone aborts before the handshake and this never gets a peer.177+     */178+    private suspend fun attackerReceivesGrant(179+        socket: RelayBinarySocket,180+        keys: SyncIdentityKeypair,181+    ) {182+        val noise = noiseNKResponder(keys.secretKey, keys.publicKey, socket::send, socket::receive)183+        try {184+            val channel = object : PairingTransport {185+                override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))186+                override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())187+                override suspend fun close() = socket.close()188+            }189+            sendFrame(channel, AdoptionFrame.Request("attacker-session", "Empty Browser", "extension"))190+            receiveFrame(channel)191+        } finally {192+            noise.close()193+        }194+    }195+196+    @Test fun `phone source rejects a substituted ACK static`() = runBlocking {197+        val (phoneSocket, mitmSocket) = socketPair()198+        val token = "AAECAwQFBgcICQoLDA0ODw"199+        val root = ByteArray(32) { (it + 7).toByte() }200+        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")201+        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")202+        var rootRequested = false203+204+        // The result is captured inside the job: an exception escaping a child205+        // coroutine cancels the parent scope before await() can observe it.206+        val phoneJob = async {207+            try {208+                runRelayAdoptionInitiatorSourceSession(209+                    socket = phoneSocket,210+                    invitation = PairingInvitation(211+                        "wss://relay.example.test",212+                        token,213+                        VaultStatus.EMPTY,214+                        Rendezvous.bytesToHex(responderKeys.publicKey),215+                    ),216+                    sourceDeps = object : AdoptionSourceDeps {217+                        override val sourceIdentity =218+                            identity("phone", deriveLocalSyncKeypair(root, "phone-id").publicKey)219+                        override suspend fun rootKeyHex(): String {220+                            rootRequested = true221+                            return Rendezvous.bytesToHex(root)222+                        }223+                        override suspend fun vaultId() = computeVaultId(root)224+                        override suspend fun onConsent(request: AdoptionFrame.Request) = true225+                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit226+                    },227+                    confirm = { _, _ -> true },228+                )229+                "completed"230+            } catch (expected: IllegalArgumentException) {231+                expected.message232+            }233+        }234+235+        mitmSocket.receive() // PEACH_HELLO236+        // The relay terminates the session itself: a different static in the ACK.237+        mitmSocket.send(238+            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")239+                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))240+                .toString().toByteArray(),241+        )242+        // The attacker then plays the receiver through a complete handshake so243+        // the pre-fix code path runs to the grant instead of stalling.244+        val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }245+246+        // Pre-fix the phone completes the handshake against the attacker's key247+        // and stalls waiting on the protocol; either way it is not this error.248+        assertEquals(249+            "pairing_static_key_mismatch",250+            withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },251+        )252+        attackerJob.cancel()253+        assertFalse("root key must never be read against a substituted static", rootRequested)254+    }255+256+    @Test fun `phone source rejects an invitation that pins no static key`() = runBlocking {257+        val (phoneSocket, mitmSocket) = socketPair()258+        val root = ByteArray(32) { (it + 23).toByte() }259+        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")260+261+        val phoneJob = async {262+            try {263+                runRelayAdoptionInitiatorSourceSession(264+                    socket = phoneSocket,265+                    // Pre-fix invitations carried no static; accepting one here266+                    // is the same hole as accepting a mismatch.267+                    invitation = PairingInvitation(268+                        "wss://relay.example.test",269+                        "AAECAwQFBgcICQoLDA0ODw",270+                        VaultStatus.EMPTY,271+                    ),272+                    sourceDeps = object : AdoptionSourceDeps {273+                        override val sourceIdentity =274+                            identity("phone", deriveLocalSyncKeypair(root, "phone-id").publicKey)275+                        override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)276+                        override suspend fun vaultId() = computeVaultId(root)277+                        override suspend fun onConsent(request: AdoptionFrame.Request) = true278+                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit279+                    },280+                    confirm = { _, _ -> true },281+                )282+                "completed"283+            } catch (expected: IllegalArgumentException) {284+                expected.message285+            }286+        }287+288+        mitmSocket.receive() // PEACH_HELLO289+        mitmSocket.send(290+            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")291+                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))292+                .toString().toByteArray(),293+        )294+        val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }295+296+        assertEquals(297+            "pairing_invitation_missing_static_key",298+            withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },299+        )300+        attackerJob.cancel()301+    }302+152303     @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {153304         val (phoneSocket, responderSocket) = socketPair()154305         val token = "AAECAwQFBgcICQoLDA0ODw"@@ -165,6 +316,7 @@165316                     "wss://relay.example.test",166317                     token,167318                     VaultStatus.EMPTY,319+                    Rendezvous.bytesToHex(responderKeys.publicKey),168320                 ),169321                 sourceDeps = object : AdoptionSourceDeps {170322                     override val sourceIdentity = identity("phone", phoneKeys.publicKey)

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+200 −0
@@ -1,6 +1,7 @@11 package com.peachpasswords.sync22 33 import kotlinx.coroutines.async4+import kotlinx.coroutines.withTimeoutOrNull45 import kotlinx.coroutines.channels.Channel56 import kotlinx.coroutines.runBlocking67 import org.junit.Assert.assertEquals@@ -8,6 +9,7 @@89 import org.junit.Assert.assertNotNull910 import org.junit.Assert.assertTrue1011 import org.junit.Assert.assertNull12+import org.junit.Assert.fail1113 import org.junit.Test1214 import kotlin.system.measureTimeMillis1315 @@ -519,5 +521,203 @@519521         override fun loadSettings() = settings520522         override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }521523         override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }524+    }525+}526+527+/**528+ * PB-26-103: the invitation pins the responder's Noise static so the scanner529+ * can authenticate the cleartext PEACH_HELLO_ACK before running Noise. Without530+ * the pin a relay can terminate both sessions and present its own key to each531+ * side, which is only detectable today by a human comparing the SAS.532+ *533+ * These cover both failure shapes in the receiver direction, matching the534+ * phone-source direction tests in RelayAdoptionInitiatorSourceTest.535+ */536+class RelayAdoptionInvitationPinTest {537+538+    /**539+     * Time-boxed so these adversarial tests fail rather than hang: without the540+     * pin check the receiver completes a handshake against the substituted key541+     * and then stalls on a protocol step the attacker never performs.542+     */543+    private val HANDSHAKE_TIMEOUT_MS = 5_000L544+545+    private class MemoryRelaySocket(546+        private val inbound: Channel<ByteArray>,547+        private val outbound: Channel<ByteArray>,548+    ) : RelayBinarySocket {549+        override fun send(frame: ByteArray) {550+            check(outbound.trySend(frame.copyOf()).isSuccess)551+        }552+553+        override suspend fun receive(): ByteArray = inbound.receive()554+        override fun close(code: Int, reason: String) {555+            outbound.close()556+        }557+558+        override fun closeAfterQueuedFrames(code: Int, reason: String) {559+            outbound.close()560+        }561+    }562+563+    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {564+        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)565+        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)566+        return MemoryRelaySocket(rightToLeft, leftToRight) to567+            MemoryRelaySocket(leftToRight, rightToLeft)568+    }569+570+    /**571+     * Every protocol member throws: reaching any of them means we got past the572+     * pin check. `onError` only records, so the positive control below can end573+     * the session cleanly at the SAS gate.574+     */575+    private fun abortingDeps(576+        confirmSas: Boolean = false,577+        generateDeviceId: () -> String = { error("past the pin check") },578+    ) = object : AdoptionReceiverDeps {579+        override val deviceName = "phone"580+        override val deviceType = DeviceType.ANDROID581+        override fun generateDeviceId() = generateDeviceId()582+        override suspend fun confirmSas() = confirmSas583+        override suspend fun createVaultFromRootKey(rootKeyHex: String): String =584+            error("past the pin check")585+        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = error("past the pin check")586+        override suspend fun upsertPairedDevice(device: PairedDevice) = error("past the pin check")587+        override fun onError(error: String) = Unit588+    }589+590+    private fun ack(publicKey: ByteArray) = org.json.JSONObject()591+        .put("type", "PEACH_HELLO_ACK")592+        .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(publicKey))593+        .toString()594+        .toByteArray()595+596+    @Test fun `receiver rejects an ACK static that does not match the pinned key`() = runBlocking {597+        val (phoneSocket, mitmSocket) = socketPair()598+        val root = ByteArray(32) { (it + 3).toByte() }599+        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")600+        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")601+602+        // The result is captured inside the job: an exception escaping a child603+        // coroutine cancels the parent scope before await() can observe it.604+        val phoneJob = async {605+            try {606+                runRelayAdoptionReceiverSession(607+                    socket = phoneSocket,608+                    invitation = PairingInvitation(609+                        "wss://relay.example.test",610+                        "AAECAwQFBgcICQoLDA0ODw",611+                        VaultStatus.POPULATED,612+                        Rendezvous.bytesToHex(sourceKeys.publicKey),613+                    ),614+                    receiverDeps = abortingDeps(),615+                    confirm = { error("past the pin check") },616+                )617+                "completed"618+            } catch (expected: IllegalArgumentException) {619+                expected.message620+            }621+        }622+623+        mitmSocket.receive() // PEACH_HELLO624+        mitmSocket.send(ack(attackerKeys.publicKey))625+626+        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }627+        phoneJob.cancel()628+        assertEquals("pairing_static_key_mismatch", outcome)629+    }630+631+    @Test fun `receiver rejects an invitation that pins no static key`() = runBlocking {632+        val (phoneSocket, mitmSocket) = socketPair()633+        val root = ByteArray(32) { (it + 13).toByte() }634+        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")635+636+        val phoneJob = async {637+            try {638+                runRelayAdoptionReceiverSession(639+                    socket = phoneSocket,640+                    // A pre-fix invitation. Accepting it here reopens the hole.641+                    invitation = PairingInvitation(642+                        "wss://relay.example.test",643+                        "AAECAwQFBgcICQoLDA0ODw",644+                        VaultStatus.POPULATED,645+                    ),646+                    receiverDeps = abortingDeps(),647+                    confirm = { error("past the pin check") },648+                )649+                "completed"650+            } catch (expected: IllegalArgumentException) {651+                expected.message652+            }653+        }654+655+        mitmSocket.receive() // PEACH_HELLO656+        mitmSocket.send(ack(attackerKeys.publicKey))657+658+        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }659+        phoneJob.cancel()660+        assertEquals("pairing_invitation_missing_static_key", outcome)661+    }662+663+    // Positive control: the honest source sends exactly the pinned static, so664+    // the check must let the handshake through to the SAS gate.665+    @Test fun `receiver proceeds when the ACK static matches the pinned key`() = runBlocking {666+        val (phoneSocket, sourceSocket) = socketPair()667+        val root = ByteArray(32) { (it + 17).toByte() }668+        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")669+        val reached = java.util.concurrent.atomic.AtomicBoolean(false)670+671+        val phoneJob = async {672+            runRelayAdoptionReceiverSession(673+                socket = phoneSocket,674+                invitation = PairingInvitation(675+                    "wss://relay.example.test",676+                    "AAECAwQFBgcICQoLDA0ODw",677+                    VaultStatus.POPULATED,678+                    Rendezvous.bytesToHex(sourceKeys.publicKey),679+                ),680+                receiverDeps = abortingDeps(681+                    confirmSas = false,682+                    // requestAdoption mints the device id to build its frame, so683+                    // reaching this call proves the handshake ran.684+                    generateDeviceId = { reached.set(true); "session-only" },685+                ),686+                confirm = { true },687+            )688+        }689+690+        sourceSocket.receive() // PEACH_HELLO691+        sourceSocket.send(ack(sourceKeys.publicKey))692+        val noise = noiseNKResponder(693+            sourceKeys.secretKey,694+            sourceKeys.publicKey,695+            sourceSocket::send,696+            sourceSocket::receive,697+        )698+        try {699+            val channel = object : PairingTransport {700+                override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))701+                override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())702+                override suspend fun close() = sourceSocket.close()703+            }704+            // Read and discard the receiver's request: the handshake completing705+            // at all is what this control asserts.706+            receiveFrame(channel)707+        } finally {708+            noise.close()709+        }710+711+        // Declining at the SAS gate ends the session without pairing, which712+        // proves the pin check let the matching static through.713+        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }714+        // runBlocking waits for its children, and the receiver side may still715+        // be parked on the protocol.716+        phoneJob.cancel()717+        assertNull(outcome)718+        assertTrue(719+            "the matching static must reach the adoption request, not be refused",720+            reached.get(),721+        )522722     }523723 }

view file