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
↓ peach-android-1373ec75.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+21 −4app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+37 −1app/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 ))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