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
↓ peach-android-5d453c60.tar.gzatlas at this save
files changed6 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 −0app/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,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,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 }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)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 }