diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt index 86e1f59d..318276ee 100644 --- a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt @@ -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 ────────────────────────────────────────── /** diff --git a/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift b/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift index 95345f47..0260d617 100644 --- a/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift +++ b/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift @@ -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.