Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

CKeySafeActions.swift

v0.1.0-build.85 Latest Commit ede38f5

Size
13 KB
Lines
269
Language
swift
SHA-256
f9081a106142…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1import CKeyCodec
2import Foundation
3#if canImport(SwiftUI)
4import SwiftUI
5#endif
6
7/// Where a Safe Action takes effect (spec/CKey-Safe-Actions.md).
8public enum CKeySafeActionScope: Sendable, Equatable {
9 /// The result stays in this app on this device: it runs at once, with no prompt.
10 case sameDevice
11 /// It approves something elsewhere, such as signing in a browser: it runs only
12 /// after the SDK's own confirmation.
13 case crossDevice
14}
15
16/// A Safe Action that verified: issuer, app, action and parameters are exactly as signed.
17public struct CKeyVerifiedSafeAction: Sendable, Equatable {
18 public let issuer: String
19 public let app: String
20 public let action: String
21 public let keyID: String
22 public let requestID: String
23 public let scope: CKeySafeActionScope
24 public let issuedAt: Date
25 public let expiresAt: Date
26 /// The signed parameters, normally UTF-8 JSON.
27 public let params: Data
28 public var paramsText: String? { String(data: params, encoding: .utf8) }
29}
30
31/// Information the issuer gives about a cross-device request, shown in the confirmation.
32public struct CKeySafeActionRequestInfo: Sendable, Equatable {
33 public let device: String?
34 public let location: String?
35 public let requestedAt: String?
36}
37
38/// A verified cross-device Safe Action waiting for the person's approval.
39///
40/// The handler runs only through the SDK's confirmation (`CKeySafeActionPrompt`);
41/// approval is not part of this type's public interface.
42public final class CKeySafeActionRequest: Identifiable, @unchecked Sendable {
43 public let id = UUID()
44 public let title: String
45 public let details: CKeyVerifiedSafeAction
46 public let requestInfo: CKeySafeActionRequestInfo
47 private let handler: @Sendable (CKeyVerifiedSafeAction) async -> Void
48 private let lock = NSLock()
49 private var decided = false
50
51 init(title: String, details: CKeyVerifiedSafeAction, requestInfo: CKeySafeActionRequestInfo,
52 handler: @escaping @Sendable (CKeyVerifiedSafeAction) async -> Void) {
53 self.title = title
54 self.details = details
55 self.requestInfo = requestInfo
56 self.handler = handler
57 }
58
59 private func locked<T>(_ body: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return body() }
60
61 /// Internal: called only by the SDK's confirmation view.
62 func decide(approved: Bool) async {
63 let first: Bool = locked {
64 defer { decided = true }
65 return !decided
66 }
67 guard first, approved else { return }
68 await handler(details)
69 }
70}
71
72/// What `CKeySafeActions.handle(_:)` did with a decoded payload.
73public enum CKeySafeActionOutcome: Sendable {
74 /// Not a Safe Action: follow the ordinary payload rules (show it; act on a tap).
75 case notASafeAction
76 /// It claims to be a Safe Action but did not verify. Nothing ran; show the reason.
77 case rejected(reason: String)
78 /// A verified same-device action; its handler has run.
79 case performed(CKeyVerifiedSafeAction)
80 /// A verified cross-device action; present `CKeySafeActionPrompt(request:)`.
81 case needsConfirmation(CKeySafeActionRequest)
82}
83
84/// Safe Actions for this app. The SDK decides whether an action runs: same-device
85/// actions run at once; cross-device actions run only after the SDK's confirmation;
86/// anything that does not verify runs nothing.
87///
88/// ```swift
89/// let actions = CKeySafeActions(appID: "com.viadere")
90/// actions.trust(issuer: "citeory.com", keyID: "2026-11", publicKeyHex: "…",
91/// requestInfo: "https://citeory.com/api/ckey/requests/{request_id}")
92/// actions.register("signin.request", issuers: ["citeory.com"], scope: .crossDevice) { action in … }
93/// switch await actions.handle(result.text ?? "") { … }
94/// ```
95public final class CKeySafeActions: @unchecked Sendable {
96 public static let prefix = "ckey-action:1:"
97
98 private struct Key { let bytes: [UInt8]; let requestInfo: String? }
99 private struct Registration { let issuers: Set<String>; let scope: CKeySafeActionScope; let title: String?; let handler: @Sendable (CKeyVerifiedSafeAction) async -> Void }
100
101 public let appID: String
102 private let maxLifetime: UInt32
103 private let session: URLSession
104 private let now: @Sendable () -> Date
105 private let lock = NSLock()
106 private var keys: [String: Key] = [:]
107 private var actions: [String: Registration] = [:]
108 private var seen: [String: Date] = [:]
109
110 /// - Parameters:
111 /// - maxLifetime: the longest lifetime this app accepts, 1...3600 seconds.
112 public init(appID: String, maxLifetime: UInt32 = 600, session: URLSession = .shared,
113 now: @escaping @Sendable () -> Date = { Date() }) {
114 precondition(appID.range(of: "^[A-Za-z0-9._-]{1,255}$", options: .regularExpression) != nil, "appID must be an app ID such as com.example.app")
115 precondition((1...3600).contains(maxLifetime), "maxLifetime is 1 to 3600 seconds")
116 self.appID = appID
117 self.maxLifetime = maxLifetime
118 self.session = session
119 self.now = now
120 }
121
122 private func locked<T>(_ body: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return body() }
123
124 public static func isSafeAction(_ text: String) -> Bool { text.hasPrefix(prefix) && text.count > prefix.count }
125
126 /// Pins an issuer's Ed25519 public key (64 hex digits). `requestInfo` is the
127 /// issuer's request_info URL template, required for cross-device actions.
128 @discardableResult
129 public func trust(issuer: String, keyID: String, publicKeyHex: String, requestInfo: String? = nil) -> Self {
130 precondition(publicKeyHex.range(of: "^[0-9A-Fa-f]{64}$", options: .regularExpression) != nil, "a public key is 64 hexadecimal digits")
131 let digits = Array(publicKeyHex.utf8)
132 let bytes = stride(from: 0, to: 64, by: 2).map { i -> UInt8 in
133 UInt8(String(decoding: digits[i..<(i + 2)], as: UTF8.self), radix: 16)!
134 }
135 locked { keys[issuer + " " + keyID] = Key(bytes: bytes, requestInfo: requestInfo) }
136 return self
137 }
138
139 /// Registers an action this app accepts. The stricter of this scope and the
140 /// envelope's own scope applies.
141 @discardableResult
142 public func register(_ action: String, issuers: [String], scope: CKeySafeActionScope = .sameDevice, title: String? = nil,
143 handler: @escaping @Sendable (CKeyVerifiedSafeAction) async -> Void) -> Self {
144 precondition(action.range(of: "^[a-z0-9._-]{1,64}$", options: .regularExpression) != nil, "action is 1 to 64 of a-z 0-9 . _ -")
145 precondition(!issuers.isEmpty, "name the issuers allowed to request this action")
146 locked { actions[action] = Registration(issuers: Set(issuers), scope: scope, title: title, handler: handler) }
147 return self
148 }
149
150 /// Handles a decoded payload.
151 public func handle(_ text: String) async -> CKeySafeActionOutcome {
152 guard Self.isSafeAction(text) else { return .notASafeAction }
153 let bytes = Array(text.utf8)
154 var parsed = ckey_safe_action()
155 var diag = ckey_diagnostics()
156 guard ckey_safe_action_parse(bytes, bytes.count, &parsed, &diag) == CKEY_OK else { return .rejected(reason: message(diag)) }
157 let claimed = Self.fields(parsed)
158 guard claimed.app == appID else { return .rejected(reason: "this code is for another app (\(claimed.app))") }
159 let state: (Registration?, Key?) = locked { (actions[claimed.action], keys[claimed.issuer + " " + claimed.keyID]) }
160 guard let registration = state.0 else { return .rejected(reason: "this app does not accept the action \(claimed.action)") }
161 guard registration.issuers.contains(claimed.issuer) else { return .rejected(reason: "\(claimed.issuer) may not request \(claimed.action)") }
162 guard let key = state.1 else { return .rejected(reason: "no trusted key \(claimed.keyID) for \(claimed.issuer)") }
163 var verified = ckey_safe_action()
164 let status = key.bytes.withUnsafeBufferPointer { k in
165 ckey_safe_action_verify(bytes, bytes.count, k.baseAddress, UInt64(now().timeIntervalSince1970), maxLifetime, &verified, &diag)
166 }
167 guard status == CKEY_OK else { return .rejected(reason: message(diag)) }
168 let action = Self.fields(verified)
169 let fresh: Bool = locked {
170 let current = now()
171 seen = seen.filter { $0.value > current }
172 if seen[action.requestID] != nil { return false }
173 seen[action.requestID] = action.expiresAt.addingTimeInterval(120)
174 return true
175 }
176 guard fresh else { return .rejected(reason: "this code has already been used") }
177 if action.scope == .sameDevice && registration.scope == .sameDevice {
178 await registration.handler(action)
179 return .performed(action)
180 }
181 guard let info = await requestInfo(template: key.requestInfo, issuer: action.issuer, requestID: action.requestID) else {
182 return .rejected(reason: "the issuer did not confirm a pending request, so this cross-device action cannot run")
183 }
184 return .needsConfirmation(CKeySafeActionRequest(title: registration.title ?? "Continue with \(action.issuer)?", details: action,
185 requestInfo: info, handler: registration.handler))
186 }
187
188 private func requestInfo(template: String?, issuer: String, requestID: String) async -> CKeySafeActionRequestInfo? {
189 guard let template, let encoded = requestID.addingPercentEncoding(withAllowedCharacters: .alphanumerics),
190 let url = URL(string: template.replacingOccurrences(of: "{request_id}", with: encoded)),
191 url.scheme == "https", let host = url.host, host == issuer || host.hasSuffix("." + issuer) else { return nil }
192 var request = URLRequest(url: url)
193 request.setValue("application/json", forHTTPHeaderField: "Accept")
194 guard let (data, response) = try? await session.data(for: request), (response as? HTTPURLResponse)?.statusCode == 200,
195 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
196 json["request_id"] as? String == requestID, json["status"] as? String == "pending" else { return nil }
197 let short = { (v: Any?, max: Int) -> String? in (v as? String).flatMap { $0.isEmpty || $0.count > max ? nil : $0 } }
198 return CKeySafeActionRequestInfo(device: short(json["device"], 120), location: short(json["location"], 120), requestedAt: short(json["requested_at"], 40))
199 }
200
201 private func message(_ diag: ckey_diagnostics) -> String {
202 withUnsafeBytes(of: diag.message) { raw in String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self) }
203 }
204
205 private static func text<T>(_ field: T) -> String {
206 withUnsafeBytes(of: field) { raw in String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self) }
207 }
208
209 static func fields(_ sa: ckey_safe_action) -> CKeyVerifiedSafeAction {
210 let params = withUnsafeBytes(of: sa.params) { Data($0.prefix(Int(sa.params_len))) }
211 return CKeyVerifiedSafeAction(issuer: text(sa.issuer), app: text(sa.app), action: text(sa.action), keyID: text(sa.key_id),
212 requestID: text(sa.request_id), scope: sa.scope == 1 ? .crossDevice : .sameDevice,
213 issuedAt: Date(timeIntervalSince1970: TimeInterval(sa.issued_at)),
214 expiresAt: Date(timeIntervalSince1970: TimeInterval(sa.expires_at)), params: params)
215 }
216}
217
218#if canImport(SwiftUI)
219/// The SDK's confirmation for a cross-device Safe Action. Present it (for example
220/// in a sheet); the action runs only if the person chooses Continue.
221@available(iOS 15.0, macOS 12.0, *)
222public struct CKeySafeActionPrompt: View {
223 private let request: CKeySafeActionRequest
224 private let onFinish: (Bool) -> Void
225 @State private var busy = false
226
227 public init(request: CKeySafeActionRequest, onFinish: @escaping (_ approved: Bool) -> Void = { _ in }) {
228 self.request = request
229 self.onFinish = onFinish
230 }
231
232 public var body: some View {
233 VStack(alignment: .leading, spacing: 16) {
234 Text(request.title).font(.title3.weight(.semibold))
235 VStack(alignment: .leading, spacing: 6) {
236 row("Requested by", request.details.issuer + " (verified)")
237 if let device = request.requestInfo.device { row("Device", device) }
238 if let location = request.requestInfo.location { row("Location", location) }
239 if let time = request.requestInfo.requestedAt { row("Requested", time) }
240 }
241 Text("Only continue if this is the device and place you are using right now. Someone may be trying to use a code meant for their own session.")
242 .font(.footnote).foregroundStyle(.secondary)
243 HStack {
244 Button("Cancel", role: .cancel) { finish(false) }
245 Spacer()
246 Button("Continue") { finish(true) }.buttonStyle(.borderedProminent)
247 }
248 .disabled(busy)
249 }
250 .padding(20)
251 }
252
253 private func row(_ label: String, _ value: String) -> some View {
254 HStack(alignment: .firstTextBaseline) {
255 Text(label).foregroundStyle(.secondary).frame(minWidth: 96, alignment: .leading)
256 Text(value)
257 }
258 .font(.subheadline)
259 }
260
261 private func finish(_ approved: Bool) {
262 busy = true
263 Task {
264 await request.decide(approved: approved)
265 onFinish(approved)
266 }
267 }
268}
269#endif