1package com.peachpasswords.sync23import android.content.Context4import androidx.security.crypto.EncryptedSharedPreferences5import androidx.security.crypto.MasterKey6import java.util.UUID7import java.security.SecureRandom8import com.peachpasswords.vault.VaultCrypto910private const val LOCAL_IDENTITY_PREFS = "peach_local_identity_v2"1112private fun secureIdentityPrefs(context: Context) = EncryptedSharedPreferences.create(13 context,14 LOCAL_IDENTITY_PREFS,15 MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),16 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,17 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,18)192021222324252627fun loadOrCreateLocalIdentity(context: Context): LocalIdentityCredentials {28 val stored: Map<String, Any?> = try {29 secureIdentityPrefs(context).all30 } catch (_: Exception) {31 throw IllegalStateException("identity_migration_required")32 }33 return when (val record = readIdentityRecord(stored)) {34 is IdentityRecord.Absent -> {35 if (resetRequiredByExistingTrustState(context)) {36 throw IllegalStateException("identity_migration_required")37 }38 val fresh = randomLocalIdentityCredentials(UUID.randomUUID().toString())39 persistIdentity(context, fresh, System.currentTimeMillis())40 fresh41 }42 is IdentityRecord.LegacyRandom -> {43 44 45 persistIdentity(context, record.credentials, System.currentTimeMillis())46 record.credentials47 }48 is IdentityRecord.Current -> record.credentials49 IdentityRecord.Invalid -> {50 resetRequiredByExistingTrustState(context)51 throw IllegalStateException("identity_migration_required")52 }53 }54}555657fun loadLocalIdentityOrNull(context: Context): LocalIdentityCredentials? =58 runCatching { loadOrCreateLocalIdentity(context) }.getOrNull()596061626364656667private fun resetRequiredByExistingTrustState(context: Context): Boolean = runCatching {68 mintDecision(syncGenerationStore(context), System.currentTimeMillis()) ==69 IdentityMintDecision.REFUSE_RESET_REQUIRED70}.getOrDefault(true)7172private fun persistIdentity(73 context: Context,74 credentials: LocalIdentityCredentials,75 createdAt: Long,76) {77 val editor = secureIdentityPrefs(context).edit()78 for ((key, value) in identityRecordFields(credentials, createdAt)) {79 when (value) {80 is Int -> editor.putInt(key, value)81 is Long -> editor.putLong(key, value)82 is String -> editor.putString(key, value)83 else -> error("unsupported identity record field: $key")84 }85 }86 check(editor.commit()) { "identity_persist_failed" }87}888990fun clearLocalIdentityRecord(context: Context) {91 check(secureIdentityPrefs(context).edit().clear().commit()) { "identity_clear_failed" }92}93949596private const val DEVICE_ID_KEY = "peach_chirp_device_id"97private const val DEVICE_NAME_KEY = "peach_chirp_device_name"98private const val PREFS_FILE = "peach_chirp_identity"99100data class LocalDeviceIdentity(101 val deviceId: String,102 val deviceName: String,103 val deviceType: DeviceType,104 val longTermPublicKeyHex: String,105 val signingPublicKeyHex: String,106 val wsPort: Int? = null,107 val relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,108 109110111112113 val license: String? = null,114)115116fun getOrCreateDeviceId(context: Context): String = loadOrCreateLocalIdentity(context).deviceId117118119fun rotateDeviceId(context: Context): String {120 val fresh = randomLocalIdentityCredentials(UUID.randomUUID().toString())121 persistIdentity(context, fresh, System.currentTimeMillis())122 return fresh.deviceId123}124125fun getDeviceName(context: Context): String {126 val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)127 return prefs.getString(DEVICE_NAME_KEY, null).orEmpty().ifBlank { defaultDeviceName() }128}129130fun setDeviceName(context: Context, name: String) {131 val trimmed = name.trim()132 require(trimmed.isNotEmpty()) { "device name must not be empty" }133 require(trimmed.length <= 64) { "device name must be 64 chars or fewer" }134 context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)135 .edit()136 .putString(DEVICE_NAME_KEY, trimmed)137 .apply()138}139140fun clearDeviceIdentity(context: Context) {141 context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)142 .edit()143 .clear()144 .apply()145}146147fun buildLocalIdentity(context: Context, masterKey: ByteArray): LocalDeviceIdentity {148 VaultCrypto.zero(masterKey)149 return buildLocalIdentity(context, loadOrCreateLocalIdentity(context))150}151152fun buildLocalIdentity(context: Context, credentials: LocalIdentityCredentials): LocalDeviceIdentity {153 return LocalDeviceIdentity(154 deviceId = credentials.deviceId,155 deviceName = getDeviceName(context),156 deviceType = extensionDeviceType(),157 longTermPublicKeyHex = pubkeyToHex(credentials.x25519PublicKey),158 signingPublicKeyHex = pubkeyToHex(credentials.ed25519PublicKey),159 wsPort = null,160 relayUrl = RelaySettingsStore.readRelayUrl(context),161 )162}163164private fun defaultDeviceName(): String {165 return "Android"166}167168private fun extensionDeviceType(): DeviceType {169 return DeviceType.ANDROID170}