package com.peachpasswords.sync import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json /** * The durable Sync-generation state machine (protocol §11). * * Pure and storage-agnostic so every transition — and every crash point inside * one — is unit-testable without a Keystore. [SyncGenerationStore] is the only * adapter that touches storage. */ const val LEGACY_ACTIVE = "LEGACY_ACTIVE" const val RESET_REQUIRED = "RESET_REQUIRED" const val RESET_CONFIRMED = "RESET_CONFIRMED" const val V2_ANCHOR = "V2_ANCHOR" const val REPAIRING = "REPAIRING" const val V2_ACTIVE = "V2_ACTIVE" /** No record could be read or derived. Refuses sync rather than guessing a generation. */ const val UNKNOWN_STATE = "UNKNOWN" /** User-visible outcomes. Distinct on purpose: they ask the user for different things. */ const val RESET_REQUIRED_OUTCOME = "reset_required" const val RESET_NOT_COMPLETED = "reset_not_completed" const val PEER_PROTOCOL_OUTDATED = "peer_protocol_outdated" const val SYNC_GENERATION_UNKNOWN = "sync_generation_unknown" const val SYNC_GENERATION_SCHEMA_VERSION = 1 private const val LEGACY_GENERATION = 1 private const val V2_GENERATION = 2 @Serializable data class SyncGeneration( val schemaVersion: Int = SYNC_GENERATION_SCHEMA_VERSION, val generation: Int, val state: String, val anchorDeviceId: String? = null, val legacyArchiveRef: String? = null, val createdAt: Long, ) /** * One blob, one key. `previous` is retained so a rewrite of the active record * can be validated against what it replaces, and so a crash on either side of * that write is recoverable (§11.2, seal before switch). */ @Serializable data class SyncGenerationBlob( val schemaVersion: Int = SYNC_GENERATION_SCHEMA_VERSION, val active: SyncGeneration, val previous: SyncGeneration? = null, ) enum class SyncDecision { ALLOWED, RESET_REQUIRED, UNKNOWN } enum class ResetResult { V2_ANCHOR, RESET_NOT_COMPLETED } /** * The storage boundary. Deliberately coarse: the trust probe is split per class * so a test can assert the refusal for each one, and the archive and clear steps * are separate so a crash between them is injectable. */ interface SyncGenerationPort { fun readBlob(): String? fun writeBlob(blob: String): Boolean fun hasPairedDevices(): Boolean fun hasRevocations(): Boolean fun hasIssuerSigningKeys(): Boolean fun hasPendingVouchedDevices(): Boolean fun archiveLegacyState(generation: Int): String? fun clearLegacyTrustState(): Boolean fun ensureV2Identity(): Boolean fun localDeviceId(): String? } // ignoreUnknownKeys keeps a future additive field readable; schemaVersion is // checked explicitly, so an unrecognised schema still resolves to UNKNOWN. private val json = Json { ignoreUnknownKeys = true } private val states = setOf(LEGACY_ACTIVE, RESET_REQUIRED, RESET_CONFIRMED, V2_ANCHOR, REPAIRING, V2_ACTIVE) private fun unknownGeneration(nowMs: Long) = SyncGeneration( generation = LEGACY_GENERATION, state = UNKNOWN_STATE, createdAt = nowMs, ) fun SyncGenerationPort.hasLegacyTrustState(): Boolean = hasPairedDevices() || hasRevocations() || hasIssuerSigningKeys() || hasPendingVouchedDevices() /** * Read the persisted record, or derive one when none has ever been written. * A blob that is unreadable, of another schema, or in an unknown state is * never reinterpreted: it resolves to UNKNOWN and sync stops. */ fun resolveGeneration(port: SyncGenerationPort, nowMs: Long): SyncGeneration { val raw = port.readBlob() if (raw != null) { return runCatching { json.decodeFromString(raw).takeIf { it.schemaVersion == SYNC_GENERATION_SCHEMA_VERSION } ?.active ?.takeIf { it.generation >= LEGACY_GENERATION && it.state in states } }.getOrNull() ?: unknownGeneration(nowMs) } val hasTrustState = port.hasLegacyTrustState() val derived = SyncGeneration( generation = if (hasTrustState) LEGACY_GENERATION else V2_GENERATION, state = if (hasTrustState) LEGACY_ACTIVE else V2_ACTIVE, createdAt = nowMs, ) return if (port.writeBlob(encodeGeneration(SyncGenerationBlob(active = derived)))) derived else unknownGeneration(nowMs) } /** * Enter the state that blocks sync and states the migration. Reached by the * user asking for a reset, and by the create-on-miss refusal. Idempotent, and * never moves a device that has already reset back into the legacy generation. */ fun enterResetRequired(port: SyncGenerationPort, nowMs: Long): SyncGeneration { val current = resolveGeneration(port, nowMs) if (current.state != LEGACY_ACTIVE && current.state != RESET_REQUIRED) return current val next = current.copy(state = RESET_REQUIRED) port.writeBlob(encodeGeneration(SyncGenerationBlob(active = next, previous = current))) return next } /** * Archive the legacy generation, mint the v2 identity, then flip the pointer — * in that order, so every crash point leaves either a recoverable * RESET_CONFIRMED or a committed V2_ANCHOR, and never a lost archive (§11.3). * * The caller has already confirmed the master password. `checkpoint` fires * immediately before each step that can fail; a test throws from it to inject a * crash at exactly that point. */ fun confirmSecureReset(port: SyncGenerationPort, nowMs: Long, checkpoint: (String) -> Unit = {}): ResetResult { val current = resolveGeneration(port, nowMs) if (current.state !in setOf(LEGACY_ACTIVE, RESET_REQUIRED, RESET_CONFIRMED)) return ResetResult.RESET_NOT_COMPLETED fun persist(state: SyncGeneration): Boolean = port.writeBlob(encodeGeneration(SyncGenerationBlob(active = state, previous = current))) checkpoint("before-confirmed") if (!persist(current.copy(state = RESET_CONFIRMED))) return ResetResult.RESET_NOT_COMPLETED checkpoint("before-archive") val archiveRef = port.archiveLegacyState(current.generation) ?: return ResetResult.RESET_NOT_COMPLETED checkpoint("after-archive") checkpoint("before-clear") if (!port.clearLegacyTrustState()) return ResetResult.RESET_NOT_COMPLETED checkpoint("after-clear") checkpoint("before-identity") if (!port.ensureV2Identity()) return ResetResult.RESET_NOT_COMPLETED checkpoint("after-identity") val next = SyncGeneration( generation = current.generation + 1, state = V2_ANCHOR, anchorDeviceId = port.localDeviceId(), legacyArchiveRef = archiveRef, createdAt = nowMs, ) checkpoint("before-generation-write") if (!port.writeBlob(encodeGeneration(SyncGenerationBlob(active = next, previous = current)))) { return ResetResult.RESET_NOT_COMPLETED } checkpoint("after-generation-write") return ResetResult.V2_ANCHOR } /** An anchored device stops being "not re-paired" once a direct v2 peer exists. */ fun onDirectV2PairingEstablished(port: SyncGenerationPort, hasDirectDevice: Boolean, nowMs: Long) { val current = resolveGeneration(port, nowMs) if (current.state != V2_ANCHOR && current.state != REPAIRING) return val next = current.copy(state = if (hasDirectDevice) V2_ACTIVE else REPAIRING) runCatching { port.writeBlob(encodeGeneration(SyncGenerationBlob(active = next, previous = current))) } } /** Consulted before a sync session or an adoption grant is opened. */ fun assertSyncAllowed(port: SyncGenerationPort, nowMs: Long): SyncDecision = when (resolveGeneration(port, nowMs).state) { LEGACY_ACTIVE, V2_ANCHOR, REPAIRING, V2_ACTIVE -> SyncDecision.ALLOWED RESET_REQUIRED, RESET_CONFIRMED -> SyncDecision.RESET_REQUIRED else -> SyncDecision.UNKNOWN } /** The user-facing reason when sync must not start, or null when it may. */ internal fun syncRefusalReason(port: SyncGenerationPort, nowMs: Long): SyncOutcomeReason? = when (assertSyncAllowed(port, nowMs)) { SyncDecision.ALLOWED -> null SyncDecision.RESET_REQUIRED -> SyncOutcomeReason.RESET_REQUIRED SyncDecision.UNKNOWN -> SyncOutcomeReason.SYNC_GENERATION_UNKNOWN } /** True once a reset has retired the legacy generation. */ fun SyncGenerationPort.isPostReset(nowMs: Long): Boolean = resolveGeneration(this, nowMs).state in setOf(V2_ANCHOR, REPAIRING, V2_ACTIVE) enum class IdentityMintDecision { MINT, REFUSE_RESET_REQUIRED } /** * Whether a missing or corrupt identity may be minted (§11.5). Split out of the * Android loader so every class of trust state can be asserted without storage: * a missing identity is only mintable on a genuinely fresh installation, never * over any trust this device already holds. */ fun mintDecision(port: SyncGenerationPort, nowMs: Long): IdentityMintDecision = if (port.hasLegacyTrustState()) { enterResetRequired(port, nowMs) IdentityMintDecision.REFUSE_RESET_REQUIRED } else { IdentityMintDecision.MINT } internal fun encodeGeneration(blob: SyncGenerationBlob): String = json.encodeToString(SyncGenerationBlob.serializer(), blob)