package com.peachpasswords.sync import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import java.util.UUID import java.security.SecureRandom import com.peachpasswords.vault.VaultCrypto private const val LOCAL_IDENTITY_PREFS = "peach_local_identity_v2" private fun secureIdentityPrefs(context: Context) = EncryptedSharedPreferences.create( context, LOCAL_IDENTITY_PREFS, MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) /** * Loads the canonical random identity, creating it once under Keystore * protection. A record that is absent, partial, of an unknown version, or * inconsistent between its private and stored public values fails closed with * `identity_migration_required`; it is never regenerated underneath an existing * installation. The schema and its consistency rules live in [readIdentityRecord]. */ fun loadOrCreateLocalIdentity(context: Context): LocalIdentityCredentials { val stored: Map = try { secureIdentityPrefs(context).all } catch (_: Exception) { throw IllegalStateException("identity_migration_required") } return when (val record = readIdentityRecord(stored)) { is IdentityRecord.Absent -> { if (resetRequiredByExistingTrustState(context)) { throw IllegalStateException("identity_migration_required") } val fresh = randomLocalIdentityCredentials(UUID.randomUUID().toString()) persistIdentity(context, fresh, System.currentTimeMillis()) fresh } is IdentityRecord.LegacyRandom -> { // Same private material, missing stored public values and creation // time: complete the record without changing the identity. persistIdentity(context, record.credentials, System.currentTimeMillis()) record.credentials } is IdentityRecord.Current -> record.credentials IdentityRecord.Invalid -> { resetRequiredByExistingTrustState(context) throw IllegalStateException("identity_migration_required") } } } /** Returns no identity when the record cannot be used; never mints on a refusal. */ fun loadLocalIdentityOrNull(context: Context): LocalIdentityCredentials? = runCatching { loadOrCreateLocalIdentity(context) }.getOrNull() /** * A missing or corrupt identity may only be minted on a genuinely fresh * installation. With any trust state present it is a reset condition (§11.5): * minting would hand every peer an unknown device whose new keys no longer match * the revocation ledger. An unresolvable store therefore counts as trust state — * the refusal is the safe direction. */ private fun resetRequiredByExistingTrustState(context: Context): Boolean = runCatching { mintDecision(syncGenerationStore(context), System.currentTimeMillis()) == IdentityMintDecision.REFUSE_RESET_REQUIRED }.getOrDefault(true) private fun persistIdentity( context: Context, credentials: LocalIdentityCredentials, createdAt: Long, ) { val editor = secureIdentityPrefs(context).edit() for ((key, value) in identityRecordFields(credentials, createdAt)) { when (value) { is Int -> editor.putInt(key, value) is Long -> editor.putLong(key, value) is String -> editor.putString(key, value) else -> error("unsupported identity record field: $key") } } check(editor.commit()) { "identity_persist_failed" } } /** Remove the encrypted identity record, including its device keys. */ fun clearLocalIdentityRecord(context: Context) { check(secureIdentityPrefs(context).edit().clear().commit()) { "identity_clear_failed" } } // The persisted names below are retained for device-name migration only. // upgrade. The name is stale; the cost of fixing it is not worth paying. private const val DEVICE_ID_KEY = "peach_chirp_device_id" private const val DEVICE_NAME_KEY = "peach_chirp_device_name" private const val PREFS_FILE = "peach_chirp_identity" data class LocalDeviceIdentity( val deviceId: String, val deviceName: String, val deviceType: DeviceType, val longTermPublicKeyHex: String, val signingPublicKeyHex: String, val wsPort: Int? = null, val relayUrl: String = Rendezvous.DEFAULT_RELAY_URL, /** * This device's signed Pro license blob (`PPK-…`), set only when Pro. Sent * in the outbound PAIR_IDENTITY so the peer can activate it after SAS — one * Pro device grants Pro to the other when pairing. Either direction. */ val license: String? = null, ) fun getOrCreateDeviceId(context: Context): String = loadOrCreateLocalIdentity(context).deviceId /** Replace the local identity after an authenticated peer detects a collision. */ fun rotateDeviceId(context: Context): String { val fresh = randomLocalIdentityCredentials(UUID.randomUUID().toString()) persistIdentity(context, fresh, System.currentTimeMillis()) return fresh.deviceId } fun getDeviceName(context: Context): String { val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) return prefs.getString(DEVICE_NAME_KEY, null).orEmpty().ifBlank { defaultDeviceName() } } fun setDeviceName(context: Context, name: String) { val trimmed = name.trim() require(trimmed.isNotEmpty()) { "device name must not be empty" } require(trimmed.length <= 64) { "device name must be 64 chars or fewer" } context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) .edit() .putString(DEVICE_NAME_KEY, trimmed) .apply() } fun clearDeviceIdentity(context: Context) { context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) .edit() .clear() .apply() } fun buildLocalIdentity(context: Context, masterKey: ByteArray): LocalDeviceIdentity { VaultCrypto.zero(masterKey) return buildLocalIdentity(context, loadOrCreateLocalIdentity(context)) } fun buildLocalIdentity(context: Context, credentials: LocalIdentityCredentials): LocalDeviceIdentity { return LocalDeviceIdentity( deviceId = credentials.deviceId, deviceName = getDeviceName(context), deviceType = extensionDeviceType(), longTermPublicKeyHex = pubkeyToHex(credentials.x25519PublicKey), signingPublicKeyHex = pubkeyToHex(credentials.ed25519PublicKey), wsPort = null, relayUrl = RelaySettingsStore.readRelayUrl(context), ) } private fun defaultDeviceName(): String { return "Android" } private fun extensionDeviceType(): DeviceType { return DeviceType.ANDROID }