Update Models, Protocols, Services + tests + docs

Summary:
- Sources: Models, Protocols, Services
- Tests: EncryptionHelperTests.swift
- Docs: README
- Added symbols: struct RemoteKeyProvider, func keyMaterial, struct KeyMaterialSource, protocol KeyMaterialProviding, func registerKeyMaterialProvider, struct StaticKeyMaterialProvider

Stats:
- 6 files changed, 104 insertions(+), 14 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-14 09:55:15 -06:00
parent 12e1f3b008
commit 8f3fc8da51
6 changed files with 104 additions and 14 deletions

View File

@ -20,6 +20,7 @@ StorageRouter (main entry point)
### Protocols ### Protocols
- **StorageKey** - Define storage configuration for each data type - **StorageKey** - Define storage configuration for each data type
- **StorageProviding** - Abstraction for storage operations - **StorageProviding** - Abstraction for storage operations
- **KeyMaterialProviding** - Supplies external key material for encryption
### Services (Actors) ### Services (Actors)
- **StorageRouter** - Main entry point for all storage operations - **StorageRouter** - Main entry point for all storage operations
@ -115,6 +116,24 @@ try await StorageRouter.shared.remove(key)
- Configurable PBKDF2 iteration count - Configurable PBKDF2 iteration count
- Master key stored securely in keychain - Master key stored securely in keychain
- Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF) - Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF)
- External key material providers can be registered via `EncryptionHelper`
```swift
struct RemoteKeyProvider: KeyMaterialProviding {
func keyMaterial(for keyName: String) async throws -> Data {
// Example only: fetch from service or keychain
Data(repeating: 1, count: 32)
}
}
let source = KeyMaterialSource(id: "remote.key")
await EncryptionHelper.shared.registerKeyMaterialProvider(RemoteKeyProvider(), for: source)
let policy: SecurityPolicy.EncryptionPolicy = .external(
source: source,
keyDerivation: .hkdf()
)
```
## Sync Behavior ## Sync Behavior

View File

@ -0,0 +1,9 @@
import Foundation
public struct KeyMaterialSource: Hashable, Sendable {
public let id: String
public init(id: String) {
self.id = id
}
}

View File

