AdoptionProtocol.kt

17.9 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable56/**7 * Adoption transcript v2.8 *9 * v1 hands over the root-bearing `ADOPT_GRANT` before the receiver's identity10 * has been exchanged or validated, so the source's pending record is keyed on a11 * session label that is explicitly not an identity — the source can list and12 * revoke nothing durable. v2 places a validated identity before every13 * root-bearing frame.14 *15 * Frozen contract: `peach-browser/docs/sync-protocol.md` section 10.16 */1718/** Adoption transcript version. A v1 request carries no version field at all. */19const val ADOPTION_VERSION = 22021private const val REASON_PRO_REQUIRED = "ADOPT_PRO_REQUIRED"22private const val REASON_DEVICE_ID_COLLISION = "ADOPT_DEVICE_ID_COLLISION"2324/**25 * §11.6: legacy v1 adoption is allowed only before a secure reset. A v1 peer's26 * identity is derived from the legacy root and is therefore forgeable by any27 * former root holder — the exact property the reset exists to retire. Both28 * relay source sessions call this one predicate, so the rule cannot hold on one29 * pairing entry point and not the other (PB-26-117).30 */31internal fun allowLegacyV1(postReset: Boolean): Boolean = !postReset3233/**34 * The v2 grant as it appears on the wire.35 *36 * It is deliberately NOT part of [AdoptionFrame]: the frozen v1 `Grant` carries37 * a required `source`, a sealed hierarchy can bind only one class to the38 * `ADOPT_GRANT` discriminator, and widening the v1 type to satisfy a v2 shape39 * would let v1-only code accept a grant it can never receive. The v2 transcript40 * therefore reads and writes this shape directly.41 */42@Serializable43data class AdoptGrantV2(44    val type: String,45    @SerialName("root_key_hex") val rootKeyHex: String,46    @SerialName("vault_id") val vaultId: String,47    val license: String? = null,48)4950private const val GRANT_TYPE = "ADOPT_GRANT"5152/**53 * What the RECEIVER (joining device) needs to adopt a vault over the v254 * transcript: the v1 surface plus the v2 additions. Extending the v1 interface55 * is what makes the union compile-time visible — delegating through a cast56 * would fail only once a peer triggered the missing callback.57 */58interface AdoptionReceiverV2Deps : AdoptionReceiverDeps {59    /** The receiver's durable device identity. Available with no vault present. */60    fun localIdentity(): LocalDeviceIdentity6162    suspend fun rotateLocalIdentity(): LocalDeviceIdentity6364    suspend fun validateSource(prepare: AdoptionFrame.Prepare)6566    suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare): Boolean67}6869/** What the SOURCE (existing device) needs to grant its vault over the v2 transcript. */70interface AdoptionSourceV2Deps : AdoptionSourceDeps {71    suspend fun onConsent(request: AdoptionFrame.RequestV2): Boolean7273    // Re-declared abstract: the v1 defaults would silently accept an unrotated74    // identity or throw only after a peer asked for the callback.75    override suspend fun rotateSourceIdentity(): LocalDeviceIdentity7677    override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean7879    /**80     * Durably record the validated receiver identity before any root-bearing81     * frame. Named apart from the v1 member, which is keyed on a request label.82     */83    suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean8485    /** Promote the pending identity to a direct paired device. */86    suspend fun completePendingIdentity(device: PairedDevice): Boolean8788    fun snapshotCapable(): Boolean89}9091/**92 * Bind a source session's deps to the live session facts.93 *94 * Both relay source adapters — the phone showing a QR, and the phone scanning an95 * empty invitation — build the same object; the only differences are which96 * identity is presented and whether snapshot support is advertised, so both are97 * parameters rather than two near-identical anonymous objects.98 *99 * Extracted from the session functions so §10.5 can be proven: the test dirties100 * every member here and asserts each one reaches its sink. A callback that is101 * dropped, or one that returns without calling through, fails that test.102 */103internal fun bindSourceSessionDeps(104    sourceDeps: AdoptionSourceV2Deps,105    sourceIdentity: LocalDeviceIdentity,106    pairState: AdoptionPairState,107    sas: String,108    snapshotCapable: Boolean,109    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,110): AdoptionSourceV2Deps = object : AdoptionSourceV2Deps {111    override val sourceIdentity = sourceIdentity112    override val pairState = pairState113    override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()114    override suspend fun vaultId() = sourceDeps.vaultId()115    override suspend fun license() = sourceDeps.license()116    override suspend fun applyLicense(license: String) = sourceDeps.applyLicense(license)117    override suspend fun onConsent(request: AdoptionFrame.Request) = true118119    // The transcript sends ADOPT_CONFIRM_REQUIRED before calling this, so the120    // receiver's own prompt is already live when we ask our human to compare the121    // same SAS. The request is only a prompt label here — but the call MUST122    // reach `confirm`: returning true without it was a real defect, because the123    // source-side prompt then never appeared.124    override suspend fun onConsent(request: AdoptionFrame.RequestV2): Boolean =125        confirm(126            sas,127            AdoptionFrame.Request(128                sessionRequestId = request.deviceId,129                deviceName = request.deviceName,130                deviceType = request.deviceType,131                license = request.license,132                snapshotCapable = request.snapshotCapable,133            ),134        )135136    override suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long) =137        sourceDeps.persistPendingHolder(request, nowMs)138139    override suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice) =140        sourceDeps.completePendingHolder(request, device)141142    override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) =143        sourceDeps.persistPendingIdentity(device, nowMs)144145    override suspend fun completePendingIdentity(device: PairedDevice) =146        sourceDeps.completePendingIdentity(device)147148    override suspend fun upsertPairedDevice(device: PairedDevice) =149        sourceDeps.upsertPairedDevice(device)150151    override fun hasDeviceIdentityCollision(device: PairedDevice) =152        sourceDeps.hasDeviceIdentityCollision(device)153154    override suspend fun rotateSourceIdentity() = sourceDeps.rotateSourceIdentity()155    override fun snapshotCapable() = snapshotCapable156    override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()157    override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()158    override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)159    override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()160}161162private fun AdoptionFrame.RequestV2.valid(): Boolean =163    adoptionVersion == ADOPTION_VERSION &&164        deviceId.isNotEmpty() &&165        Hex64.matches(longTermPublicKeyHex) &&166        Hex64.matches(signingPublicKeyHex) &&167        isDeviceType(deviceType)168169private fun AdoptionFrame.Prepare.valid(): Boolean =170    deviceId.isNotEmpty() &&171        Hex64.matches(longTermPublicKeyHex) &&172        Hex64.matches(signingPublicKeyHex) &&173        isDeviceType(deviceType) &&174        Hex64.matches(vaultId)175176/**177 * RECEIVER side: present our durable identity, then adopt the vault the source178 * prepares. Returns null on any protocol failure, with the reason via179 * [AdoptionReceiverV2Deps.onError].180 */181suspend fun requestAdoptionV2(182    channel: PairingTransport,183    deps: AdoptionReceiverV2Deps,184    // Called with the accepted ADOPT_PREPARE once it has been validated, so the185    // caller can read the source's capabilities before the root is granted.186    onPrepare: ((AdoptionFrame.Prepare) -> Unit)? = null,187    nowMs: Long = System.currentTimeMillis(),188): PairedDevice? {189    val receiverLicense = deps.license()190191    fun requestFrame(identity: LocalDeviceIdentity): AdoptionFrame.RequestV2 =192        AdoptionFrame.RequestV2(193            adoptionVersion = ADOPTION_VERSION,194            deviceId = identity.deviceId,195            deviceName = identity.deviceName,196            deviceType = identity.deviceType.wireValue,197            longTermPublicKeyHex = identity.longTermPublicKeyHex,198            signingPublicKeyHex = identity.signingPublicKeyHex,199            snapshotCapable = true,200        )201202    // A malformed root key must not escape as an exception; every failure here203    // is the same protocol outcome.204    fun rootKeyMatchesVault(rootKeyHex: String, vaultId: String): Boolean {205        if (!validateRootKeyHex(rootKeyHex) || !Hex64.matches(vaultId)) return false206        val rootKeyBytes = hexToPubkey(rootKeyHex)207        return try {208            computeVaultId(rootKeyBytes) == vaultId209        } finally {210            rootKeyBytes.fill(0)211        }212    }213214    sendFrame(channel, requestFrame(deps.localIdentity()))215216    if (receiveFrame(channel) !is AdoptionFrame.ConfirmRequired) {217        deps.onError("ADOPT_UNEXPECTED_FRAME")218        return null219    }220221    val confirmed = deps.confirmSas()222    // The Pro key travels only after the SAS decision (PB-26-121): a peer that223    // controls the scanned invitation reads the request frame before the user224    // has compared anything.225    sendFrame(channel, AdoptionFrame.Confirm(confirmed, license = receiverLicense))226    if (!confirmed) {227        deps.onError("ADOPT_SAS_REJECTED")228        return null229    }230231    // The source rejects our identity once if it is already in its terminal232    // ledger; we rotate exactly once and present the fresh identity.233    var prepared = receiveFrame(channel)234    if (prepared is AdoptionFrame.Reject && prepared.reason == REASON_DEVICE_ID_COLLISION) {235        sendFrame(channel, requestFrame(deps.rotateLocalIdentity()))236        prepared = receiveFrame(channel)237    }238    if (prepared is AdoptionFrame.Reject) {239        deps.onError(prepared.reason)240        return null241    }242    if (prepared !is AdoptionFrame.Prepare || !prepared.valid()) {243        deps.onError("ADOPT_UNEXPECTED_FRAME")244        return null245    }246247    deps.validateSource(prepared)248249    // The source's identity may be in our terminal ledger. Reject once, accept250    // only a rotated preparation, and treat a second collision as terminal.251    if (deps.hasSourceIdentityCollision(prepared)) {252        sendFrame(channel, AdoptionFrame.Reject(REASON_DEVICE_ID_COLLISION))253        val rotated = receiveFrame(channel)254        if (rotated !is AdoptionFrame.Prepare || !rotated.valid()) {255            deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED")256            return null257        }258        deps.validateSource(rotated)259        if (deps.hasSourceIdentityCollision(rotated)) {260            sendFrame(channel, AdoptionFrame.Reject(REASON_DEVICE_ID_COLLISION))261            deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED")262            return null263        }264        prepared = rotated265    }266267    if (receiverLicense == null && prepared.license == null) {268        deps.onError(REASON_PRO_REQUIRED)269        return null270    }271272    onPrepare?.invoke(prepared)273274    sendFrame(channel, AdoptionFrame.Ready)275276    val raw = String(channel.receive(), Charsets.UTF_8)277    // Either the v2 grant or a terminal rejection.278    val rejection = runCatching {279        AdoptJson.decodeFromString(AdoptionFrame.serializer(), raw)280    }.getOrNull() as? AdoptionFrame.Reject281    if (rejection != null) {282        deps.onError(rejection.reason)283        return null284    }285    val grant = runCatching {286        AdoptJson.decodeFromString(AdoptGrantV2.serializer(), raw)287    }.getOrNull()288    if (grant == null || grant.type != GRANT_TYPE) {289        deps.onError("ADOPT_UNEXPECTED_FRAME")290        return null291    }292    // The source may not change the vault between preparing and granting, and293    // the granted root key must actually derive that vault id.294    if (grant.vaultId != prepared.vaultId || !rootKeyMatchesVault(grant.rootKeyHex, grant.vaultId)) {295        deps.onError("ADOPT_VAULT_ID_MISMATCH")296        return null297    }298299    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)300    if (vaultId != grant.vaultId) {301        deps.onError("ADOPT_VAULT_ID_MISMATCH")302        return null303    }304    if (receiverLicense == null) {305        grant.license?.let { deps.applyLicense(it) }306    }307308    // The source is recorded from the validated preparation, never from the309    // grant: the preparation is what we validated against our terminal ledger.310    val sourceDevice = PairedDevice(311        deviceId = prepared.deviceId,312        deviceName = prepared.deviceName,313        deviceType = prepared.deviceType,314        longTermPublicKeyHex = prepared.longTermPublicKeyHex,315        signingPublicKeyHex = prepared.signingPublicKeyHex,316        trustSource = "direct",317        wsPort = prepared.wsPort,318        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,319        relayUrl = deps.pairState?.relayUrl ?: prepared.relayUrl,320        pairedAt = nowMs,321    )322    deps.upsertPairedDeviceAndClearRevocation(sourceDevice)323    sendFrame(channel, AdoptionFrame.Applied)324325    if (receiveFrame(channel) !is AdoptionFrame.Complete) {326        deps.onError("ADOPT_COMPLETION_MISSING")327        return null328    }329    return sourceDevice330}331332/**333 * SOURCE side: answer a v2 request. The receiver's validated identity is334 * durably recorded before any root-bearing frame, and is promoted to a direct335 * paired device only once the receiver acknowledges the vault.336 */337suspend fun grantAdoptionV2(338    channel: PairingTransport,339    request: AdoptionFrame.RequestV2,340    deps: AdoptionSourceV2Deps,341    nowMs: Long = System.currentTimeMillis(),342): PairedDevice? {343    if (!request.valid()) return null344345    // Acknowledge before asking the human, so the receiver can raise its own346    // SAS prompt instead of waiting on this device's consent dialog.347    sendFrame(channel, AdoptionFrame.ConfirmRequired)348    if (!deps.onConsent(request)) return null349350    val confirmation = receiveFrame(channel)351    if (confirmation !is AdoptionFrame.Confirm || !confirmation.confirmed) return null352353    fun receiverFrom(identity: AdoptionFrame.RequestV2): PairedDevice =354        PairedDevice(355            deviceId = identity.deviceId,356            deviceName = identity.deviceName,357            deviceType = identity.deviceType,358            longTermPublicKeyHex = identity.longTermPublicKeyHex,359            signingPublicKeyHex = identity.signingPublicKeyHex,360            trustSource = "direct",361            rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,362            relayUrl = deps.pairState?.relayUrl,363            pairedAt = nowMs,364        )365366    // The receiver's identity may collide with a paired device or a terminal367    // revocation. Reject once, accept only a rotated request, then stop.368    var receiver = receiverFrom(request)369    if (deps.hasDeviceIdentityCollision(receiver)) {370        sendFrame(channel, AdoptionFrame.Reject(REASON_DEVICE_ID_COLLISION))371        val rotated = receiveFrame(channel)372        if (rotated !is AdoptionFrame.RequestV2 || !rotated.valid()) return null373        receiver = receiverFrom(rotated)374        if (deps.hasDeviceIdentityCollision(receiver)) {375            sendFrame(channel, AdoptionFrame.Reject(REASON_DEVICE_ID_COLLISION))376            return null377        }378    }379380    var license = deps.license()381    // The receiver's key arrives on the confirm frame, after both SAS decisions382    // (PB-26-121). request.license is read only for a peer built before that383    // change.384    val receiverLicense = confirmation.license ?: request.license385    if (license == null && receiverLicense != null) {386        deps.applyLicense(receiverLicense)387        license = deps.license()388    }389    if (license == null) {390        sendFrame(channel, AdoptionFrame.Reject(REASON_PRO_REQUIRED))391        return null392    }393394    // No root-bearing frame before this commit: afterwards the receiver is395    // listable and revocable even if the session dies mid-grant.396    if (!deps.persistPendingIdentity(receiver, nowMs)) return null397398    val rootKeyHex = deps.rootKeyHex()399    if (!validateRootKeyHex(rootKeyHex)) return null400401    var sourceIdentity = deps.sourceIdentity402    var sourceRotations = 0403    while (true) {404        sendFrame(405            channel,406            AdoptionFrame.Prepare(407                deviceId = sourceIdentity.deviceId,408                deviceName = sourceIdentity.deviceName,409                deviceType = sourceIdentity.deviceType.wireValue,410                longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,411                signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,412                vaultId = deps.vaultId(),413                wsPort = sourceIdentity.wsPort,414                relayUrl = deps.pairState?.relayUrl,415                license = license,416                snapshotCapable = deps.snapshotCapable(),417            ),418        )419420        val next = receiveFrame(channel)421        if (next is AdoptionFrame.Reject &&422            next.reason == REASON_DEVICE_ID_COLLISION &&423            sourceRotations == 0424        ) {425            sourceIdentity = deps.rotateSourceIdentity()426            sourceRotations++427            continue428        }429        if (next !is AdoptionFrame.Ready) return null430        break431    }432433    // Sent with its own serializer: the v2 grant carries no source, so it is434    // not a member of the AdoptionFrame hierarchy (see AdoptGrantV2).435    channel.send(436        AdoptJson.encodeToString(437            AdoptGrantV2.serializer(),438            AdoptGrantV2(439                type = GRANT_TYPE,440                rootKeyHex = rootKeyHex,441                vaultId = deps.vaultId(),442                license = license,443            ),444        ).toByteArray(Charsets.UTF_8),445    )446447    if (receiveFrame(channel) !is AdoptionFrame.Applied) return null448449    // Promotion is legal only now: the receiver holds the vault and has said so.450    if (!deps.completePendingIdentity(receiver)) return null451    deps.upsertPairedDevice(receiver)452    sendFrame(channel, AdoptionFrame.Complete)453    return receiver454}