feat(native-sdks): add Keychain/SecureStore key derivation to BLFieldEncrypt

- Swift: getOrCreateKey(service:account:), loadKey(), deleteKey()
  - Generates random AES-256 key, persists as hex in BLKeychain
  - Subsequent calls return the same key for stable per-device DEK

- Kotlin: getOrCreateKey(store:account:), loadKey(), deleteKey()
  - Generates random AES-256 key, persists as hex in BLSecureStore
  - Uses EncryptedSharedPreferences for at-rest protection

- All existing tests still pass (21/21 Kotlin)
This commit is contained in:
saravanakumardb1 2026-03-21 11:10:02 -07:00
parent e59dcdb9ac
commit 9bb322113a
2 changed files with 100 additions and 0 deletions

View File

@ -159,6 +159,59 @@ object BLFieldEncrypt {
return SecretKeySpec(bytes, "AES")
}
// ── Secure Store Key Derivation ────────────────────────
/**
* Get or create a persistent encryption key in [BLSecureStore].
*
* On first call, generates a random 32-byte AES-256 key and stores it
* as a hex string in EncryptedSharedPreferences. On subsequent calls,
* loads the existing key. This provides a stable per-device DEK for
* client-side encryption without requiring the backend to provision keys.
*
* @param store The [BLSecureStore] instance for the current app.
* @param account Storage key name (default: `"field_encrypt_dek"`).
* @return A 32-byte [SecretKey] backed by secure storage.
*/
fun getOrCreateKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey {
val existingHex = store.read(account)
if (existingHex != null) {
return keyFromHex(existingHex)
}
val newKey = generateKey()
val hex = newKey.encoded.toHexString()
store.save(account, hex)
return newKey
}
/**
* Load an existing encryption key from [BLSecureStore] without creating one.
*
* @param store The [BLSecureStore] instance.
* @param account Storage key name (default: `"field_encrypt_dek"`).
* @return The stored [SecretKey], or `null` if none exists.
*/
fun loadKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey? {
val hex = store.read(account) ?: return null
return try {
keyFromHex(hex)
} catch (_: IllegalArgumentException) {
null
}
}
/**
* Delete the stored encryption key from [BLSecureStore].
*
* @param store The [BLSecureStore] instance.
* @param account Storage key name.
* @return `true` if the key was deleted.
*/
fun deleteKey(store: BLSecureStore, account: String = "field_encrypt_dek"): Boolean {
return store.delete(account)
}
// ── Type Guard ──────────────────────────────────────────
/**

View File

@ -160,6 +160,53 @@ public enum BLFieldEncrypt {
return SymmetricKey(data: data)
}
// MARK: - Keychain Key Derivation
/// Get or create a persistent encryption key in the Keychain.
///
/// On first call, generates a random 32-byte AES-256 key and stores it
/// as a hex string in the Keychain. On subsequent calls, loads the
/// existing key. This provides a stable per-device DEK for client-side
/// encryption without requiring the backend to provision keys.
///
/// - Parameters:
/// - service: Keychain service identifier (typically the app's bundle ID).
/// - account: Keychain account key (e.g., `"field_encrypt_dek"`).
/// - Returns: A 32-byte `SymmetricKey` backed by Keychain storage.
/// - Throws: `BLFieldEncryptError.invalidHexEncoding` if stored key is corrupt.
public static func getOrCreateKey(service: String, account: String = "field_encrypt_dek") throws -> SymmetricKey {
if let existingHex = BLKeychain.read(service: service, key: account) {
return try keyFromHex(existingHex)
}
let newKey = generateKey()
let hex = newKey.withUnsafeBytes { Data($0).hexString }
BLKeychain.save(service: service, key: account, value: hex)
return newKey
}
/// Load an existing encryption key from the Keychain without creating one.
///
/// - Parameters:
/// - service: Keychain service identifier.
/// - account: Keychain account key (e.g., `"field_encrypt_dek"`).
/// - Returns: The stored `SymmetricKey`, or `nil` if none exists.
public static func loadKey(service: String, account: String = "field_encrypt_dek") -> SymmetricKey? {
guard let hex = BLKeychain.read(service: service, key: account) else { return nil }
return try? keyFromHex(hex)
}
/// Delete the stored encryption key from the Keychain.
///
/// - Parameters:
/// - service: Keychain service identifier.
/// - account: Keychain account key.
/// - Returns: `true` if the key was deleted or didn't exist.
@discardableResult
public static func deleteKey(service: String, account: String = "field_encrypt_dek") -> Bool {
BLKeychain.delete(service: service, key: account)
}
// MARK: - Type Guard
/// Check if a JSON value represents an encrypted field.