feat(native-sdks): add BLFieldEncrypt to Swift + Kotlin platform SDKs

- 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)
This commit is contained in:
saravanakumardb1 2026-03-21 10:58:02 -07:00
parent b8ce14c259
commit ee762b4612
5 changed files with 823 additions and 1 deletions

View File

@ -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<String, Any?>?): 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()
}
}

View File

@ -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.). */

View File

@ -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<AEADBadTagException> {
BLFieldEncrypt.decrypt(encrypted, key, "wrong")
}
}
@Test
fun `decrypt with missing AAD fails`() {
val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "some-aad")
assertThrows<AEADBadTagException> {
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<AEADBadTagException> {
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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<String, Any?>(
"__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<String, Any?>("__encrypted" to true, "v" to 1)
assertFalse(BLFieldEncrypt.isEncrypted(map))
}
@Test
fun `isEncrypted with null map`() {
assertFalse(BLFieldEncrypt.isEncrypted(null as Map<String, Any?>?))
}
// ── 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<IllegalArgumentException> {
"a".hexToByteArray()
}
}
}

View File

@ -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..<nextIndex], radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
self = data
}
}

View File

@ -0,0 +1,186 @@
import XCTest
import CryptoKit
@testable import ByteLystPlatformSDK
final class BLFieldEncryptTests: XCTestCase {
private var key: SymmetricKey!
private let dekId = "dek_user1_test"
override func setUp() {
super.setUp()
key = BLFieldEncrypt.generateKey()
}
// MARK: - Encrypt / Decrypt Roundtrip
func testEncryptDecryptRoundtrip() throws {
let plaintext = "Hello, World!"
let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId)
let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key)
XCTAssertEqual(decrypted, plaintext)
}
func testEncryptDecryptEmptyString() throws {
let plaintext = ""
let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId)
let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key)
XCTAssertEqual(decrypted, plaintext)
}
func testEncryptDecryptUnicode() throws {
let plaintext = "こんにちは世界 🌍 مرحبا Ñoño"
let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId)
let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key)
XCTAssertEqual(decrypted, plaintext)
}
func testEncryptDecryptLargePayload() throws {
let plaintext = String(repeating: "A", count: 100_000)
let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId)
let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key)
XCTAssertEqual(decrypted, plaintext)
}
// MARK: - EncryptedField Structure
func testEncryptedFieldHasCorrectSentinel() throws {
let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId)
XCTAssertTrue(encrypted.__encrypted)
XCTAssertEqual(encrypted.v, 1)
XCTAssertEqual(encrypted.alg, "aes-256-gcm")
XCTAssertEqual(encrypted.dekId, dekId)
}
func testEncryptedFieldHasCorrectHexLengths() throws {
let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId)
// IV: 12 bytes = 24 hex chars
XCTAssertEqual(encrypted.iv.count, 24)
// Tag: 16 bytes = 32 hex chars
XCTAssertEqual(encrypted.tag.count, 32)
// ct should be non-empty hex
XCTAssertFalse(encrypted.ct.isEmpty)
}
func testEachEncryptionProducesUniqueIV() throws {
let a = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId)
let b = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId)
XCTAssertNotEqual(a.iv, b.iv, "Each encryption should use a unique IV")
XCTAssertNotEqual(a.ct, b.ct, "Ciphertext should differ with different IVs")
}
// MARK: - AAD (Additional Authenticated Data)
func testEncryptDecryptWithAAD() throws {
let plaintext = "secret data"
let aad = "user123:notes"
let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId, aad: aad)
let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key, aad: aad)
XCTAssertEqual(decrypted, plaintext)
}
func testDecryptWithWrongAADFails() throws {
let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "correct")
XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key, aad: "wrong"))
}
func testDecryptWithMissingAADFails() throws {
let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "some-aad")
XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key))
}
// MARK: - Wrong Key
func testDecryptWithWrongKeyFails() throws {
let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId)
let wrongKey = BLFieldEncrypt.generateKey()
XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: wrongKey))
}
// MARK: - Key Size Validation
func testEncryptRejectsShortKey() {
let shortKey = SymmetricKey(size: .bits128)
XCTAssertThrowsError(try BLFieldEncrypt.encrypt("test", key: shortKey, dekId: dekId)) { error in
guard let encError = error as? BLFieldEncryptError,
case .invalidKeySize = encError else {
XCTFail("Expected invalidKeySize error")
return
}
}
}
// MARK: - Key from Hex
func testKeyFromHex() throws {
let hex = String(repeating: "ab", count: 32) // 64 hex chars = 32 bytes
let key = try BLFieldEncrypt.keyFromHex(hex)
let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId)
let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key)
XCTAssertEqual(decrypted, "test")
}
func testKeyFromHexRejectsInvalidLength() {
XCTAssertThrowsError(try BLFieldEncrypt.keyFromHex("aabb"))
}
// MARK: - isEncrypted Type Guard
func testIsEncryptedWithValidField() throws {
let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId)
XCTAssertTrue(BLFieldEncrypt.isEncrypted(encrypted))
}
func testIsEncryptedWithNil() {
XCTAssertFalse(BLFieldEncrypt.isEncrypted(nil as BLEncryptedField?))
}
func testIsEncryptedWithDictionary() {
let dict: [String: Any] = [
"__encrypted": true,
"v": 1,
"alg": "aes-256-gcm",
"ct": "abcd",
"iv": "1234",
"tag": "5678",
"dekId": "dek_test",
]
XCTAssertTrue(BLFieldEncrypt.isEncrypted(dict))
}
func testIsEncryptedWithPlainString() {
XCTAssertFalse(BLFieldEncrypt.isEncrypted("just a string" as Any))
}
func testIsEncryptedWithIncompleteDict() {
let dict: [String: Any] = ["__encrypted": true, "v": 1]
XCTAssertFalse(BLFieldEncrypt.isEncrypted(dict))
}
// MARK: - JSON Codable Roundtrip
func testEncryptedFieldCodableRoundtrip() throws {
let encrypted = try BLFieldEncrypt.encrypt("codable test", key: key, dekId: dekId)
let encoder = JSONEncoder()
let data = try encoder.encode(encrypted)
let decoder = JSONDecoder()
let decoded = try decoder.decode(BLEncryptedField.self, from: data)
let decrypted = try BLFieldEncrypt.decrypt(decoded, key: key)
XCTAssertEqual(decrypted, "codable test")
}
// MARK: - Hex Helpers
func testDataHexRoundtrip() {
let original = Data([0x00, 0x0f, 0xff, 0xab, 0xcd])
let hex = original.hexString
XCTAssertEqual(hex, "000fffabcd")
let restored = Data(hexString: hex)
XCTAssertEqual(restored, original)
}
func testDataHexInvalidString() {
XCTAssertNil(Data(hexString: "xyz"))
XCTAssertNil(Data(hexString: "a")) // odd length
}
}