ChirpAdopt.kt

10.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    /**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    ) : AdoptionFrame()4041    @Serializable42    @SerialName("ADOPT_GRANT")43    data class Grant(44        @SerialName("root_key_hex") val rootKeyHex: String,45        @SerialName("vault_id") val vaultId: String,46        val license: String? = null,47        val source: Source,48    ) : AdoptionFrame()4950    @Serializable51    @SerialName("ADOPT_IDENTITY")52    data class Identity(53        @SerialName("device_id") val deviceId: String,54        @SerialName("device_name") val deviceName: String,55        @SerialName("device_type") val deviceType: String,56        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,57        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,58    ) : AdoptionFrame()5960    @Serializable61    data class Source(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        @SerialName("ws_port") val wsPort: Int? = null,68    )69}7071/** What the RECEIVER (joining device) needs to adopt a vault from a source. */72interface AdoptionReceiverDeps {73    val deviceName: String74    val deviceType: DeviceType75    fun generateDeviceId(): String7677    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */78    suspend fun createVaultFromRootKey(rootKeyHex: String): String79    suspend fun deriveLocalIdentity(): LocalChirpIdentity80    suspend fun upsertPairedDevice(device: PairedDevice)81    suspend fun applyLicense(license: String) {}82    fun onError(error: String)83}8485/** What the SOURCE (existing device) needs to grant its vault to a receiver. */86interface AdoptionSourceDeps {87    val sourceIdentity: LocalChirpIdentity88    suspend fun rootKeyHex(): String89    suspend fun vaultId(): String90    suspend fun license(): String? = null9192    /**93     * Ask the user to approve handing this vault to the requesting device. The94     * request's name/type/label are for the human to read; the decision must not95     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which96     * carries no continuity between attempts.97     */98    suspend fun onConsent(request: AdoptionFrame.Request): Boolean99    suspend fun upsertPairedDevice(device: PairedDevice)100}101102private val Hex64 = Regex("^[0-9a-f]{64}\$")103104fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)105106fun isAdoptRequest(frame: AdoptionFrame?): Boolean =107    frame is AdoptionFrame.Request &&108        frame.sessionRequestId.isNotEmpty() &&109        isDeviceType(frame.deviceType)110111fun isAdoptGrant(frame: AdoptionFrame?): Boolean =112    frame is AdoptionFrame.Grant &&113        validateRootKeyHex(frame.rootKeyHex) &&114        Hex64.matches(frame.vaultId) &&115        isDeviceType(frame.source.deviceType) &&116        Hex64.matches(frame.source.longTermPublicKeyHex)117118fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =119    frame is AdoptionFrame.Identity &&120        frame.deviceId.isNotEmpty() &&121        isDeviceType(frame.deviceType) &&122        Hex64.matches(frame.longTermPublicKeyHex)123124/**125 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the126 * granted root key actually derives the advertised vault id (catching a lying or127 * corrupted source before it can seal a mismatched vault), seals the local vault,128 * applies any carried license, returns our identity, and records the source as a129 * paired device. Returns null on any protocol failure (reason via [deps.onError]).130 */131suspend fun requestAdoption(132    channel: PairingTransport,133    deps: AdoptionReceiverDeps,134    nowMs: () -> Long = { System.currentTimeMillis() },135): PairedDevice? {136    // Fresh every attempt, and deliberately not our real device id: no identity137    // exists yet at this point in the protocol.138    sendFrame(channel, AdoptionFrame.Request(139        sessionRequestId = deps.generateDeviceId(),140        deviceName = deps.deviceName,141        deviceType = deps.deviceType.wireValue,142    ))143144    val frame = receiveFrame(channel)145    if (!isAdoptGrant(frame)) {146        deps.onError("ADOPT_UNEXPECTED_FRAME")147        return null148    }149    val grant = frame as AdoptionFrame.Grant150151    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)152    try {153        if (computeVaultId(rootKeyBytes) != grant.vaultId) {154            deps.onError("ADOPT_VAULT_ID_MISMATCH")155            return null156        }157    } finally {158        rootKeyBytes.fill(0)159    }160161    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)162    if (vaultId != grant.vaultId) {163        deps.onError("ADOPT_VAULT_ID_MISMATCH")164        return null165    }166167    grant.license?.let { deps.applyLicense(it) }168169    val identity = deps.deriveLocalIdentity()170    sendFrame(channel, AdoptionFrame.Identity(171        deviceId = identity.deviceId,172        deviceName = identity.deviceName,173        deviceType = identity.deviceType.wireValue,174        longTermPublicKeyHex = identity.longTermPublicKeyHex,175        signingPublicKeyHex = identity.signingPublicKeyHex,176    ))177178    val sourceDevice = PairedDevice(179        deviceId = grant.source.deviceId,180        deviceName = grant.source.deviceName,181        deviceType = grant.source.deviceType,182        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,183        signingPublicKeyHex = grant.source.signingPublicKeyHex,184        trustSource = "direct",185        wsPort = grant.source.wsPort,186        pairedAt = nowMs(),187    )188    deps.upsertPairedDevice(sourceDevice)189    return sourceDevice190}191192/**193 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the194 * root key, then records the receiver's returned identity as a paired device.195 * Returns null if consent is denied or the receiver's identity frame is invalid.196 */197suspend fun grantAdoption(198    channel: PairingTransport,199    request: AdoptionFrame.Request,200    deps: AdoptionSourceDeps,201    nowMs: () -> Long = { System.currentTimeMillis() },202): PairedDevice? {203    if (!deps.onConsent(request)) return null204205    val rootKeyHex = deps.rootKeyHex()206    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")207208    sendFrame(channel, AdoptionFrame.Grant(209        rootKeyHex = rootKeyHex,210        vaultId = deps.vaultId(),211        license = deps.license(),212        source = AdoptionFrame.Source(213            deviceId = deps.sourceIdentity.deviceId,214            deviceName = deps.sourceIdentity.deviceName,215            deviceType = deps.sourceIdentity.deviceType.wireValue,216            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,217            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,218            wsPort = deps.sourceIdentity.wsPort,219        ),220    ))221222    val identity = receiveFrame(channel)223    if (!isAdoptIdentity(identity)) return null224    identity as AdoptionFrame.Identity225226    val device = PairedDevice(227        deviceId = identity.deviceId,228        deviceName = identity.deviceName,229        deviceType = identity.deviceType,230        longTermPublicKeyHex = identity.longTermPublicKeyHex,231        signingPublicKeyHex = identity.signingPublicKeyHex,232        trustSource = "direct",233        pairedAt = nowMs(),234    )235    deps.upsertPairedDevice(device)236    return device237}238239/**240 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat241 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent242 * optionals exactly like `JSON.stringify` drops `undefined`.243 */244val AdoptJson: Json = Json {245    classDiscriminator = "type"246    encodeDefaults = false247    ignoreUnknownKeys = true248}249250suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {251    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))252}253254/** Decode one frame, tolerating a malformed/unknown payload by returning null. */255suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =256    runCatching {257        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))258    }.getOrNull()259260private fun isDeviceType(value: String): Boolean =261    DeviceType.entries.any { it.wireValue == value }