Second pass with the avoid-ai-writing detector

The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.

One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.

Comments and strings only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 19:20:07 -04:00
co-authored by Claude Fable 5.1
parent 4dd544083c
commit 8a7f40fb13
131 changed files with 521 additions and 525 deletions
+2 -2
View File
@@ -13,13 +13,13 @@ the pipeline for the branch that made it*. See `.e2e-ref.md` there for the full
account; the conclusion was that a pin makes that situation impossible rather
than survivable.
**A pin alone is worse than HEAD, though.** The argument for HEAD is real: a
A pin alone is worse than HEAD, though. The argument for HEAD is real: a
client tested against a pinned old harness is tested against a pinned old spec,
and if the vectors move and nothing says so, this suite quietly stops checking
the protocol anybody is actually running. That is why `vectors-head` exists and
is `allow_failure: true`.
**Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.** A red
Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop. A red
`vectors-head` means an update is waiting, not that anything is wrong.
## Taking an update
+1 -1
View File
@@ -23,7 +23,7 @@ export ANDROID_HOME=$HOME/Library/Android/sdk
Those three suites are the merge gate.
**JVM crypto tests prove nothing about Android.** The JVM's JCA and Android's
JVM crypto tests prove nothing about Android. The JVM's JCA and Android's
Conscrypt are different providers with different opinions. X25519/X448 need DER
key specs and API 33 on device, and code that passes `:engine:test` on the JVM
can throw on a handset. Anything touching key agreement or signatures needs an
+68 -70
View File
@@ -4,7 +4,7 @@ What the iOS app does, what Android does, and what is left. Read from the
sources listed, not from memory, every row names the iOS file it came from so
it can be re-checked rather than trusted.
**Standing rule: this file is the definition of done for the Android app.** A
Standing rule: this file is the definition of done for the Android app. A
row moves to when the behaviour exists *and* something tests it, not when the
screen renders.
@@ -19,7 +19,7 @@ screen renders.
| Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips |
| Ships to | TestFlight, once a distribution cert is minted (see below) | a debug APK that installs and runs; the release keystore is configured |
Android's engine is **verified against the web engine in both directions**
Android's engine is verified against the web engine in both directions
(`engine/test/interop-kotlin.mjs`), which is a stronger claim than the iOS app
could make for most of its history. What Android has no version of at all is
everything above the protocol.
@@ -179,7 +179,7 @@ the ratchet, so the SDP, codecs and every candidate address, is end-to-end
encrypted; a relay that could read it would learn both parties' addresses even
on a call it never carries.
**A direct call shows the other person your IP address.** That is what makes
A direct call shows the other person your IP address. That is what makes
it direct. The app says so before the FIRST call and waits for an answer,
once, not every time, because a prompt people see repeatedly is one they stop
reading.
@@ -209,13 +209,13 @@ being the one that leaks.
| Zero key material after use | | | ios#39, done, but the JVM guarantee is **weaker than iOS**; see below |
| Pin the relay's identity | | | ios#19. Android already pinned the key and drew the **wrong conclusion** from a change; see below |
**The back-off is not a defence of the data.** It slows a person holding an
The back-off is not a defence of the data. It slows a person holding an
unlocked phone. The vault and the ratchet keys are protected by the Android
keystore, and no amount of back-off changes that, the same wording `AppLock`
itself uses, kept identical on both platforms so neither can be read as
promising more.
**The clock rule is an asymmetry, not a correction.** A story disappearing
The clock rule is an asymmetry, not a correction. A story disappearing
after an hour is a promise the SENDER made, enforced on the READER's device, so
a reader whose clock runs behind keeps a message the sender was told would be
gone, and nobody involved can tell. Expiry therefore takes the LATER of the
@@ -255,7 +255,7 @@ rather than guaranteed. There is no way to pin a Java array, so this is a
ceiling on what any implementation here can offer, not a gap to close later.
The claim Android supports is therefore narrower and is written that way in
`SecureBytes`: **a derived key is not left sitting in the heap after use.** Not
`SecureBytes`: a derived key is not left sitting in the heap after use. Not
"the key is gone".
One divergence from iOS, and it is a bug the Swift version cannot have. iOS
@@ -275,8 +275,8 @@ pinned to the published vector and wired into sign-in for a local relay, and
`MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and
read halves for a while. The document was describing a gap that had closed.
**What was genuinely missing was on the READING side, on BOTH clients.** An
older home record is *genuinely signed*, the account holder published it,
What was missing was on the READING side, on BOTH clients. An
older home record is *signed*, the account holder published it,
before they added a second relay, so a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first, that
record verifies perfectly, and the sender delivers to one relay believing it is
@@ -292,15 +292,14 @@ could raise it with a forged `Int.MAX_VALUE` and lock out every later genuine
record, turning the rollback defence into a denial of service against the
account holder.
Neither client claims an attack when it refuses. A relay that has genuinely
fallen behind serves an old record for honest reasons, and nothing in a client
Neither client claims an attack when it refuses. A relay that has fallen behind serves an old record for honest reasons, and nothing in a client
can tell that from the other case.
---
## Inbound control envelopes
**The app never asked the engine whether an envelope belonged in a thread.**
The app never asked the engine whether an envelope belonged in a thread.
`Content.isRenderable` has existed since §6.15 and `Session`'s inbound loop did
not consult it, so three things arrived from the iPhone app and were filed as
ordinary bubbles:
@@ -332,7 +331,7 @@ their own, because the words would carry somebody else's name.
| No credential written into the source | | | both fixed together, ios#62 |
| A guard that fails the build if either regresses | | | `Tests/UITestSafetyTests.swift`, `client/.../TestSafetyTest.kt` |
**iOS was creating real accounts on production on every plain test run**, with
iOS was creating real accounts on production on every plain test run, with
a literal password from a public repository; Android's equivalent suites were
properly gated but still carried
`const val PASSPHRASE = "a-long-enough-passphrase"` and used it to sign up on
@@ -392,9 +391,9 @@ the messenger rather than staying separate apps.
| Wallet engine: address derivation and signing | BIP-44 path, Base58 address, and the engine's own RFC 8032 signs, no API-33 floor |
| Wallet: BIP-39 seed, create and restore | create, import, restore, sealed at rest, with the phrase shown once and explained |
| Wallet: fresh receive address per receive | counter in the vault; the screen says why the address changes |
| Wallet: balance and transactions from a node | both, with **unread drawn as an em dash and never as zero** |
| Wallet: balance and transactions from a node | both, with unread drawn as an em dash and never as zero |
**The device run found a bug no JVM test could have.** Android's bundled
The device run found a bug no JVM test could have. Android's bundled
`org.json` coerces a JSON `null` to the four-character string `"null"`; the Maven
`org.json` the tests run against returns `""`. Every nullable field in the vault
index therefore passed on a laptop and broke on a phone:
@@ -512,11 +511,11 @@ Two Kotlin-specific decisions, both money-relevant:
is worse than a failed sign-in: a phrase containing anything outside ASCII
would stretch differently under a different provider and derive a different
empty wallet with no error anywhere.
- **Base58 is a digit loop, not `BigInteger`.** The obvious Kotlin version is
- Base58 is a digit loop, not `BigInteger`. The obvious Kotlin version is
`BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading
zero bytes, encoding two different keys to one address.
**Android can SIGN, and iOS's constraint does not apply here.** `AccountKdf`
Android can SIGN, and iOS's constraint does not apply here. `AccountKdf`
notes that a raw Ed25519 seed needs `EdECPrivateKeySpec`, which Android exposes
only from API 33, but the engine carries its own RFC 8032 (`Rfc8032`), so
`HdAccount.signingSeed` produces a key that signs on any supported device. The
@@ -529,19 +528,19 @@ convenient, would need no backup at all, and would tie every payment somebody
ever received to their handle on a public ledger for ever. The chain does not
forget, so there is no undoing it later.
**The record stores the ENTROPY, never the seed.** The seed is a one-way
The record stores the ENTROPY, never the seed. The seed is a one-way
function of the phrase, so a record holding it would open the wallet and be
unable to produce the words, somebody who lost their phrase but still had their
account could spend, and could never write it down again or move elsewhere.
Self-custody in name only.
**Erasing the device destroys the record; the phrase is then the only copy.**
Erasing the device destroys the record; the phrase is then the only copy.
Plain sign-out keeps it, and so does a password change (which signs out
plainly), `signOut(erasingDevice = true)` and `deleteAccount` do not. That is
what a recovery phrase is for, and it has to be said at the moment the phrase is
shown rather than discovered afterwards.
**The node is somebody else's server and is trusted with nothing.** It is
The node is somebody else's server and is trusted with nothing. It is
asked what a public address holds and handed transactions that are already
signed; it never sees a key. What it can do is lie about a balance or withhold a
history, so no answer is treated as authoritative beyond "this is what that node
@@ -549,7 +548,7 @@ said". And which node somebody points at is a privacy decision the app has to
state: asking a public endpoint for an address's balance ties that address to
this device's IP in somebody's logs.
**An unread balance is drawn as an em dash, never as 0.00.** Null means this
An unread balance is drawn as an em dash, never as 0.00. Null means this
app could not read the balance; zero means the account is empty. Drawing the
first as the second tells somebody their money is gone, on every flaky
connection, which is exactly when they are most likely to believe it. Same rule
@@ -559,7 +558,7 @@ Reached from Account, not the tab bar. The bar is four icons by design and
Alerts was taken OUT of it in August for being a fifth; a wallet people open
occasionally does not earn the slot that decision refused.
**A JSON-RPC refusal arrives with HTTP 200.** A client checking only the
A JSON-RPC refusal arrives with HTTP 200. A client checking only the
status code carries on with a body that has no result, fails somewhere less
obvious, and throws the node's own explanation away on the route, the
difference between "insufficient funds for rent exemption" on screen and a parse
@@ -618,7 +617,7 @@ measurement so nobody has to guess again.
**XDH is fine**: Conscrypt serves it from API 33, which is why `minSdk` is 33.
**The wire shapes were wrong three times, and each was silent.** `prekeys` not
The wire shapes were wrong three times, and each was silent. `prekeys` not
`opks` (relay answered 200 and stored nothing); the send body names the
recipient once at the top, not per envelope (400, at least loud); the buddy
list uses `id`/`presence`/`screen_name`, not `user_id`/`status`. None of these
@@ -640,7 +639,7 @@ contact list rather than from Account, and several controls sat below the fold.
## Cross-platform, 2026-08-25
**iOS → Android is proven, through both UIs.** The iOS app signed up on
iOS → Android is proven, through both UIs. The iOS app signed up on
`relay1`, added the Android account as a contact, and sent; the Android
instrumentation logged the exact text back:
@@ -674,13 +673,13 @@ gossip now exist and are tested. What each one is worth:
what was pinned the first time, and a send to a changed identity is refused
until a person accepts it. The comparison is `all offered keys are pinned`,
not `any`, a relay adding one key of its own alongside the real ones is the
attack, and every genuine device still working is what makes it invisible.
attack, and every device still working is what makes it invisible.
**Key-log enforcement is opt-in** (`require-key-log` on the Account screen),
for the same reason the web engine's is: a relay older than the log has none,
and refusing every peer there is a worse failure than the one being prevented.
**`verifyConsistency` did not exist in the Kotlin engine and now does.** The
`verifyConsistency` did not exist in the Kotlin engine and now does. The
engine had inclusion only, which on its own is worth very little: a relay that
rewrites history reissues perfectly valid inclusion proofs against its new
tree. Consistency against a pinned head is the check it cannot satisfy.
@@ -694,13 +693,12 @@ served key absent from the log, an inclusion proof for somebody else's leaf,
and a rewritten log that cannot be reconciled with a pinned root.
**Exercised against `relay1`**, 2026-08-25, `LiveKeyLogTest`, opt-in via
`FN_LIVE_FLEET`. Against the real 231-leaf log: a key the relay genuinely
published verifies; a substituted key is refused; a second check reconciles the
`FN_LIVE_FLEET`. Against the real 231-leaf log: a key the relay published verifies; a substituted key is refused; a second check reconciles the
pinned head with a grown tree using proofs the relay generated; a root we never
saw at our pinned size is a fork; and the send path itself refuses on a bad pin
while NOT refusing an honestly published key with enforcement on.
**Contacts carry the peer's global address, and enforcement uses it.**
Contacts carry the peer's global address, and enforcement uses it.
The relay files a leaf under an account's **fnid** once it has one, and under
`local:<audience>/<user_id>` before that. The subject is inside the hashed
@@ -721,7 +719,7 @@ still trust-on-first-use, taking the relay's word once is not a check; what it
buys is that every later answer has to match. The safety number covers the
first sighting and nothing here replaces it.
**A changed address is an identity change**, and for a stronger reason than a
A changed address is an identity change, and for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not a
new device, it is a different account key, which is to say a different person.
@@ -737,7 +735,7 @@ would then read as the address changing, the alarm, for a network blip.
## Sealed sender, 2026-08-25
**The relay no longer learns who sent a message.** Verified against relay1:
The relay no longer learns who sent a message. Verified against relay1:
a sealed submission is accepted unauthenticated, the stored row carries
`from_user = 0`, and the recipient still reads it with the sender recovered
from inside the seal.
@@ -754,13 +752,13 @@ more; the ratchet opening it is what makes it a fact. There is a test that
forges a sender on purpose so that nobody later "tidies" the naming into
something that looks authoritative.
**The first message to a new contact cannot be sealed**, and `Sent.sealed`
The first message to a new contact cannot be sealed, and `Sent.sealed`
reports which it was rather than letting the UI imply otherwise. Sealing needs
the recipient's delivery key, and that key travels inside an E2E message, so
there has to be a prior exchange. Claiming anonymity for that first message
would be lying about the one thing the feature is for.
**Rotation is the only lever against a sealed spammer, and it is blunt.**
Rotation is the only lever against a sealed spammer, and it is blunt.
There is no sender to block, so the recipient replaces the delivery key and
every contact loses sealed access until the next time they are written to.
Reachable from Account; the copy says so.
@@ -776,14 +774,14 @@ connect" and "Software caused connection abort" on sign-up, with the relay
reachable from the host at the same moment. A rerun succeeds. Worth knowing
before treating a red instrumented run as a regression.
**Instrumented tests share a process and a vault.** The Compose rule gives
Instrumented tests share a process and a vault. The Compose rule gives
each test a fresh Activity, but the app spends its first moments reading the
vault behind a spinner. A reset that checks for a signed-in control immediately
finds nothing, concludes "already signed out", and then the session restores
underneath it, so the suite passes one test at a time and fails as a set. Wait
for the app to settle before deciding anything.
**Two peer processes on one account race for the inbox.** The one that polls
Two peer processes on one account race for the inbox. The one that polls
first takes the message and the other reports zero, which reads as "nothing was
sent". Run a single `autoreply` at a time.
@@ -816,7 +814,7 @@ it has nothing to do with which app is more finished.
Found running `TwoSimulatorUITests` against `relay1`. **Both reproduce with the
machine idle**, so neither is the load artifact they were first taken for.
**1. One-way delivery between two fresh accounts.** Alpha receives bravo's
1. One-way delivery between two fresh accounts. Alpha receives bravo's
message, test1 passes on alpha's side, 113s. Bravo never shows alpha's, across
a full three-minute wait, on two separate runs. The direction is stable.
@@ -828,7 +826,7 @@ therefore either never fetched them or could not open them. The first reading
of this, "the app records messages it could not deliver", was **wrong**, and
reading `E2EInbox` is what disproved it. Do not act on that theory.
**2. Creating a group does not produce a group.** The sheet stayed open past two
2. Creating a group does not produce a group. The sheet stayed open past two
minutes; the tree captured at failure shows Home, the buddy row present, and the
Groups section reading "No groups yet. Start one with your buddies."
`createGroup` appends to `groups` on success and `groups` is `@Published`, so a
@@ -861,12 +859,12 @@ exactly why it works.
### Disproved, and narrowed further (same day)
**The `existing != nil` theory is WRONG.** The suspect was
The `existing != nil` theory is WRONG. The suspect was
`E2EInbox.swift:995`, where `ingest` returns `true`, consume the envelope,
rather than holding the cursor, whenever the recipient already has its own
session. Two things kill it:
1. `ratchet` is nil there **only when the envelope is not type `prekey`**. An
1. `ratchet` is nil there only when the envelope is not type `prekey`. An
inbound prekey envelope always builds a session and never reaches that guard,
so it cannot drop a first message.
2. Measured. `testARoundTripWithANonIOSClient` **passed in 54.2s**: iOS signed
@@ -882,10 +880,10 @@ iOS round trip → passed (54.234s)
So **non-iOS → iOS works even when the iOS client already holds an outgoing
session to that device.** Both directions are proven in isolation.
**What is left is genuine simultaneous initiation.** In the round trip the peer
What is left is simultaneous initiation. In the round trip the peer
received iOS's prekey and established its session *before* replying, so both
sides ended up on one chain. In the two-simulator case both sign up, both open
the chat, and both send **before either has processed the other's prekey**: so
the chat, and both send before either has processed the other's prekey: so
each creates its own session and then meets a message on a different chain.
**Leading hypothesis, not confirmed:** `E2EInbox.swift:1006` calls
@@ -934,11 +932,11 @@ p1/p2 poll {"messages":[]} {"messages":[]} ❌ both lost
p1/p2 poll second-from-Q2 / second-from-Q1 ✅ both arrive
```
**What the first attempt got wrong, and it is worth remembering.** Retaining
What the first attempt got wrong, and it is worth remembering. Retaining
the retired session was necessary; storing the WRONG OBJECT made it useless.
`DoubleRatchet` is a `final class` on iOS and `E2ESession` is a class on
Android, so the failed `decrypt` attempt that proves the peer is on another
chain **mutates the very instance it was handed**: skipped keys consumed, chain
chain mutates the very instance it was handed: skipped keys consumed, chain
stepped. Archiving that instance stores a chain that can no longer open
anything, which is indistinguishable from not archiving at all. The copy in the
vault was never handed to a decrypt, so it is the pristine one: **re-read it
@@ -979,7 +977,7 @@ and caught one thing that would have bounced the upload.
| Third-party SDK manifest | WebRTC ships its own (no ITMS-91061) |
| Trackers | none, grepped for firebase/analytics/crashlytics/sentry/etc. |
**The privacy manifest was a hard blocker.** Apple requires one for any app
The privacy manifest was a hard blocker. Apple requires one for any app
touching a "required reason" API, and this app uses `UserDefaults` in five files
mute and pin flags, the push toggle, the chosen relay. Without it the upload is
rejected **ITMS-91053 before it ever reaches TestFlight**. The first archive
@@ -1004,12 +1002,12 @@ be the first to exercise them.
Both platforms hide one contact's stories without blocking them. Local on each
device: no envelope, no relay call, and the author is never told.
**Stored in the vault, not shared preferences.** Who somebody has muted is a
Stored in the vault, not shared preferences. Who somebody has muted is a
statement about their social graph, and the point of the vault is that such a
statement is not readable by anything that reaches the file. iOS puts it in the
sealed history snapshot for the same reason.
**A muted contact's stories are still received and still stored.** The filter
A muted contact's stories are still received and still stored. The filter
runs on the way out to the strip, `Vault.visibleStories`, and `saveStories`
runs before it. Dropping them on receipt would make unmuting show an empty strip
until their next post, so muting would quietly mean something other than it says.
@@ -1020,14 +1018,14 @@ having failed to send.
## Screenshot notices (§6.27, ios#36)
A disappearing-message timer promises the message goes away; a screenshot breaks
that silently. It cannot be prevented on either platform, so it is reported.
**Both platforms refuse to let the absence of a notice mean anything.** The
Both platforms refuse to let the absence of a notice mean anything. The
device that took the screenshot is the device that reports it, so a modified
build, a second phone photographing the screen, or the platform's own recorder
produce nothing at all. A notice means one was probably taken; **no notice means
nothing**. Neither platform counts notices, a running total would be read as a
measurement, and its zero as "nobody screenshotted", which is exactly the
inference §6.27 forbids.
**The platforms genuinely differ, and the copy says so rather than hiding
**The platforms differ, and the copy says so rather than hiding
it.** iOS reports a screenshot to the foreground app reliably. Android has no
callback at all before API 34, and the one added there does not cover screen
recording. So the disclaimer says "you are told when their app can tell", and
@@ -1037,17 +1035,17 @@ does not exist would be worse than the gap, because somebody would rely on it.
general surveillance feature rather than the narrow one it is. The timer check
runs BEFORE the rate-limit check on both, so a suppression window cannot make an
untimed conversation start reporting.
**Last-notice times are in memory on both.** Persisting them would store who
Last-notice times are in memory on both. Persisting them would store who
screenshotted whom, on disk, to enforce a five-second window.
## Text and link stories (§6.21, ios#32)
A story may carry no attachment; the caption is then the whole story. Both
platforms encode, decode, store, render and post one.
**The decode sits BEFORE the attachment branch on both.** That branch
The decode sits BEFORE the attachment branch on both. That branch
requires `att` for `kind: "story"`; a text story failing it falls through to
the ordinary-message path and appears as a chat bubble in the conversation that
never expires, both of the things §6.6 says a story must not be. Each engine
has a test named for exactly that.
**`ref` is nullable/optional rather than a sentinel**, so nothing can read a
`ref` is nullable/optional rather than a sentinel, so nothing can read a
blob id off a story that has no blob. Both platforms therefore need a `key` for
seen-marks and de-duplication: with no blob id, a text story is named by what
cannot repeat for one author, when it was posted and what it said.
@@ -1060,14 +1058,14 @@ and both composers say so instead of implying more.
## Saving your own story (ios#34)
Both platforms can write a story you posted yourself out to the device gallery
before it expires. Local only; no protocol change.
**Only ever your own, and the rule lives in the engine**: `Vault.isSaveable`
Only ever your own, and the rule lives in the engine: `Vault.isSaveable`
on Android, `E2EInbox.isSaveable` on iOS, rather than in the button. Saving
somebody else's story keeps a copy of something they were told would disappear,
and a guard living in a view is one refactor away from being lost. Both are
tested from both directions.
**Neither asks for broad storage access.** Android writes through
`MediaStore`, which needs no runtime grant; iOS asks for `.addOnly`
authorisation. Requesting read access in order to perform a write would be a
authorisation. Requesting read access to perform a write would be a
much larger ask than the feature.
**iOS needs `NSPhotoLibraryAddUsageDescription`**: without it the app is
terminated on first save rather than warned. Android has no equivalent trap.
@@ -1076,32 +1074,32 @@ it went. "Saved" alone would let somebody assume the expiry still covered it.
## Story replies (§6.20, ios#30)
A reply to a story is an ordinary direct message to its author carrying
`sre.sid`. Stories gain no comment thread and no viewer list.
**`sre` carries the id and nothing else**: no caption, no thumbnail, no
`sre` carries the id and nothing else: no caption, no thumbnail, no
blob. This diverges from §6.9's `re` on purpose: a quoted message may be one
the recipient never had, but a story's author had it by definition, so the only
way they no longer hold it is that their own timer removed it. Sending a copy
back would resurrect it at a moment they did not choose.
**`Content.text` returns raw bytes when it has nothing to carry**, so
`Content.text` returns raw bytes when it has nothing to carry, so
`storyReplyId` had to join that condition. Left out, a reply with no message id
and no quote would go out as plain text and arrive with the reference silently
dropped, on exactly the path a story reply normally takes.
**The disclosure comes before the first reply**, on both platforms.
The disclosure comes before the first reply, on both platforms.
Replying tells the author you watched, which stories otherwise never reveal.
Neither platform sends anything automatic when a story is merely opened.
## Private story archive (ios#54)
Your own stories, kept past their expiry. Local only; no protocol change.
**This keeps something the interface told everybody would disappear**, so
This keeps something the interface told everybody would disappear, so
the shape is the argument for it being acceptable: off unless switched on,
labelled as what it is rather than as a feature, and **only ever your own**.
`Vault.archive` / `E2EInbox.archive` refuse anything not the author's own, the
callers filter too, but these are the guards that survive a caller being
rewritten. Both are tested from both directions.
**Archiving happens at the moment expiry is applied**, which is the only
Archiving happens at the moment expiry is applied, which is the only
moment it can. iOS catches it on restore (a story past its expiry is never
loaded into `stories`); Android catches it in `Session.stories()` before
`saveStories` drops it. Both run repeatedly, so the fold is idempotent,
otherwise one long-expired story would be appended on every pass.
**The archive is not swept by the expiry rule.** Everything in it is expired
The archive is not swept by the expiry rule. Everything in it is expired
by definition, so reusing `saveStories` would empty it on the next write.
Android has a separate record and a test named for exactly that.
Android injects the setting into `Session` as a lambda rather than reading
@@ -1110,12 +1108,12 @@ value reads as OFF on both platforms.
## Multi-item stories (§6.23, ios#53)
Several pictures as one sequence. Each item is an ordinary story envelope
carrying a `set` that names the sequence, its position and its size.
**One envelope per item, not one carrying an array.** The array is the
One envelope per item, not one carrying an array. The array is the
obvious encoding and degrades badly: a client predating §6.23 requires a single
`att`, fails that guard, and renders the whole set as a message in the
conversation. Separate envelopes degrade to what they actually are, several
stories from one person, in order, which every existing build already draws.
**Expiry is per set, and the earliest stamp wins.** Items are required to
Expiry is per set, and the earliest stamp wins. Items are required to
carry the same `exp`, but a sender can write different ones; if the latest won,
the difference would keep part of a sequence alive after the rest had gone. That
decay is confusing to look at *and* a disclosure, the gaps reveal the order the
@@ -1128,13 +1126,13 @@ no set are keyed so two of them cannot collide into one group.
## Story reactions (§6.22, ios#52)
One tap, sealed to the author like a story reply.
**Decoded before §6.9's reaction path on both platforms.** That path requires
Decoded before §6.9's reaction path on both platforms. That path requires
`re`; a story reaction carries `sre` instead. A story is not in the conversation
and has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave it
unhandled and drawn as an empty line in the thread. Both engines test it in both
directions, including that an ordinary thread reaction is untouched.
**Its own disclosure and its own stored key** on both, `reactDisclosureSeen`,
Its own disclosure and its own stored key on both, `reactDisclosureSeen`,
not the reply one. A reaction discloses exactly what a reply discloses; what
differs is deliberation. A reply is typed; a reaction is one tap on something
already filling the screen, so an account told about replies has been warned
@@ -1153,10 +1151,10 @@ message part of the group cannot see. Both platforms expose `groupStoryReach`,
which returns *the overlap* rather than a warning, who is excluded by the
story audience, and who is not a contact at all, counted separately, since a
non-contact was never on that list.
**The story branch runs BEFORE group routing on both platforms.** Falling
The story branch runs BEFORE group routing on both platforms. Falling
through would render a story as a message in the group's conversation: a story
that is a message, and one that never expires out of the thread.
**Android adopts the group label only from an actual member.** Its roster
Android adopts the group label only from an actual member. Its roster
check guards group *messages* and runs after the story branch, so without this a
sender could attach the id of a group they are not in and have their story shown
as having come from it. The label is dropped to null rather than the story
@@ -1168,21 +1166,21 @@ extend it.
Blocking existed in a conversation and nowhere near a story, the surface a
stranger is most likely to reach somebody through. Reporting did not exist at
all, on either platform or in the relay.
**A report is the one thing either app sends that the server can read.**
A report is the one thing either app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report about
one is only actionable if the reporter hands over what they saw. Both platforms
state that before sending, in those words: what leaves the device, who can read
it, and that it cannot be undone. Declining to include a copy is offered on
both, and says plainly there may then be nothing to act on.
**Both send only what they were passed.** Neither reaches into the thread for
Both send only what they were passed. Neither reaches into the thread for
surrounding context, §6.25 forbids including anything the reporter did not
choose to include, and a helpful "we also attached the conversation" would be
exactly the wrong kind of help.
**Blocking and reporting are offered together and never merged**, on both.
Blocking and reporting are offered together and never merged, on both.
Blocking is the reporter's own decision and takes effect now; a report is a
request to somebody else and nothing may come of it. A combined control would
make one of the two invisible.
**The reasons are enumerated to match the relay**, with a test on each
The reasons are enumerated to match the relay, with a test on each
platform asserting the lists are equal, a reason the relay rejects is a report
somebody believed they had filed. CSAM is its own reason on both rather than
folded into abuse: it carries a different obligation, and an operator cannot
@@ -1200,8 +1198,8 @@ message the server could never read anyway.
**Aggregate counts only.** Per-contact counts would be more useful and would
also build a record of who this account talks to and how often, the precise
thing sealing exists to keep off a server, kept instead on the device.
**Zero sends reports as "nothing sent yet"**, never a reassuring 0%.
**Refusing costs nothing that was already spent.** On both platforms the
Zero sends reports as "nothing sent yet", never a reassuring 0%.
Refusing costs nothing that was already spent. On both platforms the
gate sits where `sealed` is false exactly when no capability was taken, so
refusing cannot burn a token per attempt and drain the contact's batch.
Android injects the policy and the counter into `Conversations`/`Session` as
+1 -1
View File
@@ -35,7 +35,7 @@ attachments, voice notes, stories, calls, sealed sender, key transparency and
the encrypted vault are built and tested; a cross-platform round trip against
iOS was verified on 2026-08-26.
**This file said "Nothing here is an app yet" until 2026-08-28**, above a
This file said "Nothing here is an app yet" until 2026-08-28, above a
tree containing the finished app, the README described the repository as it
was when the engine landed and nothing updated it as the app grew around it. If
a claim here ever disagrees with PARITY.md or with the tree, believe those, and
+5 -5
View File
@@ -100,8 +100,8 @@ android {
buildTypes {
release {
// **WebRTC ships native libraries for four ABIs and they
// dominate the APK**, about 41 MB uncompressed, of which x86 and
// WebRTC ships native libraries for four ABIs and they
// dominate the APK, about 41 MB uncompressed, of which x86 and
// x86_64 are ~24 MB and exist only for emulators. Real phones are
// arm64-v8a, with armeabi-v7a for old ones.
//
@@ -148,8 +148,8 @@ android {
all {
it.systemProperty("robolectric.graphicsMode", "NATIVE")
// **The screenshot tests are debug-variant tests, and
// `./gradlew test` runs BOTH variants.**
// The screenshot tests are debug-variant tests, and
// `./gradlew test` runs BOTH variants.
//
// They launch a `ComponentActivity`, which is declared by
// `androidx.compose.ui:ui-test-manifest`, a `debugImplementation`
@@ -180,7 +180,7 @@ dependencies {
implementation(project(":client"))
// A direct dependency rather than leaning on `:client` to re-export it.
// The settings screen reads bridge lines and the handshake counter itself,
// so the app genuinely uses these types; borrowing them through another
// so the app uses these types; borrowing them through another
// module's classpath would make `:client` unable to change its own
// dependencies without breaking a screen that never mentioned it.
implementation(project(":obfs4"))
@@ -29,7 +29,7 @@ import org.junit.runner.RunWith
*
* Run with `adb shell am instrument`, not `connectedAndroidTest`, which
* uninstalls the app afterwards and takes the vault, and therefore the account
* with it. The whole point is that the account survives between steps.
* with it. The point is that the account survives between steps.
*/
@RunWith(AndroidJUnit4::class)
class CrossPlatformTest {
@@ -31,8 +31,8 @@ import androidx.compose.ui.unit.sp
* platforms disagreeing about a security guarantee is how one of them ends up
* being the one that leaks.
*
* **What this defends against, stated plainly, because the alternative is
* implying more:** somebody who picks up your unlocked phone. That is the
* What this defends against, stated plainly, because the alternative is
* implying more: somebody who picks up your unlocked phone. That is the
* threat people actually have, and it is worth something.
*
* What it does not defend against: anyone with the device and the tools to
@@ -168,8 +168,8 @@ class AppLock(private val context: Context) {
/**
* When the next attempt is allowed, or null if one is allowed now.
*
* **Wall-clock, and somebody who can change the device clock can skip
* the wait.** Said rather than hidden: this slows a person holding the
* Wall-clock, and somebody who can change the device clock can skip
* the wait. Said rather than hidden: this slows a person holding the
* phone, and it is not a defence against somebody with the device and the
* patience to work at it. What actually protects the messages is the
* keystore, exactly as the note at the top of this file says.
@@ -186,7 +186,7 @@ class AppLock(private val context: Context) {
}
/**
* Only a genuine wrong answer counts. Dismissing the prompt is not an
* Only a wrong answer counts. Dismissing the prompt is not an
* attempt, counting a cancel would let the owner back themselves into a
* wait by changing their mind twice. See the error handling in
* [authenticate], which is where that distinction is actually made.
@@ -238,8 +238,8 @@ class CallManager(
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
// **Nothing may throw out of here, and nothing may call back
// into WebRTC from here.** This runs on WebRTC's signaling thread,
// Nothing may throw out of here, and nothing may call back
// into WebRTC from here. This runs on WebRTC's signaling thread,
// and its JNI asserts RTC_CHECK(!env->ExceptionCheck()) on the way
// out, so ANY escaping Kotlin exception aborts the process:
// SIGABRT, no AndroidRuntime trace, nothing to catch.
@@ -649,8 +649,8 @@ fun ColumnScope.NotificationsScreen(
return
}
// **Rows on the kit's 72dp pitch with an avatar, like every other list
// in the app.** This was a lazy list of two stacked text lines with an 8dp
// Rows on the kit's 72dp pitch with an avatar, like every other list
// in the app. This was a lazy list of two stacked text lines with an 8dp
// dot beside the unread ones, a different rhythm, a different type scale
// and a different unread marker from the contact list two taps away. One
// app, one row grammar.
@@ -2008,8 +2008,8 @@ fun ColumnScope.ChatScreen(
)
}
DropdownMenuItem(
// **Said "Unsend" on your own messages and did
// nothing of the kind (android#18).** `deleteMessage`
// Said "Unsend" on your own messages and did
// nothing of the kind (android#18). `deleteMessage`
// reaches `vault.deleteLine` and stops there, no
// envelope leaves the phone, and the recipient's copy
// stays exactly where it is. Somebody who sent to the
@@ -2367,7 +2367,7 @@ private fun ColumnScope.VoiceComposer(
// Only the ACTIVE recording. Starting one is the mic icon in the
// composer row now; this is the timer and the two decisions, which the kit
// has no equivalent for and which genuinely need a line of their own.
// has no equivalent for and which need a line of their own.
val seconds = chat.recordingSeconds ?: return
run {
Row(
@@ -2467,8 +2467,8 @@ private fun Bubble(
Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("forwarded-marker"))
}
// **A message nobody received must not look like one that was
// received.** That sameness is the whole of what makes a ghost
// A message nobody received must not look like one that was
// received. That sameness is the whole of what makes a ghost
// message possible; every specific cause behind one is downstream
// of it. Only `false` marks, null means nothing was claimed, which
// is every received message and everything stored before the field
@@ -2964,8 +2964,8 @@ private fun ColumnScope.GroupMembers(
) {
Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall)
Row {
// **The only way a group gets a name at all after the first
// one.** Names used to be fixed at creation and served by the
// The only way a group gets a name at all after the first
// one. Names used to be fixed at creation and served by the
// relay; the relay has stopped holding them, so every group made
// before that change reads as a placeholder on every phone and
// there is nobody left to ask what it was called.
@@ -3250,8 +3250,8 @@ fun ColumnScope.AddContactForm(
return
}
// **A scanned code is an ADDRESS, and this form treated everything
// as a username.** The scanner already prefills here, so somebody who
// A scanned code is an ADDRESS, and this form treated everything
// as a username. The scanner already prefills here, so somebody who
// scanned a card from another relay got their address looked up as a
// handle on ours, was told no such person exists, and had no other way
// in. One field either way, what it is, is a fact about the text, not
@@ -4124,8 +4124,8 @@ private fun EditMessageSheet(
/**
* Pass a message's words on to another conversation (§6.16).
*
* **The list is people and groups, and the message travels without its
* author.** What is sent is the text with `fwd` set, no original thread, no
* The list is people and groups, and the message travels without its
* author. What is sent is the text with `fwd` set, no original thread, no
* name, no quote. A forward that named its source would republish a private
* message's author to somebody who was never in that conversation, which is
* the one thing §6.16 exists to prevent.
@@ -13,8 +13,8 @@ import android.content.Context
* Security, and somebody switching them off to disappear would have been wrong.
* They were renamed on 2026-09-06; these are named that way from the start.
*
* **There is no last-seen setting here, because Android does not show last
* seen at all.** iOS does. That is a missing feature rather than a missing
* There is no last-seen setting here, because Android does not show last
* seen at all. iOS does. That is a missing feature rather than a missing
* toggle, and adding a switch for something the app never draws would be the
* exact decoration the parity scrub exists to catch.
*/
@@ -118,8 +118,8 @@ private fun ColumnScope.FilesSetUp(
var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
// **"A passphrase of their own" told people to invent a second secret,
// and iOS never did.** iOS says "your passphrase" and takes whatever is
// "A passphrase of their own" told people to invent a second secret,
// and iOS never did. iOS says "your passphrase" and takes whatever is
// typed, so a person naturally uses their Flamenet one and it simply
// works. Android's copy sent them off to make up something else, then
// asked for it again weeks later, and forgetting it is the one-way door
@@ -425,8 +425,8 @@ private fun bytes(count: Int): String = when {
// ---- Sheets --------------------------------------------------------------
/**
* **The choice between copying and linking is made here, with both consequences
* written out**, rather than by a default somebody discovers later. They are not
* The choice between copying and linking is made here, with both consequences
* written out, rather than by a default somebody discovers later. They are not
* two ways of doing one thing: one produces ciphertext this app owns, the other
* produces a pointer at a file it does not.
*/
@@ -641,8 +641,8 @@ fun ColumnScope.PickFileToSendSheet(
fontSize = 12.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
// **The photo button strips location; this one does not
// (android#17).** A picture attached from the picker is re-encoded
// The photo button strips location; this one does not
// (android#17). A picture attached from the picker is re-encoded
// through ImagePrep and video through MediaScrub, so no metadata
// container survives. `Session.sendFile` passes the vault's bytes
// straight through, EXIF and all, correct for "send this file",
@@ -87,8 +87,8 @@ object ImagePrep {
/**
* Decode bytes that came from somebody else, bounded.
*
* **The render paths were calling `decodeByteArray` with no options at
* all.** Sending was careful, bounds first, then a sampled decode, but
* The render paths were calling `decodeByteArray` with no options at
* all. Sending was careful, bounds first, then a sampled decode, but
* the two places that draw an image RECEIVED from a peer decoded at full
* size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly
* chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A
@@ -97,7 +97,7 @@ object ImagePrep {
* way for the victim to get past it.
*
* Bounds are read first so an enormous picture is never fully decoded
* just to discover how big it is. That is the whole point; a size check on
* just to discover how big it is. That is the point; a size check on
* the byte count alone does not bound the decoded bitmap.
*/
fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? {
@@ -21,8 +21,8 @@ import javax.crypto.SecretKey
* The key does not leave this device, so neither does the vault. It is
* generated here on first launch and is not exportable or backupable, which is
* the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A
* consequence worth stating plainly: **uninstalling the app destroys the key,
* and every stored conversation with it.** That is the intended behaviour,
* consequence worth stating plainly: uninstalling the app destroys the key,
* and every stored conversation with it. That is the intended behaviour,
* "deleted means deleted", but it means the vault is not a backup, and the
* manifest turns `allowBackup` off so the records cannot be restored somewhere
* their key can never follow.
@@ -88,7 +88,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
// Two platforms disagreeing about a security guarantee is how one of
// them ends up being the one that leaks.
//
// It also blocks screen recording and casting, which is the point,
// It also blocks screen recording and casting,
// and is not something to make optional: a setting here is a setting
// people leave off and then get surprised by.
window.setFlags(
@@ -223,8 +223,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// already happening.
CallBanner(model, current)
StoryComposerDialog(model, current)
// **Here, above the screen switch, for the same reason
// the call banner is.** Nothing installs this app but the
// Here, above the screen switch, for the same reason
// the call banner is. Nothing installs this app but the
// person holding the phone, so the one moment they can be
// told a fix exists is while they have it open, whatever
// screen that happens to be.
@@ -236,8 +236,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
myUserId = current.userId, groups = current.groups)
} else {
// **The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar
// The content area is weighted, and the tab bar is
// not. These used to be plain siblings, so the tab bar
// sat immediately under whatever the screen had drawn:
// near the TOP of an empty Alerts tab, and pushed clean
// OFF the bottom by the Account tab, which is taller
@@ -326,8 +326,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// Only a relay keeping its own accounts needs an address for reset mail.
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
// **The kit's Welcome screen, which is a masthead and not a form
// heading.** A logo mark, a 40pt greeting, and one line saying what this
// The kit's Welcome screen, which is a masthead and not a form
// heading. A logo mark, a 40pt greeting, and one line saying what this
// is, centred, with room above it. This was a 24pt product name jammed
// against a Server field, which is the layout of a login dialog rather
// than the first thing anyone sees of the app. See `DESIGN.md`.
@@ -752,8 +752,8 @@ internal fun ColumnScope.SignedIn(
return
}
// **Home is two things now, on purpose: your people, and their
// stories.** The conversation list, the groups and the search moved to
// Home is two things now, on purpose: your people, and their
// stories. The conversation list, the groups and the search moved to
// the Messages tab, this screen is the kit's social front page: a
// horizontal row of contacts, then everyone's stories. Managing the
// roster stays here (the Add action, and a long-press on a circle for
@@ -108,8 +108,8 @@ class MediaSettings(context: Context) {
/**
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
*
* **Off by default, and that is the honest default rather than the lazy
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
* Off by default, and that is the honest default rather than the lazy
* one. An unsealed send is still end-to-end encrypted; what leaks is that
* this account sent something to that one. Refusing turns a metadata leak
* into an undelivered message, which for most people is the worse outcome
* so the default degrades and SAYS SO.
@@ -139,8 +139,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* The relays that hold mail for this account (§20.4).
*
* **Your address survives a relay disappearing; your mailbox
* does not.** Mail sitting at a relay that goes away is gone, and a
* Your address survives a relay disappearing; your mailbox
* does not. Mail sitting at a relay that goes away is gone, and a
* contact holding only that relay's name has nowhere to write. A
* second home fixes both, and only if it was added first, because
* afterwards there is nothing left to publish a new record from.
@@ -460,8 +460,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Put this device back in the transparency log if it is not in it (android#6).
*
* **A device registers exactly once, at sign-in, and never again, so a
* key-log append that did not land is permanent.** `signIn` calls
* A device registers exactly once, at sign-in, and never again, so a
* key-log append that did not land is permanent. `signIn` calls
* `registerDevice`; `restore` did not. If the append failed for any reason
* a transient write, a relay mid-restart, the device stays registered
* with no leaf, and every peer that enforces the log then refuses to send
@@ -501,8 +501,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Forget whatever the last sign-in attempt said.
*
* **Switching between signing in and creating an account has to do
* this.** The message belongs to the mode it was produced in, "that
* Switching between signing in and creating an account has to do
* this. The message belongs to the mode it was produced in, "that
* handle is already taken" is advice about signing up, and it stayed on
* screen after the person took that advice and switched to signing in,
* where it reads as a refusal of the thing they are now trying to do.
@@ -600,8 +600,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
val credential = account.appPassword
// **The address, derived here because this is the only moment the
// passphrase exists.** Android could encode an address and had no way
// The address, derived here because this is the only moment the
// passphrase exists. Android could encode an address and had no way
// to produce its own, so the Account screen could not show one and
// nobody on another relay could be given one, the federation work was
// iOS-only in practice. A shared account gets the seed back from the
@@ -623,8 +623,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
val vault = vault ?: error("the vault is not open")
// **The stored identity FIRST, and this line is why signing out and
// back in used to cost every contact a prompt.** Sign-in only ever
// The stored identity FIRST, and this line is why signing out and
// back in used to cost every contact a prompt. Sign-in only ever
// consulted the in-memory field, and sign-out clears it, so it minted
// a fresh identity and overwrote the vault's, making this phone a brand
// new device on every sign-in. Contacts saw a safety-number change,
@@ -902,7 +902,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching {
val record = current.relay.homes(fnid) ?: return@runCatching null
// And not older than one already seen. An old record is
// genuinely signed, published before a second home was added,
// signed, published before a second home was added,
// so a relay wanting to be somebody's only mailbox does not
// have to forge anything; it just serves the record it saw
// first. The signature check alone accepts that, which is what
@@ -913,7 +913,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// Advanced only after the record was ACCEPTED. Recording the
// serial of a record that failed verification would let a
// hostile relay raise the floor with a forgery and lock out
// every later genuine record.
// every later record.
io.flamenet.client.AccountIdentity.verifiedSerial(record)
?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) }
relays
@@ -1174,8 +1174,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Rename a group, and tell the other members.
*
* **The only way a group gets its name back after the relay stopped
* holding one.** Names were fixed at creation and served by the relay for
* The only way a group gets its name back after the relay stopped
* holding one. Names were fixed at creation and served by the relay for
* ever, so nothing needed this. Now the server has dropped them, every
* group that existed before the change reads as the placeholder on every
* phone, and there is nobody left to ask what it used to be called, the
@@ -1351,8 +1351,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Make a wallet, and show the phrase once.
*
* **The phrase is shown and not stored anywhere it can be read back
* casually.** It is in the returned state for as long as the screen holds
* The phrase is shown and not stored anywhere it can be read back
* casually. It is in the returned state for as long as the screen holds
* it, and the record on disk is the ENTROPY, the words are regenerated
* from it when somebody deliberately asks. Somebody who never writes them
* down and later erases the device has nothing, which is what a recovery
@@ -1817,8 +1817,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Send a document from the vault into this conversation.
*
* **This is a downgrade, and the screen says so before the tap rather
* than after.** The content key goes in the same envelope, so the
* This is a downgrade, and the screen says so before the tap rather
* than after. The content key goes in the same envelope, so the
* classification does not stop the recipient reading it, it travels so
* their phone files it the same way. Nothing here refuses on the label,
* because nothing here could: refusing in a client whose user holds the
@@ -1868,8 +1868,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* phone to point at, the bytes arrived over the wire, so the two storage
* modes are not a choice here.
*
* **Filed under the label it arrived with, not under one this device
* chose.** Re-labelling somebody else's document on receipt would quietly
* Filed under the label it arrived with, not under one this device
* chose. Re-labelling somebody else's document on receipt would quietly
* launder its classification, and the number is the only thing that survived
* the trip.
*/
@@ -2389,8 +2389,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// WebRTC callback thread it is raised from.
viewModelScope.launch(Dispatchers.IO) {
runCatching { session?.sendCallSignal(peerId, signal) }
// **This was `runCatching { }` and nothing else, so
// every failure here vanished.** The one that matters
// This was `runCatching { }` and nothing else, so
// every failure here vanished. The one that matters
// is a changed key: the send path REFUSES to encrypt to
// an unreviewed identity. TOFU working exactly as
// intended, and the exception was swallowed, so the
@@ -2585,8 +2585,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Ask to place a call.
*
* **The first call on this device explains what a direct connection
* discloses, and waits.** A peer-to-peer call shows the other person your
* The first call on this device explains what a direct connection
* discloses, and waits. A peer-to-peer call shows the other person your
* IP address; that is what makes it direct, and it is not something to
* find out afterwards. Asked once, not every time, a prompt people see
* repeatedly is a prompt they stop reading.
@@ -2670,7 +2670,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* The live manager, for the call screen's video renderers only. Views
* otherwise take plain values; a video surface is the one thing that
* genuinely needs the track objects and the shared EGL context.
* needs the track objects and the shared EGL context.
*/
fun activeCallManager(): CallManager? = calls
@@ -3726,8 +3726,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* End the session, and optionally erase this phone with it.
*
* Signing out used to cost every one of your contacts a prompt. It
* destroyed the vault unconditionally, so signing back in was a genuinely
* NEW device: every contact had the old key pinned, saw a safety-number
* destroyed the vault unconditionally, so signing back in was a * NEW device: every contact had the old key pinned, saw a safety-number
* change, and could not message you until they accepted it. For someone
* stepping away for an afternoon that is a strange price to pay, and it is
* why signing out and back in kept looking like the app breaking.
@@ -3742,8 +3741,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/**
* Throw away a vault that cannot be opened, so sign-in is possible again.
*
* **The "Erase and start fresh" button used to call `signOut()`, which
* erases nothing.** `signOut` defaults to `erasingDevice = false`, the
* The "Erase and start fresh" button used to call `signOut()`, which
* erases nothing. `signOut` defaults to `erasingDevice = false`, the
* door that deliberately KEEPS the device so coming back is silent, so it
* set the phase to SignedOut and left the unreadable vault exactly where it
* was. The next `restore()` opened it, failed again, and went straight back
@@ -7,8 +7,8 @@ import java.io.ByteArrayOutputStream
/**
* Strips metadata from an image on its way out, whichever door it left by.
*
* **A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
* not** (android#17, ios#69). The picker path runs through [ImagePrep], which
* A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
* not (android#17, ios#69). The picker path runs through [ImagePrep], which
* decodes to a bitmap and re-encodes, the output is pixels, with no container
* to carry EXIF. `Session.sendFile` handed the stored bytes straight to
* `sendAttachment`. Somebody who had learned "Flamenet strips location from my
@@ -76,8 +76,8 @@ fun ColumnScope.NetworkSection(
modifier = Modifier.testTag("obfs4-status"),
)
// **Offers from contacts, and the reason they are a list and not a
// setting.** A bridge line routes every relay connection through a machine
// Offers from contacts, and the reason they are a list and not a
// setting. A bridge line routes every relay connection through a machine
// somebody else chose. Nothing applies one on arrival, a contact, or
// whoever has taken their account, could otherwise move this phone's
// traffic onto a host they control. They could not read it, but they would
@@ -37,8 +37,8 @@ class NotificationSettings(context: Context) {
/**
* Put the sender's name, and a hint of the message, on the lock screen.
*
* **Off by default, and that is the honest default rather than the
* cautious one.** Turning it on is a real choice with a real cost: anyone
* Off by default, and that is the honest default rather than the
* cautious one. Turning it on is a real choice with a real cost: anyone
* who can see the phone can see who is writing to you, which on some
* phones is a more sensitive fact than what they said. The people for whom
* that is fine can have it; nobody should acquire it by installing an
@@ -74,8 +74,8 @@ class NotificationSettings(context: Context) {
companion object {
/**
* **Pure, and takes the preference as an argument, so a test can
* call THIS rather than a copy of it.** The first version of the test
* Pure, and takes the preference as an argument, so a test can
* call THIS rather than a copy of it. The first version of the test
* beside this file reimplemented the rules against a fake and passed
* perfectly while asserting nothing about the code that ships, the
* same shape as two identically-wrong engines agreeing. A `Context` is
@@ -54,8 +54,8 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
}
/**
* **Opening the stream, not asking the provider whether the file
* exists.** `DocumentsContract` will happily describe a document that can no
* Opening the stream, not asking the provider whether the file
* exists. `DocumentsContract` will happily describe a document that can no
* longer be read, a cloud provider with the account signed out is the
* everyday case, and a row that says it is fine and then fails on tap is
* the thing this whole distinction exists to avoid.
@@ -44,8 +44,8 @@ class Session(
/**
* What identifies this message to the other side.
*
* **Null for anything stored before ids existed, and that is the
* whole compatibility story.** A reaction, an edit and a pin all say
* Null for anything stored before ids existed, and that is the
* whole compatibility story. A reaction, an edit and a pin all say
* "this one" by id; a line with none simply cannot be the subject of
* any of them. Making one up on read would be worse, two devices
* would invent different ids for the same message and a reaction would
@@ -427,15 +427,15 @@ class Session(
/**
* Delete cached attachment bytes that nothing refers to any more.
*
* **`attachmentBytes` writes DECRYPTED bytes to the cache and nothing
* ever removed them.** So a disappearing message expired, its bubble went,
* `attachmentBytes` writes DECRYPTED bytes to the cache and nothing
* ever removed them. So a disappearing message expired, its bubble went,
* and the plaintext photo or voice note stayed on disk indefinitely, which
* is the exact failure the feature exists to prevent. Same for a story once
* its expiry passed. Spec §6.11 says it plainly: attachments MUST be purged
* with the message that referenced them.
*
* **A sweep, not a purge at the point of deletion, and that is a
* deliberate choice.** Four different paths drop a row, expiry, story
* A sweep, not a purge at the point of deletion, and that is a
* deliberate choice. Four different paths drop a row, expiry, story
* expiry, delete-for-me and clear-thread, and threading "here is what I
* removed" out of each one would still miss anything a future path drops,
* and would never reach the orphans already on disk. Asking instead "what
@@ -501,8 +501,8 @@ class Session(
/**
* Send, and record it either way, with what happened.
*
* **This used to record only on success, and that is how a message
* disappeared.** Sent to somebody signed out, nothing reached a device, so
* This used to record only on success, and that is how a message
* disappeared. Sent to somebody signed out, nothing reached a device, so
* nothing was written and nothing was shown: the sender watched their own
* text vanish out of the box with no bubble and no error, and sent it
* again to see whether it would stick. The alternative the group path took
@@ -529,8 +529,8 @@ class Session(
/**
* The message this one answers (android#16).
*
* **The preview inside it is the sender's own copy of the quoted
* words, and it has to travel.** A recipient who joined the thread
* The preview inside it is the sender's own copy of the quoted
* words, and it has to travel. A recipient who joined the thread
* later, or cleared their history, has never seen the message being
* answered, a reply rendering as "> [missing]" is worse than one that
* shows what was said.
@@ -917,8 +917,8 @@ class Session(
* receiving side enforces the same thing (`applyEdit` refuses an edit from
* anybody but the author), so neither end is trusting the other.
*
* **Applied locally first, then sent, and rolled back if the send
* fails**, the same shape as `react`. Waiting on a round trip to see your
* Applied locally first, then sent, and rolled back if the send
* fails, the same shape as `react`. Waiting on a round trip to see your
* own correction appear makes it feel broken on a slow connection, and
* leaving a correction that exists only on this phone is worse: you would
* believe you had fixed it.
@@ -974,7 +974,7 @@ class Session(
* Pass somebody's words on to another conversation (§6.16).
*
* The forward carries the words and NOT where they came from. No
* author, no original thread, no quote, that is the whole point of §6.16,
* author, no original thread, no quote, that is the point of §6.16,
* and it is why this sends a plain text envelope with `fwd` set rather than
* a reply that references the original. A forward that named its source
* would republish a private message's author to somebody who was never in
@@ -1124,7 +1124,7 @@ class Session(
// restating on the first success would mark it delivered while other
// members are still owed it, and "will send" would quietly stop being
// true for them. Until the set drains, PENDING remains the honest
// label, the queue genuinely will keep sending.
// label, the queue will keep sending.
for ((thread, at) in groupDelivered) {
var remaining = false
for (i in 0 until stillWaiting.length()) {
@@ -1541,8 +1541,8 @@ class Session(
/**
* Post a story to a group (§6.24).
*
* **The group's roster is the audience, and `storyAudience` does NOT
* apply.** Two different lists making two different statements, who may
* The group's roster is the audience, and `storyAudience` does NOT
* apply. Two different lists making two different statements, who may
* see this account's stories, and who is in a conversation, and they will
* disagree. The roster wins: a story addressed to a group that silently
* dropped members would be a group message part of the group cannot see.
@@ -1608,8 +1608,8 @@ class Session(
/**
* File a report about a MESSAGE (§6.25, android#20 / ios#12).
*
* **`kind = "message"` has always been accepted and nothing ever sent
* one.** Reporting existed on stories only, so the surface where
* `kind = "message"` has always been accepted and nothing ever sent
* one. Reporting existed on stories only, so the surface where
* unsolicited contact, spam and harassment actually arrive had no way to
* tell anybody. The person being messaged by a stranger had Block, which
* protects them and tells the operator nothing, so the account moves on
@@ -1936,14 +1936,14 @@ class Session(
/**
* Add or remove members of a sealed group.
*
* **Owner only, and the relay checks a signature rather than an
* account.** We hold the owner's private key iff we created the group; a
* Owner only, and the relay checks a signature rather than an
* account. We hold the owner's private key iff we created the group; a
* member who is not the owner has nothing to sign with, and is refused here
* rather than sent to be rejected.
*
* The version must increase. A roster signature stays valid for ever, so
* a stale roster could otherwise be replayed to re-add somebody who was just
* removed, with a signature that genuinely verifies.
* removed, with a signature that verifies.
*/
fun updateSealedGroupMembers(gid: String, add: List<Long>, remove: List<Long>) {
val secrets = vault.groupSecrets(gid)
@@ -2032,7 +2032,7 @@ class Session(
/**
* Seal a group that already exists in the clear, keeping its gid.
*
* The whole point of the feature for anybody who already has groups.
* The point of the feature for anybody who already has groups.
* The migration is additive by design, the relay keeps a plaintext roster
* until a client replaces it, so a group made before sealing stays readable
* in the database for ever unless something does this.
@@ -2131,7 +2131,7 @@ class Session(
* Adopt the group-name claims that arrived as control traffic, checking
* each against the relay's roster.
*
* The check is the whole point. A claim naming a group we are not in
* The check is the point. A claim naming a group we are not in
* or one from somebody who is not in it, is dropped. Without that,
* anyone able to reach us could write whatever they liked into our chat
* list under a group heading the reader has no reason to doubt.
@@ -2150,8 +2150,8 @@ class Session(
* The only way these ever reach us. The relay holds neither, by
* construction, so a member who is not told cannot fetch the group at all.
*
* **Not subject to the roster check the NAME goes through, and cannot
* be.** That check asks the relay who is in the group, and for a group we
* Not subject to the roster check the NAME goes through, and cannot
* be. That check asks the relay who is in the group, and for a group we
* have not joined yet, we cannot fetch it to ask. What guards this instead
* is that an invitation is accepted only for a group we hold no secrets
* for: a stranger cannot overwrite a group we are really in, which would
@@ -32,8 +32,8 @@ data class SiteConfig(
/**
* The probe was made and came back with nothing.
*
* **Without this, "still asking" and "asked and could not reach it" are
* the same state.** `discovered` is null in both, so a relay that never
* Without this, "still asking" and "asked and could not reach it" are
* the same state. `discovered` is null in both, so a relay that never
* answers leaves "Checking what this server uses…" on screen forever and
* the form permanently unusable, no error, no retry, nothing to act on.
* Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android
@@ -179,7 +179,7 @@ private fun StoryCard(
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
// The deadline, in the slot the kit gives a description.
// It is the one fact about a story that changes while you are
// looking at it, and the whole point of the feature.
// looking at it, and the point of the feature.
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
}
// Unseen is marked here rather than by styling the whole card: a
@@ -11,7 +11,7 @@ import android.provider.MediaStore
*
* MediaStore, so no storage permission is asked for. An app writing its
* own entry through `MediaStore` needs no runtime grant on any version this app
* supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture
* supports. Asking for `WRITE_EXTERNAL_STORAGE` to save one picture
* would request read access to everything the person has, to perform a write,
* a much larger ask than the feature.
*/
@@ -5,8 +5,8 @@ import android.content.Context
/**
* Which chain node the wallet reads from.
*
* **Pointing at a node is a privacy decision, and the app says so rather
* than choosing quietly.** Asking a public RPC endpoint what an address holds
* Pointing at a node is a privacy decision, and the app says so rather
* than choosing quietly. Asking a public RPC endpoint what an address holds
* ties that address to this device's IP in somebody else's logs, every time
* the screen refreshes. Running your own node is the only answer that removes
* it. The default exists so the wallet works at all, not because it is private.
@@ -49,8 +49,8 @@ object WalletChain {
/**
* What a transfer costs, and what must stay behind.
*
* **An account below the rent-exempt minimum is reclaimed by the
* network.** Emptying a wallet to the last lamport does not leave it empty;
* An account below the rent-exempt minimum is reclaimed by the
* network. Emptying a wallet to the last lamport does not leave it empty;
* it deletes it. Anything offering to send "everything" has to stop here,
* and the two numbers are named rather than buried in an expression so that
* the reason survives.
@@ -39,8 +39,8 @@ import java.util.Locale
/**
* The wallet.
*
* **A balance this app could not read is drawn as an em dash, never as
* zero.** Showing 0.00 because a node did not answer tells somebody their money
* A balance this app could not read is drawn as an em dash, never as
* zero. Showing 0.00 because a node did not answer tells somebody their money
* is gone, the single worst sentence this screen can say, and one it would say
* on every flaky connection. Null is a different fact from empty and is drawn
* as one.
@@ -135,8 +135,8 @@ class AttachmentPurgeTest {
}
/**
* **An id containing a slash, which is the reason the transformation
* exists.** `attachmentBytes` writes the file as `att-<id with / -> _>`, so
* An id containing a slash, which is the reason the transformation
* exists. `attachmentBytes` writes the file as `att-<id with / -> _>`, so
* membership has to be tested against the SAME mangling. Compare raw ids to
* mangled filenames and nothing matches, every attachment with a slash in
* its id is deleted while still on screen.
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/**
* A refused-because-the-key-changed failure has to say so (android#20).
*
* **Reported from a real session: "the calls wouldn't connect because the
* keys were changed. it wasn't obvious why the call failed."** It was not
* Reported from a real session: "the calls wouldn't connect because the
* keys were changed. it wasn't obvious why the call failed." It was not
* obvious because nothing said it. The call's signalling lambda was
* `runCatching { … }` with no `onFailure`, so `IdentityChangedException`, the
* send path refusing to encrypt to an unreviewed identity, which is TOFU
@@ -15,8 +15,8 @@ import kotlin.test.assertEquals
class DisplaySettingsTest {
/**
* **16sp with the setting off, because that is what the kit specifies
* for a message body.** A "larger type" switch that quietly restyled every
* 16sp with the setting off, because that is what the kit specifies
* for a message body. A "larger type" switch that quietly restyled every
* message when it was turned OFF would be a worse bug than the absence it
* fixes, and the number is easy to get wrong because it lives in one file
* and is drawn in another.
@@ -10,8 +10,8 @@ import kotlin.test.assertTrue
/**
* What the three new controls actually put on the wire (§6.15/§6.16/§6.19).
*
* **The privacy rule for a forward is the one worth testing, because it is
* the one a "helpful" change would break.** §6.16 says a forward carries the
* The privacy rule for a forward is the one worth testing, because it is
* the one a "helpful" change would break. §6.16 says a forward carries the
* words and nothing about where they came from. The obvious improvement,
* carrying a reference so the reader can see the original, would republish a
* private message's author to somebody who was never in that conversation. So
@@ -29,7 +29,7 @@ class EditForwardPinWireTest {
// ---- Forward (§6.16) -------------------------------------------------
/**
* The whole point of the feature: forwarded, and no trail back.
* The point of the feature: forwarded, and no trail back.
*/
@Test
fun `a forward carries the words and nothing about their origin`() {
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/**
* Files asks for the Flamenet passphrase, not a second secret.
*
* **The screen used to say the files were encrypted "with a passphrase of
* their own", and iOS never said anything like it.** iOS says "your
* The screen used to say the files were encrypted "with a passphrase of
* their own", and iOS never said anything like it. iOS says "your
* passphrase" and takes whatever is typed, so people use their Flamenet one
* and it works. Android's copy sent them off to invent something else, and
* forgetting it is a one-way door: the label keys are derived from the
@@ -125,7 +125,7 @@ class GroupNameClaimTest {
}
/**
* The whole point. A stranger naming a group they are not in must get
*A stranger naming a group they are not in must get
* nowhere, however well-formed their envelope is.
*/
@Test
@@ -61,8 +61,8 @@ class InboundControlTest {
}
/**
* **The case an allowlist cannot cover, and the reason the app branches
* on `isRenderable` rather than on a list of kinds it knows.** A control
* The case an allowlist cannot cover, and the reason the app branches
* on `isRenderable` rather than on a list of kinds it knows. A control
* envelope from a NEWER peer has a kind nothing here has heard of. Keying on
* known kinds ships a bubble full of JSON to every older install the first
* time a new control message is added on the other platform.
@@ -26,8 +26,8 @@ import org.robolectric.annotation.Config
* transcript does record a `mid`, `MessageReactionTest` folds a reaction onto
* a message by id, so the stated reason is already false.
*
* But relay#46's rule is the point: **do not flip a claim because the code
* looks present; flip it because a run proved it, and say which run.**
* But relay#46's rule is the point: do not flip a claim because the code
* looks present; flip it because a run proved it, and say which run.
* `applyEdit` was implemented and wired with nothing exercising it, because
* `InboundControlTest` covers the codec and says in its own header that the
* app-side application needs a `Vault` and is out of scope there. This is that
@@ -8,8 +8,8 @@ import kotlin.test.assertTrue
/**
* A photo sent as a file must lose its location, like one sent as a photo.
*
* **The bug this pins was found by an audit, not by a test, because no test
* went down the file path at all** (android#17, ios#69). The picker path was
* The bug this pins was found by an audit, not by a test, because no test
* went down the file path at all (android#17, ios#69). The picker path was
* checked and correct; `Session.sendFile` handed stored bytes straight to
* `sendAttachment`. The two doors disagreed, and the safer one was the one that
* looked like a shortcut.
@@ -8,8 +8,8 @@ import kotlin.test.assertTrue
/**
* What the Network section says is happening.
*
* **A settings screen that reads the stored line and calls it a status is
* the failure this feature is built around.** A saved bridge that is not there
* A settings screen that reads the stored line and calls it a status is
* the failure this feature is built around. A saved bridge that is not there
* leaves the app connecting directly; the preference cannot tell you that and
* a completed handshake can. `statusLine` is lifted out of the Composable
* precisely so this can be asserted, a string built inline in a `Text(...)` is
@@ -28,7 +28,7 @@ import org.robolectric.annotation.Config
*
* Nothing expires. There is no TTL and nothing is discarded: a contact
* who reinstalls in a month gets it in a month. Timing out would mean deleting
* something somebody wrote in order to tidy up a label.
* something somebody wrote to tidy up a label.
*
* The honest limit, which the UI wording has to respect: the queue is on
* the sender's phone, because an envelope is sealed to a device's keys and
@@ -223,7 +223,7 @@ class OutboxTest {
* look broken to the person reading the thread.
*
* Serves a REAL bundle built from a second identity, not a hand-written
* fake, so the send has to complete genuine X3DH against it. A fake bundle
* fake, so the send has to complete X3DH against it. A fake bundle
* would agree with whatever the client did, including the wrong thing.
*/
@Test
@@ -48,7 +48,7 @@ class StoryReportTest {
}
/**
* Without one, the picker forces a person to mis-categorise in order to
* Without one, the picker forces a person to mis-categorise to
* report at all.
*/
@Test
@@ -50,8 +50,8 @@ class WalletDisplayTest {
// ---- What can actually be sent ---------------------------------------
/**
* **An account below the rent-exempt minimum is RECLAIMED by the
* network.** Emptying a wallet to the last lamport does not leave it empty,
* An account below the rent-exempt minimum is RECLAIMED by the
* network. Emptying a wallet to the last lamport does not leave it empty,
* it deletes it, so "send everything" has to stop short of everything.
*/
@Test
@@ -15,8 +15,8 @@ import io.flamenet.e2e.FNID
* the address. No server issues it, so no server can take it away.
*
* The practical consequence is the one worth stating: sign in with the same
* passphrase at a relay that has never heard of you and you get **the same
* address**. Contacts who accepted you before still see the same person. A
* passphrase at a relay that has never heard of you and you get the same
* address. Contacts who accepted you before still see the same person. A
* relay shutting down costs a mailbox, not an identity.
*
* ## Why it is here and not in the engine
@@ -124,8 +124,8 @@ object AccountIdentity {
* Relays named by a record, but only once it verifies AND is not older than
* one already seen.
*
* **A signature alone does not stop a rollback, and that is the whole
* reason the serial exists.** An older record is *genuinely signed*: it was
* A signature alone does not stop a rollback, and that is the whole
* reason the serial exists. An older record is *signed*: it was
* published by the account holder, before they added a second home. A relay
* that wants to be somebody's only mailbox does not have to forge anything
* it serves the record it saw first, that record verifies perfectly, and
@@ -141,7 +141,7 @@ object AccountIdentity {
* re-read is the ordinary case. Only strictly lower is a rollback.
*
* A refusal here is not proof of an attack, and the caller must not
* present it as one. A relay that has genuinely fallen behind serves an old
* present it as one. A relay that has fallen behind serves an old
* record for honest reasons; what the client can say is that it has seen a
* newer one, and that it will keep using it.
*/
@@ -16,7 +16,7 @@ import java.security.SecureRandom
* The passphrase never leaves the device. It is stretched here and only
* the derived secret is sent, which is why sign-in is two round trips rather
* than one: the salt and iteration count are per account, and the service
* cannot derive the secret itself. That is the entire point, and collapsing it
* cannot derive the secret itself. That is the point, and collapsing it
* to one call would mean sending the passphrase.
*
* Used only for relays whose accounts live in the shared service. A self-hosted
@@ -10,14 +10,14 @@ import org.json.JSONObject
/**
* Talking to a chain node, for the wallet.
*
* **The node is somebody else's server and it is not trusted with
* anything.** It is asked what a public address holds and handed transactions
* The node is somebody else's server and it is not trusted with
* anything. It is asked what a public address holds and handed transactions
* that are already signed; it never sees a key, and it cannot move money. What
* it CAN do is lie about a balance or withhold a history, so nothing here
* treats an answer as authoritative beyond "this is what that node said".
*
* **Which node somebody points at is a privacy decision, and the app has to
* say so.** Asking a public RPC endpoint for the balance of an address ties
* Which node somebody points at is a privacy decision, and the app has to
* say so. Asking a public RPC endpoint for the balance of an address ties
* that address to this device's IP, on somebody else's logs. Running your own
* node is the only answer that removes it; the default exists so the wallet
* works at all, not because it is private.
@@ -19,8 +19,8 @@ import io.flamenet.e2e.Vault
* recipient device, each under its own ratchet. A client that encrypts to
* "the" device works perfectly in testing, where everybody has one.
*
* **Every send and every receive moves ratchet state, and unsaved ratchet
* state is a broken conversation.** Each operation persists before it returns.
* Every send and every receive moves ratchet state, and unsaved ratchet
* state is a broken conversation. Each operation persists before it returns.
* That costs a write per message and buys the difference between an app that
* survives being killed and one that has to be reinstalled.
*/
@@ -122,8 +122,8 @@ class Conversations(
* Whether this arrived sealed (§14).
*
* For a sealed message [fromUser] and [fromDevice] come from inside
* the blob, and are trustworthy **only because the inner envelope
* decrypted** under a session those identities selected. Never surface
* the blob, and are trustworthy only because the inner envelope
* decrypted under a session those identities selected. Never surface
* a sealed message's sender on a path that skips that.
*/
val sealed: Boolean = false,
@@ -689,8 +689,8 @@ class Conversations(
proto = o.optInt("proto", 1),
)
}
// **A stub cannot open a session, so the two lists must be merged,
// not chosen between.** `listDevices` returns identity keys only, no
// A stub cannot open a session, so the two lists must be merged,
// not chosen between. `listDevices` returns identity keys only, no
// signed prekey, no signature, which is all an EXISTING session needs.
// A device with no session needs a full bundle, and `E2ESession.initiate`
// verifies the signed prekey's signature before anything else: handed a
@@ -726,8 +726,8 @@ class Conversations(
// where the key route returned one.
stubs.map { full[it.deviceId] ?: it }
}
// **The send path had no logging at all, and that is why a message
// that never left the phone was indistinguishable from one that did.**
// The send path had no logging at all, and that is why a message
// that never left the phone was indistinguishable from one that did.
// The relay records refusals, so an absent POST looks identical to a
// successful one from the server side; only this side knows.
System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " +
@@ -754,7 +754,7 @@ class Conversations(
//
// That is the whole bug behind "I wasn't signed in on the other end and
// the message got lost": signing out deregisters the device, so the
// recipient genuinely has none, and this is the one path that has to
// recipient has none, and this is the one path that has to
// notice. iOS already threw here; this side never did.
//
// Recorded in `failed` rather than thrown, because the group senders
@@ -971,7 +971,7 @@ class Conversations(
throw IdentityChangedException(0L)
}
// Full bundles only when something genuinely needs establishing. Same
// Full bundles only when something needs establishing. Same
// stub/full union as the local path and for the same reason: a stub
// cannot open a session, and a device silently dropping out of the
// fan-out is how somebody stops receiving on their tablet with nothing
@@ -1132,8 +1132,8 @@ class Conversations(
/**
* Read the inbox, decrypt what we can, and advance the watermark.
*
* The watermark advances **per message, after it is decrypted and its
* session is stored**, not once for the batch. Advancing for the batch up
* The watermark advances per message, after it is decrypted and its
* session is stored, not once for the batch. Advancing for the batch up
* front means a crash halfway loses every message after the crash, and the
* relay will never offer those ids again.
*
@@ -1264,8 +1264,8 @@ class Conversations(
val existing = existingSession(fromDevice)
val retired = runCatching { vault.loadRetiredSession(fromDevice) }.getOrNull()
// **Which slot the winner belongs in is not the same question
// as which chain opened it**, and conflating the two swaps them.
// Which slot the winner belongs in is not the same question
// as which chain opened it, and conflating the two swaps them.
// Each branch writes back deliberately.
val fromCurrent = openWith(existing, envelope)
val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null
@@ -1283,8 +1283,8 @@ class Conversations(
Wire.unb64(envelope.handshake!!.getString("ik_sig"))
}.getOrNull()
val claimant = opened?.claimedFromUser ?: message.fromUser
// **The gate applies in whichever keyspace the
// sender is in.** A first handshake from another relay
// The gate applies in whichever keyspace the
// sender is in. A first handshake from another relay
// carries a user id that means nothing here, so
// checking it against the local pins would either
// compare it to an unrelated contact's keys or fall
@@ -1353,8 +1353,8 @@ class Conversations(
// renegotiation, a device claiming a different user now is
// lying, and its message files under the user it is bound to.
val claimed = opened?.claimedFromUser ?: message.fromUser
// **A federated sender is resolved in the ADDRESS keyspace,
// never the local one.** Their `from_user` is a row number on
// A federated sender is resolved in the ADDRESS keyspace,
// never the local one. Their `from_user` is a row number on
// their relay; binding a device to it here would either file
// the message under a local contact who happens to share the
// number, or, where the relay recorded no sender at all,
@@ -1579,8 +1579,8 @@ class Conversations(
* act means rotating for everybody and redistributing to the rest, which is
* why in practice it never happens.
*
* **This moves them back onto the shared key; it does not silence
* them.** A complete cut-off is this AND [rotateDeliveryKey], every other
* This moves them back onto the shared key; it does not silence
* them. A complete cut-off is this AND [rotateDeliveryKey], every other
* contact recovers from that on its own now, because the new key rides the
* same prelude a batch does. Two doors, and closing one does not close the
* other.
@@ -1633,7 +1633,7 @@ class Conversations(
val key = vault.loadDelivery()?.key ?: return
val payload = SealedSender.deliveryKeyEnvelope(
key, groupId = groupId, groupName = name)
// Our own account is included, and that is the point. Fan-out
// Our own account is included. Fan-out
// excludes us everywhere else, so a second phone we own would be told
// by nobody: the relay has no name to serve it and the members only
// send to each other. The fan-out loop already skips our own device
@@ -8,8 +8,8 @@ import okhttp3.Request
/**
* Fetches a preview for a link the user is about to send (spec §6.12).
*
* **This runs on the SENDER's device, and the result travels inside the
* ciphertext.** The obvious implementation, each recipient fetching the URL to
* This runs on the SENDER's device, and the result travels inside the
* ciphertext. The obvious implementation, each recipient fetching the URL to
* draw a card, is the one that must not be built: it tells the site the
* reader's IP address, that they received a link, and roughly when they opened
* it, once per member on a group message. A messenger that hides the message
@@ -243,8 +243,8 @@ class LocalVault(
* value, wrapped to the recipient, the same grant the server used to
* broker, minus the broker.
*
* **Binds the whole canonical label encoding, not just the
* classification.** Deriving from the level alone would give one key to
* Binds the whole canonical label encoding, not just the
* classification. Deriving from the level alone would give one key to
* every compartment at that level, which is precisely the separation
* compartments exist for.
*/
@@ -322,8 +322,8 @@ class LocalVault(
.put("bookmark", node.bookmark ?: JSONObject.NULL)
/**
* **`optString` is not safe on a nullable field, and the JVM will not
* tell you.** Android's bundled `org.json` coerces a JSON `null` to the
* `optString` is not safe on a nullable field, and the JVM will not
* tell you. Android's bundled `org.json` coerces a JSON `null` to the
* four-character string `"null"`; the Maven `org.json` these tests run
* against returns `""`. Every nullable field below therefore passed on a
* laptop and broke on a phone:
@@ -529,15 +529,15 @@ class LocalVault(
/**
* Turn a reference into an encrypted copy, in place.
*
* **The bridge between the two ways of holding a file, and the reason
* both exist.** Referencing avoids duplicating anything; vaulting is what
* The bridge between the two ways of holding a file, and the reason
* both exist. Referencing avoids duplicating anything; vaulting is what
* makes a classification a control rather than a note. Most files want the
* first and a few want the second, and nobody knows which is which until
* after they have added them, so this promotes one without re-picking it.
*
* It does NOT delete the person's original: that is their file, and
* removing it is a separate decision the interface asks about afterwards.
* Until they do, this genuinely is two copies, the honest trade for having
* Until they do, this is two copies, the honest trade for having
* the encrypted one.
*
* Returns the bookmark the original was read from, so a caller can offer
@@ -565,7 +565,7 @@ class LocalVault(
storage = Storage.VAULT,
// The bookmark goes. Keeping it would leave a vaulted row still
// able to read through to a plaintext original, so the encrypted
// copy would not be the only way in, which is the entire point of
// copy would not be the only way in, which is the point of
// vaulting it.
bookmark = null,
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
@@ -166,8 +166,8 @@ class MailboxHoming(
/**
* Stop keeping a mailbox at [relay].
*
* **The shorter list is published first, and the local credential goes
* only if that worked.** Forgetting locally while senders are still told to
* The shorter list is published first, and the local credential goes
* only if that worked. Forgetting locally while senders are still told to
* deliver there leaves mail arriving at a mailbox this device no longer
* reads, indistinguishable, from the sender's side, from delivery.
*
@@ -243,8 +243,8 @@ class MessengerApi(
* through `send()`, which does `JSONObject(text)` and throws on an array,
* and the throw was swallowed by `runCatching { }.getOrNull()`.
*
* The result: **every lookup reported "no account called X here" even when
* the relay had found the account and returned it.** Adding a contact was
* The result: every lookup reported "no account called X here" even when
* the relay had found the account and returned it. Adding a contact was
* impossible on Android, which is the first thing anyone does. Measured
* against relay.flamenet.io, the relay returned
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the
@@ -7,8 +7,8 @@ import java.util.concurrent.TimeUnit
/**
* Which bridge, if any, the app's HTTP clients route through.
*
* **A holder rather than a constructor argument, because there are ten
* construction sites and no way to be sure of catching them all.** `RelayClient`,
* A holder rather than a constructor argument, because there are ten
* construction sites and no way to be sure of catching them all. `RelayClient`,
* `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`,
* `RelayDiscovery` and the background service each build their own client, some
* from default parameters evaluated at field-initialisation time. Threading a
@@ -56,8 +56,7 @@ class RelayAccounts(
account(post("account/signup", body()))
} catch (asked: RelayException.ChallengeRequired) {
// Rethrown, not swallowed, when we cannot answer. A difficulty
// above the cap or a search that ran out leaves this app genuinely
// unable to sign up here, and "update the app" is then the true
// above the cap or a search that ran out leaves this app // unable to sign up here, and "update the app" is then the true
// sentence, quietly failing with something else would send
// somebody looking in the wrong place.
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
@@ -11,8 +11,8 @@ import java.util.UUID
* design decision rather than an oversight to work around. A credential good
* for every route on every one of an account's devices means a token lifted
* from one device drains another's prekey pool and reads its inbox. What the
* relay wants instead is a short-lived token scoped to **one device and one
* relay**, plus a per-request signature proving the sender holds the identity
* relay wants instead is a short-lived token scoped to one device and one
* relay, plus a per-request signature proving the sender holds the identity
* key that token names, so a stolen token, on its own, is inert.
*
* The nonce is not decoration. Without it, two inbox polls in the same
@@ -75,8 +75,8 @@ class RelayClient(
* host where the bridge shares the relay's address, would not even help
* against IP blocking.
*
* **The bridge is a separate hop, so obfs4 hides traffic from the
* NETWORK, not from us.** It is not a privacy feature against the
* The bridge is a separate hop, so obfs4 hides traffic from the
* NETWORK, not from us. It is not a privacy feature against the
* operator, and must never be described as one.
*/
fun defaultHttpClient(bridge: Obfs4Bridge? = null): OkHttpClient =
@@ -86,7 +86,7 @@ class RelayClient(
// sealed blobs and envelope traffic surviving on disk after the user
// deletes a chat is exactly what "deleted means deleted" forbids.
.cache(null)
// Installed unconditionally, and that is the point. The
// Installed unconditionally. The
// parameter above is for tests and interop, which pin one
// bridge. Everything the app builds passes nothing, so if the
// factory were only installed when a bridge was already known,
@@ -176,8 +176,8 @@ class RelayClient(
data class Keys(val fnid: String?, val bundles: List<PeerBundle>)
/**
* **The `fnid` is not decoration; without it the key log cannot be
* checked.** The relay files a leaf under `fnid:<address>` once an account
* The `fnid` is not decoration; without it the key log cannot be
* checked. The relay files a leaf under `fnid:<address>` once an account
* has claimed one, and under `local:<audience>/<id>` before that. A
* verifier that does not know the address computes the other subject, finds
* no matching leaf, and refuses to send, reporting "has published keys,
@@ -254,7 +254,7 @@ class RelayClient(
* submission is sealed: the relay is not told who is sending, and
* `fromDevice` is omitted entirely rather than sent as a courtesy.
*
* Omitting `from_device` is the whole point, not tidiness. Sending it
* Omitting `from_device` is the point, not tidiness. Sending it
* alongside a sealed payload would hand the relay the sender's identity at
* request time, so the seal would hide the sender from the stored row and
* from nobody else, strictly worse than not sealing, because it looks like
@@ -399,8 +399,8 @@ class RelayClient(
/**
* `POST /e2e/moderation/report`, file a report (§6.25).
*
* **The one call that deliberately sends readable content to the
* server.** Everything else this client sends is sealed. A relay that
* The one call that deliberately sends readable content to the
* server. Everything else this client sends is sealed. A relay that
* cannot open a reported story cannot act on an accusation about it, so
* the reporter's own copy travels with the report, and the caller is
* required to have obtained consent naming that, before reaching here.
@@ -475,8 +475,8 @@ class RelayClient(
* client-side against the contact list.
*
* This is the exact shape the iOS client got wrong: it required a `name`
* on each member that the relay has never sent, so **every group decode
* failed** and groups were completely broken while looking implemented.
* on each member that the relay has never sent, so every group decode
* failed and groups were completely broken while looking implemented.
*/
data class Group(
val id: String,
@@ -658,7 +658,7 @@ class RelayClient(
* Prekey bundles for a remote address.
*
* Pops a one-time prekey on the far side, exactly as the local
* `/e2e/keys` route does. Ask only when there is genuinely a session to
* `/e2e/keys` route does. Ask only when there is a session to
* open, never to find out who somebody is.
*/
fun remoteBundles(fnid: String, relay: String): JSONArray =
@@ -670,7 +670,7 @@ class RelayClient(
* The reply says how many copies were taken, which addresses the far relay
* did not recognise, and which of that account's mailboxes could not be
* reached, all three matter, because with several homes a send is
* genuinely partial and a client that cannot tell cannot decide between
* partial and a client that cannot tell cannot decide between
* retrying and warning.
*/
fun sendRemote(toFnid: String, toRelay: String, messages: List<RemoteMessage>): JSONObject {
@@ -714,7 +714,7 @@ class RelayClient(
*
* [serial] must exceed whatever is already published. It is not a version
* for humans to read, it is the only thing that stops a relay replaying an
* older, genuinely signed list that happens to name only itself.
* older, signed list that happens to name only itself.
*/
fun publishHomes(serial: Int, relays: List<String>, signatureB64: String): JSONObject =
postJson("/e2e/account/homes", JSONObject()
@@ -8,8 +8,8 @@ import org.json.JSONObject
/**
* What a relay says about itself (`GET /.well-known/flamenet/relay`).
*
* **A client must know where accounts live before it sends a credential
* anywhere.** The two modes stretch the passphrase differently and send the
* A client must know where accounts live before it sends a credential
* anywhere. The two modes stretch the passphrase differently and send the
* result to different hosts; guessing wrong means sending an authentication
* secret to a service that was never meant to receive it.
*
@@ -72,8 +72,8 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
* The account exists and the credential is right; the address has not been
* proved reachable yet.
*
* **Its own type because everything else about it looks like a
* credential failure.** Without this it arrives as a bare 403 and
* Its own type because everything else about it looks like a
* credential failure. Without this it arrives as a bare 403 and
* [RelayAuth] logs "minting failed" before falling back to Basic, which
* the e2e routes refuse, so the person sees calls not working and an inbox
* that does nothing, and nothing anywhere says the remedy is a code sitting
@@ -3,8 +3,8 @@ package io.flamenet.client
/**
* Which relay this is, beyond which certificate it presented (ios#19).
*
* **TLS answers "is this host who DNS says it is". It does not answer "is
* this the same relay I have been talking to".** A mis-issued certificate, a
* TLS answers "is this host who DNS says it is". It does not answer "is
* this the same relay I have been talking to". A mis-issued certificate, a
* hijacked DNS record, or a hostile party who obtains the domain all produce a
* server with a perfectly valid certificate for the right name, and every one
* of them is a different relay wearing the same address.
@@ -25,8 +25,8 @@ enum class IdentityTrust {
/**
* A key we have never seen is being offered for someone we have.
*
* **This is not "they got a new phone" and it is not "you are being
* attacked".** It is exactly the state where those two are
* This is not "they got a new phone" and it is not "you are being
* attacked". It is exactly the state where those two are
* indistinguishable to software, which is why it stops and asks a human
* rather than picking one.
*/
@@ -62,8 +62,8 @@ class KeyLogForkException(detail: String) :
/**
* A served identity key could not be found in the log the relay publishes.
*
* **Two very different situations, and the difference matters enough to
* carry.** [hadEntries] is false when the log holds nothing at all for this
* Two very different situations, and the difference matters enough to
* carry. [hadEntries] is false when the log holds nothing at all for this
* device, the key was never published, which is what a substitution looks
* like. It is true when the device HAS publications but none of them hash to
* the leaf this bundle implies under the subject we were able to compute.
@@ -380,8 +380,8 @@ class Trust(
* hashed bytes, so guessing wrong makes an honest key unverifiable.
*
* Taking it from the contact record rather than from the relay's current
* answer is the point: it checks the key was logged under **the person this
* contact has always been**, not under whatever subject the relay would
* answer is the point: it checks the key was logged under the person this
* contact has always been, not under whatever subject the relay would
* like to file it against today.
*/
/**
@@ -410,7 +410,7 @@ class Trust(
val head = try {
head().also { vault.rememberKeyLogServed(relay.host) }
} catch (e: RelayException.NotFound) {
// Only a route that is genuinely absent. A timeout, a 500 or a 429
// Only a route that is absent. A timeout, a 500 or a 429
// is a relay having a bad day and must not read as one without
// transparency, that would make enforcement skippable by anybody
// able to make one request fail.
@@ -461,8 +461,8 @@ class Trust(
// by whichever relay signed this head, and the qualified subject embeds
// that relay's own name.
//
// **Both subject forms are accepted, and the reason is a migration
// rather than a weakness.** A leaf names its subject as `fnid:` once
// Both subject forms are accepted, and the reason is a migration
// rather than a weakness. A leaf names its subject as `fnid:` once
// that account has claimed an address and `local:<audience>/<id>`
// before, and the leaf hash committed to whichever string was true
// when it was written. A contact who registered a device and then
@@ -15,7 +15,7 @@ import org.json.JSONObject
* updater to install it. Without this check a person can run a build from
* months ago indefinitely and never know, and the builds they are missing
* include security fixes. A sideloaded app that cannot tell you it is out of
* date is the part of sideloading that is genuinely worse, and this is the only
* date is the part of sideloading that is worse, and this is the only
* thing that closes it.
*
* Asked of the relay you already talk to, not of a fixed address of ours.
@@ -116,8 +116,8 @@ class AccountIdentityTest {
}
/**
* **A relay serving a truncated list is the exact failure multi-homing
* exists to survive.** Drop a home from the record and it stops being
* A relay serving a truncated list is the exact failure multi-homing
* exists to survive. Drop a home from the record and it stops being
* signed, the sender then delivers everywhere the real record names,
* because it checks rather than trusts.
*/
@@ -141,7 +141,7 @@ class AccountIdentityTest {
/**
* The serial is inside the signature, which is what stops the rollback a
* signature alone permits: an old list is genuinely signed, so without a
* signature alone permits: an old list is signed, so without a
* counter a relay could serve one from before a second home was added and
* quietly become the only mailbox again.
*/
@@ -18,8 +18,8 @@ import kotlin.test.assertTrue
/**
* §19 enforcement with no setting to find first.
*
* **This used to be opt-in and off, so the protection shipped and almost
* nobody had it.** The argument for a switch, a relay predating the log has
* This used to be opt-in and off, so the protection shipped and almost
* nobody had it. The argument for a switch, a relay predating the log has
* none, and refusing every peer there is worse than the attack, was an
* argument for skipping relays that *cannot* answer, never for skipping the
* ones that can. These tests pin that distinction, because it is the whole
@@ -21,8 +21,8 @@ import kotlin.test.assertTrue
/**
* Single-use delivery capabilities (§21.4).
*
* **The property under test is that a capability is SPENT, not merely
* checked.** One that survives its use is a reusable per-contact key, which is
* The property under test is that a capability is SPENT, not merely
* checked. One that survives its use is a reusable per-contact key, which is
* a stable value the relay sees on every message from that sender, exactly the
* pseudonym §21.2 rejects. So "the same one twice is refused" is not a
* nice-to-have; it is the whole reason this is not per-contact delivery keys
@@ -195,7 +195,7 @@ class DeliveryTokenTest {
assertEquals(2, spent)
}
// MARK: - Revocation, which is the point
// MARK: - Revocation
@Test
fun `revoking drops exactly that contact's batch`() {
@@ -213,8 +213,8 @@ class DeliveryTokenTest {
}
/**
* **Revoking moves a sender back onto the shared key; it does not
* silence them.** Stated as a test because a client that implied otherwise
* Revoking moves a sender back onto the shared key; it does not
* silence them. Stated as a test because a client that implied otherwise
* would be lying about the one thing this section is for.
*/
@Test
@@ -134,7 +134,7 @@ class GossipHeadSentTest {
}
/**
* In-band is the whole point. The relay is the party being audited, so
* In-band is the point. The relay is the party being audited, so
* it must not be able to read which head we hold nor rewrite it in transit.
* A head that appears in the clear in the POST body would let a forked
* relay answer each client with the branch that client already believes,
@@ -103,8 +103,8 @@ class GroupNameTest {
}
/**
* **The announcement rides `delivery_key` precisely because that kind is
* old.** A new `group_name` kind would arrive on every phone running v5 as
* The announcement rides `delivery_key` precisely because that kind is
* old. A new `group_name` kind would arrive on every phone running v5 as
* a bubble full of raw JSON: those builds recognise control traffic by kind
* and read the `ctl` flag with `optBoolean`, which does not coerce the
* number §6.10 specifies. This asserts the envelope is still a well-formed
@@ -294,8 +294,8 @@ class GroupNameTest {
}
/**
* **`Received` carries whether the sender was proven, because a caller
* that acts on `fromUser` has to be able to ask.** An unproven device's
* `Received` carries whether the sender was proven, because a caller
* that acts on `fromUser` has to be able to ask. An unproven device's
* sender id is a claim, the binding says it belongs to somebody else,
* so a roster check keyed on it asks "is the person they are pretending to
* be in this group", which answers yes for any member they name.
@@ -315,7 +315,7 @@ class GroupNameTest {
}
/**
* The announcement goes to our own account too, and that is the point.
* The announcement goes to our own account too.
* Fan-out excludes us everywhere else, so a second phone we own would be
* told by nobody: the relay has no name to serve it and the members only
* send to each other.
@@ -13,8 +13,8 @@ import org.json.JSONObject
/**
* A home record that is older than one already seen must be refused (§20.4).
*
* **A signature alone does not stop a rollback, and both clients checked
* only the signature.** An older home record is *genuinely signed*, the
* A signature alone does not stop a rollback, and both clients checked
* only the signature. An older home record is *signed*, the
* account holder published it, before they added a second relay. So a relay
* that wants to be somebody's only mailbox does not have to forge anything: it
* serves the record it saw first, that record verifies perfectly, and the
@@ -92,7 +92,7 @@ class HomeRecordRollbackTest {
* A serial is only reported for a record that VERIFIES. Otherwise a
* hostile relay could hand over an unsigned record carrying serial
* 2,000,000,000, have the client raise its floor to it, and lock out every
* genuine record for ever, turning the rollback defence into a denial of
* record for ever, turning the rollback defence into a denial of
* service against the account holder.
*/
@Test
@@ -269,7 +269,7 @@ class LiveConversationTest {
* and deliberately will not grow one, because a relay caching display names
* is a relay that leaks them. Every group decode failed, so groups were
* completely broken while looking implemented. Asserting the member shape
* against live bytes is the whole point of this test.
* against live bytes is the point of this test.
*/
@Test
fun `a group is created, described and messaged`() {
@@ -13,8 +13,8 @@ import kotlin.test.assertTrue
/**
* §19 enforcement against a relay that is actually running.
*
* **Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
* than production.** It creates accounts and publishes keys, which is what a
* Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
* than production. It creates accounts and publishes keys, which is what a
* throwaway relay is for and what `relay.flamenet.io` is not.
*
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
@@ -90,7 +90,7 @@ class LiveKeyLogTest {
val bob = Actor(url, "b")
// Alice looks Bob up the way a send does, and enforces §19 on what she
// was served. Bob genuinely published this key a moment ago, so the
// was served. Bob published this key a moment ago, so the
// relay can prove it, and if this throws, enforcement is refusing
// honest keys, which is worse than not enforcing at all.
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
@@ -12,8 +12,8 @@ import kotlin.test.assertTrue
/**
* All three deployment shapes, three rounds each, repeatably.
*
* The shapes are not variations on a theme, they differ in **where a
* passphrase-derived secret is sent**, which is the one thing a messenger must
* The shapes are not variations on a theme, they differ in where a
* passphrase-derived secret is sent, which is the one thing a messenger must
* not get wrong:
*
* - Managed (`relay.flamenet.io`), accounts in the shared FlameNet
@@ -94,8 +94,8 @@ class LiveShapesTest {
return
} catch (e: AccountsClient.Failure.RateLimited) {
if (attempt == 2) {
// **Not a product failure, and not something to retry
// through.** The service allows three registrations per
// Not a product failure, and not something to retry
// through. The service allows three registrations per
// address per HOUR, deliberately, a short backoff cannot
// outwait that, and pretending otherwise would turn a
// correct defence into a flaky test. Say what happened.
@@ -5,8 +5,8 @@ import java.net.URI
/**
* Where a live suite is allowed to point.
*
* **On 2026-08-31 a thirty-minute test round created 39 accounts in the
* PRODUCTION database.** They are still there: `jvmt…`, `kt…`, `sl…`, `live…`,
* On 2026-08-31 a thirty-minute test round created 39 accounts in the
* PRODUCTION database. They are still there: `jvmt…`, `kt…`, `sl…`, `live…`,
* 55 of 86 accounts on the relay, which made the launch signup metric unusable
* a real person signing up was invisible inside the burst.
*
@@ -81,8 +81,8 @@ object LiveTarget {
/**
* The passphrase a live suite signs up with.
*
* **From the environment, with no fallback, and that is the whole
* point.** Both live suites carried
* From the environment, with no fallback, and that is the whole
* point. Both live suites carried
* `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
* accounts on `accounts.flamenet.io` whenever somebody set
* `FN_LIVE_MANAGED=1`. A credential written into a source file is a
@@ -11,8 +11,8 @@ import kotlin.test.assertTrue
/**
* One credential, two products, and whether either is optional.
*
* The claim the product makes is narrow and worth testing exactly: **hosting
* only Messenger leaves nothing missing.** Flamenet Files is not a dependency,
* The claim the product makes is narrow and worth testing exactly: hosting
* only Messenger leaves nothing missing. Flamenet Files is not a dependency,
* a fallback, or a hidden requirement. What the shared account service buys is
* one credential across both, and nothing else.
*
@@ -369,7 +369,7 @@ class LocalVaultTest {
/**
* Promoting to the vault drops the bookmark. Keeping it would leave a
* vaulted row still able to read through to a plaintext original, so the
* encrypted copy would not be the only way in, which is the entire point of
* encrypted copy would not be the only way in, which is the point of
* vaulting it.
*/
@Test
@@ -435,7 +435,7 @@ class LocalVaultTest {
assertTrue(listOf(before) != listOf(after), "the LIST compares equal too")
}
/** ...while two reads of an unchanged row still are equal, which is the point. */
/** ...while two reads of an unchanged row still are equal. */
@Test
fun `an unchanged row read twice compares equal`() {
val node = vault.add("x".toByteArray(), "f.txt", secret)
@@ -486,8 +486,8 @@ class LocalVaultTest {
* Those bytes are their file, sitting where they put it. This is the one
* mistake in the whole class that cannot be undone.
*
* **Asserting only that the file survived would be a test that cannot
* fail**, because the delete path does not hold a resolver call to remove,
* Asserting only that the file survived would be a test that cannot
* fail, because the delete path does not hold a resolver call to remove,
* the protection is structural: [LocalVault.ReferenceResolver] has no way to
* delete anything. So this asserts the property that actually constrains
* future code: removing a row reaches the person's storage not at all.
@@ -226,8 +226,8 @@ class MailboxHomingTest {
// MARK: - Dropping one
/**
* **The shorter list is published first and the credential goes only if
* that worked.** Forgetting locally while senders are still told to deliver
* The shorter list is published first and the credential goes only if
* that worked. Forgetting locally while senders are still told to deliver
* there leaves mail arriving at a mailbox this device no longer reads,
* which, from the sender's side, is indistinguishable from delivery.
*/
@@ -266,7 +266,7 @@ class MailboxHomingTest {
/**
* The serial has to move forward or the relay refuses the record as a
* rollback, by the very check that stops an attacker replaying an old,
* genuinely signed list that happens to name only itself.
* signed list that happens to name only itself.
*/
@Test
fun `each published record carries a serial above the last`() {
@@ -10,8 +10,8 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* **The relay answers a lookup with an ARRAY, and the client used to insist
* it was an object.**
* The relay answers a lookup with an ARRAY, and the client used to insist
* it was an object.
*
* `RoutesAccount.lookup` returns `[MeResponse]`, empty for a miss, one element
* for a hit, deliberately, so that a miss and a hit differ only in length and
@@ -13,8 +13,8 @@ import kotlin.test.assertTrue
/**
* What the roster, notification and account routes actually decode.
*
* **Three separate field-name bugs are recorded in comments in this one
* file, and all three shipped.** The block list read `user_id` where the relay
* Three separate field-name bugs are recorded in comments in this one
* file, and all three shipped. The block list read `user_id` where the relay
* sends `id`, so the Block button did nothing and survived review because the
* list was always empty. Notifications read `kind`/`body`/`read`, names that
* exist nowhere, for months, so every alert decoded as a blank unread row and
@@ -118,7 +118,7 @@ class MultiMailboxPollTest {
// MARK: - Reading
/**
* The whole point. A relay this device is not signed in to is holding
*A relay this device is not signed in to is holding
* mail, and without this the message is simply never read.
*/
@Test
@@ -134,8 +134,8 @@ class MultiMailboxPollTest {
}
/**
* **A copy at every home is the design, so a copy read twice is the
* failure.** And it would not present as a duplicate: the ratchet deletes a
* A copy at every home is the design, so a copy read twice is the
* failure. And it would not present as a duplicate: the ratchet deletes a
* message key once used, so the second copy fails to open and is logged as
* a dropped envelope, a message that looks lost rather than repeated.
*/
@@ -180,8 +180,8 @@ class MultiMailboxPollTest {
// MARK: - When something goes wrong
/**
* **A 410 from a BACKUP home is that mailbox ending, not this account
* ending.** Treating it like the signed-in relay's 410 would purge
* A 410 from a BACKUP home is that mailbox ending, not this account
* ending. Treating it like the signed-in relay's 410 would purge
* somebody's messages because one spare relay dropped a registration.
*/
@Test
@@ -44,8 +44,8 @@ class Obfs4RoutingTest {
}
/**
* **The factory is installed whether or not a bridge is set, so the
* no-bridge path has to be an ordinary socket.** If it were an
* The factory is installed whether or not a bridge is set, so the
* no-bridge path has to be an ordinary socket. If it were an
* `Obfs4Socket` pointed at nothing, installing the factory would break the
* app for the overwhelming majority who never configure a bridge, and it
* would break it at connect time, on the network, not at build time.
@@ -24,7 +24,7 @@ import kotlin.test.fail
* A fake written alongside the client agrees with it about everything,
* including the field name it got wrong, which is exactly how this protocol's
* one shipped bug survived until a live round trip. MockWebServer at least
* makes the client produce genuine bytes and parse genuine responses.
* makes the client produce bytes and parse responses.
*/
class RelayClientTest {
@@ -9,8 +9,8 @@ import kotlin.test.assertTrue
/**
* Pinning the relay's identity (ios#19, Android half).
*
* **The finding this closes is not a missing feature, it is a wrong
* conclusion.** Android already pinned `issuerPublicKey`, inside
* The finding this closes is not a missing feature, it is a wrong
* conclusion. Android already pinned `issuerPublicKey`, inside
* `Trust.verifyHeadSignature`. When the key later changed, the head signature
* stopped verifying and the only conclusion available at that point was
* `KeyLogForkException`, so a rotation somebody performed on purpose was
@@ -58,8 +58,8 @@ class RelayIdentityPinTest {
}
/**
* **A relay that publishes no key is a weaker deployment, not an
* attack.** Somebody self-hosting an older build has nothing to pin, and
* A relay that publishes no key is a weaker deployment, not an
* attack. Somebody self-hosting an older build has nothing to pin, and
* refusing to talk to them would be a worse failure than the one this
* prevents.
*/
@@ -121,8 +121,8 @@ class RelayIdentityPinTest {
}
/**
* **The exception is not a fork exception, and that separation is the
* point of this change.** A caller catching `KeyLogForkException` to decide
* The exception is not a fork exception, and that separation is the
* point of this change. A caller catching `KeyLogForkException` to decide
* "the log is untrustworthy" must not be handed a rotation.
*/
@Test
@@ -214,8 +214,8 @@ class SendToAddressTest {
// MARK: - Trust in the address keyspace
/**
* **A key never seen before is either a new device of theirs or one their
* relay minted, and nothing on the wire tells those apart.** The pin hangs
* A key never seen before is either a new device of theirs or one their
* relay minted, and nothing on the wire tells those apart. The pin hangs
* off the address, because a user id on their relay means nothing on ours.
*/
@Test
@@ -17,7 +17,7 @@ import kotlin.test.assertTrue
* A message nobody can receive must not report success.
*
* This shipped, and it lost real messages. Signing out deregisters the
* device, so a signed-out recipient genuinely has no devices. Envelopes are
* device, so a signed-out recipient has no devices. Envelopes are
* addressed per device, so `sendPlaintext` looped over an empty bundle list,
* made no request at all, and returned `Sent(delivered = [], failed = {})`.
* Every caller reads an empty `failed` as "it went", the message was written
@@ -23,8 +23,8 @@ import java.security.MessageDigest
* leading zeros" could see it, because overshooting also satisfies N. That is
* why the vectors below are pinned to exact values.
*
* The other half of the contract is that **nothing here may prevent a
* sign-up**. A relay that predates the feature, a request that times out, a
* The other half of the contract is that nothing here may prevent a
* sign-up. A relay that predates the feature, a request that times out, a
* proxy returning HTML, all of those mean "no work wanted", not "no account
* for you".
*/
@@ -176,7 +176,7 @@ class SignupProofOfWorkTest {
* and get one, solve it, and send the same fields again with the answer.
*
* Three exchanges, in this order. The first sign-up carries no challenge
* at all, which is the point: a relay with this feature off, every relay
* at all: a relay with this feature off, every relay
* running today, sees exactly one request and nobody waits twice.
*/
@Test
@@ -209,7 +209,7 @@ class SignupProofOfWorkTest {
}
/**
* Rethrown, not swallowed. When this app genuinely cannot answer, a
* Rethrown, not swallowed. When this app cannot answer, a
* difficulty above the cap, or a relay that will not hand out a challenge,
* "update the app" is the true sentence, and failing with anything else
* sends somebody looking in the wrong place.
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/**
* A test run must not publish a credential (the Kotlin half of ios#62).
*
* **The iOS side had this bug twice, and Android had a quieter version of
* it.** Every live suite here is properly gated behind `assumeTrue`, better
* The iOS side had this bug twice, and Android had a quieter version of
* it. Every live suite here is properly gated behind `assumeTrue`, better
* than iOS managed, but two of them carried
* `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
* accounts on `accounts.flamenet.io` whenever somebody set `FN_LIVE_MANAGED=1`.
@@ -69,8 +69,8 @@ class TestSafetyTest {
/**
* Whether this file can actually reach a relay somebody else runs.
*
* **`MockWebServer` is the discriminator, and getting this wrong made
* the guard useless twice.** Keying on the `Live` name prefix flagged the
* `MockWebServer` is the discriminator, and getting this wrong made
* the guard useless twice. Keying on the `Live` name prefix flagged the
* guard helper and its own unit test; keying on the string "http" flagged
* four suites that sign up against a local mock, which is exactly the
* correct way to test sign-up. A file that stands up a mock server cannot
@@ -17,8 +17,8 @@ import kotlin.test.assertTrue
/**
* The scheduled half of key transparency (§19.3, §19.4, audit S4/M1).
*
* **`verifyLogConsistencyIfDue` and `consistent` were at 0%, and
* `verifyHeadSignature` at 7%.** These are what make transparency *act* rather
* `verifyLogConsistencyIfDue` and `consistent` were at 0%, and
* `verifyHeadSignature` at 7%. These are what make transparency *act* rather
* than merely record. Checking the log only while establishing a session
* leaves an established conversation unchecked indefinitely, which is exactly
* the conversation worth attacking, so a relay that rewound or forked between
@@ -68,7 +68,7 @@ class TrustLogConsistencyTest {
private fun json(body: String) = MockResponse()
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
// -- A real tree, so proofs are genuine rather than stubbed --------------
// -- A real tree, so proofs are rather than stubbed --------------
private val leaves = (0 until 8).map { KeyLog.leafHash(byteArrayOf(it.toByte())) }
@@ -96,8 +96,8 @@ class TrustTest {
}
/**
* **The doc comment on `rememberFnid` said this and the code did the
* opposite.** It overwrote a different address in place, the one thing an
* The doc comment on `rememberFnid` said this and the code did the
* opposite. It overwrote a different address in place, the one thing an
* address pin exists to prevent, since an fnid is derived from the account
* key, so a new address is not a new device but a different account.
*
@@ -182,7 +182,7 @@ class TrustTest {
/**
* The attack. A relay adds ONE key of its own alongside the real ones.
*
* Every genuine device still works, so nothing looks wrong to either
* Every device still works, so nothing looks wrong to either
* person, while a device the attacker controls receives a copy of
* everything. A check written as "any offered key is pinned" accepts this.
*/
@@ -317,7 +317,7 @@ class TrustTest {
server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}"""))
server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}"""))
// No fnid argument: it comes from the pin, which is the whole point.
// No fnid argument: it comes from the pin.
t.enforceKeyLog(b, 7)
assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root)
}
@@ -345,7 +345,7 @@ class TrustTest {
/**
* A key the relay serves but never logged must be refused.
*
* This is the whole point of the mechanism: the relay is free to hand out
* This is the point of the mechanism: the relay is free to hand out
* whatever key it likes, and the client's job is to notice that no such
* publication exists in the log the relay itself publishes.
*/
@@ -6,9 +6,9 @@ import javax.crypto.spec.PBEKeySpec
/**
* Key derivation for the shared FlameNet account service.
*
* One passphrase produces several unrelated secrets: an **authentication
* One passphrase produces several unrelated secrets: an authentication
* secret, which is sent to the account service, and per-product identity
* keys**, which never leave the device. Both come from the same expensive
* keys, which never leave the device. Both come from the same expensive
* stretch and are separated afterwards by an HKDF context string, so a fully
* compromised account service can impersonate someone to itself and decrypt
* nothing of theirs.
@@ -27,8 +27,8 @@ object Aead {
* Seal [plaintext], returning `nonce || ciphertext || tag`.
*
* The nonce is supplied rather than generated so callers cannot accidentally
* reuse one and so this is testable against fixed vectors. **A nonce must
* never repeat under one key**, GCM does not degrade gracefully there, it
* reuse one and so this is testable against fixed vectors. A nonce must
* never repeat under one key, GCM does not degrade gracefully there, it
* leaks the authentication subkey and with it the ability to forge.
*/
fun seal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray,
@@ -163,8 +163,8 @@ object Bip39 {
/**
* PBKDF2-HMAC-SHA512.
*
* **Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money.**
* Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money.
* The JCA path takes a `CharArray` and the provider decides how to encode
* it, `AccountKdf.master` already carries a note about that for the
* account KDF, where the same ambiguity exists. For a wallet the
@@ -576,8 +576,8 @@ data class Content(
* and would need adding to two clients, an engine and a spec before
* anything worked.
*
* **`re.author` is not evidence and the receiver must not read it as
* evidence.** Anyone can put any user id there. The receiver decides
* `re.author` is not evidence and the receiver must not read it as
* evidence. Anyone can put any user id there. The receiver decides
* whether an edit is allowed from the copy of the message it already
* holds, never from what the envelope claims about itself.
*/
@@ -73,8 +73,8 @@ class DeviceIdentity private constructor(
const val ROTATE_AFTER_MS = 30L * 24 * 60 * 60 * 1000
/**
* How long a retired prekey stays usable. **Must exceed the relay's
* undelivered-envelope retention** (30 days by default), with margin
* How long a retired prekey stays usable. Must exceed the relay's
* undelivered-envelope retention (30 days by default), with margin
* for a sender that fetched a bundle and then went offline. Too short
* does not fail loudly, it drops old messages.
*/
@@ -230,7 +230,7 @@ class DeviceIdentity private constructor(
/**
* The seed for whichever prekey a peer encapsulated to.
*
* A one-time key is removed, which is the entire point of it; the
* A one-time key is removed, which is the point of it; the
* last-resort one is not. Returns null when the id names neither, which a
* responder must treat as "this handshake cannot be v2" rather than as an
* error, an envelope naming a prekey we already spent is what a duplicate
@@ -57,8 +57,8 @@ class DoubleRatchet private constructor(
/**
* `{pub, ct}` to attach to outgoing messages (`PQpending`).
*
* **Repeated on every message of the sending chain, not just the first
* (§13.3).** Sending it once would mean losing that single message strands
* Repeated on every message of the sending chain, not just the first
* (§13.3). Sending it once would mean losing that single message strands
* the whole chain: the receiver never gets the material to advance its root
* key, and nothing later in the chain can be decrypted. It is replaced by
* the next ratchet step, and is NOT cleared when the peer answers, it
@@ -284,7 +284,7 @@ class DoubleRatchet private constructor(
//
// Removed only AFTER the tag verifies (audit LOW). Consuming it
// first meant a forged ciphertext for a stashed (dh, n) burned the key
// the real delayed message needed, the genuine message could then
// the real delayed message needed, the message could then
// never be opened. Today the caller discards the whole session object
// on a failed decrypt, so the loss never reaches the vault; that is a
// property of the CALLER, and the moment anything caches a ratchet in
@@ -352,8 +352,8 @@ class DoubleRatchet private constructor(
sent = 0
received = 0
// **Decapsulate BEFORE adopting their new PQ key, and getting this
// order wrong is SILENT.** The peer encapsulated to the key we held
// Decapsulate BEFORE adopting their new PQ key, and getting this
// order wrong is SILENT. The peer encapsulated to the key we held
// going into this step, so `pqSelf` here must still be the old one
// (§13.2). Reversing these derives a different root key with no error
// until an AEAD open fails somewhere later.
@@ -8,10 +8,10 @@ import org.json.JSONObject
* This is the layer an app actually uses: [initiate] or [respond] once, then
* [encrypt] and [decrypt] strings. Everything below it, the handshake, the
* ratchet, the associated data, is set up here and then never thought about
* again, which is the point.
* again.
*
* **The first messages are `prekey` envelopes and must stay that way until
* the peer replies.** Not until the first one is *sent*, until one is
* The first messages are `prekey` envelopes and must stay that way until
* the peer replies. Not until the first one is *sent*, until one is
* *answered*. Any single message may be the first to arrive, so all of them
* have to carry the handshake block; sending it once means losing that one
* message strands the whole conversation, and the symptom is a contact whose
@@ -173,8 +173,8 @@ class E2ESession private constructor(
associatedData = X3DH.associatedData(
theirIdentitySigning, identity.identitySigningPublic, pq?.second),
pendingHandshake = null,
// **Honour what the initiator declared; this could never
// return 3.** `initiate` above sets 3 whenever the PQ ratchet
// Honour what the initiator declared; this could never
// return 3. `initiate` above sets 3 whenever the PQ ratchet
// is on, but responding capped at 2, so a session this device
// ANSWERED was labelled v2 while the far side ran it as v3.
// Every message then arrived at a v3 peer without the
@@ -95,7 +95,7 @@ object FNID {
* What a client signs to say which relays hold its mailboxes.
*
* The serial is inside the signature, which is what stops the rollback a
* signature alone permits: an old list is genuinely signed, so without a
* signature alone permits: an old list is signed, so without a
* counter a relay could serve one from before a second home was added and
* quietly become the only mailbox again.
*/
@@ -3,8 +3,8 @@ package io.flamenet.e2e
/**
* File contents in the Files vault. Mirrors §7 of the published specification.
*
* **Framed into independently authenticated chunks, and that framing is the
* security.** A single AES-GCM blob cannot be verified until all of it has been
* Framed into independently authenticated chunks, and that framing is the
* security. A single AES-GCM blob cannot be verified until all of it has been
* buffered, which is untenable for a large file on a phone, so a naive
* implementation streams it out unverified, and the person reads attacker-chosen
* bytes before the tag ever fails.
@@ -82,8 +82,8 @@ object FileWrap {
/**
* The key hierarchy.
*
* **A node key is wrapped to its OWN label key, never to the folder above
* it.** The tree deliberately allows a Restricted file to sit inside a
* A node key is wrapped to its OWN label key, never to the folder above
* it. The tree deliberately allows a Restricted file to sit inside a
* Confidential folder, so a folder-derived key would hand every holder of that
* folder's key content above their clearance. When the rows lived in Postgres
* an RLS predicate would still have refused to return them; there is no
@@ -6,8 +6,8 @@ import org.json.JSONObject
* The Files identity keypair, and the record that protects it. Mirrors §3 of
* the published specification.
*
* **The record is written by every FlameNet client and must open in all of
* them.** The browser writes it, iOS writes it, and this reads what either
* The record is written by every FlameNet client and must open in all of
* them. The browser writes it, iOS writes it, and this reads what either
* produced. That is why the awkward details below are pinned rather than
* tidied: each one is a place where two implementations could disagree and only
* find out when somebody's files will not open on their new phone.
@@ -13,7 +13,7 @@ import javax.crypto.spec.SecretKeySpec
* says nothing about the Files one.
*
* Not [AccountKdf]. Same shape, different context strings and a different
* salt, and the two must not be collapsed: the whole point of the split is that
* salt, and the two must not be collapsed: the point of the split is that
* one product's derived secret is useless in another.
*/
object FilesKdf {
@@ -43,9 +43,9 @@ object FilesKdf {
/**
* PBKDF2-HMAC-SHA256.
*
* **Hand-written on top of [Mac] rather than through
* Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA256")`, for the same reason
* [Bip39.pbkdf2HmacSha512] is.** The JCA path takes a `CharArray` and lets
* [Bip39.pbkdf2HmacSha512] is. The JCA path takes a `CharArray` and lets
* the provider decide how to encode it. The reference implementation here
* is WebCrypto, which takes *bytes*, and CommonCrypto on iOS takes bytes
* too. Handing the JCA characters and hoping puts a provider between us and
@@ -34,8 +34,8 @@ data class HdAccount(
/**
* The address at [index], and the path it came from.
*
* **Base58 of the raw public key, with no checksum, because Solana
* addresses have none.** A mistyped address that still decodes to 32 bytes
* Base58 of the raw public key, with no checksum, because Solana
* addresses have none. A mistyped address that still decodes to 32 bytes
* is a *valid* address belonging to nobody, money sent there is gone, and
* there is no layer below this that will catch it. Anything offering an
* address to be typed has to say so; this function cannot.
@@ -76,8 +76,8 @@ data class HdAccount(
/**
* Whether [address] could be an address on this chain.
*
* **This says "well formed", NEVER "correct", and the difference is
* money.** A Solana address is Base58 of a 32-byte public key and
* This says "well formed", NEVER "correct", and the difference is
* money. A Solana address is Base58 of a 32-byte public key and
* carries no checksum, so a single mistyped character that happens to
* decode to 32 bytes produces a perfectly valid address that nobody
* holds the key for. Bitcoin's Base58Check would catch that; this
@@ -12,7 +12,7 @@ import javax.crypto.spec.SecretKeySpec
* A history backup the platform cannot read.
*
* The relay keeps no history, that is the product's whole promise, which
* means a new device genuinely starts empty. This is the designed remedy: the
* means a new device starts empty. This is the designed remedy: the
* vault's history, re-sealed under a key derived from a passphrase the user
* chooses, so the file is safe to park anywhere and useless without the words.
*
@@ -3,8 +3,8 @@ package io.flamenet.e2e
/**
* The security lattice. Mirrors §1 of the published Files specification.
*
* **The ten-byte encoding is authenticated data for every construction that
* uses it**, so a label that encodes differently on two platforms does not
* The ten-byte encoding is authenticated data for every construction that
* uses it, so a label that encodes differently on two platforms does not
* produce a warning, it produces a decryption failure, or worse, plaintext
* that quietly belongs to the wrong sensitivity. That is why [bytes] is pinned
* to a vector rather than left to look obviously right.
@@ -84,8 +84,8 @@ data class Clearance(val maximum: Label, val minimum: Label = Label.bottom) {
/**
* Whether a write to [label] is allowed.
*
* **The SAME check as reading, and that is a deliberate match with iOS
* rather than an oversight.** `FilesSession.ensureLabelKey` and `use` both
* The SAME check as reading, and that is a deliberate match with iOS
* rather than an oversight. `FilesSession.ensureLabelKey` and `use` both
* gate on `clearance.permits`, so the range is the whole rule on both
* platforms.
*
@@ -193,8 +193,8 @@ object SealedSender {
* The control message handing a contact our delivery key (§14.4) and, since
* §21.4, a batch of single-use capabilities alongside it.
*
* **One envelope, not two, and that is a compatibility decision rather
* than tidiness.** A receiver recognises control traffic by KIND first and
* One envelope, not two, and that is a compatibility decision rather
* than tidiness. A receiver recognises control traffic by KIND first and
* falls back to the `ctl` flag only for kinds it has never heard of, and
* the build in the field reads that flag with `optBoolean`, which does not
* coerce the number §6.10 specifies. A new `delivery_tokens` kind would
@@ -5,15 +5,15 @@ import java.util.Arrays
/**
* Zeroing key material once it has been used (ios#39).
*
* **This narrows a window. It does not remove the material, and claiming
* otherwise would spend the whole value of the feature on a lie.** What it buys
* This narrows a window. It does not remove the material, and claiming
* otherwise would spend the whole value of the feature on a lie. What it buys
* is that a derived key does not sit in the process for the lifetime of the app
* after the one moment it was needed, which matters for a heap dump, a crash
* report, or a debugger attached later. It buys nothing against somebody
* reading memory *while* the secret is in use.
*
* **The JVM guarantee is weaker than the iOS one, and the difference is not
* a detail.** iOS has `memset_s`, which is specified not to be optimised away,
* The JVM guarantee is weaker than the iOS one, and the difference is not
* a detail. iOS has `memset_s`, which is specified not to be optimised away,
* and iOS does not page application memory to disk. Neither holds here:
*
* - A compacting garbage collector moves objects. A `ByteArray` can be
@@ -36,8 +36,8 @@ import java.util.Arrays
* `AccountKdf.master` already calls `PBEKeySpec.clearPassword()` rather than
* trying to do anything about the `String` it was handed.
*
* So the claim this type supports, precisely: **a derived key is not left
* sitting in the heap after use.** Not "the key is gone".
* So the claim this type supports, precisely: a derived key is not left
* sitting in the heap after use. Not "the key is gone".
*/
object SecureBytes {
@@ -68,7 +68,7 @@ object SecureBytes {
}
/**
* A copy of [source], for callers that genuinely need one.
* A copy of [source], for callers that need one.
*
* The copy is ordinary memory and this object stops being responsible
* for it. Every use of this is a decision to leave a second copy behind,
@@ -3,8 +3,8 @@ package io.flamenet.e2e
/**
* What a viewer is told before an embedded live stream connects (spec §6.13).
*
* **This is the one media path where the viewer must contact a third party,
* and it cannot be designed away.** A photo or video story is a sealed blob:
* This is the one media path where the viewer must contact a third party,
* and it cannot be designed away. A photo or video story is a sealed blob:
* the relay stores ciphertext and nobody else is involved. A live stream is
* continuous and hosted elsewhere, so there is nothing to seal, watching it
* means the viewer's device connects to that host, which learns their IP
+23 -23
View File
@@ -215,8 +215,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* How long a message actually lives, given its own timer and its
* conversation's (§6.17). Null means it does not expire.
*
* **The shorter of the two always wins, and that is the whole
* rule.** A per-message timer may only take life away, never add it.
* The shorter of the two always wins, and that is the whole
* rule. A per-message timer may only take life away, never add it.
* Otherwise a sender could defeat the recipient's conversation timer by
* putting a long one on every message, turning a setting the recipient
* chose, on their own device, into something the other party overrules.
@@ -237,8 +237,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/**
* Whether this story may be written out of the app (ios#34).
*
* **Only ever your own, and the rule lives here rather than in the
* button.** Saving somebody else's story keeps a copy of something
* Only ever your own, and the rule lives here rather than in the
* button. Saving somebody else's story keeps a copy of something
* they were told would disappear, the opposite of what the feature
* promises them, and no wording on a menu item makes that acceptable.
* A guard in a composable is one refactor away from being lost.
@@ -406,8 +406,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
// -- Identity trust (§19) ------------------------------------------------
//
// **Encrypted at rest like everything else here, and that is the point
// rather than a nicety.** These records are what the client compares a
// Encrypted at rest like everything else here
// rather than a nicety. These records are what the client compares a
// served key against. Somewhere an attacker can rewrite them is somewhere
// an attacker can pin their own key and have the comparison agree with
// them for ever afterwards, silently, because a match is what "fine"
@@ -464,8 +464,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
Trust(
identities = (0 until a.length()).map { a.getString(it) },
verified = o.optBoolean("verified", false),
// **`"null"` is rejected, and that is not paranoia, records
// written that way are already on phones.** `saveTrust` used to
// `"null"` is rejected, and that is not paranoia, records
// written that way are already on phones. `saveTrust` used to
// write an explicit JSON null here, and Android's `org.json`
// coerces that to the four-character string on the way back out.
// See the note on [saveTrust]; this is the half that repairs what
@@ -476,8 +476,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
}
/**
* **The key is OMITTED when there is no address, never written as an
* explicit JSON null.** Android's bundled `org.json` reads a JSON null back
* The key is OMITTED when there is no address, never written as an
* explicit JSON null. Android's bundled `org.json` reads a JSON null back
* as the four-character string `"null"`, so a contact pinned before their
* fnid was known reloaded with `fnid = "null"`, a non-null value that is
* not an address. What that caused:
@@ -651,7 +651,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* What this device holds for one sealed group.
*
* Lose these and the group is gone. The relay cannot tell you what
* you are a member of, that is the entire point of sealing, so the tag is
* you are a member of, that is the point of sealing, so the tag is
* the only way to fetch the group and the key is the only way to read it.
* Both ride the encrypted history export and the group invitation, the same
* paths the group name already uses.
@@ -751,8 +751,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/**
* A half-written message, kept across a relaunch.
*
* **In the vault rather than in SharedPreferences, and that is the whole
* point.** A draft is plaintext the person has not decided to send, very
* In the vault rather than in SharedPreferences, and that is the whole
* point. A draft is plaintext the person has not decided to send, very
* often the most sensitive text in the app, because it is the thing they
* are still weighing. `SharedPreferences` writes it as readable XML that
* survives into backups; here it is sealed under the same key as the
@@ -1068,8 +1068,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/**
* Whether this message has already been read from another mailbox.
*
* **The point of several mailboxes is that a message goes to all of
* them.** Without this the person reads it once per home, and would not,
* The point of several mailboxes is that a message goes to all of
* them. Without this the person reads it once per home, and would not,
* in fact, because the ratchet deletes a message key once it is used, so
* the second copy fails to decrypt. The visible symptom is therefore not a
* duplicate but a dropped envelope and a message that looks lost.
@@ -1337,8 +1337,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/**
* Messages typed but not yet delivered to anybody.
*
* **This has to live here, on the sender's phone, and that is forced by
* the encryption rather than chosen.** A relay cannot hold a message for
* This has to live here, on the sender's phone, and that is forced by
* the encryption rather than chosen. A relay cannot hold a message for
* somebody with no registered device: every envelope is sealed to a
* specific device's keys, so with no device there is nothing to seal it to.
* The relay could only queue plaintext, which is the one thing this app
@@ -1565,8 +1565,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
// of groups all called "Group", the exact loss this file exists to
// prevent.
.also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } }
// **Without these a restored device loses its sealed groups for
// good.** The relay cannot say what you are a member of, that is
// Without these a restored device loses its sealed groups for
// good. The relay cannot say what you are a member of, that is
// the point of sealing, so the tag is the only way to fetch a group
// and the key the only way to read it. A backup carrying the
// messages but not these restores a device that can see old
@@ -1671,8 +1671,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
saveRoster(current, offServer || imported.optBoolean("offServer", false))
}
// **Group names ride the backup, because nothing else can supply
// them now.** The relay stopped holding them, so a restored device that
// Group names ride the backup, because nothing else can supply
// them now. The relay stopped holding them, so a restored device that
// did not read them here would show every group as "Group" until some
// other member happened to send a message.
//
@@ -1813,7 +1813,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* Sealed by the vault, which means erasing this device destroys it.
* Plain sign-out keeps it (and so does a password change, which signs out
* plainly); "erase this phone" and "delete account" do not. After those, the
* recovery phrase is the only copy that exists, which is the whole point of
* recovery phrase is the only copy that exists, which is the point of
* a recovery phrase, and has to be SAID rather than discovered.
*/
fun walletEntropy(): ByteArray? =
@@ -1890,7 +1890,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* The highest home-record serial seen for an address, or null.
*
* This is what makes the serial mean anything on the reading side. An
* older home record is genuinely signed, published by the account holder
* older home record is signed, published by the account holder
* before they added a second relay, so a relay serving it is handing out
* something that verifies. Remembering the highest seen is the only thing
* that turns "this signature is valid" into "this is not a rollback".

Some files were not shown because too many files have changed in this diff Show More