Update Models, Protocols, Services + tests + docs + config

Summary:
- Sources: Models, Protocols, Services
- Tests: EncryptionHelperTests.swift, LocalDataTests.swift
- Docs: README
- Config: Package
- Added symbols: extension StorageKey, func deriveKeyMaterial, func encryptWithKey, func encryptWithAESGCM, func decryptWithKey, func decryptWithAESGCM (+6 more)
- Removed symbols: func extractDerivationParams, func encryptWithKey, func decryptWithKey, class LocalDataTests, func testUserDefaultsRoundTrip, func testFileSystemRoundTrip

Stats:
- 8 files changed, 210 insertions(+), 64 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-13 23:20:28 -06:00
parent 4228fbc849
commit 312b2592dc
8 changed files with 210 additions and 64 deletions

23
Package.resolved Normal file
View File

@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
},
{
"identity" : "swift-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-testing.git",
"state" : {
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
}
],
"version" : 2
}

View File

@ -15,13 +15,19 @@ let package = Package(
targets: ["LocalData"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-testing.git", from: "0.7.0")
],
targets: [
.target(
name: "LocalData"
),
.testTarget(
name: "LocalDataTests",
dependencies: ["LocalData"]
dependencies: [
"LocalData",
.product(name: "Testing", package: "swift-testing")
]
),
]
)

View File

@ -24,14 +24,14 @@ StorageRouter (main entry point)
### Services (Actors)
- **StorageRouter** - Main entry point for all storage operations
- **KeychainHelper** - Secure keychain storage
- **EncryptionHelper** - AES-256-GCM encryption with PBKDF2
- **EncryptionHelper** - AES-256-GCM or ChaCha20-Poly1305 with PBKDF2/HKDF
- **FileStorageHelper** - File system operations
- **UserDefaultsHelper** - UserDefaults with suite support
- **SyncHelper** - WatchConnectivity sync
### Models
- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem
- **SecurityPolicy** - none, keychain, encrypted (AES-256)
- **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305)
- **Serializer** - JSON, plist, Data, or custom
- **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync
- **SyncPolicy** - never, manual, automaticSmall
@ -66,6 +66,7 @@ extension StorageKeys {
}
}
```
If you omit `security`, it defaults to `SecurityPolicy.recommended`.
### 2. Use StorageRouter
```swift
@ -87,7 +88,7 @@ try await StorageRouter.shared.remove(key)
| `userDefaults` | Preferences, small settings |
| `keychain` | Credentials, tokens, sensitive data |
| `fileSystem` | Documents, cached data, large files |
| `encryptedFileSystem` | Sensitive files with AES-256 encryption |
| `encryptedFileSystem` | Sensitive files with encryption policies |
## Security Options
@ -109,9 +110,11 @@ try await StorageRouter.shared.remove(key)
- `biometryCurrentSetOrDevicePasscode` - Current biometric or passcode
### Encryption
- AES-256-GCM with PBKDF2-SHA256 key derivation
- Configurable iteration count
- AES-256-GCM or ChaCha20-Poly1305
- PBKDF2-SHA256 or HKDF-SHA256 key derivation
- Configurable PBKDF2 iteration count
- Master key stored securely in keychain
- Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF)
## Sync Behavior

View File

@ -7,11 +7,17 @@ public enum SecurityPolicy: Sendable {
case encrypted(EncryptionPolicy)
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
public static let recommended: SecurityPolicy = .encrypted(.recommended)
public enum EncryptionPolicy: Sendable {
case aes256(keyDerivation: KeyDerivation)
case chacha20Poly1305(keyDerivation: KeyDerivation)
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
}
public enum KeyDerivation: Sendable {
case pbkdf2(iterations: Int, salt: Data? = nil)
case hkdf(salt: Data? = nil, info: Data? = nil)
}
}

View File

@ -0,0 +1,3 @@
public extension StorageKey {
var security: SecurityPolicy { .recommended }
}

