c3ad251a7980

fix: align pairing and locked sync security with browser (PB-26-103, PB-26-104, PB-26-117, v2.14.88)

time
2026-09-02 03:07 UTC · 1 month ago
machine
koh2
agent
gpt-5.6-sol
session
2026-09-01 security assessment remediation
parent
a74b757cd7ad
19 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/LockedSyncSpoolStore.kt+2 −13app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+25 −12app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+14 −5app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+5 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+10 −0app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1app/src/main/jniLibs/arm64-v8a/libpeach_noise_core.sobinaryapp/src/main/jniLibs/armeabi-v7a/libpeach_noise_core.sobinaryapp/src/main/jniLibs/x86/libpeach_noise_core.sobinaryapp/src/main/jniLibs/x86_64/libpeach_noise_core.sobinaryapp/src/test/java/com/peachpasswords/sync/LockedSyncSpoolModelTest.kt+0 −1app/src/test/java/com/peachpasswords/sync/NoiseKKTest.kt+1 −1app/src/test/java/com/peachpasswords/sync/PairedDeviceRevocationTest.kt+37 −0app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+15 −1app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+10 −2app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+20 −4app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+22 −8app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+5 −7
VERSION+1 −1
@@ -1 +1 @@1-2.14.87\ No newline at end of file1+2.14.88

view file

