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
↓ peach-android-6ee5aa4e.tar.gzatlas at this save
files changed7 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 −2app/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"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) {}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 }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) {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) {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 }