SyncSuiteTest.kt

9.5 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.StoredVault4import com.peachpasswords.vault.UnlockResult5import com.peachpasswords.vault.VaultEntry6import com.peachpasswords.vault.VaultRepository7import com.peachpasswords.vault.VaultSettings8import com.peachpasswords.vault.VaultStorage9import kotlinx.coroutines.async10import kotlinx.coroutines.runBlocking11import kotlinx.coroutines.yield12import org.junit.Assert.assertArrayEquals13import org.junit.Assert.assertEquals14import org.junit.Assert.assertFalse15import org.junit.Assert.assertTrue16import org.junit.Test17import java.io.ByteArrayInputStream18import java.io.ByteArrayOutputStream1920/**21 * Sync suite (brief Suite 13): length-prefixed pairing frames, SAS22 * derivation KAT, and cross-device entry sync through the [VaultSyncAdapter]23 * (two repositories made to share a sync root key via the Codex blob).24 */25class SyncSuiteTest {2627    private class Storage : VaultStorage {28        var slotA: StoredVault? = null29        var slotB: String? = null30        private var ver: Long = 031        private var settings = VaultSettings()32        override fun loadSlotA() = slotA33        override fun hasVault() = slotA != null34        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }35        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }36        var decoy: StoredVault? = null37        override fun loadDecoySlot() = decoy38        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }39        override fun hasDecoySlot() = decoy != null40        override fun clearDecoySlot() { decoy = null }41        override fun version() = ver42        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }43        override fun loadSettings() = settings44        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }45        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }46    }4748    // ── Pairing frames ────────────────────────────────────────────────────────4950    private fun roundTripFrame(payload: ByteArray): ByteArray {51        val out = ByteArrayOutputStream()52        writeFrame(out, payload)53        return readFrame(ByteArrayInputStream(out.toByteArray()))54    }5556    @Test fun frame_round_trips_small_payload() {57        val p = "noise-handshake-msg".toByteArray()58        assertArrayEquals(p, roundTripFrame(p))59    }6061    @Test fun frame_round_trips_empty_payload() {62        assertArrayEquals(ByteArray(0), roundTripFrame(ByteArray(0)))63    }6465    @Test fun frame_round_trips_various_sizes() {66        for (n in intArrayOf(1, 100, 4096, 65536)) {67            val p = ByteArray(n) { (it % 251).toByte() }68            assertArrayEquals("size $n", p, roundTripFrame(p))69        }70    }7172    @Test fun frame_round_trips_at_max_size() {73        val p = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() }74        assertEquals(MAX_PAIRING_FRAME_BYTES, roundTripFrame(p).size)75    }7677    @Test fun write_frame_rejects_oversize_payload() {78        val out = ByteArrayOutputStream()79        var threw = false80        try { writeFrame(out, ByteArray(MAX_PAIRING_FRAME_BYTES + 1)) } catch (_: IllegalArgumentException) { threw = true }81        assertTrue(threw)82    }8384    @Test fun read_frame_rejects_truncated_body() {85        // A header claiming 100 bytes but only 3 present must fail (EOF / mismatch).86        val bytes = byteArrayOf(0, 0, 0, 100, 1, 2, 3)87        var threw = false88        try { readFrame(ByteArrayInputStream(bytes)) } catch (_: Exception) { threw = true }89        assertTrue(threw)90    }9192    // ── SAS derivation ────────────────────────────────────────────────────────9394    @Test fun sas_is_eight_digits_zero_padded() {95        assertEquals("00000000", SASDerivation.derive(ByteArray(8)))96        assertEquals("00000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 5)))97    }9899    @Test fun sas_kat_known_vectors() {100        // First 8 bytes as an unsigned big-endian integer, mod 100_000_000.101        assertEquals(102            "52306183",103            SASDerivation.derive(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)),104        )105        assertEquals("09551615", SASDerivation.derive(ByteArray(8) { 0xFF.toByte() }))106    }107108    @Test fun sas_ignores_bytes_past_the_first_eight() {109        val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9))110        val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0))111        assertEquals(a, b)112    }113114    @Test fun sas_requires_at_least_eight_bytes() {115        var threw = false116        try { SASDerivation.derive(ByteArray(7)) } catch (_: IllegalArgumentException) { threw = true }117        assertTrue(threw)118    }119120    @Test fun `HV-1 a far-future remote modifiedAt is clamped into the skew window`() = runBlocking {121        val (a, _, _) = pairedDevices()122        val local = a.addEntry(123            VaultEntry(id = "", title = "Local", type = "login", password = "local-pw", createdAt = 0, modifiedAt = 0),124        )125126        // A hostile peer claims an edit ten years in the future, trying to pin127        // its version against every honest local edit for a decade.128        val farFuture = System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000129        a.applyMergedRemoteEntries(130            listOf(local.copy(title = "Injected", password = "attacker-pw", modifiedAt = farFuture)),131            emptyList(),132        )133134        // The edit still lands (it is genuinely newer) but only within the skew135        // window, so the monotonic local entry clock can always advance past it.136        // The overtake itself is not exercised here: updateEntry would push137        // VaultCrypto's process-global monotonic counter forward and skew every138        // later test in the same JVM.139        val pinned = a.getEntry(local.id)!!140        assertTrue(141            "remote modifiedAt must be clamped into the skew window",142            pinned.modifiedAt <= System.currentTimeMillis() + SyncLimits.MAX_CLOCK_SKEW_MS,143        )144        assertTrue("clamped value must be far below the claim", pinned.modifiedAt < farFuture)145    }146147    @Test fun sas_gate_never_completes_without_explicit_user_confirmation() = runBlocking {148        val session = PairingSession()149        val waiter = async { session.await() }150        yield()151        assertFalse("SAS session must not auto-confirm", waiter.isCompleted)152        session.confirm(true)153        assertTrue(waiter.await())154    }155156    // ── Cross-device entry sync (shared root key via Codex) ───────────────────157158    private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {159        val a = VaultRepository(Storage())160        runBlocking { a.createVault("shared-master-pass") }161        // Device B restores A's blob → identical sync root key, so entry keys match.162        val b = VaultRepository(Storage())163        runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) }164        return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b"))165    }166167    @Test fun paired_devices_share_a_vault_id() = runBlocking {168        val (_, _, adapters) = pairedDevices()169        assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId())170    }171172    @Test fun entry_added_on_a_syncs_to_b() = runBlocking {173        val (a, b, adapters) = pairedDevices()174        val (adapterA, adapterB) = adapters175        val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0))176        val envelopes = adapterA.getEncryptedEntries(listOf(e.id))177        adapterB.applyEntries(envelopes)178        assertEquals("GitHub", b.getEntry(e.id)?.title)179        assertEquals("p", b.getEntry(e.id)?.password)180    }181182    @Test fun tombstone_from_a_deletes_on_b() = runBlocking {183        val (a, b, adapters) = pairedDevices()184        val (adapterA, adapterB) = adapters185        val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0))186        adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id)))187        assertEquals(1, b.getEntries().size)188        a.deleteEntry(e.id)189        adapterB.applyTombstones(adapterA.buildTombstoneManifest())190        assertEquals(0, b.getEntries().size)191    }192193    @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking {194        val (a, _, adapters) = pairedDevices()195        val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0))196        // A zero/negative tombstone timestamp is filtered out, so the entry survives.197        adapters.first.applyTombstones(mapOf(e.id to 0L))198        assertEquals(1, a.getEntries().size)199    }200201    @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking {202        val (a, b, adapters) = pairedDevices()203        val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0))204        val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id)205        adapters.second.applyEntries(mapOf("forged-id" to blob))206        assertFalse(b.getEntries().any { it.id == "forged-id" })207    }208}