ChirpAdopt.kt

9.0 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/chirp-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    @Serializable24    @SerialName("ADOPT_REQUEST")25    data class Request(26        @SerialName("device_id") val deviceId: String,27        @SerialName("device_name") val deviceName: String,28        @SerialName("device_type") val deviceType: String,29    ) : AdoptionFrame()3031    @Serializable32    @SerialName("ADOPT_GRANT")33    data class Grant(34        @SerialName("root_key_hex") val rootKeyHex: String,35        @SerialName("vault_id") val vaultId: String,36        val license: String? = null,37        val source: Source,38    ) : AdoptionFrame()3940    @Serializable41    @SerialName("ADOPT_IDENTITY")42    data class Identity(43        @SerialName("device_id") val deviceId: String,44        @SerialName("device_name") val deviceName: String,45        @SerialName("device_type") val deviceType: String,46        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,47        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,48    ) : AdoptionFrame()4950    @Serializable51    data class Source(52        @SerialName("device_id") val deviceId: String,53        @SerialName("device_name") val deviceName: String,54        @SerialName("device_type") val deviceType: String,55        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,56        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,57        @SerialName("ws_port") val wsPort: Int? = null,58    )59}6061/** What the RECEIVER (joining device) needs to adopt a vault from a source. */62interface AdoptionReceiverDeps {63    val deviceName: String64    val deviceType: DeviceType65    fun generateDeviceId(): String6667    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */68    suspend fun createVaultFromRootKey(rootKeyHex: String): String69    suspend fun deriveLocalIdentity(): LocalChirpIdentity70    suspend fun upsertPairedDevice(device: PairedDevice)71    suspend fun applyLicense(license: String) {}72    fun onError(error: String)73}7475/** What the SOURCE (existing device) needs to grant its vault to a receiver. */76interface AdoptionSourceDeps {77    val sourceIdentity: LocalChirpIdentity78    suspend fun rootKeyHex(): String79    suspend fun vaultId(): String80    suspend fun license(): String? = null8182    /** Ask the user to approve handing this vault to the requesting device. */83    suspend fun onConsent(request: AdoptionFrame.Request): Boolean84    suspend fun upsertPairedDevice(device: PairedDevice)85}8687private val Hex64 = Regex("^[0-9a-f]{64}\$")8889fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)9091fun isAdoptRequest(frame: AdoptionFrame?): Boolean =92    frame is AdoptionFrame.Request &&93        frame.deviceId.isNotEmpty() &&94        isDeviceType(frame.deviceType)9596fun isAdoptGrant(frame: AdoptionFrame?): Boolean =97    frame is AdoptionFrame.Grant &&98        validateRootKeyHex(frame.rootKeyHex) &&99        Hex64.matches(frame.vaultId) &&100        isDeviceType(frame.source.deviceType) &&101        Hex64.matches(frame.source.longTermPublicKeyHex)102103fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =104    frame is AdoptionFrame.Identity &&105        frame.deviceId.isNotEmpty() &&106        isDeviceType(frame.deviceType) &&107        Hex64.matches(frame.longTermPublicKeyHex)108109/**110 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the111 * granted root key actually derives the advertised vault id (catching a lying or112 * corrupted source before it can seal a mismatched vault), seals the local vault,113 * applies any carried license, returns our identity, and records the source as a114 * paired device. Returns null on any protocol failure (reason via [deps.onError]).115 */116suspend fun requestAdoption(117    channel: PairingTransport,118    deps: AdoptionReceiverDeps,119    nowMs: () -> Long = { System.currentTimeMillis() },120): PairedDevice? {121    sendFrame(channel, AdoptionFrame.Request(122        deviceId = deps.generateDeviceId(),123        deviceName = deps.deviceName,124        deviceType = deps.deviceType.wireValue,125    ))126127    val frame = receiveFrame(channel)128    if (!isAdoptGrant(frame)) {129        deps.onError("ADOPT_UNEXPECTED_FRAME")130        return null131    }132    val grant = frame as AdoptionFrame.Grant133134    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)135    try {136        if (computeVaultId(rootKeyBytes) != grant.vaultId) {137            deps.onError("ADOPT_VAULT_ID_MISMATCH")138            return null139        }140    } finally {141        rootKeyBytes.fill(0)142    }143144    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)145    if (vaultId != grant.vaultId) {146        deps.onError("ADOPT_VAULT_ID_MISMATCH")147        return null148    }149150    grant.license?.let { deps.applyLicense(it) }151152    val identity = deps.deriveLocalIdentity()153    sendFrame(channel, AdoptionFrame.Identity(154        deviceId = identity.deviceId,155        deviceName = identity.deviceName,156        deviceType = identity.deviceType.wireValue,157        longTermPublicKeyHex = identity.longTermPublicKeyHex,158        signingPublicKeyHex = identity.signingPublicKeyHex,159    ))160161    val sourceDevice = PairedDevice(162        deviceId = grant.source.deviceId,163        deviceName = grant.source.deviceName,164        deviceType = grant.source.deviceType,165        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,166        signingPublicKeyHex = grant.source.signingPublicKeyHex,167        trustSource = "direct",168        wsPort = grant.source.wsPort,169        pairedAt = nowMs(),170    )171    deps.upsertPairedDevice(sourceDevice)172    return sourceDevice173}174175/**176 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the177 * root key, then records the receiver's returned identity as a paired device.178 * Returns null if consent is denied or the receiver's identity frame is invalid.179 */180suspend fun grantAdoption(181    channel: PairingTransport,182    request: AdoptionFrame.Request,183    deps: AdoptionSourceDeps,184    nowMs: () -> Long = { System.currentTimeMillis() },185): PairedDevice? {186    if (!deps.onConsent(request)) return null187188    val rootKeyHex = deps.rootKeyHex()189    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")190191    sendFrame(channel, AdoptionFrame.Grant(192        rootKeyHex = rootKeyHex,193        vaultId = deps.vaultId(),194        license = deps.license(),195        source = AdoptionFrame.Source(196            deviceId = deps.sourceIdentity.deviceId,197            deviceName = deps.sourceIdentity.deviceName,198            deviceType = deps.sourceIdentity.deviceType.wireValue,199            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,200            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,201            wsPort = deps.sourceIdentity.wsPort,202        ),203    ))204205    val identity = receiveFrame(channel)206    if (!isAdoptIdentity(identity)) return null207    identity as AdoptionFrame.Identity208209    val device = PairedDevice(210        deviceId = identity.deviceId,211        deviceName = identity.deviceName,212        deviceType = identity.deviceType,213        longTermPublicKeyHex = identity.longTermPublicKeyHex,214        signingPublicKeyHex = identity.signingPublicKeyHex,215        trustSource = "direct",216        pairedAt = nowMs(),217    )218    deps.upsertPairedDevice(device)219    return device220}221222/**223 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat224 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent225 * optionals exactly like `JSON.stringify` drops `undefined`.226 */227val AdoptJson: Json = Json {228    classDiscriminator = "type"229    encodeDefaults = false230    ignoreUnknownKeys = true231}232233suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {234    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))235}236237/** Decode one frame, tolerating a malformed/unknown payload by returning null. */238suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =239    runCatching {240        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))241    }.getOrNull()242243private fun isDeviceType(value: String): Boolean =244    DeviceType.entries.any { it.wireValue == value }