Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e1faaa4e62
commit
e0d03c5a86
23
Package.resolved
Normal file
23
Package.resolved
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
13
README.md
13
README.md
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
3
Sources/LocalData/Protocols/StorageKey+Defaults.swift
Normal file
3
Sources/LocalData/Protocols/StorageKey+Defaults.swift
Normal file
@ -0,0 +1,3 @@
|
||||
public extension StorageKey {
|
||||
var security: SecurityPolicy { .recommended }
|
||||
}
|
||||
@ -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):
|
||||
let masterKey = try await getMasterKey()
|
||||
case .aes256(let derivation),
|
||||
.chacha20Poly1305(let derivation):
|
||||
keyDerivation = derivation
|
||||
}
|
||||
|
||||
// Extract salt and iterations from key derivation
|
||||
let (salt, iterations) = extractDerivationParams(keyDerivation, keyName: keyName)
|
||||
let masterKey = try await getMasterKey()
|
||||
return try deriveKeyMaterial(
|
||||
keyName: keyName,
|
||||
derivation: keyDerivation,
|
||||
masterKey: masterKey
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
57
Tests/LocalDataTests/EncryptionHelperTests.swift
Normal file
57
Tests/LocalDataTests/EncryptionHelperTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
if let defaults = UserDefaults(suiteName: suiteName) {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user