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 private val revocations = mutableListOf<DeviceRevocation>()41 override fun getAll(): List<PairedDevice> = byId.values.toList()42 override fun get(id: String): PairedDevice? = byId[id]43 override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }44 override fun remove(deviceId: String) { byId.remove(deviceId) }45 override fun listRevocations(): List<DeviceRevocation> = revocations.toList()46 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {47 val known = revocations.map { it.deviceId }.toSet()48 val fresh = incoming.filterNot { known.contains(it.deviceId) }49 revocations += fresh50 return fresh.map { it.deviceId }51 }52 override fun clearRevocation(deviceId: String) {53 revocations.removeAll { it.deviceId == deviceId }54 }55 }5657 private class FakePendingRegistry : PendingVouchedRegistry {58 val devices = mutableListOf<PendingVouchedDevice>()59 override fun getAll(): List<PendingVouchedDevice> = devices.toList()60 override fun upsert(device: PendingVouchedDevice) {61 devices.removeAll { it.deviceId == device.deviceId }62 devices += device63 }64 override fun remove(id: String) { devices.removeAll { it.deviceId == id } }65 }6667 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {68 val repo = VaultRepository(FakeStorage())69 runBlocking { repo.createVault("correct horse") }70 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")71 }7273 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {74 val (repo, adapter) = newAdapter()75 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))76 val manifest = adapter.buildEntryManifest()77 assertEquals(setOf(e.id), manifest.keys)78 assertTrue(adapter.computeVaultId().length == 64) 79 }8081 @Test fun envelope_round_trips_within_the_vault() = runBlocking {82 val (repo, adapter) = newAdapter()83 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))84 val envelopes = adapter.getEncryptedEntries(listOf(e.id))85 assertTrue(envelopes.containsKey(e.id))86 87 adapter.applyEntries(envelopes)88 assertEquals(1, repo.getEntries().size)89 }9091 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {92 val (repo, adapter) = newAdapter()93 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))94 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)95 96 97 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))98 assertNull(repo.getEntry("attacker-chosen-id"))99 assertEquals(1, repo.getEntries().size)100 }101102 103 104 105 106 @Test fun legacy_info_envelope_fails_closed() = runBlocking {107 val (repo, adapter) = newAdapter()108 val master = repo.syncMasterKey()!!109 val entryId = "11111111-2222-4333-8444-555555555555"110 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")111112 113 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)114 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())115 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))116 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }117118 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }119 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)120 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }121 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")122 cipher.init(123 javax.crypto.Cipher.ENCRYPT_MODE,124 javax.crypto.spec.SecretKeySpec(key, "AES"),125 javax.crypto.spec.GCMParameterSpec(128, nonce),126 )127 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))128 val ct = cipher.doFinal(plaintext)129 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)130131 adapter.applyEntries(mapOf(entryId to legacyEnvelope))132 133 assertNull(repo.getEntry(entryId))134 }135136 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {137 val master = ByteArray(32) { it.toByte() }138 val signing = deriveSyncSigningKeypair(master, "extension-issuer")139 val manifest = buildSignedDeviceManifest(140 issuerDeviceId = "extension-issuer",141 devices = listOf(142 ManifestDeviceEntry(143 deviceId = "android-peer",144 deviceName = "Phone",145 deviceType = "android",146 longTermPublicKeyHex = "aa".repeat(32),147 signingPublicKeyHex = "bb".repeat(32),148 pairedAt = 123L,149 ),150 ),151 signingPrivateKey = signing.privateKey,152 nowMs = 1_000L,153 )154155 val verified = verifyDeviceManifest(156 manifest,157 signing.publicKeyRaw,158 nowMs = 1_000L,159 )160161 assertEquals("extension-issuer", verified?.issuerDeviceId)162 assertEquals("android-peer", verified?.devices?.single()?.deviceId)163 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)164 }165166 @Test fun `revocation evicts the device and permanently blocks re-vouching`() = runBlocking {167 val repo = VaultRepository(FakeStorage())168 repo.createVault("correct horse")169 val master = repo.syncMasterKey()!!170 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")171 val paired = FakeDeviceRegistry(172 listOf(173 PairedDevice(174 deviceId = "extension-issuer",175 deviceName = "Peach Browser",176 deviceType = "extension",177 longTermPublicKeyHex = "11".repeat(32),178 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),179 trustSource = "direct",180 pairedAt = 1L,181 ),182 PairedDevice(183 deviceId = "stolen-laptop",184 deviceName = "Old Laptop",185 deviceType = "extension",186 longTermPublicKeyHex = "dd".repeat(32),187 signingPublicKeyHex = "ee".repeat(32),188 trustSource = "direct",189 pairedAt = 2L,190 ),191 ),192 )193 val pending = FakePendingRegistry()194 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)195196 val revocation = buildSignedDeviceManifest(197 issuerDeviceId = "extension-issuer",198 devices = emptyList(),199 signingPrivateKey = issuerSigning.privateKey,200 revoked = listOf(201 DeviceRevocation(202 deviceId = "stolen-laptop",203 longTermPublicKeyHex = "dd".repeat(32),204 revokedAt = System.currentTimeMillis(),205 revokedByDeviceId = "extension-issuer",206 ),207 ),208 )209 adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })210211 assertNull(paired.get("stolen-laptop"))212213 214 215 val revouch = buildSignedDeviceManifest(216 issuerDeviceId = "extension-issuer",217 devices = listOf(218 ManifestDeviceEntry(219 deviceId = "stolen-laptop",220 deviceName = "Old Laptop",221 deviceType = "extension",222 longTermPublicKeyHex = "dd".repeat(32),223 signingPublicKeyHex = "ee".repeat(32),224 pairedAt = System.currentTimeMillis() + 60_000L,225 ),226 ),227 signingPrivateKey = issuerSigning.privateKey,228 )229 adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })230231 assertNull(paired.get("stolen-laptop"))232 assertEquals(0, pending.devices.size)233 master.fill(0)234 }235236 @Test fun `a peer cannot revoke this device`() = runBlocking {237 val repo = VaultRepository(FakeStorage())238 repo.createVault("correct horse")239 val master = repo.syncMasterKey()!!240 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")241 val paired = FakeDeviceRegistry(242 listOf(243 PairedDevice(244 deviceId = "extension-issuer",245 deviceName = "Peach Browser",246 deviceType = "extension",247 longTermPublicKeyHex = "11".repeat(32),248 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),249 trustSource = "direct",250 pairedAt = 1L,251 ),252 ),253 )254 val pending = FakePendingRegistry()255 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)256257 val selfRevoke = buildSignedDeviceManifest(258 issuerDeviceId = "extension-issuer",259 devices = emptyList(),260 signingPrivateKey = issuerSigning.privateKey,261 revoked = listOf(262 DeviceRevocation(263 deviceId = "android-self",264 longTermPublicKeyHex = "ff".repeat(32),265 revokedAt = System.currentTimeMillis(),266 revokedByDeviceId = "extension-issuer",267 ),268 ),269 )270 adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })271272 assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })273 master.fill(0)274 }275276 @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {277 val repo = VaultRepository(FakeStorage())278 repo.createVault("correct horse")279 val master = repo.syncMasterKey()!!280 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")281 val paired = FakeDeviceRegistry(282 listOf(283 PairedDevice(284 deviceId = "extension-issuer",285 deviceName = "Peach Browser",286 deviceType = "extension",287 longTermPublicKeyHex = "11".repeat(32),288 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),289 trustSource = "direct",290 pairedAt = 1L,291 ),292 ),293 )294 val pending = FakePendingRegistry()295 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)296 val manifest = buildSignedDeviceManifest(297 issuerDeviceId = "extension-issuer",298 devices = listOf(299 ManifestDeviceEntry(300 deviceId = "new-phone",301 deviceName = "June's Phone",302 deviceType = "android",303 longTermPublicKeyHex = "aa".repeat(32),304 signingPublicKeyHex = "bb".repeat(32),305 pairedAt = 44L,306 ),307 ),308 signingPrivateKey = issuerSigning.privateKey,309 )310311 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })312313 assertNull(paired.get("new-phone"))314 assertEquals(1, pending.devices.size)315 assertEquals("new-phone", pending.devices.single().deviceId)316 assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })317 master.fill(0)318 }319 320321322323324325326 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {327 val repo = VaultRepository(FakeStorage())328 repo.createVault("correct horse")329 val master = repo.syncMasterKey()!!330 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")331 val paired = FakeDeviceRegistry(332 listOf(333 PairedDevice(334 deviceId = "phone-issuer",335 deviceName = "Other Phone",336 deviceType = "android",337 longTermPublicKeyHex = "11".repeat(32),338 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),339 trustSource = "direct",340 pairedAt = 1L,341 ),342 ),343 )344 val pending = FakePendingRegistry()345 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)346 val manifest = buildSignedDeviceManifest(347 issuerDeviceId = "phone-issuer",348 devices = listOf(349 ManifestDeviceEntry(350 deviceId = "laptop-b",351 deviceName = "Laptop B",352 deviceType = "extension",353 longTermPublicKeyHex = "aa".repeat(32),354 signingPublicKeyHex = "bb".repeat(32),355 pairedAt = 44L,356 ),357 ),358 signingPrivateKey = issuerSigning.privateKey,359 )360361 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })362363 assertEquals(1, pending.devices.size)364 assertEquals("laptop-b", pending.devices.single().deviceId)365 master.fill(0)366 }367368 369370371372373 @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {374 val repo = VaultRepository(FakeStorage())375 repo.createVault("correct horse")376 val master = repo.syncMasterKey()!!377 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")378 val paired = FakeDeviceRegistry(379 listOf(380 PairedDevice(381 deviceId = "vouched-issuer",382 deviceName = "Vouched Device",383 deviceType = "extension",384 longTermPublicKeyHex = "11".repeat(32),385 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),386 trustSource = "vouched",387 pairedAt = 1L,388 ),389 ),390 )391 val pending = FakePendingRegistry()392 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)393 val manifest = buildSignedDeviceManifest(394 issuerDeviceId = "vouched-issuer",395 devices = listOf(396 ManifestDeviceEntry(397 deviceId = "smuggled-device",398 deviceName = "Smuggled",399 deviceType = "extension",400 longTermPublicKeyHex = "aa".repeat(32),401 signingPublicKeyHex = "bb".repeat(32),402 pairedAt = 44L,403 ),404 ),405 signingPrivateKey = issuerSigning.privateKey,406 )407408 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })409410 assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)411 master.fill(0)412 }413414 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {415 val repo = VaultRepository(FakeStorage())416 repo.createVault("correct horse")417 val master = repo.syncMasterKey()!!418 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")419 val paired = FakeDeviceRegistry(420 listOf(421 PairedDevice(422 deviceId = "claimed-issuer",423 deviceName = "Claimed Browser",424 deviceType = "extension",425 longTermPublicKeyHex = "11".repeat(32),426 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),427 trustSource = "direct",428 pairedAt = 1L,429 ),430 PairedDevice(431 deviceId = "authenticated-peer",432 deviceName = "Authenticated Phone",433 deviceType = "android",434 longTermPublicKeyHex = "22".repeat(32),435 signingPublicKeyHex = "33".repeat(32),436 trustSource = "direct",437 pairedAt = 2L,438 ),439 ),440 )441 val pending = FakePendingRegistry()442 val adapter = VaultSyncAdapter(443 repo,444 ourDeviceId = "android-self",445 pairedDevices = paired,446 pendingVouched = pending,447 )448 val manifest = buildSignedDeviceManifest(449 issuerDeviceId = "claimed-issuer",450 devices = listOf(451 ManifestDeviceEntry(452 deviceId = "smuggled-device",453 deviceName = "Smuggled",454 deviceType = "extension",455 longTermPublicKeyHex = "aa".repeat(32),456 signingPublicKeyHex = "bb".repeat(32),457 pairedAt = 44L,458 ),459 ),460 signingPrivateKey = claimedSigning.privateKey,461 )462463 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })464465 assertEquals(0, pending.devices.size)466 master.fill(0)467 }468469 @Test fun `public key decoder requires exactly 32 bytes`() {470 assertEquals(32, hexToPubkey("ab".repeat(32)).size)471 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)472 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)473 assertEquals(64, hexToBytes("ab".repeat(64)).size)474 }475}