rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import Foundation
3import RastrilloNative
4import Security
5
6/// A single Keychain item makes key rotation crash recoverable: retain the old
7/// key until every file has been atomically resealed for its original path.
8public struct StoreKeyState: Codable, Sendable {
9 public var current: Data
10 public var previous: Data?
11 public var seals: UInt64
12 public init(current: Data, previous: Data? = nil, seals: UInt64 = 0) {
13 self.current = current; self.previous = previous; self.seals = seals
14 }
15}
16
17/// Where an account's store key lives. `KeychainKeyProvider` is the real one;
18/// tests script every Keychain status with `ScriptedKeyProvider`.
19///
20/// It answers in raw Keychain statuses on purpose: telling *unavailable*
21/// from *lost* is the store's job, and it must see the status to do it.
22public protocol KeyProvider: Sendable {
23 /// `errSecSuccess` with the key, or the status the Keychain gave.
24 func copyKey() -> (OSStatus, SymmetricKey?)
25 func addKey(_ key: SymmetricKey) -> OSStatus
26 func deleteKey() -> OSStatus
27 func sealState() -> (OSStatus, StoreKeyState?)
28 func saveSealState(_ state: StoreKeyState) -> OSStatus
29}
30
31/// One AES-256 key per account, a generic password under service
32/// `<bundle id>.store` and account `store:<account id>|<origin>`, readable
33/// only while the device is unlocked, on this device, and never synchronised.
34public struct KeychainKeyProvider: KeyProvider {
35 public let service: String
36 public let account: String
37
38 public init(accountID: String, origin: URL, bundleID: String = Bundle.main.bundleIdentifier ?? "oficina.native") {
39 service = bundleID + ".store"
40 account = "store:\(accountID)|\(origin.absoluteString)"
41 }
42
43 private var query: [String: Any] {
44 [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
45 kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false]
46 }
47
48 public func copyKey() -> (OSStatus, SymmetricKey?) {
49 var q = query
50 q[kSecReturnData as String] = true
51 q[kSecMatchLimit as String] = kSecMatchLimitOne
52 var result: CFTypeRef?
53 let status = SecItemCopyMatching(q as CFDictionary, &result)
54 guard status == errSecSuccess else { return (status, nil) }
55 guard let data = result as? Data else { return (errSecDecode, nil) }
56 if data.count == 32 { return (status, SymmetricKey(data: data)) }
57 guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32 else { return (errSecDecode, nil) }
58 return (status, SymmetricKey(data: state.current))
59 }
60
61 public func addKey(_ key: SymmetricKey) -> OSStatus {
62 var q = query
63 q[kSecValueData as String] = key.withUnsafeBytes { Data($0) }
64 q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
65 return SecItemAdd(q as CFDictionary, nil)
66 }
67
68 public func sealState() -> (OSStatus, StoreKeyState?) {
69 var q = query; q[kSecReturnData as String] = true; q[kSecMatchLimit as String] = kSecMatchLimitOne
70 var result: CFTypeRef?
71 let status = SecItemCopyMatching(q as CFDictionary, &result)
72 guard status == errSecSuccess, let data = result as? Data else { return (status, nil) }
73 if data.count == 32 { return (status, StoreKeyState(current: data)) }
74 guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32,
75 state.previous == nil || state.previous?.count == 32 else { return (errSecDecode, nil) }
76 return (status, state)
77 }
78 public func saveSealState(_ state: StoreKeyState) -> OSStatus {
79 guard let bytes = try? JSONEncoder().encode(state) else { return errSecParam }
80 return SecItemUpdate(query as CFDictionary, [kSecValueData as String: bytes] as CFDictionary)
81 }
82
83 public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) }
84}
85
86/// Atomic authenticated files, bound to their final relative paths. A lock
87/// covers key creation and disk mutations; no plaintext is written to disk.
88public final class NativeSealedStore: BlobStore, @unchecked Sendable {
89 public static let formatVersion = 1
90 public let root: URL
91 public let accountID: String
92 private let keys: KeyProvider
93 private let lock = NSLock()
94 private let fm = FileManager.default
95 // One store owns an account directory. Cached material remains protected on
96 // disk by completeFileProtection; cold opens still distinguish locked/lost.
97 private var cachedState: StoreKeyState?
98 private var cachedKey: SymmetricKey?
99 private var reservedUntil: UInt64 = 0
100 private var backupExcluded = false
101 var rotationLimit: UInt64 = 1 << 31
102 public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID; self.keys = keys }
103 static let writeOptions: Data.WritingOptions = [.atomic, .completeFileProtection]
104 private func url(_ path: String, allowRoot: Bool = false) throws -> URL {
105 guard (allowRoot || !path.isEmpty), !path.hasPrefix("/"), !path.contains("\0"),
106 !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." || $0 == "." }) else { throw BlobStoreError.unavailable }
107 let result = root.appendingPathComponent(path)
108 // Reject symlinks too: a relative spelling alone does not contain I/O.
109 let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL.path
110 let resolved = result.resolvingSymlinksInPath().standardizedFileURL.path
111 guard resolved == resolvedRoot || resolved.hasPrefix(resolvedRoot + "/") else { throw BlobStoreError.unavailable }
112 return result
113 }
114 private func missing(_ error: Error) -> Bool {
115 let error = error as NSError
116 return error.domain == NSCocoaErrorDomain && [NSFileReadNoSuchFileError, NSFileNoSuchFileError].contains(error.code)
117 }
118 private func io<T>(_ body: () throws -> T) throws -> T {
119 do { return try body() } catch {
120 // A failed write may have committed its reservation. Abandon its entire
121 // unused tail before retrying, including interrupted key rotation.
122 cachedState = nil; cachedKey = nil; reservedUntil = 0; backupExcluded = false
123 throw (error as? BlobStoreError) ?? .unavailable
124 }
125 }
126 private func hasFiles(_ directory: URL) throws -> Bool {
127 let children: [URL]
128 do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) }
129 catch { if missing(error) { return false }; throw error }
130 for child in children {
131 if try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true {
132 if try hasFiles(child) { return true }
133 } else { return true }
134 }
135 return false
136 }
137 private func key(create: Bool) throws -> SymmetricKey? {
138 if let cachedKey { return cachedKey }
139 let (status, key) = keys.copyKey()
140 if status == errSecSuccess, let key { return key }
141 guard status == errSecItemNotFound else { throw BlobStoreError.unavailable }
142 if try hasFiles(root) {
143 try fm.removeItem(at: root)
144 throw BlobStoreError.keyLost
145 }
146 guard create else { return nil }
147 let new = SymmetricKey(size: .bits256)
148 let added = keys.addKey(new)
149 if added == errSecSuccess { return new }
150 if added == errSecDuplicateItem {
151 let (status, existing) = keys.copyKey()
152 if status == errSecSuccess, let existing { return existing }
153 }
154 throw BlobStoreError.unavailable
155 }
156 private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) }
157 private func readKeyState() throws -> StoreKeyState {
158 if let cachedState { return cachedState }
159 let (status, state) = keys.sealState()
160 guard status == errSecSuccess, let state else { throw BlobStoreError.unavailable }
161 cachedState = state; cachedKey = SymmetricKey(data: state.current)
162 reservedUntil = state.seals
163 return state
164 }
165 private func saveKeyState(_ state: StoreKeyState) throws {
166 var durable = state
167 durable.seals = max(state.seals, reservedUntil)
168 guard keys.saveSealState(durable) == errSecSuccess else { throw BlobStoreError.unavailable }
169 cachedState = state; cachedKey = SymmetricKey(data: state.current)
170 }
171 private func reserveSeal(_ state: inout StoreKeyState) throws {
172 guard state.seals < rotationLimit else { throw BlobStoreError.unavailable }
173 if state.seals == reservedUntil {
174 // Persist a high-water mark before using any slot. Relaunch starts at
175 // that mark, sacrificing the unused tail rather than reusing a quota.
176 reservedUntil = state.seals + min(1024, rotationLimit - state.seals)
177 try saveKeyState(state)
178 }
179 state.seals += 1
180 cachedState = state
181 }
182 private func open(_ data: Data, path: String, state: StoreKeyState) throws -> Data {
183 let box = try AES.GCM.SealedBox(combined: data)
184 do { return try AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path)) }
185 catch {
186 guard let previous = state.previous else { throw error }
187 return try AES.GCM.open(box, using: SymmetricKey(data: previous), authenticating: aad(path))
188 }
189 }
190 private func files(_ directory: URL) throws -> [URL] {
191 let children: [URL]
192 do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) }
193 catch { if missing(error) { return [] }; throw error }
194 return try children.flatMap { child in
195 try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true ? files(child) : [child]
196 }
197 }
198 private func rotateIfNeeded(_ state: inout StoreKeyState) throws {
199 guard state.previous != nil || state.seals >= rotationLimit else { return }
200 if state.previous == nil {
201 state = StoreKeyState(current: SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }, previous: state.current)
202 reservedUntil = 0
203 try saveKeyState(state)
204 }
205 for file in try files(root) {
206 let path = String(file.resolvingSymlinksInPath().path.dropFirst(root.resolvingSymlinksInPath().path.count + 1))
207 let combined = try Data(contentsOf: file)
208 if let box = try? AES.GCM.SealedBox(combined: combined),
209 (try? AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path))) != nil { continue }
210 let plaintext: Data
211 do { plaintext = try open(combined, path: path, state: state) }
212 catch { try fm.removeItem(at: file); throw BlobStoreError.corrupt(path) }
213 try reserveSeal(&state)
214 let resealed = try AES.GCM.seal(plaintext, using: SymmetricKey(data: state.current), authenticating: aad(path))
215 try resealed.combined!.write(to: file, options: Self.writeOptions)
216 }
217 state.previous = nil
218 try saveKeyState(state)
219 }
220 public func write(_ data: Data, to path: String) throws {
221 try lock.withLock { try io {
222 let target = try url(path)
223 _ = try key(create: true)
224 var state = try readKeyState()
225 try rotateIfNeeded(&state)
226 try reserveSeal(&state)
227 let key = cachedKey!
228 try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
229 if !backupExcluded {
230 var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true
231 try directory.setResourceValues(values); backupExcluded = true
232 }
233 let sealed = try AES.GCM.seal(data, using: key, authenticating: aad(path))
234 guard let combined = sealed.combined else { throw BlobStoreError.unavailable }
235 try combined.write(to: target, options: Self.writeOptions)
236 } }
237 }
238 public func read(_ path: String) throws -> Data? {
239 try lock.withLock { try io {
240 let target = try url(path)
241 let data: Data
242 do { data = try Data(contentsOf: target) } catch { if missing(error) { return nil }; throw error }
243 guard try key(create: false) != nil else { throw BlobStoreError.unavailable }
244 let state = try readKeyState()
245 do { return try open(data, path: path, state: state) }
246 catch {
247 // Only an authentication/decode failure reaches this catch. I/O or
248 // Keychain refusal must never turn into a destructive corruption path.
249 try fm.removeItem(at: target)
250 throw BlobStoreError.corrupt(path)
251 }
252 } }
253 }
254 public func list(_ directory: String) throws -> [String] {
255 try lock.withLock { try io {
256 let target = try url(directory, allowRoot: true)
257 do {
258 return try fm.contentsOfDirectory(at: target, includingPropertiesForKeys: [.isRegularFileKey])
259 .filter { try $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true }.map(\.lastPathComponent).sorted()
260 } catch { if missing(error) { return [] }; throw error }
261 } }
262 }
263 public func remove(_ path: String) throws {
264 try lock.withLock { try io {
265 let target = try url(path)
266 do { try fm.removeItem(at: target) } catch { if !missing(error) { throw error } }
267 } }
268 }
269 public func wipe() throws {
270 try lock.withLock { try io {
271 let status = keys.deleteKey()
272 guard status == errSecSuccess || status == errSecItemNotFound else { throw BlobStoreError.unavailable }
273 cachedState = nil; cachedKey = nil; reservedUntil = 0; backupExcluded = false
274 do { try fm.removeItem(at: root) } catch { if !missing(error) { throw error } }
275 } }
276 }
277}
278