View File

@ -11,6 +11,7 @@ public actor EncryptionHelper {
static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32
static let defaultHKDFInfo = "LocalData.Encryption"
}
private init() {}
@ -30,7 +31,7 @@ public actor EncryptionHelper {
policy: SecurityPolicy.EncryptionPolicy
) async throws -> Data {
let key = try await deriveKey(keyName: keyName, policy: policy)
return try encryptWithKey(data, using: key)
return try encryptWithKey(data, using: key, policy: policy)
}
/// Decrypts data using AES-GCM.
@ -46,7 +47,7 @@ public actor EncryptionHelper {
policy: SecurityPolicy.EncryptionPolicy
) async throws -> Data {
let key = try await deriveKey(keyName: keyName, policy: policy)
return try decryptWithKey(data, using: key)
return try decryptWithKey(data, using: key, policy: policy)
}
// MARK: - Key Derivation
@ -56,30 +57,47 @@ public actor EncryptionHelper {
keyName: String,
policy: SecurityPolicy.EncryptionPolicy
) async throws -> SymmetricKey {
let keyDerivation: SecurityPolicy.KeyDerivation
switch policy {
case .aes256(let keyDerivation):
case .aes256(let derivation),
.chacha20Poly1305(let derivation):
keyDerivation = derivation
}
let masterKey = try await getMasterKey()
return try deriveKeyMaterial(
keyName: keyName,
derivation: keyDerivation,
masterKey: masterKey
)
}
// Extract salt and iterations from key derivation
let (salt, iterations) = extractDerivationParams(keyDerivation, keyName: keyName)
/// Derives key material based on the provided key derivation strategy.
private func deriveKeyMaterial(
keyName: String,
derivation: SecurityPolicy.KeyDerivation,
masterKey: Data
) throws -> SymmetricKey {
switch derivation {
case .pbkdf2(let iterations, let customSalt):
let salt = customSalt ?? defaultSalt(for: keyName)
let derivedKeyData = try pbkdf2SHA256(
password: masterKey,
salt: salt,
iterations: iterations,
keyLength: Constants.masterKeyLength
)
return SymmetricKey(data: derivedKeyData)
}
}
/// Extracts parameters from KeyDerivation enum.
private func extractDerivationParams(_ derivation: SecurityPolicy.KeyDerivation, keyName: String) -> (salt: Data, iterations: Int) {
switch derivation {
case .pbkdf2(let iterations, let customSalt):
let salt = customSalt ?? Data(keyName.utf8)
return (salt, iterations)
case .hkdf(let customSalt, let customInfo):
let salt = customSalt ?? defaultSalt(for: keyName)
let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8)
let inputKey = SymmetricKey(data: masterKey)
return HKDF<SHA256>.deriveKey(
inputKeyMaterial: inputKey,
salt: salt,
info: info,
outputByteCount: Constants.masterKeyLength
)
}
}
@ -115,7 +133,20 @@ public actor EncryptionHelper {
// MARK: - AES-GCM Operations
private func encryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data {
private func encryptWithKey(
_ data: Data,
using key: SymmetricKey,
policy: SecurityPolicy.EncryptionPolicy
) throws -> Data {
switch policy {
case .aes256:
return try encryptWithAESGCM(data, using: key)
case .chacha20Poly1305:
return try encryptWithChaChaPoly(data, using: key)
}
}
private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else {
@ -127,7 +158,20 @@ public actor EncryptionHelper {
}
}
private func decryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data {
private func decryptWithKey(
_ data: Data,
using key: SymmetricKey,
policy: SecurityPolicy.EncryptionPolicy
) throws -> Data {
switch policy {
case .aes256:
return try decryptWithAESGCM(data, using: key)
case .chacha20Poly1305:
return try decryptWithChaChaPoly(data, using: key)
}
}
private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
@ -136,6 +180,24 @@ public actor EncryptionHelper {
}
}
private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try ChaChaPoly.seal(data, using: key)
return sealedBox.combined
} catch {
throw StorageError.securityApplicationFailed
}
}
private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
return try ChaChaPoly.open(sealedBox, using: key)
} catch {
throw StorageError.securityApplicationFailed
}
}
// MARK: - PBKDF2 Implementation
private func pbkdf2SHA256(
@ -188,4 +250,8 @@ public actor EncryptionHelper {
var bigEndian = value.bigEndian
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.size)
}
private func defaultSalt(for keyName: String) -> Data {
Data(keyName.utf8)
}
}

