package com.peachpasswords.sync import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Inject import javax.inject.Singleton /** * Storage adapter for the Sync-generation state machine. * * The record itself lives in plain prefs under a key no legacy build knows, so * an older build cannot overwrite it and a Keystore failure cannot masquerade as * a trust reset. Everything it *reads* — the paired registry, the terminal * ledger, known-key history, pending holders — comes from the real stores, * because those are Keystore-encrypted and a plain re-read of their file would * silently see nothing. */ @Singleton class SyncGenerationStore @Inject constructor( @ApplicationContext private val context: Context, private val devices: PairedDeviceStore, private val pendingDevices: PendingVouchedDeviceStore, private val lockedSyncSpool: LockedSyncSpoolStore, ) : SyncGenerationPort { private val generationPrefs = context.getSharedPreferences(GENERATION_PREFS_FILE, Context.MODE_PRIVATE) override fun readBlob(): String? = generationPrefs.getString(KEY_GENERATION, null) override fun writeBlob(blob: String): Boolean = generationPrefs.edit().putString(KEY_GENERATION, blob).commit() && readBlob() == blob override fun hasPairedDevices(): Boolean = devices.getAll().isNotEmpty() override fun hasRevocations(): Boolean = devices.listRevocations().isNotEmpty() override fun hasIssuerSigningKeys(): Boolean = devices.hasIssuerSigningKeys() override fun hasPendingVouchedDevices(): Boolean = pendingDevices.getAll().isNotEmpty() /** * Copy the legacy trust material verbatim into an archive tagged with its * generation. Encrypted with the same construction as its source: the * registry carries per-pair rendezvous secrets, so a plaintext archive would * downgrade them at rest. * * One fixed file, with the generation in every key: Android's backup rules * take literal paths only, so a per-generation filename could not be * excluded from cloud backup and device transfer. */ override fun archiveLegacyState(generation: Int): String? = runCatching { val archive = encryptedPrefs(context, ARCHIVE_PREFS_FILE) val prefix = "g$generation:" val expected = buildMap { put("${prefix}meta", """{"schemaVersion":1,"generation":$generation,"archivedAt":${System.currentTimeMillis()}}""") for ((key, value) in devices.snapshot()) put("$prefix$ARCHIVE_DEVICES_PREFIX$key", value) for ((key, value) in pendingDevices.snapshot()) put("$prefix$ARCHIVE_PENDING_PREFIX$key", value) } val edit = archive.edit() for ((key, value) in expected) { when (value) { is String -> edit.putString(key, value) is Set<*> -> edit.putStringSet(key, value.filterIsInstance().toSet()) } } check(edit.commit()) { "generation_archive_persist_failed" } check(expected.all { (key, value) -> archiveMatches(archive, key, value) }) { "generation_archive_verify_failed" } ARCHIVE_PREFS_FILE }.getOrNull() private fun archiveMatches(archive: SharedPreferences, key: String, expected: Any?): Boolean = when (expected) { is String -> archive.getString(key, null) == expected is Set<*> -> archive.getStringSet(key, null) == expected.filterIsInstance().toSet() else -> false } override fun clearLegacyTrustState(): Boolean = runCatching { PeachSyncService.stop(context) lockedSyncSpool.clear() devices.clear() pendingDevices.clear() devices.getAll().isEmpty() && devices.listRevocations().isEmpty() && !devices.hasIssuerSigningKeys() && pendingDevices.getAll().isEmpty() }.getOrDefault(false) /** * The reset's identity step (§11.3). Only reached at RESET_CONFIRMED, after * the legacy trust has been archived and cleared, so minting here is the * anchored device's own deliberate action rather than a silent replacement. * The guard still refuses to mint over live trust, which is what would * otherwise turn the create-on-miss refusal into a bypass. */ override fun ensureV2Identity(): Boolean = runCatching { loadOrCreateLocalIdentity(context) true }.recoverCatching { check(!hasLegacyTrustState()) { "identity_mint_over_live_trust" } rotateDeviceId(context) true }.getOrDefault(false) override fun localDeviceId(): String? = runCatching { loadOrCreateLocalIdentity(context).deviceId }.getOrNull() /** Forget the generation record: only a genuinely destroyed vault reaches this. */ fun clear() { generationPrefs.edit().clear().commit() } private companion object { const val GENERATION_PREFS_FILE = "peach_sync_generation_v1" const val ARCHIVE_PREFS_FILE = "peach_sync_archive_v1" const val KEY_GENERATION = "sync_generation" const val ARCHIVE_DEVICES_PREFIX = "paired:" const val ARCHIVE_PENDING_PREFIX = "pending:" } } /** * [loadOrCreateLocalIdentity] is called from services and screens that hold no * injector, so the singleton is fetched here the same way [PeachSyncService] * fetches its outcome store. */ internal fun syncGenerationStore(context: Context): SyncGenerationStore = EntryPointAccessors .fromApplication(context.applicationContext, SyncGenerationEntryPoint::class.java) .syncGenerationStore() @EntryPoint @InstallIn(SingletonComponent::class) internal interface SyncGenerationEntryPoint { fun syncGenerationStore(): SyncGenerationStore } private fun encryptedPrefs(context: Context, name: String): SharedPreferences = EncryptedSharedPreferences.create( context, name, MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, )