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.runBlocking10import org.junit.Assert.assertArrayEquals11import org.junit.Assert.assertEquals12import org.junit.Assert.assertFalse13import org.junit.Assert.assertTrue14import org.junit.Test15import java.io.ByteArrayInputStream16import java.io.ByteArrayOutputStream17181920212223class ChirpSyncSuiteTest {2425 private class Storage : VaultStorage {26 var slotA: StoredVault? = null27 var slotB: String? = null28 private var ver: Long = 029 private var settings = VaultSettings()30 override fun loadSlotA() = slotA31 override fun hasVault() = slotA != null32 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }33 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }34 var decoy: StoredVault? = null35 override fun loadDecoySlot() = decoy36 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }37 override fun hasDecoySlot() = decoy != null38 override fun clearDecoySlot() { decoy = null }39 override fun version() = ver40 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }41 override fun loadSettings() = settings42 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }43 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }44 }4546 4748 private fun roundTripFrame(payload: ByteArray): ByteArray {49 val out = ByteArrayOutputStream()50 writeFrame(out, payload)51 return readFrame(ByteArrayInputStream(out.toByteArray()))52 }5354 @Test fun frame_round_trips_small_payload() {55 val p = "noise-handshake-msg".toByteArray()56 assertArrayEquals(p, roundTripFrame(p))57 }5859 @Test fun frame_round_trips_empty_payload() {60 assertArrayEquals(ByteArray(0), roundTripFrame(ByteArray(0)))61 }6263 @Test fun frame_round_trips_various_sizes() {64 for (n in intArrayOf(1, 100, 4096, 65536)) {65 val p = ByteArray(n) { (it % 251).toByte() }66 assertArrayEquals("size $n", p, roundTripFrame(p))67 }68 }6970 @Test fun frame_round_trips_at_max_size() {71 val p = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() }72 assertEquals(MAX_PAIRING_FRAME_BYTES, roundTripFrame(p).size)73 }7475 @Test fun write_frame_rejects_oversize_payload() {76 val out = ByteArrayOutputStream()77 var threw = false78 try { writeFrame(out, ByteArray(MAX_PAIRING_FRAME_BYTES + 1)) } catch (_: IllegalArgumentException) { threw = true }79 assertTrue(threw)80 }8182 @Test fun read_frame_rejects_truncated_body() {83 84 val bytes = byteArrayOf(0, 0, 0, 100, 1, 2, 3)85 var threw = false86 try { readFrame(ByteArrayInputStream(bytes)) } catch (_: Exception) { threw = true }87 assertTrue(threw)88 }8990 9192 @Test fun sas_is_six_digits_zero_padded() {93 assertEquals("000000", SASDerivation.derive(byteArrayOf(0, 0, 0, 0)))94 assertEquals("000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 5)))95 }9697 @Test fun sas_kat_known_vectors() {98 99 assertEquals("999999", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x3F)))100 assertEquals("000000", SASDerivation.derive(byteArrayOf(0x00, 0x0F, 0x42, 0x40))) 101 assertEquals("967295", SASDerivation.derive(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())))102 }103104 @Test fun sas_ignores_bytes_past_the_first_four() {105 val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 9, 9, 9))106 val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 0, 0, 0))107 assertEquals(a, b)108 }109110 @Test fun sas_requires_at_least_four_bytes() {111 var threw = false112 try { SASDerivation.derive(byteArrayOf(1, 2, 3)) } catch (_: IllegalArgumentException) { threw = true }113 assertTrue(threw)114 }115116 117118 private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {119 val a = VaultRepository(Storage())120 runBlocking { a.createVault("shared-master-pass") }121 122 val b = VaultRepository(Storage())123 runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) }124 return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b"))125 }126127 @Test fun paired_devices_share_a_vault_id() = runBlocking {128 val (_, _, adapters) = pairedDevices()129 assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId())130 }131132 @Test fun entry_added_on_a_syncs_to_b() = runBlocking {133 val (a, b, adapters) = pairedDevices()134 val (adapterA, adapterB) = adapters135 val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0))136 val envelopes = adapterA.getEncryptedEntries(listOf(e.id))137 adapterB.applyEntries(envelopes)138 assertEquals("GitHub", b.getEntry(e.id)?.title)139 assertEquals("p", b.getEntry(e.id)?.password)140 }141142 @Test fun tombstone_from_a_deletes_on_b() = runBlocking {143 val (a, b, adapters) = pairedDevices()144 val (adapterA, adapterB) = adapters145 val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0))146 adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id)))147 assertEquals(1, b.getEntries().size)148 a.deleteEntry(e.id)149 adapterB.applyTombstones(adapterA.buildTombstoneManifest())150 assertEquals(0, b.getEntries().size)151 }152153 @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking {154 val (a, _, adapters) = pairedDevices()155 val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0))156 157 adapters.first.applyTombstones(mapOf(e.id to 0L))158 assertEquals(1, a.getEntries().size)159 }160161 @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking {162 val (a, b, adapters) = pairedDevices()163 val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0))164 val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id)165 adapters.second.applyEntries(mapOf("forged-id" to blob))166 assertFalse(b.getEntries().any { it.id == "forged-id" })167 }168}