d0174f9b05d3
fix: bind sync manifests to authenticated peer identity (v2.3.69)
- time
- 2026-07-26 02:13 UTC · 3 months ago
- machine
- koh2
- agent
- gpt-5
- session
- peach relay sync implementation
- parent
- 43e6d3efc508
↓ peach-android-d0174f9b.tar.gzatlas at this save
files changed12 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+19 −18app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+5 −1app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+7 −2app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+5 −0app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+2 −1app/src/main/java/com/peachpasswords/sync/SyncIdentity.kt+6 −1app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+3 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+13 −4app/src/test/java/com/peachpasswords/sync/ChirpSyncCoordinatorTest.kt+37 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+65 −3app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23680020- versionName "2.3.68"19+ versionCode 23690020+ versionName "2.3.69"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+19 −18
@@ -29,15 +29,6 @@2929 private const val SESSION_TIMEOUT_MS = 30_000L3030 private const val CLOCK_SKEW_WARN_MS = 120_000L3131 32-/** 128 hex zeroes — the placeholder a peer sends when it has no real manifest. */33-private const val EMPTY_MANIFEST_SIGNATURE = "00000000000000000000000000000000" +34- "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"35-36-val EMPTY_MANIFEST_PLACEHOLDER = SignedDeviceManifest(37- body = DeviceManifestBody(manifestVersion = 1, issuerDeviceId = "", issuedAt = 0, devices = emptyList()),38- signatureHex = EMPTY_MANIFEST_SIGNATURE,39-)40-4132 /**4233 * Vault-side dependency of the coordinator. The vault holds the canonical4334 * entry/tombstone tables; the coordinator pulls a manifest, requests entries by@@ -68,8 +59,14 @@6859 6960 /** Optional device-manifest exchange (v2 brief §4.3). Null disables it. */7061 suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = null71- suspend fun applyIncomingDeviceManifest(manifest: SignedDeviceManifest) {}62+ suspend fun applyIncomingDeviceManifest(63+ manifest: SignedDeviceManifest,64+ authenticatedRemoteStaticPublicKey: ByteArray,65+ ) {}7266 67+ /** Bind user-visible sync alerts to a stored, authenticated peer record. */68+ fun bindAuthenticatedPeer(peer: PairedDevice) {}69+7370 /** Both ends must opt in for the device-manifest step to run. */7471 val supportsDeviceManifest: Boolean get() = false7572 }@@ -148,11 +145,13 @@148145 tombstonesReceived = peerTombstones.tombstones.size149146 150147 if (vault.supportsDeviceManifest) {151- val ours = vault.buildOutgoingDeviceManifest() ?: EMPTY_MANIFEST_PLACEHOLDER152- session.sendMsg(SyncMessage.DeviceManifest(ours))148+ session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))153149 val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")154- if (theirs.manifest.signatureHex != EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {155- vault.applyIncomingDeviceManifest(theirs.manifest)150+ if (theirs.manifest != null) {151+ vault.applyIncomingDeviceManifest(152+ theirs.manifest,153+ session.remoteStaticPublicKey,154+ )156155 }157156 }158157 @@ -241,10 +240,12 @@241240 242241 if (vault.supportsDeviceManifest) {243242 val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")244- val ours = vault.buildOutgoingDeviceManifest() ?: EMPTY_MANIFEST_PLACEHOLDER245- session.sendMsg(SyncMessage.DeviceManifest(ours))246- if (theirs.manifest.signatureHex != EMPTY_MANIFEST_PLACEHOLDER.signatureHex) {247- vault.applyIncomingDeviceManifest(theirs.manifest)243+ session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))244+ if (theirs.manifest != null) {245+ vault.applyIncomingDeviceManifest(246+ theirs.manifest,247+ session.remoteStaticPublicKey,248+ )248249 }249250 }250251 app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+5 −1
@@ -10,6 +10,10 @@1010 interface PairedDeviceRegistry {1111 fun getAll(): List<PairedDevice>1212 fun get(id: String): PairedDevice?13+ fun getByPubkey(pubkeyHex: String): PairedDevice? =14+ getAll().firstOrNull {15+ it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true16+ }1317 fun upsert(device: PairedDevice)1418 }1519 @@ -41,7 +45,7 @@4145 if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null4246 if (!isHex(manifest.signatureHex, 128)) return null4347 if (!body.devices.all { normaliseManifestEntry(it) != null }) return null44- val signature = runCatching { hexToPubkey(manifest.signatureHex) }.getOrNull() ?: return null48+ val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null4549 val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)4650 if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null4751 return bodyapp/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+7 −2
@@ -144,6 +144,7 @@144144 // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.145145 val vault = vaultFactory()146146 if (vault != null) {147+ vault.bindAuthenticatedPeer(device)147148 val syncSession = chirpSessionOver(session, transport)148149 val outcome = runChirpSyncResponder(syncSession, vault)149150 if (outcome.status == SyncOutcomeStatus.COMPLETED) {@@ -269,9 +270,13 @@269270 * the encrypted vault exchange. Send/receive splice the Noise cipher onto the270271 * length-prefixed frame layer; `remoteStaticPublicKey` is informational here.271272 */272-internal fun chirpSessionOver(session: NoiseNKSession, transport: SocketFrameIO): ChirpSyncSession =273+internal fun chirpSessionOver(274+ session: NoiseNKSession,275+ transport: SocketFrameIO,276+ remoteStaticPublicKey: ByteArray = ByteArray(0),277+): ChirpSyncSession =273278 ChirpSyncSession(274- remoteStaticPublicKey = ByteArray(0),279+ remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),275280 // ChirpSyncSession.send is non-suspending (matches openChirpSyncSession);276281 // SocketFrameIO.sendBlocking writes a frame synchronously.277282 send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+5 −0
@@ -59,6 +59,11 @@5959 6060 override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6161 62+ override fun getByPubkey(pubkeyHex: String): PairedDevice? =63+ getAll().firstOrNull {64+ it.longTermPublicKeyHex?.equals(pubkeyHex, ignoreCase = true) == true65+ }66+6267 /** Insert or replace by [PairedDevice.deviceId]. */6368 override fun upsert(device: PairedDevice) {6469 val next = getAll().filterNot { it.deviceId == device.deviceId } + deviceapp/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+2 −1
@@ -131,7 +131,8 @@131131 // Encrypted vault sync — real initiator round.132132 val vault = vaultFactory()133133 if (vault != null) {134- val syncSession = chirpSessionOver(session, transport)134+ vault.bindAuthenticatedPeer(device)135+ val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)135136 val outcome = runChirpSyncInitiator(syncSession, vault)136137 if (outcome.status == SyncOutcomeStatus.COMPLETED) {137138 deviceStore.updateLastSync(device.deviceId)app/src/main/java/com/peachpasswords/sync/SyncIdentity.kt+6 −1
@@ -70,7 +70,7 @@7070 7171 fun pubkeyToHex(pk: ByteArray): String = pk.joinToString("") { "%02x".format(it) }7272 73-fun hexToPubkey(hex: String): ByteArray {73+fun hexToBytes(hex: String): ByteArray {7474 require(hex.length % 2 == 0) { "hex length must be even" }7575 val out = ByteArray(hex.length / 2)7676 for (i in out.indices) {@@ -78,6 +78,11 @@7878 out[i] = byte.toByte()7979 }8080 return out81+}82+83+fun hexToPubkey(hex: String): ByteArray {84+ require(hex.length == 64) { "public key must be exactly 32 bytes" }85+ return hexToBytes(hex)8186 }8287 8388 private fun hkdf(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1
@@ -42,7 +42,7 @@4242 4343 @Serializable4444 @SerialName("SYNC_DEVICE_MANIFEST")45- data class DeviceManifest(val manifest: SignedDeviceManifest) : SyncMessage()45+ data class DeviceManifest(val manifest: SignedDeviceManifest?) : SyncMessage()4646 4747 @Serializable4848 @SerialName("SYNC_DONE")app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+3 −1
@@ -132,7 +132,7 @@132132 ws.send(session.encrypt(plaintext))133133 }134134 try {135- dispatch(link, session, ws, vaultFactory)135+ dispatch(link, session, ws, known, vaultFactory)136136 } finally {137137 relayHub.unregister(link)138138 }@@ -154,6 +154,7 @@154154 link: RelayLink,155155 session: NoiseNKSession,156156 ws: WebSocketConnection,157+ authenticatedPeer: PairedDevice,157158 vaultFactory: () -> ChirpSyncVaultAdapter?,158159 ) {159160 while (true) {@@ -166,6 +167,7 @@166167 if (!relayHub.beginSync(link)) return167168 try {168169 val vault = vaultFactory() ?: return170+ vault.bindAuthenticatedPeer(authenticatedPeer)169171 runChirpSyncResponder(webSocketSyncSession(session, ws, pushback = frame), vault)170172 } finally {171173 relayHub.endSync(link)app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+13 −4
@@ -34,7 +34,7 @@3434 private val pairedDevices: PairedDeviceRegistry? = null,3535 private val pendingVouched: PendingVouchedRegistry? = null,3636 private val syncAlerts: SyncAlertRecorder? = null,37- private val remoteDeviceName: String = "paired device",37+ private var remoteDeviceName: String = "paired device",3838 override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,3939 ) : ChirpSyncVaultAdapter {4040 @@ -113,6 +113,12 @@113113 }114114 }115115 116+ override fun bindAuthenticatedPeer(peer: PairedDevice) {117+ remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }118+ ?: peer.deviceId.takeIf { it.isNotBlank() }119+ ?: "paired device"120+ }121+116122 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {117123 val registry = pairedDevices ?: return null118124 val master = requireMasterKey()@@ -125,11 +131,14 @@125131 }126132 }127133 128- override suspend fun applyIncomingDeviceManifest(manifest: SignedDeviceManifest) {134+ override suspend fun applyIncomingDeviceManifest(135+ manifest: SignedDeviceManifest,136+ authenticatedRemoteStaticPublicKey: ByteArray,137+ ) {129138 val registry = pairedDevices ?: return130139 val pending = pendingVouched ?: return131- val issuerId = manifest.body.issuerDeviceId132- val issuer = registry.get(issuerId) ?: return140+ if (authenticatedRemoteStaticPublicKey.size != 32) return141+ val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return133142 // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):134143 // trust a manifest only from a DIRECTLY paired issuer. This previously135144 // keyed on deviceType == "extension" instead, which got both halvesapp/src/test/java/com/peachpasswords/sync/ChirpSyncCoordinatorTest.kt+37 −0
@@ -3,8 +3,10 @@33 import kotlinx.coroutines.async44 import kotlinx.coroutines.channels.Channel55 import kotlinx.coroutines.runBlocking6+import org.junit.Assert.assertArrayEquals67 import org.junit.Assert.assertEquals78 import org.junit.Assert.assertFalse9+import org.junit.Assert.assertNull810 import org.junit.Assert.assertTrue911 import org.junit.Test1012 @@ -26,10 +28,14 @@2628 val deviceId: String,2729 var version: Long,2830 val vaultId: String = "VAULT",31+ override val supportsDeviceManifest: Boolean = false,2932 ) : ChirpSyncVaultAdapter {3033 val entries = HashMap<String, Long>()3134 val blobs = HashMap<String, String>()3235 val tombstones = HashMap<String, Long>()36+ var outgoingDeviceManifest: SignedDeviceManifest? = null37+ var incomingDeviceManifest: SignedDeviceManifest? = null38+ var incomingAuthenticatedKey: ByteArray? = null3339 3440 fun put(id: String, mtime: Long) { entries[id] = mtime; blobs[id] = mtime.toString() }3541 @@ -46,6 +52,14 @@4652 override suspend fun applyTombstones(tombstones: Map<String, Long>) {4753 for ((id, ts) in tombstones) this.tombstones[id] = ts4854 }55+ override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = outgoingDeviceManifest56+ override suspend fun applyIncomingDeviceManifest(57+ manifest: SignedDeviceManifest,58+ authenticatedRemoteStaticPublicKey: ByteArray,59+ ) {60+ incomingDeviceManifest = manifest61+ incomingAuthenticatedKey = authenticatedRemoteStaticPublicKey.copyOf()62+ }4963 }5064 5165 class ChirpSyncCoordinatorTest {@@ -107,5 +121,28 @@107121 ra.await(); rb.await()108122 109123 assertEquals(500L, b.tombstones["gone"])124+ }125+126+ @Test fun absent_device_manifest_is_explicit_null_and_peer_key_is_bound() = runBlocking {127+ val (sa, sb) = duplex()128+ val manifest = SignedDeviceManifest(129+ body = DeviceManifestBody(1, "A", 1_000L, emptyList()),130+ signatureHex = "ab".repeat(64),131+ )132+ val a = FakeAdapter("A", version = 1, supportsDeviceManifest = true).apply {133+ outgoingDeviceManifest = manifest134+ }135+ val b = FakeAdapter("B", version = 2, supportsDeviceManifest = true)136+137+ val ra = async { runChirpSyncInitiator(sa, a, nowMs = 1_000L) }138+ val rb = async { runChirpSyncResponder(sb, b, nowMs = 1_000L) }139+ assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)140+ assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)141+142+ assertNull(a.incomingDeviceManifest)143+ assertEquals(manifest, b.incomingDeviceManifest)144+ assertArrayEquals(ByteArray(32), b.incomingAuthenticatedKey!!)145+ val wire = decodeSyncMessage(SyncMessage.DeviceManifest(null).encodeToBytes())146+ assertEquals(SyncMessage.DeviceManifest(null), wire)110147 }111148 }app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+65 −3
@@ -186,7 +186,7 @@186186 signingPrivateKey = issuerSigning.privateKey,187187 )188188 189- adapter.applyIncomingDeviceManifest(manifest)189+ adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })190190 191191 assertNull(paired.get("new-phone"))192192 assertEquals(1, pending.devices.size)@@ -236,7 +236,7 @@236236 signingPrivateKey = issuerSigning.privateKey,237237 )238238 239- adapter.applyIncomingDeviceManifest(manifest)239+ adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })240240 241241 assertEquals(1, pending.devices.size)242242 assertEquals("laptop-b", pending.devices.single().deviceId)@@ -283,9 +283,71 @@283283 signingPrivateKey = issuerSigning.privateKey,284284 )285285 286- adapter.applyIncomingDeviceManifest(manifest)286+ adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })287287 288288 assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)289289 master.fill(0)290+ }291+292+ @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {293+ val repo = VaultRepository(FakeStorage())294+ repo.createVault("correct horse")295+ val master = repo.syncMasterKey()!!296+ val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")297+ val paired = FakeDeviceRegistry(298+ listOf(299+ PairedDevice(300+ deviceId = "claimed-issuer",301+ deviceName = "Claimed Browser",302+ deviceType = "extension",303+ longTermPublicKeyHex = "11".repeat(32),304+ signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),305+ trustSource = "direct",306+ pairedAt = 1L,307+ ),308+ PairedDevice(309+ deviceId = "authenticated-peer",310+ deviceName = "Authenticated Phone",311+ deviceType = "android",312+ longTermPublicKeyHex = "22".repeat(32),313+ signingPublicKeyHex = "33".repeat(32),314+ trustSource = "direct",315+ pairedAt = 2L,316+ ),317+ ),318+ )319+ val pending = FakePendingRegistry()320+ val adapter = VaultSyncAdapter(321+ repo,322+ ourDeviceId = "android-self",323+ pairedDevices = paired,324+ pendingVouched = pending,325+ )326+ val manifest = buildSignedDeviceManifest(327+ issuerDeviceId = "claimed-issuer",328+ devices = listOf(329+ ManifestDeviceEntry(330+ deviceId = "smuggled-device",331+ deviceName = "Smuggled",332+ deviceType = "extension",333+ longTermPublicKeyHex = "aa".repeat(32),334+ signingPublicKeyHex = "bb".repeat(32),335+ pairedAt = 44L,336+ ),337+ ),338+ signingPrivateKey = claimedSigning.privateKey,339+ )340+341+ adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })342+343+ assertEquals(0, pending.devices.size)344+ master.fill(0)345+ }346+347+ @Test fun `public key decoder requires exactly 32 bytes`() {348+ assertEquals(32, hexToPubkey("ab".repeat(32)).size)349+ assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)350+ assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)351+ assertEquals(64, hexToBytes("ab".repeat(64)).size)290352 }291353 }