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(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }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 localCredentials(deviceId: String) = randomLocalIdentityCredentials(deviceId)8384 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {85 val repo = VaultRepository(FakeStorage())86 runBlocking { repo.createVault("correct horse") }87 return repo to VaultSyncAdapter(88 repo,89 ourDeviceId = "android-test",90 localCredentials = localCredentials("android-test"),91 )92 }9394 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {95 val (repo, adapter) = newAdapter()96 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))97 val manifest = adapter.buildEntryManifest()98 assertEquals(setOf(e.id), manifest.keys)99 assertTrue(adapter.computeVaultId().length == 64) 100 }101102 @Test fun envelope_round_trips_within_the_vault() = runBlocking {103 val (repo, adapter) = newAdapter()104 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))105 val envelopes = adapter.getEncryptedEntries(listOf(e.id))106 assertTrue(envelopes.containsKey(e.id))107 108 adapter.applyEntries(envelopes)109 assertEquals(1, repo.getEntries().size)110 }111112 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {113 val (repo, adapter) = newAdapter()114 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))115 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)116 117 118 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))119 assertNull(repo.getEntry("attacker-chosen-id"))120 assertEquals(1, repo.getEntries().size)121 }122123 124 125 126 127 @Test fun legacy_info_envelope_fails_closed() = runBlocking {128 val (repo, adapter) = newAdapter()129 val master = repo.syncMasterKey()!!130 val entryId = "11111111-2222-4333-8444-555555555555"131 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")132133 134 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)135 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())136 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))137 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }138139 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }140 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)141 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }142 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")143 cipher.init(144 javax.crypto.Cipher.ENCRYPT_MODE,145 javax.crypto.spec.SecretKeySpec(key, "AES"),146 javax.crypto.spec.GCMParameterSpec(128, nonce),147 )148 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))149 val ct = cipher.doFinal(plaintext)150 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)151152 adapter.applyEntries(mapOf(entryId to legacyEnvelope))153 154 assertNull(repo.getEntry(entryId))155 }156157 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {158 val master = ByteArray(32) { it.toByte() }159 val signing = deriveSyncSigningKeypair(master, "extension-issuer")160 val manifest = buildSignedDeviceManifest(161 issuerDeviceId = "extension-issuer",162 devices = listOf(163 ManifestDeviceEntry(164 deviceId = "android-peer",165 deviceName = "Phone",166 deviceType = "android",167 longTermPublicKeyHex = "aa".repeat(32),168 signingPublicKeyHex = "bb".repeat(32),169 pairedAt = 123L,170 ),171 ),172 signingPrivateKey = signing.privateKey,173 nowMs = 1_000L,174 )175176 val verified = verifyDeviceManifest(177 manifest,178 signing.publicKeyRaw,179 { setOf(pubkeyToHex(signing.publicKeyRaw)) },180 nowMs = 1_000L,181 )182183 assertEquals("extension-issuer", verified?.issuerDeviceId)184 assertEquals("android-peer", verified?.devices?.single()?.deviceId)185 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, { setOf(pubkeyToHex(signing.publicKeyRaw)) }, nowMs = 1_000L) == null)186 }187188 @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {189 val repo = VaultRepository(FakeStorage())190 repo.createVault("correct horse")191 val master = repo.syncMasterKey()!!192 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")193 val paired = FakeDeviceRegistry(194 listOf(195 PairedDevice(196 deviceId = "extension-issuer",197 deviceName = "Peach Browser",198 deviceType = "extension",199 longTermPublicKeyHex = "11".repeat(32),200 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),201 trustSource = "vouched",202 pairedAt = 1L,203 ),204 PairedDevice(205 deviceId = "stolen-laptop",206 deviceName = "Old Laptop",207 deviceType = "extension",208 longTermPublicKeyHex = "dd".repeat(32),209 signingPublicKeyHex = "ee".repeat(32),210 trustSource = "direct",211 pairedAt = 2L,212 ),213 ),214 )215 val pending = FakePendingRegistry()216 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))217218 val revocation = buildSignedDeviceManifest(219 issuerDeviceId = "extension-issuer",220 devices = emptyList(),221 signingPrivateKey = issuerSigning.privateKey,222 revoked = listOf(223 DeviceRevocation(224 deviceId = "stolen-laptop",225 longTermPublicKeyHex = "dd".repeat(32),226 revokedAt = System.currentTimeMillis(),227 revokedByDeviceId = "extension-issuer",228 ),229 ),230 )231 adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })232233 assertNull(paired.get("stolen-laptop"))234235 236 237 val revouch = buildSignedDeviceManifest(238 issuerDeviceId = "extension-issuer",239 devices = listOf(240 ManifestDeviceEntry(241 deviceId = "stolen-laptop",242 deviceName = "Old Laptop",243 deviceType = "extension",244 longTermPublicKeyHex = "dd".repeat(32),245 signingPublicKeyHex = "ee".repeat(32),246 pairedAt = System.currentTimeMillis() + 60_000L,247 ),248 ),249 signingPrivateKey = issuerSigning.privateKey,250 )251 adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })252253 assertNull(paired.get("stolen-laptop"))254 assertEquals(0, pending.devices.size)255 master.fill(0)256 }257258 @Test fun `a peer cannot revoke this device`() = runBlocking {259 val repo = VaultRepository(FakeStorage())260 repo.createVault("correct horse")261 val master = repo.syncMasterKey()!!262 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")263 val paired = FakeDeviceRegistry(264 listOf(265 PairedDevice(266 deviceId = "extension-issuer",267 deviceName = "Peach Browser",268 deviceType = "extension",269 longTermPublicKeyHex = "11".repeat(32),270 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),271 trustSource = "direct",272 pairedAt = 1L,273 ),274 ),275 )276 val pending = FakePendingRegistry()277 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))278279 val selfRevoke = buildSignedDeviceManifest(280 issuerDeviceId = "extension-issuer",281 devices = emptyList(),282 signingPrivateKey = issuerSigning.privateKey,283 revoked = listOf(284 DeviceRevocation(285 deviceId = "android-self",286 longTermPublicKeyHex = "ff".repeat(32),287 revokedAt = System.currentTimeMillis(),288 revokedByDeviceId = "extension-issuer",289 ),290 ),291 )292 adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })293294 assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })295 master.fill(0)296 }297298 @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {299 val repo = VaultRepository(FakeStorage())300 repo.createVault("correct horse")301 val master = repo.syncMasterKey()!!302 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")303 val paired = FakeDeviceRegistry(304 listOf(305 PairedDevice(306 deviceId = "extension-issuer",307 deviceName = "Peach Browser",308 deviceType = "extension",309 longTermPublicKeyHex = "11".repeat(32),310 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),311 trustSource = "direct",312 pairedAt = 1L,313 ),314 ),315 )316 val pending = FakePendingRegistry()317 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))318 val manifest = buildSignedDeviceManifest(319 issuerDeviceId = "extension-issuer",320 devices = listOf(321 ManifestDeviceEntry(322 deviceId = "new-phone",323 deviceName = "June's Phone",324 deviceType = "android",325 longTermPublicKeyHex = "aa".repeat(32),326 signingPublicKeyHex = "bb".repeat(32),327 relayUrl = meshRelay,328 pairedAt = 44L,329 ),330 ),331 signingPrivateKey = issuerSigning.privateKey,332 )333334 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })335336 val admitted = paired.get("new-phone")337 assertEquals("vouched", admitted?.trustSource)338 assertEquals(meshRelay, admitted?.relayUrl)339 assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)340 assertTrue(pending.devices.isEmpty())341 master.fill(0)342 }343344 @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {345 val repo = VaultRepository(FakeStorage())346 repo.createVault("correct horse")347 val master = repo.syncMasterKey()!!348 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")349 val paired = FakeDeviceRegistry(350 listOf(351 PairedDevice(352 deviceId = "extension-issuer",353 deviceName = "Peach Browser",354 deviceType = "extension",355 longTermPublicKeyHex = "11".repeat(32),356 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),357 trustSource = "direct",358 pairedAt = 1L,359 ),360 ),361 ).also { it.failManifestCommit = true }362 val pending = FakePendingRegistry().also {363 it.upsert(364 PendingVouchedDevice(365 deviceId = "new-phone",366 deviceName = "June's Phone",367 deviceType = "android",368 longTermPublicKeyHex = "aa".repeat(32),369 signingPublicKeyHex = "bb".repeat(32),370 pairedAt = 44L,371 receivedAt = 45L,372 vouchedByDeviceId = "extension-issuer",373 ),374 )375 }376 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))377 val manifest = buildSignedDeviceManifest(378 issuerDeviceId = "extension-issuer",379 devices = listOf(380 ManifestDeviceEntry(381 deviceId = "new-phone",382 deviceName = "June's Phone",383 deviceType = "android",384 longTermPublicKeyHex = "aa".repeat(32),385 signingPublicKeyHex = "bb".repeat(32),386 relayUrl = meshRelay,387 pairedAt = 44L,388 ),389 ),390 signingPrivateKey = issuerSigning.privateKey,391 )392393 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })394395 assertNull("failed registry commit must not install trust", paired.get("new-phone"))396 assertEquals("pending introduction is restored for retry", 1, pending.devices.size)397 master.fill(0)398 }399 400401402403404405406 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {407 val repo = VaultRepository(FakeStorage())408 repo.createVault("correct horse")409 val master = repo.syncMasterKey()!!410 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")411 val paired = FakeDeviceRegistry(412 listOf(413 PairedDevice(414 deviceId = "phone-issuer",415 deviceName = "Other Phone",416 deviceType = "android",417 longTermPublicKeyHex = "11".repeat(32),418 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),419 trustSource = "direct",420 pairedAt = 1L,421 ),422 ),423 )424 val pending = FakePendingRegistry()425 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))426 val manifest = buildSignedDeviceManifest(427 issuerDeviceId = "phone-issuer",428 devices = listOf(429 ManifestDeviceEntry(430 deviceId = "laptop-b",431 deviceName = "Laptop B",432 deviceType = "extension",433 longTermPublicKeyHex = "aa".repeat(32),434 signingPublicKeyHex = "bb".repeat(32),435 relayUrl = meshRelay,436 pairedAt = 44L,437 ),438 ),439 signingPrivateKey = issuerSigning.privateKey,440 )441442 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })443444 assertEquals("vouched", paired.get("laptop-b")?.trustSource)445 assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)446 assertTrue(pending.devices.isEmpty())447 master.fill(0)448 }449450 451 @Test fun `a vouched issuer cannot introduce further mesh devices`() = runBlocking {452 val repo = VaultRepository(FakeStorage())453 repo.createVault("correct horse")454 val master = repo.syncMasterKey()!!455 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")456 val paired = FakeDeviceRegistry(457 listOf(458 PairedDevice(459 deviceId = "vouched-issuer",460 deviceName = "Vouched Device",461 deviceType = "extension",462 longTermPublicKeyHex = "11".repeat(32),463 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),464 trustSource = "vouched",465 vouchDepth = MAX_VOUCH_DEPTH,466 pairedAt = 1L,467 ),468 ),469 )470 val pending = FakePendingRegistry()471 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))472 val manifest = buildSignedDeviceManifest(473 issuerDeviceId = "vouched-issuer",474 devices = listOf(475 ManifestDeviceEntry(476 deviceId = "onward-mesh-device",477 deviceName = "Onward mesh device",478 deviceType = "extension",479 longTermPublicKeyHex = "aa".repeat(32),480 signingPublicKeyHex = "bb".repeat(32),481 relayUrl = meshRelay,482 pairedAt = 44L,483 ),484 ),485 signingPrivateKey = issuerSigning.privateKey,486 )487488 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })489490 assertNull(paired.get("onward-mesh-device"))491 assertEquals(0, pending.devices.size)492 master.fill(0)493 }494495 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {496 val repo = VaultRepository(FakeStorage())497 repo.createVault("correct horse")498 val master = repo.syncMasterKey()!!499 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")500 val paired = FakeDeviceRegistry(501 listOf(502 PairedDevice(503 deviceId = "claimed-issuer",504 deviceName = "Claimed Browser",505 deviceType = "extension",506 longTermPublicKeyHex = "11".repeat(32),507 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),508 trustSource = "direct",509 pairedAt = 1L,510 ),511 PairedDevice(512 deviceId = "authenticated-peer",513 deviceName = "Authenticated Phone",514 deviceType = "android",515 longTermPublicKeyHex = "22".repeat(32),516 signingPublicKeyHex = "33".repeat(32),517 trustSource = "direct",518 pairedAt = 2L,519 ),520 ),521 )522 val pending = FakePendingRegistry()523 val adapter = VaultSyncAdapter(524 repo,525 ourDeviceId = "android-self",526 pairedDevices = paired,527 pendingVouched = pending,528 localCredentials = localCredentials("android-self"),529 )530 val manifest = buildSignedDeviceManifest(531 issuerDeviceId = "claimed-issuer",532 devices = listOf(533 ManifestDeviceEntry(534 deviceId = "smuggled-device",535 deviceName = "Smuggled",536 deviceType = "extension",537 longTermPublicKeyHex = "aa".repeat(32),538 signingPublicKeyHex = "bb".repeat(32),539 relayUrl = meshRelay,540 pairedAt = 44L,541 ),542 ),543 signingPrivateKey = claimedSigning.privateKey,544 )545546 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })547548 assertEquals(0, pending.devices.size)549550 551 552 553 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })554 assertEquals("vouched", paired.get("smuggled-device")?.trustSource)555 assertTrue(pending.devices.isEmpty())556 master.fill(0)557 }558559 @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {560 val repo = VaultRepository(FakeStorage())561 repo.createVault("correct horse")562 val master = repo.syncMasterKey()!!563 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")564 val stolenKey = "dd".repeat(32)565 val paired = FakeDeviceRegistry(566 listOf(567 PairedDevice(568 deviceId = "phone-issuer",569 deviceName = "Pixel",570 deviceType = "android",571 longTermPublicKeyHex = "11".repeat(32),572 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),573 trustSource = "direct",574 pairedAt = 1L,575 ),576 PairedDevice(577 deviceId = "stolen-laptop",578 deviceName = "Old Laptop",579 deviceType = "extension",580 longTermPublicKeyHex = stolenKey,581 signingPublicKeyHex = "ee".repeat(32),582 trustSource = "direct",583 pairedAt = 2L,584 ),585 ),586 )587 val pending = FakePendingRegistry()588 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))589590 adapter.applyIncomingDeviceManifest(591 buildSignedDeviceManifest(592 issuerDeviceId = "phone-issuer",593 devices = emptyList(),594 signingPrivateKey = issuerSigning.privateKey,595 revoked = listOf(596 DeviceRevocation(597 deviceId = "stolen-laptop",598 longTermPublicKeyHex = stolenKey,599 revokedAt = System.currentTimeMillis(),600 revokedByDeviceId = "phone-issuer",601 ),602 ),603 ),604 ByteArray(32) { 0x11 },605 )606 assertNull(paired.get("stolen-laptop"))607608 609 610 adapter.applyIncomingDeviceManifest(611 buildSignedDeviceManifest(612 issuerDeviceId = "phone-issuer",613 devices = listOf(614 ManifestDeviceEntry(615 deviceId = "stolen-laptop-mk2",616 deviceName = "New Laptop",617 deviceType = "extension",618 longTermPublicKeyHex = stolenKey,619 signingPublicKeyHex = "ee".repeat(32),620 relayUrl = meshRelay,621 pairedAt = System.currentTimeMillis(),622 ),623 ),624 signingPrivateKey = issuerSigning.privateKey,625 ),626 ByteArray(32) { 0x11 },627 )628 assertEquals(0, pending.devices.size)629 assertNull(paired.get("stolen-laptop-mk2"))630631 632 633 adapter.applyIncomingDeviceManifest(634 buildSignedDeviceManifest(635 issuerDeviceId = "phone-issuer",636 devices = listOf(637 ManifestDeviceEntry(638 deviceId = "stolen-laptop-mk2",639 deviceName = "New Laptop",640 deviceType = "extension",641 longTermPublicKeyHex = "99".repeat(32),642 signingPublicKeyHex = "ee".repeat(32),643 relayUrl = meshRelay,644 pairedAt = System.currentTimeMillis(),645 ),646 ),647 signingPrivateKey = issuerSigning.privateKey,648 ),649 ByteArray(32) { 0x11 },650 )651 assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)652 assertTrue(pending.devices.isEmpty())653 master.fill(0)654 }655656 @Test fun `direct and vouched peers reject long term and signing key rotation`() = runBlocking {657 val repo = VaultRepository(FakeStorage())658 repo.createVault("correct horse")659 val master = repo.syncMasterKey()!!660 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")661 val revokedKey = "dd".repeat(32)662 val paired = FakeDeviceRegistry(663 listOf(664 PairedDevice(665 deviceId = "phone-issuer",666 deviceName = "Pixel",667 deviceType = "android",668 longTermPublicKeyHex = "11".repeat(32),669 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),670 trustSource = "direct",671 pairedAt = 1L,672 ),673 PairedDevice(674 deviceId = "trusted-tablet",675 deviceName = "Tablet",676 deviceType = "android",677 longTermPublicKeyHex = "77".repeat(32),678 signingPublicKeyHex = "ee".repeat(32),679 trustSource = "direct",680 pairedAt = 2L,681 ),682 PairedDevice(683 deviceId = "vouched-laptop",684 deviceName = "Laptop",685 deviceType = "extension",686 longTermPublicKeyHex = "66".repeat(32),687 signingPublicKeyHex = "aa".repeat(32),688 trustSource = "vouched",689 pairedAt = 3L,690 ),691 ),692 )693 val adapter = VaultSyncAdapter(694 repo,695 ourDeviceId = "android-self",696 pairedDevices = paired,697 pendingVouched = FakePendingRegistry(),698 localCredentials = localCredentials("android-self"),699 )700701 adapter.applyIncomingDeviceManifest(702 buildSignedDeviceManifest(703 issuerDeviceId = "phone-issuer",704 devices = emptyList(),705 signingPrivateKey = issuerSigning.privateKey,706 revoked = listOf(707 DeviceRevocation(708 deviceId = "stolen-laptop",709 longTermPublicKeyHex = revokedKey,710 revokedAt = System.currentTimeMillis(),711 revokedByDeviceId = "phone-issuer",712 ),713 ),714 ),715 ByteArray(32) { 0x11 },716 )717718 719 adapter.applyIncomingDeviceManifest(720 buildSignedDeviceManifest(721 issuerDeviceId = "phone-issuer",722 devices = listOf(723 ManifestDeviceEntry(724 deviceId = "trusted-tablet",725 deviceName = "Tablet",726 deviceType = "android",727 longTermPublicKeyHex = revokedKey,728 signingPublicKeyHex = "ff".repeat(32),729 pairedAt = 2L,730 ),731 ManifestDeviceEntry(732 deviceId = "vouched-laptop",733 deviceName = "Laptop",734 deviceType = "extension",735 longTermPublicKeyHex = "55".repeat(32),736 signingPublicKeyHex = "bb".repeat(32),737 relayUrl = "wss://relay.peachpasswords.com",738 pairedAt = 3L,739 ),740 ),741 signingPrivateKey = issuerSigning.privateKey,742 ),743 ByteArray(32) { 0x11 },744 )745 assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)746 assertEquals("ee".repeat(32), paired.get("trusted-tablet")?.signingPublicKeyHex)747 assertEquals("66".repeat(32), paired.get("vouched-laptop")?.longTermPublicKeyHex)748 assertEquals("aa".repeat(32), paired.get("vouched-laptop")?.signingPublicKeyHex)749 master.fill(0)750 }751752 753 @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {754 val registry = FakeDeviceRegistry()755 registry.mergeRevocations(756 listOf(757 DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),758 DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),759 ),760 )761 assertTrue(registry.isRevoked("gone"))762 assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))763 assertTrue(registry.isRevoked("keyless"))764 assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))765 766 767 assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))768 }769770 @Test fun `public key decoder requires exactly 32 bytes`() {771 assertEquals(32, hexToPubkey("ab".repeat(32)).size)772 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)773 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)774 assertEquals(64, hexToBytes("ab".repeat(64)).size)775 }776}