VaultAdopt.kt

21.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    ) : AdoptionFrame()5859    @Serializable60    @SerialName("ADOPT_IDENTITY")61    data class Identity(62        @SerialName("device_id") val deviceId: String,63        @SerialName("device_name") val deviceName: String,64        @SerialName("device_type") val deviceType: String,65        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,66        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,67    ) : AdoptionFrame()6869    /**70     * Encrypted source-to-receiver acknowledgement emitted only after the source71     * has durably recorded the receiver. The receiver cannot truthfully report a72     * successful pair before this arrives.73     */74    @Serializable75    @SerialName("ADOPT_COMPLETE")76    data object Complete : AdoptionFrame()7778    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */79    @Serializable80    @SerialName("ADOPT_APPLIED")81    data object Applied : AdoptionFrame()8283    /** Neither peer proved Pro, so the pairing must not commit. */84    @Serializable85    @SerialName("ADOPT_REJECT")86    data class Reject(87        val reason: String,88    ) : AdoptionFrame()8990    /**91     * One chunk of the inline vault snapshot sent by the source after92     * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are93     * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.94     */95    @Serializable96    @SerialName("ADOPT_SNAPSHOT")97    data class Snapshot(98        @SerialName("chunk_index") val chunkIndex: Int,99        @SerialName("chunk_count") val chunkCount: Int,100        val entries: Map<String, String>,101        val tombstones: Map<String, Long>,102        @SerialName("duress_slot") val duressSlot: String? = null,103    ) : AdoptionFrame()104105    /** Receiver acknowledgement sent after all snapshot chunks are applied. */106    @Serializable107    @SerialName("ADOPT_SNAPSHOT_APPLIED")108    data class SnapshotApplied(109        @SerialName("entries_received") val entriesReceived: Int,110        @SerialName("tombstones_received") val tombstonesReceived: Int,111    ) : AdoptionFrame()112113    /** Optional signed device graph appended after the snapshot (manifest v3+). */114    @Serializable115    @SerialName("ADOPT_DEVICE_MANIFEST")116    data class DeviceManifestFrame(117        val manifest: SignedDeviceManifest,118    ) : AdoptionFrame()119120    /** Receiver acknowledgement for an applied device manifest. */121    @Serializable122    @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")123    data object DeviceManifestApplied : AdoptionFrame()124125    @Serializable126    data class Source(127        @SerialName("device_id") val deviceId: String,128        @SerialName("device_name") val deviceName: String,129        @SerialName("device_type") val deviceType: String,130        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,131        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,132        @SerialName("ws_port") val wsPort: Int? = null,133        @SerialName("relay_url") val relayUrl: String? = null,134    )135}136137data class AdoptionPairState(138    val rendezvousSecretHex: String,139    val relayUrl: String? = null,140)141142/**143 * Receiver-side reassembly result from [receiveVaultSnapshot]. Carries the144 * aggregated entries and tombstones plus the optional browser duress slot145 * JSON (present on the final chunk only, when the source has duress configured).146 */147data class ReceivedSnapshot(148    val entries: Map<String, String>,149    val tombstones: Map<String, Long>,150    val duressSlot: String? = null,151)152153/**154 * Wire guardrails for the inline snapshot. A chunk's serialized entries map155 * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at156 * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver157 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).158 */159const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB160const val MAX_SNAPSHOT_CHUNKS = 200161162// Headroom added to the per-chunk entries budget when validating an encoded163// frame on the wire: JSON envelope plus the final chunk's tombstone map.164private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024165166/** What the RECEIVER (joining device) needs to adopt a vault from a source. */167interface AdoptionReceiverDeps {168    val deviceName: String169    val deviceType: DeviceType170    fun generateDeviceId(): String171    /** Root-key transfer cannot begin until this device confirms the SAS. */172    suspend fun confirmSas(): Boolean173    /**174     * Local safety policy evaluated after the authenticated grant is validated175     * but before any vault state changes.176     */177    suspend fun validateSource(source: AdoptionFrame.Source) {}178179    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */180    suspend fun createVaultFromRootKey(rootKeyHex: String): String181    suspend fun deriveLocalIdentity(): LocalDeviceIdentity182    /** Only called after a successful, SAS-confirmed direct pairing. */183    suspend fun clearPairedDeviceRevocation(deviceId: String) {}184    suspend fun upsertPairedDevice(device: PairedDevice)185    suspend fun applyLicense(license: String) {}186    /** Return this receiver's locally validated Pro key, if present. */187    suspend fun license(): String? = null188    val pairState: AdoptionPairState? get() = null189    fun onError(error: String)190    /** Apply encrypted entries + tombstones from the inline snapshot. */191    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}192    /** Mark the paired device as synced after a successful snapshot. */193    suspend fun markSynced(deviceId: String) {}194}195196/** What the SOURCE (existing device) needs to grant its vault to a receiver. */197interface AdoptionSourceDeps {198    val sourceIdentity: LocalDeviceIdentity199    suspend fun rootKeyHex(): String200    suspend fun vaultId(): String201    suspend fun license(): String? = null202    /** Validate and durably store a Pro key offered by the receiver. */203    suspend fun applyLicense(license: String) {}204205    /**206     * Ask the user to approve handing this vault to the requesting device. The207     * request's name/type/label are for the human to read; the decision must not208     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which209     * carries no continuity between attempts.210     */211    suspend fun onConsent(request: AdoptionFrame.Request): Boolean212    suspend fun upsertPairedDevice(device: PairedDevice)213    val pairState: AdoptionPairState? get() = null214    /** Return all encrypted entries + tombstones for the inline snapshot. */215    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null216    /** Return the signed device manifest for the graph, if available. */217    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null218    /** Mark the paired device as synced after a successful snapshot. */219    suspend fun markSynced(deviceId: String) {}220    /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */221    suspend fun duressSlotJson(): String? = null222}223224private val Hex64 = Regex("^[0-9a-f]{64}\$")225226fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)227228fun isAdoptRequest(frame: AdoptionFrame?): Boolean =229    frame is AdoptionFrame.Request &&230        frame.sessionRequestId.isNotEmpty() &&231        isDeviceType(frame.deviceType)232233fun isAdoptGrant(frame: AdoptionFrame?): Boolean =234    frame is AdoptionFrame.Grant &&235        validateRootKeyHex(frame.rootKeyHex) &&236        Hex64.matches(frame.vaultId) &&237        isDeviceType(frame.source.deviceType) &&238        Hex64.matches(frame.source.longTermPublicKeyHex) &&239        (frame.source.relayUrl == null ||240            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)241242fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =243    frame is AdoptionFrame.Confirm244245fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =246    frame is AdoptionFrame.Identity &&247        frame.deviceId.isNotEmpty() &&248        isDeviceType(frame.deviceType) &&249        Hex64.matches(frame.longTermPublicKeyHex)250251fun isAdoptComplete(frame: AdoptionFrame?): Boolean =252    frame is AdoptionFrame.Complete253254fun isAdoptApplied(frame: AdoptionFrame?): Boolean =255    frame is AdoptionFrame.Applied256257fun isAdoptReject(frame: AdoptionFrame?): Boolean =258    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"259260fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =261    frame is AdoptionFrame.Snapshot &&262        frame.chunkIndex >= 0 &&263        frame.chunkCount >= 1264265/**266 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the267 * granted root key actually derives the advertised vault id (catching a lying or268 * corrupted source before it can seal a mismatched vault), seals the local vault,269 * applies any carried license, returns our identity, and records the source as a270 * paired device. Returns null on any protocol failure (reason via [deps.onError]).271 */272suspend fun requestAdoption(273    channel: PairingTransport,274    deps: AdoptionReceiverDeps,275    nowMs: () -> Long = { System.currentTimeMillis() },276): PairedDevice? {277    val receiverLicense = deps.license()278    // Fresh every attempt, and deliberately not our real device id: no identity279    // exists yet at this point in the protocol.280    sendFrame(channel, AdoptionFrame.Request(281        sessionRequestId = deps.generateDeviceId(),282        deviceName = deps.deviceName,283        deviceType = deps.deviceType.wireValue,284        license = receiverLicense,285        snapshotCapable = true,286    ))287288    val confirmed = deps.confirmSas()289    sendFrame(channel, AdoptionFrame.Confirm(confirmed))290    if (!confirmed) {291        deps.onError("ADOPT_SAS_REJECTED")292        return null293    }294295    val frame = receiveFrame(channel)296    if (isAdoptReject(frame)) {297        deps.onError((frame as AdoptionFrame.Reject).reason)298        return null299    }300    if (!isAdoptGrant(frame)) {301        deps.onError("ADOPT_UNEXPECTED_FRAME")302        return null303    }304    val grant = frame as AdoptionFrame.Grant305306    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)307    try {308        if (computeVaultId(rootKeyBytes) != grant.vaultId) {309            deps.onError("ADOPT_VAULT_ID_MISMATCH")310            return null311        }312    } finally {313        rootKeyBytes.fill(0)314    }315    if (receiverLicense == null && grant.license == null) {316        deps.onError("ADOPT_PRO_REQUIRED")317        return null318    }319320    deps.validateSource(grant.source)321    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)322    if (vaultId != grant.vaultId) {323        deps.onError("ADOPT_VAULT_ID_MISMATCH")324        return null325    }326327    if (receiverLicense == null) {328        grant.license?.let { deps.applyLicense(it) }329    }330331    val identity = deps.deriveLocalIdentity()332    sendFrame(channel, AdoptionFrame.Identity(333        deviceId = identity.deviceId,334        deviceName = identity.deviceName,335        deviceType = identity.deviceType.wireValue,336        longTermPublicKeyHex = identity.longTermPublicKeyHex,337        signingPublicKeyHex = identity.signingPublicKeyHex,338    ))339340    if (!isAdoptComplete(receiveFrame(channel))) {341        deps.onError("ADOPT_COMPLETION_MISSING")342        return null343    }344345    val sourceDevice = PairedDevice(346        deviceId = grant.source.deviceId,347        deviceName = grant.source.deviceName,348        deviceType = grant.source.deviceType,349        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,350        signingPublicKeyHex = grant.source.signingPublicKeyHex,351        trustSource = "direct",352        wsPort = grant.source.wsPort,353        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,354        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,355        pairedAt = nowMs(),356    )357    // A physically confirmed direct re-pair is the sole path allowed to lift358    // terminal revocation. Clear before upsert so storage can never expose the359    // source as both paired and revoked, even transiently.360    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)361    deps.upsertPairedDevice(sourceDevice)362    sendFrame(channel, AdoptionFrame.Applied)363    return sourceDevice364}365366/**367 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the368 * root key, then records the receiver's returned identity as a paired device.369 * Returns null if consent is denied or the receiver's identity frame is invalid.370 */371suspend fun grantAdoption(372    channel: PairingTransport,373    request: AdoptionFrame.Request,374    deps: AdoptionSourceDeps,375    nowMs: () -> Long = { System.currentTimeMillis() },376): PairedDevice? {377    if (!deps.onConsent(request)) return null378379    val confirmation = receiveFrame(channel)380    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {381        return null382    }383384    var sourceLicense = deps.license()385    if (sourceLicense == null && request.license != null) {386        sourceLicense = runCatching {387            deps.applyLicense(request.license)388            deps.license()389        }.getOrNull()390    }391    if (sourceLicense == null) {392        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))393        throw IllegalStateException("ADOPT_PRO_REQUIRED")394    }395396    val rootKeyHex = deps.rootKeyHex()397    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")398399    sendFrame(channel, AdoptionFrame.Grant(400        rootKeyHex = rootKeyHex,401        vaultId = deps.vaultId(),402        license = sourceLicense,403        source = AdoptionFrame.Source(404            deviceId = deps.sourceIdentity.deviceId,405            deviceName = deps.sourceIdentity.deviceName,406            deviceType = deps.sourceIdentity.deviceType.wireValue,407            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,408            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,409            wsPort = deps.sourceIdentity.wsPort,410            relayUrl = deps.pairState?.relayUrl,411        ),412    ))413414    val identity = receiveFrame(channel)415    if (!isAdoptIdentity(identity)) return null416    identity as AdoptionFrame.Identity417418    val device = PairedDevice(419        deviceId = identity.deviceId,420        deviceName = identity.deviceName,421        deviceType = identity.deviceType,422        longTermPublicKeyHex = identity.longTermPublicKeyHex,423        signingPublicKeyHex = identity.signingPublicKeyHex,424        trustSource = "direct",425        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,426        relayUrl = deps.pairState?.relayUrl,427        pairedAt = nowMs(),428    )429    deps.upsertPairedDevice(device)430    sendFrame(channel, AdoptionFrame.Complete)431    if (!isAdoptApplied(receiveFrame(channel))) return null432    return device433}434435/**436 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat437 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent438 * optionals exactly like `JSON.stringify` drops `undefined`.439 */440val AdoptJson: Json = Json {441    classDiscriminator = "type"442    encodeDefaults = false443    ignoreUnknownKeys = true444}445446suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {447    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))448}449450/** Decode one frame, tolerating a malformed/unknown payload by returning null. */451suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =452    runCatching {453        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))454    }.getOrNull()455456/**457 * Source side: chunk the vault snapshot and send it over the pairing channel.458 * Entries are split so each chunk's serialized entries map stays under459 * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault460 * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are461 * sent and the fallback sync pipeline fills the remainder.462 */463suspend fun sendVaultSnapshot(464    channel: PairingTransport,465    entries: Map<String, String>,466    tombstones: Map<String, Long>,467    duressSlot: String? = null,468) {469    val chunks = mutableListOf<Map<String, String>>()470    var current = LinkedHashMap<String, String>()471    var currentBytes = 2 // "{}"472    for ((id, envelope) in entries) {473        // +1 for the separating comma; an overestimate only shrinks chunks.474        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1475        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {476            chunks.add(current)477            current = LinkedHashMap()478            currentBytes = 2479        }480        current[id] = envelope481        currentBytes += entryBytes482    }483    chunks.add(current)484485    val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)486    for (index in 0 until chunkCount) {487        val isFinal = index == chunkCount - 1488        sendFrame(channel, AdoptionFrame.Snapshot(489            chunkIndex = index,490            chunkCount = chunkCount,491            entries = chunks[index],492            tombstones = if (isFinal) tombstones else emptyMap(),493            duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,494        ))495    }496}497498/**499 * Receiver side: receive and reassemble every snapshot chunk. Validates the500 * encoded frame size before parsing, then enforces sequential 0-based501 * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and502 * structural validity of each frame. Throws on any validation failure so the503 * caller can fall back to the sync pipeline.504 */505suspend fun receiveVaultSnapshot(506    channel: PairingTransport,507): ReceivedSnapshot {508    val entries = LinkedHashMap<String, String>()509    val tombstones = LinkedHashMap<String, Long>()510    var duressSlot: String? = null511    var chunkCount = -1512    var received = 0513    while (chunkCount == -1 || received < chunkCount) {514        val raw = channel.receive()515        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {516            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")517        }518        val frame = runCatching {519            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))520        }.getOrNull()521        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")522        frame as AdoptionFrame.Snapshot523        if (chunkCount == -1) {524            chunkCount = frame.chunkCount525            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {526                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")527            }528        } else if (frame.chunkCount != chunkCount) {529            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")530        }531        if (frame.chunkIndex != received) {532            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")533        }534        entries.putAll(frame.entries)535        tombstones.putAll(frame.tombstones)536        if (frame.duressSlot != null) duressSlot = frame.duressSlot537        received++538    }539    return ReceivedSnapshot(entries, tombstones, duressSlot)540}541542private fun isDeviceType(value: String): Boolean =543    DeviceType.entries.any { it.wireValue == value }