Share the native Ocho engine through Go mobile bindings
Keep SwiftUI and native passkey prompts, move protocol/crypto/networking to one Go core, preserve existing wrapper identities without WebKit, and verify actual native messaging through the bindings.
Co-Authored-By: Codex <codex@openai.com>
41 files changed,
+1801
−325
.gitignore+2 −0apple/Infinity/AccountSheets.swift+3 −1apple/Infinity/AccountStore.swift+8 −0apple/Infinity/SetupProgressView.swift+29 −0apple/Infinity/WelcomeView.swift+1 −3apple/InfinityUITests/NativeChatTests.swift+2 −2apple/OchoCore/Package.swift+1 −1apple/OchoCore/Sources/OchoCore/API.swift+8 −39apple/OchoCore/Sources/OchoCore/Client.swift+17 −110apple/OchoCore/Sources/OchoCore/Crypto.swift+19 −76apple/OchoCore/Sources/OchoCore/Engine.swift+11 −0apple/OchoCore/Sources/OchoCore/LegacyIdentity.swift+42 −0apple/OchoCore/Sources/OchoCore/Models.swift+8 −39apple/OchoCore/Sources/OchoCore/Registration.swift+11 −42apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift+1 −1apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift+2 −2apple/OchoCore/Tests/OchoCoreTests/LegacyTests.swift+24 −0apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift+4 −4docs/infinity/android-handoff.md+25 −0docs/infinity/native-passkeys.md+5 −1docs/infinity/status.md+6 −0internal/infinity/static/api.js+3 −0internal/infinity/static/client.js+3 −2mobile/client.go+195 −0mobile/conversations.go+205 −0mobile/crypto.go+187 −0mobile/crypto_test.go+124 −0mobile/go.mod+15 −0mobile/go.sum+14 −0mobile/identity.go+126 −0mobile/invite.go+72 −0mobile/network.go+92 −0mobile/projection.go+265 −0mobile/recovery.go+117 −0mobile/registration.go+103 −0test/infinity/home-browser.mjs+26 −1tools/infinity/build-mobile+15 −0tools/infinity/check+2 −0tools/infinity/check-native+1 −0tools/infinity/check-native-crypto+6 −1tools/infinity/check-native-ui+1 −0
Intent
diff --git a/.gitignore b/.gitignore| index 6d6e625..38417b5 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -49,3 +49,5 @@ node_modules/ |
| # Native development builds and SwiftPM workspace state. |
| apple/build-*/ |
| apple/OchoCore/.swiftpm/ |
| +# Rebuilt reproducibly from the shared Go engine; never commit binary frameworks. |
| +apple/OchoCore/Artifacts/ |
diff --git a/apple/Infinity/AccountSheets.swift b/apple/Infinity/AccountSheets.swift| index 9871ce3..ce7fee5 100644 |
| --- a/apple/Infinity/AccountSheets.swift |
| +++ b/apple/Infinity/AccountSheets.swift |
| @@ -42,9 +42,11 @@ struct RecoverySheet: View { |
| @State private var password = "" |
| @State private var document: RecoveryDocument? |
| @State private var exporting = false |
| + @State private var passkeySaved = false |
| var body: some View { |
| NavigationStack { Form { |
| - Button("Add a passkey") { Task { await account.addPasskey() } }.disabled(account.busy) |
| + Button("Add a passkey") { Task { await account.addPasskey(); passkeySaved = account.error == nil } }.disabled(account.busy) |
| + if passkeySaved { Text("Passkey saved. You can use it on your other devices.").foregroundStyle(.secondary) } |
| 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) |
diff --git a/apple/Infinity/AccountStore.swift b/apple/Infinity/AccountStore.swift| index ef4ee68..5e43ad4 100644 |
| --- a/apple/Infinity/AccountStore.swift |
| +++ b/apple/Infinity/AccountStore.swift |
| @@ -26,6 +26,13 @@ import OchoCore |
| 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) |
| + } else { |
| + let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] |
| + let bundle = Bundle.main.bundleIdentifier ?? "chat.ocho.infinity" |
| + let roots = [library.appendingPathComponent("WebKit/WebsiteData"), library.appendingPathComponent("WebKit/" + bundle + "/WebsiteData")] |
| + if let id = try LegacyIdentity.discover(in: roots, preferredOrigin: UserDefaults.standard.string(forKey: "infinity.account-origin")) { |
| + try KeychainIdentity.write(JSONEncoder().encode(id)); identity = id; client = try OchoClient(identity: id) |
| + } |
| } |
| } catch { self.error = error.localizedDescription } |
| } |
| @@ -39,6 +46,7 @@ import OchoCore |
| if let claim { try await c.setup(claim: claim) } |
| let view = try await c.refresh() |
| try KeychainIdentity.write(JSONEncoder().encode(id)) |
| + if claim != nil { stage = "To infinity!"; try await Task.sleep(for: .milliseconds(800)) } |
| client = c; identity = id; threads = view.threads; requests = view.requests |
| } |
| struct Pending: Codable { var identity: Identity; var claim: String } |
diff --git a/apple/Infinity/SetupProgressView.swift b/apple/Infinity/SetupProgressView.swift| new file mode 100644 |
| index 0000000..0099eab |
| --- /dev/null |
| +++ b/apple/Infinity/SetupProgressView.swift |
| @@ -0,0 +1,29 @@ |
| +// 🤖 The native setup meter goes past eight to infinity only after readiness. |
| +import SwiftUI |
| +struct SetupProgressView: View { |
| + let stage: String |
| + @Environment(\.accessibilityReduceMotion) private var reducedMotion |
| + @State private var began = Date() |
| + var complete: Bool { stage == "To infinity!" } |
| + var body: some View { |
| + VStack(alignment: .leading, spacing: 24) { |
| + Text("THIS ONE GOES TO ∞").font(.caption.weight(.bold)).tracking(2).foregroundStyle(.tint) |
| + TimelineView(.animation(minimumInterval: 0.1, paused: reducedMotion || complete)) { timeline in |
| + GeometryReader { geometry in |
| + let track = max(60, geometry.size.width - 80) |
| + let elapsed = max(0, timeline.date.timeIntervalSince(began)) |
| + let fraction = reducedMotion ? 0.45 : min(0.88, 0.02 + 0.86 * (1 - exp(-elapsed / 45))) |
| + ZStack(alignment: .leading) { |
| + Capsule().fill(.quaternary).frame(width: track, height: 10) |
| + Capsule().fill(Color.accentColor.gradient).frame(width: complete ? track + 60 : track * fraction, height: 10) |
| + .animation(reducedMotion ? nil : .easeIn(duration: 0.8), value: complete) |
| + Image(systemName: "infinity").font(.system(size: 32, weight: .semibold)).foregroundStyle(.tint).offset(x: track + 36) |
| + }.frame(height: 32) |
| + HStack { ForEach(0...8, id: \.self) { value in Text(String(value)).font(.caption); if value != 8 { Spacer(minLength: 0) } } }.foregroundStyle(.secondary).frame(width: track).offset(y: 38) |
| + }.frame(height: 58) |
| + }.accessibilityElement(children: .ignore).accessibilityLabel(complete ? "Your account is ready" : "Setting up your account") |
| + Text(stage).font(.headline).accessibilityAddTraits(.updatesFrequently) |
| + Text("A little space of your own. A lot of possibilities.").foregroundStyle(.secondary) |
| + } |
| + } |
| +} |
diff --git a/apple/Infinity/WelcomeView.swift b/apple/Infinity/WelcomeView.swift| index a3db5ed..08ba692 100644 |
| --- a/apple/Infinity/WelcomeView.swift |
| +++ b/apple/Infinity/WelcomeView.swift |
| @@ -13,9 +13,7 @@ struct WelcomeView: View { |
| 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) |
| + SetupProgressView(stage: stage) |
| } 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) |
diff --git a/apple/InfinityUITests/NativeChatTests.swift b/apple/InfinityUITests/NativeChatTests.swift| index 2fc2139..bd4745f 100644 |
| --- a/apple/InfinityUITests/NativeChatTests.swift |
| +++ b/apple/InfinityUITests/NativeChatTests.swift |
| @@ -4,8 +4,8 @@ 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 me = try Identity.create(origin: "http://localhost:18871", name: "Native UI Alice") |
| + let peer = try 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)) |
diff --git a/apple/OchoCore/Package.swift b/apple/OchoCore/Package.swift| index d7a50df..8b5d69c 100644 |
| --- a/apple/OchoCore/Package.swift |
| +++ b/apple/OchoCore/Package.swift |
| @@ -1,4 +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"])]) |
| +let package = Package(name: "OchoCore", platforms: [.macOS(.v14), .iOS(.v17)], products: [.library(name: "OchoCore", targets: ["OchoCore"])], targets: [.binaryTarget(name: "OchoEngine", path: "Artifacts/OchoEngine.xcframework"), .target(name: "OchoCore", dependencies: ["OchoEngine"]), .testTarget(name: "OchoCoreTests", dependencies: ["OchoCore"])]) |
diff --git a/apple/OchoCore/Sources/OchoCore/API.swift b/apple/OchoCore/Sources/OchoCore/API.swift| index 67b6241..4c7d588 100644 |
| --- a/apple/OchoCore/Sources/OchoCore/API.swift |
| +++ b/apple/OchoCore/Sources/OchoCore/API.swift |
| @@ -1,54 +1,23 @@ |
| // 🤖 Bounded native JSON transport. No cookies, redirects or private-key uploads. |
| import Foundation |
| +import OchoEngine |
| 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 |
| + try engineCheck { OchoValidateOrigin(text, $0) } |
| + guard let url = URL(string: text) else { throw OchoError.invalid("Invalid account address.") }; return url |
| } |
| - 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) |
| + let payload = body.map { String(decoding: $0, as: UTF8.self) } ?? "" |
| + let text = try await Task.detached { try engineString { OchoRequestJSON(origin, path, auth ?? "", method, payload, $0) } }.value |
| + return try fromJSON(text, as: type) |
| } |
| } |
| + |
| 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 |
| + try fromJSON(engineString { OchoParseInvite(input, $0) }, as: Invite.self) |
| } |
| public var link: URL { |
| var c = URLComponents(string: "https://ocho.chat")! |
diff --git a/apple/OchoCore/Sources/OchoCore/Client.swift b/apple/OchoCore/Sources/OchoCore/Client.swift| index e90c9a1..2bb2667 100644 |
| --- a/apple/OchoCore/Sources/OchoCore/Client.swift |
| +++ b/apple/OchoCore/Sources/OchoCore/Client.swift |
| @@ -1,118 +1,25 @@ |
| -// 🤖 Serialized account state and authenticated protocol operations. |
| +// 🤖 Swift actor facade; account synchronization, crypto and state live in Go. |
| import Foundation |
| +import OchoEngine |
| public actor OchoClient { |
| + private let core: OchoEngine.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 init(identity: Identity) throws { |
| + self.identity = identity; let text = try json(identity) |
| + var error: NSError?; guard let core = OchoNewClient(text, &error) else { throw error ?? OchoError.invalid("Could not open this account.") as NSError } |
| + self.core = core |
| } |
| + public func setup(claim: String) async throws { try core.setup(claim) } |
| 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…" |
| - } |
| - } |
| - } |
| + let view = try fromJSON(engineString { core.refresh($0) }, as: Projection.self) |
| + identity = try fromJSON(engineString { core.identityJSON($0) }, as: Identity.self) |
| 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)) |
| - } |
| + public func invite() async throws -> URL { guard let url = URL(string: try engineString { core.invite($0) }) else { throw OchoError.invalid("Could not create an invitation.") }; return url } |
| + public func connect(_ invite: Invite) async throws -> String { try engineString { core.connect(invite.link.absoluteString, error: $0) } } |
| + public func accept(_ request: Event) async throws { try core.accept(request.thread) } |
| + public func reject(_ request: Event) async throws { try core.reject(request.thread) } |
| + public func message(thread: String, text: String) async throws { try core.message(thread, text: text) } |
| + public func group(name: String, contacts: Set<String>) async throws -> String { let ids = try json(Array(contacts)); return try engineString { core.group(name, contactIDsJSON: ids, error: $0) } } |
| + public func removeMember(thread: String, signing: String) async throws { try core.removeMember(thread, signing: signing) } |
| } |
diff --git a/apple/OchoCore/Sources/OchoCore/Crypto.swift b/apple/OchoCore/Sources/OchoCore/Crypto.swift| index 2588b0d..1b599f3 100644 |
| --- a/apple/OchoCore/Sources/OchoCore/Crypto.swift |
| +++ b/apple/OchoCore/Sources/OchoCore/Crypto.swift |
| @@ -1,7 +1,6 @@ |
| -// 🤖 Wire-compatible with internal/infinity/static/crypto.js. Keys stay on device. |
| +// 🤖 Swift value types and binding calls; cryptography lives only in the shared Go engine. |
| import Foundation |
| -import CryptoKit |
| -import CommonCrypto |
| +import OchoEngine |
| public enum OchoError: LocalizedError { |
| case invalid(String) |
| @@ -34,19 +33,11 @@ public struct Identity: Codable, Sendable { |
| 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 static func create(origin: String, name: String) throws -> Identity { |
| + try fromJSON(engineString { OchoNewIdentity(origin, name, $0) }, as: Identity.self) |
| } |
| + public func validate() throws { let text = try json(self); try engineCheck { OchoValidateIdentity(text, $0) } } |
| + |
| } |
| public struct Envelope: Codable, Sendable { |
| public var id: String, to: String, sender: String, ephemeral: String, nonce: String, box: String, signature: String |
| @@ -54,77 +45,29 @@ public struct Envelope: Codable, Sendable { |
| 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 random() -> String { OchoRandom() } |
| 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 |
| + let identity = try json(identity), recipient = try json(recipient), value = try json(value) |
| + return try fromJSON(engineString { OchoSeal(identity, recipient, value, $0) }, as: Envelope.self) |
| } |
| - 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 open<T: Decodable>(_ identity: Identity, envelope: Envelope, as type: T.Type) throws -> T { |
| + let identity = try json(identity), envelope = try json(envelope) |
| + return try fromJSON(engineString { OchoOpen(identity, envelope, $0) }, as: type) |
| } |
| 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 |
| + let value = try json(value); return try engineString { OchoLock(key, value, $0) } |
| } |
| 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))) |
| + try fromJSON(engineString { OchoUnlock(key, text, $0) }, as: type) |
| } |
| - 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)))) |
| + let id = try json(identity); return Data(try engineString { OchoExportRecovery(id, password, $0) }.utf8) |
| } |
| 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 |
| + let text = String(decoding: data, as: UTF8.self) |
| + return try fromJSON(engineString { OchoImportRecovery(text, password, $0) }, as: Identity.self) |
| } |
| -} |
| - |
| -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 } |
| + public static func passkeyWrappingKey(output: Data, credentialID: Data) throws -> String { |
| + try engineString { OchoPasskeyWrappingKey(output, credentialID, $0) } |
| } |
| } |
diff --git a/apple/OchoCore/Sources/OchoCore/Engine.swift b/apple/OchoCore/Sources/OchoCore/Engine.swift| new file mode 100644 |
| index 0000000..6d1887e |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/Engine.swift |
| @@ -0,0 +1,11 @@ |
| +// 🤖 Thin Objective-C binding adapter. The single Go engine owns all protocol decisions. |
| +import Foundation |
| +import OchoEngine |
| +func engineString(_ action: (AutoreleasingUnsafeMutablePointer<NSError?>) -> String) throws -> String { |
| + var error: NSError?; let value = action(&error); if let error { throw error }; return value |
| +} |
| +func engineCheck(_ action: (AutoreleasingUnsafeMutablePointer<NSError?>) -> Bool) throws { |
| + var error: NSError?; let ok = action(&error); if let error { throw error }; if !ok { throw OchoError.invalid("Could not finish this operation.") } |
| +} |
| +func json<T: Encodable>(_ value: T) throws -> String { String(decoding: try JSONEncoder().encode(value), as: UTF8.self) } |
| +func fromJSON<T: Decodable>(_ text: String, as type: T.Type) throws -> T { try JSONDecoder().decode(type, from: Data(text.utf8)) } |
diff --git a/apple/OchoCore/Sources/OchoCore/LegacyIdentity.swift b/apple/OchoCore/Sources/OchoCore/LegacyIdentity.swift| new file mode 100644 |
| index 0000000..29d4eac |
| --- /dev/null |
| +++ b/apple/OchoCore/Sources/OchoCore/LegacyIdentity.swift |
| @@ -0,0 +1,42 @@ |
| +// 🤖 One-time read-only import of this app's former local storage. No WebKit runtime. |
| +import Foundation |
| +import SQLite3 |
| +public enum LegacyIdentity { |
| + public static func read(database: URL) throws -> Identity? { |
| + var handle: OpaquePointer? |
| + guard sqlite3_open_v2(database.path, &handle, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nil) == SQLITE_OK else { |
| + if handle != nil { sqlite3_close(handle) }; return nil |
| + } |
| + defer { sqlite3_close(handle) } |
| + var statement: OpaquePointer? |
| + guard sqlite3_prepare_v2(handle, "SELECT value FROM ItemTable WHERE key='infinity.identity.v1' LIMIT 1", -1, &statement, nil) == SQLITE_OK else { return nil } |
| + defer { sqlite3_finalize(statement) } |
| + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } |
| + let count = Int(sqlite3_column_bytes(statement, 0)) |
| + guard count > 0, count <= 100000, let bytes = sqlite3_column_blob(statement, 0) else { return nil } |
| + let data = Data(bytes: bytes, count: count) |
| + for encoding: String.Encoding in [.utf16LittleEndian, .utf8] { |
| + guard let text = String(data: data, encoding: encoding), let identity = try? JSONDecoder().decode(Identity.self, from: Data(text.utf8)) else { continue } |
| + try identity.validate(); return identity |
| + } |
| + return nil |
| + } |
| + public static func discover(in roots: [URL], preferredOrigin: String?) throws -> Identity? { |
| + var identities: [String: Identity] = [:] |
| + for root in roots { |
| + guard let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles]) else { continue } |
| + var count = 0 |
| + for case let file as URL in files { |
| + count += 1; if count > 10000 { break } |
| + let values = try? file.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey]) |
| + if values?.isSymbolicLink == true { files.skipDescendants(); continue } |
| + guard values?.isRegularFile == true, file.lastPathComponent == "localstorage.sqlite3" || file.pathExtension == "localstorage" else { continue } |
| + if let identity = try? read(database: file) { identities[identity.origin] = identity } |
| + } |
| + } |
| + if let preferredOrigin, let id = identities[preferredOrigin] { return id } |
| + if identities.count == 1 { return identities.values.first } |
| + if identities.count > 1 { throw OchoError.invalid("There are several saved accounts on this device. Open the account you want with its passkey or recovery file.") } |
| + return nil |
| + } |
| +} |
diff --git a/apple/OchoCore/Sources/OchoCore/Models.swift b/apple/OchoCore/Sources/OchoCore/Models.swift| index f8156a5..c09ef96 100644 |
| --- a/apple/OchoCore/Sources/OchoCore/Models.swift |
| +++ b/apple/OchoCore/Sources/OchoCore/Models.swift |
| @@ -1,5 +1,6 @@ |
| // 🤖 Readable models exist only on the client, inside the encrypted account vault. |
| import Foundation |
| +import OchoEngine |
| public struct Contact: Codable, Sendable { public var person: Person; public var pending: Bool } |
| public struct Vault: Codable, Sendable { |
| public var name: String |
| @@ -15,53 +16,21 @@ public struct Event: Codable, Sendable { |
| 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 struct Message: Identifiable, Codable, 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 struct Thread: Identifiable, Codable, 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 struct Projection: Codable, 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)) |
| + do { |
| + let identity = try json(identity), state = try json(state), events = try json(events) |
| + return try fromJSON(engineString { OchoProject(identity, state, events, $0) }, as: Projection.self) |
| + } catch { return Projection() } |
| } |
| } |
diff --git a/apple/OchoCore/Sources/OchoCore/Registration.swift b/apple/OchoCore/Sources/OchoCore/Registration.swift| index 68bb066..2b0d5e7 100644 |
| --- a/apple/OchoCore/Sources/OchoCore/Registration.swift |
| +++ b/apple/OchoCore/Sources/OchoCore/Registration.swift |
| @@ -1,47 +1,16 @@ |
| -// 🤖 Framework-compatible proof of work, computed away from the UI thread. |
| +// 🤖 Go performs proof/provisioning; this adapter returns stage changes to SwiftUI. |
| import Foundation |
| -import CryptoKit |
| +import OchoEngine |
| +final class RegistrationProgress: NSObject, OchoProgressProtocol, @unchecked Sendable { |
| + let callback: @Sendable (String) async -> Void |
| + init(_ callback: @escaping @Sendable (String) async -> Void) { self.callback = callback } |
| + func update(_ message: String?) { if let message { Task { await callback(message) } } } |
| +} |
| 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.") |
| + let progress = RegistrationProgress(progress) |
| + let text = try await Task.detached { try engineString { OchoRegister(home, name, nonce, progress, $0) } }.value |
| + struct Pending: Decodable { var identity: Identity; var claim: String } |
| + let pending = try fromJSON(text, as: Pending.self); return (pending.identity, pending.claim) |
| } |
| } |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift b/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift| index 928ebf2..29c0d8f 100644 |
| --- a/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift |
| @@ -25,7 +25,7 @@ final class ClientTests: XCTestCase { |
| try await Task.sleep(for: .milliseconds(100)) |
| } |
| XCTAssertTrue(ready) |
| - let id = Identity.create(origin: origin, name: ["Alice", "Bob", "Carol"][i]); ids.append(id) |
| + let id = try 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. |
| } |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift b/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift| index 1f613f1..f8b609b 100644 |
| --- a/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift |
| @@ -3,7 +3,7 @@ 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 a = try Identity.create(origin: "https://a.example", name: "Alice"), b = try 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) |
| @@ -12,7 +12,7 @@ final class CryptoTests: XCTestCase { |
| XCTAssertThrowsError(try Crypto.open(b, envelope: e, as: Event.self)) |
| } |
| func testRecoveryAndVault() throws { |
| - let a = Identity.create(origin: "https://a.example", name: "Alice") |
| + let a = try 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")) |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/LegacyTests.swift b/apple/OchoCore/Tests/OchoCoreTests/LegacyTests.swift| new file mode 100644 |
| index 0000000..a0b556d |
| --- /dev/null |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/LegacyTests.swift |
| @@ -0,0 +1,24 @@ |
| +// 🤖 Migration reads only the identity item and leaves the legacy database untouched. |
| +import XCTest |
| +import SQLite3 |
| +@testable import OchoCore |
| +final class LegacyTests: XCTestCase { |
| + func testReadOnlyUTF16Migration() throws { |
| + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| + defer { try? FileManager.default.removeItem(at: dir) } |
| + let file = dir.appendingPathComponent("localstorage.sqlite3") |
| + let id = try Identity.create(origin: "https://legacy.example", name: "Migration") |
| + let text = String(decoding: try JSONEncoder().encode(id), as: UTF8.self) |
| + let data = try XCTUnwrap(text.data(using: .utf16LittleEndian)) |
| + var db: OpaquePointer?; XCTAssertEqual(sqlite3_open(file.path, &db), SQLITE_OK) |
| + XCTAssertEqual(sqlite3_exec(db, "CREATE TABLE ItemTable(key TEXT PRIMARY KEY,value BLOB)", nil, nil, nil), SQLITE_OK) |
| + var stmt: OpaquePointer?; XCTAssertEqual(sqlite3_prepare_v2(db, "INSERT INTO ItemTable VALUES('infinity.identity.v1',?)", -1, &stmt, nil), SQLITE_OK) |
| + _ = data.withUnsafeBytes { sqlite3_bind_blob(stmt, 1, $0.baseAddress, Int32(data.count), unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } |
| + XCTAssertEqual(sqlite3_step(stmt), SQLITE_DONE); sqlite3_finalize(stmt); sqlite3_close(db) |
| + let before = try Data(contentsOf: file) |
| + let restored = try LegacyIdentity.discover(in: [dir], preferredOrigin: nil) |
| + XCTAssertEqual(restored?.enc, id.enc); XCTAssertEqual(restored?.privateEnc.d, id.privateEnc.d) |
| + XCTAssertEqual(try Data(contentsOf: file), before) |
| + } |
| +} |
diff --git a/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift b/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift| index 81e3403..6e110de 100644 |
| --- a/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift |
| +++ b/apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift |
| @@ -2,8 +2,8 @@ |
| 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") |
| + func testForgedMembershipAndEpochs() throws { |
| + let a = try Identity.create(origin: "https://a.example", name: "Alice"), b = try Identity.create(origin: "https://b.example", name: "Bob"), c = try 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) |
| @@ -20,8 +20,8 @@ final class ProjectionTests: XCTestCase { |
| 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") |
| + func testUnknownGroupNeedsKnownCreatorAndConsent() throws { |
| + let a = try Identity.create(origin: "https://a.example", name: "Alice"), b = try 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) |
diff --git a/docs/infinity/android-handoff.md b/docs/infinity/android-handoff.md| new file mode 100644 |
| index 0000000..127f698 |
| --- /dev/null |
| +++ b/docs/infinity/android-handoff.md |
| @@ -0,0 +1,25 @@ |
| +# 🤖 Ocho Android / shared engine handoff — 2026-09-06 |
| + |
| +Paul requested Android and a shared Go/gomobile engine while the fully native Apple rewrite was underway. This supersedes independent Swift protocol implementations as the shipping architecture. SwiftUI stays native; Android should use native Kotlin/Compose. No WebViews or JavaScript runtimes in native apps. |
| + |
| +Working checkout: `/Users/paulcampbell/amadan.net/ocho/ocho/.worktrees/infinity`, branch `infinity-v1`, Amadan `eleven/ocho`. Do not edit the original Eleven checkout or merge Ocho main. Current prototype Apple sources are under `apple/`; the shared engine is being introduced under `mobile/`. The Swift/Web Crypto compatibility and native integration tests are reusable acceptance evidence for the Go replacement, not a reason to keep two protocol implementations. |
| + |
| +## Binding boundary |
| + |
| +A small JSON-oriented Go API owns identity/envelope/vault crypto, verified event projection, networking, account setup and chat operations. gomobile emits an Apple XCFramework and Android AAR. UI, secure platform storage, system file/share flows and passkey ceremonies remain platform-owned. PRF key derivation and identity wrapping are shared Go functions. Do not send PRF outputs, identity private keys or decrypted content to the server. |
| + |
| +Ocho v1 wire format remains unchanged: P-256 ECDH, HKDF-SHA256 (`infinity-message-v1`), AES-256-GCM and separate P-256 ECDSA keys. Signatures on message envelopes are IEEE-P1363 raw r||s, not DER. Passkey assertions use the normal WebAuthn DER signature. Identity private keys are JWK with base64url x/y/d. See `internal/infinity/static/crypto.js`, `protocol.js`, `client.js` and `docs/infinity/native-passkeys.md`. |
| + |
| +## Passkeys |
| + |
| +RP ID `ocho.chat`; discoverable credentials with required user verification. Native Android uses Credential Manager and Digital Asset Links for the exact Android application ID/signing certificate. No certificate fingerprints or Android association have been invented yet. Register/login endpoints are `/api/passkeys/{register,login}/{begin,finish}` at the front door. PRF input bytes are UTF-8 `ocho-passkey-identity-v1`. Derive wrapping key via HKDF-SHA256, salt UTF-8 `ocho-passkey-wrap-v1`, info raw credential ID, 32 bytes; wrap identity using the existing vault box format. The PRF output stays entirely client-side. Unsupported PRF requires explicit recovery-file fallback, never server-readable key escrow. |
| + |
| +## Machine tooling inventory |
| + |
| +`~/go/bin/gomobile` and `gobind` already exist, x/mobile version `v0.0.0-20260821190718-4776eadac327`, built with Go 1.26.0. As initially checked, adb/sdkmanager were absent from PATH, `~/Library/Android/sdk` and `/Applications/Android Studio.app` were absent, and `/usr/libexec/java_home -V` reported no JDK. Coordinate one installation of JDK, SDK command-line/platform/build tools and NDK; avoid concurrent installers. This session will build/test the engine bindings and record pinned versions. The Android session should identify its repo, SDK path and preferred API/NDK versions. |
| + |
| +Cross-session messaging discovery currently fails because its local MCP endpoint (`127.0.0.1:50341`) is unavailable. Paul has been asked for the Android repo/session. This document is safe to share: no credentials, private identities or real invitation links. |
| + |
| +## Updated machine observation |
| + |
| +The Eleven Android session is `01a0788c-520f-76c2-9edf-4a3d2ee19721`. Its active installer is populating `~/Library/Android/sdk` with command-line tools 19.0, platform-tools, platforms;android-35, build-tools;35.0.0, ndk;28.2.13676358 and an API35 arm64 emulator. JDK path: `/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home`. Ocho will reuse these paths and will not run a second installer. Cross-session message delivery has not succeeded yet. |
diff --git a/docs/infinity/native-passkeys.md b/docs/infinity/native-passkeys.md| index fbe9235..a49df29 100644 |
| --- a/docs/infinity/native-passkeys.md |
| +++ b/docs/infinity/native-passkeys.md |
| @@ -16,4 +16,8 @@ Relying-party ID ocho.chat, shared by web and associated native applications. Us |
| ## 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. |
| +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. A one-time read-only SQLite migration reads only the old app’s identity item from its own local-storage directories, validates the key pairs and saves it in Keychain. It loads no WebKit runtime and leaves old files untouched. Multiple ambiguous saved identities require explicit recovery/passkey selection; never choose arbitrarily. No implementation merge to main without Paul's approval. |
| + |
| +## Android steering — Paul, 2026-09-06 |
| + |
| +Share the native engine with Android through Go/gomobile. The Swift implementation and its interoperability tests established compatibility, but shipping clients will call one Go engine for protocol/crypto/network/state and retain native platform UI, Keychain/Keystore and passkey prompts. Android build-tool coordination and the binding contract are tracked in `android-handoff.md`. |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index f873c65..5b277e8 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -55,3 +55,9 @@ Both apps now serve f4d3acb8, verified from their /api/version response bodies. |
| 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. |
| + |
| +### Shared Go engine and native UI verification |
| + |
| +Paul added Android/shared-engine scope. The shipping native architecture now uses a Go/gomobile engine (`mobile/`) for crypto, verified event projection, serialized account state, networking and registration. Swift keeps native UI, Keychain, system passkey prompts and thin value/binding adapters. There is no independent Swift crypto/protocol implementation and no WebView/JavaScript runtime. Apple XCFramework generation succeeds; Android AAR generation will reuse the Eleven session’s in-progress SDK/NDK installation. |
| + |
| +Seven native acceptance tests pass through the Go bindings, including bidirectional Web Crypto compatibility for envelopes/vaults/recovery/PRF wrapping, three real personal processes, forged group membership and legacy identity migration. The actual iPhone simulator UI test passed again after the Go switch: receive, navigate, compose, send, confirm peer receipt and assert zero WebViews. The full existing Chromium/WebKit chat suite passes. Extended front-door tests pass encrypted history restoration through a discoverable PRF passkey and WebKit recovery without leaving the public origin. Physical Apple passkey interaction remains unverified; no claim of production deployment or installation yet. |
diff --git a/internal/infinity/static/api.js b/internal/infinity/static/api.js| index 46dd7b6..2fef49d 100644 |
| --- a/internal/infinity/static/api.js |
| +++ b/internal/infinity/static/api.js |
| @@ -25,6 +25,9 @@ export function peerOrigin(text) { |
| return u.origin; |
| } |
| export function parseInvite(text) { |
| + const wrapper = new URL(text); |
| + const nested = new URLSearchParams(wrapper.hash.slice(1)).get('invite'); |
| + if (nested) text = nested; |
| const u = new URL(text); const data = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(u.hash.slice(1).replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0)))); |
| if (data.version !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(data.thread) || !/^[A-Za-z0-9_-]{43}$/.test(data.token) || !/^[A-Za-z0-9_-]{87}$/.test(data.person?.enc) || !/^[A-Za-z0-9_-]{87}$/.test(data.person?.signing) || typeof data.person?.name !== 'string' || data.person.name.length > 80 || peerOrigin(data.person.origin) !== u.origin) throw new Error('This invitation is incomplete.'); |
| return data; |
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js| index 1c18fbe..351f826 100644 |
| --- a/internal/infinity/static/client.js |
| +++ b/internal/infinity/static/client.js |
| @@ -1,5 +1,5 @@ |
| // 🤖 Account coordinator; all readable state stays in the client. |
| -import { request, peerOrigin } from './api.js'; |
| +import { request, peerOrigin, usingRelay } from './api.js'; |
| import { random, publicIdentity, seal, open, lock, unlock, b64 } from './crypto.js'; |
| import { project } from './protocol.js'; |
| export class Client { |
| @@ -66,7 +66,8 @@ export class Client { |
| const data = { version: 1, thread, token, person: publicIdentity(this.id) }; |
| // The permanent inbound capability is exchanged only inside the encrypted handshake. |
| delete data.person.cap; |
| - return this.id.origin + '/connect#' + b64(new TextEncoder().encode(JSON.stringify(data))); |
| + const link = this.id.origin + '/connect#' + b64(new TextEncoder().encode(JSON.stringify(data))); |
| + return usingRelay() ? location.origin + '/#invite=' + encodeURIComponent(link) : link; |
| } |
| async connect(invite) { |
| if (invite.person.signing === this.id.signing) throw new Error('This is your own invitation. Share it with someone else.'); |
diff --git a/mobile/client.go b/mobile/client.go| new file mode 100644 |
| index 0000000..023f1e8 |
| --- /dev/null |
| +++ b/mobile/client.go |
| @@ -0,0 +1,195 @@ |
| +// 🤖 Serialized native account state and authenticated synchronization. |
| +package ocho |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "strconv" |
| + "sync" |
| +) |
| + |
| +// Client is safe to call from native background tasks; UI state is returned as JSON. |
| +type Client struct { |
| + mu sync.Mutex |
| + id identity |
| + state vault |
| + revision int64 |
| + events []event |
| + cursor int64 |
| +} |
| + |
| +// NewClient opens an existing validated identity without contacting the network. |
| +func NewClient(identityJSON string) (*Client, error) { |
| + id, e := readIdentity(identityJSON) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return &Client{id: id, state: newVault(id.Name), events: []event{}}, nil |
| +} |
| +func (c *Client) api(path, method string, body, out any) error { |
| + return request(c.id.Origin, path, c.id.Auth, method, body, out) |
| +} |
| + |
| +type vaultReply struct { |
| + Revision int64 `json:"revision"` |
| + Box string `json:"box"` |
| +} |
| + |
| +func (c *Client) load() error { |
| + var v vaultReply |
| + if e := c.api("/api/vault", "GET", nil, &v); e != nil { |
| + return e |
| + } |
| + state := c.state |
| + if v.Box != "" { |
| + plain, e := Unlock(c.id.VaultKey, v.Box) |
| + if e != nil { |
| + return e |
| + } |
| + state = newVault(c.id.Name) |
| + if e = parse(plain, &state); e != nil { |
| + return e |
| + } |
| + } |
| + c.state = state |
| + c.revision = v.Revision |
| + c.id.Name = state.Name |
| + return nil |
| +} |
| +func (c *Client) save() error { |
| + plain, e := marshal(c.state) |
| + if e != nil { |
| + return e |
| + } |
| + box, e := Lock(c.id.VaultKey, plain) |
| + if e != nil { |
| + return e |
| + } |
| + var out struct { |
| + Revision int64 `json:"revision"` |
| + } |
| + e = c.api("/api/vault", "PUT", vaultReply{c.revision, box}, &out) |
| + if e == nil { |
| + c.revision = out.Revision |
| + } |
| + return e |
| +} |
| + |
| +// IdentityJSON returns local identity material only to platform secure storage. |
| +func (c *Client) IdentityJSON() (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return marshal(c.id) |
| +} |
| + |
| +// Setup claims only a fresh instance, or safely resumes the exact same identity. |
| +func (c *Client) Setup(claim string) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + body := map[string]string{"auth": c.id.Auth, "enc": c.id.Enc, "signing": c.id.Signing, "claim": claim} |
| + if e := c.api("/api/setup", "POST", body, nil); e != nil { |
| + if e = c.load(); e != nil { |
| + return e |
| + } |
| + } |
| + if e := c.api("/api/capabilities", "POST", map[string]any{"token": c.id.Cap, "once": false}, nil); e != nil { |
| + return e |
| + } |
| + if e := c.load(); e != nil { |
| + return e |
| + } |
| + if c.revision == 0 { |
| + return c.save() |
| + } |
| + return nil |
| +} |
| + |
| +type row struct { |
| + Seq int64 `json:"seq"` |
| + Envelope envelope `json:"envelope"` |
| +} |
| +type delivery struct { |
| + ID string `json:"id"` |
| + Destination string `json:"destination"` |
| + Delivered int64 `json:"delivered"` |
| + Attempts int `json:"attempts"` |
| +} |
| + |
| +func (c *Client) refresh() (projection, error) { |
| + var out projection |
| + if e := c.load(); e != nil { |
| + return out, e |
| + } |
| + if e := c.api("/api/retry", "POST", nil, nil); e != nil { |
| + return out, e |
| + } |
| + for { |
| + var rows []row |
| + if e := c.api("/api/events?after="+strconv.FormatInt(c.cursor, 10), "GET", nil, &rows); e != nil { |
| + return out, e |
| + } |
| + advanced := false |
| + for _, r := range rows { |
| + if r.Seq <= c.cursor { |
| + continue |
| + } |
| + if plain, e := open(c.id, r.Envelope); e == nil { |
| + var event event |
| + if json.Unmarshal(plain, &event) == nil { |
| + event.Signer = r.Envelope.Sender |
| + c.events = append(c.events, event) |
| + } |
| + } |
| + c.cursor = r.Seq |
| + advanced = true |
| + } |
| + if len(rows) < 100 { |
| + break |
| + } |
| + if !advanced { |
| + return out, errors.New("Account history did not advance.") |
| + } |
| + } |
| + var receipts []delivery |
| + if e := c.api("/api/delivery", "GET", nil, &receipts); e != nil { |
| + return out, e |
| + } |
| + out = project(c.id, c.state, c.events) |
| + for _, t := range out.Threads { |
| + for i, m := range t.Messages { |
| + if !m.Mine { |
| + continue |
| + } |
| + found, all, attempted := false, true, false |
| + for _, r := range receipts { |
| + if r.ID != m.ID || r.Destination == c.id.Origin { |
| + continue |
| + } |
| + found = true |
| + all = all && r.Delivered > 0 |
| + attempted = attempted || r.Attempts > 0 |
| + } |
| + if found { |
| + status := "Sending…" |
| + if all { |
| + status = "Sent" |
| + } else if attempted { |
| + status = "Not delivered · retrying" |
| + } |
| + t.Messages[i].Delivery = status |
| + } |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// Refresh decrypts new envelopes and returns authenticated conversations/requests. |
| +func (c *Client) Refresh() (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + out, e := c.refresh() |
| + if e != nil { |
| + return "", e |
| + } |
| + return marshal(out) |
| +} |
diff --git a/mobile/conversations.go b/mobile/conversations.go| new file mode 100644 |
| index 0000000..531741b |
| --- /dev/null |
| +++ b/mobile/conversations.go |
| @@ -0,0 +1,205 @@ |
| +// 🤖 Shared native invitation, consent, messaging and group operations. |
| +package ocho |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "time" |
| +) |
| + |
| +func (c *Client) emit(people []person, p event) error { |
| + type item struct { |
| + Destination string `json:"destination"` |
| + Capability string `json:"capability"` |
| + Envelope envelope `json:"envelope"` |
| + } |
| + items := []item{} |
| + seen := map[string]bool{} |
| + body, e := json.Marshal(p) |
| + if e != nil { |
| + return e |
| + } |
| + for _, to := range people { |
| + if seen[to.Signing] { |
| + continue |
| + } |
| + seen[to.Signing] = true |
| + if _, e := secureOrigin(to.Origin); e != nil { |
| + return e |
| + } |
| + if to.Cap == "" { |
| + return errors.New("This contact is not ready.") |
| + } |
| + id := "" |
| + if p.Kind == "message" { |
| + id = p.ID |
| + } |
| + box, e := seal(c.id, to, body, id) |
| + if e != nil { |
| + return e |
| + } |
| + items = append(items, item{to.Origin, to.Cap, box}) |
| + } |
| + return c.api("/api/send", "POST", map[string]any{"items": items}, nil) |
| +} |
| +func now() *int64 { n := time.Now().UnixMilli(); return &n } |
| +func epoch(n int) *int { return &n } |
| + |
| +// Invite creates a one-use invitation that opens at the public front door. |
| +func (c *Client) Invite() (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if e := c.load(); e != nil { |
| + return "", e |
| + } |
| + token, thread := Random(), Random() |
| + if e := c.api("/api/capabilities", "POST", map[string]any{"token": token, "once": true}, nil); e != nil { |
| + return "", e |
| + } |
| + c.state.Invites[thread] = true |
| + if e := c.save(); e != nil { |
| + return "", e |
| + } |
| + p := c.id.person() |
| + p.Cap = "" |
| + return inviteLink(invite{1, thread, token, p}) |
| +} |
| + |
| +// Connect sends a consent request; it never grants owner access to another person. |
| +func (c *Client) Connect(link string) (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + inv, e := parseInvite(link) |
| + if e != nil { |
| + return "", e |
| + } |
| + if inv.Person.Signing == c.id.Signing { |
| + return "", errors.New("Share this invitation with someone else.") |
| + } |
| + if e = c.load(); e != nil { |
| + return "", e |
| + } |
| + c.state.Contacts[inv.Thread] = contact{inv.Person, true} |
| + if e = c.save(); e != nil { |
| + return "", e |
| + } |
| + to := inv.Person |
| + to.Cap = inv.Token |
| + me := c.id.person() |
| + e = c.emit([]person{to}, event{Kind: "contact-request", Thread: inv.Thread, Person: &me, At: now()}) |
| + return inv.Thread, e |
| +} |
| + |
| +// Accept accepts an authenticated pending request by its thread identifier. |
| +func (c *Client) Accept(threadID string) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + view, e := c.refresh() |
| + if e != nil { |
| + return e |
| + } |
| + for _, r := range view.Requests { |
| + if r.Thread != threadID { |
| + continue |
| + } |
| + if r.Kind == "contact-request" && r.Person != nil { |
| + c.state.Contacts[r.Thread] = contact{*r.Person, false} |
| + if e = c.save(); e != nil { |
| + return e |
| + } |
| + me := c.id.person() |
| + return c.emit([]person{*r.Person, me}, event{Kind: "contact-accept", Thread: r.Thread, Person: &me, At: now()}) |
| + } |
| + if r.Kind == "group-request" { |
| + c.state.Accepted[r.Thread] = true |
| + return c.save() |
| + } |
| + } |
| + return errors.New("This invitation is no longer pending.") |
| +} |
| + |
| +// Reject hides a pending thread persistently in the encrypted vault. |
| +func (c *Client) Reject(threadID string) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if e := c.load(); e != nil { |
| + return e |
| + } |
| + c.state.Blocked[threadID] = true |
| + return c.save() |
| +} |
| + |
| +// Message validates membership immediately before sending to the current recipients. |
| +func (c *Client) Message(threadID, text string) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + text = strings.TrimSpace(text) |
| + if text == "" || textLength(text) > 8000 { |
| + return errors.New("Write a message of up to 8,000 characters.") |
| + } |
| + view, e := c.refresh() |
| + if e != nil { |
| + return e |
| + } |
| + for _, t := range view.Threads { |
| + if t.ID == threadID && !t.Pending && !t.Left { |
| + return c.emit(t.Members, event{Kind: "message", Thread: threadID, ID: Random(), Text: text, At: now(), Epoch: epoch(t.Epoch)}) |
| + } |
| + } |
| + return errors.New("This conversation is not ready to send.") |
| +} |
| + |
| +// Group creates an encrypted group with between two and 31 accepted contacts. |
| +func (c *Client) Group(name, contactIDsJSON string) (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + var ids []string |
| + if e := parse(contactIDsJSON, &ids); e != nil { |
| + return "", e |
| + } |
| + view, e := c.refresh() |
| + if e != nil { |
| + return "", e |
| + } |
| + wanted := map[string]bool{} |
| + for _, id := range ids { |
| + wanted[id] = true |
| + } |
| + members := []person{c.id.person()} |
| + for _, t := range view.Threads { |
| + if wanted[t.ID] && !t.Group && !t.Pending && t.Peer != nil { |
| + members = append(members, *t.Peer) |
| + } |
| + } |
| + name = strings.TrimSpace(name) |
| + if name == "" || textLength(name) > 80 || len(members) < 3 || len(members) > 32 || len(members) != len(wanted)+1 { |
| + return "", errors.New("Choose a name and between 2 and 31 contacts.") |
| + } |
| + id := Random() |
| + e = c.emit(members, event{Kind: "group", Thread: id, Name: name, Members: members, Owner: c.id.Signing, Epoch: epoch(1), At: now()}) |
| + return id, e |
| +} |
| + |
| +// RemoveMember advances the creator-signed group epoch and stops new delivery to that member. |
| +func (c *Client) RemoveMember(threadID, signing string) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + view, e := c.refresh() |
| + if e != nil { |
| + return e |
| + } |
| + for _, t := range view.Threads { |
| + if t.ID != threadID || !t.Group || t.Owner != c.id.Signing || signing == c.id.Signing || !contains(t.Members, signing) { |
| + continue |
| + } |
| + members := []person{} |
| + for _, p := range t.Members { |
| + if p.Signing != signing { |
| + members = append(members, p) |
| + } |
| + } |
| + return c.emit(t.Members, event{Kind: "group", Thread: t.ID, Name: t.Name, Members: members, Owner: t.Owner, Epoch: epoch(t.Epoch + 1), At: now()}) |
| + } |
| + return errors.New("Only the group creator can remove another member.") |
| +} |
diff --git a/mobile/crypto.go b/mobile/crypto.go| new file mode 100644 |
| index 0000000..5cc279f |
| --- /dev/null |
| +++ b/mobile/crypto.go |
| @@ -0,0 +1,187 @@ |
| +// 🤖 Existing Ocho envelope format, shared by every native platform. |
| +package ocho |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/ecdh" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/hkdf" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "errors" |
| + "math/big" |
| + "strings" |
| +) |
| + |
| +type envelope struct { |
| + ID string `json:"id"` |
| + To string `json:"to"` |
| + Sender string `json:"sender"` |
| + Ephemeral string `json:"ephemeral"` |
| + Nonce string `json:"nonce"` |
| + Box string `json:"box"` |
| + Signature string `json:"signature"` |
| +} |
| + |
| +func (e envelope) header() []byte { |
| + return []byte(strings.Join([]string{"infinity-envelope-v1", e.ID, e.To, e.Sender, e.Ephemeral, e.Nonce}, "\n")) |
| +} |
| +func (e envelope) signed() []byte { return append(e.header(), []byte("\n"+e.Box)...) } |
| +func aead(raw []byte) (cipher.AEAD, error) { |
| + if len(raw) != 32 { |
| + return nil, errors.New("Invalid encryption key.") |
| + } |
| + block, e := aes.NewCipher(raw) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| +func seal(id identity, to person, payload []byte, messageID string) (envelope, error) { |
| + var out envelope |
| + pub, e := decode(to.Enc) |
| + if e != nil { |
| + return out, e |
| + } |
| + recipient, e := ecdh.P256().NewPublicKey(pub) |
| + if e != nil { |
| + return out, e |
| + } |
| + ephemeral, e := ecdh.P256().GenerateKey(rand.Reader) |
| + if e != nil { |
| + return out, e |
| + } |
| + shared, e := ephemeral.ECDH(recipient) |
| + if e != nil { |
| + return out, e |
| + } |
| + nonce := randomBytes(12) |
| + key, e := hkdf.Key(sha256.New, shared, nonce, "infinity-message-v1", 32) |
| + if e != nil { |
| + return out, e |
| + } |
| + box, e := aead(key) |
| + if e != nil { |
| + return out, e |
| + } |
| + if messageID == "" { |
| + messageID = Random() |
| + } |
| + out = envelope{ID: messageID, To: to.Enc, Sender: id.Signing, Ephemeral: encode(ephemeral.PublicKey().Bytes()), Nonce: encode(nonce)} |
| + out.Box = encode(box.Seal(nil, nonce, payload, out.header())) |
| + raw, e := decode(id.PrivateSign.D) |
| + if e != nil { |
| + return out, e |
| + } |
| + x, y := elliptic.P256().ScalarBaseMult(raw) |
| + sk := &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, D: new(big.Int).SetBytes(raw)} |
| + hash := sha256.Sum256(out.signed()) |
| + r, s, e := ecdsa.Sign(rand.Reader, sk, hash[:]) |
| + if e != nil { |
| + return out, e |
| + } |
| + out.Signature = encode(append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)) |
| + return out, nil |
| +} |
| +func open(id identity, e envelope) ([]byte, error) { |
| + if e.To != id.Enc { |
| + return nil, errors.New("This message belongs to another account.") |
| + } |
| + pub, err := decode(e.Sender) |
| + if err != nil { |
| + return nil, err |
| + } |
| + x, y := elliptic.Unmarshal(elliptic.P256(), pub) |
| + if x == nil { |
| + return nil, errors.New("Invalid signing key.") |
| + } |
| + signature, err := decode(e.Signature) |
| + if err != nil || len(signature) != 64 { |
| + return nil, errors.New("Invalid message signature.") |
| + } |
| + hash := sha256.Sum256(e.signed()) |
| + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, hash[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:])) { |
| + return nil, errors.New("Message signature did not match.") |
| + } |
| + raw, err := decode(id.PrivateEnc.D) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sk, err := ecdh.P256().NewPrivateKey(raw) |
| + if err != nil { |
| + return nil, err |
| + } |
| + ep, err := decode(e.Ephemeral) |
| + if err != nil { |
| + return nil, err |
| + } |
| + pk, err := ecdh.P256().NewPublicKey(ep) |
| + if err != nil { |
| + return nil, err |
| + } |
| + shared, err := sk.ECDH(pk) |
| + if err != nil { |
| + return nil, err |
| + } |
| + nonce, err := decode(e.Nonce) |
| + if err != nil || len(nonce) != 12 { |
| + return nil, errors.New("Invalid message nonce.") |
| + } |
| + key, err := hkdf.Key(sha256.New, shared, nonce, "infinity-message-v1", 32) |
| + if err != nil { |
| + return nil, err |
| + } |
| + box, err := aead(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + data, err := decode(e.Box) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return box.Open(nil, nonce, data, e.header()) |
| +} |
| + |
| +// Seal produces a signed encrypted v1 envelope, never a plaintext server request. |
| +func Seal(identityJSON, recipientJSON, payloadJSON string) (string, error) { |
| + id, e := readIdentity(identityJSON) |
| + if e != nil { |
| + return "", e |
| + } |
| + var to person |
| + if e = parse(recipientJSON, &to); e != nil { |
| + return "", e |
| + } |
| + var p struct { |
| + Kind string `json:"kind"` |
| + ID string `json:"id"` |
| + } |
| + if e = parse(payloadJSON, &p); e != nil { |
| + return "", e |
| + } |
| + messageID := "" |
| + if p.Kind == "message" { |
| + messageID = p.ID |
| + } |
| + out, e := seal(id, to, []byte(payloadJSON), messageID) |
| + if e != nil { |
| + return "", e |
| + } |
| + return marshal(out) |
| +} |
| + |
| +// Open verifies an envelope before returning decrypted JSON to native UI code. |
| +func Open(identityJSON, envelopeJSON string) (string, error) { |
| + id, e := readIdentity(identityJSON) |
| + if e != nil { |
| + return "", e |
| + } |
| + var box envelope |
| + if e = parse(envelopeJSON, &box); e != nil { |
| + return "", e |
| + } |
| + plain, e := open(id, box) |
| + return string(plain), e |
| +} |
diff --git a/mobile/crypto_test.go b/mobile/crypto_test.go| new file mode 100644 |
| index 0000000..c0b519b |
| --- /dev/null |
| +++ b/mobile/crypto_test.go |
| @@ -0,0 +1,124 @@ |
| +// 🤖 Native engine acceptance: real Web Crypto fixtures and fail-closed inputs. |
| +package ocho |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestCryptoAndTampering(t *testing.T) { |
| + a, e := NewIdentity("https://a.example", "Alice") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b, e := NewIdentity("https://b.example", "Bob") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + bid, _ := readIdentity(b) |
| + recipient, _ := marshal(bid.person()) |
| + payload := `{"kind":"message","thread":"test","id":"message","at":42,"epoch":0,"text":"Native Go ∞"}` |
| + box, e := Seal(a, recipient, payload) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + plain, e := Open(b, box) |
| + if e != nil || plain != payload { |
| + t.Fatal("roundtrip failed", e) |
| + } |
| + if _, e = Open(a, box); e == nil { |
| + t.Fatal("wrong recipient decrypted") |
| + } |
| + var envelope envelope |
| + _ = parse(box, &envelope) |
| + envelope.ID = "tampered" |
| + tampered, _ := marshal(envelope) |
| + if _, e = Open(b, tampered); e == nil { |
| + t.Fatal("tampered header accepted") |
| + } |
| + file, e := ExportRecovery(b, "recovery test password") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if strings.Contains(file, bid.PrivateEnc.D) { |
| + t.Fatal("private key leaked") |
| + } |
| + restored, e := ImportRecovery(file, "recovery test password") |
| + if e != nil || restored != b { |
| + t.Fatal("recovery failed", e) |
| + } |
| + if _, e = ImportRecovery(file, "incorrect test password"); e == nil { |
| + t.Fatal("wrong password accepted") |
| + } |
| + for _, input := range []string{"", `null`, `{}`, `{"to":"x"}`, strings.Repeat(".", 10)} { |
| + if _, e = Open(b, input); e == nil { |
| + t.Fatalf("accepted invalid envelope %q", input) |
| + } |
| + if _, e = Unlock(bid.VaultKey, input); e == nil { |
| + t.Fatalf("accepted invalid box %q", input) |
| + } |
| + } |
| +} |
| +func TestBrowserInterop(t *testing.T) { |
| + path := os.Getenv("OCHO_INTEROP_FIXTURE") |
| + if path == "" { |
| + t.Skip("tools/infinity/check-native supplies Web Crypto fixture") |
| + } |
| + var f struct { |
| + Sender identity `json:"sender"` |
| + Recipient identity `json:"recipient"` |
| + Envelope envelope `json:"envelope"` |
| + Vault string `json:"vault"` |
| + Recovery string `json:"recovery"` |
| + PRFKey string `json:"prfKey"` |
| + } |
| + b, e := os.ReadFile(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = json.Unmarshal(b, &f); e != nil { |
| + t.Fatal(e) |
| + } |
| + sender, _ := marshal(f.Sender) |
| + recipient, _ := marshal(f.Recipient) |
| + envelope, _ := marshal(f.Envelope) |
| + plain, e := Open(recipient, envelope) |
| + if e != nil || !strings.Contains(plain, "Web to Swift ∞") { |
| + t.Fatal("web envelope", e) |
| + } |
| + plain, e = Unlock(f.Recipient.VaultKey, f.Vault) |
| + if e != nil || !strings.Contains(plain, "Browser") { |
| + t.Fatal("web vault", e) |
| + } |
| + recovered, e := ImportRecovery(f.Recovery, "interop recovery password") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + id, e := readIdentity(recovered) |
| + if e != nil || id.Enc != f.Recipient.Enc { |
| + t.Fatal("web recovery", e) |
| + } |
| + key, e := PasskeyWrappingKey([]byte(strings.Repeat(string(byte(17)), 32)), []byte(strings.Repeat(string(byte(23)), 32))) |
| + if e != nil || key != f.PRFKey { |
| + t.Fatal("PRF derivation differs", e) |
| + } |
| + person, _ := marshal(f.Sender.person()) |
| + sealed, e := Seal(recipient, person, `{"kind":"message","thread":"interop","id":"go-message","text":"Go to Web ∞","at":42,"epoch":0}`) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + vault, e := Lock(f.Sender.VaultKey, `{"name":"Go","contacts":{},"invites":{},"accepted":{},"blocked":{}}`) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + recovery, e := ExportRecovery(sender, "interop recovery password") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + out, _ := json.Marshal(map[string]any{"envelope": json.RawMessage(sealed), "vault": vault, "recovery": recovery}) |
| + if e = os.WriteFile(path+".go-reply", out, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
diff --git a/mobile/go.mod b/mobile/go.mod| new file mode 100644 |
| index 0000000..ad36cab |
| --- /dev/null |
| +++ b/mobile/go.mod |
| @@ -0,0 +1,15 @@ |
| +module ocho.chat/mobile |
| + |
| +go 1.26.0 |
| + |
| +require ( |
| + golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 // indirect |
| + golang.org/x/mod v0.39.0 // indirect |
| + golang.org/x/sync v0.22.0 // indirect |
| + golang.org/x/tools v0.49.0 // indirect |
| +) |
| + |
| +tool ( |
| + golang.org/x/mobile/cmd/gobind |
| + golang.org/x/mobile/cmd/gomobile |
| +) |
diff --git a/mobile/go.sum b/mobile/go.sum| new file mode 100644 |
| index 0000000..27bc72a |
| --- /dev/null |
| +++ b/mobile/go.sum |
| @@ -0,0 +1,14 @@ |
| +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= |
| +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
| +golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 h1:D/wiQ6AoTYjDtSD0HMPhU8O40NUP8EF0UmDhIYCnG4I= |
| +golang.org/x/mobile v0.0.0-20260821190718-4776eadac327/go.mod h1:D9q8rgXu13Q3uuM+Vuy6F/DG1WF/giTPLtqQ9on5B1M= |
| +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= |
| +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= |
| +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= |
| +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= |
| +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= |
| +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= |
| +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= |
| +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= |
diff --git a/mobile/identity.go b/mobile/identity.go| new file mode 100644 |
| index 0000000..82ca5e1 |
| --- /dev/null |
| +++ b/mobile/identity.go |
| @@ -0,0 +1,126 @@ |
| +// Package ocho is Ocho’s shared native engine. Private identities stay on device. |
| +// 🤖 JSON boundary for gomobile Swift and Kotlin bindings. |
| +package ocho |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| +) |
| + |
| +func encode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| +func decode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } |
| +func randomBytes(n int) []byte { |
| + b := make([]byte, n) |
| + if _, e := rand.Read(b); e != nil { |
| + panic(e) |
| + } |
| + return b |
| +} |
| + |
| +// Random produces an opaque 256-bit identifier. |
| +func Random() string { return encode(randomBytes(32)) } |
| +func marshal(v any) (string, error) { b, e := json.Marshal(v); return string(b), e } |
| +func parse(s string, v any) error { |
| + if len(s) > 16<<20 { |
| + return errors.New("Input is too large.") |
| + } |
| + return json.Unmarshal([]byte(s), v) |
| +} |
| + |
| +type jwk struct { |
| + Kty string `json:"kty"` |
| + Crv string `json:"crv"` |
| + X string `json:"x"` |
| + Y string `json:"y"` |
| + D string `json:"d"` |
| + Ext bool `json:"ext"` |
| + Ops []string `json:"key_ops"` |
| +} |
| +type person struct { |
| + Origin string `json:"origin"` |
| + Name string `json:"name"` |
| + Enc string `json:"enc"` |
| + Signing string `json:"signing"` |
| + Cap string `json:"cap,omitempty"` |
| +} |
| +type identity struct { |
| + Version int `json:"version"` |
| + Origin string `json:"origin"` |
| + Name string `json:"name"` |
| + Auth string `json:"auth"` |
| + VaultKey string `json:"vaultKey"` |
| + Cap string `json:"cap"` |
| + Enc string `json:"enc"` |
| + Signing string `json:"signing"` |
| + PrivateEnc jwk `json:"privateEnc"` |
| + PrivateSign jwk `json:"privateSign"` |
| +} |
| + |
| +func (id identity) person() person { return person{id.Origin, id.Name, id.Enc, id.Signing, id.Cap} } |
| +func makeJWK(raw, pub []byte, op string) jwk { |
| + return jwk{"EC", "P-256", encode(pub[1:33]), encode(pub[33:65]), encode(raw), true, []string{op}} |
| +} |
| +func readIdentity(text string) (identity, error) { |
| + var id identity |
| + if e := parse(text, &id); e != nil { |
| + return id, e |
| + } |
| + if id.Version != 1 || len([]rune(id.Name)) > 80 { |
| + return id, errors.New("Invalid account identity.") |
| + } |
| + if _, e := secureOrigin(id.Origin); e != nil { |
| + return id, e |
| + } |
| + for _, s := range []string{id.Auth, id.Cap, id.VaultKey} { |
| + b, e := decode(s) |
| + if e != nil || len(b) != 32 { |
| + return id, errors.New("Invalid account key.") |
| + } |
| + } |
| + ep, e := decode(id.PrivateEnc.D) |
| + if e != nil { |
| + return id, e |
| + } |
| + enc, e := ecdh.P256().NewPrivateKey(ep) |
| + if e != nil { |
| + return id, e |
| + } |
| + sp, e := decode(id.PrivateSign.D) |
| + if e != nil { |
| + return id, e |
| + } |
| + sign, e := ecdh.P256().NewPrivateKey(sp) |
| + if e != nil { |
| + return id, e |
| + } |
| + if encode(enc.PublicKey().Bytes()) != id.Enc || encode(sign.PublicKey().Bytes()) != id.Signing { |
| + return id, errors.New("Account keys do not match.") |
| + } |
| + return id, nil |
| +} |
| + |
| +// NewIdentity creates independent encryption and signing keys on the device. |
| +func NewIdentity(origin, name string) (string, error) { |
| + if _, e := secureOrigin(origin); e != nil { |
| + return "", e |
| + } |
| + if len([]rune(name)) > 80 { |
| + return "", errors.New("Name is too long.") |
| + } |
| + enc, e := ecdh.P256().GenerateKey(rand.Reader) |
| + if e != nil { |
| + return "", e |
| + } |
| + sign, e := ecdh.P256().GenerateKey(rand.Reader) |
| + if e != nil { |
| + return "", e |
| + } |
| + id := identity{1, origin, name, Random(), Random(), Random(), encode(enc.PublicKey().Bytes()), encode(sign.PublicKey().Bytes()), makeJWK(enc.Bytes(), enc.PublicKey().Bytes(), "deriveBits"), makeJWK(sign.Bytes(), sign.PublicKey().Bytes(), "sign")} |
| + return marshal(id) |
| +} |
| + |
| +// ValidateIdentity rejects mismatched or malformed saved private identities. |
| +func ValidateIdentity(text string) error { _, e := readIdentity(text); return e } |
diff --git a/mobile/invite.go b/mobile/invite.go| new file mode 100644 |
| index 0000000..cfa0172 |
| --- /dev/null |
| +++ b/mobile/invite.go |
| @@ -0,0 +1,72 @@ |
| +// 🤖 Public links carry an invitation capability, never the owner's private identity. |
| +package ocho |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "net/url" |
| + "strings" |
| +) |
| + |
| +type invite struct { |
| + Version int `json:"version"` |
| + Thread string `json:"thread"` |
| + Token string `json:"token"` |
| + Person person `json:"person"` |
| +} |
| + |
| +func parseInvite(text string) (invite, error) { |
| + var out invite |
| + if len(text) > 16000 { |
| + return out, errors.New("Invitation is too long.") |
| + } |
| + u, e := url.Parse(text) |
| + if e != nil { |
| + return out, e |
| + } |
| + query, _ := url.ParseQuery(u.Fragment) |
| + if nested := query.Get("invite"); nested != "" { |
| + u, e = url.Parse(nested) |
| + if e != nil { |
| + return out, e |
| + } |
| + } |
| + b, e := decode(u.Fragment) |
| + if e != nil { |
| + return out, e |
| + } |
| + if e = json.Unmarshal(b, &out); e != nil { |
| + return out, e |
| + } |
| + origin, e := secureOrigin(out.Person.Origin) |
| + if e != nil { |
| + return out, e |
| + } |
| + if out.Version != 1 || textLength(out.Person.Name) > 80 || origin.Scheme != u.Scheme || origin.Host != u.Host { |
| + return out, errors.New("This invitation is incomplete.") |
| + } |
| + for s, n := range map[string]int{out.Thread: 32, out.Token: 32, out.Person.Enc: 65, out.Person.Signing: 65} { |
| + b, e := decode(s) |
| + if e != nil || len(b) != n { |
| + return out, errors.New("This invitation is incomplete.") |
| + } |
| + } |
| + return out, nil |
| +} |
| +func inviteLink(inv invite) (string, error) { |
| + b, e := json.Marshal(inv) |
| + if e != nil { |
| + return "", e |
| + } |
| + raw := inv.Person.Origin + "/connect#" + encode(b) |
| + return "https://ocho.chat/#invite=" + strings.ReplaceAll(url.QueryEscape(raw), "+", "%20"), nil |
| +} |
| + |
| +// ParseInvite validates a direct or public-front-door invitation and returns its JSON. |
| +func ParseInvite(link string) (string, error) { |
| + inv, e := parseInvite(link) |
| + if e != nil { |
| + return "", e |
| + } |
| + return marshal(inv) |
| +} |
diff --git a/mobile/network.go b/mobile/network.go| new file mode 100644 |
| index 0000000..906a3bb |
| --- /dev/null |
| +++ b/mobile/network.go |
| @@ -0,0 +1,92 @@ |
| +// 🤖 Bounded transport; native API calls never carry cookies or follow redirects. |
| +package ocho |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +func secureOrigin(text string) (*url.URL, error) { |
| + u, e := url.Parse(text) |
| + if e != nil { |
| + return nil, e |
| + } |
| + local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" |
| + if u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || (u.Scheme != "https" && !(u.Scheme == "http" && local)) { |
| + return nil, errors.New("Use a secure Ocho account address.") |
| + } |
| + return u, nil |
| +} |
| + |
| +var transport = &http.Client{Timeout: 35 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + |
| +func request(origin, path, auth, method string, body any, out any) error { |
| + if _, e := secureOrigin(origin); e != nil { |
| + return e |
| + } |
| + if !strings.HasPrefix(path, "/api/") { |
| + return errors.New("Invalid account request.") |
| + } |
| + var b []byte |
| + var e error |
| + if body != nil { |
| + b, e = json.Marshal(body) |
| + if e != nil { |
| + return e |
| + } |
| + } |
| + req, e := http.NewRequest(method, strings.TrimSuffix(origin, "/")+path, bytes.NewReader(b)) |
| + if e != nil { |
| + return e |
| + } |
| + req.Header.Set("Origin", origin) |
| + if auth != "" { |
| + req.Header.Set("Authorization", "Bearer "+auth) |
| + } |
| + if body != nil { |
| + req.Header.Set("Content-Type", "application/json") |
| + } |
| + res, e := transport.Do(req) |
| + if e != nil { |
| + return errors.New("Your account is temporarily unavailable. Please try again.") |
| + } |
| + defer res.Body.Close() |
| + data, e := io.ReadAll(io.LimitReader(res.Body, (16<<20)+1)) |
| + if e != nil || len(data) > 16<<20 { |
| + return errors.New("Your account response is unavailable.") |
| + } |
| + if res.StatusCode < 200 || res.StatusCode >= 300 { |
| + var failure struct { |
| + Error string `json:"error"` |
| + } |
| + _ = json.Unmarshal(data, &failure) |
| + if failure.Error != "" { |
| + return errors.New(failure.Error) |
| + } |
| + return errors.New("Your account is temporarily unavailable. Please try again.") |
| + } |
| + if out == nil { |
| + return nil |
| + } |
| + return json.Unmarshal(data, out) |
| +} |
| + |
| +// RequestJSON exposes bounded HTTPS/localhost JSON APIs for platform-owned passkey ceremonies. |
| +// Callers must never pass private identity or PRF values as a request body. |
| +func RequestJSON(origin, path, auth, method, bodyJSON string) (string, error) { |
| + var body any |
| + if bodyJSON != "" { |
| + if e := parse(bodyJSON, &body); e != nil { |
| + return "", e |
| + } |
| + } |
| + var out json.RawMessage |
| + e := request(origin, path, auth, method, body, &out) |
| + return string(out), e |
| +} |
diff --git a/mobile/projection.go b/mobile/projection.go| new file mode 100644 |
| index 0000000..d821109 |
| --- /dev/null |
| +++ b/mobile/projection.go |
| @@ -0,0 +1,265 @@ |
| +// 🤖 Verified relationship projection is shared by both native platforms. |
| +package ocho |
| + |
| +import ( |
| + "encoding/json" |
| + "sort" |
| + "strconv" |
| + "unicode/utf16" |
| +) |
| + |
| +type contact struct { |
| + Person person `json:"person"` |
| + Pending bool `json:"pending"` |
| +} |
| +type vault struct { |
| + Name string `json:"name"` |
| + Contacts map[string]contact `json:"contacts"` |
| + Invites map[string]bool `json:"invites"` |
| + Accepted map[string]bool `json:"accepted"` |
| + Blocked map[string]bool `json:"blocked"` |
| +} |
| + |
| +func newVault(name string) vault { |
| + return vault{name, map[string]contact{}, map[string]bool{}, map[string]bool{}, map[string]bool{}} |
| +} |
| + |
| +type event struct { |
| + invalid bool |
| + Kind string `json:"kind"` |
| + Thread string `json:"thread"` |
| + Person *person `json:"person,omitempty"` |
| + At *int64 `json:"at,omitempty"` |
| + ID string `json:"id,omitempty"` |
| + Text string `json:"text,omitempty"` |
| + Epoch *int `json:"epoch,omitempty"` |
| + Name string `json:"name,omitempty"` |
| + Members []person `json:"members,omitempty"` |
| + Owner string `json:"owner,omitempty"` |
| + Signer string `json:"signer,omitempty"` |
| +} |
| + |
| +func (e *event) UnmarshalJSON(data []byte) error { |
| + type plain event |
| + var value plain |
| + if err := json.Unmarshal(data, &value); err != nil { |
| + return err |
| + } |
| + *e = event(value) |
| + var fields map[string]json.RawMessage |
| + if err := json.Unmarshal(data, &fields); err != nil { |
| + return err |
| + } |
| + if e.Kind == "message" { |
| + var text string |
| + e.invalid = fields["text"] == nil || string(fields["text"]) == "null" || json.Unmarshal(fields["text"], &text) != nil |
| + } |
| + if e.Kind == "group" { |
| + var name string |
| + e.invalid = fields["name"] == nil || string(fields["name"]) == "null" || json.Unmarshal(fields["name"], &name) != nil |
| + } |
| + return nil |
| +} |
| + |
| +type message struct { |
| + ID string `json:"id"` |
| + Text string `json:"text"` |
| + At int64 `json:"at"` |
| + Mine bool `json:"mine"` |
| + Name string `json:"name"` |
| + Signer string `json:"signer"` |
| + Delivery string `json:"delivery,omitempty"` |
| +} |
| +type thread struct { |
| + ID string `json:"id"` |
| + Name string `json:"name"` |
| + Peer *person `json:"peer,omitempty"` |
| + Members []person `json:"members"` |
| + Pending bool `json:"pending"` |
| + Group bool `json:"group"` |
| + Owner string `json:"owner,omitempty"` |
| + Epoch int `json:"epoch"` |
| + Left bool `json:"left"` |
| + Messages []message `json:"messages"` |
| +} |
| +type projection struct { |
| + Threads []*thread `json:"threads"` |
| + Requests []event `json:"requests"` |
| +} |
| + |
| +func textLength(s string) int { return len(utf16.Encode([]rune(s))) } |
| +func validPerson(p person) bool { |
| + u, e := secureOrigin(p.Origin) |
| + return e == nil && u.Scheme+"://"+u.Host == p.Origin && textLength(p.Name) <= 80 && p.Cap != "" |
| +} |
| +func contains(members []person, signer string) bool { |
| + for _, p := range members { |
| + if p.Signing == signer { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| +func project(id identity, state vault, events []event) projection { |
| + me := id.person() |
| + me.Name = state.Name |
| + threads := map[string]*thread{} |
| + requests := map[string]event{} |
| + snapshots := map[string][]person{} |
| + for key, c := range state.Contacts { |
| + if state.Blocked[key] { |
| + continue |
| + } |
| + p := c.Person |
| + threads[key] = &thread{ID: key, Name: p.Name, Peer: &p, Members: []person{me, p}, Pending: c.Pending, Messages: []message{}} |
| + } |
| + for _, e := range events { |
| + if state.Blocked[e.Thread] { |
| + continue |
| + } |
| + t := threads[e.Thread] |
| + p := e.Person |
| + if e.Kind == "contact-request" && state.Invites[e.Thread] && p != nil && validPerson(*p) && e.Signer == p.Signing && e.Signer != me.Signing && t == nil { |
| + if _, ok := requests[e.Thread]; !ok { |
| + requests[e.Thread] = e |
| + } |
| + } |
| + if e.Kind == "contact-accept" && t != nil && t.Peer != nil && p != nil && validPerson(*p) && t.Peer.Signing == e.Signer && p.Signing == e.Signer && p.Enc == t.Peer.Enc && p.Origin == t.Peer.Origin { |
| + t.Peer = p |
| + t.Members = []person{me, *p} |
| + t.Pending = false |
| + } |
| + } |
| + groups := []event{} |
| + for _, e := range events { |
| + if !e.invalid && e.Kind == "group" && e.Epoch != nil { |
| + groups = append(groups, e) |
| + } |
| + } |
| + sort.SliceStable(groups, func(i, j int) bool { return *groups[i].Epoch < *groups[j].Epoch }) |
| + for _, e := range groups { |
| + epoch := *e.Epoch |
| + if state.Blocked[e.Thread] || epoch < 1 || e.Owner != e.Signer || textLength(e.Name) > 80 || len(e.Members) == 0 || len(e.Members) > 32 || !contains(e.Members, e.Owner) { |
| + continue |
| + } |
| + unique := map[string]bool{} |
| + valid := true |
| + for _, p := range e.Members { |
| + if !validPerson(p) || unique[p.Signing] { |
| + valid = false |
| + break |
| + } |
| + unique[p.Signing] = true |
| + } |
| + if !valid { |
| + continue |
| + } |
| + old := threads[e.Thread] |
| + if old != nil { |
| + if !old.Group || old.Owner != e.Owner || epoch != old.Epoch+1 { |
| + continue |
| + } |
| + } else { |
| + if epoch != 1 || !contains(e.Members, me.Signing) { |
| + continue |
| + } |
| + known := e.Owner == me.Signing |
| + for _, t := range threads { |
| + if !t.Group && !t.Pending && t.Peer != nil && t.Peer.Signing == e.Owner { |
| + known = true |
| + } |
| + } |
| + if !known { |
| + continue |
| + } |
| + } |
| + snapshots[e.Thread+":"+strconv.Itoa(epoch)] = e.Members |
| + pending := e.Owner != me.Signing && !state.Accepted[e.Thread] |
| + left := !contains(e.Members, me.Signing) |
| + threads[e.Thread] = &thread{ID: e.Thread, Name: e.Name, Group: true, Owner: e.Owner, Epoch: epoch, Members: e.Members, Pending: pending, Left: left, Messages: []message{}} |
| + if pending && !left { |
| + e.Kind = "group-request" |
| + requests[e.Thread] = e |
| + } |
| + } |
| + seen := map[string]bool{} |
| + for _, e := range events { |
| + if e.invalid || e.Kind != "message" || textLength(e.Text) > 8000 || e.ID == "" || e.At == nil || *e.At < 0 || *e.At > 8640000000000000 { |
| + continue |
| + } |
| + t := threads[e.Thread] |
| + if t == nil || t.Pending || e.Epoch == nil { |
| + continue |
| + } |
| + members := t.Members |
| + if t.Group { |
| + members = snapshots[e.Thread+":"+strconv.Itoa(*e.Epoch)] |
| + } else if *e.Epoch != 0 { |
| + continue |
| + } |
| + if !contains(members, e.Signer) || !contains(members, me.Signing) { |
| + continue |
| + } |
| + key := e.Thread + ":" + e.ID |
| + if seen[key] { |
| + continue |
| + } |
| + seen[key] = true |
| + name := "" |
| + for _, p := range members { |
| + if p.Signing == e.Signer { |
| + name = p.Name |
| + } |
| + } |
| + t.Messages = append(t.Messages, message{ID: e.ID, Text: e.Text, At: *e.At, Mine: e.Signer == me.Signing, Name: name, Signer: e.Signer}) |
| + } |
| + out := projection{Threads: []*thread{}, Requests: []event{}} |
| + for _, t := range threads { |
| + if t.Pending && t.Group { |
| + continue |
| + } |
| + sort.SliceStable(t.Messages, func(i, j int) bool { |
| + a, b := t.Messages[i], t.Messages[j] |
| + if a.At == b.At { |
| + return a.ID < b.ID |
| + } |
| + return a.At < b.At |
| + }) |
| + out.Threads = append(out.Threads, t) |
| + } |
| + latest := func(t *thread) int64 { |
| + if len(t.Messages) == 0 { |
| + return 0 |
| + } |
| + return t.Messages[len(t.Messages)-1].At |
| + } |
| + sort.Slice(out.Threads, func(i, j int) bool { |
| + a, b := out.Threads[i], out.Threads[j] |
| + if latest(a) == latest(b) { |
| + return a.ID < b.ID |
| + } |
| + return latest(a) > latest(b) |
| + }) |
| + for _, r := range requests { |
| + out.Requests = append(out.Requests, r) |
| + } |
| + sort.Slice(out.Requests, func(i, j int) bool { return out.Requests[i].Thread < out.Requests[j].Thread }) |
| + return out |
| +} |
| + |
| +// Project rebuilds a native view from already-verified events and a decrypted vault. |
| +func Project(identityJSON, vaultJSON, eventsJSON string) (string, error) { |
| + id, e := readIdentity(identityJSON) |
| + if e != nil { |
| + return "", e |
| + } |
| + var state vault |
| + var events []event |
| + if e = parse(vaultJSON, &state); e != nil { |
| + return "", e |
| + } |
| + if e = parse(eventsJSON, &events); e != nil { |
| + return "", e |
| + } |
| + return marshal(project(id, state, events)) |
| +} |
diff --git a/mobile/recovery.go b/mobile/recovery.go| new file mode 100644 |
| index 0000000..718f17b |
| --- /dev/null |
| +++ b/mobile/recovery.go |
| @@ -0,0 +1,117 @@ |
| +// 🤖 Local vault encryption, password recovery and passkey PRF key wrapping. |
| +package ocho |
| + |
| +import ( |
| + "crypto/hkdf" |
| + "crypto/pbkdf2" |
| + "crypto/sha256" |
| + "errors" |
| + "strings" |
| +) |
| + |
| +// Lock uses the existing Ocho vault framing and authenticated-data domain. |
| +func Lock(key, valueJSON string) (string, error) { |
| + raw, e := decode(key) |
| + if e != nil { |
| + return "", e |
| + } |
| + box, e := aead(raw) |
| + if e != nil { |
| + return "", e |
| + } |
| + nonce := randomBytes(12) |
| + return encode(nonce) + "." + encode(box.Seal(nil, nonce, []byte(valueJSON), []byte("infinity-vault-v1"))), nil |
| +} |
| + |
| +// Unlock decrypts a vault or sealed identity locally. |
| +func Unlock(key, text string) (string, error) { |
| + parts := strings.Split(text, ".") |
| + if len(parts) != 2 { |
| + return "", errors.New("Invalid encrypted file.") |
| + } |
| + nonce, e := decode(parts[0]) |
| + if e != nil || len(nonce) != 12 { |
| + return "", errors.New("Invalid encrypted nonce.") |
| + } |
| + raw, e := decode(key) |
| + if e != nil { |
| + return "", e |
| + } |
| + box, e := aead(raw) |
| + if e != nil { |
| + return "", e |
| + } |
| + data, e := decode(parts[1]) |
| + if e != nil { |
| + return "", e |
| + } |
| + plain, e := box.Open(nil, nonce, data, []byte("infinity-vault-v1")) |
| + return string(plain), e |
| +} |
| + |
| +type recovery struct { |
| + Format string `json:"format"` |
| + Salt string `json:"salt"` |
| + Box string `json:"box"` |
| +} |
| + |
| +func recoveryKey(password, salt string) (string, error) { |
| + if len([]rune(password)) < 12 { |
| + return "", errors.New("Use at least 12 characters for your recovery password.") |
| + } |
| + s, e := decode(salt) |
| + if e != nil { |
| + return "", e |
| + } |
| + key, e := pbkdf2.Key(sha256.New, password, s, 600000, 32) |
| + return encode(key), e |
| +} |
| + |
| +// ExportRecovery protects the identity using the user's recovery password. |
| +func ExportRecovery(identityJSON, password string) (string, error) { |
| + if e := ValidateIdentity(identityJSON); e != nil { |
| + return "", e |
| + } |
| + salt := Random() |
| + key, e := recoveryKey(password, salt) |
| + if e != nil { |
| + return "", e |
| + } |
| + box, e := Lock(key, identityJSON) |
| + if e != nil { |
| + return "", e |
| + } |
| + return marshal(recovery{"infinity-recovery-v1", salt, box}) |
| +} |
| + |
| +// ImportRecovery authenticates/decrypts and validates an existing recovery file. |
| +func ImportRecovery(text, password string) (string, error) { |
| + if len(text) > 100000 { |
| + return "", errors.New("Recovery file is too large.") |
| + } |
| + var file recovery |
| + if e := parse(text, &file); e != nil { |
| + return "", e |
| + } |
| + if file.Format != "infinity-recovery-v1" { |
| + return "", errors.New("Not an Ocho recovery file.") |
| + } |
| + key, e := recoveryKey(password, file.Salt) |
| + if e != nil { |
| + return "", e |
| + } |
| + id, e := Unlock(key, file.Box) |
| + if e != nil { |
| + return "", e |
| + } |
| + return id, ValidateIdentity(id) |
| +} |
| + |
| +// PasskeyWrappingKey accepts a local PRF result, not a server-provided secret. |
| +func PasskeyWrappingKey(output, credentialID []byte) (string, error) { |
| + if len(output) != 32 { |
| + return "", errors.New("This passkey provider cannot unlock encrypted messages.") |
| + } |
| + key, e := hkdf.Key(sha256.New, output, []byte("ocho-passkey-wrap-v1"), string(credentialID), 32) |
| + return encode(key), e |
| +} |
diff --git a/mobile/registration.go b/mobile/registration.go| new file mode 100644 |
| index 0000000..474d1f9 |
| --- /dev/null |
| +++ b/mobile/registration.go |
| @@ -0,0 +1,103 @@ |
| +// 🤖 Native signup shares the framework's proof and idempotent provisioning flow. |
| +package ocho |
| + |
| +import ( |
| + "crypto/sha256" |
| + "errors" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Progress carries human-readable stages back to native UI code. |
| +type Progress interface{ Update(message string) } |
| + |
| +func report(p Progress, message string) { |
| + if p != nil { |
| + p.Update(message) |
| + } |
| +} |
| + |
| +// Register allocates an account and returns identity+claim for secure local retry storage. |
| +func Register(home, name, nonce string, progress Progress) (string, error) { |
| + var challenge struct { |
| + Fields struct { |
| + Values map[string]string `json:"values"` |
| + Counter string `json:"counter"` |
| + } `json:"fields"` |
| + Nonce string `json:"nonce"` |
| + Difficulty int `json:"difficulty"` |
| + Wait int `json:"wait"` |
| + } |
| + report(progress, "Checking this device…") |
| + if e := request(home, "/api/registration-challenge", "", "GET", nil, &challenge); e != nil { |
| + return "", e |
| + } |
| + if challenge.Fields.Values == nil || challenge.Fields.Counter == "" || challenge.Difficulty < 0 || challenge.Difficulty > 24 || challenge.Wait < 0 || challenge.Wait > 60000 { |
| + return "", errors.New("Account setup is unavailable.") |
| + } |
| + began := time.Now() |
| + prefix := challenge.Nonce + ":" + strings.ToLower(strings.TrimSpace(nonce)) + ":" |
| + counter := "" |
| + for n := 0; n < 100000000; n++ { |
| + if n%4096 == 0 && time.Since(began) > 60*time.Second { |
| + break |
| + } |
| + sum := sha256.Sum256([]byte(prefix + strconv.Itoa(n))) |
| + remaining, valid := challenge.Difficulty, true |
| + for _, b := range sum { |
| + bits := min(remaining, 8) |
| + if bits == 0 { |
| + break |
| + } |
| + if b>>(8-bits) != 0 { |
| + valid = false |
| + break |
| + } |
| + remaining -= bits |
| + } |
| + if valid { |
| + counter = strconv.Itoa(n) |
| + break |
| + } |
| + } |
| + if counter == "" { |
| + return "", errors.New("Account setup took too long. Please try again.") |
| + } |
| + challenge.Fields.Values[challenge.Fields.Counter] = counter |
| + if wait := time.Duration(challenge.Wait)*time.Millisecond - time.Since(began); wait > 0 { |
| + time.Sleep(wait) |
| + } |
| + deadline := time.Now().Add(120 * time.Second) |
| + report(progress, "Making room for you…") |
| + for time.Now().Before(deadline) { |
| + var reply struct { |
| + Origin string `json:"origin"` |
| + Claim string `json:"claim"` |
| + Preparing bool `json:"preparing"` |
| + } |
| + if e := request(home, "/api/accounts", "", "POST", map[string]any{"nonce": nonce, "proof": challenge.Fields.Values}, &reply); e != nil { |
| + return "", e |
| + } |
| + if reply.Origin != "" && reply.Claim != "" { |
| + text, e := NewIdentity(reply.Origin, name) |
| + if e != nil { |
| + return "", e |
| + } |
| + id, e := readIdentity(text) |
| + if e != nil { |
| + return "", e |
| + } |
| + return marshal(struct { |
| + Identity identity `json:"identity"` |
| + Claim string `json:"claim"` |
| + }{id, reply.Claim}) |
| + } |
| + report(progress, "Starting your private account…") |
| + time.Sleep(1500 * time.Millisecond) |
| + } |
| + return "", errors.New("Your account is taking longer than expected. Try again to continue setting up the same account.") |
| +} |
| + |
| +// ValidateOrigin is shared by platform-native recovery and connection flows. |
| +func ValidateOrigin(origin string) error { _, e := secureOrigin(origin); return e } |
diff --git a/test/infinity/home-browser.mjs b/test/infinity/home-browser.mjs| index 3e16e66..4bcd0fc 100644 |
| --- a/test/infinity/home-browser.mjs |
| +++ b/test/infinity/home-browser.mjs |
| @@ -20,6 +20,16 @@ try { |
| 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')); |
| + // Put real encrypted history in the account before testing key recovery. |
| + const otherContext=await browser.newContext(), other=await otherContext.newPage(); |
| + await other.goto(home);await other.getByLabel('Your name',{exact:true}).fill('Bob'); |
| + await other.getByRole('button',{name:'Create your account',exact:true}).click();await other.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000}); |
| + const invitation=await page.evaluate(async()=>{const {Client}=await import('/static/client.js');return new Client(JSON.parse(localStorage.getItem('infinity.identity.v1'))).invite();}); |
| + await other.evaluate(async invite=>{const {Client}=await import('/static/client.js');const {parseInvite}=await import('/static/api.js');await new Client(JSON.parse(localStorage.getItem('infinity.identity.v1'))).connect(parseInvite(invite));},invitation); |
| + await page.getByRole('button',{name:'Accept',exact:true}).click(); |
| + await page.getByRole('textbox',{name:'Message',exact:true}).fill('History survives passkey sign-in');await page.getByRole('button',{name:'Send message',exact:true}).click(); |
| + await page.getByRole('log').getByText('History survives passkey sign-in',{exact:true}).waitFor(); |
| + |
| 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(); |
| @@ -31,7 +41,22 @@ try { |
| 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.locator('.thread-row').filter({hasText:'Bob'}).click();await page.getByRole('log').getByText('History survives passkey sign-in',{exact:true}).waitFor(); |
| + |
| 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.'); |
| + console.log('Chromium: stable-origin signup, encrypted DMs, PRF enrollment, discoverable sign-in, restored message history and signed-in reload passed.'); |
| + const safari=await webkit.launch(); |
| + try { |
| + const p=await safari.newPage();await p.goto(home); |
| + await p.getByLabel('Your name',{exact:true}).fill('Safari recovery');await p.getByRole('button',{name:'Create your account',exact:true}).click();await p.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000}); |
| + const saved=await p.evaluate(async()=>{const {exportRecovery}=await import('/static/crypto.js');return exportRecovery(JSON.parse(localStorage.getItem('infinity.identity.v1')),'safari recovery password');}); |
| + const enc=await p.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')).enc); |
| + await p.evaluate(()=>localStorage.clear());await p.reload();await p.getByRole('button',{name:'I have a recovery file',exact:true}).click(); |
| + await p.getByLabel('Recovery file',{exact:true}).setInputFiles({name:'recovery.json',mimeType:'application/json',buffer:Buffer.from(saved)}); |
| + await p.getByLabel('Recovery password',{exact:true}).fill('safari recovery password');await p.getByRole('button',{name:'Open your account',exact:true}).click();await p.getByRole('button',{name:'Invite',exact:true}).waitFor(); |
| + assert.equal(new URL(p.url()).origin,home);assert.equal(await p.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')).enc),enc); |
| + console.log('WebKit: stable-origin signup and recovery-file restoration passed.'); |
| + } finally {await safari.close();} |
| + |
| } finally {if(browser)await browser.close();server.kill('SIGTERM');} |
diff --git a/tools/infinity/build-mobile b/tools/infinity/build-mobile| new file mode 100755 |
| index 0000000..894be14 |
| --- /dev/null |
| +++ b/tools/infinity/build-mobile |
| @@ -0,0 +1,15 @@ |
| +#!/bin/sh |
| +# 🤖 Shared native engine, pinned gomobile toolchain; no server packages in the artifact. |
| +set -eu |
| +cd "$(dirname "$0")/../../mobile" |
| +case "${1:-apple}" in |
| + apple) |
| + mkdir -p ../apple/OchoCore/Artifacts |
| + go tool gomobile bind -target=ios/arm64,iossimulator/arm64,macos/arm64 -o ../apple/OchoCore/Artifacts/OchoEngine.xcframework . |
| + ;; |
| + android) |
| + mkdir -p ../.build/mobile |
| + go tool gomobile bind -target=android/arm64,android/amd64 -androidapi=26 -javapkg=chat.ocho.engine -o ../.build/mobile/ocho-engine.aar . |
| + ;; |
| + *) echo 'usage: tools/infinity/build-mobile [apple|android]' >&2;exit 2;; |
| +esac |
diff --git a/tools/infinity/check b/tools/infinity/check| index f067270..74a3e4c 100755 |
| --- a/tools/infinity/check |
| +++ b/tools/infinity/check |
| @@ -13,10 +13,12 @@ case "${1:-all}" in |
| INFINITY_BROWSER=webkit node test/infinity/browser.mjs |
| ;; |
| mac) |
| + tools/infinity/build-mobile apple |
| xcodegen generate --spec apple/project.yml |
| xcodebuild -project apple/Infinity.xcodeproj -scheme InfinityMac -destination 'platform=macOS' -derivedDataPath apple/build CODE_SIGNING_ALLOWED=NO build |
| ;; |
| ios) |
| + tools/infinity/build-mobile apple |
| xcodegen generate --spec apple/project.yml |
| xcodebuild -project apple/Infinity.xcodeproj -scheme Infinity -destination 'generic/platform=iOS Simulator' -derivedDataPath apple/build CODE_SIGNING_ALLOWED=NO build |
| ;; |
diff --git a/tools/infinity/check-native b/tools/infinity/check-native| index eac6e51..14e9036 100755 |
| --- a/tools/infinity/check-native |
| +++ b/tools/infinity/check-native |
| @@ -2,6 +2,7 @@ |
| # 🤖 Native protocol, browser compatibility and real personal-process integration. |
| set -eu |
| cd "$(dirname "$0")/../.." |
| +tools/infinity/build-mobile apple |
| 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" |
diff --git a/tools/infinity/check-native-crypto b/tools/infinity/check-native-crypto| index 9cc0113..e5dca29 100755 |
| --- a/tools/infinity/check-native-crypto |
| +++ b/tools/infinity/check-native-crypto |
| @@ -16,6 +16,7 @@ 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 |
| +(cd mobile && go test ./...) |
| swift test --package-path apple/OchoCore |
| node --input-type=module <<'JS' |
| import {readFileSync} from 'node:fs'; |
| @@ -26,5 +27,9 @@ 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.'); |
| +const go = JSON.parse(readFileSync(process.env.OCHO_INTEROP_FIXTURE+'.go-reply')); |
| +assert.equal((await open(fixture.sender,go.envelope)).text,'Go to Web ∞'); |
| +assert.equal((await unlock(fixture.sender.vaultKey,go.vault)).name,'Go'); |
| +assert.equal((await importRecovery(go.recovery,'interop recovery password')).enc,fixture.sender.enc); |
| +console.log('Shared Go engine ↔ Web Crypto envelopes, vaults, recovery and PRF passed through both Go and Swift bindings.'); |
| JS |
diff --git a/tools/infinity/check-native-ui b/tools/infinity/check-native-ui| index aae89de..d23d16e 100755 |
| --- a/tools/infinity/check-native-ui |
| +++ b/tools/infinity/check-native-ui |
| @@ -2,6 +2,7 @@ |
| # 🤖 Two disposable accounts drive the actual iPhone SwiftUI app. |
| set -eu |
| cd "$(dirname "$0")/../.." |
| +tools/infinity/build-mobile apple |
| mkdir -p .build/native-check |
| CGO_ENABLED=0 go build -o .build/native-check/infinity ./cmd/infinity |
| native_ui_dir=$(mktemp -d) |