VaultAdopt.kt

14.3 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json67/**8 * Vault adoption frame protocol — exact port of the browser `shared/vault-adopt.ts`.9 *10 * Adoption lets a brand-new device join an existing vault: the SOURCE (an already11 * set-up device) hands its root key to the RECEIVER over the pairing channel so12 * both devices end up with the same vault id and can sync. The receiver seals an13 * empty local vault under its own password but reuses the adopted root key14 * ([VaultRepository.createVaultFromRootKey]).15 *16 * Frames travel as JSON with a `type` discriminator and SNAKE_CASE keys — unlike17 * the camelCase [SyncMessage] sync wire — so the keys must match the browser18 * byte-for-byte for cross-platform adoption. Optional fields are omitted when19 * absent (encodeDefaults = false) to mirror `JSON.stringify` dropping `undefined`.20 */21@Serializable22sealed class AdoptionFrame {23    /**24     * [sessionRequestId] carries the wire's `device_id`, but it is NOT an25     * identity: the requester generates it locally before the handshake that26     * establishes any long-term key, so it cannot be the device's real one27     * (see `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2). It is a label for the28     * consent prompt and nothing else — never persist it, never compare it29     * against a previous attempt's, never key trust on it. The only identity30     * that may be persisted is `long_term_public_key_hex`, which arrives later31     * in ADOPT_IDENTITY / ADOPT_GRANT.source.32     */33    @Serializable34    @SerialName("ADOPT_REQUEST")35    data class Request(36        @SerialName("device_id") val sessionRequestId: String,37        @SerialName("device_name") val deviceName: String,38        @SerialName("device_type") val deviceType: String,39        val license: String? = null,40    ) : AdoptionFrame()4142    @Serializable43    @SerialName("ADOPT_CONFIRM")44    data class Confirm(45        val confirmed: Boolean,46    ) : AdoptionFrame()4748    @Serializable49    @SerialName("ADOPT_GRANT")50    data class Grant(51        @SerialName("root_key_hex") val rootKeyHex: String,52        @SerialName("vault_id") val vaultId: String,53        val license: String? = null,54        val source: Source,55    ) : AdoptionFrame()5657    @Serializable58    @SerialName("ADOPT_IDENTITY")59    data class Identity(60        @SerialName("device_id") val deviceId: String,61        @SerialName("device_name") val deviceName: String,62        @SerialName("device_type") val deviceType: String,63        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,64        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,65    ) : AdoptionFrame()6667    /**68     * Encrypted source-to-receiver acknowledgement emitted only after the source69     * has durably recorded the receiver. The receiver cannot truthfully report a70     * successful pair before this arrives.71     */72    @Serializable73    @SerialName("ADOPT_COMPLETE")74    data object Complete : AdoptionFrame()7576    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */77    @Serializable78    @SerialName("ADOPT_APPLIED")79    data object Applied : AdoptionFrame()8081    /** Neither peer proved Pro, so the pairing must not commit. */82    @Serializable83    @SerialName("ADOPT_REJECT")84    data class Reject(85        val reason: String,86    ) : AdoptionFrame()8788    @Serializable89    data class Source(90        @SerialName("device_id") val deviceId: String,91        @SerialName("device_name") val deviceName: String,92        @SerialName("device_type") val deviceType: String,93        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,94        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,95        @SerialName("ws_port") val wsPort: Int? = null,96        @SerialName("relay_url") val relayUrl: String? = null,97    )98}99100data class AdoptionPairState(101    val rendezvousSecretHex: String,102    val relayUrl: String? = null,103)104105/** What the RECEIVER (joining device) needs to adopt a vault from a source. */106interface AdoptionReceiverDeps {107    val deviceName: String108    val deviceType: DeviceType109    fun generateDeviceId(): String110    /** Root-key transfer cannot begin until this device confirms the SAS. */111    suspend fun confirmSas(): Boolean112    /**113     * Local safety policy evaluated after the authenticated grant is validated114     * but before any vault state changes.115     */116    suspend fun validateSource(source: AdoptionFrame.Source) {}117118    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */119    suspend fun createVaultFromRootKey(rootKeyHex: String): String120    suspend fun deriveLocalIdentity(): LocalDeviceIdentity121    /** Only called after a successful, SAS-confirmed direct pairing. */122    suspend fun clearPairedDeviceRevocation(deviceId: String) {}123    suspend fun upsertPairedDevice(device: PairedDevice)124    suspend fun applyLicense(license: String) {}125    /** Return this receiver's locally validated Pro key, if present. */126    suspend fun license(): String? = null127    val pairState: AdoptionPairState? get() = null128    fun onError(error: String)129}130131/** What the SOURCE (existing device) needs to grant its vault to a receiver. */132interface AdoptionSourceDeps {133    val sourceIdentity: LocalDeviceIdentity134    suspend fun rootKeyHex(): String135    suspend fun vaultId(): String136    suspend fun license(): String? = null137    /** Validate and durably store a Pro key offered by the receiver. */138    suspend fun applyLicense(license: String) {}139140    /**141     * Ask the user to approve handing this vault to the requesting device. The142     * request's name/type/label are for the human to read; the decision must not143     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which144     * carries no continuity between attempts.145     */146    suspend fun onConsent(request: AdoptionFrame.Request): Boolean147    suspend fun upsertPairedDevice(device: PairedDevice)148    val pairState: AdoptionPairState? get() = null149}150151private val Hex64 = Regex("^[0-9a-f]{64}\$")152153fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)154155fun isAdoptRequest(frame: AdoptionFrame?): Boolean =156    frame is AdoptionFrame.Request &&157        frame.sessionRequestId.isNotEmpty() &&158        isDeviceType(frame.deviceType)159160fun isAdoptGrant(frame: AdoptionFrame?): Boolean =161    frame is AdoptionFrame.Grant &&162        validateRootKeyHex(frame.rootKeyHex) &&163        Hex64.matches(frame.vaultId) &&164        isDeviceType(frame.source.deviceType) &&165        Hex64.matches(frame.source.longTermPublicKeyHex) &&166        (frame.source.relayUrl == null ||167            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)168169fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =170    frame is AdoptionFrame.Confirm171172fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =173    frame is AdoptionFrame.Identity &&174        frame.deviceId.isNotEmpty() &&175        isDeviceType(frame.deviceType) &&176        Hex64.matches(frame.longTermPublicKeyHex)177178fun isAdoptComplete(frame: AdoptionFrame?): Boolean =179    frame is AdoptionFrame.Complete180181fun isAdoptApplied(frame: AdoptionFrame?): Boolean =182    frame is AdoptionFrame.Applied183184fun isAdoptReject(frame: AdoptionFrame?): Boolean =185    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"186187/**188 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the189 * granted root key actually derives the advertised vault id (catching a lying or190 * corrupted source before it can seal a mismatched vault), seals the local vault,191 * applies any carried license, returns our identity, and records the source as a192 * paired device. Returns null on any protocol failure (reason via [deps.onError]).193 */194suspend fun requestAdoption(195    channel: PairingTransport,196    deps: AdoptionReceiverDeps,197    nowMs: () -> Long = { System.currentTimeMillis() },198): PairedDevice? {199    val receiverLicense = deps.license()200    // Fresh every attempt, and deliberately not our real device id: no identity201    // exists yet at this point in the protocol.202    sendFrame(channel, AdoptionFrame.Request(203        sessionRequestId = deps.generateDeviceId(),204        deviceName = deps.deviceName,205        deviceType = deps.deviceType.wireValue,206        license = receiverLicense,207    ))208209    val confirmed = deps.confirmSas()210    sendFrame(channel, AdoptionFrame.Confirm(confirmed))211    if (!confirmed) {212        deps.onError("ADOPT_SAS_REJECTED")213        return null214    }215216    val frame = receiveFrame(channel)217    if (isAdoptReject(frame)) {218        deps.onError((frame as AdoptionFrame.Reject).reason)219        return null220    }221    if (!isAdoptGrant(frame)) {222        deps.onError("ADOPT_UNEXPECTED_FRAME")223        return null224    }225    val grant = frame as AdoptionFrame.Grant226227    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)228    try {229        if (computeVaultId(rootKeyBytes) != grant.vaultId) {230            deps.onError("ADOPT_VAULT_ID_MISMATCH")231            return null232        }233    } finally {234        rootKeyBytes.fill(0)235    }236    if (receiverLicense == null && grant.license == null) {237        deps.onError("ADOPT_PRO_REQUIRED")238        return null239    }240241    deps.validateSource(grant.source)242    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)243    if (vaultId != grant.vaultId) {244        deps.onError("ADOPT_VAULT_ID_MISMATCH")245        return null246    }247248    if (receiverLicense == null) {249        grant.license?.let { deps.applyLicense(it) }250    }251252    val identity = deps.deriveLocalIdentity()253    sendFrame(channel, AdoptionFrame.Identity(254        deviceId = identity.deviceId,255        deviceName = identity.deviceName,256        deviceType = identity.deviceType.wireValue,257        longTermPublicKeyHex = identity.longTermPublicKeyHex,258        signingPublicKeyHex = identity.signingPublicKeyHex,259    ))260261    if (!isAdoptComplete(receiveFrame(channel))) {262        deps.onError("ADOPT_COMPLETION_MISSING")263        return null264    }265266    val sourceDevice = PairedDevice(267        deviceId = grant.source.deviceId,268        deviceName = grant.source.deviceName,269        deviceType = grant.source.deviceType,270        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,271        signingPublicKeyHex = grant.source.signingPublicKeyHex,272        trustSource = "direct",273        wsPort = grant.source.wsPort,274        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,275        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,276        pairedAt = nowMs(),277    )278    // A physically confirmed direct re-pair is the sole path allowed to lift279    // terminal revocation. Clear before upsert so storage can never expose the280    // source as both paired and revoked, even transiently.281    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)282    deps.upsertPairedDevice(sourceDevice)283    sendFrame(channel, AdoptionFrame.Applied)284    return sourceDevice285}286287/**288 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the289 * root key, then records the receiver's returned identity as a paired device.290 * Returns null if consent is denied or the receiver's identity frame is invalid.291 */292suspend fun grantAdoption(293    channel: PairingTransport,294    request: AdoptionFrame.Request,295    deps: AdoptionSourceDeps,296    nowMs: () -> Long = { System.currentTimeMillis() },297): PairedDevice? {298    if (!deps.onConsent(request)) return null299300    val confirmation = receiveFrame(channel)301    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {302        return null303    }304305    var sourceLicense = deps.license()306    if (sourceLicense == null && request.license != null) {307        sourceLicense = runCatching {308            deps.applyLicense(request.license)309            deps.license()310        }.getOrNull()311    }312    if (sourceLicense == null) {313        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))314        throw IllegalStateException("ADOPT_PRO_REQUIRED")315    }316317    val rootKeyHex = deps.rootKeyHex()318    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")319320    sendFrame(channel, AdoptionFrame.Grant(321        rootKeyHex = rootKeyHex,322        vaultId = deps.vaultId(),323        license = sourceLicense,324        source = AdoptionFrame.Source(325            deviceId = deps.sourceIdentity.deviceId,326            deviceName = deps.sourceIdentity.deviceName,327            deviceType = deps.sourceIdentity.deviceType.wireValue,328            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,329            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,330            wsPort = deps.sourceIdentity.wsPort,331            relayUrl = deps.pairState?.relayUrl,332        ),333    ))334335    val identity = receiveFrame(channel)336    if (!isAdoptIdentity(identity)) return null337    identity as AdoptionFrame.Identity338339    val device = PairedDevice(340        deviceId = identity.deviceId,341        deviceName = identity.deviceName,342        deviceType = identity.deviceType,343        longTermPublicKeyHex = identity.longTermPublicKeyHex,344        signingPublicKeyHex = identity.signingPublicKeyHex,345        trustSource = "direct",346        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,347        relayUrl = deps.pairState?.relayUrl,348        pairedAt = nowMs(),349    )350    deps.upsertPairedDevice(device)351    sendFrame(channel, AdoptionFrame.Complete)352    if (!isAdoptApplied(receiveFrame(channel))) return null353    return device354}355356/**357 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat358 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent359 * optionals exactly like `JSON.stringify` drops `undefined`.360 */361val AdoptJson: Json = Json {362    classDiscriminator = "type"363    encodeDefaults = false364    ignoreUnknownKeys = true365}366367suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {368    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))369}370371/** Decode one frame, tolerating a malformed/unknown payload by returning null. */372suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =373    runCatching {374        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))375    }.getOrNull()376377private fun isDeviceType(value: String): Boolean =378    DeviceType.entries.any { it.wireValue == value }