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.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield 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_eight_digits_zero_padded() { assertEquals("00000000", SASDerivation.derive(ByteArray(8))) assertEquals("00000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 5))) } @Test fun sas_kat_known_vectors() { // First 8 bytes as an unsigned big-endian integer, mod 100_000_000. assertEquals( "52306183", SASDerivation.derive(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)), ) assertEquals("09551615", SASDerivation.derive(ByteArray(8) { 0xFF.toByte() })) } @Test fun sas_ignores_bytes_past_the_first_eight() { val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9)) val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0)) assertEquals(a, b) } @Test fun sas_requires_at_least_eight_bytes() { var threw = false try { SASDerivation.derive(ByteArray(7)) } catch (_: IllegalArgumentException) { threw = true } assertTrue(threw) } @Test fun sas_gate_never_completes_without_explicit_user_confirmation() = runBlocking { val session = PairingSession() val waiter = async { session.await() } yield() assertFalse("SAS session must not auto-confirm", waiter.isCompleted) session.confirm(true) assertTrue(waiter.await()) } // ── 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" }) } }