Regenerate the spec page for §6.15 through §6.27 #92
+549
-3
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/SPEC.md.
|
||||
Edits made HERE are deleted the next time that script runs.
|
||||
Change the markdown source and re-run it.
|
||||
source-sha256: ba0062ff88813c30 -->
|
||||
source-sha256: 94f4d086a225f72b -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="flamenet-messenger--end-to-end-encryption-spec">Flamenet
|
||||
@@ -364,8 +364,9 @@ required, <code>dur</code> absent), <strong><code>audio</code></strong>
|
||||
sent as <code>0</code>; RECOMMENDED encoding AAC in an MPEG-4 container,
|
||||
<code>mime: "audio/mp4"</code>, so every platform's native decoder opens
|
||||
it), and <strong><code>story</code></strong>.</p>
|
||||
<p><strong>Stories.</strong> A story is an <code>image</code> attachment
|
||||
addressed to <em>every</em> buddy: the sender seals and uploads the blob
|
||||
<p><strong>Stories.</strong> A story is usually an <code>image</code>
|
||||
attachment addressed to <em>every</em> buddy (a story with no attachment
|
||||
at all is §6.21): the sender seals and uploads the blob
|
||||
<strong>once</strong>, then sends one <code>kind: "story"</code>
|
||||
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.</p>
|
||||
<p>For the same reason, a client that lets a reader <strong>tap a quote
|
||||
to reach the message it answers</strong> MUST select that target by
|
||||
<code>mid</code> alone. Matching on <code>preview</code> — or falling
|
||||
back to it when the <code>mid</code> 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
|
||||
<code>mid</code> the receiver does not hold has no target: the correct
|
||||
behaviour is to render the preview and offer no jump, not to guess.</p>
|
||||
<p><strong>Reactions.</strong> <code>kind</code> is
|
||||
<code>"react"</code>, <code>caption</code> is the emoji, <code>re</code>
|
||||
names the target:</p>
|
||||
@@ -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.</p>
|
||||
<h3 id="615-editing-a-message-that-was-already-sent">6.15 Editing a
|
||||
message that was already sent</h3>
|
||||
<p>A sender MAY replace the text of a message it sent earlier.
|
||||
<code>kind</code> is <code>"edit"</code>, <code>re</code> names the
|
||||
target, and <code>caption</code> carries the new wording:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "edit", "caption": "meet at 9", "re": { "mid": "<target>", "author": 0, "preview": "" } }</code></pre>
|
||||
<p><strong>The receiver decides authorship, and MUST NOT read it from
|
||||
the envelope.</strong> <code>re.author</code> 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
|
||||
<em>and</em> already attributes to the sender of the edit. An
|
||||
implementation that trusted <code>re.author</code> would let one
|
||||
envelope rewrite what somebody else said, inside the reader's own
|
||||
transcript. Senders SHOULD write <code>0</code> in that field, as this
|
||||
specification's reference implementations do, precisely so that nothing
|
||||
is tempted to believe it.</p>
|
||||
<p><strong>An edit MUST be visible as an edit.</strong> 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.</p>
|
||||
<p>Further requirements:</p>
|
||||
<ul>
|
||||
<li>A receiving client MUST NOT render an edit as a message. It is
|
||||
folded onto the target and, if the target is unknown, dropped — the same
|
||||
rule as a reaction (§6.9), and for the same reason: an unrecognised edit
|
||||
drawn as text appears as a duplicate of the message it was meant to
|
||||
replace.</li>
|
||||
<li>An edit MUST NOT raise a notification. Being buzzed a second time
|
||||
because somebody fixed a typo is the behaviour people turn a messenger
|
||||
off over.</li>
|
||||
<li>An edit MUST NOT empty a message. Retracting a message is a
|
||||
different operation with different honesty requirements, and reusing an
|
||||
edit for it leaves a blank bubble marked "edited", which explains
|
||||
nothing.</li>
|
||||
<li>Edits are <strong>monotonic</strong>. Two edits can arrive out of
|
||||
order, because a relay orders by when it received an envelope rather
|
||||
than by when it was written. A receiving client MUST NOT let an edit
|
||||
written earlier replace one written later; the relay's timestamp for the
|
||||
envelope is the ordering key.</li>
|
||||
<li>A message with no <code>mid</code> cannot be edited, for the same
|
||||
reason it cannot be reacted to: there is nothing to name it by.</li>
|
||||
</ul>
|
||||
<p><code>gid</code> applies as elsewhere: an edit of a group message
|
||||
carries the group id and routes to the group thread.</p>
|
||||
<p><strong>Status.</strong> 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 <code>mid</code> for each line. That single gap also blocks replies
|
||||
and reactions on Android (see §6.9), and closing it enables all
|
||||
three.</p>
|
||||
<h3 id="616-forwarding">6.16 Forwarding</h3>
|
||||
<p>A sender MAY pass a message on to another conversation. A forward
|
||||
carries the content and <strong>nothing about where it came
|
||||
from</strong>:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "text", "fwd": 1, "mid": "<a NEW id>", "caption": "meet at 6" }</code></pre>
|
||||
<p><code>fwd</code> follows §6.10's flag shape — <code>1</code>, with
|
||||
<code>true</code> accepted on the way in.</p>
|
||||
<p><strong>What MUST NOT travel.</strong> A forwarded envelope MUST NOT
|
||||
carry <code>re</code>, and MUST NOT carry the <code>mid</code> 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 <code>mid</code> is
|
||||
unique within a conversation, so the same one appearing in two
|
||||
conversations is a handle the <strong>relay</strong> can use to tie
|
||||
those two conversations together — which is the link a forward is
|
||||
supposed to break. A forward MUST mint a fresh <code>mid</code>.</p>
|
||||
<p><strong>Attachments are re-uploaded, not re-referenced.</strong> A
|
||||
forwarded blob MUST be sealed again under a new key with a new id,
|
||||
rather than the original <code>att</code> 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
|
||||
<code>mid</code> 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 <code>exp</code>: a
|
||||
story hours from vanishing must not hand the new recipient a copy that
|
||||
vanishes with it.</p>
|
||||
<p><strong>What <code>fwd</code> says, and what it does not.</strong> It
|
||||
says the sender is passing on somebody else's words. It says nothing
|
||||
about <em>whose</em>. 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.</p>
|
||||
<p><code>gid</code> applies as elsewhere.</p>
|
||||
<p><strong>Status.</strong> 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.</p>
|
||||
<h3 id="617-a-timer-on-one-message">6.17 A timer on one message</h3>
|
||||
<p>A sender MAY put a timer on a single message rather than on the whole
|
||||
conversation. It rides the content envelope in <code>secs</code>,
|
||||
alongside the message it applies to:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "text", "mid": "<id>", "secs": 60, "caption": "meet at 6" }</code></pre>
|
||||
<p><strong>The shorter timer always wins.</strong> Where a message
|
||||
carries <code>secs</code> and its conversation has a timer from §6.11,
|
||||
the message lives for the <em>lesser</em> 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 <code>secs</code> 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.</p>
|
||||
<p><strong>A duration, never a deadline.</strong> <code>secs</code> 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.</p>
|
||||
<p>Further requirements:</p>
|
||||
<ul>
|
||||
<li>Zero, absent, or negative all mean <strong>no timer</strong>, and
|
||||
MUST NOT be read as "expire immediately". A message whose sender chose
|
||||
nothing must not vanish on arrival.</li>
|
||||
<li>A sender MUST expire its own copy too. A timer that only cleared the
|
||||
recipient's phone would leave the message on the device belonging to the
|
||||
person who set it, which is the opposite of the promise.</li>
|
||||
<li>Pruning MUST run per message, not per conversation. A conversation
|
||||
with no timer of its own can still hold messages that carry one, and an
|
||||
implementation that skips such a thread keeps every timed message in it
|
||||
for ever — while appearing to work on the sending device.</li>
|
||||
<li>⚠️ <code>secs</code> on a <code>kind: "ttl"</code> envelope means
|
||||
something else entirely: it sets the whole conversation (§6.11). A
|
||||
receiver MUST distinguish them by <code>kind</code>.</li>
|
||||
</ul>
|
||||
<p><strong>What the relay learns: nothing.</strong> 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.</p>
|
||||
<p><strong>Status.</strong> Implemented in the iOS client and in the
|
||||
Kotlin engine and Android app, including the combining rule and
|
||||
per-message pruning on both.</p>
|
||||
<h3 id="618-mentions">6.18 Mentions</h3>
|
||||
<p>A group message MAY name the members it is addressed to, in
|
||||
<code>ment</code>:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "text", "gid": "<group>", "ment": [12, 34], "caption": "@Ada @Adam thoughts?" }</code></pre>
|
||||
<p><strong>A mention decides whether the receiver is notified, and
|
||||
nothing else.</strong> It confers no permission, changes no state, and
|
||||
is not evidence of anything. This matters because <code>ment</code> 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.</p>
|
||||
<p><strong>Which is the rule that makes mentions safe to have.</strong>
|
||||
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:</p>
|
||||
<ul>
|
||||
<li>the <strong>sender</strong> decides that a message is addressed to
|
||||
somebody;</li>
|
||||
<li>the <strong>receiver</strong> decides whether being addressed is
|
||||
worth a notification.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
<p>Further requirements:</p>
|
||||
<ul>
|
||||
<li>A receiving client MUST resolve names against the group's roster and
|
||||
MUST NOT treat <code>ment</code> as naming anyone who is not a
|
||||
member.</li>
|
||||
<li><code>ment</code> MUST be bounded and deduplicated on receipt. A
|
||||
sender can name ten thousand ids in one envelope, and the cost of that
|
||||
lands on every recipient's device rather than theirs. The reference
|
||||
implementation caps at 256, far above any real roster.</li>
|
||||
<li>A client MUST NOT notify for a mention of somebody else, and SHOULD
|
||||
NOT let a person mention themselves — the only thing that achieves is
|
||||
making your own phone buzz.</li>
|
||||
<li>Reading the conversation beats a mention. A banner about a message
|
||||
already on screen is no less absurd for naming you.</li>
|
||||
<li>Omitted entirely when there are no mentions, which is nearly every
|
||||
message.</li>
|
||||
</ul>
|
||||
<p>⚠️ <strong>Muting is per conversation, not per author.</strong>
|
||||
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.</p>
|
||||
<p><strong>What the relay learns: nothing.</strong> <code>ment</code> 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.</p>
|
||||
<p><strong>Status.</strong> 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.</p>
|
||||
<h3 id="619-pinning-a-message">6.19 Pinning a message</h3>
|
||||
<p>A conversation MAY have one pinned message. <code>kind</code> is
|
||||
<code>"pin"</code> and <code>re</code> names it:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "pin", "re": { "mid": "<target>", "author": 0, "preview": "" } }</code></pre>
|
||||
<p>An <strong>empty</strong> <code>mid</code> 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.</p>
|
||||
<p><strong>A pin is an id, and a receiving client MUST NOT store a copy
|
||||
of the message.</strong> 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.</p>
|
||||
<p><strong>Pins are monotonic.</strong> 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.</p>
|
||||
<p>Further requirements:</p>
|
||||
<ul>
|
||||
<li>A pin MUST NOT render as a message, and MUST NOT raise a
|
||||
notification. Somebody pinning an address you both already have is not
|
||||
news.</li>
|
||||
<li>A pin naming a <code>mid</code> the receiver does not hold shows
|
||||
nothing, rather than an empty banner claiming something is pinned.</li>
|
||||
<li><code>re.author</code> is not read, exactly as in §6.15. Senders
|
||||
SHOULD write <code>0</code>.</li>
|
||||
<li><code>gid</code> applies as elsewhere: a pin in a group carries the
|
||||
group id and pins there.</li>
|
||||
</ul>
|
||||
<p>⚠️ <strong>One pin, not a list.</strong> 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.</p>
|
||||
<p><strong>Status.</strong> 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 <code>mid</code> for each message; the moment it does, the pin becomes
|
||||
a lookup like the other.</p>
|
||||
<h3 id="620-story-replies">6.20 Story replies</h3>
|
||||
<p>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:</p>
|
||||
<pre class="json"><code>{"fnc":1,"kind":"text","caption":"where is this?","sre":{"sid":"<story id>"}}</code></pre>
|
||||
<p><code>sre.sid</code> 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.</p>
|
||||
<p>🔴 <strong><code>sre</code> carries no copy of the story — no
|
||||
caption, no blob, no thumbnail.</strong> This diverges from
|
||||
<code>re</code> 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 <code>preview</code> 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.</p>
|
||||
<p>A receiver whose copy of <code>sid</code> 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.</p>
|
||||
<p>⚠️ <strong>Replying tells the author you watched, and that is a
|
||||
disclosure the replier makes.</strong> 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.</p>
|
||||
<p>⚠️ <code>sid</code> 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.</p>
|
||||
<h3 id="621-text-and-link-stories">6.21 Text and link stories</h3>
|
||||
<p>A story MAY carry no attachment. The caption is then the whole
|
||||
story:</p>
|
||||
<pre class="json"><code>{"fnc":1,"kind":"story","caption":"back in the city on Thursday","exp":1788000000,
|
||||
"bg":3}</code></pre>
|
||||
<p><code>att</code> is absent, <code>caption</code> is REQUIRED and MUST
|
||||
NOT be empty, and <code>exp</code> rides at the top level exactly as it
|
||||
does for a story with a blob. <code>bg</code> 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.</p>
|
||||
<p>A <strong>link story</strong> is a text story whose caption contains
|
||||
a URL and which carries a §6.12 <code>lnk</code> preview. Everything
|
||||
§6.12 says applies unchanged and is the reason it is not a separate
|
||||
kind: <strong>the sender fetches the preview</strong>, 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.</p>
|
||||
<p>🔴 <strong>A client that predates this section renders a text story
|
||||
into the conversation.</strong> Its decoder requires <code>att</code>
|
||||
for <code>kind: "story"</code>, fails that guard, and falls through to
|
||||
the path that shows <code>caption</code> 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 <code>kind</code> — 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.</p>
|
||||
<p>⚠️ A text story carries no blob, so there is nothing for the server's
|
||||
retention purge (§9) to collect. Its entire lifetime is the
|
||||
<code>exp</code> that honest clients enforce, which makes the advisory
|
||||
nature of story expiry stated in §6.6 the <em>only</em> mechanism here
|
||||
rather than a backstop to one.</p>
|
||||
<h3 id="622-story-reactions">6.22 Story reactions</h3>
|
||||
<p>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:</p>
|
||||
<pre class="json"><code>{"fnc":1,"kind":"react","caption":"\ud83d\udc4d","sre":{"sid":"<story id>"}}</code></pre>
|
||||
<p><code>kind</code> is <code>react</code> and <code>caption</code> is
|
||||
the emoji, matching §6.9's reaction envelope; <code>sre</code> names the
|
||||
story, matching §6.20. An empty <code>caption</code> 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.</p>
|
||||
<p>🔴 <strong><code>re</code> is absent and MUST be.</strong> §6.9's
|
||||
reaction names a message by <code>mid</code> and folds onto it; a story
|
||||
is not in the conversation and has no <code>mid</code> there, so a story
|
||||
reaction that carried <code>re</code> would fold onto whatever message
|
||||
happened to share the id — or onto nothing. A receiver MUST route on
|
||||
<code>sre</code> and MUST NOT render a story reaction into the thread as
|
||||
a message.</p>
|
||||
<p>⚠️ <strong>The disclosure is the same as §6.20's, and MUST be made
|
||||
separately.</strong> 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<h3 id="623-multi-item-stories">6.23 Multi-item stories</h3>
|
||||
<p>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:</p>
|
||||
<pre class="json"><code>{"fnc":1,"kind":"story","att":{…},"exp":1788000000,
|
||||
"set":{"id":"<set id>","i":0,"n":3}}</code></pre>
|
||||
<p><code>set.id</code> is an opaque identifier the sender mints,
|
||||
<code>i</code> is the item's position from zero, and <code>n</code> is
|
||||
how many items the set has. A client SHOULD group items sharing a
|
||||
<code>set.id</code> from the same author and present them in
|
||||
<code>i</code> order.</p>
|
||||
<p>🔴 <strong>One envelope per item, not one envelope carrying
|
||||
many.</strong> An array of attachments would be the obvious encoding and
|
||||
it degrades badly: a client that predates this section requires a single
|
||||
<code>att</code>, 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.</p>
|
||||
<p>⚠️ <strong>Expiry is per SET, not per item, and every item MUST carry
|
||||
the same <code>exp</code>.</strong> 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 <code>exp</code>
|
||||
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.</p>
|
||||
<p>⚠️ <code>n</code> 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 <code>n</code> items, and
|
||||
MUST NOT show a set as incomplete. A missing item is indistinguishable
|
||||
from one never sent.</p>
|
||||
<p>⚠️ The cost is the sender's: <code>n</code> items is <code>n</code>
|
||||
blobs uploaded and <code>n</code> 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.</p>
|
||||
<h3 id="624-stories-posted-to-a-group">6.24 Stories posted to a
|
||||
group</h3>
|
||||
<p>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):</p>
|
||||
<pre class="json"><code>{"fnc":1,"kind":"story","att":{…},"exp":1788000000,"gid":"<group id>"}</code></pre>
|
||||
<p>Receivers MUST render it in the story surface, not in the group's
|
||||
message thread — a story is not a message, and <code>gid</code> here
|
||||
names the audience rather than the destination thread.</p>
|
||||
<p>🔴 <strong>The group's roster is the audience, and the sender's story
|
||||
audience does NOT apply.</strong> 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.</p>
|
||||
<p>⚠️ <strong>A client MUST say so before the first group story an
|
||||
account posts</strong>, 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.</p>
|
||||
<p>⚠️ <strong><code>exp</code> still governs, and is not extended by
|
||||
group membership.</strong> 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<h3 id="625-reporting-content">6.25 Reporting content</h3>
|
||||
<p>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.</p>
|
||||
<p>🔴 <strong>A report is the one place where a user deliberately breaks
|
||||
their own confidentiality.</strong> 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.</p>
|
||||
<p>⚠️ <strong>Therefore consent, stated in those terms, before the
|
||||
report is sent.</strong> 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.</p>
|
||||
<p>The submission is an ordinary authenticated request, not a content
|
||||
envelope — it is addressed to the operator rather than to a person:</p>
|
||||
<pre><code>POST /moderation/report
|
||||
{ "about": 12, "kind": "story", "reason": "abuse",
|
||||
"content": "<the reporter&#("#")39;s decrypted copy>", "note": "<optional, the reporter&#("#")39;s words>" }</code></pre>
|
||||
<p><code>about</code> is the account being reported. <code>kind</code>
|
||||
distinguishes a story from a message so an operator knows what surface
|
||||
it came from. <code>reason</code> is a short enumerated value.
|
||||
<code>content</code> 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.</p>
|
||||
<p>⚠️ <strong>A report MUST NOT be presented as, or accompanied by, a
|
||||
claim that the operator can see the conversation.</strong> 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<h3 id="626-wiping-a-lost-device">6.26 Wiping a lost device</h3>
|
||||
<p>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:</p>
|
||||
<pre class="json"><code>{"wipe":1,"device":"<device id>","at":1788000000,
|
||||
"sig":"<b64 Ed25519 over the canonical bytes below>"}</code></pre>
|
||||
<p>The signed bytes are
|
||||
<code>"fnwipe:v1:" || device_id || ":" || decimal(at)</code>, exactly,
|
||||
with no whitespace. The verifying device already holds what it needs:
|
||||
its own account address is <code>fnid@relay</code>, and the fnid IS the
|
||||
account public key (§20.3), saved at sign-in.</p>
|
||||
<p>🔴 <strong>The relay must not be able to cause a wipe, and this is
|
||||
the whole point of the section.</strong> 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.</p>
|
||||
<p>⚠️ <strong>Signing requires the passphrase, and that is
|
||||
deliberate.</strong> 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.</p>
|
||||
<p>⚠️ <strong>Best effort, and clients MUST describe it that
|
||||
way.</strong> A device that is off, offline, or wiped-and-reset never
|
||||
sees the instruction. <code>at</code> 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.</p>
|
||||
<p>⚠️ A wipe instruction MUST be scoped to one <code>device</code>, 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<h3 id="627-screenshot-notices">6.27 Screenshot notices</h3>
|
||||
<p>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:</p>
|
||||
<pre class="json"><code>{"fnc":1,"kind":"shot","ctl":1,"at":1788000000}</code></pre>
|
||||
<p><code>ctl</code> is set (§6.10), so a build that does not know
|
||||
<code>shot</code> 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.</p>
|
||||
<p>🔴 <strong>This is a courtesy from the taker's own client, and
|
||||
nothing else.</strong> 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; <strong>no notice arriving
|
||||
means nothing whatsoever</strong>. 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.</p>
|
||||
<p>⚠️ <strong>The platforms differ and the interface MUST NOT imply
|
||||
otherwise.</strong> 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<p>⚠️ 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.</p>
|
||||
<h2 id="7-safety-numbers-verification">7. Safety numbers
|
||||
(verification)</h2>
|
||||
<p>For a local identity <code>IK_sig_local_pub</code> and remote
|
||||
|
||||
Reference in New Issue
Block a user