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
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | import CKeyCodec |
| 2 | import Foundation |
| 3 | #if canImport(SwiftUI) |
| 4 | import SwiftUI |
| 5 | #endif |
| 6 | |
| 7 | /// Where a Safe Action takes effect (spec/CKey-Safe-Actions.md). |
| 8 | public 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. |
| 17 | public 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. |
| 32 | public 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. |
| 42 | public 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. |
| 73 | public 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 | /// ``` |
| 95 | public 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.0macOS 12.0*) |
| 222 | public 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 |