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 /** * Sync 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 SyncSuiteTest { 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(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } // ── 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 `HV-1 a far-future remote modifiedAt is clamped into the skew window`() = runBlocking { val (a, _, _) = pairedDevices() val local = a.addEntry( VaultEntry(id = "", title = "Local", type = "login", password = "local-pw", createdAt = 0, modifiedAt = 0), ) // A hostile peer claims an edit ten years in the future, trying to pin // its version against every honest local edit for a decade. val farFuture = System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000 a.applyMergedRemoteEntries( listOf(local.copy(title = "Injected", password = "attacker-pw", modifiedAt = farFuture)), emptyList(), ) // The edit still lands (it is genuinely newer) but only within the skew // window, so the monotonic local entry clock can always advance past it. // The overtake itself is not exercised here: updateEntry would push // VaultCrypto's process-global monotonic counter forward and skew every // later test in the same JVM. val pinned = a.getEntry(local.id)!! assertTrue( "remote modifiedAt must be clamped into the skew window", pinned.modifiedAt <= System.currentTimeMillis() + SyncLimits.MAX_CLOCK_SKEW_MS, ) assertTrue("clamped value must be far below the claim", pinned.modifiedAt < farFuture) } @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" }) } }