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.assertFalse11import org.junit.Assert.assertNull12import org.junit.Assert.assertTrue13import org.junit.Test1415private class FakeStorage : VaultStorage {16 private var slotA: StoredVault? = null17 private var slotB: String? = null18 private var ver: Long = 019 private var settings = VaultSettings()20 override fun loadSlotA() = slotA21 override fun hasVault() = slotA != null22 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }23 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }24 private var decoy: StoredVault? = null25 override fun loadDecoySlot() = decoy26 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }27 override fun hasDecoySlot() = decoy != null28 override fun clearDecoySlot() { decoy = null }29 override fun version() = ver30 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }31 override fun loadSettings() = settings32 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }33 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }34}3536class VaultSyncAdapterTest {37 private val meshRelay = "wss://relay.peachpasswords.com"38 private class FakeDeviceRegistry(39 devices: List<PairedDevice> = emptyList(),40 ) : PairedDeviceRegistry {41 var failManifestCommit = false42 private val byId = devices.associateBy { it.deviceId }.toMutableMap()43 private val revocations = mutableListOf<DeviceRevocation>()44 override fun getAll(): List<PairedDevice> = byId.values.toList()45 override fun get(id: String): PairedDevice? = byId[id]46 override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }47 override fun remove(deviceId: String) { byId.remove(deviceId) }48 override fun listRevocations(): List<DeviceRevocation> = revocations.toList()49 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {50 if (failManifestCommit) return false51 val nextRevocations = revocations.toMutableList()52 val known = nextRevocations.map { it.deviceId }.toSet()53 nextRevocations += batch.incomingRevocations.filterNot { known.contains(it.deviceId) }54 val nextDevices = byId.toMutableMap()55 batch.removals.forEach(nextDevices::remove)56 batch.upserts.forEach { nextDevices[it.deviceId] = it }57 revocations.clear()58 revocations += nextRevocations59 byId.clear()60 byId.putAll(nextDevices)61 return true62 }63 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {64 val known = revocations.map { it.deviceId }.toSet()65 val fresh = incoming.filterNot { known.contains(it.deviceId) }66 revocations += fresh67 return fresh.map { it.deviceId }68 }69 override fun clearRevocation(deviceId: String) {70 revocations.removeAll { it.deviceId == deviceId }71 }72 }7374 private class FakePendingRegistry : PendingVouchedRegistry {75 val devices = mutableListOf<PendingVouchedDevice>()76 override fun getAll(): List<PendingVouchedDevice> = devices.toList()77 override fun upsert(device: PendingVouchedDevice) {78 devices.removeAll { it.deviceId == device.deviceId }79 devices += device80 }81 override fun remove(id: String) { devices.removeAll { it.deviceId == id } }82 }8384 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {85 val repo = VaultRepository(FakeStorage())86 runBlocking { repo.createVault("correct horse") }87 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")88 }8990 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {91 val (repo, adapter) = newAdapter()92 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))93 val manifest = adapter.buildEntryManifest()94 assertEquals(setOf(e.id), manifest.keys)95 assertTrue(adapter.computeVaultId().length == 64) 96 }9798 @Test fun envelope_round_trips_within_the_vault() = runBlocking {99 val (repo, adapter) = newAdapter()100 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))101 val envelopes = adapter.getEncryptedEntries(listOf(e.id))102 assertTrue(envelopes.containsKey(e.id))103 104 adapter.applyEntries(envelopes)105 assertEquals(1, repo.getEntries().size)106 }107108 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {109 val (repo, adapter) = newAdapter()110 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))111 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)112 113 114 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))115 assertNull(repo.getEntry("attacker-chosen-id"))116 assertEquals(1, repo.getEntries().size)117 }118119 120 121 122 123 @Test fun legacy_info_envelope_fails_closed() = runBlocking {124 val (repo, adapter) = newAdapter()125 val master = repo.syncMasterKey()!!126 val entryId = "11111111-2222-4333-8444-555555555555"127 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")128129 130 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)131 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())132 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))133 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }134135 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }136 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)137 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }138 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")139 cipher.init(140 javax.crypto.Cipher.ENCRYPT_MODE,141 javax.crypto.spec.SecretKeySpec(key, "AES"),142 javax.crypto.spec.GCMParameterSpec(128, nonce),143 )144 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))145 val ct = cipher.doFinal(plaintext)146 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)147148 adapter.applyEntries(mapOf(entryId to legacyEnvelope))149 150 assertNull(repo.getEntry(entryId))151 }152153 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {154 val master = ByteArray(32) { it.toByte() }155 val signing = deriveSyncSigningKeypair(master, "extension-issuer")156 val manifest = buildSignedDeviceManifest(157 issuerDeviceId = "extension-issuer",158 devices = listOf(159 ManifestDeviceEntry(160 deviceId = "android-peer",161 deviceName = "Phone",162 deviceType = "android",163 longTermPublicKeyHex = "aa".repeat(32),164 signingPublicKeyHex = "bb".repeat(32),165 pairedAt = 123L,166 ),167 ),168 signingPrivateKey = signing.privateKey,169 nowMs = 1_000L,170 )171172 val verified = verifyDeviceManifest(173 manifest,174 signing.publicKeyRaw,175 nowMs = 1_000L,176 )177178 assertEquals("extension-issuer", verified?.issuerDeviceId)179 assertEquals("android-peer", verified?.devices?.single()?.deviceId)180 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)181 }182183 @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {184 val repo = VaultRepository(FakeStorage())185 repo.createVault("correct horse")186 val master = repo.syncMasterKey()!!187 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")188 val paired = FakeDeviceRegistry(189 listOf(190 PairedDevice(191 deviceId = "extension-issuer",192 deviceName = "Peach Browser",193 deviceType = "extension",194 longTermPublicKeyHex = "11".repeat(32),195 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),196 trustSource = "vouched",197 pairedAt = 1L,198 ),199 PairedDevice(200 deviceId = "stolen-laptop",201 deviceName = "Old Laptop",202 deviceType = "extension",203 longTermPublicKeyHex = "dd".repeat(32),204 signingPublicKeyHex = "ee".repeat(32),205 trustSource = "direct",206 pairedAt = 2L,207 ),208 ),209 )210 val pending = FakePendingRegistry()211 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)212213 val revocation = buildSignedDeviceManifest(214 issuerDeviceId = "extension-issuer",215 devices = emptyList(),216 signingPrivateKey = issuerSigning.privateKey,217 revoked = listOf(218 DeviceRevocation(219 deviceId = "stolen-laptop",220 longTermPublicKeyHex = "dd".repeat(32),221 revokedAt = System.currentTimeMillis(),222 revokedByDeviceId = "extension-issuer",223 ),224 ),225 )226 adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })227228 assertNull(paired.get("stolen-laptop"))229230 231 232 val revouch = buildSignedDeviceManifest(233 issuerDeviceId = "extension-issuer",234 devices = listOf(235 ManifestDeviceEntry(236 deviceId = "stolen-laptop",237 deviceName = "Old Laptop",238 deviceType = "extension",239 longTermPublicKeyHex = "dd".repeat(32),240 signingPublicKeyHex = "ee".repeat(32),241 pairedAt = System.currentTimeMillis() + 60_000L,242 ),243 ),244 signingPrivateKey = issuerSigning.privateKey,245 )246 adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })247248 assertNull(paired.get("stolen-laptop"))249 assertEquals(0, pending.devices.size)250 master.fill(0)251 }252253 @Test fun `a peer cannot revoke this device`() = runBlocking {254 val repo = VaultRepository(FakeStorage())255 repo.createVault("correct horse")256 val master = repo.syncMasterKey()!!257 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")258 val paired = FakeDeviceRegistry(259 listOf(260 PairedDevice(261 deviceId = "extension-issuer",262 deviceName = "Peach Browser",263 deviceType = "extension",264 longTermPublicKeyHex = "11".repeat(32),265 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),266 trustSource = "direct",267 pairedAt = 1L,268 ),269 ),270 )271 val pending = FakePendingRegistry()272 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)273274 val selfRevoke = buildSignedDeviceManifest(275 issuerDeviceId = "extension-issuer",276 devices = emptyList(),277 signingPrivateKey = issuerSigning.privateKey,278 revoked = listOf(279 DeviceRevocation(280 deviceId = "android-self",281 longTermPublicKeyHex = "ff".repeat(32),282 revokedAt = System.currentTimeMillis(),283 revokedByDeviceId = "extension-issuer",284 ),285 ),286 )287 adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })288289 assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })290 master.fill(0)291 }292293 @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {294 val repo = VaultRepository(FakeStorage())295 repo.createVault("correct horse")296 val master = repo.syncMasterKey()!!297 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")298 val paired = FakeDeviceRegistry(299 listOf(300 PairedDevice(301 deviceId = "extension-issuer",302 deviceName = "Peach Browser",303 deviceType = "extension",304 longTermPublicKeyHex = "11".repeat(32),305 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),306 trustSource = "direct",307 pairedAt = 1L,308 ),309 ),310 )311 val pending = FakePendingRegistry()312 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)313 val manifest = buildSignedDeviceManifest(314 issuerDeviceId = "extension-issuer",315 devices = listOf(316 ManifestDeviceEntry(317 deviceId = "new-phone",318 deviceName = "June's Phone",319 deviceType = "android",320 longTermPublicKeyHex = "aa".repeat(32),321 signingPublicKeyHex = "bb".repeat(32),322 relayUrl = meshRelay,323 pairedAt = 44L,324 ),325 ),326 signingPrivateKey = issuerSigning.privateKey,327 )328329 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })330331 val admitted = paired.get("new-phone")332 assertEquals("vouched", admitted?.trustSource)333 assertEquals(meshRelay, admitted?.relayUrl)334 assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)335 assertTrue(pending.devices.isEmpty())336 master.fill(0)337 }338339 @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {340 val repo = VaultRepository(FakeStorage())341 repo.createVault("correct horse")342 val master = repo.syncMasterKey()!!343 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")344 val paired = FakeDeviceRegistry(345 listOf(346 PairedDevice(347 deviceId = "extension-issuer",348 deviceName = "Peach Browser",349 deviceType = "extension",350 longTermPublicKeyHex = "11".repeat(32),351 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),352 trustSource = "direct",353 pairedAt = 1L,354 ),355 ),356 ).also { it.failManifestCommit = true }357 val pending = FakePendingRegistry().also {358 it.upsert(359 PendingVouchedDevice(360 deviceId = "new-phone",361 deviceName = "June's Phone",362 deviceType = "android",363 longTermPublicKeyHex = "aa".repeat(32),364 signingPublicKeyHex = "bb".repeat(32),365 pairedAt = 44L,366 receivedAt = 45L,367 vouchedByDeviceId = "extension-issuer",368 ),369 )370 }371 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)372 val manifest = buildSignedDeviceManifest(373 issuerDeviceId = "extension-issuer",374 devices = listOf(375 ManifestDeviceEntry(376 deviceId = "new-phone",377 deviceName = "June's Phone",378 deviceType = "android",379 longTermPublicKeyHex = "aa".repeat(32),380 signingPublicKeyHex = "bb".repeat(32),381 relayUrl = meshRelay,382 pairedAt = 44L,383 ),384 ),385 signingPrivateKey = issuerSigning.privateKey,386 )387388 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })389390 assertNull("failed registry commit must not install trust", paired.get("new-phone"))391 assertEquals("pending introduction is restored for retry", 1, pending.devices.size)392 master.fill(0)393 }394 395396397398399400401 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {402 val repo = VaultRepository(FakeStorage())403 repo.createVault("correct horse")404 val master = repo.syncMasterKey()!!405 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")406 val paired = FakeDeviceRegistry(407 listOf(408 PairedDevice(409 deviceId = "phone-issuer",410 deviceName = "Other Phone",411 deviceType = "android",412 longTermPublicKeyHex = "11".repeat(32),413 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),414 trustSource = "direct",415 pairedAt = 1L,416 ),417 ),418 )419 val pending = FakePendingRegistry()420 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)421 val manifest = buildSignedDeviceManifest(422 issuerDeviceId = "phone-issuer",423 devices = listOf(424 ManifestDeviceEntry(425 deviceId = "laptop-b",426 deviceName = "Laptop B",427 deviceType = "extension",428 longTermPublicKeyHex = "aa".repeat(32),429 signingPublicKeyHex = "bb".repeat(32),430 relayUrl = meshRelay,431 pairedAt = 44L,432 ),433 ),434 signingPrivateKey = issuerSigning.privateKey,435 )436437 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })438439 assertEquals("vouched", paired.get("laptop-b")?.trustSource)440 assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)441 assertTrue(pending.devices.isEmpty())442 master.fill(0)443 }444445 446 @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {447 val repo = VaultRepository(FakeStorage())448 repo.createVault("correct horse")449 val master = repo.syncMasterKey()!!450 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")451 val paired = FakeDeviceRegistry(452 listOf(453 PairedDevice(454 deviceId = "vouched-issuer",455 deviceName = "Vouched Device",456 deviceType = "extension",457 longTermPublicKeyHex = "11".repeat(32),458 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),459 trustSource = "vouched",460 pairedAt = 1L,461 ),462 ),463 )464 val pending = FakePendingRegistry()465 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)466 val manifest = buildSignedDeviceManifest(467 issuerDeviceId = "vouched-issuer",468 devices = listOf(469 ManifestDeviceEntry(470 deviceId = "onward-mesh-device",471 deviceName = "Onward mesh device",472 deviceType = "extension",473 longTermPublicKeyHex = "aa".repeat(32),474 signingPublicKeyHex = "bb".repeat(32),475 relayUrl = meshRelay,476 pairedAt = 44L,477 ),478 ),479 signingPrivateKey = issuerSigning.privateKey,480 )481482 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })483484 assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)485 assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)486 assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)487 assertEquals(0, pending.devices.size)488 master.fill(0)489 }490491 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {492 val repo = VaultRepository(FakeStorage())493 repo.createVault("correct horse")494 val master = repo.syncMasterKey()!!495 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")496 val paired = FakeDeviceRegistry(497 listOf(498 PairedDevice(499 deviceId = "claimed-issuer",500 deviceName = "Claimed Browser",501 deviceType = "extension",502 longTermPublicKeyHex = "11".repeat(32),503 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),504 trustSource = "direct",505 pairedAt = 1L,506 ),507 PairedDevice(508 deviceId = "authenticated-peer",509 deviceName = "Authenticated Phone",510 deviceType = "android",511 longTermPublicKeyHex = "22".repeat(32),512 signingPublicKeyHex = "33".repeat(32),513 trustSource = "direct",514 pairedAt = 2L,515 ),516 ),517 )518 val pending = FakePendingRegistry()519 val adapter = VaultSyncAdapter(520 repo,521 ourDeviceId = "android-self",522 pairedDevices = paired,523 pendingVouched = pending,524 )525 val manifest = buildSignedDeviceManifest(526 issuerDeviceId = "claimed-issuer",527 devices = listOf(528 ManifestDeviceEntry(529 deviceId = "smuggled-device",530 deviceName = "Smuggled",531 deviceType = "extension",532 longTermPublicKeyHex = "aa".repeat(32),533 signingPublicKeyHex = "bb".repeat(32),534 relayUrl = meshRelay,535 pairedAt = 44L,536 ),537 ),538 signingPrivateKey = claimedSigning.privateKey,539 )540541 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })542543 assertEquals(0, pending.devices.size)544545 546 547 548 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })549 assertEquals("vouched", paired.get("smuggled-device")?.trustSource)550 assertTrue(pending.devices.isEmpty())551 master.fill(0)552 }553554 @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {555 val repo = VaultRepository(FakeStorage())556 repo.createVault("correct horse")557 val master = repo.syncMasterKey()!!558 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")559 val stolenKey = "dd".repeat(32)560 val paired = FakeDeviceRegistry(561 listOf(562 PairedDevice(563 deviceId = "phone-issuer",564 deviceName = "Pixel",565 deviceType = "android",566 longTermPublicKeyHex = "11".repeat(32),567 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),568 trustSource = "direct",569 pairedAt = 1L,570 ),571 PairedDevice(572 deviceId = "stolen-laptop",573 deviceName = "Old Laptop",574 deviceType = "extension",575 longTermPublicKeyHex = stolenKey,576 signingPublicKeyHex = "ee".repeat(32),577 trustSource = "direct",578 pairedAt = 2L,579 ),580 ),581 )582 val pending = FakePendingRegistry()583 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)584585 adapter.applyIncomingDeviceManifest(586 buildSignedDeviceManifest(587 issuerDeviceId = "phone-issuer",588 devices = emptyList(),589 signingPrivateKey = issuerSigning.privateKey,590 revoked = listOf(591 DeviceRevocation(592 deviceId = "stolen-laptop",593 longTermPublicKeyHex = stolenKey,594 revokedAt = System.currentTimeMillis(),595 revokedByDeviceId = "phone-issuer",596 ),597 ),598 ),599 ByteArray(32) { 0x11 },600 )601 assertNull(paired.get("stolen-laptop"))602603 604 605 adapter.applyIncomingDeviceManifest(606 buildSignedDeviceManifest(607 issuerDeviceId = "phone-issuer",608 devices = listOf(609 ManifestDeviceEntry(610 deviceId = "stolen-laptop-mk2",611 deviceName = "New Laptop",612 deviceType = "extension",613 longTermPublicKeyHex = stolenKey,614 signingPublicKeyHex = "ee".repeat(32),615 relayUrl = meshRelay,616 pairedAt = System.currentTimeMillis(),617 ),618 ),619 signingPrivateKey = issuerSigning.privateKey,620 ),621 ByteArray(32) { 0x11 },622 )623 assertEquals(0, pending.devices.size)624 assertNull(paired.get("stolen-laptop-mk2"))625626 627 628 adapter.applyIncomingDeviceManifest(629 buildSignedDeviceManifest(630 issuerDeviceId = "phone-issuer",631 devices = listOf(632 ManifestDeviceEntry(633 deviceId = "stolen-laptop-mk2",634 deviceName = "New Laptop",635 deviceType = "extension",636 longTermPublicKeyHex = "99".repeat(32),637 signingPublicKeyHex = "ee".repeat(32),638 relayUrl = meshRelay,639 pairedAt = System.currentTimeMillis(),640 ),641 ),642 signingPrivateKey = issuerSigning.privateKey,643 ),644 ByteArray(32) { 0x11 },645 )646 assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)647 assertTrue(pending.devices.isEmpty())648 master.fill(0)649 }650651 @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {652 val repo = VaultRepository(FakeStorage())653 repo.createVault("correct horse")654 val master = repo.syncMasterKey()!!655 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")656 val revokedKey = "dd".repeat(32)657 val paired = FakeDeviceRegistry(658 listOf(659 PairedDevice(660 deviceId = "phone-issuer",661 deviceName = "Pixel",662 deviceType = "android",663 longTermPublicKeyHex = "11".repeat(32),664 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),665 trustSource = "direct",666 pairedAt = 1L,667 ),668 PairedDevice(669 deviceId = "trusted-tablet",670 deviceName = "Tablet",671 deviceType = "android",672 longTermPublicKeyHex = "77".repeat(32),673 signingPublicKeyHex = "ee".repeat(32),674 trustSource = "direct",675 pairedAt = 2L,676 ),677 ),678 )679 val adapter = VaultSyncAdapter(680 repo,681 ourDeviceId = "android-self",682 pairedDevices = paired,683 pendingVouched = FakePendingRegistry(),684 )685686 adapter.applyIncomingDeviceManifest(687 buildSignedDeviceManifest(688 issuerDeviceId = "phone-issuer",689 devices = emptyList(),690 signingPrivateKey = issuerSigning.privateKey,691 revoked = listOf(692 DeviceRevocation(693 deviceId = "stolen-laptop",694 longTermPublicKeyHex = revokedKey,695 revokedAt = System.currentTimeMillis(),696 revokedByDeviceId = "phone-issuer",697 ),698 ),699 ),700 ByteArray(32) { 0x11 },701 )702703 704 adapter.applyIncomingDeviceManifest(705 buildSignedDeviceManifest(706 issuerDeviceId = "phone-issuer",707 devices = listOf(708 ManifestDeviceEntry(709 deviceId = "trusted-tablet",710 deviceName = "Tablet",711 deviceType = "android",712 longTermPublicKeyHex = revokedKey,713 signingPublicKeyHex = "ee".repeat(32),714 pairedAt = 2L,715 ),716 ),717 signingPrivateKey = issuerSigning.privateKey,718 ),719 ByteArray(32) { 0x11 },720 )721 assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)722 master.fill(0)723 }724725 726 @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {727 val registry = FakeDeviceRegistry()728 registry.mergeRevocations(729 listOf(730 DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),731 DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),732 ),733 )734 assertTrue(registry.isRevoked("gone"))735 assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))736 assertTrue(registry.isRevoked("keyless"))737 assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))738 739 740 assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))741 }742743 @Test fun `public key decoder requires exactly 32 bytes`() {744 assertEquals(32, hexToPubkey("ab".repeat(32)).size)745 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)746 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)747 assertEquals(64, hexToBytes("ab".repeat(64)).size)748 }749}