d0174f9b05d3

fix: bind sync manifests to authenticated peer identity (v2.3.69)

time
2026-07-26 02:13 UTC · 3 months ago
machine
koh2
agent
gpt-5
session
peach relay sync implementation
parent
43e6d3efc508
12 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+19 −18app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+5 −1app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+7 −2app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+5 −0app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+2 −1app/src/main/java/com/peachpasswords/sync/SyncIdentity.kt+6 −1app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+3 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+13 −4app/src/test/java/com/peachpasswords/sync/ChirpSyncCoordinatorTest.kt+37 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+65 −3
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23680020-        versionName "2.3.68"19+        versionCode 23690020+        versionName "2.3.69"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+19 −18
@@ -29,15 +29,6 @@2929 private const val SESSION_TIMEOUT_MS = 30_000L3030 private const val CLOCK_SKEW_WARN_MS = 120_000L3131 32-/** 128 hex zeroes — the placeholder a peer sends when it has no real manifest. */33-private const val EMPTY_MANIFEST_SIGNATURE = "00000000000000000000000000000000" +34-    "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"35-36-val EMPTY_MANIFEST_PLACEHOLDER = SignedDeviceManifest(37-    body = DeviceManifestBody(manifestVersion = 1, issuerDeviceId = "", issuedAt = 0, devices = emptyList()),38-    signatureHex = EMPTY_MANIFEST_SIGNATURE,39-)40-4132 /**4233  * Vault-side dependency of the coordinator. The vault holds the canonical4334  * entry/tombstone tables; the coordinator pulls a manifest, requests entries by@@ -68,8 +59,14 @@6859 6960     /** Optional device-manifest exchange (v2 brief §4.3). Null disables it. */7061     suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = null71-    suspend fun applyIncomingDeviceManifest(manifest: SignedDeviceManifest) {}62+    suspend fun applyIncomingDeviceManifest(63+        manifest: SignedDeviceManifest,64+        authenticatedRemoteStaticPublicKey: ByteArray,65+    ) {}7266 67+    /** Bind user-visible sync alerts to a stored, authenticated peer record. */68+    fun bindAuthenticatedPeer(peer: PairedDevice) {}69+7370     /** Both ends must opt in for the device-manifest step to run. */7471     val supportsDeviceManifest: Boolean get() = false7572 }@@ -148,11 +145,13 @@148145             tombstonesReceived = peerTombstones.tombstones.size149146 150147             if (vault.supportsDeviceManifest) {151-                val ours = vault.buildOutgoingDeviceManifest() ?: EMPTY_MANIFEST_PLACEHOLDER152-                session.sendMsg(SyncMessage.DeviceManifest(ours))148+                session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))153149                 val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")154-                if (theirs.manifest.signatureHex != EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {155-                    vault.applyIncomingDeviceManifest(theirs.manifest)150+                if (theirs.manifest != null) {151+                    vault.applyIncomingDeviceManifest(152+                        theirs.manifest,153+                        session.remoteStaticPublicKey,154+                    )156155                 }157156             }158157 @@ -241,10 +240,12 @@241240 242241             if (vault.supportsDeviceManifest) {243242                 val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")244-                val ours = vault.buildOutgoingDeviceManifest() ?: EMPTY_MANIFEST_PLACEHOLDER245-                session.sendMsg(SyncMessage.DeviceManifest(ours))246-                if (theirs.manifest.signatureHex != EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {247-                    vault.applyIncomingDeviceManifest(theirs.manifest)243+                session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))244+                if (theirs.manifest != null) {245+                    vault.applyIncomingDeviceManifest(246+                        theirs.manifest,247+                        session.remoteStaticPublicKey,248+                    )248249                 }249250             }250251 

view file