@ -12,8 +12,12 @@ public enum SecurityPolicy: Sendable {
public enum EncryptionPolicy: Sendable { public enum EncryptionPolicy: Sendable {
case aes256(keyDerivation: KeyDerivation) case aes256(keyDerivation: KeyDerivation)
case chacha20Poly1305(keyDerivation: KeyDerivation) case chacha20Poly1305(keyDerivation: KeyDerivation)
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf()) public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
public static func external(source: KeyMaterialSource) -> EncryptionPolicy {
.external(source: source, keyDerivation: .hkdf())
}
} }
public enum KeyDerivation: Sendable { public enum KeyDerivation: Sendable {

View File

@ -0,0 +1,5 @@
import Foundation
public protocol KeyMaterialProviding: Sendable {
func keyMaterial(for keyName: String) async throws -> Data
}

View File

@ -6,18 +6,28 @@ import CryptoKit
public actor EncryptionHelper { public actor EncryptionHelper {
public static let shared = EncryptionHelper() public static let shared = EncryptionHelper()
private enum Constants { private enum Constants {
static let masterKeyService = "LocalData.MasterKey" static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey" static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32 static let masterKeyLength = 32
static let defaultHKDFInfo = "LocalData.Encryption" static let defaultHKDFInfo = "LocalData.Encryption"
} }
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
private init() {} private init() {}
// MARK: - Public Interface // MARK: - Public Interface
/// Registers a key material provider for external encryption policies.
public func registerKeyMaterialProvider(
_ provider: any KeyMaterialProviding,
for source: KeyMaterialSource
) {
keyMaterialProviders[source] = provider
}
/// Encrypts data using AES-GCM. /// Encrypts data using AES-GCM.
/// - Parameters: /// - Parameters:
/// - data: The plaintext data to encrypt. /// - data: The plaintext data to encrypt.
@ -57,32 +67,39 @@ public actor EncryptionHelper {
keyName: String, keyName: String,
policy: SecurityPolicy.EncryptionPolicy policy: SecurityPolicy.EncryptionPolicy
) async throws -> SymmetricKey { ) async throws -> SymmetricKey {
let keyDerivation: SecurityPolicy.KeyDerivation
switch policy { switch policy {
case .aes256(let derivation), case .aes256(let derivation),
.chacha20Poly1305(let derivation): .chacha20Poly1305(let derivation):
keyDerivation = derivation let masterKey = try await getMasterKey()
return try deriveKeyMaterial(
keyName: keyName,
derivation: derivation,
baseKeyMaterial: masterKey
)
case .external(let source, let derivation):
guard let provider = keyMaterialProviders[source] else {
throw StorageError.securityApplicationFailed
}
let baseMaterial = try await provider.keyMaterial(for: keyName)
return try deriveKeyMaterial(
keyName: keyName,
derivation: derivation,
baseKeyMaterial: baseMaterial
)
} }
let masterKey = try await getMasterKey()
return try deriveKeyMaterial(
keyName: keyName,
derivation: keyDerivation,
masterKey: masterKey
)
} }
/// Derives key material based on the provided key derivation strategy. /// Derives key material based on the provided key derivation strategy.
private func deriveKeyMaterial( private func deriveKeyMaterial(
keyName: String, keyName: String,
derivation: SecurityPolicy.KeyDerivation, derivation: SecurityPolicy.KeyDerivation,
masterKey: Data baseKeyMaterial: Data
) throws -> SymmetricKey { ) throws -> SymmetricKey {
switch derivation { switch derivation {
case .pbkdf2(let iterations, let customSalt): case .pbkdf2(let iterations, let customSalt):
let salt = customSalt ?? defaultSalt(for: keyName) let salt = customSalt ?? defaultSalt(for: keyName)
let derivedKeyData = try pbkdf2SHA256( let derivedKeyData = try pbkdf2SHA256(
password: masterKey, password: baseKeyMaterial,
salt: salt, salt: salt,
iterations: iterations, iterations: iterations,
keyLength: Constants.masterKeyLength keyLength: Constants.masterKeyLength
@ -91,7 +108,7 @@ public actor EncryptionHelper {
case .hkdf(let customSalt, let customInfo): case .hkdf(let customSalt, let customInfo):
let salt = customSalt ?? defaultSalt(for: keyName) let salt = customSalt ?? defaultSalt(for: keyName)
let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8) let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8)
let inputKey = SymmetricKey(data: masterKey) let inputKey = SymmetricKey(data: baseKeyMaterial)
return HKDF<SHA256>.deriveKey( return HKDF<SHA256>.deriveKey(
inputKeyMaterial: inputKey, inputKeyMaterial: inputKey,
salt: salt, salt: salt,
@ -143,6 +160,8 @@ public actor EncryptionHelper {
return try encryptWithAESGCM(data, using: key) return try encryptWithAESGCM(data, using: key)
case .chacha20Poly1305: case .chacha20Poly1305:
return try encryptWithChaChaPoly(data, using: key) return try encryptWithChaChaPoly(data, using: key)
case .external:
return try encryptWithChaChaPoly(data, using: key)
} }
} }
@ -168,6 +187,8 @@ public actor EncryptionHelper {
return try decryptWithAESGCM(data, using: key) return try decryptWithAESGCM(data, using: key)
case .chacha20Poly1305: case .chacha20Poly1305:
return try decryptWithChaChaPoly(data, using: key) return try decryptWithChaChaPoly(data, using: key)
case .external:
return try decryptWithChaChaPoly(data, using: key)
} }
} }

View File

@ -50,8 +50,40 @@ struct EncryptionHelperTests {
#expect(decrypted == payload) #expect(decrypted == payload)
await clearMasterKey() await clearMasterKey()
} }
@Test func externalProviderWithHKDFRoundTrip() async throws {
let source = KeyMaterialSource(id: "test.external")
let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32))
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
let policy: SecurityPolicy.EncryptionPolicy = .external(
source: source,
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)
}
private func clearMasterKey() async { private func clearMasterKey() async {
try? await KeychainHelper.shared.deleteAll(service: masterKeyService) try? await KeychainHelper.shared.deleteAll(service: masterKeyService)
} }
} }
private struct StaticKeyMaterialProvider: KeyMaterialProviding {
let material: Data
func keyMaterial(for keyName: String) async throws -> Data {
material
}
}