Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a live test proving them and no way for a person to use them. Remove a contact. Block someone. Clear a thread. Change a password. Read a notification. All built, none reachable. So this is the CRUD pass, plus attachments. New in the UI: contact actions (block, unblock, clear chat, remove), a presence picker, a notifications screen with per-item and mark-all-read, an account screen with display name, password change and account deletion, password reset on the sign-in screen, bottom navigation, and photos -- sent through the system picker, rendered inline. `CrudSurfaceTest` walks the app and asserts the controls exist. It checks reachability rather than behaviour, because behaviour is what the live tests cover and the door is what was missing. It found two real gaps immediately: sign-out was reachable only from the contact list rather than from Account, where people look for it, and several controls sat below the fold. Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments, replies, reactions, group routing and call signalling. 17 unit tests. Two things it has to get right, and both are one-line mistakes: `fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A JSON library that sorts keys puts `att` first, every envelope it produces reads as plain text on the far side, and somebody's chat fills with raw JSON. The top level is assembled by hand. Control messages travel down the same channel as chat -- the web client sends `delivery_key`, `kt_head` and `sent` copies this way. A client that renders every envelope it does not recognise shows those to the user. They are now parsed, marked unrenderable, and handed to the caller with their payload. Attachments verified end to end against a real relay, including that the relay holds only ciphertext. That path was 401 on iOS for months because the attachment routes sent the account password, which the relay refuses on `/e2e/…`; this one uses the device-scoped token like everything else, and the test is what says so. Also: the instrumented suite passed one test at a time and failed as a set. The Compose rule gives each test a fresh Activity but the process and vault survive, so the app spends its first moments reading the vault behind a spinner -- and a reset that checks for a signed-in control immediately concludes "already signed out" while the session restores underneath it. 114 JVM tests, 37 cross-engine, 15 on-device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d30a77dd1e
commit
e65d1d987b
@@ -0,0 +1,361 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* What one decrypted message *means* (spec §6.6–§6.9).
|
||||
*
|
||||
* The ratchet stays content-agnostic: it moves opaque strings. A string that
|
||||
* begins with the marker below **and parses** is a content envelope carrying a
|
||||
* caption, an attachment reference, group routing, a reply, a reaction or a
|
||||
* call signal. Anything else is a plain text message, byte for byte.
|
||||
*
|
||||
* ⚠️ **That fallback is load-bearing.** It is what makes every message sent
|
||||
* before content envelopes existed still render, and it is why the marker
|
||||
* check must be a prefix test on the serialised bytes rather than "try to
|
||||
* parse it as JSON" — a message whose text happens to be valid JSON is still
|
||||
* a text message.
|
||||
*
|
||||
* ⚠️ **`fnc` must be encoded first**, because the marker is a prefix. A JSON
|
||||
* library that sorts keys puts `att` first and every envelope it produces
|
||||
* silently becomes plain text on the far side, rendering as raw JSON in
|
||||
* somebody's chat. The top level is therefore assembled by hand.
|
||||
*/
|
||||
data class Content(
|
||||
val text: String,
|
||||
/**
|
||||
* What this envelope claims to be. Kept rather than collapsed into flags,
|
||||
* because the caller — not the parser — is what knows whether an unknown
|
||||
* kind should be shown, consumed, or logged.
|
||||
*/
|
||||
val kind: String = Kind.TEXT,
|
||||
/**
|
||||
* The whole envelope, for kinds this engine does not interpret.
|
||||
*
|
||||
* ⚠️ Control messages travel down the same channel as chat: the web client
|
||||
* sends `delivery_key`, `kt_head` and `sent` copies this way. A client that
|
||||
* renders every envelope it does not understand shows those to the user as
|
||||
* a bubble full of JSON, which is exactly what happens without this.
|
||||
*/
|
||||
val raw: JSONObject? = null,
|
||||
val attachment: Attachment.Ref? = null,
|
||||
/** Group id, when this message is routed to a group (§6.7). */
|
||||
val groupId: String? = null,
|
||||
/** A stable id for this message, so a reply or reaction can name it. */
|
||||
val messageId: String? = null,
|
||||
/** What this replies to, or reacts to. */
|
||||
val about: Reference? = null,
|
||||
/** Set iff this is a reaction: the emoji, or empty to take one back. */
|
||||
val reaction: String? = null,
|
||||
val call: CallSignal? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
* A pointer at an earlier message.
|
||||
*
|
||||
* ⚠️ Carries `preview` as well as `messageId` deliberately. A device that
|
||||
* joined later, or cleared its history, has never seen the quoted message —
|
||||
* and a reply rendering as "> [missing]" is worse than one that shows what
|
||||
* was said. The preview is the sender's copy of their own words, the only
|
||||
* copy anyone can be sure of.
|
||||
*/
|
||||
data class Reference(val messageId: String, val author: Long, val preview: String) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("mid", messageId).put("author", author).put("preview", preview)
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject) = Reference(
|
||||
messageId = json.optString("mid"),
|
||||
author = json.optLong("author"),
|
||||
preview = json.optString("preview"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call signalling riding the ratchet (§6.8).
|
||||
*
|
||||
* The SDP — and with it the DTLS fingerprint the media keys hang off —
|
||||
* travels inside the ratchet, so the call is end-to-end authenticated with
|
||||
* no extra key agreement. Never rendered as a message.
|
||||
*/
|
||||
data class CallSignal(
|
||||
val action: String,
|
||||
val callId: String,
|
||||
val sdp: String? = null,
|
||||
val reason: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val OFFER = "call-offer"
|
||||
const val ANSWER = "call-answer"
|
||||
const val END = "call-end"
|
||||
val ACTIONS = setOf(OFFER, ANSWER, END)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this belongs in a thread.
|
||||
*
|
||||
* Reactions fold onto an existing bubble, call signals drive the call UI,
|
||||
* and control messages are consumed silently. Everything else is a message.
|
||||
*/
|
||||
val isRenderable: Boolean
|
||||
get() = reaction == null && call == null && kind !in Kind.CONTROL
|
||||
|
||||
object Kind {
|
||||
const val TEXT = "text"
|
||||
const val REACT = "react"
|
||||
|
||||
/** A contact's delivery key, so we can be reached sealed (§14.4). */
|
||||
const val DELIVERY_KEY = "delivery_key"
|
||||
|
||||
/** A gossiped transparency-log head (§19.4). */
|
||||
const val KEY_LOG_HEAD = "kt_head"
|
||||
|
||||
/** A copy of a message another of OUR devices sent (§17.2). */
|
||||
const val SENT_COPY = "sent"
|
||||
|
||||
/**
|
||||
* ⚠️ Consumed, never shown. Each of these is a real message the web
|
||||
* client sends today, and rendering one puts raw JSON in somebody's
|
||||
* chat.
|
||||
*/
|
||||
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A plaintext is an envelope iff it starts with exactly this AND parses. */
|
||||
private const val MARKER = "{\"fnc\":"
|
||||
|
||||
// -- Encoding --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Plain 1:1 text with no id and no reply stays **raw UTF-8**.
|
||||
*
|
||||
* Not an optimisation. A client that predates content envelopes shows
|
||||
* an envelope as literal JSON, so the safe default for the simplest
|
||||
* message is the format every client has always understood.
|
||||
*/
|
||||
fun text(body: String, messageId: String? = null, about: Reference? = null): String =
|
||||
if (messageId == null && about == null) body
|
||||
else assemble("text", caption = body, messageId = messageId, about = about)
|
||||
|
||||
fun groupText(
|
||||
body: String,
|
||||
groupId: String,
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
): String = assemble("text", caption = body, groupId = groupId,
|
||||
messageId = messageId, about = about)
|
||||
|
||||
fun attachment(
|
||||
ref: Attachment.Ref,
|
||||
caption: String? = null,
|
||||
groupId: String? = null,
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
): String = assemble(
|
||||
kind = ref.kind,
|
||||
caption = caption?.takeIf { it.isNotEmpty() },
|
||||
expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 },
|
||||
groupId = groupId,
|
||||
attachment = ref,
|
||||
messageId = messageId,
|
||||
about = about,
|
||||
)
|
||||
|
||||
/**
|
||||
* ⚠️ An empty emoji takes a previous reaction back. Removal has to be a
|
||||
* message rather than an absence: the other device has already rendered
|
||||
* the first one, and there is no other way to reach it.
|
||||
*/
|
||||
fun reaction(emoji: String, about: Reference, groupId: String? = null): String =
|
||||
assemble("react", caption = emoji, groupId = groupId, about = about)
|
||||
|
||||
fun callSignal(signal: CallSignal): String {
|
||||
val call = JSONObject().put("id", signal.callId)
|
||||
signal.sdp?.let { call.put("sdp", it) }
|
||||
signal.reason?.let { call.put("reason", it) }
|
||||
return """{"fnc":1,"kind":${quote(signal.action)},"call":$call}"""
|
||||
}
|
||||
|
||||
private fun assemble(
|
||||
kind: String,
|
||||
caption: String? = null,
|
||||
expires: Double? = null,
|
||||
groupId: String? = null,
|
||||
attachment: Attachment.Ref? = null,
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
): String {
|
||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||
about?.let { parts += "\"re\":${it.toJson()}" }
|
||||
caption?.let { parts += "\"caption\":${quote(it)}" }
|
||||
expires?.let { parts += "\"exp\":$it" }
|
||||
groupId?.let { parts += "\"gid\":${quote(it)}" }
|
||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||
return parts.joinToString(",", prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
/** JSON string escaping, via the library, so control characters survive. */
|
||||
private fun quote(value: String): String = JSONObject.quote(value)
|
||||
|
||||
// -- Decoding --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ⚠️ An envelope of an unknown kind renders as a visible label rather
|
||||
* than vanishing. The sender meant to say something, and a message that
|
||||
* silently disappears is the worst possible way to handle a version
|
||||
* this client does not know about.
|
||||
*/
|
||||
fun parse(plaintext: String): Content {
|
||||
if (!plaintext.startsWith(MARKER)) return Content(plaintext)
|
||||
val json = runCatching { JSONObject(plaintext) }.getOrNull()
|
||||
?: return Content(plaintext)
|
||||
if (json.optInt("fnc") != 1) return Content(plaintext)
|
||||
|
||||
val kind = json.optString("kind")
|
||||
val groupId = json.optString("gid").takeIf { it.isNotEmpty() }
|
||||
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
|
||||
val about = json.optJSONObject("re")?.let(Reference::fromJson)
|
||||
val caption = json.optString("caption")
|
||||
|
||||
// A reaction is not a message and must never become a bubble.
|
||||
// Checked before everything else so a field-ordering change cannot
|
||||
// let one fall through and render as an empty line in the thread.
|
||||
if (kind == Kind.REACT && about != null) {
|
||||
return Content("", kind = kind, raw = json, groupId = groupId,
|
||||
messageId = messageId, about = about, reaction = caption)
|
||||
}
|
||||
|
||||
// Control traffic. Returned with its envelope so the caller can act
|
||||
// on it, and never renderable.
|
||||
if (kind in Kind.CONTROL) return Content("", kind = kind, raw = json)
|
||||
|
||||
if (kind in CallSignal.ACTIONS) {
|
||||
val call = json.optJSONObject("call") ?: return Content("", kind = kind, raw = json)
|
||||
return Content("", kind = kind, raw = json, call = CallSignal(
|
||||
action = kind,
|
||||
callId = call.optString("id"),
|
||||
sdp = call.optString("sdp").takeIf { it.isNotEmpty() },
|
||||
reason = call.optString("reason").takeIf { it.isNotEmpty() },
|
||||
))
|
||||
}
|
||||
|
||||
if (kind == Kind.TEXT) {
|
||||
return Content(caption, kind = kind, raw = json, groupId = groupId,
|
||||
messageId = messageId, about = about)
|
||||
}
|
||||
|
||||
val ref = json.optJSONObject("att")
|
||||
?.let { Attachment.Ref.fromJson(kind, it, json.optDouble("exp", 0.0)) }
|
||||
?: return Content(
|
||||
// The sender meant to say something; a message that
|
||||
// silently vanishes is the worst way to meet a version this
|
||||
// client does not know.
|
||||
caption.ifEmpty { "[unsupported attachment]" },
|
||||
kind = kind, raw = json,
|
||||
groupId = groupId, messageId = messageId, about = about,
|
||||
)
|
||||
return Content(caption, kind = kind, raw = json, attachment = ref,
|
||||
groupId = groupId, messageId = messageId, about = about)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted blobs (spec §6.6).
|
||||
*
|
||||
* A fresh key per attachment, and the key travels **only inside the ratchet**.
|
||||
* The relay stores and serves a blob it cannot read; its id is a download
|
||||
* capability and nothing more.
|
||||
*/
|
||||
object Attachment {
|
||||
|
||||
const val IMAGE = "image"
|
||||
const val AUDIO = "audio"
|
||||
|
||||
/** An image for the story strip rather than a thread. */
|
||||
const val STORY = "story"
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
/** Everything needed to fetch, verify and open a blob. */
|
||||
data class Ref(
|
||||
val kind: String,
|
||||
val id: String,
|
||||
val key: ByteArray,
|
||||
/** SHA-256 of the uploaded **ciphertext**, so it can be checked before decrypting. */
|
||||
val digest: ByteArray,
|
||||
val bytes: Int,
|
||||
val mime: String,
|
||||
/** Image and story only; the bubble reserves space before the bytes arrive. */
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
/** Audio only, seconds. */
|
||||
val duration: Double = 0.0,
|
||||
/** Story only: unix seconds after which it must not render. */
|
||||
val expires: Double = 0.0,
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("id", id)
|
||||
.put("key", Bytes.b64(key))
|
||||
.put("digest", Bytes.b64(digest))
|
||||
.put("bytes", bytes)
|
||||
.put("mime", mime)
|
||||
.put("w", width)
|
||||
.put("h", height)
|
||||
.also { if (kind == AUDIO) it.put("dur", duration) }
|
||||
|
||||
companion object {
|
||||
fun fromJson(kind: String, json: JSONObject, expires: Double): Ref? {
|
||||
val key = runCatching { Bytes.unb64(json.optString("key")) }.getOrNull()
|
||||
val digest = runCatching { Bytes.unb64(json.optString("digest")) }.getOrNull()
|
||||
// Wrong-sized key material is a malformed reference, not a
|
||||
// decryption to attempt. Returning null renders the caption
|
||||
// with a label; trying anyway throws somewhere less useful.
|
||||
if (key == null || key.size != 32 || digest == null || digest.size != 32) return null
|
||||
return Ref(
|
||||
kind = kind,
|
||||
id = json.optString("id"),
|
||||
key = key,
|
||||
digest = digest,
|
||||
bytes = json.optInt("bytes"),
|
||||
mime = json.optString("mime"),
|
||||
width = json.optInt("w"),
|
||||
height = json.optInt("h"),
|
||||
duration = json.optDouble("dur", 0.0),
|
||||
expires = expires,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The blob to upload, and the key and digest that go in the message. */
|
||||
data class Sealed(val blob: ByteArray, val key: ByteArray, val digest: ByteArray)
|
||||
|
||||
fun seal(data: ByteArray): Sealed {
|
||||
val key = ByteArray(32).also(random::nextBytes)
|
||||
val nonce = ByteArray(12).also(random::nextBytes)
|
||||
val blob = Aead.seal(key, nonce, data)
|
||||
return Sealed(blob, key, Bytes.sha256(blob))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a downloaded blob.
|
||||
*
|
||||
* ⚠️ **The digest is checked before decrypting.** The relay handed us these
|
||||
* bytes and only the ratchet-delivered reference says what they should be.
|
||||
* AES-GCM would catch a substitution too, but failing on the digest names
|
||||
* the problem — "the server sent different bytes" — instead of reporting it
|
||||
* as a decryption failure indistinguishable from a broken session.
|
||||
*/
|
||||
fun open(blob: ByteArray, ref: Ref): ByteArray {
|
||||
if (!Bytes.constantTimeEquals(Bytes.sha256(blob), ref.digest)) {
|
||||
throw SecurityException("attachment digest mismatch: the relay served different bytes")
|
||||
}
|
||||
return Aead.open(ref.key, blob)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user