11import CryptoKit
22import 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.
1018final 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