diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf index 14869fa..477f821 100644 --- a/Resources/Views/spec.leaf +++ b/Resources/Views/spec.leaf @@ -3,7 +3,7 @@ + source-sha256: 94f4d086a225f72b -->

Flamenet @@ -364,8 +364,9 @@ required, dur absent), audio sent as 0; RECOMMENDED encoding AAC in an MPEG-4 container, mime: "audio/mp4", so every platform's native decoder opens it), and story.

-

Stories. A story is an image attachment -addressed to every buddy: the sender seals and uploads the blob +

Stories. A story is usually an image +attachment addressed to every buddy (a story with no attachment +at all is §6.21): the sender seals and uploads the blob once, then sends one kind: "story" envelope per buddy through that buddy's normal ratchet session. No new server surface exists — the server sees only ordinary sealed envelopes @@ -542,6 +543,14 @@ attributable to the quoted author — it is the replier's assertion about what was said, and a replier can lie about it. Clients SHOULD render it as a quote attributed to the replier's view, not as the original author's words.

+

For the same reason, a client that lets a reader tap a quote +to reach the message it answers MUST select that target by +mid alone. Matching on preview — or falling +back to it when the mid is unknown — would let a crafted +reply send the reader to a message of the sender's choosing, and would +do it invisibly, because the quote itself would look correct. A +mid the receiver does not hold has no target: the correct +behaviour is to render the preview and offer no jump, not to guess.

Reactions. kind is "react", caption is the emoji, re names the target:

@@ -754,6 +763,543 @@ connection and rings from its own foreground service, so an Android client MAY set the flag for interop but gains nothing from it. The asymmetry is Apple's process model, not a design choice, and it belongs on the privacy page for the same reason the push dependency does.

+

6.15 Editing a +message that was already sent

+

A sender MAY replace the text of a message it sent earlier. +kind is "edit", re names the +target, and caption carries the new wording:

+
{ "fnc": 1, "kind": "edit", "caption": "meet at 9", "re": { "mid": "<target>", "author": 0, "preview": "" } }
+

The receiver decides authorship, and MUST NOT read it from +the envelope. re.author is a claim: any member of +a group can put any user id there, and a 1:1 peer can put yours. A +receiving client MUST apply an edit only to a message it already holds +and already attributes to the sender of the edit. An +implementation that trusted re.author would let one +envelope rewrite what somebody else said, inside the reader's own +transcript. Senders SHOULD write 0 in that field, as this +specification's reference implementations do, precisely so that nothing +is tempted to believe it.

+

An edit MUST be visible as an edit. A receiving +client MUST show that the message was changed after it was sent. This is +not a courtesy: an edit the reader cannot detect puts new words into a +bubble they have already read and trusted, and removes any trace that +the original was different — which is a forgery primitive, and it is the +reason this section exists at all rather than the feature being a local +convenience.

+

Further requirements:

+ +

gid applies as elsewhere: an edit of a group message +carries the group id and routes to the group thread.

+

Status. In progress. The iOS client implements this +section end to end. The Kotlin engine implements the codec — an inbound +edit is recognised and is not drawn as a duplicate bubble — and the +Android app cannot yet apply one, because its transcript does not record +a mid for each line. That single gap also blocks replies +and reactions on Android (see §6.9), and closing it enables all +three.

+

6.16 Forwarding

+

A sender MAY pass a message on to another conversation. A forward +carries the content and nothing about where it came +from:

+
{ "fnc": 1, "kind": "text", "fwd": 1, "mid": "<a NEW id>", "caption": "meet at 6" }
+

fwd follows §6.10's flag shape — 1, with +true accepted on the way in.

+

What MUST NOT travel. A forwarded envelope MUST NOT +carry re, and MUST NOT carry the mid of the +message it was copied from. The first is the reply chain, whose preview +is somebody else's words from a conversation the new recipient was never +part of. The second matters more than it looks: a mid is +unique within a conversation, so the same one appearing in two +conversations is a handle the relay can use to tie +those two conversations together — which is the link a forward is +supposed to break. A forward MUST mint a fresh mid.

+

