package com.peachpasswords.sync import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters import org.bouncycastle.crypto.params.X25519PrivateKeyParameters /** * Persisted local-identity record codec (campaign task 2.1). * * Pure and storage-agnostic so the frozen schema — version, device ID, X25519 * private/public, Ed25519 seed/public, creation time, and the consistency check * between the stored public values and the private material — is unit-testable * without a Keystore. `DeviceIdentity` adapts this to EncryptedSharedPreferences. */ internal const val LOCAL_IDENTITY_RECORD_VERSION = 3 /** * The first random-identity format stored only the two private seeds. It is * completed in place — never regenerated — so an installation that predates the * frozen schema keeps its device ID and keys. */ internal const val LOCAL_IDENTITY_RECORD_VERSION_LEGACY_RANDOM = 2 internal const val FIELD_VERSION = "version" internal const val FIELD_DEVICE_ID = "device_id" internal const val FIELD_X25519_PRIVATE = "x25519_private" internal const val FIELD_X25519_PUBLIC = "x25519_public" internal const val FIELD_ED25519_SEED = "ed25519_seed" internal const val FIELD_ED25519_PUBLIC = "ed25519_public" internal const val FIELD_CREATED_AT = "created_at" private val HEX_64 = Regex("^[0-9a-fA-F]{64}$") internal sealed interface IdentityRecord { /** No identity has ever been stored on this installation. */ data object Absent : IdentityRecord /** Frozen-schema record that passed every consistency check. */ data class Current( val credentials: LocalIdentityCredentials, val createdAt: Long, ) : IdentityRecord /** Random record written before the frozen schema: private material only. */ data class LegacyRandom(val credentials: LocalIdentityCredentials) : IdentityRecord /** Partial, unknown-version, or internally inconsistent: never regenerated. */ data object Invalid : IdentityRecord } internal fun readIdentityRecord(fields: Map): IdentityRecord { val deviceId = fields[FIELD_DEVICE_ID] as? String val xPrivateHex = fields[FIELD_X25519_PRIVATE] as? String val edSeedHex = fields[FIELD_ED25519_SEED] as? String val storedVersion = (fields[FIELD_VERSION] as? Number)?.toInt() if (deviceId == null && xPrivateHex == null && edSeedHex == null) { return if (storedVersion == null) IdentityRecord.Absent else IdentityRecord.Invalid } if (storedVersion == null || deviceId.isNullOrBlank() || xPrivateHex == null || !HEX_64.matches(xPrivateHex) || edSeedHex == null || !HEX_64.matches(edSeedHex) ) { return IdentityRecord.Invalid } val xPrivate = hexToBytes(xPrivateHex) val edSeed = hexToBytes(edSeedHex) val credentials = LocalIdentityCredentials( deviceId = deviceId, x25519PrivateKey = xPrivate, x25519PublicKey = X25519PrivateKeyParameters(xPrivate, 0).generatePublicKey().encoded, ed25519Seed = edSeed, ed25519PublicKey = Ed25519PrivateKeyParameters(edSeed, 0).generatePublicKey().encoded, ) return when (storedVersion) { LOCAL_IDENTITY_RECORD_VERSION_LEGACY_RANDOM -> IdentityRecord.LegacyRandom(credentials) LOCAL_IDENTITY_RECORD_VERSION -> readFrozenRecord(fields, credentials) else -> IdentityRecord.Invalid } } /** * The version-3 record must carry public values that match the private material * and a real creation time. Recomputation alone would accept a record whose * public half was tampered with; comparison makes the mismatch explicit. */ private fun readFrozenRecord( fields: Map, credentials: LocalIdentityCredentials, ): IdentityRecord { val xPublicHex = fields[FIELD_X25519_PUBLIC] as? String val edPublicHex = fields[FIELD_ED25519_PUBLIC] as? String val createdAt = (fields[FIELD_CREATED_AT] as? Number)?.toLong() ?: 0L if (xPublicHex == null || !HEX_64.matches(xPublicHex) || edPublicHex == null || !HEX_64.matches(edPublicHex) || createdAt <= 0L ) { return IdentityRecord.Invalid } if (!xPublicHex.equals(pubkeyToHex(credentials.x25519PublicKey), ignoreCase = true) || !edPublicHex.equals(pubkeyToHex(credentials.ed25519PublicKey), ignoreCase = true) ) { return IdentityRecord.Invalid } return IdentityRecord.Current(credentials, createdAt) } internal fun identityRecordFields( credentials: LocalIdentityCredentials, createdAt: Long, ): Map = mapOf( FIELD_VERSION to LOCAL_IDENTITY_RECORD_VERSION, FIELD_DEVICE_ID to credentials.deviceId, FIELD_X25519_PRIVATE to pubkeyToHex(credentials.x25519PrivateKey), FIELD_X25519_PUBLIC to pubkeyToHex(credentials.x25519PublicKey), FIELD_ED25519_SEED to pubkeyToHex(credentials.ed25519Seed), FIELD_ED25519_PUBLIC to pubkeyToHex(credentials.ed25519PublicKey), FIELD_CREATED_AT to createdAt, )