package com.sensitiveinfo.internal.storage import org.json.JSONObject import android.util.Base64 /** * Serialized representation of an entry in SharedPreferences. * * `ciphertext` and `iv` remain optional so callers can cache metadata-only items (for example * when a secret is hardware-gated and the user has not authenticated yet). */ internal data class PersistedEntry( val alias: String, val ciphertext: ByteArray?, val iv: ByteArray?, val metadata: PersistedMetadata, val authenticators: Int, val requiresAuthentication: Boolean, val invalidateOnEnrollment: Boolean, val useStrongBox: Boolean, val keyVersion: Int = KeyVersionRegistry.INITIAL_VERSION, /** * When true, the ciphertext was sealed with AES-GCM AAD bound to `service|key|v`. Older * entries persisted before AAD shipped read this as `false` and decrypt without AAD; the next * write (or rotation) upgrades them transparently. */ val usesAad: Boolean = false, /** * Base64 HMAC-SHA256 tag over `(service, key, version, accessControl, securityLevel, timestamp, * iv, ciphertext)`. Absent on legacy entries — verification is skipped in that case. */ val integrityTag: String? = null ) { fun toJson(): JSONObject { val json = JSONObject() json.put(KEY_ALIAS, alias) json.put(KEY_AUTHENTICATORS, authenticators) json.put(KEY_REQUIRES_AUTH, requiresAuthentication) json.put(KEY_INVALIDATE_ON_ENROLLMENT, invalidateOnEnrollment) json.put(KEY_USE_STRONGBOX, useStrongBox) json.put(KEY_KEY_VERSION, keyVersion) json.put(KEY_USES_AAD, usesAad) integrityTag?.let { json.put(KEY_INTEGRITY_TAG, it) } ciphertext?.let { data -> json.put(KEY_CIPHERTEXT, Base64.encodeToString(data, Base64.NO_WRAP)) } iv?.let { data -> json.put(KEY_IV, Base64.encodeToString(data, Base64.NO_WRAP)) } val metadataJson = JSONObject() metadataJson.put(KEY_SECURITY_LEVEL, metadata.securityLevel) metadataJson.put(KEY_BACKEND, metadata.backend) metadataJson.put(KEY_ACCESS_CONTROL, metadata.accessControl) metadataJson.put(KEY_TIMESTAMP, metadata.timestamp) metadata.keyVersion?.let { metadataJson.put(KEY_METADATA_KEY_VERSION, it) } integrityTag?.let { metadataJson.put(KEY_METADATA_INTEGRITY_TAG, it) } json.put(KEY_METADATA, metadataJson) return json } companion object { private const val KEY_ALIAS = "alias" private const val KEY_CIPHERTEXT = "ciphertext" private const val KEY_IV = "iv" private const val KEY_METADATA = "metadata" private const val KEY_AUTHENTICATORS = "authenticators" private const val KEY_REQUIRES_AUTH = "requiresAuth" private const val KEY_INVALIDATE_ON_ENROLLMENT = "invalidateOnEnrollment" private const val KEY_USE_STRONGBOX = "useStrongBox" private const val KEY_KEY_VERSION = "keyVersion" private const val KEY_USES_AAD = "usesAad" private const val KEY_INTEGRITY_TAG = "integrityTag" private const val KEY_SECURITY_LEVEL = "securityLevel" private const val KEY_BACKEND = "backend" private const val KEY_ACCESS_CONTROL = "accessControl" private const val KEY_TIMESTAMP = "timestamp" private const val KEY_METADATA_KEY_VERSION = "keyVersion" private const val KEY_METADATA_INTEGRITY_TAG = "integrityTag" /** * Rehydrates a persisted entry from JSON, tolerating partially populated payloads generated by * older library versions. */ fun fromJson(raw: String): PersistedEntry? { val json = try { JSONObject(raw) } catch (_: Throwable) { return null } val metadataJson = json.optJSONObject(KEY_METADATA) ?: return null val metadataKeyVersion = if (metadataJson.has(KEY_METADATA_KEY_VERSION)) { metadataJson.optInt(KEY_METADATA_KEY_VERSION) } else { null } val metadataIntegrityTag = metadataJson.optString(KEY_METADATA_INTEGRITY_TAG, "") .takeIf { it.isNotEmpty() } val metadata = PersistedMetadata( securityLevel = metadataJson.optString(KEY_SECURITY_LEVEL), backend = metadataJson.optString(KEY_BACKEND), accessControl = metadataJson.optString(KEY_ACCESS_CONTROL), timestamp = metadataJson.optDouble(KEY_TIMESTAMP), keyVersion = metadataKeyVersion, integrityTag = metadataIntegrityTag ) val alias = json.optString(KEY_ALIAS) if (alias.isEmpty()) { return null } val ciphertextEncoded = json.optString(KEY_CIPHERTEXT, "") val ciphertext = ciphertextEncoded.takeIf { it.isNotEmpty() }?.let { Base64.decode(it, Base64.NO_WRAP) } val ivEncoded = json.optString(KEY_IV, "") val iv = ivEncoded.takeIf { it.isNotEmpty() }?.let { Base64.decode(it, Base64.NO_WRAP) } val authenticators = json.optInt(KEY_AUTHENTICATORS, 0) val requiresAuth = json.optBoolean(KEY_REQUIRES_AUTH, false) val invalidateOnEnrollment = json.optBoolean(KEY_INVALIDATE_ON_ENROLLMENT, false) val useStrongBox = json.optBoolean(KEY_USE_STRONGBOX, false) val keyVersion = json.optInt(KEY_KEY_VERSION, KeyVersionRegistry.INITIAL_VERSION) val usesAad = json.optBoolean(KEY_USES_AAD, false) val integrityTag = json.optString(KEY_INTEGRITY_TAG, "").takeIf { it.isNotEmpty() } return PersistedEntry( alias = alias, ciphertext = ciphertext, iv = iv, metadata = metadata, authenticators = authenticators, requiresAuthentication = requiresAuth, invalidateOnEnrollment = invalidateOnEnrollment, useStrongBox = useStrongBox, keyVersion = keyVersion, usesAad = usesAad, integrityTag = integrityTag ) } } }