VaultAdopt.kt

21.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    ) : 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}221222private val Hex64 = Regex("^[0-9a-f]{64}\$")223224fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)225226fun isAdoptRequest(frame: AdoptionFrame?): Boolean =227    frame is AdoptionFrame.Request &&228        frame.sessionRequestId.isNotEmpty() &&229        isDeviceType(frame.deviceType)230231fun isAdoptGrant(frame: AdoptionFrame?): Boolean =232    frame is AdoptionFrame.Grant &&233        validateRootKeyHex(frame.rootKeyHex) &&234        Hex64.matches(frame.vaultId) &&235        isDeviceType(frame.source.deviceType) &&236        Hex64.matches(frame.source.longTermPublicKeyHex) &&237        (frame.source.relayUrl == null ||238            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)239240fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =241    frame is AdoptionFrame.Confirm242243fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =244    frame is AdoptionFrame.Identity &&245        frame.deviceId.isNotEmpty() &&246        isDeviceType(frame.deviceType) &&247        Hex64.matches(frame.longTermPublicKeyHex)248249fun isAdoptComplete(frame: AdoptionFrame?): Boolean =250    frame is AdoptionFrame.Complete251252fun isAdoptApplied(frame: AdoptionFrame?): Boolean =253    frame is AdoptionFrame.Applied254255fun isAdoptReject(frame: AdoptionFrame?): Boolean =256    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"257258fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =259    frame is AdoptionFrame.Snapshot &&260        frame.chunkIndex >= 0 &&261        frame.chunkCount >= 1262263/**264 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the265 * granted root key actually derives the advertised vault id (catching a lying or266 * corrupted source before it can seal a mismatched vault), seals the local vault,267 * applies any carried license, returns our identity, and records the source as a268 * paired device. Returns null on any protocol failure (reason via [deps.onError]).269 */270suspend fun requestAdoption(271    channel: PairingTransport,272    deps: AdoptionReceiverDeps,273    nowMs: () -> Long = { System.currentTimeMillis() },274): PairedDevice? {275    val receiverLicense = deps.license()276    // Fresh every attempt, and deliberately not our real device id: no identity277    // exists yet at this point in the protocol.278    sendFrame(channel, AdoptionFrame.Request(279        sessionRequestId = deps.generateDeviceId(),280        deviceName = deps.deviceName,281        deviceType = deps.deviceType.wireValue,282        license = receiverLicense,283        snapshotCapable = true,284    ))285286    val confirmed = deps.confirmSas()287    sendFrame(channel, AdoptionFrame.Confirm(confirmed))288    if (!confirmed) {289        deps.onError("ADOPT_SAS_REJECTED")290        return null291    }292293    val frame = receiveFrame(channel)294    if (isAdoptReject(frame)) {295        deps.onError((frame as AdoptionFrame.Reject).reason)296        return null297    }298    if (!isAdoptGrant(frame)) {299        deps.onError("ADOPT_UNEXPECTED_FRAME")300        return null301    }302    val grant = frame as AdoptionFrame.Grant303304    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)305    try {306        if (computeVaultId(rootKeyBytes) != grant.vaultId) {307            deps.onError("ADOPT_VAULT_ID_MISMATCH")308            return null309        }310    } finally {311        rootKeyBytes.fill(0)312    }313    if (receiverLicense == null && grant.license == null) {314        deps.onError("ADOPT_PRO_REQUIRED")315        return null316    }317318    deps.validateSource(grant.source)319    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)320    if (vaultId != grant.vaultId) {321        deps.onError("ADOPT_VAULT_ID_MISMATCH")322        return null323    }324325    if (receiverLicense == null) {326        grant.license?.let { deps.applyLicense(it) }327    }328329    val identity = deps.deriveLocalIdentity()330    sendFrame(channel, AdoptionFrame.Identity(331        deviceId = identity.deviceId,332        deviceName = identity.deviceName,333        deviceType = identity.deviceType.wireValue,334        longTermPublicKeyHex = identity.longTermPublicKeyHex,335        signingPublicKeyHex = identity.signingPublicKeyHex,336    ))337338    if (!isAdoptComplete(receiveFrame(channel))) {339        deps.onError("ADOPT_COMPLETION_MISSING")340        return null341    }342343    val sourceDevice = PairedDevice(344        deviceId = grant.source.deviceId,345        deviceName = grant.source.deviceName,346        deviceType = grant.source.deviceType,347        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,348        signingPublicKeyHex = grant.source.signingPublicKeyHex,349        trustSource = "direct",350        wsPort = grant.source.wsPort,351        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,352        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,353        pairedAt = nowMs(),354    )355    // A physically confirmed direct re-pair is the sole path allowed to lift356    // terminal revocation. Clear before upsert so storage can never expose the357    // source as both paired and revoked, even transiently.358    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)359    deps.upsertPairedDevice(sourceDevice)360    sendFrame(channel, AdoptionFrame.Applied)361    return sourceDevice362}363364/**365 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the366 * root key, then records the receiver's returned identity as a paired device.367 * Returns null if consent is denied or the receiver's identity frame is invalid.368 */369suspend fun grantAdoption(370    channel: PairingTransport,371    request: AdoptionFrame.Request,372    deps: AdoptionSourceDeps,373    nowMs: () -> Long = { System.currentTimeMillis() },374): PairedDevice? {375    if (!deps.onConsent(request)) return null376377    val confirmation = receiveFrame(channel)378    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {379        return null380    }381382    var sourceLicense = deps.license()383    if (sourceLicense == null && request.license != null) {384        sourceLicense = runCatching {385            deps.applyLicense(request.license)386            deps.license()387        }.getOrNull()388    }389    if (sourceLicense == null) {390        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))391        throw IllegalStateException("ADOPT_PRO_REQUIRED")392    }393394    val rootKeyHex = deps.rootKeyHex()395    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")396397    sendFrame(channel, AdoptionFrame.Grant(398        rootKeyHex = rootKeyHex,399        vaultId = deps.vaultId(),400        license = sourceLicense,401        source = AdoptionFrame.Source(402            deviceId = deps.sourceIdentity.deviceId,403            deviceName = deps.sourceIdentity.deviceName,404            deviceType = deps.sourceIdentity.deviceType.wireValue,405            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,406            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,407            wsPort = deps.sourceIdentity.wsPort,408            relayUrl = deps.pairState?.relayUrl,409        ),410    ))411412    val identity = receiveFrame(channel)413    if (!isAdoptIdentity(identity)) return null414    identity as AdoptionFrame.Identity415416    val device = PairedDevice(417        deviceId = identity.deviceId,418        deviceName = identity.deviceName,419        deviceType = identity.deviceType,420        longTermPublicKeyHex = identity.longTermPublicKeyHex,421        signingPublicKeyHex = identity.signingPublicKeyHex,422        trustSource = "direct",423        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,424        relayUrl = deps.pairState?.relayUrl,425        pairedAt = nowMs(),426    )427    deps.upsertPairedDevice(device)428    sendFrame(channel, AdoptionFrame.Complete)429    if (!isAdoptApplied(receiveFrame(channel))) return null430    return device431}432433/**434 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat435 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent436 * optionals exactly like `JSON.stringify` drops `undefined`.437 */438val AdoptJson: Json = Json {439    classDiscriminator = "type"440    encodeDefaults = false441    ignoreUnknownKeys = true442}443444suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {445    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))446}447448/** Decode one frame, tolerating a malformed/unknown payload by returning null. */449suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =450    runCatching {451        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))452    }.getOrNull()453454/**455 * Source side: chunk the vault snapshot and send it over the pairing channel.456 * Entries are split so each chunk's serialized entries map stays under457 * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault458 * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are459 * sent and the fallback sync pipeline fills the remainder.460 */461suspend fun sendVaultSnapshot(462    channel: PairingTransport,463    entries: Map<String, String>,464    tombstones: Map<String, Long>,465    duressSlot: String? = null,466) {467    val chunks = mutableListOf<Map<String, String>>()468    var current = LinkedHashMap<String, String>()469    var currentBytes = 2 // "{}"470    for ((id, envelope) in entries) {471        // +1 for the separating comma; an overestimate only shrinks chunks.472        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1473        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {474            chunks.add(current)475            current = LinkedHashMap()476            currentBytes = 2477        }478        current[id] = envelope479        currentBytes += entryBytes480    }481    chunks.add(current)482483    val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)484    for (index in 0 until chunkCount) {485        val isFinal = index == chunkCount - 1486        sendFrame(channel, AdoptionFrame.Snapshot(487            chunkIndex = index,488            chunkCount = chunkCount,489            entries = chunks[index],490            tombstones = if (isFinal) tombstones else emptyMap(),491            duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,492        ))493    }494}495496/**497 * Receiver side: receive and reassemble every snapshot chunk. Validates the498 * encoded frame size before parsing, then enforces sequential 0-based499 * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and500 * structural validity of each frame. Throws on any validation failure so the501 * caller can fall back to the sync pipeline.502 */503suspend fun receiveVaultSnapshot(504    channel: PairingTransport,505): ReceivedSnapshot {506    val entries = LinkedHashMap<String, String>()507    val tombstones = LinkedHashMap<String, Long>()508    var duressSlot: String? = null509    var chunkCount = -1510    var received = 0511    while (chunkCount == -1 || received < chunkCount) {512        val raw = channel.receive()513        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {514            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")515        }516        val frame = runCatching {517            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))518        }.getOrNull()519        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")520        frame as AdoptionFrame.Snapshot521        if (chunkCount == -1) {522            chunkCount = frame.chunkCount523            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {524                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")525            }526        } else if (frame.chunkCount != chunkCount) {527            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")528        }529        if (frame.chunkIndex != received) {530            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")531        }532        entries.putAll(frame.entries)533        tombstones.putAll(frame.tombstones)534        if (frame.duressSlot != null) duressSlot = frame.duressSlot535        received++536    }537    return ReceivedSnapshot(entries, tombstones, duressSlot)538}539540private fun isDeviceType(value: String): Boolean =541    DeviceType.entries.any { it.wireValue == value }