VaultAdopt.kt

11.8 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    ) : 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        @SerialName("relay_url") val relayUrl: String? = null,75    )76}7778data class AdoptionPairState(79    val rendezvousSecretHex: String,80    val relayUrl: String? = null,81)8283/** What the RECEIVER (joining device) needs to adopt a vault from a source. */84interface AdoptionReceiverDeps {85    val deviceName: String86    val deviceType: DeviceType87    fun generateDeviceId(): String88    /** Root-key transfer cannot begin until this device confirms the SAS. */89    suspend fun confirmSas(): Boolean9091    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */92    suspend fun createVaultFromRootKey(rootKeyHex: String): String93    suspend fun deriveLocalIdentity(): LocalDeviceIdentity94    /** Only called after a successful, SAS-confirmed direct pairing. */95    suspend fun clearPairedDeviceRevocation(deviceId: String) {}96    suspend fun upsertPairedDevice(device: PairedDevice)97    suspend fun applyLicense(license: String) {}98    val pairState: AdoptionPairState? get() = null99    fun onError(error: String)100}101102/** What the SOURCE (existing device) needs to grant its vault to a receiver. */103interface AdoptionSourceDeps {104    val sourceIdentity: LocalDeviceIdentity105    suspend fun rootKeyHex(): String106    suspend fun vaultId(): String107    suspend fun license(): String? = null108109    /**110     * Ask the user to approve handing this vault to the requesting device. The111     * request's name/type/label are for the human to read; the decision must not112     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which113     * carries no continuity between attempts.114     */115    suspend fun onConsent(request: AdoptionFrame.Request): Boolean116    suspend fun upsertPairedDevice(device: PairedDevice)117    val pairState: AdoptionPairState? get() = null118}119120private val Hex64 = Regex("^[0-9a-f]{64}\$")121122fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)123124fun isAdoptRequest(frame: AdoptionFrame?): Boolean =125    frame is AdoptionFrame.Request &&126        frame.sessionRequestId.isNotEmpty() &&127        isDeviceType(frame.deviceType)128129fun isAdoptGrant(frame: AdoptionFrame?): Boolean =130    frame is AdoptionFrame.Grant &&131        validateRootKeyHex(frame.rootKeyHex) &&132        Hex64.matches(frame.vaultId) &&133        isDeviceType(frame.source.deviceType) &&134        Hex64.matches(frame.source.longTermPublicKeyHex) &&135        (frame.source.relayUrl == null ||136            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)137138fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =139    frame is AdoptionFrame.Confirm140141fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =142    frame is AdoptionFrame.Identity &&143        frame.deviceId.isNotEmpty() &&144        isDeviceType(frame.deviceType) &&145        Hex64.matches(frame.longTermPublicKeyHex)146147/**148 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the149 * granted root key actually derives the advertised vault id (catching a lying or150 * corrupted source before it can seal a mismatched vault), seals the local vault,151 * applies any carried license, returns our identity, and records the source as a152 * paired device. Returns null on any protocol failure (reason via [deps.onError]).153 */154suspend fun requestAdoption(155    channel: PairingTransport,156    deps: AdoptionReceiverDeps,157    nowMs: () -> Long = { System.currentTimeMillis() },158): PairedDevice? {159    // Fresh every attempt, and deliberately not our real device id: no identity160    // exists yet at this point in the protocol.161    sendFrame(channel, AdoptionFrame.Request(162        sessionRequestId = deps.generateDeviceId(),163        deviceName = deps.deviceName,164        deviceType = deps.deviceType.wireValue,165    ))166167    val confirmed = deps.confirmSas()168    sendFrame(channel, AdoptionFrame.Confirm(confirmed))169    if (!confirmed) {170        deps.onError("ADOPT_SAS_REJECTED")171        return null172    }173174    val frame = receiveFrame(channel)175    if (!isAdoptGrant(frame)) {176        deps.onError("ADOPT_UNEXPECTED_FRAME")177        return null178    }179    val grant = frame as AdoptionFrame.Grant180181    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)182    try {183        if (computeVaultId(rootKeyBytes) != grant.vaultId) {184            deps.onError("ADOPT_VAULT_ID_MISMATCH")185            return null186        }187    } finally {188        rootKeyBytes.fill(0)189    }190191    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)192    if (vaultId != grant.vaultId) {193        deps.onError("ADOPT_VAULT_ID_MISMATCH")194        return null195    }196197    grant.license?.let { deps.applyLicense(it) }198199    val identity = deps.deriveLocalIdentity()200    sendFrame(channel, AdoptionFrame.Identity(201        deviceId = identity.deviceId,202        deviceName = identity.deviceName,203        deviceType = identity.deviceType.wireValue,204        longTermPublicKeyHex = identity.longTermPublicKeyHex,205        signingPublicKeyHex = identity.signingPublicKeyHex,206    ))207208    val sourceDevice = PairedDevice(209        deviceId = grant.source.deviceId,210        deviceName = grant.source.deviceName,211        deviceType = grant.source.deviceType,212        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,213        signingPublicKeyHex = grant.source.signingPublicKeyHex,214        trustSource = "direct",215        wsPort = grant.source.wsPort,216        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,217        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,218        pairedAt = nowMs(),219    )220    // A physically confirmed direct re-pair is the sole path allowed to lift221    // terminal revocation. Clear before upsert so storage can never expose the222    // source as both paired and revoked, even transiently.223    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)224    deps.upsertPairedDevice(sourceDevice)225    return sourceDevice226}227228/**229 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the230 * root key, then records the receiver's returned identity as a paired device.231 * Returns null if consent is denied or the receiver's identity frame is invalid.232 */233suspend fun grantAdoption(234    channel: PairingTransport,235    request: AdoptionFrame.Request,236    deps: AdoptionSourceDeps,237    nowMs: () -> Long = { System.currentTimeMillis() },238): PairedDevice? {239    if (!deps.onConsent(request)) return null240241    val confirmation = receiveFrame(channel)242    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {243        return null244    }245246    val rootKeyHex = deps.rootKeyHex()247    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")248249    sendFrame(channel, AdoptionFrame.Grant(250        rootKeyHex = rootKeyHex,251        vaultId = deps.vaultId(),252        license = deps.license(),253        source = AdoptionFrame.Source(254            deviceId = deps.sourceIdentity.deviceId,255            deviceName = deps.sourceIdentity.deviceName,256            deviceType = deps.sourceIdentity.deviceType.wireValue,257            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,258            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,259            wsPort = deps.sourceIdentity.wsPort,260            relayUrl = deps.pairState?.relayUrl,261        ),262    ))263264    val identity = receiveFrame(channel)265    if (!isAdoptIdentity(identity)) return null266    identity as AdoptionFrame.Identity267268    val device = PairedDevice(269        deviceId = identity.deviceId,270        deviceName = identity.deviceName,271        deviceType = identity.deviceType,272        longTermPublicKeyHex = identity.longTermPublicKeyHex,273        signingPublicKeyHex = identity.signingPublicKeyHex,274        trustSource = "direct",275        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,276        relayUrl = deps.pairState?.relayUrl,277        pairedAt = nowMs(),278    )279    deps.upsertPairedDevice(device)280    return device281}282283/**284 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat285 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent286 * optionals exactly like `JSON.stringify` drops `undefined`.287 */288val AdoptJson: Json = Json {289    classDiscriminator = "type"290    encodeDefaults = false291    ignoreUnknownKeys = true292}293294suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {295    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))296}297298/** Decode one frame, tolerating a malformed/unknown payload by returning null. */299suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =300    runCatching {301        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))302    }.getOrNull()303304private fun isDeviceType(value: String): Boolean =305    DeviceType.entries.any { it.wireValue == value }