main.swift
261 lines 9.3 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 24 days ago
1 import Darwin
2 import Foundation
3 import KnowtationSecurity
4 import Security
5 import XPC
6
7 private let allowedAccounts: Set<String> = [
8 "knowtation.companion.accessToken",
9 "knowtation.companion.refreshToken",
10 "knowtation.companion.sessionMeta",
11 "knowtation.companion.loopbackToken",
12 "knowtation.companion.updateFloor",
13 ]
14
15 private struct CustodyRequest: Codable {
16 let id: String
17 let operation: String
18 let account: String?
19 let value: String?
20 }
21
22 private struct CustodyResponse: Codable {
23 let id: String
24 let ok: Bool
25 let value: String?
26 let error: String?
27 }
28
29 private enum KeychainStore {
30 static func query(account: String) throws -> [String: Any] {
31 let teamIdentifier = try CallerIdentityValidator.hostTeamIdentifier()
32 return [
33 kSecClass as String: kSecClassGenericPassword,
34 kSecAttrService as String: KnowtationReleaseContract.keychainService,
35 kSecAttrAccount as String: account,
36 kSecAttrAccessGroup as String: "\(teamIdentifier).\(KnowtationReleaseContract.custodyIdentifier)",
37 kSecUseDataProtectionKeychain as String: true,
38 ]
39 }
40
41 static func get(account: String) throws -> String? {
42 var item: CFTypeRef?
43 let query = try query(account: account).merging([
44 kSecReturnData as String: true,
45 kSecMatchLimit as String: kSecMatchLimitOne,
46 ]) { _, latest in latest }
47 let status = SecItemCopyMatching(query as CFDictionary, &item)
48 if status == errSecItemNotFound { return nil }
49 guard status == errSecSuccess,
50 let data = item as? Data,
51 let value = String(data: data, encoding: .utf8) else {
52 throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
53 }
54 return value
55 }
56
57 static func set(account: String, value: String) throws {
58 guard !value.isEmpty, value.utf8.count <= 8192 else {
59 throw NSError(domain: "KnowtationCustody", code: 1)
60 }
61 let data = Data(value.utf8)
62 let query = try query(account: account)
63 let update: [String: Any] = [
64 kSecValueData as String: data,
65 kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
66 ]
67 let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
68 if updateStatus == errSecSuccess { return }
69 guard updateStatus == errSecItemNotFound else {
70 throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus))
71 }
72 let add = query.merging(update) { _, latest in latest }
73 let addStatus = SecItemAdd(add as CFDictionary, nil)
74 guard addStatus == errSecSuccess else {
75 throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus))
76 }
77 }
78
79 static func delete(account: String) throws {
80 let query = try query(account: account)
81 let status = SecItemDelete(query as CFDictionary)
82 guard status == errSecSuccess || status == errSecItemNotFound else {
83 throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
84 }
85 }
86 }
87
88 private final class CapabilitySession {
89 let id: UUID
90 private let handle: FileHandle
91 private let queue = DispatchQueue(label: "store.knowtation.custody.session")
92 private let onClose: (UUID) -> Void
93 private var buffer = Data()
94 private var closed = false
95
96 init(id: UUID, handle: FileHandle, onClose: @escaping (UUID) -> Void) {
97 self.id = id
98 self.handle = handle
99 self.onClose = onClose
100 }
101
102 func start() {
103 handle.readabilityHandler = { [weak self] readable in
104 let data = readable.availableData
105 guard let self else { return }
106 if data.isEmpty {
107 self.queue.async { self.finish() }
108 return
109 }
110 self.queue.async { self.accept(data) }
111 }
112 }
113
114 private func accept(_ data: Data) {
115 buffer.append(data)
116 if buffer.count > 65_536 {
117 finish()
118 return
119 }
120 while let newline = buffer.firstIndex(of: 0x0a) {
121 let frame = buffer.prefix(upTo: newline)
122 buffer.removeSubrange(...newline)
123 guard frame.count <= 16_384 else { continue }
124 respond(to: Data(frame))
125 }
126 }
127
128 private func respond(to frame: Data) {
129 let request: CustodyRequest
130 do {
131 request = try JSONDecoder().decode(CustodyRequest.self, from: frame)
132 } catch {
133 write(CustodyResponse(id: "invalid", ok: false, value: nil, error: "invalid_request"))
134 return
135 }
136 guard request.id.count <= 128 else {
137 write(CustodyResponse(id: "invalid", ok: false, value: nil, error: "invalid_request"))
138 return
139 }
140 do {
141 switch request.operation {
142 case "status":
143 write(CustodyResponse(id: request.id, ok: true, value: "available", error: nil))
144 case "get":
145 let account = try requireAccount(request.account)
146 let value = try KeychainStore.get(account: account)
147 write(CustodyResponse(id: request.id, ok: true, value: value, error: nil))
148 case "set":
149 let account = try requireAccount(request.account)
150 guard let value = request.value else { throw CustodyError.invalidRequest }
151 try KeychainStore.set(account: account, value: value)
152 write(CustodyResponse(id: request.id, ok: true, value: nil, error: nil))
153 case "delete":
154 let account = try requireAccount(request.account)
155 try KeychainStore.delete(account: account)
156 write(CustodyResponse(id: request.id, ok: true, value: nil, error: nil))
157 default:
158 throw CustodyError.invalidRequest
159 }
160 } catch {
161 write(CustodyResponse(id: request.id, ok: false, value: nil, error: "custody_operation_failed"))
162 }
163 }
164
165 private enum CustodyError: Error { case invalidRequest }
166
167 private func requireAccount(_ account: String?) throws -> String {
168 guard let account, allowedAccounts.contains(account) else {
169 throw CustodyError.invalidRequest
170 }
171 return account
172 }
173
174 private func write(_ response: CustodyResponse) {
175 guard var data = try? JSONEncoder().encode(response) else { return }
176 data.append(0x0a)
177 do { try handle.write(contentsOf: data) } catch {
178 finish()
179 }
180 }
181
182 private func finish() {
183 guard !closed else { return }
184 closed = true
185 handle.readabilityHandler = nil
186 try? handle.close()
187 onClose(id)
188 }
189 }
190
191 private final class CapabilityService {
192 private var sessions: [UUID: CapabilitySession] = [:]
193 private let lock = NSLock()
194
195 func openCapability(scope: String, identity: CallerIdentity) throws -> Int32 {
196 guard let mode = KnowtationRuntimeMode(rawValue: scope),
197 mode.requiredCallerIdentifier == identity.identifier else {
198 throw CapabilityError.scopeDenied
199 }
200 var descriptors: [Int32] = [-1, -1]
201 guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else {
202 throw CapabilityError.socketFailed
203 }
204 let serverHandle = FileHandle(fileDescriptor: descriptors[0], closeOnDealloc: true)
205 let sessionID = UUID()
206 let session = CapabilitySession(id: sessionID, handle: serverHandle) { [weak self] id in
207 self?.removeSession(id)
208 }
209 lock.lock()
210 sessions[sessionID] = session
211 lock.unlock()
212 session.start()
213 return descriptors[1]
214 }
215
216 private func removeSession(_ id: UUID) {
217 lock.lock()
218 sessions.removeValue(forKey: id)
219 lock.unlock()
220 }
221
222 enum CapabilityError: Error { case scopeDenied, socketFailed }
223 }
224
225 private let capabilityService = CapabilityService()
226
227 private func handleMessage(_ message: xpc_object_t, from peer: xpc_connection_t) {
228 guard xpc_get_type(message) == XPC_TYPE_DICTIONARY,
229 let reply = xpc_dictionary_create_reply(message) else { return }
230 do {
231 let identity = try CallerIdentityValidator.validate(message: message)
232 guard let scopePointer = xpc_dictionary_get_string(message, CustodyCapability.scopeKey) else {
233 throw CapabilityService.CapabilityError.scopeDenied
234 }
235 let descriptor = try capabilityService.openCapability(
236 scope: String(cString: scopePointer),
237 identity: identity
238 )
239 xpc_dictionary_set_fd(reply, CustodyCapability.descriptorKey, descriptor)
240 close(descriptor)
241 } catch {
242 xpc_dictionary_set_string(reply, CustodyCapability.errorKey, "caller_denied")
243 }
244 xpc_connection_send_message(peer, reply)
245 }
246
247 let listener = xpc_connection_create_mach_service(
248 KnowtationReleaseContract.custodyMachService,
249 nil,
250 UInt64(XPC_CONNECTION_MACH_SERVICE_LISTENER)
251 )
252 xpc_connection_set_event_handler(listener) { peer in
253 guard xpc_get_type(peer) == XPC_TYPE_CONNECTION else { return }
254 let connection = peer
255 xpc_connection_set_event_handler(connection) { message in
256 handleMessage(message, from: connection)
257 }
258 xpc_connection_resume(connection)
259 }
260 xpc_connection_resume(listener)
261 dispatchMain()
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 24 days ago