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 private val byId = devices.associateBy { it.deviceId }.toMutableMap()42 private val revocations = mutableListOf<DeviceRevocation>()43 override fun getAll(): List<PairedDevice> = byId.values.toList()44 override fun get(id: String): PairedDevice? = byId[id]45 override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }46 override fun remove(deviceId: String) { byId.remove(deviceId) }47 override fun listRevocations(): List<DeviceRevocation> = revocations.toList()48 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {49 val known = revocations.map { it.deviceId }.toSet()50 val fresh = incoming.filterNot { known.contains(it.deviceId) }51 revocations += fresh52 return fresh.map { it.deviceId }53 }54 override fun clearRevocation(deviceId: String) {55 revocations.removeAll { it.deviceId == deviceId }56 }57 }5859 private class FakePendingRegistry : PendingVouchedRegistry {60 val devices = mutableListOf<PendingVouchedDevice>()61 override fun getAll(): List<PendingVouchedDevice> = devices.toList()62 override fun upsert(device: PendingVouchedDevice) {63 devices.removeAll { it.deviceId == device.deviceId }64 devices += device65 }66 override fun remove(id: String) { devices.removeAll { it.deviceId == id } }67 }6869 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {70 val repo = VaultRepository(FakeStorage())71 runBlocking { repo.createVault("correct horse") }72 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")73 }7475 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {76 val (repo, adapter) = newAdapter()77 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))78 val manifest = adapter.buildEntryManifest()79 assertEquals(setOf(e.id), manifest.keys)80 assertTrue(adapter.computeVaultId().length == 64) 81 }8283 @Test fun envelope_round_trips_within_the_vault() = runBlocking {84 val (repo, adapter) = newAdapter()85 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))86 val envelopes = adapter.getEncryptedEntries(listOf(e.id))87 assertTrue(envelopes.containsKey(e.id))88 89 adapter.applyEntries(envelopes)90 assertEquals(1, repo.getEntries().size)91 }9293 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {94 val (repo, adapter) = newAdapter()95 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))96 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)97 98 99 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))100 assertNull(repo.getEntry("attacker-chosen-id"))101 assertEquals(1, repo.getEntries().size)102 }103104 105 106 107 108 @Test fun legacy_info_envelope_fails_closed() = runBlocking {109 val (repo, adapter) = newAdapter()110 val master = repo.syncMasterKey()!!111 val entryId = "11111111-2222-4333-8444-555555555555"112 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")113114 115 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)116 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())117 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))118 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }119120 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }121 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)122 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }123 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")124 cipher.init(125 javax.crypto.Cipher.ENCRYPT_MODE,126 javax.crypto.spec.SecretKeySpec(key, "AES"),127 javax.crypto.spec.GCMParameterSpec(128, nonce),128 )129 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))130 val ct = cipher.doFinal(plaintext)131 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)132133 adapter.applyEntries(mapOf(entryId to legacyEnvelope))134 135 assertNull(repo.getEntry(entryId))136 }137138 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {139 val master = ByteArray(32) { it.toByte() }140 val signing = deriveSyncSigningKeypair(master, "extension-issuer")141 val manifest = buildSignedDeviceManifest(142 issuerDeviceId = "extension-issuer",143 devices = listOf(144 ManifestDeviceEntry(145 deviceId = "android-peer",146 deviceName = "Phone",147 deviceType = "android",148 longTermPublicKeyHex = "aa".repeat(32),149 signingPublicKeyHex = "bb".repeat(32),150 pairedAt = 123L,151 ),152 ),153 signingPrivateKey = signing.privateKey,154 nowMs = 1_000L,155 )156157 val verified = verifyDeviceManifest(158 manifest,159 signing.publicKeyRaw,160 nowMs = 1_000L,161 )162163 assertEquals("extension-issuer", verified?.issuerDeviceId)164 assertEquals("android-peer", verified?.devices?.single()?.deviceId)165 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)166 }167168 @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {169 val repo = VaultRepository(FakeStorage())170 repo.createVault("correct horse")171 val master = repo.syncMasterKey()!!172 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")173 val paired = FakeDeviceRegistry(174 listOf(175 PairedDevice(176 deviceId = "extension-issuer",177 deviceName = "Peach Browser",178 deviceType = "extension",179 longTermPublicKeyHex = "11".repeat(32),180 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),181 trustSource = "vouched",182 pairedAt = 1L,183 ),184 PairedDevice(185 deviceId = "stolen-laptop",186 deviceName = "Old Laptop",187 deviceType = "extension",188 longTermPublicKeyHex = "dd".repeat(32),189 signingPublicKeyHex = "ee".repeat(32),190 trustSource = "direct",191 pairedAt = 2L,192 ),193 ),194 )195 val pending = FakePendingRegistry()196 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)197198 val revocation = buildSignedDeviceManifest(199 issuerDeviceId = "extension-issuer",200 devices = emptyList(),201 signingPrivateKey = issuerSigning.privateKey,202 revoked = listOf(203 DeviceRevocation(204 deviceId = "stolen-laptop",205 longTermPublicKeyHex = "dd".repeat(32),206 revokedAt = System.currentTimeMillis(),207 revokedByDeviceId = "extension-issuer",208 ),209 ),210 )211 adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })212213 assertNull(paired.get("stolen-laptop"))214215 216 217 val revouch = buildSignedDeviceManifest(218 issuerDeviceId = "extension-issuer",219 devices = listOf(220 ManifestDeviceEntry(221 deviceId = "stolen-laptop",222 deviceName = "Old Laptop",223 deviceType = "extension",224 longTermPublicKeyHex = "dd".repeat(32),225 signingPublicKeyHex = "ee".repeat(32),226 pairedAt = System.currentTimeMillis() + 60_000L,227 ),228 ),229 signingPrivateKey = issuerSigning.privateKey,230 )231 adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })232233 assertNull(paired.get("stolen-laptop"))234 assertEquals(0, pending.devices.size)235 master.fill(0)236 }237238 @Test fun `a peer cannot revoke this device`() = runBlocking {239 val repo = VaultRepository(FakeStorage())240 repo.createVault("correct horse")241 val master = repo.syncMasterKey()!!242 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")243 val paired = FakeDeviceRegistry(244 listOf(245 PairedDevice(246 deviceId = "extension-issuer",247 deviceName = "Peach Browser",248 deviceType = "extension",249 longTermPublicKeyHex = "11".repeat(32),250 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),251 trustSource = "direct",252 pairedAt = 1L,253 ),254 ),255 )256 val pending = FakePendingRegistry()257 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)258259 val selfRevoke = buildSignedDeviceManifest(260 issuerDeviceId = "extension-issuer",261 devices = emptyList(),262 signingPrivateKey = issuerSigning.privateKey,263 revoked = listOf(264 DeviceRevocation(265 deviceId = "android-self",266 longTermPublicKeyHex = "ff".repeat(32),267 revokedAt = System.currentTimeMillis(),268 revokedByDeviceId = "extension-issuer",269 ),270 ),271 )272 adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })273274 assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })275 master.fill(0)276 }277278 @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {279 val repo = VaultRepository(FakeStorage())280 repo.createVault("correct horse")281 val master = repo.syncMasterKey()!!282 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")283 val paired = FakeDeviceRegistry(284 listOf(285 PairedDevice(286 deviceId = "extension-issuer",287 deviceName = "Peach Browser",288 deviceType = "extension",289 longTermPublicKeyHex = "11".repeat(32),290 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),291 trustSource = "direct",292 pairedAt = 1L,293 ),294 ),295 )296 val pending = FakePendingRegistry()297 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)298 val manifest = buildSignedDeviceManifest(299 issuerDeviceId = "extension-issuer",300 devices = listOf(301 ManifestDeviceEntry(302 deviceId = "new-phone",303 deviceName = "June's Phone",304 deviceType = "android",305 longTermPublicKeyHex = "aa".repeat(32),306 signingPublicKeyHex = "bb".repeat(32),307 relayUrl = meshRelay,308 pairedAt = 44L,309 ),310 ),311 signingPrivateKey = issuerSigning.privateKey,312 )313314 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })315316 val admitted = paired.get("new-phone")317 assertEquals("vouched", admitted?.trustSource)318 assertEquals(meshRelay, admitted?.relayUrl)319 assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)320 assertTrue(pending.devices.isEmpty())321 master.fill(0)322 }323 324325326327328329330 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {331 val repo = VaultRepository(FakeStorage())332 repo.createVault("correct horse")333 val master = repo.syncMasterKey()!!334 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")335 val paired = FakeDeviceRegistry(336 listOf(337 PairedDevice(338 deviceId = "phone-issuer",339 deviceName = "Other Phone",340 deviceType = "android",341 longTermPublicKeyHex = "11".repeat(32),342 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),343 trustSource = "direct",344 pairedAt = 1L,345 ),346 ),347 )348 val pending = FakePendingRegistry()349 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)350 val manifest = buildSignedDeviceManifest(351 issuerDeviceId = "phone-issuer",352 devices = listOf(353 ManifestDeviceEntry(354 deviceId = "laptop-b",355 deviceName = "Laptop B",356 deviceType = "extension",357 longTermPublicKeyHex = "aa".repeat(32),358 signingPublicKeyHex = "bb".repeat(32),359 relayUrl = meshRelay,360 pairedAt = 44L,361 ),362 ),363 signingPrivateKey = issuerSigning.privateKey,364 )365366 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })367368 assertEquals("vouched", paired.get("laptop-b")?.trustSource)369 assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)370 assertTrue(pending.devices.isEmpty())371 master.fill(0)372 }373374 375 @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {376 val repo = VaultRepository(FakeStorage())377 repo.createVault("correct horse")378 val master = repo.syncMasterKey()!!379 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")380 val paired = FakeDeviceRegistry(381 listOf(382 PairedDevice(383 deviceId = "vouched-issuer",384 deviceName = "Vouched Device",385 deviceType = "extension",386 longTermPublicKeyHex = "11".repeat(32),387 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),388 trustSource = "vouched",389 pairedAt = 1L,390 ),391 ),392 )393 val pending = FakePendingRegistry()394 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)395 val manifest = buildSignedDeviceManifest(396 issuerDeviceId = "vouched-issuer",397 devices = listOf(398 ManifestDeviceEntry(399 deviceId = "onward-mesh-device",400 deviceName = "Onward mesh device",401 deviceType = "extension",402 longTermPublicKeyHex = "aa".repeat(32),403 signingPublicKeyHex = "bb".repeat(32),404 relayUrl = meshRelay,405 pairedAt = 44L,406 ),407 ),408 signingPrivateKey = issuerSigning.privateKey,409 )410411 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })412413 assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)414 assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)415 assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)416 assertEquals(0, pending.devices.size)417 master.fill(0)418 }419420 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {421 val repo = VaultRepository(FakeStorage())422 repo.createVault("correct horse")423 val master = repo.syncMasterKey()!!424 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")425 val paired = FakeDeviceRegistry(426 listOf(427 PairedDevice(428 deviceId = "claimed-issuer",429 deviceName = "Claimed Browser",430 deviceType = "extension",431 longTermPublicKeyHex = "11".repeat(32),432 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),433 trustSource = "direct",434 pairedAt = 1L,435 ),436 PairedDevice(437 deviceId = "authenticated-peer",438 deviceName = "Authenticated Phone",439 deviceType = "android",440 longTermPublicKeyHex = "22".repeat(32),441 signingPublicKeyHex = "33".repeat(32),442 trustSource = "direct",443 pairedAt = 2L,444 ),445 ),446 )447 val pending = FakePendingRegistry()448 val adapter = VaultSyncAdapter(449 repo,450 ourDeviceId = "android-self",451 pairedDevices = paired,452 pendingVouched = pending,453 )454 val manifest = buildSignedDeviceManifest(455 issuerDeviceId = "claimed-issuer",456 devices = listOf(457 ManifestDeviceEntry(458 deviceId = "smuggled-device",459 deviceName = "Smuggled",460 deviceType = "extension",461 longTermPublicKeyHex = "aa".repeat(32),462 signingPublicKeyHex = "bb".repeat(32),463 relayUrl = meshRelay,464 pairedAt = 44L,465 ),466 ),467 signingPrivateKey = claimedSigning.privateKey,468 )469470 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })471472 assertEquals(0, pending.devices.size)473474 475 476 477 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })478 assertEquals("vouched", paired.get("smuggled-device")?.trustSource)479 assertTrue(pending.devices.isEmpty())480 master.fill(0)481 }482483 @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {484 val repo = VaultRepository(FakeStorage())485 repo.createVault("correct horse")486 val master = repo.syncMasterKey()!!487 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")488 val stolenKey = "dd".repeat(32)489 val paired = FakeDeviceRegistry(490 listOf(491 PairedDevice(492 deviceId = "phone-issuer",493 deviceName = "Pixel",494 deviceType = "android",495 longTermPublicKeyHex = "11".repeat(32),496 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),497 trustSource = "direct",498 pairedAt = 1L,499 ),500 PairedDevice(501 deviceId = "stolen-laptop",502 deviceName = "Old Laptop",503 deviceType = "extension",504 longTermPublicKeyHex = stolenKey,505 signingPublicKeyHex = "ee".repeat(32),506 trustSource = "direct",507 pairedAt = 2L,508 ),509 ),510 )511 val pending = FakePendingRegistry()512 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)513514 adapter.applyIncomingDeviceManifest(515 buildSignedDeviceManifest(516 issuerDeviceId = "phone-issuer",517 devices = emptyList(),518 signingPrivateKey = issuerSigning.privateKey,519 revoked = listOf(520 DeviceRevocation(521 deviceId = "stolen-laptop",522 longTermPublicKeyHex = stolenKey,523 revokedAt = System.currentTimeMillis(),524 revokedByDeviceId = "phone-issuer",525 ),526 ),527 ),528 ByteArray(32) { 0x11 },529 )530 assertNull(paired.get("stolen-laptop"))531532 533 534 adapter.applyIncomingDeviceManifest(535 buildSignedDeviceManifest(536 issuerDeviceId = "phone-issuer",537 devices = listOf(538 ManifestDeviceEntry(539 deviceId = "stolen-laptop-mk2",540 deviceName = "New Laptop",541 deviceType = "extension",542 longTermPublicKeyHex = stolenKey,543 signingPublicKeyHex = "ee".repeat(32),544 relayUrl = meshRelay,545 pairedAt = System.currentTimeMillis(),546 ),547 ),548 signingPrivateKey = issuerSigning.privateKey,549 ),550 ByteArray(32) { 0x11 },551 )552 assertEquals(0, pending.devices.size)553 assertNull(paired.get("stolen-laptop-mk2"))554555 556 557 adapter.applyIncomingDeviceManifest(558 buildSignedDeviceManifest(559 issuerDeviceId = "phone-issuer",560 devices = listOf(561 ManifestDeviceEntry(562 deviceId = "stolen-laptop-mk2",563 deviceName = "New Laptop",564 deviceType = "extension",565 longTermPublicKeyHex = "99".repeat(32),566 signingPublicKeyHex = "ee".repeat(32),567 relayUrl = meshRelay,568 pairedAt = System.currentTimeMillis(),569 ),570 ),571 signingPrivateKey = issuerSigning.privateKey,572 ),573 ByteArray(32) { 0x11 },574 )575 assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)576 assertTrue(pending.devices.isEmpty())577 master.fill(0)578 }579580 @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {581 val repo = VaultRepository(FakeStorage())582 repo.createVault("correct horse")583 val master = repo.syncMasterKey()!!584 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")585 val revokedKey = "dd".repeat(32)586 val paired = FakeDeviceRegistry(587 listOf(588 PairedDevice(589 deviceId = "phone-issuer",590 deviceName = "Pixel",591 deviceType = "android",592 longTermPublicKeyHex = "11".repeat(32),593 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),594 trustSource = "direct",595 pairedAt = 1L,596 ),597 PairedDevice(598 deviceId = "trusted-tablet",599 deviceName = "Tablet",600 deviceType = "android",601 longTermPublicKeyHex = "77".repeat(32),602 signingPublicKeyHex = "ee".repeat(32),603 trustSource = "direct",604 pairedAt = 2L,605 ),606 ),607 )608 val adapter = VaultSyncAdapter(609 repo,610 ourDeviceId = "android-self",611 pairedDevices = paired,612 pendingVouched = FakePendingRegistry(),613 )614615 adapter.applyIncomingDeviceManifest(616 buildSignedDeviceManifest(617 issuerDeviceId = "phone-issuer",618 devices = emptyList(),619 signingPrivateKey = issuerSigning.privateKey,620 revoked = listOf(621 DeviceRevocation(622 deviceId = "stolen-laptop",623 longTermPublicKeyHex = revokedKey,624 revokedAt = System.currentTimeMillis(),625 revokedByDeviceId = "phone-issuer",626 ),627 ),628 ),629 ByteArray(32) { 0x11 },630 )631632 633 adapter.applyIncomingDeviceManifest(634 buildSignedDeviceManifest(635 issuerDeviceId = "phone-issuer",636 devices = listOf(637 ManifestDeviceEntry(638 deviceId = "trusted-tablet",639 deviceName = "Tablet",640 deviceType = "android",641 longTermPublicKeyHex = revokedKey,642 signingPublicKeyHex = "ee".repeat(32),643 pairedAt = 2L,644 ),645 ),646 signingPrivateKey = issuerSigning.privateKey,647 ),648 ByteArray(32) { 0x11 },649 )650 assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)651 master.fill(0)652 }653654 655 @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {656 val registry = FakeDeviceRegistry()657 registry.mergeRevocations(658 listOf(659 DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),660 DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),661 ),662 )663 assertTrue(registry.isRevoked("gone"))664 assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))665 assertTrue(registry.isRevoked("keyless"))666 assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))667 668 669 assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))670 }671672 @Test fun `public key decoder requires exactly 32 bytes`() {673 assertEquals(32, hexToPubkey("ab".repeat(32)).size)674 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)675 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)676 assertEquals(64, hexToBytes("ab".repeat(64)).size)677 }678}