SyncGenerationStore.kt

6.4 KB · raw

1package com.peachpasswords.sync23import android.content.Context4import android.content.SharedPreferences5import androidx.security.crypto.EncryptedSharedPreferences6import androidx.security.crypto.MasterKey7import dagger.hilt.android.EntryPointAccessors8import dagger.hilt.android.qualifiers.ApplicationContext9import dagger.hilt.EntryPoint10import dagger.hilt.InstallIn11import dagger.hilt.components.SingletonComponent12import javax.inject.Inject13import javax.inject.Singleton1415/**16 * Storage adapter for the Sync-generation state machine.17 *18 * The record itself lives in plain prefs under a key no legacy build knows, so19 * an older build cannot overwrite it and a Keystore failure cannot masquerade as20 * a trust reset. Everything it *reads* — the paired registry, the terminal21 * ledger, known-key history, pending holders — comes from the real stores,22 * because those are Keystore-encrypted and a plain re-read of their file would23 * silently see nothing.24 */25@Singleton26class SyncGenerationStore @Inject constructor(27    @ApplicationContext private val context: Context,28    private val devices: PairedDeviceStore,29    private val pendingDevices: PendingVouchedDeviceStore,30    private val lockedSyncSpool: LockedSyncSpoolStore,31) : SyncGenerationPort {3233    private val generationPrefs =34        context.getSharedPreferences(GENERATION_PREFS_FILE, Context.MODE_PRIVATE)3536    override fun readBlob(): String? = generationPrefs.getString(KEY_GENERATION, null)3738    override fun writeBlob(blob: String): Boolean =39        generationPrefs.edit().putString(KEY_GENERATION, blob).commit() && readBlob() == blob4041    override fun hasPairedDevices(): Boolean = devices.getAll().isNotEmpty()4243    override fun hasRevocations(): Boolean = devices.listRevocations().isNotEmpty()4445    override fun hasIssuerSigningKeys(): Boolean = devices.hasIssuerSigningKeys()4647    override fun hasPendingVouchedDevices(): Boolean = pendingDevices.getAll().isNotEmpty()4849    /**50     * Copy the legacy trust material verbatim into an archive tagged with its51     * generation. Encrypted with the same construction as its source: the52     * registry carries per-pair rendezvous secrets, so a plaintext archive would53     * downgrade them at rest.54     *55     * One fixed file, with the generation in every key: Android's backup rules56     * take literal paths only, so a per-generation filename could not be57     * excluded from cloud backup and device transfer.58     */59    override fun archiveLegacyState(generation: Int): String? = runCatching {60        val archive = encryptedPrefs(context, ARCHIVE_PREFS_FILE)61        val prefix = "g$generation:"62        val expected = buildMap<String, Any?> {63            put("${prefix}meta", """{"schemaVersion":1,"generation":$generation,"archivedAt":${System.currentTimeMillis()}}""")64            for ((key, value) in devices.snapshot()) put("$prefix$ARCHIVE_DEVICES_PREFIX$key", value)65            for ((key, value) in pendingDevices.snapshot()) put("$prefix$ARCHIVE_PENDING_PREFIX$key", value)66        }67        val edit = archive.edit()68        for ((key, value) in expected) {69            when (value) {70                is String -> edit.putString(key, value)71                is Set<*> -> edit.putStringSet(key, value.filterIsInstance<String>().toSet())72            }73        }74        check(edit.commit()) { "generation_archive_persist_failed" }75        check(expected.all { (key, value) -> archiveMatches(archive, key, value) }) {76            "generation_archive_verify_failed"77        }78        ARCHIVE_PREFS_FILE79    }.getOrNull()8081    private fun archiveMatches(archive: SharedPreferences, key: String, expected: Any?): Boolean =82        when (expected) {83            is String -> archive.getString(key, null) == expected84            is Set<*> -> archive.getStringSet(key, null) == expected.filterIsInstance<String>().toSet()85            else -> false86        }8788    override fun clearLegacyTrustState(): Boolean = runCatching {89        PeachSyncService.stop(context)90        lockedSyncSpool.clear()91        devices.clear()92        pendingDevices.clear()93        devices.getAll().isEmpty() && devices.listRevocations().isEmpty() &&94            !devices.hasIssuerSigningKeys() && pendingDevices.getAll().isEmpty()95    }.getOrDefault(false)9697    /**98     * The reset's identity step (§11.3). Only reached at RESET_CONFIRMED, after99     * the legacy trust has been archived and cleared, so minting here is the100     * anchored device's own deliberate action rather than a silent replacement.101     * The guard still refuses to mint over live trust, which is what would102     * otherwise turn the create-on-miss refusal into a bypass.103     */104    override fun ensureV2Identity(): Boolean = runCatching {105        loadOrCreateLocalIdentity(context)106        true107    }.recoverCatching {108        check(!hasLegacyTrustState()) { "identity_mint_over_live_trust" }109        rotateDeviceId(context)110        true111    }.getOrDefault(false)112113    override fun localDeviceId(): String? =114        runCatching { loadOrCreateLocalIdentity(context).deviceId }.getOrNull()115116    /** Forget the generation record: only a genuinely destroyed vault reaches this. */117    fun clear() {118        generationPrefs.edit().clear().commit()119    }120121    private companion object {122        const val GENERATION_PREFS_FILE = "peach_sync_generation_v1"123        const val ARCHIVE_PREFS_FILE = "peach_sync_archive_v1"124        const val KEY_GENERATION = "sync_generation"125        const val ARCHIVE_DEVICES_PREFIX = "paired:"126        const val ARCHIVE_PENDING_PREFIX = "pending:"127    }128}129130/**131 * [loadOrCreateLocalIdentity] is called from services and screens that hold no132 * injector, so the singleton is fetched here the same way [PeachSyncService]133 * fetches its outcome store.134 */135internal fun syncGenerationStore(context: Context): SyncGenerationStore =136    EntryPointAccessors137        .fromApplication(context.applicationContext, SyncGenerationEntryPoint::class.java)138        .syncGenerationStore()139140@EntryPoint141@InstallIn(SingletonComponent::class)142internal interface SyncGenerationEntryPoint {143    fun syncGenerationStore(): SyncGenerationStore144}145146private fun encryptedPrefs(context: Context, name: String): SharedPreferences =147    EncryptedSharedPreferences.create(148        context,149        name,150        MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),151        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,152        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,153    )