LocalIdentityRecord.kt

4.9 KB · raw

1package com.peachpasswords.sync23import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters4import org.bouncycastle.crypto.params.X25519PrivateKeyParameters56/**7 * Persisted local-identity record codec (campaign task 2.1).8 *9 * Pure and storage-agnostic so the frozen schema — version, device ID, X2551910 * private/public, Ed25519 seed/public, creation time, and the consistency check11 * between the stored public values and the private material — is unit-testable12 * without a Keystore. `DeviceIdentity` adapts this to EncryptedSharedPreferences.13 */14internal const val LOCAL_IDENTITY_RECORD_VERSION = 31516/**17 * The first random-identity format stored only the two private seeds. It is18 * completed in place — never regenerated — so an installation that predates the19 * frozen schema keeps its device ID and keys.20 */21internal const val LOCAL_IDENTITY_RECORD_VERSION_LEGACY_RANDOM = 22223internal const val FIELD_VERSION = "version"24internal const val FIELD_DEVICE_ID = "device_id"25internal const val FIELD_X25519_PRIVATE = "x25519_private"26internal const val FIELD_X25519_PUBLIC = "x25519_public"27internal const val FIELD_ED25519_SEED = "ed25519_seed"28internal const val FIELD_ED25519_PUBLIC = "ed25519_public"29internal const val FIELD_CREATED_AT = "created_at"3031private val HEX_64 = Regex("^[0-9a-fA-F]{64}$")3233internal sealed interface IdentityRecord {34    /** No identity has ever been stored on this installation. */35    data object Absent : IdentityRecord3637    /** Frozen-schema record that passed every consistency check. */38    data class Current(39        val credentials: LocalIdentityCredentials,40        val createdAt: Long,41    ) : IdentityRecord4243    /** Random record written before the frozen schema: private material only. */44    data class LegacyRandom(val credentials: LocalIdentityCredentials) : IdentityRecord4546    /** Partial, unknown-version, or internally inconsistent: never regenerated. */47    data object Invalid : IdentityRecord48}4950internal fun readIdentityRecord(fields: Map<String, Any?>): IdentityRecord {51    val deviceId = fields[FIELD_DEVICE_ID] as? String52    val xPrivateHex = fields[FIELD_X25519_PRIVATE] as? String53    val edSeedHex = fields[FIELD_ED25519_SEED] as? String54    val storedVersion = (fields[FIELD_VERSION] as? Number)?.toInt()5556    if (deviceId == null && xPrivateHex == null && edSeedHex == null) {57        return if (storedVersion == null) IdentityRecord.Absent else IdentityRecord.Invalid58    }59    if (storedVersion == null ||60        deviceId.isNullOrBlank() ||61        xPrivateHex == null || !HEX_64.matches(xPrivateHex) ||62        edSeedHex == null || !HEX_64.matches(edSeedHex)63    ) {64        return IdentityRecord.Invalid65    }6667    val xPrivate = hexToBytes(xPrivateHex)68    val edSeed = hexToBytes(edSeedHex)69    val credentials = LocalIdentityCredentials(70        deviceId = deviceId,71        x25519PrivateKey = xPrivate,72        x25519PublicKey = X25519PrivateKeyParameters(xPrivate, 0).generatePublicKey().encoded,73        ed25519Seed = edSeed,74        ed25519PublicKey = Ed25519PrivateKeyParameters(edSeed, 0).generatePublicKey().encoded,75    )7677    return when (storedVersion) {78        LOCAL_IDENTITY_RECORD_VERSION_LEGACY_RANDOM -> IdentityRecord.LegacyRandom(credentials)79        LOCAL_IDENTITY_RECORD_VERSION -> readFrozenRecord(fields, credentials)80        else -> IdentityRecord.Invalid81    }82}8384/**85 * The version-3 record must carry public values that match the private material86 * and a real creation time. Recomputation alone would accept a record whose87 * public half was tampered with; comparison makes the mismatch explicit.88 */89private fun readFrozenRecord(90    fields: Map<String, Any?>,91    credentials: LocalIdentityCredentials,92): IdentityRecord {93    val xPublicHex = fields[FIELD_X25519_PUBLIC] as? String94    val edPublicHex = fields[FIELD_ED25519_PUBLIC] as? String95    val createdAt = (fields[FIELD_CREATED_AT] as? Number)?.toLong() ?: 0L96    if (xPublicHex == null || !HEX_64.matches(xPublicHex) ||97        edPublicHex == null || !HEX_64.matches(edPublicHex) ||98        createdAt <= 0L99    ) {100        return IdentityRecord.Invalid101    }102    if (!xPublicHex.equals(pubkeyToHex(credentials.x25519PublicKey), ignoreCase = true) ||103        !edPublicHex.equals(pubkeyToHex(credentials.ed25519PublicKey), ignoreCase = true)104    ) {105        return IdentityRecord.Invalid106    }107    return IdentityRecord.Current(credentials, createdAt)108}109110internal fun identityRecordFields(111    credentials: LocalIdentityCredentials,112    createdAt: Long,113): Map<String, Any?> = mapOf(114    FIELD_VERSION to LOCAL_IDENTITY_RECORD_VERSION,115    FIELD_DEVICE_ID to credentials.deviceId,116    FIELD_X25519_PRIVATE to pubkeyToHex(credentials.x25519PrivateKey),117    FIELD_X25519_PUBLIC to pubkeyToHex(credentials.x25519PublicKey),118    FIELD_ED25519_SEED to pubkeyToHex(credentials.ed25519Seed),119    FIELD_ED25519_PUBLIC to pubkeyToHex(credentials.ed25519PublicKey),120    FIELD_CREATED_AT to createdAt,121)