eleven / ocho Public

Simplify Ocho to passkey-only signup and short private invitations

Keep chats on ocho.chat, bind one recipient per encrypted invite, and share profile and consent state across web and native clients.
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 825a93fec21f0f9df4db4a17fd613921807c568a parent ac95203
62 files changed, +1765 −566
  • README.md +4 −4
  • android/README.md +2 −2
  • android/app/build.gradle.kts +1 −1
  • android/app/src/main/java/chat/ocho/app/AccountDialogs.kt +27 −19
  • android/app/src/main/java/chat/ocho/app/AccountModel.kt +12 −8
  • android/app/src/main/java/chat/ocho/app/MainActivity.kt +6 −13
  • android/app/src/main/java/chat/ocho/app/OchoScreen.kt +6 −5
  • android/app/src/main/java/chat/ocho/app/Passkeys.kt +24 −11
  • android/app/src/main/java/chat/ocho/app/ProfileAvatar.kt +46 −0
  • apple/Infinity/AccountSheets.swift +5 −36
  • apple/Infinity/AccountStore.swift +34 −16
  • apple/Infinity/NativePasskeys.swift +23 −6
  • apple/Infinity/OchoRootView.swift +5 −4
  • apple/Infinity/ProfileSheet.swift +43 −0
  • apple/Infinity/WelcomeView.swift +17 −41
  • apple/OchoCore/Sources/OchoCore/API.swift +2 −0
  • apple/OchoCore/Sources/OchoCore/Client.swift +2 −0
  • apple/OchoCore/Sources/OchoCore/Crypto.swift +3 −1
  • apple/README.md +4 −4
  • apple/project.yml +1 −1
  • docs/infinity/simple-onboarding.md +3 −1
  • docs/infinity/status.md +18 −35
  • internal/infinity/app.go +2 −2
  • internal/infinity/inbox.go +20 −1
  • internal/infinity/migrations/0003_invite_consent.sql +4 −0
  • internal/infinity/migrations/0004_vault_format.sql +2 −0
  • internal/infinity/outbox.go +10 −6
  • internal/infinity/simple_test.go +53 −0
  • internal/infinity/static/app.js +10 −5
  • internal/infinity/static/avatars.js +23 −0
  • internal/infinity/static/chat.js +6 −5
  • internal/infinity/static/client.js +44 −9
  • internal/infinity/static/crypto.js +1 −1
  • internal/infinity/static/dialogs.js +6 −34
  • internal/infinity/static/dom.js +2 −1
  • internal/infinity/static/invitations.js +64 −0
  • internal/infinity/static/onboarding.js +91 −95
  • internal/infinity/static/passkeys.js +36 −38
  • internal/infinity/static/profile.js +20 −0
  • internal/infinity/static/protocol.js +13 −2
  • internal/infinity/static/style.css +6 −0
  • internal/infinity/vault.go +17 −6
  • internal/infinityhome/home.go +2 −0
  • internal/infinityhome/invites.go +133 −0
  • internal/infinityhome/migrations/0005_simple_signup.sql +8 −0
  • internal/infinityhome/passkeys.go +5 −1
  • internal/infinityhome/signup.go +103 −0
  • internal/infinityhome/simple_test.go +139 −0
  • internal/infinityinvite/claim.go +43 −0
  • mobile/client.go +15 −1
  • mobile/consent.go +70 −0
  • mobile/consent_test.go +86 −0
  • mobile/conversations.go +20 −23
  • mobile/identity.go +6 −2
  • mobile/invite.go +11 −4
  • mobile/profile.go +114 −0
  • mobile/projection.go +32 −21
  • mobile/short_invite.go +207 −0
  • test/infinity/browser.mjs +12 −41
  • test/infinity/home-browser.mjs +32 −57
  • tools/infinity/check +2 −2
  • tools/infinity/check-native-crypto +7 −1