Attachments are re-uploaded, not re-referenced. A +forwarded blob MUST be sealed again under a new key with a new id, +rather than the original att being repeated. Repeating it +would work and would be far cheaper, and it would let the relay watch +one blob being fetched from two conversations — the same correlation the +mid rule closes, through a different door. ⚠️ The +consequence is that forwarding an attachment can fail where forwarding +text cannot: the blob may have expired from the relay. That is a failure +to report, not one to paper over by sending the caption alone. A +forwarded attachment MUST NOT inherit the original's exp: a +story hours from vanishing must not hand the new recipient a copy that +vanishes with it.

+

What fwd says, and what it does not. It +says the sender is passing on somebody else's words. It says nothing +about whose. A receiving client SHOULD show that a message was +forwarded, and MUST NOT present it as the sender's own composition; it +has nothing to attribute it to and MUST NOT invent one. Attribution is +something a person types if they want it, and this specification +deliberately provides no field for it — a field would become a default, +and a forward that carries its author by default is not a forward.

+

gid applies as elsewhere.

+

Status. In progress. The iOS client implements this +section. The Kotlin engine implements the codec, so a forward from +another client is recognised and shown as one; the Android app cannot +originate a forward yet.

+

6.17 A timer on one message

+

A sender MAY put a timer on a single message rather than on the whole +conversation. It rides the content envelope in secs, +alongside the message it applies to:

+
{ "fnc": 1, "kind": "text", "mid": "<id>", "secs": 60, "caption": "meet at 6" }
+

The shorter timer always wins. Where a message +carries secs and its conversation has a timer from §6.11, +the message lives for the lesser of the two. A per-message +timer may only take life away, never add it. This is not a tidiness +rule: without it a sender could defeat the recipient's conversation +timer by putting a long secs on every message, turning a +setting the recipient chose on their own device into something the other +party overrules. A receiving client MUST implement it this way.

+

A duration, never a deadline. secs is +how long, not when. An absolute expiry would be the sender's clock, and +a sender who chose it could backdate a message so it vanished from the +recipient's phone on arrival, or forward-date one so it never expired. +The receiver counts from the time IT recorded the message — the rule +§6.11 already follows, for the same reason.

+

Further requirements:

+ +

What the relay learns: nothing. The content envelope +is inside the ratchet, exactly where §6.11's announcement already rides, +so a timed message is indistinguishable to the relay from any other. ⚠️ +One honest limit, shared with §6.11 and not introduced here: an +attachment's blob sits on the relay under the relay's own retention, +which may outlast a short message timer. The key is gone from both +devices, so the blob is unopenable — but it has not been deleted at the +moment the message was.

+

Status. Implemented in the iOS client and in the +Kotlin engine and Android app, including the combining rule and +per-message pruning on both.

+

6.18 Mentions

+

A group message MAY name the members it is addressed to, in +ment:

+
{ "fnc": 1, "kind": "text", "gid": "<group>", "ment": [12, 34], "caption": "@Ada @Adam thoughts?" }
+

A mention decides whether the receiver is notified, and +nothing else. It confers no permission, changes no state, and +is not evidence of anything. This matters because ment is a +claim by the sender like every other field in the envelope: the worst a +dishonest one can achieve is a notification the reader is able to turn +off.

+

Which is the rule that makes mentions safe to have. +A mention that always broke through a mute would hand every member of a +group a way to interrupt somebody whenever they liked — which is how +mentions become the thing people leave a group over. A mention that +never broke through would make muting a busy group mean missing the one +message that was actually for you. So the responsibility splits:

+ +

A client MUST make the second half a setting the receiving person +controls, and MUST NOT let anything in an envelope override it. +Defaulting that setting to "a mention breaks a mute" is reasonable and +is what the reference implementation does; defaulting it to "always, +with no way off" is not.

+

Further requirements:

+ +

⚠️ Muting is per conversation, not per author. +Stated here because getting it wrong makes this section unimplementable: +if a mute is keyed on whoever sent an arriving message, a group cannot +be muted at all — silencing it means muting every member one at a time, +and anyone who joins later is not covered. There is then no mute for a +mention to break.

+

What the relay learns: nothing. ment is +inside the ratchet like the rest of the content envelope. A relay cannot +tell an addressed message from any other, and in particular cannot tell +who in a group is being singled out.

+

