eleven / ocho Public

Add encrypted attachments and prepare Ocho native beta release

Add owner-scoped file delivery, safe destination cards, per-message relay retry, and opt-in Android FCM integration. Record verified builds and the remaining device and store gates.
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 80c44170fcefba99151743ae228fa799973a7b8d parent 375b386
47 files changed, +1367 −45
  • README.md +1 −1
  • android/app/build.gradle.kts +9 −1
  • android/app/src/main/AndroidManifest.xml +5 −0
  • android/app/src/main/java/chat/ocho/app/AccountModel.kt +19 −2
  • android/app/src/main/java/chat/ocho/app/ActivityNotifications.kt +61 −0
  • android/app/src/main/java/chat/ocho/app/OchoMessagingService.kt +27 −0
  • android/app/src/main/java/chat/ocho/app/OchoScreen.kt +61 −2
  • apple/Infinity/AttachmentDocument.swift +10 −0
  • apple/Infinity/NativeConversationView.swift +53 −0
  • apple/Infinity/OutgoingMessages.swift +8 −0
  • apple/OchoCore/Sources/OchoCore/Attachment.swift +6 −0
  • apple/OchoCore/Sources/OchoCore/Client.swift +4 −0
  • apple/OchoCore/Sources/OchoCore/MessageLinks.swift +12 −0
  • apple/OchoCore/Sources/OchoCore/Models.swift +1 −0
  • apple/project.yml +1 −1
  • cmd/infinity-home/main.go +7 −0
  • docs/infinity/push.md +32 −0
  • docs/infinity/release-completion.md +74 −0
  • docs/infinity/status.md +34 −1
  • docs/infinity/tester-release.md +56 −0
  • internal/infinity/app.go +3 −0
  • internal/infinity/blobs.go +139 −0
  • internal/infinity/blobs_test.go +72 −0
  • internal/infinity/inbox.go +11 −0
  • internal/infinity/migrations/0005_attachments.sql +3 −0
  • internal/infinity/outbox.go +39 −6
  • internal/infinity/static/attachments.js +32 −0
  • internal/infinity/static/chat.js +8 −3
  • internal/infinity/static/client.js +1 −0
  • internal/infinity/static/link-cards.js +18 −0
  • internal/infinity/static/outgoing.js +10 −0
  • internal/infinity/static/protocol.js +3 −2
  • internal/infinity/static/style.css +3 −0
  • internal/infinityhome/fcm.go +144 −0
  • internal/infinityhome/fcm_test.go +88 −0
  • internal/infinityhome/push.go +11 −6
  • internal/infinityhome/relay.go +10 −3
  • internal/infinityhome/relay_test.go +7 −2
  • mobile/attachments.go +113 −0
  • mobile/attachments_test.go +103 −0
  • mobile/consent.go +1 −0
  • mobile/outgoing.go +9 −1
  • mobile/projection.go +14 −12
  • test/infinity/attachments.test.mjs +18 −0
  • test/infinity/browser.mjs +10 −1
  • tools/infinity/archive-apple +15 −0
  • tools/infinity/check +1 −1