diff --git a/README.md b/README.md
index f5b76cc..5f0ecbb 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
A personal, end-to-end encrypted messenger. Share a link, connect, chat. No phone number. One private instance per person, built on Carlos. DMs and group chats in one list; voice and video come later.
-Text DMs, consent-gated groups, encrypted history, delivery retries, recovery files and PRF passkey recovery are implemented. Native SwiftUI and Kotlin/Compose apps share a Go/gomobile engine. The web client stays at ocho.chat, backed by separate personal instances. Local and hosted browser checks pass; physical passkey provider verification and store distribution remain outstanding. See [current status](docs/infinity/status.md) and [DESIGN.md](DESIGN.md).
+Text DMs, consent-gated groups, encrypted history, delivery retries, passkey-only signup/sign-in, short single-use invitations, encrypted profiles and Apple activity notifications are implemented. Native SwiftUI and Kotlin/Compose apps share a Go/gomobile engine. The web client stays at ocho.chat, backed by separate personal instances. Local and hosted browser checks pass; physical passkey provider verification and store distribution remain outstanding. See [current status](docs/infinity/status.md) and [DESIGN.md](DESIGN.md).
## Build and check
@@ -19,9 +19,9 @@ CGO_ENABLED=0 go build -o .build/infinity ./cmd/infinity
## Run your own instance
-Run the binary with `--db /persistent/path/infinity.db` and either `--socket /run/infinity.sock` behind an HTTPS proxy or `--addr 127.0.0.1:8080` behind an HTTPS proxy. Set `INFINITY_SETUP_TOKEN` to a random 32-byte base64url secret for first setup, and open your origin with `/#claim=<that secret>`. Keep the secret out of server access logs and shell history. The first successful claim permanently binds the instance to client-generated keys. Subsequent owner requests use a separate client-held credential. Save a password-encrypted recovery file from the account menu immediately.
+Run the binary with `--db /persistent/path/infinity.db` and either `--socket /run/infinity.sock` behind an HTTPS proxy or `--addr 127.0.0.1:8080` behind an HTTPS proxy. Set `INFINITY_SETUP_TOKEN` to a random 32-byte base64url secret for first setup, and open your origin with `/#claim=<that secret>`. Keep the secret out of server access logs and shell history. The first successful claim permanently binds the instance to client-generated keys. Subsequent owner requests use a separate client-held credential. This direct setup path is for isolated development and existing self-host deployments. New passkey-only onboarding requires the shared front door with its configured WebAuthn origin and authenticated relay.
-Every person needs a separate process, database and HTTPS origin. The shared registration service is optional for self-hosting; it provisions processes but never generates a person's encryption keys. Allow outbound HTTPS for peer delivery and back up SQLite consistently, including uncheckpointed WAL. An encrypted database backup alone does not restore client keys: keep your recovery file and password separately. Without the Carlos scheduler, pending delivery retries run while the owner's client is open.
+Every person needs a separate process, database and HTTPS origin. The shared registration service is optional for self-hosting; it provisions processes but never generates a person's encryption keys. Allow outbound HTTPS for peer delivery and back up SQLite consistently, including uncheckpointed WAL. An encrypted database backup alone does not restore client keys: retain access to your enrolled passkey provider and signed-in devices. Without the Carlos scheduler, pending delivery retries run while the owner's client is open.
`INFINITY_ALLOW_LOCAL=1` allows localhost HTTP for automated development tests only; hosted instances must leave it unset. [DESIGN.md](DESIGN.md) describes the routing metadata and recovery boundary.
@@ -31,7 +31,7 @@ Every person needs a separate process, database and HTTPS origin. The shared reg
## Honest trade-offs
-Servers can observe routing metadata, timestamps and ciphertext sizes. They must never receive message plaintext or usable client private keys. Browser code delivery trusts the serving origin. Initial recipient-key encryption does not provide Signal's double-ratchet forward secrecy. Recovery secrets unlock identity and history; losing all devices and recovery material loses access. No independent security audit has been performed.
+Servers can observe routing metadata, timestamps and ciphertext sizes. They must never receive message plaintext or usable client private keys. Browser code delivery trusts the serving origin. Initial recipient-key encryption does not provide Signal's double-ratchet forward secrecy. Passkey PRF unlocks identity and history; losing all signed-in devices and enrolled passkeys loses access. No independent security audit has been performed.
## Provenance
diff --git a/android/README.md b/android/README.md
index a2a03cc..963e212 100644
--- a/android/README.md
+++ b/android/README.md
@@ -1,6 +1,6 @@
# 🤖 Ocho for Android
-Native Kotlin/Compose UI over the same Go/gomobile engine used by SwiftUI. No WebViews. Android Keystore encrypts the saved identity; platform file/share dialogs handle recovery and invitations. Credential Manager provides passkey ceremonies for `ocho.chat`; Go handles PRF wrapping and all chat cryptography.
+Native Kotlin/Compose UI over the same Go/gomobile engine used by SwiftUI. No WebViews. Android Keystore encrypts the saved identity; platform image/share dialogs handle avatars and invitations. Credential Manager provides passkey ceremonies for `ocho.chat`; Go handles PRF wrapping and all chat cryptography.
## Build
@@ -23,6 +23,6 @@ This starts two disposable personal processes, reverses only their ports, instal
## Passkeys
-The home server must publish `/.well-known/assetlinks.json` for `chat.ocho.app` with the exact installed APK signing fingerprint, configured through `INFINITY_ANDROID_CERT_SHA256`. It also pins that Android assertion origin. Update the association for the actual distribution signing certificate before release. Provider PRF support is required to unlock E2EE keys; otherwise use a recovery file. Physical-provider Android passkey interoperability still needs a user-present device test.
+The home server must publish `/.well-known/assetlinks.json` for `chat.ocho.app` with the exact installed APK signing fingerprint, configured through `INFINITY_ANDROID_CERT_SHA256`. It also pins that Android assertion origin. Update the association for the actual distribution signing certificate before release. Provider PRF support is required to unlock E2EE keys; unsupported providers show a retry message before allocating an account. Signup and sign-in are passkey-only. Physical-provider Android passkey interoperability still needs a user-present device test.
See `docs/infinity/android-handoff.md` for this machine's SDK paths and coordination notes.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 3ba94c8..3121be5 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -2,7 +2,7 @@ plugins { id("com.android.application"); id("org.jetbrains.kotlin.android"); id(
android {
namespace = "chat.ocho.app"
compileSdk = 35
- defaultConfig { applicationId = "chat.ocho.app"; minSdk = 26; targetSdk = 35; versionCode = 1; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
+ defaultConfig { applicationId = "chat.ocho.app"; minSdk = 26; targetSdk = 35; versionCode = 2; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
buildFeatures { compose = true; buildConfig = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
kotlinOptions { jvmTarget = "17" }
diff --git a/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt b/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt
index a4037a6..14b6fb3 100644
--- a/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt
+++ b/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt
@@ -1,4 +1,4 @@
-// 🤖 Native onboarding, connection and recovery controls.
+// 🤖 Native passkey onboarding, connection and profile controls.
package chat.ocho.app
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -12,43 +12,51 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.json.JSONObject
-@Composable fun WelcomeScreen(ui: AccountUI, busy: Boolean, recovery: String?, onRecovery: () -> Unit, onPasskey: () -> Unit, onCreate: (String) -> Unit, onRestore: (String, String) -> Unit) {
- var name by remember { mutableStateOf("") }; var password by remember { mutableStateOf("") }
+@Composable fun WelcomeScreen(ui: AccountUI, busy: Boolean, incoming: String, onPasskey: () -> Unit, onCreate: (String) -> Unit) {
+ var name by remember { mutableStateOf("") }; var signingUp by remember { mutableStateOf(false) }; var inviter by remember { mutableStateOf<String?>(null) }
+ LaunchedEffect(incoming) { inviter = if (incoming.isEmpty()) null else try { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { JSONObject(chat.ocho.engine.ocho.Ocho.parseInvite(incoming)).getJSONObject("person").getString("name") } } catch (_: Exception) { null } }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
Text("∞", fontSize = 72.sp, color = MaterialTheme.colorScheme.primary)
- Text(if (ui.stage != null) "To infinity.\nAnd your people." else "Just you.\nAnd your people.", style = MaterialTheme.typography.headlineLarge)
- if (ui.stage != null) { LinearProgressIndicator(Modifier.fillMaxWidth()); Text(ui.stage); Text("A little space of your own. A lot of possibilities.") }
- else {
- Text("Private conversations, without a phone number.")
- Button(enabled = !busy, onClick = onPasskey) { Text("Sign in with a passkey") }
- OutlinedTextField(value = name, onValueChange = { if (it.length <= 80) name = it }, label = { Text("Your name") }, singleLine = true)
- Button(enabled = !busy && name.isNotBlank(), onClick = { onCreate(name) }) { Text("Create your account") }
- TextButton(enabled = !busy, onClick = onRecovery) { Text("I have a recovery file") }
- if (recovery != null) { OutlinedTextField(value = password, onValueChange = { password = it }, label = { Text("Recovery password") }, visualTransformation = PasswordVisualTransformation()); Button(enabled = !busy, onClick = { onRestore(recovery, password) }) { Text("Open your account") } }
- Text("Your messages are encrypted on your device.", style = MaterialTheme.typography.bodySmall)
+ Text(if (ui.stage != null) "To infinity. And your people." else inviter?.let { "$it is inviting you to chat on Ocho" } ?: "Ocho", style = MaterialTheme.typography.headlineLarge)
+ if (ui.stage != null) { LinearProgressIndicator(Modifier.fillMaxWidth()); Text(ui.stage) }
+ else if (signingUp) {
+ OutlinedTextField(value = name, onValueChange = { if (it.length <= 80) name = it }, label = { Text("Name") }, singleLine = true)
+ Button(enabled = !busy && name.isNotBlank(), onClick = { onCreate(name) }) { Text(inviter?.let { "Connect with $it" } ?: "Create") }
+ TextButton(enabled = !busy, onClick = { signingUp = false }) { Text("Back") }
+ } else {
+ if (inviter == null) Text("Just you. And your people.")
+ Button(enabled = !busy, onClick = { signingUp = true }) { Text(if (incoming.isEmpty()) "Sign up" else "Sign up to accept") }
+ Button(enabled = !busy, onClick = onPasskey) { Text(if (incoming.isEmpty()) "Sign in" else "Accept") }
}
}
}
-@Composable fun AccountDialogs(kind: String, model: AccountModel, ui: AccountUI, thread: JSONObject?, incoming: String, onPasskey: (Boolean) -> Unit, onSave: (String) -> Unit, dismiss: () -> Unit) {
+@Composable fun AccountDialogs(kind: String, model: AccountModel, ui: AccountUI, thread: JSONObject?, incoming: String, onPasskey: (Boolean) -> Unit, onConnected: (String) -> Unit, dismiss: () -> Unit) {
val scope = rememberCoroutineScope()
- var text by remember(kind) { mutableStateOf(if (kind == "connect") incoming else "") }
+ var inviter by remember { mutableStateOf<String?>(null) }
+ LaunchedEffect(incoming) { if (incoming.isNotEmpty()) inviter = try { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { JSONObject(chat.ocho.engine.ocho.Ocho.parseInvite(incoming)).getJSONObject("person").getString("name") } } catch (_: Exception) { null } }
+ var text by remember(kind) { mutableStateOf(if (kind == "connect" || kind == "accept") incoming else if (kind == "profile") ui.identity?.optString("name") ?: "" else "") }
+ var avatar by remember(kind) { mutableStateOf(ui.identity?.optString("avatar") ?: "") }
var selected by remember { mutableStateOf(emptySet<String>()) }
- AlertDialog(onDismissRequest = dismiss, title = { Text(when (kind) { "connect" -> "Connect with someone"; "group" -> "New group"; "details" -> "People"; else -> "Recovery & another device" }) }, text = {
+ AlertDialog(onDismissRequest = dismiss, title = { Text(when (kind) { "connect" -> "Connect with someone"; "accept" -> "You’re invited"; "group" -> "New group"; "details" -> "People"; "profile" -> "Your profile"; else -> "Passkeys" }) }, text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (ui.error != null) Text(ui.error, style = MaterialTheme.typography.bodySmall)
when (kind) {
+ "accept" -> Text(inviter?.let { "$it is inviting you to chat on Ocho" } ?: "Accept this invitation to start a conversation.")
"connect" -> OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Invitation link") }, maxLines = 5)
"group" -> { OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Group name") }); ui.threads.filter { !it.getBoolean("group") && !it.getBoolean("pending") }.forEach { contact -> Row { Checkbox(checked = selected.contains(contact.getString("id")), onCheckedChange = { selected = if (it) selected + contact.getString("id") else selected - contact.getString("id") }); Text(contact.getString("name")) } } }
"details" -> thread?.getJSONArray("members")?.objects()?.forEach { person -> Row { Text(person.getString("name"), Modifier.weight(1f)); if (thread.optString("owner") == ui.identity?.getString("signing") && person.getString("signing") != ui.identity.getString("signing")) TextButton(enabled = !ui.busy, onClick = { scope.launch { model.remove(thread.getString("id"), person.getString("signing")) } }) { Text("Remove") } } }
- else -> { Button(enabled = !ui.busy, onClick = { onPasskey(true) }) { Text("Add a passkey") }; Text("Use the same passkey to open Ocho and your messages on your other devices."); OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Recovery password (12+ characters)") }, visualTransformation = PasswordVisualTransformation()) }
+ "profile" -> { ProfileAvatar(avatar, { avatar = it }, model::error); OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Display name") }) }
+ else -> { Button(enabled = !ui.busy, onClick = { onPasskey(true) }) { Text("Add a passkey") }; Text("Use the same passkey to open Ocho and your messages on your other devices.") }
+
}
}
}, confirmButton = {
when (kind) {
- "connect" -> TextButton(enabled = !ui.busy && text.isNotBlank(), onClick = { scope.launch { if (model.connect(text)) dismiss() } }) { Text("Connect") }
+ "connect", "accept" -> TextButton(enabled = !ui.busy && text.isNotBlank(), onClick = { scope.launch { if (model.connect(text, onConnected)) dismiss() } }) { Text(if (kind == "accept") "Accept" else "Connect") }
"group" -> TextButton(enabled = !ui.busy && text.isNotBlank() && selected.size >= 2, onClick = { scope.launch { if (model.group(text, selected)) dismiss() } }) { Text("Create group") }
"details" -> TextButton(onClick = dismiss) { Text("Done") }
- else -> TextButton(enabled = !ui.busy && text.length >= 12, onClick = { scope.launch { model.recovery(text, onSave) } }) { Text("Save recovery file") }
+ "profile" -> TextButton(enabled = !ui.busy && text.isNotBlank(), onClick = { scope.launch { if (model.profile(text, avatar)) dismiss() } }) { Text("Save") }
+ else -> TextButton(onClick = dismiss) { Text("Done") }
}
}, dismissButton = { TextButton(onClick = dismiss) { Text("Close") } })
}
diff --git a/android/app/src/main/java/chat/ocho/app/AccountModel.kt b/android/app/src/main/java/chat/ocho/app/AccountModel.kt
index a330c1b..e0f24e9 100644
--- a/android/app/src/main/java/chat/ocho/app/AccountModel.kt
+++ b/android/app/src/main/java/chat/ocho/app/AccountModel.kt
@@ -48,20 +48,24 @@ class AccountModel(application: Application): AndroidViewModel(application) {
secure.write("owner", identity); client = candidate
ui.value = ui.value.copy(identity = JSONObject(identity), threads = view.getJSONArray("threads").objects(), requests = view.getJSONArray("requests").objects())
}
- suspend fun create(name: String) = run {
+ @androidx.annotation.RequiresApi(28)
+ suspend fun create(name: String, passkeys: Passkeys) = run {
require(name.trim().isNotEmpty() && name.length <= 80) { "Enter your name." }
- val pending = secure.read("pending") ?: kotlin.run {
- val nonce = secure.read("signup") ?: Ocho.random().also { secure.write("signup", it) }
- Ocho.register(HOME, name.trim(), nonce, Progress { message -> ui.value = ui.value.copy(stage = message) }).also { secure.write("pending", it) }
- }
- val value = JSONObject(pending)
+ val nonce = secure.read("signup") ?: Ocho.random().also { secure.write("signup", it) }
+ val ceremony = secure.read("pending-passkey")?.let { JSONObject(it) }?.takeIf { System.currentTimeMillis() - it.optLong("createdAt") < 840000 } ?: passkeys.beginSignup(name.trim(), nonce).also { secure.write("pending-passkey", it.toString()) }
+ val pending = secure.read("pending") ?: Ocho.register(HOME, ceremony.getString("name"), ceremony.getString("nonce"), Progress { message -> ui.value = ui.value.copy(stage = message) }).also { secure.write("pending", it) }
+ val value = JSONObject(pending); val identity = value.getJSONObject("identity")
ui.value = ui.value.copy(stage = "Opening your private account…")
- activate(value.getJSONObject("identity").toString(), value.getString("claim")); secure.remove("pending"); secure.remove("signup")
+ Ocho.newClient(identity.toString()).setup(value.getString("claim"))
+ passkeys.finishSignup(identity, ceremony) { secure.write("pending-passkey", it) }
+ activate(identity.toString()); secure.remove("pending"); secure.remove("signup"); secure.remove("pending-passkey")
}
+
suspend fun restore(file: String, password: String) = run { activate(Ocho.importRecovery(file, password)) }
suspend fun signIn(identity: String) = run { activate(identity) }
suspend fun invite(onLink: (String) -> Unit) = run { val link = client!!.invite(); withContext(Dispatchers.Main) { onLink(link) } }
- suspend fun connect(link: String) = run { client!!.connect(link); refresh() }
+ suspend fun connect(link: String, selected: (String) -> Unit = {}) = run { val thread = client!!.connect(link); refresh(); withContext(Dispatchers.Main) { selected(thread) } }
+ suspend fun profile(name: String, avatar: String) = run { client!!.updateProfile(name, avatar); refresh(); secure.write("owner", client!!.identityJSON()) }
suspend fun accept(request: JSONObject) = run { client!!.accept(request.getString("thread")); refresh() }
suspend fun reject(request: JSONObject) = run { client!!.reject(request.getString("thread")); refresh() }
suspend fun send(thread: String, text: String) = run { client!!.message(thread, text); refresh() }
diff --git a/android/app/src/main/java/chat/ocho/app/MainActivity.kt b/android/app/src/main/java/chat/ocho/app/MainActivity.kt
index 25eb885..8fdf444 100644
--- a/android/app/src/main/java/chat/ocho/app/MainActivity.kt
+++ b/android/app/src/main/java/chat/ocho/app/MainActivity.kt
@@ -23,23 +23,16 @@ class MainActivity: ComponentActivity() {
val model: AccountModel = viewModel()
val ui by model.ui.collectAsState()
val scope = rememberCoroutineScope()
- var recovery by remember { mutableStateOf<String?>(null) }
- var exported by remember { mutableStateOf("") }
var passkeyBusy by remember { mutableStateOf(false) }
- val open = androidx.activity.compose.rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
- if (uri != null) try { contentResolver.openInputStream(uri)?.use { stream -> val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(4096); var count = stream.read(buffer); while (count > 0) { output.write(buffer, 0, count); require(output.size() <= 100000) { "Recovery file is too large." }; count = stream.read(buffer) }; val bytes = output.toByteArray(); require(bytes.size <= 100000) { "Recovery file is too large." }; recovery = bytes.toString(Charsets.UTF_8) } } catch (e: Exception) { model.error(e.message ?: "Could not read the recovery file.") }
- }
- val save = androidx.activity.compose.rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
- if (uri != null) try { contentResolver.openOutputStream(uri)?.use { it.write(exported.toByteArray(Charsets.UTF_8)) } } catch (e: Exception) { model.error(e.message ?: "Could not save your recovery file.") }
- exported = ""
- }
- OchoScreen(model, ui, incoming, recovery, passkeyBusy,
+ OchoScreen(model, ui, incoming, passkeyBusy,
onConsumedInvite = { incoming = "" },
- onRecovery = { open.launch(arrayOf("application/json")) },
- onSave = { file -> exported = file; save.launch("Ocho recovery.json") },
+ onCreate = { name -> scope.launch {
+ if (Build.VERSION.SDK_INT < 28) { model.error("Passkeys need Android 9 or later."); return@launch }
+ model.create(name, Passkeys(this@MainActivity))
+ } },
onShare = { link -> startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, link), "Invite someone")) },
onPasskey = { enrolling -> scope.launch {
- if (Build.VERSION.SDK_INT < 28) { model.error("Passkeys need Android 9 or later. You can use a recovery file on this device."); return@launch }
+ if (Build.VERSION.SDK_INT < 28) { model.error("Passkeys need Android 9 or later."); return@launch }
passkeyBusy = true
try { val passkeys = Passkeys(this@MainActivity); if (enrolling) { passkeys.add(ui.identity!!); model.error("Passkey saved. Use it on your other devices.") } else { model.signIn(passkeys.signIn()) } }
catch (e: Exception) { model.error(e.message ?: "Passkey sign-in was cancelled.") }
diff --git a/android/app/src/main/java/chat/ocho/app/OchoScreen.kt b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt
index 3dc8242..f5239d4 100644
--- a/android/app/src/main/java/chat/ocho/app/OchoScreen.kt
+++ b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt
@@ -20,8 +20,9 @@ import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
-@Composable fun OchoScreen(model: AccountModel, ui: AccountUI, incoming: String, recovery: String?, passkeyBusy: Boolean, onConsumedInvite: () -> Unit, onRecovery: () -> Unit, onSave: (String) -> Unit, onShare: (String) -> Unit, onPasskey: (Boolean) -> Unit) {
+@Composable fun OchoScreen(model: AccountModel, ui: AccountUI, incoming: String, passkeyBusy: Boolean, onConsumedInvite: () -> Unit, onCreate: (String) -> Unit, onShare: (String) -> Unit, onPasskey: (Boolean) -> Unit) {
val scope = rememberCoroutineScope()
+ var acceptingAfterAuth by remember { mutableStateOf(false) }
var selected by remember { mutableStateOf<String?>(null) }
var sheet by remember { mutableStateOf<String?>(null) }
var menu by remember { mutableStateOf(false) }
@@ -30,21 +31,21 @@ import java.util.Date
val busy = ui.busy || passkeyBusy
val thread = ui.threads.firstOrNull { it.getString("id") == selected }
BackHandler(selected != null) { selected = null }
- LaunchedEffect(incoming, ui.identity != null) { if (incoming.isNotEmpty() && ui.identity != null) sheet = "connect" }
+ LaunchedEffect(incoming, ui.identity != null) { if (incoming.isNotEmpty() && ui.identity != null) { if (acceptingAfterAuth) { if (model.connect(incoming) { selected = it }) { onConsumedInvite(); acceptingAfterAuth = false } } else sheet = "accept" } }
Scaffold(topBar = {
if (ui.identity != null) TopAppBar(title = { Text(thread?.getString("name") ?: "Ocho") }, navigationIcon = { if (selected != null) TextButton(onClick = { selected = null }) { Text("Back") } }, actions = {
if (thread != null) TextButton(onClick = { sheet = "details" }) { Text("Details") }
TextButton(onClick = { menu = true }) { Text("More") }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Invite someone") }, enabled = !busy, onClick = { menu = false; scope.launch { model.invite(onShare) } })
- listOf("connect" to "Connect with a link", "group" to "New group", "recovery" to "Recovery & another device").forEach { (key, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { menu = false; sheet = key }) }
+ listOf("connect" to "Connect with a link", "group" to "New group", "profile" to "Your profile", "passkeys" to "Passkeys").forEach { (key, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { menu = false; sheet = key }) }
}
})
}) { padding ->
Column(Modifier.fillMaxSize().padding(padding).imePadding()) {
if (ui.error != null) Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.secondaryContainer).padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(ui.error, Modifier.weight(1f)); TextButton(onClick = model::dismissError) { Text("Dismiss") } }
when {
- ui.identity == null -> WelcomeScreen(ui, busy, recovery, onRecovery, { onPasskey(false) }, { name -> scope.launch { model.create(name) } }, { file, password -> scope.launch { model.restore(file, password) } })
+ ui.identity == null -> WelcomeScreen(ui, busy, incoming, { acceptingAfterAuth = true; onPasskey(false) }, { name -> acceptingAfterAuth = true; onCreate(name) })
thread == null -> {
OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("Search chats") }, modifier = Modifier.fillMaxWidth().padding(16.dp), singleLine = true)
LazyColumn(Modifier.weight(1f)) {
@@ -84,5 +85,5 @@ import java.util.Date
}
}
}
- if (sheet != null) AccountDialogs(sheet!!, model, ui.copy(busy = busy), thread, incoming, onPasskey, onSave) { if (sheet == "connect") onConsumedInvite(); sheet = null }
+ if (sheet != null) AccountDialogs(sheet!!, model, ui.copy(busy = busy), thread, incoming, onPasskey, { selected = it }) { if (sheet == "connect" || sheet == "accept") onConsumedInvite(); sheet = null }
}
diff --git a/android/app/src/main/java/chat/ocho/app/Passkeys.kt b/android/app/src/main/java/chat/ocho/app/Passkeys.kt
index 8f9bbb4..f6f539a 100644
--- a/android/app/src/main/java/chat/ocho/app/Passkeys.kt
+++ b/android/app/src/main/java/chat/ocho/app/Passkeys.kt
@@ -16,28 +16,27 @@ class Passkeys(private val activity: Activity) {
private val manager = CredentialManager.create(activity)
private fun decode(value: String): ByteArray = Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
private suspend fun post(path: String, body: JSONObject, auth: String = ""): JSONObject = withContext(Dispatchers.IO) {
- JSONObject(Ocho.requestJSON(AccountModel.HOME, "/api/passkeys/$path", auth, "POST", body.toString()))
+ JSONObject(Ocho.requestJSON(AccountModel.HOME, "/api/$path", auth, "POST", body.toString()))
}
private fun extension(options: JSONObject) = JSONObject().put("prf", JSONObject().put("eval", JSONObject().put("first", options.getString("salt"))))
private suspend fun assertion(options: JSONObject, id: String? = null): JSONObject {
val request = JSONObject().put("challenge", options.getString("challenge")).put("rpId", options.getString("rpId")).put("userVerification", "required").put("timeout", 120000).put("extensions", extension(options))
if (id != null) request.put("allowCredentials", JSONArray().put(JSONObject().put("type", "public-key").put("id", id)))
val response = try { withContext(Dispatchers.Main) { manager.getCredential(activity, GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request.toString())))) } }
- catch (_: NoCredentialException) { error("No Ocho passkey was found. Choose another device or use your recovery file.") }
+ catch (_: NoCredentialException) { error("No Ocho passkey was found. Choose another device or passkey provider.") }
val credential = response.credential as? PublicKeyCredential ?: error("Choose an Ocho passkey.")
return JSONObject(credential.authenticationResponseJson)
}
private fun output(credential: JSONObject): String? = credential.optJSONObject("clientExtensionResults")?.optJSONObject("prf")?.optJSONObject("results")?.optString("first")?.takeIf { it.isNotEmpty() }
private fun wrappingKey(prf: String?, credential: JSONObject): String {
- require(prf != null) { "This passkey provider cannot unlock encrypted messages. Use a recovery file or another provider." }
+ require(prf != null) { "This passkey provider cannot unlock encrypted messages. Choose another passkey provider." }
return Ocho.passkeyWrappingKey(decode(prf), decode(credential.getString("id")))
}
- suspend fun add(identity: JSONObject) {
- val options = post("register/begin", JSONObject().put("origin", identity.getString("origin")), identity.getString("auth"))
+ private suspend fun create(options: JSONObject, name: String): JSONObject {
require(options.getString("rpId") == "ocho.chat")
val request = JSONObject().put("challenge", options.getString("challenge"))
- .put("rp", JSONObject().put("id", "ocho.chat").put("name", "Ocho"))
- .put("user", JSONObject().put("id", options.getString("userHandle")).put("name", identity.getString("name")).put("displayName", identity.getString("name")))
+ .put("rp", JSONObject().put("id", "ocho.chat").put("name", "ocho.chat"))
+ .put("user", JSONObject().put("id", options.getString("userHandle")).put("name", name).put("displayName", name))
.put("pubKeyCredParams", JSONArray().put(JSONObject().put("type", "public-key").put("alg", -7)))
.put("authenticatorSelection", JSONObject().put("residentKey", "required").put("userVerification", "required"))
.put("attestation", "none").put("timeout", 120000).put("extensions", extension(options))
@@ -46,14 +45,28 @@ class Passkeys(private val activity: Activity) {
var prf = output(credential)
if (prf == null) { val existing = assertion(options, credential.getString("id")); if (existing.getString("id") == credential.getString("id")) prf = output(existing) }
val key = wrappingKey(prf, credential)
- val box = withContext(Dispatchers.IO) { Ocho.lock(key, identity.toString()) }
val response = credential.getJSONObject("response")
- post("register/finish", JSONObject().put("challenge", options.getString("challenge")).put("clientDataJSON", response.getString("clientDataJSON")).put("attestationObject", response.getString("attestationObject")).put("box", box), identity.getString("auth"))
+ return JSONObject().put("key", key).put("challenge", options.getString("challenge")).put("clientDataJSON", response.getString("clientDataJSON")).put("attestationObject", response.getString("attestationObject"))
+ }
+ suspend fun beginSignup(name: String, nonce: String): JSONObject = create(post("signup/begin", JSONObject().put("nonce", nonce)), name).put("nonce", nonce).put("name", name).put("createdAt", System.currentTimeMillis())
+ suspend fun finishSignup(identity: JSONObject, ceremony: JSONObject, persist: (String) -> Unit) {
+ if (!ceremony.has("box")) ceremony.put("box", Ocho.lock(ceremony.getString("key"), identity.toString()))
+ persist(ceremony.toString())
+ val body = JSONObject().put("nonce", ceremony.getString("nonce")).put("origin", identity.getString("origin"))
+ for (key in listOf("challenge", "clientDataJSON", "attestationObject", "box")) body.put(key, ceremony.getString(key))
+ post("signup/finish", body, identity.getString("auth"))
+ }
+ suspend fun add(identity: JSONObject) {
+ val options = post("passkeys/register/begin", JSONObject().put("origin", identity.getString("origin")), identity.getString("auth"))
+ val ceremony = create(options, identity.getString("name"))
+ val body = JSONObject().put("box", Ocho.lock(ceremony.getString("key"), identity.toString()))
+ for (key in listOf("challenge", "clientDataJSON", "attestationObject")) body.put(key, ceremony.getString(key))
+ post("passkeys/register/finish", body, identity.getString("auth"))
}
suspend fun signIn(): String {
- val options = post("login/begin", JSONObject()); require(options.getString("rpId") == "ocho.chat")
+ val options = post("passkeys/login/begin", JSONObject()); require(options.getString("rpId") == "ocho.chat")
val credential = assertion(options); val key = wrappingKey(output(credential), credential); val response = credential.getJSONObject("response")
- val result = post("login/finish", JSONObject().put("challenge", options.getString("challenge")).put("credentialId", credential.getString("id")).put("userHandle", response.getString("userHandle")).put("clientDataJSON", response.getString("clientDataJSON")).put("authenticatorData", response.getString("authenticatorData")).put("signature", response.getString("signature")))
+ val result = post("passkeys/login/finish", JSONObject().put("challenge", options.getString("challenge")).put("credentialId", credential.getString("id")).put("userHandle", response.getString("userHandle")).put("clientDataJSON", response.getString("clientDataJSON")).put("authenticatorData", response.getString("authenticatorData")).put("signature", response.getString("signature")))
return withContext(Dispatchers.IO) { Ocho.unlock(key, result.getString("box")).also { Ocho.validateIdentity(it); require(JSONObject(it).getString("origin") == result.getString("origin")) } }
}
}
diff --git a/android/app/src/main/java/chat/ocho/app/ProfileAvatar.kt b/android/app/src/main/java/chat/ocho/app/ProfileAvatar.kt
new file mode 100644
index 0000000..9a16669
--- /dev/null
+++ b/android/app/src/main/java/chat/ocho/app/ProfileAvatar.kt
@@ -0,0 +1,46 @@
+// 🤖 Local image picker; bounded raster data enters encrypted profile state.
+package chat.ocho.app
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import java.io.ByteArrayOutputStream
+
+@Composable fun ProfileAvatar(value: String, changed: (String) -> Unit, error: (String) -> Unit) {
+ val context = LocalContext.current
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) try {
+ val data = context.contentResolver.openInputStream(uri)?.use { it.readBytesLimited(8 * 1024 * 1024) } ?: throw IllegalArgumentException("Could not open this image.")
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }; BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
+ require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Choose a supported image." }
+ val options = BitmapFactory.Options().apply { inSampleSize = maxOf(1, maxOf(bounds.outWidth, bounds.outHeight) / 256) }
+ val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, options) ?: throw IllegalArgumentException("Could not open this image.")
+ val scale = 128.0 / maxOf(bitmap.width, bitmap.height)
+ val small = Bitmap.createScaledBitmap(bitmap, maxOf(1, (bitmap.width * scale).toInt()), maxOf(1, (bitmap.height * scale).toInt()), true)
+ val output = ByteArrayOutputStream(); small.compress(Bitmap.CompressFormat.JPEG, 80, output)
+ if (small !== bitmap) small.recycle(); bitmap.recycle()
+ require(output.size() <= 32768) { "Could not resize this image." }
+ changed("data:image/jpeg;base64," + Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP))
+ } catch (e: Exception) { error(e.message ?: "Could not open this image.") }
+ }
+ val bitmap = remember(value) { if (value.length <= 45000 && value.startsWith("data:image/jpeg;base64,")) try { val bytes = Base64.decode(value.substringAfter(','), Base64.DEFAULT); BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() } catch (_: Exception) { null } else null }
+ if (bitmap != null) Image(bitmap, "Your avatar", Modifier.size(96.dp))
+ TextButton(onClick = { picker.launch(arrayOf("image/png", "image/jpeg", "image/webp")) }) { Text("Choose avatar") }
+ if (value.isNotEmpty()) TextButton(onClick = { changed("") }) { Text("Remove avatar") }
+}
+private fun java.io.InputStream.readBytesLimited(limit: Int): ByteArray {
+ val output = ByteArrayOutputStream(); val buffer = ByteArray(8192)
+ while (true) { val n = read(buffer); if (n < 0) break; require(output.size() + n <= limit) { "Choose an image under 8 MB." }; output.write(buffer, 0, n) }
+ return output.toByteArray()
+}
diff --git a/apple/Infinity/AccountSheets.swift b/apple/Infinity/AccountSheets.swift
index ce7fee5..1d70a70 100644
--- a/apple/Infinity/AccountSheets.swift
+++ b/apple/Infinity/AccountSheets.swift
@@ -1,17 +1,18 @@
-// 🤖 Native connection, group and recovery sheets.
+// 🤖 Native connection and group sheets.
import SwiftUI
-import UniformTypeIdentifiers
import OchoCore
struct ConnectSheet: View {
@EnvironmentObject var account: AccountStore
@Environment(\.dismiss) var dismiss
@State private var link = ""
+ @State private var inviter: String?
var body: some View {
NavigationStack { Form {
- TextField("Invitation link", text: $link, axis: .vertical).lineLimit(3...6)
- Button("Connect") { Task { await account.connect(link); if account.error == nil { dismiss() } } }.disabled(account.busy || link.isEmpty)
+ if let inviter { Text("\(inviter) is inviting you to chat on Ocho") } else { TextField("Invitation link", text: $link, axis: .vertical).lineLimit(1...3) }
+ Button(inviter == nil ? "Connect" : "Accept") { Task { await account.connect(link); if account.error == nil { dismiss() } } }.disabled(account.busy || link.isEmpty)
}.navigationTitle("Connect with someone").toolbar { ToolbarItem { Button("Cancel") { dismiss() } } } }
.frame(minWidth: 320, minHeight: 260).onAppear { link = account.invitation }
+ .task(id: account.invitation) { let input = account.invitation; if !input.isEmpty { inviter = try? await Task.detached { try Invite.parse(input).person.name }.value } }
}
}
struct GroupSheet: View {
@@ -29,35 +30,3 @@ struct GroupSheet: View {
}.navigationTitle("New group").toolbar { ToolbarItem { Button("Cancel") { dismiss() } } } }.frame(minWidth: 320, minHeight: 320)
}
}
-struct RecoveryDocument: FileDocument {
- static var readableContentTypes: [UTType] { [.json] }
- var data: Data
- init(data: Data) { self.data = data }
- init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
- func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
-}
-struct RecoverySheet: View {
- @EnvironmentObject var account: AccountStore
- @Environment(\.dismiss) var dismiss
- @State private var password = ""
- @State private var document: RecoveryDocument?
- @State private var exporting = false
- @State private var passkeySaved = false
- var body: some View {
- NavigationStack { Form {
- 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)
- Button("Save recovery file") { Task { await account.perform {
- guard let id = account.identity else { return }
- let password = password
- let data = try await Task.detached { try Crypto.exportRecovery(id, password: password) }.value
- document = RecoveryDocument(data: data); exporting = true
- } } }.disabled(account.busy || password.count < 12)
- }.navigationTitle("Account recovery").toolbar { ToolbarItem { Button("Done") { dismiss() } } } }
- .frame(minWidth: 320, minHeight: 280)
- .fileExporter(isPresented: $exporting, document: document, contentType: .json, defaultFilename: "Ocho recovery") { result in if case let .failure(error) = result { account.error = error.localizedDescription } }
- }
-}
diff --git a/apple/Infinity/AccountStore.swift b/apple/Infinity/AccountStore.swift
index 5e43ad4..a7b2650 100644
--- a/apple/Infinity/AccountStore.swift
+++ b/apple/Infinity/AccountStore.swift
@@ -54,22 +54,33 @@ import OchoCore
await perform {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.utf16.count <= 80 else { throw OchoError.invalid("Enter your name.") }
- if let data = try KeychainIdentity.read(account: "pending-setup") {
- let pending = try JSONDecoder().decode(Pending.self, from: data)
- self.stage = "Opening your private account…"
- try await self.activate(pending.identity, claim: pending.claim)
- try KeychainIdentity.remove(account: "pending-setup")
- UserDefaults.standard.removeObject(forKey: "ocho.signup.nonce")
- return
- }
+ guard #available(iOS 18.0, macOS 15.0, *) else { throw OchoError.invalid("Passkeys require iOS 18 or macOS 15.") }
let nonce = UserDefaults.standard.string(forKey: "ocho.signup.nonce") ?? Crypto.random()
UserDefaults.standard.set(nonce, forKey: "ocho.signup.nonce")
- let (id, claim) = try await Registration.create(home: Self.home, name: name, nonce: nonce) { message in await MainActor.run { self.stage = message } }
- try KeychainIdentity.write(JSONEncoder().encode(Pending(identity: id, claim: claim)), account: "pending-setup")
+ let passkeys = NativePasskeys()
+ let ceremony: NativePasskeys.Signup
+ if let data = try KeychainIdentity.read(account: "pending-passkey"), let saved = try? JSONDecoder().decode(NativePasskeys.Signup.self, from: data), let created = saved.createdAt, Date().timeIntervalSince(created) < 840 { ceremony = saved }
+ else {
+ ceremony = try await passkeys.beginSignup(name: name, nonce: nonce)
+ try KeychainIdentity.write(JSONEncoder().encode(ceremony), account: "pending-passkey")
+ }
+ let pending: Pending
+ if let data = try KeychainIdentity.read(account: "pending-setup") { pending = try JSONDecoder().decode(Pending.self, from: data) }
+ else {
+ let (id, claim) = try await Registration.create(home: Self.home, name: ceremony.name, nonce: ceremony.nonce) { message in await MainActor.run { self.stage = message } }
+ pending = Pending(identity: id, claim: claim)
+ try KeychainIdentity.write(JSONEncoder().encode(pending), account: "pending-setup")
+ }
self.stage = "Opening your private account…"
- try await self.activate(id, claim: claim)
+ let candidate = try OchoClient(identity: pending.identity)
+ try await candidate.setup(claim: pending.claim)
+ try await passkeys.finishSignup(ceremony, identity: pending.identity)
+ try await self.activate(pending.identity)
try KeychainIdentity.remove(account: "pending-setup")
+ try KeychainIdentity.remove(account: "pending-passkey")
UserDefaults.standard.removeObject(forKey: "ocho.signup.nonce")
+ try await self.acceptInvitation()
+
}
}
func restore(data: Data, password: String) async {
@@ -80,7 +91,7 @@ import OchoCore
}
func refresh() async throws {
guard let client else { return }
- let view = try await client.refresh(); threads = view.threads; requests = view.requests
+ let view = try await client.refresh(); threads = view.threads; requests = view.requests; identity = await client.identity
}
func poll() async {
while !Task.isCancelled {
@@ -96,22 +107,29 @@ import OchoCore
await perform {
if #available(iOS 18.0, macOS 15.0, *) {
let passkeys = NativePasskeys()
- let id = try await passkeys.signIn(); try await self.activate(id)
- } else { throw OchoError.invalid("Passkey recovery requires iOS 18 or macOS 15. You can use a recovery file.") }
+ let id = try await passkeys.signIn(); try await self.activate(id); try await self.acceptInvitation()
+ } else { throw OchoError.invalid("Passkeys require iOS 18 or macOS 15.") }
}
}
func addPasskey() async {
await perform {
guard let id = self.identity else { return }
if #available(iOS 18.0, macOS 15.0, *) { let passkeys = NativePasskeys(); try await passkeys.add(id) }
- else { throw OchoError.invalid("Passkey recovery requires iOS 18 or macOS 15. You can use a recovery file.") }
+ else { throw OchoError.invalid("Passkeys require iOS 18 or macOS 15.") }
}
}
func invite() async throws -> URL { guard let client else { throw OchoError.invalid("Open your account first.") }; return try await client.invite() }
func connect(_ text: String) async { await perform {
guard let client = self.client else { return }
- self.selected = try await client.connect(Invite.parse(text)); self.invitation = ""; try await self.refresh()
+ self.selected = try await client.connectLink(text); self.invitation = ""; try await self.refresh()
} }
+ func acceptInvitation() async throws {
+ guard !invitation.isEmpty, let client else { return }
+ selected = try await client.connectLink(invitation); invitation = ""; try await refresh()
+ }
+ func updateProfile(name: String, avatar: String) async {
+ await perform { try await self.client?.updateProfile(name: name, avatar: avatar); try await self.refresh(); if let id = self.identity { try KeychainIdentity.write(JSONEncoder().encode(id)) } }
+ }
func send(_ text: String, thread: String) async throws {
guard let client else { return }; try await client.message(thread: thread, text: text); try await refresh()
}
diff --git a/apple/Infinity/NativePasskeys.swift b/apple/Infinity/NativePasskeys.swift
index e076e8a..a578f1e 100644
--- a/apple/Infinity/NativePasskeys.swift
+++ b/apple/Infinity/NativePasskeys.swift
@@ -31,11 +31,12 @@ import CryptoKit
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { continuation?.resume(returning: authorization.credential); continuation = nil; self.controller = nil }
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { continuation?.resume(throwing: error); continuation = nil; self.controller = nil }
- func add(_ id: Identity) async throws {
- let options = try await API.request(AccountStore.home, "/api/passkeys/register/begin", auth: id.auth, method: "POST", body: JSONEncoder().encode(Origin(origin: id.origin)), as: Options.self)
+ struct Signup: Codable { var createdAt: Date? = Date(); var nonce: String, name: String, key: String, challenge: String, clientDataJSON: String, attestationObject: String; var box: String? }
+ struct SignupBody: Encodable { var nonce: String, origin: String, challenge: String, clientDataJSON: String, attestationObject: String, box: String }
+ func create(_ options: Options, name: String, nonce: String) async throws -> Signup {
guard options.rpId == "ocho.chat" else { throw OchoError.invalid("Unexpected passkey domain.") }
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId)
- let request = provider.createCredentialRegistrationRequest(challenge: try Data(base64URL: options.challenge), name: id.name, userID: try Data(base64URL: options.userHandle))
+ let request = provider.createCredentialRegistrationRequest(challenge: try Data(base64URL: options.challenge), name: name, userID: try Data(base64URL: options.userHandle))
request.userVerificationPreference = .required
request.prf = .inputValues(.saltInput1(try Data(base64URL: options.salt)))
guard let registration = try await authorize(request) as? ASAuthorizationPlatformPublicKeyCredentialRegistration, let attestation = registration.rawAttestationObject else { throw OchoError.invalid("Could not create your passkey.") }
@@ -48,9 +49,25 @@ import CryptoKit
let result = try await authorize(assertion) as? ASAuthorizationPlatformPublicKeyCredentialAssertion
if result?.credentialID == registration.credentialID { output = result?.prf?.first }
}
- guard let output else { throw OchoError.invalid("This passkey provider cannot unlock encrypted messages. Use a recovery file or another provider.") }
+ guard let output else { throw OchoError.invalid("This passkey provider cannot unlock encrypted messages. Choose another passkey provider.") }
let key = try Crypto.passkeyWrappingKey(output: output.withUnsafeBytes { Data($0) }, credentialID: registration.credentialID)
- let body = RegistrationBody(challenge: options.challenge, clientDataJSON: registration.rawClientDataJSON.base64URL, attestationObject: attestation.base64URL, box: try Crypto.lock(id, key: key))
+ return Signup(nonce: nonce, name: name, key: key, challenge: options.challenge, clientDataJSON: registration.rawClientDataJSON.base64URL, attestationObject: attestation.base64URL)
+ }
+ func beginSignup(name: String, nonce: String) async throws -> Signup {
+ let options = try await API.request(AccountStore.home, "/api/signup/begin", method: "POST", body: JSONEncoder().encode(["nonce": nonce]), as: Options.self)
+ return try await create(options, name: name, nonce: nonce)
+ }
+ func finishSignup(_ signup: Signup, identity: Identity) async throws {
+ var pending = signup
+ if pending.box == nil { pending.box = try Crypto.lock(identity, key: pending.key) }
+ try KeychainIdentity.write(JSONEncoder().encode(pending), account: "pending-passkey")
+ let body = SignupBody(nonce: pending.nonce, origin: identity.origin, challenge: pending.challenge, clientDataJSON: pending.clientDataJSON, attestationObject: pending.attestationObject, box: pending.box!)
+ _ = try await API.request(AccountStore.home, "/api/signup/finish", auth: identity.auth, method: "POST", body: JSONEncoder().encode(body), as: Empty.self)
+ }
+ func add(_ id: Identity) async throws {
+ let options = try await API.request(AccountStore.home, "/api/passkeys/register/begin", auth: id.auth, method: "POST", body: JSONEncoder().encode(Origin(origin: id.origin)), as: Options.self)
+ let ceremony = try await create(options, name: id.name, nonce: "")
+ let body = RegistrationBody(challenge: ceremony.challenge, clientDataJSON: ceremony.clientDataJSON, attestationObject: ceremony.attestationObject, box: try Crypto.lock(id, key: ceremony.key))
_ = try await API.request(AccountStore.home, "/api/passkeys/register/finish", auth: id.auth, method: "POST", body: JSONEncoder().encode(body), as: Empty.self)
}
func signIn() async throws -> Identity {
@@ -60,7 +77,7 @@ import CryptoKit
let request = provider.createCredentialAssertionRequest(challenge: try Data(base64URL: options.challenge))
request.userVerificationPreference = .required
request.prf = .inputValues(.saltInput1(try Data(base64URL: options.salt)))
- guard let assertion = try await authorize(request) as? ASAuthorizationPlatformPublicKeyCredentialAssertion, let output = assertion.prf?.first else { throw OchoError.invalid("This passkey could not unlock encrypted messages. You can use a recovery file.") }
+ guard let assertion = try await authorize(request) as? ASAuthorizationPlatformPublicKeyCredentialAssertion, let output = assertion.prf?.first else { throw OchoError.invalid("This passkey could not unlock encrypted messages. Try another passkey provider.") }
let key = try Crypto.passkeyWrappingKey(output: output.withUnsafeBytes { Data($0) }, credentialID: assertion.credentialID)
let body = AssertionBody(challenge: options.challenge, credentialId: assertion.credentialID.base64URL, userHandle: assertion.userID.base64URL, clientDataJSON: assertion.rawClientDataJSON.base64URL, authenticatorData: assertion.rawAuthenticatorData.base64URL, signature: assertion.signature.base64URL)
let opened = try await API.request(AccountStore.home, "/api/passkeys/login/finish", method: "POST", body: JSONEncoder().encode(body), as: Opened.self)
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift
index f75c5e4..0f88d92 100644
--- a/apple/Infinity/OchoRootView.swift
+++ b/apple/Infinity/OchoRootView.swift
@@ -6,7 +6,7 @@ struct OchoRootView: View {
@State private var sheet: Sheet?
@State private var shareURL: URL?
@State private var search = ""
- enum Sheet: String, Identifiable { case connect, group, recovery, notifications; var id: String { rawValue } }
+ enum Sheet: String, Identifiable { case connect, group, profile, notifications; var id: String { rawValue } }
var body: some View {
Group {
if account.identity == nil { WelcomeView() }
@@ -42,7 +42,8 @@ struct OchoRootView: View {
Button("New group", systemImage: "person.3") { sheet = .group }
Divider()
Button("Notifications", systemImage: "bell") { sheet = .notifications }
- Button("Save recovery file", systemImage: "key") { sheet = .recovery }
+ Button("Your profile", systemImage: "person.crop.circle") { sheet = .profile }
+ Button("Add a passkey", systemImage: "key") { Task { await account.addPasskey() } }
} label: { Image(systemName: "square.and.pencil") }.disabled(account.busy).accessibilityLabel("New conversation") }
}
} detail: {
@@ -61,7 +62,7 @@ struct OchoRootView: View {
switch value {
case .connect: ConnectSheet()
case .group: GroupSheet()
- case .recovery: RecoverySheet()
+ case .profile: ProfileSheet()
case .notifications: NotificationsSheet()
}
}
@@ -75,7 +76,7 @@ struct ConversationRow: View {
let thread: OchoCore.Thread
var body: some View {
HStack(spacing: 12) {
- ZStack { Circle().fill(Color.accentColor.opacity(0.12)); if thread.group { Image(systemName: "person.3.fill") } else { Text(String(thread.name.prefix(1)).uppercased()).font(.headline) } }.foregroundStyle(.tint).frame(width: 44, height: 44)
+ ZStack { Circle().fill(Color.accentColor.opacity(0.12)); if let avatar = avatarImage(thread.peer?.avatar) { avatar.resizable().scaledToFill().clipShape(Circle()) } else if thread.group { Image(systemName: "person.3.fill") } else { Text(String(thread.name.prefix(1)).uppercased()).font(.headline) } }.foregroundStyle(.tint).frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 4) {
HStack { Text(thread.name).font(.headline); Spacer(); if let last = thread.messages.last { Text(last.date, style: .time).font(.caption).foregroundStyle(.secondary) } }
Text(thread.pending ? "Waiting for them to accept…" : thread.messages.last?.text ?? "Say hello").font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
diff --git a/apple/Infinity/ProfileSheet.swift b/apple/Infinity/ProfileSheet.swift
new file mode 100644
index 0000000..14eed1d
--- /dev/null
+++ b/apple/Infinity/ProfileSheet.swift
@@ -0,0 +1,43 @@
+// 🤖 Native profile editing; only a resized embedded image enters encrypted account state.
+import SwiftUI
+import UniformTypeIdentifiers
+import ImageIO
+import OchoCore
+func avatarImage(_ value: String?) -> Image? {
+ guard let value, value.count <= 45000, value.hasPrefix("data:image/png;base64,") || value.hasPrefix("data:image/jpeg;base64,"), let part = value.split(separator: ",").last, let data = Data(base64Encoded: String(part)) else { return nil }
+ #if os(macOS)
+ guard let image = NSImage(data: data) else { return nil }; return Image(nsImage: image)
+ #else
+ guard let image = UIImage(data: data) else { return nil }; return Image(uiImage: image)
+ #endif
+}
+struct ProfileSheet: View {
+ @EnvironmentObject var account: AccountStore
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = ""
+ @State private var avatar = ""
+ @State private var picker = false
+ var body: some View {
+ VStack(spacing: 20) {
+ Text("Your profile").font(.title2.bold())
+ if let image = avatarImage(avatar) { image.resizable().scaledToFill().frame(width: 96, height: 96).clipShape(Circle()) }
+ TextField("Display name", text: $name).textFieldStyle(.roundedBorder)
+ Button("Choose avatar") { picker = true }
+ if !avatar.isEmpty { Button("Remove avatar") { avatar = "" } }
+ Button("Save") { Task { await account.updateProfile(name: name, avatar: avatar); if account.error == nil { dismiss() } } }.buttonStyle(.borderedProminent).disabled(account.busy || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ Button("Close") { dismiss() }
+ }.padding(28).frame(minWidth: 280).onAppear { name = account.identity?.name ?? ""; avatar = account.identity?.avatar ?? "" }
+ .fileImporter(isPresented: $picker, allowedContentTypes: [.png, .jpeg, .heic]) { result in
+ do {
+ let url = try result.get(), access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }
+ let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
+ guard size <= 8 * 1024 * 1024, let source = CGImageSourceCreateWithURL(url as CFURL, nil), let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: 128, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else { throw OchoError.invalid("Choose an image under 8 MB.") }
+ let output = NSMutableData()
+ guard let destination = CGImageDestinationCreateWithData(output, UTType.jpeg.identifier as CFString, 1, nil) else { throw OchoError.invalid("Could not resize this image.") }
+ CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.8] as CFDictionary)
+ guard CGImageDestinationFinalize(destination), output.length <= 32768 else { throw OchoError.invalid("Could not resize this image.") }
+ avatar = "data:image/jpeg;base64," + (output as Data).base64EncodedString()
+ } catch { account.error = error.localizedDescription }
+ }
+ }
+}
diff --git a/apple/Infinity/WelcomeView.swift b/apple/Infinity/WelcomeView.swift
index 525f052..8ffff26 100644
--- a/apple/Infinity/WelcomeView.swift
+++ b/apple/Infinity/WelcomeView.swift
@@ -1,56 +1,32 @@
-// 🤖 Native onboarding and file recovery. No embedded browser.
+// 🤖 Native passkey-only signup and sign-in, with invitation context.
import SwiftUI
-import UniformTypeIdentifiers
import OchoCore
-#if os(macOS)
-import Darwin
-#endif
struct WelcomeView: View {
@EnvironmentObject var account: AccountStore
@State private var name = ""
- @State private var filePicker = false
- @State private var legacyPicker = false
- @State private var recoveryData: Data?
- @State private var password = ""
+ @State private var signingUp = false
+ @State private var inviter: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
Image(systemName: "infinity").font(.system(size: 54, weight: .bold)).foregroundStyle(.white).padding(24).background(Color.accentColor.gradient, in: RoundedRectangle(cornerRadius: 24))
- Text(account.stage == nil ? "Just you.\nAnd your people." : "To infinity.\nAnd your people.").font(.system(size: 42, weight: .bold, design: .rounded))
- if let stage = account.stage {
- SetupProgressView(stage: stage)
+ Text(account.stage != nil ? "To infinity.\nAnd your people." : inviter.map { "\($0) is inviting you to chat on Ocho" } ?? "Ocho").font(.system(size: 42, weight: .bold, design: .rounded))
+ if let stage = account.stage { SetupProgressView(stage: stage) }
+ else if signingUp {
+ Text("What’s your name?").font(.title2)
+ TextField("Name", text: $name).textFieldStyle(.roundedBorder).accessibilityIdentifier("signup-name")
+ Button(inviter.map { "Connect with \($0)" } ?? "Create") { Task { await account.create(name: name) } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ Button("Back") { signingUp = false }.disabled(account.busy)
} else {
- Text("Private conversations, without a phone number.").font(.title3).foregroundStyle(.secondary)
- Button("Sign in with a passkey") { Task { await account.signInPasskey() } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy)
- TextField("Your name", text: $name).textFieldStyle(.roundedBorder).accessibilityIdentifier("signup-name")
- Button("Create your account") { Task { await account.create(name: name) } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- #if os(macOS)
- Button("Import from the earlier Mac app") { legacyPicker = true }.disabled(account.busy)
- #endif
- Button("I have a recovery file") { filePicker = true }.disabled(account.busy)
- if recoveryData != nil {
- SecureField("Recovery password", text: $password).textFieldStyle(.roundedBorder)
- Button("Open your account") { if let data = recoveryData { Task { await account.restore(data: data, password: password) } } }.buttonStyle(.borderedProminent).disabled(account.busy)
- }
- Text("Your messages are encrypted on your device.").font(.footnote).foregroundStyle(.secondary)
+ if inviter == nil { Text("Just you. And your people.").font(.title3).foregroundStyle(.secondary) }
+ Button(inviter == nil ? "Sign up" : "Sign up to accept") { signingUp = true }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy)
+ Button(inviter == nil ? "Sign in" : "Accept") { Task { await account.signInPasskey() } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy)
}
}.frame(maxWidth: 520, alignment: .leading).padding(32).frame(maxWidth: .infinity)
- }
- #if os(macOS)
- .fileDialogDefaultDirectory(URL(fileURLWithPath: String(cString: getpwuid(getuid()).pointee.pw_dir)).appendingPathComponent("Library/WebKit/chat.ocho.infinity.mac/WebsiteData"))
- #endif
- .fileImporter(isPresented: $legacyPicker, allowedContentTypes: [.folder]) { result in
- do {
- let url = try result.get(); let access = url.startAccessingSecurityScopedResource()
- Task { await account.perform {
- defer { if access { url.stopAccessingSecurityScopedResource() } }
- guard let id = try LegacyIdentity.discover(in: [url], preferredOrigin: UserDefaults.standard.string(forKey: "infinity.account-origin")) else { throw OchoError.invalid("No saved Ocho account was found in that folder.") }
- try await account.activate(id)
- } }
- } catch { account.error = error.localizedDescription }
- }
- .fileImporter(isPresented: $filePicker, allowedContentTypes: [.json]) { result in
- do { let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }; recoveryData = try Data(contentsOf: url) }
+ }.task(id: account.invitation) {
+ let link = account.invitation
+ guard !link.isEmpty else { inviter = nil; return }
+ do { inviter = try await Task.detached { try Invite.parse(link).person.name }.value }
catch { account.error = error.localizedDescription }
}
}
diff --git a/apple/OchoCore/Sources/OchoCore/API.swift b/apple/OchoCore/Sources/OchoCore/API.swift
index 4c7d588..3602b0b 100644
--- a/apple/OchoCore/Sources/OchoCore/API.swift
+++ b/apple/OchoCore/Sources/OchoCore/API.swift
@@ -14,12 +14,14 @@ public enum API {
}
public struct Invite: Codable, Sendable {
+ public var sourceLink: String? = nil
public var version = 1
public var thread: String, token: String, person: Person
public static func parse(_ input: String) throws -> Invite {
try fromJSON(engineString { OchoParseInvite(input, $0) }, as: Invite.self)
}
public var link: URL {
+ if let sourceLink, let original = URL(string: sourceLink) { return original }
var c = URLComponents(string: "https://ocho.chat")!
let raw = person.origin + "/connect#" + (try! JSONEncoder().encode(self)).base64URL
var fragment = URLComponents(); fragment.queryItems = [URLQueryItem(name: "invite", value: raw)]
diff --git a/apple/OchoCore/Sources/OchoCore/Client.swift b/apple/OchoCore/Sources/OchoCore/Client.swift
index 2bb2667..ff7947a 100644
--- a/apple/OchoCore/Sources/OchoCore/Client.swift
+++ b/apple/OchoCore/Sources/OchoCore/Client.swift
@@ -17,6 +17,8 @@ public actor OchoClient {
}
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 connectLink(_ link: String) async throws -> String { try engineString { core.connect(link, error: $0) } }
+ public func updateProfile(name: String, avatar: String) async throws { try core.updateProfile(name, avatar: avatar) }
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) }
diff --git a/apple/OchoCore/Sources/OchoCore/Crypto.swift b/apple/OchoCore/Sources/OchoCore/Crypto.swift
index 1b599f3..febfbca 100644
--- a/apple/OchoCore/Sources/OchoCore/Crypto.swift
+++ b/apple/OchoCore/Sources/OchoCore/Crypto.swift
@@ -24,15 +24,17 @@ public struct JWK: Codable, Sendable {
}
}
public struct Person: Codable, Sendable, Hashable {
+ public var avatar: String? = nil
public var origin: String, name: String, enc: String, signing: String
public var cap: String?
}
public struct Identity: Codable, Sendable {
+ public var avatar: String? = nil
public var version = 1
public var origin: String, name: String, auth: String, vaultKey: String, cap: String
public var enc: String, signing: String
public var privateEnc: JWK, privateSign: JWK
- public var person: Person { Person(origin: origin, name: name, enc: enc, signing: signing, cap: cap) }
+ public var person: Person { Person(avatar: avatar, origin: origin, name: name, enc: enc, signing: signing, cap: cap) }
public static func create(origin: String, name: String) throws -> Identity {
try fromJSON(engineString { OchoNewIdentity(origin, name, $0) }, as: Identity.self)
}
diff --git a/apple/README.md b/apple/README.md
index 2ac713f..427c187 100644
--- a/apple/README.md
+++ b/apple/README.md
@@ -15,13 +15,13 @@ tools/infinity/check-native-ui
The last command uses two disposable local personal processes and a dedicated iPhone simulator (override `OCHO_SIMULATOR_ID`). It verifies native receipt/composition/send, peer decryption and zero WebViews. The package tests cover Web Crypto interoperability, group integrity, restored history and read-only legacy migration.
-Build the generated `Infinity` or `InfinityMac` Xcode scheme. Device signing needs a team/profile for `chat.ocho.app` or `chat.ocho.app.mac`, with `webcredentials:ocho.chat` and the matching server association. Both apps display **Ocho**. Physical passkey ceremonies need user presence and a PRF-capable provider; unsupported providers retain explicit recovery-file fallback.
+Build the generated `Infinity` or `InfinityMac` Xcode scheme. Device signing needs a team/profile for `chat.ocho.app` or `chat.ocho.app.mac`, with `webcredentials:ocho.chat` and the matching server association. Both apps display **Ocho**. Physical passkey ceremonies need user presence and a PRF-capable provider; unsupported providers show a retry message before account allocation. Signup and sign-in are passkey-only.
## Existing accounts
-The native app reads the earlier wrapper's local identity directly from SQLite, validates it, and saves it in Keychain. It never loads WebKit or deletes the old files. On Mac the earlier unsigned app stored data outside the new sandbox, so use **Import from the earlier Mac app** and approve the preselected WebsiteData folder in the system picker. Ambiguous identities require explicit passkey/recovery selection.
+Existing signed-in accounts retain their encrypted history. Add a passkey in the existing app or web session before moving to a new device. The new onboarding has no recovery-file controls; legacy encrypted-format readers remain in the engine for compatibility. Existing installations and account data are never deleted by an update.
-Passkeys and recovery files unlock the same account/history on web and native. `ocho://open?url=<encoded invitation>` opens native invitations. Universal links, native background notifications and store distribution remain follow-ups.
+Passkeys unlock the same account/history on web and native. `ocho://open?url=<encoded invitation>` opens native invitations. Short links carry an encrypted invitation locator; accepting opens the selected conversation immediately. Profiles support display names and local image selection. Apple activity notifications are opt-in under Notifications; payloads contain no sender, account or message content. Store distribution remains a separate step.
The icon uses the outlined Myriad Pro infinity glyph. No font file is distributed.
@@ -31,4 +31,4 @@ The project defaults to **Team Tito Limited**, team `9U4A84C4WK`. Xcode's `accou
Open the generated project and build with automatic signing, or use `xcodebuild ... -allowProvisioningUpdates`. Team members need repository access and access to Team Tito's Certificates, Identifiers & Profiles in their Apple account. Do not commit certificates, private keys or provisioning profiles.
-The webcredentials association includes both personal-team installations and the new Team Tito applications. The new bundle IDs and team create separate application/Keychain containers. Preserve the existing installation and recover the account in the team build using a passkey or encrypted recovery file; do not assume it can read the old app's identity. No existing registrations or app data were deleted. App Store Connect records, TestFlight upload and invitations remain separate distribution steps.
+The webcredentials association includes both personal-team installations and the new Team Tito applications. The new bundle IDs and team create separate application/Keychain containers. Preserve the existing installation and recover the account in the team build using a passkey; do not assume it can read the old app's identity. No existing registrations or app data were deleted. App Store Connect records, TestFlight upload and invitations remain separate distribution steps.
diff --git a/apple/project.yml b/apple/project.yml
index 067be9b..f4a6dd4 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -8,7 +8,7 @@ settings:
base:
SWIFT_VERSION: '5.0'
MARKETING_VERSION: '0.2.0'
- CURRENT_PROJECT_VERSION: '5'
+ CURRENT_PROJECT_VERSION: '6'
GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_STYLE: Automatic
diff --git a/docs/infinity/simple-onboarding.md b/docs/infinity/simple-onboarding.md
index a432a8a..9f88a91 100644
--- a/docs/infinity/simple-onboarding.md
+++ b/docs/infinity/simple-onboarding.md
@@ -8,8 +8,10 @@ The authenticated same-origin relay continues to serve personal APIs beneath och
A short invitation uses 16 random bytes (128 bits, 22 base64url characters) in the URL fragment. Domain-separated derivations produce a lookup identifier, encryption key and separate redemption proof. The server stores an encrypted descriptor, lookup ID and proof hash, never the fragment/key. A read-only preview decrypts locally to show “Paul is inviting you to chat on Ocho”. Owner-authenticated redemption is atomic, bound to a recipient and retryable only by that recipient. Expiry is seven days. Link preview bots cannot consume it. Opening an invite shows Accept and Sign up to accept, or Accept for an existing session; creation shows Name then Connect with Paul then passkey. Successful acceptance opens the conversation directly.
-The inviter's signed encrypted invitation grants advance consent to the first valid redeemer. The invitation contains a dedicated one-use inbound capability and a signed descriptor. Recipient and inviter clients verify that consent, avoiding a second manual approval even if the inviter's app is closed. Delivery retries must reuse the same encrypted handshake, and the first valid recipient is pinned; forged/duplicate competitors cannot replace the chosen peer. Existing legacy/manual invitations retain their original consent semantics.
+The inviter's signed encrypted invitation grants advance consent to the first valid redeemer. The invitation contains a dedicated inbound capability that becomes permanently bound to the one selected recipient and a signed descriptor. Recipient and inviter clients verify that consent, avoiding a second manual approval even if the inviter's app is closed. Delivery retries must reuse the same encrypted handshake, and the first valid recipient is pinned; forged/duplicate competitors cannot replace the chosen peer. Existing legacy/manual invitations retain their original consent semantics.
Profile editing persists display name/avatar in the encrypted vault and sends signed encrypted profile updates to established contacts. Avatars are bounded raster data, never remote tracking URLs or executable SVG. Both the Go engine and browser preserve and validate the new fields so native saves cannot erase web profile changes. Profile editing never changes identity keys.
Acceptance: passkey signup and sign-in, cancellation before allocation, lost replies/retry, no tenant navigation, short-link entropy and encryption, preview without consumption, simultaneous redemption isolation, direct chat with inviter offline, existing chat/group integrity, avatar/name synchronization, and native compilation. Preserve pending push work and deploy both features after their checks. No test messaging real contacts or resetting user instances.
+
+Older clients are refused a vault write after format 2 is saved; new web and Go clients preserve profile, preapproval and pending handshake fields. Existing encrypted history is unchanged. Interrupted provisioning retains its nonce and private identity locally; an expired pending ceremony starts a fresh passkey ceremony for the same allocation. Installed older apps need the new build before editing a format-2 account.
diff --git a/docs/infinity/status.md b/docs/infinity/status.md
index 4d85bf7..f2626fd 100644
--- a/docs/infinity/status.md
+++ b/docs/infinity/status.md
@@ -1,49 +1,32 @@
-# 🤖 Ocho status — 2026-09-06
+# 🤖 Ocho status — 2026-09-07
-## Try it
+## Current release work
-https://ocho.chat is live. Signup, signed-in chat, passkey sign-in and recovery stay on that origin; each person still has a separate process and database. Existing tenant accounts can move through **Bring your existing account to Ocho**, a passkey, or an encrypted recovery file.
+The new onboarding is passkey-only. At ocho.chat, Sign up opens Name → Create → system passkey creation → personal provisioning → chats. Sign in uses the same passkey and restores the same identity/history. Signed-in chat stays at ocho.chat through the authenticated relay; each person still has a separate process and database. Recovery-file controls are removed; existing accounts and low-level encrypted-format compatibility are retained.
-Native SwiftUI iPhone and Mac apps replace the WebKit wrappers. The final signed iPhone build (0.2.0, build 3) was installed and launched on Paul's paired iPhone. The signed Mac app is installed at `~/Applications/Ocho.app`. The earlier unsigned Mac app stored its identity outside the new sandbox: use **Import from the earlier Mac app** and approve its WebsiteData folder in the native picker. macOS denied an attempted direct migration into the protected container, so the app uses this explicit user-granted import. No old account data was deleted or reset.
+Invitations use a 22-character fragment with 128 bits of randomness. The server holds only an encrypted descriptor and derived lookup/redemption values. Preview decrypts locally, names the inviter and does not consume the link. Acceptance binds it to one recipient and opens a usable conversation even while the inviter's client is closed. Duplicate acceptance by that recipient is retryable; another recipient cannot reuse it. Name/avatar updates are encrypted and signed. Avatars are bounded embedded raster images.
-Android now has a native Kotlin/Compose app and a buildable debug APK under `android/app/build/outputs/apk/debug/app-debug.apk`. It is tested on the dedicated OchoNativeReview emulator, not installed on a physical Android phone or published to a store.
+SwiftUI iOS/macOS and Kotlin/Compose Android remain fully native, sharing the Go/gomobile engine. The updated clients preserve invitation/profile state; the server prevents older clients from overwriting the newer encrypted vault format. Apple build 6 uses Team Tito Limited (`9U4A84C4WK`), identifiers `chat.ocho.app` and `chat.ocho.app.mac`; Android debug build 2 uses `chat.ocho.app`. Existing personal-team installations are preserved until account access in the new app is confirmed.
-## Architecture
-
-All native clients share the Go/gomobile engine in `mobile/`: identity, envelope/vault/recovery/PRF cryptography, verified event projection, serialized state, account setup, networking, DMs, groups and membership. SwiftUI and Compose own presentation; Keychain/Keystore own local secure storage; AuthenticationServices/Credential Manager own passkey prompts. Native production sources contain no WebViews or JavaScript runtime. The icon is the infinity glyph; the application name is Ocho.
-
-The Carlos front door relays only enumerated APIs to known provisioned tenants. Owner authentication remains enforced by each personal process. Rastrillo verifies WebAuthn ceremonies with required user verification, short-lived single-use challenges, ownership checks and pinned origins. Servers store public credential metadata plus an identity encrypted by a passkey PRF-derived key; PRF outputs and private keys never reach them. Recovery files remain available when a provider cannot supply PRF output.
+Apple notifications are opt-in and contain only a fixed Ocho activity alert. No sender, message, account, thread or ciphertext goes to Apple. Durable per-device wake delivery retries through the Carlos scheduler. The provider key is sealed on the home service only; personal instances hold scoped wake capabilities. Real-device APNs delivery and physical passkey ceremonies still need user-present verification.
## Verification
-- Seven native Go/Swift acceptance tests pass: bidirectional Web Crypto envelope/vault/recovery/PRF fixtures, tamper rejection, three real personal processes, DMs/groups/consent/removal/history and legacy SQLite migration.
-- Actual SwiftUI iPhone simulator test passes receipt, composer send, peer decryption and zero WebViews.
-- Actual Android Compose emulator test passes receipt, composer send, peer decryption, Keystore persistence and restored history after activity recreation; zero WebViews in the view hierarchy. Android debug build and lint pass.
-- Server tests/vet and the full Chromium/WebKit multi-instance chat suite pass, including raw database/WAL plaintext absence. Passkey tests reject replay, wrong origin/certificate, missing user verification, invalid signatures and wrong ownership.
-- Live ocho.chat Chromium virtual PRF authenticator test passed signup, encrypted DMs, enrollment, clearing local storage, discoverable sign-in, restored identical keys/message history and signed-in reload. Live WebKit passed signup and recovery-file restoration without leaving ocho.chat.
-- Signed iPhone/macOS builds pass. Physical Apple/Android passkey provider ceremonies are still unverified and require user presence; virtual authenticator results do not establish that compatibility.
-
-## Hosted state
-
-Both Carlos apps (`infinity-home` and `infinity`, account `ocho`, channel `canary/infinity`) serve release `7b90ff8c`, verified from their response bodies after scoped process restarts. The stable-origin relay, web passkeys and Apple/Android public associations are live at ocho.chat. The Android association pins the actual local debug APK certificate, not a future store signing identity. The personal deploy watcher timed out while its process still served the previous binary; a later app-scoped restart and direct response-body check verified adoption. Subsequent native-only Android guards and documentation changes do not require a server redeploy.
-
-DNSimple team `eleven` (176940) serves ocho.chat. DNS and certificate-validated HTTPS are verified. Earlier Carlos release-adoption permission failures were fixed in platform b08445b on Ireland; no tenant database/WAL was reset. App deployments upload binaries sequentially and require response-body checks because a new version header can precede process adoption.
-
-## Limits and follow-ups
-
-This is an early text-chat implementation with no independent security audit or double-ratchet forward secrecy. Servers see routing metadata, timing, sizes, public keys and delivery capabilities. Removal takes effect when a sender receives the group update; authenticated older epochs remain readable. Recovery/passkeys clone an account identity; independent device revocation is not implemented. Web clients trust code served by their origin.
-
-Universal links, native background push, attachments, voice/video, distribution signing and store releases remain follow-ups. Amadan CI is not configured; validation above ran explicitly. Implementation is pushed on isolated branch `infinity-v1` at `https://amadan.net/eleven/ocho`; main and the original Eleven checkout remain unmerged.
-
-Machine tooling and cross-session delivery limits are recorded in `android-handoff.md`. Build/test commands are in `apple/README.md` and `android/README.md`.
+- Server tests/vet and race checks: passkey origin/ownership/replay, signup nonce binding and exact finalization retries, invitation recipient pinning, expired/wrong tickets, vault downgrade protection, durable private push queue.
+- Real Chromium front-door test: passkey before allocation, lost successful signup reply/retry, cancelled/unsupported passkey without allocation, short-link preview/reload, inviter-offline immediate chat, profile synchronization, discoverable passkey sign-in and identical restored keys/history, same-recipient invite retry; all navigation stays at the front door.
+- Chromium/WebKit three-instance tests: existing DMs, groups, membership removal, reload, old encrypted-format compatibility, mobile layout and no plaintext in SQLite/WAL.
+- Web Crypto ↔ Go/Swift interoperability includes encrypted short invitations and offline consent, envelopes, vaults, PRF and legacy readers. Seven Swift package tests cover real personal processes and group/history integrity.
+- Actual SwiftUI simulator test passes receive/send/peer decryption with zero WebViews. Actual Compose emulator test passes receive/send/peer decryption, Keystore persistence and activity recreation with zero WebViews. Android build/lint pass.
+- Signed Apple builds compile under Team Tito. Simulator/virtual-authenticator tests do not verify physical passkey-provider PRF compatibility.
-## Team Tito preparation — 2026-09-07
+## Hosting and distribution
-Paul requested development ownership under Team Tito Limited (`9U4A84C4WK`). The Xcode project now defaults to that team, and the source associated-domain response retains personal-team entries while adding provisional Team Tito entries. These association changes have not been deployed. The signed build attempt failed because Apple rejected Xcode's saved `accounts@tito.io` login; no Team Tito provisioning profiles were available for Ocho. Reauthentication is required before checking identifier availability, producing signed builds and completing account migration. Existing installed apps and account data remain intact. See the Apple README for the exact next steps and unpublished-app transfer constraints.
+Dedicated Carlos account `ocho`, apps `infinity-home` and `infinity`, channel `canary/infinity`. Public alias ocho.chat is managed in DNSimple's Eleven Messenger team (`eleven`, account 176940). Deploy binaries sequentially and verify `/api/version` response bodies: version headers can lead process adoption. No user tenant database or WAL is reset.
+The new signup release is prepared for deployment; the preceding push release was shipped. Record final adoption below once verified. New native builds do not replace old installed apps automatically. No App Store Connect record, TestFlight upload, team invitations or Android store release has been created in this session. Android push has no FCM configuration.
-### Team Tito login resolved
+Source lives on isolated branch `infinity-v1` at https://amadan.net/eleven/ocho. Main and the original Eleven checkout remain unmerged. Build commands are in the Apple/Android READMEs; architecture and metadata limits are in `simple-onboarding.md`, `push.md` and the design documents.
-Apple accepted the refreshed Xcode login. Registration of `chat.ocho.infinity` under Team Tito was rejected as unavailable, so team builds now use `chat.ocho.app` and `chat.ocho.app.mac` (display name Ocho, build 4). The iOS development provisioning profile identifies Team Tito Limited and `9U4A84C4WK.chat.ocho.app`; the signed app includes `webcredentials:ocho.chat`. Existing apps remain installed and their account data is untouched. Restoring the same account in a team build requires passkey/recovery because the app and Keychain containers differ. The source association now lists the actual new team identifiers alongside the old personal ones.
+## Limits
-Team Tito completion: both iOS and macOS development builds succeeded, and their code signatures and entitlements were verified as `9U4A84C4WK.chat.ocho.app` and `9U4A84C4WK.chat.ocho.app.mac`. The home release `f375bd4f` is live, verified from the ocho.chat response body; its public webcredentials association contains both new team apps and both original personal-team apps. Server tests passed. The Team Tito build products are under `apple/build-team-tito` and `apple/build-team-tito-mac`. They have not replaced the user's installed apps; restoring account access precedes removal of any older installation. No TestFlight upload or team invitations were sent.
+Early text chat: no independent cryptographic audit or double-ratchet forward secrecy. Servers observe routing metadata, timing, sizes, public keys and delivery capabilities. Group removal applies when a sender receives the update. Passkeys unlock the same account identity; independent device-key revocation is not implemented. Web clients trust code served by their origin. Voice/video, attachments, store distribution and Android push remain follow-ups.
diff --git a/internal/infinity/app.go b/internal/infinity/app.go
index c324ebf..b4b28b4 100644
--- a/internal/infinity/app.go
+++ b/internal/infinity/app.go
@@ -78,7 +78,7 @@ func (a *App) Handler() *http.ServeMux {
static, _ := fs.Sub(assets, "static")
m.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
m.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/" && r.URL.Path != "/connect" {
+ if r.URL.Path != "/" && r.URL.Path != "/connect" && r.URL.Path != "/signup" {
http.NotFound(w, r)
return
}
@@ -242,7 +242,7 @@ func WebHandler() *http.ServeMux {
static, _ := fs.Sub(assets, "static")
m.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
m.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/" && r.URL.Path != "/connect" {
+ if r.URL.Path != "/" && r.URL.Path != "/connect" && r.URL.Path != "/signup" {
http.NotFound(w, r)
return
}
diff --git a/internal/infinity/inbox.go b/internal/infinity/inbox.go
index 4c1eb1d..659f2a7 100644
--- a/internal/infinity/inbox.go
+++ b/internal/infinity/inbox.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "lchat/internal/infinityinvite"
"net/http"
"strconv"
)
@@ -36,11 +37,29 @@ func (a *App) receive(w http.ResponseWriter, r *http.Request) {
defer tx.Rollback()
var remaining int
var expiry int64
- e = tx.QueryRowContext(r.Context(), "SELECT remaining,expires FROM capabilities WHERE hash=?", hash(token)).Scan(&remaining, &expiry)
+ var requiresClaim bool
+ var boundSigner string
+ e = tx.QueryRowContext(r.Context(), "SELECT remaining,expires,requires_claim,signer FROM capabilities WHERE hash=?", hash(token)).Scan(&remaining, &expiry, &requiresClaim, &boundSigner)
if e != nil || (expiry != 0 && expiry < now()) {
fail(w, 403, "Invitation unavailable.")
return
}
+ if requiresClaim {
+ if boundSigner != "" && boundSigner != env.Sender {
+ fail(w, 403, "Invitation already accepted by someone else.")
+ return
+ }
+ if boundSigner == "" {
+ if !infinityinvite.Verify(a.cfg.SetupPublicKey, r.Header.Get("X-Ocho-Invite-Claim"), r.Host, hash(token), env.Sender) {
+ fail(w, 403, "Accept this invitation at ocho.chat first.")
+ return
+ }
+ if _, e = tx.ExecContext(r.Context(), "UPDATE capabilities SET signer=?,expires=0 WHERE hash=?", env.Sender, hash(token)); e != nil {
+ dbError(w, e)
+ return
+ }
+ }
+ }
// Retries of an identical envelope succeed even after a one-use invitation is consumed.
var existing string
e = tx.QueryRowContext(r.Context(), "SELECT envelope FROM inbox WHERE id=?", env.ID).Scan(&existing)
diff --git a/internal/infinity/migrations/0003_invite_consent.sql b/internal/infinity/migrations/0003_invite_consent.sql
new file mode 100644
index 0000000..0f1112b
--- /dev/null
+++ b/internal/infinity/migrations/0003_invite_consent.sql
@@ -0,0 +1,4 @@
+-- 🤖 Redeemed invite channels are bound to one signing key; ordinary capabilities retain their semantics.
+ALTER TABLE capabilities ADD COLUMN requires_claim INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE capabilities ADD COLUMN signer TEXT NOT NULL DEFAULT '';
+ALTER TABLE outbox ADD COLUMN invite_claim TEXT NOT NULL DEFAULT '';
diff --git a/internal/infinity/migrations/0004_vault_format.sql b/internal/infinity/migrations/0004_vault_format.sql
new file mode 100644
index 0000000..1df9906
--- /dev/null
+++ b/internal/infinity/migrations/0004_vault_format.sql
@@ -0,0 +1,2 @@
+-- 🤖 Older clients must not erase encrypted fields introduced by newer clients.
+ALTER TABLE vault ADD COLUMN format INTEGER NOT NULL DEFAULT 0;
diff --git a/internal/infinity/outbox.go b/internal/infinity/outbox.go
index 8db2671..d75c3d5 100644
--- a/internal/infinity/outbox.go
+++ b/internal/infinity/outbox.go
@@ -17,6 +17,7 @@ type deliveryItem struct {
Destination string `json:"destination"`
Capability string `json:"capability"`
Envelope Envelope `json:"envelope"`
+ InviteClaim string `json:"claim,omitempty"`
}
func (a *App) send(w http.ResponseWriter, r *http.Request) {
@@ -37,7 +38,7 @@ func (a *App) send(w http.ResponseWriter, r *http.Request) {
}
for i, item := range p.Items {
dest, e := destination(item.Destination, a.cfg.AllowLocal)
- if e != nil || !validSecret(item.Capability) || item.Envelope.Sender != signing || item.Envelope.validate() != nil {
+ if len(item.InviteClaim) > 2048 || e != nil || !validSecret(item.Capability) || item.Envelope.Sender != signing || item.Envelope.validate() != nil {
fail(w, 400, "Invalid delivery.")
return
}
@@ -51,7 +52,7 @@ func (a *App) send(w http.ResponseWriter, r *http.Request) {
defer tx.Rollback()
for _, item := range p.Items {
b, _ := json.Marshal(item.Envelope)
- if _, e = tx.ExecContext(r.Context(), "INSERT OR IGNORE INTO outbox(id,destination,capability,envelope) VALUES(?,?,?,?)", item.Envelope.ID, item.Destination, item.Capability, string(b)); e != nil {
+ if _, e = tx.ExecContext(r.Context(), "INSERT OR IGNORE INTO outbox(id,destination,capability,envelope,invite_claim) VALUES(?,?,?,?,?)", item.Envelope.ID, item.Destination, item.Capability, string(b), item.InviteClaim); e != nil {
dbError(w, e)
return
}
@@ -67,18 +68,18 @@ func (a *App) send(w http.ResponseWriter, r *http.Request) {
reply(w, 202, map[string]bool{"queued": true})
}
func (a *App) flush(ctx context.Context) {
- rows, e := a.db.QueryContext(ctx, "SELECT id,destination,capability,envelope,attempts FROM outbox WHERE delivered=0 AND next_attempt<=? ORDER BY next_attempt LIMIT 32", now())
+ rows, e := a.db.QueryContext(ctx, "SELECT id,destination,capability,envelope,attempts,invite_claim FROM outbox WHERE delivered=0 AND next_attempt<=? ORDER BY next_attempt LIMIT 32", now())
if e != nil {
return
}
type job struct {
- id, dest, cap, raw string
- attempts int
+ id, dest, cap, raw, claim string
+ attempts int
}
jobs := []job{}
for rows.Next() {
var j job
- if rows.Scan(&j.id, &j.dest, &j.cap, &j.raw, &j.attempts) != nil {
+ if rows.Scan(&j.id, &j.dest, &j.cap, &j.raw, &j.attempts, &j.claim) != nil {
rows.Close()
return
}
@@ -95,6 +96,9 @@ func (a *App) flush(ctx context.Context) {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+j.cap)
+ if j.claim != "" {
+ req.Header.Set("X-Ocho-Invite-Claim", j.claim)
+ }
res, e := a.client.Do(req)
ok := false
if e == nil {
diff --git a/internal/infinity/simple_test.go b/internal/infinity/simple_test.go
new file mode 100644
index 0000000..215f52c
--- /dev/null
+++ b/internal/infinity/simple_test.go
@@ -0,0 +1,53 @@
+// 🤖 Selected-recipient enforcement and old-client vault downgrade protection.
+package infinity
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "lchat/internal/infinityinvite"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestVaultFormatDowngrade(t *testing.T) {
+ f := newFixture(t)
+ box := strings.Repeat("ciphertext-", 8)
+ f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 0, "box": box, "format": 2}, 200)
+ f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 1, "box": box}, 426)
+ f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 1, "box": box, "format": 2}, 200)
+ f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 1, "box": box, "format": 2}, 409)
+}
+func TestInvitationPinsRecipient(t *testing.T) {
+ a, b, c := newFixture(t), newFixture(t), newFixture(t)
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ b.a.cfg.SetupPublicKey = pub
+ cap := secret()
+ b.call(t, "POST", "/api/capabilities", b.auth, map[string]any{"token": cap, "invite": true}, 201)
+ u, _ := url.Parse(b.s.URL)
+ send := func(env Envelope, ticket string, want int) {
+ t.Helper()
+ raw, _ := json.Marshal(env)
+ r := httptest.NewRequest("POST", b.s.URL+"/api/inbox", bytes.NewReader(raw))
+ r.Header.Set("Authorization", "Bearer "+cap)
+ r.Header.Set("X-Ocho-Invite-Claim", ticket)
+ w := httptest.NewRecorder()
+ b.a.Handler().ServeHTTP(w, r)
+ if w.Code != want {
+ t.Fatalf("got %d want %d: %s", w.Code, want, w.Body.String())
+ }
+ }
+ env := signedEnvelope(t, a, b)
+ send(env, "", 403)
+ send(env, infinityinvite.Mint(key, u.Host, hash(cap), c.sign, now()+60), 403)
+ send(env, infinityinvite.Mint(key, u.Host, hash(cap), a.sign, now()-1), 403)
+ send(env, infinityinvite.Mint(key, "wrong.host", hash(cap), a.sign, now()+60), 403)
+ ticket := infinityinvite.Mint(key, u.Host, hash(cap), a.sign, now()+60)
+ send(env, ticket, 201)
+ send(env, ticket, 200)
+ send(signedEnvelope(t, a, b), "", 201)
+ send(signedEnvelope(t, c, b), infinityinvite.Mint(key, u.Host, hash(cap), c.sign, now()+60), 403)
+}
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js
index a8cb40a..4137f7e 100644
--- a/internal/infinity/static/app.js
+++ b/internal/infinity/static/app.js
@@ -1,5 +1,6 @@
// 🤖 Entry point: no content is painted until account state has been decrypted.
import { request, peerOrigin, setRelay, usingRelay } from './api.js';
+import { previewInvite } from './invitations.js';
import { setupProgress } from './setup-progress.js';
import { Client } from './client.js';
import { Chat } from './chat.js';
@@ -7,15 +8,18 @@ import { fragment, readIdentity, remember, finishSetup, welcome } from './onboar
import { unb64, b64 } from './crypto.js';
import { el, message, button } from './dom.js';
const root = document.querySelector('#app');
-async function ready(client) {
- const params = fragment(); history.replaceState(null, '', '/');
- const chat = new Chat(root, client); await chat.start(params.invite);
+async function ready(client, selected=null, invite=null) {
+ const chat = new Chat(root,client);await chat.start(invite);if(selected)chat.select(selected);
}
async function boot() {
const params = fragment();
const profile = await request(location.origin, '/api/profile');
setRelay(profile.registration === true);
const savedIdentity = readIdentity();
+ if(params.code) {
+ try{params.preview=await previewInvite(params.code);}catch(error){params.inviteError=error.message;}
+ welcome(root,params,ready,savedIdentity);return;
+ }
if (params.migrate && savedIdentity?.origin === location.origin) {
const home = peerOrigin((await request(location.origin, '/api/client-config')).homeOrigin);
const transfer = b64(new TextEncoder().encode(JSON.stringify(savedIdentity)));
@@ -41,7 +45,8 @@ async function boot() {
const target = peerOrigin(home || (await request(location.origin, '/api/client-config')).homeOrigin);
root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'You’re invited.'), el('p', {}, 'Open your own account to connect.'), button('Continue', () => location.assign(target + '/#invite=' + encodeURIComponent(params.invite)), { class: 'primary' })))); return;
}
- welcome(root, params, client => ready(client).catch(showError));
+ welcome(root,params,ready);
}
-function showError(error) { root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'Couldn’t open ∞'), message(error.message, true), button('Try again', () => location.reload(), { class: 'primary' })))); }
+function showError(error) { root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'Couldn’t open Ocho'), message(error.message, true), button('Try again', () => location.reload(), { class: 'primary' })))); }
boot().catch(showError);
+window.addEventListener('hashchange',()=>location.reload());
diff --git a/internal/infinity/static/avatars.js b/internal/infinity/static/avatars.js
new file mode 100644
index 0000000..b246287
--- /dev/null
+++ b/internal/infinity/static/avatars.js
@@ -0,0 +1,23 @@
+// 🤖 Bounded embedded raster avatars; no remote URLs or SVG.
+export function validAvatar(value) {
+ if (!value) return true;
+ if (typeof value!=='string' || value.length>45000 || !/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/.test(value)) return false;
+ try {
+ const bytes=Uint8Array.from(atob(value.split(',')[1]),c=>c.charCodeAt(0)),v=new DataView(bytes.buffer);
+ if(value.startsWith('data:image/png;')) return bytes.length>24 && v.getUint32(0)===0x89504e47 && v.getUint32(4)===0x0d0a1a0a && v.getUint32(12)===0x49484452 && v.getUint32(16)>0 && v.getUint32(16)<=256 && v.getUint32(20)>0 && v.getUint32(20)<=256;
+ if(v.getUint16(0)!==0xffd8)return false;
+ for(let i=2;i+4<bytes.length;) {
+ if(bytes[i]!==255)return false;const marker=bytes[i+1],length=v.getUint16(i+2);
+ if([0xc0,0xc1,0xc2].includes(marker))return length>=7 && i+9<=bytes.length && v.getUint16(i+5)>0 && v.getUint16(i+5)<=256 && v.getUint16(i+7)>0 && v.getUint16(i+7)<=256;
+ if(length<2)return false;i+=2+length;
+ }
+ }catch{}
+ return false;
+}
+export async function raster(file) {
+ if(!file || file.size>8*1024*1024 || !['image/png','image/jpeg','image/webp'].includes(file.type))throw Error('Choose a PNG, JPEG or WebP image under 8 MB.');
+ const bitmap=await createImageBitmap(file),canvas=document.createElement('canvas');canvas.width=canvas.height=128;
+ const scale=Math.max(128/bitmap.width,128/bitmap.height),width=bitmap.width*scale,height=bitmap.height*scale;
+ canvas.getContext('2d').drawImage(bitmap,(128-width)/2,(128-height)/2,width,height);bitmap.close();
+ const result=canvas.toDataURL('image/jpeg',0.8);if(!validAvatar(result))throw Error('Could not resize this image. Try another one.');return result;
+}
diff --git a/internal/infinity/static/chat.js b/internal/infinity/static/chat.js
index 2696991..7395666 100644
--- a/internal/infinity/static/chat.js
+++ b/internal/infinity/static/chat.js
@@ -1,6 +1,7 @@
// 🤖 Flat conversations, with no team or community navigation.
import { el, button, mark, avatar, message, busy } from './dom.js';
-import { share, connectDialog, recovery, newGroup, details } from './dialogs.js';
+import { share, connectDialog, newGroup, details } from './dialogs.js';
+import { editProfile, passkeys } from './profile.js';
export class Chat {
constructor(root, client) { this.root = root; this.client = client; this.selected = null; this.drafts = new Map(); this.query = ''; this.error = ''; this.refreshing = false; }
async start(invite) {
@@ -28,8 +29,8 @@ export class Chat {
const search = el('input', { id: 'chat-search', type: 'search', placeholder: 'Search conversations', 'aria-label': 'Search conversations', value: this.query });
search.addEventListener('input', () => { this.query = search.value; this.renderList(); });
this.list = el('nav', { class: 'thread-list', 'aria-label': 'Conversations' });
- const menu = el('details', { class: 'account-menu' }, el('summary', {}, avatar(this.client.state.name), el('span', {}, this.client.state.name), el('span', { class: 'muted' }, '⌄')),
- el('div', { class: 'menu-items' }, button('Connect with a link', () => connectDialog(this.client, id => this.select(id))), button('New group', () => newGroup(this.client, id => this.select(id))), button('Recovery & another device', () => recovery(this.client)), button('About privacy', () => this.privacy())));
+ const menu = el('details', { class: 'account-menu' }, el('summary', {}, avatar(this.client.state.name,this.client.state.avatar), el('span', {}, this.client.state.name), el('span', { class: 'muted' }, '⌄')),
+ el('div', { class: 'menu-items' }, button('Connect with a link', () => connectDialog(this.client, id => this.select(id))), button('New group', () => newGroup(this.client, id => this.select(id))), button('Your profile', () => editProfile(this.client,()=>this.render())), button('Passkeys',()=>passkeys(this.client)), button('Sign out',()=>{localStorage.removeItem('infinity.identity.v1');location.assign('/');}), button('About privacy', () => this.privacy())));
menu.open = Boolean(menuWasOpen);
const aside = el('aside', { class: 'sidebar' }, header, el('div', { class: 'search-wrap' }, search), this.list, el('p', { class: 'connection', role: 'status' }, this.error), menu);
shell.append(aside, selected ? this.conversation(selected) : this.empty()); this.root.replaceChildren(shell); this.renderList();
@@ -47,13 +48,13 @@ export class Chat {
for (const t of threads) {
const last = t.messages.at(-1), preview = t.pending ? 'Waiting for your connection to be accepted' : last ? (last.mine ? 'You: ' : '') + last.text : t.left ? 'You are no longer in this group' : 'Say hello';
this.list.append(button('', () => this.select(t.id), { class: 'thread-row' + (this.selected === t.id ? ' active' : ''), 'aria-current': this.selected === t.id ? 'page' : false }));
- this.list.lastChild.append(avatar(t.name), el('span', { class: 'thread-summary' }, el('span', { class: 'thread-top' }, el('strong', {}, t.name), last && el('time', { datetime: new Date(last.at).toISOString() }, new Date(last.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))), el('span', { class: 'preview' }, preview)));
+ this.list.lastChild.append(avatar(t.name,t.peer?.avatar), el('span', { class: 'thread-summary' }, el('span', { class: 'thread-top' }, el('strong', {}, t.name), last && el('time', { datetime: new Date(last.at).toISOString() }, new Date(last.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))), el('span', { class: 'preview' }, preview)));
}
if (!threads.length && !this.client.view.requests.length) this.list.append(el('div', { class: 'list-empty' }, el('p', {}, this.query ? 'No matching conversations.' : 'Your people will appear here.'), !this.query && button('Invite someone', () => share(this.client), { class: 'quiet' })));
}
empty() { return el('section', { class: 'empty-pane' }, mark(), el('h1', {}, 'A little closer.'), el('p', {}, 'A private place for your everyday conversations.'), button('Invite someone', () => share(this.client), { class: 'primary' }), el('span', { class: 'encryption-note' }, 'End-to-end encrypted')); }
conversation(t) {
- const header = el('header', { class: 'chat-header' }, button('‹', () => this.select(null), { class: 'back quiet', 'aria-label': 'Back to conversations' }), avatar(t.name), el('div', { class: 'chat-title' }, el('h1', {}, t.name), el('p', {}, t.group ? t.members.length + ' people · End-to-end encrypted' : 'End-to-end encrypted')), button('Details', () => details(this.client, t, id => this.select(id)), { class: 'quiet' }));
+ const header = el('header', { class: 'chat-header' }, button('‹', () => this.select(null), { class: 'back quiet', 'aria-label': 'Back to conversations' }), avatar(t.name,t.peer?.avatar), el('div', { class: 'chat-title' }, el('h1', {}, t.name), el('p', {}, t.group ? t.members.length + ' people · End-to-end encrypted' : 'End-to-end encrypted')), button('Details', () => details(this.client, t, id => this.select(id)), { class: 'quiet' }));
const log = el('div', { class: 'messages', role: 'log', 'aria-label': 'Messages', 'aria-live': 'polite' });
if (!t.messages.length) log.append(el('p', { class: 'start-note' }, t.pending ? 'Your invitation was sent. You can chat when it’s accepted.' : 'The start of your conversation.'));
let date = '';
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js
index 351f826..2e35979 100644
--- a/internal/infinity/static/client.js
+++ b/internal/infinity/static/client.js
@@ -1,6 +1,8 @@
// 🤖 Account coordinator; all readable state stays in the client.
import { request, peerOrigin, usingRelay } from './api.js';
import { random, publicIdentity, seal, open, lock, unlock, b64 } from './crypto.js';
+import { createShortInvite, contactProof, verifiedConsent } from './invitations.js';
+import { validAvatar } from './avatars.js';
import { project } from './protocol.js';
export class Client {
constructor(identity) {
@@ -24,10 +26,10 @@ export class Client {
async load() {
const v = await this.api('/api/vault'); this.revision = v.revision;
if (v.box) this.state = await unlock(this.id.vaultKey, v.box);
- this.id.name = this.state.name;
+ this.id.name = this.state.name;this.id.avatar=validAvatar(this.state.avatar)?this.state.avatar:'';
}
async save() {
- const res = await this.api('/api/vault', { method: 'PUT', body: { revision: this.revision, box: await lock(this.id.vaultKey, this.state) } });
+ const res = await this.api('/api/vault', { method: 'PUT', body: { format:2, revision: this.revision, box: await lock(this.id.vaultKey, this.state) } });
this.revision = res.revision;
}
async refresh() {
@@ -36,12 +38,24 @@ export class Client {
for (;;) {
const rows = await this.api('/api/events?after=' + this.cursor);
for (const row of rows) {
- try { this.events.push({ ...await open(this.id, row.envelope), signer: row.envelope.sender, seq: row.seq }); }
+ try {
+ const event = {...await open(this.id,row.envelope),signer:row.envelope.sender,seq:row.seq};
+ event.preapproved = false;
+ if (event.kind === 'contact-request') event.preapproved = await verifiedConsent(this.id,this.state,event);
+ this.events.push(event);
+ }
catch { /* An invalid envelope cannot become UI content or trusted state. */ }
this.cursor = row.seq;
}
if (rows.length < 100) break;
}
+ let adopted=false;
+ for(const event of this.events) {
+ if(event.kind==='contact-request'&&event.preapproved===true&&!this.state.contacts[event.thread]&&!this.state.blocked[event.thread]) {
+ this.state.contacts[event.thread]={person:event.person,pending:false};adopted=true;
+ }
+ }
+ if(adopted)await this.save();
this.statuses = await this.api('/api/delivery'); this.rebuild();
}
rebuild() {
@@ -53,12 +67,14 @@ export class Client {
}
}
async emit(people, payload) {
+ if(payload.kind==='group')payload={...payload,members:payload.members.map(({avatar,...person})=>person)};
const unique = [...new Map(people.map(p => [p.signing, p])).values()];
const items = await Promise.all(unique.map(async person => ({ destination: peerOrigin(person.origin), capability: person.cap, envelope: await seal(this.id, person, payload) })));
await this.api('/api/send', { method: 'POST', body: { items } });
return items.map(i => i.envelope.id);
}
async invite() {
+ if (usingRelay()) return createShortInvite(this);
await this.load();
const token = random(), thread = random();
await this.api('/api/capabilities', { method: 'POST', body: { token, once: true } });
@@ -70,13 +86,24 @@ export class Client {
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.');
+ if (invite.person.signing === this.id.signing) throw new Error('Share this invitation with someone else.');
await this.load();
- this.state.contacts[invite.thread] = { person: invite.person, pending: true };
- await this.save();
- const payload = { kind: 'contact-request', thread: invite.thread, person: publicIdentity(this.id), at: Date.now() };
- await this.emit([{ ...invite.person, cap: invite.token }], payload);
- await this.refresh(); return invite.thread;
+ const existing = this.state.contacts[invite.thread];
+ if (existing && existing.person.signing !== invite.person.signing) throw Error('This conversation belongs to someone else.');
+ this.state.handshakes ||= {};
+ if (!existing) {
+ const payload={kind:'contact-request',thread:invite.thread,person:publicIdentity(this.id),at:Date.now()};
+ if (invite.version===2) {payload.acceptProof=await contactProof(invite.grant,invite.thread,this.id.signing);payload.claim=invite.claim;}
+ this.state.contacts[invite.thread]={person:invite.person,pending:invite.version!==2};
+ this.state.handshakes[invite.thread]=[{destination:peerOrigin(invite.person.origin),capability:invite.token,envelope:await seal(this.id,invite.person,payload),...(invite.claim?{claim:invite.claim}:{})}];
+ await this.save();
+ }
+ const items=this.state.handshakes[invite.thread];
+ if (items) {
+ await this.api('/api/send',{method:'POST',body:{items}});
+ delete this.state.handshakes[invite.thread];await this.save();
+ }
+ await this.refresh();return invite.thread;
}
async accept(req) {
await this.load();
@@ -96,6 +123,14 @@ export class Client {
const payload = { kind: 'message', thread, id: random(), text, at: Date.now(), epoch: t.epoch || 0 };
await this.emit(t.members, payload); await this.refresh();
}
+ async updateProfile(name,avatar) {
+ name=name.trim();if(!name || name.length>80 || !validAvatar(avatar))throw Error('Choose a name and a supported avatar.');
+ await this.refresh();this.state.name=name;this.state.avatar=avatar;this.id.name=name;this.id.avatar=avatar;await this.save();
+ const people=[...new Map(this.view.threads.filter(t=>!t.pending&&!t.left).flatMap(t=>t.members).map(p=>[p.signing,p])).values()];
+ const payload={kind:'profile',thread:'profile',person:publicIdentity(this.id),at:Date.now(),id:random()};
+ for(let i=0;i<people.length;i+=16)await this.emit(people.slice(i,i+16),payload);
+ await this.refresh();
+ }
async group(name, contactIDs) {
await this.refresh();
const members = [publicIdentity(this.id), ...contactIDs.map(id => this.view.threads.find(t => t.id === id && !t.group && !t.pending)?.peer)];
diff --git a/internal/infinity/static/crypto.js b/internal/infinity/static/crypto.js
index 09867ea..cca4a30 100644
--- a/internal/infinity/static/crypto.js
+++ b/internal/infinity/static/crypto.js
@@ -16,7 +16,7 @@ export async function createIdentity(origin, name) {
enc: b64(await subtle.exportKey('raw', enc.publicKey)), signing: b64(await subtle.exportKey('raw', sign.publicKey)),
privateEnc: await subtle.exportKey('jwk', enc.privateKey), privateSign: await subtle.exportKey('jwk', sign.privateKey) };
}
-export const publicIdentity = id => ({ origin: id.origin, name: id.name, enc: id.enc, signing: id.signing, cap: id.cap });
+export const publicIdentity = id => ({ origin: id.origin, name: id.name, enc: id.enc, signing: id.signing, cap: id.cap, ...(id.avatar?{avatar:id.avatar}:{}) });
const importPublic = (raw, name, uses = []) => subtle.importKey('raw', unb64(raw), { name, namedCurve: 'P-256' }, false, uses);
async function aes(shared, nonce) {
const key = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
diff --git a/internal/infinity/static/dialogs.js b/internal/infinity/static/dialogs.js
index e0580e7..e6b08fc 100644
--- a/internal/infinity/static/dialogs.js
+++ b/internal/infinity/static/dialogs.js
@@ -1,21 +1,21 @@
// 🤖 Secondary flows each have one job, with inline feedback.
import { el, button, field, message, modal, busy } from './dom.js';
-import { exportRecovery, safetyNumber } from './crypto.js';
-import { addPasskey } from './passkeys.js';
+import { safetyNumber } from './crypto.js';
+import { shortCode, redeemInvite } from './invitations.js';
import { parseInvite, usingRelay } from './api.js';
export function share(client) {
const body = el('div', {}, el('p', {}, 'Send a private invitation to someone you know. Each link works once and lasts seven days.'));
const dialog = modal('Invite someone', body);
const create = button('Create invitation', async () => busy(create, async () => {
try {
- const link = await client.invite(); const box = el('textarea', { readonly: true, rows: 4, 'aria-label': 'Invitation link' }, link);
+ const link = await client.invite(); const box = el('textarea', { readonly: true, rows: 1, 'aria-label': 'Invitation link' }, link);
body.replaceChildren(el('p', {}, 'Send this link to your person.'), box, button('Copy link', async () => {
try { await navigator.clipboard.writeText(link); body.append(message('Link copied.')); } catch { box.select(); body.append(message('Select and copy the link above.')); }
}, { class: 'primary' }));
- if (navigator.share) body.append(button('Share…', () => navigator.share({ title: 'Chat with me on ∞', url: link }).catch(() => {}), { class: 'quiet' }));
+ if (navigator.share) body.append(button('Share…', () => navigator.share({ title: 'Chat with me on Ocho', url: link }).catch(() => {}), { class: 'quiet' }));
} catch (e) { body.append(message(e.message, true)); }
}), { class: 'primary' });
- body.append(create); return dialog;
+ body.append(create); create.click(); return dialog;
}
export function connectDialog(client, changed, initial = '') {
const link = field('Invitation link', { type: 'url', required: true, placeholder: 'Paste the link you received' }); link.input.value = initial;
@@ -23,38 +23,10 @@ export function connectDialog(client, changed, initial = '') {
const body = el('form', {}, el('p', {}, 'Only connect using a link shared by someone you trust.'), link.node, note, submit);
const dialog = modal('Connect with someone', body);
body.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => {
- try { const id = await client.connect(parseInvite(link.input.value)); dialog.close(); changed(id); }
+ try { const code=shortCode(link.input.value); const id = code ? await redeemInvite(client,code) : await client.connect(parseInvite(link.input.value)); dialog.close(); changed(id); }
catch (error) { note.replaceChildren(message(error.message, true)); }
}); });
}
-export function recovery(client) {
- const password = field('Recovery password', { type: 'password', minlength: 12, autocomplete: 'new-password', required: true });
- const confirm = field('Repeat password', { type: 'password', minlength: 12, autocomplete: 'new-password', required: true });
- const note = el('div', {}), submit = el('button', { type: 'submit', class: 'primary' }, 'Save recovery file');
- const body = el('form', {}, el('p', {}, 'Keep this file and password somewhere safe. Together they open your account and messages on another device. We cannot reset them for you.'), password.node, confirm.node, note, submit);
- const passkey = button('Add a passkey', () => busy(passkey, async () => {
- try { await addPasskey(client.id); note.replaceChildren(message('Passkey saved. Use it to open Ocho and your messages on another device.')); }
- catch (error) { note.replaceChildren(message(error.message, true)); }
- }), { class: 'primary' });
- if (usingRelay()) body.prepend(el('p', {}, 'Use the same passkey to sign in and unlock your messages on the web, iPhone and Mac.'), passkey);
- else body.prepend(el('p', {}, 'To add a passkey, bring this account to ocho.chat.'), button('Use Ocho at ocho.chat', () => location.assign('/#migrate=1'), { class: 'primary' }));
- modal('Recovery & another device', body);
- body.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => {
- try {
- if (password.input.value !== confirm.input.value) throw new Error('The passwords do not match.');
- const file = await exportRecovery(client.id, password.input.value);
- if (window.webkit?.messageHandlers?.infinity) {
- window.webkit.messageHandlers.infinity.postMessage({ type: 'recovery', content: file });
- password.input.value = ''; confirm.input.value = '';
- note.replaceChildren(message('Choose where to save your recovery file.')); return;
- }
- const url = URL.createObjectURL(new Blob([file], { type: 'application/json' }));
- const a = el('a', { href: url, download: 'infinity-recovery.json' }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000);
- note.replaceChildren(message('Save the downloaded file. On another device, choose “I have a recovery file”.'));
- password.input.value = ''; confirm.input.value = '';
- } catch (error) { note.replaceChildren(message(error.message, true)); }
- }); });
-}
export function newGroup(client, changed) {
const name = field('Group name', { required: true, maxlength: 80, placeholder: 'Give your group a name' });
const contacts = client.view.threads.filter(t => !t.group && !t.pending);
diff --git a/internal/infinity/static/dom.js b/internal/infinity/static/dom.js
index 87ca019..ed2897a 100644
--- a/internal/infinity/static/dom.js
+++ b/internal/infinity/static/dom.js
@@ -1,3 +1,4 @@
+import { validAvatar } from './avatars.js';
// 🤖 Small DOM vocabulary. Text is always a text node, never user-authored HTML.
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
@@ -12,7 +13,7 @@ export function el(tag, attrs = {}, ...children) {
}
export const button = (label, action, attrs = {}) => el('button', { type: 'button', onclick: action, ...attrs }, label);
export const mark = () => el('img', { class: 'mark', src: '/static/mark.svg', alt: '∞', width: 52, height: 52 });
-export const avatar = name => el('span', { class: 'avatar', 'aria-hidden': true }, Array.from(name || '?').slice(0, 1).join('').toUpperCase());
+export const avatar = (name,image) => image && validAvatar(image) ? el('img',{class:'avatar',src:image,alt:'',width:44,height:44}) : el('span', { class: 'avatar', 'aria-hidden': true }, Array.from(name || '?').slice(0, 1).join('').toUpperCase());
export function field(label, attrs = {}) {
const input = el('input', attrs); return { input, node: el('label', { class: 'field' }, el('span', {}, label), input) };
}
diff --git a/internal/infinity/static/invitations.js b/internal/infinity/static/invitations.js
new file mode 100644
index 0000000..9fc093e
--- /dev/null
+++ b/internal/infinity/static/invitations.js
@@ -0,0 +1,64 @@
+// 🤖 The short fragment stays local: servers receive only derived lookup/proof values and ciphertext.
+import { validAvatar } from './avatars.js';
+import { request, usingRelay, peerOrigin } from './api.js';
+import { b64, unb64, random, digest, lock, unlock, publicIdentity } from './crypto.js';
+const te = new TextEncoder();
+export function shortCode(link) {
+ try { const url = new URL(link, location.origin); return url.origin === location.origin && /^#[A-Za-z0-9_-]{22}$/.test(url.hash) ? url.hash.slice(1) : null; } catch { return null; }
+}
+async function parts(code) {
+ if (!/^[A-Za-z0-9_-]{22}$/.test(code)) throw Error('This invitation is incomplete.');
+ return { id: await digest('ocho-invite-lookup-v1\n' + code), key: await digest('ocho-invite-key-v1\n' + code), proof: await digest('ocho-invite-claim-v1\n' + code) };
+}
+export const grantText = invite => ['ocho-invite-v2', invite.thread, invite.token, invite.grant, invite.person.origin, invite.person.enc, invite.person.signing, b64(te.encode(invite.person.name))].join('\n');
+export async function contactProof(grant, thread, signer) {
+ const key = await crypto.subtle.importKey('raw', unb64(grant), {name:'HMAC', hash:'SHA-256'}, false, ['sign']);
+ return b64(await crypto.subtle.sign('HMAC', key, te.encode('ocho-contact-v2\n' + thread + '\n' + signer)));
+}
+async function descriptor(text, key) {
+ const value = await unlock(key, text);
+ if (value.version !== 2 || !/^[A-Za-z0-9_-]{43}$/.test(value.thread) || !/^[A-Za-z0-9_-]{43}$/.test(value.token) || !/^[A-Za-z0-9_-]{43}$/.test(value.grant) || typeof value.person?.name !== 'string' || value.person.name.length > 80 || peerOrigin(value.person.origin) !== value.person.origin) throw Error('This invitation is incomplete.');
+ const publicKey = await crypto.subtle.importKey('raw', unb64(value.person.signing), {name:'ECDSA', namedCurve:'P-256'}, false, ['verify']);
+ if (!await crypto.subtle.verify({name:'ECDSA',hash:'SHA-256'}, publicKey, unb64(value.signature), te.encode(grantText(value)))) throw Error('This invitation could not be verified.');
+ value.person.cap = value.token; return value;
+}
+export async function createShortInvite(client) {
+ if (!usingRelay()) throw Error('Open Ocho at ocho.chat to invite someone.');
+ await client.load();
+ const code = b64(crypto.getRandomValues(new Uint8Array(16))), keys = await parts(code);
+ const invite = {version:2,thread:random(),token:random(),grant:random(),person:publicIdentity(client.id)};
+ delete invite.person.cap;
+ const privateKey = await crypto.subtle.importKey('jwk',client.id.privateSign,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);
+ invite.signature = b64(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},privateKey,te.encode(grantText(invite))));
+ const capabilityHash = await digest(invite.token);
+ const {key:consentKey} = await request(location.origin,'/api/invite-key');
+ if(unb64(consentKey).length!==32) throw Error('Invitations are temporarily unavailable.');
+ await client.api('/api/capabilities',{method:'POST',body:{token:invite.token,invite:true}});
+ client.state.invites[invite.thread] = true;
+ (client.state.preapproved ||= {})[invite.thread] = {grant:invite.grant,capabilityHash,key:consentKey}; await client.save();
+ await request(location.origin,'/api/invites',{auth:client.id.auth,method:'POST',body:{origin:client.id.origin,id:keys.id,proofHash:await digest(keys.proof),capabilityHash,box:await lock(keys.key,invite)}});
+ return location.origin + '/#' + code;
+}
+export async function previewInvite(code) {
+ const keys = await parts(code), result = await request(location.origin,'/api/invites/'+keys.id);
+ return descriptor(result.box,keys.key);
+}
+export async function redeemInvite(client, code) {
+ const keys = await parts(code);
+ const result = await request(location.origin,'/api/invites/'+keys.id+'/claim',{auth:client.id.auth,method:'POST',body:{origin:client.id.origin,proof:keys.proof,signer:client.id.signing}});
+ const invite = await descriptor(result.box,keys.key); invite.claim = result.claim;
+ return client.connect(invite);
+}
+const verificationKeys=new Map();
+export async function verifiedConsent(identity, state, event) {
+ const consent = state.preapproved?.[event.thread];
+ if (!consent || !event.person || typeof event.person.name!=='string' || event.person.name.length>80 || !event.person.cap || !validAvatar(event.person.avatar) || event.signer !== event.person.signing || event.acceptProof !== await contactProof(consent.grant,event.thread,event.signer)) return false;
+ try {
+ if(peerOrigin(event.person.origin)!==event.person.origin)return false;
+ const ticket = JSON.parse(new TextDecoder().decode(unb64(event.claim)));
+ if (ticket.host !== new URL(identity.origin).host || ticket.capability !== consent.capabilityHash || ticket.signer !== event.signer || !Number.isSafeInteger(ticket.expires)) return false;
+ if (!verificationKeys.has(consent.key)) verificationKeys.set(consent.key,await crypto.subtle.importKey('raw',unb64(consent.key),'Ed25519',false,['verify']));
+ const text = ['ocho-invite-claim-v1',ticket.host,ticket.capability,ticket.signer,ticket.expires].join('\n');
+ return crypto.subtle.verify('Ed25519',verificationKeys.get(consent.key),unb64(ticket.signature),te.encode(text));
+ } catch { return false; }
+}
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js
index 68bc387..311d89b 100644
--- a/internal/infinity/static/onboarding.js
+++ b/internal/infinity/static/onboarding.js
@@ -1,115 +1,111 @@
-// 🤖 Link-first account setup; secrets travel only in URL fragments or encrypted files.
+// 🤖 Passkey-only public onboarding; no tenant navigation or recovery-file choices.
import { el, button, mark, field, message, busy } from './dom.js';
-import { createIdentity, importRecovery, b64, unb64, random } from './crypto.js';
+import { createIdentity, unb64, random } from './crypto.js';
import { request, peerOrigin, usingRelay } from './api.js';
import { Client } from './client.js';
import { registrationProof } from './registration.js';
-import { signInPasskey } from './passkeys.js';
+import { signInPasskey, beginSignup, finishSignup } from './passkeys.js';
+import { redeemInvite } from './invitations.js';
import { setupProgress } from './setup-progress.js';
export const storageKey = 'infinity.identity.v1';
-export function remember(id) { localStorage.setItem(storageKey, JSON.stringify(id)); }
+export function remember(id) { localStorage.setItem(storageKey,JSON.stringify(id)); }
export function readIdentity() { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } }
export function fragment() {
- const hash = location.hash.slice(1);
- if (location.pathname === '/connect' && hash && !hash.includes('=')) return { invite: location.href };
+ const hash=location.hash.slice(1);
+ if (/^[A-Za-z0-9_-]{22}$/.test(hash)) return {code:hash};
+ if (location.pathname==='/connect' && hash && !hash.includes('=')) return {invite:location.href};
return Object.fromEntries(new URLSearchParams(hash));
}
export async function finishSetup(params) {
if (!params.setup) return null;
- const data = JSON.parse(new TextDecoder().decode(unb64(params.setup)));
- if (data.identity.origin !== location.origin && !usingRelay()) throw new Error('Setup belongs to a different account.');
- const client = new Client(data.identity); await client.setup(data.claim); remember(data.identity);
- history.replaceState(null, '', '/' + (data.invite ? '#invite=' + encodeURIComponent(data.invite) : ''));
- return client;
+ const data=JSON.parse(new TextDecoder().decode(unb64(params.setup)));
+ if (data.identity.origin!==location.origin && !usingRelay()) throw Error('Setup belongs to a different account.');
+ const client=new Client(data.identity);await client.setup(data.claim);remember(data.identity);return client;
}
-export function welcome(root, params, ready) {
- const note = el('div', {});
- let welcomeScreen;
- const content = el('section', { class: 'welcome' }, mark(), el('h1', {}, 'Just you. And your people.'),
- el('p', { class: 'lead' }, 'Private conversations, without a phone number.'),
- el('p', { class: 'muted' }, params.invite ? 'Someone shared an invitation with you. Open your account to connect.' : 'Share a link. Start a conversation. Make yourself at home.'), note);
- const name = field('Your name', { autocomplete: 'nickname', maxlength: 80, required: true, placeholder: 'What should people call you?' });
- const form = el('form', { class: 'onboard-form' }, name.node);
- const submit = el('button', { type: 'submit', class: 'primary' }, 'Create your account'); form.append(submit);
- form.addEventListener('submit', async event => {
- event.preventDefault(); note.replaceChildren();
- await busy(submit, async () => {
+async function provision(name,nonce,progress) {
+ const pending=sessionStorage.getItem('infinity.pending-setup');
+ if (pending) return JSON.parse(pending);
+ const proof=await registrationProof(nonce),deadline=Date.now()+120000;
+ let result;
+ while(Date.now()<deadline) {
+ progress.update(result?.preparing?'starting':'creating');
+ result=await request(location.origin,'/api/accounts',{method:'POST',body:{nonce,proof},timeoutMs:Math.min(30000,deadline-Date.now())});
+ if(result.origin) break;
+ await new Promise(resolve=>setTimeout(resolve,1500));
+ }
+ if(!result?.origin) throw Error('Your account is taking longer to start. Press Create again to continue.');
+ const data={identity:await createIdentity(peerOrigin(result.origin),name),claim:result.claim};
+ sessionStorage.setItem('infinity.pending-setup',JSON.stringify(data));return data;
+}
+export function welcome(root,params,ready,saved=null) {
+ // Explicit setup links remain available to isolated self-host/test instances.
+ if(params.claim && !usingRelay()) return developmentSetup(root,params,ready);
+ const inviter=params.preview?.person.name;
+ const title=inviter ? inviter+' is inviting you to chat on Ocho' : 'Ocho';
+ const note=el('div',{});
+ if(params.inviteError) note.append(message(params.inviteError,true));
+ const shell=el('section',{class:'welcome simple-welcome'},mark(),el('h1',{},title),!params.code&&el('p',{class:'lead'},'Just you. And your people.'),note);
+ const screen=el('div',{class:'welcome-wrap'},shell);
+ const hash=location.hash;
+ const open=async client=>{
+ const selected=params.code ? await redeemInvite(client,params.code) : null;
+ remember(client.id);history.replaceState(null,'','/');await ready(client,selected,params.invite);
+ };
+ const error=err=>{
+ root.replaceChildren(screen);
+ note.replaceChildren(message(err.name==='NotAllowedError'?'Passkey creation or sign-in was cancelled. Try again when you’re ready.':err.message,true));
+ };
+ const signIn=button(params.code?'Accept':'Sign in',()=>busy(signIn,async()=>{
+ try {const current=saved||readIdentity();const id=current||await signInPasskey();const client=new Client(id);await client.refresh();remember(id);await open(client);}catch(e){error(e);}
+ }),{class:'primary'});
+ const name=field('Name',{autocomplete:'nickname',maxlength:80,required:true,placeholder:'Your name'});
+ let remembered;try{remembered=JSON.parse(sessionStorage.getItem('ocho.signup.passkey'));}catch{}
+ if(remembered?.name) name.input.value=remembered.name;
+ const submit=el('button',{type:'submit',class:'primary'},inviter?'Connect with '+inviter:'Create');
+ const form=el('form',{class:'onboard-form'},name.node,submit);
+ const showName=()=>{
+ history.pushState(null,'','/signup'+hash);note.replaceChildren();
+ shell.replaceChildren(mark(),el('h1',{},inviter?'Connect with '+inviter:'What’s your name?'),form,note,button('Back',showChoices,{class:'quiet'}));name.input.focus();
+ };
+ const showChoices=()=>{
+ history.replaceState(null,'','/'+hash);
+ shell.replaceChildren(mark(),el('h1',{},title),!params.code&&el('p',{class:'lead'},'Just you. And your people.'),note);
+ if(!saved) shell.append(button(params.code?'Sign up to accept':'Sign up',showName,{class:'primary'}));
+ shell.append(signIn);
+ };
+ form.addEventListener('submit',async event=>{
+ event.preventDefault();note.replaceChildren();
+ await busy(submit,async()=>{
+ let pending;
try {
- const displayName = name.input.value.trim(); if (!displayName) throw new Error('Enter your name.');
- const progress = setupProgress(root);
- const pending = sessionStorage.getItem('infinity.pending-setup');
- if (pending) {
- const data = JSON.parse(pending), client = new Client(data.identity);
- progress.update('opening'); await client.setup(data.claim); remember(data.identity);
- sessionStorage.removeItem('infinity.pending-setup'); sessionStorage.removeItem('infinity.signup');
- await progress.complete(); ready(client); return;
- }
- if (params.claim) {
- progress.update('opening');
- const id = await createIdentity(location.origin, displayName); const c = new Client(id); await c.setup(params.claim); remember(id);
- await progress.complete();
- history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return;
- }
- const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce);
- const deadline = Date.now() + 120000;
- progress.update('checking');
- const proof = await registrationProof(nonce);
- let result;
- while (Date.now() < deadline) {
- progress.update(result?.preparing ? 'starting' : 'creating');
- result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce, proof }, timeoutMs: Math.min(30000, deadline - Date.now()) });
- if (!result.preparing) break;
- await new Promise(resolve => setTimeout(resolve, Math.min(1500, Math.max(0, deadline - Date.now()))));
+ const displayName=name.input.value.trim();if(!displayName) throw Error('Enter your name.');
+ try{pending=JSON.parse(sessionStorage.getItem('ocho.signup.passkey'));}catch{}
+ if(pending && (!pending.createdAt || Date.now()-pending.createdAt>840000)) pending=null;
+ const nonce=pending?.nonce||sessionStorage.getItem('infinity.signup')||random();sessionStorage.setItem('infinity.signup',nonce);
+ if(!pending) {
+ note.replaceChildren(message('Create your passkey…'));
+ pending={nonce,name:displayName,createdAt:Date.now(),ceremony:await beginSignup(displayName,nonce)};
+ sessionStorage.setItem('ocho.signup.passkey',JSON.stringify(pending));
}
- if (!result?.origin) throw new Error('Your account is taking longer than expected to start. Try again to continue setting up the same account.');
- progress.update('opening');
- const origin = peerOrigin(result.origin), id = await createIdentity(origin, displayName);
- localStorage.setItem('infinity.home', origin);
- const client = new Client(id);
- // Keep interrupted setup on this origin so a retry never generates different keys.
- sessionStorage.setItem('infinity.pending-setup', JSON.stringify({ identity: id, claim: result.claim }));
- await client.setup(result.claim); remember(id);
- sessionStorage.removeItem('infinity.pending-setup'); sessionStorage.removeItem('infinity.signup');
- await progress.complete();
- ready(client);
- } catch (error) {
- root.replaceChildren(welcomeScreen);
- note.replaceChildren(message(error.message, true));
- name.input.focus({ preventScroll: true });
+ const progress=setupProgress(root);progress.update('checking');
+ const data=await provision(pending.name,nonce,progress),client=new Client(data.identity);
+ progress.update('opening');await client.setup(data.claim);
+ await finishSignup(data.identity,nonce,pending.ceremony,()=>sessionStorage.setItem('ocho.signup.passkey',JSON.stringify(pending)));
+ remember(data.identity);
+ for(const key of ['infinity.signup','infinity.pending-setup','ocho.signup.passkey'])sessionStorage.removeItem(key);
+ await progress.complete();await open(client);
+ } catch(e) {
+ if(pending)sessionStorage.setItem('ocho.signup.passkey',JSON.stringify(pending));error(e);
+ if(readIdentity()) {saved=readIdentity();showChoices();}
}
});
});
- const saved = localStorage.getItem('infinity.home');
- if (saved) content.append(button(usingRelay() ? 'Bring your existing account to Ocho' : 'Open your account', () => location.assign(peerOrigin(saved) + (usingRelay() ? '/#migrate=1' : '/#') + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')), { class: 'primary' }));
- if (usingRelay()) {
- const signIn = button('Sign in with a passkey', () => busy(signIn, async () => {
- try { const id = await signInPasskey(), client = new Client(id); await client.refresh(); remember(id); ready(client); }
- catch (error) { note.replaceChildren(message(error.message, true)); }
- }), { class: 'primary' });
- content.append(signIn);
- }
- content.append(form, button('I have a recovery file', () => restore(root, params, ready), { class: 'quiet' }), el('p', { class: 'footnote' }, 'Your messages are encrypted on your device. Add a passkey or save a recovery file after creating your account.'));
- welcomeScreen = el('div', { class: 'welcome-wrap' }, content);
- root.replaceChildren(welcomeScreen);
+ root.replaceChildren(screen);
+ if(location.pathname==='/signup'&&!saved)showName();else showChoices();
}
-export function restore(root, params, ready) {
- const file = field('Recovery file', { type: 'file', accept: '.json,application/json', required: true });
- const password = field('Recovery password', { type: 'password', autocomplete: 'current-password', required: true, minlength: 12 });
- const note = el('div', {}), submit = el('button', { class: 'primary', type: 'submit' }, 'Open your account');
- const form = el('form', {}, file.node, password.node, note, submit);
- form.addEventListener('submit', async event => {
- event.preventDefault();
- await busy(submit, async () => { try {
- if (!file.input.files[0] || file.input.files[0].size > 100000) throw new Error('Choose your ∞ recovery file.');
- const id = await importRecovery(await file.input.files[0].text(), password.input.value);
- peerOrigin(id.origin);
- if (id.origin !== location.origin && !usingRelay()) {
- // Transfer stays in a fragment; neither serving origin receives the identity.
- const transfer = b64(new TextEncoder().encode(JSON.stringify(id)));
- location.assign(id.origin + '/#restore=' + transfer + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')); return;
- }
- const c = new Client(id); await c.refresh(); remember(id); history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c);
- } catch { note.replaceChildren(message('Could not open that recovery file. Check the file and password.', true)); } });
- });
- root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, mark(), el('h1', {}, 'Welcome back.'), el('p', {}, 'Your file and password open your account on this device.'), form, button('Back', () => welcome(root, params, ready), { class: 'quiet' }))));
+function developmentSetup(root,params,ready) {
+ const name=field('Your name',{required:true,maxlength:80}),submit=el('button',{type:'submit',class:'primary'},'Create your account'),note=el('div',{});
+ const form=el('form',{},name.node,submit,note);
+ form.addEventListener('submit',async event=>{event.preventDefault();await busy(submit,async()=>{try{const id=await createIdentity(location.origin,name.input.value.trim()),client=new Client(id);await client.setup(params.claim);remember(id);history.replaceState(null,'','/');await ready(client);}catch(e){note.replaceChildren(message(e.message,true));}});});
+ root.replaceChildren(el('div',{class:'welcome-wrap'},el('section',{class:'welcome'},mark(),el('h1',{},'Set up this instance'),form)));
}
diff --git a/internal/infinity/static/passkeys.js b/internal/infinity/static/passkeys.js
index 9385bfe..9e21348 100644
--- a/internal/infinity/static/passkeys.js
+++ b/internal/infinity/static/passkeys.js
@@ -1,54 +1,52 @@
-// 🤖 PRF output never enters a request. It only wraps the client-held identity.
+// 🤖 PRF output stays on device and wraps the same encrypted account identity.
import { request, usingRelay, peerOrigin } from './api.js';
import { b64, unb64, lock, unlock } from './crypto.js';
const te = new TextEncoder();
export async function passkeyWrappingKey(output, credentialID) {
- if (!output || output.byteLength !== 32) throw new Error('This passkey provider cannot unlock encrypted messages. Use a recovery file or a passkey provider that supports encryption.');
+ if (!output || output.byteLength !== 32) throw new Error('This passkey provider cannot unlock encrypted messages. Choose another passkey provider or a supported device.');
const key = await crypto.subtle.importKey('raw', output, 'HKDF', false, ['deriveBits']);
- return b64(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: te.encode('ocho-passkey-wrap-v1'), info: credentialID }, key, 256));
+ return b64(await crypto.subtle.deriveBits({name:'HKDF',hash:'SHA-256',salt:te.encode('ocho-passkey-wrap-v1'),info:credentialID},key,256));
}
function available() {
- if (!usingRelay()) throw new Error('Open your account at ocho.chat to use passkeys.');
- if (!navigator.credentials || !globalThis.PublicKeyCredential) throw new Error('Passkeys are not available on this device. You can use a recovery file.');
+ if (!usingRelay()) throw new Error('Open ocho.chat to use passkeys.');
+ if (!navigator.credentials || !globalThis.PublicKeyCredential) throw new Error('This device cannot use passkeys. Open Ocho on a supported device.');
}
-const post = (path, body, auth) => request(location.origin, '/api/passkeys/' + path, { method: 'POST', body, auth });
-export async function addPasskey(identity) {
- available();
- const options = await post('register/begin', { origin: identity.origin }, identity.auth);
- const credential = await navigator.credentials.create({ publicKey: {
- challenge: unb64(options.challenge), rp: { id: options.rpId, name: 'Ocho' },
- user: { id: unb64(options.userHandle), name: identity.name, displayName: identity.name },
- pubKeyCredParams: [{ type: 'public-key', alg: -7 }], timeout: options.timeout,
- authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, attestation: 'none',
- extensions: { prf: { eval: { first: unb64(options.salt) } } }
- } });
- if (!credential) throw new Error('Passkey setup was cancelled.');
+const post = (path,body,auth) => request(location.origin,path,{method:'POST',body,auth});
+async function create(options,name) {
+ const credential = await navigator.credentials.create({publicKey:{
+ challenge:unb64(options.challenge),rp:{id:options.rpId,name:'ocho.chat'},
+ user:{id:unb64(options.userHandle),name,displayName:name},pubKeyCredParams:[{type:'public-key',alg:-7}],timeout:options.timeout,
+ authenticatorSelection:{residentKey:'required',userVerification:'required'},attestation:'none',extensions:{prf:{eval:{first:unb64(options.salt)}}}
+ }});
+ if (!credential) throw new Error('Passkey creation was cancelled.');
let output = credential.getClientExtensionResults().prf?.results?.first;
if (!output) {
- // Some providers support PRF only during an assertion, after creating the key.
- const assertion = await navigator.credentials.get({ publicKey: {
- challenge: unb64(options.challenge), rpId: options.rpId, timeout: options.timeout,
- userVerification: 'required', allowCredentials: [{ type: 'public-key', id: credential.rawId }],
- extensions: { prf: { eval: { first: unb64(options.salt) } } }
- } });
- if (assertion && b64(assertion.rawId) === b64(credential.rawId)) output = assertion.getClientExtensionResults().prf?.results?.first;
+ const assertion = await navigator.credentials.get({publicKey:{challenge:unb64(options.challenge),rpId:options.rpId,timeout:options.timeout,userVerification:'required',allowCredentials:[{type:'public-key',id:credential.rawId}],extensions:{prf:{eval:{first:unb64(options.salt)}}}}});
+ if (assertion && b64(assertion.rawId)===b64(credential.rawId)) output=assertion.getClientExtensionResults().prf?.results?.first;
}
- const key = await passkeyWrappingKey(output, credential.rawId);
- const box = await lock(key, identity);
- await post('register/finish', { challenge: options.challenge, clientDataJSON: b64(credential.response.clientDataJSON), attestationObject: b64(credential.response.attestationObject), box }, identity.auth);
+ return {key:await passkeyWrappingKey(output,credential.rawId),challenge:options.challenge,clientDataJSON:b64(credential.response.clientDataJSON),attestationObject:b64(credential.response.attestationObject)};
+}
+export async function beginSignup(name,nonce) {
+ available();return create(await post('/api/signup/begin',{nonce}),name);
+}
+export async function finishSignup(identity,nonce,ceremony,persist=()=>{}) {
+ // Cache the box so retries send identical bytes after a lost successful reply.
+ ceremony.box ||= await lock(ceremony.key,identity);
+ persist();
+ return post('/api/signup/finish',{nonce,origin:identity.origin,challenge:ceremony.challenge,clientDataJSON:ceremony.clientDataJSON,attestationObject:ceremony.attestationObject,box:ceremony.box},identity.auth);
+}
+export async function addPasskey(identity) {
+ available();const options=await post('/api/passkeys/register/begin',{origin:identity.origin},identity.auth);
+ const ceremony=await create(options,identity.name),box=await lock(ceremony.key,identity);
+ await post('/api/passkeys/register/finish',{challenge:ceremony.challenge,clientDataJSON:ceremony.clientDataJSON,attestationObject:ceremony.attestationObject,box},identity.auth);
}
export async function signInPasskey() {
- available();
- const options = await post('login/begin', {});
- const credential = await navigator.credentials.get({ publicKey: {
- challenge: unb64(options.challenge), rpId: options.rpId, timeout: options.timeout,
- userVerification: 'required', extensions: { prf: { eval: { first: unb64(options.salt) } } }
- } });
+ available();const options=await post('/api/passkeys/login/begin',{});
+ const credential=await navigator.credentials.get({publicKey:{challenge:unb64(options.challenge),rpId:options.rpId,timeout:options.timeout,userVerification:'required',extensions:{prf:{eval:{first:unb64(options.salt)}}}}});
if (!credential) throw new Error('Sign-in was cancelled.');
- const key = await passkeyWrappingKey(credential.getClientExtensionResults().prf?.results?.first, credential.rawId);
- const response = credential.response;
- const result = await post('login/finish', { challenge: options.challenge, credentialId: b64(credential.rawId), userHandle: b64(response.userHandle || new Uint8Array()), clientDataJSON: b64(response.clientDataJSON), authenticatorData: b64(response.authenticatorData), signature: b64(response.signature) });
- const identity = await unlock(key, result.box);
- if (identity.version !== 1 || identity.origin !== peerOrigin(result.origin) || !identity.privateEnc || !identity.privateSign) throw new Error('This passkey could not open your account.');
+ const key=await passkeyWrappingKey(credential.getClientExtensionResults().prf?.results?.first,credential.rawId),response=credential.response;
+ const result=await post('/api/passkeys/login/finish',{challenge:options.challenge,credentialId:b64(credential.rawId),userHandle:b64(response.userHandle||new Uint8Array()),clientDataJSON:b64(response.clientDataJSON),authenticatorData:b64(response.authenticatorData),signature:b64(response.signature)});
+ const identity=await unlock(key,result.box);
+ if(identity.version!==1 || identity.origin!==peerOrigin(result.origin) || !identity.privateEnc || !identity.privateSign) throw new Error('This passkey could not open your account.');
return identity;
}
diff --git a/internal/infinity/static/profile.js b/internal/infinity/static/profile.js
new file mode 100644
index 0000000..2f3f5a2
--- /dev/null
+++ b/internal/infinity/static/profile.js
@@ -0,0 +1,20 @@
+// 🤖 Profile data is encrypted; avatars are small embedded raster images, never tracking URLs.
+import { el, button, field, message, modal, busy } from './dom.js';
+import { addPasskey } from './passkeys.js';
+import { validAvatar, raster } from './avatars.js';
+export function editProfile(client,changed) {
+ const name=field('Display name',{value:client.state.name,required:true,maxlength:80,autocomplete:'nickname'});
+ const image=field('Avatar',{type:'file',accept:'image/png,image/jpeg,image/webp'}),note=el('div',{});
+ let avatar=validAvatar(client.state.avatar)?client.state.avatar||'':'';
+ const preview=el('div',{class:'profile-avatar'});
+ const paint=()=>preview.replaceChildren(avatar?el('img',{src:avatar,alt:'Your avatar',width:80,height:80}):el('span',{},'No avatar'));paint();
+ image.input.addEventListener('change',async()=>{try{avatar=await raster(image.input.files[0]);paint();note.replaceChildren();}catch(e){note.replaceChildren(message(e.message,true));}});
+ const submit=el('button',{class:'primary',type:'submit'},'Save');
+ const body=el('form',{},preview,name.node,image.node,button('Remove avatar',()=>{avatar='';paint();},{class:'quiet'}),note,submit);
+ const dialog=modal('Your profile',body);
+ body.addEventListener('submit',async event=>{event.preventDefault();await busy(submit,async()=>{try{await client.updateProfile(name.input.value,avatar);dialog.close();changed();}catch(e){note.replaceChildren(message(e.message,true));}});});
+}
+export function passkeys(client) {
+ const note=el('div',{}),add=button('Add a passkey',()=>busy(add,async()=>{try{await addPasskey(client.id);note.replaceChildren(message('Passkey saved.'));}catch(e){note.replaceChildren(message(e.message,true));}}),{class:'primary'});
+ modal('Passkeys',el('div',{},el('p',{},'Your passkey opens Ocho and your messages on your other devices.'),add,note));
+}
diff --git a/internal/infinity/static/protocol.js b/internal/infinity/static/protocol.js
index 966bd7b..53a4af3 100644
--- a/internal/infinity/static/protocol.js
+++ b/internal/infinity/static/protocol.js
@@ -1,6 +1,7 @@
// 🤖 Projection authenticates relationships independently of the delivery server.
+import { validAvatar } from './avatars.js';
function validPerson(p) {
- if (!p || typeof p.name !== 'string' || p.name.length > 80 || typeof p.enc !== 'string' || typeof p.signing !== 'string' || typeof p.cap !== 'string') return false;
+ if (!p || !validAvatar(p.avatar) || typeof p.name !== 'string' || p.name.length > 80 || typeof p.enc !== 'string' || typeof p.signing !== 'string' || typeof p.cap !== 'string') return false;
try { const u = new URL(p.origin); return u.origin === p.origin && (u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))); } catch { return false; }
}
export function project(identity, state, events) {
@@ -13,7 +14,9 @@ export function project(identity, state, events) {
for (const e of events) {
if (state.blocked[e.thread]) continue;
if (e.kind === 'contact-request' && state.invites[e.thread] && validPerson(e.person) && e.signer === e.person.signing && e.signer !== self.signing && !threads.has(e.thread)) {
- if (!requests.has(e.thread)) requests.set(e.thread, e);
+ if (state.preapproved?.[e.thread] && e.preapproved === true) {
+ threads.set(e.thread,{id:e.thread,name:e.person.name,peer:e.person,members:[self,e.person],pending:false,messages:[],group:false});
+ } else if (!state.preapproved?.[e.thread] && !requests.has(e.thread)) requests.set(e.thread,e);
}
if (e.kind === 'contact-accept') {
const t = threads.get(e.thread);
@@ -38,6 +41,14 @@ export function project(identity, state, events) {
threads.set(e.thread, t);
if (pending && !t.left) requests.set(e.thread, { ...e, kind: 'group-request' });
}
+ const profiles=new Map();
+ for(const e of events) {
+ if(e.kind!=='profile'||!validPerson(e.person)||e.signer!==e.person.signing||!Number.isSafeInteger(e.at)||e.at<0)continue;
+ const old=profiles.get(e.signer);if(!old||e.at>old.at||(e.at===old.at&&String(e.id)>String(old.id)))profiles.set(e.signer,e);
+ }
+ const updated=p=>{const e=profiles.get(p.signing);return e&&e.person.enc===p.enc&&e.person.origin===p.origin?{...p,name:e.person.name,avatar:e.person.avatar||''}:p;};
+ for(const t of threads.values()){t.members=t.members.map(updated);if(t.peer){t.peer=updated(t.peer);t.name=t.peer.name;}}
+ for(const [key,members] of snapshots)snapshots.set(key,members.map(updated));
const seen = new Set();
for (const e of events) {
if (e.kind !== 'message' || typeof e.text !== 'string' || e.text.length > 8000 || typeof e.id !== 'string' || !Number.isSafeInteger(e.at) || e.at < 0 || e.at > 8640000000000000) continue;
diff --git a/internal/infinity/static/style.css b/internal/infinity/static/style.css
index ba989c8..e59b109 100644
--- a/internal/infinity/static/style.css
+++ b/internal/infinity/static/style.css
@@ -8,3 +8,9 @@
/* 🤖 The runway gives infinity its own space beyond the conventional end. */
.infinity-meter{--infinity-runway:120px;padding-right:var(--infinity-runway)}.infinity-track{overflow:visible}.infinity-trail{width:2%;animation:none;background:linear-gradient(90deg,#a997f0,var(--accent));box-shadow:0 0 14px #7965de20}.infinity-beyond{animation:none;opacity:.75}.infinity-meter::after{right:49px;width:calc(var(--infinity-runway) - 49px)}[data-phase=complete] .infinity-beyond{opacity:1;text-shadow:0 0 22px #7965de66}[data-phase=complete] .infinity-trail{width:calc(100% + var(--infinity-runway) - 22px)}@media(max-width:420px){.infinity-meter{--infinity-runway:96px}}@media(prefers-reduced-motion:reduce){.infinity-trail{left:0;width:45%;opacity:1}}
+/* 🤖 Quiet passkey-first entry, with one decision per screen. */
+.simple-welcome { max-width: 30rem; }
+.simple-welcome > button { display:block; width:100%; margin-top:1rem; }
+.simple-welcome h1 { font-size:clamp(2rem,6vw,3.3rem); }
+.simple-welcome .onboard-form button { width:100%; }
+.profile-avatar img,.avatar { object-fit:cover; border-radius:50%; }
diff --git a/internal/infinity/vault.go b/internal/infinity/vault.go
index cfe9386..5c344f5 100644
--- a/internal/infinity/vault.go
+++ b/internal/infinity/vault.go
@@ -21,20 +21,21 @@ func (a *App) putVault(w http.ResponseWriter, r *http.Request) {
var p struct {
Revision int64 `json:"revision"`
Box string `json:"box"`
+ Format int `json:"format"`
}
if !decode(w, r, &p) {
return
}
- if p.Revision < 0 || len(p.Box) < 40 || len(p.Box) > 1<<20 {
+ if p.Format < 0 || p.Format > 2 || p.Revision < 0 || len(p.Box) < 40 || len(p.Box) > 1<<20 {
fail(w, 400, "Invalid saved state.")
return
}
var res sql.Result
var e error
if p.Revision == 0 {
- res, e = a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO vault(singleton,revision,box) VALUES(1,1,?)", p.Box)
+ res, e = a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO vault(singleton,revision,box,format) VALUES(1,1,?,?)", p.Box, p.Format)
} else {
- res, e = a.db.ExecContext(r.Context(), "UPDATE vault SET revision=revision+1,box=? WHERE singleton=1 AND revision=?", p.Box, p.Revision)
+ res, e = a.db.ExecContext(r.Context(), "UPDATE vault SET revision=revision+1,box=?,format=? WHERE singleton=1 AND revision=? AND format<=?", p.Box, p.Format, p.Revision, p.Format)
}
if e != nil {
dbError(w, e)
@@ -46,6 +47,11 @@ func (a *App) putVault(w http.ResponseWriter, r *http.Request) {
return
}
if n != 1 {
+ var format int
+ if a.db.QueryRowContext(r.Context(), "SELECT format FROM vault WHERE singleton=1").Scan(&format) == nil && format > p.Format {
+ fail(w, 426, "Please update Ocho to save changes on this account.")
+ return
+ }
fail(w, 409, "Another device saved changes. Refresh and try again.")
return
}
@@ -53,8 +59,9 @@ func (a *App) putVault(w http.ResponseWriter, r *http.Request) {
}
func (a *App) capability(w http.ResponseWriter, r *http.Request) {
var p struct {
- Token string `json:"token"`
- Once bool `json:"once"`
+ Token string `json:"token"`
+ Once bool `json:"once"`
+ Invite bool `json:"invite"`
}
if !decode(w, r, &p) {
return
@@ -69,7 +76,11 @@ func (a *App) capability(w http.ResponseWriter, r *http.Request) {
remaining = 1
expiry = now() + 7*86400
}
- _, e := a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO capabilities(hash,remaining,expires) VALUES(?,?,?)", hash(p.Token), remaining, expiry)
+ if p.Invite {
+ remaining = -1
+ expiry = now() + 7*86400
+ }
+ _, e := a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO capabilities(hash,remaining,expires,requires_claim) VALUES(?,?,?,?)", hash(p.Token), remaining, expiry, p.Invite)
if e != nil {
dbError(w, e)
return
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go
index 2423808..ff39676 100644
--- a/internal/infinityhome/home.go
+++ b/internal/infinityhome/home.go
@@ -76,6 +76,8 @@ func (h *Home) Handler() *http.ServeMux {
m := infinity.WebHandler()
h.passkeyRoutes(m)
h.pushRoutes(m)
+ h.signupRoutes(m)
+ h.inviteRoutes(m)
m.HandleFunc("GET /.well-known/assetlinks.json", h.androidAssociation)
m.HandleFunc("GET /.well-known/apple-app-site-association", appleAssociation)
for _, method := range []string{"GET", "POST", "PUT"} {
diff --git a/internal/infinityhome/invites.go b/internal/infinityhome/invites.go
new file mode 100644
index 0000000..06ae1c5
--- /dev/null
+++ b/internal/infinityhome/invites.go
@@ -0,0 +1,133 @@
+// 🤖 Opaque invitation pointers; viewing is read-only and redemption pins one owner.
+package infinityhome
+
+import (
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "crypto/ecdh"
+ "crypto/ed25519"
+
+ "encoding/json"
+ "lchat/internal/infinityinvite"
+ "net/http"
+ "time"
+)
+
+func (h *Home) inviteRoutes(m *http.ServeMux) {
+ m.Handle("POST /api/invites", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.createInvite)))
+ m.HandleFunc("GET /api/invites/{id}", h.previewInvite)
+ m.HandleFunc("GET /api/invite-key", func(w http.ResponseWriter, r *http.Request) {
+ write(w, 200, map[string]string{"key": encoded(h.cfg.ClaimKey.Public().(ed25519.PublicKey))})
+ })
+ m.Handle("POST /api/invites/{id}/claim", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.claimInvite)))
+}
+func inviteInput(w http.ResponseWriter, r *http.Request, p any) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, 100000)
+ d := json.NewDecoder(r.Body)
+ d.DisallowUnknownFields()
+ if d.Decode(p) != nil {
+ failure(w, 400, "Invalid invitation.")
+ return false
+ }
+ return true
+}
+func (h *Home) createInvite(w http.ResponseWriter, r *http.Request) {
+ var p struct {
+ Origin string `json:"origin"`
+ ID string `json:"id"`
+ ProofHash string `json:"proofHash"`
+ CapabilityHash string `json:"capabilityHash"`
+ Box string `json:"box"`
+ }
+ if !inviteInput(w, r, &p) {
+ return
+ }
+
+ if !validPushSecret(p.ID) || !validPushSecret(p.ProofHash) || !validPushSecret(p.CapabilityHash) || len(p.Box) < 40 || len(p.Box) > 90000 {
+ failure(w, 400, "Invalid invitation.")
+ return
+ }
+ host, ok := h.ownedHost(r, p.Origin)
+ if !ok {
+ failure(w, 403, "Sign in to invite someone.")
+ return
+ }
+ tx, e := h.db.BeginTx(r.Context(), nil)
+ if e != nil {
+ failure(w, 503, "Please try again.")
+ return
+ }
+ defer tx.Rollback()
+ if _, e = tx.ExecContext(r.Context(), "DELETE FROM ocho_invites WHERE expires<?", time.Now().Unix()); e != nil {
+ failure(w, 503, "Please try again.")
+ return
+ }
+ var n int
+ if tx.QueryRowContext(r.Context(), "SELECT count(*) FROM ocho_invites WHERE host=?", host).Scan(&n) != nil || n >= 100 {
+ failure(w, 429, "You have too many open invitations. Try again later.")
+ return
+ }
+ _, e = tx.ExecContext(r.Context(), "INSERT INTO ocho_invites(id,host,proof_hash,cap_hash,box,expires) VALUES(?,?,?,?,?,?)", p.ID, host, p.ProofHash, p.CapabilityHash, p.Box, time.Now().Add(7*24*time.Hour).Unix())
+ if e != nil || tx.Commit() != nil {
+ failure(w, 409, "Please create another invitation.")
+ return
+ }
+ write(w, 201, map[string]bool{"ok": true})
+}
+func (h *Home) previewInvite(w http.ResponseWriter, r *http.Request) {
+ if !validPushSecret(r.PathValue("id")) {
+ failure(w, 404, "Invitation unavailable.")
+ return
+ }
+ var box string
+ if h.db.QueryRowContext(r.Context(), "SELECT box FROM ocho_invites WHERE id=? AND expires>=? AND recipient=''", r.PathValue("id"), time.Now().Unix()).Scan(&box) != nil {
+ failure(w, 410, "This invitation has expired or has already been accepted.")
+ return
+ }
+ write(w, 200, map[string]string{"box": box})
+}
+func (h *Home) claimInvite(w http.ResponseWriter, r *http.Request) {
+ var p struct {
+ Origin string `json:"origin"`
+ Proof string `json:"proof"`
+ Signer string `json:"signer"`
+ }
+ if !inviteInput(w, r, &p) {
+ return
+ }
+ if !validPushSecret(r.PathValue("id")) || !validPushSecret(p.Proof) {
+ failure(w, 400, "Invalid invitation.")
+ return
+ }
+ if _, e := ecdh.P256().NewPublicKey(decoded(p.Signer)); e != nil {
+ failure(w, 400, "Invalid account key.")
+ return
+ }
+ host, ok := h.ownedHost(r, p.Origin)
+ if !ok {
+ failure(w, 403, "Sign in to accept this invitation.")
+ return
+ }
+ tx, e := h.db.BeginTx(r.Context(), nil)
+ if e != nil {
+ failure(w, 503, "Please try again.")
+ return
+ }
+ defer tx.Rollback()
+ var source, box, recipient, signer, cap string
+ var expires int64
+ e = tx.QueryRowContext(r.Context(), "SELECT host,box,recipient,recipient_signing,cap_hash,expires FROM ocho_invites WHERE id=? AND proof_hash=? AND expires>=?", r.PathValue("id"), pushHash(p.Proof), time.Now().Unix()).Scan(&source, &box, &recipient, &signer, &cap, &expires)
+ if e != nil || (recipient != "" && (recipient != host || signer != p.Signer)) {
+ failure(w, 410, "This invitation has expired or has already been accepted.")
+ return
+ }
+ if source == host {
+ failure(w, 400, "Share this invitation with someone else.")
+ return
+ }
+ _, e = tx.ExecContext(r.Context(), "UPDATE ocho_invites SET recipient=?,recipient_signing=? WHERE id=?", host, p.Signer, r.PathValue("id"))
+ if e != nil || tx.Commit() != nil {
+ failure(w, 409, "Please retry accepting this invitation.")
+ return
+ }
+ write(w, 200, map[string]string{"box": box, "claim": infinityinvite.Mint(h.cfg.ClaimKey, source, cap, p.Signer, expires)})
+}
diff --git a/internal/infinityhome/migrations/0005_simple_signup.sql b/internal/infinityhome/migrations/0005_simple_signup.sql
new file mode 100644
index 0000000..649366c
--- /dev/null
+++ b/internal/infinityhome/migrations/0005_simple_signup.sql
@@ -0,0 +1,8 @@
+-- 🤖 Idempotent passkey-first onboarding and opaque single-use invitation records.
+ALTER TABLE ocho_passkey_challenges ADD COLUMN result_hash TEXT NOT NULL DEFAULT '';
+CREATE TABLE ocho_invites (
+ id TEXT PRIMARY KEY, host TEXT NOT NULL, proof_hash TEXT NOT NULL,
+ cap_hash TEXT NOT NULL, box TEXT NOT NULL, expires INTEGER NOT NULL,
+ recipient TEXT NOT NULL DEFAULT '', recipient_signing TEXT NOT NULL DEFAULT ''
+);
+CREATE INDEX ocho_invites_host ON ocho_invites(host);
diff --git a/internal/infinityhome/passkeys.go b/internal/infinityhome/passkeys.go
index eda5782..ef4785a 100644
--- a/internal/infinityhome/passkeys.go
+++ b/internal/infinityhome/passkeys.go
@@ -97,7 +97,11 @@ func (h *Home) issue(w http.ResponseWriter, r *http.Request, operation, host, ow
return
}
challenge, user := randomID(), randomID()
- _, e := h.db.ExecContext(r.Context(), "INSERT INTO ocho_passkey_challenges(challenge,operation,host,user_handle,owner_hash,expires) VALUES(?,?,?,?,?,?)", challenge, operation, host, user, owner, now+120)
+ lifetime := int64(120)
+ if operation == "signup" {
+ lifetime = 900
+ }
+ _, e := h.db.ExecContext(r.Context(), "INSERT INTO ocho_passkey_challenges(challenge,operation,host,user_handle,owner_hash,expires) VALUES(?,?,?,?,?,?)", challenge, operation, host, user, owner, now+lifetime)
if e != nil {
failure(w, 503, "Passkeys are temporarily unavailable.")
return
diff --git a/internal/infinityhome/signup.go b/internal/infinityhome/signup.go
new file mode 100644
index 0000000..d469e3c
--- /dev/null
+++ b/internal/infinityhome/signup.go
@@ -0,0 +1,103 @@
+// 🤖 Create the passkey first, then bind its sealed identity to the allocated owner.
+package infinityhome
+
+import (
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "time"
+)
+
+func nonceHash(nonce string) string {
+ b := decoded(nonce)
+ if len(b) != 32 {
+ return ""
+ }
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+func (h *Home) signupRoutes(m *http.ServeMux) {
+ m.Handle("POST /api/signup/begin", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.signupBegin)))
+ m.Handle("POST /api/signup/finish", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.signupFinish)))
+}
+func (h *Home) signupBegin(w http.ResponseWriter, r *http.Request) {
+ var p struct {
+ Nonce string `json:"nonce"`
+ }
+ if !decodePasskey(w, r, &p) {
+ return
+ }
+ key := nonceHash(p.Nonce)
+ if key == "" {
+ failure(w, 400, "Please restart signup.")
+ return
+ }
+ h.issue(w, r, "signup", "", key)
+}
+func (h *Home) signupFinish(w http.ResponseWriter, r *http.Request) {
+ var p struct {
+ Nonce string `json:"nonce"`
+ Origin string `json:"origin"`
+ Challenge string `json:"challenge"`
+ ClientData string `json:"clientDataJSON"`
+ Attestation string `json:"attestationObject"`
+ Box string `json:"box"`
+ }
+ if !decodePasskey(w, r, &p) {
+ return
+ }
+ if nonceHash(p.Nonce) == "" || !sameOriginCeremony(p.ClientData) || len(p.Box) < 40 || len(p.Box) > 100000 {
+ failure(w, 400, "Invalid signup.")
+ return
+ }
+ host, ok := h.ownedHost(r, p.Origin)
+ if !ok {
+ failure(w, 403, "Could not verify your new account.")
+ return
+ }
+ var allocation string
+ if h.db.QueryRowContext(r.Context(), "SELECT host FROM registrations WHERE nonce_hash=? AND provisioned=1", nonceHash(p.Nonce)).Scan(&allocation) != nil || allocation != host {
+ failure(w, 403, "Signup belongs to another account.")
+ return
+ }
+ raw, _ := json.Marshal(p)
+ digest := pushHash(string(raw))
+ tx, e := h.db.BeginTx(r.Context(), nil)
+ if e != nil {
+ failure(w, 503, "Please retry signup.")
+ return
+ }
+ defer tx.Rollback()
+ var operation, user, owner, result, priorHost string
+ e = tx.QueryRowContext(r.Context(), "SELECT operation,user_handle,owner_hash,result_hash,host FROM ocho_passkey_challenges WHERE challenge=? AND expires>=?", p.Challenge, time.Now().Unix()).Scan(&operation, &user, &owner, &result, &priorHost)
+ if e != nil {
+ failure(w, 403, "Signup expired. Sign in if you already created your passkey.")
+ return
+ }
+ if operation == "signup-complete" && owner == ownerHash(r) && priorHost == host && result == digest {
+ write(w, 200, map[string]bool{"ok": true})
+ return
+ }
+ if operation != "signup" || owner != nonceHash(p.Nonce) {
+ failure(w, 403, "This passkey belongs to another signup.")
+ return
+ }
+ cred, e := h.ceremonyConfig(p.ClientData).Register(decoded(p.Challenge), decoded(p.ClientData), decoded(p.Attestation))
+ if e != nil {
+ failure(w, 403, "Could not verify your passkey.")
+ return
+ }
+ _, e = tx.ExecContext(r.Context(), "INSERT INTO ocho_passkeys(credential_id,user_handle,host,public_key,sign_count,box,created) VALUES(?,?,?,?,?,?,?)", encoded(cred.ID), user, host, cred.PublicKey, cred.SignCount, p.Box, time.Now().Unix())
+ if e != nil {
+ failure(w, 409, "This passkey has already been used. Please sign in.")
+ return
+ }
+ _, e = tx.ExecContext(r.Context(), "UPDATE ocho_passkey_challenges SET operation='signup-complete',host=?,owner_hash=?,result_hash=? WHERE challenge=? AND operation='signup'", host, ownerHash(r), digest, p.Challenge)
+ if e != nil || tx.Commit() != nil {
+ failure(w, 503, "Please retry to finish signup.")
+ return
+ }
+ write(w, 201, map[string]bool{"ok": true})
+}
diff --git a/internal/infinityhome/simple_test.go b/internal/infinityhome/simple_test.go
new file mode 100644
index 0000000..d7f71ef
--- /dev/null
+++ b/internal/infinityhome/simple_test.go
@@ -0,0 +1,139 @@
+// 🤖 Signup ownership, durable ceremony retries and one-recipient invitation claims.
+package infinityhome
+
+import (
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/webauthn/authtest"
+ "bytes"
+ "crypto/ecdh"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "io"
+ "lchat/internal/infinityinvite"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSimpleSignupAndInvites(t *testing.T) {
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ d, e := db.Open(filepath.Join(t.TempDir(), "home.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer d.Close()
+ hosts := []string{"p-0123456789abcdef01234567.bdy.oncarlos.com", "p-1123456789abcdef01234567.bdy.oncarlos.com", "p-2123456789abcdef01234567.bdy.oncarlos.com"}
+ h, e := New(d, Config{Origin: "https://ocho.chat", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Token: "scoped", ClaimKey: key, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ status := 403
+ for _, host := range hosts {
+ if r.URL.Host == host && r.Header.Get("Authorization") == "Bearer "+host {
+ status = 200
+ }
+ }
+ return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
+ })}})
+ if e != nil {
+ t.Fatal(e)
+ }
+ nonce := randomID()
+ for i, host := range hosts {
+ hash := randomID()
+ if i == 0 {
+ hash = nonceHash(nonce)
+ }
+ if _, e = h.db.Exec("INSERT INTO registrations(nonce_hash,host,created,provisioned) VALUES(?,?,1,1)", hash, host); e != nil {
+ t.Fatal(e)
+ }
+ }
+ handler := h.Handler()
+ call := func(method, path, auth string, body any, want int) map[string]string {
+ t.Helper()
+ b, _ := json.Marshal(body)
+ r := httptest.NewRequest(method, "https://ocho.chat/api/"+path, bytes.NewReader(b))
+ r.Header.Set("Origin", "https://ocho.chat")
+ if auth != "" {
+ r.Header.Set("Authorization", "Bearer "+auth)
+ }
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, r)
+ if w.Code != want {
+ t.Fatalf("%s: %d want %d: %s", path, w.Code, want, w.Body.String())
+ }
+ var out map[string]json.RawMessage
+ json.Unmarshal(w.Body.Bytes(), &out)
+ values := map[string]string{}
+ for k, v := range out {
+ var s string
+ if json.Unmarshal(v, &s) == nil {
+ values[k] = s
+ }
+ }
+ return values
+ }
+ call("POST", "signup/begin", "", map[string]string{"nonce": "bad"}, 400)
+ begin := call("POST", "signup/begin", "", map[string]string{"nonce": nonce}, 200)
+ authenticator, _ := authtest.New()
+ cd, att := authenticator.Create(decoded(begin["challenge"]), authtest.Options{RPID: "ocho.chat", Origin: "https://ocho.chat"})
+ finish := map[string]string{"nonce": nonce, "origin": "https://" + hosts[0], "challenge": begin["challenge"], "clientDataJSON": encoded(cd), "attestationObject": encoded(att), "box": strings.Repeat("ciphertext-", 8)}
+ call("POST", "signup/finish", "wrong", finish, 403)
+ finish["origin"] = "https://" + hosts[1]
+ call("POST", "signup/finish", hosts[1], finish, 403)
+ finish["origin"] = "https://" + hosts[0]
+ call("POST", "signup/finish", hosts[0], finish, 201)
+ call("POST", "signup/finish", hosts[0], finish, 200)
+ finish["box"] += "changed"
+ call("POST", "signup/finish", hosts[0], finish, 403)
+ var count int
+ h.db.QueryRow("SELECT count(*) FROM ocho_passkeys").Scan(&count)
+ if count != 1 {
+ t.Fatal("duplicated passkey")
+ }
+ id, proof, cap := randomID(), randomID(), randomID()
+ box := strings.Repeat("encrypted-only-", 8)
+ invite := map[string]string{"origin": "https://" + hosts[0], "id": id, "proofHash": pushHash(proof), "capabilityHash": cap, "box": box}
+ call("POST", "invites", "wrong", invite, 403)
+ call("POST", "invites", hosts[0], invite, 201)
+ for i := 0; i < 2; i++ {
+ if call("GET", "invites/"+id, "", nil, 200)["box"] != box {
+ t.Fatal("preview changed")
+ }
+ }
+ peer, _ := ecdh.P256().GenerateKey(rand.Reader)
+ signer := encoded(peer.PublicKey().Bytes())
+ claim := map[string]string{"origin": "https://" + hosts[1], "proof": randomID(), "signer": signer}
+ call("POST", "invites/"+id+"/claim", hosts[1], claim, 410)
+ claim["proof"] = proof
+ accepted := call("POST", "invites/"+id+"/claim", hosts[1], claim, 200)
+ if !infinityinvite.Verify(pub, accepted["claim"], hosts[0], cap, signer) {
+ t.Fatal("bad ticket")
+ }
+ call("POST", "invites/"+id+"/claim", hosts[1], claim, 200)
+ call("GET", "invites/"+id, "", nil, 410)
+ claim["origin"] = "https://" + hosts[2]
+ call("POST", "invites/"+id+"/claim", hosts[2], claim, 410)
+ // Competing owners cannot both redeem the same locator.
+ next := randomID()
+ invite["id"] = next
+ call("POST", "invites", hosts[0], invite, 201)
+ results := make(chan int, 2)
+ for _, host := range hosts[1:] {
+ go func(host string) {
+ raw, _ := json.Marshal(map[string]string{"origin": "https://" + host, "proof": proof, "signer": signer})
+ r := httptest.NewRequest("POST", "https://ocho.chat/api/invites/"+next+"/claim", bytes.NewReader(raw))
+ r.Header.Set("Origin", "https://ocho.chat")
+ r.Header.Set("Authorization", "Bearer "+host)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, r)
+ results <- w.Code
+ }(host)
+ }
+ first, second := <-results, <-results
+ if !((first == 200 && second == 410) || (first == 410 && second == 200)) {
+ t.Fatalf("competing redemptions: %d, %d", first, second)
+ }
+
+}
diff --git a/internal/infinityinvite/claim.go b/internal/infinityinvite/claim.go
new file mode 100644
index 0000000..bfcd92e
--- /dev/null
+++ b/internal/infinityinvite/claim.go
@@ -0,0 +1,43 @@
+// 🤖 A redemption ticket pins an invitation's inbound capability to one recipient.
+package infinityinvite
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "strconv"
+ "time"
+)
+
+type Ticket struct {
+ Host string `json:"host"`
+ Capability string `json:"capability"`
+ Signer string `json:"signer"`
+ Expires int64 `json:"expires"`
+ Signature string `json:"signature"`
+}
+
+func (t Ticket) bytes() []byte {
+ return []byte("ocho-invite-claim-v1\n" + t.Host + "\n" + t.Capability + "\n" + t.Signer + "\n" + strconv.FormatInt(t.Expires, 10))
+}
+func Mint(key ed25519.PrivateKey, host, capability, signer string, expires int64) string {
+ t := Ticket{Host: host, Capability: capability, Signer: signer, Expires: expires}
+ t.Signature = base64.RawURLEncoding.EncodeToString(ed25519.Sign(key, t.bytes()))
+ b, _ := json.Marshal(t)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+func Verify(key ed25519.PublicKey, value, host, capability, signer string) bool {
+ if len(key) != ed25519.PublicKeySize || len(value) > 2048 {
+ return false
+ }
+ b, e := base64.RawURLEncoding.DecodeString(value)
+ if e != nil {
+ return false
+ }
+ var t Ticket
+ if json.Unmarshal(b, &t) != nil {
+ return false
+ }
+ sig, e := base64.RawURLEncoding.DecodeString(t.Signature)
+ return e == nil && t.Host == host && t.Capability == capability && t.Signer == signer && t.Expires >= time.Now().Unix() && ed25519.Verify(key, t.bytes(), sig)
+}
diff --git a/mobile/client.go b/mobile/client.go
index 023f1e8..b47f6da 100644
--- a/mobile/client.go
+++ b/mobile/client.go
@@ -31,6 +31,7 @@ func (c *Client) api(path, method string, body, out any) error {
}
type vaultReply struct {
+ Format int `json:"format,omitempty"`
Revision int64 `json:"revision"`
Box string `json:"box"`
}
@@ -54,6 +55,7 @@ func (c *Client) load() error {
c.state = state
c.revision = v.Revision
c.id.Name = state.Name
+ c.id.Avatar = state.Avatar
return nil
}
func (c *Client) save() error {
@@ -68,7 +70,7 @@ func (c *Client) save() error {
var out struct {
Revision int64 `json:"revision"`
}
- e = c.api("/api/vault", "PUT", vaultReply{c.revision, box}, &out)
+ e = c.api("/api/vault", "PUT", vaultReply{Revision: c.revision, Box: box, Format: 2}, &out)
if e == nil {
c.revision = out.Revision
}
@@ -150,6 +152,18 @@ func (c *Client) refresh() (projection, error) {
return out, errors.New("Account history did not advance.")
}
}
+ adopted := false
+ for _, e := range c.events {
+ if _, exists := c.state.Contacts[e.Thread]; !exists && !c.state.Blocked[e.Thread] && e.Kind == "contact-request" && e.Person != nil && validPerson(*e.Person) && verifiedConsent(c.id, c.state, e) {
+ c.state.Contacts[e.Thread] = contact{*e.Person, false}
+ adopted = true
+ }
+ }
+ if adopted {
+ if e := c.save(); e != nil {
+ return out, e
+ }
+ }
var receipts []delivery
if e := c.api("/api/delivery", "GET", nil, &receipts); e != nil {
return out, e
diff --git a/mobile/consent.go b/mobile/consent.go
new file mode 100644
index 0000000..f51d4c6
--- /dev/null
+++ b/mobile/consent.go
@@ -0,0 +1,70 @@
+// 🤖 Offline invitation consent is bound to the selected recipient and encrypted vault.
+package ocho
+
+import (
+ "crypto/ed25519"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/json"
+ "net/url"
+ "strconv"
+)
+
+type preapproval struct {
+ Grant string `json:"grant"`
+ CapabilityHash string `json:"capabilityHash"`
+ Key string `json:"key"`
+}
+type deliveryItem struct {
+ Destination string `json:"destination"`
+ Capability string `json:"capability"`
+ Envelope envelope `json:"envelope"`
+ Claim string `json:"claim,omitempty"`
+}
+
+func digest(text string) string { b := sha256.Sum256([]byte(text)); return encode(b[:]) }
+func contactProof(grant, thread, signer string) string {
+ key, e := decode(grant)
+ if e != nil || len(key) != 32 {
+ return ""
+ }
+ h := hmac.New(sha256.New, key)
+ h.Write([]byte("ocho-contact-v2\n" + thread + "\n" + signer))
+ return encode(h.Sum(nil))
+}
+func verifiedConsent(id identity, state vault, e event) bool {
+ consent, ok := state.Preapproved[e.Thread]
+ if !ok || e.Person == nil || e.Signer != e.Person.Signing || len(e.Claim) > 2048 {
+ return false
+ }
+ proof := contactProof(consent.Grant, e.Thread, e.Signer)
+ if proof == "" || !hmac.Equal([]byte(e.AcceptProof), []byte(proof)) {
+ return false
+ }
+ var ticket struct {
+ Host string `json:"host"`
+ Capability string `json:"capability"`
+ Signer string `json:"signer"`
+ Expires int64 `json:"expires"`
+ Signature string `json:"signature"`
+ }
+ b, err := decode(e.Claim)
+ if err != nil || json.Unmarshal(b, &ticket) != nil {
+ return false
+ }
+ origin, err := url.Parse(id.Origin)
+ if err != nil || ticket.Host != origin.Host || ticket.Capability != consent.CapabilityHash || ticket.Signer != e.Signer {
+ return false
+ }
+ key, err := decode(consent.Key)
+ if err != nil || len(key) != ed25519.PublicKeySize {
+ return false
+ }
+ sig, err := decode(ticket.Signature)
+ if err != nil {
+ return false
+ }
+ // Expiry is checked at delivery; historical consent remains valid when replayed.
+ text := "ocho-invite-claim-v1\n" + ticket.Host + "\n" + ticket.Capability + "\n" + ticket.Signer + "\n" + strconv.FormatInt(ticket.Expires, 10)
+ return ed25519.Verify(key, []byte(text), sig)
+}
diff --git a/mobile/consent_test.go b/mobile/consent_test.go
new file mode 100644
index 0000000..f157326
--- /dev/null
+++ b/mobile/consent_test.go
@@ -0,0 +1,86 @@
+// 🤖 Cross-client consent fixtures and fail-closed profile projections.
+package ocho
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "net/url"
+ "os"
+ "testing"
+)
+
+func TestBrowserInvitationConsent(t *testing.T) {
+ path := os.Getenv("OCHO_INTEROP_FIXTURE")
+ if path == "" {
+ t.Skip("check-native-crypto supplies Web Crypto fixture")
+ }
+ var f struct {
+ Sender identity `json:"sender"`
+ Recipient identity `json:"recipient"`
+ InviteBox string `json:"inviteBox"`
+ InviteKey string `json:"inviteKey"`
+ Grant string `json:"grant"`
+ Proof string `json:"proof"`
+ }
+ b, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = json.Unmarshal(b, &f); e != nil {
+ t.Fatal(e)
+ }
+ inv, e := decodeDescriptor(f.InviteBox, f.InviteKey)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if inv.Person.Signing != f.Sender.Signing || contactProof(f.Grant, inv.Thread, f.Recipient.Signing) != f.Proof {
+ t.Fatal("web/native consent mismatch")
+ }
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ state := newVault(f.Sender.Name)
+ state.Invites[inv.Thread] = true
+ state.Preapproved[inv.Thread] = preapproval{Grant: inv.Grant, CapabilityHash: digest(inv.Token), Key: encode(pub)}
+ host, _ := url.Parse(f.Sender.Origin)
+ ticket := map[string]any{"host": host.Host, "capability": digest(inv.Token), "signer": f.Recipient.Signing, "expires": int64(1)}
+ text := "ocho-invite-claim-v1\n" + host.Host + "\n" + digest(inv.Token) + "\n" + f.Recipient.Signing + "\n1"
+ ticket["signature"] = encode(ed25519.Sign(key, []byte(text)))
+ raw, _ := json.Marshal(ticket)
+ peer := f.Recipient.person()
+ ev := event{Kind: "contact-request", Thread: inv.Thread, Person: &peer, Signer: peer.Signing, AcceptProof: f.Proof, Claim: encode(raw)}
+ view := project(f.Sender, state, []event{ev})
+ if len(view.Threads) != 1 || view.Threads[0].Pending || len(view.Requests) != 0 {
+ t.Fatal("valid offline consent not accepted")
+ }
+ ev.Claim = encode([]byte(`{}`))
+ view = project(f.Sender, state, []event{ev})
+ if len(view.Threads) != 0 || len(view.Requests) != 0 {
+ t.Fatal("invalid v2 became consent")
+ }
+}
+func TestProfileCannotChangeKeysOrFetchRemoteImage(t *testing.T) {
+ a, _ := NewIdentity("https://a.example", "Alice")
+ b, _ := NewIdentity("https://b.example", "Bob")
+ id, _ := readIdentity(a)
+ peer, _ := readIdentity(b)
+ state := newVault("Alice")
+ state.Contacts["dm"] = contact{peer.person(), false}
+ person := peer.person()
+ person.Name = "New Bob"
+ ev := event{Kind: "profile", Person: &person, Signer: peer.Signing, At: now(), ID: Random()}
+ view := project(id, state, []event{ev})
+ if view.Threads[0].Name != "New Bob" {
+ t.Fatal("profile not applied")
+ }
+ person.Avatar = "https://tracking.example/pixel"
+ view = project(id, state, []event{ev})
+ if view.Threads[0].Name != "Bob" {
+ t.Fatal("remote avatar accepted")
+ }
+ person.Avatar = ""
+ person.Enc = id.Enc
+ view = project(id, state, []event{ev})
+ if view.Threads[0].Peer.Enc != peer.Enc || view.Threads[0].Name != "Bob" {
+ t.Fatal("profile changed identity keys")
+ }
+}
diff --git a/mobile/conversations.go b/mobile/conversations.go
index 531741b..bcd3792 100644
--- a/mobile/conversations.go
+++ b/mobile/conversations.go
@@ -9,12 +9,14 @@ import (
)
func (c *Client) emit(people []person, p event) error {
- type item struct {
- Destination string `json:"destination"`
- Capability string `json:"capability"`
- Envelope envelope `json:"envelope"`
+ if p.Kind == "group" {
+ members := append([]person(nil), p.Members...)
+ for i := range members {
+ members[i].Avatar = ""
+ }
+ p.Members = members
}
- items := []item{}
+ items := []deliveryItem{}
seen := map[string]bool{}
body, e := json.Marshal(p)
if e != nil {
@@ -39,7 +41,7 @@ func (c *Client) emit(people []person, p event) error {
if e != nil {
return e
}
- items = append(items, item{to.Origin, to.Cap, box})
+ items = append(items, deliveryItem{Destination: to.Origin, Capability: to.Cap, Envelope: box})
}
return c.api("/api/send", "POST", map[string]any{"items": items}, nil)
}
@@ -53,6 +55,9 @@ func (c *Client) Invite() (string, error) {
if e := c.load(); e != nil {
return "", e
}
+ if strings.HasPrefix(c.id.Origin, "https://p-") && strings.HasSuffix(c.id.Origin, ".bdy.oncarlos.com") {
+ return c.inviteShort(publicHome)
+ }
token, thread := Random(), Random()
if e := c.api("/api/capabilities", "POST", map[string]any{"token": token, "once": true}, nil); e != nil {
return "", e
@@ -63,32 +68,24 @@ func (c *Client) Invite() (string, error) {
}
p := c.id.person()
p.Cap = ""
- return inviteLink(invite{1, thread, token, p})
+ return inviteLink(invite{Version: 1, Thread: thread, Token: token, Person: 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
+ var inv invite
+ var e error
+ if home, code, ok := shortInvitation(link); ok {
+ inv, e = c.claimShort(home, code)
+ } else {
+ inv, e = parseInvite(link)
}
- 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 {
+ if 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
+ return c.connectInvite(inv)
}
// Accept accepts an authenticated pending request by its thread identifier.
diff --git a/mobile/identity.go b/mobile/identity.go
index 82ca5e1..2c6f2d0 100644
--- a/mobile/identity.go
+++ b/mobile/identity.go
@@ -40,6 +40,7 @@ type jwk struct {
Ops []string `json:"key_ops"`
}
type person struct {
+ Avatar string `json:"avatar,omitempty"`
Origin string `json:"origin"`
Name string `json:"name"`
Enc string `json:"enc"`
@@ -47,6 +48,7 @@ type person struct {
Cap string `json:"cap,omitempty"`
}
type identity struct {
+ Avatar string `json:"avatar,omitempty"`
Version int `json:"version"`
Origin string `json:"origin"`
Name string `json:"name"`
@@ -59,7 +61,9 @@ type identity struct {
PrivateSign jwk `json:"privateSign"`
}
-func (id identity) person() person { return person{id.Origin, id.Name, id.Enc, id.Signing, id.Cap} }
+func (id identity) person() person {
+ return person{Origin: id.Origin, Name: id.Name, Enc: id.Enc, Signing: id.Signing, Cap: id.Cap, Avatar: id.Avatar}
+}
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}}
}
@@ -118,7 +122,7 @@ func NewIdentity(origin, name string) (string, error) {
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")}
+ id := identity{Version: 1, Origin: origin, Name: name, Auth: Random(), VaultKey: Random(), Cap: Random(), Enc: encode(enc.PublicKey().Bytes()), Signing: encode(sign.PublicKey().Bytes()), PrivateEnc: makeJWK(enc.Bytes(), enc.PublicKey().Bytes(), "deriveBits"), PrivateSign: makeJWK(sign.Bytes(), sign.PublicKey().Bytes(), "sign")}
return marshal(id)
}
diff --git a/mobile/invite.go b/mobile/invite.go
index cfa0172..6f7a06a 100644
--- a/mobile/invite.go
+++ b/mobile/invite.go
@@ -9,13 +9,20 @@ import (
)
type invite struct {
- Version int `json:"version"`
- Thread string `json:"thread"`
- Token string `json:"token"`
- Person person `json:"person"`
+ Grant string `json:"grant,omitempty"`
+ Signature string `json:"signature,omitempty"`
+ Claim string `json:"claim,omitempty"`
+ SourceLink string `json:"sourceLink,omitempty"`
+ Version int `json:"version"`
+ Thread string `json:"thread"`
+ Token string `json:"token"`
+ Person person `json:"person"`
}
func parseInvite(text string) (invite, error) {
+ if home, code, ok := shortInvitation(text); ok {
+ return previewShort(text, home, code)
+ }
var out invite
if len(text) > 16000 {
return out, errors.New("Invitation is too long.")
diff --git a/mobile/profile.go b/mobile/profile.go
new file mode 100644
index 0000000..4fecd9a
--- /dev/null
+++ b/mobile/profile.go
@@ -0,0 +1,114 @@
+// 🤖 Profiles remain encrypted; avatar data is bounded raster content, never a remote URL.
+package ocho
+
+import (
+ "bytes"
+ "encoding/base64"
+ "errors"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
+ "strings"
+)
+
+func validAvatar(value string) bool {
+ if value == "" {
+ return true
+ }
+ if len(value) > 45000 {
+ return false
+ }
+ prefix := "data:image/png;base64,"
+ format := "png"
+ if strings.HasPrefix(value, "data:image/jpeg;base64,") {
+ prefix = "data:image/jpeg;base64,"
+ format = "jpeg"
+ }
+ if !strings.HasPrefix(value, prefix) {
+ return false
+ }
+ b, e := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, prefix))
+ if e != nil {
+ return false
+ }
+ cfg, kind, e := image.DecodeConfig(bytes.NewReader(b))
+ return e == nil && kind == format && cfg.Width > 0 && cfg.Height > 0 && cfg.Width <= 256 && cfg.Height <= 256
+}
+func applyProfiles(threads map[string]*thread, snapshots map[string][]person, events []event) {
+ latest := map[string]event{}
+ for _, e := range events {
+ if e.Kind != "profile" || e.Person == nil || !validPerson(*e.Person) || e.Signer != e.Person.Signing || e.At == nil || *e.At < 0 || *e.At > 9007199254740991 {
+ continue
+ }
+ prior, ok := latest[e.Signer]
+ if !ok || *e.At > *prior.At || (*e.At == *prior.At && e.ID > prior.ID) {
+ latest[e.Signer] = e
+ }
+ }
+ update := func(p person) person {
+ e, ok := latest[p.Signing]
+ if ok && e.Person.Enc == p.Enc && e.Person.Origin == p.Origin {
+ p.Name = e.Person.Name
+ p.Avatar = e.Person.Avatar
+ }
+ return p
+ }
+ for _, t := range threads {
+ for i, p := range t.Members {
+ t.Members[i] = update(p)
+ }
+ if t.Peer != nil {
+ p := update(*t.Peer)
+ t.Peer = &p
+ t.Name = p.Name
+ }
+ }
+ for _, members := range snapshots {
+ for i, p := range members {
+ members[i] = update(p)
+ }
+ }
+}
+
+// UpdateProfile saves encrypted display details and shares them with accepted peers.
+func (c *Client) UpdateProfile(name, avatar string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ name = strings.TrimSpace(name)
+ if name == "" || textLength(name) > 80 || !validAvatar(avatar) {
+ return errors.New("Choose a name and a supported avatar.")
+ }
+ view, e := c.refresh()
+ if e != nil {
+ return e
+ }
+ c.state.Name = name
+ c.state.Avatar = avatar
+ c.id.Name = name
+ c.id.Avatar = avatar
+ if e = c.save(); e != nil {
+ return e
+ }
+ people := []person{}
+ seen := map[string]bool{}
+ for _, t := range view.Threads {
+ if t.Pending || t.Left {
+ continue
+ }
+ for _, p := range t.Members {
+ if !seen[p.Signing] {
+ people = append(people, p)
+ seen[p.Signing] = true
+ }
+ }
+ }
+ me := c.id.person()
+ payload := event{Kind: "profile", Thread: "profile", Person: &me, At: now(), ID: Random()}
+ for i := 0; i < len(people); i += 16 {
+ end := min(i+16, len(people))
+ if e = c.emit(people[i:end], payload); e != nil {
+ return e
+ }
+ }
+ return nil
+}
diff --git a/mobile/projection.go b/mobile/projection.go
index d821109..2b9eb0b 100644
--- a/mobile/projection.go
+++ b/mobile/projection.go
@@ -13,30 +13,35 @@ type contact struct {
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"`
+ Avatar string `json:"avatar,omitempty"`
+ Preapproved map[string]preapproval `json:"preapproved"`
+ Handshakes map[string][]deliveryItem `json:"handshakes"`
+ 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{}}
+ return vault{Name: name, Contacts: map[string]contact{}, Invites: map[string]bool{}, Accepted: map[string]bool{}, Blocked: map[string]bool{}, Preapproved: map[string]preapproval{}, Handshakes: map[string][]deliveryItem{}}
}
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"`
+ AcceptProof string `json:"acceptProof,omitempty"`
+ Claim string `json:"claim,omitempty"`
+ 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 {
@@ -90,7 +95,7 @@ type projection struct {
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 != ""
+ return e == nil && u.Scheme+"://"+u.Host == p.Origin && textLength(p.Name) <= 80 && p.Cap != "" && validAvatar(p.Avatar)
}
func contains(members []person, signer string) bool {
for _, p := range members {
@@ -103,6 +108,7 @@ func contains(members []person, signer string) bool {
func project(id identity, state vault, events []event) projection {
me := id.person()
me.Name = state.Name
+ me.Avatar = state.Avatar
threads := map[string]*thread{}
requests := map[string]event{}
snapshots := map[string][]person{}
@@ -120,8 +126,12 @@ func project(id identity, state vault, events []event) projection {
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 verifiedConsent(id, state, e) {
+ threads[e.Thread] = &thread{ID: e.Thread, Name: p.Name, Peer: p, Members: []person{me, *p}, Messages: []message{}}
+ } else if _, upgraded := state.Preapproved[e.Thread]; !upgraded {
+ 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 {
@@ -182,6 +192,7 @@ func project(id identity, state vault, events []event) projection {
requests[e.Thread] = e
}
}
+ applyProfiles(threads, snapshots, events)
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 {
diff --git a/mobile/short_invite.go b/mobile/short_invite.go
new file mode 100644
index 0000000..3e056cc
--- /dev/null
+++ b/mobile/short_invite.go
@@ -0,0 +1,207 @@
+// 🤖 Native encrypted short invitations and durable consent delivery.
+package ocho
+
+import (
+ "crypto/ecdh"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "math/big"
+ "net/url"
+ "strings"
+)
+
+const publicHome = "https://ocho.chat"
+
+func shortInvitation(link string) (string, string, bool) {
+ u, e := url.Parse(link)
+ if e != nil || u.User != nil || u.RawQuery != "" || (u.Path != "" && u.Path != "/") {
+ return "", "", false
+ }
+ home := u.Scheme + "://" + u.Host
+ local := u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1")
+ if home != publicHome && !local {
+ return "", "", false
+ }
+ raw, e := decode(u.Fragment)
+ if e != nil || len(raw) != 16 || len(u.Fragment) != 22 {
+ return "", "", false
+ }
+ return home, u.Fragment, true
+}
+func inviteParts(code string) (string, string, string) {
+ return digest("ocho-invite-lookup-v1\n" + code), digest("ocho-invite-key-v1\n" + code), digest("ocho-invite-claim-v1\n" + code)
+}
+func (inv invite) grantText() string {
+ return strings.Join([]string{"ocho-invite-v2", inv.Thread, inv.Token, inv.Grant, inv.Person.Origin, inv.Person.Enc, inv.Person.Signing, encode([]byte(inv.Person.Name))}, "\n")
+}
+func decodeDescriptor(box, key string) (invite, error) {
+ var inv invite
+ plain, e := Unlock(key, box)
+ if e != nil {
+ return inv, e
+ }
+ if e = parse(plain, &inv); e != nil {
+ return inv, e
+ }
+ bad := errors.New("This invitation could not be verified.")
+ inv.Person.Cap = inv.Token
+ if inv.Version != 2 || !validPerson(inv.Person) {
+ return inv, bad
+ }
+ for _, v := range []string{inv.Thread, inv.Token, inv.Grant} {
+ b, e := decode(v)
+ if e != nil || len(b) != 32 {
+ return inv, bad
+ }
+ }
+ enc, e := decode(inv.Person.Enc)
+ if e != nil {
+ return inv, bad
+ }
+ if _, e = ecdh.P256().NewPublicKey(enc); e != nil {
+ return inv, bad
+ }
+ pub, e := decode(inv.Person.Signing)
+ if e != nil {
+ return inv, bad
+ }
+ x, y := elliptic.Unmarshal(elliptic.P256(), pub)
+ if x == nil {
+ return inv, bad
+ }
+ sig, e := decode(inv.Signature)
+ if e != nil || len(sig) != 64 {
+ return inv, bad
+ }
+ hash := sha256.Sum256([]byte(inv.grantText()))
+ if !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, hash[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
+ return inv, bad
+ }
+ return inv, nil
+}
+func previewShort(link, home, code string) (invite, error) {
+ locator, key, _ := inviteParts(code)
+ var out struct {
+ Box string `json:"box"`
+ }
+ if e := request(home, "/api/invites/"+locator, "", "GET", nil, &out); e != nil {
+ return invite{}, e
+ }
+ inv, e := decodeDescriptor(out.Box, key)
+ inv.SourceLink = link
+ return inv, e
+}
+func (c *Client) claimShort(home, code string) (invite, error) {
+ locator, key, proof := inviteParts(code)
+ var out struct {
+ Box string `json:"box"`
+ Claim string `json:"claim"`
+ }
+ e := request(home, "/api/invites/"+locator+"/claim", c.id.Auth, "POST", map[string]string{"origin": c.id.Origin, "proof": proof, "signer": c.id.Signing}, &out)
+ if e != nil {
+ return invite{}, e
+ }
+ inv, e := decodeDescriptor(out.Box, key)
+ inv.Claim = out.Claim
+ return inv, e
+}
+func (c *Client) inviteShort(home string) (string, error) {
+ code := encode(randomBytes(16))
+ locator, key, proof := inviteParts(code)
+ inv := invite{Version: 2, Thread: Random(), Token: Random(), Grant: Random(), Person: c.id.person()}
+ inv.Person.Cap = ""
+ raw, e := decode(c.id.PrivateSign.D)
+ if e != nil {
+ return "", 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([]byte(inv.grantText()))
+ r, s, e := ecdsa.Sign(rand.Reader, sk, hash[:])
+ if e != nil {
+ return "", e
+ }
+ inv.Signature = encode(append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...))
+ capHash := digest(inv.Token)
+ var config struct {
+ Key string `json:"key"`
+ }
+ if e = request(home, "/api/invite-key", "", "GET", nil, &config); e != nil {
+ return "", e
+ }
+ public, e := decode(config.Key)
+ if e != nil || len(public) != 32 {
+ return "", errors.New("Invitations are temporarily unavailable.")
+ }
+ if e = c.api("/api/capabilities", "POST", map[string]any{"token": inv.Token, "invite": true}, nil); e != nil {
+ return "", e
+ }
+ c.state.Invites[inv.Thread] = true
+ if c.state.Preapproved == nil {
+ c.state.Preapproved = map[string]preapproval{}
+ }
+ c.state.Preapproved[inv.Thread] = preapproval{Grant: inv.Grant, CapabilityHash: capHash, Key: config.Key}
+ if e = c.save(); e != nil {
+ return "", e
+ }
+ plain, e := marshal(inv)
+ if e != nil {
+ return "", e
+ }
+ box, e := Lock(key, plain)
+ if e != nil {
+ return "", e
+ }
+ e = request(home, "/api/invites", c.id.Auth, "POST", map[string]string{"origin": c.id.Origin, "id": locator, "proofHash": digest(proof), "capabilityHash": capHash, "box": box}, nil)
+ return home + "/#" + code, e
+}
+func (c *Client) connectInvite(inv invite) (string, error) {
+ if inv.Person.Signing == c.id.Signing {
+ return "", errors.New("Share this invitation with someone else.")
+ }
+ if e := c.load(); e != nil {
+ return "", e
+ }
+ prior, exists := c.state.Contacts[inv.Thread]
+ if exists && prior.Person.Signing != inv.Person.Signing {
+ return "", errors.New("This conversation belongs to someone else.")
+ }
+ if c.state.Handshakes == nil {
+ c.state.Handshakes = map[string][]deliveryItem{}
+ }
+ if !exists {
+ me := c.id.person()
+ payload := event{Kind: "contact-request", Thread: inv.Thread, Person: &me, At: now()}
+ if inv.Version == 2 {
+ payload.AcceptProof = contactProof(inv.Grant, inv.Thread, c.id.Signing)
+ payload.Claim = inv.Claim
+ }
+ body, e := json.Marshal(payload)
+ if e != nil {
+ return "", e
+ }
+ box, e := seal(c.id, inv.Person, body, "")
+ if e != nil {
+ return "", e
+ }
+ c.state.Contacts[inv.Thread] = contact{inv.Person, inv.Version != 2}
+ c.state.Handshakes[inv.Thread] = []deliveryItem{{Destination: inv.Person.Origin, Capability: inv.Token, Envelope: box, Claim: inv.Claim}}
+ if e = c.save(); e != nil {
+ return "", e
+ }
+ }
+ if items, ok := c.state.Handshakes[inv.Thread]; ok {
+ if e := c.api("/api/send", "POST", map[string]any{"items": items}, nil); e != nil {
+ return "", e
+ }
+ delete(c.state.Handshakes, inv.Thread)
+ if e := c.save(); e != nil {
+ return "", e
+ }
+ }
+ return inv.Thread, nil
+}
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs
index a68bddd..1d5fa7e 100644
--- a/test/infinity/browser.mjs
+++ b/test/infinity/browser.mjs
@@ -12,47 +12,23 @@ try{
for(let attempt=0;;attempt++){try{if((await fetch(origins[i]+'/api/profile')).ok)break;}catch{}if(attempt>100)throw Error('server failed to start');await new Promise(r=>setTimeout(r,100));}
}
browser=await engine.launch({headless:true});
- // A stalled connection must release the form and preserve the signup nonce for retry.
+ // A stalled request times out instead of trapping the setup screen indefinitely.
const stalledContext=await browser.newContext(),stalled=await stalledContext.newPage();
- await stalled.clock.install();
- await stalled.route('**/api/registration-challenge',()=>{});
- await stalled.goto(origins[0]);await stalled.getByLabel('Your name',{exact:true}).fill('Timeout test');
- const pending=stalled.waitForRequest('**/api/registration-challenge');
- await stalled.getByRole('button',{name:'Create your account',exact:true}).click();await pending;
- await stalled.getByRole('heading',{name:/To infinity/}).waitFor();
- assert.equal(await stalled.locator('.onboard-form').count(),0);
- assert.equal(await stalled.getByRole('progressbar').getAttribute('aria-valuenow'),null);
- const nonce=await stalled.evaluate(()=>sessionStorage.getItem('infinity.signup'));
- await stalled.clock.fastForward(31000);
- await stalled.getByRole('alert').filter({hasText:'The connection took too long.'}).waitFor();
- assert.equal(await stalled.getByRole('button',{name:'Create your account',exact:true}).isEnabled(),true);
- assert.equal(await stalled.getByLabel('Your name',{exact:true}).inputValue(),'Timeout test');
- const retry=stalled.waitForRequest('**/api/registration-challenge');
- await stalled.getByRole('button',{name:'Create your account',exact:true}).click();await retry;
- assert.equal(await stalled.evaluate(()=>sessionStorage.getItem('infinity.signup')),nonce);
- await stalledContext.close();
-
-
+ await stalled.goto(origins[0]);await stalled.clock.install();await stalled.route('**/api/registration-challenge',()=>{});
+ const timeout=stalled.evaluate(async()=>{const {request}=await import('/static/api.js');try{await request(location.origin,'/api/registration-challenge');return '';}catch(e){return e.message;}});
+ await stalled.waitForRequest('**/api/registration-challenge');await stalled.clock.fastForward(31000);assert.match(await timeout,/connection took too long/i);await stalledContext.close();
for(let i=0;i<3;i++){
- const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();pages.push(page);
+ const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();page.setDefaultTimeout(20000);pages.push(page);
page.on('pageerror',e=>errors.push(e.message));page.on('console',msg=>{if(msg.type()==='error')errors.push(msg.text())});
await page.goto(origins[i]+'/#claim='+claim);
await page.getByLabel('Your name',{exact:true}).fill(['Alice','Bob','Carol'][i]);await page.getByRole('button',{name:'Create your account',exact:true}).click();
await page.getByRole('button',{name:'Invite',exact:true}).waitFor();
}
const [a,b,c]=pages;
- async function invite(page){await page.getByRole('button',{name:'Invite',exact:true}).click();await page.getByRole('button',{name:'Create invitation',exact:true}).click();const link=await page.getByLabel('Invitation link').inputValue();await page.getByRole('button',{name:'Close',exact:true}).click();return link;}
+ async function invite(page){await page.getByRole('button',{name:'Invite',exact:true}).click();await page.getByLabel('Invitation link').waitFor();const link=await page.getByLabel('Invitation link').inputValue();await page.getByRole('button',{name:'Close',exact:true}).click();return link;}
async function connect(page,link){await page.locator('.account-menu summary').click();await page.getByRole('button',{name:'Connect with a link',exact:true}).click();await page.getByLabel('Invitation link').fill(link);await page.getByRole('button',{name:'Connect',exact:true}).click();await page.getByRole('dialog').waitFor({state:'detached'});}
- // Follow the invitation as a recipient: inviter -> configured front door -> saved account.
- const directInvite=await invite(a);
- await b.goto(origins[2]);await b.evaluate(origin=>localStorage.setItem('infinity.home',origin),origins[1]);
- await b.goto(directInvite);await b.getByRole('button',{name:'Continue',exact:true}).click();
- await b.getByRole('button',{name:'Open your account',exact:true}).waitFor();
- assert.equal(new URL(b.url()).origin,origins[2]);assert.equal(new URL(b.url()).searchParams.size,0);
- assert.equal(new URLSearchParams(new URL(b.url()).hash.slice(1)).get('invite'),directInvite);
- await b.getByRole('button',{name:'Open your account',exact:true}).click();
- assert.equal(await b.getByLabel('Invitation link').inputValue(),directInvite);
- await b.getByRole('button',{name:'Connect',exact:true}).click();await b.getByRole('dialog').waitFor({state:'detached'});
+ // Legacy self-hosted links still exchange consent using the native-format descriptor.
+ await connect(b,await invite(a));
await a.getByRole('button',{name:'Accept',exact:true}).click();
await b.locator('.thread-row').filter({hasText:'Alice'}).click();
await b.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Alice — private-canary-text');await b.getByRole('button',{name:'Send message',exact:true}).click();
@@ -72,21 +48,16 @@ try{
await a.reload();await a.locator('.thread-row').filter({hasText:'The weekend'}).click();await a.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor();
// Repeating setup after a lost reply preserves the same identity/history.
await a.evaluate(async claim => { const { Client } = await import('/static/client.js'); const id=JSON.parse(localStorage.getItem('infinity.identity.v1')); await new Client(id).setup(claim); },claim);
- // Recover through the real file/password flow in a fourth, empty browser context.
- await a.locator('.account-menu summary').click();await a.getByRole('button',{name:'Recovery & another device',exact:true}).click();
- await a.getByLabel('Recovery password',{exact:true}).fill('our test recovery password');await a.getByLabel('Repeat password',{exact:true}).fill('our test recovery password');
- const downloaded=a.waitForEvent('download');await a.getByRole('button',{name:'Save recovery file',exact:true}).click();const download=await downloaded;const recoveryFile=path.join(dir,'recovery.json');await download.saveAs(recoveryFile);await a.getByRole('button',{name:'Close',exact:true}).click();
- const recoveryInvite=await invite(b);
- const recovered=await (await browser.newContext()).newPage();await recovered.goto(origins[0]+'/#invite='+encodeURIComponent(recoveryInvite));await recovered.getByRole('button',{name:'I have a recovery file',exact:true}).click();
- await recovered.getByLabel('Recovery file',{exact:true}).setInputFiles(recoveryFile);await recovered.getByLabel('Recovery password',{exact:true}).fill('our test recovery password');await recovered.getByRole('button',{name:'Open your account',exact:true}).click();
- assert.equal(await recovered.getByLabel('Invitation link').inputValue(),recoveryInvite);await recovered.getByRole('button',{name:'Close',exact:true}).click();
+ // Retain legacy encrypted-format compatibility without exposing recovery-file UI.
+ const restoredIdentity=await a.evaluate(async()=>{const {exportRecovery,importRecovery}=await import('/static/crypto.js');const id=JSON.parse(localStorage.getItem('infinity.identity.v1'));const file=await exportRecovery(id,'our test recovery password');return importRecovery(file,'our test recovery password');});
+ const recovered=await (await browser.newContext()).newPage();await recovered.goto(origins[0]);await recovered.evaluate(id=>localStorage.setItem('infinity.identity.v1',JSON.stringify(id)),restoredIdentity);await recovered.reload();
await recovered.locator('.thread-row').filter({hasText:'The weekend'}).click();await recovered.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor();await recovered.close();
await b.setViewportSize({width:390,height:844});if(engine!==webkit)await b.screenshot({caret:'initial',path:path.join(dir,'mobile.png'),fullPage:true});
assert(await b.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'mobile overflow');
// Raw database + WAL check includes the actual browser-generated plaintext marker.
for(let i=0;i<3;i++)for(const suffix of ['', '-wal']){let bytes;try{bytes=await readFile(path.join(dir,i+'.db'+suffix));}catch(e){if(e.code==='ENOENT')continue;throw e;}for(const text of ['private-canary-text','Anyone for a walk?','This is only for Alice and Bob.'])assert(!bytes.includes(Buffer.from(text)),'plaintext persisted in DB/WAL');}
for(const page of pages){const text=await page.locator('body').innerText();assert(!/\[object Object\]|\bundefined\b|\bNaN\b/.test(text),'rendered JS junk');}
- assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:engine===webkit?null:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','idempotent setup','recovery on another device','mobile layout','SQLite/WAL plaintext absence','no browser errors']}));
+ assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:engine===webkit?null:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','idempotent setup','legacy encrypted recovery format compatibility','mobile layout','SQLite/WAL plaintext absence','no browser errors']}));
}catch(error){
for(let i=0;i<pages.length;i++){console.log('PAGE',i,await pages[i].locator('body').innerText());await pages[i].screenshot({caret:'initial',path:path.join(dir,'failure-'+i+'.png')});}
console.log('ARTIFACTS',dir);throw error;
diff --git a/test/infinity/home-browser.mjs b/test/infinity/home-browser.mjs
index d16e899..6926f30 100644
--- a/test/infinity/home-browser.mjs
+++ b/test/infinity/home-browser.mjs
@@ -1,64 +1,39 @@
-// 🤖 Real front-door signup and passkey/PRF sign-in with an isolated virtual authenticator.
+// 🤖 Passkey-first signup, short invitations, offline consent and same-origin encrypted history.
import {createRequire} from 'node:module';
import {spawn} from 'node:child_process';
import assert from 'node:assert/strict';
const require=createRequire(new URL('./package.json',import.meta.url));
const {chromium,webkit}=require('playwright');
-const home=process.env.OCHO_HOME_URL || 'http://localhost:18860';
-const live=process.env.OCHO_LIVE==='1';
-if (!live && home!=='http://localhost:18860') throw Error('Remote verification requires OCHO_LIVE=1');
-const server=live?null:spawn('.build/native-check/home-test',['-test.run','^TestHomeBrowserHarness$','-test.timeout','5m'],{env:{...process.env,OCHO_HOME_BROWSER:'1'},stdio:['ignore','pipe','pipe']});
+const home=process.env.OCHO_HOME_URL||'http://localhost:18860',live=process.env.OCHO_LIVE==='1';
+if(!live&&home!=='http://localhost:18860')throw Error('Remote verification requires OCHO_LIVE=1');
+const server=live?null:spawn('.build/native-check/home-test',['-test.run','^TestHomeBrowserHarness$','-test.timeout','10m'],{env:{...process.env,OCHO_HOME_BROWSER:'1'},stdio:['ignore','pipe','pipe']});
let browser;
-try {
+async function account(browser,prf=true){const context=await browser.newContext();context.setDefaultTimeout(15000);const page=await context.newPage(),cdp=await context.newCDPSession(page);await cdp.send('WebAuthn.enable');const {authenticatorId}=await cdp.send('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',ctap2Version:'ctap2_1',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true,hasPrf:prf}});return{context,page,cdp,authenticatorId};}
+async function chat(page){try{await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000});}catch{throw Error((await page.locator('#app').innerText()).slice(0,2000));}assert.equal(new URL(page.url()).origin,home);}
+try{
for(let n=0;;n++){try{if((await fetch(home+'/api/profile')).ok)break;}catch{}if(n>100)throw Error('home harness failed');await new Promise(r=>setTimeout(r,100));}
- browser=await chromium.launch();
- const context=await browser.newContext(), page=await context.newPage();
- const errors=[];page.on('pageerror',e=>errors.push(e.message));
- const cdp=await context.newCDPSession(page);await cdp.send('WebAuthn.enable');
- const {authenticatorId}=await cdp.send('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',ctap2Version:'ctap2_1',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true,hasPrf:true}});
- await page.goto(home);await page.getByLabel('Your name',{exact:true}).fill('Native migration canary');
- await page.getByRole('button',{name:'Create your account',exact:true}).click();
- await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000});
- assert.equal(new URL(page.url()).origin,home);
- const identity=await page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')));
- assert.ok(identity.origin.endsWith(live?'.bdy.oncarlos.com':'.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();
- try { await page.getByText('Passkey saved.',{exact:false}).waitFor({timeout:10000}); } catch { throw Error(await page.getByRole('dialog').innerText()); }
- const {credentials}=await cdp.send('WebAuthn.getCredentials',{authenticatorId});assert.equal(credentials.length,1);
- await page.evaluate(()=>localStorage.clear());await page.reload();
- await page.getByRole('button',{name:'Sign in with a passkey',exact:true}).click();
- await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:30000});
- assert.equal(new URL(page.url()).origin,home);
- const restored=await page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')));
- assert.equal(restored.enc,identity.enc);assert.equal(restored.vaultKey,identity.vaultKey);
- await page.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, 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');}
+ browser=await chromium.launch();const alice=await account(browser),bob=await account(browser);const errors=[];alice.page.on('pageerror',e=>errors.push(e.message));bob.page.on('pageerror',e=>errors.push(e.message));
+ let allocated=0,createdPasskey=false;
+ alice.page.on('request',r=>{if(r.url().endsWith('/api/accounts')){allocated++;assert.ok(createdPasskey,'instance allocated before passkey');}});
+ await alice.cdp.send('WebAuthn.enable');alice.cdp.on('WebAuthn.credentialAdded',()=>{createdPasskey=true;});
+ let lostFinish=false;await alice.page.route('**/api/signup/finish',async route=>{if(!lostFinish){lostFinish=true;const response=await route.fetch();assert.equal(response.status(),201);await route.abort();}else await route.continue();});
+ console.log('Opening signup');await alice.page.goto(home);assert.equal(await alice.page.getByLabel('Name',{exact:true}).count(),0);assert.equal(await alice.page.getByText(/recovery file/i).count(),0);
+ await alice.page.getByRole('button',{name:'Sign up',exact:true}).click();await alice.page.getByLabel('Name',{exact:true}).fill('Paul');await alice.page.getByRole('button',{name:'Create',exact:true}).click();await alice.page.getByRole('alert').waitFor();await alice.page.getByRole('button',{name:'Create',exact:true}).waitFor();await alice.page.getByRole('button',{name:'Create',exact:true}).click();await chat(alice.page);console.log('Signup passed');assert.ok(allocated>0);
+ const identity=await alice.page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')));
+ await alice.page.getByRole('button',{name:'Invite',exact:true}).click();const link=alice.page.getByLabel('Invitation link');await link.waitFor();const invitation=await link.inputValue();assert.match(invitation,new RegExp('^'+home.replaceAll('.','\\.')+'/#[-_A-Za-z0-9]{22}$'));
+ await alice.page.getByRole('button',{name:'Close',exact:true}).click();
+ await bob.page.goto(invitation);await bob.page.getByRole('heading',{name:'Paul is inviting you to chat on Ocho',exact:true}).waitFor();
+ await bob.page.reload();await bob.page.getByRole('button',{name:'Sign up to accept',exact:true}).click();await bob.page.getByLabel('Name',{exact:true}).fill('Bob');
+ await alice.page.goto('about:blank'); // The inviter's client cannot approve or exchange another capability.
+ await bob.page.getByRole('button',{name:'Connect with Paul',exact:true}).click();await chat(bob.page);console.log('Invite signup passed');await bob.page.getByRole('textbox',{name:'Message',exact:true}).fill('Straight into our conversation');await bob.page.getByRole('button',{name:'Send message',exact:true}).click();await bob.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();
+ await alice.page.goto(home);await chat(alice.page);await alice.page.locator('.thread-row').filter({hasText:'Bob'}).click();await alice.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();assert.equal(await alice.page.getByRole('button',{name:'Accept',exact:true}).count(),0);
+ await alice.page.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Bob');await alice.page.getByRole('button',{name:'Send message',exact:true}).click();await bob.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
+ await alice.page.locator('.account-menu summary').click();await alice.page.getByRole('button',{name:'Your profile',exact:true}).click();await alice.page.getByLabel('Display name',{exact:true}).fill('Paul updated');await alice.page.getByRole('button',{name:'Save',exact:true}).click();await bob.page.getByRole('heading',{name:'Paul updated',exact:true}).waitFor();
+ await alice.page.evaluate(()=>localStorage.clear());await alice.page.reload();await alice.page.getByRole('button',{name:'Sign in',exact:true}).click();await chat(alice.page);
+ const restored=await alice.page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')));assert.equal(restored.enc,identity.enc);assert.equal(restored.vaultKey,identity.vaultKey);await alice.page.locator('.thread-row').filter({hasText:'Bob'}).click();await alice.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
+ await bob.page.goto(invitation);await bob.page.getByRole('button',{name:'Accept',exact:true}).click();await chat(bob.page);await bob.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
+ const unsupported=await account(browser,false);let unwantedAllocations=0;unsupported.page.on('request',r=>{if(r.url().endsWith('/api/accounts'))unwantedAllocations++;});await unsupported.page.goto(home);await unsupported.page.getByRole('button',{name:'Sign up',exact:true}).click();await unsupported.page.getByLabel('Name',{exact:true}).fill('Unsupported provider');await unsupported.page.getByRole('button',{name:'Create',exact:true}).click();await unsupported.page.getByText('This passkey provider cannot unlock encrypted messages.',{exact:false}).waitFor();assert.equal(unwantedAllocations,0);
+ const cancelled=await account(browser);let cancelledAllocations=0;cancelled.page.on('request',r=>{if(r.url().endsWith('/api/accounts'))cancelledAllocations++;});await cancelled.page.goto(home);await cancelled.page.evaluate(()=>{navigator.credentials.create=async()=>{throw new DOMException('Cancelled','NotAllowedError');};});await cancelled.page.getByRole('button',{name:'Sign up',exact:true}).click();await cancelled.page.getByLabel('Name',{exact:true}).fill('Cancelled');await cancelled.page.getByRole('button',{name:'Create',exact:true}).click();await cancelled.page.getByText('Passkey creation or sign-in was cancelled. Try again when you’re ready.',{exact:true}).waitFor();assert.equal(cancelledAllocations,0);
+ assert.deepEqual(errors,[]);console.log('Chromium: passkey-first signup, no recovery UI, 22-character invite, read-only preview, inviter-offline direct consent, DMs, profile sync, passkey history restoration and same-recipient invite retry passed.');
+ const safari=await webkit.launch();try{const page=await safari.newPage();await page.goto(home);await page.getByRole('button',{name:'Sign up',exact:true}).waitFor();await page.getByRole('button',{name:'Sign up',exact:true}).click();await page.getByLabel('Name',{exact:true}).fill('Safari');assert.equal(new URL(page.url()).origin,home);assert.equal(await page.getByText(/recovery file/i).count(),0);console.log('WebKit: public entry and name step stay on the front door with no recovery-file controls. Real Safari passkey ceremony requires user presence.');}finally{await safari.close();}
+}finally{await browser?.close();server?.kill('SIGTERM');}
diff --git a/tools/infinity/check b/tools/infinity/check
index 74a3e4c..65e3723 100755
--- a/tools/infinity/check
+++ b/tools/infinity/check
@@ -4,8 +4,8 @@ set -eu
cd "$(dirname "$0")/../.."
case "${1:-all}" in
server)
- CGO_ENABLED=0 go test ./internal/infinity ./internal/infinityhome ./cmd/infinity ./cmd/infinity-home
- CGO_ENABLED=0 go vet ./internal/infinity ./internal/infinityhome ./cmd/infinity ./cmd/infinity-home
+ CGO_ENABLED=0 go test ./internal/infinity ./internal/infinityinvite ./internal/infinityhome ./cmd/infinity ./cmd/infinity-home
+ CGO_ENABLED=0 go vet ./internal/infinity ./internal/infinityinvite ./internal/infinityhome ./cmd/infinity ./cmd/infinity-home
;;
browser)
CGO_ENABLED=0 go build -o /private/tmp/infinity-browser/infinity ./cmd/infinity
diff --git a/tools/infinity/check-native-crypto b/tools/infinity/check-native-crypto
index e5dca29..280f62d 100755
--- a/tools/infinity/check-native-crypto
+++ b/tools/infinity/check-native-crypto
@@ -13,8 +13,14 @@ const prfOutput = new Uint8Array(32).fill(17), credentialID = new Uint8Array(32)
const prfKey = await passkeyWrappingKey(prfOutput, credentialID);
const sender = await createIdentity('https://sender.example','Sender');
const recipient = await createIdentity('https://recipient.example','Recipient');
+const {grantText,contactProof}=await import('./internal/infinity/static/invitations.js');
+const grant=Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'),inviteKey=grant;
+const invitation={version:2,thread:grant,token:grant,grant,person:{origin:sender.origin,name:sender.name,enc:sender.enc,signing:sender.signing}};
+const privateKey=await crypto.subtle.importKey('jwk',sender.privateSign,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);
+invitation.signature=Buffer.from(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},privateKey,new TextEncoder().encode(grantText(invitation)))).toString('base64url');
+const inviteBox=await lock(inviteKey,invitation),proof=await contactProof(grant,invitation.thread,recipient.signing);
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')}));
+writeFileSync(process.env.OCHO_INTEROP_FIXTURE, JSON.stringify({sender,recipient,envelope,prfKey,inviteBox,inviteKey,grant,proof,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