package com.peachpasswords.sync import com.peachpasswords.vault.StoredVault import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.VaultSettings import com.peachpasswords.vault.VaultStorage import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test private class FakeStorage : VaultStorage { private var slotA: StoredVault? = null private var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } private var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } class VaultSyncAdapterTest { private val meshRelay = "wss://relay.peachpasswords.com" private class FakeDeviceRegistry( devices: List = emptyList(), ) : PairedDeviceRegistry { var failManifestCommit = false private val byId = devices.associateBy { it.deviceId }.toMutableMap() private val revocations = mutableListOf() override fun getAll(): List = byId.values.toList() override fun get(id: String): PairedDevice? = byId[id] override fun upsert(device: PairedDevice) { byId[device.deviceId] = device } override fun remove(deviceId: String) { byId.remove(deviceId) } override fun listRevocations(): List = revocations.toList() override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean { if (failManifestCommit) return false val nextRevocations = mergeRevocationRecords(revocations, batch.incomingRevocations).records.toMutableList() val nextDevices = byId.toMutableMap() batch.removals.forEach(nextDevices::remove) batch.upserts.forEach { nextDevices[it.deviceId] = it } revocations.clear() revocations += nextRevocations byId.clear() byId.putAll(nextDevices) return true } override fun mergeRevocations(incoming: List): List { val merged = mergeRevocationRecords(revocations, incoming) revocations.clear() revocations += merged.records return merged.admitted.map { it.deviceId } } override fun clearRevocation(deviceId: String) { revocations.removeAll { it.deviceId == deviceId } } } private class FakePendingRegistry : PendingVouchedRegistry { val devices = mutableListOf() override fun getAll(): List = devices.toList() override fun upsert(device: PendingVouchedDevice) { devices.removeAll { it.deviceId == device.deviceId } devices += device } override fun remove(id: String) { devices.removeAll { it.deviceId == id } } } private fun localCredentials(deviceId: String) = randomLocalIdentityCredentials(deviceId) private fun newAdapter(): Pair { val repo = VaultRepository(FakeStorage()) runBlocking { repo.createVault("correct horse") } return repo to VaultSyncAdapter( repo, ourDeviceId = "android-test", localCredentials = localCredentials("android-test"), ) } @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking { val (repo, adapter) = newAdapter() val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p")) val manifest = adapter.buildEntryManifest() assertEquals(setOf(e.id), manifest.keys) assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex } @Test fun envelope_round_trips_within_the_vault() = runBlocking { val (repo, adapter) = newAdapter() val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret")) val envelopes = adapter.getEncryptedEntries(listOf(e.id)) assertTrue(envelopes.containsKey(e.id)) // Re-applying the same envelope is a safe no-op (same mtime, no overwrite). adapter.applyEntries(envelopes) assertEquals(1, repo.getEntries().size) } @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking { val (repo, adapter) = newAdapter() val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x")) val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id) // Present the ciphertext under a different id — the decrypted entry.id // won't match, so it must be dropped, not poison a new slot. adapter.applyEntries(mapOf("attacker-chosen-id" to blob)) assertNull(repo.getEntry("attacker-chosen-id")) assertEquals(1, repo.getEntries().size) } // HKDF separator (coordinated with peach-browser): an envelope minted under // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This // locks in the conscious wire break and asserts byte-for-byte parity with the // browser's matching `legacy_info_envelope_fails_closed` test. @Test fun legacy_info_envelope_fails_closed() = runBlocking { val (repo, adapter) = newAdapter() val master = repo.syncMasterKey()!! val entryId = "11111111-2222-4333-8444-555555555555" val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw") // Legacy info: no U+0000 separator. val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8) val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest()) gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo)) val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) } val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true } val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8) val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) } val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding") cipher.init( javax.crypto.Cipher.ENCRYPT_MODE, javax.crypto.spec.SecretKeySpec(key, "AES"), javax.crypto.spec.GCMParameterSpec(128, nonce), ) cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8)) val ct = cipher.doFinal(plaintext) val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct) adapter.applyEntries(mapOf(entryId to legacyEnvelope)) // Patched adapter derives with the separated info → decrypt fails → dropped. assertNull(repo.getEntry(entryId)) } @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking { val master = ByteArray(32) { it.toByte() } val signing = deriveSyncSigningKeypair(master, "extension-issuer") val manifest = buildSignedDeviceManifest( issuerDeviceId = "extension-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "android-peer", deviceName = "Phone", deviceType = "android", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), pairedAt = 123L, ), ), signingPrivateKey = signing.privateKey, nowMs = 1_000L, ) val verified = verifyDeviceManifest( manifest, signing.publicKeyRaw, { setOf(pubkeyToHex(signing.publicKeyRaw)) }, nowMs = 1_000L, ) assertEquals("extension-issuer", verified?.issuerDeviceId) assertEquals("android-peer", verified?.devices?.single()?.deviceId) assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, { setOf(pubkeyToHex(signing.publicKeyRaw)) }, nowMs = 1_000L) == null) } @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "extension-issuer", deviceName = "Peach Browser", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "vouched", pairedAt = 1L, ), PairedDevice( deviceId = "stolen-laptop", deviceName = "Old Laptop", deviceType = "extension", longTermPublicKeyHex = "dd".repeat(32), signingPublicKeyHex = "ee".repeat(32), trustSource = "direct", pairedAt = 2L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) val revocation = buildSignedDeviceManifest( issuerDeviceId = "extension-issuer", devices = emptyList(), signingPrivateKey = issuerSigning.privateKey, revoked = listOf( DeviceRevocation( deviceId = "stolen-laptop", longTermPublicKeyHex = "dd".repeat(32), revokedAt = System.currentTimeMillis(), revokedByDeviceId = "extension-issuer", ), ), ) adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 }) assertNull(paired.get("stolen-laptop")) // A later manifest vouches it back with a fresh pairedAt — exactly the // resurrection a timestamp-ordered design would allow. val revouch = buildSignedDeviceManifest( issuerDeviceId = "extension-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "stolen-laptop", deviceName = "Old Laptop", deviceType = "extension", longTermPublicKeyHex = "dd".repeat(32), signingPublicKeyHex = "ee".repeat(32), pairedAt = System.currentTimeMillis() + 60_000L, ), ), signingPrivateKey = issuerSigning.privateKey, ) adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 }) assertNull(paired.get("stolen-laptop")) assertEquals(0, pending.devices.size) master.fill(0) } @Test fun `a peer cannot revoke this device`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "extension-issuer", deviceName = "Peach Browser", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) val selfRevoke = buildSignedDeviceManifest( issuerDeviceId = "extension-issuer", devices = emptyList(), signingPrivateKey = issuerSigning.privateKey, revoked = listOf( DeviceRevocation( deviceId = "android-self", longTermPublicKeyHex = "ff".repeat(32), revokedAt = System.currentTimeMillis(), revokedByDeviceId = "extension-issuer", ), ), ) adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 }) assertTrue(paired.listRevocations().none { it.deviceId == "android-self" }) master.fill(0) } @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "extension-issuer", deviceName = "Peach Browser", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) val manifest = buildSignedDeviceManifest( issuerDeviceId = "extension-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "new-phone", deviceName = "June's Phone", deviceType = "android", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = meshRelay, pairedAt = 44L, ), ), signingPrivateKey = issuerSigning.privateKey, ) adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 }) val admitted = paired.get("new-phone") assertEquals("vouched", admitted?.trustSource) assertEquals(meshRelay, admitted?.relayUrl) assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true) assertTrue(pending.devices.isEmpty()) master.fill(0) } @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "extension-issuer", deviceName = "Peach Browser", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), ), ).also { it.failManifestCommit = true } val pending = FakePendingRegistry().also { it.upsert( PendingVouchedDevice( deviceId = "new-phone", deviceName = "June's Phone", deviceType = "android", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), pairedAt = 44L, receivedAt = 45L, vouchedByDeviceId = "extension-issuer", ), ) } val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) val manifest = buildSignedDeviceManifest( issuerDeviceId = "extension-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "new-phone", deviceName = "June's Phone", deviceType = "android", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = meshRelay, pairedAt = 44L, ), ), signingPrivateKey = issuerSigning.privateKey, ) adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 }) assertNull("failed registry commit must not install trust", paired.get("new-phone")) assertEquals("pending introduction is restored for retry", 1, pending.devices.size) master.fill(0) } /** * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer * being DIRECTLY paired, not on it being a browser. Android used to require * deviceType == "extension", which silently killed mesh propagation between * native devices — the case the mesh exists for now that phones are the * listening side. */ @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "phone-issuer", deviceName = "Other Phone", deviceType = "android", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) val manifest = buildSignedDeviceManifest( issuerDeviceId = "phone-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "laptop-b", deviceName = "Laptop B", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = meshRelay, pairedAt = 44L, ), ), signingPrivateKey = issuerSigning.privateKey, ) adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 }) assertEquals("vouched", paired.get("laptop-b")?.trustSource) assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl) assertTrue(pending.devices.isEmpty()) master.fill(0) } /** A vouched peer cannot extend the trust graph beyond one hop. */ @Test fun `a vouched issuer cannot introduce further mesh devices`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "vouched-issuer", deviceName = "Vouched Device", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "vouched", vouchDepth = MAX_VOUCH_DEPTH, pairedAt = 1L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) val manifest = buildSignedDeviceManifest( issuerDeviceId = "vouched-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "onward-mesh-device", deviceName = "Onward mesh device", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = meshRelay, pairedAt = 44L, ), ), signingPrivateKey = issuerSigning.privateKey, ) adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 }) assertNull(paired.get("onward-mesh-device")) assertEquals(0, pending.devices.size) master.fill(0) } @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer") val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "claimed-issuer", deviceName = "Claimed Browser", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), PairedDevice( deviceId = "authenticated-peer", deviceName = "Authenticated Phone", deviceType = "android", longTermPublicKeyHex = "22".repeat(32), signingPublicKeyHex = "33".repeat(32), trustSource = "direct", pairedAt = 2L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter( repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"), ) val manifest = buildSignedDeviceManifest( issuerDeviceId = "claimed-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "smuggled-device", deviceName = "Smuggled", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = meshRelay, pairedAt = 44L, ), ), signingPrivateKey = claimedSigning.privateKey, ) adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 }) assertEquals(0, pending.devices.size) // Control: the same manifest over the claimed issuer's OWN session does // admit the device. Without it the assertion above could pass for any // reason — a malformed body, a bad signature, a dead code path. adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 }) assertEquals("vouched", paired.get("smuggled-device")?.trustSource) assertTrue(pending.devices.isEmpty()) master.fill(0) } @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer") val stolenKey = "dd".repeat(32) val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "phone-issuer", deviceName = "Pixel", deviceType = "android", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), PairedDevice( deviceId = "stolen-laptop", deviceName = "Old Laptop", deviceType = "extension", longTermPublicKeyHex = stolenKey, signingPublicKeyHex = "ee".repeat(32), trustSource = "direct", pairedAt = 2L, ), ), ) val pending = FakePendingRegistry() val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self")) adapter.applyIncomingDeviceManifest( buildSignedDeviceManifest( issuerDeviceId = "phone-issuer", devices = emptyList(), signingPrivateKey = issuerSigning.privateKey, revoked = listOf( DeviceRevocation( deviceId = "stolen-laptop", longTermPublicKeyHex = stolenKey, revokedAt = System.currentTimeMillis(), revokedByDeviceId = "phone-issuer", ), ), ), ByteArray(32) { 0x11 }, ) assertNull(paired.get("stolen-laptop")) // deviceId is the issuer's to choose, so re-vouching the same key under // a new one is free. Terminality has to follow the key, not the label. adapter.applyIncomingDeviceManifest( buildSignedDeviceManifest( issuerDeviceId = "phone-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "stolen-laptop-mk2", deviceName = "New Laptop", deviceType = "extension", longTermPublicKeyHex = stolenKey, signingPublicKeyHex = "ee".repeat(32), relayUrl = meshRelay, pairedAt = System.currentTimeMillis(), ), ), signingPrivateKey = issuerSigning.privateKey, ), ByteArray(32) { 0x11 }, ) assertEquals(0, pending.devices.size) assertNull(paired.get("stolen-laptop-mk2")) // Control: the same fresh id carrying a key that was never revoked // queues normally, so the rejection above is the key check, not the id. adapter.applyIncomingDeviceManifest( buildSignedDeviceManifest( issuerDeviceId = "phone-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "stolen-laptop-mk2", deviceName = "New Laptop", deviceType = "extension", longTermPublicKeyHex = "99".repeat(32), signingPublicKeyHex = "ee".repeat(32), relayUrl = meshRelay, pairedAt = System.currentTimeMillis(), ), ), signingPrivateKey = issuerSigning.privateKey, ), ByteArray(32) { 0x11 }, ) assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource) assertTrue(pending.devices.isEmpty()) master.fill(0) } @Test fun `direct and vouched peers reject long term and signing key rotation`() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse") val master = repo.syncMasterKey()!! val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer") val revokedKey = "dd".repeat(32) val paired = FakeDeviceRegistry( listOf( PairedDevice( deviceId = "phone-issuer", deviceName = "Pixel", deviceType = "android", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw), trustSource = "direct", pairedAt = 1L, ), PairedDevice( deviceId = "trusted-tablet", deviceName = "Tablet", deviceType = "android", longTermPublicKeyHex = "77".repeat(32), signingPublicKeyHex = "ee".repeat(32), trustSource = "direct", pairedAt = 2L, ), PairedDevice( deviceId = "vouched-laptop", deviceName = "Laptop", deviceType = "extension", longTermPublicKeyHex = "66".repeat(32), signingPublicKeyHex = "aa".repeat(32), trustSource = "vouched", pairedAt = 3L, ), ), ) val adapter = VaultSyncAdapter( repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = FakePendingRegistry(), localCredentials = localCredentials("android-self"), ) adapter.applyIncomingDeviceManifest( buildSignedDeviceManifest( issuerDeviceId = "phone-issuer", devices = emptyList(), signingPrivateKey = issuerSigning.privateKey, revoked = listOf( DeviceRevocation( deviceId = "stolen-laptop", longTermPublicKeyHex = revokedKey, revokedAt = System.currentTimeMillis(), revokedByDeviceId = "phone-issuer", ), ), ), ByteArray(32) { 0x11 }, ) // Re-admission dressed as a key rotation on a device we already trust. adapter.applyIncomingDeviceManifest( buildSignedDeviceManifest( issuerDeviceId = "phone-issuer", devices = listOf( ManifestDeviceEntry( deviceId = "trusted-tablet", deviceName = "Tablet", deviceType = "android", longTermPublicKeyHex = revokedKey, signingPublicKeyHex = "ff".repeat(32), pairedAt = 2L, ), ManifestDeviceEntry( deviceId = "vouched-laptop", deviceName = "Laptop", deviceType = "extension", longTermPublicKeyHex = "55".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = "wss://relay.peachpasswords.com", pairedAt = 3L, ), ), signingPrivateKey = issuerSigning.privateKey, ), ByteArray(32) { 0x11 }, ) assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex) assertEquals("ee".repeat(32), paired.get("trusted-tablet")?.signingPublicKeyHex) assertEquals("66".repeat(32), paired.get("vouched-laptop")?.longTermPublicKeyHex) assertEquals("aa".repeat(32), paired.get("vouched-laptop")?.signingPublicKeyHex) master.fill(0) } /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */ @Test fun `revocation matches on id or key but never on the empty-key sentinel`() { val registry = FakeDeviceRegistry() registry.mergeRevocations( listOf( DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"), DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"), ), ) assertTrue(registry.isRevoked("gone")) assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32))) assertTrue(registry.isRevoked("keyless")) assertFalse(registry.isRevoked("fresh-id", "99".repeat(32))) // A device that genuinely never stored a key must not inherit the // sentinel's revocation. assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL)) } @Test fun `public key decoder requires exactly 32 bytes`() { assertEquals(32, hexToPubkey("ab".repeat(32)).size) assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure) assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure) assertEquals(64, hexToBytes("ab".repeat(64)).size) } }