ChirpAdopt.kt

10.7 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_CONFIRM")43    data class Confirm(44        val confirmed: Boolean,45    ) : AdoptionFrame()4647    @Serializable48    @SerialName("ADOPT_GRANT")49    data class Grant(50        @SerialName("root_key_hex") val rootKeyHex: String,51        @SerialName("vault_id") val vaultId: String,52        val license: String? = null,53        val source: Source,54    ) : AdoptionFrame()5556    @Serializable57    @SerialName("ADOPT_IDENTITY")58    data class Identity(59        @SerialName("device_id") val deviceId: String,60        @SerialName("device_name") val deviceName: String,61        @SerialName("device_type") val deviceType: String,62        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,63        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,64    ) : AdoptionFrame()6566    @Serializable67    data class Source(68        @SerialName("device_id") val deviceId: String,69        @SerialName("device_name") val deviceName: String,70        @SerialName("device_type") val deviceType: String,71        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,72        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,73        @SerialName("ws_port") val wsPort: Int? = null,74    )75}7677/** What the RECEIVER (joining device) needs to adopt a vault from a source. */78interface AdoptionReceiverDeps {79    val deviceName: String80    val deviceType: DeviceType81    fun generateDeviceId(): String82    /** Root-key transfer cannot begin until this device confirms the SAS. */83    suspend fun confirmSas(): Boolean8485    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */86    suspend fun createVaultFromRootKey(rootKeyHex: String): String87    suspend fun deriveLocalIdentity(): LocalChirpIdentity88    suspend fun upsertPairedDevice(device: PairedDevice)89    suspend fun applyLicense(license: String) {}90    fun onError(error: String)91}9293/** What the SOURCE (existing device) needs to grant its vault to a receiver. */94interface AdoptionSourceDeps {95    val sourceIdentity: LocalChirpIdentity96    suspend fun rootKeyHex(): String97    suspend fun vaultId(): String98    suspend fun license(): String? = null99100    /**101     * Ask the user to approve handing this vault to the requesting device. The102     * request's name/type/label are for the human to read; the decision must not103     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which104     * carries no continuity between attempts.105     */106    suspend fun onConsent(request: AdoptionFrame.Request): Boolean107    suspend fun upsertPairedDevice(device: PairedDevice)108}109110private val Hex64 = Regex("^[0-9a-f]{64}\$")111112fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)113114fun isAdoptRequest(frame: AdoptionFrame?): Boolean =115    frame is AdoptionFrame.Request &&116        frame.sessionRequestId.isNotEmpty() &&117        isDeviceType(frame.deviceType)118119fun isAdoptGrant(frame: AdoptionFrame?): Boolean =120    frame is AdoptionFrame.Grant &&121        validateRootKeyHex(frame.rootKeyHex) &&122        Hex64.matches(frame.vaultId) &&123        isDeviceType(frame.source.deviceType) &&124        Hex64.matches(frame.source.longTermPublicKeyHex)125126fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =127    frame is AdoptionFrame.Confirm128129fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =130    frame is AdoptionFrame.Identity &&131        frame.deviceId.isNotEmpty() &&132        isDeviceType(frame.deviceType) &&133        Hex64.matches(frame.longTermPublicKeyHex)134135/**136 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the137 * granted root key actually derives the advertised vault id (catching a lying or138 * corrupted source before it can seal a mismatched vault), seals the local vault,139 * applies any carried license, returns our identity, and records the source as a140 * paired device. Returns null on any protocol failure (reason via [deps.onError]).141 */142suspend fun requestAdoption(143    channel: PairingTransport,144    deps: AdoptionReceiverDeps,145    nowMs: () -> Long = { System.currentTimeMillis() },146): PairedDevice? {147    // Fresh every attempt, and deliberately not our real device id: no identity148    // exists yet at this point in the protocol.149    sendFrame(channel, AdoptionFrame.Request(150        sessionRequestId = deps.generateDeviceId(),151        deviceName = deps.deviceName,152        deviceType = deps.deviceType.wireValue,153    ))154155    val confirmed = deps.confirmSas()156    sendFrame(channel, AdoptionFrame.Confirm(confirmed))157    if (!confirmed) {158        deps.onError("ADOPT_SAS_REJECTED")159        return null160    }161162    val frame = receiveFrame(channel)163    if (!isAdoptGrant(frame)) {164        deps.onError("ADOPT_UNEXPECTED_FRAME")165        return null166    }167    val grant = frame as AdoptionFrame.Grant168169    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)170    try {171        if (computeVaultId(rootKeyBytes) != grant.vaultId) {172            deps.onError("ADOPT_VAULT_ID_MISMATCH")173            return null174        }175    } finally {176        rootKeyBytes.fill(0)177    }178179    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)180    if (vaultId != grant.vaultId) {181        deps.onError("ADOPT_VAULT_ID_MISMATCH")182        return null183    }184185    grant.license?.let { deps.applyLicense(it) }186187    val identity = deps.deriveLocalIdentity()188    sendFrame(channel, AdoptionFrame.Identity(189        deviceId = identity.deviceId,190        deviceName = identity.deviceName,191        deviceType = identity.deviceType.wireValue,192        longTermPublicKeyHex = identity.longTermPublicKeyHex,193        signingPublicKeyHex = identity.signingPublicKeyHex,194    ))195196    val sourceDevice = PairedDevice(197        deviceId = grant.source.deviceId,198        deviceName = grant.source.deviceName,199        deviceType = grant.source.deviceType,200        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,201        signingPublicKeyHex = grant.source.signingPublicKeyHex,202        trustSource = "direct",203        wsPort = grant.source.wsPort,204        pairedAt = nowMs(),205    )206    deps.upsertPairedDevice(sourceDevice)207    return sourceDevice208}209210/**211 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the212 * root key, then records the receiver's returned identity as a paired device.213 * Returns null if consent is denied or the receiver's identity frame is invalid.214 */215suspend fun grantAdoption(216    channel: PairingTransport,217    request: AdoptionFrame.Request,218    deps: AdoptionSourceDeps,219    nowMs: () -> Long = { System.currentTimeMillis() },220): PairedDevice? {221    if (!deps.onConsent(request)) return null222223    val confirmation = receiveFrame(channel)224    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {225        return null226    }227228    val rootKeyHex = deps.rootKeyHex()229    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")230231    sendFrame(channel, AdoptionFrame.Grant(232        rootKeyHex = rootKeyHex,233        vaultId = deps.vaultId(),234        license = deps.license(),235        source = AdoptionFrame.Source(236            deviceId = deps.sourceIdentity.deviceId,237            deviceName = deps.sourceIdentity.deviceName,238            deviceType = deps.sourceIdentity.deviceType.wireValue,239            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,240            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,241            wsPort = deps.sourceIdentity.wsPort,242        ),243    ))244245    val identity = receiveFrame(channel)246    if (!isAdoptIdentity(identity)) return null247    identity as AdoptionFrame.Identity248249    val device = PairedDevice(250        deviceId = identity.deviceId,251        deviceName = identity.deviceName,252        deviceType = identity.deviceType,253        longTermPublicKeyHex = identity.longTermPublicKeyHex,254        signingPublicKeyHex = identity.signingPublicKeyHex,255        trustSource = "direct",256        pairedAt = nowMs(),257    )258    deps.upsertPairedDevice(device)259    return device260}261262/**263 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat264 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent265 * optionals exactly like `JSON.stringify` drops `undefined`.266 */267val AdoptJson: Json = Json {268    classDiscriminator = "type"269    encodeDefaults = false270    ignoreUnknownKeys = true271}272273suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {274    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))275}276277/** Decode one frame, tolerating a malformed/unknown payload by returning null. */278suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =279    runCatching {280        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))281    }.getOrNull()282283private fun isDeviceType(value: String): Boolean =284    DeviceType.entries.any { it.wireValue == value }