VaultAdopt.kt

31.2 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.172internal const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024173174/**175 * PB-26-206 aggregate budget shared by sender and receiver — exact mirror of176 * the browser's `vault-adopt.ts` ceilings. The per-frame guard above bounds177 * one frame; this bounds the whole transfer independently of the 200-frame178 * count, so many individually valid frames cannot OOM the receiver.179 */180const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 // 10 MiB181const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000182const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000183/** Same value as [SyncLimits.MAX_ID_LENGTH]; named here so vectors read identically on both clients. */184const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH185/**186 * Maximum encrypted entry envelope: plaintext ceiling plus the existing187 * base64 overhead allowance. Int arithmetic gives 699,114 — the browser's188 * float comparison accepts and rejects at exactly the same lengths.189 */190const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES191192/**193 * Running aggregate for one snapshot transfer. Counts ride alongside the maps194 * (never re-derived per frame) so the bound stays O(n).195 */196class AdoptSnapshotBudget {197    val entries = LinkedHashMap<String, String>()198    val tombstones = LinkedHashMap<String, Long>()199    var duressSlot: String? = null200    /** Sum of encoded wire-frame bytes consumed so far. */201    var bytes: Int = 0202    var entryCount: Int = 0203    var tombstoneCount: Int = 0204}205206fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()207208/**209 * PB-26-206 shared preflight/merge helper used by BOTH the sender (preflight210 * before the first frame goes out) and the receiver (per frame, after the211 * byte-budget check, before decoded maps are merged).212 *213 * One bounded pass per attacker-controlled map: each key/value is validated214 * and projected counts are enforced inside the same loop that merges, so the215 * maps are never iterated count→size→merge repeatedly. Throws before merging216 * the offending frame, leaving no partial adopted state.217 */218fun applyAdoptSnapshotFrame(219    acc: AdoptSnapshotBudget,220    entries: Map<String, String>,221    tombstones: Map<String, Long>,222    duressSlot: String?,223    frameBytes: Int,224) {225    if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {226        throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")227    }228    for ((id, envelope) in entries) {229        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")230        if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {231            throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")232        }233        if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {234            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")235        }236        acc.entryCount++237        acc.entries[id] = envelope238    }239    for ((id, ts) in tombstones) {240        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")241        // Long is always finite, so non-finite hostile JSON dies at the242        // strict codec decode before reaching this merge (see V7).243        if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {244            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")245        }246        acc.tombstoneCount++247        acc.tombstones[id] = ts248    }249    if (!duressSlot.isNullOrEmpty()) {250        // The duress slot rides inside wire frames already bounded by the251        // frame ceiling, and its raw bytes count against the aggregate above.252        if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {253            throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")254        }255        acc.duressSlot = duressSlot256    }257    acc.bytes += frameBytes258}259260/** What the RECEIVER (joining device) needs to adopt a vault from a source. */261interface AdoptionReceiverDeps {262    val deviceName: String263    val deviceType: DeviceType264    fun generateDeviceId(): String265    /** Root-key transfer cannot begin until this device confirms the SAS. */266    suspend fun confirmSas(): Boolean267    /**268     * Local safety policy evaluated after the authenticated grant is validated269     * but before any vault state changes.270     */271    suspend fun validateSource(source: AdoptionFrame.Source) {}272273    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */274    suspend fun createVaultFromRootKey(rootKeyHex: String): String275    suspend fun deriveLocalIdentity(): LocalDeviceIdentity276    /** Only called after a successful, SAS-confirmed direct pairing. */277    suspend fun clearPairedDeviceRevocation(deviceId: String) {}278    suspend fun upsertPairedDevice(device: PairedDevice)279    suspend fun applyLicense(license: String) {}280    /** Return this receiver's locally validated Pro key, if present. */281    suspend fun license(): String? = null282    val pairState: AdoptionPairState? get() = null283    fun onError(error: String)284    /** Apply encrypted entries + tombstones from the inline snapshot. */285    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}286    /**287     * Apply the source's signed device graph so the receiver graduates into the288     * mesh. The [authenticatedRemoteStaticPublicKey] is the source's Noise289     * transport static key (NOT the signing key); [applyIncomingDeviceManifest]290     * resolves it to the source's [PairedDevice] locally and uses the stored291     * signing key to verify the manifest signature. Default no-op preserves292     * the contract for callers that haven't wired the mesh path yet.293     */294    suspend fun applyIncomingDeviceManifest(295        manifest: SignedDeviceManifest,296        authenticatedRemoteStaticPublicKey: ByteArray,297    ) {}298    /** Mark the paired device as synced after a successful snapshot. */299    suspend fun markSynced(deviceId: String) {}300}301302/** What the SOURCE (existing device) needs to grant its vault to a receiver. */303interface AdoptionSourceDeps {304    val sourceIdentity: LocalDeviceIdentity305    suspend fun rootKeyHex(): String306    suspend fun vaultId(): String307    suspend fun license(): String? = null308    /** Validate and durably store a Pro key offered by the receiver. */309    suspend fun applyLicense(license: String) {}310311    /**312     * Ask the user to approve handing this vault to the requesting device. The313     * request's name/type/label are for the human to read; the decision must not314     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which315     * carries no continuity between attempts.316     */317    suspend fun onConsent(request: AdoptionFrame.Request): Boolean318    suspend fun upsertPairedDevice(device: PairedDevice)319    val pairState: AdoptionPairState? get() = null320    /** Return all encrypted entries + tombstones for the inline snapshot. */321    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null322    /** Return the signed device manifest for the graph, if available. */323    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null324    /** Mark the paired device as synced after a successful snapshot. */325    suspend fun markSynced(deviceId: String) {}326    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */327    suspend fun duressSlotJson(): String? = null328}329330private val Hex64 = Regex("^[0-9a-f]{64}\$")331332fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)333334fun isAdoptRequest(frame: AdoptionFrame?): Boolean =335    frame is AdoptionFrame.Request &&336        frame.sessionRequestId.isNotEmpty() &&337        isDeviceType(frame.deviceType)338339fun isAdoptGrant(frame: AdoptionFrame?): Boolean =340    frame is AdoptionFrame.Grant &&341        validateRootKeyHex(frame.rootKeyHex) &&342        Hex64.matches(frame.vaultId) &&343        isDeviceType(frame.source.deviceType) &&344        Hex64.matches(frame.source.longTermPublicKeyHex) &&345        (frame.source.relayUrl == null ||346            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)347348fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =349    frame is AdoptionFrame.Confirm350351fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =352    frame is AdoptionFrame.Identity &&353        frame.deviceId.isNotEmpty() &&354        isDeviceType(frame.deviceType) &&355        Hex64.matches(frame.longTermPublicKeyHex)356357fun isAdoptComplete(frame: AdoptionFrame?): Boolean =358    frame is AdoptionFrame.Complete359360fun isAdoptApplied(frame: AdoptionFrame?): Boolean =361    frame is AdoptionFrame.Applied362363fun isAdoptReject(frame: AdoptionFrame?): Boolean =364    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"365366fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =367    frame is AdoptionFrame.Snapshot &&368        frame.chunkIndex >= 0 &&369        frame.chunkCount >= 1370371/**372 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the373 * granted root key actually derives the advertised vault id (catching a lying or374 * corrupted source before it can seal a mismatched vault), seals the local vault,375 * applies any carried license, returns our identity, and records the source as a376 * paired device. Returns null on any protocol failure (reason via [deps.onError]).377 */378suspend fun requestAdoption(379    channel: PairingTransport,380    deps: AdoptionReceiverDeps,381    // Called once after a valid ADOPT_GRANT is decoded and validated, before382    // any side effects (vault creation, paired-device upsert). Used by the383    // receiver to inspect the grant's snapshot_capable flag and skip the 15s384    // inline-snapshot wait when the source has nothing to send. Defaults to no-op385    // for callers (tests, other helpers) that don't need to inspect the grant.386    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,387    nowMs: () -> Long = { System.currentTimeMillis() },388): PairedDevice? {389    val receiverLicense = deps.license()390    // Fresh every attempt, and deliberately not our real device id: no identity391    // exists yet at this point in the protocol.392    sendFrame(channel, AdoptionFrame.Request(393        sessionRequestId = deps.generateDeviceId(),394        deviceName = deps.deviceName,395        deviceType = deps.deviceType.wireValue,396        license = receiverLicense,397        snapshotCapable = true,398    ))399400    val confirmed = deps.confirmSas()401    sendFrame(channel, AdoptionFrame.Confirm(confirmed))402    if (!confirmed) {403        deps.onError("ADOPT_SAS_REJECTED")404        return null405    }406407    val frame = receiveFrame(channel)408    if (isAdoptReject(frame)) {409        deps.onError((frame as AdoptionFrame.Reject).reason)410        return null411    }412    if (!isAdoptGrant(frame)) {413        deps.onError("ADOPT_UNEXPECTED_FRAME")414        return null415    }416    val grant = frame as AdoptionFrame.Grant417    onGrant?.invoke(grant)418419    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)420    try {421        if (computeVaultId(rootKeyBytes) != grant.vaultId) {422            deps.onError("ADOPT_VAULT_ID_MISMATCH")423            return null424        }425    } finally {426        rootKeyBytes.fill(0)427    }428    if (receiverLicense == null && grant.license == null) {429        deps.onError("ADOPT_PRO_REQUIRED")430        return null431    }432433    deps.validateSource(grant.source)434    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)435    if (vaultId != grant.vaultId) {436        deps.onError("ADOPT_VAULT_ID_MISMATCH")437        return null438    }439440    if (receiverLicense == null) {441        grant.license?.let { deps.applyLicense(it) }442    }443444    val identity = deps.deriveLocalIdentity()445    sendFrame(channel, AdoptionFrame.Identity(446        deviceId = identity.deviceId,447        deviceName = identity.deviceName,448        deviceType = identity.deviceType.wireValue,449        longTermPublicKeyHex = identity.longTermPublicKeyHex,450        signingPublicKeyHex = identity.signingPublicKeyHex,451    ))452453    if (!isAdoptComplete(receiveFrame(channel))) {454        deps.onError("ADOPT_COMPLETION_MISSING")455        return null456    }457458    val sourceDevice = PairedDevice(459        deviceId = grant.source.deviceId,460        deviceName = grant.source.deviceName,461        deviceType = grant.source.deviceType,462        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,463        signingPublicKeyHex = grant.source.signingPublicKeyHex,464        trustSource = "direct",465        wsPort = grant.source.wsPort,466        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,467        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,468        pairedAt = nowMs(),469    )470    // A physically confirmed direct re-pair is the sole path allowed to lift471    // terminal revocation. Clear before upsert so storage can never expose the472    // source as both paired and revoked, even transiently.473    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)474    deps.upsertPairedDevice(sourceDevice)475    sendFrame(channel, AdoptionFrame.Applied)476    return sourceDevice477}478479/**480 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the481 * root key, then records the receiver's returned identity as a paired device.482 * Returns null if consent is denied or the receiver's identity frame is invalid.483 */484suspend fun grantAdoption(485    channel: PairingTransport,486    request: AdoptionFrame.Request,487    deps: AdoptionSourceDeps,488    nowMs: () -> Long = { System.currentTimeMillis() },489): PairedDevice? {490    if (!deps.onConsent(request)) return null491492    val confirmation = receiveFrame(channel)493    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {494        return null495    }496497    var sourceLicense = deps.license()498    if (sourceLicense == null && request.license != null) {499        sourceLicense = runCatching {500            deps.applyLicense(request.license)501            deps.license()502        }.getOrNull()503    }504    if (sourceLicense == null) {505        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))506        throw IllegalStateException("ADOPT_PRO_REQUIRED")507    }508509    val rootKeyHex = deps.rootKeyHex()510    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")511512    sendFrame(channel, AdoptionFrame.Grant(513        rootKeyHex = rootKeyHex,514        vaultId = deps.vaultId(),515        license = sourceLicense,516        source = AdoptionFrame.Source(517            deviceId = deps.sourceIdentity.deviceId,518            deviceName = deps.sourceIdentity.deviceName,519            deviceType = deps.sourceIdentity.deviceType.wireValue,520            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,521            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,522            wsPort = deps.sourceIdentity.wsPort,523            relayUrl = deps.pairState?.relayUrl,524        ),525        snapshotCapable = request.snapshotCapable == true,526    ))527528    val identity = receiveFrame(channel)529    if (!isAdoptIdentity(identity)) return null530    identity as AdoptionFrame.Identity531532    val device = PairedDevice(533        deviceId = identity.deviceId,534        deviceName = identity.deviceName,535        deviceType = identity.deviceType,536        longTermPublicKeyHex = identity.longTermPublicKeyHex,537        signingPublicKeyHex = identity.signingPublicKeyHex,538        trustSource = "direct",539        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,540        relayUrl = deps.pairState?.relayUrl,541        pairedAt = nowMs(),542    )543    deps.upsertPairedDevice(device)544    sendFrame(channel, AdoptionFrame.Complete)545    if (!isAdoptApplied(receiveFrame(channel))) return null546    return device547}548549/**550 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat551 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent552 * optionals exactly like `JSON.stringify` drops `undefined`.553 */554val AdoptJson: Json = Json {555    classDiscriminator = "type"556    encodeDefaults = false557    ignoreUnknownKeys = true558}559560suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {561    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))562}563564/** Decode one frame, tolerating a malformed/unknown payload by returning null. */565suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =566    runCatching {567        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))568    }.getOrNull()569570/**571 * Source side: chunk the vault snapshot and send it over the pairing channel.572 *573 * Wire shape (v2.9.9+, duress on its own frame; mirrors browser v4.6.4):574 *   - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries.575 *     Tombstones ride the last entries frame when no duress follows.576 *   - If duressSlot is set, one extra frame after the entries carrying only577 *     the duress slot (entries: empty, tombstones: tombstone map,578 *     duress_slot: duressSlot). This moves duress off the entries frame so a579 *     sizeable sealed duress vault can no longer push the entries frame raw580 *     bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB).581 *582 * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200):583 *   - When duress is configured, ONE of the 200 frames is reserved for the584 *     duress slot. The entries cap is [MAX_SNAPSHOT_CHUNKS] - 1 = 199 entries585 *     chunks; excess entries are deferred to the sync fallback pipeline (sync586 *     carries entries and tombstones on Android too, so this is a graceful587 *     truncation). This reservation is REQUIRED: ordinary sync does NOT588 *     carry duress on Android (VaultRepository.exportDuressSlotForAdoption589 *     is the only transfer path), so silently dropping a duress frame at590 *     the cap would leave the adopted device permanently without its duress591 *     vault. We prioritize duress delivery over completeness of entries.592 *593 * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]594 * is rejected with a thrown error BEFORE any snapshot frame is sent. Hard stop;595 * the caller must split duress out of band. We do not silently truncate596 * duress; sync cannot restore it.597 *598 * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries599 * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a600 * duress-only frame has empty `entries` and the complete tombstone map),601 * so both shapes decode and both new->old / old->new directions interoperate.602 */603suspend fun sendVaultSnapshot(604    channel: PairingTransport,605    entries: Map<String, String>,606    tombstones: Map<String, Long>,607    duressSlot: String? = null,608) {609    val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)610    // PB-26-206 preflight with the SAME helper the receiver enforces: nothing611    // is sent until every frame fits the aggregate budget, so an over-budget612    // snapshot is never partially sent or silently truncated. The caller falls613    // through to the regular sync pipeline (deny-only: no wire-field change,614    // invitation prefix unchanged).615    val acc = createAdoptSnapshotBudget()616    val encoded = frames.map {617        AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()618    }619    for (i in frames.indices) {620        val frame = frames[i]621        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)622    }623    for (raw in encoded) channel.send(raw)624}625626/**627 * Pure frame construction for [sendVaultSnapshot]: chunking, the duress-frame628 * reservation, and the oversized-duress hard stop. No I/O happens here, so629 * the preflight above can reject before the first byte goes out.630 */631private fun buildAdoptSnapshotFrames(632    entries: Map<String, String>,633    tombstones: Map<String, Long>,634    duressSlot: String?,635): List<AdoptionFrame.Snapshot> {636    val includeDuress = !duressSlot.isNullOrBlank()637    // Reserve one frame for duress when configured so duress is never638    // silently dropped at the cap; ordinary sync does NOT carry duress on639    // Android (VaultRepository.exportDuressSlotForAdoption is the only640    // transfer path), so dropping a duress frame would orphan the adopted641    // device's duress vault.642    val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS643644    val chunks = mutableListOf<Map<String, String>>()645    var current = LinkedHashMap<String, String>()646    var currentBytes = 2 // "{}"647    for ((id, envelope) in entries) {648        // +1 for the separating comma; an overestimate only shrinks chunks.649        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1650        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {651            chunks.add(current)652            current = LinkedHashMap()653            currentBytes = 2654        }655        current[id] = envelope656        currentBytes += entryBytes657    }658    chunks.add(current)659660    val entriesChunkCount = minOf(chunks.size, entryCap)661    val sendDuress = includeDuress662    val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)663    val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(664        chunkIndex = entriesChunkCount,665        chunkCount = totalChunks,666        entries = emptyMap(),667        tombstones = tombstones,668        duressSlot = duressSlot,669    ) else null670671    if (672        duressFrame != null &&673        AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)674            .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING675    ) {676        throw SnapshotDuressFrameTooLargeException()677    }678679    val frames = mutableListOf<AdoptionFrame.Snapshot>()680    for (index in 0 until entriesChunkCount) {681        val isFinalEntries = index == entriesChunkCount - 1682        frames.add(AdoptionFrame.Snapshot(683            chunkIndex = index,684            chunkCount = totalChunks,685            entries = chunks[index],686            // Tombstones ride the LAST wire frame: when duress follows the687            // entries, it goes on the duress frame; otherwise on the last688            // entries chunk.689            tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),690            duressSlot = null,691        ))692    }693694    if (duressFrame != null) frames.add(duressFrame)695    return frames696}697698internal class SnapshotDuressFrameTooLargeException :699    IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")700701/**702 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame703 * bytes to the aggregate counter before decoding (rejecting before JSON704 * deserialization when the next frame exceeds the remaining budget), then705 * enforces the per-frame wire ceiling, sequential 0-based chunk_index706 * ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and structural707 * validity of each frame. Merging goes through the shared aggregate helper:708 * every key/value and projected count is validated in one bounded pass before709 * anything lands in the accumulator, so a rejection leaves no partial adopted710 * state and the caller can fall back to the sync pipeline.711 */712suspend fun receiveVaultSnapshot(713    channel: PairingTransport,714): ReceivedSnapshot {715    val acc = createAdoptSnapshotBudget()716    var chunkCount = -1717    var received = 0718    while (chunkCount == -1 || received < chunkCount) {719        val raw = channel.receive()720        if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {721            throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")722        }723        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {724            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")725        }726        val frame = runCatching {727            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))728        }.getOrNull()729        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")730        frame as AdoptionFrame.Snapshot731        if (chunkCount == -1) {732            chunkCount = frame.chunkCount733            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {734                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")735            }736        } else if (frame.chunkCount != chunkCount) {737            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")738        }739        if (frame.chunkIndex != received) {740            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")741        }742        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)743        received++744    }745    return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)746}747748private fun isDeviceType(value: String): Boolean =749    DeviceType.entries.any { it.wireValue == value }