app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+5 −1
@@ -10,6 +10,10 @@1010 interface PairedDeviceRegistry {1111     fun getAll(): List<PairedDevice>1212     fun get(id: String): PairedDevice?13+    fun getByPubkey(pubkeyHex: String): PairedDevice? =14+        getAll().firstOrNull {15+            it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true16+        }1317     fun upsert(device: PairedDevice)1418 }1519 @@ -41,7 +45,7 @@4145     if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null4246     if (!isHex(manifest.signatureHex, 128)) return null4347     if (!body.devices.all { normaliseManifestEntry(it) != null }) return null44-    val signature = runCatching { hexToPubkey(manifest.signatureHex) }.getOrNull() ?: return null48+    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null4549     val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)4650     if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null4751     return body

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+7 −2
@@ -144,6 +144,7 @@144144             // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.145145             val vault = vaultFactory()146146             if (vault != null) {147+                vault.bindAuthenticatedPeer(device)147148                 val syncSession = chirpSessionOver(session, transport)148149                 val outcome = runChirpSyncResponder(syncSession, vault)149150                 if (outcome.status == SyncOutcomeStatus.COMPLETED) {@@ -269,9 +270,13 @@269270  * the encrypted vault exchange. Send/receive splice the Noise cipher onto the270271  * length-prefixed frame layer; `remoteStaticPublicKey` is informational here.271272  */272-internal fun chirpSessionOver(session: NoiseNKSession, transport: SocketFrameIO): ChirpSyncSession =273+internal fun chirpSessionOver(274+    session: NoiseNKSession,275+    transport: SocketFrameIO,276+    remoteStaticPublicKey: ByteArray = ByteArray(0),277+): ChirpSyncSession =273278     ChirpSyncSession(274-        remoteStaticPublicKey = ByteArray(0),279+        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),275280         // ChirpSyncSession.send is non-suspending (matches openChirpSyncSession);276281         // SocketFrameIO.sendBlocking writes a frame synchronously.277282         send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+5 −0
@@ -59,6 +59,11 @@5959 6060     override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6161 62+    override fun getByPubkey(pubkeyHex: String): PairedDevice? =63+        getAll().firstOrNull {64+            it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true65+        }66+6267     /** Insert or replace by [PairedDevice.deviceId]. */6368     override fun upsert(device: PairedDevice) {6469         val next = getAll().filterNot { it.deviceId == device.deviceId } + device

view file

app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+2 −1
@@ -131,7 +131,8 @@131131             // Encrypted vault sync — real initiator round.132132             val vault = vaultFactory()133133             if (vault != null) {134-                val syncSession = chirpSessionOver(session, transport)134+                vault.bindAuthenticatedPeer(device)135+                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)135136                 val outcome = runChirpSyncInitiator(syncSession, vault)136137                 if (outcome.status == SyncOutcomeStatus.COMPLETED) {137138                     deviceStore.updateLastSync(device.deviceId)

view file

app/src/main/java/com/peachpasswords/sync/SyncIdentity.kt+6 −1
@@ -70,7 +70,7 @@7070 7171 fun pubkeyToHex(pk: ByteArray): String = pk.joinToString("") { "%02x".format(it) }7272 73-fun hexToPubkey(hex: String): ByteArray {73+fun hexToBytes(hex: String): ByteArray {7474     require(hex.length % 2 == 0) { "hex length must be even" }7575     val out = ByteArray(hex.length / 2)7676     for (i in out.indices) {@@ -78,6 +78,11 @@7878         out[i] = byte.toByte()7979     }8080     return out81+}82+83+fun hexToPubkey(hex: String): ByteArray {84+    require(hex.length == 64) { "public key must be exactly 32 bytes" }85+    return hexToBytes(hex)8186 }8287 8388 private fun hkdf(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {

view file

app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1
@@ -42,7 +42,7 @@4242 4343     @Serializable4444     @SerialName("SYNC_DEVICE_MANIFEST")45-    data class DeviceManifest(val manifest: SignedDeviceManifest) : SyncMessage()45+    data class DeviceManifest(val manifest: SignedDeviceManifest?) : SyncMessage()4646 4747     @Serializable4848     @SerialName("SYNC_DONE")

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+3 −1
@@ -132,7 +132,7 @@132132                 ws.send(session.encrypt(plaintext))133133             }134134             try {135-                dispatch(link, session, ws, vaultFactory)135+                dispatch(link, session, ws, known, vaultFactory)136136             } finally {137137                 relayHub.unregister(link)138138             }@@ -154,6 +154,7 @@154154         link: RelayLink,155155         session: NoiseNKSession,156156         ws: WebSocketConnection,157+        authenticatedPeer: PairedDevice,157158         vaultFactory: () -> ChirpSyncVaultAdapter?,158159     ) {159160         while (true) {@@ -166,6 +167,7 @@166167                     if (!relayHub.beginSync(link)) return167168                     try {168169                         val vault = vaultFactory() ?: return170+                        vault.bindAuthenticatedPeer(authenticatedPeer)169171                         runChirpSyncResponder(webSocketSyncSession(session, ws, pushback = frame), vault)170172                     } finally {171173                         relayHub.endSync(link)

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+13 −4
@@ -34,7 +34,7 @@3434     private val pairedDevices: PairedDeviceRegistry? = null,3535     private val pendingVouched: PendingVouchedRegistry? = null,3636     private val syncAlerts: SyncAlertRecorder? = null,37-    private val remoteDeviceName: String = "paired device",37+    private var remoteDeviceName: String = "paired device",3838     override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,3939 ) : ChirpSyncVaultAdapter {4040 @@ -113,6 +113,12 @@113113         }114114     }115115 116+    override fun bindAuthenticatedPeer(peer: PairedDevice) {117+        remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }118+            ?: peer.deviceId.takeIf { it.isNotBlank() }119+            ?: "paired device"120+    }121+116122     override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {117123         val registry = pairedDevices ?: return null118124         val master = requireMasterKey()@@ -125,11 +131,14 @@125131         }126132     }127133 128-    override suspend fun applyIncomingDeviceManifest(manifest: SignedDeviceManifest) {134+    override suspend fun applyIncomingDeviceManifest(135+        manifest: SignedDeviceManifest,136+        authenticatedRemoteStaticPublicKey: ByteArray,137+    ) {129138         val registry = pairedDevices ?: return130139         val pending = pendingVouched ?: return131-        val issuerId = manifest.body.issuerDeviceId132-        val issuer = registry.get(issuerId) ?: return140+        if (authenticatedRemoteStaticPublicKey.size != 32) return141+        val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return133142         // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):134143         // trust a manifest only from a DIRECTLY paired issuer. This previously135144         // keyed on deviceType == "extension" instead, which got both halves

view file

app/src/test/java/com/peachpasswords/sync/ChirpSyncCoordinatorTest.kt+37 −0
@@ -3,8 +3,10 @@33 import kotlinx.coroutines.async44 import kotlinx.coroutines.channels.Channel55 import kotlinx.coroutines.runBlocking6+import org.junit.Assert.assertArrayEquals67 import org.junit.Assert.assertEquals78 import org.junit.Assert.assertFalse9+import org.junit.Assert.assertNull810 import org.junit.Assert.assertTrue911 import org.junit.Test1012 @@ -26,10 +28,14 @@2628     val deviceId: String,2729     var version: Long,2830     val vaultId: String = "VAULT",31+    override val supportsDeviceManifest: Boolean = false,2932 ) : ChirpSyncVaultAdapter {3033     val entries = HashMap<String, Long>()3134     val blobs = HashMap<String, String>()3235     val tombstones = HashMap<String, Long>()36+    var outgoingDeviceManifest: SignedDeviceManifest? = null37+    var incomingDeviceManifest: SignedDeviceManifest? = null38+    var incomingAuthenticatedKey: ByteArray? = null3339 3440     fun put(id: String, mtime: Long) { entries[id] = mtime; blobs[id] = mtime.toString() }3541 @@ -46,6 +52,14 @@4652     override suspend fun applyTombstones(tombstones: Map<String, Long>) {4753         for ((id, ts) in tombstones) this.tombstones[id] = ts4854     }55+    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = outgoingDeviceManifest56+    override suspend fun applyIncomingDeviceManifest(57+        manifest: SignedDeviceManifest,58+        authenticatedRemoteStaticPublicKey: ByteArray,59+    ) {60+        incomingDeviceManifest = manifest61+        incomingAuthenticatedKey = authenticatedRemoteStaticPublicKey.copyOf()62+    }4963 }5064 5165 class ChirpSyncCoordinatorTest {@@ -107,5 +121,28 @@107121         ra.await(); rb.await()108122 109123         assertEquals(500L, b.tombstones["gone"])124+    }125+126+    @Test fun absent_device_manifest_is_explicit_null_and_peer_key_is_bound() = runBlocking {127+        val (sa, sb) = duplex()128+        val manifest = SignedDeviceManifest(129+            body = DeviceManifestBody(1, "A", 1_000L, emptyList()),130+            signatureHex = "ab".repeat(64),131+        )132+        val a = FakeAdapter("A", version = 1, supportsDeviceManifest = true).apply {133+            outgoingDeviceManifest = manifest134+        }135+        val b = FakeAdapter("B", version = 2, supportsDeviceManifest = true)136+137+        val ra = async { runChirpSyncInitiator(sa, a, nowMs = 1_000L) }138+        val rb = async { runChirpSyncResponder(sb, b, nowMs = 1_000L) }139+        assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)140+        assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)141+142+        assertNull(a.incomingDeviceManifest)143+        assertEquals(manifest, b.incomingDeviceManifest)144+        assertArrayEquals(ByteArray(32), b.incomingAuthenticatedKey!!)145+        val wire = decodeSyncMessage(SyncMessage.DeviceManifest(null).encodeToBytes())146+        assertEquals(SyncMessage.DeviceManifest(null), wire)110147     }111148 }

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+65 −3
@@ -186,7 +186,7 @@186186             signingPrivateKey = issuerSigning.privateKey,187187         )188188 189-        adapter.applyIncomingDeviceManifest(manifest)189+        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })190190 191191         assertNull(paired.get("new-phone"))192192         assertEquals(1, pending.devices.size)@@ -236,7 +236,7 @@236236             signingPrivateKey = issuerSigning.privateKey,237237         )238238 239-        adapter.applyIncomingDeviceManifest(manifest)239+        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })240240 241241         assertEquals(1, pending.devices.size)242242         assertEquals("laptop-b", pending.devices.single().deviceId)@@ -283,9 +283,71 @@283283             signingPrivateKey = issuerSigning.privateKey,284284         )285285 286-        adapter.applyIncomingDeviceManifest(manifest)286+        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })287287 288288         assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)289289         master.fill(0)290+    }291+292+    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {293+        val repo = VaultRepository(FakeStorage())294+        repo.createVault("correct horse")295+        val master = repo.syncMasterKey()!!296+        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")297+        val paired = FakeDeviceRegistry(298+            listOf(299+                PairedDevice(300+                    deviceId = "claimed-issuer",301+                    deviceName = "Claimed Browser",302+                    deviceType = "extension",303+                    longTermPublicKeyHex = "11".repeat(32),304+                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),305+                    trustSource = "direct",306+                    pairedAt = 1L,307+                ),308+                PairedDevice(309+                    deviceId = "authenticated-peer",310+                    deviceName = "Authenticated Phone",311+                    deviceType = "android",312+                    longTermPublicKeyHex = "22".repeat(32),313+                    signingPublicKeyHex = "33".repeat(32),314+                    trustSource = "direct",315+                    pairedAt = 2L,316+                ),317+            ),318+        )319+        val pending = FakePendingRegistry()320+        val adapter = VaultSyncAdapter(321+            repo,322+            ourDeviceId = "android-self",323+            pairedDevices = paired,324+            pendingVouched = pending,325+        )326+        val manifest = buildSignedDeviceManifest(327+            issuerDeviceId = "claimed-issuer",328+            devices = listOf(329+                ManifestDeviceEntry(330+                    deviceId = "smuggled-device",331+                    deviceName = "Smuggled",332+                    deviceType = "extension",333+                    longTermPublicKeyHex = "aa".repeat(32),334+                    signingPublicKeyHex = "bb".repeat(32),335+                    pairedAt = 44L,336+                ),337+            ),338+            signingPrivateKey = claimedSigning.privateKey,339+        )340+341+        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })342+343+        assertEquals(0, pending.devices.size)344+        master.fill(0)345+    }346+347+    @Test fun `public key decoder requires exactly 32 bytes`() {348+        assertEquals(32, hexToPubkey("ab".repeat(32)).size)349+        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)350+        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)351+        assertEquals(64, hexToBytes("ab".repeat(64)).size)290352     }291353 }

view file