Update Models, Services and tests
Summary: - Sources: update Models, Services - Tests: update tests for EncryptionHelperTests.swift Stats: - 4 files changed, 93 insertions(+), 20 deletions(-)
This commit is contained in:
parent
de8bc75892
commit
0fed77a68e
23
Sources/LocalData/Models/EncryptionConfiguration.swift
Normal file
23
Sources/LocalData/Models/EncryptionConfiguration.swift
Normal 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()
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user