Build native Ocho clients and passkey-backed stable-origin chat
Replace WebKit with SwiftUI, CryptoKit and URLSession; preserve the existing encrypted wire format, add a bounded tenant relay and client-only PRF identity wrapping. Native UI and physical passkey validation remain in progress.
Co-Authored-By: Codex <codex@openai.com>
42 files changed,
+1937
−175
.gitignore+3 −0apple/Infinity/AccountSheets.swift+61 −0apple/Infinity/AccountStore.swift+142 −0apple/Infinity/AccountWebView.swift+0 −117apple/Infinity/Infinity.entitlements+1 −0apple/Infinity/InfinityApp.swift+8 −43apple/Infinity/NativeConversationView.swift+58 −0apple/Infinity/NativePasskeys.swift+72 −0apple/Infinity/OchoRootView.swift+83 −0apple/Infinity/WelcomeView.swift+38 −0apple/InfinityMac/InfinityMac.entitlements+1 −1apple/InfinityUITests/NativeChatTests.swift+29 −0apple/OchoCore/Package.swift+4 −0apple/OchoCore/Sources/OchoCore/API.swift+60 −0apple/OchoCore/Sources/OchoCore/Client.swift+118 −0apple/OchoCore/Sources/OchoCore/Crypto.swift+130 −0apple/OchoCore/Sources/OchoCore/Models.swift+67 −0apple/OchoCore/Sources/OchoCore/Registration.swift+47 −0apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift+61 −0apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift+39 −0apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift+34 −0apple/project.yml+28 −0docs/infinity/native-passkeys.md+19 −0docs/infinity/status.md+6 −0internal/infinity/static/api.js+5 −1internal/infinity/static/app.js+12 −5internal/infinity/static/dialogs.js+8 −1internal/infinity/static/onboarding.js+27 −7internal/infinity/static/passkeys.js+54 −0internal/infinityhome/apple.go+8 −0internal/infinityhome/browser_test.go+85 −0internal/infinityhome/home.go+5 −0internal/infinityhome/migrations/0003_passkeys.sql+20 −0internal/infinityhome/passkeys.go+225 −0internal/infinityhome/passkeys_test.go+114 −0internal/infinityhome/relay.go+80 −0internal/infinityhome/relay_test.go+84 −0test/infinity/home-browser.mjs+37 −0tools/infinity/check-home-browser+7 −0tools/infinity/check-native+12 −0tools/infinity/check-native-crypto+30 −0tools/infinity/check-native-ui+15 −0
Intent
diff --git a/.gitignore b/.gitignore| index 29578a2..6d6e625 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -46,3 +46,6 @@ home-linux-amd64 |
| node_modules/ |
| .worktrees/ |
| .build/ |
| +# Native development builds and SwiftPM workspace state. |
| +apple/build-*/ |
| +apple/OchoCore/.swiftpm/ |
diff --git a/apple/Infinity/AccountSheets.swift b/apple/Infinity/AccountSheets.swift| new file mode 100644 |
| index 0000000..9871ce3 |
| --- /dev/null |
| +++ b/apple/Infinity/AccountSheets.swift |
| @@ -0,0 +1,61 @@ |
| +// ๐ค Native connection, group and recovery sheets. |
| +import SwiftUI |
| +import UniformTypeIdentifiers |
| +import OchoCore |
| +struct ConnectSheet: View { |
| + @EnvironmentObject var account: AccountStore |
| + @Environment(\.dismiss) var dismiss |
| + @State private var link = "" |
| + var body: some View { |
| + NavigationStack { Form { |
| + TextField("Invitation link", text: $link, axis: .vertical).lineLimit(3...6) |
| + Button("Connect") { Task { await account.connect(link); if account.error == nil { dismiss() } } }.disabled(account.busy || link.isEmpty) |
| + }.navigationTitle("Connect with someone").toolbar { ToolbarItem { Button("Cancel") { dismiss() } } } } |
| + .frame(minWidth: 320, minHeight: 260).onAppear { link = account.invitation } |
| + } |
| +} |
| +struct GroupSheet: View { |
| + @EnvironmentObject var account: AccountStore |
| + @Environment(\.dismiss) var dismiss |
| + @State private var name = "" |
| + @State private var selected = Set<String>() |
| + var body: some View { |
| + NavigationStack { Form { |
| + TextField("Group name", text: $name) |
| + Section("People") { ForEach(account.threads.filter { !$0.group && !$0.pending }) { thread in |
| + Toggle(thread.name, isOn: Binding(get: { selected.contains(thread.id) }, set: { if $0 { selected.insert(thread.id) } else { selected.remove(thread.id) } })) |
| + } } |
| + Button("Create group") { Task { await account.perform { try await account.group(name: name, contacts: selected); dismiss() } } }.disabled(account.busy || selected.count < 2 || name.isEmpty) |
| + }.navigationTitle("New group").toolbar { ToolbarItem { Button("Cancel") { dismiss() } } } }.frame(minWidth: 320, minHeight: 320) |
| + } |
| +} |
| +struct RecoveryDocument: FileDocument { |
| + static var readableContentTypes: [UTType] { [.json] } |
| + var data: Data |
| + init(data: Data) { self.data = data } |
| + init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } |
| + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } |
| +} |
| +struct RecoverySheet: View { |
| + @EnvironmentObject var account: AccountStore |
| + @Environment(\.dismiss) var dismiss |
| + @State private var password = "" |
| + @State private var document: RecoveryDocument? |
| + @State private var exporting = false |
| + var body: some View { |
| + NavigationStack { Form { |
| + Button("Add a passkey") { Task { await account.addPasskey() } }.disabled(account.busy) |
| + Text("Use the same passkey to open Ocho and your messages on the web, iPhone and Mac.") |
| + Text("Keep this file and its password somewhere safe. They can open your account on another device.") |
| + SecureField("Recovery password (12+ characters)", text: $password) |
| + Button("Save recovery file") { Task { await account.perform { |
| + guard let id = account.identity else { return } |
| + let password = password |
| + let data = try await Task.detached { try Crypto.exportRecovery(id, password: password) }.value |
| + document = RecoveryDocument(data: data); exporting = true |
| + } } }.disabled(account.busy || password.count < 12) |
| + }.navigationTitle("Account recovery").toolbar { ToolbarItem { Button("Done") { dismiss() } } } } |
| + .frame(minWidth: 320, minHeight: 280) |
| + .fileExporter(isPresented: $exporting, document: document, contentType: .json, defaultFilename: "Ocho recovery") { result in if case let .failure(error) = result { account.error = error.localizedDescription } } |
| + } |
| +} |
diff --git a/apple/Infinity/AccountStore.swift b/apple/Infinity/AccountStore.swift| new file mode 100644 |
| index 0000000..ef4ee68 |
| --- /dev/null |
| +++ b/apple/Infinity/AccountStore.swift |
| @@ -0,0 +1,142 @@ |
| +// ๐ค Native account coordinator. Keychain owns private identity; URLSession owns transport. |
| +import SwiftUI |
| +import Security |
| +import OchoCore |
| + |
| +@MainActor final class AccountStore: ObservableObject { |
| + @Published var identity: Identity? |
| + @Published var threads: [OchoCore.Thread] = [] |
| + @Published var requests: [Event] = [] |
| + @Published var error: String? |
| + @Published var busy = false |
| + @Published var stage: String? |
| + @Published var selected: String? |
| + @Published var invitation: String = "" |
| + private var client: OchoClient? |
| + static let home = "https://ocho.chat" |
| + init() { |
| + #if DEBUG |
| + if let encoded = ProcessInfo.processInfo.environment["OCHO_UI_IDENTITY"], |
| + let data = try? Data(base64URL: encoded), let id = try? JSONDecoder().decode(Identity.self, from: data), |
| + (try? id.validate()) != nil, URL(string: id.origin)?.host == "localhost" { |
| + identity = id; client = try? OchoClient(identity: id); return |
| + } |
| + #endif |
| + do { |
| + if let data = try KeychainIdentity.read() { |
| + let id = try JSONDecoder().decode(Identity.self, from: data) |
| + try id.validate(); identity = id; client = try OchoClient(identity: id) |
| + } |
| + } catch { self.error = error.localizedDescription } |
| + } |
| + func perform(_ operation: @escaping () async throws -> Void) async { |
| + guard !busy else { return }; busy = true; error = nil |
| + defer { busy = false; stage = nil } |
| + do { try await operation() } catch { self.error = error.localizedDescription } |
| + } |
| + func activate(_ id: Identity, claim: String? = nil) async throws { |
| + let c = try OchoClient(identity: id) |
| + if let claim { try await c.setup(claim: claim) } |
| + let view = try await c.refresh() |
| + try KeychainIdentity.write(JSONEncoder().encode(id)) |
| + client = c; identity = id; threads = view.threads; requests = view.requests |
| + } |
| + struct Pending: Codable { var identity: Identity; var claim: String } |
| + func create(name: String) async { |
| + await perform { |
| + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard !name.isEmpty, name.utf16.count <= 80 else { throw OchoError.invalid("Enter your name.") } |
| + if let data = try KeychainIdentity.read(account: "pending-setup") { |
| + let pending = try JSONDecoder().decode(Pending.self, from: data) |
| + self.stage = "Opening your private accountโฆ" |
| + try await self.activate(pending.identity, claim: pending.claim) |
| + try KeychainIdentity.remove(account: "pending-setup") |
| + UserDefaults.standard.removeObject(forKey: "ocho.signup.nonce") |
| + return |
| + } |
| + let nonce = UserDefaults.standard.string(forKey: "ocho.signup.nonce") ?? Crypto.random() |
| + UserDefaults.standard.set(nonce, forKey: "ocho.signup.nonce") |
| + let (id, claim) = try await Registration.create(home: Self.home, name: name, nonce: nonce) { message in await MainActor.run { self.stage = message } } |
| + try KeychainIdentity.write(JSONEncoder().encode(Pending(identity: id, claim: claim)), account: "pending-setup") |
| + self.stage = "Opening your private accountโฆ" |
| + try await self.activate(id, claim: claim) |
| + try KeychainIdentity.remove(account: "pending-setup") |
| + UserDefaults.standard.removeObject(forKey: "ocho.signup.nonce") |
| + } |
| + } |
| + func restore(data: Data, password: String) async { |
| + await perform { |
| + let id = try await Task.detached { try Crypto.importRecovery(data, password: password) }.value |
| + try await self.activate(id) |
| + } |
| + } |
| + func refresh() async throws { |
| + guard let client else { return } |
| + let view = try await client.refresh(); threads = view.threads; requests = view.requests |
| + } |
| + func poll() async { |
| + while !Task.isCancelled { |
| + if client != nil && !busy { |
| + busy = true |
| + do { try await refresh() } catch { self.error = error.localizedDescription } |
| + busy = false |
| + } |
| + try? await Task.sleep(for: .seconds(3)) |
| + } |
| + } |
| + func signInPasskey() async { |
| + await perform { |
| + if #available(iOS 18.0, macOS 15.0, *) { |
| + let passkeys = NativePasskeys() |
| + let id = try await passkeys.signIn(); try await self.activate(id) |
| + } else { throw OchoError.invalid("Passkey recovery requires iOS 18 or macOS 15. You can use a recovery file.") } |
| + } |
| + } |
| + func addPasskey() async { |
| + await perform { |
| + guard let id = self.identity else { return } |
| + if #available(iOS 18.0, macOS 15.0, *) { let passkeys = NativePasskeys(); try await passkeys.add(id) } |
| + else { throw OchoError.invalid("Passkey recovery requires iOS 18 or macOS 15. You can use a recovery file.") } |
| + } |
| + } |
| + func invite() async throws -> URL { guard let client else { throw OchoError.invalid("Open your account first.") }; return try await client.invite() } |
| + func connect(_ text: String) async { await perform { |
| + guard let client = self.client else { return } |
| + self.selected = try await client.connect(Invite.parse(text)); self.invitation = ""; try await self.refresh() |
| + } } |
| + func send(_ text: String, thread: String) async throws { |
| + guard let client else { return }; try await client.message(thread: thread, text: text); try await refresh() |
| + } |
| + func accept(_ request: Event) async { await perform { try await self.client?.accept(request); try await self.refresh() } } |
| + func reject(_ request: Event) async { await perform { try await self.client?.reject(request); try await self.refresh() } } |
| + func group(name: String, contacts: Set<String>) async throws { |
| + selected = try await client?.group(name: name, contacts: contacts); try await refresh() |
| + } |
| + func remove(thread: String, signing: String) async { await perform { try await self.client?.removeMember(thread: thread, signing: signing); try await self.refresh() } } |
| + func open(_ url: URL) { |
| + if url.scheme == "ocho", let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "url" })?.value { invitation = value } |
| + else { invitation = url.absoluteString } |
| + } |
| +} |
| + |
| +enum KeychainIdentity { |
| + static func remove(account: String) throws { |
| + let status = SecItemDelete(query(account) as CFDictionary) |
| + guard status == errSecSuccess || status == errSecItemNotFound else { throw OchoError.invalid("Could not finish saving your account.") } |
| + } |
| + static func query(_ account: String) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "chat.ocho.identity", kSecAttrAccount as String: account] } |
| + static func read(account: String = "owner") throws -> Data? { |
| + var q = query(account); q[kSecReturnData as String] = true |
| + var value: CFTypeRef?; let status = SecItemCopyMatching(q as CFDictionary, &value) |
| + if status == errSecItemNotFound { return nil } |
| + guard status == errSecSuccess else { throw OchoError.invalid("Could not unlock your saved account.") } |
| + return value as? Data |
| + } |
| + static func write(_ data: Data, account: String = "owner") throws { |
| + let query = query(account) |
| + let attributes: [String: Any] = [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] |
| + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) |
| + if status == errSecItemNotFound { status = SecItemAdd(query.merging(attributes) { _, new in new } as CFDictionary, nil) } |
| + guard status == errSecSuccess else { throw OchoError.invalid("Could not securely save your account on this device.") } |
| + } |
| +} |
diff --git a/apple/Infinity/AccountWebView.swift b/apple/Infinity/AccountWebView.swift| deleted file mode 100644 |
| index ea9c4c8..0000000 |
| --- a/apple/Infinity/AccountWebView.swift |
| +++ /dev/null |
| @@ -1,117 +0,0 @@ |
| -// ๐ค WebKit owns origin-isolated storage. No Eleven entitlements or shared keychains. |
| -import SwiftUI |
| -import WebKit |
| -#if os(macOS) |
| -import AppKit |
| -#else |
| -import UIKit |
| -#endif |
| - |
| -struct AccountWebView { |
| - @ObservedObject var location: AccountLocation |
| - func makeCoordinator() -> Coordinator { Coordinator(location: location) } |
| - func makeWebView(context: Context) -> WKWebView { |
| - let config = WKWebViewConfiguration() |
| - config.websiteDataStore = .default() |
| - config.userContentController.add(context.coordinator, name: "infinity") |
| - let web = WKWebView(frame: .zero, configuration: config) |
| - web.navigationDelegate = context.coordinator |
| - web.uiDelegate = context.coordinator |
| - web.allowsBackForwardNavigationGestures = true |
| - #if DEBUG |
| - web.isInspectable = true |
| - #endif |
| - context.coordinator.web = web |
| - web.load(URLRequest(url: location.url)) |
| - context.coordinator.lastURL = location.url |
| - return web |
| - } |
| - func update(_ web: WKWebView, coordinator: Coordinator) { |
| - if coordinator.lastURL != location.url { |
| - coordinator.lastURL = location.url |
| - web.load(URLRequest(url: location.url)) |
| - } |
| - if coordinator.reloadID != location.reloadID { |
| - coordinator.reloadID = location.reloadID |
| - web.reload() |
| - } |
| - } |
| - |
| - @MainActor |
| - final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { |
| - let location: AccountLocation |
| - weak var web: WKWebView? |
| - var lastURL: URL? |
| - var reloadID = 0 |
| - init(location: AccountLocation) { self.location = location } |
| - func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { |
| - guard let url = action.request.url else { decisionHandler(.cancel); return } |
| - if url.scheme == "https" || url.scheme == "about" || url.scheme == "blob" { decisionHandler(.allow); return } |
| - decisionHandler(.cancel) |
| - } |
| - func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { |
| - if action.targetFrame == nil, action.request.url?.scheme == "https" { webView.load(action.request) } |
| - return nil |
| - } |
| - func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| - guard message.frameInfo.isMainFrame, |
| - message.frameInfo.securityOrigin.protocol == "https", |
| - let body = message.body as? [String: Any] else { return } |
| - if body["type"] as? String == "account" { |
| - // The client sends this after authenticated setup/restore finishes; |
| - // didFinish navigation fires before that asynchronous work completes. |
| - guard let origin = body["origin"] as? String, |
| - let url = URL(string: origin), url.scheme == "https", |
| - url.host == message.frameInfo.securityOrigin.host, |
| - (url.port ?? 443) == (message.frameInfo.securityOrigin.port == 0 ? 443 : message.frameInfo.securityOrigin.port), |
| - url.user == nil, url.password == nil, |
| - url.path.isEmpty || url.path == "/", |
| - url.query == nil, url.fragment == nil else { return } |
| - UserDefaults.standard.set(origin, forKey: "infinity.account-origin") |
| - return |
| - } |
| - guard body["type"] as? String == "recovery", |
| - let text = body["content"] as? String, text.utf8.count < 100_000, |
| - let data = text.data(using: .utf8), |
| - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
| - object["format"] as? String == "infinity-recovery-v1" else { return } |
| - saveRecovery(data) |
| - } |
| - private func saveRecovery(_ data: Data) { |
| - #if os(macOS) |
| - let panel = NSSavePanel() |
| - panel.nameFieldStringValue = "infinity-recovery.json" |
| - panel.canCreateDirectories = true |
| - panel.begin { response in |
| - guard response == .OK, let url = panel.url else { return } |
| - do { try data.write(to: url, options: .atomic) } |
| - catch { NSAlert(error: error).runModal() } |
| - } |
| - #else |
| - let url = FileManager.default.temporaryDirectory.appendingPathComponent("infinity-recovery-\(UUID().uuidString).json") |
| - do { |
| - try data.write(to: url, options: [.atomic, .completeFileProtection]) |
| - let sheet = UIActivityViewController(activityItems: [url], applicationActivities: nil) |
| - sheet.completionWithItemsHandler = { _, _, _, _ in try? FileManager.default.removeItem(at: url) } |
| - guard let window = web?.window, let root = window.rootViewController else { return } |
| - var presenter = root |
| - while let presented = presenter.presentedViewController { presenter = presented } |
| - sheet.popoverPresentationController?.sourceView = web |
| - sheet.popoverPresentationController?.sourceRect = CGRect(x: 20, y: 20, width: 1, height: 1) |
| - presenter.present(sheet, animated: true) |
| - } catch { /* The encrypted source remains in the web client for another attempt. */ } |
| - #endif |
| - } |
| - } |
| -} |
| -#if os(macOS) |
| -extension AccountWebView: NSViewRepresentable { |
| - func makeNSView(context: Context) -> WKWebView { makeWebView(context: context) } |
| - func updateNSView(_ web: WKWebView, context: Context) { update(web, coordinator: context.coordinator) } |
| -} |
| -#else |
| -extension AccountWebView: UIViewRepresentable { |
| - func makeUIView(context: Context) -> WKWebView { makeWebView(context: context) } |
| - func updateUIView(_ web: WKWebView, context: Context) { update(web, coordinator: context.coordinator) } |
| -} |
| -#endif |
diff --git a/apple/Infinity/Infinity.entitlements b/apple/Infinity/Infinity.entitlements| new file mode 100644 |
| index 0000000..f7f17c0 |
| --- /dev/null |
| +++ b/apple/Infinity/Infinity.entitlements |
| @@ -0,0 +1 @@ |
| +<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>com.apple.developer.associated-domains</key><array><string>webcredentials:ocho.chat</string></array></dict></plist> |
diff --git a/apple/Infinity/InfinityApp.swift b/apple/Infinity/InfinityApp.swift| index e2ed67c..7c755c4 100644 |
| --- a/apple/Infinity/InfinityApp.swift |
| +++ b/apple/Infinity/InfinityApp.swift |
| @@ -1,51 +1,16 @@ |
| -// ๐ค โ Apple shell. The shared, tested client performs all encryption. |
| +// ๐ค Fully native Ocho: SwiftUI, CryptoKit, URLSession. No WebViews. |
| import SwiftUI |
| - |
| -@main |
| -struct InfinityApp: App { |
| - @StateObject private var location = AccountLocation() |
| +@main struct InfinityApp: App { |
| + @StateObject private var account = AccountStore() |
| var body: some Scene { |
| - WindowGroup { |
| - AccountWebView(location: location) |
| - .frame(minWidth: 320, minHeight: 480) |
| - .onOpenURL { location.open($0) } |
| + WindowGroup("Ocho") { |
| + OchoRootView().environmentObject(account) |
| + .tint(Color(red: 0.40, green: 0.29, blue: 0.82)) |
| + .onOpenURL { account.open($0) } |
| + .task { await account.poll() } |
| } |
| #if os(macOS) |
| .defaultSize(width: 1050, height: 760) |
| - .commands { |
| - CommandGroup(after: .newItem) { |
| - Button("Open Ocho Home") { location.url = AccountLocation.home } |
| - Button("Reload") { location.reloadID += 1 } |
| - .keyboardShortcut("r", modifiers: .command) |
| - } |
| - } |
| - #endif |
| - } |
| -} |
| - |
| -@MainActor |
| -final class AccountLocation: ObservableObject { |
| - static var home: URL { |
| - #if DEBUG |
| - if let value = ProcessInfo.processInfo.environment["INFINITY_HOME"], |
| - let url = URL(string: value), url.scheme == "https" { return url } |
| #endif |
| - return URL(string: "https://ocho.chat")! |
| - } |
| - @Published var url: URL |
| - @Published var reloadID = 0 |
| - init() { |
| - if let text = UserDefaults.standard.string(forKey: "infinity.account-origin"), |
| - let saved = URL(string: text), saved.scheme == "https" { |
| - url = saved |
| - } else { url = Self.home } |
| - } |
| - func open(_ incoming: URL) { |
| - if incoming.scheme == "https" { url = incoming; return } |
| - guard incoming.scheme == "ocho", |
| - let components = URLComponents(url: incoming, resolvingAgainstBaseURL: false), |
| - let raw = components.queryItems?.first(where: { $0.name == "url" })?.value, |
| - let target = URL(string: raw), target.scheme == "https" else { return } |
| - url = target |
| } |
| } |
diff --git a/apple/Infinity/NativeConversationView.swift b/apple/Infinity/NativeConversationView.swift| new file mode 100644 |
| index 0000000..aa88aba |
| --- /dev/null |
| +++ b/apple/Infinity/NativeConversationView.swift |
| @@ -0,0 +1,58 @@ |
| +// ๐ค Native feed and composer, following Eleven's split-view/chat-screen shape. |
| +import SwiftUI |
| +import OchoCore |
| +struct NativeConversationView: View { |
| + @EnvironmentObject var account: AccountStore |
| + let thread: OchoCore.Thread |
| + @State private var draft = "" |
| + @State private var members = false |
| + @FocusState private var composerFocused: Bool |
| + var body: some View { |
| + VStack(spacing: 0) { |
| + ScrollViewReader { scroll in |
| + ScrollView { |
| + LazyVStack(spacing: 12) { |
| + ForEach(thread.messages) { message in |
| + HStack(alignment: .bottom) { |
| + if message.mine { Spacer(minLength: 48) } |
| + VStack(alignment: message.mine ? .trailing : .leading, spacing: 4) { |
| + if thread.group && !message.mine { Text(message.name).font(.caption.weight(.semibold)).foregroundStyle(.secondary) } |
| + Text(message.text).textSelection(.enabled).padding(.horizontal, 14).padding(.vertical, 10) |
| + .foregroundStyle(message.mine ? Color.white : Color.primary) |
| + .background(message.mine ? Color.accentColor : Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 18)) |
| + HStack(spacing: 5) { Text(message.date, style: .time); if let delivery = message.delivery { Text(delivery) } }.font(.caption2).foregroundStyle(.secondary) |
| + }.frame(maxWidth: 560, alignment: message.mine ? .trailing : .leading) |
| + if !message.mine { Spacer(minLength: 48) } |
| + }.id(message.id) |
| + } |
| + }.padding() |
| + } |
| + .defaultScrollAnchor(.bottom) |
| + .onChange(of: thread.messages.last?.id) { _, id in if let id { withAnimation { scroll.scrollTo(id, anchor: .bottom) } } } |
| + .overlay { if thread.messages.isEmpty { ContentUnavailableView(thread.pending ? "Invitation sent" : "Say hello", systemImage: "bubble.left", description: Text(thread.pending ? "You can chat once they accept." : "This is the beginning of your conversation.")) } } |
| + } |
| + Divider() |
| + if thread.left { Text("Youโre no longer in this group.").foregroundStyle(.secondary).padding() } |
| + else { |
| + HStack(alignment: .bottom, spacing: 12) { |
| + TextField("Message", text: $draft, axis: .vertical).lineLimit(1...6).textFieldStyle(.plain).padding(12).background(.quaternary, in: RoundedRectangle(cornerRadius: 18)).focused($composerFocused) |
| + Button { |
| + let text = draft |
| + Task { await account.perform { try await account.send(text, thread: thread.id); if draft == text { draft = "" }; composerFocused = true } } |
| + } label: { Image(systemName: "arrow.up.circle.fill").font(.system(size: 32)) } |
| + .buttonStyle(.plain).foregroundStyle(.tint).disabled(account.busy || thread.pending || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty).accessibilityLabel("Send message") |
| + .keyboardShortcut(.return, modifiers: .command) |
| + }.padding() |
| + } |
| + } |
| + .navigationTitle(thread.name) |
| + .toolbar { ToolbarItem { Button { members = true } label: { Image(systemName: "info.circle") }.accessibilityLabel("Conversation details") } } |
| + .sheet(isPresented: $members) { |
| + NavigationStack { |
| + List(thread.members, id: \.signing) { person in |
| + HStack { Text(person.name); Spacer(); if thread.owner == account.identity?.signing && person.signing != account.identity?.signing { Button("Remove", role: .destructive) { Task { await account.remove(thread: thread.id, signing: person.signing) } }.disabled(account.busy) } } |
| + }.navigationTitle(thread.group ? "Group members" : "People").toolbar { ToolbarItem { Button("Done") { members = false } } } |
| + }.frame(minWidth: 320, minHeight: 280) |
| + } |
| + } |
| +} |
diff --git a/apple/Infinity/NativePasskeys.swift b/apple/Infinity/NativePasskeys.swift| new file mode 100644 |
| index 0000000..e076e8a |
| --- /dev/null |
| +++ b/apple/Infinity/NativePasskeys.swift |
| @@ -0,0 +1,72 @@ |
| +// ๐ค AuthenticationServices ceremonies share ocho.chat and the web's PRF wrapping format. |
| +import SwiftUI |
| +import AuthenticationServices |
| +import OchoCore |
| +import CryptoKit |
| + |
| +@available(iOS 18.0, macOS 15.0, *) |
| +@MainActor final class NativePasskeys: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { |
| + struct Options: Decodable { var challenge: String, userHandle: String, rpId: String, salt: String } |
| + struct Origin: Encodable { var origin: String } |
| + struct RegistrationBody: Encodable { var challenge: String, clientDataJSON: String, attestationObject: String, box: String } |
| + struct AssertionBody: Encodable { var challenge: String, credentialId: String, userHandle: String, clientDataJSON: String, authenticatorData: String, signature: String } |
| + struct Opened: Decodable { var box: String, origin: String } |
| + struct Empty: Codable {} |
| + var continuation: CheckedContinuation<ASAuthorizationCredential, Error>? |
| + var controller: ASAuthorizationController? |
| + func authorize(_ request: ASAuthorizationRequest) async throws -> ASAuthorizationCredential { |
| + guard continuation == nil else { throw OchoError.invalid("A passkey request is already open.") } |
| + return try await withCheckedThrowingContinuation { continuation in |
| + self.continuation = continuation |
| + let controller = ASAuthorizationController(authorizationRequests: [request]); self.controller = controller |
| + controller.delegate = self; controller.presentationContextProvider = self; controller.performRequests() |
| + } |
| + } |
| + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { |
| + #if os(macOS) |
| + return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor() |
| + #else |
| + return UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor() |
| + #endif |
| + } |
| + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { continuation?.resume(returning: authorization.credential); continuation = nil; self.controller = nil } |
| + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { continuation?.resume(throwing: error); continuation = nil; self.controller = nil } |
| + func add(_ id: Identity) async throws { |
| + let options = try await API.request(AccountStore.home, "/api/passkeys/register/begin", auth: id.auth, method: "POST", body: JSONEncoder().encode(Origin(origin: id.origin)), as: Options.self) |
| + guard options.rpId == "ocho.chat" else { throw OchoError.invalid("Unexpected passkey domain.") } |
| + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) |
| + let request = provider.createCredentialRegistrationRequest(challenge: try Data(base64URL: options.challenge), name: id.name, userID: try Data(base64URL: options.userHandle)) |
| + request.userVerificationPreference = .required |
| + request.prf = .inputValues(.saltInput1(try Data(base64URL: options.salt))) |
| + guard let registration = try await authorize(request) as? ASAuthorizationPlatformPublicKeyCredentialRegistration, let attestation = registration.rawAttestationObject else { throw OchoError.invalid("Could not create your passkey.") } |
| + var output = registration.prf?.first |
| + if output == nil { |
| + let assertion = provider.createCredentialAssertionRequest(challenge: try Data(base64URL: options.challenge)) |
| + assertion.allowedCredentials = [.init(credentialID: registration.credentialID)] |
| + assertion.userVerificationPreference = .required |
| + assertion.prf = .inputValues(.saltInput1(try Data(base64URL: options.salt))) |
| + let result = try await authorize(assertion) as? ASAuthorizationPlatformPublicKeyCredentialAssertion |
| + if result?.credentialID == registration.credentialID { output = result?.prf?.first } |
| + } |
| + guard let output else { throw OchoError.invalid("This passkey provider cannot unlock encrypted messages. Use a recovery file or another provider.") } |
| + let key = try Crypto.passkeyWrappingKey(output: output.withUnsafeBytes { Data($0) }, credentialID: registration.credentialID) |
| + let body = RegistrationBody(challenge: options.challenge, clientDataJSON: registration.rawClientDataJSON.base64URL, attestationObject: attestation.base64URL, box: try Crypto.lock(id, key: key)) |
| + _ = try await API.request(AccountStore.home, "/api/passkeys/register/finish", auth: id.auth, method: "POST", body: JSONEncoder().encode(body), as: Empty.self) |
| + } |
| + func signIn() async throws -> Identity { |
| + let options = try await API.request(AccountStore.home, "/api/passkeys/login/begin", method: "POST", body: JSONEncoder().encode(Empty()), as: Options.self) |
| + guard options.rpId == "ocho.chat" else { throw OchoError.invalid("Unexpected passkey domain.") } |
| + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) |
| + let request = provider.createCredentialAssertionRequest(challenge: try Data(base64URL: options.challenge)) |
| + request.userVerificationPreference = .required |
| + request.prf = .inputValues(.saltInput1(try Data(base64URL: options.salt))) |
| + guard let assertion = try await authorize(request) as? ASAuthorizationPlatformPublicKeyCredentialAssertion, let output = assertion.prf?.first else { throw OchoError.invalid("This passkey could not unlock encrypted messages. You can use a recovery file.") } |
| + let key = try Crypto.passkeyWrappingKey(output: output.withUnsafeBytes { Data($0) }, credentialID: assertion.credentialID) |
| + let body = AssertionBody(challenge: options.challenge, credentialId: assertion.credentialID.base64URL, userHandle: assertion.userID.base64URL, clientDataJSON: assertion.rawClientDataJSON.base64URL, authenticatorData: assertion.rawAuthenticatorData.base64URL, signature: assertion.signature.base64URL) |
| + let opened = try await API.request(AccountStore.home, "/api/passkeys/login/finish", method: "POST", body: JSONEncoder().encode(body), as: Opened.self) |
| + let id = try Crypto.unlock(opened.box, key: key, as: Identity.self) |
| + try id.validate() |
| + guard id.origin == opened.origin else { throw OchoError.invalid("This passkey opened a different account.") } |
| + return id |
| + } |
| +} |
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift| new file mode 100644 |
| index 0000000..1e545fc |
| --- /dev/null |
| +++ b/apple/Infinity/OchoRootView.swift |
| @@ -0,0 +1,83 @@ |
| +// ๐ค Native flat conversation navigation, request consent and system sharing. |
| +import SwiftUI |
| +import OchoCore |
| +struct OchoRootView: View { |
| + @EnvironmentObject var account: AccountStore |
| + @State private var sheet: Sheet? |
| + @State private var shareURL: URL? |
| + @State private var search = "" |
| + enum Sheet: String, Identifiable { case connect, group, recovery; var id: String { rawValue } } |
| + var body: some View { |
| + Group { |
| + if account.identity == nil { WelcomeView() } |
| + else { |
| + NavigationSplitView { |
| + List(selection: $account.selected) { |
| + if !account.requests.isEmpty { |
| + Section("Invitations") { |
| + ForEach(account.requests, id: \.thread) { request in |
| + VStack(alignment: .leading, spacing: 8) { |
| + Text(request.name ?? request.person?.name ?? "New conversation").font(.headline) |
| + HStack { |
| + Button("Accept") { Task { await account.accept(request) } } |
| + Button("Decline", role: .destructive) { Task { await account.reject(request) } } |
| + }.buttonStyle(.bordered).disabled(account.busy) |
| + }.padding(.vertical, 6) |
| + } |
| + } |
| + } |
| + Section("Chats") { |
| + ForEach(account.threads.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) }) { thread in |
| + NavigationLink(value: thread.id) { ConversationRow(thread: thread) } |
| + } |
| + } |
| + } |
| + .navigationTitle("Ocho") |
| + .searchable(text: $search, prompt: "Search chats") |
| + .overlay { if account.threads.isEmpty && account.requests.isEmpty { ContentUnavailableView("Your people, right here", systemImage: "bubble.left.and.bubble.right", description: Text("Share an invitation to start a conversation.")) } } |
| + .toolbar { |
| + ToolbarItem { Menu { |
| + Button("Invite someone", systemImage: "square.and.arrow.up") { Task { await account.perform { shareURL = try await account.invite() } } } |
| + Button("Connect with a link", systemImage: "link") { sheet = .connect } |
| + Button("New group", systemImage: "person.3") { sheet = .group } |
| + Divider() |
| + Button("Save recovery file", systemImage: "key") { sheet = .recovery } |
| + } label: { Image(systemName: "square.and.pencil") }.disabled(account.busy).accessibilityLabel("New conversation") } |
| + } |
| + } detail: { |
| + if let thread = account.threads.first(where: { $0.id == account.selected }) { NativeConversationView(thread: thread).id(thread.id) } |
| + else { ContentUnavailableView("Ocho", systemImage: "infinity", description: Text("Just you. And your people.")) } |
| + } |
| + } |
| + } |
| + .safeAreaInset(edge: .bottom) { |
| + if let error = account.error { |
| + HStack { Text(error).font(.callout); Spacer(); Button("Dismiss") { account.error = nil } } |
| + .padding().background(.regularMaterial).accessibilityElement(children: .combine) |
| + } |
| + } |
| + .sheet(item: $sheet) { value in |
| + switch value { |
| + case .connect: ConnectSheet() |
| + case .group: GroupSheet() |
| + case .recovery: RecoverySheet() |
| + } |
| + } |
| + .sheet(isPresented: Binding(get: { shareURL != nil }, set: { if !$0 { shareURL = nil } })) { |
| + if let shareURL { VStack(spacing: 24) { Image(systemName: "infinity").font(.system(size: 48)).foregroundStyle(.tint); Text("Your people are one link away.").font(.title2.bold()); ShareLink("Share invitation", item: shareURL).buttonStyle(.borderedProminent); Button("Done") { self.shareURL = nil } }.padding(36) } |
| + } |
| + .onChange(of: account.invitation) { _, value in if !value.isEmpty && account.identity != nil { sheet = .connect } } |
| + } |
| +} |
| +struct ConversationRow: View { |
| + let thread: OchoCore.Thread |
| + var body: some View { |
| + HStack(spacing: 12) { |
| + ZStack { Circle().fill(Color.accentColor.opacity(0.12)); if thread.group { Image(systemName: "person.3.fill") } else { Text(String(thread.name.prefix(1)).uppercased()).font(.headline) } }.foregroundStyle(.tint).frame(width: 44, height: 44) |
| + VStack(alignment: .leading, spacing: 4) { |
| + HStack { Text(thread.name).font(.headline); Spacer(); if let last = thread.messages.last { Text(last.date, style: .time).font(.caption).foregroundStyle(.secondary) } } |
| + Text(thread.pending ? "Waiting for them to acceptโฆ" : thread.messages.last?.text ?? "Say hello").font(.subheadline).foregroundStyle(.secondary).lineLimit(2) |
| + } |
| + }.padding(.vertical, 5) |
| + } |
| +} |
diff --git a/apple/Infinity/WelcomeView.swift b/apple/Infinity/WelcomeView.swift| new file mode 100644 |
| index 0000000..a3db5ed |
| --- /dev/null |
| +++ b/apple/Infinity/WelcomeView.swift |
| @@ -0,0 +1,38 @@ |
| +// ๐ค Native onboarding and file recovery. No embedded browser. |
| +import SwiftUI |
| +import UniformTypeIdentifiers |
| +struct WelcomeView: View { |
| + @EnvironmentObject var account: AccountStore |
| + @State private var name = "" |
| + @State private var filePicker = false |
| + @State private var recoveryData: Data? |
| + @State private var password = "" |
| + var body: some View { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 24) { |
| + Image(systemName: "infinity").font(.system(size: 54, weight: .bold)).foregroundStyle(.white).padding(24).background(Color.accentColor.gradient, in: RoundedRectangle(cornerRadius: 24)) |
| + Text(account.stage == nil ? "Just you.\nAnd your people." : "To infinity.\nAnd your people.").font(.system(size: 42, weight: .bold, design: .rounded)) |
| + if let stage = account.stage { |
| + ProgressView().controlSize(.large) |
| + Text(stage).font(.headline) |
| + Text("A little space of your own. A lot of possibilities.").foregroundStyle(.secondary) |
| + } else { |
| + Text("Private conversations, without a phone number.").font(.title3).foregroundStyle(.secondary) |
| + Button("Sign in with a passkey") { Task { await account.signInPasskey() } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy) |
| + TextField("Your name", text: $name).textFieldStyle(.roundedBorder).accessibilityIdentifier("signup-name") |
| + Button("Create your account") { Task { await account.create(name: name) } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| + Button("I have a recovery file") { filePicker = true }.disabled(account.busy) |
| + if recoveryData != nil { |
| + SecureField("Recovery password", text: $password).textFieldStyle(.roundedBorder) |
| + Button("Open your account") { if let data = recoveryData { Task { await account.restore(data: data, password: password) } } }.buttonStyle(.borderedProminent).disabled(account.busy) |
| + } |
| + Text("Your messages are encrypted on your device.").font(.footnote).foregroundStyle(.secondary) |
| + } |
| + }.frame(maxWidth: 520, alignment: .leading).padding(32).frame(maxWidth: .infinity) |
| + } |
| + .fileImporter(isPresented: $filePicker, allowedContentTypes: [.json]) { result in |
| + do { let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }; recoveryData = try Data(contentsOf: url) } |
| + catch { account.error = error.localizedDescription } |
| + } |
| + } |
| +} |
diff --git a/apple/InfinityMac/InfinityMac.entitlements b/apple/InfinityMac/InfinityMac.entitlements| index fd8a894..4bc1832 100644 |
| --- a/apple/InfinityMac/InfinityMac.entitlements |
| +++ b/apple/InfinityMac/InfinityMac.entitlements |
| @@ -1 +1 @@ |
| -<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/><key>com.apple.security.files.user-selected.read-write</key><true/></dict></plist> |
| +<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/><key>com.apple.security.files.user-selected.read-write</key><true/><key>com.apple.developer.associated-domains</key><array><string>webcredentials:ocho.chat</string></array></dict></plist> |
diff --git a/apple/InfinityUITests/NativeChatTests.swift b/apple/InfinityUITests/NativeChatTests.swift| new file mode 100644 |
| index 0000000..2fc2139 |
| --- /dev/null |
| +++ b/apple/InfinityUITests/NativeChatTests.swift |
| @@ -0,0 +1,29 @@ |
| +// ๐ค Real SwiftUI feed/composer against two disposable local personal processes. |
| +import XCTest |
| +import OchoCore |
| +@MainActor final class NativeChatTests: XCTestCase { |
| + func testNativeConversationAndSend() async throws { |
| + let claim = String(repeating: "a", count: 43) |
| + let me = Identity.create(origin: "http://localhost:18871", name: "Native UI Alice") |
| + let peer = Identity.create(origin: "http://localhost:18872", name: "Native UI Bob") |
| + let a = try OchoClient(identity: me), b = try OchoClient(identity: peer) |
| + try await a.setup(claim: claim); try await b.setup(claim: claim) |
| + let thread = try await b.connect(Invite.parse(try await a.invite().absoluteString)) |
| + let view = try await a.refresh(); try await a.accept(XCTUnwrap(view.requests.first)) |
| + try await b.message(thread: thread, text: "Hello from the other native client") |
| + let app = XCUIApplication() |
| + app.launchEnvironment["OCHO_UI_IDENTITY"] = try JSONEncoder().encode(me).base64URL |
| + app.launch() |
| + XCTAssertTrue(app.staticTexts["Native UI Bob"].firstMatch.waitForExistence(timeout: 20)) |
| + app.staticTexts["Native UI Bob"].firstMatch.tap() |
| + XCTAssertTrue(app.staticTexts["Hello from the other native client"].waitForExistence(timeout: 10)) |
| + let field = app.textFields["Message"].exists ? app.textFields["Message"] : app.textViews.firstMatch |
| + XCTAssertTrue(field.waitForExistence(timeout: 5)); field.tap(); field.typeText("Sent from the native SwiftUI composer") |
| + app.buttons["Send message"].tap() |
| + XCTAssertTrue(app.staticTexts["Sent from the native SwiftUI composer"].waitForExistence(timeout: 15)) |
| + let received = try await b.refresh() |
| + XCTAssertEqual(received.threads.first?.messages.last?.text, "Sent from the native SwiftUI composer") |
| + XCTAssertEqual(app.webViews.count, 0) |
| + let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.lifetime = .keepAlways; add(attachment) |
| + } |
| +} |
diff --git a/apple/OchoCore/Package.swift b/apple/OchoCore/Package.swift| new file mode 100644 |
| index 0000000..d7a50df |
| --- /dev/null |
| +++ b/apple/OchoCore/Package.swift |
| @@ -0,0 +1,4 @@ |
| +// swift-tools-version: 5.9 |
| +// ๐ค Shared native Ocho protocol; no WebKit or JavaScript runtime. |
| +import PackageDescription |
| +let package = Package(name: "OchoCore", platforms: [.macOS(.v14), .iOS(.v17)], products: [.library(name: "OchoCore", targets: ["OchoCore"])], targets: [.target(name: "OchoCore"), .testTarget(name: "OchoCoreTests", dependencies: ["OchoCore"])]) |
diff --git a/apple/OchoCore/Sources/OchoCore/API.swift b/apple/OchoCore/Sources/OchoCore/API.swift| new file mode 100644 |
| index 0000000..67b6241 |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/API.swift |
| @@ -0,0 +1,60 @@ |
| +// ๐ค Bounded native JSON transport. No cookies, redirects or private-key uploads. |
| +import Foundation |
| +public enum API { |
| + struct Failure: Decodable { let error: String } |
| + public static func origin(_ text: String) throws -> URL { |
| + guard let u = URL(string: text), u.user == nil, u.password == nil, |
| + u.query == nil, u.fragment == nil, u.path.isEmpty || u.path == "/", |
| + u.scheme == "https" || (u.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(u.host ?? "")), u.host != nil else { throw OchoError.invalid("Use a secure Ocho account address.") } |
| + return u |
| + } |
| + final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { |
| + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } |
| + } |
| + static let session: URLSession = { |
| + let c = URLSessionConfiguration.ephemeral |
| + c.timeoutIntervalForRequest = 30; c.timeoutIntervalForResource = 45 |
| + c.httpShouldSetCookies = false; c.urlCache = nil |
| + return URLSession(configuration: c, delegate: NoRedirects(), delegateQueue: nil) |
| + }() |
| + public static func request<T: Decodable>(_ origin: String, _ path: String, auth: String? = nil, method: String = "GET", body: Data? = nil, as type: T.Type) async throws -> T { |
| + let base = try self.origin(origin) |
| + guard path.hasPrefix("/api/"), let url = URL(string: base.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path) else { throw OchoError.invalid("Invalid account request.") } |
| + var request = URLRequest(url: url); request.httpMethod = method; request.httpBody = body |
| + if let auth { request.setValue("Bearer " + auth, forHTTPHeaderField: "Authorization") } |
| + request.setValue(origin, forHTTPHeaderField: "Origin") |
| + if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } |
| + let (data, response) = try await session.data(for: request) |
| + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { |
| + throw OchoError.invalid((try? JSONDecoder().decode(Failure.self, from: data).error) ?? "Your account is temporarily unavailable. Please try again.") |
| + } |
| + guard data.count <= 16 * 1024 * 1024 else { throw OchoError.invalid("Account response is too large.") } |
| + return try JSONDecoder().decode(type, from: data) |
| + } |
| +} |
| +public struct Invite: Codable, Sendable { |
| + public var version = 1 |
| + public var thread: String, token: String, person: Person |
| + public static func parse(_ input: String) throws -> Invite { |
| + guard let url = URL(string: input) else { throw OchoError.invalid("Invalid invitation.") } |
| + let query = URLComponents(string: "https://ocho.chat/?" + (url.fragment ?? ""))?.queryItems |
| + if let nested = query?.first(where: { $0.name == "invite" })?.value { return try parseDirect(nested) } |
| + return try parseDirect(input) |
| + } |
| + private static func parseDirect(_ input: String) throws -> Invite { |
| + guard let u = URL(string: input), let fragment = u.fragment, fragment.count < 10000 else { throw OchoError.invalid("Invalid invitation.") } |
| + let invite = try JSONDecoder().decode(Invite.self, from: Data(base64URL: fragment)) |
| + let origin = try API.origin(invite.person.origin) |
| + guard invite.version == 1, try Data(base64URL: invite.thread).count == 32, try Data(base64URL: invite.token).count == 32, |
| + try Data(base64URL: invite.person.enc).count == 65, try Data(base64URL: invite.person.signing).count == 65, |
| + invite.person.name.count <= 80, origin.host == u.host, origin.scheme == u.scheme, origin.port == u.port else { throw OchoError.invalid("This invitation is incomplete.") } |
| + return invite |
| + } |
| + public var link: URL { |
| + var c = URLComponents(string: "https://ocho.chat")! |
| + let raw = person.origin + "/connect#" + (try! JSONEncoder().encode(self)).base64URL |
| + var fragment = URLComponents(); fragment.queryItems = [URLQueryItem(name: "invite", value: raw)] |
| + c.percentEncodedFragment = fragment.percentEncodedQuery |
| + return c.url! |
| + } |
| +} |
diff --git a/apple/OchoCore/Sources/OchoCore/Client.swift b/apple/OchoCore/Sources/OchoCore/Client.swift| new file mode 100644 |
| index 0000000..e90c9a1 |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/Client.swift |
| @@ -0,0 +1,118 @@ |
| +// ๐ค Serialized account state and authenticated protocol operations. |
| +import Foundation |
| +public actor OchoClient { |
| + public private(set) var identity: Identity |
| + var state: Vault, revision = 0, events: [Event] = [], cursor: Int64 = 0 |
| + public init(identity: Identity) throws { try identity.validate(); self.identity = identity; state = Vault(name: identity.name) } |
| + struct Empty: Decodable {} |
| + struct VaultReply: Codable { var revision: Int; var box: String } |
| + struct Revision: Decodable { var revision: Int } |
| + struct Row: Decodable { var seq: Int64; var envelope: Envelope } |
| + struct Delivery: Decodable { var id: String, destination: String; var delivered: Int64, attempts: Int } |
| + struct Capability: Encodable { var token: String, once: Bool } |
| + struct Setup: Encodable { var auth: String, enc: String, signing: String, claim: String } |
| + struct Item: Encodable { var destination: String, capability: String; var envelope: Envelope } |
| + struct Send: Encodable { var items: [Item] } |
| + func api<T: Decodable>(_ path: String, method: String = "GET", body: Data? = nil, as type: T.Type) async throws -> T { |
| + try await API.request(identity.origin, path, auth: identity.auth, method: method, body: body, as: type) |
| + } |
| + public func setup(claim: String) async throws { |
| + let body = Setup(auth: identity.auth, enc: identity.enc, signing: identity.signing, claim: claim) |
| + do { _ = try await api("/api/setup", method: "POST", body: JSONEncoder().encode(body), as: Empty.self) } |
| + catch { try await load() } // Only an authenticated existing identity may recover a lost reply. |
| + _ = try await api("/api/capabilities", method: "POST", body: JSONEncoder().encode(Capability(token: identity.cap, once: false)), as: Empty.self) |
| + try await load() |
| + if revision == 0 { try await save() } |
| + } |
| + func load() async throws { |
| + let vault = try await api("/api/vault", as: VaultReply.self) |
| + let decrypted = vault.box.isEmpty ? state : try Crypto.unlock(vault.box, key: identity.vaultKey, as: Vault.self) |
| + revision = vault.revision; state = decrypted; identity.name = state.name |
| + } |
| + func save() async throws { |
| + let body = VaultReply(revision: revision, box: try Crypto.lock(state, key: identity.vaultKey)) |
| + let reply = try await api("/api/vault", method: "PUT", body: JSONEncoder().encode(body), as: Revision.self) |
| + revision = reply.revision |
| + } |
| + public func refresh() async throws -> Projection { |
| + try await load() |
| + _ = try await api("/api/retry", method: "POST", as: Empty.self) |
| + while true { |
| + let rows = try await api("/api/events?after=\(cursor)", as: [Row].self) |
| + for row in rows where row.seq > cursor { |
| + if var event = try? Crypto.open(identity, envelope: row.envelope, as: Event.self) { |
| + event.signer = row.envelope.sender; events.append(event) |
| + } |
| + cursor = row.seq |
| + } |
| + if rows.count < 100 { break } |
| + } |
| + let deliveries = try await api("/api/delivery", as: [Delivery].self) |
| + var view = Projector.project(identity, state: state, events: events) |
| + for ti in view.threads.indices { |
| + for mi in view.threads[ti].messages.indices where view.threads[ti].messages[mi].mine { |
| + let id = view.threads[ti].messages[mi].id |
| + let rows = deliveries.filter { $0.id == id && $0.destination != identity.origin } |
| + if !rows.isEmpty { |
| + view.threads[ti].messages[mi].delivery = rows.allSatisfy { $0.delivered > 0 } ? "Sent" : rows.contains { $0.attempts > 0 } ? "Not delivered ยท retrying" : "Sendingโฆ" |
| + } |
| + } |
| + } |
| + return view |
| + } |
| + func emit(_ people: [Person], event: Event) async throws { |
| + var seen = Set<String>(), items: [Item] = [] |
| + for person in people where seen.insert(person.signing).inserted { |
| + _ = try API.origin(person.origin) |
| + guard let capability = person.cap else { throw OchoError.invalid("This contact is not ready.") } |
| + items.append(Item(destination: person.origin, capability: capability, envelope: try Crypto.seal(identity, to: person, value: event, id: event.kind == "message" ? event.id : nil))) |
| + } |
| + _ = try await api("/api/send", method: "POST", body: JSONEncoder().encode(Send(items: items)), as: Empty.self) |
| + } |
| + public func invite() async throws -> URL { |
| + try await load() |
| + let token = Crypto.random(), thread = Crypto.random() |
| + _ = try await api("/api/capabilities", method: "POST", body: JSONEncoder().encode(Capability(token: token, once: true)), as: Empty.self) |
| + state.invites[thread] = true; try await save() |
| + var person = identity.person; person.cap = nil |
| + return Invite(thread: thread, token: token, person: person).link |
| + } |
| + static var now: Int64 { Int64(Date().timeIntervalSince1970 * 1000) } |
| + public func connect(_ invite: Invite) async throws -> String { |
| + guard invite.person.signing != identity.signing else { throw OchoError.invalid("Share this invitation with someone else.") } |
| + try await load() |
| + state.contacts[invite.thread] = Contact(person: invite.person, pending: true); try await save() |
| + var person = invite.person; person.cap = invite.token |
| + try await emit([person], event: Event(kind: "contact-request", thread: invite.thread, person: identity.person, at: Self.now)) |
| + return invite.thread |
| + } |
| + public func accept(_ request: Event) async throws { |
| + try await load() |
| + if request.kind == "contact-request", let person = request.person { |
| + state.contacts[request.thread] = Contact(person: person, pending: false); try await save() |
| + try await emit([person, identity.person], event: Event(kind: "contact-accept", thread: request.thread, person: identity.person, at: Self.now)) |
| + } else if request.kind == "group-request" { state.accepted[request.thread] = true; try await save() } |
| + } |
| + public func reject(_ request: Event) async throws { try await load(); state.blocked[request.thread] = true; try await save() } |
| + public func message(thread: String, text: String) async throws { |
| + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard !text.isEmpty, text.utf16.count <= 8000 else { throw OchoError.invalid("Write a message of up to 8,000 characters.") } |
| + let view = try await refresh() |
| + guard let t = view.threads.first(where: { $0.id == thread }), !t.pending, !t.left else { throw OchoError.invalid("This conversation is not ready to send.") } |
| + try await emit(t.members, event: Event(kind: "message", thread: thread, at: Self.now, id: Crypto.random(), text: text, epoch: t.epoch)) |
| + } |
| + public func group(name: String, contacts: Set<String>) async throws -> String { |
| + let view = try await refresh() |
| + let members = [identity.person] + view.threads.filter { contacts.contains($0.id) && !$0.group && !$0.pending }.compactMap(\.peer) |
| + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard !name.isEmpty, name.utf16.count <= 80, members.count >= 3, members.count <= 32, members.count == contacts.count + 1 else { throw OchoError.invalid("Choose a name and between 2 and 31 contacts.") } |
| + let id = Crypto.random() |
| + try await emit(members, event: Event(kind: "group", thread: id, at: Self.now, epoch: 1, name: name, members: members, owner: identity.signing)) |
| + return id |
| + } |
| + public func removeMember(thread: String, signing: String) async throws { |
| + let view = try await refresh() |
| + guard let t = view.threads.first(where: { $0.id == thread }), t.group, t.owner == identity.signing, signing != identity.signing, t.members.contains(where: { $0.signing == signing }) else { throw OchoError.invalid("Only the group creator can remove another member.") } |
| + try await emit(t.members, event: Event(kind: "group", thread: thread, at: Self.now, epoch: t.epoch + 1, name: t.name, members: t.members.filter { $0.signing != signing }, owner: t.owner)) |
| + } |
| +} |
diff --git a/apple/OchoCore/Sources/OchoCore/Crypto.swift b/apple/OchoCore/Sources/OchoCore/Crypto.swift| new file mode 100644 |
| index 0000000..2588b0d |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/Crypto.swift |
| @@ -0,0 +1,130 @@ |
| +// ๐ค Wire-compatible with internal/infinity/static/crypto.js. Keys stay on device. |
| +import Foundation |
| +import CryptoKit |
| +import CommonCrypto |
| + |
| +public enum OchoError: LocalizedError { |
| + case invalid(String) |
| + public var errorDescription: String? { if case let .invalid(text) = self { return text }; return nil } |
| +} |
| +public extension Data { |
| + var base64URL: String { base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } |
| + init(base64URL: String) throws { |
| + let s = base64URL.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") |
| + guard let data = Data(base64Encoded: s + String(repeating: "=", count: (4 - s.count % 4) % 4)) else { throw OchoError.invalid("Invalid encoded data.") } |
| + self = data |
| + } |
| +} |
| +public struct JWK: Codable, Sendable { |
| + public var kty = "EC", crv = "P-256", x: String, y: String, d: String |
| + public var ext = true |
| + public var key_ops: [String] |
| + init(raw: Data, publicKey: Data, operations: [String]) { |
| + x = publicKey.subdata(in: 1..<33).base64URL; y = publicKey.subdata(in: 33..<65).base64URL |
| + d = raw.base64URL; key_ops = operations |
| + } |
| +} |
| +public struct Person: Codable, Sendable, Hashable { |
| + public var origin: String, name: String, enc: String, signing: String |
| + public var cap: String? |
| +} |
| +public struct Identity: Codable, Sendable { |
| + public var version = 1 |
| + public var origin: String, name: String, auth: String, vaultKey: String, cap: String |
| + public var enc: String, signing: String |
| + public var privateEnc: JWK, privateSign: JWK |
| + public var person: Person { Person(origin: origin, name: name, enc: enc, signing: signing, cap: cap) } |
| + public static func create(origin: String, name: String) -> Identity { |
| + let e = P256.KeyAgreement.PrivateKey(), s = P256.Signing.PrivateKey() |
| + return Identity(origin: origin, name: name, auth: Crypto.random(), vaultKey: Crypto.random(), cap: Crypto.random(), enc: e.publicKey.x963Representation.base64URL, signing: s.publicKey.x963Representation.base64URL, |
| + privateEnc: JWK(raw: e.rawRepresentation, publicKey: e.publicKey.x963Representation, operations: ["deriveBits"]), privateSign: JWK(raw: s.rawRepresentation, publicKey: s.publicKey.x963Representation, operations: ["sign"])) |
| + } |
| + public func validate() throws { |
| + guard version == 1, name.count <= 80, try Data(base64URL: auth).count == 32, |
| + try Data(base64URL: vaultKey).count == 32, try Data(base64URL: cap).count == 32 else { throw OchoError.invalid("Invalid account identity.") } |
| + let e = try P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64URL: privateEnc.d)) |
| + let s = try P256.Signing.PrivateKey(rawRepresentation: Data(base64URL: privateSign.d)) |
| + guard e.publicKey.x963Representation.base64URL == enc, s.publicKey.x963Representation.base64URL == signing else { throw OchoError.invalid("Account keys do not match.") } |
| + _ = try API.origin(origin) |
| + } |
| +} |
| +public struct Envelope: Codable, Sendable { |
| + public var id: String, to: String, sender: String, ephemeral: String, nonce: String, box: String, signature: String |
| + var header: Data { Data(["infinity-envelope-v1", id, to, sender, ephemeral, nonce].joined(separator: "\n").utf8) } |
| + var signed: Data { header + Data(("\n" + box).utf8) } |
| +} |
| +public enum Crypto { |
| + public static func random(_ count: Int = 32) -> String { |
| + var bytes = [UInt8](repeating: 0, count: count) |
| + precondition(SecRandomCopyBytes(kSecRandomDefault, count, &bytes) == errSecSuccess) |
| + return Data(bytes).base64URL |
| + } |
| + static func key(_ secret: SharedSecret, nonce: Data) -> SymmetricKey { |
| + secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: nonce, sharedInfo: Data("infinity-message-v1".utf8), outputByteCount: 32) |
| + } |
| + public static func seal<T: Encodable>(_ identity: Identity, to recipient: Person, value: T, id: String? = nil) throws -> Envelope { |
| + let ephemeral = P256.KeyAgreement.PrivateKey(), nonce = AES.GCM.Nonce() |
| + let pub = try P256.KeyAgreement.PublicKey(x963Representation: Data(base64URL: recipient.enc)) |
| + let secret = try ephemeral.sharedSecretFromKeyAgreement(with: pub) |
| + var e = Envelope(id: id ?? random(), to: recipient.enc, sender: identity.signing, ephemeral: ephemeral.publicKey.x963Representation.base64URL, nonce: Data(nonce).base64URL, box: "", signature: "") |
| + let sealed = try AES.GCM.seal(JSONEncoder().encode(value), using: key(secret, nonce: Data(nonce)), nonce: nonce, authenticating: e.header) |
| + e.box = (sealed.ciphertext + sealed.tag).base64URL |
| + let signing = try P256.Signing.PrivateKey(rawRepresentation: Data(base64URL: identity.privateSign.d)) |
| + e.signature = try signing.signature(for: e.signed).rawRepresentation.base64URL |
| + return e |
| + } |
| + public static func open<T: Decodable>(_ identity: Identity, envelope e: Envelope, as type: T.Type) throws -> T { |
| + guard e.to == identity.enc else { throw OchoError.invalid("This message belongs to another account.") } |
| + let signing = try P256.Signing.PublicKey(x963Representation: Data(base64URL: e.sender)) |
| + guard try signing.isValidSignature(P256.Signing.ECDSASignature(rawRepresentation: Data(base64URL: e.signature)), for: e.signed) else { throw OchoError.invalid("Message signature did not match.") } |
| + let secret = try P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64URL: identity.privateEnc.d)).sharedSecretFromKeyAgreement(with: P256.KeyAgreement.PublicKey(x963Representation: Data(base64URL: e.ephemeral))) |
| + let nonce = try Data(base64URL: e.nonce), box = try Data(base64URL: e.box) |
| + guard box.count >= 16 else { throw OchoError.invalid("Invalid encrypted message.") } |
| + let sealed = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: nonce), ciphertext: box.dropLast(16), tag: box.suffix(16)) |
| + return try JSONDecoder().decode(type, from: AES.GCM.open(sealed, using: key(secret, nonce: nonce), authenticating: e.header)) |
| + } |
| + public static func lock<T: Encodable>(_ value: T, key: String) throws -> String { |
| + let nonce = AES.GCM.Nonce(), raw = try Data(base64URL: key) |
| + guard raw.count == 32 else { throw OchoError.invalid("Invalid encryption key.") } |
| + let box = try AES.GCM.seal(JSONEncoder().encode(value), using: SymmetricKey(data: raw), nonce: nonce, authenticating: Data("infinity-vault-v1".utf8)) |
| + return Data(nonce).base64URL + "." + (box.ciphertext + box.tag).base64URL |
| + } |
| + public static func unlock<T: Decodable>(_ text: String, key: String, as type: T.Type) throws -> T { |
| + let parts = text.split(separator: ".") |
| + guard parts.count == 2 else { throw OchoError.invalid("Invalid encrypted file.") } |
| + let bytes = try Data(base64URL: String(parts[1])), raw = try Data(base64URL: key) |
| + guard bytes.count >= 16, raw.count == 32 else { throw OchoError.invalid("Invalid encrypted file.") } |
| + let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: Data(base64URL: String(parts[0]))), ciphertext: bytes.dropLast(16), tag: bytes.suffix(16)) |
| + return try JSONDecoder().decode(type, from: AES.GCM.open(box, using: SymmetricKey(data: raw), authenticating: Data("infinity-vault-v1".utf8))) |
| + } |
| + static func recoveryKey(_ password: String, salt: String) throws -> String { |
| + guard password.count >= 12 else { throw OchoError.invalid("Use at least 12 characters for your recovery password.") } |
| + let salt = try Data(base64URL: salt), password = Array(password.utf8) |
| + var result = [UInt8](repeating: 0, count: 32) |
| + let status = password.withUnsafeBytes { p in salt.withUnsafeBytes { s in |
| + CCKeyDerivationPBKDF(CCPBKDFAlgorithm(kCCPBKDF2), p.bindMemory(to: Int8.self).baseAddress, password.count, s.bindMemory(to: UInt8.self).baseAddress, salt.count, CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), 600000, &result, 32) |
| + } } |
| + guard status == kCCSuccess else { throw OchoError.invalid("Could not protect your recovery file.") } |
| + return Data(result).base64URL |
| + } |
| + struct Recovery: Codable { var format = "infinity-recovery-v1"; var salt: String, box: String } |
| + public static func exportRecovery(_ identity: Identity, password: String) throws -> Data { |
| + let salt = random() |
| + return try JSONEncoder().encode(Recovery(salt: salt, box: lock(identity, key: recoveryKey(password, salt: salt)))) |
| + } |
| + public static func importRecovery(_ data: Data, password: String) throws -> Identity { |
| + guard data.count <= 100000 else { throw OchoError.invalid("Recovery file is too large.") } |
| + let file = try JSONDecoder().decode(Recovery.self, from: data) |
| + guard file.format == "infinity-recovery-v1" else { throw OchoError.invalid("Not an Ocho recovery file.") } |
| + let identity = try unlock(file.box, key: recoveryKey(password, salt: file.salt), as: Identity.self) |
| + try identity.validate(); return identity |
| + } |
| +} |
| + |
| +public extension Crypto { |
| + static func passkeyWrappingKey(output: Data, credentialID: Data) throws -> String { |
| + guard output.count == 32 else { throw OchoError.invalid("This passkey provider cannot unlock encrypted messages. Use a recovery file or another passkey provider.") } |
| + let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: SymmetricKey(data: output), salt: Data("ocho-passkey-wrap-v1".utf8), info: credentialID, outputByteCount: 32) |
| + return key.withUnsafeBytes { Data($0).base64URL } |
| + } |
| +} |
diff --git a/apple/OchoCore/Sources/OchoCore/Models.swift b/apple/OchoCore/Sources/OchoCore/Models.swift| new file mode 100644 |
| index 0000000..f8156a5 |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/Models.swift |
| @@ -0,0 +1,67 @@ |
| +// ๐ค Readable models exist only on the client, inside the encrypted account vault. |
| +import Foundation |
| +public struct Contact: Codable, Sendable { public var person: Person; public var pending: Bool } |
| +public struct Vault: Codable, Sendable { |
| + public var name: String |
| + public var contacts: [String: Contact] = [:], invites: [String: Bool] = [:], accepted: [String: Bool] = [:], blocked: [String: Bool] = [:] |
| + public init(name: String) { self.name = name } |
| +} |
| +public struct Event: Codable, Sendable { |
| + public var kind: String, thread: String |
| + public var person: Person?, at: Int64?, id: String?, text: String?, epoch: Int?, name: String?, members: [Person]?, owner: String? |
| + // Signer is provided by the verified envelope, never by the encrypted payload. |
| + public var signer: String? |
| + public init(kind: String, thread: String, person: Person? = nil, at: Int64? = nil, id: String? = nil, text: String? = nil, epoch: Int? = nil, name: String? = nil, members: [Person]? = nil, owner: String? = nil) { |
| + self.kind = kind; self.thread = thread; self.person = person; self.at = at; self.id = id; self.text = text; self.epoch = epoch; self.name = name; self.members = members; self.owner = owner |
| + } |
| +} |
| +public struct Message: Identifiable, Sendable { |
| + public var id: String, text: String, at: Int64, mine: Bool, name: String, signer: String |
| + public var delivery: String? |
| + public var date: Date { Date(timeIntervalSince1970: Double(at) / 1000) } |
| +} |
| +public struct Thread: Identifiable, Sendable { |
| + public var id: String, name: String, peer: Person? = nil, members: [Person], pending: Bool, group = false, owner: String? = nil, epoch = 0, left = false |
| + public var messages: [Message] = [] |
| +} |
| +public struct Projection: Sendable { public var threads: [Thread] = []; public var requests: [Event] = [] } |
| +public enum Projector { |
| + static func valid(_ p: Person) -> Bool { p.name.count <= 80 && p.cap != nil && (try? API.origin(p.origin)) != nil } |
| + public static func project(_ identity: Identity, state: Vault, events: [Event]) -> Projection { |
| + var me = identity.person; me.name = state.name |
| + var threads: [String: Thread] = [:], requests: [String: Event] = [:], snapshots: [String: [Person]] = [:] |
| + for (id, contact) in state.contacts where state.blocked[id] != true { |
| + threads[id] = Thread(id: id, name: contact.person.name, peer: contact.person, members: [me, contact.person], pending: contact.pending) |
| + } |
| + for e in events where state.blocked[e.thread] != true { |
| + if e.kind == "contact-request", state.invites[e.thread] == true, let person = e.person, valid(person), e.signer == person.signing, e.signer != me.signing, threads[e.thread] == nil, requests[e.thread] == nil { requests[e.thread] = e } |
| + if e.kind == "contact-accept", var t = threads[e.thread], let person = e.person, valid(person), t.peer?.signing == e.signer, person.signing == e.signer, person.enc == t.peer?.enc, person.origin == t.peer?.origin { |
| + t.peer = person; t.members = [me, person]; t.pending = false; threads[e.thread] = t |
| + } |
| + } |
| + for e in events.filter({ $0.kind == "group" }).sorted(by: { ($0.epoch ?? 0) < ($1.epoch ?? 0) }) { |
| + guard state.blocked[e.thread] != true, let epoch = e.epoch, epoch >= 1, e.owner == e.signer, let owner = e.owner, let name = e.name, name.count <= 80, let members = e.members, !members.isEmpty, members.count <= 32, members.allSatisfy(valid), Set(members.map(\.signing)).count == members.count, members.contains(where: { $0.signing == owner }) else { continue } |
| + if let old = threads[e.thread] { |
| + guard old.group, old.owner == owner, epoch == old.epoch + 1 else { continue } |
| + } else { |
| + guard epoch == 1, members.contains(where: { $0.signing == me.signing }), owner == me.signing || threads.values.contains(where: { !$0.group && !$0.pending && $0.peer?.signing == owner }) else { continue } |
| + } |
| + snapshots[e.thread + ":" + String(epoch)] = members |
| + let pending = owner != me.signing && state.accepted[e.thread] != true |
| + let left = !members.contains(where: { $0.signing == me.signing }) |
| + threads[e.thread] = Thread(id: e.thread, name: name, members: members, pending: pending, group: true, owner: owner, epoch: epoch, left: left) |
| + if pending && !left { var req = e; req.kind = "group-request"; requests[e.thread] = req } |
| + } |
| + var seen = Set<String>() |
| + for e in events { |
| + guard e.kind == "message", let text = e.text, text.utf16.count <= 8000, let id = e.id, let at = e.at, at >= 0, at <= 8640000000000000, let signer = e.signer, var t = threads[e.thread], !t.pending else { continue } |
| + let members = t.group ? snapshots[e.thread + ":" + String(e.epoch ?? -1)] : t.members |
| + guard let members, let person = members.first(where: { $0.signing == signer }), members.contains(where: { $0.signing == me.signing }), t.group || e.epoch == 0, seen.insert(e.thread + ":" + id).inserted else { continue } |
| + t.messages.append(Message(id: id, text: text, at: at, mine: signer == me.signing, name: person.name, signer: signer)); threads[e.thread] = t |
| + } |
| + var all = threads.values.filter { !$0.pending || !$0.group } |
| + for i in all.indices { all[i].messages.sort { $0.at == $1.at ? $0.id < $1.id : $0.at < $1.at } } |
| + all.sort { ($0.messages.last?.at ?? 0) > ($1.messages.last?.at ?? 0) } |
| + return Projection(threads: all, requests: Array(requests.values)) |
| + } |
| +} |
diff --git a/apple/OchoCore/Sources/OchoCore/Registration.swift b/apple/OchoCore/Sources/OchoCore/Registration.swift| new file mode 100644 |
| index 0000000..68bb066 |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/Registration.swift |
| @@ -0,0 +1,47 @@ |
| +// ๐ค Framework-compatible proof of work, computed away from the UI thread. |
| +import Foundation |
| +import CryptoKit |
| +public enum Registration { |
| + struct Fields: Decodable { var values: [String: String]; var counter: String } |
| + struct Challenge: Decodable { var fields: Fields; var nonce: String; var difficulty: Int; var wait: Int } |
| + struct Body: Encodable { var nonce: String; var proof: [String: String] } |
| + struct Reply: Decodable { var preparing: Bool?; var origin: String?; var claim: String? } |
| + public static func create(home: String, name: String, nonce: String, progress: @escaping @Sendable (String) async -> Void) async throws -> (Identity, String) { |
| + await progress("Checking this deviceโฆ") |
| + let challenge = try await API.request(home, "/api/registration-challenge", as: Challenge.self) |
| + guard (0...24).contains(challenge.difficulty), (0...60000).contains(challenge.wait) else { throw OchoError.invalid("Account setup is unavailable.") } |
| + let began = Date() |
| + let counter = try await Task.detached { |
| + let prefix = challenge.nonce + ":" + nonce.lowercased() + ":" |
| + for counter in 0..<100_000_000 { |
| + if counter % 4096 == 0 { try Task.checkCancellation(); if Date().timeIntervalSince(began) > 60 { break } } |
| + let hash = SHA256.hash(data: Data((prefix + String(counter)).utf8)) |
| + var remaining = challenge.difficulty, valid = true |
| + for byte in hash { |
| + let bits = min(remaining, 8) |
| + if bits == 0 { break } |
| + if byte >> (8 - bits) != 0 { valid = false; break }; remaining -= bits |
| + } |
| + if valid { return String(counter) } |
| + } |
| + throw OchoError.invalid("Account setup took too long. Please try again.") |
| + }.value |
| + let remaining = Double(challenge.wait) / 1000 - Date().timeIntervalSince(began) |
| + if remaining > 0 { try await Task.sleep(for: .seconds(remaining)) } |
| + var proof = challenge.fields.values; proof[challenge.fields.counter] = counter |
| + let body = try JSONEncoder().encode(Body(nonce: nonce, proof: proof)) |
| + let deadline = Date().addingTimeInterval(120) |
| + await progress("Making room for youโฆ") |
| + while Date() < deadline { |
| + try Task.checkCancellation() |
| + let reply = try await API.request(home, "/api/accounts", method: "POST", body: body, as: Reply.self) |
| + if let origin = reply.origin, let claim = reply.claim { |
| + _ = try API.origin(origin) |
| + return (Identity.create(origin: origin, name: name), claim) |
| + } |
| + await progress("Starting your private accountโฆ") |
| + try await Task.sleep(for: .seconds(1.5)) |
| + } |
| + throw OchoError.invalid("Your account is taking longer than expected. Try again to continue setting up the same account.") |
| + } |
| +} |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift b/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift| new file mode 100644 |
| index 0000000..928ebf2 |
| --- /dev/null |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift |
| @@ -0,0 +1,61 @@ |
| +// ๐ค Three real personal processes validate the native protocol end to end. |
| +import XCTest |
| +@testable import OchoCore |
| +#if os(macOS) |
| +final class ClientTests: XCTestCase { |
| + func testNativeAccountsAndGroups() async throws { |
| + guard let binary = ProcessInfo.processInfo.environment["OCHO_INTEGRATION_BINARY"] else { throw XCTSkip("Run tools/infinity/check-native for personal-process integration.") } |
| + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| + let claim = Crypto.random() |
| + var children: [Process] = [] |
| + defer { for p in children where p.isRunning { p.terminate(); p.waitUntilExit() }; try? FileManager.default.removeItem(at: dir) } |
| + var ids: [Identity] = [], clients: [OchoClient] = [] |
| + for i in 0..<3 { |
| + let origin = "http://127.0.0.1:\(18851 + i)" |
| + let p = Process(); p.executableURL = URL(fileURLWithPath: binary) |
| + p.arguments = ["--addr", "127.0.0.1:\(18851 + i)", "--db", dir.appendingPathComponent("\(i).db").path] |
| + p.environment = ProcessInfo.processInfo.environment.merging(["INFINITY_SETUP_TOKEN": claim, "INFINITY_ALLOW_LOCAL": "1"]) { _, new in new } |
| + p.standardOutput = FileHandle.nullDevice; p.standardError = FileHandle.nullDevice |
| + try p.run(); children.append(p) |
| + struct Profile: Decodable { var ready: Bool } |
| + var ready = false |
| + for _ in 0..<100 { |
| + if (try? await API.request(origin, "/api/profile", as: Profile.self)) != nil { ready = true; break } |
| + try await Task.sleep(for: .milliseconds(100)) |
| + } |
| + XCTAssertTrue(ready) |
| + let id = Identity.create(origin: origin, name: ["Alice", "Bob", "Carol"][i]); ids.append(id) |
| + let client = try OchoClient(identity: id); clients.append(client); try await client.setup(claim: claim) |
| + try await client.setup(claim: claim) // Idempotent setup, same keys. |
| + } |
| + let a = clients[0], b = clients[1], c = clients[2] |
| + let invitation = try await a.invite() |
| + let thread = try await b.connect(Invite.parse(invitation.absoluteString)) |
| + var view = try await a.refresh() |
| + XCTAssertEqual(view.requests.count, 1) |
| + try await a.accept(XCTUnwrap(view.requests.first)) |
| + try await b.message(thread: thread, text: "Native Bob to Alice โ") |
| + view = try await a.refresh() |
| + XCTAssertEqual(view.threads.first?.messages.last?.text, "Native Bob to Alice โ") |
| + try await a.message(thread: thread, text: "Native Alice to Bob") |
| + let bob = try await b.refresh(); XCTAssertEqual(bob.threads.first?.messages.last?.text, "Native Alice to Bob") |
| + let second = try await c.connect(Invite.parse(try await a.invite().absoluteString)) |
| + view = try await a.refresh(); try await a.accept(XCTUnwrap(view.requests.first)) |
| + let group = try await a.group(name: "Native friends", contacts: Set([thread, second])) |
| + let br = try await b.refresh(), cr = try await c.refresh() |
| + try await b.accept(XCTUnwrap(br.requests.first)); try await c.accept(XCTUnwrap(cr.requests.first)) |
| + try await c.message(thread: group, text: "Native group hello") |
| + view = try await a.refresh(); XCTAssertEqual(view.threads.first(where: { $0.id == group })?.messages.last?.text, "Native group hello") |
| + try await a.removeMember(thread: group, signing: ids[2].signing) |
| + let removed = try await c.refresh(); XCTAssertTrue(removed.threads.first(where: { $0.id == group })?.left == true) |
| + do { try await c.message(thread: group, text: "Must not send"); XCTFail("Removed member sent") } catch {} |
| + let recovered = try OchoClient(identity: ids[0]); let history = try await recovered.refresh() |
| + XCTAssertEqual(history.threads.first(where: { $0.id == thread })?.messages.count, 2) |
| + for filename in try FileManager.default.contentsOfDirectory(atPath: dir.path) { |
| + let bytes = try Data(contentsOf: dir.appendingPathComponent(filename)) |
| + for secret in ["Native group hello", "Native Bob to Alice", ids[0].privateEnc.d, ids[0].vaultKey] { XCTAssertNil(bytes.range(of: Data(secret.utf8)), "Server persisted a secret") } |
| + } |
| + } |
| +} |
| +#endif |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift b/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift| new file mode 100644 |
| index 0000000..1f613f1 |
| --- /dev/null |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift |
| @@ -0,0 +1,39 @@ |
| +// ๐ค Synthetic identities only. Native/Web Crypto share the existing v1 format. |
| +import XCTest |
| +@testable import OchoCore |
| +final class CryptoTests: XCTestCase { |
| + func testEnvelopeAndTamper() throws { |
| + let a = Identity.create(origin: "https://a.example", name: "Alice"), b = Identity.create(origin: "https://b.example", name: "Bob") |
| + let event = Event(kind: "message", thread: Crypto.random(), at: 123, id: Crypto.random(), text: "Hello โ", epoch: 0) |
| + var e = try Crypto.seal(a, to: b.person, value: event, id: event.id) |
| + XCTAssertEqual(try Crypto.open(b, envelope: e, as: Event.self).text, event.text) |
| + XCTAssertThrowsError(try Crypto.open(a, envelope: e, as: Event.self)) |
| + e.id = Crypto.random() |
| + XCTAssertThrowsError(try Crypto.open(b, envelope: e, as: Event.self)) |
| + } |
| + func testRecoveryAndVault() throws { |
| + let a = Identity.create(origin: "https://a.example", name: "Alice") |
| + let data = try Crypto.exportRecovery(a, password: "correct horse battery") |
| + XCTAssertEqual(try Crypto.importRecovery(data, password: "correct horse battery").enc, a.enc) |
| + XCTAssertThrowsError(try Crypto.importRecovery(data, password: "incorrect horse battery")) |
| + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains(a.privateEnc.d)) |
| + let vault = try Crypto.lock(Vault(name: "Alice"), key: a.vaultKey) |
| + XCTAssertEqual(try Crypto.unlock(vault, key: a.vaultKey, as: Vault.self).name, "Alice") |
| + XCTAssertThrowsError(try Crypto.unlock(vault, key: Crypto.random(), as: Vault.self)) |
| + } |
| + func testBrowserInterop() throws { |
| + guard let path = ProcessInfo.processInfo.environment["OCHO_INTEROP_FIXTURE"] else { throw XCTSkip("Run tools/infinity/check-native-crypto for browser interoperability.") } |
| + struct Fixture: Codable { var sender: Identity, recipient: Identity, envelope: Envelope, vault: String, recovery: String, prfKey: String } |
| + let f = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) |
| + XCTAssertEqual(try Crypto.passkeyWrappingKey(output: Data(repeating: 17, count: 32), credentialID: Data(repeating: 23, count: 32)), f.prfKey) |
| + try f.sender.validate(); try f.recipient.validate() |
| + XCTAssertEqual(try Crypto.open(f.recipient, envelope: f.envelope, as: Event.self).text, "Web to Swift โ") |
| + XCTAssertEqual(try Crypto.unlock(f.vault, key: f.recipient.vaultKey, as: Vault.self).name, "Browser") |
| + XCTAssertEqual(try Crypto.importRecovery(Data(f.recovery.utf8), password: "interop recovery password").enc, f.recipient.enc) |
| + let event = Event(kind: "message", thread: "interop", at: 42, id: Crypto.random(), text: "Swift to Web โ", epoch: 0) |
| + let envelope = try Crypto.seal(f.recipient, to: f.sender.person, value: event, id: event.id) |
| + struct Reply: Encodable { var envelope: Envelope, recovery: String, vault: String } |
| + let reply = Reply(envelope: envelope, recovery: String(decoding: try Crypto.exportRecovery(f.recipient, password: "interop recovery password"), as: UTF8.self), vault: try Crypto.lock(Vault(name: "Native"), key: f.sender.vaultKey)) |
| + try JSONEncoder().encode(reply).write(to: URL(fileURLWithPath: path + ".reply")) |
| + } |
| +} |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift b/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift| new file mode 100644 |
| index 0000000..81e3403 |
| --- /dev/null |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift |
| @@ -0,0 +1,34 @@ |
| +// ๐ค Membership authority is verified locally, independently of the delivery server. |
| +import XCTest |
| +@testable import OchoCore |
| +final class ProjectionTests: XCTestCase { |
| + func testForgedMembershipAndEpochs() { |
| + let a = Identity.create(origin: "https://a.example", name: "Alice"), b = Identity.create(origin: "https://b.example", name: "Bob"), c = Identity.create(origin: "https://c.example", name: "Carol") |
| + var vault = Vault(name: "Alice"); vault.contacts["dm"] = Contact(person: b.person, pending: false) |
| + func signed(_ e: Event, _ signer: String) -> Event { var e = e; e.signer = signer; return e } |
| + let first = signed(Event(kind: "group", thread: "group", at: 1, epoch: 1, name: "Friends", members: [a.person,b.person,c.person], owner: a.signing), a.signing) |
| + let removed = signed(Event(kind: "group", thread: "group", at: 2, epoch: 2, name: "Friends", members: [a.person,b.person], owner: a.signing), a.signing) |
| + let forged = signed(Event(kind: "group", thread: "group", at: 3, epoch: 3, name: "Taken over", members: [a.person,c.person], owner: a.signing), c.signing) |
| + let oldMessage = signed(Event(kind: "message", thread: "group", at: 4, id: "old", text: "History", epoch: 1), c.signing) |
| + let newMessage = signed(Event(kind: "message", thread: "group", at: 5, id: "new", text: "Forgery", epoch: 2), c.signing) |
| + let view = Projector.project(a, state: vault, events: [removed, first, forged, oldMessage, newMessage]) |
| + let group = view.threads.first { $0.id == "group" } |
| + XCTAssertEqual(group?.epoch, 2); XCTAssertEqual(group?.name, "Friends") |
| + XCTAssertEqual(group?.messages.map(\.text), ["History"]) |
| + let missing = Projector.project(a, state: vault, events: [removed, forged, newMessage]) |
| + XCTAssertNil(missing.threads.first { $0.id == "group" }) |
| + vault.blocked["group"] = true |
| + XCTAssertNil(Projector.project(a, state: vault, events: [first, oldMessage]).threads.first { $0.id == "group" }) |
| + } |
| + func testUnknownGroupNeedsKnownCreatorAndConsent() { |
| + let a = Identity.create(origin: "https://a.example", name: "Alice"), b = Identity.create(origin: "https://b.example", name: "Bob") |
| + var vault = Vault(name: "Alice") |
| + var group = Event(kind: "group", thread: "group", at: 1, epoch: 1, name: "Friends", members: [a.person,b.person], owner: b.signing); group.signer = b.signing |
| + XCTAssertTrue(Projector.project(a, state: vault, events: [group]).requests.isEmpty) |
| + vault.contacts["dm"] = Contact(person: b.person, pending: false) |
| + let pending = Projector.project(a, state: vault, events: [group]) |
| + XCTAssertEqual(pending.requests.count, 1); XCTAssertNil(pending.threads.first { $0.group }) |
| + vault.accepted["group"] = true |
| + XCTAssertNotNil(Projector.project(a, state: vault, events: [group]).threads.first { $0.group }) |
| + } |
| +} |
diff --git a/apple/project.yml b/apple/project.yml| index 67a7def..115c04c 100644 |
| --- a/apple/project.yml |
| +++ b/apple/project.yml |
| @@ -13,8 +13,13 @@ settings: |
| ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| CODE_SIGN_STYLE: Automatic |
| INFOPLIST_KEY_CFBundleDisplayName: Ocho |
| +packages: |
| + OchoCore: |
| + path: OchoCore |
| targets: |
| Infinity: |
| + dependencies: |
| + - package: OchoCore |
| type: application |
| platform: iOS |
| sources: |
| @@ -22,6 +27,7 @@ targets: |
| settings: |
| base: |
| PRODUCT_BUNDLE_IDENTIFIER: chat.ocho.infinity |
| + CODE_SIGN_ENTITLEMENTS: Infinity/Infinity.entitlements |
| TARGETED_DEVICE_FAMILY: '1,2' |
| INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES |
| @@ -29,10 +35,15 @@ targets: |
| path: generated/Info-iOS.plist |
| properties: |
| CFBundleDisplayName: Ocho |
| + CFBundleName: Ocho |
| + NSAppTransportSecurity: |
| + NSAllowsLocalNetworking: true |
| CFBundleURLTypes: |
| - CFBundleURLName: chat.ocho.infinity |
| CFBundleURLSchemes: [ocho] |
| InfinityMac: |
| + dependencies: |
| + - package: OchoCore |
| type: application |
| platform: macOS |
| sources: |
| @@ -49,11 +60,28 @@ targets: |
| path: generated/Info-macOS.plist |
| properties: |
| CFBundleDisplayName: Ocho |
| + CFBundleName: Ocho |
| + NSAppTransportSecurity: |
| + NSAllowsLocalNetworking: true |
| CFBundleURLTypes: |
| - CFBundleURLName: chat.ocho.infinity |
| CFBundleURLSchemes: [ocho] |
| + InfinityUITests: |
| + type: bundle.ui-testing |
| + platform: iOS |
| + sources: [InfinityUITests] |
| + dependencies: |
| + - target: Infinity |
| + - package: OchoCore |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: chat.ocho.infinity.uitests |
| + GENERATE_INFOPLIST_FILE: YES |
| + INFOPLIST_KEY_NSAppTransportSecurity: { NSAllowsLocalNetworking: true } |
| schemes: |
| Infinity: |
| + test: |
| + targets: [InfinityUITests] |
| build: |
| targets: |
| Infinity: all |
diff --git a/docs/infinity/native-passkeys.md b/docs/infinity/native-passkeys.md| new file mode 100644 |
| index 0000000..fbe9235 |
| --- /dev/null |
| +++ b/docs/infinity/native-passkeys.md |
| @@ -0,0 +1,19 @@ |
| +# ๐ค Native Ocho and passkeys โ 2026-09-06, Codex |
| + |
| +Paul requires fully native iOS/macOS apps, no WebViews anywhere, passkeys as an alternative to recovery files, and signed-in web chats at ocho.chat. The earlier WebKit-shell assumption is superseded. The infinity glyph remains the icon; the product name is Ocho. |
| + |
| +## Native client |
| + |
| +Use Eleven's native chat as a reference for navigation, a flat conversation list, native message feed, composer, group sheets and sharing. Do not import its command-host WebViews or its different wire protocol. Implement Ocho's current version-1 envelope and vault protocol in a shared Swift/CryptoKit package, with bidirectional Web Crypto compatibility fixtures. Use URLSession for networking, Keychain for saved identities, SwiftUI for all screens and AuthenticationServices for passkeys. Existing web events must remain readable. No account reset, destructive migration, or server-side decryption. |
| + |
| +## One web origin |
| + |
| +Keep the client and its stored identity at ocho.chat. Tenant processes and databases remain separate; tenant origins are routing metadata, not passwords. Add a bounded authenticated API relay restricted to provisioned Ocho tenants and enumerated personal API routes, without generic proxying or redirects. Relay only encrypted vaults/envelopes and existing bearer-authorized requests. Migrate existing users through explicit recovery or a fragment-only transfer from their existing origin; never put private identities in query strings or server requests. Setup must finish on the front door without navigating away. |
| + |
| +## Passkeys |
| + |
| +Relying-party ID ocho.chat, shared by web and associated native applications. Use discoverable credentials and required user verification. WebAuthn challenges are short-lived, single-use and bound to their operation. Use the existing maintained WebAuthn/Rastrillo facilities for verification, not custom signature verification. A credential's PRF output derives a domain-separated key that encrypts the existing Ocho identity. The server stores the credential public metadata, tenant association and encrypted identity only; never PRF output or private keys. Validate ownership before enrolling a credential for an existing tenant. Signing in verifies the assertion, retrieves the sealed identity and decrypts on the client before loading the existing encrypted vault and events. A passkey without PRF support cannot silently become an unencrypted recovery mechanism. Keep recovery-file support as an explicit alternative. Native and browser PRF salt/derivation must be tested together; associated-domain signing is a real-device prerequisite. |
| + |
| +## Verification and rollout |
| + |
| +CryptoKit/Web Crypto interoperability and tamper rejection; projection parity including group membership epochs and forged events; relay cross-tenant and SSRF rejection; passkey replay/origin/ownership and encrypted-storage tests; browser signup/invitation/recovery/chat flows at a stable origin; native builds and simulator UI flows; physical-device passkey test when user presence is available. Deploy compatible backend/web changes before native installation. Existing wrapper localStorage is not directly readable without WebKit: preserve its data and provide recovery/passkey migration, never claim it was migrated automatically. No implementation merge to main without Paul's approval. |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 071907a..f873c65 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -49,3 +49,9 @@ The setup interlude is live in both apps at 2017ba3d. Go tests/vet, Chromium/Web |
| At Paulโs request, infinity sits farther beyond the bar and the fill advances slowly during setup instead of looping. This is an estimated visual cue, with no numerical percentage presented; it remains below the end until readiness is confirmed. The start time travels with the existing setup fragment to keep motion continuous across the account handoff. Successful personal setup triggers an accelerating 800 ms burst beyond the bar to infinity before opening chat. Reduced motion skips that animation. Chromium/WebKit checks verified increasing fill, the pending cap, extra spacing, completion overshoot and no mobile overflow. |
| Both apps now serve f4d3acb8, verified from their /api/version response bodies. A fresh deployed WebKit signup passed the complete account handoff, observed the readiness-only completion state and then opened chat without browser errors. Go tests/vet, Chromium/WebKit flows and all nine crypto/protocol checks passed. The local Mac app was opened for Paul. |
| + |
| +## Native/passkey rewrite in progress โ 2026-09-06 (Codex) |
| + |
| +Paul superseded the wrapper approach: native SwiftUI on both platforms, CryptoKit and URLSession in a shared OchoCore package, no WebKit/JavaScript runtime in either app. The replacement currently compiles for Mac and iPhone. Bidirectional Web Crypto/CryptoKit envelopes, vaults, recovery files and PRF wrapping derivation pass. Three actual personal processes pass native DMs, groups, consent, removal and restored history; projection tests reject forged membership and missing epochs. The real iPhone UI test is still being run. |
| + |
| +The front door now has a bounded known-tenant API relay plus Rastrillo WebAuthn registration/assertion verification and a public associated-domain file. Additive tables store only public credential metadata, routing association, short-lived challenges and PRF-sealed identities. Origin/RP/verification/signature/replay/ownership tests pass. An isolated Chromium virtual authenticator with PRF passed signup, enrollment, clearing local storage, discoverable sign-in, restoration of the same private identity and reload while remaining on the front door. This is not yet evidence of a physical Apple passkey ceremony. Changes are not deployed or installed yet; do not confuse these local results with the existing production wrapper release. |
diff --git a/internal/infinity/static/api.js b/internal/infinity/static/api.js| index 28ff7a1..46dd7b6 100644 |
| --- a/internal/infinity/static/api.js |
| +++ b/internal/infinity/static/api.js |
| @@ -1,11 +1,15 @@ |
| // ๐ค Only explicit owner credentials authorize account APIs. |
| +let relay = false; |
| +export function setRelay(value) { relay = value; } |
| +export const usingRelay = () => relay; |
| export async function request(origin, path, { auth, method = 'GET', body, timeoutMs = 30000 } = {}) { |
| const headers = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; |
| if (auth) headers.Authorization = 'Bearer ' + auth; |
| const controller = new AbortController(); |
| const timer = setTimeout(() => controller.abort(), timeoutMs); |
| try { |
| - const response = await fetch(origin + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'omit', cache: 'no-store', signal: controller.signal }); |
| + const target = relay && origin !== location.origin ? location.origin + '/api/tenant/' + new URL(peerOrigin(origin)).host + path.slice(4) : origin + path; |
| + const response = await fetch(target, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'omit', cache: 'no-store', signal: controller.signal }); |
| let value; try { value = await response.json(); } catch { throw new Error('Your account is temporarily unavailable. Please try again.'); } |
| if (!response.ok) { const error = new Error(value.error || 'Could not finish. Please try again.'); error.status = response.status; throw error; } |
| return value; |
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js| index c2188fb..a8cb40a 100644 |
| --- a/internal/infinity/static/app.js |
| +++ b/internal/infinity/static/app.js |
| @@ -1,22 +1,29 @@ |
| // ๐ค Entry point: no content is painted until account state has been decrypted. |
| -import { request, peerOrigin } from './api.js'; |
| +import { request, peerOrigin, setRelay, usingRelay } from './api.js'; |
| import { setupProgress } from './setup-progress.js'; |
| import { Client } from './client.js'; |
| import { Chat } from './chat.js'; |
| import { fragment, readIdentity, remember, finishSetup, welcome } from './onboarding.js'; |
| -import { unb64 } from './crypto.js'; |
| +import { unb64, b64 } from './crypto.js'; |
| import { el, message, button } from './dom.js'; |
| const root = document.querySelector('#app'); |
| async function ready(client) { |
| const params = fragment(); history.replaceState(null, '', '/'); |
| const chat = new Chat(root, client); await chat.start(params.invite); |
| - window.webkit?.messageHandlers?.infinity?.postMessage({ type: 'account', origin: client.id.origin }); |
| } |
| async function boot() { |
| const params = fragment(); |
| + const profile = await request(location.origin, '/api/profile'); |
| + setRelay(profile.registration === true); |
| + const savedIdentity = readIdentity(); |
| + if (params.migrate && savedIdentity?.origin === location.origin) { |
| + const home = peerOrigin((await request(location.origin, '/api/client-config')).homeOrigin); |
| + const transfer = b64(new TextEncoder().encode(JSON.stringify(savedIdentity))); |
| + location.replace(home + '/#restore=' + transfer + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')); return; |
| + } |
| if (params.restore) { |
| const identity = JSON.parse(new TextDecoder().decode(unb64(params.restore))); |
| - if (identity.origin !== location.origin) throw new Error('This account belongs to a different address.'); |
| + if (identity.origin !== location.origin && !usingRelay()) throw new Error('This account belongs to a different address.'); |
| const client = new Client(identity); await client.refresh(); remember(identity); return ready(client); |
| } |
| let progress; |
| @@ -27,7 +34,7 @@ async function boot() { |
| const setup = await finishSetup(params); |
| if (setup) { await progress.complete(); return ready(setup); } |
| const identity = readIdentity(); |
| - if (identity && identity.origin === location.origin) return ready(new Client(identity)); |
| + if (identity && (identity.origin === location.origin || usingRelay())) return ready(new Client(identity)); |
| // Invitations open on the inviter's origin. Move to the public front door or an existing account. |
| if (params.invite && location.pathname === '/connect') { |
| const home = localStorage.getItem('infinity.home'); |
diff --git a/internal/infinity/static/dialogs.js b/internal/infinity/static/dialogs.js| index c73958b..e0580e7 100644 |
| --- a/internal/infinity/static/dialogs.js |
| +++ b/internal/infinity/static/dialogs.js |
| @@ -1,7 +1,8 @@ |
| // ๐ค Secondary flows each have one job, with inline feedback. |
| import { el, button, field, message, modal, busy } from './dom.js'; |
| import { exportRecovery, safetyNumber } from './crypto.js'; |
| -import { parseInvite } from './api.js'; |
| +import { addPasskey } from './passkeys.js'; |
| +import { parseInvite, usingRelay } from './api.js'; |
| export function share(client) { |
| const body = el('div', {}, el('p', {}, 'Send a private invitation to someone you know. Each link works once and lasts seven days.')); |
| const dialog = modal('Invite someone', body); |
| @@ -31,6 +32,12 @@ export function recovery(client) { |
| const confirm = field('Repeat password', { type: 'password', minlength: 12, autocomplete: 'new-password', required: true }); |
| const note = el('div', {}), submit = el('button', { type: 'submit', class: 'primary' }, 'Save recovery file'); |
| const body = el('form', {}, el('p', {}, 'Keep this file and password somewhere safe. Together they open your account and messages on another device. We cannot reset them for you.'), password.node, confirm.node, note, submit); |
| + const passkey = button('Add a passkey', () => busy(passkey, async () => { |
| + try { await addPasskey(client.id); note.replaceChildren(message('Passkey saved. Use it to open Ocho and your messages on another device.')); } |
| + catch (error) { note.replaceChildren(message(error.message, true)); } |
| + }), { class: 'primary' }); |
| + if (usingRelay()) body.prepend(el('p', {}, 'Use the same passkey to sign in and unlock your messages on the web, iPhone and Mac.'), passkey); |
| + else body.prepend(el('p', {}, 'To add a passkey, bring this account to ocho.chat.'), button('Use Ocho at ocho.chat', () => location.assign('/#migrate=1'), { class: 'primary' })); |
| modal('Recovery & another device', body); |
| body.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => { |
| try { |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| index 288f36d..68bc387 100644 |
| --- a/internal/infinity/static/onboarding.js |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -1,9 +1,10 @@ |
| // ๐ค Link-first account setup; secrets travel only in URL fragments or encrypted files. |
| import { el, button, mark, field, message, busy } from './dom.js'; |
| import { createIdentity, importRecovery, b64, unb64, random } from './crypto.js'; |
| -import { request, peerOrigin } from './api.js'; |
| +import { request, peerOrigin, usingRelay } from './api.js'; |
| import { Client } from './client.js'; |
| import { registrationProof } from './registration.js'; |
| +import { signInPasskey } from './passkeys.js'; |
| import { setupProgress } from './setup-progress.js'; |
| export const storageKey = 'infinity.identity.v1'; |
| export function remember(id) { localStorage.setItem(storageKey, JSON.stringify(id)); } |
| @@ -16,7 +17,7 @@ export function fragment() { |
| export async function finishSetup(params) { |
| if (!params.setup) return null; |
| const data = JSON.parse(new TextDecoder().decode(unb64(params.setup))); |
| - if (data.identity.origin !== location.origin) throw new Error('Setup belongs to a different account.'); |
| + if (data.identity.origin !== location.origin && !usingRelay()) throw new Error('Setup belongs to a different account.'); |
| const client = new Client(data.identity); await client.setup(data.claim); remember(data.identity); |
| history.replaceState(null, '', '/' + (data.invite ? '#invite=' + encodeURIComponent(data.invite) : '')); |
| return client; |
| @@ -36,6 +37,13 @@ export function welcome(root, params, ready) { |
| try { |
| const displayName = name.input.value.trim(); if (!displayName) throw new Error('Enter your name.'); |
| const progress = setupProgress(root); |
| + const pending = sessionStorage.getItem('infinity.pending-setup'); |
| + if (pending) { |
| + const data = JSON.parse(pending), client = new Client(data.identity); |
| + progress.update('opening'); await client.setup(data.claim); remember(data.identity); |
| + sessionStorage.removeItem('infinity.pending-setup'); sessionStorage.removeItem('infinity.signup'); |
| + await progress.complete(); ready(client); return; |
| + } |
| if (params.claim) { |
| progress.update('opening'); |
| const id = await createIdentity(location.origin, displayName); const c = new Client(id); await c.setup(params.claim); remember(id); |
| @@ -57,8 +65,13 @@ export function welcome(root, params, ready) { |
| progress.update('opening'); |
| const origin = peerOrigin(result.origin), id = await createIdentity(origin, displayName); |
| localStorage.setItem('infinity.home', origin); |
| - const setup = b64(new TextEncoder().encode(JSON.stringify({ identity: id, claim: result.claim, invite: params.invite, progressStarted: progress.startedAt }))); |
| - location.assign(origin + '/#setup=' + setup); |
| + const client = new Client(id); |
| + // Keep interrupted setup on this origin so a retry never generates different keys. |
| + sessionStorage.setItem('infinity.pending-setup', JSON.stringify({ identity: id, claim: result.claim })); |
| + await client.setup(result.claim); remember(id); |
| + sessionStorage.removeItem('infinity.pending-setup'); sessionStorage.removeItem('infinity.signup'); |
| + await progress.complete(); |
| + ready(client); |
| } catch (error) { |
| root.replaceChildren(welcomeScreen); |
| note.replaceChildren(message(error.message, true)); |
| @@ -67,8 +80,15 @@ export function welcome(root, params, ready) { |
| }); |
| }); |
| const saved = localStorage.getItem('infinity.home'); |
| - if (saved) content.append(button('Open your account', () => location.assign(peerOrigin(saved) + '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')), { class: 'primary' })); |
| - content.append(form, button('I have a recovery file', () => restore(root, params, ready), { class: 'quiet' }), el('p', { class: 'footnote' }, 'Your messages are encrypted on your device. Save a recovery file after creating your account.')); |
| + if (saved) content.append(button(usingRelay() ? 'Bring your existing account to Ocho' : 'Open your account', () => location.assign(peerOrigin(saved) + (usingRelay() ? '/#migrate=1' : '/#') + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')), { class: 'primary' })); |
| + if (usingRelay()) { |
| + const signIn = button('Sign in with a passkey', () => busy(signIn, async () => { |
| + try { const id = await signInPasskey(), client = new Client(id); await client.refresh(); remember(id); ready(client); } |
| + catch (error) { note.replaceChildren(message(error.message, true)); } |
| + }), { class: 'primary' }); |
| + content.append(signIn); |
| + } |
| + content.append(form, button('I have a recovery file', () => restore(root, params, ready), { class: 'quiet' }), el('p', { class: 'footnote' }, 'Your messages are encrypted on your device. Add a passkey or save a recovery file after creating your account.')); |
| welcomeScreen = el('div', { class: 'welcome-wrap' }, content); |
| root.replaceChildren(welcomeScreen); |
| } |
| @@ -83,7 +103,7 @@ export function restore(root, params, ready) { |
| if (!file.input.files[0] || file.input.files[0].size > 100000) throw new Error('Choose your โ recovery file.'); |
| const id = await importRecovery(await file.input.files[0].text(), password.input.value); |
| peerOrigin(id.origin); |
| - if (id.origin !== location.origin) { |
| + if (id.origin !== location.origin && !usingRelay()) { |
| // Transfer stays in a fragment; neither serving origin receives the identity. |
| const transfer = b64(new TextEncoder().encode(JSON.stringify(id))); |
| location.assign(id.origin + '/#restore=' + transfer + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')); return; |
diff --git a/internal/infinity/static/passkeys.js b/internal/infinity/static/passkeys.js| new file mode 100644 |
| index 0000000..9385bfe |
| --- /dev/null |
| +++ b/internal/infinity/static/passkeys.js |
| @@ -0,0 +1,54 @@ |
| +// ๐ค PRF output never enters a request. It only wraps the client-held identity. |
| +import { request, usingRelay, peerOrigin } from './api.js'; |
| +import { b64, unb64, lock, unlock } from './crypto.js'; |
| +const te = new TextEncoder(); |
| +export async function passkeyWrappingKey(output, credentialID) { |
| + if (!output || output.byteLength !== 32) throw new Error('This passkey provider cannot unlock encrypted messages. Use a recovery file or a passkey provider that supports encryption.'); |
| + const key = await crypto.subtle.importKey('raw', output, 'HKDF', false, ['deriveBits']); |
| + return b64(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: te.encode('ocho-passkey-wrap-v1'), info: credentialID }, key, 256)); |
| +} |
| +function available() { |
| + if (!usingRelay()) throw new Error('Open your account at ocho.chat to use passkeys.'); |
| + if (!navigator.credentials || !globalThis.PublicKeyCredential) throw new Error('Passkeys are not available on this device. You can use a recovery file.'); |
| +} |
| +const post = (path, body, auth) => request(location.origin, '/api/passkeys/' + path, { method: 'POST', body, auth }); |
| +export async function addPasskey(identity) { |
| + available(); |
| + const options = await post('register/begin', { origin: identity.origin }, identity.auth); |
| + const credential = await navigator.credentials.create({ publicKey: { |
| + challenge: unb64(options.challenge), rp: { id: options.rpId, name: 'Ocho' }, |
| + user: { id: unb64(options.userHandle), name: identity.name, displayName: identity.name }, |
| + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], timeout: options.timeout, |
| + authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, attestation: 'none', |
| + extensions: { prf: { eval: { first: unb64(options.salt) } } } |
| + } }); |
| + if (!credential) throw new Error('Passkey setup was cancelled.'); |
| + let output = credential.getClientExtensionResults().prf?.results?.first; |
| + if (!output) { |
| + // Some providers support PRF only during an assertion, after creating the key. |
| + const assertion = await navigator.credentials.get({ publicKey: { |
| + challenge: unb64(options.challenge), rpId: options.rpId, timeout: options.timeout, |
| + userVerification: 'required', allowCredentials: [{ type: 'public-key', id: credential.rawId }], |
| + extensions: { prf: { eval: { first: unb64(options.salt) } } } |
| + } }); |
| + if (assertion && b64(assertion.rawId) === b64(credential.rawId)) output = assertion.getClientExtensionResults().prf?.results?.first; |
| + } |
| + const key = await passkeyWrappingKey(output, credential.rawId); |
| + const box = await lock(key, identity); |
| + await post('register/finish', { challenge: options.challenge, clientDataJSON: b64(credential.response.clientDataJSON), attestationObject: b64(credential.response.attestationObject), box }, identity.auth); |
| +} |
| +export async function signInPasskey() { |
| + available(); |
| + const options = await post('login/begin', {}); |
| + const credential = await navigator.credentials.get({ publicKey: { |
| + challenge: unb64(options.challenge), rpId: options.rpId, timeout: options.timeout, |
| + userVerification: 'required', extensions: { prf: { eval: { first: unb64(options.salt) } } } |
| + } }); |
| + if (!credential) throw new Error('Sign-in was cancelled.'); |
| + const key = await passkeyWrappingKey(credential.getClientExtensionResults().prf?.results?.first, credential.rawId); |
| + const response = credential.response; |
| + const result = await post('login/finish', { challenge: options.challenge, credentialId: b64(credential.rawId), userHandle: b64(response.userHandle || new Uint8Array()), clientDataJSON: b64(response.clientDataJSON), authenticatorData: b64(response.authenticatorData), signature: b64(response.signature) }); |
| + const identity = await unlock(key, result.box); |
| + if (identity.version !== 1 || identity.origin !== peerOrigin(result.origin) || !identity.privateEnc || !identity.privateSign) throw new Error('This passkey could not open your account.'); |
| + return identity; |
| +} |
diff --git a/internal/infinityhome/apple.go b/internal/infinityhome/apple.go| new file mode 100644 |
| index 0000000..ecc8652 |
| --- /dev/null |
| +++ b/internal/infinityhome/apple.go |
| @@ -0,0 +1,8 @@ |
| +// ๐ค Only Ocho's separately signed native apps may use the web's passkeys. |
| +package infinityhome |
| + |
| +import "net/http" |
| + |
| +func appleAssociation(w http.ResponseWriter, r *http.Request) { |
| + write(w, 200, map[string]any{"webcredentials": map[string]any{"apps": []string{"J5NDF5PCD8.chat.ocho.infinity", "J5NDF5PCD8.chat.ocho.infinity.mac"}}}) |
| +} |
diff --git a/internal/infinityhome/browser_test.go b/internal/infinityhome/browser_test.go| new file mode 100644 |
| index 0000000..b82603c |
| --- /dev/null |
| +++ b/internal/infinityhome/browser_test.go |
| @@ -0,0 +1,85 @@ |
| +// ๐ค In-process isolated fleet for real-browser signup and passkey tests only. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "lchat/internal/infinity" |
| + "log/slog" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| +) |
| + |
| +func TestHomeBrowserHarness(t *testing.T) { |
| + if os.Getenv("OCHO_HOME_BROWSER") != "1" { |
| + t.Skip("Run tools/infinity/check-home-browser") |
| + } |
| + pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| + dir := t.TempDir() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + var mu sync.Mutex |
| + tenants := map[string]http.Handler{} |
| + var client *http.Client |
| + client = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Host == "console.example" { |
| + var p struct { |
| + Host string `json:"host"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&p) != nil { |
| + return nil, fmt.Errorf("bad provision") |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if tenants[p.Host] == nil { |
| + d, e := db.Open(filepath.Join(dir, p.Host+".db"), logger) |
| + if e != nil { |
| + return nil, e |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + a, e := infinity.New(d, infinity.Config{SetupPublicKey: pub, Client: client, HomeOrigin: "http://localhost:18860"}) |
| + if e != nil { |
| + return nil, e |
| + } |
| + tenants[p.Host] = a.Handler() |
| + } |
| + w := httptest.NewRecorder() |
| + write(w, 201, map[string]bool{"ok": true}) |
| + return w.Result(), nil |
| + } |
| + mu.Lock() |
| + handler := tenants[r.URL.Host] |
| + mu.Unlock() |
| + if handler == nil { |
| + return nil, fmt.Errorf("unknown tenant") |
| + } |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + return w.Result(), nil |
| + })} |
| + d, e := db.Open(filepath.Join(dir, "home.db"), logger) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer d.Close() |
| + h, e := New(d, Config{Origin: "http://localhost:18860", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "example.test", Token: "test-token", ClaimKey: key, Client: client}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + listener, e := net.Listen("tcp", "127.0.0.1:18860") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer listener.Close() |
| + if e = http.Serve(listener, h.Handler()); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go| index 1eb3af6..9076f80 100644 |
| --- a/internal/infinityhome/home.go |
| +++ b/internal/infinityhome/home.go |
| @@ -72,6 +72,11 @@ func New(d *db.DB, cfg Config) (*Home, error) { |
| } |
| func (h *Home) Handler() *http.ServeMux { |
| m := infinity.WebHandler() |
| + h.passkeyRoutes(m) |
| + m.HandleFunc("GET /.well-known/apple-app-site-association", appleAssociation) |
| + for _, method := range []string{"GET", "POST", "PUT"} { |
| + m.Handle(method+" /api/tenant/{host}/{path...}", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.relay))) |
| + } |
| assets := rastrillo.NewAssets(pow.Assets()) |
| m.Handle("GET /pow/", http.StripPrefix("/pow/", assets.Handler())) |
| m.HandleFunc("GET /api/registration-challenge", func(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/infinityhome/migrations/0003_passkeys.sql b/internal/infinityhome/migrations/0003_passkeys.sql| new file mode 100644 |
| index 0000000..49a22a8 |
| --- /dev/null |
| +++ b/internal/infinityhome/migrations/0003_passkeys.sql |
| @@ -0,0 +1,20 @@ |
| +-- ๐ค Discoverable public credentials and sealed identities; no usable private keys. |
| +CREATE TABLE ocho_passkeys ( |
| + credential_id TEXT PRIMARY KEY, |
| + user_handle TEXT NOT NULL, |
| + host TEXT NOT NULL, |
| + public_key BLOB NOT NULL, |
| + sign_count INTEGER NOT NULL DEFAULT 0, |
| + box TEXT NOT NULL, |
| + created INTEGER NOT NULL |
| +); |
| +CREATE INDEX ocho_passkeys_host ON ocho_passkeys(host); |
| +CREATE TABLE ocho_passkey_challenges ( |
| + challenge TEXT PRIMARY KEY, |
| + operation TEXT NOT NULL, |
| + host TEXT NOT NULL DEFAULT '', |
| + user_handle TEXT NOT NULL DEFAULT '', |
| + owner_hash TEXT NOT NULL DEFAULT '', |
| + expires INTEGER NOT NULL |
| +); |
| +CREATE INDEX ocho_passkey_challenges_expiry ON ocho_passkey_challenges(expires); |
diff --git a/internal/infinityhome/passkeys.go b/internal/infinityhome/passkeys.go| new file mode 100644 |
| index 0000000..764c0fa |
| --- /dev/null |
| +++ b/internal/infinityhome/passkeys.go |
| @@ -0,0 +1,225 @@ |
| +// ๐ค Passkeys authenticate; their PRF opens sealed identities only on the client. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/webauthn" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const prfSalt = "ocho-passkey-identity-v1" |
| + |
| +func encoded(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| +func decoded(s string) []byte { b, _ := base64.RawURLEncoding.DecodeString(s); return b } |
| +func randomID() string { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + panic(err) |
| + } |
| + return encoded(b) |
| +} |
| +func ownerHash(r *http.Request) string { |
| + sum := sha256.Sum256([]byte(r.Header.Get("Authorization"))) |
| + return encoded(sum[:]) |
| +} |
| +func (h *Home) webauthnConfig() webauthn.Config { |
| + u, _ := url.Parse(h.cfg.Origin) |
| + return webauthn.Config{RPID: u.Hostname(), Origin: h.cfg.Origin} |
| +} |
| +func decodePasskey(w http.ResponseWriter, r *http.Request, v any) bool { |
| + r.Body = http.MaxBytesReader(w, r.Body, 128<<10) |
| + d := json.NewDecoder(r.Body) |
| + d.DisallowUnknownFields() |
| + if d.Decode(v) != nil { |
| + failure(w, 400, "Invalid passkey request.") |
| + return false |
| + } |
| + return true |
| +} |
| +func (h *Home) passkeyRoutes(m *http.ServeMux) { |
| + routes := map[string]http.HandlerFunc{ |
| + "POST /api/passkeys/register/begin": h.passkeyRegisterBegin, |
| + "POST /api/passkeys/register/finish": h.passkeyRegisterFinish, |
| + "POST /api/passkeys/login/begin": h.passkeyLoginBegin, |
| + "POST /api/passkeys/login/finish": h.passkeyLoginFinish, |
| + } |
| + for path, handler := range routes { |
| + m.Handle(path, csrf.Protect(h.cfg.Origin)(handler)) |
| + } |
| +} |
| +func (h *Home) ownedHost(r *http.Request, origin string) (string, bool) { |
| + u, e := url.Parse(origin) |
| + if e != nil || u.Scheme != "https" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Port() != "" { |
| + return "", false |
| + } |
| + host := u.Host |
| + label := strings.TrimSuffix(host, "."+h.cfg.Domain) |
| + if !tenantLabel.MatchString(label) || host != label+"."+h.cfg.Domain { |
| + return "", false |
| + } |
| + var found string |
| + if h.db.QueryRowContext(r.Context(), "SELECT host FROM registrations WHERE host=? AND provisioned=1", host).Scan(&found) != nil { |
| + return "", false |
| + } |
| + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { |
| + return "", false |
| + } |
| + req, e := http.NewRequestWithContext(r.Context(), "GET", origin+"/api/vault", nil) |
| + if e != nil { |
| + return "", false |
| + } |
| + req.Header.Set("Authorization", r.Header.Get("Authorization")) |
| + client := *h.client |
| + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } |
| + res, e := client.Do(req) |
| + if e != nil { |
| + return "", false |
| + } |
| + defer res.Body.Close() |
| + _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 4096)) |
| + return host, res.StatusCode == 200 |
| +} |
| +func (h *Home) issue(w http.ResponseWriter, r *http.Request, operation, host, owner string) { |
| + now := time.Now().Unix() |
| + _, _ = h.db.ExecContext(r.Context(), "DELETE FROM ocho_passkey_challenges WHERE expires<?", now) |
| + // Bound unauthenticated persistent work, including a short lifetime and global cap. |
| + var count int |
| + if h.db.QueryRowContext(r.Context(), "SELECT count(*) FROM ocho_passkey_challenges").Scan(&count) != nil || count >= 2000 { |
| + failure(w, 429, "Please try signing in again shortly.") |
| + return |
| + } |
| + challenge, user := randomID(), randomID() |
| + _, e := h.db.ExecContext(r.Context(), "INSERT INTO ocho_passkey_challenges(challenge,operation,host,user_handle,owner_hash,expires) VALUES(?,?,?,?,?,?)", challenge, operation, host, user, owner, now+120) |
| + if e != nil { |
| + failure(w, 503, "Passkeys are temporarily unavailable.") |
| + return |
| + } |
| + write(w, 200, map[string]any{"challenge": challenge, "userHandle": user, "rpId": h.webauthnConfig().RPID, "salt": encoded([]byte(prfSalt)), "timeout": 120000}) |
| +} |
| +func (h *Home) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Origin string `json:"origin"` |
| + } |
| + if !decodePasskey(w, r, &p) { |
| + return |
| + } |
| + host, ok := h.ownedHost(r, p.Origin) |
| + if !ok { |
| + failure(w, 403, "Open your existing account before adding a passkey.") |
| + return |
| + } |
| + var count int |
| + if h.db.QueryRowContext(r.Context(), "SELECT count(*) FROM ocho_passkeys WHERE host=?", host).Scan(&count) != nil || count >= 16 { |
| + failure(w, 409, "This account already has the maximum number of passkeys.") |
| + return |
| + } |
| + h.issue(w, r, "register", host, ownerHash(r)) |
| +} |
| +func (h *Home) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) { |
| + h.issue(w, r, "login", "", "") |
| +} |
| + |
| +type ceremony struct{ Host, User, Owner string } |
| + |
| +func (h *Home) consume(r *http.Request, challenge, operation string) (ceremony, bool) { |
| + var c ceremony |
| + // DELETE RETURNING atomically consumes the challenge, including on failure. |
| + e := h.db.QueryRowContext(r.Context(), "DELETE FROM ocho_passkey_challenges WHERE challenge=? AND operation=? AND expires>=? RETURNING host,user_handle,owner_hash", challenge, operation, time.Now().Unix()).Scan(&c.Host, &c.User, &c.Owner) |
| + return c, e == nil |
| +} |
| +func sameOriginCeremony(data string) bool { |
| + var c struct { |
| + CrossOrigin bool `json:"crossOrigin"` |
| + TopOrigin string `json:"topOrigin"` |
| + } |
| + return json.Unmarshal(decoded(data), &c) == nil && !c.CrossOrigin && c.TopOrigin == "" |
| +} |
| +func (h *Home) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Challenge string `json:"challenge"` |
| + ClientData string `json:"clientDataJSON"` |
| + Attestation string `json:"attestationObject"` |
| + Box string `json:"box"` |
| + } |
| + if !decodePasskey(w, r, &p) { |
| + return |
| + } |
| + if !sameOriginCeremony(p.ClientData) { |
| + failure(w, 403, "Use Ocho directly to set up a passkey.") |
| + return |
| + } |
| + c, ok := h.consume(r, p.Challenge, "register") |
| + if !ok || c.Owner != ownerHash(r) { |
| + failure(w, 403, "Passkey setup expired. Please try again.") |
| + return |
| + } |
| + if len(p.Box) < 40 || len(p.Box) > 100000 { |
| + failure(w, 400, "Invalid encrypted account.") |
| + return |
| + } |
| + cred, e := h.webauthnConfig().Register(decoded(p.Challenge), decoded(p.ClientData), decoded(p.Attestation)) |
| + if e != nil { |
| + failure(w, 403, "Could not verify this passkey.") |
| + return |
| + } |
| + _, e = h.db.ExecContext(r.Context(), "INSERT INTO ocho_passkeys(credential_id,user_handle,host,public_key,sign_count,box,created) VALUES(?,?,?,?,?,?,?)", encoded(cred.ID), c.User, c.Host, cred.PublicKey, cred.SignCount, p.Box, time.Now().Unix()) |
| + if e != nil { |
| + failure(w, 409, "Could not save this passkey. Please try again.") |
| + return |
| + } |
| + write(w, 201, map[string]bool{"ok": true}) |
| +} |
| +func (h *Home) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Challenge string `json:"challenge"` |
| + ID string `json:"credentialId"` |
| + User string `json:"userHandle"` |
| + ClientData string `json:"clientDataJSON"` |
| + AuthData string `json:"authenticatorData"` |
| + Signature string `json:"signature"` |
| + } |
| + if !decodePasskey(w, r, &p) { |
| + return |
| + } |
| + if !sameOriginCeremony(p.ClientData) { |
| + failure(w, 403, "Use Ocho directly to sign in.") |
| + return |
| + } |
| + _, ok := h.consume(r, p.Challenge, "login") |
| + if !ok { |
| + failure(w, 403, "Sign-in expired. Please try again.") |
| + return |
| + } |
| + var host, user, box string |
| + var key []byte |
| + var count uint32 |
| + e := h.db.QueryRowContext(r.Context(), "SELECT host,user_handle,public_key,sign_count,box FROM ocho_passkeys WHERE credential_id=?", p.ID).Scan(&host, &user, &key, &count, &box) |
| + if e != nil || user != p.User { |
| + failure(w, 403, "Could not verify this passkey.") |
| + return |
| + } |
| + next, e := h.webauthnConfig().Verify(webauthn.Credential{ID: decoded(p.ID), PublicKey: key, SignCount: count}, decoded(p.Challenge), decoded(p.ClientData), decoded(p.AuthData), decoded(p.Signature)) |
| + if e != nil { |
| + failure(w, 403, "Could not verify this passkey.") |
| + return |
| + } |
| + res, e := h.db.ExecContext(r.Context(), "UPDATE ocho_passkeys SET sign_count=? WHERE credential_id=? AND sign_count=?", next, p.ID, count) |
| + if e != nil { |
| + failure(w, 503, "Please try signing in again.") |
| + return |
| + } |
| + n, _ := res.RowsAffected() |
| + if n != 1 { |
| + failure(w, 409, "Please try signing in again.") |
| + return |
| + } |
| + write(w, 200, map[string]string{"box": box, "origin": "https://" + host}) |
| +} |
diff --git a/internal/infinityhome/passkeys_test.go b/internal/infinityhome/passkeys_test.go| new file mode 100644 |
| index 0000000..ea3b553 |
| --- /dev/null |
| +++ b/internal/infinityhome/passkeys_test.go |
| @@ -0,0 +1,114 @@ |
| +// ๐ค Real signed ceremonies exercise ownership, origin, replay and encrypted storage. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/webauthn/authtest" |
| + "bytes" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestPasskeyCeremonies(t *testing.T) { |
| + _, key, _ := ed25519.GenerateKey(rand.Reader) |
| + path := filepath.Join(t.TempDir(), "home.db") |
| + d, e := db.Open(path, slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer d.Close() |
| + host := "p-0123456789abcdef01234567.bdy.oncarlos.com" |
| + h, e := New(d, Config{Origin: "https://ocho.chat", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Token: "scoped", ClaimKey: key, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| + status := 403 |
| + if r.URL.Host == host && r.Header.Get("Authorization") == "Bearer owner" { |
| + status = 200 |
| + } |
| + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil |
| + })}}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + _, e = h.db.Exec("INSERT INTO registrations(nonce_hash,host,created,provisioned) VALUES('test',?,1,1)", host) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + handler := h.Handler() |
| + post := func(path string, body any, auth string, want int) map[string]string { |
| + t.Helper() |
| + b, _ := json.Marshal(body) |
| + r := httptest.NewRequest("POST", "https://ocho.chat/api/passkeys/"+path, bytes.NewReader(b)) |
| + r.Header.Set("Origin", "https://ocho.chat") |
| + if auth != "" { |
| + r.Header.Set("Authorization", "Bearer "+auth) |
| + } |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + if w.Code != want { |
| + t.Fatalf("%s: got %d want %d: %s", path, w.Code, want, w.Body.String()) |
| + } |
| + var out map[string]json.RawMessage |
| + _ = json.Unmarshal(w.Body.Bytes(), &out) |
| + values := map[string]string{} |
| + for k, v := range out { |
| + var s string |
| + if json.Unmarshal(v, &s) == nil { |
| + values[k] = s |
| + } |
| + } |
| + return values |
| + } |
| + post("register/begin", map[string]string{"origin": "https://" + host}, "wrong", 403) |
| + post("register/begin", map[string]string{"origin": "https://localhost"}, "owner", 403) |
| + begin := post("register/begin", map[string]string{"origin": "https://" + host}, "owner", 200) |
| + authenticator, _ := authtest.New() |
| + options := authtest.Options{RPID: "ocho.chat", Origin: "https://ocho.chat"} |
| + cd, att := authenticator.Create(decoded(begin["challenge"]), options) |
| + box := strings.Repeat("sealed-ciphertext-", 8) |
| + registration := map[string]string{"challenge": begin["challenge"], "clientDataJSON": encoded(cd), "attestationObject": encoded(att), "box": box} |
| + post("register/finish", registration, "owner", 201) |
| + post("register/finish", registration, "owner", 403) |
| + sign := func(opt authtest.Options, user string, want int) map[string]string { |
| + t.Helper() |
| + login := post("login/begin", map[string]string{}, "", 200) |
| + cd, ad, sig, e := authenticator.Get(decoded(login["challenge"]), opt) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + body := map[string]string{"challenge": login["challenge"], "credentialId": encoded(authenticator.CredID), "userHandle": user, "clientDataJSON": encoded(cd), "authenticatorData": encoded(ad), "signature": encoded(sig)} |
| + got := post("login/finish", body, "", want) |
| + post("login/finish", body, "", 403) |
| + return got |
| + } |
| + opened := sign(options, begin["userHandle"], 200) |
| + if opened["box"] != box || opened["origin"] != "https://"+host { |
| + t.Fatal("wrong sealed account") |
| + } |
| + bad := options |
| + bad.Origin = "https://evil.test" |
| + sign(bad, begin["userHandle"], 403) |
| + bad = options |
| + bad.RPID = "evil.test" |
| + sign(bad, begin["userHandle"], 403) |
| + bad = options |
| + bad.Flags = authtest.FlagUserPresent |
| + sign(bad, begin["userHandle"], 403) |
| + bad = options |
| + bad.CorruptSig = true |
| + sign(bad, begin["userHandle"], 403) |
| + sign(options, "wrong-user", 403) |
| + begin = post("register/begin", map[string]string{"origin": "https://" + host}, "owner", 200) |
| + registration["challenge"] = begin["challenge"] |
| + post("register/finish", registration, "wrong", 403) |
| + var count int |
| + if h.db.QueryRow("SELECT count(*) FROM ocho_passkeys").Scan(&count) != nil || count != 1 { |
| + t.Fatal("unexpected credentials") |
| + } |
| +} |
diff --git a/internal/infinityhome/relay.go b/internal/infinityhome/relay.go| new file mode 100644 |
| index 0000000..0b7f64d |
| --- /dev/null |
| +++ b/internal/infinityhome/relay.go |
| @@ -0,0 +1,80 @@ |
| +// ๐ค The stable web origin relays only known tenants' bounded JSON APIs. |
| +package infinityhome |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +var tenantLabel = regexp.MustCompile(`^p-[0-9a-f]{24}$`) |
| +var relayRoutes = map[string]bool{ |
| + "GET /api/profile": true, "POST /api/setup": true, |
| + "GET /api/vault": true, "PUT /api/vault": true, |
| + "POST /api/capabilities": true, "GET /api/events": true, |
| + "GET /api/delivery": true, "POST /api/retry": true, "POST /api/send": true, |
| +} |
| + |
| +func (h *Home) relay(w http.ResponseWriter, r *http.Request) { |
| + host, path := r.PathValue("host"), "/api/"+r.PathValue("path") |
| + label := strings.TrimSuffix(host, "."+h.cfg.Domain) |
| + if !tenantLabel.MatchString(label) || host != label+"."+h.cfg.Domain || !relayRoutes[r.Method+" "+path] { |
| + failure(w, 404, "Account route not found.") |
| + return |
| + } |
| + var found string |
| + if h.db.QueryRowContext(r.Context(), "SELECT host FROM registrations WHERE host=? AND provisioned=1", host).Scan(&found) != nil { |
| + failure(w, 404, "Account not found.") |
| + return |
| + } |
| + if path != "/api/setup" && path != "/api/profile" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { |
| + failure(w, 401, "Open your account first.") |
| + return |
| + } |
| + // A browser request must originate at this front door. Native clients use |
| + // personal APIs directly. Never accept arbitrary upstreams or follow redirects. |
| + if origin := r.Header.Get("Origin"); origin != "" && origin != h.cfg.Origin { |
| + failure(w, 403, "Invalid request origin.") |
| + return |
| + } |
| + query := "" |
| + if path == "/api/events" { |
| + query = "?" + r.URL.Query().Encode() |
| + } else if r.URL.RawQuery != "" { |
| + failure(w, 400, "Unexpected account parameters.") |
| + return |
| + } |
| + body := http.MaxBytesReader(w, r.Body, 2<<20) |
| + req, err := http.NewRequestWithContext(r.Context(), r.Method, "https://"+host+path+query, body) |
| + if err != nil { |
| + failure(w, 400, "Invalid account request.") |
| + return |
| + } |
| + req.Header.Set("Origin", "https://"+host) |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Authorization", r.Header.Get("Authorization")) |
| + // This copy also constrains injected/custom clients used by callers. |
| + client := *h.client |
| + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } |
| + response, err := client.Do(req) |
| + if err != nil { |
| + failure(w, 502, "Your account is temporarily unavailable. Please try again.") |
| + return |
| + } |
| + defer response.Body.Close() |
| + if response.StatusCode >= 300 && response.StatusCode < 400 || !strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") { |
| + failure(w, 502, "Your account returned an unexpected response.") |
| + return |
| + } |
| + data, err := io.ReadAll(io.LimitReader(response.Body, (16<<20)+1)) |
| + if err != nil || len(data) > 16<<20 { |
| + failure(w, 502, "Your account response is unavailable.") |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| + w.WriteHeader(response.StatusCode) |
| + _, _ = w.Write(data) |
| +} |
diff --git a/internal/infinityhome/relay_test.go b/internal/infinityhome/relay_test.go| new file mode 100644 |
| index 0000000..3d383a6 |
| --- /dev/null |
| +++ b/internal/infinityhome/relay_test.go |
| @@ -0,0 +1,84 @@ |
| +// ๐ค The front door is a tenant relay, never an open proxy or an authentication bypass. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestTenantRelayBoundaries(t *testing.T) { |
| + _, key, _ := ed25519.GenerateKey(rand.Reader) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "home.db"), slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + host := "p-0123456789abcdef01234567.bdy.oncarlos.com" |
| + calls, status := 0, 200 |
| + h, err := New(d, Config{Origin: "https://ocho.chat", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Token: "scoped", ClaimKey: key, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| + calls++ |
| + if r.URL.Host != host || r.Header.Get("Origin") != "https://"+host || r.Header.Get("Cookie") != "" { |
| + t.Error("relay leaked or changed destination") |
| + } |
| + if r.Header.Get("Authorization") != "Bearer owner" { |
| + t.Error("owner bearer not preserved") |
| + } |
| + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(`{"revision":1,"box":"ciphertext"}`)), Header: http.Header{"Content-Type": []string{"application/json"}, "Location": []string{"https://evil.test"}, "Set-Cookie": []string{"leak=yes"}}}, nil |
| + })}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = h.db.Exec("INSERT INTO registrations(nonce_hash,host,created,provisioned) VALUES('test',?,1,1)", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + handler := h.Handler() |
| + for _, tc := range []struct { |
| + host, path, method, auth, origin string |
| + want int |
| + }{ |
| + {host, "vault", "GET", "owner", "https://ocho.chat", 200}, |
| + {host, "vault", "GET", "", "https://ocho.chat", 401}, |
| + {host, "vault", "PUT", "owner", "https://evil.test", 403}, |
| + {"localhost", "vault", "GET", "owner", "https://ocho.chat", 404}, |
| + {"p-aaaaaaaaaaaaaaaaaaaaaaaa.bdy.oncarlos.com", "vault", "GET", "owner", "https://ocho.chat", 404}, |
| + {host, "inbox", "POST", "owner", "https://ocho.chat", 404}, |
| + {host, "vault?url=https://evil.test", "GET", "owner", "https://ocho.chat", 400}, |
| + } { |
| + t.Run(tc.host+tc.method+tc.auth+tc.origin+tc.path, func(t *testing.T) { |
| + r := httptest.NewRequest(tc.method, "https://ocho.chat/api/tenant/"+tc.host+"/"+tc.path, nil) |
| + r.Header.Set("Origin", tc.origin) |
| + r.Header.Set("Cookie", "secret=yes") |
| + if tc.auth != "" { |
| + r.Header.Set("Authorization", "Bearer "+tc.auth) |
| + } |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + if w.Code != tc.want { |
| + t.Fatalf("got %d want %d: %s", w.Code, tc.want, w.Body.String()) |
| + } |
| + if w.Header().Get("Set-Cookie") != "" || w.Header().Get("Location") != "" { |
| + t.Fatal("upstream headers escaped") |
| + } |
| + }) |
| + } |
| + if calls != 1 { |
| + t.Fatalf("unexpected upstream requests: %d", calls) |
| + } |
| + status = 302 |
| + r := httptest.NewRequest("GET", "https://ocho.chat/api/tenant/"+host+"/vault", nil) |
| + r.Header.Set("Authorization", "Bearer owner") |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + if w.Code != 502 || calls != 2 { |
| + t.Fatal("redirect followed or exposed") |
| + } |
| +} |
diff --git a/test/infinity/home-browser.mjs b/test/infinity/home-browser.mjs| new file mode 100644 |
| index 0000000..3e16e66 |
| --- /dev/null |
| +++ b/test/infinity/home-browser.mjs |
| @@ -0,0 +1,37 @@ |
| +// ๐ค Real front-door signup and passkey/PRF sign-in with an isolated virtual authenticator. |
| +import {createRequire} from 'node:module'; |
| +import {spawn} from 'node:child_process'; |
| +import assert from 'node:assert/strict'; |
| +const require=createRequire(new URL('./package.json',import.meta.url)); |
| +const {chromium,webkit}=require('playwright'); |
| +const home='http://localhost:18860'; |
| +const server=spawn('.build/native-check/home-test',['-test.run','^TestHomeBrowserHarness$','-test.timeout','5m'],{env:{...process.env,OCHO_HOME_BROWSER:'1'},stdio:['ignore','pipe','pipe']}); |
| +let browser; |
| +try { |
| + for(let n=0;;n++){try{if((await fetch(home+'/api/profile')).ok)break;}catch{}if(n>100)throw Error('home harness failed');await new Promise(r=>setTimeout(r,100));} |
| + browser=await chromium.launch(); |
| + const context=await browser.newContext(), page=await context.newPage(); |
| + const errors=[];page.on('pageerror',e=>errors.push(e.message)); |
| + const cdp=await context.newCDPSession(page);await cdp.send('WebAuthn.enable'); |
| + const {authenticatorId}=await cdp.send('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',ctap2Version:'ctap2_1',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true,hasPrf:true}}); |
| + await page.goto(home);await page.getByLabel('Your name',{exact:true}).fill('Native migration canary'); |
| + await page.getByRole('button',{name:'Create your account',exact:true}).click(); |
| + await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000}); |
| + assert.equal(new URL(page.url()).origin,home); |
| + const identity=await page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1'))); |
| + assert.ok(identity.origin.endsWith('.example.test')); |
| + await page.locator('.account-menu summary').click(); |
| + await page.getByRole('button',{name:'Recovery & another device',exact:true}).click(); |
| + await page.getByRole('button',{name:'Add a passkey',exact:true}).click(); |
| + try { await page.getByText('Passkey saved.',{exact:false}).waitFor({timeout:10000}); } catch { throw Error(await page.getByRole('dialog').innerText()); } |
| + const {credentials}=await cdp.send('WebAuthn.getCredentials',{authenticatorId});assert.equal(credentials.length,1); |
| + await page.evaluate(()=>localStorage.clear());await page.reload(); |
| + await page.getByRole('button',{name:'Sign in with a passkey',exact:true}).click(); |
| + await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:30000}); |
| + assert.equal(new URL(page.url()).origin,home); |
| + const restored=await page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1'))); |
| + assert.equal(restored.enc,identity.enc);assert.equal(restored.vaultKey,identity.vaultKey); |
| + await page.reload();await page.getByRole('button',{name:'Invite',exact:true}).waitFor();assert.equal(new URL(page.url()).origin,home); |
| + assert.deepEqual(errors,[]); |
| + console.log('Chromium: stable-origin signup, PRF enrollment, discoverable sign-in, same identity and signed-in reload passed.'); |
| +} finally {if(browser)await browser.close();server.kill('SIGTERM');} |
diff --git a/tools/infinity/check-home-browser b/tools/infinity/check-home-browser| new file mode 100755 |
| index 0000000..8cde3be |
| --- /dev/null |
| +++ b/tools/infinity/check-home-browser |
| @@ -0,0 +1,7 @@ |
| +#!/bin/sh |
| +# ๐ค Isolated home/tenant harness; no production accounts or real passkeys. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +mkdir -p .build/native-check |
| +CGO_ENABLED=0 go test -c -o .build/native-check/home-test ./internal/infinityhome |
| +node test/infinity/home-browser.mjs |
diff --git a/tools/infinity/check-native b/tools/infinity/check-native| new file mode 100755 |
| index 0000000..eac6e51 |
| --- /dev/null |
| +++ b/tools/infinity/check-native |
| @@ -0,0 +1,12 @@ |
| +#!/bin/sh |
| +# ๐ค Native protocol, browser compatibility and real personal-process integration. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +mkdir -p .build/native-check |
| +CGO_ENABLED=0 go build -o .build/native-check/infinity ./cmd/infinity |
| +export OCHO_INTEGRATION_BINARY="$PWD/.build/native-check/infinity" |
| +tools/infinity/check-native-crypto |
| +if rg -n 'import WebKit|WKWebView|JSContext|JavaScriptCore' apple/Infinity apple/OchoCore/Sources; then |
| + echo 'Native Ocho must not load a WebView or JavaScript runtime.' >&2 |
| + exit 1 |
| +fi |
diff --git a/tools/infinity/check-native-crypto b/tools/infinity/check-native-crypto| new file mode 100755 |
| index 0000000..9cc0113 |
| --- /dev/null |
| +++ b/tools/infinity/check-native-crypto |
| @@ -0,0 +1,30 @@ |
| +#!/bin/sh |
| +# ๐ค Bidirectional crypto compatibility; fixture identities are synthetic and temporary. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +fixture_dir=$(mktemp -d) |
| +trap 'rm -rf "$fixture_dir"' EXIT |
| +export OCHO_INTEROP_FIXTURE="$fixture_dir/fixture.json" |
| +node --input-type=module <<'JS' |
| +import {writeFileSync} from 'node:fs'; |
| +import {createIdentity, seal, lock, exportRecovery} from './internal/infinity/static/crypto.js'; |
| +const {passkeyWrappingKey} = await import('./internal/infinity/static/passkeys.js'); |
| +const prfOutput = new Uint8Array(32).fill(17), credentialID = new Uint8Array(32).fill(23); |
| +const prfKey = await passkeyWrappingKey(prfOutput, credentialID); |
| +const sender = await createIdentity('https://sender.example','Sender'); |
| +const recipient = await createIdentity('https://recipient.example','Recipient'); |
| +const envelope = await seal(sender, recipient, {kind:'message',thread:'interop',id:'interop',text:'Web to Swift โ',at:42,epoch:0}); |
| +writeFileSync(process.env.OCHO_INTEROP_FIXTURE, JSON.stringify({sender,recipient,envelope,prfKey,vault:await lock(recipient.vaultKey,{name:'Browser',contacts:{},invites:{},accepted:{},blocked:{}}),recovery:await exportRecovery(recipient,'interop recovery password')})); |
| +JS |
| +swift test --package-path apple/OchoCore |
| +node --input-type=module <<'JS' |
| +import {readFileSync} from 'node:fs'; |
| +import assert from 'node:assert/strict'; |
| +import {open,unlock,importRecovery} from './internal/infinity/static/crypto.js'; |
| +const fixture = JSON.parse(readFileSync(process.env.OCHO_INTEROP_FIXTURE)); |
| +const reply = JSON.parse(readFileSync(process.env.OCHO_INTEROP_FIXTURE+'.reply')); |
| +assert.equal((await open(fixture.sender,reply.envelope)).text,'Swift to Web โ'); |
| +assert.equal((await unlock(fixture.sender.vaultKey,reply.vault)).name,'Native'); |
| +assert.equal((await importRecovery(reply.recovery,'interop recovery password')).enc,fixture.recipient.enc); |
| +console.log('Native โ Web Crypto envelopes, vaults and recovery passed.'); |
| +JS |
diff --git a/tools/infinity/check-native-ui b/tools/infinity/check-native-ui| new file mode 100755 |
| index 0000000..aae89de |
| --- /dev/null |
| +++ b/tools/infinity/check-native-ui |
| @@ -0,0 +1,15 @@ |
| +#!/bin/sh |
| +# ๐ค Two disposable accounts drive the actual iPhone SwiftUI app. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +mkdir -p .build/native-check |
| +CGO_ENABLED=0 go build -o .build/native-check/infinity ./cmd/infinity |
| +native_ui_dir=$(mktemp -d) |
| +first_pid= second_pid= |
| +trap 'kill ${first_pid:-} ${second_pid:-} 2>/dev/null || true; rm -rf "$native_ui_dir"' EXIT |
| +INFINITY_SETUP_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa INFINITY_ALLOW_LOCAL=1 .build/native-check/infinity --addr 127.0.0.1:18871 --db "$native_ui_dir/a.db" > "$native_ui_dir/a.log" 2>&1 & |
| +first_pid=$! |
| +INFINITY_SETUP_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa INFINITY_ALLOW_LOCAL=1 .build/native-check/infinity --addr 127.0.0.1:18872 --db "$native_ui_dir/b.db" > "$native_ui_dir/b.log" 2>&1 & |
| +second_pid=$! |
| +xcodegen generate --spec apple/project.yml |
| +xcodebuild -project apple/Infinity.xcodeproj -scheme Infinity -destination "platform=iOS Simulator,id=${OCHO_SIMULATOR_ID:-288849AA-5B20-4177-A1E2-2AC635FB4CC0}" -derivedDataPath apple/build-native-ios CODE_SIGNING_ALLOWED=NO test |