eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 4d5be3db3dadbdb26a0fb10afecb42d7cbd60d44 parent b726569
41 files changed, +1801 −325
  • .gitignore +2 −0
  • apple/Infinity/AccountSheets.swift +3 −1
  • apple/Infinity/AccountStore.swift +8 −0
  • apple/Infinity/SetupProgressView.swift +29 −0
  • apple/Infinity/WelcomeView.swift +1 −3
  • apple/InfinityUITests/NativeChatTests.swift +2 −2
  • apple/OchoCore/Package.swift +1 −1
  • apple/OchoCore/Sources/OchoCore/API.swift +8 −39
  • apple/OchoCore/Sources/OchoCore/Client.swift +17 −110
  • apple/OchoCore/Sources/OchoCore/Crypto.swift +19 −76
  • apple/OchoCore/Sources/OchoCore/Engine.swift +11 −0
  • apple/OchoCore/Sources/OchoCore/LegacyIdentity.swift +42 −0
  • apple/OchoCore/Sources/OchoCore/Models.swift +8 −39
  • apple/OchoCore/Sources/OchoCore/Registration.swift +11 −42
  • apple/OchoCore/Tests/OchoCoreTests/ClientTests.swift +1 −1
  • apple/OchoCore/Tests/OchoCoreTests/CryptoTests.swift +2 −2
  • apple/OchoCore/Tests/OchoCoreTests/LegacyTests.swift +24 −0
  • apple/OchoCore/Tests/OchoCoreTests/ProjectionTests.swift +4 −4
  • docs/infinity/android-handoff.md +25 −0
  • docs/infinity/native-passkeys.md +5 −1
  • docs/infinity/status.md +6 −0
  • internal/infinity/static/api.js +3 −0
  • internal/infinity/static/client.js +3 −2
  • mobile/client.go +195 −0
  • mobile/conversations.go +205 −0
  • mobile/crypto.go +187 −0
  • mobile/crypto_test.go +124 −0
  • mobile/go.mod +15 −0
  • mobile/go.sum +14 −0
  • mobile/identity.go +126 −0
  • mobile/invite.go +72 −0
  • mobile/network.go +92 −0
  • mobile/projection.go +265 −0
  • mobile/recovery.go +117 −0
  • mobile/registration.go +103 −0
  • test/infinity/home-browser.mjs +26 −1
  • tools/infinity/build-mobile +15 −0
  • tools/infinity/check +2 −0
  • tools/infinity/check-native +1 −0
  • tools/infinity/check-native-crypto +6 −1
  • tools/infinity/check-native-ui +1 −0
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)