View File

@ -0,0 +1,57 @@
import Foundation
import Testing
@testable import LocalData
struct EncryptionHelperTests {
private let masterKeyService = "LocalData.MasterKey"
private let keyName = "LocalDataTests.encryption"
private let payload = Data("payload".utf8)
@Test func aesGCMWithPBKDF2RoundTrip() async throws {
await clearMasterKey()
let policy: SecurityPolicy.EncryptionPolicy = .aes256(
keyDerivation: .pbkdf2(iterations: 1_000)
)
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)
await clearMasterKey()
}
@Test func chaChaPolyWithHKDFRoundTrip() async throws {
await clearMasterKey()
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)
await clearMasterKey()
}
private func clearMasterKey() async {
try? await KeychainHelper.shared.deleteAll(service: masterKeyService)
}
}

View File

@ -1,5 +1,5 @@
import Foundation
import XCTest
import Testing
@testable import LocalData
private struct TestUserDefaultsKey: StorageKey {
@ -36,66 +36,48 @@ private struct TestFileKey: StorageKey {
}
}
final class LocalDataTests: XCTestCase {
private var suiteName: String = ""
private var tempDirectory: URL = .temporaryDirectory
override func setUp() {
super.setUp()
suiteName = "LocalDataTests.\(UUID().uuidString)"
tempDirectory = FileManager.default.temporaryDirectory
.appending(path: "LocalDataTests")
.appending(path: UUID().uuidString)
}
override func tearDown() {
struct LocalDataTests {
@Test func userDefaultsRoundTrip() async throws {
let suiteName = "LocalDataTests.\(UUID().uuidString)"
defer {
if let defaults = UserDefaults(suiteName: suiteName) {
defaults.removePersistentDomain(forName: suiteName)
}
try? FileManager.default.removeItem(at: tempDirectory)
super.tearDown()
}
func testUserDefaultsRoundTrip() async throws {
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
let storedValue = "1.0.0"
try await StorageRouter.shared.set(storedValue, for: key)
let fetched = try await StorageRouter.shared.get(key)
XCTAssertEqual(fetched, storedValue)
#expect(fetched == storedValue)
try await StorageRouter.shared.remove(key)
do {
await #expect(throws: StorageError.notFound) {
_ = try await StorageRouter.shared.get(key)
XCTFail("Expected notFound error after removal")
} catch StorageError.notFound {
return
} catch {
XCTFail("Unexpected error: \(error)")
}
}
func testFileSystemRoundTrip() async throws {
@Test func fileSystemRoundTrip() async throws {
let tempDirectory = FileManager.default.temporaryDirectory
.appending(path: "LocalDataTests")
.appending(path: UUID().uuidString)
defer {
try? FileManager.default.removeItem(at: tempDirectory)
}
let key = TestFileKey(name: "test.json", directory: tempDirectory)
let storedValue = "payload"
try await StorageRouter.shared.set(storedValue, for: key)
let fetched = try await StorageRouter.shared.get(key)
XCTAssertEqual(fetched, storedValue)
#expect(fetched == storedValue)
try await StorageRouter.shared.remove(key)
do {
await #expect(throws: StorageError.notFound) {
_ = try await StorageRouter.shared.get(key)
XCTFail("Expected notFound error after removal")
} catch StorageError.notFound {
return
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}