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 class FakeDeviceRegistry(38 devices: List<PairedDevice> = emptyList(),39 ) : PairedDeviceRegistry {40 private val byId = devices.associateBy { it.deviceId }.toMutableMap()41 private val revocations = mutableListOf<DeviceRevocation>()42 override fun getAll(): List<PairedDevice> = byId.values.toList()43 override fun get(id: String): PairedDevice? = byId[id]44 override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }45 override fun remove(deviceId: String) { byId.remove(deviceId) }46 override fun listRevocations(): List<DeviceRevocation> = revocations.toList()47 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {48 val known = revocations.map { it.deviceId }.toSet()49 val fresh = incoming.filterNot { known.contains(it.deviceId) }50 revocations += fresh51 return fresh.map { it.deviceId }52 }53 override fun clearRevocation(deviceId: String) {54 revocations.removeAll { it.deviceId == deviceId }55 }56 }5758 private class FakePendingRegistry : PendingVouchedRegistry {59 val devices = mutableListOf<PendingVouchedDevice>()60 override fun getAll(): List<PendingVouchedDevice> = devices.toList()61 override fun upsert(device: PendingVouchedDevice) {62 devices.removeAll { it.deviceId == device.deviceId }63 devices += device64 }65 override fun remove(id: String) { devices.removeAll { it.deviceId == id } }66 }6768 private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {69 val repo = VaultRepository(FakeStorage())70 runBlocking { repo.createVault("correct horse") }71 return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")72 }7374 @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {75 val (repo, adapter) = newAdapter()76 val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))77 val manifest = adapter.buildEntryManifest()78 assertEquals(setOf(e.id), manifest.keys)79 assertTrue(adapter.computeVaultId().length == 64) 80 }8182 @Test fun envelope_round_trips_within_the_vault() = runBlocking {83 val (repo, adapter) = newAdapter()84 val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))85 val envelopes = adapter.getEncryptedEntries(listOf(e.id))86 assertTrue(envelopes.containsKey(e.id))87 88 adapter.applyEntries(envelopes)89 assertEquals(1, repo.getEntries().size)90 }9192 @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {93 val (repo, adapter) = newAdapter()94 val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))95 val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)96 97 98 adapter.applyEntries(mapOf("attacker-chosen-id" to blob))99 assertNull(repo.getEntry("attacker-chosen-id"))100 assertEquals(1, repo.getEntries().size)101 }102103 104 105 106 107 @Test fun legacy_info_envelope_fails_closed() = runBlocking {108 val (repo, adapter) = newAdapter()109 val master = repo.syncMasterKey()!!110 val entryId = "11111111-2222-4333-8444-555555555555"111 val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")112113 114 val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)115 val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())116 gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))117 val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }118119 val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }120 val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)121 val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }122 val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")123 cipher.init(124 javax.crypto.Cipher.ENCRYPT_MODE,125 javax.crypto.spec.SecretKeySpec(key, "AES"),126 javax.crypto.spec.GCMParameterSpec(128, nonce),127 )128 cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))129 val ct = cipher.doFinal(plaintext)130 val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)131132 adapter.applyEntries(mapOf(entryId to legacyEnvelope))133 134 assertNull(repo.getEntry(entryId))135 }136137 @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {138 val master = ByteArray(32) { it.toByte() }139 val signing = deriveSyncSigningKeypair(master, "extension-issuer")140 val manifest = buildSignedDeviceManifest(141 issuerDeviceId = "extension-issuer",142 devices = listOf(143 ManifestDeviceEntry(144 deviceId = "android-peer",145 deviceName = "Phone",146 deviceType = "android",147 longTermPublicKeyHex = "aa".repeat(32),148 signingPublicKeyHex = "bb".repeat(32),149 pairedAt = 123L,150 ),151 ),152 signingPrivateKey = signing.privateKey,153 nowMs = 1_000L,154 )155156 val verified = verifyDeviceManifest(157 manifest,158 signing.publicKeyRaw,159 nowMs = 1_000L,160 )161162 assertEquals("extension-issuer", verified?.issuerDeviceId)163 assertEquals("android-peer", verified?.devices?.single()?.deviceId)164 assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)165 }166167 @Test fun `revocation evicts the device and permanently blocks re-vouching`() = runBlocking {168 val repo = VaultRepository(FakeStorage())169 repo.createVault("correct horse")170 val master = repo.syncMasterKey()!!171 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")172 val paired = FakeDeviceRegistry(173 listOf(174 PairedDevice(175 deviceId = "extension-issuer",176 deviceName = "Peach Browser",177 deviceType = "extension",178 longTermPublicKeyHex = "11".repeat(32),179 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),180 trustSource = "direct",181 pairedAt = 1L,182 ),183 PairedDevice(184 deviceId = "stolen-laptop",185 deviceName = "Old Laptop",186 deviceType = "extension",187 longTermPublicKeyHex = "dd".repeat(32),188 signingPublicKeyHex = "ee".repeat(32),189 trustSource = "direct",190 pairedAt = 2L,191 ),192 ),193 )194 val pending = FakePendingRegistry()195 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)196197 val revocation = buildSignedDeviceManifest(198 issuerDeviceId = "extension-issuer",199 devices = emptyList(),200 signingPrivateKey = issuerSigning.privateKey,201 revoked = listOf(202 DeviceRevocation(203 deviceId = "stolen-laptop",204 longTermPublicKeyHex = "dd".repeat(32),205 revokedAt = System.currentTimeMillis(),206 revokedByDeviceId = "extension-issuer",207 ),208 ),209 )210 adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })211212 assertNull(paired.get("stolen-laptop"))213214 215 216 val revouch = buildSignedDeviceManifest(217 issuerDeviceId = "extension-issuer",218 devices = listOf(219 ManifestDeviceEntry(220 deviceId = "stolen-laptop",221 deviceName = "Old Laptop",222 deviceType = "extension",223 longTermPublicKeyHex = "dd".repeat(32),224 signingPublicKeyHex = "ee".repeat(32),225 pairedAt = System.currentTimeMillis() + 60_000L,226 ),227 ),228 signingPrivateKey = issuerSigning.privateKey,229 )230 adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })231232 assertNull(paired.get("stolen-laptop"))233 assertEquals(0, pending.devices.size)234 master.fill(0)235 }236237 @Test fun `a peer cannot revoke this device`() = runBlocking {238 val repo = VaultRepository(FakeStorage())239 repo.createVault("correct horse")240 val master = repo.syncMasterKey()!!241 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")242 val paired = FakeDeviceRegistry(243 listOf(244 PairedDevice(245 deviceId = "extension-issuer",246 deviceName = "Peach Browser",247 deviceType = "extension",248 longTermPublicKeyHex = "11".repeat(32),249 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),250 trustSource = "direct",251 pairedAt = 1L,252 ),253 ),254 )255 val pending = FakePendingRegistry()256 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)257258 val selfRevoke = buildSignedDeviceManifest(259 issuerDeviceId = "extension-issuer",260 devices = emptyList(),261 signingPrivateKey = issuerSigning.privateKey,262 revoked = listOf(263 DeviceRevocation(264 deviceId = "android-self",265 longTermPublicKeyHex = "ff".repeat(32),266 revokedAt = System.currentTimeMillis(),267 revokedByDeviceId = "extension-issuer",268 ),269 ),270 )271 adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })272273 assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })274 master.fill(0)275 }276277 @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {278 val repo = VaultRepository(FakeStorage())279 repo.createVault("correct horse")280 val master = repo.syncMasterKey()!!281 val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")282 val paired = FakeDeviceRegistry(283 listOf(284 PairedDevice(285 deviceId = "extension-issuer",286 deviceName = "Peach Browser",287 deviceType = "extension",288 longTermPublicKeyHex = "11".repeat(32),289 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),290 trustSource = "direct",291 pairedAt = 1L,292 ),293 ),294 )295 val pending = FakePendingRegistry()296 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)297 val manifest = buildSignedDeviceManifest(298 issuerDeviceId = "extension-issuer",299 devices = listOf(300 ManifestDeviceEntry(301 deviceId = "new-phone",302 deviceName = "June's Phone",303 deviceType = "android",304 longTermPublicKeyHex = "aa".repeat(32),305 signingPublicKeyHex = "bb".repeat(32),306 pairedAt = 44L,307 ),308 ),309 signingPrivateKey = issuerSigning.privateKey,310 )311312 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })313314 assertNull(paired.get("new-phone"))315 assertEquals(1, pending.devices.size)316 assertEquals("new-phone", pending.devices.single().deviceId)317 assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })318 master.fill(0)319 }320 321322323324325326327 @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {328 val repo = VaultRepository(FakeStorage())329 repo.createVault("correct horse")330 val master = repo.syncMasterKey()!!331 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")332 val paired = FakeDeviceRegistry(333 listOf(334 PairedDevice(335 deviceId = "phone-issuer",336 deviceName = "Other Phone",337 deviceType = "android",338 longTermPublicKeyHex = "11".repeat(32),339 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),340 trustSource = "direct",341 pairedAt = 1L,342 ),343 ),344 )345 val pending = FakePendingRegistry()346 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)347 val manifest = buildSignedDeviceManifest(348 issuerDeviceId = "phone-issuer",349 devices = listOf(350 ManifestDeviceEntry(351 deviceId = "laptop-b",352 deviceName = "Laptop B",353 deviceType = "extension",354 longTermPublicKeyHex = "aa".repeat(32),355 signingPublicKeyHex = "bb".repeat(32),356 pairedAt = 44L,357 ),358 ),359 signingPrivateKey = issuerSigning.privateKey,360 )361362 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })363364 assertEquals(1, pending.devices.size)365 assertEquals("laptop-b", pending.devices.single().deviceId)366 master.fill(0)367 }368369 370371372373374 @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {375 val repo = VaultRepository(FakeStorage())376 repo.createVault("correct horse")377 val master = repo.syncMasterKey()!!378 val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")379 val paired = FakeDeviceRegistry(380 listOf(381 PairedDevice(382 deviceId = "vouched-issuer",383 deviceName = "Vouched Device",384 deviceType = "extension",385 longTermPublicKeyHex = "11".repeat(32),386 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),387 trustSource = "vouched",388 pairedAt = 1L,389 ),390 ),391 )392 val pending = FakePendingRegistry()393 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)394 val manifest = buildSignedDeviceManifest(395 issuerDeviceId = "vouched-issuer",396 devices = listOf(397 ManifestDeviceEntry(398 deviceId = "smuggled-device",399 deviceName = "Smuggled",400 deviceType = "extension",401 longTermPublicKeyHex = "aa".repeat(32),402 signingPublicKeyHex = "bb".repeat(32),403 pairedAt = 44L,404 ),405 ),406 signingPrivateKey = issuerSigning.privateKey,407 )408409 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })410411 assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)412 master.fill(0)413 }414415 @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {416 val repo = VaultRepository(FakeStorage())417 repo.createVault("correct horse")418 val master = repo.syncMasterKey()!!419 val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")420 val paired = FakeDeviceRegistry(421 listOf(422 PairedDevice(423 deviceId = "claimed-issuer",424 deviceName = "Claimed Browser",425 deviceType = "extension",426 longTermPublicKeyHex = "11".repeat(32),427 signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),428 trustSource = "direct",429 pairedAt = 1L,430 ),431 PairedDevice(432 deviceId = "authenticated-peer",433 deviceName = "Authenticated Phone",434 deviceType = "android",435 longTermPublicKeyHex = "22".repeat(32),436 signingPublicKeyHex = "33".repeat(32),437 trustSource = "direct",438 pairedAt = 2L,439 ),440 ),441 )442 val pending = FakePendingRegistry()443 val adapter = VaultSyncAdapter(444 repo,445 ourDeviceId = "android-self",446 pairedDevices = paired,447 pendingVouched = pending,448 )449 val manifest = buildSignedDeviceManifest(450 issuerDeviceId = "claimed-issuer",451 devices = listOf(452 ManifestDeviceEntry(453 deviceId = "smuggled-device",454 deviceName = "Smuggled",455 deviceType = "extension",456 longTermPublicKeyHex = "aa".repeat(32),457 signingPublicKeyHex = "bb".repeat(32),458 pairedAt = 44L,459 ),460 ),461 signingPrivateKey = claimedSigning.privateKey,462 )463464 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })465466 assertEquals(0, pending.devices.size)467468 469 470 471 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })472 assertEquals(listOf("smuggled-device"), pending.devices.map { it.deviceId })473 master.fill(0)474 }475476 @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {477 val repo = VaultRepository(FakeStorage())478 repo.createVault("correct horse")479 val master = repo.syncMasterKey()!!480 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")481 val stolenKey = "dd".repeat(32)482 val paired = FakeDeviceRegistry(483 listOf(484 PairedDevice(485 deviceId = "phone-issuer",486 deviceName = "Pixel",487 deviceType = "android",488 longTermPublicKeyHex = "11".repeat(32),489 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),490 trustSource = "direct",491 pairedAt = 1L,492 ),493 PairedDevice(494 deviceId = "stolen-laptop",495 deviceName = "Old Laptop",496 deviceType = "extension",497 longTermPublicKeyHex = stolenKey,498 signingPublicKeyHex = "ee".repeat(32),499 trustSource = "direct",500 pairedAt = 2L,501 ),502 ),503 )504 val pending = FakePendingRegistry()505 val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)506507 adapter.applyIncomingDeviceManifest(508 buildSignedDeviceManifest(509 issuerDeviceId = "phone-issuer",510 devices = emptyList(),511 signingPrivateKey = issuerSigning.privateKey,512 revoked = listOf(513 DeviceRevocation(514 deviceId = "stolen-laptop",515 longTermPublicKeyHex = stolenKey,516 revokedAt = System.currentTimeMillis(),517 revokedByDeviceId = "phone-issuer",518 ),519 ),520 ),521 ByteArray(32) { 0x11 },522 )523 assertNull(paired.get("stolen-laptop"))524525 526 527 adapter.applyIncomingDeviceManifest(528 buildSignedDeviceManifest(529 issuerDeviceId = "phone-issuer",530 devices = listOf(531 ManifestDeviceEntry(532 deviceId = "stolen-laptop-mk2",533 deviceName = "New Laptop",534 deviceType = "extension",535 longTermPublicKeyHex = stolenKey,536 signingPublicKeyHex = "ee".repeat(32),537 pairedAt = System.currentTimeMillis(),538 ),539 ),540 signingPrivateKey = issuerSigning.privateKey,541 ),542 ByteArray(32) { 0x11 },543 )544 assertEquals(0, pending.devices.size)545 assertNull(paired.get("stolen-laptop-mk2"))546547 548 549 adapter.applyIncomingDeviceManifest(550 buildSignedDeviceManifest(551 issuerDeviceId = "phone-issuer",552 devices = listOf(553 ManifestDeviceEntry(554 deviceId = "stolen-laptop-mk2",555 deviceName = "New Laptop",556 deviceType = "extension",557 longTermPublicKeyHex = "99".repeat(32),558 signingPublicKeyHex = "ee".repeat(32),559 pairedAt = System.currentTimeMillis(),560 ),561 ),562 signingPrivateKey = issuerSigning.privateKey,563 ),564 ByteArray(32) { 0x11 },565 )566 assertEquals(listOf("stolen-laptop-mk2"), pending.devices.map { it.deviceId })567 master.fill(0)568 }569570 @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {571 val repo = VaultRepository(FakeStorage())572 repo.createVault("correct horse")573 val master = repo.syncMasterKey()!!574 val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")575 val revokedKey = "dd".repeat(32)576 val paired = FakeDeviceRegistry(577 listOf(578 PairedDevice(579 deviceId = "phone-issuer",580 deviceName = "Pixel",581 deviceType = "android",582 longTermPublicKeyHex = "11".repeat(32),583 signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),584 trustSource = "direct",585 pairedAt = 1L,586 ),587 PairedDevice(588 deviceId = "trusted-tablet",589 deviceName = "Tablet",590 deviceType = "android",591 longTermPublicKeyHex = "77".repeat(32),592 signingPublicKeyHex = "ee".repeat(32),593 trustSource = "direct",594 pairedAt = 2L,595 ),596 ),597 )598 val adapter = VaultSyncAdapter(599 repo,600 ourDeviceId = "android-self",601 pairedDevices = paired,602 pendingVouched = FakePendingRegistry(),603 )604605 adapter.applyIncomingDeviceManifest(606 buildSignedDeviceManifest(607 issuerDeviceId = "phone-issuer",608 devices = emptyList(),609 signingPrivateKey = issuerSigning.privateKey,610 revoked = listOf(611 DeviceRevocation(612 deviceId = "stolen-laptop",613 longTermPublicKeyHex = revokedKey,614 revokedAt = System.currentTimeMillis(),615 revokedByDeviceId = "phone-issuer",616 ),617 ),618 ),619 ByteArray(32) { 0x11 },620 )621622 623 adapter.applyIncomingDeviceManifest(624 buildSignedDeviceManifest(625 issuerDeviceId = "phone-issuer",626 devices = listOf(627 ManifestDeviceEntry(628 deviceId = "trusted-tablet",629 deviceName = "Tablet",630 deviceType = "android",631 longTermPublicKeyHex = revokedKey,632 signingPublicKeyHex = "ee".repeat(32),633 pairedAt = 2L,634 ),635 ),636 signingPrivateKey = issuerSigning.privateKey,637 ),638 ByteArray(32) { 0x11 },639 )640 assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)641 master.fill(0)642 }643644 645 @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {646 val registry = FakeDeviceRegistry()647 registry.mergeRevocations(648 listOf(649 DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),650 DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),651 ),652 )653 assertTrue(registry.isRevoked("gone"))654 assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))655 assertTrue(registry.isRevoked("keyless"))656 assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))657 658 659 assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))660 }661662 @Test fun `public key decoder requires exactly 32 bytes`() {663 assertEquals(32, hexToPubkey("ab".repeat(32)).size)664 assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)665 assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)666 assertEquals(64, hexToBytes("ab".repeat(64)).size)667 }668}