VaultAdopt.kt

37.1 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        /**49         * The receiver's Pro key. It rides the confirm frame — sent only after50         * the SAS comparison — rather than the first request: a peer that51         * controls the scanned invitation could otherwise read the key and walk52         * away without ever being authenticated (PB-26-121).53         */54        val license: String? = null,55    ) : AdoptionFrame()5657    @Serializable58    @SerialName("ADOPT_GRANT")59    data class Grant(60        @SerialName("root_key_hex") val rootKeyHex: String,61        @SerialName("vault_id") val vaultId: String,62        val license: String? = null,63        val source: Source,64        // v1-only receiver capability metadata.65        @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,66    ) : AdoptionFrame()6768    @Serializable69    @SerialName("ADOPT_IDENTITY")70    data class Identity(71        @SerialName("device_id") val deviceId: String,72        @SerialName("device_name") val deviceName: String,73        @SerialName("device_type") val deviceType: String,74        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,75        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,76    ) : AdoptionFrame()7778    /**79     * Encrypted source-to-receiver acknowledgement emitted only after the source80     * has durably recorded the receiver. The receiver cannot truthfully report a81     * successful pair before this arrives.82     */83    @Serializable84    @SerialName("ADOPT_COMPLETE")85    data object Complete : AdoptionFrame()8687    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */88    @Serializable89    @SerialName("ADOPT_APPLIED")90    data object Applied : AdoptionFrame()9192    /** Neither peer proved Pro, so the pairing must not commit. */93    @Serializable94    @SerialName("ADOPT_REJECT")95    data class Reject(96        val reason: String,97    ) : AdoptionFrame()9899    /**100     * One chunk of the inline vault snapshot sent by the source after101     * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are102     * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.103     */104    @Serializable105    @SerialName("ADOPT_SNAPSHOT")106    data class Snapshot(107        @SerialName("chunk_index") val chunkIndex: Int,108        @SerialName("chunk_count") val chunkCount: Int,109        val entries: Map<String, String>,110        val tombstones: Map<String, Long>,111        @SerialName("duress_slot") val duressSlot: String? = null,112    ) : AdoptionFrame()113114    /** Receiver acknowledgement sent after all snapshot chunks are applied. */115    @Serializable116    @SerialName("ADOPT_SNAPSHOT_APPLIED")117    data class SnapshotApplied(118        @SerialName("entries_received") val entriesReceived: Int,119        @SerialName("tombstones_received") val tombstonesReceived: Int,120    ) : AdoptionFrame()121122    /** Optional signed device graph appended after the snapshot (manifest v3+). */123    @Serializable124    @SerialName("ADOPT_DEVICE_MANIFEST")125    data class DeviceManifestFrame(126        val manifest: SignedDeviceManifest,127    ) : AdoptionFrame()128129    /** Receiver acknowledgement for an applied device manifest. */130    @Serializable131    @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")132    data object DeviceManifestApplied : AdoptionFrame()133    @Serializable134    @SerialName("ADOPT_REQUEST_V2")135    data class RequestV2(136        @SerialName("adoptionVersion") val adoptionVersion: Int,137        @SerialName("device_id") val deviceId: String,138        @SerialName("device_name") val deviceName: String,139        @SerialName("device_type") val deviceType: String,140        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,141        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String,142        val license: String? = null,143        @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,144    ) : AdoptionFrame()145146    @Serializable @SerialName("ADOPT_CONFIRM_REQUIRED")147    data object ConfirmRequired : AdoptionFrame()148149    @Serializable @SerialName("ADOPT_PREPARE")150    data class Prepare(151        @SerialName("device_id") val deviceId: String,152        @SerialName("device_name") val deviceName: String,153        @SerialName("device_type") val deviceType: String,154        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,155        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String,156        @SerialName("vault_id") val vaultId: String,157        @SerialName("ws_port") val wsPort: Int? = null,158        @SerialName("relay_url") val relayUrl: String? = null,159        val license: String? = null,160        @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,161    ) : AdoptionFrame()162    @Serializable @SerialName("ADOPT_READY")163    data object Ready : AdoptionFrame()164    @Serializable165    data class Source(166        @SerialName("device_id") val deviceId: String,167        @SerialName("device_name") val deviceName: String,168        @SerialName("device_type") val deviceType: String,169        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,170        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,171        @SerialName("ws_port") val wsPort: Int? = null,172        @SerialName("relay_url") val relayUrl: String? = null,173    )174}175176data class AdoptionPairState(177    val rendezvousSecretHex: String,178    val relayUrl: String? = null,179)180181/**182 * Receiver-side reassembly result from [receiveVaultSnapshot]. Carries the183 * aggregated entries and tombstones plus the optional browser duress slot184 * JSON (present on the final chunk only, when the source has duress configured).185 */186data class ReceivedSnapshot(187    val entries: Map<String, String>,188    val tombstones: Map<String, Long>,189    val duressSlot: String? = null,190)191192/**193 * Wire guardrails for the inline snapshot. A chunk's serialized entries map194 * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at195 * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver196 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).197 */198const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB199const val MAX_SNAPSHOT_CHUNKS = 200200201// Headroom added to the per-chunk entries budget when validating an encoded202// frame on the wire: JSON envelope plus the final chunk's tombstone map.203internal const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024204internal val MAX_PADDED_SNAPSHOT_FRAME_BYTES = SyncPadding.bucket(SNAPSHOT_FRAME_WIRE_CEILING + 4)205206/**207 * PB-26-206 aggregate budget shared by sender and receiver — exact mirror of208 * the browser's `vault-adopt.ts` ceilings. The per-frame guard above bounds209 * one frame; this bounds the whole transfer independently of the 200-frame210 * count, so many individually valid frames cannot OOM the receiver.211 */212const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 // 10 MiB213const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000214const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000215/** Same value as [SyncLimits.MAX_ID_LENGTH]; named here so vectors read identically on both clients. */216const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH217/**218 * Maximum encrypted entry envelope: plaintext ceiling plus the existing219 * base64 overhead allowance. Int arithmetic gives 699,114 — the browser's220 * float comparison accepts and rejects at exactly the same lengths.221 */222const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES223224/**225 * Running aggregate for one snapshot transfer. Counts ride alongside the maps226 * (never re-derived per frame) so the bound stays O(n).227 */228class AdoptSnapshotBudget {229    val entries = LinkedHashMap<String, String>()230    val tombstones = LinkedHashMap<String, Long>()231    var duressSlot: String? = null232    /** Sum of encoded wire-frame bytes consumed so far. */233    var bytes: Int = 0234    var entryCount: Int = 0235    var tombstoneCount: Int = 0236}237238fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()239240/**241 * PB-26-206 shared preflight/merge helper used by BOTH the sender (preflight242 * before the first frame goes out) and the receiver (per frame, after the243 * byte-budget check, before decoded maps are merged).244 *245 * One bounded pass per attacker-controlled map: each key/value is validated246 * and projected counts are enforced inside the same loop that merges, so the247 * maps are never iterated count→size→merge repeatedly. Throws before merging248 * the offending frame, leaving no partial adopted state.249 */250fun applyAdoptSnapshotFrame(251    acc: AdoptSnapshotBudget,252    entries: Map<String, String>,253    tombstones: Map<String, Long>,254    duressSlot: String?,255    frameBytes: Int,256) {257    if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {258        throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")259    }260    for ((id, envelope) in entries) {261        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")262        if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {263            throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")264        }265        if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {266            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")267        }268        acc.entryCount++269        acc.entries[id] = envelope270    }271    for ((id, ts) in tombstones) {272        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")273        // Long is always finite, so non-finite hostile JSON dies at the274        // strict codec decode before reaching this merge (see V7).275        if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {276            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")277        }278        acc.tombstoneCount++279        acc.tombstones[id] = ts280    }281    if (!duressSlot.isNullOrEmpty()) {282        // The duress slot rides inside wire frames already bounded by the283        // frame ceiling, and its raw bytes count against the aggregate above.284        if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {285            throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")286        }287        acc.duressSlot = duressSlot288    }289    acc.bytes += frameBytes290}291292/** What the RECEIVER (joining device) needs to adopt a vault from a source. */293interface AdoptionReceiverDeps {294    val deviceName: String295    val deviceType: DeviceType296    fun generateDeviceId(): String297    /** Root-key transfer cannot begin until this device confirms the SAS. */298    suspend fun confirmSas(): Boolean299    /**300     * Local safety policy evaluated after the authenticated grant is validated301     * but before any vault state changes.302     */303    suspend fun validateSource(source: AdoptionFrame.Source) {}304305    /**306     * True when the source's identity (from ADOPT_GRANT) is in the local307     * terminal revocation ledger. When true, the receiver sends308     * ADOPT_DEVICE_ID_COLLISION so the source rotates and resends the grant.309     */310    suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean = false311312    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */313    suspend fun createVaultFromRootKey(rootKeyHex: String): String314    /** Build the receiver's local identity after the adopted vault exists. */315    suspend fun deriveLocalIdentity(): LocalDeviceIdentity316    /** Persist the direct pairing and lift its terminal revocation as one store operation. */317    suspend fun upsertPairedDeviceAndClearRevocation(device: PairedDevice) {318        upsertPairedDevice(device)319        clearPairedDeviceRevocation(device.deviceId)320    }321    suspend fun clearPairedDeviceRevocation(deviceId: String) {}322    suspend fun upsertPairedDevice(device: PairedDevice)323    suspend fun applyLicense(license: String) {}324    /** Return this receiver's locally validated Pro key, if present. */325    suspend fun license(): String? = null326    val pairState: AdoptionPairState? get() = null327    fun onError(error: String)328    /** Apply encrypted entries + tombstones from the inline snapshot. */329    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}330    /**331     * Apply the source's signed device graph so the receiver graduates into the332     * mesh. The [authenticatedRemoteStaticPublicKey] is the source's Noise333     * transport static key (NOT the signing key); [applyIncomingDeviceManifest]334     * resolves it to the source's [PairedDevice] locally and uses the stored335     * signing key to verify the manifest signature. Default no-op preserves336     * the contract for callers that haven't wired the mesh path yet.337     */338    suspend fun applyIncomingDeviceManifest(339        manifest: SignedDeviceManifest,340        authenticatedRemoteStaticPublicKey: ByteArray,341    ) {}342    /** Mark the paired device as synced after a successful snapshot. */343    suspend fun markSynced(deviceId: String) {}344}345346/** What the SOURCE (existing device) needs to grant its vault to a receiver. */347interface AdoptionSourceDeps {348    val sourceIdentity: LocalDeviceIdentity349    suspend fun rootKeyHex(): String350    suspend fun vaultId(): String351    suspend fun license(): String? = null352    /** Validate and durably store a Pro key offered by the receiver. */353    suspend fun applyLicense(license: String) {}354    /**355     * Ask the user to approve handing this vault to the requesting device. The356     * request's name/type/label are for the human to read; the decision must not357     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which358     * carries no continuity between attempts.359     */360    suspend fun onConsent(request: AdoptionFrame.Request): Boolean361    suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long): Boolean = true362    suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice): Boolean = true363    suspend fun upsertPairedDevice(device: PairedDevice)364    fun hasDeviceIdentityCollision(device: PairedDevice): Boolean = false365    /**366     * Rotate the source's identity after the receiver reports a collision on367     * the source's own identity. Returns the fresh identity for the resent368     * ADOPT_GRANT.369     */370    suspend fun rotateSourceIdentity(): LocalDeviceIdentity = error("rotateSourceIdentity not wired")371    val pairState: AdoptionPairState? get() = null372    /** Return all encrypted entries + tombstones for the inline snapshot. */373    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null374    /** Return the signed device manifest for the graph, if available. */375    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null376    /** Mark the paired device as synced after a successful snapshot. */377    suspend fun markSynced(deviceId: String) {}378    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */379    suspend fun duressSlotJson(): String? = null380}381382internal val Hex64 = Regex("^[0-9a-f]{64}$")383384fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)385386fun isAdoptRequest(frame: AdoptionFrame?): Boolean =387    frame is AdoptionFrame.Request &&388        frame.sessionRequestId.isNotEmpty() &&389        isDeviceType(frame.deviceType)390391fun isAdoptGrant(frame: AdoptionFrame?): Boolean =392    frame is AdoptionFrame.Grant &&393        validateRootKeyHex(frame.rootKeyHex) &&394        Hex64.matches(frame.vaultId) &&395        isDeviceType(frame.source.deviceType) &&396        Hex64.matches(frame.source.longTermPublicKeyHex) &&397        (frame.source.relayUrl == null ||398            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)399400fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =401    frame is AdoptionFrame.Confirm402403fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =404    frame is AdoptionFrame.Identity &&405        frame.deviceId.isNotEmpty() &&406        isDeviceType(frame.deviceType) &&407        Hex64.matches(frame.longTermPublicKeyHex)408409fun isAdoptComplete(frame: AdoptionFrame?): Boolean =410    frame is AdoptionFrame.Complete411412fun isAdoptApplied(frame: AdoptionFrame?): Boolean =413    frame is AdoptionFrame.Applied414415fun isAdoptReject(frame: AdoptionFrame?): Boolean =416    frame is AdoptionFrame.Reject &&417        (frame.reason == "ADOPT_PRO_REQUIRED" || frame.reason == "ADOPT_DEVICE_ID_COLLISION")418419fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =420    frame is AdoptionFrame.Snapshot &&421        frame.chunkIndex >= 0 &&422        frame.chunkCount >= 1423424/**425 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the426 * granted root key actually derives the advertised vault id (catching a lying or427 * corrupted source before it can seal a mismatched vault), seals the local vault,428 * applies any carried license, returns our identity, and records the source as a429 * paired device. Returns null on any protocol failure (reason via [deps.onError]).430 */431suspend fun requestAdoption(432    channel: PairingTransport,433    deps: AdoptionReceiverDeps,434    // Called once after a valid ADOPT_GRANT is decoded and validated, before435    // any side effects (vault creation, paired-device upsert). Used by the436    // receiver to inspect the grant's snapshot_capable flag and skip the 15s437    // inline-snapshot wait when the source has nothing to send. Defaults to no-op438    // for callers (tests, other helpers) that don't need to inspect the grant.439    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,440    nowMs: () -> Long = { System.currentTimeMillis() },441): PairedDevice? {442    val receiverLicense = deps.license()443    // Fresh every attempt, and deliberately not our real device id: no identity444    // exists yet at this point in the protocol.445    sendFrame(channel, AdoptionFrame.Request(446        sessionRequestId = deps.generateDeviceId(),447        deviceName = deps.deviceName,448        deviceType = deps.deviceType.wireValue,449        snapshotCapable = true,450    ))451452    val confirmed = deps.confirmSas()453    // Sent only after the SAS decision (PB-26-121): a request-frame licence454    // reaches a peer the user has not authenticated yet.455    sendFrame(channel, AdoptionFrame.Confirm(confirmed, license = receiverLicense))456    if (!confirmed) {457        deps.onError("ADOPT_SAS_REJECTED")458        return null459    }460461    val frame = receiveFrame(channel)462    if (isAdoptReject(frame)) {463        deps.onError((frame as AdoptionFrame.Reject).reason)464        return null465    }466    if (!isAdoptGrant(frame)) {467        deps.onError("ADOPT_UNEXPECTED_FRAME")468        return null469    }470    val grant = frame as AdoptionFrame.Grant471    onGrant?.invoke(grant)472473    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)474    try {475        if (computeVaultId(rootKeyBytes) != grant.vaultId) {476            deps.onError("ADOPT_VAULT_ID_MISMATCH")477            return null478        }479    } finally {480        rootKeyBytes.fill(0)481    }482    if (receiverLicense == null && grant.license == null) {483        deps.onError("ADOPT_PRO_REQUIRED")484        return null485    }486487    var acceptedGrant = grant488    var acceptedSource = grant.source ?: run {489        deps.onError("ADOPT_UNEXPECTED_FRAME")490        return null491    }492    deps.validateSource(acceptedSource)493494    // Check the source's identity against the local revocation ledger. A495    // previously-revoked device must present a fresh identity so that stale496    // signed revocations held by other mesh peers cannot remove it after497    // re-pair. If the source's identity is revoked, send ADOPT_DEVICE_ID_COLLISION498    // and wait for a fresh grant (one retry).499    if (deps.hasSourceIdentityCollision(acceptedSource)) {500        sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))501        val retry = receiveFrame(channel)502        if (!isAdoptGrant(retry)) {503            deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED")504            return null505        }506        acceptedGrant = retry as AdoptionFrame.Grant507        acceptedSource = acceptedGrant.source ?: run {508            deps.onError("ADOPT_UNEXPECTED_FRAME")509            return null510        }511        if (deps.hasSourceIdentityCollision(acceptedSource)) {512            sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))513            deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED")514            return null515        }516        // Re-validate the fresh grant's vault id.517        val freshRootBytes = hexToPubkey(acceptedGrant.rootKeyHex)518        try {519            if (computeVaultId(freshRootBytes) != acceptedGrant.vaultId) {520                deps.onError("ADOPT_VAULT_ID_MISMATCH")521                return null522            }523        } finally {524            freshRootBytes.fill(0)525        }526    }527    if (receiverLicense == null && acceptedGrant.license == null) {528        deps.onError("ADOPT_PRO_REQUIRED")529        return null530    }531532533    if (receiverLicense == null) {534        acceptedGrant.license?.let { deps.applyLicense(it) }535    }536    val vaultId = deps.createVaultFromRootKey(acceptedGrant.rootKeyHex)537    if (vaultId != acceptedGrant.vaultId) {538        deps.onError("ADOPT_VAULT_ID_MISMATCH")539        return null540    }541542543    val identity = deps.deriveLocalIdentity()544    sendFrame(channel, AdoptionFrame.Identity(545        deviceId = identity.deviceId,546        deviceName = identity.deviceName,547        deviceType = identity.deviceType.wireValue,548        longTermPublicKeyHex = identity.longTermPublicKeyHex,549        signingPublicKeyHex = identity.signingPublicKeyHex,550    ))551552    if (!isAdoptComplete(receiveFrame(channel))) {553        deps.onError("ADOPT_COMPLETION_MISSING")554        return null555    }556557    val sourceDevice = PairedDevice(558        deviceId = acceptedSource.deviceId,559        deviceName = acceptedSource.deviceName,560        deviceType = acceptedSource.deviceType,561        longTermPublicKeyHex = acceptedSource.longTermPublicKeyHex,562        signingPublicKeyHex = acceptedSource.signingPublicKeyHex,563        trustSource = "direct",564        wsPort = acceptedSource.wsPort,565        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,566        relayUrl = deps.pairState?.relayUrl ?: acceptedSource.relayUrl,567        pairedAt = nowMs(),568    )569    // A physically confirmed direct re-pair is the sole path allowed to lift570    // terminal revocation. The registry validates identity before either value571    // is changed, so a mismatch leaves the revocation intact.572    deps.upsertPairedDeviceAndClearRevocation(sourceDevice)573    sendFrame(channel, AdoptionFrame.Applied)574    return sourceDevice575}576577/**578 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the579 * root key, then records the receiver's returned identity as a paired device.580 * Returns null if consent is denied or the receiver's identity frame is invalid.581 */582suspend fun grantAdoption(583    channel: PairingTransport,584    request: AdoptionFrame.Request,585    deps: AdoptionSourceDeps,586    nowMs: () -> Long = { System.currentTimeMillis() },587): PairedDevice? {588    if (!deps.onConsent(request)) return null589590    val confirmation = receiveFrame(channel)591    val confirmedFrame = confirmation as AdoptionFrame.Confirm592    if (!isAdoptConfirm(confirmation) || !confirmedFrame.confirmed) {593        return null594    }595596    var sourceLicense = deps.license()597    // The receiver's key arrives on the confirm frame, after both SAS decisions598    // (PB-26-121). request.license is read only for a peer built before that599    // change.600    val receiverLicense = confirmedFrame.license ?: request.license601    if (sourceLicense == null && receiverLicense != null) {602        sourceLicense = runCatching {603            deps.applyLicense(receiverLicense)604            deps.license()605        }.getOrNull()606    }607    if (sourceLicense == null) {608        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))609        throw IllegalStateException("ADOPT_PRO_REQUIRED")610    }611612    if (!deps.persistPendingHolder(request, nowMs())) return null613    val rootKeyHex = deps.rootKeyHex()614    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")615616    var sourceIdentity = deps.sourceIdentity617    var grantAttempts = 0618    while (true) {619        sendFrame(channel, AdoptionFrame.Grant(620            rootKeyHex = rootKeyHex,621            vaultId = deps.vaultId(),622            license = sourceLicense,623            source = AdoptionFrame.Source(624                deviceId = sourceIdentity.deviceId,625                deviceName = sourceIdentity.deviceName,626                deviceType = sourceIdentity.deviceType.wireValue,627                longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,628                signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,629                wsPort = sourceIdentity.wsPort,630                relayUrl = deps.pairState?.relayUrl,631            ),632            snapshotCapable = request.snapshotCapable == true,633        ))634635        var identity = receiveFrame(channel)636637        // Receiver may reject the source's identity if it is in the receiver's638        // revocation ledger. Rotate and resend (one retry).639        if (identity is AdoptionFrame.Reject &&640            identity.reason == "ADOPT_DEVICE_ID_COLLISION" &&641            grantAttempts == 0642        ) {643            sourceIdentity = deps.rotateSourceIdentity()644            grantAttempts++645            continue646        }647648        if (!isAdoptIdentity(identity)) return null649        identity as AdoptionFrame.Identity650651        var device = PairedDevice(652            deviceId = identity.deviceId,653            deviceName = identity.deviceName,654            deviceType = identity.deviceType,655            longTermPublicKeyHex = identity.longTermPublicKeyHex,656            signingPublicKeyHex = identity.signingPublicKeyHex,657            trustSource = "direct",658            rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,659            relayUrl = deps.pairState?.relayUrl,660            pairedAt = nowMs(),661        )662        if (deps.hasDeviceIdentityCollision(device)) {663            sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))664            identity = receiveFrame(channel)665            if (!isAdoptIdentity(identity)) return null666            identity as AdoptionFrame.Identity667            device = device.copy(668                deviceId = identity.deviceId,669                deviceName = identity.deviceName,670                deviceType = identity.deviceType,671                longTermPublicKeyHex = identity.longTermPublicKeyHex,672                signingPublicKeyHex = identity.signingPublicKeyHex,673            )674            if (deps.hasDeviceIdentityCollision(device)) {675                sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))676                return null677            }678        }679        deps.upsertPairedDevice(device)680        sendFrame(channel, AdoptionFrame.Complete)681        if (!isAdoptApplied(receiveFrame(channel))) return null682        if (!deps.completePendingHolder(request, device)) return null683        return device684    }685}686687/**688 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat689 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent690 * optionals exactly like `JSON.stringify` drops `undefined`.691 */692val AdoptJson: Json = Json {693    classDiscriminator = "type"694    encodeDefaults = false695    ignoreUnknownKeys = true696}697698suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {699    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))700}701702/** Decode one frame, tolerating a malformed/unknown payload by returning null. */703suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =704    runCatching {705        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))706    }.getOrNull()707708/**709 * Source side: chunk the vault snapshot and send it over the pairing channel.710 *711 * Wire shape (v2.9.9+, supplemental frame; mirrors browser v4.6.4):712 *   - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries.713 *   - One supplemental frame always follows the entries. It carries the714 *     tombstones and, when configured, the duress slot. Sending the same frame715 *     count in both cases prevents a relay from learning duress presence.716 *     Keeping duress off the entries frame also prevents a sizeable sealed717 *     duress vault from pushing the entries frame over the wire ceiling.718 *719 * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200):720 *   - ONE of the 200 frames is always reserved for the supplemental frame;721 *     entries use at most 199 chunks. Excess entries are deferred to the sync722 *     fallback pipeline (sync carries entries and tombstones on Android too,723 *     so this is a graceful truncation). The supplemental frame is required724 *     even without duress so its presence cannot disclose configuration. When725 *     configured, it carries the duress slot; omitting it would leave the726 *     adopted device permanently without its duress vault.727 *728 * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]729 * is rejected with a thrown error BEFORE any snapshot frame is sent. Hard stop;730 * the caller must split duress out of band. We do not silently truncate731 * duress; sync cannot restore it.732 *733 * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries734 * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a735 * duress-only frame has empty `entries` and the complete tombstone map),736 * so both shapes decode and both new->old / old->new directions interoperate.737 */738suspend fun sendVaultSnapshot(739    channel: PairingTransport,740    entries: Map<String, String>,741    tombstones: Map<String, Long>,742    duressSlot: String? = null,743) {744    val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)745    // PB-26-206 preflight with the SAME helper the receiver enforces: nothing746    // is sent until every frame fits the aggregate budget, so an over-budget747    // snapshot is never partially sent or silently truncated. The caller falls748    // through to the regular sync pipeline (deny-only: no wire-field change,749    // invitation prefix unchanged).750    val acc = createAdoptSnapshotBudget()751    val encoded = frames.map {752        AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()753    }754    for (i in frames.indices) {755        val frame = frames[i]756        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)757    }758    for (raw in encoded) channel.send(SyncPadding.pad(raw))759}760761/**762 * Pure frame construction for [sendVaultSnapshot]: chunking, the duress-frame763 * reservation, and the oversized-duress hard stop. No I/O happens here, so764 * the preflight above can reject before the first byte goes out.765 */766private fun buildAdoptSnapshotFrames(767    entries: Map<String, String>,768    tombstones: Map<String, Long>,769    duressSlot: String?,770): List<AdoptionFrame.Snapshot> {771    val includeDuress = !duressSlot.isNullOrBlank()772    // Always reserve the supplemental frame so its presence cannot disclose773    // whether the source has a duress vault.774    val entryCap = MAX_SNAPSHOT_CHUNKS - 1775776    val chunks = mutableListOf<Map<String, String>>()777    var current = LinkedHashMap<String, String>()778    var currentBytes = 2 // "{}"779    for ((id, envelope) in entries) {780        // +1 for the separating comma; an overestimate only shrinks chunks.781        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1782        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {783            chunks.add(current)784            current = LinkedHashMap()785            currentBytes = 2786        }787        current[id] = envelope788        currentBytes += entryBytes789    }790    chunks.add(current)791792    val entriesChunkCount = minOf(chunks.size, entryCap)793    val totalChunks = entriesChunkCount + 1794    val supplementalFrame = AdoptionFrame.Snapshot(795        chunkIndex = entriesChunkCount,796        chunkCount = totalChunks,797        entries = emptyMap(),798        tombstones = tombstones,799        duressSlot = duressSlot.takeIf { includeDuress },800    )801802    if (803        AdoptJson.encodeToString(AdoptionFrame.serializer(), supplementalFrame)804            .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING805    ) {806        if (includeDuress) throw SnapshotDuressFrameTooLargeException()807        throw IllegalStateException("ADOPT_SNAPSHOT_SUPPLEMENTAL_FRAME_TOO_LARGE")808    }809810    val frames = mutableListOf<AdoptionFrame.Snapshot>()811    for (index in 0 until entriesChunkCount) {812        frames.add(AdoptionFrame.Snapshot(813            chunkIndex = index,814            chunkCount = totalChunks,815            entries = chunks[index],816            tombstones = emptyMap(),817            duressSlot = null,818        ))819    }820    frames.add(supplementalFrame)821    return frames822}823824internal class SnapshotDuressFrameTooLargeException :825    IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")826827/**828 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame829 * bytes to the aggregate counter before decoding (rejecting before JSON830 * deserialization when the next frame exceeds the remaining budget), then831 * enforces the per-frame wire ceiling, sequential 0-based chunk_index832 * ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and structural833 * validity of each frame. Merging goes through the shared aggregate helper:834 * every key/value and projected count is validated in one bounded pass before835 * anything lands in the accumulator, so a rejection leaves no partial adopted836 * state and the caller can fall back to the sync pipeline.837 */838suspend fun receiveVaultSnapshot(839    channel: PairingTransport,840): ReceivedSnapshot {841    val acc = createAdoptSnapshotBudget()842    var chunkCount = -1843    var received = 0844    while (chunkCount == -1 || received < chunkCount) {845        val wire = channel.receive()846        if (wire.size > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {847            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")848        }849        val raw = runCatching { SyncPadding.unpad(wire) }850            .getOrElse { throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME", it) }851        if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {852            throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")853        }854        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {855            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")856        }857        val frame = runCatching {858            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))859        }.getOrNull()860        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")861        frame as AdoptionFrame.Snapshot862        if (chunkCount == -1) {863            chunkCount = frame.chunkCount864            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {865                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")866            }867        } else if (frame.chunkCount != chunkCount) {868            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")869        }870        if (frame.chunkIndex != received) {871            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")872        }873        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)874        received++875    }876    return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)877}878879fun isDeviceType(value: String): Boolean =880    DeviceType.entries.any { it.wireValue == value }