VaultAdopt.kt

22.3 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        // Always sent as explicit true or false (never omitted) so receivers can rely60        // on the field's presence as a deliberate answer. Pre-C sources default to61        // false here and receivers fall back to the sync pipeline immediately.62        @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,63    ) : AdoptionFrame()6465    @Serializable66    @SerialName("ADOPT_IDENTITY")67    data class Identity(68        @SerialName("device_id") val deviceId: String,69        @SerialName("device_name") val deviceName: String,70        @SerialName("device_type") val deviceType: String,71        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,72        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,73    ) : AdoptionFrame()7475    /**76     * Encrypted source-to-receiver acknowledgement emitted only after the source77     * has durably recorded the receiver. The receiver cannot truthfully report a78     * successful pair before this arrives.79     */80    @Serializable81    @SerialName("ADOPT_COMPLETE")82    data object Complete : AdoptionFrame()8384    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */85    @Serializable86    @SerialName("ADOPT_APPLIED")87    data object Applied : AdoptionFrame()8889    /** Neither peer proved Pro, so the pairing must not commit. */90    @Serializable91    @SerialName("ADOPT_REJECT")92    data class Reject(93        val reason: String,94    ) : AdoptionFrame()9596    /**97     * One chunk of the inline vault snapshot sent by the source after98     * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are99     * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.100     */101    @Serializable102    @SerialName("ADOPT_SNAPSHOT")103    data class Snapshot(104        @SerialName("chunk_index") val chunkIndex: Int,105        @SerialName("chunk_count") val chunkCount: Int,106        val entries: Map<String, String>,107        val tombstones: Map<String, Long>,108        @SerialName("duress_slot") val duressSlot: String? = null,109    ) : AdoptionFrame()110111    /** Receiver acknowledgement sent after all snapshot chunks are applied. */112    @Serializable113    @SerialName("ADOPT_SNAPSHOT_APPLIED")114    data class SnapshotApplied(115        @SerialName("entries_received") val entriesReceived: Int,116        @SerialName("tombstones_received") val tombstonesReceived: Int,117    ) : AdoptionFrame()118119    /** Optional signed device graph appended after the snapshot (manifest v3+). */120    @Serializable121    @SerialName("ADOPT_DEVICE_MANIFEST")122    data class DeviceManifestFrame(123        val manifest: SignedDeviceManifest,124    ) : AdoptionFrame()125126    /** Receiver acknowledgement for an applied device manifest. */127    @Serializable128    @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")129    data object DeviceManifestApplied : AdoptionFrame()130131    @Serializable132    data class Source(133        @SerialName("device_id") val deviceId: String,134        @SerialName("device_name") val deviceName: String,135        @SerialName("device_type") val deviceType: String,136        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,137        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,138        @SerialName("ws_port") val wsPort: Int? = null,139        @SerialName("relay_url") val relayUrl: String? = null,140    )141}142143data class AdoptionPairState(144    val rendezvousSecretHex: String,145    val relayUrl: String? = null,146)147148/**149 * Receiver-side reassembly result from [receiveVaultSnapshot]. Carries the150 * aggregated entries and tombstones plus the optional browser duress slot151 * JSON (present on the final chunk only, when the source has duress configured).152 */153data class ReceivedSnapshot(154    val entries: Map<String, String>,155    val tombstones: Map<String, Long>,156    val duressSlot: String? = null,157)158159/**160 * Wire guardrails for the inline snapshot. A chunk's serialized entries map161 * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at162 * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver163 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).164 */165const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB166const val MAX_SNAPSHOT_CHUNKS = 200167168// Headroom added to the per-chunk entries budget when validating an encoded169// frame on the wire: JSON envelope plus the final chunk's tombstone map.170private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024171172/** What the RECEIVER (joining device) needs to adopt a vault from a source. */173interface AdoptionReceiverDeps {174    val deviceName: String175    val deviceType: DeviceType176    fun generateDeviceId(): String177    /** Root-key transfer cannot begin until this device confirms the SAS. */178    suspend fun confirmSas(): Boolean179    /**180     * Local safety policy evaluated after the authenticated grant is validated181     * but before any vault state changes.182     */183    suspend fun validateSource(source: AdoptionFrame.Source) {}184185    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */186    suspend fun createVaultFromRootKey(rootKeyHex: String): String187    suspend fun deriveLocalIdentity(): LocalDeviceIdentity188    /** Only called after a successful, SAS-confirmed direct pairing. */189    suspend fun clearPairedDeviceRevocation(deviceId: String) {}190    suspend fun upsertPairedDevice(device: PairedDevice)191    suspend fun applyLicense(license: String) {}192    /** Return this receiver's locally validated Pro key, if present. */193    suspend fun license(): String? = null194    val pairState: AdoptionPairState? get() = null195    fun onError(error: String)196    /** Apply encrypted entries + tombstones from the inline snapshot. */197    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}198    /** Mark the paired device as synced after a successful snapshot. */199    suspend fun markSynced(deviceId: String) {}200}201202/** What the SOURCE (existing device) needs to grant its vault to a receiver. */203interface AdoptionSourceDeps {204    val sourceIdentity: LocalDeviceIdentity205    suspend fun rootKeyHex(): String206    suspend fun vaultId(): String207    suspend fun license(): String? = null208    /** Validate and durably store a Pro key offered by the receiver. */209    suspend fun applyLicense(license: String) {}210211    /**212     * Ask the user to approve handing this vault to the requesting device. The213     * request's name/type/label are for the human to read; the decision must not214     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which215     * carries no continuity between attempts.216     */217    suspend fun onConsent(request: AdoptionFrame.Request): Boolean218    suspend fun upsertPairedDevice(device: PairedDevice)219    val pairState: AdoptionPairState? get() = null220    /** Return all encrypted entries + tombstones for the inline snapshot. */221    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null222    /** Return the signed device manifest for the graph, if available. */223    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null224    /** Mark the paired device as synced after a successful snapshot. */225    suspend fun markSynced(deviceId: String) {}226    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */227    suspend fun duressSlotJson(): String? = null228}229230private val Hex64 = Regex("^[0-9a-f]{64}\$")231232fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)233234fun isAdoptRequest(frame: AdoptionFrame?): Boolean =235    frame is AdoptionFrame.Request &&236        frame.sessionRequestId.isNotEmpty() &&237        isDeviceType(frame.deviceType)238239fun isAdoptGrant(frame: AdoptionFrame?): Boolean =240    frame is AdoptionFrame.Grant &&241        validateRootKeyHex(frame.rootKeyHex) &&242        Hex64.matches(frame.vaultId) &&243        isDeviceType(frame.source.deviceType) &&244        Hex64.matches(frame.source.longTermPublicKeyHex) &&245        (frame.source.relayUrl == null ||246            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)247248fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =249    frame is AdoptionFrame.Confirm250251fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =252    frame is AdoptionFrame.Identity &&253        frame.deviceId.isNotEmpty() &&254        isDeviceType(frame.deviceType) &&255        Hex64.matches(frame.longTermPublicKeyHex)256257fun isAdoptComplete(frame: AdoptionFrame?): Boolean =258    frame is AdoptionFrame.Complete259260fun isAdoptApplied(frame: AdoptionFrame?): Boolean =261    frame is AdoptionFrame.Applied262263fun isAdoptReject(frame: AdoptionFrame?): Boolean =264    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"265266fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =267    frame is AdoptionFrame.Snapshot &&268        frame.chunkIndex >= 0 &&269        frame.chunkCount >= 1270271/**272 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the273 * granted root key actually derives the advertised vault id (catching a lying or274 * corrupted source before it can seal a mismatched vault), seals the local vault,275 * applies any carried license, returns our identity, and records the source as a276 * paired device. Returns null on any protocol failure (reason via [deps.onError]).277 */278suspend fun requestAdoption(279    channel: PairingTransport,280    deps: AdoptionReceiverDeps,281    // Called once after a valid ADOPT_GRANT is decoded and validated, before282    // any side effects (vault creation, paired-device upsert). Used by the283    // receiver to inspect the grant's snapshot_capable flag and skip the 15s284    // inline-snapshot wait when the source has nothing to send. Defaults to no-op285    // for callers (tests, other helpers) that don't need to inspect the grant.286    onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,287    nowMs: () -> Long = { System.currentTimeMillis() },288): PairedDevice? {289    val receiverLicense = deps.license()290    // Fresh every attempt, and deliberately not our real device id: no identity291    // exists yet at this point in the protocol.292    sendFrame(channel, AdoptionFrame.Request(293        sessionRequestId = deps.generateDeviceId(),294        deviceName = deps.deviceName,295        deviceType = deps.deviceType.wireValue,296        license = receiverLicense,297        snapshotCapable = true,298    ))299300    val confirmed = deps.confirmSas()301    sendFrame(channel, AdoptionFrame.Confirm(confirmed))302    if (!confirmed) {303        deps.onError("ADOPT_SAS_REJECTED")304        return null305    }306307    val frame = receiveFrame(channel)308    if (isAdoptReject(frame)) {309        deps.onError((frame as AdoptionFrame.Reject).reason)310        return null311    }312    if (!isAdoptGrant(frame)) {313        deps.onError("ADOPT_UNEXPECTED_FRAME")314        return null315    }316    val grant = frame as AdoptionFrame.Grant317    onGrant?.invoke(grant)318319    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)320    try {321        if (computeVaultId(rootKeyBytes) != grant.vaultId) {322            deps.onError("ADOPT_VAULT_ID_MISMATCH")323            return null324        }325    } finally {326        rootKeyBytes.fill(0)327    }328    if (receiverLicense == null && grant.license == null) {329        deps.onError("ADOPT_PRO_REQUIRED")330        return null331    }332333    deps.validateSource(grant.source)334    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)335    if (vaultId != grant.vaultId) {336        deps.onError("ADOPT_VAULT_ID_MISMATCH")337        return null338    }339340    if (receiverLicense == null) {341        grant.license?.let { deps.applyLicense(it) }342    }343344    val identity = deps.deriveLocalIdentity()345    sendFrame(channel, AdoptionFrame.Identity(346        deviceId = identity.deviceId,347        deviceName = identity.deviceName,348        deviceType = identity.deviceType.wireValue,349        longTermPublicKeyHex = identity.longTermPublicKeyHex,350        signingPublicKeyHex = identity.signingPublicKeyHex,351    ))352353    if (!isAdoptComplete(receiveFrame(channel))) {354        deps.onError("ADOPT_COMPLETION_MISSING")355        return null356    }357358    val sourceDevice = PairedDevice(359        deviceId = grant.source.deviceId,360        deviceName = grant.source.deviceName,361        deviceType = grant.source.deviceType,362        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,363        signingPublicKeyHex = grant.source.signingPublicKeyHex,364        trustSource = "direct",365        wsPort = grant.source.wsPort,366        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,367        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,368        pairedAt = nowMs(),369    )370    // A physically confirmed direct re-pair is the sole path allowed to lift371    // terminal revocation. Clear before upsert so storage can never expose the372    // source as both paired and revoked, even transiently.373    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)374    deps.upsertPairedDevice(sourceDevice)375    sendFrame(channel, AdoptionFrame.Applied)376    return sourceDevice377}378379/**380 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the381 * root key, then records the receiver's returned identity as a paired device.382 * Returns null if consent is denied or the receiver's identity frame is invalid.383 */384suspend fun grantAdoption(385    channel: PairingTransport,386    request: AdoptionFrame.Request,387    deps: AdoptionSourceDeps,388    nowMs: () -> Long = { System.currentTimeMillis() },389): PairedDevice? {390    if (!deps.onConsent(request)) return null391392    val confirmation = receiveFrame(channel)393    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {394        return null395    }396397    var sourceLicense = deps.license()398    if (sourceLicense == null && request.license != null) {399        sourceLicense = runCatching {400            deps.applyLicense(request.license)401            deps.license()402        }.getOrNull()403    }404    if (sourceLicense == null) {405        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))406        throw IllegalStateException("ADOPT_PRO_REQUIRED")407    }408409    val rootKeyHex = deps.rootKeyHex()410    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")411412    sendFrame(channel, AdoptionFrame.Grant(413        rootKeyHex = rootKeyHex,414        vaultId = deps.vaultId(),415        license = sourceLicense,416        source = AdoptionFrame.Source(417            deviceId = deps.sourceIdentity.deviceId,418            deviceName = deps.sourceIdentity.deviceName,419            deviceType = deps.sourceIdentity.deviceType.wireValue,420            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,421            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,422            wsPort = deps.sourceIdentity.wsPort,423            relayUrl = deps.pairState?.relayUrl,424        ),425        snapshotCapable = request.snapshotCapable == true,426    ))427428    val identity = receiveFrame(channel)429    if (!isAdoptIdentity(identity)) return null430    identity as AdoptionFrame.Identity431432    val device = PairedDevice(433        deviceId = identity.deviceId,434        deviceName = identity.deviceName,435        deviceType = identity.deviceType,436        longTermPublicKeyHex = identity.longTermPublicKeyHex,437        signingPublicKeyHex = identity.signingPublicKeyHex,438        trustSource = "direct",439        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,440        relayUrl = deps.pairState?.relayUrl,441        pairedAt = nowMs(),442    )443    deps.upsertPairedDevice(device)444    sendFrame(channel, AdoptionFrame.Complete)445    if (!isAdoptApplied(receiveFrame(channel))) return null446    return device447}448449/**450 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat451 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent452 * optionals exactly like `JSON.stringify` drops `undefined`.453 */454val AdoptJson: Json = Json {455    classDiscriminator = "type"456    encodeDefaults = false457    ignoreUnknownKeys = true458}459460suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {461    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))462}463464/** Decode one frame, tolerating a malformed/unknown payload by returning null. */465suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =466    runCatching {467        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))468    }.getOrNull()469470/**471 * Source side: chunk the vault snapshot and send it over the pairing channel.472 * Entries are split so each chunk's serialized entries map stays under473 * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault474 * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are475 * sent and the fallback sync pipeline fills the remainder.476 */477suspend fun sendVaultSnapshot(478    channel: PairingTransport,479    entries: Map<String, String>,480    tombstones: Map<String, Long>,481    duressSlot: String? = null,482) {483    val chunks = mutableListOf<Map<String, String>>()484    var current = LinkedHashMap<String, String>()485    var currentBytes = 2 // "{}"486    for ((id, envelope) in entries) {487        // +1 for the separating comma; an overestimate only shrinks chunks.488        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1489        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {490            chunks.add(current)491            current = LinkedHashMap()492            currentBytes = 2493        }494        current[id] = envelope495        currentBytes += entryBytes496    }497    chunks.add(current)498499    val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)500    for (index in 0 until chunkCount) {501        val isFinal = index == chunkCount - 1502        sendFrame(channel, AdoptionFrame.Snapshot(503            chunkIndex = index,504            chunkCount = chunkCount,505            entries = chunks[index],506            tombstones = if (isFinal) tombstones else emptyMap(),507            duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,508        ))509    }510}511512/**513 * Receiver side: receive and reassemble every snapshot chunk. Validates the514 * encoded frame size before parsing, then enforces sequential 0-based515 * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and516 * structural validity of each frame. Throws on any validation failure so the517 * caller can fall back to the sync pipeline.518 */519suspend fun receiveVaultSnapshot(520    channel: PairingTransport,521): ReceivedSnapshot {522    val entries = LinkedHashMap<String, String>()523    val tombstones = LinkedHashMap<String, Long>()524    var duressSlot: String? = null525    var chunkCount = -1526    var received = 0527    while (chunkCount == -1 || received < chunkCount) {528        val raw = channel.receive()529        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {530            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")531        }532        val frame = runCatching {533            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))534        }.getOrNull()535        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")536        frame as AdoptionFrame.Snapshot537        if (chunkCount == -1) {538            chunkCount = frame.chunkCount539            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {540                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")541            }542        } else if (frame.chunkCount != chunkCount) {543            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")544        }545        if (frame.chunkIndex != received) {546            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")547        }548        entries.putAll(frame.entries)549        tombstones.putAll(frame.tombstones)550        if (frame.duressSlot != null) duressSlot = frame.duressSlot551        received++552    }553    return ReceivedSnapshot(entries, tombstones, duressSlot)554}555556private fun isDeviceType(value: String): Boolean =557    DeviceType.entries.any { it.wireValue == value }