- Swift: getOrCreateKey(service:account:), loadKey(), deleteKey() - Generates random AES-256 key, persists as hex in BLKeychain - Subsequent calls return the same key for stable per-device DEK - Kotlin: getOrCreateKey(store:account:), loadKey(), deleteKey() - Generates random AES-256 key, persists as hex in BLSecureStore - Uses EncryptedSharedPreferences for at-rest protection - All existing tests still pass (21/21 Kotlin)
278 lines
9.9 KiB
Swift
278 lines
9.9 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: - 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.
|
|
/// 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
|
|
}
|
|
}
|