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.ByteArrayOutputStream19202122232425class 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 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 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 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 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 127 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 135 136 137 138 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 157158 private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {159 val a = VaultRepository(Storage())160 runBlocking { a.createVault("shared-master-pass") }161 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 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}