1package com.peachpasswords.sync23import com.peachpasswords.vault.StoredVault4import com.peachpasswords.vault.VaultEntry5import com.peachpasswords.vault.VaultRepository6import com.peachpasswords.vault.VaultSettings7import com.peachpasswords.vault.VaultStorage8import kotlinx.coroutines.runBlocking9import org.junit.Assert.assertEquals10import org.junit.Assert.assertNull11import org.junit.Assert.assertTrue12import org.junit.Test1314private class FakeStorage : VaultStorage {15 private var slotA: StoredVault? = null16 private var slotB: String? = null17 private var ver: Long = 018 private var settings = VaultSettings()19 override fun loadSlotA() = slotA20 override fun hasVault() = slotA != null21 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }22 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }23 private var decoy: StoredVault? = null24 override fun loadDecoySlot() = decoy25 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }26 override fun hasDecoySlot() = decoy != null27 override fun clearDecoySlot() { decoy = null }28 override fun version() = ver29 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }30 override fun loadSettings() = settings31 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }32 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }33}3435class VaultSyncAdapterTest {36 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {37 val repo = VaultRepository(FakeStorage())38 runBlocking { repo.createVault("correct horse") }39 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")40 }4142 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {43 val (repo, adapter) = newAdapter()44 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))45 val manifest = adapter.buildEntryManifest()46 assertEquals(setOf(e.id), manifest.keys)47 assertTrue(adapter.computeVaultId().length == 64) 48 }4950 @Test fun envelope_round_trips_within_the_vault() = runBlocking {51 val (repo, adapter) = newAdapter()52 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))53 val envelopes = adapter.getEncryptedEntries(listOf(e.id))54 assertTrue(envelopes.containsKey(e.id))55 56 adapter.applyEntries(envelopes)57 assertEquals(1, repo.getEntries().size)58 }5960 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {61 val (repo, adapter) = newAdapter()62 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))63 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)64 65 66 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))67 assertNull(repo.getEntry("attacker-chosen-id"))68 assertEquals(1, repo.getEntries().size)69 }7071 72 73 74 75 @Test fun legacy_info_envelope_fails_closed() = runBlocking {76 val (repo, adapter) = newAdapter()77 val master = repo.syncMasterKey()!!78 val entryId = "11111111-2222-4333-8444-555555555555"79 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")8081 82 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)83 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())84 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))85 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }8687 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }88 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)89 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }90 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")91 cipher.init(92 javax.crypto.Cipher.ENCRYPT_MODE,93 javax.crypto.spec.SecretKeySpec(key, "AES"),94 javax.crypto.spec.GCMParameterSpec(128, nonce),95 )96 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))97 val ct = cipher.doFinal(plaintext)98 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)99100 adapter.applyEntries(mapOf(entryId to legacyEnvelope))101 102 assertNull(repo.getEntry(entryId))103 }104}