VaultAdopt.kt

20.4 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    ) : AdoptionFrame()103104    /** Receiver acknowledgement sent after all snapshot chunks are applied. */105    @Serializable106    @SerialName("ADOPT_SNAPSHOT_APPLIED")107    data class SnapshotApplied(108        @SerialName("entries_received") val entriesReceived: Int,109        @SerialName("tombstones_received") val tombstonesReceived: Int,110    ) : AdoptionFrame()111112    /** Optional signed device graph appended after the snapshot (manifest v3+). */113    @Serializable114    @SerialName("ADOPT_DEVICE_MANIFEST")115    data class DeviceManifestFrame(116        val manifest: SignedDeviceManifest,117    ) : AdoptionFrame()118119    /** Receiver acknowledgement for an applied device manifest. */120    @Serializable121    @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")122    data object DeviceManifestApplied : AdoptionFrame()123124    @Serializable125    data class Source(126        @SerialName("device_id") val deviceId: String,127        @SerialName("device_name") val deviceName: String,128        @SerialName("device_type") val deviceType: String,129        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,130        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,131        @SerialName("ws_port") val wsPort: Int? = null,132        @SerialName("relay_url") val relayUrl: String? = null,133    )134}135136data class AdoptionPairState(137    val rendezvousSecretHex: String,138    val relayUrl: String? = null,139)140141/**142 * Wire guardrails for the inline snapshot. A chunk's serialized entries map143 * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at144 * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver145 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).146 */147const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB148const val MAX_SNAPSHOT_CHUNKS = 200149150// Headroom added to the per-chunk entries budget when validating an encoded151// frame on the wire: JSON envelope plus the final chunk's tombstone map.152private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024153154/** What the RECEIVER (joining device) needs to adopt a vault from a source. */155interface AdoptionReceiverDeps {156    val deviceName: String157    val deviceType: DeviceType158    fun generateDeviceId(): String159    /** Root-key transfer cannot begin until this device confirms the SAS. */160    suspend fun confirmSas(): Boolean161    /**162     * Local safety policy evaluated after the authenticated grant is validated163     * but before any vault state changes.164     */165    suspend fun validateSource(source: AdoptionFrame.Source) {}166167    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */168    suspend fun createVaultFromRootKey(rootKeyHex: String): String169    suspend fun deriveLocalIdentity(): LocalDeviceIdentity170    /** Only called after a successful, SAS-confirmed direct pairing. */171    suspend fun clearPairedDeviceRevocation(deviceId: String) {}172    suspend fun upsertPairedDevice(device: PairedDevice)173    suspend fun applyLicense(license: String) {}174    /** Return this receiver's locally validated Pro key, if present. */175    suspend fun license(): String? = null176    val pairState: AdoptionPairState? get() = null177    fun onError(error: String)178    /** Apply encrypted entries + tombstones from the inline snapshot. */179    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {}180    /** Mark the paired device as synced after a successful snapshot. */181    suspend fun markSynced(deviceId: String) {}182}183184/** What the SOURCE (existing device) needs to grant its vault to a receiver. */185interface AdoptionSourceDeps {186    val sourceIdentity: LocalDeviceIdentity187    suspend fun rootKeyHex(): String188    suspend fun vaultId(): String189    suspend fun license(): String? = null190    /** Validate and durably store a Pro key offered by the receiver. */191    suspend fun applyLicense(license: String) {}192193    /**194     * Ask the user to approve handing this vault to the requesting device. The195     * request's name/type/label are for the human to read; the decision must not196     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which197     * carries no continuity between attempts.198     */199    suspend fun onConsent(request: AdoptionFrame.Request): Boolean200    suspend fun upsertPairedDevice(device: PairedDevice)201    val pairState: AdoptionPairState? get() = null202    /** Return all encrypted entries + tombstones for the inline snapshot. */203    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null204    /** Return the signed device manifest for the graph, if available. */205    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null206    /** Mark the paired device as synced after a successful snapshot. */207    suspend fun markSynced(deviceId: String) {}208}209210private val Hex64 = Regex("^[0-9a-f]{64}\$")211212fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)213214fun isAdoptRequest(frame: AdoptionFrame?): Boolean =215    frame is AdoptionFrame.Request &&216        frame.sessionRequestId.isNotEmpty() &&217        isDeviceType(frame.deviceType)218219fun isAdoptGrant(frame: AdoptionFrame?): Boolean =220    frame is AdoptionFrame.Grant &&221        validateRootKeyHex(frame.rootKeyHex) &&222        Hex64.matches(frame.vaultId) &&223        isDeviceType(frame.source.deviceType) &&224        Hex64.matches(frame.source.longTermPublicKeyHex) &&225        (frame.source.relayUrl == null ||226            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)227228fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =229    frame is AdoptionFrame.Confirm230231fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =232    frame is AdoptionFrame.Identity &&233        frame.deviceId.isNotEmpty() &&234        isDeviceType(frame.deviceType) &&235        Hex64.matches(frame.longTermPublicKeyHex)236237fun isAdoptComplete(frame: AdoptionFrame?): Boolean =238    frame is AdoptionFrame.Complete239240fun isAdoptApplied(frame: AdoptionFrame?): Boolean =241    frame is AdoptionFrame.Applied242243fun isAdoptReject(frame: AdoptionFrame?): Boolean =244    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"245246fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =247    frame is AdoptionFrame.Snapshot &&248        frame.chunkIndex >= 0 &&249        frame.chunkCount >= 1250251/**252 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the253 * granted root key actually derives the advertised vault id (catching a lying or254 * corrupted source before it can seal a mismatched vault), seals the local vault,255 * applies any carried license, returns our identity, and records the source as a256 * paired device. Returns null on any protocol failure (reason via [deps.onError]).257 */258suspend fun requestAdoption(259    channel: PairingTransport,260    deps: AdoptionReceiverDeps,261    nowMs: () -> Long = { System.currentTimeMillis() },262): PairedDevice? {263    val receiverLicense = deps.license()264    // Fresh every attempt, and deliberately not our real device id: no identity265    // exists yet at this point in the protocol.266    sendFrame(channel, AdoptionFrame.Request(267        sessionRequestId = deps.generateDeviceId(),268        deviceName = deps.deviceName,269        deviceType = deps.deviceType.wireValue,270        license = receiverLicense,271        snapshotCapable = true,272    ))273274    val confirmed = deps.confirmSas()275    sendFrame(channel, AdoptionFrame.Confirm(confirmed))276    if (!confirmed) {277        deps.onError("ADOPT_SAS_REJECTED")278        return null279    }280281    val frame = receiveFrame(channel)282    if (isAdoptReject(frame)) {283        deps.onError((frame as AdoptionFrame.Reject).reason)284        return null285    }286    if (!isAdoptGrant(frame)) {287        deps.onError("ADOPT_UNEXPECTED_FRAME")288        return null289    }290    val grant = frame as AdoptionFrame.Grant291292    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)293    try {294        if (computeVaultId(rootKeyBytes) != grant.vaultId) {295            deps.onError("ADOPT_VAULT_ID_MISMATCH")296            return null297        }298    } finally {299        rootKeyBytes.fill(0)300    }301    if (receiverLicense == null && grant.license == null) {302        deps.onError("ADOPT_PRO_REQUIRED")303        return null304    }305306    deps.validateSource(grant.source)307    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)308    if (vaultId != grant.vaultId) {309        deps.onError("ADOPT_VAULT_ID_MISMATCH")310        return null311    }312313    if (receiverLicense == null) {314        grant.license?.let { deps.applyLicense(it) }315    }316317    val identity = deps.deriveLocalIdentity()318    sendFrame(channel, AdoptionFrame.Identity(319        deviceId = identity.deviceId,320        deviceName = identity.deviceName,321        deviceType = identity.deviceType.wireValue,322        longTermPublicKeyHex = identity.longTermPublicKeyHex,323        signingPublicKeyHex = identity.signingPublicKeyHex,324    ))325326    if (!isAdoptComplete(receiveFrame(channel))) {327        deps.onError("ADOPT_COMPLETION_MISSING")328        return null329    }330331    val sourceDevice = PairedDevice(332        deviceId = grant.source.deviceId,333        deviceName = grant.source.deviceName,334        deviceType = grant.source.deviceType,335        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,336        signingPublicKeyHex = grant.source.signingPublicKeyHex,337        trustSource = "direct",338        wsPort = grant.source.wsPort,339        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,340        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,341        pairedAt = nowMs(),342    )343    // A physically confirmed direct re-pair is the sole path allowed to lift344    // terminal revocation. Clear before upsert so storage can never expose the345    // source as both paired and revoked, even transiently.346    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)347    deps.upsertPairedDevice(sourceDevice)348    sendFrame(channel, AdoptionFrame.Applied)349    return sourceDevice350}351352/**353 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the354 * root key, then records the receiver's returned identity as a paired device.355 * Returns null if consent is denied or the receiver's identity frame is invalid.356 */357suspend fun grantAdoption(358    channel: PairingTransport,359    request: AdoptionFrame.Request,360    deps: AdoptionSourceDeps,361    nowMs: () -> Long = { System.currentTimeMillis() },362): PairedDevice? {363    if (!deps.onConsent(request)) return null364365    val confirmation = receiveFrame(channel)366    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {367        return null368    }369370    var sourceLicense = deps.license()371    if (sourceLicense == null && request.license != null) {372        sourceLicense = runCatching {373            deps.applyLicense(request.license)374            deps.license()375        }.getOrNull()376    }377    if (sourceLicense == null) {378        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))379        throw IllegalStateException("ADOPT_PRO_REQUIRED")380    }381382    val rootKeyHex = deps.rootKeyHex()383    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")384385    sendFrame(channel, AdoptionFrame.Grant(386        rootKeyHex = rootKeyHex,387        vaultId = deps.vaultId(),388        license = sourceLicense,389        source = AdoptionFrame.Source(390            deviceId = deps.sourceIdentity.deviceId,391            deviceName = deps.sourceIdentity.deviceName,392            deviceType = deps.sourceIdentity.deviceType.wireValue,393            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,394            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,395            wsPort = deps.sourceIdentity.wsPort,396            relayUrl = deps.pairState?.relayUrl,397        ),398    ))399400    val identity = receiveFrame(channel)401    if (!isAdoptIdentity(identity)) return null402    identity as AdoptionFrame.Identity403404    val device = PairedDevice(405        deviceId = identity.deviceId,406        deviceName = identity.deviceName,407        deviceType = identity.deviceType,408        longTermPublicKeyHex = identity.longTermPublicKeyHex,409        signingPublicKeyHex = identity.signingPublicKeyHex,410        trustSource = "direct",411        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,412        relayUrl = deps.pairState?.relayUrl,413        pairedAt = nowMs(),414    )415    deps.upsertPairedDevice(device)416    sendFrame(channel, AdoptionFrame.Complete)417    if (!isAdoptApplied(receiveFrame(channel))) return null418    return device419}420421/**422 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat423 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent424 * optionals exactly like `JSON.stringify` drops `undefined`.425 */426val AdoptJson: Json = Json {427    classDiscriminator = "type"428    encodeDefaults = false429    ignoreUnknownKeys = true430}431432suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {433    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))434}435436/** Decode one frame, tolerating a malformed/unknown payload by returning null. */437suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =438    runCatching {439        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))440    }.getOrNull()441442/**443 * Source side: chunk the vault snapshot and send it over the pairing channel.444 * Entries are split so each chunk's serialized entries map stays under445 * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault446 * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are447 * sent and the fallback sync pipeline fills the remainder.448 */449suspend fun sendVaultSnapshot(450    channel: PairingTransport,451    entries: Map<String, String>,452    tombstones: Map<String, Long>,453) {454    val chunks = mutableListOf<Map<String, String>>()455    var current = LinkedHashMap<String, String>()456    var currentBytes = 2 // "{}"457    for ((id, envelope) in entries) {458        // +1 for the separating comma; an overestimate only shrinks chunks.459        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1460        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {461            chunks.add(current)462            current = LinkedHashMap()463            currentBytes = 2464        }465        current[id] = envelope466        currentBytes += entryBytes467    }468    chunks.add(current)469470    val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)471    for (index in 0 until chunkCount) {472        val isFinal = index == chunkCount - 1473        sendFrame(channel, AdoptionFrame.Snapshot(474            chunkIndex = index,475            chunkCount = chunkCount,476            entries = chunks[index],477            tombstones = if (isFinal) tombstones else emptyMap(),478        ))479    }480}481482/**483 * Receiver side: receive and reassemble every snapshot chunk. Validates the484 * encoded frame size before parsing, then enforces sequential 0-based485 * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and486 * structural validity of each frame. Throws on any validation failure so the487 * caller can fall back to the sync pipeline.488 */489suspend fun receiveVaultSnapshot(490    channel: PairingTransport,491): Pair<Map<String, String>, Map<String, Long>> {492    val entries = LinkedHashMap<String, String>()493    val tombstones = LinkedHashMap<String, Long>()494    var chunkCount = -1495    var received = 0496    while (chunkCount == -1 || received < chunkCount) {497        val raw = channel.receive()498        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {499            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")500        }501        val frame = runCatching {502            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))503        }.getOrNull()504        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")505        frame as AdoptionFrame.Snapshot506        if (chunkCount == -1) {507            chunkCount = frame.chunkCount508            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {509                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")510            }511        } else if (frame.chunkCount != chunkCount) {512            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")513        }514        if (frame.chunkIndex != received) {515            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")516        }517        entries.putAll(frame.entries)518        tombstones.putAll(frame.tombstones)519        received++520    }521    return entries to tombstones522}523524private fun isDeviceType(value: String): Boolean =525    DeviceType.entries.any { it.wireValue == value }