app/src/main/java/com/peachpasswords/sync/LockedSyncSpoolStore.kt+2 −13
@@ -22,7 +22,6 @@2222     val deviceName: String,2323     val localVersion: Long,2424     val longTermPublicKeyHex: String,25-    val longTermPrivateKeyHex: String,2625     val signingPublicKeyHex: String,2726     val entries: Map<String, LockedSyncCachedEntry>,2827     val tombstones: Map<String, Long>,@@ -76,7 +75,6 @@7675                     deviceName = getDeviceName(context),7776                     localVersion = repo.getVaultVersion(),7877                     longTermPublicKeyHex = pubkeyToHex(keys.publicKey),79-                    longTermPrivateKeyHex = pubkeyToHex(keys.secretKey),8078                     signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),8179                     entries = entries,8280                     tombstones = repo.getTombstones().associate { it.id to it.deletedAt },@@ -91,17 +89,8 @@9189     }9290 9391     internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {94-        val state = read() ?: return null95-        val privateKey = runCatching { hexToPubkey(state.longTermPrivateKeyHex) }.getOrNull() ?: return null96-        val publicKey = runCatching { hexToPubkey(state.longTermPublicKeyHex) }.getOrNull() ?: return null97-        return SyncIdentityKeypair(publicKey, privateKey) to LocalDeviceIdentity(98-            deviceId = state.deviceId,99-            deviceName = state.deviceName,100-            deviceType = DeviceType.ANDROID,101-            longTermPublicKeyHex = state.longTermPublicKeyHex,102-            signingPublicKeyHex = state.signingPublicKeyHex,103-            relayUrl = RelaySettingsStore.readRelayUrl(context),104-        )92+        // The private key is intentionally not persisted; authenticated Noise waits for unlock.93+        return null10594     }10695 10796     internal fun adapter(): SyncVaultAdapter? = read()?.let { LockedSyncVaultAdapter(this, it.vaultId) }

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+25 −12
@@ -98,10 +98,7 @@9898         val priorPrefs = readPrefsState()9999         val currentDevices = getAll()100100         val currentRevocations = listRevocations()101-        val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()102-        val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }103-        val admittedRevocations = freshRevocations.take((MAX_REVOCATIONS - currentRevocations.size).coerceAtLeast(0))104-        val nextRevocations = currentRevocations + admittedRevocations101+        val nextRevocations = mergeRevocationRecords(currentRevocations, batch.incomingRevocations).records105102         val removed = batch.removals106103         val nextDevices = currentDevices107104             .filterNot { removed.contains(it.deviceId) }@@ -169,12 +166,9 @@169166      */170167     override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {171168         val existing = listRevocations()172-        val known = existing.map { it.deviceId }.toSet()173-        val fresh = incoming.filterNot { known.contains(it.deviceId) }174-        if (fresh.isEmpty()) return emptyList()175-        val admitted = fresh.take((MAX_REVOCATIONS - existing.size).coerceAtLeast(0))176-        if (admitted.isNotEmpty()) persistRevocations(existing + admitted)177-        return admitted.map { it.deviceId }169+        val merged = mergeRevocationRecords(existing, incoming)170+        if (merged.records != existing) persistRevocations(merged.records)171+        return merged.admitted.map { it.deviceId }178172     }179173 180174     /**@@ -318,13 +312,32 @@318312         revokedAt = nowMs,319313         revokedByDeviceId = revokedByDeviceId,320314     )321-    val retained = if (revocations.size < MAX_REVOCATIONS) {315+    val retained = if (revocations.size < MAX_REVOCATION_RECORDS) {322316         revocations323317     } else {324318         val removable = revocations.indexOfFirst { it.revokedByDeviceId != revokedByDeviceId }325319         if (removable >= 0) revocations.toMutableList().also { it.removeAt(removable) } else revocations326320     }327-    return DeviceRevocationMutation(remaining, (retained + record).takeLast(MAX_REVOCATIONS), record)321+    return DeviceRevocationMutation(322+        remaining,323+        (retained + record).takeLast(MAX_REVOCATION_RECORDS),324+        record,325+    )326+}327+328+internal data class RevocationMergeResult(329+    val records: List<DeviceRevocation>,330+    val admitted: List<DeviceRevocation>,331+)332+333+internal fun mergeRevocationRecords(334+    existing: List<DeviceRevocation>,335+    incoming: List<DeviceRevocation>,336+): RevocationMergeResult {337+    val known = existing.map { it.deviceId }.toSet()338+    val fresh = incoming.filterNot { known.contains(it.deviceId) }339+    val admitted = fresh.take((MAX_REVOCATION_RECORDS - existing.size).coerceAtLeast(0))340+    return RevocationMergeResult(existing + admitted, admitted)328341 }329342 330343 /**

view file

app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+14 −5
@@ -7,6 +7,7 @@77 88 private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:"99 private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")10+private val NoiseStaticPattern = Regex("^[0-9a-f]{64}$")1011 1112 /**1213  * Which side of the vault transfer the issuing device is on:@@ -23,6 +24,7 @@2324     val relayUrl: String,2425     val token: String,2526     val vaultStatus: VaultStatus = VaultStatus.POPULATED,27+    val noiseStaticPublicKeyHex: String? = null,2628 )2729 2830 @Serializable@@ -30,26 +32,31 @@3032     val relay: String,3133     val token: String,3234     val vault: String? = null,35+    val static: String? = null,3336 )3437 3538 /**36- * QR/manual first-pair payload. Only reachability is present before Noise:37- * a credential-free wss relay and a fresh 128-bit token. [vaultStatus] tells38- * the scanner which side of the transfer it is on; older invitations omit it39- * and parse as POPULATED. Never add identity or vault fields here.39+ * QR/manual first-pair payload. It carries relay reachability, a fresh 128-bit40+ * token, the transfer direction, and the issuer's Noise static public key so41+ * the scanner can authenticate the cleartext ACK before the handshake.4042  */4143 fun createPairingInvitation(4244     relayUrl: String,4345     token: String = Rendezvous.newPairingToken(),4446     vaultStatus: VaultStatus = VaultStatus.POPULATED,47+    noiseStaticPublicKeyHex: String? = null,4548 ): String {4649     require(PairingTokenPattern.matches(token)) {4750         "pairing invitation token must be 128-bit base64url"4851     }52+    require(noiseStaticPublicKeyHex == null || NoiseStaticPattern.matches(noiseStaticPublicKeyHex)) {53+        "invalid Noise static public key"54+    }4955     val wire = PairingInvitationWire(5056         relay = Rendezvous.normalizeRelayUrl(relayUrl),5157         token = token,5258         vault = wireVaultStatus(vaultStatus),59+        static = noiseStaticPublicKeyHex,5360     )5461     val encoded = Base64.getUrlEncoder().withoutPadding()5562         .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))@@ -67,10 +74,12 @@6774             String(decoded, Charsets.UTF_8),6875         )6976         require(PairingTokenPattern.matches(wire.token))77+        require(wire.static == null || NoiseStaticPattern.matches(wire.static))7078         PairingInvitation(7179             relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),7280             token = wire.token,7381             vaultStatus = parseVaultStatus(wire.vault),82+            noiseStaticPublicKeyHex = wire.static,7483         )7584     }.getOrNull()7685 }@@ -88,4 +97,4 @@8897     else -> VaultStatus.POPULATED8998 }9099 91-fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)\ No newline at end of file100+fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+5 −1
@@ -56,7 +56,11 @@5656             is RelayDecision.Dial -> decision.relayUrl5757         }5858         val token = Rendezvous.newPairingToken()59-        val invitation = createPairingInvitation(relayUrl, token)59+        val invitation = createPairingInvitation(60+            relayUrl,61+            token,62+            noiseStaticPublicKeyHex = Rendezvous.bytesToHex(ourStaticPublicKey),63+        )6064 6165         job = scope.launch(Dispatchers.IO) {6266             try {

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+10 −0
@@ -1,6 +1,7 @@11 package com.peachpasswords.sync22 33 import android.util.Log4+import java.security.MessageDigest45 import javax.inject.Inject56 import javax.inject.Singleton67 import kotlinx.coroutines.CancellationException@@ -166,6 +167,15 @@166167         "expected PEACH_HELLO_ACK"167168     }168169     val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))170+    val invitedStatic = invitation.noiseStaticPublicKeyHex171+        ?.let(::hexToPubkey)172+        ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")173+    val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)174+    invitedStatic.fill(0)175+    if (!staticMatches) {176+        sourceStatic.fill(0)177+        throw IllegalArgumentException("pairing_static_key_mismatch")178+    }169179     val noise = try {170180         noiseNKInitiator(171181             sourceStatic,

