- 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)
231 lines
7.8 KiB
Swift
231 lines
7.8 KiB
Swift
// ── 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
|
|
}
|
|
}
|