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 ChirpSyncSuiteTest {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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }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 sas_gate_never_completes_without_explicit_user_confirmation() = runBlocking {121 val session = PairingSession()122 val waiter = async { session.await() }123 yield()124 assertFalse("SAS session must not auto-confirm", waiter.isCompleted)125 session.confirm(true)126 assertTrue(waiter.await())127 }128129 130131 private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {132 val a = VaultRepository(Storage())133 runBlocking { a.createVault("shared-master-pass") }134 135 val b = VaultRepository(Storage())136 runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) }137 return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b"))138 }139140 @Test fun paired_devices_share_a_vault_id() = runBlocking {141 val (_, _, adapters) = pairedDevices()142 assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId())143 }144145 @Test fun entry_added_on_a_syncs_to_b() = runBlocking {146 val (a, b, adapters) = pairedDevices()147 val (adapterA, adapterB) = adapters148 val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0))149 val envelopes = adapterA.getEncryptedEntries(listOf(e.id))150 adapterB.applyEntries(envelopes)151 assertEquals("GitHub", b.getEntry(e.id)?.title)152 assertEquals("p", b.getEntry(e.id)?.password)153 }154155 @Test fun tombstone_from_a_deletes_on_b() = runBlocking {156 val (a, b, adapters) = pairedDevices()157 val (adapterA, adapterB) = adapters158 val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0))159 adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id)))160 assertEquals(1, b.getEntries().size)161 a.deleteEntry(e.id)162 adapterB.applyTombstones(adapterA.buildTombstoneManifest())163 assertEquals(0, b.getEntries().size)164 }165166 @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking {167 val (a, _, adapters) = pairedDevices()168 val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0))169 170 adapters.first.applyTombstones(mapOf(e.id to 0L))171 assertEquals(1, a.getEntries().size)172 }173174 @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking {175 val (a, b, adapters) = pairedDevices()176 val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0))177 val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id)178 adapters.second.applyEntries(mapOf("forged-id" to blob))179 assertFalse(b.getEntries().any { it.id == "forged-id" })180 }181}