VaultAdopt.kt

11.4 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    suspend fun upsertPairedDevice(device: PairedDevice)95    suspend fun applyLicense(license: String) {}96    val pairState: AdoptionPairState? get() = null97    fun onError(error: String)98}99100/** What the SOURCE (existing device) needs to grant its vault to a receiver. */101interface AdoptionSourceDeps {102    val sourceIdentity: LocalDeviceIdentity103    suspend fun rootKeyHex(): String104    suspend fun vaultId(): String105    suspend fun license(): String? = null106107    /**108     * Ask the user to approve handing this vault to the requesting device. The109     * request's name/type/label are for the human to read; the decision must not110     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which111     * carries no continuity between attempts.112     */113    suspend fun onConsent(request: AdoptionFrame.Request): Boolean114    suspend fun upsertPairedDevice(device: PairedDevice)115    val pairState: AdoptionPairState? get() = null116}117118private val Hex64 = Regex("^[0-9a-f]{64}\$")119120fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)121122fun isAdoptRequest(frame: AdoptionFrame?): Boolean =123    frame is AdoptionFrame.Request &&124        frame.sessionRequestId.isNotEmpty() &&125        isDeviceType(frame.deviceType)126127fun isAdoptGrant(frame: AdoptionFrame?): Boolean =128    frame is AdoptionFrame.Grant &&129        validateRootKeyHex(frame.rootKeyHex) &&130        Hex64.matches(frame.vaultId) &&131        isDeviceType(frame.source.deviceType) &&132        Hex64.matches(frame.source.longTermPublicKeyHex) &&133        (frame.source.relayUrl == null ||134            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)135136fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =137    frame is AdoptionFrame.Confirm138139fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =140    frame is AdoptionFrame.Identity &&141        frame.deviceId.isNotEmpty() &&142        isDeviceType(frame.deviceType) &&143        Hex64.matches(frame.longTermPublicKeyHex)144145/**146 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the147 * granted root key actually derives the advertised vault id (catching a lying or148 * corrupted source before it can seal a mismatched vault), seals the local vault,149 * applies any carried license, returns our identity, and records the source as a150 * paired device. Returns null on any protocol failure (reason via [deps.onError]).151 */152suspend fun requestAdoption(153    channel: PairingTransport,154    deps: AdoptionReceiverDeps,155    nowMs: () -> Long = { System.currentTimeMillis() },156): PairedDevice? {157    // Fresh every attempt, and deliberately not our real device id: no identity158    // exists yet at this point in the protocol.159    sendFrame(channel, AdoptionFrame.Request(160        sessionRequestId = deps.generateDeviceId(),161        deviceName = deps.deviceName,162        deviceType = deps.deviceType.wireValue,163    ))164165    val confirmed = deps.confirmSas()166    sendFrame(channel, AdoptionFrame.Confirm(confirmed))167    if (!confirmed) {168        deps.onError("ADOPT_SAS_REJECTED")169        return null170    }171172    val frame = receiveFrame(channel)173    if (!isAdoptGrant(frame)) {174        deps.onError("ADOPT_UNEXPECTED_FRAME")175        return null176    }177    val grant = frame as AdoptionFrame.Grant178179    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)180    try {181        if (computeVaultId(rootKeyBytes) != grant.vaultId) {182            deps.onError("ADOPT_VAULT_ID_MISMATCH")183            return null184        }185    } finally {186        rootKeyBytes.fill(0)187    }188189    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)190    if (vaultId != grant.vaultId) {191        deps.onError("ADOPT_VAULT_ID_MISMATCH")192        return null193    }194195    grant.license?.let { deps.applyLicense(it) }196197    val identity = deps.deriveLocalIdentity()198    sendFrame(channel, AdoptionFrame.Identity(199        deviceId = identity.deviceId,200        deviceName = identity.deviceName,201        deviceType = identity.deviceType.wireValue,202        longTermPublicKeyHex = identity.longTermPublicKeyHex,203        signingPublicKeyHex = identity.signingPublicKeyHex,204    ))205206    val sourceDevice = PairedDevice(207        deviceId = grant.source.deviceId,208        deviceName = grant.source.deviceName,209        deviceType = grant.source.deviceType,210        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,211        signingPublicKeyHex = grant.source.signingPublicKeyHex,212        trustSource = "direct",213        wsPort = grant.source.wsPort,214        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,215        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,216        pairedAt = nowMs(),217    )218    deps.upsertPairedDevice(sourceDevice)219    return sourceDevice220}221222/**223 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the224 * root key, then records the receiver's returned identity as a paired device.225 * Returns null if consent is denied or the receiver's identity frame is invalid.226 */227suspend fun grantAdoption(228    channel: PairingTransport,229    request: AdoptionFrame.Request,230    deps: AdoptionSourceDeps,231    nowMs: () -> Long = { System.currentTimeMillis() },232): PairedDevice? {233    if (!deps.onConsent(request)) return null234235    val confirmation = receiveFrame(channel)236    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {237        return null238    }239240    val rootKeyHex = deps.rootKeyHex()241    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")242243    sendFrame(channel, AdoptionFrame.Grant(244        rootKeyHex = rootKeyHex,245        vaultId = deps.vaultId(),246        license = deps.license(),247        source = AdoptionFrame.Source(248            deviceId = deps.sourceIdentity.deviceId,249            deviceName = deps.sourceIdentity.deviceName,250            deviceType = deps.sourceIdentity.deviceType.wireValue,251            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,252            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,253            wsPort = deps.sourceIdentity.wsPort,254            relayUrl = deps.pairState?.relayUrl,255        ),256    ))257258    val identity = receiveFrame(channel)259    if (!isAdoptIdentity(identity)) return null260    identity as AdoptionFrame.Identity261262    val device = PairedDevice(263        deviceId = identity.deviceId,264        deviceName = identity.deviceName,265        deviceType = identity.deviceType,266        longTermPublicKeyHex = identity.longTermPublicKeyHex,267        signingPublicKeyHex = identity.signingPublicKeyHex,268        trustSource = "direct",269        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,270        relayUrl = deps.pairState?.relayUrl,271        pairedAt = nowMs(),272    )273    deps.upsertPairedDevice(device)274    return device275}276277/**278 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat279 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent280 * optionals exactly like `JSON.stringify` drops `undefined`.281 */282val AdoptJson: Json = Json {283    classDiscriminator = "type"284    encodeDefaults = false285    ignoreUnknownKeys = true286}287288suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {289    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))290}291292/** Decode one frame, tolerating a malformed/unknown payload by returning null. */293suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =294    runCatching {295        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))296    }.getOrNull()297298private fun isDeviceType(value: String): Boolean =299    DeviceType.entries.any { it.wireValue == value }