Files
tutti/apps/mobile/ios/TuttiMobile/MobileSecurityModule.swift
T
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

446 lines
13 KiB
Swift

import CryptoKit
import Foundation
import React
import Security
import UIKit
@objc(TuttiMobileSecurity)
final class MobileSecurityModule: NSObject {
private let browserAuthBridge = MobileBrowserAuthBridge()
private let store = MobileSecureStore()
private var scannerActive = false
private var scannerCancellationResolvers: [RCTPromiseResolveBlock] = []
private weak var scannerViewController: QRCodeScannerViewController?
@objc
static func requiresMainQueueSetup() -> Bool {
true
}
@objc
func constantsToExport() -> [AnyHashable: Any] {
[
"clientVersion": Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String ?? "0.0.0",
"localeIdentifier": Locale.current.identifier,
]
}
@objc(getOrCreateIdentity:rejecter:)
func getOrCreateIdentity(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
do {
let identity = try store.getOrCreateIdentity()
resolve([
"deviceId": identity.deviceID,
"publicKey": identity.publicKey,
"arch": deviceArchitecture(),
"deviceName": UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name,
])
} catch {
reject("IDENTITY_UNAVAILABLE", "Unable to load device identity", error)
}
}
@objc(sign:resolver:rejecter:)
func sign(
_ message: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
do {
resolve(try store.sign(Data(message.utf8)))
} catch {
reject("SIGN_FAILED", "Unable to sign device proof", error)
}
}
@objc(loadSession:rejecter:)
func loadSession(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
do {
guard var session = try store.loadSession() else {
resolve(nil)
return
}
session["avatarURL"] = session["avatarURL"] ?? ""
resolve(session)
} catch {
reject("SESSION_READ_FAILED", "Unable to read account session", error)
}
}
@objc(saveSession:userId:email:name:avatarURL:resolver:rejecter:)
func saveSession(
_ sessionID: String,
userId: String,
email: String,
name: String,
avatarURL: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
do {
try store.saveSession([
"sessionId": sessionID.trimmingCharacters(in: .whitespacesAndNewlines),
"userId": userId.trimmingCharacters(in: .whitespacesAndNewlines),
"email": email.trimmingCharacters(in: .whitespacesAndNewlines),
"name": name.trimmingCharacters(in: .whitespacesAndNewlines),
"avatarURL": avatarURL.trimmingCharacters(in: .whitespacesAndNewlines),
])
resolve(nil)
} catch {
reject("SESSION_WRITE_FAILED", "Unable to save account session", error)
}
}
@objc(clearSession:rejecter:)
func clearSession(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
do {
try store.clearSession()
resolve(nil)
} catch {
reject("SESSION_CLEAR_FAILED", "Unable to clear account session", error)
}
}
@objc(clearLegacySessionCookie:resolver:rejecter:)
func clearLegacySessionCookie(
_ accountBaseURL: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
do {
let url = try validatedCookieURL(accountBaseURL)
for cookie in HTTPCookieStorage.shared.cookies(for: url) ?? []
where cookie.name == "session_id" {
HTTPCookieStorage.shared.deleteCookie(cookie)
}
resolve(nil)
} catch {
reject(
"SESSION_COOKIE_CLEAR_FAILED",
"Unable to clear legacy account session cookie",
error
)
}
}
@objc(startBrowserLogin:authLoginURL:appCallbackURL:resolver:rejecter:)
func startBrowserLogin(
_ appID: String,
authLoginURL: String,
appCallbackURL: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
do {
let identity = try store.getOrCreateIdentity()
browserAuthBridge.start(
appID: appID,
authLoginURL: authLoginURL,
appCallbackURL: appCallbackURL,
deviceID: identity.deviceID,
deviceName: UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name,
clientVersion: constantsToExport()["clientVersion"] as? String ?? "0.0.0"
) { result in
switch result {
case .success(let completion):
resolve(completion)
case .failure(let error):
let bridgeError = error as? MobileBrowserAuthError
reject(
bridgeError?.code ?? "BROWSER_LOGIN_FAILED",
bridgeError?.message ?? "Unable to complete browser login",
error
)
}
}
} catch {
reject("BROWSER_LOGIN_FAILED", "Unable to start browser login", error)
}
}
@objc(scanQRCode:rejecter:)
func scanQRCode(
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
#if targetEnvironment(simulator)
reject("SCANNER_UNAVAILABLE", "QR scanner is unavailable in Simulator", nil)
#else
DispatchQueue.main.async { [weak self] in
guard let self else {
reject("SCANNER_UNAVAILABLE", "QR scanner is unavailable", nil)
return
}
self.presentQRCodeScanner(resolve, rejecter: reject)
}
#endif
}
private func presentQRCodeScanner(
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
guard !scannerActive else {
reject("SCANNER_BUSY", "A QR scan is already active", nil)
return
}
guard let presenter = topViewController() else {
reject("SCANNER_UNAVAILABLE", "No active iOS view controller", nil)
return
}
scannerActive = true
let scanner = QRCodeScannerViewController()
scannerViewController = scanner
scanner.modalPresentationStyle = .fullScreen
scanner.onCompletion = { result in
self.scannerActive = false
self.scannerViewController = nil
switch result {
case .success(.manual):
resolve(["kind": "manual"])
case .success(.scanned(let value)):
resolve(["kind": "scanned", "value": value])
case .failure(let error):
switch error {
case QRCodeScannerError.permissionDenied:
reject("SCANNER_PERMISSION_DENIED", "Camera permission is required", error)
case QRCodeScannerError.cancelled:
reject("SCAN_CANCELLED", "QR scan cancelled", error)
case QRCodeScannerError.emptyCode:
reject("EMPTY_QR_CODE", "The scanned QR code is empty", error)
default:
reject("SCANNER_UNAVAILABLE", "QR scanner is unavailable", error)
}
}
let cancellationResolvers = self.scannerCancellationResolvers
self.scannerCancellationResolvers.removeAll()
for resolveCancellation in cancellationResolvers {
resolveCancellation(nil)
}
}
presenter.present(scanner, animated: true)
}
@objc(cancelQRCodeScan:rejecter:)
func cancelQRCodeScan(
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter _: @escaping RCTPromiseRejectBlock
) {
DispatchQueue.main.async { [weak self] in
guard let self, let scanner = self.scannerViewController else {
resolve(nil)
return
}
self.scannerCancellationResolvers.append(resolve)
scanner.cancelScanning()
}
}
@objc
func invalidate() {
browserAuthBridge.close()
DispatchQueue.main.async { [weak self] in
self?.scannerViewController?.cancelScanning()
}
}
private func topViewController() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes.compactMap {
$0 as? UIWindowScene
}
let root =
scenes
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
var current = root
while let presented = current?.presentedViewController {
current = presented
}
return current
}
private func validatedCookieURL(_ rawURL: String) throws -> URL {
guard
let url = URL(string: rawURL.trimmingCharacters(in: .whitespacesAndNewlines)),
url.scheme == "https",
url.host != nil
else {
throw MobileSecurityError.invalidAccountURL
}
return url
}
}
private struct MobileIdentity {
let deviceID: String
let publicKey: String
}
private enum MobileSecurityError: Error {
case invalidAccountURL
case invalidSession
case invalidStoredIdentity
case keychain(OSStatus)
}
private final class MobileSecureStore {
private let keychain = MobileKeychain()
func getOrCreateIdentity() throws -> MobileIdentity {
let deviceID: String
if let stored = try keychain.read(account: "device-id"),
let value = String(data: stored, encoding: .utf8),
!value.isEmpty
{
deviceID = value
} else {
deviceID = UUID().uuidString
try keychain.write(Data(deviceID.utf8), account: "device-id")
}
let privateKey = try signingKey()
return MobileIdentity(
deviceID: deviceID,
publicKey: privateKey.publicKey.rawRepresentation.base64URLEncodedString()
)
}
func sign(_ message: Data) throws -> String {
try signingKey().signature(for: message).base64EncodedString()
}
func loadSession() throws -> [String: String]? {
guard let raw = try keychain.read(account: "account-session") else {
return nil
}
guard
let object = try JSONSerialization.jsonObject(with: raw) as? [String: String],
object["sessionId"]?.isEmpty == false
else {
try? keychain.delete(account: "account-session")
return nil
}
return object
}
func saveSession(_ session: [String: String]) throws {
guard session["sessionId"]?.isEmpty == false else {
throw MobileSecurityError.invalidSession
}
try keychain.write(
JSONSerialization.data(withJSONObject: session),
account: "account-session"
)
}
func clearSession() throws {
try keychain.delete(account: "account-session")
}
private func signingKey() throws -> Curve25519.Signing.PrivateKey {
if let stored = try keychain.read(account: "signing-ed25519-v1") {
guard
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: stored)
else {
try keychain.delete(account: "signing-ed25519-v1")
throw MobileSecurityError.invalidStoredIdentity
}
return key
}
let key = Curve25519.Signing.PrivateKey()
try keychain.write(key.rawRepresentation, account: "signing-ed25519-v1")
return key
}
}
private final class MobileKeychain {
private let service =
(Bundle.main.bundleIdentifier ?? "sh.tutti.mobile") + ".secure-state"
func read(account: String) throws -> Data? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound {
return nil
}
guard status == errSecSuccess else {
throw MobileSecurityError.keychain(status)
}
return result as? Data
}
func write(_ data: Data, account: String) throws {
let query = baseQuery(account: account)
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let updateStatus = SecItemUpdate(
query as CFDictionary,
attributes as CFDictionary
)
if updateStatus == errSecItemNotFound {
var insertion = query
for (key, value) in attributes {
insertion[key] = value
}
let addStatus = SecItemAdd(insertion as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw MobileSecurityError.keychain(addStatus)
}
} else if updateStatus != errSecSuccess {
throw MobileSecurityError.keychain(updateStatus)
}
}
func delete(account: String) throws {
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw MobileSecurityError.keychain(status)
}
}
private func baseQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
}
private func deviceArchitecture() -> String {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafePointer(to: &systemInfo.machine) { pointer in
pointer.withMemoryRebound(to: CChar.self, capacity: 1) {
String(cString: $0)
}
}
}
extension Data {
fileprivate func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}