Status. In progress. The iOS client implements this +section. The Kotlin engine implements the codec; the Android app does +not compose or act on mentions yet.

+

6.19 Pinning a message

+

A conversation MAY have one pinned message. kind is +"pin" and re names it:

+
{ "fnc": 1, "kind": "pin", "re": { "mid": "<target>", "author": 0, "preview": "" } }
+

An empty mid takes the pin down. +Removal is a message rather than an absence, for the same reason a +reaction's removal is (§6.9): the other device has already drawn it.

+

A pin is an id, and a receiving client MUST NOT store a copy +of the message. This is the requirement the section exists for. +A pin that carried the text would keep a message readable after its own +timer had removed it (§6.11, §6.17) — pinning would become a way to +defeat disappearing messages, silently, from either side of a +conversation. The pin is resolved through the thread each time it is +drawn; when the message is gone the pin shows nothing and the banner +goes with it.

+

Pins are monotonic. A relay orders envelopes by when +it received them, not by when they were written, so two people pinning +at once can arrive in either order. A receiving client MUST NOT let a +pin written earlier replace one written later, and MUST apply the same +rule to an unpin — otherwise a late-arriving unpin from before somebody +pinned something new silently clears it. Unlike a message, a pin is +shared state both people are looking at, so an inconsistency here does +not resolve itself.

+

Further requirements:

+ +

⚠️ One pin, not a list. A set of pins needs an +ordering, a cap, and a rule for what happens when two people add the +tenth at once — three more pieces of shared state to keep consistent +across devices that cannot talk to each other directly. One pin has +exactly one rule, above.

+

Status. In progress. The iOS client implements this +for one-to-one conversations. A group pin is carried correctly on the +wire but cannot resolve yet, because a group transcript does not record +a mid for each message; the moment it does, the pin becomes +a lookup like the other.

+

6.20 Story replies

+

A story has no comment thread and no viewer list, and MUST NOT grow +one. A reply to a story is an ordinary direct message to its author, +carrying a reference to the story it answers:

+
{"fnc":1,"kind":"text","caption":"where is this?","sre":{"sid":"<story id>"}}
+

sre.sid is the story's id as the replier received it. +The envelope is a normal text message in every other respect: it rides +the pairwise ratchet to the author, lands in the existing conversation, +and the relay sees nothing it does not already see for any message.

+

🔴 sre carries no copy of the story — no +caption, no blob, no thumbnail. This diverges from +re in §6.9, and the reason the two differ is worth stating. +A quoted message may be one the recipient never had: they joined late or +cleared their history, so §6.9 sends a preview and accepts +that it is the replier's assertion. A story's author, by definition, had +the story — the only way they no longer hold it is that it EXPIRED. +Sending a copy back would therefore do exactly one thing: resurrect +content the author's own timer had already removed, at a moment they did +not choose. That is the trap §6.19 avoids for pins, and it is the same +trap here.

+

A receiver whose copy of sid has expired or was never +held MUST render the reply as an ordinary message, and MAY note that it +answers a story that is no longer available. It MUST NOT render a +placeholder that implies the content could be recovered.

+

⚠️ Replying tells the author you watched, and that is a +disclosure the replier makes. Stories deliberately have no +viewer list: the author cannot otherwise learn who opened one. A reply +necessarily reveals it — there is no way to answer somebody without +their knowing. The disclosure is therefore acceptable, but it MUST be a +decision and not a side effect: a client MUST tell the user, before the +first story reply that account sends, that replying reveals they +watched. A client MUST NOT send any automatic acknowledgement — a read +receipt, a delivery marker, a typing indicator — in response to a story +being opened.

+

⚠️ sid is a local identifier, not a global one. It is +meaningful to the pair of devices that exchanged the story and MUST NOT +be treated as a handle the relay or any third party can resolve.

+ +

A story MAY carry no attachment. The caption is then the whole +story:

+
{"fnc":1,"kind":"story","caption":"back in the city on Thursday","exp":1788000000,
+ "bg":3}
+

att is absent, caption is REQUIRED and MUST +NOT be empty, and exp rides at the top level exactly as it +does for a story with a blob. bg is an OPTIONAL integer +naming one of the client's own background styles; it is a rendering hint +with no meaning on the wire, and a receiver that does not recognise the +value MUST fall back to its default rather than refuse the story.

