6ee5aa4e778d

fix: receiver applies device manifest during adoption (v2.9.19)

time
2026-07-31 15:54 UTC · 2 months ago
machine
koh2
parent
4abca173f83c
7 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+19 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+12 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+6 −2app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+21 −0app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+188 −2
VERSION+1 −1
@@ -1 +1 @@1-2.9.18\ No newline at end of file1+2.9.19

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24016135+        versionCode 2401623636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+19 −1
@@ -7,6 +7,7 @@77 import kotlinx.coroutines.withTimeout88 import kotlinx.coroutines.withTimeoutOrNull99 import org.json.JSONObject10+import com.peachpasswords.sync.hexToPubkey1011 1112 /**1213  * Receiver half of relay-first vault adoption.@@ -224,6 +225,12 @@224225             override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {225226                 receiverDeps.applySnapshot(entries, tombstones, duressSlot)226227             }228+            override suspend fun applyIncomingDeviceManifest(229+                manifest: SignedDeviceManifest,230+                authenticatedRemoteStaticPublicKey: ByteArray,231+            ) {232+                receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)233+            }227234             override suspend fun markSynced(deviceId: String) {228235                 receiverDeps.markSynced(deviceId)229236             }@@ -249,10 +256,21 @@249256                         entriesReceived = snapshot.entries.size,250257                         tombstonesReceived = snapshot.tombstones.size,251258                     ))252-                    // Optional device manifest from source259+                    // Optional device manifest from source. Apply before acking so260+                    // the receiver graduates into the mesh pool on the same261+                    // adoption session; a missing or invalid long-term key is262+                    // accepted (we still ack) because the graph can arrive via263+                    // the regular sync path on the next SYNC_DEVICE_MANIFEST.253264                     try {254265                         val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }255266                         if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {267+                            val sourceLongTerm = adopted.longTermPublicKeyHex268+                            if (sourceLongTerm != null && sourceLongTerm.length == 64) {269+                                boundDeps.applyIncomingDeviceManifest(270+                                    manifestFrame.manifest,271+                                    hexToPubkey(sourceLongTerm),272+                                )273+                            }256274                             sendFrame(channel, AdoptionFrame.DeviceManifestApplied)257275                         }258276                     } catch (_: Exception) {}

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+12 −0
@@ -197,6 +197,18 @@197197     fun onError(error: String)198198     /** Apply encrypted entries + tombstones from the inline snapshot. */199199     suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}200+    /**201+     * Apply the source's signed device graph so the receiver graduates into the202+     * mesh. The [authenticatedRemoteStaticPublicKey] is the source's Noise203+     * transport static key (NOT the signing key); [applyIncomingDeviceManifest]204+     * resolves it to the source's [PairedDevice] locally and uses the stored205+     * signing key to verify the manifest signature. Default no-op preserves206+     * the contract for callers that haven't wired the mesh path yet.207+     */208+    suspend fun applyIncomingDeviceManifest(209+        manifest: SignedDeviceManifest,210+        authenticatedRemoteStaticPublicKey: ByteArray,211+    ) {}200212     /** Mark the paired device as synced after a successful snapshot. */201213     suspend fun markSynced(deviceId: String) {}202214 }

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+6 −2
@@ -207,8 +207,12 @@207207                 )208208             }209209             for (entry in body.devices) {210-                if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue211-                if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) continue210+                if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) {211+                    continue212+                }213+                if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) {214+                    continue215+                }212216                 val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)213217                 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)214218                 if (existing == null) {

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+21 −0
@@ -13,6 +13,7 @@1313 import com.peachpasswords.sync.LocalDeviceIdentity1414 import com.peachpasswords.sync.PairedDevice1515 import com.peachpasswords.sync.PairedDeviceStore16+import com.peachpasswords.sync.PendingVouchedDeviceStore1617 import com.peachpasswords.sync.SignedDeviceManifest1718 import com.peachpasswords.sync.VaultSyncAdapter1819 import com.peachpasswords.sync.PeachSyncService@@ -70,6 +71,7 @@7071     private val relaySettings: RelaySettingsStore,7172     private val backgroundSync: BackgroundSyncPreference,7273     private val license: LicenseRepository,74+    private val pendingVouchedStore: PendingVouchedDeviceStore,7375     @ApplicationContext private val appContext: Context,7476 ) : ViewModel() {7577     /**@@ -331,6 +333,25 @@331333                         Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)332334                     }333335                 }336+            }337+338+            override suspend fun applyIncomingDeviceManifest(339+                manifest: SignedDeviceManifest,340+                authenticatedRemoteStaticPublicKey: ByteArray,341+            ) {342+                // Mesh graduation: the source's signed device graph promotes343+                // the receiver into the full mesh pool. The receiver must344+                // already have the source as a paired device (see applySnapshot345+                // above which calls upsertPairedDevice via the Grant path),346+                // so applyIncomingDeviceManifest can resolve the issuer by its347+                // transport static key and verify the manifest signature.348+                val adapter = VaultSyncAdapter(349+                    repo = vault,350+                    ourDeviceId = getOrCreateDeviceId(appContext),351+                    pairedDevices = devices,352+                    pendingVouched = pendingVouchedStore,353+                )354+                adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)334355             }335356 336357             override suspend fun markSynced(deviceId: String) {

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+188 −2
@@ -7,6 +7,7 @@77 import org.junit.Assert.assertFalse88 import org.junit.Assert.assertNotNull99 import org.junit.Assert.assertTrue10+import org.junit.Assert.assertNull1011 import org.junit.Test1112 import kotlin.system.measureTimeMillis1213 @@ -54,12 +55,12 @@5455             MemoryRelaySocket(leftToRight, rightToLeft)5556     }5657 57-    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(58+    private fun identity(name: String, publicKey: ByteArray, signingPublicKeyHex: String = "cd".repeat(32)) = LocalDeviceIdentity(5859         deviceId = "$name-id",5960         deviceName = name,6061         deviceType = DeviceType.ANDROID,6162         longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),62-        signingPublicKeyHex = "cd".repeat(32),63+        signingPublicKeyHex = signingPublicKeyHex,6364         wsPort = 49142,6465         relayUrl = "wss://relay.example.test",6566     )@@ -317,5 +318,190 @@317318         sourceJob.await()318319         assertFalse(receiverApplied)319320         assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)321+    }322+323+    @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {324+        // Mesh propagation: a 3rd device pairs with a pool of 2 already-paired325+        // devices. The third must graduate into the full mesh during the same326+        // adoption session, not just learn about the source. Verifies the327+        // adoption-receiver apply path wires applyIncomingDeviceManifest to328+        // the adapter and the adapter puts the vouched peers into the local329+        // registry.330+        val (sourceSocket, receiverSocket) = socketPair()331+        val token = "AAECAwQFBgcICQoLDA0ODw"332+        val relay = "wss://relay.example.test"333+        val root = ByteArray(32) { (it + 13).toByte() }334+        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")335+        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")336+        val sourceSigning = deriveSyncSigningKeypair(root, "source-id")337+        val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))338+        val receiverIdentity = identity("receiver", receiverKeys.publicKey)339+340+        // The source's pool already contains two direct-trust peers (B and C).341+        // The signed manifest is what the source transmits during adoption.342+        val manifest = buildSignedDeviceManifest(343+            issuerDeviceId = "source-id",344+            devices = listOf(345+                ManifestDeviceEntry(346+                    deviceId = "peer-b",347+                    deviceName = "Pool B",348+                    deviceType = "extension",349+                    longTermPublicKeyHex = "11".repeat(32),350+                    signingPublicKeyHex = "22".repeat(32),351+                    relayUrl = "wss://relay.example.test",352+                    pairedAt = 1L,353+                ),354+                ManifestDeviceEntry(355+                    deviceId = "peer-c",356+                    deviceName = "Pool C",357+                    deviceType = "android",358+                    longTermPublicKeyHex = "aa".repeat(32),359+                    signingPublicKeyHex = "bb".repeat(32),360+                    relayUrl = "wss://relay.example.test",361+                    pairedAt = 2L,362+                ),363+            ),364+            signingPrivateKey = sourceSigning.privateKey,365+        )366+367+        // Receiver's in-memory registry. Starts empty; the source gets added368+        // by the Grant path during adoption, and the two vouched peers get369+        // appended when the manifest is applied.370+        val receiverRegistry = mutableMapOf<String, PairedDevice>()371+        val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()372+        val receiverRegistryStore = object : PairedDeviceRegistry {373+            override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()374+            override fun get(id: String): PairedDevice? = receiverRegistry[id]375+            override fun getByPubkey(pubkeyHex: String): PairedDevice? =376+                receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }377+            override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }378+            override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }379+            override fun listRevocations(): List<DeviceRevocation> = emptyList()380+            override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {381+                batch.removals.forEach { receiverRegistry.remove(it) }382+                batch.upserts.forEach { receiverRegistry[it.deviceId] = it }383+                return true384+            }385+            override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()386+            override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false387+            override fun clearRevocation(deviceId: String) {}388+        }389+        val receiverPendingStore = object : PendingVouchedRegistry {390+            override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()391+            override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }392+            override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }393+            override fun removeBatch(ids: Set<String>): Boolean {394+                receiverPending.removeAll { it.deviceId in ids }395+                return true396+            }397+            override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true398+        }399+        var receiverAppliedManifest: SignedDeviceManifest? = null400+401+        val sourceJob = async {402+            runRelayAdoptionSourceSession(403+                sourceSocket,404+                token,405+                relay,406+                sourceKeys.secretKey,407+                sourceKeys.publicKey,408+                sourceIdentity,409+                object : AdoptionSourceDeps {410+                    override val sourceIdentity = sourceIdentity411+                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)412+                    override suspend fun vaultId() = computeVaultId(root)413+                    override suspend fun license() = "PPK-test"414+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true415+                    override suspend fun upsertPairedDevice(device: PairedDevice) {}416+                    override suspend fun vaultSnapshot() = smallSnapshot()417+                    override suspend fun duressSlotJson() = null418+                    override suspend fun outgoingDeviceManifest() = manifest419+                    override suspend fun markSynced(deviceId: String) {}420+                },421+                confirm = { _, _ -> true },422+            )423+        }424+425+        val received = runRelayAdoptionReceiverSession(426+            receiverSocket,427+            PairingInvitation(relay, token),428+            object : AdoptionReceiverDeps {429+                override val deviceName = "receiver"430+                override val deviceType = DeviceType.ANDROID431+                override fun generateDeviceId() = "session-only-request"432+                override suspend fun confirmSas() = true433+                override suspend fun createVaultFromRootKey(rootKeyHex: String) =434+                    computeVaultId(hexToPubkey(rootKeyHex))435+                override suspend fun deriveLocalIdentity() = receiverIdentity436+                override suspend fun upsertPairedDevice(device: PairedDevice) {437+                    receiverRegistry[device.deviceId] = device438+                }439+                override suspend fun applySnapshot(440+                    entries: Map<String, String>,441+                    tombstones: Map<String, Long>,442+                    duressSlot: String?,443+                ) {}444+                override suspend fun applyIncomingDeviceManifest(445+                    manifest: SignedDeviceManifest,446+                    authenticatedRemoteStaticPublicKey: ByteArray,447+                ) {448+                    receiverAppliedManifest = manifest449+                    val adapter = VaultSyncAdapter(450+                        repo = createReceiverVault(root),451+                        ourDeviceId = "receiver-id",452+                        pairedDevices = receiverRegistryStore,453+                        pendingVouched = receiverPendingStore,454+                    )455+                    adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)456+                }457+                override suspend fun markSynced(deviceId: String) {}458+                override fun onError(error: String) = throw AssertionError(error)459+            },460+            confirm = { true },461+        )462+463+        assertNotNull(received)464+        sourceJob.await()465+        assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)466+        // After the adoption + manifest apply, the receiver's registry must467+        // contain the source (added by Grant) AND the two vouched peers.468+        val deviceIds = receiverRegistry.keys.toSet()469+        assertEquals(470+            "receiver should see the source + both vouched peers",471+            setOf("source-id", "peer-b", "peer-c"),472+            deviceIds,473+        )474+        // The two vouched peers arrive with trustSource=vouched, not direct.475+        assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)476+        assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)477+        // The pending store is drained after a successful commit.478+        assertTrue("pending vouched should be cleared", receiverPending.isEmpty())479+    }480+481+    private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {482+        val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())483+        repo.createVault(root.joinToString("") { "%02x".format(it) })484+        return repo485+    }486+487+    private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {488+        private var slotA: com.peachpasswords.vault.StoredVault? = null489+        private var slotB: String? = null490+        private var decoy: com.peachpasswords.vault.StoredVault? = null491+        private var ver: Long = 0492+        private var settings = com.peachpasswords.vault.VaultSettings()493+        override fun loadSlotA() = slotA494+        override fun hasVault() = slotA != null495+        override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }496+        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }497+        override fun loadDecoySlot() = decoy498+        override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }499+        override fun hasDecoySlot() = decoy != null500+        override fun clearDecoySlot() { decoy = null }501+        override fun version() = ver502+        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }503+        override fun loadSettings() = settings504+        override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }505+        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }320506     }321507 }

view file