4cf6c7a7417d
feat: Queue vouched sync devices (v2.3.25)
- time
- 2026-06-26 08:51 UTC · 4 months ago
- machine
- koh2
- parent
- 6453b0dda43f
↓ peach-android-4cf6c7a7.tar.gzatlas at this save
files changed8 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+122 −0app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+4 −4app/src/main/java/com/peachpasswords/sync/PendingVouchedDeviceStore.kt+90 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+58 −1app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+21 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+34 −1app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+93 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23240020- versionName "2.3.24"19+ versionCode 23250020+ versionName "2.3.25"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+122 −0
@@ -0,0 +1,122 @@1+package com.peachpasswords.sync2+3+import kotlinx.serialization.builtins.serializer4+import kotlinx.serialization.json.Json5+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters6+7+const val DEVICE_MANIFEST_VERSION = 0x018+private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L9+10+interface PairedDeviceRegistry {11+ fun getAll(): List<PairedDevice>12+ fun get(id: String): PairedDevice?13+ fun upsert(device: PairedDevice)14+}15+16+fun buildSignedDeviceManifest(17+ issuerDeviceId: String,18+ devices: List<ManifestDeviceEntry>,19+ signingPrivateKey: Ed25519PrivateKeyParameters,20+ nowMs: Long = System.currentTimeMillis(),21+): SignedDeviceManifest {22+ require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }23+ val body = DeviceManifestBody(24+ manifestVersion = DEVICE_MANIFEST_VERSION,25+ issuerDeviceId = issuerDeviceId,26+ issuedAt = nowMs,27+ devices = devices.mapNotNull(::normaliseManifestEntry),28+ )29+ val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))30+ return SignedDeviceManifest(body, pubkeyToHex(signature))31+}32+33+fun verifyDeviceManifest(34+ manifest: SignedDeviceManifest,35+ issuerSigningPublicKey: ByteArray,36+ nowMs: Long = System.currentTimeMillis(),37+ freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,38+): DeviceManifestBody? {39+ val body = manifest.body40+ if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null41+ if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null42+ if (!isHex(manifest.signatureHex, 128)) return null43+ if (!body.devices.all { normaliseManifestEntry(it) != null }) return null44+ val signature = runCatching { hexToPubkey(manifest.signatureHex) }.getOrNull() ?: return null45+ val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)46+ if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null47+ return body48+}49+50+fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =51+ canonicalise(52+ mapOf(53+ "devices" to body.devices.map { manifestEntryMap(it) },54+ "issuedAt" to body.issuedAt,55+ "issuerDeviceId" to body.issuerDeviceId,56+ "manifestVersion" to body.manifestVersion,57+ ),58+ )59+60+fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {61+ val id = deviceId.takeIf { it.isNotBlank() } ?: return null62+ val name = deviceName?.takeIf { it.isNotBlank() } ?: return null63+ val type = deviceType?.takeIf { it in setOf("extension", "android", "ios", "desktop") } ?: return null64+ val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null65+ val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null66+ return ManifestDeviceEntry(67+ deviceId = id,68+ deviceName = name,69+ deviceType = type,70+ longTermPublicKeyHex = longTerm,71+ signingPublicKeyHex = signing,72+ wsPort = wsPort,73+ pairedAt = pairedAt,74+ )75+}76+77+private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {78+ if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null79+ if (entry.deviceType !in setOf("extension", "android", "ios", "desktop")) return null80+ if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null81+ if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null82+ return entry.copy(83+ longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),84+ signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),85+ )86+}87+88+private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {89+ val normalised = normaliseManifestEntry(entry) ?: entry90+ val map = linkedMapOf<String, Any>(91+ "deviceId" to normalised.deviceId,92+ "deviceName" to normalised.deviceName,93+ "deviceType" to normalised.deviceType,94+ "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),95+ "pairedAt" to normalised.pairedAt,96+ "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),97+ )98+ normalised.wsPort?.let { map["wsPort"] = it }99+ return map100+}101+102+private fun canonicalise(value: Any?): String = when (value) {103+ null -> "null"104+ is Number -> {105+ val d = value.toDouble()106+ require(d.isFinite()) { "non-finite number" }107+ if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()108+ }109+ is String -> Json.encodeToString(String.serializer(), value)110+ is Boolean -> if (value) "true" else "false"111+ is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }112+ is Map<*, *> -> value.entries113+ .map { (k, v) -> k.toString() to v }114+ .sortedBy { it.first }115+ .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->116+ Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)117+ }118+ else -> error("cannot canonicalise ${value::class.simpleName}")119+}120+121+private fun isHex(value: String, length: Int): Boolean =122+ value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+4 −4
@@ -37,7 +37,7 @@3737 @Singleton3838 class PairedDeviceStore @Inject constructor(3939 @ApplicationContext context: Context,40-) {40+) : PairedDeviceRegistry {4141 private val masterKey = MasterKey.Builder(context)4242 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)4343 .build()@@ -52,15 +52,15 @@5252 5353 private val listSerializer = ListSerializer(PairedDevice.serializer())5454 55- fun getAll(): List<PairedDevice> {55+ override fun getAll(): List<PairedDevice> {5656 val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()5757 return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())5858 }5959 60- fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }60+ override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6161 6262 /** Insert or replace by [PairedDevice.deviceId]. */63- fun upsert(device: PairedDevice) {63+ override fun upsert(device: PairedDevice) {6464 val next = getAll().filterNot { it.deviceId == device.deviceId } + device6565 persist(next)6666 }app/src/main/java/com/peachpasswords/sync/PendingVouchedDeviceStore.kt+90 −0
@@ -0,0 +1,90 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import androidx.security.crypto.EncryptedSharedPreferences5+import androidx.security.crypto.MasterKey6+import dagger.hilt.android.qualifiers.ApplicationContext7+import kotlinx.coroutines.channels.awaitClose8+import kotlinx.coroutines.flow.Flow9+import kotlinx.coroutines.flow.callbackFlow10+import kotlinx.serialization.Serializable11+import kotlinx.serialization.builtins.ListSerializer12+import kotlinx.serialization.json.Json13+import javax.inject.Inject14+import javax.inject.Singleton15+16+@Serializable17+data class PendingVouchedDevice(18+ val deviceId: String,19+ val deviceName: String,20+ val deviceType: String,21+ val longTermPublicKeyHex: String,22+ val signingPublicKeyHex: String,23+ val wsPort: Int? = null,24+ val pairedAt: Long,25+ val receivedAt: Long,26+ val vouchedByDeviceId: String,27+)28+29+interface PendingVouchedRegistry {30+ fun getAll(): List<PendingVouchedDevice>31+ fun upsert(device: PendingVouchedDevice)32+ fun remove(id: String)33+}34+35+@Singleton36+class PendingVouchedDeviceStore @Inject constructor(37+ @ApplicationContext context: Context,38+) : PendingVouchedRegistry {39+ private val masterKey = MasterKey.Builder(context)40+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)41+ .build()42+43+ private val prefs = EncryptedSharedPreferences.create(44+ context,45+ PREFS_FILE,46+ masterKey,47+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,48+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,49+ )50+51+ private val listSerializer = ListSerializer(PendingVouchedDevice.serializer())52+53+ override fun getAll(): List<PendingVouchedDevice> {54+ val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList()55+ return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())56+ }57+58+ fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id }59+60+ override fun upsert(device: PendingVouchedDevice) {61+ val next = (getAll().filterNot { it.deviceId == device.deviceId } + device).takeLast(MAX_PENDING)62+ persist(next)63+ }64+65+ override fun remove(id: String) {66+ persist(getAll().filterNot { it.deviceId == id })67+ }68+69+ fun observeAll(): Flow<List<PendingVouchedDevice>> = callbackFlow {70+ trySend(getAll())71+ val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->72+ if (key == KEY_PENDING || key == null) trySend(getAll())73+ }74+ prefs.registerOnSharedPreferenceChangeListener(listener)75+ awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }76+ }77+78+ private fun persist(devices: List<PendingVouchedDevice>) {79+ prefs.edit()80+ .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))81+ .apply()82+ }83+84+ private companion object {85+ const val PREFS_FILE = "peach_pending_vouched_devices"86+ const val KEY_PENDING = "pending_vouched_devices"87+ const val MAX_PENDING = 5088+ val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }89+ }90+}app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+58 −1
@@ -31,7 +31,9 @@3131 class VaultSyncAdapter(3232 private val repo: VaultRepository,3333 private val ourDeviceId: String,34- override val supportsDeviceManifest: Boolean = false,34+ private val pairedDevices: PairedDeviceRegistry? = null,35+ private val pendingVouched: PendingVouchedRegistry? = null,36+ override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,3537 ) : ChirpSyncVaultAdapter {3638 3739 override suspend fun computeVaultId(): String =@@ -81,6 +83,61 @@8183 .filterValues { it > 0L }8284 .map { (id, ts) -> Tombstone(id, ts) }8385 if (cleaned.isNotEmpty()) repo.applyMergedRemoteEntries(emptyList(), cleaned)86+ }87+88+ override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {89+ val registry = pairedDevices ?: return null90+ val master = requireMasterKey()91+ return try {92+ val signing = deriveSyncSigningKeypair(master, ourDeviceId)93+ val devices = registry.getAll().mapNotNull { it.toManifestEntry() }94+ buildSignedDeviceManifest(ourDeviceId, devices, signing.privateKey)95+ } finally {96+ master.fill(0)97+ }98+ }99+100+ override suspend fun applyIncomingDeviceManifest(manifest: SignedDeviceManifest) {101+ val registry = pairedDevices ?: return102+ val pending = pendingVouched ?: return103+ val issuerId = manifest.body.issuerDeviceId104+ val issuer = registry.get(issuerId) ?: return105+ if (issuer.deviceType != "extension") return106+ val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return107+ val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return108+ if (body.issuerDeviceId != issuer.deviceId) return109+110+ for (entry in body.devices) {111+ if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue112+ val existing = registry.get(entry.deviceId)113+ if (existing == null) {114+ pending.upsert(115+ PendingVouchedDevice(116+ deviceId = entry.deviceId,117+ deviceName = entry.deviceName,118+ deviceType = entry.deviceType,119+ longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),120+ signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),121+ wsPort = entry.wsPort,122+ pairedAt = entry.pairedAt,123+ receivedAt = System.currentTimeMillis(),124+ vouchedByDeviceId = issuer.deviceId,125+ ),126+ )127+ } else {128+ registry.upsert(129+ existing.copy(130+ deviceName = entry.deviceName,131+ deviceType = entry.deviceType,132+ longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),133+ signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),134+ wsPort = existing.wsPort ?: entry.wsPort,135+ pairedAt = existing.pairedAt,136+ trustSource = existing.trustSource ?: "direct",137+ ),138+ )139+ }140+ }84141 }85142 86143 private fun requireMasterKey(): ByteArray =app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+21 −0
@@ -573,6 +573,7 @@573573 private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {574574 val syncVm: SyncViewModel = hiltViewModel()575575 val pairedDevices by syncVm.pairedDevices.collectAsState()576+ val pendingVouched by syncVm.pendingVouched.collectAsState()576577 val syncStatus by syncVm.status.collectAsState()577578 val scope = rememberCoroutineScope()578579 var confirmUnpair by remember { mutableStateOf<String?>(null) }@@ -643,6 +644,26 @@643644 ValueText(syncFlashLabel(syncStatus), fg)644645 SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {645646 if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()647+ }648+ }649+ }650+651+ if (pendingVouched.isNotEmpty()) {652+ SubLabel("PENDING DEVICES", fg)653+ pendingVouched.forEach { dev ->654+ RowLine(fg) {655+ Column(Modifier.weight(1f)) {656+ Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)657+ Text(658+ "Was added by another of your devices. Allow it to sync with this vault?",659+ color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,660+ modifier = Modifier.padding(top = 2.dp),661+ )662+ }663+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {664+ SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }665+ SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }666+ }646667 }647668 }648669 }app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+34 −1
@@ -6,6 +6,8 @@66 import com.peachpasswords.sync.ChirpSyncVaultAdapter77 import com.peachpasswords.sync.PairedDevice88 import com.peachpasswords.sync.PairedDeviceStore9+import com.peachpasswords.sync.PendingVouchedDevice10+import com.peachpasswords.sync.PendingVouchedDeviceStore911 import com.peachpasswords.sync.PairingDiscoveryClient1012 import com.peachpasswords.sync.SyncEndpoint1113 import com.peachpasswords.sync.SyncOutcomeStatus@@ -46,6 +48,7 @@4648 @ApplicationContext private val context: Context,4749 private val vault: VaultRepository,4850 private val deviceStore: PairedDeviceStore,51+ private val pendingVouchedStore: PendingVouchedDeviceStore,4952 private val discoveryClient: PairingDiscoveryClient,5053 ) : ViewModel() {5154 @@ -57,9 +60,34 @@5760 deviceStore.observeAll()5861 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())5962 63+ val pendingVouched: StateFlow<List<PendingVouchedDevice>> =64+ pendingVouchedStore.observeAll()65+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())66+6067 /** Forget a paired device — it can no longer be dialled for sync. */6168 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }6269 70+ fun confirmVouchedDevice(id: String) = viewModelScope.launch {71+ val pending = pendingVouchedStore.get(id) ?: return@launch72+ deviceStore.upsert(73+ PairedDevice(74+ deviceId = pending.deviceId,75+ deviceName = pending.deviceName,76+ deviceType = pending.deviceType,77+ longTermPublicKeyHex = pending.longTermPublicKeyHex,78+ signingPublicKeyHex = pending.signingPublicKeyHex,79+ trustSource = "vouched",80+ wsPort = pending.wsPort,81+ pairedAt = pending.pairedAt,82+ ),83+ )84+ pendingVouchedStore.remove(id)85+ }86+87+ fun rejectVouchedDevice(id: String) = viewModelScope.launch {88+ pendingVouchedStore.remove(id)89+ }90+6391 /** Browser-style nuclear recovery path: re-key sync and forget every peer. */6492 suspend fun rotateSyncIdentity(): Boolean {6593 val ok = vault.rotateSyncIdentity()@@ -73,7 +101,12 @@73101 /** Adapter over the live vault; null while the vault is locked. */74102 fun buildAdapter(): ChirpSyncVaultAdapter? {75103 if (!vault.isUnlocked.value) return null76- return VaultSyncAdapter(vault, getOrCreateDeviceId(context))104+ return VaultSyncAdapter(105+ repo = vault,106+ ourDeviceId = getOrCreateDeviceId(context),107+ pairedDevices = deviceStore,108+ pendingVouched = pendingVouchedStore,109+ )77110 }78111 79112 /**app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+93 −0
@@ -33,6 +33,25 @@3333 }3434 3535 class VaultSyncAdapterTest {36+ private class FakeDeviceRegistry(37+ devices: List<PairedDevice> = emptyList(),38+ ) : PairedDeviceRegistry {39+ private val byId = devices.associateBy { it.deviceId }.toMutableMap()40+ override fun getAll(): List<PairedDevice> = byId.values.toList()41+ override fun get(id: String): PairedDevice? = byId[id]42+ override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }43+ }44+45+ private class FakePendingRegistry : PendingVouchedRegistry {46+ val devices = mutableListOf<PendingVouchedDevice>()47+ override fun getAll(): List<PendingVouchedDevice> = devices.toList()48+ override fun upsert(device: PendingVouchedDevice) {49+ devices.removeAll { it.deviceId == device.deviceId }50+ devices += device51+ }52+ override fun remove(id: String) { devices.removeAll { it.deviceId == id } }53+ }54+3655 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {3756 val repo = VaultRepository(FakeStorage())3857 runBlocking { repo.createVault("correct horse") }@@ -100,5 +119,79 @@100119 adapter.applyEntries(mapOf(entryId to legacyEnvelope))101120 // Patched adapter derives with the separated info → decrypt fails → dropped.102121 assertNull(repo.getEntry(entryId))122+ }123+124+ @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {125+ val master = ByteArray(32) { it.toByte() }126+ val signing = deriveSyncSigningKeypair(master, "extension-issuer")127+ val manifest = buildSignedDeviceManifest(128+ issuerDeviceId = "extension-issuer",129+ devices = listOf(130+ ManifestDeviceEntry(131+ deviceId = "android-peer",132+ deviceName = "Phone",133+ deviceType = "android",134+ longTermPublicKeyHex = "aa".repeat(32),135+ signingPublicKeyHex = "bb".repeat(32),136+ pairedAt = 123L,137+ ),138+ ),139+ signingPrivateKey = signing.privateKey,140+ nowMs = 1_000L,141+ )142+143+ val verified = verifyDeviceManifest(144+ manifest,145+ signing.publicKeyRaw,146+ nowMs = 1_000L,147+ )148+149+ assertEquals("extension-issuer", verified?.issuerDeviceId)150+ assertEquals("android-peer", verified?.devices?.single()?.deviceId)151+ assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)152+ }153+154+ @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {155+ val repo = VaultRepository(FakeStorage())156+ repo.createVault("correct horse")157+ val master = repo.syncMasterKey()!!158+ val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")159+ val paired = FakeDeviceRegistry(160+ listOf(161+ PairedDevice(162+ deviceId = "extension-issuer",163+ deviceName = "Peach Browser",164+ deviceType = "extension",165+ longTermPublicKeyHex = "11".repeat(32),166+ signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),167+ trustSource = "direct",168+ pairedAt = 1L,169+ ),170+ ),171+ )172+ val pending = FakePendingRegistry()173+ val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)174+ val manifest = buildSignedDeviceManifest(175+ issuerDeviceId = "extension-issuer",176+ devices = listOf(177+ ManifestDeviceEntry(178+ deviceId = "new-phone",179+ deviceName = "June's Phone",180+ deviceType = "android",181+ longTermPublicKeyHex = "aa".repeat(32),182+ signingPublicKeyHex = "bb".repeat(32),183+ pairedAt = 44L,184+ ),185+ ),186+ signingPrivateKey = issuerSigning.privateKey,187+ )188+189+ adapter.applyIncomingDeviceManifest(manifest)190+191+ assertNull(paired.get("new-phone"))192+ assertEquals(1, pending.devices.size)193+ assertEquals("new-phone", pending.devices.single().deviceId)194+ assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })195+ master.fill(0)103196 }104197 }