DeviceIdentity.kt

6.7 KB · raw

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)1920/**21 * Loads the canonical random identity, creating it once under Keystore22 * protection. A record that is absent, partial, of an unknown version, or23 * inconsistent between its private and stored public values fails closed with24 * `identity_migration_required`; it is never regenerated underneath an existing25 * installation. The schema and its consistency rules live in [readIdentityRecord].26 */27fun 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            // Same private material, missing stored public values and creation44            // time: complete the record without changing the identity.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}5556/** Returns no identity when the record cannot be used; never mints on a refusal. */57fun loadLocalIdentityOrNull(context: Context): LocalIdentityCredentials? =58    runCatching { loadOrCreateLocalIdentity(context) }.getOrNull()5960/**61 * A missing or corrupt identity may only be minted on a genuinely fresh62 * installation. With any trust state present it is a reset condition (§11.5):63 * minting would hand every peer an unknown device whose new keys no longer match64 * the revocation ledger. An unresolvable store therefore counts as trust state —65 * the refusal is the safe direction.66 */67private 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}8889/** Remove the encrypted identity record, including its device keys. */90fun clearLocalIdentityRecord(context: Context) {91    check(secureIdentityPrefs(context).edit().clear().commit()) { "identity_clear_failed" }92}9394// The persisted names below are retained for device-name migration only.95// upgrade. The name is stale; the cost of fixing it is not worth paying.96private 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    /**109     * This device's signed Pro license blob (`PPK-…`), set only when Pro. Sent110     * in the outbound PAIR_IDENTITY so the peer can activate it after SAS — one111     * Pro device grants Pro to the other when pairing. Either direction.112     */113    val license: String? = null,114)115116fun getOrCreateDeviceId(context: Context): String = loadOrCreateLocalIdentity(context).deviceId117118/** Replace the local identity after an authenticated peer detects a collision. */119fun 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}