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
↓ peach-android-598fbf2a.tar.gzatlas at this save
files changed5 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 −0app/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 {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 }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 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+}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)