ChirpSyncSuiteTest.kt

7.8 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.runBlocking10import org.junit.Assert.assertArrayEquals11import org.junit.Assert.assertEquals12import org.junit.Assert.assertFalse13import org.junit.Assert.assertTrue14import org.junit.Test15import java.io.ByteArrayInputStream16import java.io.ByteArrayOutputStream1718/**19 * ChirpSync suite (brief Suite 13): length-prefixed pairing frames, SAS20 * derivation KAT, and cross-device entry sync through the [VaultSyncAdapter]21 * (two repositories made to share a sync root key via the Codex blob).22 */23class ChirpSyncSuiteTest {2425    private class Storage : VaultStorage {26        var slotA: StoredVault? = null27        var slotB: String? = null28        private var ver: Long = 029        private var settings = VaultSettings()30        override fun loadSlotA() = slotA31        override fun hasVault() = slotA != null32        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }33        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }34        var decoy: StoredVault? = null35        override fun loadDecoySlot() = decoy36        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }37        override fun hasDecoySlot() = decoy != null38        override fun clearDecoySlot() { decoy = null }39        override fun version() = ver40        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }41        override fun loadSettings() = settings42        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }43        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }44    }4546    // ── Pairing frames ────────────────────────────────────────────────────────4748    private fun roundTripFrame(payload: ByteArray): ByteArray {49        val out = ByteArrayOutputStream()50        writeFrame(out, payload)51        return readFrame(ByteArrayInputStream(out.toByteArray()))52    }5354    @Test fun frame_round_trips_small_payload() {55        val p = "noise-handshake-msg".toByteArray()56        assertArrayEquals(p, roundTripFrame(p))57    }5859    @Test fun frame_round_trips_empty_payload() {60        assertArrayEquals(ByteArray(0), roundTripFrame(ByteArray(0)))61    }6263    @Test fun frame_round_trips_various_sizes() {64        for (n in intArrayOf(1, 100, 4096, 65536)) {65            val p = ByteArray(n) { (it % 251).toByte() }66            assertArrayEquals("size $n", p, roundTripFrame(p))67        }68    }6970    @Test fun frame_round_trips_at_max_size() {71        val p = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() }72        assertEquals(MAX_PAIRING_FRAME_BYTES, roundTripFrame(p).size)73    }7475    @Test fun write_frame_rejects_oversize_payload() {76        val out = ByteArrayOutputStream()77        var threw = false78        try { writeFrame(out, ByteArray(MAX_PAIRING_FRAME_BYTES + 1)) } catch (_: IllegalArgumentException) { threw = true }79        assertTrue(threw)80    }8182    @Test fun read_frame_rejects_truncated_body() {83        // A header claiming 100 bytes but only 3 present must fail (EOF / mismatch).84        val bytes = byteArrayOf(0, 0, 0, 100, 1, 2, 3)85        var threw = false86        try { readFrame(ByteArrayInputStream(bytes)) } catch (_: Exception) { threw = true }87        assertTrue(threw)88    }8990    // ── SAS derivation ────────────────────────────────────────────────────────9192    @Test fun sas_is_six_digits_zero_padded() {93        assertEquals("000000", SASDerivation.derive(byteArrayOf(0, 0, 0, 0)))94        assertEquals("000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 5)))95    }9697    @Test fun sas_kat_known_vectors() {98        // First 4 bytes as big-endian uint32, mod 1_000_000.99        assertEquals("999999", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x3F)))100        assertEquals("000000", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x40))) // 1_000_000101        assertEquals("967295", SASDerivation.derive(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())))102    }103104    @Test fun sas_ignores_bytes_past_the_first_four() {105        val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 9, 9, 9))106        val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 0, 0, 0))107        assertEquals(a, b)108    }109110    @Test fun sas_requires_at_least_four_bytes() {111        var threw = false112        try { SASDerivation.derive(byteArrayOf(1, 2, 3)) } catch (_: IllegalArgumentException) { threw = true }113        assertTrue(threw)114    }115116    // ── Cross-device entry sync (shared root key via Codex) ───────────────────117118    private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {119        val a = VaultRepository(Storage())120        runBlocking { a.createVault("shared-master-pass") }121        // Device B restores A's blob → identical sync root key, so entry keys match.122        val b = VaultRepository(Storage())123        runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) }124        return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b"))125    }126127    @Test fun paired_devices_share_a_vault_id() = runBlocking {128        val (_, _, adapters) = pairedDevices()129        assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId())130    }131132    @Test fun entry_added_on_a_syncs_to_b() = runBlocking {133        val (a, b, adapters) = pairedDevices()134        val (adapterA, adapterB) = adapters135        val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0))136        val envelopes = adapterA.getEncryptedEntries(listOf(e.id))137        adapterB.applyEntries(envelopes)138        assertEquals("GitHub", b.getEntry(e.id)?.title)139        assertEquals("p", b.getEntry(e.id)?.password)140    }141142    @Test fun tombstone_from_a_deletes_on_b() = runBlocking {143        val (a, b, adapters) = pairedDevices()144        val (adapterA, adapterB) = adapters145        val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0))146        adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id)))147        assertEquals(1, b.getEntries().size)148        a.deleteEntry(e.id)149        adapterB.applyTombstones(adapterA.buildTombstoneManifest())150        assertEquals(0, b.getEntries().size)151    }152153    @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking {154        val (a, _, adapters) = pairedDevices()155        val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0))156        // A zero/negative tombstone timestamp is filtered out, so the entry survives.157        adapters.first.applyTombstones(mapOf(e.id to 0L))158        assertEquals(1, a.getEntries().size)159    }160161    @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking {162        val (a, b, adapters) = pairedDevices()163        val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0))164        val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id)165        adapters.second.applyEntries(mapOf("forged-id" to blob))166        assertFalse(b.getEntries().any { it.id == "forged-id" })167    }168}