源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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: "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user