From ee762b46126dca39d1dc5bb4eaf8157faa822ede Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 21 Mar 2026 10:58:02 -0700 Subject: [PATCH] feat(native-sdks): add BLFieldEncrypt to Swift + Kotlin platform SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swift: BLFieldEncrypt.swift + BLFieldEncryptTests.swift (22 tests) - CryptoKit AES-256-GCM, BLEncryptedField Codable struct - encrypt/decrypt, AAD support, generateKey, keyFromHex, isEncrypted - Data hex helpers (hexString, init?(hexString:)) - Kotlin: BLFieldEncrypt.kt + BLFieldEncryptTest.kt (21 tests) - javax.crypto AES-256-GCM, BLEncryptedField data class - encrypt/decrypt, AAD support, generateKey, keyFromHex, isEncrypted - ByteArray/String hex extension functions - Wire-compatible: same EncryptedField JSON structure as @bytelyst/field-encrypt (TS) - { __encrypted: true, v: 1, alg: 'aes-256-gcm', ct, iv, tag, dekId } - All hex-encoded, 12-byte IV, 16-byte auth tag - Fix: ByteLystPlatform.kt getString() → read() (pre-existing compile error) --- .../com/bytelyst/platform/BLFieldEncrypt.kt | 200 +++++++++++++++ .../com/bytelyst/platform/ByteLystPlatform.kt | 2 +- .../bytelyst/platform/BLFieldEncryptTest.kt | 206 ++++++++++++++++ .../Sources/BLFieldEncrypt.swift | 230 ++++++++++++++++++ .../Tests/BLFieldEncryptTests.swift | 186 ++++++++++++++ 5 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt create mode 100644 packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt create mode 100644 packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift create mode 100644 packages/swift-platform-sdk/Tests/BLFieldEncryptTests.swift 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 new file mode 100644 index 00000000..86e1f59d --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt @@ -0,0 +1,200 @@ +package com.bytelyst.platform + +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom + +/** + * Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). + * All byte arrays are hex-encoded strings for JSON serialization. + */ +data class BLEncryptedField( + /** Sentinel — always true for encrypted fields. */ + val __encrypted: Boolean = true, + /** Schema version (currently 1). */ + val v: Int = 1, + /** Algorithm identifier. */ + val alg: String = "aes-256-gcm", + /** Ciphertext (hex-encoded). */ + val ct: String, + /** Initialization vector (hex-encoded, 12 bytes = 24 hex chars). */ + val iv: String, + /** GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). */ + val tag: String, + /** DEK identifier — identifies which key was used for encryption. */ + val dekId: String, +) + +/** + * AES-256-GCM field-level encryption. + * + * Produces [BLEncryptedField] objects that are wire-compatible with the + * TypeScript `@bytelyst/field-encrypt` package. Backends and native clients + * can encrypt/decrypt the same fields interchangeably. + * + * Usage: + * ```kotlin + * val key = BLFieldEncrypt.generateKey() + * val encrypted = BLFieldEncrypt.encrypt("sensitive data", key, "dek_user1_notes") + * val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + * ``` + */ +object BLFieldEncrypt { + + private const val ALGORITHM = "AES/GCM/NoPadding" + private const val KEY_SIZE_BYTES = 32 + private const val KEY_SIZE_BITS = KEY_SIZE_BYTES * 8 + private const val IV_SIZE_BYTES = 12 + private const val TAG_SIZE_BITS = 128 + + private val secureRandom = SecureRandom() + + // ── Encrypt ───────────────────────────────────────────── + + /** + * Encrypt a plaintext string with AES-256-GCM. + * + * @param plaintext UTF-8 string to encrypt. + * @param key 32-byte AES secret key. + * @param dekId DEK identifier stored in the output for key lookup on decrypt. + * @param aad Optional additional authenticated data (e.g., "userId:context"). + * @return [BLEncryptedField] with hex-encoded ciphertext, IV, and tag. + * @throws IllegalArgumentException if key size is wrong. + */ + fun encrypt( + plaintext: String, + key: SecretKey, + dekId: String, + aad: String? = null, + ): BLEncryptedField { + require(key.encoded.size == KEY_SIZE_BYTES) { + "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" + } + + val iv = ByteArray(IV_SIZE_BYTES).also { secureRandom.nextBytes(it) } + + val cipher = Cipher.getInstance(ALGORITHM) + val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) + cipher.init(Cipher.ENCRYPT_MODE, key, spec) + + if (aad != null) { + cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) + } + + // GCM output = ciphertext || tag (last 16 bytes) + val ciphertextWithTag = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val tagOffset = ciphertextWithTag.size - TAG_SIZE_BITS / 8 + val ct = ciphertextWithTag.copyOfRange(0, tagOffset) + val tag = ciphertextWithTag.copyOfRange(tagOffset, ciphertextWithTag.size) + + return BLEncryptedField( + ct = ct.toHexString(), + iv = iv.toHexString(), + tag = tag.toHexString(), + dekId = dekId, + ) + } + + // ── Decrypt ───────────────────────────────────────────── + + /** + * Decrypt a [BLEncryptedField] back to plaintext. + * + * @param field Encrypted field object (from Cosmos DB or API response). + * @param key 32-byte AES secret key (must match the key used to encrypt). + * @param aad Optional AAD (must match the AAD used during encryption). + * @return Decrypted UTF-8 string. + * @throws javax.crypto.AEADBadTagException if authentication fails (tampered data or wrong key). + */ + fun decrypt( + field: BLEncryptedField, + key: SecretKey, + aad: String? = null, + ): String { + require(key.encoded.size == KEY_SIZE_BYTES) { + "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" + } + + val iv = field.iv.hexToByteArray() + val ct = field.ct.hexToByteArray() + val tag = field.tag.hexToByteArray() + + // GCM expects ciphertext || tag as input + val ciphertextWithTag = ct + tag + + val cipher = Cipher.getInstance(ALGORITHM) + val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + + if (aad != null) { + cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) + } + + val plaintextBytes = cipher.doFinal(ciphertextWithTag) + return String(plaintextBytes, Charsets.UTF_8) + } + + // ── Key Generation ────────────────────────────────────── + + /** + * Generate a random 32-byte AES-256 secret key. + */ + fun generateKey(): SecretKey { + val keyGen = KeyGenerator.getInstance("AES") + keyGen.init(KEY_SIZE_BITS, secureRandom) + return keyGen.generateKey() + } + + /** + * Create a [SecretKey] from a hex-encoded string (64 hex chars = 32 bytes). + */ + fun keyFromHex(hex: String): SecretKey { + val bytes = hex.hexToByteArray() + require(bytes.size == KEY_SIZE_BYTES) { + "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${bytes.size}" + } + return SecretKeySpec(bytes, "AES") + } + + // ── Type Guard ────────────────────────────────────────── + + /** + * Check if a JSON-like map represents an encrypted field. + * Compatible with the TypeScript `isEncryptedField()` type guard. + */ + fun isEncrypted(value: Map?): Boolean { + if (value == null) return false + return value["__encrypted"] == true && + value["v"] != null && + value["alg"] != null && + value["ct"] != null && + value["iv"] != null && + value["tag"] != null && + value["dekId"] != null + } + + /** + * Check if a [BLEncryptedField] is valid. + */ + fun isEncrypted(field: BLEncryptedField?): Boolean { + return field?.__encrypted == true + } +} + +// ── Hex Helpers ───────────────────────────────────────────── + +/** Hex-encode a byte array to a lowercase string. */ +fun ByteArray.toHexString(): String = + joinToString("") { "%02x".format(it) } + +/** Decode a hex-encoded string to a byte array. */ +fun String.hexToByteArray(): ByteArray { + require(length % 2 == 0) { "Hex string must have even length, got $length" } + return ByteArray(length / 2) { i -> + val offset = i * 2 + substring(offset, offset + 2).toInt(16).toByte() + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt index 64dfdb12..3fd4d4b7 100644 --- a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt @@ -34,7 +34,7 @@ class ByteLystPlatform( /** HTTP client shared by services that need a token provider. */ val client: BLPlatformClient = BLPlatformClient(config) { - secureStore.getString("access_token") + secureStore.read("access_token") } /** Auth client (login, register, refresh, MFA, etc.). */ diff --git a/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt b/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt new file mode 100644 index 00000000..969cc1ab --- /dev/null +++ b/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt @@ -0,0 +1,206 @@ +package com.bytelyst.platform + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import javax.crypto.AEADBadTagException +import javax.crypto.SecretKey + +class BLFieldEncryptTest { + + private lateinit var key: SecretKey + private val dekId = "dek_user1_test" + + @BeforeEach + fun setUp() { + key = BLFieldEncrypt.generateKey() + } + + // ── Encrypt / Decrypt Roundtrip ───────────────────────── + + @Test + fun `encrypt decrypt roundtrip`() { + val plaintext = "Hello, World!" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + @Test + fun `encrypt decrypt empty string`() { + val plaintext = "" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + @Test + fun `encrypt decrypt unicode`() { + val plaintext = "こんにちは世界 \uD83C\uDF0D مرحبا Ñoño" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + @Test + fun `encrypt decrypt large payload`() { + val plaintext = "A".repeat(100_000) + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + // ── EncryptedField Structure ──────────────────────────── + + @Test + fun `encrypted field has correct sentinel`() { + val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) + assertTrue(encrypted.__encrypted) + assertEquals(1, encrypted.v) + assertEquals("aes-256-gcm", encrypted.alg) + assertEquals(dekId, encrypted.dekId) + } + + @Test + fun `encrypted field has correct hex lengths`() { + val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) + // IV: 12 bytes = 24 hex chars + assertEquals(24, encrypted.iv.length) + // Tag: 16 bytes = 32 hex chars + assertEquals(32, encrypted.tag.length) + // ct should be non-empty + assertTrue(encrypted.ct.isNotEmpty()) + } + + @Test + fun `each encryption produces unique IV`() { + val a = BLFieldEncrypt.encrypt("same", key, dekId) + val b = BLFieldEncrypt.encrypt("same", key, dekId) + assertNotEquals(a.iv, b.iv, "Each encryption should use a unique IV") + assertNotEquals(a.ct, b.ct, "Ciphertext should differ with different IVs") + } + + // ── AAD (Additional Authenticated Data) ───────────────── + + @Test + fun `encrypt decrypt with AAD`() { + val plaintext = "secret data" + val aad = "user123:notes" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId, aad) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key, aad) + assertEquals(plaintext, decrypted) + } + + @Test + fun `decrypt with wrong AAD fails`() { + val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "correct") + assertThrows { + BLFieldEncrypt.decrypt(encrypted, key, "wrong") + } + } + + @Test + fun `decrypt with missing AAD fails`() { + val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "some-aad") + assertThrows { + BLFieldEncrypt.decrypt(encrypted, key) + } + } + + // ── Wrong Key ─────────────────────────────────────────── + + @Test + fun `decrypt with wrong key fails`() { + val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId) + val wrongKey = BLFieldEncrypt.generateKey() + assertThrows { + BLFieldEncrypt.decrypt(encrypted, wrongKey) + } + } + + // ── Key Size Validation ───────────────────────────────── + + @Test + fun `encrypt rejects short key`() { + val shortKeyBytes = ByteArray(16) // 128-bit instead of 256-bit + val shortKey = javax.crypto.spec.SecretKeySpec(shortKeyBytes, "AES") + assertThrows { + BLFieldEncrypt.encrypt("test", shortKey, dekId) + } + } + + // ── Key from Hex ──────────────────────────────────────── + + @Test + fun `key from hex`() { + val hex = "ab".repeat(32) // 64 hex chars = 32 bytes + val hexKey = BLFieldEncrypt.keyFromHex(hex) + val encrypted = BLFieldEncrypt.encrypt("test", hexKey, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, hexKey) + assertEquals("test", decrypted) + } + + @Test + fun `key from hex rejects invalid length`() { + assertThrows { + BLFieldEncrypt.keyFromHex("aabb") + } + } + + // ── isEncrypted Type Guard ────────────────────────────── + + @Test + fun `isEncrypted with valid field`() { + val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) + assertTrue(BLFieldEncrypt.isEncrypted(encrypted)) + } + + @Test + fun `isEncrypted with null field`() { + assertFalse(BLFieldEncrypt.isEncrypted(null as BLEncryptedField?)) + } + + @Test + fun `isEncrypted with map`() { + val map = mapOf( + "__encrypted" to true, + "v" to 1, + "alg" to "aes-256-gcm", + "ct" to "abcd", + "iv" to "1234", + "tag" to "5678", + "dekId" to "dek_test", + ) + assertTrue(BLFieldEncrypt.isEncrypted(map)) + } + + @Test + fun `isEncrypted with incomplete map`() { + val map = mapOf("__encrypted" to true, "v" to 1) + assertFalse(BLFieldEncrypt.isEncrypted(map)) + } + + @Test + fun `isEncrypted with null map`() { + assertFalse(BLFieldEncrypt.isEncrypted(null as Map?)) + } + + // ── Hex Helpers ───────────────────────────────────────── + + @Test + fun `byte array hex roundtrip`() { + val original = byteArrayOf(0x00, 0x0f, 0xff.toByte(), 0xab.toByte(), 0xcd.toByte()) + val hex = original.toHexString() + assertEquals("000fffabcd", hex) + val restored = hex.hexToByteArray() + assertArrayEquals(original, restored) + } + + @Test + fun `hex to byte array rejects odd length`() { + assertThrows { + "a".hexToByteArray() + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift b/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift new file mode 100644 index 00000000..95345f47 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift @@ -0,0 +1,230 @@ +// ── Field Encryption ──────────────────────────────────────── +// AES-256-GCM field-level encryption compatible with @bytelyst/field-encrypt (TypeScript). +// Produces and consumes the same EncryptedField JSON structure across all platforms. +// Uses Apple CryptoKit — available on iOS 13+, macOS 10.15+, watchOS 6+. + +import CryptoKit +import Foundation + +// MARK: - EncryptedField Model + +/// Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). +/// All byte arrays are hex-encoded strings for JSON serialization. +public struct BLEncryptedField: Codable, Sendable, Equatable { + /// Sentinel — always `true` for encrypted fields. + public let __encrypted: Bool + /// Schema version (currently 1). + public let v: Int + /// Algorithm identifier. + public let alg: String + /// Ciphertext (hex-encoded). + public let ct: String + /// Initialization vector (hex-encoded, 12 bytes = 24 hex chars). + public let iv: String + /// GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). + public let tag: String + /// DEK identifier — identifies which key was used for encryption. + public let dekId: String + + public init(ct: String, iv: String, tag: String, dekId: String) { + self.__encrypted = true + self.v = 1 + self.alg = "aes-256-gcm" + self.ct = ct + self.iv = iv + self.tag = tag + self.dekId = dekId + } +} + +// MARK: - BLFieldEncrypt + +/// AES-256-GCM field-level encryption. +/// +/// Produces `BLEncryptedField` objects that are wire-compatible with the +/// TypeScript `@bytelyst/field-encrypt` package. Backends and native clients +/// can encrypt/decrypt the same fields interchangeably. +/// +/// Usage: +/// ```swift +/// let key = BLFieldEncrypt.generateKey() +/// let encrypted = try BLFieldEncrypt.encrypt("sensitive data", key: key, dekId: "dek_user1_notes") +/// let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) +/// ``` +public enum BLFieldEncrypt { + + /// AES-256-GCM key size in bytes. + public static let keySize = 32 + + /// GCM nonce (IV) size in bytes. + private static let nonceSize = 12 + + // MARK: - Encrypt + + /// Encrypt a plaintext string with AES-256-GCM. + /// + /// - Parameters: + /// - plaintext: UTF-8 string to encrypt. + /// - key: 32-byte symmetric key. + /// - dekId: DEK identifier stored in the output for key lookup on decrypt. + /// - aad: Optional additional authenticated data (e.g., "userId:context"). + /// - Returns: `BLEncryptedField` with hex-encoded ciphertext, IV, and tag. + /// - Throws: `BLFieldEncryptError` if key size is wrong or encryption fails. + public static func encrypt( + _ plaintext: String, + key: SymmetricKey, + dekId: String, + aad: String? = nil + ) throws -> BLEncryptedField { + guard key.bitCount == keySize * 8 else { + throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) + } + + let plaintextData = Data(plaintext.utf8) + let nonce = AES.GCM.Nonce() + + let sealedBox: AES.GCM.SealedBox + if let aad = aad { + sealedBox = try AES.GCM.seal( + plaintextData, + using: key, + nonce: nonce, + authenticating: Data(aad.utf8) + ) + } else { + sealedBox = try AES.GCM.seal(plaintextData, using: key, nonce: nonce) + } + + return BLEncryptedField( + ct: sealedBox.ciphertext.hexString, + iv: Data(sealedBox.nonce).hexString, + tag: sealedBox.tag.hexString, + dekId: dekId + ) + } + + // MARK: - Decrypt + + /// Decrypt a `BLEncryptedField` back to plaintext. + /// + /// - Parameters: + /// - field: Encrypted field object (from Cosmos DB or API response). + /// - key: 32-byte symmetric key (must match the key used to encrypt). + /// - aad: Optional AAD (must match the AAD used during encryption). + /// - Returns: Decrypted UTF-8 string. + /// - Throws: `BLFieldEncryptError` if decryption or authentication fails. + public static func decrypt( + _ field: BLEncryptedField, + key: SymmetricKey, + aad: String? = nil + ) throws -> String { + guard key.bitCount == keySize * 8 else { + throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) + } + + guard let ctData = Data(hexString: field.ct), + let ivData = Data(hexString: field.iv), + let tagData = Data(hexString: field.tag) else { + throw BLFieldEncryptError.invalidHexEncoding + } + + let nonce = try AES.GCM.Nonce(data: ivData) + let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctData, tag: tagData) + + let decryptedData: Data + if let aad = aad { + decryptedData = try AES.GCM.open(sealedBox, using: key, authenticating: Data(aad.utf8)) + } else { + decryptedData = try AES.GCM.open(sealedBox, using: key) + } + + guard let plaintext = String(data: decryptedData, encoding: .utf8) else { + throw BLFieldEncryptError.utf8DecodingFailed + } + + return plaintext + } + + // MARK: - Key Generation + + /// Generate a random 32-byte AES-256 symmetric key. + public static func generateKey() -> SymmetricKey { + SymmetricKey(size: .bits256) + } + + /// Create a `SymmetricKey` from a hex-encoded string (64 hex chars = 32 bytes). + public static func keyFromHex(_ hex: String) throws -> SymmetricKey { + guard let data = Data(hexString: hex), data.count == keySize else { + throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: hex.count / 2) + } + return SymmetricKey(data: data) + } + + // MARK: - Type Guard + + /// Check if a JSON value represents an encrypted field. + /// Compatible with the TypeScript `isEncryptedField()` type guard. + public static func isEncrypted(_ value: Any?) -> Bool { + if let dict = value as? [String: Any], + let sentinel = dict["__encrypted"] as? Bool, + sentinel == true, + dict["v"] != nil, + dict["alg"] != nil, + dict["ct"] != nil, + dict["iv"] != nil, + dict["tag"] != nil, + dict["dekId"] != nil { + return true + } + return false + } + + /// Check if a `Codable` value is a `BLEncryptedField`. + public static func isEncrypted(_ field: BLEncryptedField?) -> Bool { + field?.__encrypted == true + } +} + +// MARK: - Errors + +public enum BLFieldEncryptError: LocalizedError { + case invalidKeySize(expected: Int, actual: Int) + case invalidHexEncoding + case utf8DecodingFailed + + public var errorDescription: String? { + switch self { + case .invalidKeySize(let expected, let actual): + return "AES-256-GCM requires a \(expected)-byte key, got \(actual)" + case .invalidHexEncoding: + return "Failed to decode hex-encoded field data" + case .utf8DecodingFailed: + return "Decrypted data is not valid UTF-8" + } + } +} + +// MARK: - Hex Helpers + +extension Data { + /// Hex-encode data to a lowercase string. + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } + + /// Initialize Data from a hex-encoded string. + init?(hexString: String) { + let hex = hexString.lowercased() + guard hex.count.isMultiple(of: 2) else { return nil } + + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index..