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 class FakeDeviceRegistry(37 devices: List<PairedDevice> = emptyList(),38 ) : PairedDeviceRegistry {39 private val byId = devices.associateBy { it.deviceId }.toMutableMap()40 override fun getAll(): List<PairedDevice> = byId.values.toList()41 override fun get(id: String): PairedDevice? = byId[id]42 override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }43 }4445 private class FakePendingRegistry : PendingVouchedRegistry {46 val devices = mutableListOf<PendingVouchedDevice>()47 override fun getAll(): List<PendingVouchedDevice> = devices.toList()48 override fun upsert(device: PendingVouchedDevice) {49 devices.removeAll { it.deviceId == device.deviceId }50 devices += device51 }52 override fun remove(id: String) { devices.removeAll { it.deviceId == id } }53 }5455 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {56 val repo = VaultRepository(FakeStorage())57 runBlocking { repo.createVault("correct horse") }58 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")59 }6061 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {62 val (repo, adapter) = newAdapter()63 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))64 val manifest = adapter.buildEntryManifest()65 assertEquals(setOf(e.id), manifest.keys)66 assertTrue(adapter.computeVaultId().length == 64) 67 }6869 @Test fun envelope_round_trips_within_the_vault() = runBlocking {70 val (repo, adapter) = newAdapter()71 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))72 val envelopes = adapter.getEncryptedEntries(listOf(e.id))73 assertTrue(envelopes.containsKey(e.id))74 75 adapter.applyEntries(envelopes)76 assertEquals(1, repo.getEntries().size)77 }7879 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {80 val (repo, adapter) = newAdapter()81 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))82 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)83 84 85 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))86 assertNull(repo.getEntry("attacker-chosen-id"))87 assertEquals(1, repo.getEntries().size)88 }8990 91 92 93 94 @Test fun legacy_info_envelope_fails_closed() = runBlocking {95 val (repo, adapter) = newAdapter()96 val master = repo.syncMasterKey()!!97 val entryId = "11111111-2222-4333-8444-555555555555"98 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")99100 101 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)102 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())103 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))104 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }105106 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }107 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)108 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }109 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")110 cipher.init(111 javax.crypto.Cipher.ENCRYPT_MODE,112 javax.crypto.spec.SecretKeySpec(key, "AES"),113 javax.crypto.spec.GCMParameterSpec(128, nonce),114 )115 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))116 val ct = cipher.doFinal(plaintext)117 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)118119 adapter.applyEntries(mapOf(entryId to legacyEnvelope))120 121 assertNull(repo.getEntry(entryId))122 }123124 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {125 val master = ByteArray(32) { it.toByte() }126 val signing = deriveSyncSigningKeypair(master, "extension-issuer")127 val manifest = buildSignedDeviceManifest(128 issuerDeviceId = "extension-issuer",129 devices = listOf(130 ManifestDeviceEntry(131 deviceId = "android-peer",132 deviceName = "Phone",133 deviceType = "android",134 longTermPublicKeyHex = "aa".repeat(32),135 signingPublicKeyHex = "bb".repeat(32),136 pairedAt = 123L,137 ),138 ),139 signingPrivateKey = signing.privateKey,140 nowMs = 1_000L,141 )142143 val verified = verifyDeviceManifest(144 manifest,145 signing.publicKeyRaw,146 nowMs = 1_000L,147 )148149 assertEquals("extension-issuer", verified?.issuerDeviceId)150 assertEquals("android-peer", verified?.devices?.single()?.deviceId)151 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)152 }153154 @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {155 val repo = VaultRepository(FakeStorage())156 repo.createVault("correct horse")157 val master = repo.syncMasterKey()!!158 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")159 val paired = FakeDeviceRegistry(160 listOf(161 PairedDevice(162 deviceId = "extension-issuer",163 deviceName = "Peach Browser",164 deviceType = "extension",165 longTermPublicKeyHex = "11".repeat(32),166 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),167 trustSource = "direct",168 pairedAt = 1L,169 ),170 ),171 )172 val pending = FakePendingRegistry()173 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)174 val manifest = buildSignedDeviceManifest(175 issuerDeviceId = "extension-issuer",176 devices = listOf(177 ManifestDeviceEntry(178 deviceId = "new-phone",179 deviceName = "June's Phone",180 deviceType = "android",181 longTermPublicKeyHex = "aa".repeat(32),182 signingPublicKeyHex = "bb".repeat(32),183 pairedAt = 44L,184 ),185 ),186 signingPrivateKey = issuerSigning.privateKey,187 )188189 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })190191 assertNull(paired.get("new-phone"))192 assertEquals(1, pending.devices.size)193 assertEquals("new-phone", pending.devices.single().deviceId)194 assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })195 master.fill(0)196 }197 198199200201202203204 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {205 val repo = VaultRepository(FakeStorage())206 repo.createVault("correct horse")207 val master = repo.syncMasterKey()!!208 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")209 val paired = FakeDeviceRegistry(210 listOf(211 PairedDevice(212 deviceId = "phone-issuer",213 deviceName = "Other Phone",214 deviceType = "android",215 longTermPublicKeyHex = "11".repeat(32),216 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),217 trustSource = "direct",218 pairedAt = 1L,219 ),220 ),221 )222 val pending = FakePendingRegistry()223 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)224 val manifest = buildSignedDeviceManifest(225 issuerDeviceId = "phone-issuer",226 devices = listOf(227 ManifestDeviceEntry(228 deviceId = "laptop-b",229 deviceName = "Laptop B",230 deviceType = "extension",231 longTermPublicKeyHex = "aa".repeat(32),232 signingPublicKeyHex = "bb".repeat(32),233 pairedAt = 44L,234 ),235 ),236 signingPrivateKey = issuerSigning.privateKey,237 )238239 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })240241 assertEquals(1, pending.devices.size)242 assertEquals("laptop-b", pending.devices.single().deviceId)243 master.fill(0)244 }245246 247248249250251 @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {252 val repo = VaultRepository(FakeStorage())253 repo.createVault("correct horse")254 val master = repo.syncMasterKey()!!255 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")256 val paired = FakeDeviceRegistry(257 listOf(258 PairedDevice(259 deviceId = "vouched-issuer",260 deviceName = "Vouched Device",261 deviceType = "extension",262 longTermPublicKeyHex = "11".repeat(32),263 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),264 trustSource = "vouched",265 pairedAt = 1L,266 ),267 ),268 )269 val pending = FakePendingRegistry()270 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)271 val manifest = buildSignedDeviceManifest(272 issuerDeviceId = "vouched-issuer",273 devices = listOf(274 ManifestDeviceEntry(275 deviceId = "smuggled-device",276 deviceName = "Smuggled",277 deviceType = "extension",278 longTermPublicKeyHex = "aa".repeat(32),279 signingPublicKeyHex = "bb".repeat(32),280 pairedAt = 44L,281 ),282 ),283 signingPrivateKey = issuerSigning.privateKey,284 )285286 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })287288 assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)289 master.fill(0)290 }291292 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {293 val repo = VaultRepository(FakeStorage())294 repo.createVault("correct horse")295 val master = repo.syncMasterKey()!!296 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")297 val paired = FakeDeviceRegistry(298 listOf(299 PairedDevice(300 deviceId = "claimed-issuer",301 deviceName = "Claimed Browser",302 deviceType = "extension",303 longTermPublicKeyHex = "11".repeat(32),304 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),305 trustSource = "direct",306 pairedAt = 1L,307 ),308 PairedDevice(309 deviceId = "authenticated-peer",310 deviceName = "Authenticated Phone",311 deviceType = "android",312 longTermPublicKeyHex = "22".repeat(32),313 signingPublicKeyHex = "33".repeat(32),314 trustSource = "direct",315 pairedAt = 2L,316 ),317 ),318 )319 val pending = FakePendingRegistry()320 val adapter = VaultSyncAdapter(321 repo,322 ourDeviceId = "android-self",323 pairedDevices = paired,324 pendingVouched = pending,325 )326 val manifest = buildSignedDeviceManifest(327 issuerDeviceId = "claimed-issuer",328 devices = listOf(329 ManifestDeviceEntry(330 deviceId = "smuggled-device",331 deviceName = "Smuggled",332 deviceType = "extension",333 longTermPublicKeyHex = "aa".repeat(32),334 signingPublicKeyHex = "bb".repeat(32),335 pairedAt = 44L,336 ),337 ),338 signingPrivateKey = claimedSigning.privateKey,339 )340341 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })342343 assertEquals(0, pending.devices.size)344 master.fill(0)345 }346347 @Test fun `public key decoder requires exactly 32 bytes`() {348 assertEquals(32, hexToPubkey("ab".repeat(32)).size)349 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)350 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)351 assertEquals(64, hexToBytes("ab".repeat(64)).size)352 }353}