b7c31dd4fa00

fix: gate vault adoption on both confirmations (v2.3.77)

time
2026-07-26 04:47 UTC · 3 months ago
machine
koh2
parent
bdf1f16e8bcf
3 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+23 −0app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+33 −2
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23760020-        versionName "2.3.76"19+        versionCode 23770020+        versionName "2.3.77"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+23 −0
@@ -39,6 +39,12 @@3939     ) : AdoptionFrame()4040 4141     @Serializable42+    @SerialName("ADOPT_CONFIRM")43+    data class Confirm(44+        val confirmed: Boolean,45+    ) : AdoptionFrame()46+47+    @Serializable4248     @SerialName("ADOPT_GRANT")4349     data class Grant(4450         @SerialName("root_key_hex") val rootKeyHex: String,@@ -73,6 +79,8 @@7379     val deviceName: String7480     val deviceType: DeviceType7581     fun generateDeviceId(): String82+    /** Root-key transfer cannot begin until this device confirms the SAS. */83+    suspend fun confirmSas(): Boolean7684 7785     /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */7886     suspend fun createVaultFromRootKey(rootKeyHex: String): String@@ -115,6 +123,9 @@115123         isDeviceType(frame.source.deviceType) &&116124         Hex64.matches(frame.source.longTermPublicKeyHex)117125 126+fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =127+    frame is AdoptionFrame.Confirm128+118129 fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =119130     frame is AdoptionFrame.Identity &&120131         frame.deviceId.isNotEmpty() &&@@ -141,6 +152,13 @@141152         deviceType = deps.deviceType.wireValue,142153     ))143154 155+    val confirmed = deps.confirmSas()156+    sendFrame(channel, AdoptionFrame.Confirm(confirmed))157+    if (!confirmed) {158+        deps.onError("ADOPT_SAS_REJECTED")159+        return null160+    }161+144162     val frame = receiveFrame(channel)145163     if (!isAdoptGrant(frame)) {146164         deps.onError("ADOPT_UNEXPECTED_FRAME")@@ -201,6 +219,11 @@201219     nowMs: () -> Long = { System.currentTimeMillis() },202220 ): PairedDevice? {203221     if (!deps.onConsent(request)) return null222+223+    val confirmation = receiveFrame(channel)224+    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {225+        return null226+    }204227 205228     val rootKeyHex = deps.rootKeyHex()206229     if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")

view file

app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+33 −2
@@ -56,12 +56,14 @@5656         private val vaultId: String?,5757         private val createThrows: Boolean = false,5858         private val derived: LocalChirpIdentity? = null,59+        private val sasConfirmed: Boolean = true,5960     ) : AdoptionReceiverDeps {6061         var createCalledWith: String? = null6162         var licenseApplied: String? = null6263         val upserted = mutableListOf<PairedDevice>()6364         val errors = mutableListOf<String>()6465         override fun generateDeviceId(): String = "receiver-ephemeral"66+        override suspend fun confirmSas(): Boolean = sasConfirmed6567         override suspend fun createVaultFromRootKey(rootKeyHex: String): String {6668             createCalledWith = rootKeyHex6769             if (createThrows) throw IllegalStateException("commit_failed")@@ -82,7 +84,11 @@8284     ) : AdoptionSourceDeps {8385         val upserted = mutableListOf<PairedDevice>()8486         var consentAsked = false85-        override suspend fun rootKeyHex(): String = rootKeyHex87+        var rootRequested = false88+        override suspend fun rootKeyHex(): String {89+            rootRequested = true90+            return rootKeyHex91+        }8692         override suspend fun vaultId(): String = vaultId8793         override suspend fun license(): String? = license8894         override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {@@ -129,6 +135,7 @@129135         val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")130136 131137         val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }138+        sendFrame(receiverCh, AdoptionFrame.Confirm(true))132139         receiveFrame(receiverCh) // the grant133140         sendFrame(134141             receiverCh,@@ -176,10 +183,33 @@176183 177184         assertNull(granted)178185         assertTrue(source.consentAsked)186+        assertFalse(source.rootRequested)179187         assertTrue(source.upserted.isEmpty())180188         assertNull(receiver.createCalledWith)181189     }182190 191+    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {192+        val (receiverCh, sourceCh) = memoryPair()193+        val rootKeyHex = "66".repeat(32)194+        val receiver = RecorderReceiver(195+            "Receiver",196+            DeviceType.ANDROID,197+            vaultIdFor(rootKeyHex),198+            sasConfirmed = false,199+        )200+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))201+202+        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }203+        val request = receiveFrame(sourceCh) as AdoptionFrame.Request204+        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }205+206+        assertNull(receiverJob.await())207+        assertNull(sourceJob.await())208+        assertFalse(source.rootRequested)209+        assertNull(receiver.createCalledWith)210+        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)211+    }212+183213     @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {184214         val (receiverCh, sourceCh) = memoryPair()185215         val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)@@ -213,11 +243,12 @@213243     }214244 215245     @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {216-        val (_, sourceCh) = memoryPair()246+        val (receiverCh, sourceCh) = memoryPair()217247         val rootKeyHex = "33".repeat(32)218248         val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))219249         val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")220250 251+        sendFrame(receiverCh, AdoptionFrame.Confirm(true))221252         val result = withTimeoutOrNull(150) {222253             // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.223254             grantAdoption(sourceCh, request, source) { 0L }

view file