1package com.peachpasswords.sync23import kotlinx.serialization.Serializable4import kotlinx.serialization.json.Json567891011121314const 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"202122const val UNKNOWN_STATE = "UNKNOWN"232425const 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)43444546474849@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 }59606162636465interface 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}77787980private 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()91929394959697fun 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}115116117118119120121fun 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}128129130131132133134135136137138fun 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}174175176fun 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}182183184fun 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 }190191192internal 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 }198199200fun SyncGenerationPort.isPostReset(nowMs: Long): Boolean =201 resolveGeneration(this, nowMs).state in setOf(V2_ANCHOR, REPAIRING, V2_ACTIVE)202203enum class IdentityMintDecision { MINT, REFUSE_RESET_REQUIRED }204205206207208209210211fun 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)