+

A link story is a text story whose caption contains +a URL and which carries a §6.12 lnk preview. Everything +§6.12 says applies unchanged and is the reason it is not a separate +kind: the sender fetches the preview, and it rides +inside the ciphertext. A receiver MUST NOT fetch the URL to draw the +card — doing so would tell the site the address of every viewer, once +per viewer, which for a story is the whole audience rather than one +correspondent.

+

🔴 A client that predates this section renders a text story +into the conversation. Its decoder requires att +for kind: "story", fails that guard, and falls through to +the path that shows caption as an ordinary message. The +result is a story appearing as a chat bubble that never expires — the +two things §6.6 says a story must not do. This is a real cost and it is +stated here rather than discovered: senders SHOULD NOT post text stories +until the recipients' builds are known to understand them, and a client +MAY choose to send a text story as a rendered image to unknown builds. +The alternative — a new kind — would degrade no better, +since §6.10 makes an unknown kind invisible rather than legible, and a +story silently vanishing is not obviously the kinder failure.

+

⚠️ A text story carries no blob, so there is nothing for the server's +retention purge (§9) to collect. Its entire lifetime is the +exp that honest clients enforce, which makes the advisory +nature of story expiry stated in §6.6 the only mechanism here +rather than a backstop to one.

+

6.22 Story reactions

+

A one-tap acknowledgement of a story. It is a sealed direct message +to the author, exactly as a story reply is (§6.20), and carries the same +reference:

+
{"fnc":1,"kind":"react","caption":"\ud83d\udc4d","sre":{"sid":"<story id>"}}
+

kind is react and caption is +the emoji, matching §6.9's reaction envelope; sre names the +story, matching §6.20. An empty caption withdraws a +reaction already sent — removal must be a message rather than an +absence, because the author's device has already rendered the first one +and there is no other way to reach it.

+

🔴 re is absent and MUST be. §6.9's +reaction names a message by mid and folds onto it; a story +is not in the conversation and has no mid there, so a story +reaction that carried re would fold onto whatever message +happened to share the id — or onto nothing. A receiver MUST route on +sre and MUST NOT render a story reaction into the thread as +a message.

+

⚠️ The disclosure is the same as §6.20's, and MUST be made +separately. A reaction reveals that you watched, exactly as a +reply does. What differs is the deliberation behind it: a reply is +typed, and a reaction is one tap on a story that is already filling the +screen. A client that has disclosed the reply case MUST NOT treat that +as covering this one — the first reaction an account sends MUST be +preceded by its own statement that the author will learn they were +watched.

+

⚠️ A client MUST NOT offer a reaction control that sends on the same +gesture that advances or dismisses the story. An acknowledgement +delivered by a mis-tap is a disclosure the person did not make.

+

6.23 Multi-item stories

+

Several pictures posted as one sequence. Each item is an ordinary +story envelope (§6.6) with one extra field naming the set it belongs +to:

+
{"fnc":1,"kind":"story","att":{…},"exp":1788000000,
+ "set":{"id":"<set id>","i":0,"n":3}}
+

set.id is an opaque identifier the sender mints, +i is the item's position from zero, and n is +how many items the set has. A client SHOULD group items sharing a +set.id from the same author and present them in +i order.

+

🔴 One envelope per item, not one envelope carrying +many. An array of attachments would be the obvious encoding and +it degrades badly: a client that predates this section requires a single +att, so it would fail the guard and render the whole set as +an ordinary message in the conversation (§6.21 documents that failure). +Separate envelopes degrade to exactly what they are — several stories +from one person, in order, which is what the sender meant and what every +existing build already knows how to draw.

+

⚠️ Expiry is per SET, not per item, and every item MUST carry +the same exp. This is a decision rather than a +detail. Per-item expiry makes a sequence decay: after some hours the set +holds items 0 and 2, which is both confusing to look at and a disclosure +— the gaps reveal the order the author added things and roughly when. A +receiver that finds items of one set carrying different exp +values MUST treat the EARLIEST as the expiry of the whole set, so a +sender cannot use the difference to keep part of a sequence alive after +the rest has gone.

+