diff --git a/README.md b/README.md
index 5f0ecbb..8671cf5 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, 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).
+Text DMs, consent-gated groups, encrypted history, delivery retries, passkey-only signup/sign-in, short single-use invitations, encrypted profiles, files up to 8 MiB and safe destination link cards are implemented. Apple activity notifications and an Android FCM integration are present; Android requires separate Firebase configuration. 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
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b113f64..bd675f1 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -2,12 +2,20 @@ 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 = 3; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
+ defaultConfig { applicationId = "chat.ocho.app"; minSdk = 26; targetSdk = 35; versionCode = 4; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
+ defaultConfig {
+ for (key in listOf("OCHO_FIREBASE_APP_ID", "OCHO_FIREBASE_API_KEY", "OCHO_FIREBASE_PROJECT_ID", "OCHO_FIREBASE_SENDER_ID")) {
+ val value = providers.gradleProperty(key).orElse(providers.environmentVariable(key)).getOrElse("")
+ require(value.matches(Regex("[A-Za-z0-9_:.-]*"))) { "Invalid Firebase client configuration" }
+ buildConfigField("String", key, "\"$value\"")
+ }
+ }
buildFeatures { compose = true; buildConfig = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
kotlinOptions { jvmTarget = "17" }
}
dependencies {
+ implementation("com.google.firebase:firebase-messaging:25.1.2")
implementation(files("../../.build/mobile/ocho-engine.aar"))
implementation(platform("androidx.compose:compose-bom:2025.04.01"))
implementation("androidx.activity:activity-compose:1.10.1")
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8e67bb5..cf98480 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,7 +1,12 @@
<!-- πŸ€– Platform-native Ocho; private identity backup is explicit through passkeys/recovery. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application android:label="Ocho" android:icon="@drawable/ocho_icon" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" android:theme="@style/OchoTheme">
+ <meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
+ <meta-data android:name="firebase_analytics_collection_enabled" android:value="false" />
+ <meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="ocho_activity" />
+ <service android:name=".OchoMessagingService" android:exported="false"><intent-filter><action android:name="com.google.firebase.MESSAGING_EVENT" /></intent-filter></service>
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
<intent-filter><action android:name="android.intent.action.VIEW"/><category android:name="android.intent.category.DEFAULT"/><category android:name="android.intent.category.BROWSABLE"/><data android:scheme="ocho"/></intent-filter>
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 e0f24e9..fb159a4 100644
--- a/android/app/src/main/java/chat/ocho/app/AccountModel.kt
+++ b/android/app/src/main/java/chat/ocho/app/AccountModel.kt
@@ -13,17 +13,18 @@ import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.json.JSONObject
-data class AccountUI(val identity: JSONObject? = null, val threads: List<JSONObject> = emptyList(), val requests: List<JSONObject> = emptyList(), val busy: Boolean = false, val stage: String? = null, val error: String? = null)
+data class AccountUI(val identity: JSONObject? = null, val threads: List<JSONObject> = emptyList(), val requests: List<JSONObject> = emptyList(), val busy: Boolean = false, val stage: String? = null, val error: String? = null, val notificationStatus: String? = null)
fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) }
class AccountModel(application: Application): AndroidViewModel(application) {
companion object { const val HOME = "https://ocho.chat" }
val ui = MutableStateFlow(AccountUI())
+ private val notifications = ActivityNotifications(application)
private val secure = SecureStore(application)
private val mutex = Mutex()
private var client: Client? = null
init {
viewModelScope.launch { run {
- secure.read("owner")?.let { identity -> Ocho.validateIdentity(identity); client = Ocho.newClient(identity); ui.value = ui.value.copy(identity = JSONObject(identity)); refresh() }
+ secure.read("owner")?.let { identity -> Ocho.validateIdentity(identity); client = Ocho.newClient(identity); ui.value = ui.value.copy(identity = JSONObject(identity)); refresh(); notifications.resume() }
} }
viewModelScope.launch { while (isActive) { delay(3000); if (client != null && !ui.value.busy) run(clearError = false) { refresh() } } }
}
@@ -68,10 +69,26 @@ class AccountModel(application: Application): AndroidViewModel(application) {
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 sendAttachment(thread: String, name: String, type: String, bytes: ByteArray) = run {
+ check(secure.read("pending-attachment") == null) { "Retry the pending attachment before sending another." }
+ val prepared = client!!.prepareAttachment(thread, name, type, bytes, Ocho.random(), System.currentTimeMillis())
+ secure.write("pending-attachment", prepared)
+ client!!.sendPreparedMessage(prepared); secure.remove("pending-attachment"); refresh()
+ }
+ suspend fun retryAttachment() = run {
+ val prepared = secure.read("pending-attachment") ?: return@run
+ client!!.sendPreparedMessage(prepared); secure.remove("pending-attachment"); refresh()
+ }
+ suspend fun downloadAttachment(thread: String, message: String, ready: (ByteArray) -> Unit) = run {
+ val bytes = client!!.downloadAttachment(thread, message)
+ withContext(Dispatchers.Main) { ready(bytes) }
+ }
suspend fun send(thread: String, text: String) = run { client!!.message(thread, text); refresh() }
suspend fun group(name: String, contacts: Set<String>) = run { client!!.group(name, JSONArray(contacts.toList()).toString()); refresh() }
suspend fun remove(thread: String, person: String) = run { client!!.removeMember(thread, person); refresh() }
suspend fun recovery(password: String, ready: (String) -> Unit) = run { val file = Ocho.exportRecovery(client!!.identityJSON(), password); withContext(Dispatchers.Main) { ready(file) } }
+ suspend fun enableNotifications() = run { notifications.enable(); ui.value = ui.value.copy(notificationStatus = "Notifications are on. Message contents stay private.") }
+ suspend fun disableNotifications() = run { notifications.disable(); ui.value = ui.value.copy(notificationStatus = "Notifications are off on this device.") }
fun dismissError() { ui.value = ui.value.copy(error = null) }
fun error(message: String) { ui.value = ui.value.copy(error = message) }
}
diff --git a/android/app/src/main/java/chat/ocho/app/ActivityNotifications.kt b/android/app/src/main/java/chat/ocho/app/ActivityNotifications.kt
new file mode 100644
index 0000000..761d361
--- /dev/null
+++ b/android/app/src/main/java/chat/ocho/app/ActivityNotifications.kt
@@ -0,0 +1,61 @@
+// πŸ€– Opt-in FCM registration. Google receives only a fixed activity alert and device token.
+package chat.ocho.app
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import chat.ocho.engine.ocho.Ocho
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+class ActivityNotifications(private val context: Context) {
+ private val secure = SecureStore(context)
+ private val preferences = context.getSharedPreferences("ocho.notifications", Context.MODE_PRIVATE)
+ private fun messaging(): FirebaseMessaging {
+ require(BuildConfig.OCHO_FIREBASE_APP_ID.isNotEmpty() && BuildConfig.OCHO_FIREBASE_API_KEY.isNotEmpty() && BuildConfig.OCHO_FIREBASE_PROJECT_ID.isNotEmpty() && BuildConfig.OCHO_FIREBASE_SENDER_ID.isNotEmpty()) { "Android notifications are not configured in this build yet." }
+ if (FirebaseApp.getApps(context).isEmpty()) FirebaseApp.initializeApp(context, FirebaseOptions.Builder()
+ .setApplicationId(BuildConfig.OCHO_FIREBASE_APP_ID).setApiKey(BuildConfig.OCHO_FIREBASE_API_KEY)
+ .setProjectId(BuildConfig.OCHO_FIREBASE_PROJECT_ID).setGcmSenderId(BuildConfig.OCHO_FIREBASE_SENDER_ID).build())
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(NotificationChannel("ocho_activity", "Ocho activity", NotificationManager.IMPORTANCE_DEFAULT))
+ return FirebaseMessaging.getInstance()
+ }
+ private fun post(id: JSONObject, origin: String, path: String, body: JSONObject) {
+ Ocho.requestJSON(origin, path, id.getString("auth"), "POST", body.toString())
+ }
+ suspend fun enable() {
+ val manager = messaging()
+ val token = suspendCancellableCoroutine<String> { continuation -> manager.token.addOnCompleteListener {
+ if (continuation.isActive) { if (it.isSuccessful) continuation.resume(it.result) else continuation.resumeWithException(it.exception ?: IllegalStateException("Could not register notifications.")) }
+ } }
+ withContext(Dispatchers.IO) { register(token); check(preferences.edit().putBoolean("enabled", true).commit()) }
+ manager.isAutoInitEnabled = true
+ }
+ fun register(token: String) {
+ val id = JSONObject(secure.read("owner") ?: error("Open your account first."))
+ val key = "push-route:" + id.getString("origin")
+ val secret = secure.read(key) ?: Ocho.random().also { secure.write(key, it) }
+ post(id, AccountModel.HOME, "/api/push/register", JSONObject().put("origin", id.getString("origin")).put("secret", secret).put("token", token).put("topic", "chat.ocho.app.android").put("sandbox", false).put("enabled", true))
+ post(id, id.getString("origin"), "/api/push", JSONObject().put("secret", secret).put("enabled", true))
+ }
+ suspend fun resume() { if (preferences.getBoolean("enabled", false)) enable() }
+ suspend fun disable() {
+ withContext(Dispatchers.IO) {
+ val id = JSONObject(secure.read("owner") ?: error("Open your account first."))
+ val secret = secure.read("push-route:" + id.getString("origin"))
+ if (secret != null) {
+ post(id, id.getString("origin"), "/api/push", JSONObject().put("secret", secret).put("enabled", false))
+ post(id, AccountModel.HOME, "/api/push/register", JSONObject().put("origin", id.getString("origin")).put("secret", secret).put("enabled", false))
+ }
+ check(preferences.edit().putBoolean("enabled", false).commit())
+ }
+ if (FirebaseApp.getApps(context).isNotEmpty()) { FirebaseMessaging.getInstance().isAutoInitEnabled = false; FirebaseMessaging.getInstance().deleteToken() }
+ context.getSystemService(NotificationManager::class.java).cancel(1)
+ }
+}
diff --git a/android/app/src/main/java/chat/ocho/app/OchoMessagingService.kt b/android/app/src/main/java/chat/ocho/app/OchoMessagingService.kt
new file mode 100644
index 0000000..bdb851d
--- /dev/null
+++ b/android/app/src/main/java/chat/ocho/app/OchoMessagingService.kt
@@ -0,0 +1,27 @@
+// πŸ€– Fixed foreground notification text; no provider-supplied content is rendered.
+package chat.ocho.app
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+
+class OchoMessagingService: FirebaseMessagingService() {
+ override fun onNewToken(token: String) {
+ if (!getSharedPreferences("ocho.notifications", MODE_PRIVATE).getBoolean("enabled", false)) return
+ // FCM invokes this on its background worker. A failed registration is retried on launch.
+ try { go.Seq.setContext(applicationContext); ActivityNotifications(this).register(token) } catch (_: Exception) { }
+ }
+ override fun onMessageReceived(message: RemoteMessage) {
+ if (!getSharedPreferences("ocho.notifications", MODE_PRIVATE).getBoolean("enabled", false)) return
+ val manager = getSystemService(NotificationManager::class.java)
+ if (!manager.areNotificationsEnabled()) return
+ val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ val notification = Notification.Builder(this, "ocho_activity").setSmallIcon(android.R.drawable.stat_notify_chat)
+ .setContentTitle("Ocho").setContentText("New activity in Ocho. Open the app to catch up.")
+ .setContentIntent(open).setAutoCancel(true).build()
+ manager.notify(1, notification)
+ }
+}
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 f5239d4..6b8ba33 100644
--- a/android/app/src/main/java/chat/ocho/app/OchoScreen.kt
+++ b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt
@@ -1,6 +1,12 @@
// πŸ€– Flat native chats, consent requests and a platform-native composer.
package chat.ocho.app
import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.ui.platform.LocalContext
+import android.provider.OpenableColumns
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -28,6 +34,35 @@ import java.util.Date
var menu by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var draft by remember(selected) { mutableStateOf("") }
+ val context = LocalContext.current
+ var download by remember { mutableStateOf<ByteArray?>(null) }
+ val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) scope.launch { model.enableNotifications() } else model.error("Allow Ocho notifications in system Settings to receive alerts.")
+ }
+ val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
+ val bytes = download; download = null
+ if (uri != null && bytes != null) scope.launch {
+ try { withContext(Dispatchers.IO) { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("Could not save attachment.") } }
+ catch (e: Exception) { model.error(e.message ?: "Could not save attachment.") }
+ }
+ }
+ val chooseFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ val threadID = selected
+ if (uri != null && threadID != null) scope.launch {
+ try {
+ val content = withContext(Dispatchers.IO) {
+ val resolver = context.contentResolver
+ val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { if (it.moveToFirst()) it.getString(0) else null } ?: "Attachment"
+ val bytes = resolver.openInputStream(uri)?.use { input ->
+ val out = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192)
+ while (out.size() <= 8 * 1024 * 1024) { val count = input.read(buffer, 0, minOf(buffer.size, 8 * 1024 * 1024 + 1 - out.size())); if (count < 0) break; out.write(buffer, 0, count) }; out.toByteArray() } ?: error("Could not read attachment.")
+ require(bytes.size <= 8 * 1024 * 1024) { "Choose a file up to 8 MB." }
+ Triple(name, resolver.getType(uri) ?: "application/octet-stream", bytes)
+ }
+ model.sendAttachment(threadID, content.first, content.second, content.third)
+ } catch (e: Exception) { model.error(e.message ?: "Could not send attachment.") }
+ }
+ }
val busy = ui.busy || passkeyBusy
val thread = ui.threads.firstOrNull { it.getString("id") == selected }
BackHandler(selected != null) { selected = null }
@@ -38,12 +73,19 @@ import java.util.Date
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) } })
+ DropdownMenuItem(text = { Text("Enable notifications") }, enabled = !busy, onClick = {
+ menu = false
+ if (android.os.Build.VERSION.SDK_INT >= 33) notificationPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ else scope.launch { model.enableNotifications() }
+ })
+ DropdownMenuItem(text = { Text("Disable notifications") }, enabled = !busy, onClick = { menu = false; scope.launch { model.disableNotifications() } })
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") } }
+ if (ui.notificationStatus != null) Text(ui.notificationStatus, Modifier.padding(12.dp))
+ if (ui.error != null) Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.secondaryContainer).padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(ui.error, Modifier.weight(1f)); TextButton(enabled = !busy, onClick = { scope.launch { model.retryAttachment() } }) { Text("Retry attachment") }; TextButton(onClick = model::dismissError) { Text("Dismiss") } }
when {
ui.identity == null -> WelcomeScreen(ui, busy, incoming, { acceptingAfterAuth = true; onPasskey(false) }, { name -> acceptingAfterAuth = true; onCreate(name) })
thread == null -> {
@@ -72,12 +114,29 @@ import java.util.Date
val mine = message.getBoolean("mine")
Column(Modifier.fillMaxWidth(), horizontalAlignment = if (mine) Alignment.End else Alignment.Start) {
if (thread.getBoolean("group") && !mine) Text(message.getString("name"), style = MaterialTheme.typography.labelSmall)
- SelectionContainer { Text(message.getString("text"), Modifier.widthIn(max = 320.dp).background(if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(18.dp)).padding(12.dp)) }
+ val file = message.optJSONObject("attachment")
+ if (file != null) TextButton(enabled = !busy, onClick = { scope.launch {
+ model.downloadAttachment(thread.getString("id"), message.getString("id")) { bytes -> download = bytes; saveFile.launch(file.getString("name")) }
+ } }) { Text("Save " + file.getString("name")) }
+ else SelectionContainer { Text(message.getString("text"), Modifier.widthIn(max = 320.dp).background(if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(18.dp)).padding(12.dp)) }
+ android.util.Patterns.WEB_URL.matcher(message.getString("text")).let { matcher ->
+ val links = mutableListOf<String>()
+ while (matcher.find() && links.size < 3) {
+ val raw = matcher.group() ?: continue
+ val uri = android.net.Uri.parse(raw)
+ if (uri.scheme in listOf("http", "https") && uri.userInfo == null && raw.length <= 2048 && raw !in links) links.add(raw)
+ }
+ links.forEach { raw -> TextButton(onClick = {
+ try { context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(raw))) }
+ catch (e: Exception) { model.error("No app can open this link.") }
+ }) { Text(android.net.Uri.parse(raw).host ?: raw) } }
+ }
Text(DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.getLong("at"))) + if (mine) " Β· " + message.optString("delivery") else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (thread.getBoolean("left")) Text("You’re no longer in this group.", Modifier.padding(16.dp)) else Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.Bottom) {
+ TextButton(enabled = !busy && !thread.getBoolean("pending"), onClick = { chooseFile.launch(arrayOf("*/*")) }) { Text("Attach") }
OutlinedTextField(value = draft, onValueChange = { if (it.length <= 8000) draft = it }, label = { Text("Message") }, modifier = Modifier.weight(1f), maxLines = 6)
TextButton(enabled = !busy && !thread.getBoolean("pending") && draft.isNotBlank(), onClick = { val text = draft; scope.launch { if (model.send(thread.getString("id"), text) && draft == text) draft = "" } }) { Text("Send") }
}
diff --git a/apple/Infinity/AttachmentDocument.swift b/apple/Infinity/AttachmentDocument.swift
new file mode 100644
index 0000000..219edea
--- /dev/null
+++ b/apple/Infinity/AttachmentDocument.swift
@@ -0,0 +1,10 @@
+// πŸ€– Decrypted bytes leave the app only through the user's explicit Save action.
+import SwiftUI
+import UniformTypeIdentifiers
+struct AttachmentDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.data] }
+ 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) }
+}
diff --git a/apple/Infinity/NativeConversationView.swift b/apple/Infinity/NativeConversationView.swift
index dbae25d..628a785 100644
--- a/apple/Infinity/NativeConversationView.swift
+++ b/apple/Infinity/NativeConversationView.swift
@@ -1,10 +1,16 @@
// πŸ€– Native feed and composer, following Eleven's split-view/chat-screen shape.
import SwiftUI
+import UniformTypeIdentifiers
import OchoCore
struct NativeConversationView: View {
@EnvironmentObject var account: AccountStore
let thread: OchoCore.Thread
@State private var draft = ""
+ @State private var attachmentPicker = false
+ @State private var attachmentBusy = false
+ @State private var exportFile = false
+ @State private var fileDocument = AttachmentDocument(data: Data())
+ @State private var fileName = "Attachment"
@State private var members = false
@State private var composerHeight: CGFloat = 22
@FocusState private var composerFocused: Bool
@@ -24,9 +30,29 @@ struct NativeConversationView: View {
if message.mine { Spacer(minLength: 48) }
VStack(alignment: message.mine ? .trailing : .leading, spacing: 4) {
if thread.group && !message.mine { Text(message.name).font(.caption.weight(.semibold)).foregroundStyle(.secondary) }
+ if let file = message.attachment {
+ Button("Save " + file.name, systemImage: "arrow.down.doc") {
+ Task {
+ do {
+ guard let client = account.client else { return }
+ fileDocument = AttachmentDocument(data: try await client.downloadAttachment(thread: thread.id, message: message.id))
+ fileName = file.name; exportFile = true
+ } catch { account.error = error.localizedDescription }
+ }
+ }.buttonStyle(.bordered)
+ } else {
Text(message.text).textSelection(.enabled).padding(.horizontal, 14).padding(.vertical, 10)
.foregroundStyle(message.mine ? Color.white : Color.primary)
.background(message.mine ? Color.accentColor : Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 18))
+ }
+ ForEach(message.links, id: \.absoluteString) { url in
+ Link(destination: url) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(url.host ?? url.absoluteString).font(.subheadline.weight(.semibold))
+ Text(url.path.isEmpty ? "Open link" : url.path).font(.caption).lineLimit(2)
+ }.padding(10).background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
+ }
+ }
HStack(spacing: 5) { Text(message.date, style: .time); if let delivery = message.delivery { Text(delivery) }; if message.retry == true { Button { account.retryMessage(message.id) } label: { Text("Retry").underline() }.buttonStyle(.plain).accessibilityLabel("Retry sending message") } }.font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: 560, alignment: message.mine ? .trailing : .leading)
if !message.mine { Spacer(minLength: 48) }
@@ -41,6 +67,9 @@ struct NativeConversationView: View {
if thread.left { Text("You’re no longer in this group.").foregroundStyle(.secondary).padding() }
else {
HStack(alignment: .center, spacing: 12) {
+ Button("Attach a file", systemImage: "plus") { attachmentPicker = true }
+ .labelStyle(.iconOnly).disabled(thread.pending || attachmentBusy)
+ if attachmentBusy { ProgressView().controlSize(.small).accessibilityLabel("Sending attachment") }
#if os(macOS)
MacMessageField(text: $draft, height: $composerHeight, onSend: send)
.frame(height: composerHeight)
@@ -57,6 +86,30 @@ struct NativeConversationView: View {
}.padding()
}
}
+ .fileImporter(isPresented: $attachmentPicker, allowedContentTypes: [.item]) { result in
+ do {
+ let url = try result.get()
+ attachmentBusy = true
+ Task {
+ defer { attachmentBusy = false }
+ do {
+ let content = try await Task.detached { () throws -> Data in
+ let access = url.startAccessingSecurityScopedResource()
+ defer { if access { url.stopAccessingSecurityScopedResource() } }
+ let handle = try FileHandle(forReadingFrom: url); defer { try? handle.close() }
+ let data = try handle.read(upToCount: 8 * 1024 * 1024 + 1) ?? Data()
+ guard data.count <= 8 * 1024 * 1024 else { throw OchoError.invalid("Choose a file up to 8 MB.") }
+ return data
+ }.value
+ try await account.queueAttachment(data: content, name: url.lastPathComponent, type: UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream", thread: thread.id)
+ } catch { account.error = error.localizedDescription }
+ }
+ } catch { account.error = error.localizedDescription }
+ }
+ .fileExporter(isPresented: $exportFile, document: fileDocument, contentType: .data, defaultFilename: fileName) { result in
+ if case .failure(let error) = result { account.error = error.localizedDescription }
+ fileDocument = AttachmentDocument(data: Data())
+ }
.navigationTitle(thread.name)
.toolbar { ToolbarItem { Button { members = true } label: { Image(systemName: "info.circle") }.accessibilityLabel("Conversation details") } }
.sheet(isPresented: $members) {
diff --git a/apple/Infinity/OutgoingMessages.swift b/apple/Infinity/OutgoingMessages.swift
index b957b26..ef0132e 100644
--- a/apple/Infinity/OutgoingMessages.swift
+++ b/apple/Infinity/OutgoingMessages.swift
@@ -70,6 +70,14 @@ extension AccountStore {
}
outgoing[id]?.status = "sending"; scheduleSend(id)
}
+ func queueAttachment(data: Data, name: String, type: String, thread: String) async throws {
+ guard let client else { throw OchoError.invalid("Open your account first.") }
+ let id = Crypto.random(), at = Int64(Date().timeIntervalSince1970 * 1000)
+ let prepared = try await client.prepareAttachment(thread: thread, name: name, type: type, data: data, id: id, at: at)
+ outgoing[id] = PendingSend(id: id, thread: thread, text: "Attachment: " + name, at: at, prepared: prepared, status: "sending")
+ do { try persistOutgoing() } catch { outgoing.removeValue(forKey: id); throw error }
+ scheduleSend(id)
+ }
private func scheduleSend(_ id: String) {
let previous = sendingTask
sendingTask = Task { await previous?.value; await transmitMessage(id) }
diff --git a/apple/OchoCore/Sources/OchoCore/Attachment.swift b/apple/OchoCore/Sources/OchoCore/Attachment.swift
new file mode 100644
index 0000000..73cda1c
--- /dev/null
+++ b/apple/OchoCore/Sources/OchoCore/Attachment.swift
@@ -0,0 +1,6 @@
+// πŸ€– This descriptor is decoded only after authenticating and decrypting a message.
+import Foundation
+public struct Attachment: Codable, Sendable {
+ public var id: String, key: String, nonce: String, name: String, type: String
+ public var size: Int
+}
diff --git a/apple/OchoCore/Sources/OchoCore/Client.swift b/apple/OchoCore/Sources/OchoCore/Client.swift
index 55b05fa..f0a613c 100644
--- a/apple/OchoCore/Sources/OchoCore/Client.swift
+++ b/apple/OchoCore/Sources/OchoCore/Client.swift
@@ -23,6 +23,10 @@ public actor OchoClient {
public func reject(_ request: Event) async throws { try core.reject(request.thread) }
public func message(thread: String, text: String) async throws { try core.message(thread, text: text) }
public func prepareMessage(thread: String, text: String, id: String, at: Int64) async throws -> String { try engineString { core.prepareMessage(thread, text: text, id_: id, at: at, error: $0) } }
+ public func prepareAttachment(thread: String, name: String, type: String, data: Data, id: String, at: Int64) async throws -> String { try engineString { core.prepareAttachment(thread, name: name, mediaType: type, data: data, id_: id, at: at, error: $0) } }
+ public func downloadAttachment(thread: String, message: String) async throws -> Data {
+ try core.downloadAttachment(thread, messageID: message)
+ }
public func retryDelivery(_ id: String) async throws { try core.retryDelivery(id) }
public func sendPreparedMessage(_ value: String) async throws { try core.sendPreparedMessage(value) }
public func group(name: String, contacts: Set<String>) async throws -> String { let ids = try json(Array(contacts)); return try engineString { core.group(name, contactIDsJSON: ids, error: $0) } }
diff --git a/apple/OchoCore/Sources/OchoCore/MessageLinks.swift b/apple/OchoCore/Sources/OchoCore/MessageLinks.swift
new file mode 100644
index 0000000..cadfc7c
--- /dev/null
+++ b/apple/OchoCore/Sources/OchoCore/MessageLinks.swift
@@ -0,0 +1,12 @@
+// πŸ€– Safe destination cards never fetch remote content while reading a conversation.
+import Foundation
+extension Message {
+ public var links: [URL] {
+ guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return [] }
+ var seen = Set<String>()
+ return detector.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
+ guard let url = match.url, let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), url.user == nil, url.password == nil, url.absoluteString.count <= 2048, seen.insert(url.absoluteString).inserted else { return nil }
+ return url
+ }.prefix(3).map { $0 }
+ }
+}
diff --git a/apple/OchoCore/Sources/OchoCore/Models.swift b/apple/OchoCore/Sources/OchoCore/Models.swift
index 289639b..cab509b 100644
--- a/apple/OchoCore/Sources/OchoCore/Models.swift
+++ b/apple/OchoCore/Sources/OchoCore/Models.swift
@@ -17,6 +17,7 @@ public struct Event: Codable, Sendable {
}
}
public struct Message: Identifiable, Codable, Sendable {
+ public var attachment: Attachment?
public var id: String, text: String, at: Int64, mine: Bool, name: String, signer: String
public var delivery: String?
public var retry: Bool?
diff --git a/apple/project.yml b/apple/project.yml
index cb9dbcf..1276973 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: '10'
+ CURRENT_PROJECT_VERSION: '11'
GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_STYLE: Automatic
diff --git a/cmd/infinity-home/main.go b/cmd/infinity-home/main.go
index 52cedaf..ada802e 100644
--- a/cmd/infinity-home/main.go
+++ b/cmd/infinity-home/main.go
@@ -41,6 +41,13 @@ func run() error {
push[topic] = sender
}
}
+ if raw := os.Getenv("INFINITY_FCM_SERVICE_ACCOUNT"); raw != "" {
+ sender, err := infinityhome.NewFCMSender(raw)
+ if err != nil {
+ return err
+ }
+ push["chat.ocho.app.android"] = sender
+ }
h, e := infinityhome.New(d, infinityhome.Config{Push: push, AndroidCertSHA256: os.Getenv("INFINITY_ANDROID_CERT_SHA256"), Origin: os.Getenv("INFINITY_ORIGIN"), Console: "https://console.carloku.com", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: os.Getenv("INFINITY_CHANNEL"), Token: os.Getenv("INFINITY_PROVISION_TOKEN"), ClaimKey: key})
if e != nil {
return e
diff --git a/docs/infinity/push.md b/docs/infinity/push.md
index b77e3be..e1eaf94 100644
--- a/docs/infinity/push.md
+++ b/docs/infinity/push.md
@@ -13,3 +13,35 @@ The notification contains only fixed Ocho branding/activity text and sound. No s
Validate owner isolation, topic/token input, duplicate suppression, retry/generation safety, route revocation and generic APNs payloads with stub servers. Build/sign both native apps with push entitlements and verify actual provisioning. Deploy home before personal and verify response bodies. Real device notification permission/token registration and APNs delivery require a device account and user permission; do not fabricate this evidence or send messages to real contacts for testing.
Implementation checkpoint: personal/home/APNs tests pass under the race detector; Chromium/WebKit chat regressions and the actual SwiftUI chat UI test pass. Both Team Tito builds sign with development APNs entitlements. The key is sealed only on infinity-home; sandbox/production provider probes for both bundle IDs return the expected invalid-token response. No real device delivery has been claimed. Deployment and a user-enabled device token remain to be verified.
+
+## Android extension β€” September 13 (Codex)
+
+The home relay also accepts `chat.ocho.app.android` for FCM HTTP v1. It preserves
+case-sensitive FCM tokens, authenticates the personal owner exactly as for Apple,
+uses the existing durable wake queue, and revokes only provider-confirmed
+`UNREGISTERED` destinations. Authentication, quota and transient provider errors
+remain retryable. OAuth tokens are scoped to Firebase Messaging; the private
+service-account key is read only by the home process from the sealed
+`INFINITY_FCM_SERVICE_ACCOUNT` configuration. No Google endpoint is derived from
+untrusted request input. The fixed activity payload contains no sender, message,
+account, thread or encrypted content. Google sees app installation/token and
+notification timing metadata. No Analytics SDK is included.
+
+Android opts in through system permission and the More menu; registration updates
+on token rotation and enabled-app launch. Auto-initialization and analytics
+collection default off. Builds take public Firebase client configuration via
+Gradle properties or environment: `OCHO_FIREBASE_APP_ID`, `OCHO_FIREBASE_API_KEY`,
+`OCHO_FIREBASE_PROJECT_ID`, `OCHO_FIREBASE_SENDER_ID`. Empty configuration builds
+successfully but cannot enable Android push. Never use a service-account key as
+an Android build property. Live FCM configuration and device delivery are separate
+release gates, not implied by passing stub-provider tests.
+
+Provider references: [Android setup](https://firebase.google.com/docs/cloud-messaging/android/get-started),
+[HTTP v1 authorization](https://firebase.google.com/docs/cloud-messaging/send/v1-api),
+[error codes](https://firebase.google.com/docs/cloud-messaging/error-codes).
+
+FCM currently uses the supported registration-token API (`getToken`/`onNewToken`),
+which Firebase 25.1.2 marks deprecated in favor of opt-in installation-ID
+registration. The relay targets registration tokens; do not turn on the
+`firebase_messaging_installation_id_enabled` flag without migrating the provider
+target contract and testing the complete new registration/delivery path.
diff --git a/docs/infinity/release-completion.md b/docs/infinity/release-completion.md
new file mode 100644
index 0000000..d242b2d
--- /dev/null
+++ b/docs/infinity/release-completion.md
@@ -0,0 +1,74 @@
+# πŸ€– Ocho release completion β€” 2026-09-13
+
+Paul requested completion of the outstanding beta work: physical-device checks,
+tester distribution, Android notifications, encrypted attachments and link previews.
+Voice/video remains behind the existing chat-validation milestone. This is a
+working plan by Codex; a checked item must have recorded evidence.
+
+## Release order
+
+1. Re-run the existing server, browser and native checks; resolve regressions.
+2. Validate physical passkey signup/sign-in, invitation acceptance and notification
+ delivery with disposable review accounts and user-present system prompts.
+3. Package signed Apple and Android tester builds, validate their associations,
+ and prepare distribution metadata. Record uploads separately from processing
+ and actual installation. Do not send invitations to contacts without instruction.
+4. Add encrypted attachments across web and the shared native engine, preserving
+ exact retries, membership checks and encrypted storage. Use bounded ciphertext
+ blobs separate from the event log, so synchronization never downloads file bodies.
+5. Add link cards without sending conversation URLs to an Ocho server. Received
+ links must never trigger a network fetch automatically. Any metadata acquisition
+ must be explicit, bounded and client-side; plain safe links remain usable.
+6. Extend the content-free, opt-in notification relay to Android FCM, with
+ provider credentials only on the home service and release configuration supplied
+ separately from source. Verify real-device receipt before claiming delivery.
+
+## Evidence and outstanding inputs
+
+- September 13: live `https://ocho.chat/api/version` returned `64d67031`.
+- Carlos CLI authentication succeeds with access to account `ocho`.
+- The shell defaults to Go 1.23.3 with `GOTOOLCHAIN=local`; use the installed
+ Go 1.26.0 toolchain for checks rather than changing the machine's global Go setup.
+- Apple's device list finds Paul's paired iPhone. An available pairing is not
+ evidence of an unlocked, reachable device or a successful passkey ceremony.
+- Physical passkey verification needs Paul to complete the system prompts.
+- Android Firebase project/client configuration and distribution signing must be
+ identified before enabling FCM or claiming a store-ready build.
+- Apple app-record creation requires App Store Connect website access; a local
+ signing key alone is not evidence of an existing app record or upload access.
+
+## Acceptance
+
+Keep existing identities, passkeys and histories intact. Tests must include failed
+delivery/retry, recipient isolation, removal from groups, malformed inputs and
+inspection of SQLite/WAL for plaintext. Device, hosting and store gates remain
+open until individually verified; simulator success never closes a physical gate.
+
+## Attachment protocol
+
+Each file is at most 8 MiB. Clients encrypt raw bytes with a fresh random 256-bit
+AES-GCM key, a 12-byte nonce and `ocho-file-v1` associated data. SHA-256 of the
+ciphertext is its storage ID. An authenticated encrypted message carries the
+ID, key, nonce, original name, MIME type and plaintext size. Old clients display
+`Attachment: <name>` as fallback text. New clients fetch bytes only on Save,
+verify the digest, authenticate decryption and check the size before exporting.
+Active file formats never render in the web origin.
+
+Owner-only JSON blob APIs upload/download ciphertext. The existing owner outbox
+accepts one opaque blob ID per recipient; its retry copies ciphertext with the
+signed envelope to a recipient's capability-authorized inbox. Blob storage and
+inbox insertion commit together. File bodies never appear in `/api/events`.
+The home relay allows only these bounded paths and keeps the tenant owner check.
+A 512 MiB per-instance file quota bounds storage; there is no automatic deletion
+of delivered attachments. Retention/cleanup is a follow-up before expanding this
+quota. Orphaned uploads count against it. Backups must include the additive blob
+table. Servers additionally observe ciphertext file IDs, sizes and which
+recipient copies share a file; names, MIME types and usable keys stay encrypted.
+
+## Link cards
+
+This release shows up to three HTTP(S) destination cards per message. Host/path
+come from the message's authenticated text, with credentials and unsafe schemes
+rejected. Cards open only on an explicit click/tap. No remote thumbnail, favicon,
+OpenGraph fetch or third-party request happens merely because a message arrived.
+Rich title/image previews are not implemented by these destination cards.
diff --git a/docs/infinity/status.md b/docs/infinity/status.md
index 9c4675b..cb874e9 100644
--- a/docs/infinity/status.md
+++ b/docs/infinity/status.md
@@ -1,4 +1,37 @@
-# πŸ€– Ocho status β€” 2026-09-08
+# πŸ€– Ocho status β€” 2026-09-13
+
+## September 13 release work (Codex)
+
+Encrypted attachments up to 8 MiB now use owner-authenticated blob APIs and durable
+cross-instance copying. File names, MIME types and keys stay in the encrypted
+message; file bodies stay out of event synchronization. Web/Apple/Android provide
+explicit file selection and Save controls. Destination link cards perform no
+remote fetch on receipt; rich fetched title/image previews remain unfinished.
+The web per-message Retry relay route is fixed and covered by boundary tests.
+
+The full server/browser suite passes, including Chromium/WebKit encrypted-file
+roundtrips, exact downloaded bytes, no automatic link requests and SQLite/WAL
+plaintext absence. Shared native race tests pass, including attachment integrity
+and wrong-key checks. Seven Swift package tests and Go/Swift/Web Crypto checks
+pass. Both Apple build 11 targets compile and sign under Team Tito; build 11 was
+installed over build 7 on Paul's iPhone. Physical passkey and notification checks
+remain pending the user's on-device prompts.
+
+FCM HTTP v1 integration is implemented with fixed generic payloads, scoped OAuth,
+case-preserving tokens, retries and provider-confirmed revocation. The Android
+client has opt-in permission/registration/disable controls. No Firebase project
+configuration or real FCM delivery has yet been verified. The Team Tito 1Password
+lookup timed out waiting for user authorization; no secret values were printed.
+
+The Mac UI suite did not execute its assertions: macOS timed out enabling UI
+automation. This is a verification gap, not a passing native UI test. Android debug build/lint pass. Compose and iPhone simulator runs are tracked separately.
+The signed iOS Release archive is also built; App Store Connect export is being validated. Signed development builds are not TestFlight uploads or store releases. The
+release checklist and remaining distribution inputs are in `tester-release.md`;
+protocol/storage limits and exact pending work are in `release-completion.md`.
+
+Hosting remains on the previous September 8 versions until the new deployment is
+explicitly verified. September 13's live front-door version check returned
+`64d67031`. No merge into main has been performed.
## Native connection status β€” September 8
diff --git a/docs/infinity/tester-release.md b/docs/infinity/tester-release.md
new file mode 100644
index 0000000..b024bb3
--- /dev/null
+++ b/docs/infinity/tester-release.md
@@ -0,0 +1,56 @@
+# πŸ€– Ocho tester release β€” prepared September 13, 2026
+
+## Build notes
+
+Ocho 0.2.0: Apple build 11, Android build 4. End-to-end encrypted text and files
+up to 8 MiB, short private invitations, passkey signup/sign-in and destination
+link cards. Apple notifications use a generic activity alert; Android includes
+the same opt-in flow but requires configured Firebase credentials before use.
+No phone number is needed. Voice/video and rich fetched link previews are absent.
+
+## Device walkthrough
+
+Use review accounts and an invitation exchanged between your own devices.
+Never sign out of the only usable copy of an existing account to run a test.
+
+1. Install the signed update over the existing Ocho app; confirm existing chats
+ still open. On a separate signed-out device or browser profile, create a
+ review account with the system passkey prompt. Record device/OS/provider.
+2. Sign in to that review account on the other platform with its passkey and
+ verify identical name and history. An unsupported PRF provider must fail
+ clearly before a new personal instance is allocated.
+3. Create a short invitation, open its preview, cancel, reopen and accept on a
+ different review account. Confirm the conversation opens while the inviter
+ is closed. Reaccept on the same recipient; then verify another recipient
+ cannot reuse it.
+4. Exchange text and a small file; save the file and compare its bytes. Send
+ while disconnected, restore connectivity and retry. Confirm one message,
+ preserved newer drafts, and no lost history after relaunch.
+5. Enable notifications through Ocho and the system prompt. Background the
+ recipient; send from your other review account. Confirm a generic activity
+ alert without sender or message text. Tap it and verify the message appears.
+ Disable notifications and confirm another event produces no alert.
+6. Repeat on Android with the actual Firebase-configured build, including
+ process restart and token re-registration. Emulator tests cannot close this gate.
+
+## Distribution prerequisites still to verify
+
+- App Store Connect app record under Team Tito for `chat.ocho.app` and the Mac
+ platform/bundle configuration; appropriate upload access and tester group.
+- Store privacy/support URLs, contact details, content rating and encryption
+ declaration reviewed against the actual product. Do not guess an encryption
+ exemption: the app implements E2EE in a shared Go engine.
+- Release archive/export and upload validation; processing status and actual
+ TestFlight installation recorded separately. No tester invitations are sent
+ without an explicit recipient instruction.
+- Android release keystore/Play signing fingerprint and Digital Asset Links;
+ Firebase Android client configuration for `chat.ocho.app` and a least-privilege
+ service account on the home service. Debug APKs are local review artifacts.
+- Public store launch also needs a reviewed account/data deletion flow and
+ finished store metadata. A signed development build is not an App Store release.
+
+Apple requires creating new app records on the
+[App Store Connect website](https://developer.apple.com/help/app-store-connect/create-an-app-record/add-a-new-app);
+the [Apps API](https://developer.apple.com/documentation/appstoreconnectapi/apps)
+does not create them. These notes are prepared release material, not evidence of
+an upload, external review approval, or completed physical-device verification.
diff --git a/internal/infinity/app.go b/internal/infinity/app.go
index 09eb02b..1f4cc54 100644
--- a/internal/infinity/app.go
+++ b/internal/infinity/app.go
@@ -65,6 +65,9 @@ func (a *App) Handler() *http.ServeMux {
})
m.HandleFunc("POST /api/setup", a.setup)
m.HandleFunc("POST /api/inbox", a.receive)
+ m.HandleFunc("POST /api/inbox-files", a.receiveFiles)
+ m.Handle("POST /api/blobs", a.owner(a.putBlob))
+ m.Handle("GET /api/blobs/{id}", a.owner(a.getBlob))
m.Handle("GET /api/events", a.owner(a.events))
m.Handle("GET /api/vault", a.owner(a.getVault))
m.Handle("PUT /api/vault", a.owner(a.putVault))
diff --git a/internal/infinity/blobs.go b/internal/infinity/blobs.go
new file mode 100644
index 0000000..dd8ce61
--- /dev/null
+++ b/internal/infinity/blobs.go
@@ -0,0 +1,139 @@
+// πŸ€– Bounded encrypted files are copied with delivery, never fetched from arbitrary URLs.
+package infinity
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+)
+
+const maxFileBox = (8 << 20) + 16
+const maxFileRequest = 12 << 20
+const maxFileStorage = 512 << 20
+
+type fileBlob struct {
+ ID string `json:"id"`
+ Box string `json:"box"`
+}
+type fileDelivery struct {
+ Envelope json.RawMessage `json:"envelope"`
+ Blobs []fileBlob `json:"blobs"`
+}
+
+func blobBytes(blob fileBlob) ([]byte, error) {
+ if !validSecret(blob.ID) || len(blob.Box) > base64.RawURLEncoding.EncodedLen(maxFileBox) {
+ return nil, errors.New("invalid attachment")
+ }
+ b, e := base64.RawURLEncoding.DecodeString(blob.Box)
+ if e != nil || len(b) < 16 || len(b) > maxFileBox {
+ return nil, errors.New("invalid attachment")
+ }
+ sum := sha256.Sum256(b)
+ if base64.RawURLEncoding.EncodeToString(sum[:]) != blob.ID {
+ return nil, errors.New("attachment digest mismatch")
+ }
+ return b, nil
+}
+func decodeFile(w http.ResponseWriter, r *http.Request, out any) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, maxFileRequest)
+ d := json.NewDecoder(r.Body)
+ d.DisallowUnknownFields()
+ if d.Decode(out) != nil || d.Decode(new(any)) != io.EOF {
+ fail(w, 400, "Invalid attachment request.")
+ return false
+ }
+ return true
+}
+func storeBlob(ctx context.Context, tx *sql.Tx, blob fileBlob) error {
+ b, e := blobBytes(blob)
+ if e != nil {
+ return e
+ }
+ var exists int
+ if e = tx.QueryRowContext(ctx, "SELECT count(*) FROM file_blobs WHERE id=?", blob.ID).Scan(&exists); e != nil {
+ return e
+ }
+ if exists != 0 {
+ return nil
+ }
+ var used int64
+ if e = tx.QueryRowContext(ctx, "SELECT coalesce(sum(length(box)),0) FROM file_blobs").Scan(&used); e != nil {
+ return e
+ }
+ if used+int64(len(b)) > maxFileStorage {
+ return errors.New("attachment storage full")
+ }
+ _, e = tx.ExecContext(ctx, "INSERT INTO file_blobs(id,box,created) VALUES(?,?,?)", blob.ID, b, now())
+ return e
+}
+func (a *App) readBlob(ctx context.Context, id string) (fileBlob, error) {
+ if !validSecret(id) {
+ return fileBlob{}, errors.New("invalid attachment")
+ }
+ var b []byte
+ e := a.db.QueryRowContext(ctx, "SELECT box FROM file_blobs WHERE id=?", id).Scan(&b)
+ return fileBlob{ID: id, Box: base64.RawURLEncoding.EncodeToString(b)}, e
+}
+func (a *App) putBlob(w http.ResponseWriter, r *http.Request) {
+ var blob fileBlob
+ if !decodeFile(w, r, &blob) {
+ return
+ }
+ if _, e := blobBytes(blob); e != nil {
+ fail(w, 400, "Invalid encrypted attachment.")
+ return
+ }
+ tx, e := a.db.BeginTx(r.Context(), nil)
+ if e != nil {
+ dbError(w, e)
+ return
+ }
+ defer tx.Rollback()
+ if e = storeBlob(r.Context(), tx, blob); e != nil {
+ fail(w, 413, "Attachment storage is full.")
+ return
+ }
+ if e = tx.Commit(); e != nil {
+ dbError(w, e)
+ return
+ }
+ reply(w, 200, map[string]string{"id": blob.ID})
+}
+func (a *App) getBlob(w http.ResponseWriter, r *http.Request) {
+ blob, e := a.readBlob(r.Context(), r.PathValue("id"))
+ if e != nil {
+ fail(w, 404, "Attachment unavailable.")
+ return
+ }
+ reply(w, 200, blob)
+}
+func (a *App) receiveFiles(w http.ResponseWriter, r *http.Request) {
+ if !validSecret(bearer(r)) {
+ fail(w, 403, "Invitation unavailable.")
+ return
+ }
+ var delivery fileDelivery
+ if !decodeFile(w, r, &delivery) {
+ return
+ }
+ var env Envelope
+ if json.Unmarshal(delivery.Envelope, &env) != nil || env.validate() != nil || len(delivery.Blobs) != 1 {
+ fail(w, 400, "Invalid encrypted delivery.")
+ return
+ }
+ if _, e := blobBytes(delivery.Blobs[0]); e != nil {
+ fail(w, 400, "Invalid encrypted attachment.")
+ return
+ }
+ var to string
+ if a.db.QueryRowContext(r.Context(), "SELECT enc FROM identity WHERE singleton=1").Scan(&to) != nil || env.To != to {
+ fail(w, 403, "Wrong recipient.")
+ return
+ }
+ a.receiveEnvelope(w, r, env, delivery.Blobs)
+}
diff --git a/internal/infinity/blobs_test.go b/internal/infinity/blobs_test.go
new file mode 100644
index 0000000..f53aa89
--- /dev/null
+++ b/internal/infinity/blobs_test.go
@@ -0,0 +1,72 @@
+// πŸ€– Files must remain opaque, owner-scoped, bounded and durable with inbox delivery.
+package infinity
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestEncryptedAttachmentDelivery(t *testing.T) {
+ a, b, other := newFixture(t), newFixture(t), newFixture(t)
+ bytes := make([]byte, 1024)
+ rand.Read(bytes)
+ sum := sha256.Sum256(bytes)
+ blob := fileBlob{ID: base64.RawURLEncoding.EncodeToString(sum[:]), Box: base64.RawURLEncoding.EncodeToString(bytes)}
+ a.call(t, "POST", "/api/blobs", "", blob, 401)
+ a.call(t, "POST", "/api/blobs", a.auth, fileBlob{ID: secret(), Box: blob.Box}, 400)
+ a.call(t, "POST", "/api/blobs", a.auth, blob, 200)
+ a.call(t, "POST", "/api/blobs", a.auth, blob, 200)
+ a.call(t, "GET", "/api/blobs/"+blob.ID, a.cap, nil, 401)
+ other.call(t, "GET", "/api/blobs/"+blob.ID, other.auth, nil, 404)
+ env := signedEnvelope(t, a, b)
+ item := deliveryItem{Destination: b.s.URL, Capability: b.cap, Envelope: env, BlobIDs: []string{blob.ID}}
+ a.call(t, "POST", "/api/send", a.auth, map[string]any{"items": []deliveryItem{item}}, 202)
+ var received fileBlob
+ json.Unmarshal(b.call(t, "GET", "/api/blobs/"+blob.ID, b.auth, nil, 200), &received)
+ if received != blob {
+ t.Fatal("ciphertext changed during delivery")
+ }
+ events := b.call(t, "GET", "/api/events", b.auth, nil, 200)
+ if strings.Contains(string(events), blob.Box) {
+ t.Fatal("file body entered event synchronization")
+ }
+ a.call(t, "POST", "/api/send", a.auth, map[string]any{"items": []deliveryItem{item}}, 202)
+ var count int
+ b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&count)
+ if count != 1 {
+ t.Fatal("retry duplicated the message")
+ }
+ b.a.db.QueryRow("SELECT count(*) FROM file_blobs").Scan(&count)
+ if count != 1 {
+ t.Fatal("retry duplicated file storage")
+ }
+ item.BlobIDs = []string{secret()}
+ a.call(t, "POST", "/api/send", a.auth, map[string]any{"items": []deliveryItem{item}}, 400)
+}
+
+func TestAttachmentInboxFailureIsAtomic(t *testing.T) {
+ a, b := newFixture(t), newFixture(t)
+ bytes := make([]byte, 32)
+ rand.Read(bytes)
+ sum := sha256.Sum256(bytes)
+ blob := fileBlob{ID: base64.RawURLEncoding.EncodeToString(sum[:]), Box: base64.RawURLEncoding.EncodeToString(bytes)}
+ env := signedEnvelope(t, a, b)
+ raw, _ := json.Marshal(env)
+ body := fileDelivery{Envelope: raw, Blobs: []fileBlob{blob}}
+ b.call(t, "POST", "/api/inbox-files", secret(), body, 403)
+ var count int
+ b.a.db.QueryRow("SELECT count(*) FROM file_blobs").Scan(&count)
+ if count != 0 {
+ t.Fatal("unauthorized file persisted")
+ }
+ body.Blobs[0].Box = secret()
+ b.call(t, "POST", "/api/inbox-files", b.cap, body, 400)
+ b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&count)
+ if count != 0 {
+ t.Fatal("invalid file consumed inbox delivery")
+ }
+}
diff --git a/internal/infinity/inbox.go b/internal/infinity/inbox.go
index 659f2a7..327e471 100644
--- a/internal/infinity/inbox.go
+++ b/internal/infinity/inbox.go
@@ -29,6 +29,11 @@ func (a *App) receive(w http.ResponseWriter, r *http.Request) {
fail(w, 403, "Wrong recipient.")
return
}
+ a.receiveEnvelope(w, r, env, nil)
+}
+
+func (a *App) receiveEnvelope(w http.ResponseWriter, r *http.Request, env Envelope, blobs []fileBlob) {
+ token := bearer(r)
tx, e := a.db.BeginTx(r.Context(), nil)
if e != nil {
dbError(w, e)
@@ -84,6 +89,12 @@ func (a *App) receive(w http.ResponseWriter, r *http.Request) {
dbError(w, e)
return
}
+ for _, blob := range blobs {
+ if e = storeBlob(r.Context(), tx, blob); e != nil {
+ fail(w, 413, "Attachment storage is full or invalid.")
+ return
+ }
+ }
if _, e = tx.ExecContext(r.Context(), "INSERT INTO inbox(id,envelope,created) VALUES(?,?,?)", env.ID, string(raw), now()); e != nil {
dbError(w, e)
return
diff --git a/internal/infinity/migrations/0005_attachments.sql b/internal/infinity/migrations/0005_attachments.sql
new file mode 100644
index 0000000..dd4ccbf
--- /dev/null
+++ b/internal/infinity/migrations/0005_attachments.sql
@@ -0,0 +1,3 @@
+-- πŸ€– Opaque file bytes stay out of event synchronization; no names or usable keys.
+CREATE TABLE file_blobs (id TEXT PRIMARY KEY, box BLOB NOT NULL, created INTEGER NOT NULL);
+ALTER TABLE outbox ADD COLUMN blob_ids TEXT NOT NULL DEFAULT '[]';
diff --git a/internal/infinity/outbox.go b/internal/infinity/outbox.go
index bf73778..1cab98e 100644
--- a/internal/infinity/outbox.go
+++ b/internal/infinity/outbox.go
@@ -18,6 +18,7 @@ type deliveryItem struct {
Capability string `json:"capability"`
Envelope Envelope `json:"envelope"`
InviteClaim string `json:"claim,omitempty"`
+ BlobIDs []string `json:"blobs,omitempty"`
}
func (a *App) send(w http.ResponseWriter, r *http.Request) {
@@ -42,6 +43,16 @@ func (a *App) send(w http.ResponseWriter, r *http.Request) {
fail(w, 400, "Invalid delivery.")
return
}
+ if len(item.BlobIDs) > 1 {
+ fail(w, 400, "Send one attachment at a time.")
+ return
+ }
+ for _, id := range item.BlobIDs {
+ if _, e := a.readBlob(r.Context(), id); e != nil {
+ fail(w, 400, "Upload the attachment first.")
+ return
+ }
+ }
p.Items[i].Destination = dest
}
tx, e := a.db.BeginTx(r.Context(), nil)
@@ -52,7 +63,8 @@ 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,invite_claim) VALUES(?,?,?,?,?)", item.Envelope.ID, item.Destination, item.Capability, string(b), item.InviteClaim); e != nil {
+ blobs, _ := json.Marshal(item.BlobIDs)
+ if _, e = tx.ExecContext(r.Context(), "INSERT OR IGNORE INTO outbox(id,destination,capability,envelope,invite_claim,blob_ids) VALUES(?,?,?,?,?,?)", item.Envelope.ID, item.Destination, item.Capability, string(b), item.InviteClaim, string(blobs)); e != nil {
dbError(w, e)
return
}
@@ -68,18 +80,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,invite_claim 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,blob_ids 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, claim string
- attempts int
+ id, dest, cap, raw, claim, blobs string
+ attempts int
}
jobs := []job{}
for rows.Next() {
var j job
- if rows.Scan(&j.id, &j.dest, &j.cap, &j.raw, &j.attempts, &j.claim) != nil {
+ if rows.Scan(&j.id, &j.dest, &j.cap, &j.raw, &j.attempts, &j.claim, &j.blobs) != nil {
rows.Close()
return
}
@@ -90,7 +102,28 @@ func (a *App) flush(ctx context.Context) {
if ctx.Err() != nil {
return
}
- req, e := http.NewRequestWithContext(ctx, "POST", j.dest+"/api/inbox", bytes.NewBufferString(j.raw))
+ path, body := "/api/inbox", j.raw
+ var ids []string
+ if json.Unmarshal([]byte(j.blobs), &ids) != nil {
+ continue
+ }
+ if len(ids) > 0 {
+ var blobs []fileBlob
+ for _, id := range ids {
+ if blob, e := a.readBlob(ctx, id); e == nil {
+ blobs = append(blobs, blob)
+ }
+ }
+ if len(blobs) != len(ids) {
+ continue
+ }
+ raw, e := json.Marshal(fileDelivery{Envelope: json.RawMessage(j.raw), Blobs: blobs})
+ if e != nil {
+ continue
+ }
+ body, path = string(raw), "/api/inbox-files"
+ }
+ req, e := http.NewRequestWithContext(ctx, "POST", j.dest+path, bytes.NewBufferString(body))
if e != nil {
continue
}
diff --git a/internal/infinity/static/attachments.js b/internal/infinity/static/attachments.js
new file mode 100644
index 0000000..c47c678
--- /dev/null
+++ b/internal/infinity/static/attachments.js
@@ -0,0 +1,32 @@
+// πŸ€– File bodies are encrypted locally; downloads use only the owner's instance.
+import { b64, unb64, random } from './crypto.js';
+export const maxAttachment = 8 * 1024 * 1024;
+const aad = new TextEncoder().encode('ocho-file-v1');
+export function validAttachment(a) {
+ if (a === undefined || a === null) return true;
+ return typeof a === 'object' && /^[A-Za-z0-9_-]{43}$/.test(a.id) && /^[A-Za-z0-9_-]{43}$/.test(a.key) && /^[A-Za-z0-9_-]{16}$/.test(a.nonce) && Number.isSafeInteger(a.size) && a.size >= 0 && a.size <= maxAttachment && typeof a.name === 'string' && a.name.length > 0 && a.name.length <= 255 && !/[/\\\x00\r\n]/.test(a.name) && typeof a.type === 'string' && a.type.length <= 100;
+}
+export async function encryptAttachment(file) {
+ if (file.size > maxAttachment) throw Error('Choose a file up to 8 MB.');
+ const key = random(), nonce = crypto.getRandomValues(new Uint8Array(12));
+ const secret = await crypto.subtle.importKey('raw', unb64(key), 'AES-GCM', false, ['encrypt']);
+ const box = await crypto.subtle.encrypt({name:'AES-GCM',iv:nonce,additionalData:aad},secret,await file.arrayBuffer());
+ const id = b64(await crypto.subtle.digest('SHA-256',box));
+ const attachment = {id,key,nonce:b64(nonce),name:file.name,type:file.type || 'application/octet-stream',size:file.size};
+ if (!validAttachment(attachment)) throw Error('Choose a file with a valid name.');
+ return {attachment,blob:{id,box:b64(box)}};
+}
+export async function downloadAttachment(client, attachment) {
+ if (!attachment || !validAttachment(attachment)) throw Error('Invalid attachment.');
+ const value = await client.api('/api/blobs/'+attachment.id);
+ if (typeof value.box !== 'string' || value.box.length > (maxAttachment+16)*4/3+4) throw Error('Invalid attachment size.');
+ const box = unb64(value.box);
+ if (value.id !== attachment.id || b64(await crypto.subtle.digest('SHA-256',box)) !== attachment.id) throw Error('Attachment integrity check failed.');
+ const key = await crypto.subtle.importKey('raw',unb64(attachment.key),'AES-GCM',false,['decrypt']);
+ const bytes = await crypto.subtle.decrypt({name:'AES-GCM',iv:unb64(attachment.nonce),additionalData:aad},key,box);
+ if (bytes.byteLength !== attachment.size) throw Error('Attachment size did not match.');
+ // Always download: HTML, SVG and other active formats never execute in the chat origin.
+ const url = URL.createObjectURL(new Blob([bytes],{type:'application/octet-stream'}));
+ const link = document.createElement('a');link.href=url;link.download=attachment.name;link.click();
+ setTimeout(()=>URL.revokeObjectURL(url),60000);
+}
diff --git a/internal/infinity/static/chat.js b/internal/infinity/static/chat.js
index d26b74c..4747efc 100644
--- a/internal/infinity/static/chat.js
+++ b/internal/infinity/static/chat.js
@@ -1,3 +1,5 @@
+import { linkCards } from './link-cards.js';
+import { downloadAttachment } from './attachments.js';
// πŸ€– Flat conversations, with no team or community navigation.
import { el, button, mark, avatar, message, busy } from './dom.js';
import { share, connectDialog, newGroup, details } from './dialogs.js';
@@ -65,14 +67,17 @@ export class Chat {
for (const m of t.messages) {
const day = new Date(m.at).toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
if (day !== date) { log.append(el('div', { class: 'date-label' }, day)); date = day; }
- log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), el('p', {}, m.text), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), m.delivery ? ' Β· ' + m.delivery : '', m.retry && button('Retry',()=>this.client.retryMessage(m.id),{class:'retry-send','aria-label':'Retry sending message'}))));
+ log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), m.attachment ? button('↓ '+m.attachment.name,async()=>{try{await downloadAttachment(this.client,m.attachment);}catch(e){this.error=e.message;this.render();}},{class:'attachment-download'}) : el('div', {}, el('p', {}, m.text),linkCards(m.text)), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), m.delivery ? ' Β· ' + m.delivery : '', m.retry && button('Retry',()=>this.client.retryMessage(m.id),{class:'retry-send','aria-label':'Retry sending message'}))));
}
const note = el('div', { class: 'composer-note', role: 'status' });
const input = el('textarea', { id: 'composer', rows: 1, maxlength: 8000, placeholder: 'Message', 'aria-label': 'Message', disabled: t.pending || t.left }); input.value = this.drafts.get(t.id) || '';
const submit = el('button', { class: 'send primary', type: 'submit', 'aria-label': 'Send message', disabled: t.pending || t.left }, '↑');
const update=()=>{this.drafts.set(t.id,input.value);submit.hidden=!input.value.trim();input.style.height='auto';input.style.height=Math.min(140,input.scrollHeight)+'px';};
input.addEventListener('input',update);submit.hidden=!input.value.trim();
- const form = el('form', { class: 'composer' }, el('div',{class:'composer-input'},input,submit));
+ const picker=el('input',{type:'file',hidden:true,'aria-label':'Choose attachment'});
+ const attach=button('+',()=>picker.click(),{class:'quiet','aria-label':'Attach a file',disabled:t.pending||t.left});
+ picker.addEventListener('change',async()=>{const file=picker.files?.[0];if(!file)return;attach.disabled=true;note.textContent='Sending attachment…';try{await this.client.attachment(t.id,file);note.textContent='';}catch(e){note.textContent=e.message;}finally{attach.disabled=t.pending||t.left;picker.value='';}});
+ const form = el('form', { class: 'composer' },picker,attach, el('div',{class:'composer-input'},input,submit));
form.addEventListener('submit', event => {
event.preventDefault();if(!input.value.trim()||t.pending||t.left)return;
const text=input.value;this.drafts.delete(t.id);
@@ -87,7 +92,7 @@ export class Chat {
import('./dom.js').then(({ modal }) => modal('Your privacy', el('div', {},
el('p', {}, 'Messages are encrypted on your device. Each person keeps their own encrypted copy.'),
el('p', {}, 'Servers can see where messages go, when they arrive and their size. An unlocked device can read your conversations.'),
- el('p', {}, 'This is an early version. Encryption has not been independently audited and does not yet provide forward secrecy. Keep your devices and recovery file safe.'),
+ el('p', {}, 'This is an early version. Encryption has not been independently audited and does not yet provide forward secrecy. Keep your devices and passkeys safe.'),
el('p', { class: 'footnote' }, 'πŸ€– Built with assistance from Codex. Based on Eleven Messenger and Carlos.'))));
}
}
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js
index 9b1b54a..e48fe82 100644
--- a/internal/infinity/static/client.js
+++ b/internal/infinity/static/client.js
@@ -132,6 +132,7 @@ export class Client {
await this.refresh();
}
async reject(req) { await this.load(); this.state.blocked[req.thread] = true; await this.save(); this.rebuild(); }
+ attachment(thread,file) { return this.outgoing.attachment(thread,file); }
message(thread, text) { return this.outgoing.message(thread,text); }
async retryMessage(id) {
if(this.outgoing.rows.get(id)?.status==='failed')return this.outgoing.retry(id);
diff --git a/internal/infinity/static/link-cards.js b/internal/infinity/static/link-cards.js
new file mode 100644
index 0000000..d63d464
--- /dev/null
+++ b/internal/infinity/static/link-cards.js
@@ -0,0 +1,18 @@
+// πŸ€– Destination previews make no requests: reading a message never contacts its links.
+import { el } from './dom.js';
+export function linksIn(text) {
+ const result=[];
+ for(const match of text.matchAll(/https?:\/\/[^\s<>"'\x00-\x1f]+/gi)) {
+ try {
+ const raw=match[0].replace(/[.,!?;:)\]}]+$/,'');
+ const url=new URL(raw);
+ if(url.username || url.password || !['http:','https:'].includes(url.protocol) || raw.length>2048 || result.some(x=>x.url===url.href))continue;
+ result.push({url:url.href,host:url.host,path:url.pathname+url.search});
+ if(result.length===3)break;
+ } catch { /* Malformed text is still displayed as ordinary text. */ }
+ }
+ return result;
+}
+export function linkCards(text) {
+ return el('div',{class:'link-cards'},...linksIn(text).map(link=>el('a',{class:'link-card',href:link.url,target:'_blank',rel:'noopener noreferrer',referrerpolicy:'no-referrer'},el('strong',{},link.host),el('span',{},link.path==='/'?'Open link':link.path))));
+}
diff --git a/internal/infinity/static/outgoing.js b/internal/infinity/static/outgoing.js
index abcbb38..1e023b3 100644
--- a/internal/infinity/static/outgoing.js
+++ b/internal/infinity/static/outgoing.js
@@ -1,4 +1,5 @@
// πŸ€– Local pending rows are encrypted at rest; retries keep their original envelope IDs.
+import { encryptAttachment } from './attachments.js';
import { random, lock, unlock } from './crypto.js';
export class Outgoing {
constructor(client) {
@@ -28,6 +29,14 @@ export class Outgoing {
const row={payload:{kind:'message',thread,id:random(),text,at:Date.now(),epoch:t.epoch||0},status:'sending'};
this.rows.set(row.payload.id,row);this.changed();return this.enqueue(row);
}
+ async attachment(thread,file) {
+ const t=this.client.view.threads.find(t=>t.id===thread);
+ if(!t||t.pending||t.left)throw Error('This conversation is not ready to send.');
+ const {attachment,blob}=await encryptAttachment(file);
+ await this.client.api('/api/blobs',{method:'POST',body:blob});
+ const row={payload:{kind:'message',thread,id:random(),text:'Attachment: '+attachment.name,attachment,at:Date.now(),epoch:t.epoch||0},status:'sending'};
+ this.rows.set(row.payload.id,row);this.changed();return this.enqueue(row);
+ }
retry(id) {
const row=this.rows.get(id);if(!row||row.status==='sending')return Promise.resolve();
row.status='sending';this.changed();return this.enqueue(row);
@@ -42,6 +51,7 @@ export class Outgoing {
const t=this.client.view.threads.find(t=>t.id===row.payload.thread);
if(!t||t.pending||t.left||(t.epoch||0)!==row.payload.epoch)throw Error('The conversation membership changed.');
row.items ||= await this.client.envelopes(t.members,row.payload);
+ if(row.payload.attachment)for(const item of row.items)item.blobs=[row.payload.attachment.id];
await this.persist();
await this.client.api('/api/send',{method:'POST',body:{items:row.items}});
row.status='accepted';await this.persist().catch(()=>{});this.changed();
diff --git a/internal/infinity/static/protocol.js b/internal/infinity/static/protocol.js
index 53a4af3..6f6955a 100644
--- a/internal/infinity/static/protocol.js
+++ b/internal/infinity/static/protocol.js
@@ -1,4 +1,5 @@
// πŸ€– Projection authenticates relationships independently of the delivery server.
+import { validAttachment } from './attachments.js';
import { validAvatar } from './avatars.js';
function validPerson(p) {
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;
@@ -51,13 +52,13 @@ export function project(identity, state, events) {
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;
+ if (!validAttachment(e.attachment) || 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;
const t = threads.get(e.thread); if (!t || t.pending) continue;
const members = t.group ? snapshots.get(e.thread + ':' + e.epoch) : t.members;
if (!members?.some(m => m.signing === e.signer) || !members.some(m => m.signing === self.signing)) continue;
if (!t.group && e.epoch !== 0) continue;
const key = e.thread + ':' + e.id; if (seen.has(key)) continue; seen.add(key);
- t.messages.push({ id: e.id, text: e.text, at: e.at, mine: e.signer === self.signing, name: members.find(m => m.signing === e.signer).name, signer: e.signer });
+ t.messages.push({ attachment:e.attachment, id: e.id, text: e.text, at: e.at, mine: e.signer === self.signing, name: members.find(m => m.signing === e.signer).name, signer: e.signer });
}
const all = [...threads.values()].filter(t => !t.pending || !t.group);
for (const t of all) t.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
diff --git a/internal/infinity/static/style.css b/internal/infinity/static/style.css
index bddaa3d..786f34b 100644
--- a/internal/infinity/static/style.css
+++ b/internal/infinity/static/style.css
@@ -25,3 +25,6 @@
.composer-input textarea{color:inherit}
.composer-input textarea::placeholder{color:#706c7f}
+
+/* πŸ€– Safe destination cards: no remote thumbnails or automatic URL requests. */
+.link-cards{display:grid;gap:6px}.link-card{display:flex;flex-direction:column;gap:3px;padding:10px 12px;border:1px solid currentColor;border-radius:10px;color:inherit;text-decoration:none;max-width:100%;overflow-wrap:anywhere}.link-card span{font-size:.85em;opacity:.8}.attachment-download{color:inherit;background:transparent;border:1px solid currentColor;border-radius:10px;padding:10px;overflow-wrap:anywhere}.composer>.quiet{align-self:center}
diff --git a/internal/infinityhome/fcm.go b/internal/infinityhome/fcm.go
new file mode 100644
index 0000000..e585230
--- /dev/null
+++ b/internal/infinityhome/fcm.go
@@ -0,0 +1,144 @@
+// πŸ€– FCM HTTP v1 sends fixed activity notices; no account or conversation data goes to Google.
+package infinityhome
+
+import (
+ "bytes"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "io"
+ "lchat/internal/apns"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+)
+
+const androidPushTopic = "chat.ocho.app.android"
+
+var fcmToken = regexp.MustCompile(`^[A-Za-z0-9_:.-]+$`)
+
+func validFCMToken(token string) bool {
+ return len(token) >= 20 && len(token) <= 4096 && fcmToken.MatchString(token)
+}
+
+type FCMSender struct {
+ mu sync.Mutex
+ key *rsa.PrivateKey
+ email, project, token string
+ expires time.Time
+ client *http.Client
+}
+
+func NewFCMSender(raw string) (*FCMSender, error) {
+ var account struct {
+ Type string `json:"type"`
+ Email string `json:"client_email"`
+ Project string `json:"project_id"`
+ Key string `json:"private_key"`
+ }
+ if json.Unmarshal([]byte(raw), &account) != nil || account.Type != "service_account" || !regexp.MustCompile(`^[a-z][a-z0-9-]{4,61}[a-z0-9]$`).MatchString(account.Project) || !strings.HasSuffix(account.Email, ".iam.gserviceaccount.com") {
+ return nil, errors.New("Invalid FCM service account configuration.")
+ }
+ block, _ := pem.Decode([]byte(account.Key))
+ if block == nil {
+ return nil, errors.New("Invalid FCM signing key.")
+ }
+ parsed, e := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if e != nil {
+ return nil, errors.New("Invalid FCM signing key.")
+ }
+ key, ok := parsed.(*rsa.PrivateKey)
+ if !ok || key.N.BitLen() < 2048 {
+ return nil, errors.New("Invalid FCM signing key.")
+ }
+ return &FCMSender{key: key, email: account.Email, project: account.Project, client: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
+}
+func (s *FCMSender) accessToken() (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.token != "" && time.Until(s.expires) > time.Minute {
+ return s.token, nil
+ }
+ now := time.Now().Unix()
+ claims, _ := json.Marshal(map[string]any{"iss": s.email, "scope": "https://www.googleapis.com/auth/firebase.messaging", "aud": "https://oauth2.googleapis.com/token", "iat": now, "exp": now + 3600})
+ unsigned := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + base64.RawURLEncoding.EncodeToString(claims)
+ hash := sha256.Sum256([]byte(unsigned))
+ signature, e := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, hash[:])
+ if e != nil {
+ return "", e
+ }
+ form := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, "assertion": {unsigned + "." + base64.RawURLEncoding.EncodeToString(signature)}}
+ r, e := s.client.PostForm("https://oauth2.googleapis.com/token", form)
+ if e != nil {
+ return "", errors.New("FCM authorization unavailable.")
+ }
+ defer r.Body.Close()
+ var token struct {
+ Access string `json:"access_token"`
+ Expires int `json:"expires_in"`
+ }
+ if r.StatusCode != 200 || json.NewDecoder(io.LimitReader(r.Body, 16384)).Decode(&token) != nil || token.Access == "" || token.Expires < 60 || token.Expires > 3600 {
+ return "", errors.New("FCM authorization rejected.")
+ }
+ s.token = token.Access
+ s.expires = time.Now().Add(time.Duration(token.Expires) * time.Second)
+ return s.token, nil
+}
+func (s *FCMSender) SendNotice(token string, sandbox bool, title, body string) error {
+ if sandbox || !validFCMToken(token) {
+ return errors.New("Invalid Android destination.")
+ }
+ auth, e := s.accessToken()
+ if e != nil {
+ return e
+ }
+ // Ignore caller text by construction; never allow the provider boundary to widen.
+ payload := map[string]any{"message": map[string]any{"token": token, "notification": map[string]string{"title": "Ocho", "body": "New activity in Ocho. Open the app to catch up."}, "android": map[string]any{"priority": "HIGH", "ttl": "86400s", "collapse_key": "ocho-activity", "restricted_package_name": "chat.ocho.app", "notification": map[string]string{"channel_id": "ocho_activity", "tag": "ocho-activity"}}}}
+ raw, _ := json.Marshal(payload)
+ req, _ := http.NewRequest("POST", "https://fcm.googleapis.com/v1/projects/"+s.project+"/messages:send", bytes.NewReader(raw))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+auth)
+ res, e := s.client.Do(req)
+ if e != nil {
+ return errors.New("FCM delivery unavailable.")
+ }
+ defer res.Body.Close()
+ data, e := io.ReadAll(io.LimitReader(res.Body, 16385))
+ if e != nil || len(data) > 16384 {
+ return errors.New("Invalid FCM response.")
+ }
+ if res.StatusCode == 200 {
+ return nil
+ }
+ var failure struct {
+ Error struct {
+ Details []struct {
+ Type string `json:"@type"`
+ Code string `json:"errorCode"`
+ } `json:"details"`
+ } `json:"error"`
+ }
+ _ = json.Unmarshal(data, &failure)
+ for _, detail := range failure.Error.Details {
+ if detail.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmError" && detail.Code == "UNREGISTERED" {
+ return apns.ErrGone
+ }
+ }
+ if res.StatusCode == 401 {
+ s.mu.Lock()
+ if s.token == auth {
+ s.token = ""
+ }
+ s.mu.Unlock()
+ }
+ return errors.New("FCM delivery rejected; queued for retry.")
+}
diff --git a/internal/infinityhome/fcm_test.go b/internal/infinityhome/fcm_test.go
new file mode 100644
index 0000000..a38ff74
--- /dev/null
+++ b/internal/infinityhome/fcm_test.go
@@ -0,0 +1,88 @@
+// πŸ€– Provider contract: scoped OAuth, fixed payloads, token case and retryable errors.
+package infinityhome
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "io"
+ "lchat/internal/apns"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestFCMPrivateNoticeContract(t *testing.T) {
+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
+ der, _ := x509.MarshalPKCS8PrivateKey(key)
+ config, _ := json.Marshal(map[string]string{"type": "service_account", "project_id": "ocho-review", "client_email": "sender@ocho-review.iam.gserviceaccount.com", "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))})
+ s, e := NewFCMSender(string(config))
+ if e != nil {
+ t.Fatal(e)
+ }
+ authCalls, sends, status := 0, 0, 200
+ s.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ response := `{"name":"accepted"}`
+ if r.URL.Host == "oauth2.googleapis.com" {
+ authCalls++
+ r.ParseForm()
+ parts := strings.Split(r.Form.Get("assertion"), ".")
+ if len(parts) != 3 {
+ t.Fatal("invalid JWT")
+ }
+ hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
+ signature, _ := base64.RawURLEncoding.DecodeString(parts[2])
+ if rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], signature) != nil {
+ t.Fatal("invalid OAuth signature")
+ }
+ claims, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ if !strings.Contains(string(claims), "https://www.googleapis.com/auth/firebase.messaging") {
+ t.Fatal("wrong scope")
+ }
+ response = `{"access_token":"test-access","expires_in":3600}`
+ } else {
+ sends++
+ if r.URL.String() != "https://fcm.googleapis.com/v1/projects/ocho-review/messages:send" || r.Header.Get("Authorization") != "Bearer test-access" {
+ t.Fatal("wrong endpoint/auth")
+ }
+ var data struct {
+ Message map[string]json.RawMessage `json:"message"`
+ }
+ json.NewDecoder(r.Body).Decode(&data)
+ if len(data.Message) != 3 || string(data.Message["token"]) != `"AaBbCcDdEeFfGgHhIiJjKk:MixedCase"` {
+ t.Fatal("token mutated or metadata added")
+ }
+ var notice map[string]string
+ json.Unmarshal(data.Message["notification"], &notice)
+ if len(notice) != 2 || notice["title"] != "Ocho" || notice["body"] != "New activity in Ocho. Open the app to catch up." {
+ t.Fatal("private input crossed provider boundary")
+ }
+ if status == 404 {
+ response = `{"error":{"details":[{"@type":"type.googleapis.com/google.firebase.fcm.v1.FcmError","errorCode":"UNREGISTERED"}]}}`
+ }
+ }
+ return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil
+ })
+ for i := 0; i < 2; i++ {
+ if e = s.SendNotice("AaBbCcDdEeFfGgHhIiJjKk:MixedCase", false, "PRIVATE NAME", "PRIVATE MESSAGE"); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if authCalls != 1 || sends != 2 {
+ t.Fatal("token cache failed")
+ }
+ status = 503
+ if e = s.SendNotice("AaBbCcDdEeFfGgHhIiJjKk:MixedCase", false, "", ""); e == nil || errors.Is(e, apns.ErrGone) {
+ t.Fatal("transient error pruned token")
+ }
+ status = 404
+ if e = s.SendNotice("AaBbCcDdEeFfGgHhIiJjKk:MixedCase", false, "", ""); !errors.Is(e, apns.ErrGone) {
+ t.Fatal("unregistered token not pruned")
+ }
+}
diff --git a/internal/infinityhome/push.go b/internal/infinityhome/push.go
index 6ca142e..750e731 100644
--- a/internal/infinityhome/push.go
+++ b/internal/infinityhome/push.go
@@ -35,7 +35,7 @@ func (h *Home) registerPush(w http.ResponseWriter, r *http.Request) {
Sandbox bool `json:"sandbox"`
Enabled bool `json:"enabled"`
}
- r.Body = http.MaxBytesReader(w, r.Body, 4096)
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if decoder.Decode(&p) != nil || !validPushSecret(p.Secret) {
@@ -55,14 +55,19 @@ func (h *Home) registerPush(w http.ResponseWriter, r *http.Request) {
write(w, 200, map[string]bool{"ok": true})
return
}
- if (p.Topic != "chat.ocho.app" && p.Topic != "chat.ocho.app.mac") || !apns.ValidDeviceToken(p.Token) || len(p.Token) < 32 || len(p.Token)%2 != 0 {
- failure(w, 400, "Invalid Apple notification destination.")
+ android := p.Topic == androidPushTopic
+ validApple := (p.Topic == "chat.ocho.app" || p.Topic == "chat.ocho.app.mac") && apns.ValidDeviceToken(p.Token) && len(p.Token) >= 32 && len(p.Token)%2 == 0
+ if !(validApple || android && !p.Sandbox && validFCMToken(p.Token)) {
+ failure(w, 400, "Invalid notification destination.")
return
}
if h.cfg.Push[p.Topic] == nil {
- failure(w, 503, "Apple notifications are not configured yet.")
+ failure(w, 503, "Notifications are not configured yet.")
return
}
+ if !android {
+ p.Token = strings.ToLower(p.Token)
+ }
tx, e := h.db.BeginTx(r.Context(), nil)
if e != nil {
failure(w, 503, "Could not save notifications.")
@@ -79,7 +84,7 @@ func (h *Home) registerPush(w http.ResponseWriter, r *http.Request) {
failure(w, 503, "Could not save notifications.")
return
}
- result, e := tx.ExecContext(r.Context(), `INSERT INTO ocho_push_routes(secret_hash,host,token,topic,sandbox,expires) VALUES(?,?,?,?,?,?) ON CONFLICT(secret_hash) DO UPDATE SET token=excluded.token,topic=excluded.topic,sandbox=excluded.sandbox,expires=excluded.expires WHERE host=excluded.host`, pushHash(p.Secret), host, strings.ToLower(p.Token), p.Topic, p.Sandbox, time.Now().Add(180*24*time.Hour).Unix())
+ result, e := tx.ExecContext(r.Context(), `INSERT INTO ocho_push_routes(secret_hash,host,token,topic,sandbox,expires) VALUES(?,?,?,?,?,?) ON CONFLICT(secret_hash) DO UPDATE SET token=excluded.token,topic=excluded.topic,sandbox=excluded.sandbox,expires=excluded.expires WHERE host=excluded.host`, pushHash(p.Secret), host, p.Token, p.Topic, p.Sandbox, time.Now().Add(180*24*time.Hour).Unix())
if e != nil {
failure(w, 503, "Could not save notifications.")
return
@@ -143,7 +148,7 @@ func (h *Home) wakePush(w http.ResponseWriter, r *http.Request) {
return
}
if e != nil {
- failure(w, 503, "Apple notifications temporarily unavailable.")
+ failure(w, 503, "Notifications temporarily unavailable.")
return
}
write(w, 200, map[string]bool{"ok": true})
diff --git a/internal/infinityhome/relay.go b/internal/infinityhome/relay.go
index 0b7f64d..0af207d 100644
--- a/internal/infinityhome/relay.go
+++ b/internal/infinityhome/relay.go
@@ -9,8 +9,10 @@ import (
)
var tenantLabel = regexp.MustCompile(`^p-[0-9a-f]{24}$`)
+var retryMessageRoute = regexp.MustCompile(`^/api/retry/[A-Za-z0-9_-]{43}$`)
+var blobRoute = regexp.MustCompile(`^/api/blobs/[A-Za-z0-9_-]{43}$`)
var relayRoutes = map[string]bool{
- "GET /api/profile": true, "POST /api/setup": true,
+ "POST /api/blobs": true, "GET /api/profile": true, "POST /api/setup": true,
"GET /api/vault": true, "PUT /api/vault": true,
"POST /api/capabilities": true, "GET /api/events": true,
"GET /api/delivery": true, "POST /api/retry": true, "POST /api/send": true,
@@ -19,7 +21,8 @@ var relayRoutes = map[string]bool{
func (h *Home) relay(w http.ResponseWriter, r *http.Request) {
host, path := r.PathValue("host"), "/api/"+r.PathValue("path")
label := strings.TrimSuffix(host, "."+h.cfg.Domain)
- if !tenantLabel.MatchString(label) || host != label+"."+h.cfg.Domain || !relayRoutes[r.Method+" "+path] {
+ allowed := relayRoutes[r.Method+" "+path] || r.Method == "POST" && retryMessageRoute.MatchString(path) || r.Method == "GET" && blobRoute.MatchString(path)
+ if !tenantLabel.MatchString(label) || host != label+"."+h.cfg.Domain || !allowed {
failure(w, 404, "Account route not found.")
return
}
@@ -45,7 +48,11 @@ func (h *Home) relay(w http.ResponseWriter, r *http.Request) {
failure(w, 400, "Unexpected account parameters.")
return
}
- body := http.MaxBytesReader(w, r.Body, 2<<20)
+ limit := int64(2 << 20)
+ if path == "/api/blobs" {
+ limit = 12 << 20
+ }
+ body := http.MaxBytesReader(w, r.Body, limit)
req, err := http.NewRequestWithContext(r.Context(), r.Method, "https://"+host+path+query, body)
if err != nil {
failure(w, 400, "Invalid account request.")
diff --git a/internal/infinityhome/relay_test.go b/internal/infinityhome/relay_test.go
index 3d383a6..29f0aec 100644
--- a/internal/infinityhome/relay_test.go
+++ b/internal/infinityhome/relay_test.go
@@ -46,6 +46,11 @@ func TestTenantRelayBoundaries(t *testing.T) {
want int
}{
{host, "vault", "GET", "owner", "https://ocho.chat", 200},
+ {host, "retry/" + strings.Repeat("a", 43), "POST", "owner", "https://ocho.chat", 200},
+ {host, "retry/" + strings.Repeat("a", 43), "POST", "", "https://ocho.chat", 401},
+ {host, "retry/" + strings.Repeat("a", 43), "GET", "owner", "https://ocho.chat", 404},
+ {host, "retry/invalid", "POST", "owner", "https://ocho.chat", 404},
+ {host, "retry/" + strings.Repeat("a", 43) + "/extra", "POST", "owner", "https://ocho.chat", 404},
{host, "vault", "GET", "", "https://ocho.chat", 401},
{host, "vault", "PUT", "owner", "https://evil.test", 403},
{"localhost", "vault", "GET", "owner", "https://ocho.chat", 404},
@@ -70,7 +75,7 @@ func TestTenantRelayBoundaries(t *testing.T) {
}
})
}
- if calls != 1 {
+ if calls != 2 {
t.Fatalf("unexpected upstream requests: %d", calls)
}
status = 302
@@ -78,7 +83,7 @@ func TestTenantRelayBoundaries(t *testing.T) {
r.Header.Set("Authorization", "Bearer owner")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
- if w.Code != 502 || calls != 2 {
+ if w.Code != 502 || calls != 3 {
t.Fatal("redirect followed or exposed")
}
}
diff --git a/mobile/attachments.go b/mobile/attachments.go
new file mode 100644
index 0000000..57ca8bd
--- /dev/null
+++ b/mobile/attachments.go
@@ -0,0 +1,113 @@
+// πŸ€– Only native clients hold file names and keys; servers copy opaque file bytes.
+package ocho
+
+import (
+ "crypto/sha256"
+ "errors"
+ "strings"
+)
+
+const maxAttachment = 8 << 20
+
+type attachment struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ Nonce string `json:"nonce"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Size int `json:"size"`
+}
+
+func validAttachment(a *attachment) bool {
+ if a == nil {
+ return true
+ }
+ id, e1 := decode(a.ID)
+ key, e2 := decode(a.Key)
+ nonce, e3 := decode(a.Nonce)
+ return e1 == nil && e2 == nil && e3 == nil && len(id) == 32 && len(key) == 32 && len(nonce) == 12 && a.Size >= 0 && a.Size <= maxAttachment && textLength(a.Name) > 0 && textLength(a.Name) <= 255 && textLength(a.Type) <= 100 && !strings.ContainsAny(a.Name, "/\\\x00\r\n")
+}
+
+// PrepareAttachment encrypts and uploads a file, then prepares a stable message.
+// The returned prepared message must be persisted before SendPreparedMessage.
+func (c *Client) PrepareAttachment(threadID, name, mediaType string, data []byte, id string, at int64) (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ file := &attachment{ID: Random(), Key: Random(), Nonce: encode(randomBytes(12)), Name: name, Type: mediaType, Size: len(data)}
+ if !validAttachment(file) {
+ return "", errors.New("Choose a file up to 8 MB with a valid name.")
+ }
+ key, _ := decode(file.Key)
+ nonce, _ := decode(file.Nonce)
+ box, e := aead(key)
+ if e != nil {
+ return "", e
+ }
+ ciphertext := box.Seal(nil, nonce, data, []byte("ocho-file-v1"))
+ sum := sha256.Sum256(ciphertext)
+ file.ID = encode(sum[:])
+ // Membership is checked before uploading and again before delivery.
+ prepared, e := c.prepareMessage(threadID, "Attachment: "+name, id, at, file)
+ if e != nil {
+ return "", e
+ }
+ e = c.api("/api/blobs", "POST", map[string]string{"id": file.ID, "box": encode(ciphertext)}, nil)
+ return prepared, e
+}
+
+// DownloadAttachment accepts only a descriptor from an authenticated message in this account.
+func (c *Client) DownloadAttachment(threadID, messageID string) ([]byte, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ view, e := c.refresh()
+ if e != nil {
+ return nil, e
+ }
+ for _, t := range view.Threads {
+ if t.ID == threadID {
+ for _, m := range t.Messages {
+ if m.ID == messageID && m.Attachment != nil {
+ return c.downloadAttachment(m.Attachment)
+ }
+ }
+ }
+ }
+ return nil, errors.New("Attachment unavailable.")
+}
+func (c *Client) downloadAttachment(file *attachment) ([]byte, error) {
+ if file == nil || !validAttachment(file) {
+ return nil, errors.New("Invalid attachment.")
+ }
+ var body struct {
+ ID string `json:"id"`
+ Box string `json:"box"`
+ }
+ if e := c.api("/api/blobs/"+file.ID, "GET", nil, &body); e != nil {
+ return nil, e
+ }
+ if len(body.Box) > (maxAttachment+16)*4/3+4 {
+ return nil, errors.New("Invalid attachment size.")
+ }
+ ciphertext, e := decode(body.Box)
+ if e != nil {
+ return nil, e
+ }
+ sum := sha256.Sum256(ciphertext)
+ if body.ID != file.ID || encode(sum[:]) != file.ID {
+ return nil, errors.New("Attachment integrity check failed.")
+ }
+ key, _ := decode(file.Key)
+ nonce, _ := decode(file.Nonce)
+ box, e := aead(key)
+ if e != nil {
+ return nil, e
+ }
+ plain, e := box.Open(nil, nonce, ciphertext, []byte("ocho-file-v1"))
+ if e != nil {
+ return nil, e
+ }
+ if len(plain) != file.Size {
+ return nil, errors.New("Attachment size did not match.")
+ }
+ return plain, nil
+}
diff --git a/mobile/attachments_test.go b/mobile/attachments_test.go
new file mode 100644
index 0000000..3cdba11
--- /dev/null
+++ b/mobile/attachments_test.go
@@ -0,0 +1,103 @@
+// πŸ€– Native attachment preparation hides metadata and binds retried messages to the same file.
+package ocho
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestNativeAttachmentPrepareAndIntegrity(t *testing.T) {
+ var disk vaultReply
+ var blob struct {
+ ID string `json:"id"`
+ Box string `json:"box"`
+ }
+ var owner string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer "+owner {
+ t.Error("owner authorization missing")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.URL.Path == "/api/vault":
+ json.NewEncoder(w).Encode(disk)
+ case r.URL.Path == "/api/blobs":
+ raw, _ := io.ReadAll(r.Body)
+ if bytes.Contains(raw, []byte("private-file")) || bytes.Contains(raw, []byte("private-content")) {
+ t.Error("plaintext uploaded")
+ }
+ json.Unmarshal(raw, &blob)
+ w.Write([]byte(`{}`))
+ case strings.HasPrefix(r.URL.Path, "/api/blobs/"):
+ json.NewEncoder(w).Encode(blob)
+ default:
+ w.Write([]byte(`[]`))
+ }
+ }))
+ defer server.Close()
+ raw, _ := NewIdentity(server.URL, "Self")
+ me, _ := readIdentity(raw)
+ owner = me.Auth
+ peerRaw, _ := NewIdentity("https://peer.example", "Peer")
+ peer, _ := readIdentity(peerRaw)
+ state := newVault("Self")
+ state.Contacts["thread"] = contact{Person: peer.person()}
+ plain, _ := marshal(state)
+ disk.Box, _ = Lock(me.VaultKey, plain)
+ disk.Revision = 1
+ c, _ := NewClient(raw)
+ prepared, e := c.PrepareAttachment("thread", "private-file.txt", "text/plain", []byte("private-content"), Random(), 123)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var message preparedMessage
+ if parse(prepared, &message) != nil || len(message.Items) != 2 {
+ t.Fatal("invalid prepared recipients")
+ }
+ var descriptor *attachment
+ for _, item := range message.Items {
+ if len(item.BlobIDs) != 1 || item.BlobIDs[0] != blob.ID {
+ t.Fatal("file lost from delivery")
+ }
+ id := me
+ if item.Envelope.To == peer.Enc {
+ id = peer
+ }
+ body, e := open(id, item.Envelope)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var payload event
+ json.Unmarshal(body, &payload)
+ if payload.Attachment == nil || payload.Attachment.Name != "private-file.txt" {
+ t.Fatal("encrypted descriptor missing")
+ }
+ descriptor = payload.Attachment
+ }
+ data, e := c.downloadAttachment(descriptor)
+ if e != nil || string(data) != "private-content" {
+ t.Fatal("file roundtrip failed", e)
+ }
+ wrong := *descriptor
+ wrong.Key = Random()
+ if _, e = c.downloadAttachment(&wrong); e == nil {
+ t.Fatal("wrong key decrypted")
+ }
+ wrong = *descriptor
+ wrong.Size++
+ if _, e = c.downloadAttachment(&wrong); e == nil {
+ t.Fatal("wrong size accepted")
+ }
+ blob.Box = encode([]byte("tampered ciphertext"))
+ if _, e = c.downloadAttachment(descriptor); e == nil {
+ t.Fatal("tampered file accepted")
+ }
+ if _, e = c.PrepareAttachment("missing", "file", "text/plain", nil, Random(), 123); e == nil {
+ t.Fatal("unknown conversation accepted")
+ }
+}
diff --git a/mobile/consent.go b/mobile/consent.go
index f51d4c6..445810d 100644
--- a/mobile/consent.go
+++ b/mobile/consent.go
@@ -16,6 +16,7 @@ type preapproval struct {
Key string `json:"key"`
}
type deliveryItem struct {
+ BlobIDs []string `json:"blobs,omitempty"`
Destination string `json:"destination"`
Capability string `json:"capability"`
Envelope envelope `json:"envelope"`
diff --git a/mobile/outgoing.go b/mobile/outgoing.go
index 25c1f73..b056ac4 100644
--- a/mobile/outgoing.go
+++ b/mobile/outgoing.go
@@ -16,6 +16,9 @@ type preparedMessage struct {
func (c *Client) PrepareMessage(threadID, text, id string, at int64) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
+ return c.prepareMessage(threadID, text, id, at, nil)
+}
+func (c *Client) prepareMessage(threadID, text, id string, at int64, file *attachment) (string, error) {
key, keyError := decode(id)
text = strings.TrimSpace(text)
if text == "" || textLength(text) > 8000 || (keyError != nil || len(key) != 32) || at < 0 {
@@ -27,10 +30,15 @@ func (c *Client) PrepareMessage(threadID, text, id string, at int64) (string, er
}
for _, t := range view.Threads {
if t.ID == threadID && !t.Pending && !t.Left {
- items, e := c.envelopes(t.Members, event{Kind: "message", Thread: threadID, ID: id, Text: text, At: &at, Epoch: epoch(t.Epoch)})
+ items, e := c.envelopes(t.Members, event{Attachment: file, Kind: "message", Thread: threadID, ID: id, Text: text, At: &at, Epoch: epoch(t.Epoch)})
if e != nil {
return "", e
}
+ if file != nil {
+ for i := range items {
+ items[i].BlobIDs = []string{file.ID}
+ }
+ }
return marshal(preparedMessage{Thread: threadID, Epoch: t.Epoch, Items: items})
}
}
diff --git a/mobile/projection.go b/mobile/projection.go
index 8708eb8..a6b650f 100644
--- a/mobile/projection.go
+++ b/mobile/projection.go
@@ -28,8 +28,9 @@ func newVault(name string) vault {
}
type event struct {
- AcceptProof string `json:"acceptProof,omitempty"`
- Claim string `json:"claim,omitempty"`
+ Attachment *attachment `json:"attachment,omitempty"`
+ AcceptProof string `json:"acceptProof,omitempty"`
+ Claim string `json:"claim,omitempty"`
invalid bool
Kind string `json:"kind"`
Thread string `json:"thread"`
@@ -67,14 +68,15 @@ func (e *event) UnmarshalJSON(data []byte) error {
}
type message struct {
- ID string `json:"id"`
- Text string `json:"text"`
- At int64 `json:"at"`
- Mine bool `json:"mine"`
- Name string `json:"name"`
- Signer string `json:"signer"`
- Delivery string `json:"delivery,omitempty"`
- Retry bool `json:"retry,omitempty"`
+ Attachment *attachment `json:"attachment,omitempty"`
+ ID string `json:"id"`
+ Text string `json:"text"`
+ At int64 `json:"at"`
+ Mine bool `json:"mine"`
+ Name string `json:"name"`
+ Signer string `json:"signer"`
+ Delivery string `json:"delivery,omitempty"`
+ Retry bool `json:"retry,omitempty"`
}
type thread struct {
ID string `json:"id"`
@@ -196,7 +198,7 @@ func project(id identity, state vault, events []event) projection {
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 {
+ if !validAttachment(e.Attachment) || e.invalid || e.Kind != "message" || textLength(e.Text) > 8000 || e.ID == "" || e.At == nil || *e.At < 0 || *e.At > 8640000000000000 {
continue
}
t := threads[e.Thread]
@@ -223,7 +225,7 @@ func project(id identity, state vault, events []event) projection {
name = p.Name
}
}
- t.Messages = append(t.Messages, message{ID: e.ID, Text: e.Text, At: *e.At, Mine: e.Signer == me.Signing, Name: name, Signer: e.Signer})
+ t.Messages = append(t.Messages, message{Attachment: e.Attachment, ID: e.ID, Text: e.Text, At: *e.At, Mine: e.Signer == me.Signing, Name: name, Signer: e.Signer})
}
out := projection{Threads: []*thread{}, Requests: []event{}}
for _, t := range threads {
diff --git a/test/infinity/attachments.test.mjs b/test/infinity/attachments.test.mjs
new file mode 100644
index 0000000..c4f87b1
--- /dev/null
+++ b/test/infinity/attachments.test.mjs
@@ -0,0 +1,18 @@
+// πŸ€– Browser-side attachment bytes, metadata and integrity checks.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {encryptAttachment,validAttachment,maxAttachment} from '../../internal/infinity/static/attachments.js';
+import {unb64} from '../../internal/infinity/static/crypto.js';
+test('file encryption is randomized and interoperable AES-GCM with authenticated metadata descriptor',async()=>{
+ const file=new File(['private file contents ∞'],'private.txt',{type:'text/plain'});
+ const a=await encryptAttachment(file), b=await encryptAttachment(file);
+ assert.ok(validAttachment(a.attachment));assert.notEqual(a.blob.id,b.blob.id);
+ assert.ok(!a.blob.box.includes('private'));assert.equal(a.attachment.size,file.size);
+ const key=await crypto.subtle.importKey('raw',unb64(a.attachment.key),'AES-GCM',false,['decrypt']);
+ const plain=await crypto.subtle.decrypt({name:'AES-GCM',iv:unb64(a.attachment.nonce),additionalData:new TextEncoder().encode('ocho-file-v1')},key,unb64(a.blob.box));
+ assert.equal(new TextDecoder().decode(plain),await file.text());
+ assert.equal(validAttachment({...a.attachment,name:'../secret'}),false);
+ assert.equal(validAttachment({...a.attachment,size:maxAttachment+1}),false);
+ assert.equal(validAttachment({...a.attachment,key:'bad'}),false);
+ await assert.rejects(encryptAttachment({size:maxAttachment+1}));
+});
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs
index 1d5fa7e..84da954 100644
--- a/test/infinity/browser.mjs
+++ b/test/infinity/browser.mjs
@@ -35,6 +35,15 @@ try{
await a.getByRole('log').getByText('Hello Alice β€” private-canary-text',{exact:true}).waitFor();
await a.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Bob, it works both ways.');await a.getByRole('button',{name:'Send message',exact:true}).click();
await b.getByRole('log').getByText('Hello Bob, it works both ways.',{exact:true}).waitFor();
+ // Files are copied encrypted and downloaded only when the recipient chooses Save.
+ const fileBytes=Buffer.from('ocho-private-attachment-content-September-13');
+ await a.getByLabel('Choose attachment',{exact:true}).setInputFiles({name:'private-attachment.txt',mimeType:'text/plain',buffer:fileBytes});
+ const save=b.getByRole('button',{name:'↓ private-attachment.txt',exact:true});await save.waitFor();
+ const downloading=b.waitForEvent('download');await save.click();const downloaded=await downloading;
+ assert.equal(downloaded.suggestedFilename(),'private-attachment.txt');assert.deepEqual(await readFile(await downloaded.path()),fileBytes);
+ let externalRequests=0;await a.route('https://example.com/**',route=>{externalRequests++;return route.abort();});
+ await b.getByRole('textbox',{name:'Message',exact:true}).fill('See https://example.com/a-safe-link');await b.getByRole('button',{name:'Send message',exact:true}).click();
+ await a.getByRole('link',{name:'example.com /a-safe-link',exact:true}).waitFor();assert.equal(externalRequests,0);
await connect(c,await invite(a));await a.getByRole('button',{name:'Accept',exact:true}).click();await a.getByRole('heading',{name:'Carol',exact:true}).waitFor();
await a.locator('.account-menu summary').click();await a.getByRole('button',{name:'New group',exact:true}).click();await a.getByLabel('Group name').fill('The weekend');await a.getByLabel('Bob',{exact:true}).check();await a.getByLabel('Carol',{exact:true}).check();await a.getByRole('button',{name:'Create group',exact:true}).click();
await b.getByRole('button',{name:'Accept',exact:true}).click();await b.getByRole('heading',{name:'The weekend',exact:true}).waitFor();await c.getByRole('button',{name:'Accept',exact:true}).click();await c.getByRole('heading',{name:'The weekend',exact:true}).waitFor();
@@ -55,7 +64,7 @@ try{
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(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.','ocho-private-attachment-content-September-13','private-attachment.txt','https://example.com/a-safe-link'])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','legacy encrypted recovery format compatibility','mobile layout','SQLite/WAL plaintext absence','no browser errors']}));
}catch(error){
diff --git a/tools/infinity/archive-apple b/tools/infinity/archive-apple
new file mode 100755
index 0000000..49c599b
--- /dev/null
+++ b/tools/infinity/archive-apple
@@ -0,0 +1,15 @@
+#!/bin/sh
+# πŸ€– Prepare a signed store archive. Upload/processing is a separate, observable step.
+set -eu
+cd "$(dirname "$0")/../.."
+case "${1:-ios}" in
+ ios) scheme=Infinity; destination='generic/platform=iOS' ;;
+ mac) scheme=InfinityMac; destination='generic/platform=macOS' ;;
+ *) echo 'usage: tools/infinity/archive-apple ios|mac' >&2; exit 2 ;;
+esac
+test -d apple/OchoCore/Artifacts/OchoEngine.xcframework || tools/infinity/build-mobile apple
+xcodegen generate --spec apple/project.yml
+mkdir -p .build/distribution
+xcodebuild -project apple/Infinity.xcodeproj -scheme "$scheme" -configuration Release \
+ -destination "$destination" -archivePath ".build/distribution/$scheme.xcarchive" \
+ -derivedDataPath "apple/build-archive-$scheme" -allowProvisioningUpdates archive
diff --git a/tools/infinity/check b/tools/infinity/check
index 403c714..1f6d8b2 100755
--- a/tools/infinity/check
+++ b/tools/infinity/check
@@ -14,7 +14,7 @@ case "${1:-all}" in
;;
home-browser)
node test/infinity/composer-layout.mjs
- node --test test/infinity/outgoing.test.mjs
+ node --test test/infinity/outgoing.test.mjs test/infinity/attachments.test.mjs
CGO_ENABLED=0 go test -c -o .build/native-check/home-test ./internal/infinityhome
node test/infinity/home-browser.mjs
;;