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 = mergeRevocationRecords(revocations, batch.incomingRevocations).records.toMutableList()52 val nextDevices = byId.toMutableMap()53 batch.removals.forEach(nextDevices::remove)54 batch.upserts.forEach { nextDevices[it.deviceId] = it }55 revocations.clear()56 revocations += nextRevocations57 byId.clear()58 byId.putAll(nextDevices)59 return true60 }61 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {62 val merged = mergeRevocationRecords(revocations, incoming)63 revocations.clear()64 revocations += merged.records65 return merged.admitted.map { it.deviceId }66 }67 override fun clearRevocation(deviceId: String) {68 revocations.removeAll { it.deviceId == deviceId }69 }70 }7172 private class FakePendingRegistry : PendingVouchedRegistry {73 val devices = mutableListOf<PendingVouchedDevice>()74 override fun getAll(): List<PendingVouchedDevice> = devices.toList()75 override fun upsert(device: PendingVouchedDevice) {76 devices.removeAll { it.deviceId == device.deviceId }77 devices += device78 }79 override fun remove(id: String) { devices.removeAll { it.deviceId == id } }80 }8182 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {83 val repo = VaultRepository(FakeStorage())84 runBlocking { repo.createVault("correct horse") }85 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")86 }8788 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {89 val (repo, adapter) = newAdapter()90 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))91 val manifest = adapter.buildEntryManifest()92 assertEquals(setOf(e.id), manifest.keys)93 assertTrue(adapter.computeVaultId().length == 64) 94 }9596 @Test fun envelope_round_trips_within_the_vault() = runBlocking {97 val (repo, adapter) = newAdapter()98 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))99 val envelopes = adapter.getEncryptedEntries(listOf(e.id))100 assertTrue(envelopes.containsKey(e.id))101 102 adapter.applyEntries(envelopes)103 assertEquals(1, repo.getEntries().size)104 }105106 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {107 val (repo, adapter) = newAdapter()108 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))109 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)110 111 112 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))113 assertNull(repo.getEntry("attacker-chosen-id"))114 assertEquals(1, repo.getEntries().size)115 }116117 118 119 120 121 @Test fun legacy_info_envelope_fails_closed() = runBlocking {122 val (repo, adapter) = newAdapter()123 val master = repo.syncMasterKey()!!124 val entryId = "11111111-2222-4333-8444-555555555555"125 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")126127 128 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)129 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())130 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))131 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }132133 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }134 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)135 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }136 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")137 cipher.init(138 javax.crypto.Cipher.ENCRYPT_MODE,139 javax.crypto.spec.SecretKeySpec(key, "AES"),140 javax.crypto.spec.GCMParameterSpec(128, nonce),141 )142 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))143 val ct = cipher.doFinal(plaintext)144 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)145146 adapter.applyEntries(mapOf(entryId to legacyEnvelope))147 148 assertNull(repo.getEntry(entryId))149 }150151 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {152 val master = ByteArray(32) { it.toByte() }153 val signing = deriveSyncSigningKeypair(master, "extension-issuer")154 val manifest = buildSignedDeviceManifest(155 issuerDeviceId = "extension-issuer",156 devices = listOf(157 ManifestDeviceEntry(158 deviceId = "android-peer",159 deviceName = "Phone",160 deviceType = "android",161 longTermPublicKeyHex = "aa".repeat(32),162 signingPublicKeyHex = "bb".repeat(32),163 pairedAt = 123L,164 ),165 ),166 signingPrivateKey = signing.privateKey,167 nowMs = 1_000L,168 )169170 val verified = verifyDeviceManifest(171 manifest,172 signing.publicKeyRaw,173 nowMs = 1_000L,174 )175176 assertEquals("extension-issuer", verified?.issuerDeviceId)177 assertEquals("android-peer", verified?.devices?.single()?.deviceId)178 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)179 }180181 @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {182 val repo = VaultRepository(FakeStorage())183 repo.createVault("correct horse")184 val master = repo.syncMasterKey()!!185 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")186 val paired = FakeDeviceRegistry(187 listOf(188 PairedDevice(189 deviceId = "extension-issuer",190 deviceName = "Peach Browser",191 deviceType = "extension",192 longTermPublicKeyHex = "11".repeat(32),193 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),194 trustSource = "vouched",195 pairedAt = 1L,196 ),197 PairedDevice(198 deviceId = "stolen-laptop",199 deviceName = "Old Laptop",200 deviceType = "extension",201 longTermPublicKeyHex = "dd".repeat(32),202 signingPublicKeyHex = "ee".repeat(32),203 trustSource = "direct",204 pairedAt = 2L,205 ),206 ),207 )208 val pending = FakePendingRegistry()209 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)210211 val revocation = buildSignedDeviceManifest(212 issuerDeviceId = "extension-issuer",213 devices = emptyList(),214 signingPrivateKey = issuerSigning.privateKey,215 revoked = listOf(216 DeviceRevocation(217 deviceId = "stolen-laptop",218 longTermPublicKeyHex = "dd".repeat(32),219 revokedAt = System.currentTimeMillis(),220 revokedByDeviceId = "extension-issuer",221 ),222 ),223 )224 adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })225226 assertNull(paired.get("stolen-laptop"))227228 229 230 val revouch = buildSignedDeviceManifest(231 issuerDeviceId = "extension-issuer",232 devices = listOf(233 ManifestDeviceEntry(234 deviceId = "stolen-laptop",235 deviceName = "Old Laptop",236 deviceType = "extension",237 longTermPublicKeyHex = "dd".repeat(32),238 signingPublicKeyHex = "ee".repeat(32),239 pairedAt = System.currentTimeMillis() + 60_000L,240 ),241 ),242 signingPrivateKey = issuerSigning.privateKey,243 )244 adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })245246 assertNull(paired.get("stolen-laptop"))247 assertEquals(0, pending.devices.size)248 master.fill(0)249 }250251 @Test fun `a peer cannot revoke this device`() = runBlocking {252 val repo = VaultRepository(FakeStorage())253 repo.createVault("correct horse")254 val master = repo.syncMasterKey()!!255 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")256 val paired = FakeDeviceRegistry(257 listOf(258 PairedDevice(259 deviceId = "extension-issuer",260 deviceName = "Peach Browser",261 deviceType = "extension",262 longTermPublicKeyHex = "11".repeat(32),263 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),264 trustSource = "direct",265 pairedAt = 1L,266 ),267 ),268 )269 val pending = FakePendingRegistry()270 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)271272 val selfRevoke = buildSignedDeviceManifest(273 issuerDeviceId = "extension-issuer",274 devices = emptyList(),275 signingPrivateKey = issuerSigning.privateKey,276 revoked = listOf(277 DeviceRevocation(278 deviceId = "android-self",279 longTermPublicKeyHex = "ff".repeat(32),280 revokedAt = System.currentTimeMillis(),281 revokedByDeviceId = "extension-issuer",282 ),283 ),284 )285 adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })286287 assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })288 master.fill(0)289 }290291 @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {292 val repo = VaultRepository(FakeStorage())293 repo.createVault("correct horse")294 val master = repo.syncMasterKey()!!295 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")296 val paired = FakeDeviceRegistry(297 listOf(298 PairedDevice(299 deviceId = "extension-issuer",300 deviceName = "Peach Browser",301 deviceType = "extension",302 longTermPublicKeyHex = "11".repeat(32),303 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),304 trustSource = "direct",305 pairedAt = 1L,306 ),307 ),308 )309 val pending = FakePendingRegistry()310 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)311 val manifest = buildSignedDeviceManifest(312 issuerDeviceId = "extension-issuer",313 devices = listOf(314 ManifestDeviceEntry(315 deviceId = "new-phone",316 deviceName = "June's Phone",317 deviceType = "android",318 longTermPublicKeyHex = "aa".repeat(32),319 signingPublicKeyHex = "bb".repeat(32),320 relayUrl = meshRelay,321 pairedAt = 44L,322 ),323 ),324 signingPrivateKey = issuerSigning.privateKey,325 )326327 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })328329 val admitted = paired.get("new-phone")330 assertEquals("vouched", admitted?.trustSource)331 assertEquals(meshRelay, admitted?.relayUrl)332 assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)333 assertTrue(pending.devices.isEmpty())334 master.fill(0)335 }336337 @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {338 val repo = VaultRepository(FakeStorage())339 repo.createVault("correct horse")340 val master = repo.syncMasterKey()!!341 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")342 val paired = FakeDeviceRegistry(343 listOf(344 PairedDevice(345 deviceId = "extension-issuer",346 deviceName = "Peach Browser",347 deviceType = "extension",348 longTermPublicKeyHex = "11".repeat(32),349 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),350 trustSource = "direct",351 pairedAt = 1L,352 ),353 ),354 ).also { it.failManifestCommit = true }355 val pending = FakePendingRegistry().also {356 it.upsert(357 PendingVouchedDevice(358 deviceId = "new-phone",359 deviceName = "June's Phone",360 deviceType = "android",361 longTermPublicKeyHex = "aa".repeat(32),362 signingPublicKeyHex = "bb".repeat(32),363 pairedAt = 44L,364 receivedAt = 45L,365 vouchedByDeviceId = "extension-issuer",366 ),367 )368 }369 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)370 val manifest = buildSignedDeviceManifest(371 issuerDeviceId = "extension-issuer",372 devices = listOf(373 ManifestDeviceEntry(374 deviceId = "new-phone",375 deviceName = "June's Phone",376 deviceType = "android",377 longTermPublicKeyHex = "aa".repeat(32),378 signingPublicKeyHex = "bb".repeat(32),379 relayUrl = meshRelay,380 pairedAt = 44L,381 ),382 ),383 signingPrivateKey = issuerSigning.privateKey,384 )385386 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })387388 assertNull("failed registry commit must not install trust", paired.get("new-phone"))389 assertEquals("pending introduction is restored for retry", 1, pending.devices.size)390 master.fill(0)391 }392 393394395396397398399 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {400 val repo = VaultRepository(FakeStorage())401 repo.createVault("correct horse")402 val master = repo.syncMasterKey()!!403 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")404 val paired = FakeDeviceRegistry(405 listOf(406 PairedDevice(407 deviceId = "phone-issuer",408 deviceName = "Other Phone",409 deviceType = "android",410 longTermPublicKeyHex = "11".repeat(32),411 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),412 trustSource = "direct",413 pairedAt = 1L,414 ),415 ),416 )417 val pending = FakePendingRegistry()418 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)419 val manifest = buildSignedDeviceManifest(420 issuerDeviceId = "phone-issuer",421 devices = listOf(422 ManifestDeviceEntry(423 deviceId = "laptop-b",424 deviceName = "Laptop B",425 deviceType = "extension",426 longTermPublicKeyHex = "aa".repeat(32),427 signingPublicKeyHex = "bb".repeat(32),428 relayUrl = meshRelay,429 pairedAt = 44L,430 ),431 ),432 signingPrivateKey = issuerSigning.privateKey,433 )434435 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })436437 assertEquals("vouched", paired.get("laptop-b")?.trustSource)438 assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)439 assertTrue(pending.devices.isEmpty())440 master.fill(0)441 }442443 444 @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {445 val repo = VaultRepository(FakeStorage())446 repo.createVault("correct horse")447 val master = repo.syncMasterKey()!!448 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")449 val paired = FakeDeviceRegistry(450 listOf(451 PairedDevice(452 deviceId = "vouched-issuer",453 deviceName = "Vouched Device",454 deviceType = "extension",455 longTermPublicKeyHex = "11".repeat(32),456 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),457 trustSource = "vouched",458 pairedAt = 1L,459 ),460 ),461 )462 val pending = FakePendingRegistry()463 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)464 val manifest = buildSignedDeviceManifest(465 issuerDeviceId = "vouched-issuer",466 devices = listOf(467 ManifestDeviceEntry(468 deviceId = "onward-mesh-device",469 deviceName = "Onward mesh device",470 deviceType = "extension",471 longTermPublicKeyHex = "aa".repeat(32),472 signingPublicKeyHex = "bb".repeat(32),473 relayUrl = meshRelay,474 pairedAt = 44L,475 ),476 ),477 signingPrivateKey = issuerSigning.privateKey,478 )479480 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })481482 assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)483 assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)484 assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)485 assertEquals(0, pending.devices.size)486 master.fill(0)487 }488489 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {490 val repo = VaultRepository(FakeStorage())491 repo.createVault("correct horse")492 val master = repo.syncMasterKey()!!493 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")494 val paired = FakeDeviceRegistry(495 listOf(496 PairedDevice(497 deviceId = "claimed-issuer",498 deviceName = "Claimed Browser",499 deviceType = "extension",500 longTermPublicKeyHex = "11".repeat(32),501 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),502 trustSource = "direct",503 pairedAt = 1L,504 ),505 PairedDevice(506 deviceId = "authenticated-peer",507 deviceName = "Authenticated Phone",508 deviceType = "android",509 longTermPublicKeyHex = "22".repeat(32),510 signingPublicKeyHex = "33".repeat(32),511 trustSource = "direct",512 pairedAt = 2L,513 ),514 ),515 )516 val pending = FakePendingRegistry()517 val adapter = VaultSyncAdapter(518 repo,519 ourDeviceId = "android-self",520 pairedDevices = paired,521 pendingVouched = pending,522 )523 val manifest = buildSignedDeviceManifest(524 issuerDeviceId = "claimed-issuer",525 devices = listOf(526 ManifestDeviceEntry(527 deviceId = "smuggled-device",528 deviceName = "Smuggled",529 deviceType = "extension",530 longTermPublicKeyHex = "aa".repeat(32),531 signingPublicKeyHex = "bb".repeat(32),532 relayUrl = meshRelay,533 pairedAt = 44L,534 ),535 ),536 signingPrivateKey = claimedSigning.privateKey,537 )538539 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })540541 assertEquals(0, pending.devices.size)542543 544 545 546 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })547 assertEquals("vouched", paired.get("smuggled-device")?.trustSource)548 assertTrue(pending.devices.isEmpty())549 master.fill(0)550 }551552 @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {553 val repo = VaultRepository(FakeStorage())554 repo.createVault("correct horse")555 val master = repo.syncMasterKey()!!556 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")557 val stolenKey = "dd".repeat(32)558 val paired = FakeDeviceRegistry(559 listOf(560 PairedDevice(561 deviceId = "phone-issuer",562 deviceName = "Pixel",563 deviceType = "android",564 longTermPublicKeyHex = "11".repeat(32),565 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),566 trustSource = "direct",567 pairedAt = 1L,568 ),569 PairedDevice(570 deviceId = "stolen-laptop",571 deviceName = "Old Laptop",572 deviceType = "extension",573 longTermPublicKeyHex = stolenKey,574 signingPublicKeyHex = "ee".repeat(32),575 trustSource = "direct",576 pairedAt = 2L,577 ),578 ),579 )580 val pending = FakePendingRegistry()581 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)582583 adapter.applyIncomingDeviceManifest(584 buildSignedDeviceManifest(585 issuerDeviceId = "phone-issuer",586 devices = emptyList(),587 signingPrivateKey = issuerSigning.privateKey,588 revoked = listOf(589 DeviceRevocation(590 deviceId = "stolen-laptop",591 longTermPublicKeyHex = stolenKey,592 revokedAt = System.currentTimeMillis(),593 revokedByDeviceId = "phone-issuer",594 ),595 ),596 ),597 ByteArray(32) { 0x11 },598 )599 assertNull(paired.get("stolen-laptop"))600601 602 603 adapter.applyIncomingDeviceManifest(604 buildSignedDeviceManifest(605 issuerDeviceId = "phone-issuer",606 devices = listOf(607 ManifestDeviceEntry(608 deviceId = "stolen-laptop-mk2",609 deviceName = "New Laptop",610 deviceType = "extension",611 longTermPublicKeyHex = stolenKey,612 signingPublicKeyHex = "ee".repeat(32),613 relayUrl = meshRelay,614 pairedAt = System.currentTimeMillis(),615 ),616 ),617 signingPrivateKey = issuerSigning.privateKey,618 ),619 ByteArray(32) { 0x11 },620 )621 assertEquals(0, pending.devices.size)622 assertNull(paired.get("stolen-laptop-mk2"))623624 625 626 adapter.applyIncomingDeviceManifest(627 buildSignedDeviceManifest(628 issuerDeviceId = "phone-issuer",629 devices = listOf(630 ManifestDeviceEntry(631 deviceId = "stolen-laptop-mk2",632 deviceName = "New Laptop",633 deviceType = "extension",634 longTermPublicKeyHex = "99".repeat(32),635 signingPublicKeyHex = "ee".repeat(32),636 relayUrl = meshRelay,637 pairedAt = System.currentTimeMillis(),638 ),639 ),640 signingPrivateKey = issuerSigning.privateKey,641 ),642 ByteArray(32) { 0x11 },643 )644 assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)645 assertTrue(pending.devices.isEmpty())646 master.fill(0)647 }648649 @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {650 val repo = VaultRepository(FakeStorage())651 repo.createVault("correct horse")652 val master = repo.syncMasterKey()!!653 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")654 val revokedKey = "dd".repeat(32)655 val paired = FakeDeviceRegistry(656 listOf(657 PairedDevice(658 deviceId = "phone-issuer",659 deviceName = "Pixel",660 deviceType = "android",661 longTermPublicKeyHex = "11".repeat(32),662 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),663 trustSource = "direct",664 pairedAt = 1L,665 ),666 PairedDevice(667 deviceId = "trusted-tablet",668 deviceName = "Tablet",669 deviceType = "android",670 longTermPublicKeyHex = "77".repeat(32),671 signingPublicKeyHex = "ee".repeat(32),672 trustSource = "direct",673 pairedAt = 2L,674 ),675 ),676 )677 val adapter = VaultSyncAdapter(678 repo,679 ourDeviceId = "android-self",680 pairedDevices = paired,681 pendingVouched = FakePendingRegistry(),682 )683684 adapter.applyIncomingDeviceManifest(685 buildSignedDeviceManifest(686 issuerDeviceId = "phone-issuer",687 devices = emptyList(),688 signingPrivateKey = issuerSigning.privateKey,689 revoked = listOf(690 DeviceRevocation(691 deviceId = "stolen-laptop",692 longTermPublicKeyHex = revokedKey,693 revokedAt = System.currentTimeMillis(),694 revokedByDeviceId = "phone-issuer",695 ),696 ),697 ),698 ByteArray(32) { 0x11 },699 )700701 702 adapter.applyIncomingDeviceManifest(703 buildSignedDeviceManifest(704 issuerDeviceId = "phone-issuer",705 devices = listOf(706 ManifestDeviceEntry(707 deviceId = "trusted-tablet",708 deviceName = "Tablet",709 deviceType = "android",710 longTermPublicKeyHex = revokedKey,711 signingPublicKeyHex = "ee".repeat(32),712 pairedAt = 2L,713 ),714 ),715 signingPrivateKey = issuerSigning.privateKey,716 ),717 ByteArray(32) { 0x11 },718 )719 assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)720 master.fill(0)721 }722723 724 @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {725 val registry = FakeDeviceRegistry()726 registry.mergeRevocations(727 listOf(728 DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),729 DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),730 ),731 )732 assertTrue(registry.isRevoked("gone"))733 assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))734 assertTrue(registry.isRevoked("keyless"))735 assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))736 737 738 assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))739 }740741 @Test fun `public key decoder requires exactly 32 bytes`() {742 assertEquals(32, hexToPubkey("ab".repeat(32)).size)743 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)744 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)745 assertEquals(64, hexToBytes("ab".repeat(64)).size)746 }747}