VaultSyncAdapterTest.kt

15.8 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        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    }4445    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    }5455    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {56        val repo = VaultRepository(FakeStorage())57        runBlocking { repo.createVault("correct horse") }58        return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")59    }6061    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {62        val (repo, adapter) = newAdapter()63        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))64        val manifest = adapter.buildEntryManifest()65        assertEquals(setOf(e.id), manifest.keys)66        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex67    }6869    @Test fun envelope_round_trips_within_the_vault() = runBlocking {70        val (repo, adapter) = newAdapter()71        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))72        val envelopes = adapter.getEncryptedEntries(listOf(e.id))73        assertTrue(envelopes.containsKey(e.id))74        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).75        adapter.applyEntries(envelopes)76        assertEquals(1, repo.getEntries().size)77    }7879    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {80        val (repo, adapter) = newAdapter()81        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))82        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)83        // Present the ciphertext under a different id — the decrypted entry.id84        // won't match, so it must be dropped, not poison a new slot.85        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))86        assertNull(repo.getEntry("attacker-chosen-id"))87        assertEquals(1, repo.getEntries().size)88    }8990    // HKDF separator (coordinated with peach-browser): an envelope minted under91    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This92    // locks in the conscious wire break and asserts byte-for-byte parity with the93    // browser's matching `legacy_info_envelope_fails_closed` test.94    @Test fun legacy_info_envelope_fails_closed() = runBlocking {95        val (repo, adapter) = newAdapter()96        val master = repo.syncMasterKey()!!97        val entryId = "11111111-2222-4333-8444-555555555555"98        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")99100        // Legacy info: no U+0000 separator.101        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)102        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())103        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))104        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }105106        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }107        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)108        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }109        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")110        cipher.init(111            javax.crypto.Cipher.ENCRYPT_MODE,112            javax.crypto.spec.SecretKeySpec(key, "AES"),113            javax.crypto.spec.GCMParameterSpec(128, nonce),114        )115        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))116        val ct = cipher.doFinal(plaintext)117        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)118119        adapter.applyEntries(mapOf(entryId to legacyEnvelope))120        // Patched adapter derives with the separated info → decrypt fails → dropped.121        assertNull(repo.getEntry(entryId))122    }123124    @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        )142143        val verified = verifyDeviceManifest(144            manifest,145            signing.publicKeyRaw,146            nowMs = 1_000L,147        )148149        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    }153154    @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        )188189        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })190191        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)196    }197    /**198     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer199     * being DIRECTLY paired, not on it being a browser. Android used to require200     * deviceType == "extension", which silently killed mesh propagation between201     * native devices — the case the mesh exists for now that phones are the202     * listening side.203     */204    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {205        val repo = VaultRepository(FakeStorage())206        repo.createVault("correct horse")207        val master = repo.syncMasterKey()!!208        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")209        val paired = FakeDeviceRegistry(210            listOf(211                PairedDevice(212                    deviceId = "phone-issuer",213                    deviceName = "Other Phone",214                    deviceType = "android",215                    longTermPublicKeyHex = "11".repeat(32),216                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),217                    trustSource = "direct",218                    pairedAt = 1L,219                ),220            ),221        )222        val pending = FakePendingRegistry()223        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)224        val manifest = buildSignedDeviceManifest(225            issuerDeviceId = "phone-issuer",226            devices = listOf(227                ManifestDeviceEntry(228                    deviceId = "laptop-b",229                    deviceName = "Laptop B",230                    deviceType = "extension",231                    longTermPublicKeyHex = "aa".repeat(32),232                    signingPublicKeyHex = "bb".repeat(32),233                    pairedAt = 44L,234                ),235            ),236            signingPrivateKey = issuerSigning.privateKey,237        )238239        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })240241        assertEquals(1, pending.devices.size)242        assertEquals("laptop-b", pending.devices.single().deviceId)243        master.fill(0)244    }245246    /**247     * The other half: a device that was itself only vouched for must not be able248     * to vouch for anyone else. Without this, trust chains transitively from a249     * single direct pairing — which the browser refuses outright.250     */251    @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {252        val repo = VaultRepository(FakeStorage())253        repo.createVault("correct horse")254        val master = repo.syncMasterKey()!!255        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")256        val paired = FakeDeviceRegistry(257            listOf(258                PairedDevice(259                    deviceId = "vouched-issuer",260                    deviceName = "Vouched Device",261                    deviceType = "extension",262                    longTermPublicKeyHex = "11".repeat(32),263                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),264                    trustSource = "vouched",265                    pairedAt = 1L,266                ),267            ),268        )269        val pending = FakePendingRegistry()270        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)271        val manifest = buildSignedDeviceManifest(272            issuerDeviceId = "vouched-issuer",273            devices = listOf(274                ManifestDeviceEntry(275                    deviceId = "smuggled-device",276                    deviceName = "Smuggled",277                    deviceType = "extension",278                    longTermPublicKeyHex = "aa".repeat(32),279                    signingPublicKeyHex = "bb".repeat(32),280                    pairedAt = 44L,281                ),282            ),283            signingPrivateKey = issuerSigning.privateKey,284        )285286        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })287288        assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)289        master.fill(0)290    }291292    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {293        val repo = VaultRepository(FakeStorage())294        repo.createVault("correct horse")295        val master = repo.syncMasterKey()!!296        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")297        val paired = FakeDeviceRegistry(298            listOf(299                PairedDevice(300                    deviceId = "claimed-issuer",301                    deviceName = "Claimed Browser",302                    deviceType = "extension",303                    longTermPublicKeyHex = "11".repeat(32),304                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),305                    trustSource = "direct",306                    pairedAt = 1L,307                ),308                PairedDevice(309                    deviceId = "authenticated-peer",310                    deviceName = "Authenticated Phone",311                    deviceType = "android",312                    longTermPublicKeyHex = "22".repeat(32),313                    signingPublicKeyHex = "33".repeat(32),314                    trustSource = "direct",315                    pairedAt = 2L,316                ),317            ),318        )319        val pending = FakePendingRegistry()320        val adapter = VaultSyncAdapter(321            repo,322            ourDeviceId = "android-self",323            pairedDevices = paired,324            pendingVouched = pending,325        )326        val manifest = buildSignedDeviceManifest(327            issuerDeviceId = "claimed-issuer",328            devices = listOf(329                ManifestDeviceEntry(330                    deviceId = "smuggled-device",331                    deviceName = "Smuggled",332                    deviceType = "extension",333                    longTermPublicKeyHex = "aa".repeat(32),334                    signingPublicKeyHex = "bb".repeat(32),335                    pairedAt = 44L,336                ),337            ),338            signingPrivateKey = claimedSigning.privateKey,339        )340341        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })342343        assertEquals(0, pending.devices.size)344        master.fill(0)345    }346347    @Test fun `public key decoder requires exactly 32 bytes`() {348        assertEquals(32, hexToPubkey("ab".repeat(32)).size)349        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)350        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)351        assertEquals(64, hexToBytes("ab".repeat(64)).size)352    }353}