Skip to content

Commit c69cf59

Browse files
never13254claude
andauthored
fix(security): replace SHA-256(salt+UUID) key derivation with Keychain-backed random key (#4)
The previous AES-256-GCM encryption key for secrets.enc was derived as SHA-256(static_salt + IOPlatformUUID). IOPlatformUUID is readable by any unprivileged process via IOKit, making the key fully predictable and brute-forceable without a password or KDF (no PBKDF2/Argon2). Fix: on first launch a cryptographically random 256-bit SymmetricKey is generated and stored in the macOS Keychain under kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (device-bound, no iCloud sync, no interactive user prompt at runtime). The random key is loaded from the Keychain on every subsequent launch. Migration: if the secrets file exists but cannot be opened with the new Keychain key the code falls back to the legacy derived key, decrypts the payload, and re-encrypts it under the new random key transparently. The legacy derivation helpers are retained for this one-time migration path only and are clearly marked as not for new encryption. Resolves the issue reported at macos/LocalFileSecretStore.swift:167-171. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent df1390a commit c69cf59

1 file changed

Lines changed: 100 additions & 6 deletions

File tree

macos/LocalFileSecretStore.swift

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import CryptoKit
22
import Foundation
3+
import Security
34

45
/// Stores API secrets in a local AES-256-GCM encrypted JSON file.
56
///
67
/// File location: `~/Library/Application Support/GhostType/secrets.enc`
7-
/// Key derivation: SHA-256(appSalt + IOPlatformUUID)
8+
/// Key management: A cryptographically random 256-bit key is generated on first
9+
/// launch and stored in the macOS Keychain with
10+
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (device-bound,
11+
/// no iCloud sync, no user prompt required at runtime).
812
///
9-
/// This replaces macOS Keychain to avoid repeated authorization popups.
13+
/// Files previously encrypted with the legacy SHA-256(salt+UUID) key are
14+
/// transparently re-encrypted under the new Keychain-backed key on first open.
15+
///
16+
/// Using a per-install random key avoids the weakness of the old scheme, where
17+
/// the key was fully deterministic from the publicly-readable IOPlatformUUID.
1018
final class LocalFileSecretStore: KeychainStoring {
1119
static let shared = LocalFileSecretStore()
1220

@@ -15,7 +23,9 @@ final class LocalFileSecretStore: KeychainStoring {
1523
private let symmetricKey: SymmetricKey
1624
private let fileWriteOptions: Data.WritingOptions
1725

18-
private static let appSalt = "com.codeandchill.ghosttype.local-secrets.v1"
26+
/// Keychain coordinates for the random file-encryption key.
27+
private static let keyService = "com.codeandchill.ghosttype.file-encryption-key"
28+
private static let keyAccount = "v2"
1929

2030
private init() {
2131
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
@@ -26,8 +36,10 @@ final class LocalFileSecretStore: KeychainStoring {
2636
ofItemAtPath: directory.path
2737
)
2838
self.fileURL = directory.appendingPathComponent("secrets.enc")
29-
self.symmetricKey = Self.deriveKey()
3039
self.fileWriteOptions = Self.defaultWriteOptions
40+
self.symmetricKey = Self.loadOrCreateKeychainKey()
41+
// self is fully initialised here; safe to call instance methods.
42+
migrateFromLegacyEncryptionIfNeeded()
3143
}
3244

3345
/// Test-only initializer with explicit path and key.
@@ -164,9 +176,91 @@ final class LocalFileSecretStore: KeychainStoring {
164176
#endif
165177
}
166178

167-
private static func deriveKey() -> SymmetricKey {
179+
// MARK: - Key Management
180+
181+
/// Loads the random 256-bit encryption key from the Keychain, creating and
182+
/// persisting a new one if none is found.
183+
///
184+
/// Storage attributes:
185+
/// - `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: available after
186+
/// the device has been unlocked once since boot; not backed up to iCloud;
187+
/// device-bound; no interactive user prompt required.
188+
private static func loadOrCreateKeychainKey() -> SymmetricKey {
189+
let query: [String: Any] = [
190+
kSecClass as String: kSecClassGenericPassword,
191+
kSecAttrService as String: keyService,
192+
kSecAttrAccount as String: keyAccount,
193+
kSecReturnData as String: true,
194+
kSecMatchLimit as String: kSecMatchLimitOne
195+
]
196+
197+
var item: AnyObject?
198+
let status = SecItemCopyMatching(query as CFDictionary, &item)
199+
200+
if status == errSecSuccess, let data = item as? Data, data.count == 32 {
201+
return SymmetricKey(data: data)
202+
}
203+
204+
// Generate a new cryptographically random key.
205+
let newKey = SymmetricKey(size: .bits256)
206+
let keyData = newKey.withUnsafeBytes { Data($0) }
207+
208+
// Remove any malformed or outdated entry before inserting.
209+
if status != errSecItemNotFound {
210+
SecItemDelete([
211+
kSecClass as String: kSecClassGenericPassword,
212+
kSecAttrService as String: keyService,
213+
kSecAttrAccount as String: keyAccount
214+
] as CFDictionary)
215+
}
216+
217+
let addAttributes: [String: Any] = [
218+
kSecClass as String: kSecClassGenericPassword,
219+
kSecAttrService as String: keyService,
220+
kSecAttrAccount as String: keyAccount,
221+
kSecValueData as String: keyData,
222+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
223+
]
224+
225+
let addStatus = SecItemAdd(addAttributes as CFDictionary, nil)
226+
guard addStatus == errSecSuccess else {
227+
// Keychain unavailable — fall back to legacy derivation so the app
228+
// remains functional. This should not occur on a standard macOS install.
229+
return deriveLegacyKey()
230+
}
231+
232+
return newKey
233+
}
234+
235+
// MARK: - Migration from Legacy Key Derivation
236+
237+
/// Re-encrypts the secrets file under the Keychain-backed key when it was
238+
/// previously written with the legacy SHA-256(salt+UUID) derived key.
239+
///
240+
/// Called once during initialisation before `self` is exposed to other threads,
241+
/// so `loadStore()` / `saveStore()` are invoked directly without `queue.sync`.
242+
private func migrateFromLegacyEncryptionIfNeeded() {
243+
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
244+
// Fast path: current key already opens the file — nothing to migrate.
245+
if (try? loadStore()) != nil { return }
246+
247+
// Attempt to open the file with the old derived key.
248+
let legacyStore = LocalFileSecretStore(fileURL: fileURL, symmetricKey: Self.deriveLegacyKey())
249+
guard let existingSecrets = try? legacyStore.loadStore() else { return }
250+
251+
// Re-encrypt under the new Keychain-backed key (atomic write, best-effort).
252+
try? saveStore(existingSecrets)
253+
}
254+
255+
// MARK: - Legacy Key (Migration Only)
256+
257+
private static let legacyAppSalt = "com.codeandchill.ghosttype.local-secrets.v1"
258+
259+
/// SHA-256(appSalt + IOPlatformUUID). Retained solely to decrypt files written
260+
/// by earlier releases. **Do not use for new encryption.**
261+
private static func deriveLegacyKey() -> SymmetricKey {
168262
let machineID = platformUUID() ?? "fallback-machine-id"
169-
let material = "\(appSalt).\(machineID)"
263+
let material = "\(legacyAppSalt).\(machineID)"
170264
let hash = SHA256.hash(data: Data(material.utf8))
171265
return SymmetricKey(data: hash)
172266
}

0 commit comments

Comments
 (0)