VaultAdopt.kt

26.0 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    /**201     * Apply the source's signed device graph so the receiver graduates into the202     * mesh. The [authenticatedRemoteStaticPublicKey] is the source's Noise203     * transport static key (NOT the signing key); [applyIncomingDeviceManifest]204     * resolves it to the source's [PairedDevice] locally and uses the stored205     * signing key to verify the manifest signature. Default no-op preserves206     * the contract for callers that haven't wired the mesh path yet.207     */208    suspend fun applyIncomingDeviceManifest(209        manifest: SignedDeviceManifest,210        authenticatedRemoteStaticPublicKey: ByteArray,211    ) {}212    /** Mark the paired device as synced after a successful snapshot. */213    suspend fun markSynced(deviceId: String) {}214}215216/** What the SOURCE (existing device) needs to grant its vault to a receiver. */217interface AdoptionSourceDeps {218    val sourceIdentity: LocalDeviceIdentity219    suspend fun rootKeyHex(): String220    suspend fun vaultId(): String221    suspend fun license(): String? = null222    /** Validate and durably store a Pro key offered by the receiver. */223    suspend fun applyLicense(license: String) {}224225    /**226     * Ask the user to approve handing this vault to the requesting device. The227     * request's name/type/label are for the human to read; the decision must not228     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which229     * carries no continuity between attempts.230     */231    suspend fun onConsent(request: AdoptionFrame.Request): Boolean232    suspend fun upsertPairedDevice(device: PairedDevice)233    val pairState: AdoptionPairState? get() = null234    /** Return all encrypted entries + tombstones for the inline snapshot. */235    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null236    /** Return the signed device manifest for the graph, if available. */237    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null238    /** Mark the paired device as synced after a successful snapshot. */239    suspend fun markSynced(deviceId: String) {}240    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */241    suspend fun duressSlotJson(): String? = null242}243244private val Hex64 = Regex("^[0-9a-f]{64}\$")245246fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)247248fun isAdoptRequest(frame: AdoptionFrame?): Boolean =249    frame is AdoptionFrame.Request &&250        frame.sessionRequestId.isNotEmpty() &&251        isDeviceType(frame.deviceType)252253fun isAdoptGrant(frame: AdoptionFrame?): Boolean =254    frame is AdoptionFrame.Grant &&255        validateRootKeyHex(frame.rootKeyHex) &&256        Hex64.matches(frame.vaultId) &&257        isDeviceType(frame.source.deviceType) &&258        Hex64.matches(frame.source.longTermPublicKeyHex) &&259        (frame.source.relayUrl == null ||260            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)261262fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =263    frame is AdoptionFrame.Confirm264265fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =266    frame is AdoptionFrame.Identity &&267        frame.deviceId.isNotEmpty() &&268        isDeviceType(frame.deviceType) &&269        Hex64.matches(frame.longTermPublicKeyHex)270271fun isAdoptComplete(frame: AdoptionFrame?): Boolean =272    frame is AdoptionFrame.Complete273274fun isAdoptApplied(frame: AdoptionFrame?): Boolean =275    frame is AdoptionFrame.Applied276277fun isAdoptReject(frame: AdoptionFrame?): Boolean =278    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"279280fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =281    frame is AdoptionFrame.Snapshot &&282        frame.chunkIndex >= 0 &&283        frame.chunkCount >= 1284285/**286 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the287 * granted root key actually derives the advertised vault id (catching a lying or288 * corrupted source before it can seal a mismatched vault), seals the local vault,289 * applies any carried license, returns our identity, and records the source as a290 * paired device. Returns null on any protocol failure (reason via [deps.onError]).291 */292suspend fun requestAdoption(293    channel: PairingTransport,294    deps: AdoptionReceiverDeps,295    // Called once after a valid ADOPT_GRANT is decoded and validated, before296    // any side effects (vault creation, paired-device upsert). Used by the297    // receiver to inspect the grant's snapshot_capable flag and skip the 15s298    // inline-snapshot wait when the source has nothing to send. Defaults to no-op299    // for callers (tests, other helpers) that don't need to inspect the grant.300    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,301    nowMs: () -> Long = { System.currentTimeMillis() },302): PairedDevice? {303    val receiverLicense = deps.license()304    // Fresh every attempt, and deliberately not our real device id: no identity305    // exists yet at this point in the protocol.306    sendFrame(channel, AdoptionFrame.Request(307        sessionRequestId = deps.generateDeviceId(),308        deviceName = deps.deviceName,309        deviceType = deps.deviceType.wireValue,310        license = receiverLicense,311        snapshotCapable = true,312    ))313314    val confirmed = deps.confirmSas()315    sendFrame(channel, AdoptionFrame.Confirm(confirmed))316    if (!confirmed) {317        deps.onError("ADOPT_SAS_REJECTED")318        return null319    }320321    val frame = receiveFrame(channel)322    if (isAdoptReject(frame)) {323        deps.onError((frame as AdoptionFrame.Reject).reason)324        return null325    }326    if (!isAdoptGrant(frame)) {327        deps.onError("ADOPT_UNEXPECTED_FRAME")328        return null329    }330    val grant = frame as AdoptionFrame.Grant331    onGrant?.invoke(grant)332333    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)334    try {335        if (computeVaultId(rootKeyBytes) != grant.vaultId) {336            deps.onError("ADOPT_VAULT_ID_MISMATCH")337            return null338        }339    } finally {340        rootKeyBytes.fill(0)341    }342    if (receiverLicense == null && grant.license == null) {343        deps.onError("ADOPT_PRO_REQUIRED")344        return null345    }346347    deps.validateSource(grant.source)348    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)349    if (vaultId != grant.vaultId) {350        deps.onError("ADOPT_VAULT_ID_MISMATCH")351        return null352    }353354    if (receiverLicense == null) {355        grant.license?.let { deps.applyLicense(it) }356    }357358    val identity = deps.deriveLocalIdentity()359    sendFrame(channel, AdoptionFrame.Identity(360        deviceId = identity.deviceId,361        deviceName = identity.deviceName,362        deviceType = identity.deviceType.wireValue,363        longTermPublicKeyHex = identity.longTermPublicKeyHex,364        signingPublicKeyHex = identity.signingPublicKeyHex,365    ))366367    if (!isAdoptComplete(receiveFrame(channel))) {368        deps.onError("ADOPT_COMPLETION_MISSING")369        return null370    }371372    val sourceDevice = PairedDevice(373        deviceId = grant.source.deviceId,374        deviceName = grant.source.deviceName,375        deviceType = grant.source.deviceType,376        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,377        signingPublicKeyHex = grant.source.signingPublicKeyHex,378        trustSource = "direct",379        wsPort = grant.source.wsPort,380        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,381        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,382        pairedAt = nowMs(),383    )384    // A physically confirmed direct re-pair is the sole path allowed to lift385    // terminal revocation. Clear before upsert so storage can never expose the386    // source as both paired and revoked, even transiently.387    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)388    deps.upsertPairedDevice(sourceDevice)389    sendFrame(channel, AdoptionFrame.Applied)390    return sourceDevice391}392393/**394 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the395 * root key, then records the receiver's returned identity as a paired device.396 * Returns null if consent is denied or the receiver's identity frame is invalid.397 */398suspend fun grantAdoption(399    channel: PairingTransport,400    request: AdoptionFrame.Request,401    deps: AdoptionSourceDeps,402    nowMs: () -> Long = { System.currentTimeMillis() },403): PairedDevice? {404    if (!deps.onConsent(request)) return null405406    val confirmation = receiveFrame(channel)407    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {408        return null409    }410411    var sourceLicense = deps.license()412    if (sourceLicense == null && request.license != null) {413        sourceLicense = runCatching {414            deps.applyLicense(request.license)415            deps.license()416        }.getOrNull()417    }418    if (sourceLicense == null) {419        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))420        throw IllegalStateException("ADOPT_PRO_REQUIRED")421    }422423    val rootKeyHex = deps.rootKeyHex()424    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")425426    sendFrame(channel, AdoptionFrame.Grant(427        rootKeyHex = rootKeyHex,428        vaultId = deps.vaultId(),429        license = sourceLicense,430        source = AdoptionFrame.Source(431            deviceId = deps.sourceIdentity.deviceId,432            deviceName = deps.sourceIdentity.deviceName,433            deviceType = deps.sourceIdentity.deviceType.wireValue,434            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,435            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,436            wsPort = deps.sourceIdentity.wsPort,437            relayUrl = deps.pairState?.relayUrl,438        ),439        snapshotCapable = request.snapshotCapable == true,440    ))441442    val identity = receiveFrame(channel)443    if (!isAdoptIdentity(identity)) return null444    identity as AdoptionFrame.Identity445446    val device = PairedDevice(447        deviceId = identity.deviceId,448        deviceName = identity.deviceName,449        deviceType = identity.deviceType,450        longTermPublicKeyHex = identity.longTermPublicKeyHex,451        signingPublicKeyHex = identity.signingPublicKeyHex,452        trustSource = "direct",453        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,454        relayUrl = deps.pairState?.relayUrl,455        pairedAt = nowMs(),456    )457    deps.upsertPairedDevice(device)458    sendFrame(channel, AdoptionFrame.Complete)459    if (!isAdoptApplied(receiveFrame(channel))) return null460    return device461}462463/**464 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat465 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent466 * optionals exactly like `JSON.stringify` drops `undefined`.467 */468val AdoptJson: Json = Json {469    classDiscriminator = "type"470    encodeDefaults = false471    ignoreUnknownKeys = true472}473474suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {475    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))476}477478/** Decode one frame, tolerating a malformed/unknown payload by returning null. */479suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =480    runCatching {481        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))482    }.getOrNull()483484/**485 * Source side: chunk the vault snapshot and send it over the pairing channel.486 *487 * Wire shape (v2.9.9+, duress on its own frame; mirrors browser v4.6.4):488 *   - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries.489 *     Tombstones ride the last entries frame when no duress follows.490 *   - If duressSlot is set, one extra frame after the entries carrying only491 *     the duress slot (entries: empty, tombstones: tombstone map,492 *     duress_slot: duressSlot). This moves duress off the entries frame so a493 *     sizeable sealed duress vault can no longer push the entries frame raw494 *     bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB).495 *496 * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200):497 *   - When duress is configured, ONE of the 200 frames is reserved for the498 *     duress slot. The entries cap is [MAX_SNAPSHOT_CHUNKS] - 1 = 199 entries499 *     chunks; excess entries are deferred to the sync fallback pipeline (sync500 *     carries entries and tombstones on Android too, so this is a graceful501 *     truncation). This reservation is REQUIRED: ordinary sync does NOT502 *     carry duress on Android (VaultRepository.exportDuressSlotForAdoption503 *     is the only transfer path), so silently dropping a duress frame at504 *     the cap would leave the adopted device permanently without its duress505 *     vault. We prioritize duress delivery over completeness of entries.506 *507 * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]508 * is rejected with a thrown error BEFORE any snapshot frame is sent. Hard stop;509 * the caller must split duress out of band. We do not silently truncate510 * duress; sync cannot restore it.511 *512 * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries513 * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a514 * duress-only frame has empty `entries` and the complete tombstone map),515 * so both shapes decode and both new->old / old->new directions interoperate.516 */517suspend fun sendVaultSnapshot(518    channel: PairingTransport,519    entries: Map<String, String>,520    tombstones: Map<String, Long>,521    duressSlot: String? = null,522) {523    val includeDuress = !duressSlot.isNullOrBlank()524    // Reserve one frame for duress when configured so duress is never525    // silently dropped at the cap; ordinary sync does NOT carry duress on526    // Android (VaultRepository.exportDuressSlotForAdoption is the only527    // transfer path), so dropping a duress frame would orphan the adopted528    // device's duress vault.529    val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS530531    val chunks = mutableListOf<Map<String, String>>()532    var current = LinkedHashMap<String, String>()533    var currentBytes = 2 // "{}"534    for ((id, envelope) in entries) {535        // +1 for the separating comma; an overestimate only shrinks chunks.536        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1537        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {538            chunks.add(current)539            current = LinkedHashMap()540            currentBytes = 2541        }542        current[id] = envelope543        currentBytes += entryBytes544    }545    chunks.add(current)546547    val entriesChunkCount = minOf(chunks.size, entryCap)548    val sendDuress = includeDuress549    val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)550    val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(551        chunkIndex = entriesChunkCount,552        chunkCount = totalChunks,553        entries = emptyMap(),554        tombstones = tombstones,555        duressSlot = duressSlot,556    ) else null557558    if (559        duressFrame != null &&560        AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)561            .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING562    ) {563        throw SnapshotDuressFrameTooLargeException()564    }565566    for (index in 0 until entriesChunkCount) {567        val isFinalEntries = index == entriesChunkCount - 1568        sendFrame(channel, AdoptionFrame.Snapshot(569            chunkIndex = index,570            chunkCount = totalChunks,571            entries = chunks[index],572            // Tombstones ride the LAST wire frame: when duress follows the573            // entries, it goes on the duress frame; otherwise on the last574            // entries chunk.575            tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),576            duressSlot = null,577        ))578    }579580    if (duressFrame != null) sendFrame(channel, duressFrame)581}582583internal class SnapshotDuressFrameTooLargeException :584    IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")585586/**587 * Receiver side: receive and reassemble every snapshot chunk. Validates the588 * encoded frame size before parsing, then enforces sequential 0-based589 * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and590 * structural validity of each frame. Throws on any validation failure so the591 * caller can fall back to the sync pipeline.592 */593suspend fun receiveVaultSnapshot(594    channel: PairingTransport,595): ReceivedSnapshot {596    val entries = LinkedHashMap<String, String>()597    val tombstones = LinkedHashMap<String, Long>()598    var duressSlot: String? = null599    var chunkCount = -1600    var received = 0601    while (chunkCount == -1 || received < chunkCount) {602        val raw = channel.receive()603        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {604            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")605        }606        val frame = runCatching {607            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))608        }.getOrNull()609        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")610        frame as AdoptionFrame.Snapshot611        if (chunkCount == -1) {612            chunkCount = frame.chunkCount613            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {614                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")615            }616        } else if (frame.chunkCount != chunkCount) {617            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")618        }619        if (frame.chunkIndex != received) {620            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")621        }622        entries.putAll(frame.entries)623        tombstones.putAll(frame.tombstones)624        if (frame.duressSlot != null) duressSlot = frame.duressSlot625        received++626    }627    return ReceivedSnapshot(entries, tombstones, duressSlot)628}629630private fun isDeviceType(value: String): Boolean =631    DeviceType.entries.any { it.wireValue == value }