SyncGeneration.kt

8.9 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.Serializable4import kotlinx.serialization.json.Json56/**7 * The durable Sync-generation state machine (protocol §11).8 *9 * Pure and storage-agnostic so every transition — and every crash point inside10 * one — is unit-testable without a Keystore. [SyncGenerationStore] is the only11 * adapter that touches storage.12 */1314const val LEGACY_ACTIVE = "LEGACY_ACTIVE"15const val RESET_REQUIRED = "RESET_REQUIRED"16const val RESET_CONFIRMED = "RESET_CONFIRMED"17const val V2_ANCHOR = "V2_ANCHOR"18const val REPAIRING = "REPAIRING"19const val V2_ACTIVE = "V2_ACTIVE"2021/** No record could be read or derived. Refuses sync rather than guessing a generation. */22const val UNKNOWN_STATE = "UNKNOWN"2324/** User-visible outcomes. Distinct on purpose: they ask the user for different things. */25const val RESET_REQUIRED_OUTCOME = "reset_required"26const val RESET_NOT_COMPLETED = "reset_not_completed"27const val PEER_PROTOCOL_OUTDATED = "peer_protocol_outdated"28const val SYNC_GENERATION_UNKNOWN = "sync_generation_unknown"2930const val SYNC_GENERATION_SCHEMA_VERSION = 131private const val LEGACY_GENERATION = 132private const val V2_GENERATION = 23334@Serializable35data class SyncGeneration(36    val schemaVersion: Int = SYNC_GENERATION_SCHEMA_VERSION,37    val generation: Int,38    val state: String,39    val anchorDeviceId: String? = null,40    val legacyArchiveRef: String? = null,41    val createdAt: Long,42)4344/**45 * One blob, one key. `previous` is retained so a rewrite of the active record46 * can be validated against what it replaces, and so a crash on either side of47 * that write is recoverable (§11.2, seal before switch).48 */49@Serializable50data class SyncGenerationBlob(51    val schemaVersion: Int = SYNC_GENERATION_SCHEMA_VERSION,52    val active: SyncGeneration,53    val previous: SyncGeneration? = null,54)5556enum class SyncDecision { ALLOWED, RESET_REQUIRED, UNKNOWN }5758enum class ResetResult { V2_ANCHOR, RESET_NOT_COMPLETED }5960/**61 * The storage boundary. Deliberately coarse: the trust probe is split per class62 * so a test can assert the refusal for each one, and the archive and clear steps63 * are separate so a crash between them is injectable.64 */65interface SyncGenerationPort {66    fun readBlob(): String?67    fun writeBlob(blob: String): Boolean68    fun hasPairedDevices(): Boolean69    fun hasRevocations(): Boolean70    fun hasIssuerSigningKeys(): Boolean71    fun hasPendingVouchedDevices(): Boolean72    fun archiveLegacyState(generation: Int): String?73    fun clearLegacyTrustState(): Boolean74    fun ensureV2Identity(): Boolean75    fun localDeviceId(): String?76}7778// ignoreUnknownKeys keeps a future additive field readable; schemaVersion is79// checked explicitly, so an unrecognised schema still resolves to UNKNOWN.80private val json = Json { ignoreUnknownKeys = true }81private val states = setOf(LEGACY_ACTIVE, RESET_REQUIRED, RESET_CONFIRMED, V2_ANCHOR, REPAIRING, V2_ACTIVE)8283private fun unknownGeneration(nowMs: Long) = SyncGeneration(84    generation = LEGACY_GENERATION,85    state = UNKNOWN_STATE,86    createdAt = nowMs,87)8889fun SyncGenerationPort.hasLegacyTrustState(): Boolean =90    hasPairedDevices() || hasRevocations() || hasIssuerSigningKeys() || hasPendingVouchedDevices()9192/**93 * Read the persisted record, or derive one when none has ever been written.94 * A blob that is unreadable, of another schema, or in an unknown state is95 * never reinterpreted: it resolves to UNKNOWN and sync stops.96 */97fun resolveGeneration(port: SyncGenerationPort, nowMs: Long): SyncGeneration {98    val raw = port.readBlob()99    if (raw != null) {100        return runCatching {101            json.decodeFromString<SyncGenerationBlob>(raw).takeIf { it.schemaVersion == SYNC_GENERATION_SCHEMA_VERSION }102                ?.active103                ?.takeIf { it.generation >= LEGACY_GENERATION && it.state in states }104        }.getOrNull() ?: unknownGeneration(nowMs)105    }106107    val hasTrustState = port.hasLegacyTrustState()108    val derived = SyncGeneration(109        generation = if (hasTrustState) LEGACY_GENERATION else V2_GENERATION,110        state = if (hasTrustState) LEGACY_ACTIVE else V2_ACTIVE,111        createdAt = nowMs,112    )113    return if (port.writeBlob(encodeGeneration(SyncGenerationBlob(active = derived)))) derived else unknownGeneration(nowMs)114}115116/**117 * Enter the state that blocks sync and states the migration. Reached by the118 * user asking for a reset, and by the create-on-miss refusal. Idempotent, and119 * never moves a device that has already reset back into the legacy generation.120 */121fun enterResetRequired(port: SyncGenerationPort, nowMs: Long): SyncGeneration {122    val current = resolveGeneration(port, nowMs)123    if (current.state != LEGACY_ACTIVE && current.state != RESET_REQUIRED) return current124    val next = current.copy(state = RESET_REQUIRED)125    port.writeBlob(encodeGeneration(SyncGenerationBlob(active = next, previous = current)))126    return next127}128129/**130 * Archive the legacy generation, mint the v2 identity, then flip the pointer —131 * in that order, so every crash point leaves either a recoverable132 * RESET_CONFIRMED or a committed V2_ANCHOR, and never a lost archive (§11.3).133 *134 * The caller has already confirmed the master password. `checkpoint` fires135 * immediately before each step that can fail; a test throws from it to inject a136 * crash at exactly that point.137 */138fun confirmSecureReset(port: SyncGenerationPort, nowMs: Long, checkpoint: (String) -> Unit = {}): ResetResult {139    val current = resolveGeneration(port, nowMs)140    if (current.state !in setOf(LEGACY_ACTIVE, RESET_REQUIRED, RESET_CONFIRMED)) return ResetResult.RESET_NOT_COMPLETED141142    fun persist(state: SyncGeneration): Boolean =143        port.writeBlob(encodeGeneration(SyncGenerationBlob(active = state, previous = current)))144145    checkpoint("before-confirmed")146    if (!persist(current.copy(state = RESET_CONFIRMED))) return ResetResult.RESET_NOT_COMPLETED147148    checkpoint("before-archive")149    val archiveRef = port.archiveLegacyState(current.generation) ?: return ResetResult.RESET_NOT_COMPLETED150    checkpoint("after-archive")151152    checkpoint("before-clear")153    if (!port.clearLegacyTrustState()) return ResetResult.RESET_NOT_COMPLETED154    checkpoint("after-clear")155156    checkpoint("before-identity")157    if (!port.ensureV2Identity()) return ResetResult.RESET_NOT_COMPLETED158    checkpoint("after-identity")159160    val next = SyncGeneration(161        generation = current.generation + 1,162        state = V2_ANCHOR,163        anchorDeviceId = port.localDeviceId(),164        legacyArchiveRef = archiveRef,165        createdAt = nowMs,166    )167    checkpoint("before-generation-write")168    if (!port.writeBlob(encodeGeneration(SyncGenerationBlob(active = next, previous = current)))) {169        return ResetResult.RESET_NOT_COMPLETED170    }171    checkpoint("after-generation-write")172    return ResetResult.V2_ANCHOR173}174175/** An anchored device stops being "not re-paired" once a direct v2 peer exists. */176fun onDirectV2PairingEstablished(port: SyncGenerationPort, hasDirectDevice: Boolean, nowMs: Long) {177    val current = resolveGeneration(port, nowMs)178    if (current.state != V2_ANCHOR && current.state != REPAIRING) return179    val next = current.copy(state = if (hasDirectDevice) V2_ACTIVE else REPAIRING)180    runCatching { port.writeBlob(encodeGeneration(SyncGenerationBlob(active = next, previous = current))) }181}182183/** Consulted before a sync session or an adoption grant is opened. */184fun assertSyncAllowed(port: SyncGenerationPort, nowMs: Long): SyncDecision =185    when (resolveGeneration(port, nowMs).state) {186        LEGACY_ACTIVE, V2_ANCHOR, REPAIRING, V2_ACTIVE -> SyncDecision.ALLOWED187        RESET_REQUIRED, RESET_CONFIRMED -> SyncDecision.RESET_REQUIRED188        else -> SyncDecision.UNKNOWN189    }190191/** The user-facing reason when sync must not start, or null when it may. */192internal fun syncRefusalReason(port: SyncGenerationPort, nowMs: Long): SyncOutcomeReason? =193    when (assertSyncAllowed(port, nowMs)) {194        SyncDecision.ALLOWED -> null195        SyncDecision.RESET_REQUIRED -> SyncOutcomeReason.RESET_REQUIRED196        SyncDecision.UNKNOWN -> SyncOutcomeReason.SYNC_GENERATION_UNKNOWN197    }198199/** True once a reset has retired the legacy generation. */200fun SyncGenerationPort.isPostReset(nowMs: Long): Boolean =201    resolveGeneration(this, nowMs).state in setOf(V2_ANCHOR, REPAIRING, V2_ACTIVE)202203enum class IdentityMintDecision { MINT, REFUSE_RESET_REQUIRED }204205/**206 * Whether a missing or corrupt identity may be minted (§11.5). Split out of the207 * Android loader so every class of trust state can be asserted without storage:208 * a missing identity is only mintable on a genuinely fresh installation, never209 * over any trust this device already holds.210 */211fun mintDecision(port: SyncGenerationPort, nowMs: Long): IdentityMintDecision =212    if (port.hasLegacyTrustState()) {213        enterResetRequired(port, nowMs)214        IdentityMintDecision.REFUSE_RESET_REQUIRED215    } else {216        IdentityMintDecision.MINT217    }218219internal fun encodeGeneration(blob: SyncGenerationBlob): String =220    json.encodeToString(SyncGenerationBlob.serializer(), blob)