598fbf2a0602

port browser adoption frame protocol to ChirpAdopt (v2.3.58)

time
2026-06-26 16:49 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
t009 adoption foundation
parent
24202ed6005b
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+244 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+61 −0app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+237 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+51 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23560020-        versionName "2.3.56"19+        versionCode 23580020+        versionName "2.3.58"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+244 −0
@@ -0,0 +1,244 @@1+package com.peachpasswords.sync2+3+import kotlinx.serialization.SerialName4+import kotlinx.serialization.Serializable5+import kotlinx.serialization.json.Json6+7+/**8+ * Vault adoption frame protocol — exact port of the browser `shared/chirp-adopt.ts`.9+ *10+ * Adoption lets a brand-new device join an existing vault: the SOURCE (an already11+ * set-up device) hands its root key to the RECEIVER over the pairing channel so12+ * both devices end up with the same vault id and can sync. The receiver seals an13+ * empty local vault under its own password but reuses the adopted root key14+ * ([VaultRepository.createVaultFromRootKey]).15+ *16+ * Frames travel as JSON with a `type` discriminator and SNAKE_CASE keys — unlike17+ * the camelCase [SyncMessage] sync wire — so the keys must match the browser18+ * byte-for-byte for cross-platform adoption. Optional fields are omitted when19+ * absent (encodeDefaults = false) to mirror `JSON.stringify` dropping `undefined`.20+ */21+@Serializable22+sealed class AdoptionFrame {23+    @Serializable24+    @SerialName("ADOPT_REQUEST")25+    data class Request(26+        @SerialName("device_id") val deviceId: String,27+        @SerialName("device_name") val deviceName: String,28+        @SerialName("device_type") val deviceType: String,29+    ) : AdoptionFrame()30+31+    @Serializable32+    @SerialName("ADOPT_GRANT")33+    data class Grant(34+        @SerialName("root_key_hex") val rootKeyHex: String,35+        @SerialName("vault_id") val vaultId: String,36+        val license: String? = null,37+        val source: Source,38+    ) : AdoptionFrame()39+40+    @Serializable41+    @SerialName("ADOPT_IDENTITY")42+    data class Identity(43+        @SerialName("device_id") val deviceId: String,44+        @SerialName("device_name") val deviceName: String,45+        @SerialName("device_type") val deviceType: String,46+        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,47+        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,48+    ) : AdoptionFrame()49+50+    @Serializable51+    data class Source(52+        @SerialName("device_id") val deviceId: String,53+        @SerialName("device_name") val deviceName: String,54+        @SerialName("device_type") val deviceType: String,55+        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,56+        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,57+        @SerialName("ws_port") val wsPort: Int? = null,58+    )59+}60+61+/** What the RECEIVER (joining device) needs to adopt a vault from a source. */62+interface AdoptionReceiverDeps {63+    val deviceName: String64+    val deviceType: DeviceType65+    fun generateDeviceId(): String66+67+    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */68+    suspend fun createVaultFromRootKey(rootKeyHex: String): String69+    suspend fun deriveLocalIdentity(): LocalChirpIdentity70+    suspend fun upsertPairedDevice(device: PairedDevice)71+    suspend fun applyLicense(license: String) {}72+    fun onError(error: String)73+}74+75+/** What the SOURCE (existing device) needs to grant its vault to a receiver. */76+interface AdoptionSourceDeps {77+    val sourceIdentity: LocalChirpIdentity78+    suspend fun rootKeyHex(): String79+    suspend fun vaultId(): String80+    suspend fun license(): String? = null81+82+    /** Ask the user to approve handing this vault to the requesting device. */83+    suspend fun onConsent(request: AdoptionFrame.Request): Boolean84+    suspend fun upsertPairedDevice(device: PairedDevice)85+}86+87+private val Hex64 = Regex("^[0-9a-f]{64}\$")88+89+fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)90+91+fun isAdoptRequest(frame: AdoptionFrame?): Boolean =92+    frame is AdoptionFrame.Request &&93+        frame.deviceId.isNotEmpty() &&94+        isDeviceType(frame.deviceType)95+96+fun isAdoptGrant(frame: AdoptionFrame?): Boolean =97+    frame is AdoptionFrame.Grant &&98+        validateRootKeyHex(frame.rootKeyHex) &&99+        Hex64.matches(frame.vaultId) &&100+        isDeviceType(frame.source.deviceType) &&101+        Hex64.matches(frame.source.longTermPublicKeyHex)102+103+fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =104+    frame is AdoptionFrame.Identity &&105+        frame.deviceId.isNotEmpty() &&106+        isDeviceType(frame.deviceType) &&107+        Hex64.matches(frame.longTermPublicKeyHex)108+109+/**110+ * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the111+ * granted root key actually derives the advertised vault id (catching a lying or112+ * corrupted source before it can seal a mismatched vault), seals the local vault,113+ * applies any carried license, returns our identity, and records the source as a114+ * paired device. Returns null on any protocol failure (reason via [deps.onError]).115+ */116+suspend fun requestAdoption(117+    channel: PairingTransport,118+    deps: AdoptionReceiverDeps,119+    nowMs: () -> Long = { System.currentTimeMillis() },120+): PairedDevice? {121+    sendFrame(channel, AdoptionFrame.Request(122+        deviceId = deps.generateDeviceId(),123+        deviceName = deps.deviceName,124+        deviceType = deps.deviceType.wireValue,125+    ))126+127+    val frame = receiveFrame(channel)128+    if (!isAdoptGrant(frame)) {129+        deps.onError("ADOPT_UNEXPECTED_FRAME")130+        return null131+    }132+    val grant = frame as AdoptionFrame.Grant133+134+    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)135+    try {136+        if (computeVaultId(rootKeyBytes) != grant.vaultId) {137+            deps.onError("ADOPT_VAULT_ID_MISMATCH")138+            return null139+        }140+    } finally {141+        rootKeyBytes.fill(0)142+    }143+144+    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)145+    if (vaultId != grant.vaultId) {146+        deps.onError("ADOPT_VAULT_ID_MISMATCH")147+        return null148+    }149+150+    grant.license?.let { deps.applyLicense(it) }151+152+    val identity = deps.deriveLocalIdentity()153+    sendFrame(channel, AdoptionFrame.Identity(154+        deviceId = identity.deviceId,155+        deviceName = identity.deviceName,156+        deviceType = identity.deviceType.wireValue,157+        longTermPublicKeyHex = identity.longTermPublicKeyHex,158+        signingPublicKeyHex = identity.signingPublicKeyHex,159+    ))160+161+    val sourceDevice = PairedDevice(162+        deviceId = grant.source.deviceId,163+        deviceName = grant.source.deviceName,164+        deviceType = grant.source.deviceType,165+        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,166+        signingPublicKeyHex = grant.source.signingPublicKeyHex,167+        trustSource = "direct",168+        wsPort = grant.source.wsPort,169+        pairedAt = nowMs(),170+    )171+    deps.upsertPairedDevice(sourceDevice)172+    return sourceDevice173+}174+175+/**176+ * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the177+ * root key, then records the receiver's returned identity as a paired device.178+ * Returns null if consent is denied or the receiver's identity frame is invalid.179+ */180+suspend fun grantAdoption(181+    channel: PairingTransport,182+    request: AdoptionFrame.Request,183+    deps: AdoptionSourceDeps,184+    nowMs: () -> Long = { System.currentTimeMillis() },185+): PairedDevice? {186+    if (!deps.onConsent(request)) return null187+188+    val rootKeyHex = deps.rootKeyHex()189+    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")190+191+    sendFrame(channel, AdoptionFrame.Grant(192+        rootKeyHex = rootKeyHex,193+        vaultId = deps.vaultId(),194+        license = deps.license(),195+        source = AdoptionFrame.Source(196+            deviceId = deps.sourceIdentity.deviceId,197+            deviceName = deps.sourceIdentity.deviceName,198+            deviceType = deps.sourceIdentity.deviceType.wireValue,199+            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,200+            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,201+            wsPort = deps.sourceIdentity.wsPort,202+        ),203+    ))204+205+    val identity = receiveFrame(channel)206+    if (!isAdoptIdentity(identity)) return null207+    identity as AdoptionFrame.Identity208+209+    val device = PairedDevice(210+        deviceId = identity.deviceId,211+        deviceName = identity.deviceName,212+        deviceType = identity.deviceType,213+        longTermPublicKeyHex = identity.longTermPublicKeyHex,214+        signingPublicKeyHex = identity.signingPublicKeyHex,215+        trustSource = "direct",216+        pairedAt = nowMs(),217+    )218+    deps.upsertPairedDevice(device)219+    return device220+}221+222+/**223+ * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat224+ * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent225+ * optionals exactly like `JSON.stringify` drops `undefined`.226+ */227+val AdoptJson: Json = Json {228+    classDiscriminator = "type"229+    encodeDefaults = false230+    ignoreUnknownKeys = true231+}232+233+suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {234+    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))235+}236+237+/** Decode one frame, tolerating a malformed/unknown payload by returning null. */238+suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =239+    runCatching {240+        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))241+    }.getOrNull()242+243+private fun isDeviceType(value: String): Boolean =244+    DeviceType.entries.any { it.wireValue == value }

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+61 −0
@@ -30,6 +30,11 @@3030     data class Error(val reason: String) : CreateVaultResult3131 }3232 33+sealed interface AdoptVaultResult {34+    data class Success(val vaultId: String) : AdoptVaultResult35+    data class Error(val reason: String) : AdoptVaultResult36+}37+3338 /**3439  * In-memory vault session backed by [VaultStore]. Faithful port of the browser3540  * `vault.ts` semantics that matter for correctness:@@ -125,6 +130,62 @@125130             CreateVaultResult.Success126131         } catch (e: Exception) {127132             CreateVaultResult.Error(e.message ?: "create_failed")133+        }134+    }135+136+    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {137+        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")138+        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {139+            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")140+        }141+        var adoptedRootKey: ByteArray? = null142+        var nextDerivedKey: ByteArray? = null143+        var sessionRootKey: ByteArray? = null144+        return try {145+            adoptedRootKey = hexToRootKey(rootKeyHex)146+            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {147+                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")148+            }149+            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)150+            val salt = VaultCrypto.generateSalt()151+            nextDerivedKey = VaultCrypto.deriveKey(password, salt)152+            sessionRootKey = adoptedRootKey.copyOf()153+            val payload = VaultPayload(154+                entries = emptyList(),155+                duressConfigured = false,156+                rootKeyB64 = b64(sessionRootKey),157+                recoveryWordHash = null,158+                recoveryWordsSet = false,159+            )160+            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))161+            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)162+            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")163+            writeMatchingNoiseSlotB(slotA)164+            store.stampLocalMutation()165+            store.writeSettings(_settings.value)166+167+            val previousDerivedKey = derivedKey168+            val previousRootKey = rootKey169+            derivedKey = nextDerivedKey170+            rootKey = sessionRootKey171+            nextDerivedKey = null172+            sessionRootKey = null173+            entryList = mutableListOf()174+            duressConfigured = false175+            recoveryWordHash = null176+            recoveryWordsSet = false177+            decoyMode = false178+            publish()179+            _isUnlocked.value = true180+            previousDerivedKey?.let(VaultCrypto::zero)181+            previousRootKey?.let(VaultCrypto::zero)182+            AdoptVaultResult.Success(vaultId)183+        } catch (e: Exception) {184+            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")185+        } finally {186+            adoptedRootKey?.let(VaultCrypto::zero)187+            nextDerivedKey?.let(VaultCrypto::zero)188+            sessionRootKey?.let(VaultCrypto::zero)128189         }129190     }130191 

view file

app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+237 −0
@@ -0,0 +1,237 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import kotlinx.coroutines.withTimeoutOrNull7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertFalse9+import org.junit.Assert.assertNull10+import org.junit.Assert.assertTrue11+import org.junit.Test12+13+/**14+ * Adoption frame protocol parity with the browser `chirp-adopt.test.ts`. The15+ * receiver must verify the granted root key derives the advertised vault id16+ * BEFORE sealing a vault, and a denied consent must hand over nothing.17+ */18+class ChirpAdoptTest {19+20+    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21+        val a2b = Channel<ByteArray>(Channel.UNLIMITED)22+        val b2a = Channel<ByteArray>(Channel.UNLIMITED)23+        val a = object : PairingTransport {24+            override suspend fun send(msg: ByteArray) { a2b.send(msg) }25+            override suspend fun receive(): ByteArray = b2a.receive()26+            override suspend fun close() {}27+        }28+        val b = object : PairingTransport {29+            override suspend fun send(msg: ByteArray) { b2a.send(msg) }30+            override suspend fun receive(): ByteArray = a2b.receive()31+            override suspend fun close() {}32+        }33+        return a to b34+    }35+36+    private fun identity(name: String) = LocalChirpIdentity(37+        deviceId = "$name-id",38+        deviceName = name,39+        deviceType = DeviceType.EXTENSION,40+        longTermPublicKeyHex = "ab".repeat(32),41+        signingPublicKeyHex = "cd".repeat(32),42+    )43+44+    private fun vaultIdFor(rootKeyHex: String): String {45+        val bytes = hexToPubkey(rootKeyHex)46+        return try {47+            computeVaultId(bytes)48+        } finally {49+            bytes.fill(0)50+        }51+    }52+53+    private class RecorderReceiver(54+        override val deviceName: String,55+        override val deviceType: DeviceType,56+        private val vaultId: String?,57+        private val createThrows: Boolean = false,58+        private val derived: LocalChirpIdentity? = null,59+    ) : AdoptionReceiverDeps {60+        var createCalledWith: String? = null61+        var licenseApplied: String? = null62+        val upserted = mutableListOf<PairedDevice>()63+        val errors = mutableListOf<String>()64+        override fun generateDeviceId(): String = "receiver-ephemeral"65+        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {66+            createCalledWith = rootKeyHex67+            if (createThrows) throw IllegalStateException("commit_failed")68+            return vaultId ?: error("no vault id")69+        }70+        override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity")71+        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }72+        override suspend fun applyLicense(license: String) { licenseApplied = license }73+        override fun onError(error: String) { errors += error }74+    }75+76+    private class RecorderSource(77+        override val sourceIdentity: LocalChirpIdentity,78+        private val rootKeyHex: String,79+        private val vaultId: String,80+        private val license: String? = null,81+        private val consent: Boolean = true,82+    ) : AdoptionSourceDeps {83+        val upserted = mutableListOf<PairedDevice>()84+        var consentAsked = false85+        override suspend fun rootKeyHex(): String = rootKeyHex86+        override suspend fun vaultId(): String = vaultId87+        override suspend fun license(): String? = license88+        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {89+            consentAsked = true90+            return consent91+        }92+        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }93+    }94+95+    @Test fun completes_request_grant_identity_round_trip() = runBlocking {96+        val (receiverCh, sourceCh) = memoryPair()97+        val rootKeyHex = "11".repeat(32)98+        val vaultId = vaultIdFor(rootKeyHex)99+        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)100+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)101+        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")102+103+        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }104+        val grantJob = async {105+            val request = receiveFrame(sourceCh) as AdoptionFrame.Request106+            grantAdoption(sourceCh, request, source) { 222L }107+        }108+        recvJob.await()109+        grantJob.await()110+111+        assertEquals(rootKeyHex, receiver.createCalledWith)112+        assertEquals("PPK-test", receiver.licenseApplied)113+        assertEquals("source-id", receiver.upserted.single().deviceId)114+        assertEquals("receiver-id", source.upserted.single().deviceId)115+        assertEquals("android", source.upserted.single().deviceType)116+    }117+118+    @Test fun request_carries_receiver_device_type() = runBlocking {119+        val (receiverCh, sourceCh) = memoryPair()120+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)121+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }122+        val request = receiveFrame(sourceCh) as AdoptionFrame.Request123+        recvJob.cancel()124+125+        assertEquals("android", request.deviceType)126+        assertEquals("receiver-ephemeral", request.deviceId)127+        assertEquals("Receiver", request.deviceName)128+    }129+130+    @Test fun denied_consent_grants_nothing() = runBlocking {131+        val (receiverCh, sourceCh) = memoryPair()132+        val rootKeyHex = "33".repeat(32)133+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))134+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)135+136+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }137+        val request = receiveFrame(sourceCh) as AdoptionFrame.Request138+        val granted = grantAdoption(sourceCh, request, source) { 0L }139+        recvJob.cancel()140+141+        assertNull(granted)142+        assertTrue(source.consentAsked)143+        assertTrue(source.upserted.isEmpty())144+        assertNull(receiver.createCalledWith)145+    }146+147+    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {148+        val (receiverCh, sourceCh) = memoryPair()149+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)150+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }151+        receiveFrame(sourceCh)152+        sendFrame(sourceCh, AdoptionFrame.Grant(153+            rootKeyHex = "bad",154+            vaultId = "00".repeat(32),155+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),156+        ))157+        recvJob.await()158+159+        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)160+        assertNull(receiver.createCalledWith)161+    }162+163+    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {164+        val (receiverCh, sourceCh) = memoryPair()165+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)166+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }167+        receiveFrame(sourceCh)168+        sendFrame(sourceCh, AdoptionFrame.Grant(169+            rootKeyHex = "22".repeat(32),170+            vaultId = "00".repeat(32),171+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),172+        ))173+        recvJob.await()174+175+        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)176+        assertNull(receiver.createCalledWith)177+    }178+179+    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {180+        val (_, sourceCh) = memoryPair()181+        val rootKeyHex = "33".repeat(32)182+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))183+        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")184+185+        val result = withTimeoutOrNull(150) {186+            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.187+            grantAdoption(sourceCh, request, source) { 0L }188+        }189+190+        assertNull(result)191+        assertTrue(source.upserted.isEmpty())192+    }193+194+    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {195+        val (receiverCh, sourceCh) = memoryPair()196+        val rootKeyHex = "44".repeat(32)197+        val vaultId = vaultIdFor(rootKeyHex)198+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)199+        val recvJob = async {200+            runCatching { requestAdoption(receiverCh, receiver) { 0L } }201+        }202+        receiveFrame(sourceCh)203+        sendFrame(sourceCh, AdoptionFrame.Grant(204+            rootKeyHex = rootKeyHex,205+            vaultId = vaultId,206+            license = "PPK-test",207+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),208+        ))209+        val outcome = recvJob.await()210+211+        assertTrue(outcome.isFailure)212+        assertNull(receiver.licenseApplied)213+    }214+215+    @Test fun validator_rejects_short_and_uppercase_root_keys() {216+        assertTrue(validateRootKeyHex("ab".repeat(32)))217+        assertFalse(validateRootKeyHex("ab".repeat(31)))218+        assertFalse(validateRootKeyHex("AB".repeat(32)))219+        assertFalse(validateRootKeyHex("zz".repeat(32)))220+        assertFalse(validateRootKeyHex(null))221+    }222+223+    @Test fun grant_omits_absent_optionals_like_json_stringify() {224+        val grant = AdoptionFrame.Grant(225+            rootKeyHex = "11".repeat(32),226+            vaultId = "22".repeat(32),227+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),228+        )229+        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)230+231+        assertFalse(json.contains("license"))232+        assertFalse(json.contains("ws_port"))233+        assertFalse(json.contains("signing_public_key_hex"))234+        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))235+        assertTrue(json.contains("\"root_key_hex\""))236+    }237+}

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+51 −0
@@ -77,6 +77,57 @@7777         assertTrue(dst.getEntries().any { it.title == "Portable" })7878     }7979 80+    @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {81+        val rootKeyHex = "66".repeat(32)82+        val rootKey = hexToRootKey(rootKeyHex)83+        val expectedVaultId = try {84+            com.peachpasswords.sync.computeVaultId(rootKey)85+        } finally {86+            VaultCrypto.zero(rootKey)87+        }88+        val r = VaultRepository(Storage())89+90+        val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)91+92+        assertEquals(AdoptVaultResult.Success(expectedVaultId), result)93+        assertTrue(r.isUnlocked.value)94+        assertEquals(expectedVaultId, r.syncVaultId())95+    }96+97+    @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {98+        val rootKeyHex = "33".repeat(32)99+        val r = VaultRepository(Storage())100+        val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)101+        val vaultId = (adopted as AdoptVaultResult.Success).vaultId102+103+        r.addEntry(login("Example", password = "secret"))104+105+        assertTrue(r.getEntries().any { it.title == "Example" })106+        assertEquals(vaultId, r.syncVaultId())107+    }108+109+    @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {110+        val r = VaultRepository(Storage())111+112+        listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->113+            val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)114+            assertTrue(result is AdoptVaultResult.Error)115+            assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))116+        }117+        assertFalse(r.hasVault())118+        assertFalse(r.isUnlocked.value)119+    }120+121+    @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {122+        val r = VaultRepository(Storage())123+124+        assertEquals(125+            AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),126+            r.createVaultFromRootKey("", "44".repeat(32)),127+        )128+        assertFalse(r.hasVault())129+    }130+80131     @Test fun create_then_destroy_then_create_fresh() = runBlocking {81132         val storage = Storage()82133         val r = VaultRepository(storage)

view file