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
↓ peach-android-c3ad251a.tar.gzatlas at this save
files changed19 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 −7app/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) }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 /**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)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 {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,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 }) {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(),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)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,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+}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.ANDROIDapp/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.ANDROIDapp/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) }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 }