⚠️ n is the sender's claim and MAY be wrong — items are +separate envelopes and any of them can fail to arrive. A client MUST +render what it has rather than waiting for n items, and +MUST NOT show a set as incomplete. A missing item is indistinguishable +from one never sent.

+

⚠️ The cost is the sender's: n items is n +blobs uploaded and n envelopes per recipient, so a set of +ten to twenty contacts is two hundred sends. Clients SHOULD bound the +number of items they will post in one set.

+

6.24 Stories posted to a +group

+

A story MAY be addressed to a group rather than to contacts. It is an +ordinary story envelope (§6.6) carrying the group it belongs to, fanned +out pairwise to the members exactly as a group message is (§6.7):

+
{"fnc":1,"kind":"story","att":{…},"exp":1788000000,"gid":"<group id>"}
+

Receivers MUST render it in the story surface, not in the group's +message thread — a story is not a message, and gid here +names the audience rather than the destination thread.

+

🔴 The group's roster is the audience, and the sender's story +audience does NOT apply. These are two different lists with two +different rules, and this is the whole hazard of the feature. §6.6's +audience is a statement about who may see this person's stories; a group +roster is a statement about who is in a conversation. They will +disagree, and when they do the roster wins, because a story addressed to +a group that silently dropped some members would be a group message that +some of the group cannot see — a worse failure than the one being +avoided.

+

⚠️ A client MUST say so before the first group story an +account posts, and MUST name the consequence concretely: that +it goes to everyone in the group, including members this account has +excluded from its stories, and including members who are not contacts at +all. A sender who believes their story audience still applies is being +misled by the interface, not by the protocol.

+

⚠️ exp still governs, and is not extended by +group membership. A group story expires on the same terms as +any other; a client MUST NOT keep it because it also happens to be +addressed to a conversation that keeps history.

+

⚠️ A group story MUST NOT be forwarded into the contacts story +surface of members who are not in that group, and MUST NOT be treated as +evidence that its author is a contact. Group membership and the contact +list are separate facts, and a story is not an introduction.

+

6.25 Reporting content

+

Blocking needs no protocol: it is a local decision plus the relay's +existing block list, and nothing about the reported content leaves the +reporter's device. Reporting is different, and the difference is the +whole of this section.

+

🔴 A report is the one place where a user deliberately breaks +their own confidentiality. The relay stores ciphertext it +cannot read, so it cannot examine a reported message or story on its own +account, and no amount of server-side work changes that. A report that +carried only an accusation — "this account sent me something abusive" — +gives an operator nothing to act on but the assertion, which makes +moderation either credulous or useless. So a report MUST carry the +reporter's own decrypted copy of the content being reported, and that +copy is readable by the operator.

+

⚠️ Therefore consent, stated in those terms, before the +report is sent. A client MUST tell the reporter, in the moment +and not in a policy document, that sending the report gives the operator +a readable copy of the content and of who sent it. "Report" on its own +is a button whose consequence is invisible and irreversible. A client +MUST NOT report anything the user has not explicitly confirmed, MUST NOT +batch a report with any other action, and MUST NOT include surrounding +conversation the user did not choose to include.

+

The submission is an ordinary authenticated request, not a content +envelope — it is addressed to the operator rather than to a person:

