| 1 | @preconcurrency import CallsBridge |
| 2 | import Foundation |
| 3 | import Security |
| 4 | |
| 5 | /// The sign-in the app keeps between launches: the instance, and the |
| 6 | /// session cookie the browser handoff minted for it, in the JSON shape |
| 7 | /// the bridge already reads. It is the whole of what the app needs to |
| 8 | /// be signed in again, and so it is a credential and is kept like one. |
| 9 | public struct StoredSession: Codable, Equatable, Sendable { |
| 10 | public let origin: String |
| 11 | public let cookies: String |
| 12 | |
| 13 | public init(origin: String, cookies: String) { |
| 14 | self.origin = origin |
| 15 | self.cookies = cookies |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | /// Where the kept sign-in lives. A protocol so the model can be tested |
| 20 | /// without touching the Keychain of whoever runs the tests. |
| 21 | /// |
| 22 | /// One sign-in at a time: saving replaces whatever was there. The app |
| 23 | /// signs in to one instance at a time too, and a second slot would be a |
| 24 | /// credential nobody remembers is there. |
| 25 | public protocol SessionStore: Sendable { |
| 26 | func load() -> StoredSession? |
| 27 | func save(_ session: StoredSession) |
| 28 | func clear() |
| 29 | } |
| 30 | |
| 31 | /// The Keychain, as a generic password readable only on this device and |
| 32 | /// only while it is unlocked — the session never reaches a backup or |
| 33 | /// another device, and a phone left locked in a drawer does not hand it |
| 34 | /// to anything running in the background, which this app never does. |
| 35 | /// |
| 36 | /// Failures are silent on purpose. A sign-in that could not be kept is |
| 37 | /// one the person makes again next launch, which is exactly where every |
| 38 | /// launch was before this existed. |
| 39 | public struct KeychainSessionStore: SessionStore { |
| 40 | private let service: String |
| 41 | private let account = "session" |
| 42 | |
| 43 | public init(service: String = "net.amadan.oficina.calls") { |
| 44 | self.service = service |
| 45 | } |
| 46 | |
| 47 | public func load() -> StoredSession? { |
| 48 | for keychain in Self.keychains { |
| 49 | var query = base(keychain) |
| 50 | query[kSecReturnData as String] = true |
| 51 | query[kSecMatchLimit as String] = kSecMatchLimitOne |
| 52 | var result: CFTypeRef? |
| 53 | guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, |
| 54 | let data = result as? Data |
| 55 | else { continue } |
| 56 | return try? JSONDecoder().decode(StoredSession.self, from: data) |
| 57 | } |
| 58 | return nil |
| 59 | } |
| 60 | |
| 61 | public func save(_ session: StoredSession) { |
| 62 | guard let data = try? JSONEncoder().encode(session) else { return } |
| 63 | clear() |
| 64 | for keychain in Self.keychains { |
| 65 | var item = base(keychain) |
| 66 | item[kSecValueData as String] = data |
| 67 | item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| 68 | // The first keychain that takes it is the one it lives in. On a Mac |
| 69 | // that is the data protection keychain when the build is entitled |
| 70 | // to it, and the login keychain when it is not. |
| 71 | if SecItemAdd(item as CFDictionary, nil) == errSecSuccess { return } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | public func clear() { |
| 76 | for keychain in Self.keychains { |
| 77 | SecItemDelete(base(keychain) as CFDictionary) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | private enum Keychain { case dataProtection, legacy } |
| 82 | |
| 83 | /// iPhone has one keychain. A Mac has two, and the one that honours |
| 84 | /// this-device-only needs a keychain-access-groups entitlement, which |
| 85 | /// an ad hoc signed development build does not carry: there the add |
| 86 | /// fails with errSecMissingEntitlement and the item goes to the login |
| 87 | /// keychain instead, protected by the login password and the app's |
| 88 | /// code signature rather than by the device. |
| 89 | private static var keychains: [Keychain] { |
| 90 | #if os(macOS) |
| 91 | return [.dataProtection, .legacy] |
| 92 | #else |
| 93 | return [.dataProtection] |
| 94 | #endif |
| 95 | } |
| 96 | |
| 97 | private func base(_ keychain: Keychain) -> [String: Any] { |
| 98 | var query: [String: Any] = [ |
| 99 | kSecClass as String: kSecClassGenericPassword, |
| 100 | kSecAttrService as String: service, |
| 101 | kSecAttrAccount as String: account, |
| 102 | ] |
| 103 | #if os(macOS) |
| 104 | query[kSecUseDataProtectionKeychain as String] = keychain == .dataProtection |
| 105 | #endif |
| 106 | return query |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// The three requests the model makes about a session it holds, as |
| 111 | /// closures over the bridge so tests can answer them without a server. |
| 112 | /// Each runs off the main actor: they are network calls. |
| 113 | struct SessionRemote: Sendable { |
| 114 | /// "member", "participant" or "signed-out"; throws only when the |
| 115 | /// instance could not be asked. See bridge.CheckSession. |
| 116 | var check: @Sendable (_ origin: String, _ cookies: String) throws -> String |
| 117 | /// JSON: {"room": ...} or {"signedOut": true}. See bridge.OpenLink. |
| 118 | var open: @Sendable (_ origin: String, _ cookies: String, _ address: String) throws -> String |
| 119 | /// Best effort; see bridge.SignOut. |
| 120 | var signOut: @Sendable (_ origin: String, _ cookies: String) -> Void |
| 121 | /// Starts the browser handoff for an address: an ordinary one with no |
| 122 | /// offer, or — for a link from the web's "Join in the app" — one the |
| 123 | /// browser that made the offer confirms. See bridge.BeginAuthentication |
| 124 | /// and bridge.BeginOfferAuthentication. |
| 125 | var begin: @Sendable (_ address: String, _ offer: String?) throws -> any Handoff = { address, offer in |
| 126 | var error: NSError? |
| 127 | let auth = |
| 128 | if let offer { |
| 129 | BridgeBeginOfferAuthentication(address, offer, &error) |
| 130 | } else { |
| 131 | BridgeBeginAuthentication(address, &error) |
| 132 | } |
| 133 | guard let auth else { throw error ?? NSError(domain: "Calls", code: 1) } |
| 134 | return BridgeHandoff(auth) |
| 135 | } |
| 136 | |
| 137 | static let member = "member" |
| 138 | static let participant = "participant" |
| 139 | static let signedOut = "signed-out" |
| 140 | |
| 141 | static let bridge = SessionRemote( |
| 142 | check: { origin, cookies in |
| 143 | var error: NSError? |
| 144 | let standing = BridgeCheckSession(origin, cookies, &error) |
| 145 | if let error { throw error } |
| 146 | return standing |
| 147 | }, |
| 148 | open: { origin, cookies, address in |
| 149 | var error: NSError? |
| 150 | let answer = BridgeOpenLink(origin, cookies, address, &error) |
| 151 | if let error { throw error } |
| 152 | return answer |
| 153 | }, |
| 154 | signOut: { origin, cookies in |
| 155 | // gomobile renders a func returning only error as BOOL plus an |
| 156 | // NSErrorPointer. Both are dropped: the app is signed out whatever |
| 157 | // the instance answers. |
| 158 | var error: NSError? |
| 159 | BridgeSignOut(origin, cookies, &error) |
| 160 | }) |
| 161 | } |
| 162 | |
| 163 | /// One browser handoff in progress: where its browser goes, the code both |
| 164 | /// sides show, and the wait for the session the browser approves. A |
| 165 | /// protocol so the model's handling of it can be tested without a server. |
| 166 | protocol Handoff: AnyObject, Sendable { |
| 167 | func browserURL() -> String |
| 168 | func confirmationCode() -> String |
| 169 | /// Blocks until the browser approves, refuses or the handoff expires; |
| 170 | /// the JSON a sign-in hands back (see CallsModel.adopt). |
| 171 | func wait() throws -> String |
| 172 | func cancel() |
| 173 | } |
| 174 | |
| 175 | /// The bridge's handoff. gomobile's class is safe to use from any thread |
| 176 | /// (the Go side locks); it is simply not marked so. |
| 177 | final class BridgeHandoff: Handoff, @unchecked Sendable { |
| 178 | private let auth: BridgeAuthentication |
| 179 | init(_ auth: BridgeAuthentication) { self.auth = auth } |
| 180 | func browserURL() -> String { auth.browserURL() } |
| 181 | func confirmationCode() -> String { auth.confirmationCode() } |
| 182 | func wait() throws -> String { |
| 183 | var error: NSError? |
| 184 | let result = auth.wait(&error) |
| 185 | if let error { throw error } |
| 186 | return result |
| 187 | } |
| 188 | func cancel() { auth.cancel() } |
| 189 | } |
| 190 | |