VaultAdopt.kt

31.6 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 * 1024173internal val MAX_PADDED_SNAPSHOT_FRAME_BYTES = SyncPadding.bucket(SNAPSHOT_FRAME_WIRE_CEILING + 4)174175/**176 * PB-26-206 aggregate budget shared by sender and receiver — exact mirror of177 * the browser's `vault-adopt.ts` ceilings. The per-frame guard above bounds178 * one frame; this bounds the whole transfer independently of the 200-frame179 * count, so many individually valid frames cannot OOM the receiver.180 */181const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 // 10 MiB182const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000183const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000184/** Same value as [SyncLimits.MAX_ID_LENGTH]; named here so vectors read identically on both clients. */185const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH186/**187 * Maximum encrypted entry envelope: plaintext ceiling plus the existing188 * base64 overhead allowance. Int arithmetic gives 699,114 — the browser's189 * float comparison accepts and rejects at exactly the same lengths.190 */191const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES192193/**194 * Running aggregate for one snapshot transfer. Counts ride alongside the maps195 * (never re-derived per frame) so the bound stays O(n).196 */197class AdoptSnapshotBudget {198    val entries = LinkedHashMap<String, String>()199    val tombstones = LinkedHashMap<String, Long>()200    var duressSlot: String? = null201    /** Sum of encoded wire-frame bytes consumed so far. */202    var bytes: Int = 0203    var entryCount: Int = 0204    var tombstoneCount: Int = 0205}206207fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()208209/**210 * PB-26-206 shared preflight/merge helper used by BOTH the sender (preflight211 * before the first frame goes out) and the receiver (per frame, after the212 * byte-budget check, before decoded maps are merged).213 *214 * One bounded pass per attacker-controlled map: each key/value is validated215 * and projected counts are enforced inside the same loop that merges, so the216 * maps are never iterated count→size→merge repeatedly. Throws before merging217 * the offending frame, leaving no partial adopted state.218 */219fun applyAdoptSnapshotFrame(220    acc: AdoptSnapshotBudget,221    entries: Map<String, String>,222    tombstones: Map<String, Long>,223    duressSlot: String?,224    frameBytes: Int,225) {226    if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {227        throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")228    }229    for ((id, envelope) in entries) {230        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")231        if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {232            throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")233        }234        if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {235            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")236        }237        acc.entryCount++238        acc.entries[id] = envelope239    }240    for ((id, ts) in tombstones) {241        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")242        // Long is always finite, so non-finite hostile JSON dies at the243        // strict codec decode before reaching this merge (see V7).244        if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {245            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")246        }247        acc.tombstoneCount++248        acc.tombstones[id] = ts249    }250    if (!duressSlot.isNullOrEmpty()) {251        // The duress slot rides inside wire frames already bounded by the252        // frame ceiling, and its raw bytes count against the aggregate above.253        if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {254            throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")255        }256        acc.duressSlot = duressSlot257    }258    acc.bytes += frameBytes259}260261/** What the RECEIVER (joining device) needs to adopt a vault from a source. */262interface AdoptionReceiverDeps {263    val deviceName: String264    val deviceType: DeviceType265    fun generateDeviceId(): String266    /** Root-key transfer cannot begin until this device confirms the SAS. */267    suspend fun confirmSas(): Boolean268    /**269     * Local safety policy evaluated after the authenticated grant is validated270     * but before any vault state changes.271     */272    suspend fun validateSource(source: AdoptionFrame.Source) {}273274    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */275    suspend fun createVaultFromRootKey(rootKeyHex: String): String276    suspend fun deriveLocalIdentity(): LocalDeviceIdentity277    /** Only called after a successful, SAS-confirmed direct pairing. */278    suspend fun clearPairedDeviceRevocation(deviceId: String) {}279    suspend fun upsertPairedDevice(device: PairedDevice)280    suspend fun applyLicense(license: String) {}281    /** Return this receiver's locally validated Pro key, if present. */282    suspend fun license(): String? = null283    val pairState: AdoptionPairState? get() = null284    fun onError(error: String)285    /** Apply encrypted entries + tombstones from the inline snapshot. */286    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}287    /**288     * Apply the source's signed device graph so the receiver graduates into the289     * mesh. The [authenticatedRemoteStaticPublicKey] is the source's Noise290     * transport static key (NOT the signing key); [applyIncomingDeviceManifest]291     * resolves it to the source's [PairedDevice] locally and uses the stored292     * signing key to verify the manifest signature. Default no-op preserves293     * the contract for callers that haven't wired the mesh path yet.294     */295    suspend fun applyIncomingDeviceManifest(296        manifest: SignedDeviceManifest,297        authenticatedRemoteStaticPublicKey: ByteArray,298    ) {}299    /** Mark the paired device as synced after a successful snapshot. */300    suspend fun markSynced(deviceId: String) {}301}302303/** What the SOURCE (existing device) needs to grant its vault to a receiver. */304interface AdoptionSourceDeps {305    val sourceIdentity: LocalDeviceIdentity306    suspend fun rootKeyHex(): String307    suspend fun vaultId(): String308    suspend fun license(): String? = null309    /** Validate and durably store a Pro key offered by the receiver. */310    suspend fun applyLicense(license: String) {}311312    /**313     * Ask the user to approve handing this vault to the requesting device. The314     * request's name/type/label are for the human to read; the decision must not315     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which316     * carries no continuity between attempts.317     */318    suspend fun onConsent(request: AdoptionFrame.Request): Boolean319    suspend fun upsertPairedDevice(device: PairedDevice)320    val pairState: AdoptionPairState? get() = null321    /** Return all encrypted entries + tombstones for the inline snapshot. */322    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null323    /** Return the signed device manifest for the graph, if available. */324    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null325    /** Mark the paired device as synced after a successful snapshot. */326    suspend fun markSynced(deviceId: String) {}327    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */328    suspend fun duressSlotJson(): String? = null329}330331private val Hex64 = Regex("^[0-9a-f]{64}\$")332333fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)334335fun isAdoptRequest(frame: AdoptionFrame?): Boolean =336    frame is AdoptionFrame.Request &&337        frame.sessionRequestId.isNotEmpty() &&338        isDeviceType(frame.deviceType)339340fun isAdoptGrant(frame: AdoptionFrame?): Boolean =341    frame is AdoptionFrame.Grant &&342        validateRootKeyHex(frame.rootKeyHex) &&343        Hex64.matches(frame.vaultId) &&344        isDeviceType(frame.source.deviceType) &&345        Hex64.matches(frame.source.longTermPublicKeyHex) &&346        (frame.source.relayUrl == null ||347            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)348349fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =350    frame is AdoptionFrame.Confirm351352fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =353    frame is AdoptionFrame.Identity &&354        frame.deviceId.isNotEmpty() &&355        isDeviceType(frame.deviceType) &&356        Hex64.matches(frame.longTermPublicKeyHex)357358fun isAdoptComplete(frame: AdoptionFrame?): Boolean =359    frame is AdoptionFrame.Complete360361fun isAdoptApplied(frame: AdoptionFrame?): Boolean =362    frame is AdoptionFrame.Applied363364fun isAdoptReject(frame: AdoptionFrame?): Boolean =365    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"366367fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =368    frame is AdoptionFrame.Snapshot &&369        frame.chunkIndex >= 0 &&370        frame.chunkCount >= 1371372/**373 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the374 * granted root key actually derives the advertised vault id (catching a lying or375 * corrupted source before it can seal a mismatched vault), seals the local vault,376 * applies any carried license, returns our identity, and records the source as a377 * paired device. Returns null on any protocol failure (reason via [deps.onError]).378 */379suspend fun requestAdoption(380    channel: PairingTransport,381    deps: AdoptionReceiverDeps,382    // Called once after a valid ADOPT_GRANT is decoded and validated, before383    // any side effects (vault creation, paired-device upsert). Used by the384    // receiver to inspect the grant's snapshot_capable flag and skip the 15s385    // inline-snapshot wait when the source has nothing to send. Defaults to no-op386    // for callers (tests, other helpers) that don't need to inspect the grant.387    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,388    nowMs: () -> Long = { System.currentTimeMillis() },389): PairedDevice? {390    val receiverLicense = deps.license()391    // Fresh every attempt, and deliberately not our real device id: no identity392    // exists yet at this point in the protocol.393    sendFrame(channel, AdoptionFrame.Request(394        sessionRequestId = deps.generateDeviceId(),395        deviceName = deps.deviceName,396        deviceType = deps.deviceType.wireValue,397        license = receiverLicense,398        snapshotCapable = true,399    ))400401    val confirmed = deps.confirmSas()402    sendFrame(channel, AdoptionFrame.Confirm(confirmed))403    if (!confirmed) {404        deps.onError("ADOPT_SAS_REJECTED")405        return null406    }407408    val frame = receiveFrame(channel)409    if (isAdoptReject(frame)) {410        deps.onError((frame as AdoptionFrame.Reject).reason)411        return null412    }413    if (!isAdoptGrant(frame)) {414        deps.onError("ADOPT_UNEXPECTED_FRAME")415        return null416    }417    val grant = frame as AdoptionFrame.Grant418    onGrant?.invoke(grant)419420    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)421    try {422        if (computeVaultId(rootKeyBytes) != grant.vaultId) {423            deps.onError("ADOPT_VAULT_ID_MISMATCH")424            return null425        }426    } finally {427        rootKeyBytes.fill(0)428    }429    if (receiverLicense == null && grant.license == null) {430        deps.onError("ADOPT_PRO_REQUIRED")431        return null432    }433434    deps.validateSource(grant.source)435    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)436    if (vaultId != grant.vaultId) {437        deps.onError("ADOPT_VAULT_ID_MISMATCH")438        return null439    }440441    if (receiverLicense == null) {442        grant.license?.let { deps.applyLicense(it) }443    }444445    val identity = deps.deriveLocalIdentity()446    sendFrame(channel, AdoptionFrame.Identity(447        deviceId = identity.deviceId,448        deviceName = identity.deviceName,449        deviceType = identity.deviceType.wireValue,450        longTermPublicKeyHex = identity.longTermPublicKeyHex,451        signingPublicKeyHex = identity.signingPublicKeyHex,452    ))453454    if (!isAdoptComplete(receiveFrame(channel))) {455        deps.onError("ADOPT_COMPLETION_MISSING")456        return null457    }458459    val sourceDevice = PairedDevice(460        deviceId = grant.source.deviceId,461        deviceName = grant.source.deviceName,462        deviceType = grant.source.deviceType,463        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,464        signingPublicKeyHex = grant.source.signingPublicKeyHex,465        trustSource = "direct",466        wsPort = grant.source.wsPort,467        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,468        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,469        pairedAt = nowMs(),470    )471    // A physically confirmed direct re-pair is the sole path allowed to lift472    // terminal revocation. Clear before upsert so storage can never expose the473    // source as both paired and revoked, even transiently.474    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)475    deps.upsertPairedDevice(sourceDevice)476    sendFrame(channel, AdoptionFrame.Applied)477    return sourceDevice478}479480/**481 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the482 * root key, then records the receiver's returned identity as a paired device.483 * Returns null if consent is denied or the receiver's identity frame is invalid.484 */485suspend fun grantAdoption(486    channel: PairingTransport,487    request: AdoptionFrame.Request,488    deps: AdoptionSourceDeps,489    nowMs: () -> Long = { System.currentTimeMillis() },490): PairedDevice? {491    if (!deps.onConsent(request)) return null492493    val confirmation = receiveFrame(channel)494    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {495        return null496    }497498    var sourceLicense = deps.license()499    if (sourceLicense == null && request.license != null) {500        sourceLicense = runCatching {501            deps.applyLicense(request.license)502            deps.license()503        }.getOrNull()504    }505    if (sourceLicense == null) {506        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))507        throw IllegalStateException("ADOPT_PRO_REQUIRED")508    }509510    val rootKeyHex = deps.rootKeyHex()511    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")512513    sendFrame(channel, AdoptionFrame.Grant(514        rootKeyHex = rootKeyHex,515        vaultId = deps.vaultId(),516        license = sourceLicense,517        source = AdoptionFrame.Source(518            deviceId = deps.sourceIdentity.deviceId,519            deviceName = deps.sourceIdentity.deviceName,520            deviceType = deps.sourceIdentity.deviceType.wireValue,521            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,522            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,523            wsPort = deps.sourceIdentity.wsPort,524            relayUrl = deps.pairState?.relayUrl,525        ),526        snapshotCapable = request.snapshotCapable == true,527    ))528529    val identity = receiveFrame(channel)530    if (!isAdoptIdentity(identity)) return null531    identity as AdoptionFrame.Identity532533    val device = PairedDevice(534        deviceId = identity.deviceId,535        deviceName = identity.deviceName,536        deviceType = identity.deviceType,537        longTermPublicKeyHex = identity.longTermPublicKeyHex,538        signingPublicKeyHex = identity.signingPublicKeyHex,539        trustSource = "direct",540        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,541        relayUrl = deps.pairState?.relayUrl,542        pairedAt = nowMs(),543    )544    deps.upsertPairedDevice(device)545    sendFrame(channel, AdoptionFrame.Complete)546    if (!isAdoptApplied(receiveFrame(channel))) return null547    return device548}549550/**551 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat552 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent553 * optionals exactly like `JSON.stringify` drops `undefined`.554 */555val AdoptJson: Json = Json {556    classDiscriminator = "type"557    encodeDefaults = false558    ignoreUnknownKeys = true559}560561suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {562    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))563}564565/** Decode one frame, tolerating a malformed/unknown payload by returning null. */566suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =567    runCatching {568        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))569    }.getOrNull()570571/**572 * Source side: chunk the vault snapshot and send it over the pairing channel.573 *574 * Wire shape (v2.9.9+, duress on its own frame; mirrors browser v4.6.4):575 *   - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries.576 *     Tombstones ride the last entries frame when no duress follows.577 *   - If duressSlot is set, one extra frame after the entries carrying only578 *     the duress slot (entries: empty, tombstones: tombstone map,579 *     duress_slot: duressSlot). This moves duress off the entries frame so a580 *     sizeable sealed duress vault can no longer push the entries frame raw581 *     bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB).582 *583 * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200):584 *   - When duress is configured, ONE of the 200 frames is reserved for the585 *     duress slot. The entries cap is [MAX_SNAPSHOT_CHUNKS] - 1 = 199 entries586 *     chunks; excess entries are deferred to the sync fallback pipeline (sync587 *     carries entries and tombstones on Android too, so this is a graceful588 *     truncation). This reservation is REQUIRED: ordinary sync does NOT589 *     carry duress on Android (VaultRepository.exportDuressSlotForAdoption590 *     is the only transfer path), so silently dropping a duress frame at591 *     the cap would leave the adopted device permanently without its duress592 *     vault. We prioritize duress delivery over completeness of entries.593 *594 * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]595 * is rejected with a thrown error BEFORE any snapshot frame is sent. Hard stop;596 * the caller must split duress out of band. We do not silently truncate597 * duress; sync cannot restore it.598 *599 * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries600 * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a601 * duress-only frame has empty `entries` and the complete tombstone map),602 * so both shapes decode and both new->old / old->new directions interoperate.603 */604suspend fun sendVaultSnapshot(605    channel: PairingTransport,606    entries: Map<String, String>,607    tombstones: Map<String, Long>,608    duressSlot: String? = null,609) {610    val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)611    // PB-26-206 preflight with the SAME helper the receiver enforces: nothing612    // is sent until every frame fits the aggregate budget, so an over-budget613    // snapshot is never partially sent or silently truncated. The caller falls614    // through to the regular sync pipeline (deny-only: no wire-field change,615    // invitation prefix unchanged).616    val acc = createAdoptSnapshotBudget()617    val encoded = frames.map {618        AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()619    }620    for (i in frames.indices) {621        val frame = frames[i]622        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)623    }624    for (raw in encoded) channel.send(SyncPadding.pad(raw))625}626627/**628 * Pure frame construction for [sendVaultSnapshot]: chunking, the duress-frame629 * reservation, and the oversized-duress hard stop. No I/O happens here, so630 * the preflight above can reject before the first byte goes out.631 */632private fun buildAdoptSnapshotFrames(633    entries: Map<String, String>,634    tombstones: Map<String, Long>,635    duressSlot: String?,636): List<AdoptionFrame.Snapshot> {637    val includeDuress = !duressSlot.isNullOrBlank()638    // Reserve one frame for duress when configured so duress is never639    // silently dropped at the cap; ordinary sync does NOT carry duress on640    // Android (VaultRepository.exportDuressSlotForAdoption is the only641    // transfer path), so dropping a duress frame would orphan the adopted642    // device's duress vault.643    val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS644645    val chunks = mutableListOf<Map<String, String>>()646    var current = LinkedHashMap<String, String>()647    var currentBytes = 2 // "{}"648    for ((id, envelope) in entries) {649        // +1 for the separating comma; an overestimate only shrinks chunks.650        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1651        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {652            chunks.add(current)653            current = LinkedHashMap()654            currentBytes = 2655        }656        current[id] = envelope657        currentBytes += entryBytes658    }659    chunks.add(current)660661    val entriesChunkCount = minOf(chunks.size, entryCap)662    val sendDuress = includeDuress663    val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)664    val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(665        chunkIndex = entriesChunkCount,666        chunkCount = totalChunks,667        entries = emptyMap(),668        tombstones = tombstones,669        duressSlot = duressSlot,670    ) else null671672    if (673        duressFrame != null &&674        AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)675            .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING676    ) {677        throw SnapshotDuressFrameTooLargeException()678    }679680    val frames = mutableListOf<AdoptionFrame.Snapshot>()681    for (index in 0 until entriesChunkCount) {682        val isFinalEntries = index == entriesChunkCount - 1683        frames.add(AdoptionFrame.Snapshot(684            chunkIndex = index,685            chunkCount = totalChunks,686            entries = chunks[index],687            // Tombstones ride the LAST wire frame: when duress follows the688            // entries, it goes on the duress frame; otherwise on the last689            // entries chunk.690            tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),691            duressSlot = null,692        ))693    }694695    if (duressFrame != null) frames.add(duressFrame)696    return frames697}698699internal class SnapshotDuressFrameTooLargeException :700    IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")701702/**703 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame704 * bytes to the aggregate counter before decoding (rejecting before JSON705 * deserialization when the next frame exceeds the remaining budget), then706 * enforces the per-frame wire ceiling, sequential 0-based chunk_index707 * ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and structural708 * validity of each frame. Merging goes through the shared aggregate helper:709 * every key/value and projected count is validated in one bounded pass before710 * anything lands in the accumulator, so a rejection leaves no partial adopted711 * state and the caller can fall back to the sync pipeline.712 */713suspend fun receiveVaultSnapshot(714    channel: PairingTransport,715): ReceivedSnapshot {716    val acc = createAdoptSnapshotBudget()717    var chunkCount = -1718    var received = 0719    while (chunkCount == -1 || received < chunkCount) {720        val wire = channel.receive()721        if (wire.size > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {722            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")723        }724        val raw = runCatching { SyncPadding.unpad(wire) }725            .getOrElse { throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME", it) }726        if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {727            throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")728        }729        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {730            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")731        }732        val frame = runCatching {733            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))734        }.getOrNull()735        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")736        frame as AdoptionFrame.Snapshot737        if (chunkCount == -1) {738            chunkCount = frame.chunkCount739            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {740                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")741            }742        } else if (frame.chunkCount != chunkCount) {743            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")744        }745        if (frame.chunkIndex != received) {746            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")747        }748        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)749        received++750    }751    return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)752}753754private fun isDeviceType(value: String): Boolean =755    DeviceType.entries.any { it.wireValue == value }