Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-14 13:20:26 -06:00
parent dde54f2efd
commit 743df280bc
4 changed files with 93 additions and 20 deletions

View File

@ -0,0 +1,23 @@
import Foundation
/// Configuration for the EncryptionHelper.
public struct EncryptionConfiguration: Sendable {
public let masterKeyService: String
public let masterKeyAccount: String
public let masterKeyLength: Int
public let defaultHKDFInfo: String
public init(
masterKeyService: String = "LocalData",
masterKeyAccount: String = "MasterKey",
masterKeyLength: Int = 32,
defaultHKDFInfo: String = "LocalData.Encryption"
) {
self.masterKeyService = masterKeyService
self.masterKeyAccount = masterKeyAccount
self.masterKeyLength = masterKeyLength
self.defaultHKDFInfo = defaultHKDFInfo
}
public static let `default` = EncryptionConfiguration()
}

View File

@ -2,21 +2,27 @@ import Foundation
import CryptoKit
/// Actor that handles all encryption and decryption operations.
/// Uses AES-GCM for symmetric encryption with PBKDF2 key derivation.
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys.
actor EncryptionHelper {
public static let shared = EncryptionHelper()
private enum Constants {
static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32
static let defaultHKDFInfo = "LocalData.Encryption"
}
private var configuration: EncryptionConfiguration
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
private init() {}
private init(configuration: EncryptionConfiguration = .default) {
self.configuration = configuration
}
// MARK: - Configuration
/// Updates the configuration for the actor.
/// > [!WARNING]
/// > Changing the configuration (specifically service or account) on an existing instance
/// > will cause it to look for the master key in a new location.
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
self.configuration = configuration
}
// MARK: - Public Interface
@ -28,7 +34,7 @@ actor EncryptionHelper {
keyMaterialProviders[source] = provider
}
/// Encrypts data using AES-GCM.
/// Encrypts data using AES-GCM or ChaChaPoly.
/// - Parameters:
/// - data: The plaintext data to encrypt.
/// - keyName: A unique name used for key derivation salt.
@ -44,7 +50,7 @@ actor EncryptionHelper {
return try encryptWithKey(data, using: key, policy: policy)
}
/// Decrypts data using AES-GCM.
/// Decrypts data using AES-GCM or ChaChaPoly.
/// - Parameters:
/// - data: The encrypted data (nonce + ciphertext + tag combined).
/// - keyName: The same unique name used during encryption.
@ -102,18 +108,18 @@ actor EncryptionHelper {
password: baseKeyMaterial,
salt: salt,
iterations: iterations,
keyLength: Constants.masterKeyLength
keyLength: configuration.masterKeyLength
)
return SymmetricKey(data: derivedKeyData)
case .hkdf(let customSalt, let customInfo):
let salt = customSalt ?? defaultSalt(for: keyName)
let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8)
let info = customInfo ?? Data(configuration.defaultHKDFInfo.utf8)
let inputKey = SymmetricKey(data: baseKeyMaterial)
return HKDF<SHA256>.deriveKey(
inputKeyMaterial: inputKey,
salt: salt,
info: info,
outputByteCount: Constants.masterKeyLength
outputByteCount: configuration.masterKeyLength
)
}
}
@ -121,14 +127,14 @@ actor EncryptionHelper {
/// Gets or creates the master key stored in keychain.
private func getMasterKey() async throws -> Data {
if let existing = try await KeychainHelper.shared.get(
service: Constants.masterKeyService,
key: Constants.masterKeyAccount
service: configuration.masterKeyService,
key: configuration.masterKeyAccount
) {
return existing
}
// Generate new master key
var bytes = [UInt8](repeating: 0, count: Constants.masterKeyLength)
var bytes = [UInt8](repeating: 0, count: configuration.masterKeyLength)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw StorageError.securityApplicationFailed
@ -139,8 +145,8 @@ actor EncryptionHelper {
// Store in keychain
try await KeychainHelper.shared.set(
masterKey,
service: Constants.masterKeyService,
key: Constants.masterKeyAccount,
service: configuration.masterKeyService,
key: configuration.masterKeyAccount,
accessibility: .afterFirstUnlock,
accessControl: nil
)

View File

@ -13,6 +13,16 @@ public actor StorageRouter: StorageProviding {
private init() {}
// MARK: - Configuration
/// Updates the encryption configuration.
/// > [!WARNING]
/// > Changing these constants in an existing app will cause the app to look for the master key
/// > under a new name. Previously encrypted data will be lost.
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
await EncryptionHelper.shared.updateConfiguration(configuration)
}
// MARK: - Key Material Providers
/// Registers a key material provider for external encryption policies.

View File

@ -3,7 +3,7 @@ import Testing
@testable import LocalData
struct EncryptionHelperTests {
private let masterKeyService = "LocalData.MasterKey"
private let masterKeyService = "LocalData"
private let keyName = "LocalDataTests.encryption"
private let payload = Data("payload".utf8)
@ -51,6 +51,40 @@ struct EncryptionHelperTests {
await clearMasterKey()
}
@Test func customConfigurationRoundTrip() async throws {
let customService = "Test.CustomService"
let customAccount = "Test.CustomAccount"
let config = EncryptionConfiguration(
masterKeyService: customService,
masterKeyAccount: customAccount
)
await EncryptionHelper.shared.updateConfiguration(config)
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
keyDerivation: .hkdf()
)
let encrypted = try await EncryptionHelper.shared.encrypt(
payload,
keyName: keyName,
policy: policy
)
let decrypted = try await EncryptionHelper.shared.decrypt(
encrypted,
keyName: keyName,
policy: policy
)
#expect(decrypted == payload)
// Cleanup keychain
try? await KeychainHelper.shared.deleteAll(service: customService)
// Reset to default
await EncryptionHelper.shared.updateConfiguration(.default)
}
@Test func externalProviderWithHKDFRoundTrip() async throws {
let source = KeyMaterialSource(id: "test.external")
let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32))