4cf6c7a7417d

feat: Queue vouched sync devices (v2.3.25)

- Add signed device-manifest canonical sign/verify helpers - Queue unknown vouched devices pending explicit Allow/Reject - Wire Sync card pending device actions into paired-device storage - Enable manifest exchange in the live sync adapter

time
2026-06-26 08:51 UTC · 4 months ago
machine
koh2
parent
6453b0dda43f
8 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 −0
app/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 {

view file

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' }

view file

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     }

view file

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+}

view file

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 =

view file

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     }

view file

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     /**

view file

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 }

view file