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:
co-authored by
Claude Fable 5.1
parent
4dd544083c
commit
8a7f40fb13
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user