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:
parent
b8ce14c259
commit
ee762b4612
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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.). */
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
230
packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift
Normal file
230
packages/swift-platform-sdk/Sources/BLFieldEncrypt.swift
Normal 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
|
||||
}
|
||||
}
|
||||
186
packages/swift-platform-sdk/Tests/BLFieldEncryptTests.swift
Normal file
186
packages/swift-platform-sdk/Tests/BLFieldEncryptTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user