VaultSyncAdapterTest.kt

21.0 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.StoredVault4import com.peachpasswords.vault.VaultEntry5import com.peachpasswords.vault.VaultRepository6import com.peachpasswords.vault.VaultSettings7import com.peachpasswords.vault.VaultStorage8import kotlinx.coroutines.runBlocking9import org.junit.Assert.assertEquals10import org.junit.Assert.assertNull11import org.junit.Assert.assertTrue12import org.junit.Test1314private class FakeStorage : VaultStorage {15    private var slotA: StoredVault? = null16    private var slotB: String? = null17    private var ver: Long = 018    private var settings = VaultSettings()19    override fun loadSlotA() = slotA20    override fun hasVault() = slotA != null21    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }22    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }23    private var decoy: StoredVault? = null24    override fun loadDecoySlot() = decoy25    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }26    override fun hasDecoySlot() = decoy != null27    override fun clearDecoySlot() { decoy = null }28    override fun version() = ver29    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }30    override fun loadSettings() = settings31    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }32    override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }33}3435class VaultSyncAdapterTest {36    private class FakeDeviceRegistry(37        devices: List<PairedDevice> = emptyList(),38    ) : PairedDeviceRegistry {39        private val byId = devices.associateBy { it.deviceId }.toMutableMap()40        private val revocations = mutableListOf<DeviceRevocation>()41        override fun getAll(): List<PairedDevice> = byId.values.toList()42        override fun get(id: String): PairedDevice? = byId[id]43        override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }44        override fun remove(deviceId: String) { byId.remove(deviceId) }45        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()46        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {47            val known = revocations.map { it.deviceId }.toSet()48            val fresh = incoming.filterNot { known.contains(it.deviceId) }49            revocations += fresh50            return fresh.map { it.deviceId }51        }52        override fun clearRevocation(deviceId: String) {53            revocations.removeAll { it.deviceId == deviceId }54        }55    }5657    private class FakePendingRegistry : PendingVouchedRegistry {58        val devices = mutableListOf<PendingVouchedDevice>()59        override fun getAll(): List<PendingVouchedDevice> = devices.toList()60        override fun upsert(device: PendingVouchedDevice) {61            devices.removeAll { it.deviceId == device.deviceId }62            devices += device63        }64        override fun remove(id: String) { devices.removeAll { it.deviceId == id } }65    }6667    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {68        val repo = VaultRepository(FakeStorage())69        runBlocking { repo.createVault("correct horse") }70        return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")71    }7273    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {74        val (repo, adapter) = newAdapter()75        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))76        val manifest = adapter.buildEntryManifest()77        assertEquals(setOf(e.id), manifest.keys)78        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex79    }8081    @Test fun envelope_round_trips_within_the_vault() = runBlocking {82        val (repo, adapter) = newAdapter()83        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))84        val envelopes = adapter.getEncryptedEntries(listOf(e.id))85        assertTrue(envelopes.containsKey(e.id))86        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).87        adapter.applyEntries(envelopes)88        assertEquals(1, repo.getEntries().size)89    }9091    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {92        val (repo, adapter) = newAdapter()93        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))94        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)95        // Present the ciphertext under a different id — the decrypted entry.id96        // won't match, so it must be dropped, not poison a new slot.97        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))98        assertNull(repo.getEntry("attacker-chosen-id"))99        assertEquals(1, repo.getEntries().size)100    }101102    // HKDF separator (coordinated with peach-browser): an envelope minted under103    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This104    // locks in the conscious wire break and asserts byte-for-byte parity with the105    // browser's matching `legacy_info_envelope_fails_closed` test.106    @Test fun legacy_info_envelope_fails_closed() = runBlocking {107        val (repo, adapter) = newAdapter()108        val master = repo.syncMasterKey()!!109        val entryId = "11111111-2222-4333-8444-555555555555"110        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")111112        // Legacy info: no U+0000 separator.113        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)114        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())115        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))116        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }117118        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }119        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)120        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }121        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")122        cipher.init(123            javax.crypto.Cipher.ENCRYPT_MODE,124            javax.crypto.spec.SecretKeySpec(key, "AES"),125            javax.crypto.spec.GCMParameterSpec(128, nonce),126        )127        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))128        val ct = cipher.doFinal(plaintext)129        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)130131        adapter.applyEntries(mapOf(entryId to legacyEnvelope))132        // Patched adapter derives with the separated info → decrypt fails → dropped.133        assertNull(repo.getEntry(entryId))134    }135136    @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {137        val master = ByteArray(32) { it.toByte() }138        val signing = deriveSyncSigningKeypair(master, "extension-issuer")139        val manifest = buildSignedDeviceManifest(140            issuerDeviceId = "extension-issuer",141            devices = listOf(142                ManifestDeviceEntry(143                    deviceId = "android-peer",144                    deviceName = "Phone",145                    deviceType = "android",146                    longTermPublicKeyHex = "aa".repeat(32),147                    signingPublicKeyHex = "bb".repeat(32),148                    pairedAt = 123L,149                ),150            ),151            signingPrivateKey = signing.privateKey,152            nowMs = 1_000L,153        )154155        val verified = verifyDeviceManifest(156            manifest,157            signing.publicKeyRaw,158            nowMs = 1_000L,159        )160161        assertEquals("extension-issuer", verified?.issuerDeviceId)162        assertEquals("android-peer", verified?.devices?.single()?.deviceId)163        assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)164    }165166    @Test fun `revocation evicts the device and permanently blocks re-vouching`() = runBlocking {167        val repo = VaultRepository(FakeStorage())168        repo.createVault("correct horse")169        val master = repo.syncMasterKey()!!170        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")171        val paired = FakeDeviceRegistry(172            listOf(173                PairedDevice(174                    deviceId = "extension-issuer",175                    deviceName = "Peach Browser",176                    deviceType = "extension",177                    longTermPublicKeyHex = "11".repeat(32),178                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),179                    trustSource = "direct",180                    pairedAt = 1L,181                ),182                PairedDevice(183                    deviceId = "stolen-laptop",184                    deviceName = "Old Laptop",185                    deviceType = "extension",186                    longTermPublicKeyHex = "dd".repeat(32),187                    signingPublicKeyHex = "ee".repeat(32),188                    trustSource = "direct",189                    pairedAt = 2L,190                ),191            ),192        )193        val pending = FakePendingRegistry()194        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)195196        val revocation = buildSignedDeviceManifest(197            issuerDeviceId = "extension-issuer",198            devices = emptyList(),199            signingPrivateKey = issuerSigning.privateKey,200            revoked = listOf(201                DeviceRevocation(202                    deviceId = "stolen-laptop",203                    longTermPublicKeyHex = "dd".repeat(32),204                    revokedAt = System.currentTimeMillis(),205                    revokedByDeviceId = "extension-issuer",206                ),207            ),208        )209        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })210211        assertNull(paired.get("stolen-laptop"))212213        // A later manifest vouches it back with a fresh pairedAt — exactly the214        // resurrection a timestamp-ordered design would allow.215        val revouch = buildSignedDeviceManifest(216            issuerDeviceId = "extension-issuer",217            devices = listOf(218                ManifestDeviceEntry(219                    deviceId = "stolen-laptop",220                    deviceName = "Old Laptop",221                    deviceType = "extension",222                    longTermPublicKeyHex = "dd".repeat(32),223                    signingPublicKeyHex = "ee".repeat(32),224                    pairedAt = System.currentTimeMillis() + 60_000L,225                ),226            ),227            signingPrivateKey = issuerSigning.privateKey,228        )229        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })230231        assertNull(paired.get("stolen-laptop"))232        assertEquals(0, pending.devices.size)233        master.fill(0)234    }235236    @Test fun `a peer cannot revoke this device`() = runBlocking {237        val repo = VaultRepository(FakeStorage())238        repo.createVault("correct horse")239        val master = repo.syncMasterKey()!!240        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")241        val paired = FakeDeviceRegistry(242            listOf(243                PairedDevice(244                    deviceId = "extension-issuer",245                    deviceName = "Peach Browser",246                    deviceType = "extension",247                    longTermPublicKeyHex = "11".repeat(32),248                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),249                    trustSource = "direct",250                    pairedAt = 1L,251                ),252            ),253        )254        val pending = FakePendingRegistry()255        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)256257        val selfRevoke = buildSignedDeviceManifest(258            issuerDeviceId = "extension-issuer",259            devices = emptyList(),260            signingPrivateKey = issuerSigning.privateKey,261            revoked = listOf(262                DeviceRevocation(263                    deviceId = "android-self",264                    longTermPublicKeyHex = "ff".repeat(32),265                    revokedAt = System.currentTimeMillis(),266                    revokedByDeviceId = "extension-issuer",267                ),268            ),269        )270        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })271272        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })273        master.fill(0)274    }275276    @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {277        val repo = VaultRepository(FakeStorage())278        repo.createVault("correct horse")279        val master = repo.syncMasterKey()!!280        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")281        val paired = FakeDeviceRegistry(282            listOf(283                PairedDevice(284                    deviceId = "extension-issuer",285                    deviceName = "Peach Browser",286                    deviceType = "extension",287                    longTermPublicKeyHex = "11".repeat(32),288                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),289                    trustSource = "direct",290                    pairedAt = 1L,291                ),292            ),293        )294        val pending = FakePendingRegistry()295        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)296        val manifest = buildSignedDeviceManifest(297            issuerDeviceId = "extension-issuer",298            devices = listOf(299                ManifestDeviceEntry(300                    deviceId = "new-phone",301                    deviceName = "June's Phone",302                    deviceType = "android",303                    longTermPublicKeyHex = "aa".repeat(32),304                    signingPublicKeyHex = "bb".repeat(32),305                    pairedAt = 44L,306                ),307            ),308            signingPrivateKey = issuerSigning.privateKey,309        )310311        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })312313        assertNull(paired.get("new-phone"))314        assertEquals(1, pending.devices.size)315        assertEquals("new-phone", pending.devices.single().deviceId)316        assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })317        master.fill(0)318    }319    /**320     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer321     * being DIRECTLY paired, not on it being a browser. Android used to require322     * deviceType == "extension", which silently killed mesh propagation between323     * native devices — the case the mesh exists for now that phones are the324     * listening side.325     */326    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {327        val repo = VaultRepository(FakeStorage())328        repo.createVault("correct horse")329        val master = repo.syncMasterKey()!!330        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")331        val paired = FakeDeviceRegistry(332            listOf(333                PairedDevice(334                    deviceId = "phone-issuer",335                    deviceName = "Other Phone",336                    deviceType = "android",337                    longTermPublicKeyHex = "11".repeat(32),338                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),339                    trustSource = "direct",340                    pairedAt = 1L,341                ),342            ),343        )344        val pending = FakePendingRegistry()345        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)346        val manifest = buildSignedDeviceManifest(347            issuerDeviceId = "phone-issuer",348            devices = listOf(349                ManifestDeviceEntry(350                    deviceId = "laptop-b",351                    deviceName = "Laptop B",352                    deviceType = "extension",353                    longTermPublicKeyHex = "aa".repeat(32),354                    signingPublicKeyHex = "bb".repeat(32),355                    pairedAt = 44L,356                ),357            ),358            signingPrivateKey = issuerSigning.privateKey,359        )360361        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })362363        assertEquals(1, pending.devices.size)364        assertEquals("laptop-b", pending.devices.single().deviceId)365        master.fill(0)366    }367368    /**369     * The other half: a device that was itself only vouched for must not be able370     * to vouch for anyone else. Without this, trust chains transitively from a371     * single direct pairing — which the browser refuses outright.372     */373    @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {374        val repo = VaultRepository(FakeStorage())375        repo.createVault("correct horse")376        val master = repo.syncMasterKey()!!377        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")378        val paired = FakeDeviceRegistry(379            listOf(380                PairedDevice(381                    deviceId = "vouched-issuer",382                    deviceName = "Vouched Device",383                    deviceType = "extension",384                    longTermPublicKeyHex = "11".repeat(32),385                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),386                    trustSource = "vouched",387                    pairedAt = 1L,388                ),389            ),390        )391        val pending = FakePendingRegistry()392        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)393        val manifest = buildSignedDeviceManifest(394            issuerDeviceId = "vouched-issuer",395            devices = listOf(396                ManifestDeviceEntry(397                    deviceId = "smuggled-device",398                    deviceName = "Smuggled",399                    deviceType = "extension",400                    longTermPublicKeyHex = "aa".repeat(32),401                    signingPublicKeyHex = "bb".repeat(32),402                    pairedAt = 44L,403                ),404            ),405            signingPrivateKey = issuerSigning.privateKey,406        )407408        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })409410        assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)411        master.fill(0)412    }413414    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {415        val repo = VaultRepository(FakeStorage())416        repo.createVault("correct horse")417        val master = repo.syncMasterKey()!!418        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")419        val paired = FakeDeviceRegistry(420            listOf(421                PairedDevice(422                    deviceId = "claimed-issuer",423                    deviceName = "Claimed Browser",424                    deviceType = "extension",425                    longTermPublicKeyHex = "11".repeat(32),426                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),427                    trustSource = "direct",428                    pairedAt = 1L,429                ),430                PairedDevice(431                    deviceId = "authenticated-peer",432                    deviceName = "Authenticated Phone",433                    deviceType = "android",434                    longTermPublicKeyHex = "22".repeat(32),435                    signingPublicKeyHex = "33".repeat(32),436                    trustSource = "direct",437                    pairedAt = 2L,438                ),439            ),440        )441        val pending = FakePendingRegistry()442        val adapter = VaultSyncAdapter(443            repo,444            ourDeviceId = "android-self",445            pairedDevices = paired,446            pendingVouched = pending,447        )448        val manifest = buildSignedDeviceManifest(449            issuerDeviceId = "claimed-issuer",450            devices = listOf(451                ManifestDeviceEntry(452                    deviceId = "smuggled-device",453                    deviceName = "Smuggled",454                    deviceType = "extension",455                    longTermPublicKeyHex = "aa".repeat(32),456                    signingPublicKeyHex = "bb".repeat(32),457                    pairedAt = 44L,458                ),459            ),460            signingPrivateKey = claimedSigning.privateKey,461        )462463        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })464465        assertEquals(0, pending.devices.size)466        master.fill(0)467    }468469    @Test fun `public key decoder requires exactly 32 bytes`() {470        assertEquals(32, hexToPubkey("ab".repeat(32)).size)471        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)472        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)473        assertEquals(64, hexToBytes("ab".repeat(64)).size)474    }475}