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>
47 files changed,
+1367
−45
README.md+1 −1android/app/build.gradle.kts+9 −1android/app/src/main/AndroidManifest.xml+5 −0android/app/src/main/java/chat/ocho/app/AccountModel.kt+19 −2android/app/src/main/java/chat/ocho/app/ActivityNotifications.kt+61 −0android/app/src/main/java/chat/ocho/app/OchoMessagingService.kt+27 −0android/app/src/main/java/chat/ocho/app/OchoScreen.kt+61 −2apple/Infinity/AttachmentDocument.swift+10 −0apple/Infinity/NativeConversationView.swift+53 −0apple/Infinity/OutgoingMessages.swift+8 −0apple/OchoCore/Sources/OchoCore/Attachment.swift+6 −0apple/OchoCore/Sources/OchoCore/Client.swift+4 −0apple/OchoCore/Sources/OchoCore/MessageLinks.swift+12 −0apple/OchoCore/Sources/OchoCore/Models.swift+1 −0apple/project.yml+1 −1cmd/infinity-home/main.go+7 −0docs/infinity/push.md+32 −0docs/infinity/release-completion.md+74 −0docs/infinity/status.md+34 −1docs/infinity/tester-release.md+56 −0internal/infinity/app.go+3 −0internal/infinity/blobs.go+139 −0internal/infinity/blobs_test.go+72 −0internal/infinity/inbox.go+11 −0internal/infinity/migrations/0005_attachments.sql+3 −0internal/infinity/outbox.go+39 −6internal/infinity/static/attachments.js+32 −0internal/infinity/static/chat.js+8 −3internal/infinity/static/client.js+1 −0internal/infinity/static/link-cards.js+18 −0internal/infinity/static/outgoing.js+10 −0internal/infinity/static/protocol.js+3 −2internal/infinity/static/style.css+3 −0internal/infinityhome/fcm.go+144 −0internal/infinityhome/fcm_test.go+88 −0internal/infinityhome/push.go+11 −6internal/infinityhome/relay.go+10 −3internal/infinityhome/relay_test.go+7 −2mobile/attachments.go+113 −0mobile/attachments_test.go+103 −0mobile/consent.go+1 −0mobile/outgoing.go+9 −1mobile/projection.go+14 −12test/infinity/attachments.test.mjs+18 −0test/infinity/browser.mjs+10 −1tools/infinity/archive-apple+15 −0tools/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"], ¬ice) |
| + 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 |
| ;; |