VaultAdopt.kt

13.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/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    /**67     * Encrypted source-to-receiver acknowledgement emitted only after the source68     * has durably recorded the receiver. The receiver cannot truthfully report a69     * successful pair before this arrives.70     */71    @Serializable72    @SerialName("ADOPT_COMPLETE")73    data object Complete : AdoptionFrame()7475    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */76    @Serializable77    @SerialName("ADOPT_APPLIED")78    data object Applied : AdoptionFrame()7980    @Serializable81    data class Source(82        @SerialName("device_id") val deviceId: String,83        @SerialName("device_name") val deviceName: String,84        @SerialName("device_type") val deviceType: String,85        @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,86        @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,87        @SerialName("ws_port") val wsPort: Int? = null,88        @SerialName("relay_url") val relayUrl: String? = null,89    )90}9192data class AdoptionPairState(93    val rendezvousSecretHex: String,94    val relayUrl: String? = null,95)9697/** What the RECEIVER (joining device) needs to adopt a vault from a source. */98interface AdoptionReceiverDeps {99    val deviceName: String100    val deviceType: DeviceType101    fun generateDeviceId(): String102    /** Root-key transfer cannot begin until this device confirms the SAS. */103    suspend fun confirmSas(): Boolean104    /**105     * Local safety policy evaluated after the authenticated grant is validated106     * but before any vault state changes.107     */108    suspend fun validateSource(source: AdoptionFrame.Source) {}109110    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */111    suspend fun createVaultFromRootKey(rootKeyHex: String): String112    suspend fun deriveLocalIdentity(): LocalDeviceIdentity113    /** Only called after a successful, SAS-confirmed direct pairing. */114    suspend fun clearPairedDeviceRevocation(deviceId: String) {}115    suspend fun upsertPairedDevice(device: PairedDevice)116    suspend fun applyLicense(license: String) {}117    val pairState: AdoptionPairState? get() = null118    fun onError(error: String)119}120121/** What the SOURCE (existing device) needs to grant its vault to a receiver. */122interface AdoptionSourceDeps {123    val sourceIdentity: LocalDeviceIdentity124    suspend fun rootKeyHex(): String125    suspend fun vaultId(): String126    suspend fun license(): String? = null127128    /**129     * Ask the user to approve handing this vault to the requesting device. The130     * request's name/type/label are for the human to read; the decision must not131     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which132     * carries no continuity between attempts.133     */134    suspend fun onConsent(request: AdoptionFrame.Request): Boolean135    suspend fun upsertPairedDevice(device: PairedDevice)136    val pairState: AdoptionPairState? get() = null137}138139private val Hex64 = Regex("^[0-9a-f]{64}\$")140141fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)142143fun isAdoptRequest(frame: AdoptionFrame?): Boolean =144    frame is AdoptionFrame.Request &&145        frame.sessionRequestId.isNotEmpty() &&146        isDeviceType(frame.deviceType)147148fun isAdoptGrant(frame: AdoptionFrame?): Boolean =149    frame is AdoptionFrame.Grant &&150        validateRootKeyHex(frame.rootKeyHex) &&151        Hex64.matches(frame.vaultId) &&152        isDeviceType(frame.source.deviceType) &&153        Hex64.matches(frame.source.longTermPublicKeyHex) &&154        (frame.source.relayUrl == null ||155            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)156157fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =158    frame is AdoptionFrame.Confirm159160fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =161    frame is AdoptionFrame.Identity &&162        frame.deviceId.isNotEmpty() &&163        isDeviceType(frame.deviceType) &&164        Hex64.matches(frame.longTermPublicKeyHex)165166fun isAdoptComplete(frame: AdoptionFrame?): Boolean =167    frame is AdoptionFrame.Complete168169fun isAdoptApplied(frame: AdoptionFrame?): Boolean =170    frame is AdoptionFrame.Applied171172/**173 * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the174 * granted root key actually derives the advertised vault id (catching a lying or175 * corrupted source before it can seal a mismatched vault), seals the local vault,176 * applies any carried license, returns our identity, and records the source as a177 * paired device. Returns null on any protocol failure (reason via [deps.onError]).178 */179suspend fun requestAdoption(180    channel: PairingTransport,181    deps: AdoptionReceiverDeps,182    nowMs: () -> Long = { System.currentTimeMillis() },183): PairedDevice? {184    // Fresh every attempt, and deliberately not our real device id: no identity185    // exists yet at this point in the protocol.186    sendFrame(channel, AdoptionFrame.Request(187        sessionRequestId = deps.generateDeviceId(),188        deviceName = deps.deviceName,189        deviceType = deps.deviceType.wireValue,190    ))191192    val confirmed = deps.confirmSas()193    sendFrame(channel, AdoptionFrame.Confirm(confirmed))194    if (!confirmed) {195        deps.onError("ADOPT_SAS_REJECTED")196        return null197    }198199    val frame = receiveFrame(channel)200    if (!isAdoptGrant(frame)) {201        deps.onError("ADOPT_UNEXPECTED_FRAME")202        return null203    }204    val grant = frame as AdoptionFrame.Grant205206    val rootKeyBytes = hexToPubkey(grant.rootKeyHex)207    try {208        if (computeVaultId(rootKeyBytes) != grant.vaultId) {209            deps.onError("ADOPT_VAULT_ID_MISMATCH")210            return null211        }212    } finally {213        rootKeyBytes.fill(0)214    }215216    deps.validateSource(grant.source)217    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)218    if (vaultId != grant.vaultId) {219        deps.onError("ADOPT_VAULT_ID_MISMATCH")220        return null221    }222223    grant.license?.let { deps.applyLicense(it) }224225    val identity = deps.deriveLocalIdentity()226    sendFrame(channel, AdoptionFrame.Identity(227        deviceId = identity.deviceId,228        deviceName = identity.deviceName,229        deviceType = identity.deviceType.wireValue,230        longTermPublicKeyHex = identity.longTermPublicKeyHex,231        signingPublicKeyHex = identity.signingPublicKeyHex,232    ))233234    if (!isAdoptComplete(receiveFrame(channel))) {235        deps.onError("ADOPT_COMPLETION_MISSING")236        return null237    }238239    val sourceDevice = PairedDevice(240        deviceId = grant.source.deviceId,241        deviceName = grant.source.deviceName,242        deviceType = grant.source.deviceType,243        longTermPublicKeyHex = grant.source.longTermPublicKeyHex,244        signingPublicKeyHex = grant.source.signingPublicKeyHex,245        trustSource = "direct",246        wsPort = grant.source.wsPort,247        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,248        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,249        pairedAt = nowMs(),250    )251    // A physically confirmed direct re-pair is the sole path allowed to lift252    // terminal revocation. Clear before upsert so storage can never expose the253    // source as both paired and revoked, even transiently.254    deps.clearPairedDeviceRevocation(sourceDevice.deviceId)255    deps.upsertPairedDevice(sourceDevice)256    sendFrame(channel, AdoptionFrame.Applied)257    return sourceDevice258}259260/**261 * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the262 * root key, then records the receiver's returned identity as a paired device.263 * Returns null if consent is denied or the receiver's identity frame is invalid.264 */265suspend fun grantAdoption(266    channel: PairingTransport,267    request: AdoptionFrame.Request,268    deps: AdoptionSourceDeps,269    nowMs: () -> Long = { System.currentTimeMillis() },270): PairedDevice? {271    if (!deps.onConsent(request)) return null272273    val confirmation = receiveFrame(channel)274    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {275        return null276    }277278    val rootKeyHex = deps.rootKeyHex()279    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")280281    sendFrame(channel, AdoptionFrame.Grant(282        rootKeyHex = rootKeyHex,283        vaultId = deps.vaultId(),284        license = deps.license(),285        source = AdoptionFrame.Source(286            deviceId = deps.sourceIdentity.deviceId,287            deviceName = deps.sourceIdentity.deviceName,288            deviceType = deps.sourceIdentity.deviceType.wireValue,289            longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,290            signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,291            wsPort = deps.sourceIdentity.wsPort,292            relayUrl = deps.pairState?.relayUrl,293        ),294    ))295296    val identity = receiveFrame(channel)297    if (!isAdoptIdentity(identity)) return null298    identity as AdoptionFrame.Identity299300    val device = PairedDevice(301        deviceId = identity.deviceId,302        deviceName = identity.deviceName,303        deviceType = identity.deviceType,304        longTermPublicKeyHex = identity.longTermPublicKeyHex,305        signingPublicKeyHex = identity.signingPublicKeyHex,306        trustSource = "direct",307        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,308        relayUrl = deps.pairState?.relayUrl,309        pairedAt = nowMs(),310    )311    deps.upsertPairedDevice(device)312    sendFrame(channel, AdoptionFrame.Complete)313    if (!isAdoptApplied(receiveFrame(channel))) return null314    return device315}316317/**318 * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat319 * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent320 * optionals exactly like `JSON.stringify` drops `undefined`.321 */322val AdoptJson: Json = Json {323    classDiscriminator = "type"324    encodeDefaults = false325    ignoreUnknownKeys = true326}327328suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {329    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))330}331332/** Decode one frame, tolerating a malformed/unknown payload by returning null. */333suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =334    runCatching {335        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))336    }.getOrNull()337338private fun isDeviceType(value: String): Boolean =339    DeviceType.entries.any { it.wireValue == value }