+
POST /moderation/report
+{ "about": 12, "kind": "story", "reason": "abuse",
+  "content": "<the reporter&#("#")39;s decrypted copy>", "note": "<optional, the reporter&#("#")39;s words>" }
+

about is the account being reported. kind +distinguishes a story from a message so an operator knows what surface +it came from. reason is a short enumerated value. +content is what the reporter saw, and MAY be empty when the +reporter declines to include it — a report with no content is weaker, +and a client SHOULD say so rather than sending it silently.

+

⚠️ A report MUST NOT be presented as, or accompanied by, a +claim that the operator can see the conversation. They cannot. +They see exactly what this one request carried, because the reporter +sent it, and a report from one side is one side's account.

+

⚠️ Reporting MUST NOT notify the reported account, and the relay MUST +NOT expose reports to anyone but the operator. A report that told the +subject who filed it would make reporting dangerous for exactly the +people most likely to need it.

+

⚠️ A client SHOULD offer blocking alongside reporting and SHOULD make +clear they are separate: blocking takes effect immediately and is the +reporter's own decision; a report is a request to somebody else, and +nothing may happen.

+

6.26 Wiping a lost device

+

Revoking a device stops it receiving new mail. Everything already on +it stays readable, so a phone that is lost rather than retired is still +a copy of the conversation. A device MAY be instructed to erase what it +holds, and the instruction MUST be signed by the account key (§20), not +merely served by the relay:

+
{"wipe":1,"device":"<device id>","at":1788000000,
+ "sig":"<b64 Ed25519 over the canonical bytes below>"}
+

The signed bytes are +"fnwipe:v1:" || device_id || ":" || decimal(at), exactly, +with no whitespace. The verifying device already holds what it needs: +its own account address is fnid@relay, and the fnid IS the +account public key (§20.3), saved at sign-in.

+

🔴 The relay must not be able to cause a wipe, and this is +the whole point of the section. The obvious implementation is +to erase when the relay says the device is gone — an HTTP status on the +next poll. That hands an untrusted server the power to destroy every +user's history at will, irreversibly. It cannot read anything, and it +must not be able to delete anything either. A client MUST NOT erase +local data on the strength of any relay response that carries no +signature: a device told by an unsigned mechanism that it has been +removed SHOULD sign out and stop polling, and MUST leave stored messages +alone.

+

⚠️ Signing requires the passphrase, and that is +deliberate. The account key is derived, not stored (§20.3), so +issuing a wipe means re-entering the passphrase. A device able to wipe +the account's other devices without one would turn any unlocked phone +into a weapon against its owner's other phones.

+

⚠️ Best effort, and clients MUST describe it that +way. A device that is off, offline, or wiped-and-reset never +sees the instruction. at bounds nothing on its own — a +device that reappears in a year still honours a year-old instruction, +which is correct, because the instruction has not stopped being true. +What a client MUST NOT say is that the data is gone: it is gone from +devices that have since connected, and unknown elsewhere.

+

⚠️ A wipe instruction MUST be scoped to one device, and +a device MUST ignore one naming any device but itself. "Wipe everything" +is a different and much more dangerous operation; a client wanting it +issues one instruction per device so each is separately auditable.

+

⚠️ An account with no account key — one that only ever signed in +against a single relay and never derived an fnid — cannot use this. Such +a client MUST offer revocation without claiming a wipe, rather than +falling back to an unsigned mechanism.

+

6.27 Screenshot notices

+

A disappearing-message timer (§6.11) promises the message goes away. +A screenshot breaks that promise silently, and the sender never learns. +It cannot be prevented on either platform. It can be reported, and a +client MAY tell the sender when it notices one:

+
{"fnc":1,"kind":"shot","ctl":1,"at":1788000000}
+

ctl is set (§6.10), so a build that does not know +shot stays quiet rather than rendering it. Receivers MUST +render it as a notice in the conversation, never as a message, and MUST +NOT allow it to reset or extend any timer.

+

🔴 This is a courtesy from the taker's own client, and +nothing else. The device that took the screenshot is the device +that reports it. A modified build, a second phone photographing the +screen, or the platform's own screen recorder all produce no notice at +all. That makes one direction of the inference completely invalid: a +notice arriving means one was probably taken; no notice arriving +means nothing whatsoever. A client MUST NOT present the absence +of notices as evidence that a conversation was not captured, and MUST +NOT offer any summary — "no screenshots taken", a count of zero, a green +tick — that would be read that way.

+

⚠️ The platforms differ and the interface MUST NOT imply +otherwise. iOS reports a screenshot to the foreground app +reliably. Android has no equivalent before API 34, and its callback +there covers screenshots but not screen recording. So a client MUST +describe this as "you are told when the other person's app can tell", +never as "you are told when somebody screenshots". Claiming a symmetry +that does not exist is worse than the gap, because somebody would rely +on it.

+

⚠️ Only in conversations with a timer set. Sending it everywhere +would make it a general surveillance feature rather than the narrow one +it is: the promise a screenshot breaks is the one §6.11 made, and where +no promise was made there is nothing to report against.

+

⚠️ A client SHOULD send at most one notice per conversation per short +window. A screenshot often produces several platform events, and a +person who took one screenshot should not appear to have taken four.

7. Safety numbers (verification)

For a local identity IK_sig_local_pub and remote