view file

app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1
@@ -155,7 +155,7 @@155155             checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "manifest entries")156156             checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "manifest tombstones")157157         }158-        is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_ENTRY_COUNT, "request ids")158+        is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_REQUEST_ENTRY_COUNT, "request ids")159159         is SyncMessage.Entries -> {160160             checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "entries")161161             if (entries.values.any { it.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES }) {

view file

app/src/main/jniLibs/arm64-v8a/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/main/jniLibs/armeabi-v7a/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/main/jniLibs/x86/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/main/jniLibs/x86_64/libpeach_noise_core.sobinary

binary file, not shown

view file

app/src/test/java/com/peachpasswords/sync/LockedSyncSpoolModelTest.kt+0 −1
@@ -25,7 +25,6 @@2525         deviceName = "Android",2626         localVersion = 1,2727         longTermPublicKeyHex = "b".repeat(64),28-        longTermPrivateKeyHex = "c".repeat(64),2928         signingPublicKeyHex = "d".repeat(64),3029         entries = emptyMap(),3130         tombstones = emptyMap(),

view file

app/src/test/java/com/peachpasswords/sync/NoiseKKTest.kt+1 −1
@@ -85,7 +85,7 @@8585         val a = initiatorHandshake.await()8686         val b = responderHandshake.await()8787         assertEquals(88-            "ac3dd2e4c40290fab2a0192b0b33b37bb8c31396ffec23271ffa2a99ef3a0f75",88+            "dced1b411d7dc25716d8fc7fdc6b49418ae5648f323df5fb20a105f3c0acabab",8989             pubkeyToHex(a.handshakeHash),9090         )9191         assertArrayEquals(a.handshakeHash, b.handshakeHash)

view file

app/src/test/java/com/peachpasswords/sync/PairedDeviceRevocationTest.kt+37 −0
@@ -30,6 +30,43 @@3030     }3131 3232     @Test33+    fun `full ledger keeps terminal records during peer merge capacity`() {34+        val existing = List(500) { index ->35+            DeviceRevocation("terminal-$index", index.toString(16).padStart(64, '0'), index.toLong(), "issuer-a")36+        }37+        val merged = mergeRevocationRecords(existing, listOf(38+            DeviceRevocation("peer-extra", "ff".repeat(32), 501L, "issuer-b"),39+        ))40+        assertEquals(500, merged.records.size)41+        assertEquals(0, merged.admitted.size)42+        assertEquals("terminal-0", merged.records.first().deviceId)43+    }44+45+    @Test46+    fun `same issuer local flood uses bounded capacity`() {47+        var records = emptyList<DeviceRevocation>()48+        repeat(501) { index ->49+            records = if (records.size < 500) records + DeviceRevocation("local-$index", index.toString(16).padStart(64, '0'), index.toLong(), "issuer-a")50+            else records.drop(1) + DeviceRevocation("local-$index", index.toString(16).padStart(64, '0'), index.toLong(), "issuer-a")51+        }52+        assertEquals(500, records.size)53+        assertEquals("local-1", records.first().deviceId)54+        assertEquals("local-500", records.last().deviceId)55+    }56+57+    @Test58+    fun `different issuer local capacity preserves genuine issuer record`() {59+        val existing = List(500) { index ->60+            DeviceRevocation("terminal-$index", index.toString(16).padStart(64, '0'), index.toLong(), if (index == 0) "issuer-a" else "issuer-b")61+        }62+        val removable = existing.indexOfFirst { it.revokedByDeviceId != "issuer-a" }63+        val records = existing.toMutableList().also { it.removeAt(removable); it += DeviceRevocation("local-new", "ff".repeat(32), 501L, "issuer-a") }64+        assertEquals(500, records.size)65+        assertEquals("terminal-0", records.first().deviceId)66+        assertEquals("local-new", records.last().deviceId)67+    }68+69+    @Test3370     fun `existing revocation still removes a stale overlapping paired row`() {3471         val existing = DeviceRevocation(3572             deviceId = browser.deviceId,

view file

app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+15 −1
@@ -8,6 +8,7 @@88 class PairingInvitationTest {99     private val token = "AAECAwQFBgcICQoLDA0ODw"1010     private val relay = "wss://relay.peachpasswords.com"11+    private val staticKey = "01".repeat(32)1112     private val kat =1213         "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoicG9wdWxhdGVkIn0"1314     private val emptyKat =@@ -32,6 +33,19 @@3233         assertEquals(VaultStatus.POPULATED, parsed?.vaultStatus)3334     }3435 36+    @Test fun `Noise static key round-trips and malformed keys fail closed`() {37+        val invitation = createPairingInvitation(38+            relay,39+            token,40+            VaultStatus.POPULATED,41+            staticKey,42+        )43+        assertEquals(staticKey, parsePairingInvitation(invitation)?.noiseStaticPublicKeyHex)44+        assertNull(parsePairingInvitation(raw(45+            """{"relay":"$relay","token":"$token","vault":"populated","static":"bad"}""",46+        )))47+    }48+3549     @Test fun `missing vault field defaults to populated for historical compatibility`() {3650         val older = raw("""{"relay":"$relay","token":"$token"}""")3751         val parsed = parsePairingInvitation(older)@@ -67,4 +81,4 @@6781     private fun raw(json: String): String =6882         "peach-pair-v1:" + java.util.Base64.getUrlEncoder().withoutPadding()6983             .encodeToString(json.toByteArray())70-}\ No newline at end of file84+}

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+10 −2
@@ -251,7 +251,11 @@251251         val queuedReceiverSocket = QueuedFinalFrameSocket(receiverSocket)252252         val received = adoptFromRelaySocket(253253             socket = queuedReceiverSocket,254-            invitation = PairingInvitation(relay, token),254+            invitation = PairingInvitation(255+                relay,256+                token,257+                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),258+            ),255259             deps = object : AdoptionReceiverDeps {256260                 override val deviceName = "receiver"257261                 override val deviceType = DeviceType.ANDROID@@ -315,7 +319,11 @@315319         var created = false316320         val received = runRelayAdoptionReceiverSession(317321             receiverSocket,318-            PairingInvitation(relay, token),322+            PairingInvitation(323+                relay,324+                token,325+                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),326+            ),319327             object : AdoptionReceiverDeps {320328                 override val deviceName = "receiver"321329                 override val deviceType = DeviceType.ANDROID

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+20 −4
@@ -119,7 +119,11 @@119119 120120         val received = runRelayAdoptionReceiverSession(121121             receiverSocket,122-            PairingInvitation(relay, token),122+            PairingInvitation(123+                relay,124+                token,125+                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),126+            ),123127             object : AdoptionReceiverDeps {124128                 override val deviceName = "receiver"125129                 override val deviceType = DeviceType.ANDROID@@ -199,7 +203,11 @@199203 200204         val received = runRelayAdoptionReceiverSession(201205             receiverSocket,202-            PairingInvitation(relay, token),206+            PairingInvitation(207+                relay,208+                token,209+                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),210+            ),203211             object : AdoptionReceiverDeps {204212                 override val deviceName = "receiver"205213                 override val deviceType = DeviceType.ANDROID@@ -291,7 +299,11 @@291299         val elapsedMs = measureTimeMillis {292300             received = runRelayAdoptionReceiverSession(293301                 receiverSocket,294-                PairingInvitation(relay, token),302+                PairingInvitation(303+                    relay,304+                    token,305+                    noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),306+                ),295307                 object : AdoptionReceiverDeps {296308                     override val deviceName = "receiver"297309                     override val deviceType = DeviceType.ANDROID@@ -424,7 +436,11 @@424436 425437         val received = runRelayAdoptionReceiverSession(426438             receiverSocket,427-            PairingInvitation(relay, token),439+            PairingInvitation(440+                relay,441+                token,442+                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),443+            ),428444             object : AdoptionReceiverDeps {429445                 override val deviceName = "receiver"430446                 override val deviceType = DeviceType.ANDROID

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+22 −8
@@ -372,15 +372,29 @@372372         assertTrue(out.error!!.contains("tombstones"))373373     }374374 375-    @Test fun oversized_request_is_refused() {376-        val over = (0..SyncLimits.MAX_ENTRY_COUNT).map { "id-$it" }377-        val out = scriptedResponder(378-            hello(),379-            SyncMessage.Manifest(emptyMap(), emptyMap()),380-            SyncMessage.Request(over),381-        )375+    @Test fun oversized_request_is_refused_before_adapter_work() {376+        val over = (0..SyncLimits.MAX_REQUEST_ENTRY_COUNT).map { "id-$it" }377+        val adapter = FakeAdapter("B", version = 2)378+        val out = runBlocking {379+            val queue = ArrayDeque(380+                listOf(381+                    hello(),382+                    SyncMessage.Manifest(emptyMap(), emptyMap()),383+                    SyncMessage.Request(over),384+                ).map { it.encodeToBytes() },385+            )386+            val session = SyncSession(387+                remoteStaticPublicKey = ByteArray(32),388+                send = { },389+                receive = { queue.removeFirstOrNull() ?: throw java.io.IOException("WebSocket closed") },390+                close = { },391+            )392+            runSyncResponder(session, adapter, nowMs = 0L)393+        }382394         assertEquals(SyncOutcomeStatus.ERRORED, out.status)383395         assertTrue(out.error!!.contains("request ids"))396+        assertTrue(adapter.entries.isEmpty())397+        assertTrue(adapter.blobs.isEmpty())384398     }385399 386400     @Test fun oversized_entry_envelope_is_refused() {@@ -405,7 +419,7 @@405419         val (sa, sb) = duplex()406420         val a = FakeAdapter("A", version = 1)407421         val b = FakeAdapter("B", version = 2).apply {408-            repeat(SyncLimits.MAX_ENTRY_COUNT) { put("id-$it", 1L) }422+            repeat(SyncLimits.MAX_REQUEST_ENTRY_COUNT) { put("id-$it", 1L) }409423         }410424         val ra = async { runSyncInitiator(sa, a, nowMs = 0) }411425         val rb = async { runSyncResponder(sb, b, nowMs = 0) }

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+5 −7
@@ -48,9 +48,7 @@4848         override fun listRevocations(): List<DeviceRevocation> = revocations.toList()4949         override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {5050             if (failManifestCommit) return false51-            val nextRevocations = revocations.toMutableList()52-            val known = nextRevocations.map { it.deviceId }.toSet()53-            nextRevocations += batch.incomingRevocations.filterNot { known.contains(it.deviceId) }51+            val nextRevocations = mergeRevocationRecords(revocations, batch.incomingRevocations).records.toMutableList()5452             val nextDevices = byId.toMutableMap()5553             batch.removals.forEach(nextDevices::remove)5654             batch.upserts.forEach { nextDevices[it.deviceId] = it }@@ -61,10 +59,10 @@6159             return true6260         }6361         override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {64-            val known = revocations.map { it.deviceId }.toSet()65-            val fresh = incoming.filterNot { known.contains(it.deviceId) }66-            revocations += fresh67-            return fresh.map { it.deviceId }62+            val merged = mergeRevocationRecords(revocations, incoming)63+            revocations.clear()64+            revocations += merged.records65+            return merged.admitted.map { it.deviceId }6866         }6967         override fun clearRevocation(deviceId: String) {7068             revocations.removeAll { it.deviceId == deviceId }

view file