| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | import RastrilloNative |
| 4 | import 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. |
| 8 | public 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. |
| 22 | public 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. |
| 34 | public 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. |
| 88 | public 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 | |