package com.peachpasswords.sync import com.peachpasswords.vault.StoredVault import com.peachpasswords.vault.UnlockResult 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.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream /** * ChirpSync suite (brief Suite 13): length-prefixed pairing frames, SAS * derivation KAT, and cross-device entry sync through the [VaultSyncAdapter] * (two repositories made to share a sync root key via the Codex blob). */ class ChirpSyncSuiteTest { private class Storage : VaultStorage { var slotA: StoredVault? = null 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 } 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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() } } // ── Pairing frames ──────────────────────────────────────────────────────── private fun roundTripFrame(payload: ByteArray): ByteArray { val out = ByteArrayOutputStream() writeFrame(out, payload) return readFrame(ByteArrayInputStream(out.toByteArray())) } @Test fun frame_round_trips_small_payload() { val p = "noise-handshake-msg".toByteArray() assertArrayEquals(p, roundTripFrame(p)) } @Test fun frame_round_trips_empty_payload() { assertArrayEquals(ByteArray(0), roundTripFrame(ByteArray(0))) } @Test fun frame_round_trips_various_sizes() { for (n in intArrayOf(1, 100, 4096, 65536)) { val p = ByteArray(n) { (it % 251).toByte() } assertArrayEquals("size $n", p, roundTripFrame(p)) } } @Test fun frame_round_trips_at_max_size() { val p = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() } assertEquals(MAX_PAIRING_FRAME_BYTES, roundTripFrame(p).size) } @Test fun write_frame_rejects_oversize_payload() { val out = ByteArrayOutputStream() var threw = false try { writeFrame(out, ByteArray(MAX_PAIRING_FRAME_BYTES + 1)) } catch (_: IllegalArgumentException) { threw = true } assertTrue(threw) } @Test fun read_frame_rejects_truncated_body() { // A header claiming 100 bytes but only 3 present must fail (EOF / mismatch). val bytes = byteArrayOf(0, 0, 0, 100, 1, 2, 3) var threw = false try { readFrame(ByteArrayInputStream(bytes)) } catch (_: Exception) { threw = true } assertTrue(threw) } // ── SAS derivation ──────────────────────────────────────────────────────── @Test fun sas_is_six_digits_zero_padded() { assertEquals("000000", SASDerivation.derive(byteArrayOf(0, 0, 0, 0))) assertEquals("000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 5))) } @Test fun sas_kat_known_vectors() { // First 4 bytes as big-endian uint32, mod 1_000_000. assertEquals("999999", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x3F))) assertEquals("000000", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x40))) // 1_000_000 assertEquals("967295", SASDerivation.derive(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()))) } @Test fun sas_ignores_bytes_past_the_first_four() { val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 9, 9, 9)) val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 0, 0, 0)) assertEquals(a, b) } @Test fun sas_requires_at_least_four_bytes() { var threw = false try { SASDerivation.derive(byteArrayOf(1, 2, 3)) } catch (_: IllegalArgumentException) { threw = true } assertTrue(threw) } // ── Cross-device entry sync (shared root key via Codex) ─────────────────── private fun pairedDevices(): Triple> { val a = VaultRepository(Storage()) runBlocking { a.createVault("shared-master-pass") } // Device B restores A's blob → identical sync root key, so entry keys match. val b = VaultRepository(Storage()) runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) } return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b")) } @Test fun paired_devices_share_a_vault_id() = runBlocking { val (_, _, adapters) = pairedDevices() assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId()) } @Test fun entry_added_on_a_syncs_to_b() = runBlocking { val (a, b, adapters) = pairedDevices() val (adapterA, adapterB) = adapters val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0)) val envelopes = adapterA.getEncryptedEntries(listOf(e.id)) adapterB.applyEntries(envelopes) assertEquals("GitHub", b.getEntry(e.id)?.title) assertEquals("p", b.getEntry(e.id)?.password) } @Test fun tombstone_from_a_deletes_on_b() = runBlocking { val (a, b, adapters) = pairedDevices() val (adapterA, adapterB) = adapters val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0)) adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id))) assertEquals(1, b.getEntries().size) a.deleteEntry(e.id) adapterB.applyTombstones(adapterA.buildTombstoneManifest()) assertEquals(0, b.getEntries().size) } @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking { val (a, _, adapters) = pairedDevices() val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0)) // A zero/negative tombstone timestamp is filtered out, so the entry survives. adapters.first.applyTombstones(mapOf(e.id to 0L)) assertEquals(1, a.getEntries().size) } @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking { val (a, b, adapters) = pairedDevices() val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0)) val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id) adapters.second.applyEntries(mapOf("forged-id" to blob)) assertFalse(b.getEntries().any { it.id == "forged-id" }) } }