VaultAdopt.kt

25.8 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.encodeToString6import kotlinx.serialization.json.Json78/**9 * Vault adoption frame protocol — exact port of the browser `shared/vault-adopt.ts`.10 *11 * Adoption lets a brand-new device join an existing vault: the SOURCE (an already12 * set-up device) hands its root key to the RECEIVER over the pairing channel so13 * both devices end up with the same vault id and can sync. The receiver seals an14 * empty local vault under its own password but reuses the adopted root key15 * ([VaultRepository.createVaultFromRootKey]).16 *17 * Frames travel as JSON with a `type` discriminator and SNAKE_CASE keys — unlike18 * the camelCase [SyncMessage] sync wire — so the keys must match the browser19 * byte-for-byte for cross-platform adoption. Optional fields are omitted when20 * absent (encodeDefaults = false) to mirror `JSON.stringify` dropping `undefined`.21 */22@Serializable23sealed class AdoptionFrame {24    /**25     * [sessionRequestId] carries the wire's `device_id`, but it is NOT an26     * identity: the requester generates it locally before the handshake that27     * establishes any long-term key, so it cannot be the device's real one28     * (see `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2). It is a label for the29     * consent prompt and nothing else — never persist it, never compare it30     * against a previous attempt's, never key trust on it. The only identity31     * that may be persisted is `long_term_public_key_hex`, which arrives later32     * in ADOPT_IDENTITY / ADOPT_GRANT.source.33     */34    @Serializable35    @SerialName("ADOPT_REQUEST")36    data class Request(37        @SerialName("device_id") val sessionRequestId: String,38        @SerialName("device_name") val deviceName: String,39        @SerialName("device_type") val deviceType: String,40        val license: String? = null,41        @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,42    ) : AdoptionFrame()4344    @Serializable45    @SerialName("ADOPT_CONFIRM")46    data class Confirm(47        val confirmed: Boolean,48    ) : AdoptionFrame()4950    @Serializable51    @SerialName("ADOPT_GRANT")52    data class Grant(53        @SerialName("root_key_hex") val rootKeyHex: String,54        @SerialName("vault_id") val vaultId: String,55        val license: String? = null,56        val source: Source,57        // SOURCE echoes receiver's ADOPT_REQUEST.snapshot_capable so the receiver58        // can skip its 15s inline-snapshot wait when the source has nothing to send.59        // Default false is OMITTED on the wire (encodeDefaults = false) — the60        // browser always serializes explicit true OR false via JSON.stringify, but61        // Android only serializes true; both receivers default to "skip wait" when62        // the field is absent, so the divergence is invisible to the gate.63        // Pre-C sources never set the field and decode to false here.64        @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,65    ) : AdoptionFrame()6667    @Serializable68    @SerialName("ADOPT_IDENTITY")69    data class Identity(70        @SerialName("device_id") val deviceId: String,71        @SerialName("device_name") val deviceName: String,72        @SerialName("device_type") val deviceType: String,73        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,74        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,75    ) : AdoptionFrame()7677    /**78     * Encrypted source-to-receiver acknowledgement emitted only after the source79     * has durably recorded the receiver. The receiver cannot truthfully report a80     * successful pair before this arrives.81     */82    @Serializable83    @SerialName("ADOPT_COMPLETE")84    data object Complete : AdoptionFrame()8586    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */87    @Serializable88    @SerialName("ADOPT_APPLIED")89    data object Applied : AdoptionFrame()9091    /** Neither peer proved Pro, so the pairing must not commit. */92    @Serializable93    @SerialName("ADOPT_REJECT")94    data class Reject(95        val reason: String,96    ) : AdoptionFrame()9798    /**99     * One chunk of the inline vault snapshot sent by the source after100     * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are101     * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.102     */103    @Serializable104    @SerialName("ADOPT_SNAPSHOT")105    data class Snapshot(106        @SerialName("chunk_index") val chunkIndex: Int,107        @SerialName("chunk_count") val chunkCount: Int,108        val entries: Map<String, String>,109        val tombstones: Map<String, Long>,110        @SerialName("duress_slot") val duressSlot: String? = null,111    ) : AdoptionFrame()112113    /** Receiver acknowledgement sent after all snapshot chunks are applied. */114    @Serializable115    @SerialName("ADOPT_SNAPSHOT_APPLIED")116    data class SnapshotApplied(117        @SerialName("entries_received") val entriesReceived: Int,118        @SerialName("tombstones_received") val tombstonesReceived: Int,119    ) : AdoptionFrame()120121    /** Optional signed device graph appended after the snapshot (manifest v3+). */122    @Serializable123    @SerialName("ADOPT_DEVICE_MANIFEST")124    data class DeviceManifestFrame(125        val manifest: SignedDeviceManifest,126    ) : AdoptionFrame()127128    /** Receiver acknowledgement for an applied device manifest. */129    @Serializable130    @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")131    data object DeviceManifestApplied : AdoptionFrame()132133    @Serializable134    data class Source(135        @SerialName("device_id") val deviceId: String,136        @SerialName("device_name") val deviceName: String,137        @SerialName("device_type") val deviceType: String,138        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,139        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,140        @SerialName("ws_port") val wsPort: Int? = null,141        @SerialName("relay_url") val relayUrl: String? = null,142    )143}144145data class AdoptionPairState(146    val rendezvousSecretHex: String,147    val relayUrl: String? = null,148)149150/**151 * Receiver-side reassembly result from [receiveVaultSnapshot]. Carries the152 * aggregated entries and tombstones plus the optional browser duress slot153 * JSON (present on the final chunk only, when the source has duress configured).154 */155data class ReceivedSnapshot(156    val entries: Map<String, String>,157    val tombstones: Map<String, Long>,158    val duressSlot: String? = null,159)160161/**162 * Wire guardrails for the inline snapshot. A chunk's serialized entries map163 * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at164 * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver165 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).166 */167const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB168const val MAX_SNAPSHOT_CHUNKS = 200169170// Headroom added to the per-chunk entries budget when validating an encoded171// frame on the wire: JSON envelope plus the final chunk's tombstone map.172private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024173174/** What the RECEIVER (joining device) needs to adopt a vault from a source. */175interface AdoptionReceiverDeps {176    val deviceName: String177    val deviceType: DeviceType178    fun generateDeviceId(): String179    /** Root-key transfer cannot begin until this device confirms the SAS. */180    suspend fun confirmSas(): Boolean181    /**182     * Local safety policy evaluated after the authenticated grant is validated183     * but before any vault state changes.184     */185    suspend fun validateSource(source: AdoptionFrame.Source) {}186187    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */188    suspend fun createVaultFromRootKey(rootKeyHex: String): String189    suspend fun deriveLocalIdentity(): LocalDeviceIdentity190    /** Only called after a successful, SAS-confirmed direct pairing. */191    suspend fun clearPairedDeviceRevocation(deviceId: String) {}192    suspend fun upsertPairedDevice(device: PairedDevice)193    suspend fun applyLicense(license: String) {}194    /** Return this receiver's locally validated Pro key, if present. */195    suspend fun license(): String? = null196    val pairState: AdoptionPairState? get() = null197    fun onError(error: String)198    /** Apply encrypted entries + tombstones from the inline snapshot. */199    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}200    /** Mark the paired device as synced after a successful snapshot. */201    suspend fun markSynced(deviceId: String) {}202}203204/** What the SOURCE (existing device) needs to grant its vault to a receiver. */205interface AdoptionSourceDeps {206    val sourceIdentity: LocalDeviceIdentity207    suspend fun rootKeyHex(): String208    suspend fun vaultId(): String209    suspend fun license(): String? = null210    /** Validate and durably store a Pro key offered by the receiver. */211    suspend fun applyLicense(license: String) {}212213    /**214     * Ask the user to approve handing this vault to the requesting device. The215     * request's name/type/label are for the human to read; the decision must not216     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which217     * carries no continuity between attempts.218     */219    suspend fun onConsent(request: AdoptionFrame.Request): Boolean220    suspend fun upsertPairedDevice(device: PairedDevice)221    val pairState: AdoptionPairState? get() = null222    /** Return all encrypted entries + tombstones for the inline snapshot. */223    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null224    /** Return the signed device manifest for the graph, if available. */225    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null226    /** Mark the paired device as synced after a successful snapshot. */227    suspend fun markSynced(deviceId: String) {}228    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */229    suspend fun duressSlotJson(): String? = null230}231232private val Hex64 = Regex("^[0-9a-f]{64}\$")233234fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)235236fun isAdoptRequest(frame: AdoptionFrame?): Boolean =237    frame is AdoptionFrame.Request &&238        frame.sessionRequestId.isNotEmpty() &&239        isDeviceType(frame.deviceType)240241fun isAdoptGrant(frame: AdoptionFrame?): Boolean =242    frame is AdoptionFrame.Grant &&243        validateRootKeyHex(frame.rootKeyHex) &&244        Hex64.matches(frame.vaultId) &&245        isDeviceType(frame.source.deviceType) &&246        Hex64.matches(frame.source.longTermPublicKeyHex) &&247        (frame.source.relayUrl == null ||248            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)249250fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =251    frame is AdoptionFrame.Confirm252253fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =254    frame is AdoptionFrame.Identity &&255        frame.deviceId.isNotEmpty() &&256        isDeviceType(frame.deviceType) &&257        Hex64.matches(frame.longTermPublicKeyHex)258259fun isAdoptComplete(frame: AdoptionFrame?): Boolean =260    frame is AdoptionFrame.Complete261262fun isAdoptApplied(frame: AdoptionFrame?): Boolean =263    frame is AdoptionFrame.Applied264265fun isAdoptReject(frame: AdoptionFrame?): Boolean =266    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"267268fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =269    frame is AdoptionFrame.Snapshot &&270        frame.chunkIndex >= 0 &&271        frame.chunkCount >= 1272273/**274 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the275 * granted root key actually derives the advertised vault id (catching a lying or276 * corrupted source before it can seal a mismatched vault), seals the local vault,277 * applies any carried license, returns our identity, and records the source as a278 * paired device. Returns null on any protocol failure (reason via [deps.onError]).279 */280suspend fun requestAdoption(281    channel: PairingTransport,282    deps: AdoptionReceiverDeps,283    // Called once after a valid ADOPT_GRANT is decoded and validated, before284    // any side effects (vault creation, paired-device upsert). Used by the285    // receiver to inspect the grant's snapshot_capable flag and skip the 15s286    // inline-snapshot wait when the source has nothing to send. Defaults to no-op287    // for callers (tests, other helpers) that don't need to inspect the grant.288    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,289    nowMs: () -> Long = { System.currentTimeMillis() },290): PairedDevice? {291    val receiverLicense = deps.license()292    // Fresh every attempt, and deliberately not our real device id: no identity293    // exists yet at this point in the protocol.294    sendFrame(channel, AdoptionFrame.Request(295        sessionRequestId = deps.generateDeviceId(),296        deviceName = deps.deviceName,297        deviceType = deps.deviceType.wireValue,298        license = receiverLicense,299        snapshotCapable = true,300    ))301302    val confirmed = deps.confirmSas()303    sendFrame(channel, AdoptionFrame.Confirm(confirmed))304    if (!confirmed) {305        deps.onError("ADOPT_SAS_REJECTED")306        return null307    }308309    val frame = receiveFrame(channel)310    if (isAdoptReject(frame)) {311        deps.onError((frame as AdoptionFrame.Reject).reason)312        return null313    }314    if (!isAdoptGrant(frame)) {315        deps.onError("ADOPT_UNEXPECTED_FRAME")316        return null317    }318    val grant = frame as AdoptionFrame.Grant319    onGrant?.invoke(grant)320321    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)322    try {323        if (computeVaultId(rootKeyBytes) != grant.vaultId) {324            deps.onError("ADOPT_VAULT_ID_MISMATCH")325            return null326        }327    } finally {328        rootKeyBytes.fill(0)329    }330    if (receiverLicense == null && grant.license == null) {331        deps.onError("ADOPT_PRO_REQUIRED")332        return null333    }334335    deps.validateSource(grant.source)336    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)337    if (vaultId != grant.vaultId) {338        deps.onError("ADOPT_VAULT_ID_MISMATCH")339        return null340    }341342    if (receiverLicense == null) {343        grant.license?.let { deps.applyLicense(it) }344    }345346    val identity = deps.deriveLocalIdentity()347    sendFrame(channel, AdoptionFrame.Identity(348        deviceId = identity.deviceId,349        deviceName = identity.deviceName,350        deviceType = identity.deviceType.wireValue,351        longTermPublicKeyHex = identity.longTermPublicKeyHex,352        signingPublicKeyHex = identity.signingPublicKeyHex,353    ))354355    if (!isAdoptComplete(receiveFrame(channel))) {356        deps.onError("ADOPT_COMPLETION_MISSING")357        return null358    }359360    val sourceDevice = PairedDevice(361        deviceId = grant.source.deviceId,362        deviceName = grant.source.deviceName,363        deviceType = grant.source.deviceType,364        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,365        signingPublicKeyHex = grant.source.signingPublicKeyHex,366        trustSource = "direct",367        wsPort = grant.source.wsPort,368        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,369        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,370        pairedAt = nowMs(),371    )372    // A physically confirmed direct re-pair is the sole path allowed to lift373    // terminal revocation. Clear before upsert so storage can never expose the374    // source as both paired and revoked, even transiently.375    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)376    deps.upsertPairedDevice(sourceDevice)377    sendFrame(channel, AdoptionFrame.Applied)378    return sourceDevice379}380381/**382 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the383 * root key, then records the receiver's returned identity as a paired device.384 * Returns null if consent is denied or the receiver's identity frame is invalid.385 */386suspend fun grantAdoption(387    channel: PairingTransport,388    request: AdoptionFrame.Request,389    deps: AdoptionSourceDeps,390    nowMs: () -> Long = { System.currentTimeMillis() },391): PairedDevice? {392    if (!deps.onConsent(request)) return null393394    val confirmation = receiveFrame(channel)395    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {396        return null397    }398399    var sourceLicense = deps.license()400    if (sourceLicense == null && request.license != null) {401        sourceLicense = runCatching {402            deps.applyLicense(request.license)403            deps.license()404        }.getOrNull()405    }406    if (sourceLicense == null) {407        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))408        throw IllegalStateException("ADOPT_PRO_REQUIRED")409    }410411    val rootKeyHex = deps.rootKeyHex()412    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")413414    sendFrame(channel, AdoptionFrame.Grant(415        rootKeyHex = rootKeyHex,416        vaultId = deps.vaultId(),417        license = sourceLicense,418        source = AdoptionFrame.Source(419            deviceId = deps.sourceIdentity.deviceId,420            deviceName = deps.sourceIdentity.deviceName,421            deviceType = deps.sourceIdentity.deviceType.wireValue,422            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,423            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,424            wsPort = deps.sourceIdentity.wsPort,425            relayUrl = deps.pairState?.relayUrl,426        ),427        snapshotCapable = request.snapshotCapable == true,428    ))429430    val identity = receiveFrame(channel)431    if (!isAdoptIdentity(identity)) return null432    identity as AdoptionFrame.Identity433434    val device = PairedDevice(435        deviceId = identity.deviceId,436        deviceName = identity.deviceName,437        deviceType = identity.deviceType,438        longTermPublicKeyHex = identity.longTermPublicKeyHex,439        signingPublicKeyHex = identity.signingPublicKeyHex,440        trustSource = "direct",441        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,442        relayUrl = deps.pairState?.relayUrl,443        pairedAt = nowMs(),444    )445    deps.upsertPairedDevice(device)446    sendFrame(channel, AdoptionFrame.Complete)447    if (!isAdoptApplied(receiveFrame(channel))) return null448    return device449}450451/**452 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat453 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent454 * optionals exactly like `JSON.stringify` drops `undefined`.455 */456val AdoptJson: Json = Json {457    classDiscriminator = "type"458    encodeDefaults = false459    ignoreUnknownKeys = true460}461462suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {463    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))464}465466/** Decode one frame, tolerating a malformed/unknown payload by returning null. */467suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =468    runCatching {469        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))470    }.getOrNull()471472/**473 * Source side: chunk the vault snapshot and send it over the pairing channel.474 *475 * Wire shape (v2.9.9+, duress on its own frame; mirrors browser v4.6.4):476 *   - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries.477 *     Tombstones ride the last entries frame when no duress follows.478 *   - If duressSlot is set, one extra frame after the entries carrying only479 *     the duress slot (entries: empty, tombstones: tombstone map,480 *     duress_slot: duressSlot). This moves duress off the entries frame so a481 *     sizeable sealed duress vault can no longer push the entries frame raw482 *     bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB).483 *484 * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200):485 *   - When duress is configured, ONE of the 200 frames is reserved for the486 *     duress slot. The entries cap is [MAX_SNAPSHOT_CHUNKS] - 1 = 199 entries487 *     chunks; excess entries are deferred to the sync fallback pipeline (sync488 *     carries entries and tombstones on Android too, so this is a graceful489 *     truncation). This reservation is REQUIRED: ordinary sync does NOT490 *     carry duress on Android (VaultRepository.exportDuressSlotForAdoption491 *     is the only transfer path), so silently dropping a duress frame at492 *     the cap would leave the adopted device permanently without its duress493 *     vault. We prioritize duress delivery over completeness of entries.494 *495 * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]496 * is rejected with a thrown error BEFORE the duress frame is sent. Hard stop;497 * the caller must split duress out of band. We do not silently truncate498 * duress; sync cannot restore it.499 *500 * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries501 * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a502 * duress-only frame has empty `entries`/`tombstones` which merge as a no-op),503 * so both shapes decode and both new->old / old->new directions interoperate.504 */505suspend fun sendVaultSnapshot(506    channel: PairingTransport,507    entries: Map<String, String>,508    tombstones: Map<String, Long>,509    duressSlot: String? = null,510) {511    val includeDuress = !duressSlot.isNullOrBlank()512    // Reserve one frame for duress when configured so duress is never513    // silently dropped at the cap; ordinary sync does NOT carry duress on514    // Android (VaultRepository.exportDuressSlotForAdoption is the only515    // transfer path), so dropping a duress frame would orphan the adopted516    // device's duress vault.517    val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS518519    val chunks = mutableListOf<Map<String, String>>()520    var current = LinkedHashMap<String, String>()521    var currentBytes = 2 // "{}"522    for ((id, envelope) in entries) {523        // +1 for the separating comma; an overestimate only shrinks chunks.524        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1525        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {526            chunks.add(current)527            current = LinkedHashMap()528            currentBytes = 2529        }530        current[id] = envelope531        currentBytes += entryBytes532    }533    chunks.add(current)534535    val entriesChunkCount = minOf(chunks.size, entryCap)536    val sendDuress = includeDuress537    val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)538539    for (index in 0 until entriesChunkCount) {540        val isFinalEntries = index == entriesChunkCount - 1541        sendFrame(channel, AdoptionFrame.Snapshot(542            chunkIndex = index,543            chunkCount = totalChunks,544            entries = chunks[index],545            // Tombstones ride the LAST wire frame: when duress follows the546            // entries, it goes on the duress frame; otherwise on the last547            // entries chunk.548            tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),549            duressSlot = null,550        ))551    }552553    if (sendDuress) {554        // Pre-check: a duress frame that exceeds the wire ceiling cannot be555        // sent and would fail the receiver's size check. Throw loudly so the556        // caller can split duress out of band instead of triggering a silent557        // "adoption succeeded but duress vanished" path. Sync cannot recover558        // duress on Android either — the spec is a hard stop.559        val duressEnvelope = (AdoptJson.encodeToString(AdoptionFrame.Snapshot(560            chunkIndex = entriesChunkCount,561            chunkCount = totalChunks,562            entries = emptyMap(),563            tombstones = tombstones,564            duressSlot = duressSlot,565        ))).encodeToByteArray()566        if (duressEnvelope.size > SNAPSHOT_FRAME_WIRE_CEILING) {567            throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")568        }569        sendFrame(channel, AdoptionFrame.Snapshot(570            chunkIndex = entriesChunkCount,571            chunkCount = totalChunks,572            entries = emptyMap(),573            tombstones = tombstones,574            duressSlot = duressSlot,575        ))576    }577}578579/**580 * Receiver side: receive and reassemble every snapshot chunk. Validates the581 * encoded frame size before parsing, then enforces sequential 0-based582 * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and583 * structural validity of each frame. Throws on any validation failure so the584 * caller can fall back to the sync pipeline.585 */586suspend fun receiveVaultSnapshot(587    channel: PairingTransport,588): ReceivedSnapshot {589    val entries = LinkedHashMap<String, String>()590    val tombstones = LinkedHashMap<String, Long>()591    var duressSlot: String? = null592    var chunkCount = -1593    var received = 0594    while (chunkCount == -1 || received < chunkCount) {595        val raw = channel.receive()596        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {597            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")598        }599        val frame = runCatching {600            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))601        }.getOrNull()602        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")603        frame as AdoptionFrame.Snapshot604        if (chunkCount == -1) {605            chunkCount = frame.chunkCount606            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {607                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")608            }609        } else if (frame.chunkCount != chunkCount) {610            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")611        }612        if (frame.chunkIndex != received) {613            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")614        }615        entries.putAll(frame.entries)616        tombstones.putAll(frame.tombstones)617        if (frame.duressSlot != null) duressSlot = frame.duressSlot618        received++619    }620    return ReceivedSnapshot(entries, tombstones, duressSlot)621}622623private fun isDeviceType(value: String): Boolean =624    DeviceType.entries.any { it.wireValue == value }