1373ec751bc8

name ADOPT_REQUEST's device_id sessionRequestId and pin that only the identity frame's key is persisted

time
2026-07-25 01:31 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
adoption device id
parent
fa7286f1a4f6
2 files app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+21 −4app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+37 −1
app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+21 −4
@@ -20,10 +20,20 @@2020  */2121 @Serializable2222 sealed class AdoptionFrame {23+    /**24+     * [sessionRequestId] carries the wire's `device_id`, but it is NOT an25+     * identity: the requester generates it locally before the handshake that26+     * establishes any long-term key, so it cannot be the device's real one27+     * (see `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2). It is a label for the28+     * consent prompt and nothing else — never persist it, never compare it29+     * against a previous attempt's, never key trust on it. The only identity30+     * that may be persisted is `long_term_public_key_hex`, which arrives later31+     * in ADOPT_IDENTITY / ADOPT_GRANT.source.32+     */2333     @Serializable2434     @SerialName("ADOPT_REQUEST")2535     data class Request(26-        @SerialName("device_id") val deviceId: String,36+        @SerialName("device_id") val sessionRequestId: String,2737         @SerialName("device_name") val deviceName: String,2838         @SerialName("device_type") val deviceType: String,2939     ) : AdoptionFrame()@@ -79,7 +89,12 @@7989     suspend fun vaultId(): String8090     suspend fun license(): String? = null8191 82-    /** Ask the user to approve handing this vault to the requesting device. */92+    /**93+     * Ask the user to approve handing this vault to the requesting device. The94+     * request's name/type/label are for the human to read; the decision must not95+     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which96+     * carries no continuity between attempts.97+     */8398     suspend fun onConsent(request: AdoptionFrame.Request): Boolean8499     suspend fun upsertPairedDevice(device: PairedDevice)85100 }@@ -90,7 +105,7 @@90105 91106 fun isAdoptRequest(frame: AdoptionFrame?): Boolean =92107     frame is AdoptionFrame.Request &&93-        frame.deviceId.isNotEmpty() &&108+        frame.sessionRequestId.isNotEmpty() &&94109         isDeviceType(frame.deviceType)95110 96111 fun isAdoptGrant(frame: AdoptionFrame?): Boolean =@@ -118,8 +133,10 @@118133     deps: AdoptionReceiverDeps,119134     nowMs: () -> Long = { System.currentTimeMillis() },120135 ): PairedDevice? {136+    // Fresh every attempt, and deliberately not our real device id: no identity137+    // exists yet at this point in the protocol.121138     sendFrame(channel, AdoptionFrame.Request(122-        deviceId = deps.generateDeviceId(),139+        sessionRequestId = deps.generateDeviceId(),123140         deviceName = deps.deviceName,124141         deviceType = deps.deviceType.wireValue,125142     ))

view file

app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+37 −1
@@ -115,6 +115,42 @@115115         assertEquals("android", source.upserted.single().deviceType)116116     }117117 118+    /**119+     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label120+     * on ADOPT_REQUEST is generated before any key exists, so persisting it as121+     * an identity would key trust on a value with no continuity between122+     * attempts. What lands in the store must come from ADOPT_IDENTITY.123+     */124+    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {125+        val (receiverCh, sourceCh) = memoryPair()126+        val rootKeyHex = "55".repeat(32)127+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))128+        // Deliberately unlike the identity the receiver will present.129+        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")130+131+        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }132+        receiveFrame(receiverCh) // the grant133+        sendFrame(134+            receiverCh,135+            AdoptionFrame.Identity(136+                deviceId = "receiver-id",137+                deviceName = "Receiver",138+                deviceType = "extension",139+                longTermPublicKeyHex = "aa".repeat(32),140+                signingPublicKeyHex = "bb".repeat(32),141+            ),142+        )143+        grantJob.await()144+145+        val persisted = source.upserted.single()146+        assertEquals("receiver-id", persisted.deviceId)147+        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)148+        assertTrue(149+            "the ephemeral request label must not reach the paired-device store",150+            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },151+        )152+    }153+118154     @Test fun request_carries_receiver_device_type() = runBlocking {119155         val (receiverCh, sourceCh) = memoryPair()120156         val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)@@ -123,7 +159,7 @@123159         recvJob.cancel()124160 125161         assertEquals("android", request.deviceType)126-        assertEquals("receiver-ephemeral", request.deviceId)162+        assertEquals("receiver-ephemeral", request.sessionRequestId)127163         assertEquals("Receiver", request.deviceName)128164     }129165 

view file