Remove machine-writing tells from comments and docs

Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:

  emoji markers   2680 -> 1 (the emoji picker's own list, which is the feature)
  em-dashes       2490 -> 9 (table placeholders and fenced code)

Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.

Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.

CONTRIBUTING gets a four-line rule so it does not grow back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 18:52:12 -04:00
co-authored by Claude Fable 5.1
parent 04e06452b6
commit 4dd544083c
277 changed files with 4885 additions and 4883 deletions
@@ -9,7 +9,7 @@ import io.flamenet.e2e.FNID
* ## Why this exists at all
*
* A relay's `user_id` is a row number it assigned itself. Register at one relay
* and everything about you — your name there, your devices, your mailbox — is
* and everything about you, your name there, your devices, your mailbox, is
* that relay's to keep or to lose. This makes the *identity* the user's
* instead: an Ed25519 key stretched from the passphrase, whose public half is
* the address. No server issues it, so no server can take it away.
@@ -35,9 +35,9 @@ object AccountIdentity {
* Claim the address at [relay], learning which audience to bind from the
* relay itself.
*
* ⚠️ **The audience is not the caller's to guess.** It is the string the
* The audience is not the caller's to guess. It is the string the
* relay checks the signature against, so a caller that inferred it from a
* URL would produce a claim that verifies nowhere — silently, because at
* URL would produce a claim that verifies nowhere, silently, because at
* the point of failure a wrong audience and a wrong key look identical.
* Reading it from the descriptor means the only way to get it wrong is for
* the relay to lie about its own name, which costs it the claim rather than
@@ -45,7 +45,7 @@ object AccountIdentity {
*
* Self-certifying: the signature is made with the very key being
* registered, so the relay confirms possession and forms no opinion about
* whose key it is — the property that lets a hostile relay be tolerated.
* whose key it is, the property that lets a hostile relay be tolerated.
*
* Returns the address the relay recorded.
*/
@@ -65,7 +65,7 @@ object AccountIdentity {
* for the record would be handed a stale list and would skip the others.
* Delivering the record everywhere is what keeps them from disagreeing.
*
* Takes a client **per home**, and that is the shape of the feature rather
* Takes a client per home, and that is the shape of the feature rather
* than an implementation detail: a relay will not accept a record from
* somebody with no account there, and a token minted by one relay is
* rejected by every other because the audience is bound. So homing
@@ -95,7 +95,7 @@ object AccountIdentity {
* than from local state.
*
* A device that was offline while another added a relay would otherwise
* reuse a serial and have its record refused as a rollback — by the very
* reuse a serial and have its record refused as a rollback, by the very
* check that exists to stop an attacker doing the same thing.
*/
fun nextSerial(fnid: String, clients: Collection<RelayClient>): Int {
@@ -110,7 +110,7 @@ object AccountIdentity {
/**
* Check a home record against the address it claims to be for.
*
* Needs nothing but the record, because the address *is* the public key —
* Needs nothing but the record, because the address *is* the public key,
* which is the point. A sender must not have to ask a relay whether that
* relay is telling the truth about where to deliver.
*/
@@ -124,11 +124,11 @@ 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
* **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
* 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
* it serves the record it saw first, that record verifies perfectly, and
* the sender delivers to one relay believing it is delivering to all of
* them.
*
@@ -136,11 +136,11 @@ object AccountIdentity {
* clients checked only the signature. The relay refuses a lower serial on
* WRITE; nothing refused one on READ.
*
* ⚠️ [floor] is the highest serial this device has already accepted for
* this address, or null on first sight. Equal is fine — the same record
* [floor] is the highest serial this device has already accepted for
* this address, or null on first sight. Equal is fine, the same record
* 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
* 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
* record for honest reasons; what the client can say is that it has seen a
* newer one, and that it will keep using it.
@@ -10,17 +10,17 @@ import org.json.JSONObject
import java.security.SecureRandom
/**
* The shared FlameNet account service — one credential across Messenger and
* The shared FlameNet account service, one credential across Messenger and
* Files.
*
* ⚠️ **The passphrase never leaves the device.** It is stretched here and only
* 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
* to one call would mean sending the passphrase.
*
* Used only for relays whose accounts live in the shared service. A self-hosted
* relay authenticates against its own table and never touches this — see
* relay authenticates against its own table and never touches this, see
* [SiteConfig], which decides from the hostname rather than from anything the
* relay says about itself.
*/
@@ -34,7 +34,7 @@ class AccountsClient(
/**
* The product a session is scoped to.
*
* ⚠️ **Sessions are per product, and that is the privacy property.** The
* Sessions are per product, and that is the privacy property. The
* token carries a pseudonym derived for this product, so Messenger and
* Files receive different opaque ids for the same person and cannot
* correlate their users with each other.
@@ -44,9 +44,9 @@ class AccountsClient(
/**
* What the service advertises for a handle.
*
* ⚠️ An unknown handle gets a **deterministic decoy** salt, not an error,
* An unknown handle gets a deterministic decoy salt, not an error,
* so this route cannot be used to test whether somebody has an account.
* Treat a success as "here are parameters", never as "this handle exists" —
* Treat a success as "here are parameters", never as "this handle exists",
* a UI that says "no such account" here undoes the whole design.
*/
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
@@ -54,7 +54,7 @@ class AccountsClient(
/**
* @property accountKeySeed the 32-byte Ed25519 seed whose public half is
* this account's address. Derived from the same stretch as the auth
* secret and never sent anywhere — see [AccountKdf.signInSecrets] for
* secret and never sent anywhere, see [AccountKdf.signInSecrets] for
* why it comes back here rather than being asked for separately.
*/
data class Session(
@@ -69,23 +69,23 @@ class AccountsClient(
object BadCredentials : Failure("that handle or passphrase isn't right")
/**
* The session is no longer accepted — expired, or revoked elsewhere.
* The session is no longer accepted, expired, or revoked elsewhere.
*
* 🔴 **Not the same as a wrong passphrase, and saying so matters.**
* Not the same as a wrong passphrase, and saying so matters.
* Every 401 and 403 used to become [BadCredentials], so a session that
* simply aged out told the user "that handle or passphrase isn't
* right" — in the middle of sending a message, hours after they signed
* right", in the middle of sending a message, hours after they signed
* in successfully. The natural reading is that their password stopped
* working or was changed by someone else, and the natural response is
* to change a password that was never wrong.
*/
object SessionExpired : Failure("your session has expired — sign in again")
object SessionExpired : Failure("your session has expired, sign in again")
object HandleTaken : Failure("that handle is already taken")
/**
* 🔴 **A 409 is not always a taken handle, and saying so was wrong.**
* A 409 is not always a taken handle, and saying so was wrong.
* The relay answers 409 for a taken handle, a registered email, AND a
* handle too close to an existing one — three different problems with
* handle too close to an existing one, three different problems with
* three different fixes. Collapsing them told somebody whose EMAIL was
* already registered to pick a different handle, which cannot work and
* gives them no way to discover why.
@@ -95,7 +95,7 @@ class AccountsClient(
*/
object EmailTaken : Failure("an account with that email already exists")
/** The handle folds onto an existing one — see the relay's
/** The handle folds onto an existing one, see the relay's
* `User.confusableSkeleton`. Changing a digit will not help. */
object HandleConfusable : Failure("that handle is too easily mistaken for another")
@@ -103,7 +103,7 @@ class AccountsClient(
object BadHandle : Failure("that handle isn't allowed")
object BadEmail : Failure("that doesn't look like an email address")
object WeakPassword : Failure("that passphrase is too short")
object RateLimited : Failure("too many attempts — wait a few minutes")
object RateLimited : Failure("too many attempts, wait a few minutes")
class Service(val status: Int) : Failure("the account service returned $status")
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
}
@@ -121,9 +121,9 @@ class AccountsClient(
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
val params = parameters(handle)
// ⚠️ The ADVERTISED iteration count wins over the local constant. An
// The ADVERTISED iteration count wins over the local constant. An
// account created under an older count must still be derivable, and the
// service is the one that knows which — hardcoding it here silently
// service is the one that knows which, hardcoding it here silently
// locks out every older account.
val effective = AccountKdf.Params(
id = params.kdfId,
@@ -131,7 +131,7 @@ class AccountsClient(
saltBytes = params.salt.size,
)
// Both secrets from ONE stretch. Asking for the account key separately
// would re-run 650,000 iterations for a value already in hand — several
// would re-run 650,000 iterations for a value already in hand, several
// seconds on a phone, spent twice, to sign in once.
val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective)
@@ -184,16 +184,16 @@ class AccountsClient(
} catch (_: Exception) {
throw Failure.Transport("unreadable response")
}
// ⚠️ The SAME status means different things depending on what
// The SAME status means different things depending on what
// was sent. Rejecting a passphrase is BadCredentials; refusing
// a token the user cannot see is an expired session.
401, 403 ->
throw if (carriesCredentials) Failure.BadCredentials
else Failure.SessionExpired
// 🔴 **Read the relay's own code, not just the status.** A
// Read the relay's own code, not just the status. A
// 400 became a bare "the account service returned 400" and a
// 409 became "that handle is already taken" whatever the
// actual reason — so a weak passphrase, an unusable email and
// actual reason, so a weak passphrase, an unusable email and
// an already-registered address were all reported as something
// the person could not act on, or as the wrong problem
// entirely. The relay has always sent a specific code; nothing
@@ -208,7 +208,7 @@ class AccountsClient(
/**
* The relay's error code, or null when the body is not its error shape.
*
* ⚠️ Never throws. A body that cannot be parsed is a transport problem, not
* Never throws. A body that cannot be parsed is a transport problem, not
* a reason to lose the status we already have.
*/
private fun code(body: String): String? = runCatching {
@@ -216,7 +216,7 @@ class AccountsClient(
}.getOrNull()
/**
* ⚠️ Falls back to the STATUS when the code is unknown, so a refusal this
* Falls back to the STATUS when the code is unknown, so a refusal this
* build has never heard of still reports as a refusal rather than as a
* success or a crash. A relay newer than the app is the normal case, not
* the exception.
@@ -10,13 +10,13 @@ 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
* **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
* **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
@@ -61,9 +61,9 @@ class ChainRpc(
val json = runCatching { JSONObject(text) }.getOrNull()
?: throw Failure.Malformed("not an object")
// 🔴 **A JSON-RPC error arrives with HTTP 200.** Reading only the status
// A JSON-RPC error arrives with HTTP 200. Reading only the status
// code takes a refusal for an answer, and then something fails further
// down with the node's own explanation already thrown away — which is
// down with the node's own explanation already thrown away, which is
// the difference between "insufficient funds for rent" on screen and a
// parse error nobody can act on.
json.optJSONObject("error")?.let { error ->
@@ -76,9 +76,9 @@ class ChainRpc(
// ---- Reading ---------------------------------------------------------
/**
* The balance in lamports — the chain's smallest unit.
* The balance in lamports, the chain's smallest unit.
*
* ⚠️ Lamports, not SOL, all the way to the edge of the UI. Converting early
* Lamports, not SOL, all the way to the edge of the UI. Converting early
* means carrying a fractional value through code that compares and
* subtracts, and a rounding error in a balance is money.
*/
@@ -102,7 +102,7 @@ class ChainRpc(
/**
* One line of an address's history.
*
* 🔴 **A FAILED transaction still appears in an address's history.** Not
* A FAILED transaction still appears in an address's history. Not
* showing that it failed would be the worst kind of wrong: somebody would
* believe a payment went through.
*/
@@ -110,7 +110,7 @@ class ChainRpc(
val signature: String,
val slot: Long,
/**
* Absent while a transaction is very new — the node has it but has not
* Absent while a transaction is very new, the node has it but has not
* dated it yet. Null means "just now", never 1970.
*/
val blockTimeSeconds: Long?,
@@ -125,7 +125,7 @@ class ChainRpc(
slot = json.optLong("slot", 0),
blockTimeSeconds = if (json.isNull("blockTime")) null
else json.optLong("blockTime"),
// ⚠️ `err` is an object or an array depending on the
// `err` is an object or an array depending on the
// failure, and `null` when there was none. Testing for
// "not null" rather than for a shape is what makes both
// kinds of failure visible.
File diff suppressed because it is too large Load Diff
@@ -2,19 +2,19 @@ package io.flamenet.client
/**
* What the client already knows about delivery, kept where a person can read
* it (ios#21, ios#23 — the Android half).
* it (ios#21, ios#23, the Android half).
*
* 🔴 **The client has always known this; it only ever told the log.** Every
* The client has always known this; it only ever told the log. Every
* refusal went to `System.err` and nowhere else, so a send blocked by the
* identity guard reached the person sending it as "Something went wrong
* reaching that server" — which was neither true nor actionable.
* reaching that server", which was neither true nor actionable.
*
* ⚠️ **Shape only, never content.** Counts, reasons and timestamps. No message
* text, no key material, no payloads, and no identifiers — see
* Shape only, never content. Counts, reasons and timestamps. No message
* text, no key material, no payloads, and no identifiers, see
* [normalisedReason]. A diagnostic reading that leaks what it is diagnosing is
* worse than none, and this one is meant to be safe to screenshot.
*
* ⚠️ In memory, deliberately. It describes the session you are in; persisting
* In memory, deliberately. It describes the session you are in; persisting
* it would be a growing record of who you talked to and when, which is the
* metadata this project spends its effort not keeping.
*/
@@ -33,8 +33,8 @@ object DeliveryDiagnostics {
val dropTotal: Int get() = drops.values.sum()
/**
* 🔴 A client that repeatedly repairs is a symptom, not a cure. One is
* ordinary — a contact reinstalled. Three against the same peer means
* A client that repeatedly repairs is a symptom, not a cure. One is
* ordinary, a contact reinstalled. Three against the same peer means
* something is not settling, and that is worth saying out loud.
*/
val repairsLookAbnormal: Boolean get() = repairs >= 3
@@ -83,12 +83,12 @@ object DeliveryDiagnostics {
/**
* Strip anything identifying before a reason is counted.
*
* ⚠️ Refusal messages name the thing that was wrong, which sometimes means
* naming a device id — "device … has published keys, but none match".
* Refusal messages name the thing that was wrong, which sometimes means
* naming a device id, "device … has published keys, but none match".
* A log may carry that; a screen the user is invited to screenshot may not.
* Runs of digits and hex become "…".
*
* ⚠️ Short hex ids are the ones that slip through: `9f0e` is neither a long
* Short hex ids are the ones that slip through: `9f0e` is neither a long
* hex run nor an all-digit run. Hence "four or more hex characters
* containing at least one digit", which catches truncated device ids
* without eating ordinary words like `base64`.
@@ -10,10 +10,10 @@ import kotlin.math.roundToInt
/**
* What the time is, when the device's own answer cannot be trusted (ios#45).
*
* 🔴 **A wrong device clock breaks a promise made to somebody else.** Almost
* A wrong device clock breaks a promise made to somebody else. Almost
* everything time-dependent here is a commitment: a story disappears after an
* hour, a message clears itself after a day. Those are promises the SENDER
* made, and they are enforced on the READER's device — so a reader whose clock
* made, and they are enforced on the READER's device, so a reader whose clock
* is running behind keeps a message the sender was told would be gone, and
* nobody involved can tell.
*
@@ -21,18 +21,18 @@ import kotlin.math.roundToInt
* the reader something they were going to lose anyway; being wrong in the other
* direction breaks somebody else's expectation silently. So:
*
* 🔴 **For expiry, take the LATEST plausible time.** Never the device's alone,
* never the server's alone — whichever is further ahead. A device clock set back
* For expiry, take the LATEST plausible time. Never the device's alone,
* never the server's alone, whichever is further ahead. A device clock set back
* cannot extend a message's life, and neither can a relay that lies about the
* time, because the other source overrules it. The cost is that a device clock
* set FORWARD expires things early, which is the failure worth having.
*
* ⚠️ **The relay is a hint, not an authority.** It is told the time by nobody we
* The relay is a hint, not an authority. It is told the time by nobody we
* trust, and a hostile one would like to move time backwards so that
* disappearing messages linger. Taking the later of the two is exactly what
* stops that being useful to it.
*
* ⚠️ **Process-wide and synchronised, not a singleton object with plain vars.**
* Process-wide and synchronised, not a singleton object with plain vars.
* The Swift version can be `nonisolated(unsafe) static var` because the places
* that write it are on one actor; here the observation is written from whatever
* OkHttp thread finished a call and read from the UI thread, so the fields are
@@ -46,7 +46,7 @@ object DeviceClock {
* Difference between the last server time seen and this device's clock, in
* milliseconds. Positive means the device is BEHIND.
*
* ⚠️ Not persisted. A skew learned yesterday says nothing about today — the
* Not persisted. A skew learned yesterday says nothing about today, the
* clock may have been corrected, or the phone rebooted with a fresh answer
* from the network.
*/
@@ -62,9 +62,9 @@ object DeviceClock {
/**
* How far out the clock has to be before it is worth saying anything.
*
* ⚠️ Five minutes. Ordinary drift is seconds; a phone whose clock is out by
* more than this will have visible symptoms — stories that vanish, messages
* that will not send — and being told why is better than guessing.
* Five minutes. Ordinary drift is seconds; a phone whose clock is out by
* more than this will have visible symptoms, stories that vanish, messages
* that will not send, and being told why is better than guessing.
*/
const val SUSPECT_THRESHOLD_MS = 300_000L
@@ -74,7 +74,7 @@ object DeviceClock {
/**
* Learn the skew from a response's `Date` header.
*
* ⚠️ Free: every HTTP response already carries one, so this costs no extra
* Free: every HTTP response already carries one, so this costs no extra
* request and no extra round trip.
*/
fun observe(serverMillis: Long, deviceNow: Long = System.currentTimeMillis()) {
@@ -116,7 +116,7 @@ object DeviceClock {
/**
* What to say about it, or null when there is nothing to say.
*
* ⚠️ Names the symptom before the cause. Somebody whose stories keep
* Names the symptom before the cause. Somebody whose stories keep
* vanishing does not think "my clock is wrong"; they think the app is
* broken.
*/
@@ -134,12 +134,12 @@ object DeviceClock {
/**
* The time to judge an expiry against, in milliseconds.
*
* 🔴 **The later of the device's clock and the server's.** Neither alone: a
* The later of the device's clock and the server's. Neither alone: a
* device set back would keep a disappearing message past the moment its
* sender was told it would go, and a relay that lied about the time could
* arrange the same thing. Taking whichever is further ahead means both have
* to be wrong in the same direction to extend anything, and the remaining
* failure — expiring early — costs the reader something they were about to
* failure, expiring early, costs the reader something they were about to
* lose anyway.
*/
fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long {
@@ -150,7 +150,7 @@ object DeviceClock {
/**
* The same in seconds, which is how the vault stores expiries.
*
* ⚠️ A Double of seconds, matching `Vault.Story.expires` — converting to a
* A Double of seconds, matching `Vault.Story.expires`, converting to a
* Long of seconds here would floor away sub-second precision on a boundary
* that decides whether a story is still on screen.
*/
@@ -160,8 +160,8 @@ object DeviceClock {
/**
* RFC 7231 `Date`, which is what an HTTP header carries.
*
* ⚠️ Fixed locale and zone. A formatter that picked those up from the device
* parses nothing on a phone set to most of the world — and a `SimpleDateFormat`
* Fixed locale and zone. A formatter that picked those up from the device
* parses nothing on a phone set to most of the world, and a `SimpleDateFormat`
* is not thread-safe, so one is built per call rather than shared. This runs
* once per HTTP response, which is nowhere near hot enough to care.
*/
@@ -8,15 +8,15 @@ 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
* draw a card — is the one that must not be built: it tells the site the
* **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
* and then has every reader announce its contents to a third party has given
* away what it was protecting.
*
* ⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is
* Sender-side is a smaller leak, not no leak: the site learns somebody is
* about to share that URL, from this address, before the message is sent. That
* is what the setting is for.
*/
@@ -38,8 +38,8 @@ object LinkPreviewFetcher {
fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')')
/**
* Fetch and parse Open Graph tags. Null for anything unusual — no title, a
* refusal, a timeout — each of which leaves a message with a plain URL in
* Fetch and parse Open Graph tags. Null for anything unusual, no title, a
* refusal, a timeout, each of which leaves a message with a plain URL in
* it, which is a perfectly good thing to be.
*/
fun fetch(url: String): Content.LinkPreview? = runCatching {
@@ -64,7 +64,7 @@ object LinkPreviewFetcher {
}
}.getOrNull()
// ⚠️ Regex over a bounded prefix, deliberately. A full HTML parser for two
// Regex over a bounded prefix, deliberately. A full HTML parser for two
// attributes is a dependency and an attack surface; this reads what it
// recognises and gives up otherwise, which is the correct failure here.
@@ -84,7 +84,7 @@ object LinkPreviewFetcher {
/**
* The handful of entities that actually appear in titles. Anything else is
* left alone — a literal `&copy;` is a cosmetic flaw, and expanding
* left alone, a literal `&copy;` is a cosmetic flaw, and expanding
* arbitrary entities is how a parser grows teeth.
*/
private fun clean(s: String): String = s
@@ -22,7 +22,7 @@ import org.json.JSONObject
/**
* Files that live on this phone.
*
* 🔴 **The server is not storage any more.** It is a courier: it holds
* The server is not storage any more. It is a courier: it holds
* ciphertext for as long as it takes to reach the other device, and then it does
* not. Everything a person owns is here, in this app's own container, and a
* phone that never syncs still has all of it.
@@ -30,9 +30,9 @@ import org.json.JSONObject
* What that costs is worth stating plainly, because it changed and it changed
* silently everywhere else:
*
* ⚠️ **Read-down is no longer enforced by anything that can refuse.** When the
* Read-down is no longer enforced by anything that can refuse. When the
* rows lived in Postgres, `session_dominates(classification, compartments)` was
* an RLS predicate — the database itself would not return a row above your
* an RLS predicate, the database itself would not return a row above your
* clearance, whatever the client asked for. There is no database here. A label
* still gates *decryption*, absolutely: without the label key the bytes are
* bytes. But nothing stops a sender transferring a file above a recipient's
@@ -40,7 +40,7 @@ import org.json.JSONObject
* device holds", not "what a server will show you". That is a deliberate trade,
* and the interface must not keep using the old words for the new thing.
*
* ⚠️ **The index file is device-local and is NOT an interchange format.** The
* The index file is device-local and is NOT an interchange format. The
* node *fields* match iOS's exactly, deliberately, so that a courier transfer is
* a copy rather than a translation. The JSON around them is not: iOS encodes
* dates as Swift's reference-date doubles, which is an accident of `JSONEncoder`
@@ -48,8 +48,8 @@ import org.json.JSONObject
* format. Anybody building vault migration between phones should move nodes
* through a declared wire shape, not by copying this file.
*
* ⚠️ **Synchronous, and it touches the disk.** Call it off the main thread. The
* lock below makes concurrent calls safe, not fast — a `ReentrantLock` rather
* Synchronous, and it touches the disk. Call it off the main thread. The
* lock below makes concurrent calls safe, not fast, a `ReentrantLock` rather
* than a coroutine `Mutex` because this module deliberately has no coroutines
* dependency, which is what lets its tests run without an emulator.
*/
@@ -60,8 +60,8 @@ class LocalVault(
/**
* How a referenced file is reached.
*
* 🔴 Required, with no default. A no-op default would make every reference
* row read as "missing" in an app that simply forgot to wire this up — a
* Required, with no default. A no-op default would make every reference
* row read as "missing" in an app that simply forgot to wire this up, a
* vault that looks like it has lost half its contents, with nothing
* anywhere saying why.
*/
@@ -71,14 +71,14 @@ class LocalVault(
/**
* Where a row's bytes actually are.
*
* 🔴 The two are not equally protected and the interface must not present
* The two are not equally protected and the interface must not present
* them as if they were.
*
* - [VAULT]: copied in and encrypted under the label key. A stolen backup or
* container yields ciphertext, and the classification is a control.
* - [REFERENCE]: a pointer to a file that stays where it is on the phone,
* encrypted only at the moment it is sent. Nothing is duplicated, and the
* file keeps whatever protection the platform gives it — but we are not
* file keeps whatever protection the platform gives it, but we are not
* adding any, the label is advisory until it is sent, and the row breaks
* if the file is moved or deleted.
*/
@@ -88,9 +88,9 @@ class LocalVault(
companion object {
/**
* ⚠️ Absence means [VAULT], and that is not a nicety. iOS shipped a
* Absence means [VAULT], and that is not a nicety. iOS shipped a
* `storage` field with a Swift default and discovered that a default
* does not make a `Codable` key optional — every index written
* does not make a `Codable` key optional, every index written
* before referencing existed failed to decode, which presents as a
* vault that has lost everything. Reading an unknown or missing
* value as `vault` is what those rows actually are.
@@ -110,7 +110,7 @@ class LocalVault(
*/
interface ReferenceResolver {
/**
* ⚠️ Whether the file is still there — asked separately from reading it,
* Whether the file is still there, asked separately from reading it,
* because a row that silently does nothing when tapped is worse than one
* that says the file has moved.
*/
@@ -147,18 +147,18 @@ class LocalVault(
val label: Label get() = Label(classification, compartments.toSet())
/**
* 🔴 **Every field, with the byte arrays compared by CONTENT.**
* Every field, with the byte arrays compared by CONTENT.
*
* This was `other.id == id` — written to dodge the fact that a data
* This was `other.id == id`, written to dodge the fact that a data
* class compares `ByteArray` by identity, and wrong in a way that broke
* a whole feature. Promoting a link to the vault keeps the node's id and
* changes its `storage`, so the new row compared EQUAL to the old one:
* the state object it was in compared equal too, `StateFlow` conflated
* the update away, and Compose never saw it. On screen the file vanished
* from Linked and did not appear in the vault until the app was
* restarted — which reads as losing somebody's file.
* restarted, which reads as losing somebody's file.
*
* ⚠️ The identity-vs-content trap is real, so it is handled rather than
* The identity-vs-content trap is real, so it is handled rather than
* avoided: `contentEquals` for the four byte arrays, ordinary equality
* for the rest.
*/
@@ -202,7 +202,7 @@ class LocalVault(
val node: Node,
val name: String?,
/**
* ⚠️ True when this is a reference whose file is no longer where it was.
* True when this is a reference whose file is no longer where it was.
* Surfaced rather than hidden.
*/
val isMissing: Boolean,
@@ -237,13 +237,13 @@ class LocalVault(
/**
* The key for a label, derived rather than fetched.
*
* 🔴 Deterministic from the identity key, so every device holding the
* Deterministic from the identity key, so every device holding the
* identity derives the same one and a vault restored from a courier transfer
* opens without a grant round-trip. Sharing an area means sending this
* value, wrapped to the recipient — the same grant the server used to
* 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
* **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.
@@ -258,7 +258,7 @@ class LocalVault(
/**
* The name, or null when this device cannot read the row.
*
* ⚠️ On a local vault every label the identity can derive is readable, so
* On a local vault every label the identity can derive is readable, so
* this is non-null for anything this device filed itself. It exists for the
* case that is not that: a node that arrived from somebody else, filed under
* a label whose key was never granted. Those decrypt to null and the row
@@ -284,8 +284,8 @@ class LocalVault(
}
/**
* 🔴 Written to a temporary file and moved into place. An index truncated by
* a crash mid-write is a vault that has lost every file at once — and the
* Written to a temporary file and moved into place. An index truncated by
* a crash mid-write is a vault that has lost every file at once, and the
* blobs would still be sitting there, unreferenced, taking up the space.
*/
private fun save() {
@@ -322,13 +322,13 @@ class LocalVault(
.put("bookmark", node.bookmark ?: JSONObject.NULL)
/**
* 🔴 **`optString` is not safe on a nullable field, and the JVM will not
* **`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:
*
* - `parentID` threw `Invalid UUID string: null` — so a vault holding any
* - `parentID` threw `Invalid UUID string: null`, so a vault holding any
* top-level file failed to load AT ALL. Adding the first file worked,
* because it was already in memory; reopening the app lost everything,
* with an error nobody could act on.
@@ -338,7 +338,7 @@ class LocalVault(
* - `bookmark` produced a reference pointing at `"null"`, permanently
* missing.
*
* ⚠️ So absence is decided by [JSONObject.isNull], never by comparing a
* So absence is decided by [JSONObject.isNull], never by comparing a
* decoded string to a sentinel. The literal `"null"` is rejected as well:
* it is not a valid value for any of these fields, and tolerating it costs
* nothing next to reintroducing this.
@@ -397,9 +397,9 @@ class LocalVault(
/**
* Every id in a subtree, so a screen can offer "and everything in it".
*
* 🔴 **Bounded by what it has already seen, not by a step counter.** A cycle
* Bounded by what it has already seen, not by a step counter. A cycle
* cannot be built through this API, but the index is a file on disk and
* something else could write one — so this has to terminate. iOS bounds it
* something else could write one, so this has to terminate. iOS bounds it
* with `guardRail < 10_000`, which terminates and then hands back ten
* thousand copies of the same two ids: a screen offering to delete a folder
* "and everything in it" would say 10,000 things. Refusing to revisit a node
@@ -424,14 +424,14 @@ class LocalVault(
* Every file in the whole tree, for a picker that has no business showing
* folders.
*
* 🔴 **The whole tree, not the top of it.** iOS's picker lists `list(nil)`,
* The whole tree, not the top of it. iOS's picker lists `list(nil)`,
* so a document filed into a folder cannot be sent from a conversation at
* all — and nothing says so; the file is simply absent from the list, which
* all, and nothing says so; the file is simply absent from the list, which
* reads as "it isn't there". Filed as ios#68.
*
* ⚠️ Unreadable and missing rows are dropped here rather than shown
* Unreadable and missing rows are dropped here rather than shown
* disabled. Everywhere else this class insists on showing a row you cannot
* open — because hiding it would leave somebody unable to see that anything
* open, because hiding it would leave somebody unable to see that anything
* is there. A picker is the exception: its only purpose is choosing
* something to send, and a row that can never be chosen is furniture.
*/
@@ -451,7 +451,7 @@ class LocalVault(
val bookmark = node.bookmark
if (bookmark == null || !references.exists(bookmark)) {
throw VaultException(
"That file isn't where it was — it may have been moved or deleted.")
"That file isn't where it was, it may have been moved or deleted.")
}
return references.read(bookmark)
}
@@ -464,9 +464,9 @@ class LocalVault(
}
/**
* The bytes, encrypted for sending — whichever way they are stored.
* The bytes, encrypted for sending, whichever way they are stored.
*
* 🔴 **This is where a referenced file first becomes ciphertext.** It is
* This is where a referenced file first becomes ciphertext. It is
* sealed under a fresh content key bound to the label, so the courier
* carries something it cannot read and the classification finally gates
* something. Returns the sealed blob and the key to hand the recipient.
@@ -487,7 +487,7 @@ class LocalVault(
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label)
val contentKey = Random.bytes(32)
// 🔴 Bytes to disk BEFORE the index. The other order leaves an index
// Bytes to disk BEFORE the index. The other order leaves an index
// entry for a file that is not there, which reads as corruption;
// this order leaves at worst an unreferenced blob, which is a
// cleanup job nobody notices.
@@ -511,7 +511,7 @@ class LocalVault(
/**
* File a document that stays where it is.
*
* 🔴 Nothing is copied and nothing is encrypted here — see [Storage]. The
* Nothing is copied and nothing is encrypted here, see [Storage]. The
* bytes get their content key at send time, not now, so until then this
* row's label is a statement of intent rather than a lock.
*/
@@ -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
* **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.
* 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
* 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 genuinely is two copies, the honest trade for having
* the encrypted one.
*
* Returns the bookmark the original was read from, so a caller can offer
@@ -550,7 +550,7 @@ class LocalVault(
if (node.storage != Storage.REFERENCE) return@withLock null
val origin = node.bookmark
if (origin == null || !references.exists(origin)) {
throw VaultException("That file isn't where it was — it may have been moved or deleted.")
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
}
val plaintext = readContents(node)
@@ -563,9 +563,9 @@ class LocalVault(
nodes[position] = node.copy(
storage = Storage.VAULT,
// 🔴 The bookmark goes. Keeping it would leave a vaulted row still
// 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 entire point of
// vaulting it.
bookmark = null,
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
@@ -590,7 +590,7 @@ class LocalVault(
isFolder = isFolder,
classification = label.classification,
compartments = label.compartments.sorted(),
// 🔴 Wrapped to the node's OWN label key, never the parent's. The
// Wrapped to the node's OWN label key, never the parent's. The
// tree allows a Restricted file inside a Confidential folder, so a
// folder-derived key would hand every folder-key holder content
// above their clearance.
@@ -617,7 +617,7 @@ class LocalVault(
/**
* Removes a node and its bytes.
*
* ⚠️ Refuses a folder that still has anything in it. Deleting a folder and
* Refuses a folder that still has anything in it. Deleting a folder and
* orphaning its contents would leave rows nothing can reach and bytes
* nothing will ever free.
*/
@@ -630,22 +630,22 @@ class LocalVault(
}
nodes.removeAt(position)
save()
// Never the person's own file — see the note in the bulk version.
// Never the person's own file, see the note in the bulk version.
if (node.storage == Storage.VAULT) blobFile(id).delete()
}
/**
* Delete several things at once.
*
* 🔴 **This reports rather than returning nothing.** Selecting eleven things
* This reports rather than returning nothing. Selecting eleven things
* and having nine disappear is a data-loss-shaped event in the other
* direction: the two that survived are the ones somebody now thinks are
* gone. The screen says which, and why.
*
* ⚠️ Deletes deepest-first, so selecting a folder AND everything in it
* Deletes deepest-first, so selecting a folder AND everything in it
* works. A folder whose contents were NOT all selected is kept, because the
* alternative is a bulk gesture that silently takes rows the person never
* selected — including ones filed above what this device can even read,
* selected, including ones filed above what this device can even read,
* which they could not have reviewed before tapping.
*/
fun remove(ids: List<UUID>): Removal = lock.withLock {
@@ -683,13 +683,13 @@ class LocalVault(
removed += id
}
// 🔴 One save for the whole batch, after the index is consistent. A save
// One save for the whole batch, after the index is consistent. A save
// per row would leave a half-deleted tree on disk if the process died
// mid-loop; the blobs go afterwards, so a crash costs orphaned bytes
// rather than rows pointing at files that are gone.
save()
// 🔴 Only ever our own blobs. A referenced row's bytes are the person's
// own file, sitting where they put it — deleting the row must not delete
// Only ever our own blobs. A referenced row's bytes are the person's
// own file, sitting where they put it, deleting the row must not delete
// their document, and `blobFile` for a reference names a file that was
// never written.
removedVaultIds.forEach { blobFile(it).delete() }
@@ -8,15 +8,15 @@ import io.flamenet.e2e.Vault
*
* ## What this buys, and what it does not
*
* Your **address** already survives a relay disappearing: it comes from your
* Your address already survives a relay disappearing: it comes from your
* passphrase, so signing in elsewhere produces the same one. What does not
* survive is your **mailbox** — mail sitting at a relay that goes away is gone,
* survive is your mailbox, mail sitting at a relay that goes away is gone,
* and contacts holding only that relay's name have nowhere to write.
*
* A second home fixes both, and only in advance. Senders deliver a copy to
* every relay in a signed record, so losing one loses no message, and a contact
* whose delivery fails at the first home finds the second in the same record.
* **Added after a relay disappears, none of that helps**: there is nothing left
* Added after a relay disappears, none of that helps: there is nothing left
* to publish an updated record from and no way to tell anybody where you went.
*
* ## Why it is several requests
@@ -25,12 +25,12 @@ import io.flamenet.e2e.Vault
* mailbox record from a stranger, and a token minted by one relay is refused by
* every other because the audience is bound. So: get a credential at the new
* relay, register this device so there is a mailbox to deliver into, claim the
* same address so a sender can find it — and only then publish the record, at
* **every** home rather than only the new one.
* same address so a sender can find it, and only then publish the record, at
* every home rather than only the new one.
*
* That last part is not tidiness. A home that does not know it is one still
* accepts mail, but a sender asking *it* for the record is handed a stale list
* and skips the others — so the redundancy quietly depends on which relay the
* and skips the others, so the redundancy quietly depends on which relay the
* sender happened to ask.
*/
class MailboxHoming(
@@ -47,9 +47,9 @@ class MailboxHoming(
/**
* Normalise what somebody typed into a hostname, or return null.
*
* ⚠️ Refuses a URL rather than parsing one out. A relay is a hostname,
* Refuses a URL rather than parsing one out. A relay is a hostname,
* and accepting `https://x/y` here would let a pasted string point this
* device at a path on somebody else's host — and then send it a signed
* device at a path on somebody else's host, and then send it a signed
* record naming that host as a mailbox.
*/
fun hostname(typed: String): String? {
@@ -67,9 +67,9 @@ class MailboxHoming(
* Sign in at [typed], register this device, claim the address, and publish
* the record everywhere. Returns the home list as it now stands.
*
* ⚠️ Nothing is written to the vault until the relay has actually accepted
* a registration. A half-added home — stored locally, unknown to the relay
* — would be polled forever and would show on screen as a mailbox that
* Nothing is written to the vault until the relay has actually accepted
* a registration. A half-added home, stored locally, unknown to the relay
* would be polled forever and would show on screen as a mailbox that
* never has anything in it.
*/
fun add(
@@ -83,8 +83,8 @@ class MailboxHoming(
* How to reach [typed], which is a separate question from what it is
* called.
*
* ⚠️ The signed record names a **hostname** — it is inside the
* signature, so it can carry no scheme and no port — while this is how
* The signed record names a hostname, it is inside the
* signature, so it can carry no scheme and no port, while this is how
* this device dials that host. `https://<host>` is the only correct
* answer in production and the default; naming the two separately is
* what lets a self-hosted or test deployment reachable some other way
@@ -94,7 +94,7 @@ class MailboxHoming(
): List<String> {
val host = hostname(typed) ?: throw Refused(
"That does not look like a relay's address. It is a hostname, like " +
"relay.example.com — no https:// and no path.")
"relay.example.com, no https:// and no path.")
if (host == primaryRelay || host in existingHomes) {
throw Refused("That relay already holds a mailbox for you.")
}
@@ -103,12 +103,12 @@ class MailboxHoming(
val account = try {
RelayAccounts(base).signIn(handle, passphrase)
} catch (e: Exception) {
// ⚠️ Says which of the two things went wrong. "No account there" and
// Says which of the two things went wrong. "No account there" and
// "wrong passphrase" call for opposite actions, and one message
// covering both leaves somebody retyping a correct passphrase.
throw Refused(
"$host did not accept those details. Sign up there with the same " +
"passphrase first — that is what gives you the same address.")
"passphrase first, that is what gives you the same address.")
}
val client = RelayClient(base, account.username, account.appPassword)
@@ -116,9 +116,9 @@ class MailboxHoming(
// A mailbox to deliver into.
//
// ⚠️ **No one-time prekeys here.** A one-time prekey is only usable if
// No one-time prekeys here. A one-time prekey is only usable if
// this device kept its private half, and those live in the identity
// archive keyed to no particular relay — publishing keys at a second
// archive keyed to no particular relay, publishing keys at a second
// home whose privates this device would not offer back makes every
// session opened with one undecryptable, which is worse than offering
// none. Without them a sender falls back to the signed prekey: it costs
@@ -131,7 +131,7 @@ class MailboxHoming(
// The same address here as everywhere. A sender resolving `fnid@host`
// asks that relay, and until the claim lands the answer is "no such
// address" — an unclaimed home is a mailbox nobody can address.
// address", an unclaimed home is a mailbox nobody can address.
try {
AccountIdentity.claim(client, accountKeySeed, account.userId)
} catch (e: Exception) {
@@ -152,7 +152,7 @@ class MailboxHoming(
val failures = AccountIdentity.publishHomes(
accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary })
// 🔴 The NEW relay accepting is what decides. A record every old home
// The NEW relay accepting is what decides. A record every old home
// took but the new one refused describes a mailbox that does not exist,
// and a sender told to deliver there gets nothing.
failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") }
@@ -166,12 +166,12 @@ class MailboxHoming(
/**
* Stop keeping a mailbox at [relay].
*
* ⚠️ **The shorter list is published first, and the local credential goes
* **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.
* reads, indistinguishable, from the sender's side, from delivery.
*
* 🔴 The relay being removed is **not** asked to accept the new record. It
* The relay being removed is not asked to accept the new record. It
* is the one party with a reason to refuse, and it is not in the list any
* more so it would refuse on those grounds regardless. Its copy simply goes
* stale, which costs nothing: the record is self-certifying and every other
@@ -13,11 +13,11 @@ import org.json.JSONObject
* The non-cryptographic half of the product: who your contacts are, who is
* online, what you have been notified about, who you have blocked.
*
* ⚠️ **These routes take the account credential, not the relay token.** The
* These routes take the account credential, not the relay token. The
* `/e2e/…` routes deliberately refuse the account password because a
* credential good for every device is too much authority for key material.
* These routes are the opposite case — they are about the account, not a
* device — so Basic is correct here and a device-scoped token would be wrong.
* These routes are the opposite case, they are about the account, not a
* device, so Basic is correct here and a device-scoped token would be wrong.
*/
class MessengerApi(
baseUrl: String,
@@ -32,7 +32,7 @@ class MessengerApi(
/**
* Someone in your list.
*
* ⚠️ `handle` is the name they signed up with and the only one another
* `handle` is the name they signed up with and the only one another
* person can type to reach them. It was null on this relay for a while,
* having started life as an OSCAR screen name, which meant you could not
* find your own contacts by the name you knew them by. Show it.
@@ -48,9 +48,9 @@ class MessengerApi(
* Whether this person is somebody the user chose, or somebody who
* turned up because they wrote first.
*
* 🔴 **Defaults to true, and that default is the compatibility story.**
* Defaults to true, and that default is the compatibility story.
* A relay that predates this field says nothing about acceptance, and
* the only safe reading of silence is "these are your contacts" — the
* the only safe reading of silence is "these are your contacts", the
* alternative labels every contact on every older relay a stranger.
*/
val accepted: Boolean = true,
@@ -67,7 +67,7 @@ class MessengerApi(
val list = get("buddies").optJSONArray("buddies") ?: JSONArray()
return (0 until list.length()).map {
val b = list.getJSONObject(it)
// ⚠️ `id`, `display_name`, `screen_name`, `presence` — this route
// `id`, `display_name`, `screen_name`, `presence`, this route
// does NOT use the `user_id`/`status` shape the §8 routes use, and
// guessing otherwise threw "No value for user_id" at the exact
// moment a contact was added.
@@ -82,7 +82,7 @@ class MessengerApi(
}
/**
* ⚠️ By username, not by id. A person types a name; the relay is the only
* By username, not by id. A person types a name; the relay is the only
* thing that can turn that into an id, and doing the lookup client-side
* would need a directory this product deliberately does not have.
*/
@@ -90,8 +90,8 @@ class MessengerApi(
* @param auto true when the app is adopting somebody who messaged first,
* rather than the user choosing to add them.
*
* 🔴 The distinction is the whole feature. Adopting a stranger keeps their
* first message visible — which is why adoption exists — while leaving the
* The distinction is the whole feature. Adopting a stranger keeps their
* first message visible, which is why adoption exists, while leaving the
* decision about whether they are a contact to the person, instead of
* making it for them silently.
*/
@@ -105,11 +105,11 @@ class MessengerApi(
}
/**
* Resolve a user id to a name — `GET /messenger/users/:id`.
* Resolve a user id to a name, `GET /messenger/users/:id`.
*
* 🔴 **This is how a message from a stranger becomes visible.** An E2E
* This is how a message from a stranger becomes visible. An E2E
* envelope names its sender by id only, and the buddy list is what the
* Messages screen renders — so first contact from somebody who added US
* Messages screen renders, so first contact from somebody who added US
* arrived, decrypted, filed, counted as unread, and never appeared
* anywhere. The receive path uses this to name the sender and add them.
*
@@ -137,21 +137,21 @@ class MessengerApi(
/**
* Set our presence, and return what the relay says it now is.
*
* 🔴 **Return the server's answer, never the value we asked for.** The
* Return the server's answer, never the value we asked for. The
* relay used to accept an unknown status with 200 and store nothing, so a
* caller that assumed success showed a state the server had discarded —
* caller that assumed success showed a state the server had discarded,
* "offline" on this screen while every contact still saw us online. The
* relay refuses unknown values now, but a client that reports its own
* request back to itself is wrong regardless of how the server behaves.
*
* ⚠️ The answer is not always the word sent: `offline` is stored as
* The answer is not always the word sent: `offline` is stored as
* `invisible`, which is what "appear offline" has always meant.
*/
/**
* Read the status the relay currently holds for us, without changing it.
*
* ⚠️ An empty body is a READ. The route only writes when `status` is
* present, and answers with the stored value either way — so this needs no
* An empty body is a READ. The route only writes when `status` is
* present, and answers with the stored value either way, so this needs no
* route of its own, and cannot accidentally set something.
*/
fun currentStatus(): String =
@@ -167,7 +167,7 @@ class MessengerApi(
val list = page.optJSONArray("notifications") ?: JSONArray()
return (0 until list.length()).map {
val n = list.getJSONObject(it)
// ⚠️ `type`/`message`/`is_read` — the relay's actual field names.
// `type`/`message`/`is_read`, the relay's actual field names.
// This read `kind`/`body`/`read` for months, which exist nowhere,
// so every alert decoded as an empty unread row and the screen
// looked permanently blank.
@@ -187,14 +187,14 @@ class MessengerApi(
/**
* The ids this account has blocked.
*
* 🔴 **`id`, not `user_id`.** This route answers `{id, name, slug}` — the
* `id`, not `user_id`. This route answers `{id, name, slug}`, the
* same shape as the buddy list and NOT the `user_id` shape the §8 routes
* use. Reading `user_id` here threw `No value for user_id`, and the reason
* it survived so long is the reason it was dangerous: the list is empty
* until somebody blocks their first person, so the map never ran and every
* test passed. The first block anybody performed broke it.
*
* ⚠️ It threw where a caller was defaulting to "nobody is blocked", which
* It threw where a caller was defaulting to "nobody is blocked", which
* turned a field-name typo into a blocked person being auto-added back to
* your contacts. See `adoptStrangers`.
*/
@@ -220,8 +220,8 @@ class MessengerApi(
}
/**
* ⚠️ Changing the password **invalidates other credentials**, which is the
* point of changing it — so this device has to sign in again afterwards.
* Changing the password invalidates other credentials, which is the
* point of changing it, so this device has to sign in again afterwards.
* A UI that changes the password and carries on looks fine until the next
* request 401s for no visible reason.
*/
@@ -229,7 +229,7 @@ class MessengerApi(
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password))
}
/** Irreversible, and on the relay side only — the vault is erased separately. */
/** Irreversible, and on the relay side only, the vault is erased separately. */
fun deleteAccount() {
raw("api/flamenet/v1/messenger/account", "DELETE", null)
}
@@ -237,8 +237,8 @@ class MessengerApi(
/**
* Exact-username lookup. Deliberately not a directory: no listing exists.
*
* 🔴 **The relay answers with an ARRAY**, empty for a miss and one element
* for a hit — `RoutesAccount.lookup` returns `[MeResponse]` precisely so a
* The relay answers with an ARRAY, empty for a miss and one element
* for a hit, `RoutesAccount.lookup` returns `[MeResponse]` precisely so a
* miss and a hit differ only in length and neither leaks timing. This went
* through `send()`, which does `JSONObject(text)` and throws on an array,
* and the throw was swallowed by `runCatching { }.getOrNull()`.
@@ -246,12 +246,12 @@ class MessengerApi(
* 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
* against relay.flamenet.io, the relay returned
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the
* app said no such account.
*
* ⚠️ So parse the array here, and keep `getOrNull` for TRANSPORT failure
* only — a miss is an empty array, not an exception.
* So parse the array here, and keep `getOrNull` for TRANSPORT failure
* only, a miss is an empty array, not an exception.
*/
fun lookup(username: String): Me? {
val text = rawText(
@@ -302,7 +302,7 @@ class MessengerApi(
* A GET whose body is returned as text rather than forced into a
* `JSONObject`.
*
* ⚠️ Exists for the ONE route that answers with an array. Everything else
* Exists for the ONE route that answers with an array. Everything else
* goes through `send()`; do not widen this without checking the route,
* because losing the typed error mapping is a real cost.
*/
@@ -7,16 +7,16 @@ 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
* **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
* bridge through every one would work until somebody adds an eleventh — and the
* bridge through every one would work until somebody adds an eleventh, and the
* eleventh would connect directly, successfully, with nothing to show that it
* had. A missed site here does not fail; it silently does not protect.
*
* ⚠️ **The socket factory is installed whether or not a bridge is set.** A
* The socket factory is installed whether or not a bridge is set. A
* client is built once and the setting changes later, so the factory has to be
* able to follow it. With no bridge it delegates to the platform's own factory,
* which is exactly what OkHttp would have used.
@@ -29,14 +29,14 @@ object Obfs4Routing {
/**
* One pool for every default client.
*
* 🔴 **Shared so it can be emptied.** OkHttp keeps connections alive across
* Shared so it can be emptied. OkHttp keeps connections alive across
* requests; a connection opened before the bridge changed still runs over
* the old transport, and would go on serving requests for minutes after the
* user switched bridges — or after they turned one OFF, which is the
* user switched bridges, or after they turned one OFF, which is the
* dangerous direction. Eviction on change is the only thing that stops that,
* and it needs a pool we hold.
*
* ⚠️ The idle window is short for the same reason a bridge that goes away
* The idle window is short for the same reason a bridge that goes away
* must not leave requests queued behind a dead tunnel.
*/
internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES)
@@ -8,13 +8,13 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* A relay's own account table — the identity provider a self-hosted relay is.
* A relay's own account table, the identity provider a self-hosted relay is.
*
* This is what keeps self-hosting **one** service rather than two. A relay with
* This is what keeps self-hosting one service rather than two. A relay with
* no shared account service configured still signs people up, signs them in and
* resets their passwords, on its own.
*
* ⚠️ **What comes back is not the password.** The relay mints a long-lived
* What comes back is not the password. The relay mints a long-lived
* credential, returns it once, and keeps only its hash. That credential is what
* every later request carries; the password is used at this moment and then
* should not be kept.
@@ -35,10 +35,10 @@ class RelayAccounts(
)
/**
* ⚠️ **The challenge is fetched only when the relay asks for one.**
* The challenge is fetched only when the relay asks for one.
* Fetching up front would add a round trip to every sign-up on every
* server, for a feature that is off by default and off on every relay
* running today — a second wait on the one screen where somebody is
* running today, a second wait on the one screen where somebody is
* already waiting. So the first attempt goes out plain, and only a relay
* that refuses it sends us to solve anything.
*
@@ -55,10 +55,10 @@ class RelayAccounts(
return try {
account(post("account/signup", body()))
} catch (asked: RelayException.ChallengeRequired) {
// 🔴 Rethrown, not swallowed, when we cannot answer. A difficulty
// 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
// sentence — quietly failing with something else would send
// sentence, quietly failing with something else would send
// somebody looking in the wrong place.
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
account(post("account/signup", body()
@@ -68,7 +68,7 @@ class RelayAccounts(
}
/**
* ⚠️ The field is "username **or email**" and always has been. A client
* The field is "username or email" and always has been. A client
* that only sends usernames rejects half the people who typed exactly what
* they were asked for.
*/
@@ -80,18 +80,18 @@ class RelayAccounts(
/**
* Exchange a shared FlameNet account session for a relay session.
*
* ⚠️ Present only on a relay configured to trust an account service. A
* Present only on a relay configured to trust an account service. A
* self-hosted relay answers 404 here, and that is the correct answer rather
* than a misconfiguration — it is its own identity provider.
* than a misconfiguration, it is its own identity provider.
*
* The account service's token carries a **product-scoped pseudonym**, not
* The account service's token carries a product-scoped pseudonym, not
* the handle, so the relay learns an opaque id and nothing that ties the
* person to any other FlameNet product.
*/
/**
* ⚠️ The handle travels **beside** the token, not inside it. The account
* The handle travels beside the token, not inside it. The account
* service will not put a handle in a token, so that a relay cannot
* correlate its users with another product's — the token carries a
* correlate its users with another product's, the token carries a
* product-scoped pseudonym instead. The relay uses the handle only the
* first time it sees that pseudonym; after that the pseudonym decides, and
* honouring the handle would let somebody rename themselves into an
@@ -106,7 +106,7 @@ class RelayAccounts(
/**
* Ask the relay to send a reset code.
*
* ⚠️ The relay answers identically whatever the truth is — unknown handle,
* The relay answers identically whatever the truth is, unknown handle,
* no address on file, an account that authenticates elsewhere. So this
* cannot report whether it worked, and a UI that claims it can is lying.
* Anything thrown here is a transport or server failure, never "no such
@@ -119,7 +119,7 @@ class RelayAccounts(
/**
* Spend an email verification code.
*
* ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof.
* Unauthenticated, exactly like reset confirm: the code IS the proof.
* Requiring a signed-in session would mean somebody kept out *because* they
* are unverified could never verify.
*/
@@ -168,16 +168,16 @@ class RelayAccounts(
val code = runCatching { JSONObject(text).optString("code") }
.getOrNull().orEmpty()
throw when {
// 🔴 Decided by the relay's own `code`, never by the status. A
// Decided by the relay's own `code`, never by the status. A
// 400 is also a malformed body and a bad email; telling either
// of those to go and update the app would send somebody to the
// Play Store to fix a typo.
code == "challenge_required" -> RelayException.ChallengeRequired(message)
code == "challenge_failed" -> RelayException.ChallengeFailed(message)
// 🔴 **A 403 is not an expired session.** 401 means the token
// A 403 is not an expired session. 401 means the token
// was refused and signing in again is the fix. 403 means a
// RULE refused this — too many devices, a retired device, an
// unverified address — and the relay writes a sentence saying
// RULE refused this, too many devices, a retired device, an
// unverified address, and the relay writes a sentence saying
// which. Reporting those as "your session expired, sign out and
// back in" sends somebody round a loop that cannot end: they
// sign in again, hit the same rule, and are told the same
@@ -13,9 +13,9 @@ import java.util.UUID
* 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
* key that token names — so a stolen token, on its own, is inert.
* 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
* The nonce is not decoration. Without it, two inbox polls in the same
* second with the same empty body produce byte-identical signatures, and the
* relay's replay cache cannot tell an honest repeat from a captured one. It
* then refuses the honest client, and the symptom is an app that stops
@@ -42,7 +42,7 @@ class RelayAuth(
* Set once the relay answers "no such route".
*
* Without it, a deployment that will never mint a token is asked on every
* single request — a round trip per call, forever, to learn the same thing.
* single request, a round trip per call, forever, to learn the same thing.
*/
@Volatile private var unavailable = false
@@ -51,7 +51,7 @@ class RelayAuth(
* Null when this deployment does not issue them.
*/
/** Plain Kotlin module: no android.util.Log here. On device this lands in
* logcat as `W/System.err`, which is what matters — it is READABLE. */
* logcat as `W/System.err`, which is what matters, it is READABLE. */
private fun warn(message: String) = System.err.println("fnmsg-auth: $message")
fun token(now: Long = System.currentTimeMillis() / 1000): String? {
@@ -60,10 +60,10 @@ class RelayAuth(
val minted = try {
mint(deviceId, Wire.b64(identityKey.publicKey))
} catch (e: RelayException.NotFound) {
// 🔴 **Say it.** Every failure here used to be swallowed in
// Say it. Every failure here used to be swallowed in
// silence, and the consequence was invisible: with no token the
// client falls back to Basic, the relay refuses `/e2e/*` — which it
// does by design — and the user sees "the call failed", "could not
// client falls back to Basic, the relay refuses `/e2e/*`, which it
// does by design, and the user sees "the call failed", "could not
// finish setting up", or an inbox that 410s and wipes itself. Four
// hours of debugging produced symptoms and no cause, because the
// one line that knew what went wrong discarded it.
@@ -77,8 +77,8 @@ class RelayAuth(
"${e.message}); falling back to Basic, which /e2e/* refuses.")
return null
} ?: run {
// ⚠️ A non-2xx that is not 404 arrives here as null, not as a
// throw, so this branch is NOT unreachable — it was the quietest
// A non-2xx that is not 404 arrives here as null, not as a
// throw, so this branch is NOT unreachable, it was the quietest
// path of the three.
warn("no token: /account/token answered a non-success " +
"status; falling back to Basic, which /e2e/* refuses.")
@@ -91,8 +91,8 @@ class RelayAuth(
/**
* Discard the cached token, so one stale token costs a single retry.
*
* 🔴 **Also un-latches [unavailable].** That flag is a told-once marker: it
* concludes "this deployment has no token endpoint" from a single 404 — but
* Also un-latches [unavailable]. That flag is a told-once marker: it
* concludes "this deployment has no token endpoint" from a single 404, but
* a reverse proxy answers 404 for *every* route while the relay behind it
* is down, so an outage at the wrong moment latched it against a relay that
* does issue tokens, and every request thereafter went out with no token at
@@ -108,9 +108,9 @@ class RelayAuth(
/**
* The proof headers for one request.
*
* ⚠️ `path` is the **logical §8 path**, not the URL's path. The relay mounts
* `path` is the logical §8 path, not the URL's path. The relay mounts
* these routes at both `/e2e/…` and under a REST prefix, and signs against
* the former — so the proof must be computed over the same string whichever
* the former, so the proof must be computed over the same string whichever
* mount the request is addressed to. Signing the URL path instead produces
* a valid signature over the wrong thing, and the relay rejects it with no
* hint as to why.
@@ -20,9 +20,9 @@ import java.util.concurrent.TimeUnit
/**
* The relay's §8 routes.
*
* ⚠️ **Field names are the protocol.** The one bug this system has actually
* Field names are the protocol. The one bug this system has actually
* shipped was a client and a relay disagreeing about a field name, and nothing
* caught it until a live round trip — not the type checker, not a unit test.
* caught it until a live round trip, not the type checker, not a unit test.
* So every name here appears exactly once, and the tests run against a real
* HTTP server rather than a fake that agrees with this file by construction.
*
@@ -41,9 +41,9 @@ class RelayClient(
/**
* The relay this client talks to, for anything that has to remember a fact
* about a specific one — §19's "has this relay ever served a log".
* about a specific one, §19's "has this relay ever served a log".
*
* ⚠️ The host, not the URL. Handing out the base would let a caller build a
* The host, not the URL. Handing out the base would let a caller build a
* second client for the same relay with different credentials, which is the
* one thing this type exists to keep in one place.
*/
@@ -68,14 +68,14 @@ class RelayClient(
* @param bridge when set, every request is carried inside obfs4 to that
* bridge, which proxies on to the relay.
*
* 🔴 **Null by default, and that is the shipping configuration.** obfs4
* Null by default, and that is the shipping configuration. obfs4
* is for reaching a relay on a network that blocks or fingerprints TLS
* to it. Turning it on for everyone would change the network path for
* every user to solve a problem most of them do not have, and — on a
* host where the bridge shares the relay's address — would not even help
* every user to solve a problem most of them do not have, and, on a
* 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
* **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.
*/
@@ -86,9 +86,9 @@ 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, and that is the point. The
// parameter above is for tests and interop, which pin one
// bridge. Everything the app builds passes nothing — so if the
// bridge. Everything the app builds passes nothing, so if the
// factory were only installed when a bridge was already known,
// the setting could never reach the ten sites that construct
// clients, several of them from default parameters evaluated
@@ -96,9 +96,9 @@ class RelayClient(
// the factory delegates to the platform's own, so this changes
// nothing for the overwhelming majority who never set one.
.socketFactory(Obfs4SocketFactory { bridge ?: Obfs4Routing.bridge })
// ⚠️ **Connections must not outlive a transport change**, and an
// Connections must not outlive a transport change, and an
// obfs4 handshake makes each one expensive to establish, so the
// pool is shared — `Obfs4Routing.use` empties it — and its idle
// pool is shared, `Obfs4Routing.use` empties it, and its idle
// window is short: a bridge that goes away should not leave
// requests queued behind a dead tunnel for minutes.
.connectionPool(Obfs4Routing.pool)
@@ -122,7 +122,7 @@ class RelayClient(
// -- Devices and keys ----------------------------------------------------
/** `POST /e2e/devices` — publish this device's identity and prekeys. */
/** `POST /e2e/devices`, publish this device's identity and prekeys. */
fun registerDevice(
identity: DeviceIdentity,
oneTimePreKeys: List<DeviceIdentity.PreKey>,
@@ -131,12 +131,12 @@ class RelayClient(
postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys, pqOneTimePreKeys))
/**
* `POST /e2e/prekeys` — top the pools back up.
* `POST /e2e/prekeys`, top the pools back up.
*
* ⚠️ **Both pools, in one request.** A post-quantum pool that drains ahead
* Both pools, in one request. A post-quantum pool that drains ahead
* of the classical one silently pushes every new session onto the
* last-resort key — a real weakening, since they then all share one KEM key
* — with nothing on either end saying so. Refilling them together is what
* last-resort key, a real weakening, since they then all share one KEM key
* with nothing on either end saying so. Refilling them together is what
* keeps that from happening by omission.
*/
fun uploadPreKeys(
@@ -165,9 +165,9 @@ class RelayClient(
}
/**
* `GET /e2e/keys/{user}` — a bundle per device.
* `GET /e2e/keys/{user}`, a bundle per device.
*
* ⚠️ **This consumes a one-time prekey per device, every call.** Use
* This consumes a one-time prekey per device, every call. Use
* [listDevices] to look at who a user is; use this only when actually
* starting a session. Polling it drains the pool and pushes every peer onto
* the last-resort key, which costs initial forward secrecy for everyone.
@@ -176,11 +176,11 @@ class RelayClient(
data class Keys(val fnid: String?, val bundles: List<PeerBundle>)
/**
* 🔴 **The `fnid` is not decoration; without it the key log cannot be
* **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,
* no matching leaf, and refuses to send, reporting "has published keys,
* but none match what this relay served" for a conversation in which
* nothing is wrong.
*
@@ -199,14 +199,14 @@ class RelayClient(
)
}
/** `GET /e2e/devices/{user}` — who a user is, costing no prekey. */
/** `GET /e2e/devices/{user}`, who a user is, costing no prekey. */
fun listDevices(userId: Long): JSONArray =
listDirectory(userId).devices
/**
* The directory, with the account's address alongside it.
*
* ⚠️ The address is what makes the key log checkable: a leaf is written
* The address is what makes the key log checkable: a leaf is written
* under `fnid:` once an account has claimed one, and a verifier that does
* not know it computes the wrong subject and refuses the send. The send
* path deliberately fetches full bundles only when a session needs
@@ -218,7 +218,7 @@ class RelayClient(
return Directory(
devices = o.optJSONArray("devices") ?: JSONArray(),
// Absent on a relay older than this field, which is a relay whose
// accounts have no addresses either — null is then correct, not a
// accounts have no addresses either, null is then correct, not a
// degraded answer.
fnid = o.optString("fnid").takeIf { it.isNotEmpty() },
)
@@ -227,7 +227,7 @@ class RelayClient(
data class Directory(val devices: JSONArray, val fnid: String?)
/**
* `DELETE /e2e/devices/{id}` — remove one of the ACCOUNT'S OWN devices.
* `DELETE /e2e/devices/{id}`, remove one of the ACCOUNT'S OWN devices.
* The relay refuses anyone else's, and drops the device's prekeys and
* queued mail with it (§8.3a).
*/
@@ -242,22 +242,22 @@ class RelayClient(
}
/**
* `POST /e2e/messages` — one recipient, one envelope per device of theirs.
* `POST /e2e/messages`, one recipient, one envelope per device of theirs.
*
* ⚠️ The recipient is named **once, at the top**, not per envelope. A body
* The recipient is named once, at the top, not per envelope. A body
* that repeats `to_user` inside each message is rejected outright as a
* malformed body, which at least fails loudly — the shape being wrong in a
* malformed body, which at least fails loudly, the shape being wrong in a
* way the relay tolerates is the version that costs a week.
*/
/**
* @param deliveryKey the recipient's capability, base64. When present the
* submission is **sealed**: the relay is not told who is sending, and
* 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 whole 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
* from nobody else, strictly worse than not sealing, because it looks like
* it worked.
*/
fun sendEnvelopes(
@@ -266,24 +266,24 @@ class RelayClient(
fromDevice: String,
deliveryKey: String? = null,
/**
* §21.4 — a single-use capability, spent on acceptance.
* §21.4, a single-use capability, spent on acceptance.
*
* ⚠️ **Sent instead of [deliveryKey], never alongside it.** Sending both
* Sent instead of [deliveryKey], never alongside it. Sending both
* would let the relay accept on the key when the capability had been
* revoked, so it would never learn the capability was refused — and
* revoked, so it would never learn the capability was refused, and
* revocation is the whole lever §21 adds.
*/
deliveryToken: String? = null,
/**
* Deliver without announcing: protocol traffic, not a message.
*
* 🔴 **The relay announced everything, because it cannot tell.** It
* The relay announced everything, because it cannot tell. It
* holds ciphertext, so a call answer, a delivery key and a birthday
* message are the same bytes to it — and every one of them raised
* message are the same bytes to it, and every one of them raised
* "New message" on the far end. One call produced a burst of banners
* for things nobody sent. The sender is the only party that knows.
*
* ⚠️ Advisory and unauthenticated. A sender that lies suppresses its
* Advisory and unauthenticated. A sender that lies suppresses its
* own notifications and nobody else's.
*/
silent: Boolean = false,
@@ -307,7 +307,7 @@ class RelayClient(
/**
* One envelope from the inbox, with the server id that acknowledges it.
*
* [createdAt] is when the RELAY accepted the envelope, in epoch millis —
* [createdAt] is when the RELAY accepted the envelope, in epoch millis,
* 0 when the relay sent nothing parseable. It exists for call signalling:
* an offer that sat in the mailbox while this device was closed must not
* ring minutes later for a caller who has long since given up.
@@ -323,19 +323,19 @@ class RelayClient(
* several mailboxes collapses them rather than showing the message once
* per home.
*
* ⚠️ Empty on a relay older than multi-homing, and with one mailbox
* there is nothing to collapse — so an empty value means "no reason to
* Empty on a relay older than multi-homing, and with one mailbox
* there is nothing to collapse, so an empty value means "no reason to
* think this is a duplicate", not "this is not a duplicate".
*/
val envelopeId: String = "",
)
/**
* `GET /e2e/messages` — this device's inbox.
* `GET /e2e/messages`, this device's inbox.
*
* `after` is the highest id already ingested; the relay returns `id > after`
* in ascending order. Storing that watermark only once a message has been
* **decrypted and saved** is what makes the poll idempotent — advance it on
* decrypted and saved is what makes the poll idempotent, advance it on
* receipt and a crash mid-batch loses the rest silently.
*/
fun pollInbox(deviceId: String, after: Long, limit: Int = 100): List<Incoming> {
@@ -365,7 +365,7 @@ class RelayClient(
}
/**
* `POST /e2e/delivery-key` — publish SHA-256 of our delivery key so peers
* `POST /e2e/delivery-key`, publish SHA-256 of our delivery key so peers
* may send sealed (§14.4). Only the digest goes to the relay; the key
* itself travels inside end-to-end messages.
*/
@@ -374,10 +374,10 @@ class RelayClient(
}
/**
* `POST /e2e/delivery-tokens` — add and drop single-use capabilities
* `POST /e2e/delivery-tokens`, add and drop single-use capabilities
* (§21.4). Returns how many unspent ones this account now holds in total.
*
* ⚠️ One route with two lists, not two routes. Topping one contact up and
* One route with two lists, not two routes. Topping one contact up and
* cutting another off are the same set difference to the relay, and
* splitting them would let somebody watching request *shape* tell the two
* apart without reading a byte of the body.
@@ -390,19 +390,19 @@ class RelayClient(
// -- Attachments ---------------------------------------------------------
/**
* `POST /e2e/attachments` — one sealed blob, raw `nonce||ct||tag` (§8.7).
* `POST /e2e/attachments`, one sealed blob, raw `nonce||ct||tag` (§8.7).
*
* ⚠️ The returned id is the download capability. It must only ever travel
* The returned id is the download capability. It must only ever travel
* inside the ratchet; putting it anywhere the relay can read defeats the
* point of encrypting the blob.
*/
/**
* `POST /e2e/moderation/report` — file a report (§6.25).
* `POST /e2e/moderation/report`, file a report (§6.25).
*
* 🔴 **The one call that deliberately sends readable content to the
* **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
* the reporter's own copy travels with the report, and the caller is
* required to have obtained consent naming that, before reaching here.
*/
fun submitReport(
@@ -430,8 +430,8 @@ class RelayClient(
/**
* `GET /e2e/attachments/{id}`. The caller verifies the digest and decrypts.
*
* 🔴 **Bounded before it is read (audit M5).** `maxBytes` is the size the
* sealed reference promised — it arrived through the ratchet, so the relay
* Bounded before it is read (audit M5). `maxBytes` is the size the
* sealed reference promised, it arrived through the ratchet, so the relay
* cannot inflate it. Without a ceiling, `body.bytes()` reads whatever the
* relay sends, and a malicious one answers a photo request with a
* multi-gigabyte body that kills the app before the digest check it would
@@ -468,8 +468,8 @@ class RelayClient(
/**
* A group as the relay can describe it.
*
* ⚠️ **Members are ids and nothing else.** The relay has no user table and
* deliberately will not grow one — profile data staying with the identity
* Members are ids and nothing else. The relay has no user table and
* deliberately will not grow one, profile data staying with the identity
* provider is the boundary the whole split is about, and a relay that
* caches display names is a relay that leaks them. Names are resolved
* client-side against the contact list.
@@ -499,16 +499,16 @@ class RelayClient(
}
/**
* Create a group. **The name is not a parameter, deliberately.**
* Create a group. The name is not a parameter, deliberately.
*
* The relay used to store it, in the clear, in a column it never read —
* The relay used to store it, in the clear, in a column it never read,
* "Chemotherapy support", "Union organising", sitting in a table that can
* be dumped or subpoenaed. It stopped, so the request now carries only the
* roster, which is the part the relay has to route. Members are told what
* the group is called over their pairwise ratchets, by [Conversations].
*
* The `name` that comes back on [Group] is a placeholder — the same word
* for every group — kept so builds that predate the change do not draw a
* The `name` that comes back on [Group] is a placeholder, the same word
* for every group, kept so builds that predate the change do not draw a
* blank row. Callers substitute the name they hold locally.
*/
fun createGroup(memberIds: List<Long>): Group =
@@ -528,7 +528,7 @@ class RelayClient(
/**
* Create a group whose roster the relay cannot read.
*
* ⚠️ The gid is ours to choose. The owner's signature covers it, and a
* The gid is ours to choose. The owner's signature covers it, and a
* client cannot sign an id the relay has not minted yet.
*/
fun createSealedGroup(
@@ -545,7 +545,7 @@ class RelayClient(
}
/**
* 🔴 **"Give me the groups these tags open", never "what groups am I in".**
* "Give me the groups these tags open", never "what groups am I in".
* The relay cannot answer the second question about a sealed group, and a
* client that asked it would be asking for a list only a relay holding the
* social graph could produce.
@@ -594,19 +594,19 @@ class RelayClient(
// -- Account identity (§20) ----------------------------------------------
/**
* `POST /e2e/account/identity` — register this account's Ed25519 account
* `POST /e2e/account/identity`, register this account's Ed25519 account
* key and take the fnid derived from it.
*
* Self-certifying: the signature is checked with the very key being
* registered, so the relay confirms possession and forms no opinion about
* who the key belongs to — the property that lets a hostile relay be
* who the key belongs to, the property that lets a hostile relay be
* tolerated at all.
*
* ⚠️ **Claiming changes what the key log records this account under.**
* Claiming changes what the key log records this account under.
* Leaves already in the tree keep the subject they were written with;
* publications after the claim are filed under `fnid:` instead of
* `local:<audience>/<user_id>`. A verifier that does not know the fnid
* therefore cannot recompute the leaf — see [Trust.enforceKeyLog].
* therefore cannot recompute the leaf, see [Trust.enforceKeyLog].
*
* Idempotent for the key already on file. A *different* key is refused
* rather than rotated: rotation changes the address and strands every
@@ -618,14 +618,14 @@ class RelayClient(
.put("sig", signatureB64))
/**
* `GET /e2e/account/identity/{handle}` — the global address behind a local
* `GET /e2e/account/identity/{handle}`, the global address behind a local
* handle, or null when that account has not claimed one.
*
* Unauthenticated by design: it discloses a public key's address, which was
* already public, and a directory only this relay's own users can read is
* not one a federated peer could use.
*
* ⚠️ **404 is not "no such person".** The relay answers the same way for an
* 404 is not "no such person". The relay answers the same way for an
* account that does not exist and one that has never enrolled, deliberately,
* so this cannot be used to enumerate accounts. Null here means "no address
* to pin", nothing more.
@@ -646,7 +646,7 @@ class RelayClient(
/**
* The devices a remote address currently has.
*
* ⚠️ The directory, not the prekey route — this consumes nothing. Calling
* The directory, not the prekey route, this consumes nothing. Calling
* the bundle route once per message is what drained a local prekey pool
* already; the same mistake costs more here, because the pool being emptied
* belongs to somebody on another relay who cannot see why.
@@ -657,7 +657,7 @@ class RelayClient(
/**
* Prekey bundles for a remote address.
*
* 🔴 **Pops a one-time prekey on the far side**, exactly as the local
* 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
* open, never to find out who somebody is.
*/
@@ -669,7 +669,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
* reached, all three matter, because with several homes a send is
* genuinely partial and a client that cannot tell cannot decide between
* retrying and warning.
*/
@@ -690,7 +690,7 @@ class RelayClient(
/**
* One copy of one message, for one of the recipient's devices.
*
* ⚠️ [envelopeId] is per MESSAGE, not per device or per mailbox. A
* [envelopeId] is per MESSAGE, not per device or per mailbox. A
* recipient polling several homes collapses the copies by it; a fresh id
* per copy would show them the same message once per mailbox.
*/
@@ -703,17 +703,17 @@ class RelayClient(
// -- Multi-homing (§20.4) ------------------------------------------------
/**
* `POST /e2e/account/homes` — say which relays hold mailboxes for this
* `POST /e2e/account/homes`, say which relays hold mailboxes for this
* account, so a sender delivers to all of them and losing one loses
* nothing.
*
* ⚠️ **The relay being written to must be in [relays].** Otherwise an
* The relay being written to must be in [relays]. Otherwise an
* account could point every sender elsewhere while still reading an inbox
* here, and mail addressed by the record would never reach the box the
* person actually opens. The relay refuses that rather than storing it.
*
* [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
* 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.
*/
fun publishHomes(serial: Int, relays: List<String>, signatureB64: String): JSONObject =
@@ -723,12 +723,12 @@ class RelayClient(
.put("sig", signatureB64))
/**
* `GET /e2e/account/homes/{fnid}` — where to deliver, with the signature.
* `GET /e2e/account/homes/{fnid}`, where to deliver, with the signature.
*
* Unauthenticated, and hands back the proof alongside the list precisely so
* the caller can check it instead of trusting this relay's copy. A relay
* serving a truncated list is the exact failure multi-homing exists to
* survive, so the answer has to be verifiable by whoever receives it — see
* survive, so the answer has to be verifiable by whoever receives it, see
* [AccountIdentity.verifyHomes].
*
* Null for an account that has published nothing, which is the ordinary
@@ -763,7 +763,7 @@ class RelayClient(
// -- Internals -----------------------------------------------------------
/**
* `POST /account/token` — the only route where the account password is
* `POST /account/token`, the only route where the account password is
* still presented, and deliberately not under the REST prefix: it belongs
* to the identity provider, not to §8.
*/
@@ -799,10 +799,10 @@ class RelayClient(
requestJson("GET", url(path, query), path, null)
/**
* `/.well-known/flamenet/relay` — the relay's self-description, carrying
* `/.well-known/flamenet/relay`, the relay's self-description, carrying
* the issuer key its log heads are signed with (audit M1).
*
* ⚠️ Root-relative: [routePath] prefixes the REST mount, and a well-known
* Root-relative: [routePath] prefixes the REST mount, and a well-known
* URI is not under it. Unauthenticated by design, so no proof is signed.
*/
fun relayDescriptor(): JSONObject {
@@ -822,7 +822,7 @@ class RelayClient(
*
* A token can expire between being chosen and the request landing, and a
* relay restart invalidates every outstanding one. Without the retry the
* client stays broken until something else happens to refresh it — which,
* client stays broken until something else happens to refresh it, which,
* on a phone that is only polling, may be never.
*/
private fun request(method: String, url: HttpUrl, logicalPath: String, body: RequestBody?): Response {
@@ -850,7 +850,7 @@ class RelayClient(
return builder.build()
}
/** The exact bytes the signature must cover — read once, from the body itself. */
/** The exact bytes the signature must cover, read once, from the body itself. */
private fun bodyBytes(body: RequestBody?): ByteArray {
if (body == null) return ByteArray(0)
val sink = okio.Buffer()
@@ -859,7 +859,7 @@ class RelayClient(
}
private fun checked(response: Response, path: String): Response {
// ios#45 — learn the clock skew from the header every response already
// ios#45, learn the clock skew from the header every response already
// carries. Done HERE because this is the one funnel every relay call
// passes through, and before the success check, so a 4xx teaches us the
// time just as well as a 200 does: a phone whose clock is wrong enough
@@ -870,7 +870,7 @@ class RelayClient(
val code = response.code
response.close()
// ⚠️ **Carry the relay's own sentence, not its JSON.** These messages
// Carry the relay's own sentence, not its JSON. These messages
// reach the screen: creating a group with a member who has never signed
// in showed the user
//
@@ -886,7 +886,7 @@ class RelayClient(
throw when (code) {
401 -> RelayException.Unauthorized(readable)
// ⚠️ The relay's own code, not the status. A 403 is also "that
// The relay's own code, not the status. A 403 is also "that
// device belongs to another account", and telling somebody to check
// their email for that would send them looking for a message that
// was never sent.
@@ -8,28 +8,28 @@ 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
* **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.
*
* This replaces a hardcoded list of hostnames. The list was safe but wrong: it
* meant a **self-hosted relay could never use the shared account service**,
* because it would never be on the list — so the "self-hosted, shared identity"
* meant a self-hosted relay could never use the shared account service,
* because it would never be on the list, so the "self-hosted, shared identity"
* deployment shape was unreachable in practice.
*/
object RelayDiscovery {
/**
* Relays whose account service is **pinned here and not taken from them**.
* Relays whose account service is pinned here and not taken from them.
*
* ⚠️ These are ours, and what they advertise is ignored: a compromise of
* These are ours, and what they advertise is ignored: a compromise of
* one must not be able to redirect its users' passphrase-derived secrets to
* a host the attacker controls.
*
* ⚠️ **Every other relay is believed**, and that is deliberate rather than
* Every other relay is believed, and that is deliberate rather than
* lax. A self-hoster running Messenger and Files together wants one account
* across both, held by their own service — the file server has taken
* across both, held by their own service, the file server has taken
* `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and a client
* that refused the relay's answer would make "run both" mean "run them with
* two separate logins". The user typed that hostname themselves; a server
@@ -60,7 +60,7 @@ object RelayDiscovery {
/**
* The service to actually authenticate against.
*
* ⚠️ For a relay we pin, **ours** — whatever it advertised. For anyone
* For a relay we pin, ours, whatever it advertised. For anyone
* else, what they advertised. That asymmetry is the whole rule: our
* relays cannot be made to redirect their users, and a relay somebody
* chose to type can name its own service.
@@ -72,7 +72,7 @@ object RelayDiscovery {
/**
* Ask a relay what it is.
*
* Returns null when the relay does not answer — an older deployment, or
* Returns null when the relay does not answer, an older deployment, or
* something that is not a relay at all. The caller then falls back to
* local, which is the safe default: it sends the passphrase only to the
* server the user typed in.
@@ -27,11 +27,11 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: ""))
/**
* 451 — this service is not offered in the caller's country.
* 451, this service is not offered in the caller's country.
*
* 🔴 **Its own type because the generic one reads as a broken app.** The
* Its own type because the generic one reads as a broken app. The
* edge geo-gate answers 451 before the request reaches the relay, and the
* download site is deliberately NOT gated — so somebody can read the page,
* download site is deliberately NOT gated, so somebody can read the page,
* install the app, choose a password, tap sign up, and be shown "relay
* returned 451". Nothing in that mentions their country, and it is not a
* failure they can retry past, so the only sensible reading is that the app
@@ -45,8 +45,8 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
* This relay charges a few seconds of arithmetic before a new account, and
* this app is too old to pay it.
*
* ⚠️ The remedy is an update, and it is the only refusal in sign-up where
* that is true — which is exactly why it cannot share a sentence with the
* The remedy is an update, and it is the only refusal in sign-up where
* that is true, which is exactly why it cannot share a sentence with the
* one below. Telling somebody to try again when the app will never be able
* to answer sends them round a loop that has no exit.
*/
@@ -57,9 +57,9 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
/**
* The check was answered and the answer did not hold up.
*
* ⚠️ Nearly always a challenge that expired while somebody was typing.
* The relay deliberately gives one message for all four of its refusals —
* forged, stale, unsolved, malformed — because telling an attacker which
* Nearly always a challenge that expired while somebody was typing.
* The relay deliberately gives one message for all four of its refusals,
* forged, stale, unsolved, malformed, because telling an attacker which
* knob to turn is worse than being vague with everybody else, so this side
* cannot be more specific than "start a fresh one" either.
*/
@@ -72,14 +72,14 @@ 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
* **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
* [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
* in their email.
*
* ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
* Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
* comments, so a slash-star inside one opens a comment that never closes
* and takes the whole module down with ninety unrelated errors.
*/
@@ -94,23 +94,23 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
/**
* Sealing was required and this send could not be sealed (§14.5, ios#16).
*
* ⚠️ Its own type rather than a generic failure. The user turned this on
* deliberately and the message did not go for a reason they chose — a bare
* Its own type rather than a generic failure. The user turned this on
* deliberately and the message did not go for a reason they chose, a bare
* "couldn't send" reads as the app being broken, and the next thing
* somebody does is switch the setting off without knowing that is what
* they are doing.
*/
class SealingRequired : RelayException(
"Not sent. This message could not be sealed, so the server would have seen " +
"that you sent it — and you asked Flamenet to refuse rather than send " +
"that you sent it, and you asked Flamenet to refuse rather than send " +
"anyway. It will seal again once they message you.")
}
/**
* Base64 for this module.
*
* The engine keeps its own helper `internal` on purpose — its public surface is
* the protocol, not its utilities — and widening that so the transport can
* The engine keeps its own helper `internal` on purpose, its public surface is
* the protocol, not its utilities, and widening that so the transport can
* reach in would be the tail wagging the dog. Base64 has one right answer, so a
* second call site cannot drift.
*/
@@ -3,20 +3,20 @@ 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
* **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
* server with a perfectly valid certificate for the right name, and every one
* of them is a different relay wearing the same address.
*
* The relay publishes an issuer public key in its descriptor. Pinning that on
* first contact turns "the certificate is valid" into "this is the same relay",
* which is a materially stronger statement and the one that matters.
*
* 🔴 **Android already pinned this key and drew the wrong conclusion from it.**
* Android already pinned this key and drew the wrong conclusion from it.
* `Trust.verifyHeadSignature` pins `issuerPublicKey` the first time a signed log
* head arrives, and if the key later changes the signature simply fails to
* verify — so the client raises `KeyLogForkException`, sets `forkDetected`, and
* verify, so the client raises `KeyLogForkException`, sets `forkDetected`, and
* tells somebody the relay's key log does not add up.
*
* A rotated relay key is not a forked log. They need different responses: a
@@ -24,16 +24,16 @@ package io.flamenet.client
* can be trusted; a rotation means somebody ran a command, or somebody else is
* answering on that address. Reporting the second as the first is both alarming
* and unactionable, and an alarm people cannot act on is one they learn to
* dismiss — which costs the real fork detection its credibility too.
* dismiss, which costs the real fork detection its credibility too.
*
* ⚠️ **A relay that publishes no key is a weaker deployment, not an attack.**
* 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. That case is
* distinguished from a key that CHANGED, which is not weakness.
*
* ⚠️ A change is surfaced, never silently accepted and never silently refused.
* A change is surfaced, never silently accepted and never silently refused.
* The same shape as a contact's identity changing: stop, say so, and let a
* person decide — because a legitimate rotation exists and an app that cannot
* person decide, because a legitimate rotation exists and an app that cannot
* survive one is an app that gets uninstalled.
*/
object RelayIdentityPin {
@@ -54,9 +54,9 @@ object RelayIdentityPin {
data object NotPublished : Verdict
/**
* 🔴 A different key than the one pinned. Either a deliberate rotation
* A different key than the one pinned. Either a deliberate rotation
* or a different server answering on this address, and nothing here can
* tell those apart — only a person can.
* tell those apart, only a person can.
*/
data class Changed(val pinned: ByteArray, val offered: ByteArray) : Verdict {
override fun equals(other: Any?) = other is Changed &&
@@ -68,7 +68,7 @@ object RelayIdentityPin {
/**
* Compare what the relay just published against what is pinned.
*
* ⚠️ An empty or unparseable key counts as "not published" rather than as a
* An empty or unparseable key counts as "not published" rather than as a
* change. A relay that answers with rubbish is broken, and treating broken
* as hostile would raise an alarm nobody can act on.
*/
@@ -86,14 +86,14 @@ object RelayIdentityPin {
/**
* What to tell somebody when it changed.
*
* 🔴 Names the two possibilities and does not pretend to know which. An
* Names the two possibilities and does not pretend to know which. An
* alarm that asserts an attack when a rotation is equally likely trains
* people to dismiss alarms.
*/
fun warning(host: String): String =
"The server at $host is presenting a different identity than the one " +
"this app recorded the first time it connected. That happens when the " +
"server's keys are rotated on purpose — and it also happens when " +
"server's keys are rotated on purpose, and it also happens when " +
"something else is answering in its place. Nothing here can tell those " +
"apart. If you were not expecting a change, stop and check with " +
"whoever runs it before signing in again."
@@ -102,9 +102,9 @@ object RelayIdentityPin {
/**
* The relay is not the one this app pinned.
*
* 🔴 **Deliberately NOT a [KeyLogForkException].** A fork means the relay is
* Deliberately NOT a [KeyLogForkException]. A fork means the relay is
* showing two different histories and nothing it says can be trusted. This means
* the server's identity key is not the one recorded — a rotation somebody
* the server's identity key is not the one recorded, a rotation somebody
* performed, or somebody else's server. The remedies are different, so the
* exceptions are different.
*/
@@ -9,14 +9,14 @@ import java.security.MessageDigest
/**
* A few seconds of this phone's arithmetic, spent before an account exists.
*
* 🔴 **Why this and not a CAPTCHA.** A CAPTCHA hands a third party every
* Why this and not a CAPTCHA. A CAPTCHA hands a third party every
* visitor's connection, which is a strange thing to do in a product whose
* argument is that it collects almost nothing. This collects nothing at all:
* no personal data, no third party, no picture of a crosswalk. It costs one
* person a couple of seconds and costs a farm of registrations real money.
*
* ⚠️ **Every branch here has to end in "sign up anyway".** A relay that
* predates the feature, a request that times out, a proxy that returns HTML —
* Every branch here has to end in "sign up anyway". A relay that
* predates the feature, a request that times out, a proxy that returns HTML,
* none of those are reasons somebody cannot create an account. The relay
* decides whether a challenge is *required*; this side only tries to satisfy
* one when it is offered.
@@ -24,7 +24,7 @@ import java.security.MessageDigest
object SignupProofOfWork {
/**
* What the relay hands out. `bits == 0` means it wants no work — the relay
* What the relay hands out. `bits == 0` means it wants no work, the relay
* answers that rather than 404ing, so asking is always safe and the answer
* never reveals how a deployment is configured.
*/
@@ -34,10 +34,10 @@ object SignupProofOfWork {
data class Solution(val challenge: String, val nonce: String)
/**
* 🔴 **Refuse to grind past this even if the relay asks.** The relay caps
* Refuse to grind past this even if the relay asks. The relay caps
* itself at 24 bits, but a client that trusts a number from the network
* with an unbounded loop is one misconfiguration away from an app that
* appears to hang on the sign-up button — which reads as broken, not as
* appears to hang on the sign-up button, which reads as broken, not as
* spam protection. Above the cap we send nothing and let the relay refuse,
* because a refusal with a sentence beats a spinner that never ends.
*/
@@ -45,7 +45,7 @@ object SignupProofOfWork {
/**
* Hashes to try before giving up. At 24 bits the expected count is ~16.7M,
* so this is roughly a 4x margin — generous enough that an unlucky run
* so this is roughly a 4x margin, generous enough that an unlucky run
* still finishes, bounded enough that a wrong difficulty cannot run for
* ever.
*/
@@ -54,8 +54,8 @@ object SignupProofOfWork {
/**
* Ask what work this server wants, if any.
*
* ⚠️ Returns null for every failure, deliberately. 404 is the common one —
* an older relay, or a site that has no such route — and it is
* Returns null for every failure, deliberately. 404 is the common one,
* an older relay, or a site that has no such route, and it is
* indistinguishable from success-with-no-work as far as what the caller
* should do next.
*/
@@ -74,7 +74,7 @@ object SignupProofOfWork {
)
}
} catch (_: Exception) {
// Transport, a 404 page that is not JSON, a missing field — all
// Transport, a 404 page that is not JSON, a missing field, all
// mean the same thing here, and it is not "no account for you".
null
}
@@ -95,7 +95,7 @@ object SignupProofOfWork {
val digest = MessageDigest.getInstance("SHA-256")
// Hashed as `challenge.nonce`, which is exactly what the relay
// verifies. The prefix never changes, so it is encoded once rather than
// per attempt — at tens of millions of iterations that is not a
// per attempt, at tens of millions of iterations that is not a
// micro-optimisation, it is most of the running time.
val prefix = "$challenge.".toByteArray(Charsets.UTF_8)
for (n in 0 until ceiling) {
@@ -108,7 +108,7 @@ object SignupProofOfWork {
}
/**
* ⚠️ Counted in BITS, not leading zero bytes. Bytes move in steps of 256x,
* Counted in BITS, not leading zero bytes. Bytes move in steps of 256x,
* which leaves no way to ask for two seconds rather than half a second or
* two minutes. This must agree with the relay exactly or every solution
* this phone produces is rejected by a server that is working perfectly.
@@ -131,7 +131,7 @@ object SignupProofOfWork {
* today and stays the normal case for anyone self-hosting who never turns
* it on.
*
* ⚠️ Blocking and CPU-bound, like every other call in this module. The
* Blocking and CPU-bound, like every other call in this module. The
* caller is already off the main thread; if it were not, sign-up would
* already be an ANR without any of this.
*/
@@ -25,7 +25,7 @@ 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
* **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.
@@ -34,7 +34,7 @@ enum class IdentityTrust {
}
/** The relay showed two different logs, or a log that shrank. */
/** Turn a key-log entry into a sentence — S4's "name what changed". Pure. */
/** Turn a key-log entry into a sentence. S4's "name what changed". Pure. */
object KeyChangeDescription {
fun describe(kind: String, atIso: String?): String {
val what = when (kind) {
@@ -62,15 +62,15 @@ 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
* **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
* 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.
*
* The second case is usually not an attack. The subject is inside the hashed
* bytes, and an account that has claimed an fnid is logged under it rather than
* under `local:<audience>/<user_id>` — so a verifier that does not know the
* under `local:<audience>/<user_id>`, so a verifier that does not know the
* fnid cannot recompute the leaf even though the key was published perfectly
* well. Both refuse, because refusing is the safe direction, but reporting them
* identically means an honest conversation and an interception produce the same
@@ -79,7 +79,7 @@ class KeyLogForkException(detail: String) :
class KeyNotLoggedException(val deviceId: String, val hadEntries: Boolean = false) :
RelayException(
if (hadEntries) {
"device $deviceId has published keys, but none match what this relay served — " +
"device $deviceId has published keys, but none match what this relay served, " +
"if this account has a global identity, its fnid is needed to check it"
} else {
"device $deviceId offered a key the relay never logged"
@@ -93,25 +93,25 @@ class IdentityChangedException(val userId: Long) :
/**
* Identity pinning and key-transparency enforcement (spec §19).
*
* 🔴 **This is the part that makes the transparency log worth having.** A log
* This is the part that makes the transparency log worth having. A log
* nothing checks is a log that faithfully records the substitution and is
* never read; the relay hands out a key it minted, the conversation encrypts
* to it, and every byte on the wire is correctly encrypted to the wrong
* person. Two independent checks are needed and neither is sufficient alone:
*
* - **Pinning** catches a key changing under a person you already talk to,
* - Pinning catches a key changing under a person you already talk to,
* but says nothing the first time you meet them.
* - **The log** says a key was published where the world can see it, but a
* relay that rewrites history reissues valid proofs against the new tree —
* - The log says a key was published where the world can see it, but a
* relay that rewrites history reissues valid proofs against the new tree,
* so inclusion is only meaningful against a head this device already
* pinned and can demand a consistency proof for.
*
* 🔴 **Automatic wherever the relay can answer.** This used to be behind
* [requireKeyLog], off by default — so the protection shipped and almost
* Automatic wherever the relay can answer. This used to be behind
* [requireKeyLog], off by default, so the protection shipped and almost
* nobody had it. The argument for opt-in (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.
* [enforceKeyLogIfServed] draws exactly that line. Pinning is always on — it
* [enforceKeyLogIfServed] draws exactly that line. Pinning is always on, it
* costs nothing and needs no cooperation from the relay.
*/
class Trust(
@@ -121,9 +121,9 @@ class Trust(
) {
/**
* Refuse a peer even when the relay keeps **no log at all**.
* Refuse a peer even when the relay keeps no log at all.
*
* ⚠️ Not the switch that turns checking on — checking is automatic, see
* Not the switch that turns checking on, checking is automatic, see
* [enforceKeyLogIfServed]. This is the narrower question underneath it:
* what to do about a relay that publishes no log, where there is nothing to
* check a key against.
@@ -140,7 +140,7 @@ class Trust(
*
* A standing condition rather than a thrown error: a fork is discovered
* while processing inbound traffic, where there is nothing to fail, and it
* stays true once discovered. Nothing here clears it — a relay that forked
* stays true once discovered. Nothing here clears it, a relay that forked
* once does not become trustworthy because the next request looked fine.
*/
var forkDetected: Boolean = false
@@ -153,7 +153,7 @@ class Trust(
*
* Trust on first use: with nothing pinned, the offered keys are taken and
* recorded. That is the weakest link in the whole scheme and it is a
* deliberate one — the alternative is a client that cannot start a
* deliberate one, the alternative is a client that cannot start a
* conversation without an out-of-band channel it does not have.
*/
fun record(userId: Long, offered: List<ByteArray>, fnid: String? = null): IdentityTrust {
@@ -162,7 +162,7 @@ class Trust(
// A different address for someone we already know is not a new device.
// The fnid is derived from the account key, so a new one means a new
// account key — a different person, however the relay presents it.
// account key, a different person, however the relay presents it.
if (fnid != null && existing?.fnid != null && existing.fnid != fnid) {
markChanged(userId, existing)
return IdentityTrust.CHANGED
@@ -176,15 +176,15 @@ class Trust(
)
return IdentityTrust.PINNED
}
// 🔴 **An empty offer is not agreement (audit LOW).** `all {}` on an
// An empty offer is not agreement (audit LOW). `all {}` on an
// empty list is vacuously true, so a directory answering with NO keys
// for someone already pinned read as "unchanged" — the strongest
// verdict — when it had said nothing at all. It is not evidence of a
// for someone already pinned read as "unchanged", the strongest
// verdict, when it had said nothing at all. It is not evidence of a
// swap either, so it reports UNKNOWN: nothing to compare, nothing to
// send to. The first-contact branch above already guards this case;
// this is the same rule once keys exist.
if (encoded.isEmpty()) return IdentityTrust.UNKNOWN
// ⚠️ EVERY offered key must already be pinned — not "any". A relay
// EVERY offered key must already be pinned, not "any". A relay
// adding one key of its own alongside the real ones would otherwise
// pass, and that is precisely the attack: the victim's real devices
// still work, so nothing looks wrong, while a device the attacker
@@ -204,7 +204,7 @@ class Trust(
/**
* Learn this contact's global address and pin it.
*
* 🔴 **Pinned on first sight and never quietly replaced.** The address is
* Pinned on first sight and never quietly replaced. The address is
* what the key log files their publications under, so it decides which leaf
* a served key has to hash to. Re-reading it from the relay on every check
* would let a relay that wants to substitute a key simply also change the
@@ -212,10 +212,10 @@ class Trust(
*
* A DIFFERENT address later is therefore treated like a changed identity
* key, and for a stronger reason than one: an fnid is derived from the
* account key, so a new address is not a new device — it is a different
* account key, so a new address is not a new device, it is a different
* account key, which is to say a different person.
*
* ⚠️ First contact is still trust-on-first-use. Taking the relay's word
* First contact is 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
* it. The safety number is what covers the first sighting, and nothing here
* replaces reading it aloud.
@@ -230,7 +230,7 @@ class Trust(
val resolved = try {
relay.resolveIdentity(handle)
} catch (_: Exception) {
// ⚠️ Unreachable is not "has no identity". Pinning null on a failed
// Unreachable is not "has no identity". Pinning null on a failed
// lookup would bake a wrong answer in permanently, and the next
// successful lookup would then read as an identity change.
return state(userId)
@@ -250,7 +250,7 @@ class Trust(
/**
* Record the address the relay served for this contact.
*
* ⚠️ Not a trust decision — it changes nothing about which identity keys
* Not a trust decision, it changes nothing about which identity keys
* are accepted. It only records the SUBJECT their key-log leaves are filed
* under, which a verifier needs and the device directory does not serve.
* Storing it means a later send that establishes no new session can still
@@ -259,9 +259,9 @@ class Trust(
fun rememberFnid(userId: Long, fnid: String) {
val existing = vault.loadTrust(userId) ?: return
if (existing.fnid == fnid) return
// 🔴 **The paragraph above was documentation, not behaviour.** This
// overwrote a DIFFERENT address in place — the one thing it says it must
// never do — and it went unnoticed while only the bundle route carried
// The paragraph above was documentation, not behaviour. This
// overwrote a DIFFERENT address in place, the one thing it says it must
// never do, and it went unnoticed while only the bundle route carried
// an address, because that route is reached once per conversation. The
// directory carries it on every send, so a silent replacement here
// would erase the change the pin exists to notice.
@@ -275,11 +275,11 @@ class Trust(
/** Trust as it stands, for UI that has not triggered a fetch. */
fun state(userId: Long): IdentityTrust {
val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN
// 🔴 CHANGED first, and it is persisted rather than recomputed. This
// CHANGED first, and it is persisted rather than recomputed. This
// function used to be unable to return it at all: CHANGED was worked
// out inside `record()` during a send and then dropped, so the header
// said "keys unchanged since you met" while every send was refused for
// exactly that reason — and the Accept button, which only shows for
// exactly that reason, and the Accept button, which only shows for
// CHANGED, could never appear.
if (t.changed) return IdentityTrust.CHANGED
if (t.identities.isEmpty()) return IdentityTrust.UNKNOWN
@@ -289,8 +289,8 @@ class Trust(
/**
* The number both people read to each other.
*
* ⚠️ Covers the FIRST pinned key only. Someone with several devices has
* several identity keys and one number cannot represent all of them —
* Covers the FIRST pinned key only. Someone with several devices has
* several identity keys and one number cannot represent all of them,
* per-device numbers are the honest fix and are not done here. Saying so
* matters: a number that silently describes one of three devices invites
* people to believe they checked more than they did.
@@ -307,21 +307,21 @@ class Trust(
}
/**
* Accept a changed identity — they really did reinstall or add a device.
* Accept a changed identity, they really did reinstall or add a device.
*
* ⚠️ Re-pins AND drops the verified flag. The old comparison was about a
* Re-pins AND drops the verified flag. The old comparison was about a
* key that is no longer in use and says nothing about the new one; leaving
* the tick in place would show "verified" for something nobody verified.
*/
fun acceptIdentityChange(userId: Long, handle: String? = null) {
// ⚠️ The address is taken from the SAME response that carries the keys.
// The address is taken from the SAME response that carries the keys.
// Accepting a change and then filing it under a stale address leaves
// the key log uncheckable for the very keys just accepted.
val served = relay.fetchKeys(userId)
val offered = served.bundles.map { it.identitySigning }
// 🔴 **An empty answer is a FAILURE, not an acceptance of nothing.**
// An empty answer is a FAILURE, not an acceptance of nothing.
// This returned quietly, so a fetch that was rate-limited, timed out or
// 500'd left the pin exactly as it was — while the UI reported the
// 500'd left the pin exactly as it was, while the UI reported the
// change accepted. The person taps "accept", nothing happens, every
// message stays refused, and the app insists the problem is on the
// other end. Observed after a burst of retries tripped the per-contact
@@ -330,7 +330,7 @@ class Trust(
if (offered.isEmpty()) {
throw RelayException.Malformed(
"the server returned no keys for this contact, so there was " +
"nothing to accept — try again in a moment")
"nothing to accept, try again in a moment")
}
served.fnid?.let { rememberFnid(userId, it) }
// Re-resolve rather than keep the old address: if what changed WAS the
@@ -351,7 +351,7 @@ class Trust(
/**
* Remember that something other than the pinned keys was offered.
*
* ⚠️ The pinned `identities` are left exactly as they were, and `verified`
* The pinned `identities` are left exactly as they were, and `verified`
* is dropped: whatever was checked in person was checked against the OLD
* key, so the tick cannot carry over to a key nobody has compared.
*/
@@ -360,7 +360,7 @@ class Trust(
vault.saveTrust(userId, existing.copy(changed = true, verified = false))
}
/** Forget a person entirely — used when they are removed as a contact. */
/** Forget a person entirely, used when they are removed as a contact. */
fun forget(userId: Long) = vault.forgetTrust(userId)
// -- Key transparency ----------------------------------------------------
@@ -374,9 +374,9 @@ class Trust(
* for this contact by [rememberIdentity], which is the answer that should
* almost always be used.
*
* ⚠️ **Required for any account that has claimed one**, because the relay
* Required for any account that has claimed one, because the relay
* files a leaf under the fnid once an account has it and under
* `local:<audience>/<user_id>` before that — and the subject is inside the
* `local:<audience>/<user_id>` before that, and the subject is inside the
* hashed bytes, so guessing wrong makes an honest key unverifiable.
*
* Taking it from the contact record rather than from the relay's current
@@ -389,8 +389,8 @@ class Trust(
*
* ## Why the head is asked for separately
*
* The obvious shape — call [enforceKeyLog] and read a 404 as "no log here"
* — hands the relay the switch. [enforceKeyLog] reads the head, then the
* The obvious shape, call [enforceKeyLog] and read a 404 as "no log here"
* hands the relay the switch. [enforceKeyLog] reads the head, then the
* device's leaves, then an inclusion proof, so a relay wanting to skip the
* check need only 404 the *middle* one to make a substituted key look like
* a deployment without transparency. The "is there a log" question is
@@ -400,7 +400,7 @@ class Trust(
* ## Why a relay cannot stop answering
*
* The pin is in the vault and outlives a restart. A relay that served a
* head before and 404s now has not lost its log — logs do not disappear —
* head before and 404s now has not lost its log, logs do not disappear,
* so that is a fork, not an absence. Without this the downgrade is one
* line: serve the log until a key is pinned, then stop.
*/
@@ -412,7 +412,7 @@ class Trust(
} catch (e: RelayException.NotFound) {
// Only a route that is genuinely 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
// transparency, that would make enforcement skippable by anybody
// able to make one request fail.
if (vault.keyLogServed(relay.host)) {
throw KeyLogForkException("this relay served a log before and now serves none")
@@ -428,7 +428,7 @@ class Trust(
/**
* [servedHead] is passed in rather than fetched again, because the caller
* has already had to ask for one to learn whether this relay keeps a log —
* has already had to ask for one to learn whether this relay keeps a log,
* and two reads could straddle an append, leaving an inclusion proof
* checked against a root this device never pinned.
*/
@@ -453,7 +453,7 @@ class Trust(
}
}
// ⚠️ Recomputed from the bundle we were served, NEVER read from the
// Recomputed from the bundle we were served, NEVER read from the
// relay's own reply. Taking the leaf the relay names would let it echo
// its own bytes back and satisfy the check against anything at all.
//
@@ -461,10 +461,10 @@ 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
// **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
// before, and the leaf hash committed to whichever string was true
// when it was written. A contact who registered a device and then
// claimed an address therefore has older leaves under one form and
// newer ones under the other, so a verifier computing a single subject
@@ -545,7 +545,7 @@ class Trust(
if (root != pin.root) forkDetected = true
return
}
// Different sizes are not evidence by themselves — one of us is simply
// Different sizes are not evidence by themselves, one of us is simply
// behind. The relay has to reconcile them, and if it served two
// branches it cannot.
val older = if (size < pin.size) size else pin.size
@@ -555,9 +555,9 @@ class Trust(
val ok = try {
consistent(older, newer, olderRoot, newerRoot)
} catch (_: Exception) {
// ⚠️ A failed fetch is NOT a fork. The network is allowed to be
// A failed fetch is NOT a fork. The network is allowed to be
// down, and calling that a fork cries wolf until the warning means
// nothing — which is worse than not warning at all.
// nothing, which is worse than not warning at all.
return
}
if (!ok) forkDetected = true
@@ -573,7 +573,7 @@ class Trust(
*
* This is what makes transparency act instead of merely record: a relay
* that quietly forked or rewound its log between two bundle fetches would
* otherwise go unseen — for an established conversation, possibly for ever.
* otherwise go unseen, for an established conversation, possibly for ever.
* Best effort on the network (a failed fetch is retried next cycle); a
* proven fork raises [forkDetected], the standing alarm. Call from the
* poll loop; it self-throttles.
@@ -598,7 +598,7 @@ class Trust(
/**
* A verified log head. Public because [enforceKeyLog] takes one that its
* caller has already fetched — the head is part of that contract rather
* caller has already fetched, the head is part of that contract rather
* than an internal detail.
*/
data class Head(val size: Int, val root: String, val audience: String)
@@ -613,7 +613,7 @@ class Trust(
* Check the relay's signature over a head against the pinned issuer key
* (§19.3, audit M1).
*
* ⚠️ Silent when nothing is published or the relay predates the field — a
* Silent when nothing is published or the relay predates the field, a
* deployment that cannot sign is weaker, not hostile, and refusing it
* outright is a worse failure than the one prevented. A signature that is
* present and WRONG, or that stops matching a key already pinned, is a
@@ -623,12 +623,12 @@ class Trust(
if (signatureB64.isEmpty()) return
val signature = runCatching { Wire.unb64(signatureB64) }.getOrNull() ?: return
// ios#19 — decide what the relay's published identity MEANS before
// ios#19, decide what the relay's published identity MEANS before
// using it, rather than inferring it from a signature that failed.
//
// 🔴 This is the bug the pin previously hid. A rotated relay key made
// This is the bug the pin previously hid. A rotated relay key made
// the signature stop verifying, and the only conclusion available at
// that point was "fork" — so a command somebody ran on purpose was
// that point was "fork", so a command somebody ran on purpose was
// reported as the relay showing two different histories. Different
// situations, different remedies, and an alarm nobody can act on is one
// people learn to dismiss, which costs real fork detection too.
@@ -646,7 +646,7 @@ class Trust(
}
is RelayIdentityPin.Verdict.Unchanged -> vault.keyLogIssuerPin() ?: return
is RelayIdentityPin.Verdict.Changed ->
// ⚠️ NOT a fork, and `forkDetected` is deliberately not set.
// NOT a fork, and `forkDetected` is deliberately not set.
// The log may be perfectly consistent; it is the server's
// identity that is not the one recorded.
throw RelayIdentityChangedException(relayHostForPin())
@@ -10,34 +10,34 @@ import org.json.JSONObject
* Whether a newer build than this one is published, for an app that is
* installed by hand.
*
* 🔴 **Nothing updates this app on its own.** It is downloaded as an APK from a
* Nothing updates this app on its own. It is downloaded as an APK from a
* web page, so there is no store to notice a new release and no background
* 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
* 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
* thing that closes it.
*
* 🔴 **Asked of the relay you already talk to, not of a fixed address of ours.**
* Asked of the relay you already talk to, not of a fixed address of ours.
* The obvious implementation points every install at flamenetmessenger.com, and
* that would mean an app whose whole claim is that it does not phone home
* checking in with us on a timer — from every device, including the self-hosted
* checking in with us on a timer, from every device, including the self-hosted
* ones that deliberately never contact us at all. Their traffic is their
* server's business. So the manifest is fetched from the relay this install is
* signed in to, which learns nothing it does not already know from the traffic
* it is carrying.
*
* ⚠️ **The consequence, stated rather than hidden:** a relay that publishes no
* The consequence, stated rather than hidden: a relay that publishes no
* manifest produces no prompt. Self-hosters who do not republish the APK get no
* update prompts, which is correct — we do not know what build they are meant
* to be running — but it does mean this cannot be relied on as the only way a
* update prompts, which is correct, we do not know what build they are meant
* to be running, but it does mean this cannot be relied on as the only way a
* fix reaches everybody.
*
* 🔴 **The download URL is built here and never read from the manifest.** A
* The download URL is built here and never read from the manifest. A
* `url` field in the JSON would let any relay send its users to an APK on a
* host of its choosing, which is a much larger thing to hand a server than
* "tell me a version number". Android's own signature check is the backstop —
* it refuses to install a differently-signed APK over an existing one — but a
* "tell me a version number". Android's own signature check is the backstop,
* it refuses to install a differently-signed APK over an existing one, but a
* backstop is not a reason to build the hole in front of it.
*/
object UpdateCheck {
@@ -76,7 +76,7 @@ object UpdateCheck {
/**
* What the relay publishes, or null if it publishes nothing usable.
*
* Null for every unusual case — no manifest, a refusal, a timeout, a body
* Null for every unusual case, no manifest, a refusal, a timeout, a body
* that is not the JSON expected. None of them are worth telling the user
* about: the failure mode of this whole feature is "no prompt", and a
* messenger that interrupts you to say it could not check for updates has
@@ -109,8 +109,8 @@ object UpdateCheck {
/**
* The published release if it is newer than [installedVersionCode], else null.
*
* ⚠️ Strictly greater. Equal is the common case — almost every check finds
* the build the user already has — and a prompt offering to install what is
* Strictly greater. Equal is the common case, almost every check finds
* the build the user already has, and a prompt offering to install what is
* already installed teaches people to dismiss these without reading. Older
* is possible too, on a relay that has not been updated in a while, and is
* likewise not something to offer.
@@ -20,9 +20,9 @@ import kotlin.test.assertTrue
/**
* An address nobody issued, and the record that says where mail for it waits.
*
* 🔴 **The relay must not be able to lie about either.** The address is the
* The relay must not be able to lie about either. The address is the
* public half of a key derived from the passphrase, so claiming it proves
* possession and nothing else — a relay confirms, it does not vouch. The home
* possession and nothing else, a relay confirms, it does not vouch. The home
* record is signed by that same key, so a relay serving a truncated list is
* caught by arithmetic rather than by trust. These tests are about the places
* that could quietly stop being true.
@@ -44,9 +44,9 @@ class AccountIdentityTest {
// MARK: - Claiming
/**
* 🔴 **The audience is not the caller's to guess.** It is the string the
* The audience is not the caller's to guess. It is the string the
* relay checks the signature against, so a caller inferring it from a URL
* would produce a claim that verifies nowhere — silently, because at the
* would produce a claim that verifies nowhere, silently, because at the
* point of failure a wrong audience and a wrong key look identical.
*/
fun claimingSignsWhatTheRelaySaysItsNameIs() {}
@@ -79,8 +79,8 @@ class AccountIdentityTest {
FNID.claimCanonical("relay.example.com", 42, body.getString("account_key"))),
"the claim must verify against the audience the relay named",
)
// ⚠️ And not against a different one. A claim captured at one relay must
// not be replayable at another — the same account key is MEANT to be
// And not against a different one. A claim captured at one relay must
// not be replayable at another, the same account key is MEANT to be
// registered in several places, but each registration has to be an act
// the holder took there.
assertFalse(
@@ -116,9 +116,9 @@ class AccountIdentityTest {
}
/**
* 🔴 **A relay serving a truncated list is the exact failure multi-homing
* **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,
* signed, the sender then delivers everywhere the real record names,
* because it checks rather than trusts.
*/
@Test
@@ -140,7 +140,7 @@ class AccountIdentityTest {
}
/**
* ⚠️ The serial is inside the signature, which is what stops the rollback a
* 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
* counter a relay could serve one from before a second home was added and
* quietly become the only mailbox again.
@@ -172,9 +172,9 @@ class AccountIdentityTest {
// MARK: - Serial numbers
/**
* ⚠️ Read from what is already published, not from local state. A device
* Read from what is already published, not from local state. A device
* offline while another added a relay would otherwise reuse a serial and
* have its record refused as a rollback — by the very check that exists to
* have its record refused as a rollback, by the very check that exists to
* stop an attacker doing the same thing.
*/
@Test
@@ -203,7 +203,7 @@ class AccountIdentityTest {
/**
* Every listed relay is told, not only the one being added. A home that
* does not know it is one still accepts mail, but a sender asking IT for
* the record gets a stale list and skips the others — so the redundancy
* the record gets a stale list and skips the others, so the redundancy
* would quietly depend on which relay the sender happened to ask.
*/
@Test
@@ -10,9 +10,9 @@ import kotlin.test.assertFailsWith
/**
* What a 401 from the account service is allowed to mean.
*
* 🔴 Guards a bug found by driving the app: every 401 and 403 became
* Guards a bug found by driving the app: every 401 and 403 became
* [AccountsClient.Failure.BadCredentials], so a session that simply aged out
* told the user "that handle or passphrase isn't right" — mid-send, hours
* told the user "that handle or passphrase isn't right", mid-send, hours
* after a successful sign-in. The obvious reading is that the password stopped
* working, and the obvious response is to change one that was never wrong.
*/
@@ -18,9 +18,9 @@ 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
* none, and refusing every peer there is worse than the attack — was an
* **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
* feature: the relay decides which case it is in, and it must not be able to
@@ -63,8 +63,8 @@ class AutomaticKeyLogTest {
/**
* A relay's log routes, answered by path.
*
* [head] null means the route is absent — a deployment from before §19.
* ⚠️ An unsigned head on purpose: `verifyHeadSignature` is silent when the
* [head] null means the route is absent, a deployment from before §19.
* An unsigned head on purpose: `verifyHeadSignature` is silent when the
* field is empty, which is the documented behaviour for a relay that
* publishes no issuer key, and it keeps these tests about the presence
* question rather than about signing.
@@ -91,7 +91,7 @@ class AutomaticKeyLogTest {
/**
* The case the old switch existed for. A relay from before §19 answers
* nothing, and refusing every contact on it would push people to something
* with no ratchet at all — a far worse outcome than the substitution being
* with no ratchet at all, a far worse outcome than the substitution being
* guarded against.
*/
@Test
@@ -111,7 +111,7 @@ class AutomaticKeyLogTest {
// MARK: - A relay that keeps one
/**
* 🔴 **The behaviour change.** Before this, a relay serving a log and a key
* The behaviour change. Before this, a relay serving a log and a key
* that is not in it was accepted without a murmur unless the user had found
* a switch in Privacy settings. Now the absent leaf is the answer.
*/
@@ -124,8 +124,8 @@ class AutomaticKeyLogTest {
}
/**
* ⚠️ Entries that exist but do not match are still a refusal, and the flag
* says which case it was — "we could not compute your subject" and "this
* Entries that exist but do not match are still a refusal, and the flag
* says which case it was, "we could not compute your subject" and "this
* key is not published" call for different things being said to the user.
*/
@Test
@@ -141,8 +141,8 @@ class AutomaticKeyLogTest {
// MARK: - The downgrade
/**
* 🔴 **The attack automatic enforcement would otherwise invite.** Skipping
* relays that keep no log means a relay can opt out of being checked — so
* The attack automatic enforcement would otherwise invite. Skipping
* relays that keep no log means a relay can opt out of being checked, so
* publish a log until clients are talking, then stop answering, and every
* client silently goes back to trusting whatever key it is handed. Logs do
* not disappear; a relay that answered before and 404s now is not a
@@ -152,7 +152,7 @@ class AutomaticKeyLogTest {
fun `a relay that served a log and then stops is a fork, not an absence`() {
relayServing(head = aHead)
val trust = trust()
// The first attempt fails on the absent leaf, and — crucially — it also
// The first attempt fails on the absent leaf, and, crucially, it also
// records that this relay answered.
assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLogIfServed(peer(), userId = 7) }
assertTrue(vault.keyLogServed(server.url("/").host))
@@ -164,9 +164,9 @@ class AutomaticKeyLogTest {
}
/**
* ⚠️ **A bad day is not an absence.** A timeout, a 500 or a 429 must not
* A bad day is not an absence. A timeout, a 500 or a 429 must not
* read as "this relay keeps no log", or enforcement becomes skippable by
* anybody able to make one request fail — including the relay itself, which
* anybody able to make one request fail, including the relay itself, which
* would only have to be unreliable rather than dishonest.
*/
@Test
@@ -178,9 +178,9 @@ class AutomaticKeyLogTest {
}
/**
* 🔴 **The flag is per relay, and the first version was not.** A single
* The flag is per relay, and the first version was not. A single
* global one meant that signing in to a second relay which keeps no log
* read as the FIRST going quiet — so an honest deployment without
* read as the FIRST going quiet, so an honest deployment without
* transparency became a fork warning, and every contact on it became
* unreachable.
*/
@@ -195,7 +195,7 @@ class AutomaticKeyLogTest {
}
/**
* The pin is written by the gossip path too — a head a *contact* sent — so
* The pin is written by the gossip path too, a head a *contact* sent, so
* inferring "this relay serves a log" from it would make a gossiped head
* start demanding a route the relay may never have had, and lock the user
* out of a deployment that was never dishonest.
@@ -11,14 +11,14 @@ import kotlin.test.assertTrue
/**
* The block list's wire shape, parsed from a NON-EMPTY list.
*
* 🔴 **This is the test that was missing, and its absence had teeth.** The
* client read `user_id` from rows the relay emits as `{id, name, slug}` — the
* This is the test that was missing, and its absence had teeth. The
* client read `user_id` from rows the relay emits as `{id, name, slug}`, the
* buddy-list shape, not the `user_id` shape the §8 routes use. It threw
* `No value for user_id`, and it survived because the only thing ever asserted
* was an EMPTY block list: with no rows, the map never runs and the parser is
* never exercised. Everything passed until the first person blocked somebody.
*
* ⚠️ So the case that matters is a list with something in it. An empty-list
* So the case that matters is a list with something in it. An empty-list
* assertion here would be worse than none, because it looks like coverage.
*/
class BlockListShapeTest {
@@ -37,7 +37,7 @@ class BlockListShapeTest {
@Test
fun `a populated block list parses, using id rather than user_id`() {
// Exactly what the relay emits — see BlockListResponse in Wire.swift
// Exactly what the relay emits, see BlockListResponse in Wire.swift
// and the conformance assertion "the block list carries id, name and
// slug".
serve("""{"blocked":[
@@ -54,9 +54,9 @@ class BlockListShapeTest {
}
/**
* ⚠️ A row shaped like the §8 routes must NOT quietly parse. If the relay
* A row shaped like the §8 routes must NOT quietly parse. If the relay
* ever changed shape, silently reading a different field would hand back
* an under-populated block list — and an under-populated block list is a
* an under-populated block list, and an under-populated block list is a
* blocked person becoming visible again.
*/
@Test
@@ -8,7 +8,7 @@ import kotlin.test.assertTrue
/**
* The multi-device fan-out rule (found 2026-08-29 in live two-device testing).
*
* `listDevices` returns identity-only STUBS — enough for a device we already
* `listDevices` returns identity-only STUBS, enough for a device we already
* have a session with, useless for one we do not, because
* `E2ESession.initiate` verifies the signed prekey's signature and a stub has
* none. Choosing between the two lists meant that whenever ANY device already
@@ -14,15 +14,15 @@ import org.json.JSONObject
/**
* Talking to a chain node.
*
* 🔴 **The failure this suite is really about is a refusal read as an answer.**
* A JSON-RPC error arrives with HTTP 200 — the transport succeeded, the node
* The failure this suite is really about is a refusal read as an answer.
* A JSON-RPC error arrives with HTTP 200, the transport succeeded, the node
* said no. A client that checks only the status code carries on with a body
* that has no `result` in it, fails somewhere less obvious, and throws away the
* node's own explanation on the way. "Insufficient funds for rent exemption" on
* screen versus a parse error nobody can act on is the whole difference.
*
* ⚠️ Against a `MockWebServer`, so these reach nothing real. See
* `TestSafetyTest` — a suite that signs up against a mock is the correct way to
* Against a `MockWebServer`, so these reach nothing real. See
* `TestSafetyTest`, a suite that signs up against a mock is the correct way to
* test signing up.
*/
class ChainRpcTest {
@@ -40,7 +40,7 @@ class ChainRpcTest {
// ---- The refusal that looks like success ------------------------------
/**
* 🔴 The case the whole file exists for: HTTP 200, and a refusal in the
* The case the whole file exists for: HTTP 200, and a refusal in the
* body.
*/
@Test
@@ -72,7 +72,7 @@ class ChainRpcTest {
}
/**
* ⚠️ A body with neither `result` nor `error` is malformed rather than a
* A body with neither `result` nor `error` is malformed rather than a
* balance of zero. Reading a missing result as zero would show somebody an
* empty wallet that is not empty.
*/
@@ -98,7 +98,7 @@ class ChainRpcTest {
}
/**
* ⚠️ Lamports exceed what a 32-bit value holds long before the balance is
* Lamports exceed what a 32-bit value holds long before the balance is
* interesting: 5 SOL is already over Int.MAX_VALUE. Read as a Long
* throughout, and this pins it.
*/
@@ -121,7 +121,7 @@ class ChainRpcTest {
// ---- History ---------------------------------------------------------
/**
* 🔴 **A failed transaction still appears in an address's history.** Not
* A failed transaction still appears in an address's history. Not
* showing that it failed would be the worst kind of wrong: somebody would
* believe a payment went through.
*/
@@ -140,9 +140,9 @@ class ChainRpcTest {
}
/**
* ⚠️ `err` is an object for one kind of failure and an array for another.
* `err` is an object for one kind of failure and an array for another.
* Testing for "not null" rather than for a shape is what makes both
* visible — checking for an object alone would silently pass one class of
* visible, checking for an object alone would silently pass one class of
* failure off as a success.
*/
@Test
@@ -153,7 +153,7 @@ class ChainRpcTest {
}
/**
* ⚠️ A very new transaction has no `blockTime` yet — the node has it and
* A very new transaction has no `blockTime` yet, the node has it and
* has not dated it. Null means "just now"; reading a missing value as 0
* would date it to 1970 on somebody's screen.
*/
@@ -18,7 +18,7 @@ import kotlin.test.assertTrue
/**
* The inbound path, with real ciphertext.
*
* 🔴 **`poll` was at 0%.** The tests that cover it — the `Live*` suites — are
* `poll` was at 0%. The tests that cover it, the `Live*` suites, are
* opt-in via `FN_LIVE_FLEET` and skip in an ordinary run, so the single most
* important path in the client contributed nothing to a normal build. That is
* worse than having no test, because the file listing says it is covered.
@@ -94,7 +94,7 @@ class ConversationsPollTest {
}
/**
* 🔴 An advancing watermark is what stops the same message arriving for
* An advancing watermark is what stops the same message arriving for
* ever. It must move even though the relay decides the ids.
*/
@Test
@@ -115,11 +115,11 @@ class ConversationsPollTest {
}
/**
* 🔴 **Three chains to try, not one.** This used to be
* Three chains to try, not one. This used to be
* `existingSession(fromDevice) ?: respond(...)`, which reads as a fallback
* and is not one: when a stored session existed it was the ONLY thing
* tried, decrypt threw for a peer on a different chain, and the catch
* swallowed it — so `respond` was never reached.
* swallowed it, so `respond` was never reached.
*
* That happens whenever both sides run X3DH before either has processed the
* other's prekey: two people opening the same chat at once, which is
@@ -161,7 +161,7 @@ class ConversationsPollTest {
val received = conversations().poll()
assertEquals(1, received.size)
assertEquals("still delivered", received.single().text)
// ⚠️ And the watermark passes BOTH, or the unreadable one is retried
// And the watermark passes BOTH, or the unreadable one is retried
// for ever and the inbox never drains.
assertEquals(2, vault.inboxWatermark)
}
@@ -175,9 +175,9 @@ class ConversationsPollTest {
/**
* Two peers in one page are each opened on their own chain.
*
* ⚠️ They must be different USERS. Two identities claiming one user id is
* They must be different USERS. Two identities claiming one user id is
* exactly what the C1 gate refuses, so writing this with the default sender
* makes the second message vanish — correctly, and confusingly.
* makes the second message vanish, correctly, and confusingly.
*/
@Test
fun `messages from two peers are both delivered`() {
@@ -229,7 +229,7 @@ class ConversationsPollTest {
}
/**
* 🔴 **The receive-side identity gate (audit C1).** Building a session from
* The receive-side identity gate (audit C1). Building a session from
* a prekey envelope means accepting whatever identity key it offers, so it
* must pass the SAME pin check the send path applies. Without it, anyone
* could hand us a handshake with their own key while claiming to be a
@@ -271,7 +271,7 @@ class ConversationsPollTest {
/**
* A relay that cannot be reached must surface rather than silently
* reporting an empty inbox — "no new messages" and "we could not ask" are
* reporting an empty inbox, "no new messages" and "we could not ask" are
* different states and a client that conflates them looks broken to nobody.
*/
@Test
@@ -11,7 +11,7 @@ class DeliveryDiagnosticsTest {
@Test
fun `identifiers are stripped from reasons before they are counted`() {
// 🔴 A reading meant to be safe to screenshot must not carry device ids.
// A reading meant to be safe to screenshot must not carry device ids.
// These are the real shapes: the refusal messages name what was wrong,
// and that sometimes means naming an id.
val cases = listOf(
@@ -28,7 +28,7 @@ class DeliveryDiagnosticsTest {
@Test
fun `a reason with nothing identifying survives intact`() {
// ⚠️ It has to stay readable, or the screen says nothing. "base64"
// It has to stay readable, or the screen says nothing. "base64"
// contains a digit and must NOT be mangled.
val plain = "the payload was not valid base64"
assertEquals(plain, DeliveryDiagnostics.normalisedReason(plain))
@@ -57,7 +57,7 @@ class DeliveryDiagnosticsTest {
@Test
fun `the same reason twice is one row, not two`() {
// ⚠️ Normalisation is what makes this true: without it, two refusals
// Normalisation is what makes this true: without it, two refusals
// naming different device ids would be two rows and the map would grow
// without bound.
DeliveryDiagnostics.reset()
@@ -21,9 +21,9 @@ import kotlin.test.assertTrue
/**
* Single-use delivery capabilities (§21.4).
*
* 🔴 **The property under test is that a capability is SPENT, not merely
* **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
* 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
* with extra steps.
@@ -126,7 +126,7 @@ class DeliveryTokenTest {
// MARK: - Issuing
/**
* 🔴 The batch rides the EXISTING `delivery_key` envelope. A new control
* The batch rides the EXISTING `delivery_key` envelope. A new control
* kind would have arrived on shipped phones as a bubble full of raw JSON,
* because they read the `ctl` flag with `optBoolean` and it does not coerce
* the number the spec calls for.
@@ -167,7 +167,7 @@ class DeliveryTokenTest {
assertEquals(1, spent)
assertTrue(lastSend!!.optString("delivery_token").isNotEmpty())
// ⚠️ And NOT the key alongside it. Sending both would let the relay
// And NOT the key alongside it. Sending both would let the relay
// accept on the key when the capability had been revoked, so it would
// never learn the capability was refused.
assertTrue(lastSend!!.optString("delivery_key").isEmpty())
@@ -175,7 +175,7 @@ class DeliveryTokenTest {
}
/**
* 🔴 The load-bearing one. A capability that survived its use is a reusable
* The load-bearing one. A capability that survived its use is a reusable
* per-contact key, and the relay then sees one constant per sender.
*/
@Test
@@ -213,7 +213,7 @@ class DeliveryTokenTest {
}
/**
* ⚠️ **Revoking moves a sender back onto the shared key; it does not
* **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.
*/
@@ -237,7 +237,7 @@ class DeliveryTokenTest {
// MARK: - Replenishment, counted rather than asked for
/**
* 🔴 A "running low" control message would have been a new kind, with the
* A "running low" control message would have been a new kind, with the
* same rendering problem on shipped phones. It is not needed: the issuer
* counts, because every message it receives from a contact spent one of
* their capabilities.
@@ -272,9 +272,9 @@ class DeliveryTokenTest {
}
/**
* 🔴 **This is the act the whole section exists to make possible.** The
* relay cannot enforce a block against a sealed sender — the request
* carries no sender to compare against a block list — so before §21.4,
* This is the act the whole section exists to make possible. The
* relay cannot enforce a block against a sealed sender, the request
* carries no sender to compare against a block list, so before §21.4,
* blocking stopped the identified path and nothing else, and a blocked
* person's sealed messages were dropped by the app after decryption rather
* than refused at the door.
@@ -317,7 +317,7 @@ class DeliveryTokenTest {
"a capability batch reached the thread")
}
/** ⚠️ Malformed entries are dropped rather than stored: one that is not 32
/** Malformed entries are dropped rather than stored: one that is not 32
* bytes could never be spent, and keeping it would burn a send. */
@Test
fun `a malformed capability in a batch is dropped`() {
@@ -334,9 +334,9 @@ class DeliveryTokenTest {
/**
* A contact who sends no batch leaves us on the shared key, which works.
*
* ⚠️ Needs our OWN key published, because a peer's key is stored inside our
* delivery record. That is the app's real state — `ensureDeliveryKey` runs
* at sign-in — and it is exactly why the capability store is separate and
* Needs our OWN key published, because a peer's key is stored inside our
* delivery record. That is the app's real state, `ensureDeliveryKey` runs
* at sign-in, and it is exactly why the capability store is separate and
* does NOT depend on it.
*/
@Test
@@ -11,7 +11,7 @@ import kotlin.test.assertTrue
/**
* Handling a wrong device clock (ios#45, Android half).
*
* 🔴 **The rule under test is an asymmetry, not a correction.** A story
* The rule under test is an asymmetry, not a correction. A story
* disappearing after an hour is a promise the SENDER made, enforced on the
* READER's device. A reader whose clock runs behind keeps a message the sender
* was told would be gone, and nobody involved can tell. Being wrong the other
@@ -46,7 +46,7 @@ class DeviceClockTest {
}
/**
* ⚠️ A skew learned yesterday says nothing about today: the clock may have
* A skew learned yesterday says nothing about today: the clock may have
* been corrected, or the phone rebooted with a fresh answer from the
* network. An observation that has aged out is the same as none.
*/
@@ -61,7 +61,7 @@ class DeviceClockTest {
// ---- The rule that matters ------------------------------------------
/**
* 🔴 **A device clock set BACK cannot extend a message's life.** This is the
* A device clock set BACK cannot extend a message's life. This is the
* case the whole file exists for: the phone thinks it is earlier than it is,
* so on its own reading a disappearing message is still alive. The server's
* time overrules it.
@@ -74,7 +74,7 @@ class DeviceClockTest {
}
/**
* 🔴 **And a relay that lies about the time cannot either.** A hostile relay
* And a relay that lies about the time cannot either. A hostile relay
* would like to move time BACKWARDS so disappearing messages linger. Taking
* the later of the two means its answer is simply ignored.
*/
@@ -100,7 +100,7 @@ class DeviceClockTest {
}
/**
* ⚠️ Both directions of the same rule in one place: whatever the relay says,
* Both directions of the same rule in one place: whatever the relay says,
* the answer is never EARLIER than the device's own clock.
*/
@Test
@@ -130,7 +130,7 @@ class DeviceClockTest {
assertTrue(DeviceClock.isSuspect(now))
val warning = DeviceClock.warning(now)!!
assertTrue(warning.contains("15 minutes behind"), warning)
// 🔴 The symptom before the cause. Somebody whose stories keep vanishing
// The symptom before the cause. Somebody whose stories keep vanishing
// does not think "my clock is wrong"; they think the app is broken.
assertTrue(warning.contains("Stories and disappearing messages"), warning)
}
@@ -154,7 +154,7 @@ class DeviceClockTest {
// ---- Shape ----------------------------------------------------------
/**
* ⚠️ Seconds as a Double, matching `Vault.Story.expires`. Flooring to a Long
* Seconds as a Double, matching `Vault.Story.expires`. Flooring to a Long
* of seconds here would lose the sub-second part on the boundary that
* decides whether a story is still on screen.
*/
@@ -174,9 +174,9 @@ class DeviceClockTest {
}
/**
* 🔴 The round trip through the header format, because that is what actually
* The round trip through the header format, because that is what actually
* arrives. A formatter that took its locale from the device parses nothing
* on a phone set to most of the world, and the failure is silent — the
* on a phone set to most of the world, and the failure is silent, the
* header is simply never understood and the skew is never learned.
*/
@Test
@@ -13,10 +13,10 @@ import kotlin.test.assertEquals
/**
* The seam where a peer's timer announcement becomes a stored fact (§6.11).
*
* 🔴 **Symmetric or worthless.** If only the party who set the timer expires
* Symmetric or worthless. If only the party who set the timer expires
* their copy, the feature is a lie: they believe their words are gone from a
* phone that is still holding them. So a `ttl` envelope from the other side has
* to be adopted, not merely offered — which is what this asserts.
* to be adopted, not merely offered, which is what this asserts.
*/
class DisappearingControlTest {
@@ -60,7 +60,7 @@ class DisappearingControlTest {
assertEquals(0, vault.disappearSeconds(Vault.threadForUser(42L)))
}
/** ⚠️ Per conversation. One contact's timer must not touch another's. */
/** Per conversation. One contact's timer must not touch another's. */
@Test
fun `the timer applies only to the conversation it came from`() {
val (conversations, _) = conversations()
@@ -12,15 +12,15 @@ import kotlin.test.assertTrue
/**
* A 403 that means "check your email", told apart from every other 403.
*
* 🔴 **Without this it is indistinguishable from a credential problem.** An
* Without this it is indistinguishable from a credential problem. An
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
* and falls back to Basic, which the e2e routes refuse. The person then sees
* calls that do not work and an inbox that does nothing, and nothing anywhere
* says the remedy is a code sitting in their inbox.
*
* ⚠️ The status alone is not enough: 403 is also "that device belongs to
* The status alone is not enough: 403 is also "that device belongs to
* another account". Telling somebody to check their email for that would send
* them looking for a message that was never sent — so the relay's own `code` is
* them looking for a message that was never sent, so the relay's own `code` is
* what decides, not the number.
*/
class EmailUnverifiedTest {
@@ -62,7 +62,7 @@ class EmailUnverifiedTest {
}
/**
* 🔴 The distinction that matters. A device conflict is also a 403 and must
* The distinction that matters. A device conflict is also a 403 and must
* NOT be reported as an unverified address.
*/
@Test
@@ -72,14 +72,14 @@ class EmailUnverifiedTest {
assertEquals(403, failure.status)
}
/** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */
/** A 403 with no code at all, an old relay, or a proxy, stays generic. */
@Test
fun `a 403 with no code stays generic`() {
refuse(null)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** ⚠️ And a body that is not JSON must not throw a parse error. */
/** And a body that is not JSON must not throw a parse error. */
@Test
fun `an unparseable 403 body does not crash`() {
server.enqueue(
@@ -90,7 +90,7 @@ class EmailUnverifiedTest {
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** The code is only meaningful on a 403 — the same string on a 500 is not. */
/** The code is only meaningful on a 403, the same string on a 500 is not. */
@Test
fun `the code does not change the meaning of other statuses`() {
refuse("email_unverified", status = 500)
@@ -21,11 +21,11 @@ import kotlin.test.assertTrue
/**
* Messages that crossed a relay boundary.
*
* 🔴 **The relay records no sender for one of these**, deliberately: it was not
* The relay records no sender for one of these, deliberately: it was not
* told who wrote it and must not invent an attribution. So `from_user` arrives
* as 0 and the only thing naming the sender is the address sealed inside the
* blob. Every assertion here is about what happens to a message with no
* trustworthy sender on the transport — because the failure mode is not a
* trustworthy sender on the transport, because the failure mode is not a
* crash, it is every federated message from everybody silently piling into one
* nameless thread, or worse, into a local contact's.
*
@@ -70,8 +70,8 @@ class FederatedReceiveTest {
/**
* One envelope as a federated deposit looks on our inbox: no sender at all.
*
* ⚠️ `from_user: 0` and an empty `from_device` are not laziness in the
* fixture — they are exactly what `federationDeposit` writes, and a test
* `from_user: 0` and an empty `from_device` are not laziness in the
* fixture, they are exactly what `federationDeposit` writes, and a test
* that filled them in would be testing the local path with extra steps.
*/
private fun deposited(id: Long, payload: String) =
@@ -107,16 +107,16 @@ class FederatedReceiveTest {
assertEquals("hello from over there", received.text)
assertEquals(theirAddress, received.fromAddress)
assertEquals(peer.deviceId, received.fromDevice)
// 🔴 The point of the whole test. Their `from_user` of 12 is a row
// The point of the whole test. Their `from_user` of 12 is a row
// number on their relay; adopting it here would file this under
// whoever user 12 happens to be on ours.
assertEquals(0L, received.fromUser)
}
/**
* 🔴 The claim inside a seal is only a claim: anyone holding this device's
* The claim inside a seal is only a claim: anyone holding this device's
* published `IK_dh` can write any address they like there. The ratchet
* proves the DEVICE, so the two are bound first-use-wins — and a device
* proves the DEVICE, so the two are bound first-use-wins, and a device
* that later claims somebody else's address keeps the one it was seen with.
*
* Without this, an established thread can be renamed out from under the
@@ -173,7 +173,7 @@ class FederatedReceiveTest {
assertTrue(conversations.remoteAddresses().isEmpty(), "nothing before the first message")
conversations.poll()
// 🔴 Without this the conversation is write-only: the sender is in no
// Without this the conversation is write-only: the sender is in no
// roster and never can be, so with no row anywhere the only way back
// into the thread is retyping 52 characters of base32.
assertEquals(listOf(theirAddress), conversations.remoteAddresses())
@@ -199,10 +199,10 @@ class FederatedReceiveTest {
// MARK: - What an address is allowed to be
/**
* ⚠️ A relay is a hostname, never a URL. An address is parsed from a seal
* A relay is a hostname, never a URL. An address is parsed from a seal
* written by somebody nobody authenticated, so accepting `https://x/y` here
* would let a stranger point this client at a path on a host of their
* choosing — and would do it from inside a message.
* choosing, and would do it from inside a message.
*/
@Test
fun `an address carrying a URL is not an address`() {
@@ -228,8 +228,8 @@ class FederatedReceiveTest {
/**
* A federated message cannot write control state keyed by user id.
*
* 🔴 `consumeControl(0L, ...)` would poison user 0's delivery key and
* avatar — and user 0 is the id every unattributed message carries, so one
* `consumeControl(0L, ...)` would poison user 0's delivery key and
* avatar, and user 0 is the id every unattributed message carries, so one
* stranger on another relay could break sealing for everybody.
*/
@Test
@@ -4,11 +4,11 @@ import kotlin.test.Test
import kotlin.test.assertTrue
/**
* 451 — the country gate (§ edge policy).
* 451, the country gate (§ edge policy).
*
* 🔴 **The download site is deliberately NOT gated.** Someone outside the
* The download site is deliberately NOT gated. Someone outside the
* served countries can read the page, install the app, choose a password, tap
* sign up — and only then does the edge answer 451, before the request ever
* sign up, and only then does the edge answer 451, before the request ever
* reaches the relay. Reported as a status code, that reads as a broken app:
* nothing mentions their country, and it is not a failure they can retry past.
*/
@@ -22,8 +22,8 @@ class GeoUnavailableTest {
}
/**
* ⚠️ It must also say what it is NOT. Someone who has just typed a password
* will otherwise assume they got it wrong and try again — and again.
* It must also say what it is NOT. Someone who has just typed a password
* will otherwise assume they got it wrong and try again, and again.
*/
@Test
fun `the message rules out the account and the connection`() {
@@ -14,13 +14,13 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* §19.4 — our view of the log has to actually go out.
* §19.4, our view of the log has to actually go out.
*
* 🔴 **This was dead code on both apps.** `Trust.headPayload` (and iOS's
* This was dead code on both apps. `Trust.headPayload` (and iOS's
* `keyLogHeadPayload`) built the envelope and nothing ever called it, while
* the *receiving* half was fully wired. The result is the failure that looks
* like success: every client verifies proofs, pins heads, and reports
* transparency working — and a relay serving two branches is never caught,
* transparency working, and a relay serving two branches is never caught,
* because catching it requires two clients to COMPARE heads and no client ever
* offered one. Only the JS reference client sent it, so gossip worked web→app
* and in no other direction.
@@ -69,8 +69,8 @@ class GossipHeadSentTest {
/**
* A relay that keeps no transparency log.
*
* ⚠️ Not incidental setup. Sending now asks for a head on its own, because
* key-log checking is automatic on any relay that serves one — so a queue
* Not incidental setup. Sending now asks for a head on its own, because
* key-log checking is automatic on any relay that serves one, so a queue
* of bodies matched purely by ORDER would hand the head request the device
* listing and fail on a parse error miles from the cause. Answering by path
* says what this fixture is: a relay from before §19, where gossip is the
@@ -92,7 +92,7 @@ class GossipHeadSentTest {
}
}
/** Alice, with a head already pinned — nothing is gossiped without one. */
/** Alice, with a head already pinned, nothing is gossiped without one. */
private fun alice(pinned: Boolean): Conversations {
val identity = DeviceIdentity.create()
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
@@ -134,10 +134,10 @@ class GossipHeadSentTest {
}
/**
* 🔴 In-band is the whole point. The relay is the party being audited, so
* In-band is the whole 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 —
* relay answer each client with the branch that client already believes,
* which is exactly the attack this mechanism exists to catch.
*/
@Test
@@ -146,7 +146,7 @@ class GossipHeadSentTest {
alice(pinned = true).send(toUserId = 42, text = "an ordinary message")
val payloads = postedPayloads()
// ⚠️ Not vacuous: an empty list passed every assertion below while the
// Not vacuous: an empty list passed every assertion below while the
// path filter was wrong, which is how a test that checks nothing looks
// exactly like a test that checks everything.
assertEquals(2, payloads.size, "expected the head and the message")
@@ -158,7 +158,7 @@ class GossipHeadSentTest {
}
}
/** Once per contact per session — not attached to every message. */
/** Once per contact per session, not attached to every message. */
@Test
fun `the head is not repeated on later messages`() {
noKeyLog()
@@ -20,9 +20,9 @@ import kotlin.test.assertTrue
/**
* Group names, after the relay stopped holding them.
*
* 🔴 **This was the largest single thing the server knew about anybody.** Group
* membership it needs, to route. The NAME it never needed and never read — no
* push, no enforcement, no lookup — and it was sitting in a column in the
* This was the largest single thing the server knew about anybody. Group
* membership it needs, to route. The NAME it never needed and never read, no
* push, no enforcement, no lookup, and it was sitting in a column in the
* clear: "Chemotherapy support", "Union organising", a sentence about somebody
* on a machine that can be dumped, seized or subpoenaed.
*
@@ -60,7 +60,7 @@ class GroupNameTest {
// -- What the relay is told -----------------------------------------------
/**
* 🔴 **Absent, not blank.** A request that carried an empty `name` would
* Absent, not blank. A request that carried an empty `name` would
* still be a request in which the client had decided to trust the relay
* with the field; this asserts the field is not there at all, so there is
* no version of the server that could start writing it down again without
@@ -82,7 +82,7 @@ class GroupNameTest {
// -- What the members are told --------------------------------------------
/**
* ⚠️ Every group envelope repeats the name. The announcement sent at
* Every group envelope repeats the name. The announcement sent at
* creation only reaches who was online and only the devices that existed
* then; a phone set up later would otherwise sit on the relay's placeholder
* for ever, because there is nobody left to ask.
@@ -103,12 +103,12 @@ class GroupNameTest {
}
/**
* 🔴 **The announcement rides `delivery_key` precisely because that kind is
* **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
* delivery-key message — the half a v5 phone acts on — with the name as
* delivery-key message, the half a v5 phone acts on, with the name as
* extra fields it will ignore.
*/
@Test
@@ -136,11 +136,11 @@ class GroupNameTest {
// -- What is believed ------------------------------------------------------
/**
* 🔴 **A claim, not a fact, and this layer must not adopt it.** Anyone who
* A claim, not a fact, and this layer must not adopt it. Anyone who
* can reach us can send one of these naming any gid they like. Adopting on
* the sender's word would put whatever a stranger wants into somebody's
* chat list, under a group heading the reader has no reason to doubt. The
* roster lives a layer up, so the check does too — `Session.drainGroupNames`
* roster lives a layer up, so the check does too, `Session.drainGroupNames`
* is what turns a claim into a name.
*/
@Test
@@ -157,7 +157,7 @@ class GroupNameTest {
conversations.takeClaimedGroupNames())
}
/** ⚠️ Drained, so the same claim is not re-checked on every poll. */
/** Drained, so the same claim is not re-checked on every poll. */
@Test
fun `claims are taken once`() {
val conversations = conversations(vault())
@@ -169,7 +169,7 @@ class GroupNameTest {
}
/**
* 🔴 The batch and the key still have to survive an envelope that also
* The batch and the key still have to survive an envelope that also
* carries a group name. They are independent halves of the same message and
* a name must not be able to take either of them down with it.
*/
@@ -190,7 +190,7 @@ class GroupNameTest {
// -- Renaming --------------------------------------------------------------
/**
* 🔴 **Renaming is how a group gets its name back.** Every group that
* Renaming is how a group gets its name back. Every group that
* existed before the relay dropped its names reads as the placeholder on
* every device, and nothing can recover the old one: the relay deleted its
* copy and no client had ever stored one. Names were previously fixed at
@@ -206,7 +206,7 @@ class GroupNameTest {
vault.saveGroupName("g1", "Book club, Tuesdays")
assertEquals("Book club, Tuesdays", vault.groupName("g1"))
// ⚠️ Asserted on the envelope rather than on delivery: the announcement
// Asserted on the envelope rather than on delivery: the announcement
// is best-effort per member, and a test that required a member to be
// reachable would be testing the fixture's relay, not the rename.
val wire = SealedSender.deliveryKeyEnvelope(
@@ -216,7 +216,7 @@ class GroupNameTest {
}
/**
* ⚠️ **An announcement with no name is not sent at all.** A group we have
* An announcement with no name is not sent at all. A group we have
* never been told the name of has nothing to announce, and an envelope
* carrying an empty `gname` would let a member overwrite a good name with
* a blank one.
@@ -234,7 +234,7 @@ class GroupNameTest {
}
/**
* ⚠️ **The sender decides how many claims to send, so the queue is capped.**
* The sender decides how many claims to send, so the queue is capped.
* It is drained by a layer that may not run on every path, and an unbounded
* list whose growth a stranger controls is a slow leak with someone else's
* hand on the tap.
@@ -257,11 +257,11 @@ class GroupNameTest {
// -- Surviving a reinstall -------------------------------------------------
/**
* 🔴 **A restored backup must bring the group names with it.** The relay
* A restored backup must bring the group names with it. The relay
* used to answer with the name, so a reinstalled device got it back for
* free; it holds none now, which makes the device's copy the only copy. An
* export carrying the messages but not the names restores a chat list of
* groups all called "Group" — the exact loss the export exists to stop.
* groups all called "Group", the exact loss the export exists to stop.
*/
@Test
fun `an exported backup carries group names into a fresh device`() {
@@ -277,7 +277,7 @@ class GroupNameTest {
}
/**
* ⚠️ A backup is older than the device by definition. A rename made since
* A backup is older than the device by definition. A rename made since
* the export must not be undone by restoring it.
*/
@Test
@@ -294,9 +294,9 @@ class GroupNameTest {
}
/**
* 🔴 **`Received` carries whether the sender was proven, because a caller
* **`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 —
* 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.
* `Session.receive` gates group-name adoption on this.
@@ -315,12 +315,12 @@ class GroupNameTest {
}
/**
* 🔴 **The announcement goes to our own account too, and that is the point.**
* The announcement goes to our own account too, and that is the point.
* 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.
*
* ⚠️ Safe only because the fan-out loop already skips our own device
* Safe only because the fan-out loop already skips our own device
* ("never talk to ourselves"). This asserts the recipient list, which is
* the half this file owns; the device-level skip has its own guard in the
* send path.
@@ -357,9 +357,9 @@ class GroupNameTest {
// -- Sealed group invitations (§8.9b) --------------------------------------
/**
* 🔴 **The invitation is the only way a member ever gets in.** The relay
* The invitation is the only way a member ever gets in. The relay
* holds neither the tag nor the group key, by construction, so somebody who
* is not told cannot fetch the group at all — there is nobody left to ask.
* is not told cannot fetch the group at all, there is nobody left to ask.
*/
@Test
fun `an invitation carries the tag and the group key`() {
@@ -379,15 +379,15 @@ class GroupNameTest {
}
/**
* ⚠️ A group key that is not 32 bytes is not a group key. Storing one would
* A group key that is not 32 bytes is not a group key. Storing one would
* make every roster for that group fail to open, which reads as a group
* with no members rather than as a bad invitation.
*/
@Test
fun `an invitation with a malformed key is ignored`() {
val conversations = conversations(vault())
// ⚠️ Built by hand rather than through `sealedGroupEnvelope`, which
// only ever emits a well-formed key — the shape under test is one a
// Built by hand rather than through `sealedGroupEnvelope`, which
// only ever emits a well-formed key, the shape under test is one a
// hostile or broken sender produces, not one this engine can make.
fun b64(b: ByteArray) = java.util.Base64.getEncoder().encodeToString(b)
val wire = """{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" +
@@ -399,7 +399,7 @@ class GroupNameTest {
assertTrue(conversations.takeGroupInvitations().isEmpty())
}
/** ⚠️ Drained once, so a poll does not re-apply the same invitation. */
/** Drained once, so a poll does not re-apply the same invitation. */
@Test
fun `invitations are taken once`() {
val conversations = conversations(vault())
@@ -431,7 +431,7 @@ class GroupNameTest {
assertNull(vault.groupName("g1"))
}
/** ⚠️ A peer chooses this string, so it is bounded before it is stored. */
/** A peer chooses this string, so it is bounded before it is stored. */
@Test
fun `a name is capped`() {
val vault = vault()
@@ -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 *genuinely 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
@@ -22,7 +22,7 @@ import org.json.JSONObject
*
* That is precisely the attack multi-homing exists to survive, and the serial
* is what was added to stop it. The relay refuses a lower serial on WRITE;
* until this, nothing refused one on READ — on either platform.
* until this, nothing refused one on READ, on either platform.
*/
class HomeRecordRollbackTest {
@@ -45,7 +45,7 @@ class HomeRecordRollbackTest {
// ---- The attack ------------------------------------------------------
/**
* 🔴 The whole finding, in one assertion. Both records verify; only the
* The whole finding, in one assertion. Both records verify; only the
* serial separates them.
*/
@Test
@@ -56,7 +56,7 @@ class HomeRecordRollbackTest {
assertNull(
AccountIdentity.verifiedRelays(old, floor = 4),
"a relay serving a genuinely-signed OLDER record was believed — " +
"a relay serving a genuinely-signed OLDER record was believed, " +
"which is exactly how one relay makes itself somebody's only mailbox",
)
}
@@ -71,7 +71,7 @@ class HomeRecordRollbackTest {
}
/**
* ⚠️ Equal is fine. Re-reading the same record is the ordinary case, and
* Equal is fine. Re-reading the same record is the ordinary case, and
* refusing it would break every second lookup.
*/
@Test
@@ -89,10 +89,10 @@ class HomeRecordRollbackTest {
// ---- The floor cannot be poisoned ------------------------------------
/**
* 🔴 **A serial is only reported for a record that VERIFIES.** Otherwise a
* 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
* genuine record for ever, turning the rollback defence into a denial of
* service against the account holder.
*/
@Test
@@ -121,8 +121,8 @@ class HomeRecordRollbackTest {
}
/**
* ⚠️ The relay list is part of what is signed, so swapping it invalidates
* the record — a relay cannot keep a valid signature and quietly drop the
* The relay list is part of what is signed, so swapping it invalidates
* the record, a relay cannot keep a valid signature and quietly drop the
* other homes out of the list.
*/
@Test
@@ -12,11 +12,11 @@ import kotlin.test.assertTrue
/**
* Two accounts, one relay, a real conversation.
*
* This is the test the whole stack exists to pass. Everything below it —
* X3DH, the ratchet, the vault, the transport — has been proven in isolation;
* This is the test the whole stack exists to pass. Everything below it,
* X3DH, the ratchet, the vault, the transport, has been proven in isolation;
* this is the first thing that proves they were assembled correctly.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, and pointed at the throwaway fleet. It creates
* Opt-in via `FN_LIVE_FLEET`, and pointed at the throwaway fleet. It creates
* accounts and sends mail.
*
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
@@ -27,7 +27,7 @@ class LiveConversationTest {
/**
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
* Skips are announced, not silent. `return` from an unmet
* precondition makes a test that did nothing report as PASSED, and a suite
* of those is indistinguishable from a suite that works. `assumeTrue`
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
@@ -37,14 +37,14 @@ class LiveConversationTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(fleet)
assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 The passphrase comes from the environment with no fallback. This
// file carried a hard-coded literal — a published password, used to
// create REAL accounts — and `TestSafetyTest` could
// The passphrase comes from the environment with no fallback. This
// file carried a hard-coded literal, a published password, used to
// create REAL accounts, and `TestSafetyTest` could
// not see it, because its check matched an ASSIGNMENT and this was an
// ARGUMENT. Both are fixed together.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — these tests create real accounts and this " +
"set FN_LIVE_PASSPHRASE, these tests create real accounts and this " +
"suite will not sign up with a value written into the source",
)
}
@@ -98,12 +98,12 @@ class LiveConversationTest {
/**
* What a person would see, which is what every assertion below means.
*
* 🔴 **`poll` returns control traffic too**, and since key-log checking
* `poll` returns control traffic too, and since key-log checking
* became automatic there is reliably some: the first send to a contact now
* gossips this device's view of the transparency log (§19.4), because
* enforcement sets the head pin that gossip had nothing to send without.
* That envelope is consumed by the client and never shown, and asserting on
* the raw list counted it as a message — so these tests failed on a change
* the raw list counted it as a message, so these tests failed on a change
* that made the product more correct, not less.
*
* The app has always filtered this way; the tests were reading a layer
@@ -148,9 +148,9 @@ class LiveConversationTest {
}
/**
* ⚠️ The watermark must not re-deliver. A client that re-reads its inbox
* The watermark must not re-deliver. A client that re-reads its inbox
* from zero shows every message again on every poll, and the second
* decryption fails because the ratchet has moved — so the user sees their
* decryption fails because the ratchet has moved, so the user sees their
* history replaced by errors.
*/
@Test
@@ -168,7 +168,7 @@ class LiveConversationTest {
/**
* A person signed in on two devices must receive on both.
*
* ⚠️ This is the failure that looks fine in every test where everyone has
* This is the failure that looks fine in every test where everyone has
* one device: encrypting to "the" recipient device delivers to whichever
* came back first and silently drops the rest.
*/
@@ -199,7 +199,7 @@ class LiveConversationTest {
/**
* The relay stores ciphertext and nothing else.
*
* ⚠️ Worth asserting rather than assuming: the entire claim this product
* Worth asserting rather than assuming: the entire claim this product
* makes is that the operator cannot read the mail, and the cheapest way to
* break it is a client that sends a plaintext field "just for the preview".
*/
@@ -224,7 +224,7 @@ class LiveConversationTest {
/**
* An image, end to end.
*
* ⚠️ This is the exact path that was **401 on iOS for months**: the
* This is the exact path that was 401 on iOS for months: the
* attachment routes were sending the account password, and the relay
* refuses that on `/e2e/…`. The upload here goes through the same
* device-scoped token and per-request signature as everything else, and
@@ -264,8 +264,8 @@ class LiveConversationTest {
/**
* A group, against the real relay.
*
* ⚠️ This is the shape the iOS client got wrong: it required a `name` on
* each member, and the relay has **never** sent one — it has no user table
* This is the shape the iOS client got wrong: it required a `name` on
* each member, and the relay has never sent one, it has no user table
* 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
@@ -281,10 +281,10 @@ class LiveConversationTest {
val group = alice.relay.createGroup(listOf(bob.userId, carol.userId))
assertTrue(group.id.isNotEmpty(), "no group id came back")
// 🔴 **The relay does not know what the group is called**, and this
// The relay does not know what the group is called, and this
// asserts it against a live one. It used to store the name in the clear
// in a column it never read — the single most revealing string it held
// — and now answers with the same generic placeholder for every group.
// in a column it never read, the single most revealing string it held
// and now answers with the same generic placeholder for every group.
// The real name travels between members inside their ratchets.
assertEquals("Group", group.name,
"the relay is serving a real group name again")
@@ -297,8 +297,8 @@ class LiveConversationTest {
assertTrue(alice.relay.listGroups().any { it.id == group.id },
"the group did not appear in the list")
// ⚠️ The relay does no fan-out. A group message is still pairwise
// encryption, one ratchet per recipient device — that is the honest
// The relay does no fan-out. A group message is still pairwise
// encryption, one ratchet per recipient device, that is the honest
// cost of the relay not being able to read anything.
val sent = alice.conversations.sendToGroup(group, "hello everyone", alice.userId)
assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}")
@@ -19,20 +19,20 @@ import org.junit.jupiter.api.Assumptions.assumeTrue
* A document leaves one person's vault and arrives in another's, through a real
* relay.
*
* 🔴 **This is the seam nothing else covers.** `FileShareWireTest` proves the
* This is the seam nothing else covers. `FileShareWireTest` proves the
* envelope's shape, `LocalVaultTest` proves the vault at both ends, and neither
* of them puts a byte on a wire. What is only provable here is that the label
* survives an actual upload, an actual envelope, an actual poll and an actual
* save — the four steps between two vaults that each look right on their own.
* save, the four steps between two vaults that each look right on their own.
*
* ⚠️ **The classification is the only thing that survives the trip.** The
* The classification is the only thing that survives the trip. The
* receiving phone files the document at whatever number arrives, because there
* is nothing else to consult and no server to ask. A label lost in transit does
* not error anywhere: the file simply arrives Unclassified and gets filed that
* way, on the most permissive setting it could possibly have. That is what this
* test is really watching for.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, and pointed at a throwaway relay. It creates
* Opt-in via `FN_LIVE_FLEET`, and pointed at a throwaway relay. It creates
* accounts and uploads blobs.
*
* FN_LIVE_FLEET=http://127.0.0.1:8080 ./gradlew :client:test
@@ -42,8 +42,8 @@ class LiveFileTransferTest {
private val fleet: String? = System.getenv("FN_LIVE_FLEET")
/**
* ⚠️ Announced, not silent. A `return` from an unmet precondition makes a
* test that did nothing report as PASSED — see the note in
* Announced, not silent. A `return` from an unmet precondition makes a
* test that did nothing report as PASSED, see the note in
* `LiveConversationTest`, which is where that lesson was paid for.
*/
private fun needFnLiveFleet() {
@@ -51,7 +51,7 @@ class LiveFileTransferTest {
assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay")
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — these tests create real accounts and this " +
"set FN_LIVE_PASSPHRASE, these tests create real accounts and this " +
"suite will not sign up with a value written into the source",
)
}
@@ -102,7 +102,7 @@ class LiveFileTransferTest {
vault.saveIdentity(identity)
// A real Files identity, so the label keys are the ones the app derives
// — 1,000 rounds rather than 650,000 because this asserts the format,
// 1,000 rounds rather than 650,000 because this asserts the format,
// not the work factor.
val raw = FilesIdentity.rawFromPkcs8(io.flamenet.e2e.X25519.generate().private.encoded)
val root = File.createTempFile("livevault$tag", "").let { it.delete(); it.mkdirs(); it }
@@ -154,17 +154,17 @@ class LiveFileTransferTest {
assertEquals(Attachment.FILE, ref.kind)
assertEquals("Q3 Forecast.txt", ref.filename, "the document's own name was lost")
// 🔴 The assertion this test exists for.
// The assertion this test exists for.
assertEquals(3, ref.classification,
"the classification was lost in transit — the file would be filed Unclassified")
"the classification was lost in transit, the file would be filed Unclassified")
assertEquals(listOf(1, 2), ref.compartments.sorted(),
"the compartments were lost — the file would be filed outside its areas")
"the compartments were lost, the file would be filed outside its areas")
// And the bytes come back off the relay.
val fetched = bob.conversations.fetchAttachment(ref)
assertContentEquals(bytes, fetched, "the bytes did not survive the round trip")
// Bob saves it, at the label it arrived with — never one his phone chose.
// Bob saves it, at the label it arrived with, never one his phone chose.
val saved = bob.files.add(
fetched, ref.filename,
Label(ref.classification.toShort(), ref.compartments.toSet()),
@@ -175,13 +175,13 @@ class LiveFileTransferTest {
"the document was filed under a different label than it was sent at")
assertContentEquals(bytes, bob.files.contents(saved))
// 🔴 **Two vaults, two identities, two different keys for one label.**
// Two vaults, two identities, two different keys for one label.
// The label key is derived from the identity, so the same
// classification does NOT mean the same key — which is what stops a
// classification does NOT mean the same key, which is what stops a
// stolen container from one phone opening anything on the other.
assertTrue(
!alice.files.labelKey(label).contentEquals(bob.files.labelKey(label)),
"two people derived the same key for one label — a stolen vault would open both",
"two people derived the same key for one label, a stolen vault would open both",
)
alice.root.deleteRecursively()
@@ -189,10 +189,10 @@ class LiveFileTransferTest {
}
/**
* ⚠️ An Unclassified document must arrive as one, and be distinguishable
* An Unclassified document must arrive as one, and be distinguishable
* from a label that went missing. Zero is the default for an absent field,
* so this is the case where a dropped label and a deliberate zero look
* identical at the far end — the filename and the kind are what say the
* identical at the far end, the filename and the kind are what say the
* envelope was understood.
*/
@Test
@@ -13,7 +13,7 @@ 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
* **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.
*
@@ -21,7 +21,7 @@ import kotlin.test.assertTrue
*
* The unit tests drive a MockWebServer with responses this repository wrote,
* which proves the verifier agrees with our idea of the relay. This proves it
* agrees with the relay — a distinction that has already cost this protocol
* agrees with the relay, a distinction that has already cost this protocol
* three silent wire-shape bugs, every one of them invisible to a fake.
*/
class LiveKeyLogTest {
@@ -30,7 +30,7 @@ class LiveKeyLogTest {
/**
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
* Skips are announced, not silent. `return` from an unmet
* precondition makes a test that did nothing report as PASSED, and a suite
* of those is indistinguishable from a suite that works. `assumeTrue`
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
@@ -40,12 +40,12 @@ class LiveKeyLogTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(base)
assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts — a published password, since anybody who can
// No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts, a published password, since anybody who can
// read this repository can read it.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this suite creates real accounts and will " +
"set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " +
"not sign up with a value written into the source",
)
}
@@ -91,7 +91,7 @@ class LiveKeyLogTest {
// 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
// relay can prove it — and if this throws, enforcement is refusing
// 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 }
val trust = alice.trust()
@@ -103,11 +103,11 @@ class LiveKeyLogTest {
}
/**
* 🔴 The check has to refuse something, or it is decorative.
* The check has to refuse something, or it is decorative.
*
* A device id that exists with an identity key the relay never logged for
* it. Nothing in the tree hashes to the leaf this bundle implies, so no
* proof can be produced — which is exactly the position a relay handing
* proof can be produced, which is exactly the position a relay handing
* out a substituted key is in.
*/
@Test
@@ -138,7 +138,7 @@ class LiveKeyLogTest {
/**
* The log grows constantly on a shared fleet, so a second check has to
* reconcile a pin against a bigger tree — the consistency path, against
* reconcile a pin against a bigger tree, the consistency path, against
* proofs the relay generated rather than ones this repository wrote.
*/
@Test
@@ -166,7 +166,7 @@ class LiveKeyLogTest {
}
/**
* 🔴 A pin the relay cannot reconcile must be a fork, against the real log.
* A pin the relay cannot reconcile must be a fork, against the real log.
*
* Same size, different root: a relay that served two trees. There is no
* proof that makes these agree and there never can be.
@@ -190,12 +190,12 @@ class LiveKeyLogTest {
}
/**
* 🔴 An account with a global identity, verified through the pinned address.
* An account with a global identity, verified through the pinned address.
*
* The relay files a leaf under an account's **fnid** once it has one, and
* 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 bytes, so a verifier that does not know the address cannot
* recompute the leaf — and before contacts carried one, an honest key from
* recompute the leaf, and before contacts carried one, an honest key from
* such an account was refused. That is a false positive on a real
* conversation, which is how a security setting gets switched off.
*
@@ -229,13 +229,13 @@ class LiveKeyLogTest {
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == second.deviceId }
val trust = alice.trust()
// 1. Nothing pinned: the honest key is refused, and says why — there
// 1. Nothing pinned: the honest key is refused, and says why, there
// ARE publications for this device, just none we can recompute.
val refusal = assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLog(bundle, bob.userId) }
assertTrue(refusal.hadEntries, "an address miss must not read as 'never published'")
// 2. The contact learns Bob's address from his handle, the way the
// buddy list does, and the very same key now verifies — with no
// buddy list does, and the very same key now verifies, with no
// fnid passed at the call site, because it comes from the pin.
trust.rememberIdentity(bob.userId, bob.account.username)
assertEquals(fnid, trust.pinnedFnid(bob.userId))
@@ -252,7 +252,7 @@ class LiveKeyLogTest {
* A changed address is a changed identity, against the real relay.
*
* An fnid is derived from the account key, so a different one is a
* different account key — not a new device, whatever it looks like.
* different account key, not a new device, whatever it looks like.
*/
@Test
fun `a peer whose address changes is reported as an identity change`() {
@@ -8,17 +8,17 @@ import kotlin.test.assertTrue
/**
* The client against a real relay, not a MockWebServer.
*
* ⚠️ **Opt-in, and silent when off.** Set `FN_LIVE_RELAY` to a relay's base URL
* Opt-in, and silent when off. Set `FN_LIVE_RELAY` to a relay's base URL
* to run it. A test that reaches the network cannot be part of a hermetic suite
* — it fails on an aeroplane and on a build machine with no egress, and a suite
* it fails on an aeroplane and on a build machine with no egress, and a suite
* that is red for reasons unrelated to the code stops being read.
*
* FN_LIVE_RELAY=https://relay.flamenet.io ./gradlew :client:test
*
* What it checks is the one thing MockWebServer structurally cannot: that the
* URL this client builds is a URL the deployed relay actually serves. The
* transparency-log routes are unauthenticated by design — anyone may audit the
* log, including someone who is not a user — so this needs no credentials.
* transparency-log routes are unauthenticated by design, anyone may audit the
* log, including someone who is not a user, so this needs no credentials.
*/
class LiveRelayTest {
@@ -26,7 +26,7 @@ class LiveRelayTest {
/**
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
* Skips are announced, not silent. `return` from an unmet
* precondition makes a test that did nothing report as PASSED, and a suite
* of those is indistinguishable from a suite that works. `assumeTrue`
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
@@ -45,7 +45,7 @@ class LiveRelayTest {
val head = RelayClient(base, username = "", appPassword = "").logHead()
// `audience` is the hostname the relay signs under, and a mismatch
// rejects every token with no other symptom — so it is worth asserting
// rejects every token with no other symptom, so it is worth asserting
// that the relay agrees with the address we reached it at.
assertEquals(java.net.URI(base).host, head.getString("audience"))
assertTrue(head.getInt("size") >= 0)
@@ -14,13 +14,13 @@ import kotlin.test.assertTrue
/**
* Sealed sender (§14) against a relay that is actually running.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, pointed at the disposable fleet.
* Opt-in via `FN_LIVE_FLEET`, pointed at the disposable fleet.
*
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
*
* The engine tests prove the seal is sound. What only a real relay can show is
* that the relay ACCEPTS an unauthenticated sealed submission, that it stores
* no sender against it, and that the recipient still gets the message — which
* no sender against it, and that the recipient still gets the message, which
* is the entire claim the feature makes.
*/
class LiveSealedSenderTest {
@@ -29,7 +29,7 @@ class LiveSealedSenderTest {
/**
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
* Skips are announced, not silent. `return` from an unmet
* precondition makes a test that did nothing report as PASSED, and a suite
* of those is indistinguishable from a suite that works. `assumeTrue`
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
@@ -39,12 +39,12 @@ class LiveSealedSenderTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(base)
assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts — a published password, since anybody who can
// No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts, a published password, since anybody who can
// read this repository can read it.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this suite creates real accounts and will " +
"set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " +
"not sign up with a value written into the source",
)
}
@@ -80,7 +80,7 @@ class LiveSealedSenderTest {
}
/**
* 🔴 The claim, end to end: the relay carries the message and does not
* The claim, end to end: the relay carries the message and does not
* learn who sent it.
*/
@Test
@@ -93,7 +93,7 @@ class LiveSealedSenderTest {
bob.conversations.ensureDeliveryKey()
alice.conversations.ensureDeliveryKey()
// First contact is necessarily UNSEALED — Alice does not hold Bob's
// First contact is necessarily UNSEALED. Alice does not hold Bob's
// delivery key yet, and the key arrives inside an E2E message. This is
// the honest shape of the feature, not a gap: sealing needs a prior
// exchange, and a client that claimed otherwise would be lying.
@@ -101,7 +101,7 @@ class LiveSealedSenderTest {
assertTrue(first.reachedSomeone)
assertFalse(first.sealed, "the first message to a new contact cannot be sealed")
// Bob reads it and, with it, learns nothing yet — Alice has not been
// Bob reads it and, with it, learns nothing yet. Alice has not been
// told Bob's key. Bob replying is what hands Alice his key.
val bobRead = bob.conversations.poll()
assertTrue(bobRead.any { it.content.text == "hello, unsealed" })
@@ -117,8 +117,8 @@ class LiveSealedSenderTest {
assertTrue(sealed.sealed, "Alice holds Bob's delivery key and must seal")
assertTrue(sealed.reachedSomeone)
// 🔴 The relay stored no sender for it. `from_user` 0 is the relay
// saying it does not know — which is the whole feature.
// The relay stored no sender for it. `from_user` 0 is the relay
// saying it does not know, which is the whole feature.
val inbox = bob.relay.pollInbox(bob.identity.deviceId, 0, 100)
val sealedRow = inbox.last()
assertEquals(0L, sealedRow.fromUser, "the relay must not know who sent a sealed message")
@@ -12,24 +12,24 @@ 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
* 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
* - Managed (`relay.flamenet.io`), accounts in the shared FlameNet
* service. One credential across Messenger and Files.
* - **Self-hosted, shared identity** (`relay2`) — somebody else's relay, still
* the shared account service. This shape was **unreachable** until the
* - Self-hosted, shared identity (`relay2`), somebody else's relay, still
* the shared account service. This shape was unreachable until the
* client started reading `/.well-known/flamenet/relay`: the old hardcoded
* hostname list could never contain a stranger's relay.
* - **Self-hosted, local** (`relay1`, `relay3`) — the relay is its own
* - Self-hosted, local (`relay1`, `relay3`), the relay is its own
* identity provider. The default, and not a lesser mode.
*
* ⚠️ **Every round uses fresh handles.** Reusing one makes round two fail with
* "that name is taken" — a relay answering correctly, which reads as a product
* Every round uses fresh handles. Reusing one makes round two fail with
* "that name is taken", a relay answering correctly, which reads as a product
* bug. Rounds are therefore repeatable by construction rather than by cleanup.
*
* ⚠️ Managed rounds create real accounts on `accounts.flamenet.io`. Enabled
* Managed rounds create real accounts on `accounts.flamenet.io`. Enabled
* explicitly by `FN_LIVE_MANAGED=1`, because that is not litter to leave by
* accident.
*
@@ -39,7 +39,7 @@ import kotlin.test.assertTrue
class LiveShapesTest {
/**
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
* Skips are announced, not silent. `return` from an unmet
* precondition makes a test that did nothing report as PASSED, and a suite
* of those is indistinguishable from a suite that works. `assumeTrue`
* reports SKIPPED, which is the truth.
@@ -53,10 +53,10 @@ class LiveShapesTest {
private fun needManaged() {
assumeTrue(
System.getenv("FN_LIVE_MANAGED") == "1",
"set FN_LIVE_MANAGED=1 — these create real accounts on accounts.flamenet.io")
"set FN_LIVE_MANAGED=1, these create real accounts on accounts.flamenet.io")
assumeTrue(
PASSPHRASE.isNotEmpty(),
"set FN_LIVE_PASSPHRASE — these create real accounts and will not use " +
"set FN_LIVE_PASSPHRASE, these create real accounts and will not use " +
"a passphrase written into the repository")
}
@@ -79,7 +79,7 @@ class LiveShapesTest {
/**
* Register against the shared account service, pacing for its rate limit.
*
* ⚠️ The throttle is the service working correctly, not a client failure —
* The throttle is the service working correctly, not a client failure,
* three rounds across two shapes is a dozen registrations in a burst, and
* any sane account service refuses that. So this backs off and retries
* rather than reporting a product bug, and still FAILS if the service never
@@ -94,14 +94,14 @@ class LiveShapesTest {
return
} catch (e: AccountsClient.Failure.RateLimited) {
if (attempt == 2) {
// ⚠️ **Not a product failure, and not something to retry
// **Not a product failure, and not something to retry
// through.** The service allows three registrations per
// address per HOUR, deliberately — a short backoff cannot
// 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.
throw AssertionError(
"the account service's registration budget is spent: it allows 3 per " +
"address per hour and this run needed more. Not a defect — wait for " +
"address per hour and this run needed more. Not a defect, wait for " +
"the window to roll over, or run one shape at a time.")
}
Thread.sleep(wait)
@@ -135,9 +135,9 @@ class LiveShapesTest {
}
/**
* ⚠️ **Our relays cannot be made to redirect their users.** Whatever
* Our relays cannot be made to redirect their users. Whatever
* `relay.flamenet.io` advertises, the client authenticates against the
* service pinned in the app — so compromising one of ours does not let it
* service pinned in the app, so compromising one of ours does not let it
* harvest passphrase-derived secrets.
*/
@Test
@@ -154,7 +154,7 @@ class LiveShapesTest {
}
/**
* ⚠️ And a relay that is NOT ours IS believed — deliberately. A self-hoster
* And a relay that is NOT ours IS believed, deliberately. A self-hoster
* running Messenger and Files together needs one account across both, held
* by their own service. Refusing here would make "run both" mean "run them
* with two separate logins". The user typed that hostname; it cannot
@@ -182,12 +182,12 @@ class LiveShapesTest {
}
/**
* ⚠️ **One registration, then three full rounds on it.**
* One registration, then three full rounds on it.
*
* The account service allows **three registrations per address per hour**,
* The account service allows three registrations per address per hour,
* on purpose: forty sign-in attempts is a person with a password manager
* and a bad afternoon, forty accounts is a flood. Three sign-UPS per shape
* would consume an hour's budget for one shape and fail the next — so a
* would consume an hour's budget for one shape and fail the next, so a
* test written that way measures the rate limiter, not the product.
*
* So sign-up is proven once, and the repeatable part is the flow a person
@@ -222,7 +222,7 @@ class LiveShapesTest {
val account = RelayAccounts(base).sharedSession(session.token, name)
assertTrue(account.appPassword.isNotEmpty(), "round $round: no relay credential")
// ⚠️ Every round must land on the SAME relay account. The relay
// Every round must land on the SAME relay account. The relay
// keys on the product-scoped pseudonym, not the handle; keying on
// the handle would make each round a new account and nobody would
// notice until messages stopped arriving.
@@ -240,7 +240,7 @@ class LiveShapesTest {
* A conversation on every shape, so "it works" is not a claim about one
* relay's configuration.
*
* ⚠️ Deliberately three separate conversations rather than one crossing
* Deliberately three separate conversations rather than one crossing
* relays. Cross-relay delivery is federation, which is a different
* mechanism with a different failure mode; conflating them would let a
* federation problem masquerade as a messaging problem.
@@ -252,13 +252,13 @@ class LiveShapesTest {
}
/**
* ⚠️ Split per shape rather than one test covering all three. The shared
* shapes go through the account service, which rate-limits — correctly —
* Split per shape rather than one test covering all three. The shared
* shapes go through the account service, which rate-limits, correctly,
* and a single combined test lets one shape's throttle hide another
* shape's result.
*/
/**
* ⚠️ A conversation on a shared-identity relay needs TWO accounts, and the
* A conversation on a shared-identity relay needs TWO accounts, and the
* account service allows three registrations an hour. Enabled separately
* from the round tests so a single run cannot silently consume the whole
* budget and leave the next test looking broken.
@@ -278,12 +278,12 @@ class LiveShapesTest {
/**
* The account service permits three registrations per address per hour.
* These tests each need two, so they are opt-in on top of `FN_LIVE_MANAGED`
* — running everything at once cannot fit, and a suite that cannot fit its
* running everything at once cannot fit, and a suite that cannot fit its
* own limits reports failures that are really scheduling.
*/
private fun needBudget() = assumeTrue(
System.getenv("FN_LIVE_SIGNUP_BUDGET") == "1",
"set FN_LIVE_SIGNUP_BUDGET=1 — needs 2 of the 3 registrations allowed per hour")
"set FN_LIVE_SIGNUP_BUDGET=1, needs 2 of the 3 registrations allowed per hour")
private fun talk(base: String, make: (String) -> RelayAccounts.Account) {
val alice = ready(base, make("ta"))
@@ -293,11 +293,11 @@ class LiveShapesTest {
val sent = alice.second.send(bob.first, text)
assertTrue(sent.reachedSomeone, "$base delivered nothing: ${sent.failed}")
// 🔴 What a PERSON would see. `poll` also returns control traffic, and
// What a PERSON would see. `poll` also returns control traffic, and
// since key-log checking became automatic there is reliably some: the
// first send to a contact gossips this device's view of the
// transparency log (§19.4), which enforcement is what sets the pin for.
// The client consumes that envelope and never shows it — counting it as
// The client consumes that envelope and never shows it, counting it as
// a message made this test fail on a change that made the product more
// correct.
val got = bob.second.poll().filter { it.content.isRenderable }
@@ -366,7 +366,7 @@ class LiveShapesTest {
assertTrue(account.appPassword.isNotEmpty())
registerAndProve(base, account)
// ⚠️ A second sign-in must land on the SAME relay account. The relay
// A second sign-in must land on the SAME relay account. The relay
// keys on the product-scoped pseudonym, not the handle; if it keyed on
// the handle, signing in twice would either fail or make a duplicate.
val second = RelayAccounts(base)
@@ -396,7 +396,7 @@ class LiveShapesTest {
private companion object {
/**
* 🔴 Was `"a-long-enough-passphrase"`, a literal in a source file used
* Was `"a-long-enough-passphrase"`, a literal in a source file used
* to create real accounts. See [LiveTarget.passphrase].
*/
val PASSPHRASE: String get() = LiveTarget.passphrase()
@@ -9,7 +9,7 @@ import kotlin.test.assertTrue
/**
* The whole sign-up-and-register path against a throwaway relay.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
* Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
* than production. It creates accounts, which is exactly what a disposable
* relay is for and exactly what `relay.flamenet.io` is not.
*
@@ -24,7 +24,7 @@ class LiveSignUpTest {
/**
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
* Skips are announced, not silent. `return` from an unmet
* precondition makes a test that did nothing report as PASSED, and a suite
* of those is indistinguishable from a suite that works. `assumeTrue`
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
@@ -34,12 +34,12 @@ class LiveSignUpTest {
private fun needFnLiveFleet() {
LiveTarget.refuseProduction(relay)
assumeTrue(relay != null, "set FN_LIVE_FLEET to run this against a relay")
// 🔴 No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts — a published password, since anybody who can
// No fallback. This suite carried a literal passphrase and used it
// to create REAL accounts, a published password, since anybody who can
// read this repository can read it.
assumeTrue(
LiveTarget.passphrase().isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this suite creates real accounts and will " +
"set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " +
"not sign up with a value written into the source",
)
}
@@ -66,7 +66,7 @@ class LiveSignUpTest {
client.registerDevice(identity, preKeys)
// The device is only really registered if a stranger can fetch a bundle
// for it — that is what makes this phone reachable.
// for it, that is what makes this phone reachable.
val bundles = client.fetchKeys(account.userId).bundles
assertTrue(bundles.any { it.deviceId == identity.deviceId },
"the relay did not serve a bundle for the device we just registered")
@@ -5,10 +5,10 @@ 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
* **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.
* a real person signing up was invisible inside the burst.
*
* Every one of those suites is correctly written. They are opt-in, and their
* own headers say to point them at the disposable fleet and not at production.
@@ -16,14 +16,14 @@ import java.net.URI
* cannot refuse anything. Something ran them against production regardless, and
* nothing at any layer objected.
*
* ⚠️ **A deny list, not an allow list**, and that is a deliberate trade. An
* A deny list, not an allow list, and that is a deliberate trade. An
* allow list would be stricter, and would also refuse a developer's own
* throwaway relay on localhost or a self-hosted instance — which is a normal
* throwaway relay on localhost or a self-hosted instance, which is a normal
* thing to test against and would push people to disable the check. Naming the
* hosts that must never receive test traffic keeps the guard narrow enough to
* survive.
*
* 🔴 A refusal here is an ERROR, never a skip. "This run was pointed at
* A refusal here is an ERROR, never a skip. "This run was pointed at
* production" is not a precondition that happens to be unmet; it is a mistake
* that has already done damage on one occasion, and a skip is how it would go
* unnoticed on the next.
@@ -31,10 +31,10 @@ import java.net.URI
object LiveTarget {
/**
* 🔴 **The disposable fleet lives on the same domain as production**, so it
* The disposable fleet lives on the same domain as production, so it
* is named explicitly and checked FIRST. Without this, denying
* `flamenet.io` and its subdomains refuses `relay1.flamenet.io` — the exact
* place these suites are supposed to point — and the guard blocks every
* `flamenet.io` and its subdomains refuses `relay1.flamenet.io`, the exact
* place these suites are supposed to point, and the guard blocks every
* legitimate run while appearing to block only production. A test caught
* that before it shipped, which is the argument for testing a guard rather
* than reading it.
@@ -48,7 +48,7 @@ object LiveTarget {
/**
* Hosts that serve real accounts and real people's messages.
*
* ⚠️ The bare domain is in here on purpose: a production host added later
* The bare domain is in here on purpose: a production host added later
* is denied by default rather than needing somebody to remember this file.
* That is only safe because [DISPOSABLE] names the exceptions above.
*/
@@ -60,7 +60,7 @@ object LiveTarget {
)
/**
* Throw if [url] names production. Null is fine — that is "not configured",
* Throw if [url] names production. Null is fine, that is "not configured",
* which the caller turns into an announced skip.
*/
fun refuseProduction(url: String?) {
@@ -71,7 +71,7 @@ object LiveTarget {
// `endsWith(".$it")` so a subdomain cannot slip past a bare-host match.
if (PRODUCTION.any { host == it || host.endsWith(".$it") }) {
error(
"refusing to run a live suite against $host — that is production. " +
"refusing to run a live suite against $host, that is production. " +
"These tests create real accounts. Point FN_LIVE_FLEET at the " +
"disposable fleet (relay1.flamenet.io) or a throwaway relay.",
)
@@ -81,21 +81,21 @@ object LiveTarget {
/**
* The passphrase a live suite signs up with.
*
* 🔴 **From the environment, with no fallback, and that is the whole
* **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
* published password: every account those runs ever made can be signed into
* by anybody who can read this repository, and nothing about the accounts
* being throwaway changes that — they are real accounts on a real service,
* being throwaway changes that, they are real accounts on a real service,
* and they persist.
*
* ⚠️ Empty rather than throwing, so the caller turns it into an announced
* Empty rather than throwing, so the caller turns it into an announced
* skip. A live suite that cannot run must report SKIPPED and not fail: a
* red suite for a missing variable teaches people to ignore red.
*
* The iOS side had the same class of bug twice (ios#62) — a literal
* The iOS side had the same class of bug twice (ios#62), a literal
* fallback used against production. This is the Kotlin half of that fix.
*/
fun passphrase(): String = System.getenv("FN_LIVE_PASSPHRASE").orEmpty()
@@ -6,8 +6,8 @@ import kotlin.test.assertFailsWith
/**
* The guard that stops a live suite creating accounts on production.
*
* 🔴 This is the test that runs in every pipeline, unlike the live suites it
* protects — those skip without `FN_LIVE_FLEET`, so if the rule itself were
* This is the test that runs in every pipeline, unlike the live suites it
* protects, those skip without `FN_LIVE_FLEET`, so if the rule itself were
* only exercised there it would never be checked at all.
*/
class LiveTargetTest {
@@ -19,10 +19,10 @@ class LiveTargetTest {
"https://relay.flamenet.io",
"https://accounts.flamenet.io",
"https://flamenet.io",
// ⚠️ Subdomains too. A deny list matching only the bare host would
// Subdomains too. A deny list matching only the bare host would
// wave through anything in front of it.
"https://api.flamenetmessenger.com",
// Scheme and port must not matter — the host is the whole question.
// Scheme and port must not matter, the host is the whole question.
"http://relay.flamenet.io:8080/e2e",
)) {
assertFailsWith<IllegalStateException>("$url was allowed") {
@@ -48,7 +48,7 @@ class LiveTargetTest {
}
/**
* ⚠️ Null is "not configured", which the caller turns into an announced
* Null is "not configured", which the caller turns into an announced
* skip. It must not throw, or an unconfigured machine cannot run the suite
* at all.
*/
@@ -9,14 +9,14 @@ import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* One credential, two products — and whether either is optional.
* 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,
* a fallback, or a hidden requirement. What the shared account service buys is
* one credential across both, and nothing else.
*
* ⚠️ Creates a real account on `accounts.flamenet.io`. Enabled by
* Creates a real account on `accounts.flamenet.io`. Enabled by
* `FN_LIVE_MANAGED=1`.
*/
class LiveTwoProductsTest {
@@ -29,10 +29,10 @@ class LiveTwoProductsTest {
private fun needManaged() {
assumeTrue(
System.getenv("FN_LIVE_MANAGED") == "1",
"set FN_LIVE_MANAGED=1 — this creates a real account")
"set FN_LIVE_MANAGED=1, this creates a real account")
assumeTrue(
PASSPHRASE.isNotEmpty(),
"set FN_LIVE_PASSPHRASE — this creates a real account and will not " +
"set FN_LIVE_PASSPHRASE, this creates a real account and will not " +
"use a passphrase written into the repository")
}
@@ -43,9 +43,9 @@ class LiveTwoProductsTest {
}
/**
* ⚠️ **The Messenger-only case, which is the one that matters.** A relay
* The Messenger-only case, which is the one that matters. A relay
* keeping its own accounts must work with no account service and no Files
* anywhere — no probe of either, no degraded mode, no missing feature.
* anywhere, no probe of either, no degraded mode, no missing feature.
*/
@Test
fun `a messenger-only self-hoster needs no account service and no files`() {
@@ -83,7 +83,7 @@ class LiveTwoProductsTest {
/**
* One handle and passphrase, signed in to both products.
*
* ⚠️ And the two tokens must carry **different** pseudonyms. That is the
* And the two tokens must carry different pseudonyms. That is the
* whole reason sessions are product-scoped: if Messenger and Files received
* the same opaque id, the two services could correlate their users, which
* is exactly what a shared account service must not enable.
@@ -101,7 +101,7 @@ class LiveTwoProductsTest {
assertTrue(filesSession.token.isNotEmpty(), "no files session")
assertNotEquals(messengerSession.pseudonym, filesSession.pseudonym,
"the two products got the SAME pseudonym — they could correlate their users")
"the two products got the SAME pseudonym, they could correlate their users")
// The messenger half is not merely issued, it is accepted: exchanged at
// the relay for a working relay credential.
@@ -110,7 +110,7 @@ class LiveTwoProductsTest {
assertTrue(relayAccount.appPassword.isNotEmpty(),
"the relay refused a session the account service issued")
// ⚠️ A files token must NOT be accepted by the messenger relay. The
// A files token must NOT be accepted by the messenger relay. The
// product scope is a boundary, not a label; if the relay took it, a
// token minted for one product would work on another.
val refused = runCatching {
@@ -122,7 +122,7 @@ class LiveTwoProductsTest {
private companion object {
/**
* 🔴 Was `"a-long-enough-passphrase"`, a literal in a source file used
* Was `"a-long-enough-passphrase"`, a literal in a source file used
* to create real accounts. See [LiveTarget.passphrase].
*/
val PASSPHRASE: String get() = LiveTarget.passphrase()
@@ -20,9 +20,9 @@ import org.json.JSONObject
/**
* The local vault.
*
* ⚠️ Against a real directory in a temporary folder, not an in-memory fake. Half
* of what this class does IS the filesystem — atomic replaces, blobs written
* before rows, blobs that must survive a row being removed — and a fake would
* Against a real directory in a temporary folder, not an in-memory fake. Half
* of what this class does IS the filesystem, atomic replaces, blobs written
* before rows, blobs that must survive a row being removed, and a fake would
* agree with the implementation about all of it.
*/
class LocalVaultTest {
@@ -31,7 +31,7 @@ class LocalVaultTest {
* Files somebody else owns, that we only point at.
*
* Records every call, because for the delete path "the file survived" is not
* a claim worth making on its own — see the test that uses [calls].
* a claim worth making on its own, see the test that uses [calls].
*/
private class FakeReferences : LocalVault.ReferenceResolver {
val files = mutableMapOf<String, ByteArray>()
@@ -89,9 +89,9 @@ class LocalVaultTest {
}
/**
* 🔴 **The plaintext must not be on disk anywhere.** The whole claim of a
* The plaintext must not be on disk anywhere. The whole claim of a
* vault is that a stolen container yields ciphertext, and a search for the
* bytes is the only way to be sure — a round trip passes just as happily if
* bytes is the only way to be sure, a round trip passes just as happily if
* `add` wrote the file out unencrypted and read it straight back.
*/
@Test
@@ -127,7 +127,7 @@ class LocalVaultTest {
// ---- The index -------------------------------------------------------
/**
* 🔴 **An index with no `storage` field must read as vaulted.** iOS shipped
* An index with no `storage` field must read as vaulted. iOS shipped
* this field with a Swift default and found out that a default does not make
* a `Codable` key optional: every index written before referencing existed
* failed to decode, which presents to a person as a vault that has lost
@@ -161,17 +161,17 @@ class LocalVaultTest {
}
/**
* 🔴 **The bug the JVM could not see.** Android's bundled `org.json`
* The bug the JVM could not see. Android's bundled `org.json`
* coerces a JSON `null` to the four-character string `"null"`; the Maven
* `org.json` this suite runs against returns `""`. Every nullable field in
* the index therefore passed here and broke on a phone — a vault with any
* the index therefore passed here and broke on a phone, a vault with any
* top-level file threw `Invalid UUID string: null` on load, so adding the
* first file worked and reopening the app lost everything.
*
* ⚠️ **This test writes the literal string `"null"` on purpose.** That is
* This test writes the literal string `"null"` on purpose. That is
* exactly what the decoder is handed on a device, so putting it in the JSON
* here is what makes the JVM run reproduce the device's behaviour. Feeding
* a real JSON `null` — which is what the vault actually writes — would pass
* a real JSON `null`, which is what the vault actually writes, would pass
* with the broken code and prove nothing.
*/
@Test
@@ -194,8 +194,8 @@ class LocalVaultTest {
}
/**
* 🔴 The one that would NOT have thrown. `"null"` is valid base64, so a
* content key decoded from it is a plausible three bytes — and the file
* The one that would NOT have thrown. `"null"` is valid base64, so a
* content key decoded from it is a plausible three bytes, and the file
* simply refuses to decrypt, for ever, with nothing to explain why.
*/
@Test
@@ -224,7 +224,7 @@ class LocalVaultTest {
}
/**
* ⚠️ The temporary file must not survive a save. One left behind is
* The temporary file must not survive a save. One left behind is
* harmless today and is exactly what a future "load the newest index"
* shortcut would pick up.
*/
@@ -238,9 +238,9 @@ class LocalVaultTest {
// ---- Labels ----------------------------------------------------------
/**
* 🔴 **The label key binds the WHOLE canonical encoding.** Deriving from the
* The label key binds the WHOLE canonical encoding. Deriving from the
* classification alone would hand one key to every compartment at that
* level — which is the exact separation compartments exist for, and it would
* level, which is the exact separation compartments exist for, and it would
* pass every test built from levels alone.
*/
@Test
@@ -253,7 +253,7 @@ class LocalVaultTest {
}
/**
* 🔴 **A node key is wrapped to its OWN label, never its parent's.** The tree
* A node key is wrapped to its OWN label, never its parent's. The tree
* deliberately allows a low-classification file inside a high one, so a
* folder-derived key would hand every holder of the folder's key content
* filed above them.
@@ -311,8 +311,8 @@ class LocalVaultTest {
}
/**
* 🔴 A fresh content key every time. Reusing the stored one would mean two
* recipients of one file hold the same key — so revoking either is
* A fresh content key every time. Reusing the stored one would mean two
* recipients of one file hold the same key, so revoking either is
* impossible, and the vault's own blob opens with a key that has left the
* device.
*/
@@ -345,12 +345,12 @@ class LocalVaultTest {
assertEquals(LocalVault.Storage.REFERENCE, node.storage)
assertEquals("their document".length, node.byteCount)
assertEquals(hex("their document".toByteArray()), hex(vault.contents(node)))
assertTrue(blobsOnDisk().isEmpty(), "a reference wrote a blob — it is meant to copy nothing")
assertTrue(blobsOnDisk().isEmpty(), "a reference wrote a blob, it is meant to copy nothing")
}
/**
* ⚠️ A file the person moved or deleted is not an error. It is their file.
* The row reports itself missing and they decide what to do — a row that
* A file the person moved or deleted is not an error. It is their file.
* The row reports itself missing and they decide what to do, a row that
* silently does nothing when tapped is worse than one that says so.
*/
@Test
@@ -367,9 +367,9 @@ class LocalVaultTest {
}
/**
* 🔴 **Promoting to the vault drops the bookmark.** Keeping it would leave a
* 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 entire point of
* vaulting it.
*/
@Test
@@ -385,7 +385,7 @@ class LocalVaultTest {
assertNull(after.bookmark, "a vaulted row can still read through to the plaintext original")
assertEquals(setOf(node.id.toString()), blobsOnDisk())
// ⚠️ And the person's own file is still theirs.
// And the person's own file is still theirs.
assertTrue(refs.files.containsKey("doc://1"), "promoting deleted somebody's own document")
assertEquals(hex("promote me".toByteArray()), hex(vault.contents(after)))
}
@@ -407,19 +407,19 @@ class LocalVaultTest {
// ---- Change detection ------------------------------------------------
/**
* 🔴 **A changed row must not compare equal to what it replaced.**
* A changed row must not compare equal to what it replaced.
*
* `Node.equals` was `other.id == id`, written to dodge `ByteArray`
* identity comparison. Promoting a link keeps the node's id and changes its
* `storage`, so the new row compared equal to the old one — the state object
* `storage`, so the new row compared equal to the old one, the state object
* holding it compared equal too, `StateFlow` conflated the update away, and
* the screen never changed. On a device the file vanished from Linked and
* did not reappear in the vault until the app was restarted, which reads as
* losing somebody's file.
*
* ⚠️ Found by watching it on an emulator, not by any test. The suite passed
* Found by watching it on an emulator, not by any test. The suite passed
* throughout, because everything here reads the vault rather than a
* `StateFlow` — so this asserts the property the UI actually depends on.
* `StateFlow`, so this asserts the property the UI actually depends on.
*/
@Test
fun `a promoted row does not compare equal to the reference it replaced`() {
@@ -430,7 +430,7 @@ class LocalVaultTest {
assertEquals(before.id, after.id, "the promote should keep the same row")
assertTrue(before != after,
"a row whose storage changed compares equal to its old self — any " +
"a row whose storage changed compares equal to its old self, any " +
"state holder that conflates on equality will drop the update")
assertTrue(listOf(before) != listOf(after), "the LIST compares equal too")
}
@@ -441,13 +441,13 @@ class LocalVaultTest {
val node = vault.add("x".toByteArray(), "f.txt", secret)
val a = assertNotNull(reopen().node(node.id))
val b = assertNotNull(reopen().node(node.id))
assertEquals(a, b, "two reads of one unchanged row differ — every refresh " +
assertEquals(a, b, "two reads of one unchanged row differ, every refresh " +
"would redraw and byte arrays are being compared by identity again")
assertEquals(a.hashCode(), b.hashCode())
}
/**
* ⚠️ Every mutable field, not just `storage`. A rename, a move, a
* Every mutable field, not just `storage`. A rename, a move, a
* re-classification and a re-wrap all have to be visible to a UI that
* conflates on equality.
*/
@@ -482,15 +482,15 @@ class LocalVaultTest {
}
/**
* 🔴 **Deleting a REFERENCE row must not touch the person's document.**
* Deleting a REFERENCE row must not touch the person's document.
* 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**.
* future code: removing a row reaches the person's storage not at all.
* Anybody who later routes the delete path through the resolver has to come
* here and say why.
*/
@@ -524,7 +524,7 @@ class LocalVaultTest {
}
/**
* ⚠️ Deepest-first, so selecting a folder AND everything in it works. Doing
* Deepest-first, so selecting a folder AND everything in it works. Doing
* it in the order given would refuse the folder for being non-empty and
* leave half the selection behind.
*/
@@ -543,7 +543,7 @@ class LocalVaultTest {
}
/**
* 🔴 **The report is the point.** Selecting eleven things and having nine go
* The report is the point. Selecting eleven things and having nine go
* is a data-loss-shaped event in the other direction: the two that survived
* are the ones somebody now believes are gone.
*/
@@ -561,7 +561,7 @@ class LocalVaultTest {
}
/**
* ⚠️ A row filed above what this device can read still counts as contents.
* A row filed above what this device can read still counts as contents.
* Otherwise a bulk gesture would take rows the person could not have
* reviewed before tapping.
*/
@@ -594,9 +594,9 @@ class LocalVaultTest {
// ---- The picker ------------------------------------------------------
/**
* 🔴 **The whole tree, not the top of it.** iOS's picker lists the root
* The whole tree, not the top of it. iOS's picker lists the root
* only, so a document filed into a folder cannot be sent from a
* conversation at all — and nothing says so: the file is simply absent,
* conversation at all, and nothing says so: the file is simply absent,
* which reads as "it isn't there". Filed as ios#68.
*/
@Test
@@ -617,7 +617,7 @@ class LocalVaultTest {
}
/**
* ⚠️ **The one place this class hides a row it cannot read.** Everywhere
* The one place this class hides a row it cannot read. Everywhere
* else an unreadable row is shown, because hiding it would leave somebody
* unable to see that anything is there. A picker's only purpose is choosing
* something to send, and a row that can never be chosen is furniture.
@@ -651,7 +651,7 @@ class LocalVaultTest {
}
/**
* ⚠️ A cycle cannot happen through this API, and the guard is here because
* A cycle cannot happen through this API, and the guard is here because
* an index is a file on disk that something else could write. Without it
* this is an infinite loop in a method a screen calls.
*/
@@ -670,7 +670,7 @@ class LocalVaultTest {
index.writeText(json.toString())
val cyclic = reopen()
// 🔴 Not merely "it terminated": each node once. A step-counter guard
// Not merely "it terminated": each node once. A step-counter guard
// would end the loop and hand a screen ten thousand copies of two ids,
// which is what "delete this folder and everything in it" would then
// offer to do.
@@ -23,12 +23,12 @@ import kotlin.test.assertTrue
/**
* Adding and dropping a mailbox (spec §20.4).
*
* 🔴 **Your address survives a relay disappearing; your mailbox does not.** So
* Your address survives a relay disappearing; your mailbox does not. So
* the order of operations here is the whole substance: nothing is written down
* locally until the relay has actually accepted a registration, and the record
* is published everywhere rather than only at the relay being added. A home
* that does not know it is one still accepts mail, but a sender asking IT for
* the record gets a stale list and skips the others — the redundancy would
* the record gets a stale list and skips the others, the redundancy would
* quietly depend on which relay the sender happened to ask.
*/
class MailboxHomingTest {
@@ -55,9 +55,9 @@ class MailboxHomingTest {
/**
* What each relay would serve back if asked.
*
* ⚠️ Separate from [published], which a test may clear to check who was
* Separate from [published], which a test may clear to check who was
* told THIS time. A relay does not forget a record because the test stopped
* watching, and `nextSerial` reads exactly this — so sharing one map made
* watching, and `nextSerial` reads exactly this, so sharing one map made
* every publish after a clear reuse serial 1, which a real relay would
* refuse as a rollback.
*/
@@ -102,10 +102,10 @@ class MailboxHomingTest {
json("""{"ok":true}""")
}
}
// 🔴 Serves back what it was told. `nextSerial` reads the
// Serves back what it was told. `nextSerial` reads the
// published records to decide the next one, so a relay that
// stores a record and then 404s it would have every publish
// reuse serial 1 — and the second would be refused as a
// reuse serial 1, and the second would be refused as a
// rollback by the very check that stops an attacker replaying
// an old list. A mock that only accepts writes hides that.
"/e2e/account/homes/" in path ->
@@ -126,8 +126,8 @@ class MailboxHomingTest {
RelayClient(home.url("/").toString(), "me", "pw"))
/**
* ⚠️ The record names `new.example.com`; the URL this device dials is the
* mock server. That split is the production shape too — the hostname is
* The record names `new.example.com`; the URL this device dials is the
* mock server. That split is the production shape too, the hostname is
* inside the signature and cannot carry a port.
*/
private fun add(existing: List<String> = emptyList()) =
@@ -138,9 +138,9 @@ class MailboxHomingTest {
// MARK: - What a relay's name is allowed to be
/**
* ⚠️ A relay is a hostname, never a URL. This is parsed from something a
* A relay is a hostname, never a URL. This is parsed from something a
* person typed or pasted, and accepting `https://x/y` would let it point
* the device at a path on a host of somebody else's choosing — and then
* the device at a path on a host of somebody else's choosing, and then
* send that host a signed record naming it as a mailbox.
*/
@Test
@@ -166,7 +166,7 @@ class MailboxHomingTest {
// MARK: - Nothing is written down until the relay agrees
/**
* 🔴 A half-added home — stored locally, unknown to the relay — would be
* A half-added home, stored locally, unknown to the relay, would be
* polled forever and would show on screen as a mailbox that never has
* anything in it.
*/
@@ -178,7 +178,7 @@ class MailboxHomingTest {
}
/**
* 🔴 **The NEW relay accepting is what decides.** A record every old home
* The NEW relay accepting is what decides. A record every old home
* took but the new one refused describes a mailbox that does not exist, and
* a sender told to deliver there gets nothing.
*/
@@ -226,9 +226,9 @@ class MailboxHomingTest {
// MARK: - Dropping one
/**
* ⚠️ **The shorter list is published first and the credential goes only if
* **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 —
* there leaves mail arriving at a mailbox this device no longer reads,
* which, from the sender's side, is indistinguishable from delivery.
*/
@Test
@@ -240,7 +240,7 @@ class MailboxHomingTest {
listOf("home.example.com", "new.example.com"))
assertTrue(vault.extraHomes().isEmpty(), "the credential outlived the mailbox")
// 🔴 The relay being dropped is NOT asked to accept the new record. It
// The relay being dropped is NOT asked to accept the new record. It
// is the one party with a reason to refuse, and it is not in the list
// any more so it would refuse on those grounds regardless.
assertEquals(setOf("home"), published.keys)
@@ -264,8 +264,8 @@ 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,
* 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.
*/
@Test
@@ -10,24 +10,24 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* 🔴 **The relay answers a lookup with an ARRAY, and the client used to insist
* **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
* `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
* neither timing nor shape is an oracle. The client sent it through `send()`,
* which does `JSONObject(text)` and throws on an array, and `lookup()` wrapped
* the whole thing in `runCatching { }.getOrNull()`.
*
* Every lookup therefore reported **"No account called X here"** while the relay
* was returning the account. **Adding a contact was impossible on Android** —
* the first thing anyone does — and it shipped in an APK that way.
* Every lookup therefore reported "No account called X here" while the relay
* was returning the account. Adding a contact was impossible on Android,
* the first thing anyone does, and it shipped in an APK that way.
*
* Measured against relay.flamenet.io before the fix: the relay returned
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` and the app said
* no such account existed.
*
* ⚠️ Nothing caught it because all three layers were quiet: the relay was
* Nothing caught it because all three layers were quiet: the relay was
* correct, the parse failure was swallowed, and a miss and a crash render
* identically. `CrudSurfaceTest` asserted the lookup CONTROL exists, which it
* did. The lesson is that a control being present says nothing about the
@@ -51,7 +51,7 @@ class MessengerApiLookupTest {
fun `a hit comes back as a one element array and is parsed`() {
server.enqueue(json("""[{"id":30,"name":"Bravo","slug":"appreview2demo"}]"""))
val found = api().lookup("appreview2demo")
assertNotNull(found, "the relay returned the account and the client dropped it — " +
assertNotNull(found, "the relay returned the account and the client dropped it, " +
"this is the shape bug that made adding a contact impossible on Android")
assertEquals(30L, found.userId)
assertEquals("appreview2demo", found.handle)
@@ -65,8 +65,8 @@ class MessengerApiLookupTest {
}
/**
* ⚠️ A miss and a transport failure must both read as "not found" to the
* caller, but for different reasons — this pins the miss so a future
* A miss and a transport failure must both read as "not found" to the
* caller, but for different reasons, this pins the miss so a future
* refactor cannot turn an empty array into an exception.
*/
@Test
@@ -13,11 +13,11 @@ 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
* **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
* 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
* the screen looked permanently empty. The roster route does not use the
* `user_id` shape the §8 routes use, and guessing otherwise threw
* "No value for user_id" at the exact moment a contact was added.
@@ -29,7 +29,7 @@ import kotlin.test.assertTrue
* there was no such test.
*
* So each decode test below is paired with a wrong-shape test asserting that
* the historical mistake yields nothing — which is what makes the pairing
* the historical mistake yields nothing, which is what makes the pairing
* meaningful rather than decorative.
*/
class MessengerApiShapeTest {
@@ -66,9 +66,9 @@ class MessengerApiShapeTest {
}
/**
* 🔴 **Defaults to true, and that default is the compatibility story.** A
* Defaults to true, and that default is the compatibility story. A
* relay predating the field says nothing about acceptance, and the only
* safe reading of silence is "these are your contacts" — the alternative
* safe reading of silence is "these are your contacts", the alternative
* labels every contact on every older relay a stranger.
*/
@Test
@@ -78,8 +78,8 @@ class MessengerApiShapeTest {
}
/**
* ⚠️ `handle` was null on this relay for a while, having started life as an
* OSCAR screen name — which meant you could not find your own contacts by
* `handle` was null on this relay for a while, having started life as an
* OSCAR screen name, which meant you could not find your own contacts by
* the name you knew them by.
*/
@Test
@@ -109,9 +109,9 @@ class MessengerApiShapeTest {
// -- Blocks --------------------------------------------------------------
/**
* 🔴 The bug: this parsed `user_id`, the relay sends `id`. The Block button
* The bug: this parsed `user_id`, the relay sends `id`. The Block button
* did nothing, and it survived because the list it produced was always
* empty — so nothing ever disagreed with it.
* empty, so nothing ever disagreed with it.
*/
@Test
fun `the block list decodes the id field the relay actually sends`() {
@@ -137,9 +137,9 @@ class MessengerApiShapeTest {
// -- Notifications -------------------------------------------------------
/**
* 🔴 This read `kind`/`body`/`read` for months. Those names exist nowhere,
* This read `kind`/`body`/`read` for months. Those names exist nowhere,
* so every alert decoded as an empty unread row and the screen looked
* permanently blank — the failure mode of a decode mismatch is silence.
* permanently blank, the failure mode of a decode mismatch is silence.
*/
@Test
fun `notifications decode type message and is_read`() {
@@ -177,7 +177,7 @@ class MessengerApiShapeTest {
// -- Lookup by id --------------------------------------------------------
/**
* 🔴 This is how a message from a stranger becomes visible. An E2E envelope
* This is how a message from a stranger becomes visible. An E2E envelope
* names its sender by id only, so without this a first contact from someone
* who added US arrived, decrypted, was filed and counted as unread, and
* never appeared anywhere.
@@ -262,9 +262,9 @@ class MessengerApiShapeTest {
// -- Presence ------------------------------------------------------------
/**
* 🔴 **Return the server's answer, never the value we asked for.** The
* Return the server's answer, never the value we asked for. The
* relay used to accept an unknown status with 200 and store nothing, so a
* caller that assumed success showed a state the server had discarded —
* caller that assumed success showed a state the server had discarded,
* "offline" on this screen while every contact still saw us online.
*/
@Test
@@ -19,8 +19,8 @@ import kotlin.test.assertTrue
/**
* Reading mail from more than one relay (spec §20.4).
*
* 🔴 **Publishing a home record is only half of it.** The record tells senders
* to deliver a copy everywhere and the relay fans out — but a copy sitting in a
* Publishing a home record is only half of it. The record tells senders
* to deliver a copy everywhere and the relay fans out, but a copy sitting in a
* mailbox nothing reads is storage, not redundancy. These tests are about the
* half that reads, and about the three ways it goes wrong quietly: a copy read
* twice, one relay's cursor applied to another's ids, and a backup mailbox's
@@ -47,7 +47,7 @@ class MultiMailboxPollTest {
/** What each mailbox is holding: id to payload, plus an envelope id. */
private val homeQueue = mutableListOf<Triple<Long, String, String>>()
private val backupQueue = mutableListOf<Triple<Long, String, String>>()
/** Set to make the backup answer 410 — a device revoked at a BACKUP home. */
/** Set to make the backup answer 410, a device revoked at a BACKUP home. */
private var backupGone = false
private var homeDown = false
@@ -67,7 +67,7 @@ class MultiMailboxPollTest {
/**
* A relay that serves one mailbox.
*
* ⚠️ 404s the transparency-log routes: these fixtures are pre-§19
* 404s the transparency-log routes: these fixtures are pre-§19
* deployments, so the tests are about mailboxes and nothing else.
*/
private fun queueOf(
@@ -93,14 +93,14 @@ class MultiMailboxPollTest {
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
private fun conversations(): Conversations {
// ⚠️ Written into the vault rather than passed in: the client rebuilds
// Written into the vault rather than passed in: the client rebuilds
// its list of homes from the vault on every poll, which is what lets a
// mailbox added or dropped mid-session take effect without a relaunch.
vault.saveExtraHomes(listOf(Vault.ExtraHome(
relay = backupHost, username = "me", appPassword = "pw",
// ⚠️ The record names a hostname and cannot carry a port; how this
// The record names a hostname and cannot carry a port; how this
// device DIALS that host is a separate field, which is what lets a
// test — or a self-hoster on a nonstandard port — work without the
// test, or a self-hoster on a nonstandard port, work without the
// record lying about its own name.
baseUrl = backup.url("/").toString())))
return Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw"))
@@ -118,7 +118,7 @@ class MultiMailboxPollTest {
// MARK: - Reading
/**
* 🔴 The whole point. A relay this device is not signed in to is holding
* The whole point. A relay this device is not signed in to is holding
* mail, and without this the message is simply never read.
*/
@Test
@@ -134,10 +134,10 @@ class MultiMailboxPollTest {
}
/**
* ⚠️ **A copy at every home is the design, so a copy read twice is the
* **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.
* a dropped envelope, a message that looks lost rather than repeated.
*/
@Test
fun `the same message at two relays is read once`() {
@@ -156,7 +156,7 @@ class MultiMailboxPollTest {
}
/**
* 🔴 Envelope ids come from each relay's own database, so id 900 there says
* Envelope ids come from each relay's own database, so id 900 there says
* nothing about id 900 here. One shared cursor would let the first
* mailbox's progress silently skip the second's mail.
*/
@@ -180,7 +180,7 @@ class MultiMailboxPollTest {
// MARK: - When something goes wrong
/**
* 🔴 **A 410 from a BACKUP home is that mailbox ending, not this account
* **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.
*/
@@ -195,8 +195,8 @@ class MultiMailboxPollTest {
}
/**
* 🔴 **The failure this whole feature exists to survive.** A home relay that
* is down must not take every other mailbox down with it — which is exactly
* The failure this whole feature exists to survive. A home relay that
* is down must not take every other mailbox down with it, which is exactly
* what happens if the poll returns on the first error instead of carrying
* on to the others.
*/
@@ -211,9 +211,9 @@ class MultiMailboxPollTest {
}
/**
* ⚠️ **A failed poll is still not an empty inbox.** "No new messages" and
* A failed poll is still not an empty inbox. "No new messages" and
* "could not reach anything" are different, and the app says different
* words for them — so when nothing at all could be read, the failure is
* words for them, so when nothing at all could be read, the failure is
* raised rather than swallowed into an empty list.
*/
@Test
@@ -13,11 +13,11 @@ import kotlin.test.assertTrue
/**
* The seam that carries the setting to the ten places that build HTTP clients.
*
* 🔴 **The bug this is for: obfs4 was reachable from nothing.** Every client in
* The bug this is for: obfs4 was reachable from nothing. Every client in
* the app took `defaultHttpClient()`'s null default, so the transport that had
* been built, tested and proven against lyrebird was not on any code path a
* user could reach. Threading a bridge through each construction site would
* have worked until the eleventh was added — and the eleventh would connect
* have worked until the eleventh was added, and the eleventh would connect
* directly, successfully, with nothing to show for it.
*/
class Obfs4RoutingTest {
@@ -44,10 +44,10 @@ class Obfs4RoutingTest {
}
/**
* 🔴 **The factory is installed whether or not a bridge is set, so the
* **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
* 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.
*/
@Test
@@ -70,10 +70,10 @@ class Obfs4RoutingTest {
}
/**
* 🔴 **The factory reads the setting per socket, not once at construction.**
* The factory reads the setting per socket, not once at construction.
* A client is built long before the user opens settings; a factory that
* captured the bridge at build time would keep using whatever was in force
* then — including nothing at all — for the life of the process.
* then, including nothing at all, for the life of the process.
*/
@Test
fun `a factory built before the bridge was set still picks it up`() {
@@ -89,7 +89,7 @@ class Obfs4RoutingTest {
}
/**
* ⚠️ The default client must carry the routing factory, or none of the
* The default client must carry the routing factory, or none of the
* above reaches anything the app actually uses.
*/
@Test
@@ -112,9 +112,9 @@ class Obfs4RoutingTest {
}
/**
* 🔴 **Pooled connections outlive the setting that created them.** OkHttp
* Pooled connections outlive the setting that created them. OkHttp
* keeps a connection alive across requests, so one opened through the old
* bridge would go on serving requests for minutes after the user switched —
* bridge would go on serving requests for minutes after the user switched,
* or after they turned a bridge OFF, which is the dangerous direction.
*/
@Test
@@ -125,7 +125,7 @@ class Obfs4RoutingTest {
"connections committed to the previous transport were kept")
}
/** ⚠️ Setting the same bridge twice must not tear down live connections. */
/** Setting the same bridge twice must not tear down live connections. */
@Test
fun `setting the same bridge again is not a change`() {
val one = bridge()
@@ -13,7 +13,7 @@ import kotlin.test.assertTrue
* Sign-up, sign-in and password reset against a relay that is its own identity
* provider.
*
* 🔴 **This whole class was at 0%** — every route a person meets before they
* This whole class was at 0%, every route a person meets before they
* can send a single message, including the error mapping that decides what they
* are told when it goes wrong. Nothing here had ever been executed by a test.
*
@@ -83,7 +83,7 @@ class RelayAccountsTest {
// -- Sign-in -------------------------------------------------------------
/**
* ⚠️ The field is "username **or email**" and always has been. A client
* The field is "username or email" and always has been. A client
* that only sends usernames rejects half the people who typed exactly what
* they were asked for.
*/
@@ -105,7 +105,7 @@ class RelayAccountsTest {
// -- Shared session ------------------------------------------------------
/**
* ⚠️ The handle travels BESIDE the token, not inside it: the account
* The handle travels BESIDE the token, not inside it: the account
* service will not put a handle in a token, so a relay cannot correlate its
* users with another product's.
*/
@@ -129,8 +129,8 @@ class RelayAccountsTest {
}
/**
* ⚠️ A self-hosted relay answers 404 here, and that is the CORRECT answer
* rather than a misconfiguration — it is its own identity provider. The
* A self-hosted relay answers 404 here, and that is the CORRECT answer
* rather than a misconfiguration, it is its own identity provider. The
* caller has to be able to tell that apart from a broken relay.
*/
@Test
@@ -142,7 +142,7 @@ class RelayAccountsTest {
// -- Password reset ------------------------------------------------------
/**
* ⚠️ The relay answers identically whatever the truth is — unknown handle,
* The relay answers identically whatever the truth is, unknown handle,
* no address on file, an account that authenticates elsewhere. So this
* cannot report whether it worked, and a UI that claims it can is lying.
*/
@@ -169,7 +169,7 @@ class RelayAccountsTest {
// -- What people are told when it fails ----------------------------------
/**
* 🔴 The relay puts a human-readable reason in the body, and it is better
* The relay puts a human-readable reason in the body, and it is better
* than anything this layer could invent from a status code. Losing it means
* "that name is taken" becomes "something went wrong".
*/
@@ -191,8 +191,8 @@ class RelayAccountsTest {
val e = assertFailsWith<RelayException.Unauthorized> {
accounts().signIn("ada", "wrong")
}
// ⚠️ The relay's text lives in `body`; `message` is fixed. The type is
// a DECISION — Unauthorized triggers exactly one retry — and a caller
// The relay's text lives in `body`; `message` is fixed. The type is
// a DECISION. Unauthorized triggers exactly one retry, and a caller
// matching on a string to decide that eventually matches wrong.
assertTrue(e.body.contains("Wrong password"))
}
@@ -216,7 +216,7 @@ class RelayAccountsTest {
}
/**
* 🔴 The geo-gate, not the relay. It needs a sentence, not a number — a
* The geo-gate, not the relay. It needs a sentence, not a number, a
* person blocked by region who is shown "error 451" has no idea what
* happened or that nothing is wrong with their account.
*/
@@ -227,9 +227,9 @@ class RelayAccountsTest {
val e = assertFailsWith<RelayException.Unavailable> {
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
}
// 🔴 The message is deliberately OURS, not the relay's. The download
// The message is deliberately OURS, not the relay's. The download
// site is not geo-gated, so somebody can read the page, install the
// app, pick a password, tap sign up and be told "relay returned 451" —
// app, pick a password, tap sign up and be told "relay returned 451",
// which mentions nothing about their country and cannot be retried
// past, so the only sensible reading is that the app is broken.
assertTrue(e.message!!.contains("isn't available in your country"))
@@ -21,8 +21,8 @@ import kotlin.test.fail
/**
* Against a real HTTP server, not a hand-written fake.
*
* ⚠️ 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
* 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.
*/
@@ -44,9 +44,9 @@ class RelayClientTest {
/**
* The next request the client made.
*
* ⚠️ Bounded on purpose. `takeRequest()` with no timeout blocks forever, and
* because token minting is **lazy** — `useRelayAuth` does not mint, the
* first authenticated call does — it is easy to wait for a request that has
* Bounded on purpose. `takeRequest()` with no timeout blocks forever, and
* because token minting is lazy, `useRelayAuth` does not mint, the
* first authenticated call does, it is easy to wait for a request that has
* not happened yet. That reads as a hung build, not a failing test.
*/
private fun next(): RecordedRequest =
@@ -55,8 +55,8 @@ class RelayClientTest {
/**
* A client with a token response already queued.
*
* The mint has NOT happened when this returns — it happens on the first
* authenticated call — so `/account/token` is the first request to read
* The mint has NOT happened when this returns, it happens on the first
* authenticated call, so `/account/token` is the first request to read
* back, after whatever the test then does.
*/
private fun authedClient(): Pair<RelayClient, DeviceIdentity> {
@@ -86,9 +86,9 @@ class RelayClientTest {
assertEquals(Wire.b64(identity.identitySigningPublic), body.getString("ik_sig"))
assertEquals(identity.signedPreKeyId, body.getInt("spk_id"))
assertTrue(body.has("spk") && body.has("spk_sig"))
// ⚠️ `prekeys`, not `opks`. The relay accepts a registration carrying
// `prekeys`, not `opks`. The relay accepts a registration carrying
// the wrong name with a 200 and stores no prekeys, so the device is
// published and cannot be started with — a failure with no error.
// published and cannot be started with, a failure with no error.
assertEquals(2, body.getJSONArray("prekeys").length())
assertEquals(preKeys[0].id, body.getJSONArray("prekeys").getJSONObject(0).getInt("id"))
@@ -151,7 +151,7 @@ class RelayClientTest {
}
/**
* ⚠️ The signature covers the LOGICAL §8 path, not the URL's path. The
* The signature covers the LOGICAL §8 path, not the URL's path. The
* relay mounts these routes twice and signs against `/e2e/…`; signing the
* REST-prefixed path produces a valid signature over the wrong string, and
* the relay rejects it with no hint as to why.
@@ -185,7 +185,7 @@ class RelayClientTest {
/**
* Two polls in the same second with the same empty body must not produce
* identical signatures — the relay's replay cache cannot tell an honest
* identical signatures, the relay's replay cache cannot tell an honest
* repeat from a captured one, so it refuses the honest client and the app
* silently stops receiving mail.
*/
@@ -234,7 +234,7 @@ class RelayClientTest {
/**
* A deployment with no token route is a supported configuration, not an
* error — and it must be asked exactly once. Asking on every request costs
* error, and it must be asked exactly once. Asking on every request costs
* a round trip per call, forever, to learn the same thing.
*/
@Test
@@ -293,7 +293,7 @@ class RelayClientTest {
.put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey)))
server.enqueue(json("""{"devices":[$wire]}"""))
// ⚠️ `fetchKeys` answers with the fnid alongside the bundles now — the
// `fetchKeys` answers with the fnid alongside the bundles now, the
// address is what the key log is checked against, and returning only
// the bundles threw it away at the one point it is available.
val fetched = client().fetchKeys(42)
@@ -9,18 +9,18 @@ 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
* **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
* `KeyLogForkException`, so a rotation somebody performed on purpose was
* reported to the user as the relay showing two different histories.
*
* Those need different responses. A fork means nothing the relay says can be
* trusted. A rotation means somebody ran a command, or somebody else is
* answering on that address, and a person has to decide which. Reporting the
* second as the first is alarming and unactionable, and an alarm people cannot
* act on is one they learn to dismiss — which costs the real fork detection its
* act on is one they learn to dismiss, which costs the real fork detection its
* credibility too.
*/
class RelayIdentityPinTest {
@@ -46,7 +46,7 @@ class RelayIdentityPinTest {
}
/**
* 🔴 The case the whole file exists for, and the one that used to be
* The case the whole file exists for, and the one that used to be
* reported as a forked log.
*/
@Test
@@ -58,7 +58,7 @@ class RelayIdentityPinTest {
}
/**
* ⚠️ **A relay that publishes no key is a weaker deployment, not an
* **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.
@@ -76,8 +76,8 @@ class RelayIdentityPinTest {
}
/**
* ⚠️ **Broken is not hostile.** A relay answering with rubbish counts as
* "not published" rather than as a change — treating a malformed field as
* Broken is not hostile. A relay answering with rubbish counts as
* "not published" rather than as a change, treating a malformed field as
* an attack would raise an alarm nobody can act on, on a deployment that is
* simply misconfigured.
*/
@@ -107,9 +107,9 @@ class RelayIdentityPinTest {
// ---- What somebody is told ------------------------------------------
/**
* 🔴 The warning names BOTH possibilities and asserts neither. An alarm that
* The warning names BOTH possibilities and asserts neither. An alarm that
* claims an attack when a rotation is equally likely trains people to
* dismiss alarms — and the next one may be real.
* dismiss alarms, and the next one may be real.
*/
@Test
fun `the warning does not pretend to know which it is`() {
@@ -121,14 +121,14 @@ class RelayIdentityPinTest {
}
/**
* 🔴 **The exception is not a fork exception, and that separation is the
* **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
fun `an identity change is not reported as a key-log fork`() {
// ⚠️ Held as the sealed base, deliberately. Typed as the concrete class
// the compiler folds `!is KeyLogForkException` to a constant and warns —
// Held as the sealed base, deliberately. Typed as the concrete class
// the compiler folds `!is KeyLogForkException` to a constant and warns,
// which is the compiler proving the point statically, and also means the
// assertion would test nothing. Through the base type it is the runtime
// check a caller's `when` actually performs.
@@ -145,7 +145,7 @@ class RelayIdentityPinTest {
// ---- Shape -----------------------------------------------------------
/**
* ⚠️ `ByteArray` has reference equality by default, so the verdicts carry
* `ByteArray` has reference equality by default, so the verdicts carry
* hand-written `equals`. Without them every comparison in this suite would
* be false and the tests would have been written around it.
*/
@@ -21,15 +21,15 @@ import kotlin.test.assertTrue
/**
* Sending to somebody on another relay (spec §20.5).
*
* 🔴 **Always sealed, and here that is not a preference.** A relay that accepts
* a federated envelope stores it with no sender — it was not told and must not
* invent one — so an unsealed envelope arrives unattributable and is dropped.
* Always sealed, and here that is not a preference. A relay that accepts
* a federated envelope stores it with no sender, it was not told and must not
* invent one, so an unsealed envelope arrives unattributable and is dropped.
* Locally, sealing is an improvement; across a boundary it is the only thing
* that makes the message deliverable at all.
*
* The other half is the return address. Their relay records none, and our user
* id means nothing on it, so a message without one decrypts on their phone and
* has nowhere to reply to — which reads to the recipient as the app being
* has nowhere to reply to, which reads to the recipient as the app being
* broken rather than as a message being incomplete.
*/
class SendToAddressTest {
@@ -116,7 +116,7 @@ class SendToAddressTest {
val payload = messages.getJSONObject(0).getString("payload")
assertTrue(SealedSender.isSealed(payload), "the envelope reached the wire unsealed")
// 🔴 The return address is inside the seal, where the relay cannot read
// The return address is inside the seal, where the relay cannot read
// it and cannot strip it.
val opened = SealedSender.open(
SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId)
@@ -125,7 +125,7 @@ class SendToAddressTest {
}
/**
* ⚠️ One id per MESSAGE, not per device or per mailbox. A recipient polling
* One id per MESSAGE, not per device or per mailbox. A recipient polling
* several homes collapses the copies by it; a fresh id per copy would show
* them the same message once per mailbox.
*/
@@ -156,7 +156,7 @@ class SendToAddressTest {
// MARK: - Refusals
/**
* 🔴 **Refused up front rather than sent into a dead end.** Their relay
* Refused up front rather than sent into a dead end. Their relay
* records no sender, so without our address the message decrypts on their
* phone and has nowhere to reply to.
*/
@@ -176,7 +176,7 @@ class SendToAddressTest {
}
/**
* 🔴 **`accepted` first.** Checking only the failure fields reported a send
* `accepted` first. Checking only the failure fields reported a send
* as successful when the far relay had taken nothing at all: the app said
* sent, the peer's inbox stayed empty, and no error anywhere explained it.
*/
@@ -198,7 +198,7 @@ class SendToAddressTest {
}
/**
* ⚠️ Partial delivery is a real outcome once an account has several
* Partial delivery is a real outcome once an account has several
* mailboxes, and silence about it is how somebody believes a message landed
* when a copy did not.
*/
@@ -214,7 +214,7 @@ class SendToAddressTest {
// MARK: - Trust in the address keyspace
/**
* 🔴 **A key never seen before is either a new device of theirs or one their
* **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.
*/
@@ -16,17 +16,17 @@ import kotlin.test.assertTrue
/**
* A message nobody can receive must not report success.
*
* 🔴 **This shipped, and it lost real messages.** Signing out deregisters the
* This shipped, and it lost real messages. Signing out deregisters the
* device, so a signed-out recipient genuinely 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
* Every caller reads an empty `failed` as "it went", the message was written
* into the sender's own thread and existed nowhere else. It was reported as
* "I wasn't signed in on the other end and the message got lost", and the same
* path is why a group text vanished: the group senders call this once per
* member.
*
* ⚠️ **iOS never had this bug** — `transmit` throws on an empty device list.
* iOS never had this bug, `transmit` throws on an empty device list.
* The two platforms disagreed about a delivery guarantee for as long as both
* have existed, and nothing failed, because the only tests that covered
* sending had both ends signed in. That is the actual lesson here: a test that
@@ -77,12 +77,12 @@ class SendToNobodyTest {
assertFalse(
sent.failed.isEmpty(),
"a send that reached nobody reported an empty `failed`, which every " +
"caller reads as success — this is the message-loss bug",
"caller reads as success, this is the message-loss bug",
)
}
/**
* ⚠️ And it must not quietly post the envelope anyway. The bug was not only
* And it must not quietly post the envelope anyway. The bug was not only
* in the return value: with no devices there is nothing to address, so a
* request here would mean the relay was being handed an envelope for a
* device that does not exist.
@@ -11,9 +11,9 @@ import kotlin.test.assertTrue
/**
* What a refused sign-up is reported as.
*
* 🔴 **A 409 is not always a taken handle, and this used to say it was.** The
* A 409 is not always a taken handle, and this used to say it was. The
* relay answers 409 for a taken handle, an already-registered email, and a
* handle too close to an existing one — three problems with three different
* handle too close to an existing one, three problems with three different
* fixes. Collapsing them told somebody whose EMAIL was registered to pick a
* different handle, which cannot work and leaves them no way to find out why.
*
@@ -70,8 +70,8 @@ class SignupFailureMappingTest {
}
/**
* 🔴 The regression this whole change exists for. An already-registered
* email must NOT be reported as a taken handle — the person would change
* The regression this whole change exists for. An already-registered
* email must NOT be reported as a taken handle, the person would change
* their handle, be refused again, and have no way to work out why.
*/
@Test
@@ -85,8 +85,8 @@ class SignupFailureMappingTest {
}
/**
* ⚠️ A relay newer than the app is the normal case, not the exception. An
* unknown code must still refuse — falling through to success would create
* A relay newer than the app is the normal case, not the exception. An
* unknown code must still refuse, falling through to success would create
* an account the server never made.
*/
@Test
@@ -99,7 +99,7 @@ class SignupFailureMappingTest {
)
}
/** ⚠️ A body that is not the relay's error shape must not throw a parse error. */
/** A body that is not the relay's error shape must not throw a parse error. */
@Test
fun `an unparseable body falls back to the status`() {
server.enqueue(
@@ -16,16 +16,16 @@ import java.security.MessageDigest
/**
* The client half of the sign-up puzzle.
*
* 🔴 **The bit counting has to agree with the relay exactly.** Both sides count
* The bit counting has to agree with the relay exactly. Both sides count
* leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted
* bytes, every solution this phone produced would still be accepted — it would
* simply do hundreds of times the work — and no assertion about "at least N
* bytes, every solution this phone produced would still be accepted, it would
* simply do hundreds of times the work, and no assertion about "at least N
* 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
* 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
* proxy returning HTML, all of those mean "no work wanted", not "no account
* for you".
*/
class SignupProofOfWorkTest {
@@ -46,8 +46,8 @@ class SignupProofOfWorkTest {
// ---------------------------------------------------------------- the arithmetic
/**
* ⚠️ The input is chosen so the answer is NOT a multiple of eight.
* `SHA256("pin754")` starts `000f d989…` — twelve zero bits, which a
* The input is chosen so the answer is NOT a multiple of eight.
* `SHA256("pin754")` starts `000f d989…`, twelve zero bits, which a
* byte-counting implementation reports as eight. Almost every digest has no
* leading zeros at all and cannot tell the two apart.
*/
@@ -60,8 +60,8 @@ class SignupProofOfWorkTest {
}
/**
* 🔴 The exact nonce, not merely "a nonce that is good enough". A
* byte-rounding implementation still satisfies a 9-bit difficulty — it
* The exact nonce, not merely "a nonce that is good enough". A
* byte-rounding implementation still satisfies a 9-bit difficulty, it
* overshoots to 16 bits and returns 4416 instead of 19, which is 230x the
* work for the person signing up.
*/
@@ -83,7 +83,7 @@ class SignupProofOfWorkTest {
}
/**
* 🔴 A nonce solved for one challenge must not satisfy another, or the
* A nonce solved for one challenge must not satisfy another, or the
* puzzle is solved once and reused for every registration in a farm.
*/
@Test
@@ -97,14 +97,14 @@ class SignupProofOfWorkTest {
// ---------------------------------------------------------------- refusing to grind
/** ⚠️ The ceiling is a real bound. A search that cannot finish must return. */
/** The ceiling is a real bound. A search that cannot finish must return. */
@Test
fun `the search is bounded rather than endless`() {
assertNull(SignupProofOfWork.nonce("unreachable", bits = 32, ceiling = 5_000))
}
/**
* 🔴 A difficulty above the cap is not attempted at all. Trusting a number
* A difficulty above the cap is not attempted at all. Trusting a number
* from the network with a long loop is how an app comes to look hung.
*/
@Test
@@ -122,7 +122,7 @@ class SignupProofOfWorkTest {
// ---------------------------------------------------------------- never in the way
/** 🔴 Every one of these servers must still be able to create an account. */
/** Every one of these servers must still be able to create an account. */
@Test
fun `every server that cannot offer a challenge still allows sign-up`() {
val cases = listOf(
@@ -155,7 +155,7 @@ class SignupProofOfWorkTest {
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10)
}
/** ⚠️ Asked at `account/challenge`, beside `account/signup`. */
/** Asked at `account/challenge`, beside `account/signup`. */
@Test
fun `the challenge is asked for beside sign-up`() {
server.enqueue(MockResponse().setResponseCode(404))
@@ -172,12 +172,12 @@ class SignupProofOfWorkTest {
}
/**
* 🔴 **The whole reactive flow.** Refused for a missing challenge, we go
* The whole reactive flow. Refused for a missing challenge, we go
* 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
* running today — sees exactly one request and nobody waits twice.
* 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
* running today, sees exactly one request and nobody waits twice.
*/
@Test
fun `a relay that asks for work is answered and the sign-up goes through`() {
@@ -195,7 +195,7 @@ class SignupProofOfWorkTest {
val first = server.takeRequest()
assertEquals("/account/signup", first.path)
assertTrue(!first.body.readUtf8().contains("challenge"),
"the first attempt should carry no challenge — that round trip is the cost")
"the first attempt should carry no challenge, that round trip is the cost")
assertEquals("/account/challenge", server.takeRequest().path)
@@ -209,8 +209,8 @@ class SignupProofOfWorkTest {
}
/**
* 🔴 Rethrown, not swallowed. When this app genuinely cannot answer — a
* difficulty above the cap, or a relay that will not hand out a challenge —
* Rethrown, not swallowed. When this app genuinely 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.
*/
@@ -225,7 +225,7 @@ class SignupProofOfWorkTest {
}
/**
* ⚠️ A different sentence from the one above, and it must stay different.
* A different sentence from the one above, and it must stay different.
* This one is nearly always a challenge that expired while somebody was
* typing; the remedy is another tap, not a trip to the Play Store.
*/
@@ -239,7 +239,7 @@ class SignupProofOfWorkTest {
}
/**
* 🔴 The distinction that matters. A 400 is also a bad email, and telling
* The distinction that matters. A 400 is also a bad email, and telling
* somebody to update the app to fix a typo is worse than saying nothing.
*/
@Test
@@ -250,7 +250,7 @@ class SignupProofOfWorkTest {
}
}
/** ⚠️ Neither sentence blames the person or their password. */
/** Neither sentence blames the person or their password. */
@Test
fun `neither refusal reads as a credential problem`() {
val sentences = listOf(
@@ -7,19 +7,19 @@ 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
* than iOS managed — but two of them carried
* **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`.
*
* The gate stops it running by accident. It does not stop the password being
* written down. Every account those runs ever created can be signed into by
* anybody who can read this repository, and "they were throwaway accounts"
* does not change it — they are real accounts on a real service and they
* does not change it, they are real accounts on a real service and they
* persist until somebody deletes them.
*
* ⚠️ **A source scan, and coarse on purpose.** It cannot prove a suite is safe;
* A source scan, and coarse on purpose. It cannot prove a suite is safe;
* it proves the specific thing that went wrong cannot come back silently.
* That is worth having even though it is not a proof, and it costs a few
* milliseconds in the JVM suite rather than a device.
@@ -43,9 +43,9 @@ class TestSafetyTest {
/**
* Lines that are code rather than prose.
*
* ⚠️ Trailing comments go too. The Swift version of this guard flagged an
* Trailing comments go too. The Swift version of this guard flagged an
* *email* default because the word "password" appeared in a comment at the
* end of the line — and a guard that cries wolf is a guard somebody
* end of the line, and a guard that cries wolf is a guard somebody
* switches off.
*/
private fun codeLines(body: String): List<String> =
@@ -69,7 +69,7 @@ class TestSafetyTest {
/**
* Whether this file can actually reach a relay somebody else runs.
*
* ⚠️ **`MockWebServer` is the discriminator, and getting this wrong made
* **`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
@@ -87,7 +87,7 @@ class TestSafetyTest {
}
/**
* 🔴 **No credential may be a literal in a suite that reaches a relay.**
* No credential may be a literal in a suite that reaches a relay.
*
* The rule is scoped to files that actually sign up or sign in, because a
* purely local unit test using `passphrase = "pass"` against an in-memory
@@ -100,9 +100,9 @@ class TestSafetyTest {
/**
* A name that puts whatever follows it in a credential position.
*
* ⚠️ Deliberately NOT "secret". Test prose is full of it — a marker
* Deliberately NOT "secret". Test prose is full of it, a marker
* string called `secret` that the test then greps for in a container is
* a fixture, not a credential — and a check that cries wolf is a check
* a fixture, not a credential, and a check that cries wolf is a check
* somebody deletes.
*/
fun isCredentialName(name: String) = listOf("password", "passphrase", "credential")
@@ -123,12 +123,12 @@ class TestSafetyTest {
val before = line.substring(0, literal.range.first)
// 1. Anything assigned to a credential-shaped name, with or
// without `val` — `const val PASSPHRASE = "…"`,
// without `val`, `const val PASSPHRASE = "…"`,
// `password = "…"`, `passphrase = "…"`.
//
// 🔴 The `val` was once required, and `password = "…"` on
// its own line inside a multi-line argument list — the most
// ordinary Kotlin formatting there is — walked past it in
// The `val` was once required, and `password = "…"` on
// its own line inside a multi-line argument list, the most
// ordinary Kotlin formatting there is, walked past it in
// three suites.
val assignedTo = Regex("""(\w+)\s*=\s*$""").find(before)?.groupValues?.get(1)
if (assignedTo != null && isCredentialName(assignedTo)) {
@@ -139,10 +139,10 @@ class TestSafetyTest {
// 2. A positional literal handed to sign-up on the SAME
// line. Nothing else it could be.
//
// ⚠️ Same line only, and that is a deliberate limit rather
// Same line only, and that is a deliberate limit rather
// than an oversight. Widening it to a few lines around the
// call flagged test tags, assertion messages and message
// bodies as credentials — forty of them — which is how a
// bodies as credentials, forty of them, which is how a
// check stops being read. The multi-line case is covered by
// rule 1 instead, which sees `password = "…"` wherever it
// sits, and that is the formatting that actually occurs.
@@ -161,7 +161,7 @@ class TestSafetyTest {
the source: $offenders
This repository is readable by everybody who can read it, so that
string is a published password — and these suites create REAL
string is a published password, and these suites create REAL
accounts on a real service. Being gated behind an environment
variable stops it running by accident; it does not stop the password
being written down.
@@ -175,16 +175,16 @@ class TestSafetyTest {
}
/**
* 🔴 **Every suite that reaches a live relay must be able to decline.**
* Every suite that reaches a live relay must be able to decline.
*
* `assumeTrue` reports SKIPPED, which is the truth. A bare `return` makes a
* test that did nothing report PASSED, and a suite of those is
* indistinguishable from a suite that works — the point `LiveShapesTest`
* indistinguishable from a suite that works, the point `LiveShapesTest`
* already makes in prose and this makes checkable.
*/
@Test
fun `every live suite can announce a skip`() {
// ⚠️ Scoped to what a file DOES, not to what it is called — see
// Scoped to what a file DOES, not to what it is called, see
// `reachesALiveRelay`.
val offenders = testSources()
.filter { reachesALiveRelay(codeLines(it.readText())) }
@@ -199,8 +199,8 @@ class TestSafetyTest {
}
/**
* ⚠️ A scan that finds nothing passes. This proves it is reading the real
* tree — a moved directory would otherwise turn both checks above into
* A scan that finds nothing passes. This proves it is reading the real
* tree, a moved directory would otherwise turn both checks above into
* no-ops reporting success.
*/
@Test
@@ -212,9 +212,9 @@ class TestSafetyTest {
found.any { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } },
"the scan cannot see into the files it is reading",
)
// 🔴 And the live-relay predicate must actually match something. If it
// And the live-relay predicate must actually match something. If it
// matched nothing, both checks above would pass by looking at an empty
// set — the exact way a guard reports success while guarding nothing.
// set, the exact way a guard reports success while guarding nothing.
val live = found.filter { reachesALiveRelay(codeLines(it.readText())) }
assertTrue(
live.size >= 5,
@@ -17,17 +17,17 @@ import kotlin.test.assertTrue
/**
* The scheduled half of key transparency (§19.3, §19.4, audit S4/M1).
*
* 🔴 **`verifyLogConsistencyIfDue` and `consistent` were at 0%, and
* **`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
* the conversation worth attacking, so a relay that rewound or forked between
* two bundle fetches would go unseen, possibly for ever.
*
* Two failure directions matter equally here and pull against each other. A
* check that never fires misses a real fork. A check that treats every network
* blip as a fork cries wolf until the alarm means nothing, which is worse than
* no alarm — so half of these tests assert that something is NOT reported.
* no alarm, so half of these tests assert that something is NOT reported.
*/
class TrustLogConsistencyTest {
@@ -44,9 +44,9 @@ class TrustLogConsistencyTest {
/**
* The relay the object under test is pointed at.
*
* ⚠️ The key-log pin is stored per relay, because a single pin meant that
* The key-log pin is stored per relay, because a single pin meant that
* signing in to a second relay checked ITS head against the first relay's
* root — which cannot reconcile, so an honest relay was reported as forked.
* root, which cannot reconcile, so an honest relay was reported as forked.
* A test reading the pin has to name the same relay that wrote it.
*/
private val relayHost: String get() = server.url("/").host
@@ -125,7 +125,7 @@ class TrustLogConsistencyTest {
}
/**
* 🔴 A log that got SMALLER is a rewind, and no proof can excuse it. This
* A log that got SMALLER is a rewind, and no proof can excuse it. This
* is the cheapest attack to attempt and the one an unscheduled check would
* never see.
*/
@@ -163,7 +163,7 @@ class TrustLogConsistencyTest {
}
/**
* 🔴 A head that does not extend our pin, with a proof that does not close
* A head that does not extend our pin, with a proof that does not close
* the gap, is the actual split-view attack.
*/
@Test
@@ -180,7 +180,7 @@ class TrustLogConsistencyTest {
}
/**
* ⚠️ The network is allowed to be down. Reporting a fork because a fetch
* The network is allowed to be down. Reporting a fork because a fetch
* failed would raise a standing, unclearable alarm on a working relay.
*/
@Test
@@ -260,8 +260,8 @@ class TrustLogConsistencyTest {
}
/**
* 🔴 Pinned, not fetched-and-trusted. Once an issuer key is pinned, a head
* signed by anyone else is a fork — that is the whole value of pinning it.
* Pinned, not fetched-and-trusted. Once an issuer key is pinned, a head
* signed by anyone else is a fork, that is the whole value of pinning it.
*/
@Test
fun `a head signed by a different key than the pinned one is a fork`() {
@@ -277,9 +277,9 @@ class TrustLogConsistencyTest {
}
/**
* ⚠️ Silent when nothing is signed. A deployment that cannot sign is
* Silent when nothing is signed. A deployment that cannot sign is
* weaker, not hostile, and refusing it outright is a worse failure than the
* one prevented — self-hosters would simply be locked out.
* one prevented, self-hosters would simply be locked out.
*/
@Test
fun `an unsigned head is accepted rather than refused`() {
@@ -333,7 +333,7 @@ class TrustLogConsistencyTest {
}
/**
* ⚠️ Different sizes are not evidence by themselves — one of us is simply
* Different sizes are not evidence by themselves, one of us is simply
* behind. Treating that as a fork would fire constantly in normal use.
*/
@Test
@@ -372,7 +372,7 @@ class TrustLogConsistencyTest {
}
/**
* 🔴 A relay that forked once does not become trustworthy because the next
* A relay that forked once does not become trustworthy because the next
* request looked fine. The alarm is standing, and nothing here clears it.
*/
@Test
@@ -38,9 +38,9 @@ class TrustTest {
/**
* The relay the object under test is pointed at.
*
* ⚠️ The key-log pin is stored per relay, because a single pin meant that
* The key-log pin is stored per relay, because a single pin meant that
* signing in to a second relay checked ITS head against the first relay's
* root — which cannot reconcile, so an honest relay was reported as forked.
* root, which cannot reconcile, so an honest relay was reported as forked.
* A test reading the pin has to name the same relay that wrote it.
*/
private val relayHost: String get() = server.url("/").host
@@ -65,9 +65,9 @@ class TrustTest {
// -- A detected change has to SURVIVE ------------------------------------
/**
* 🔴 The bug these guard, found by driving two real apps: `record()` worked
* The bug these guard, found by driving two real apps: `record()` worked
* out CHANGED during a send and then threw it away, because nothing stored
* it. `state()` could only answer UNKNOWN, VERIFIED or PINNED — so the chat
* it. `state()` could only answer UNKNOWN, VERIFIED or PINNED, so the chat
* header went on saying "keys unchanged since you met" while every send was
* refused with "identity keys changed", and the "Accept new keys" button,
* which only renders for CHANGED, could never appear. A contact who
@@ -96,12 +96,12 @@ 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.
*
* ⚠️ It went unnoticed while only the bundle route carried an address:
* It went unnoticed while only the bundle route carried an address:
* that route is reached once per conversation, when a session is first
* established. The directory carries it on every send.
*/
@@ -171,7 +171,7 @@ class TrustTest {
}
@Test
fun `a subset is still the same person — devices go away`() {
fun `a subset is still the same person, devices go away`() {
val t = trust()
t.record(7, listOf(key(1), key(2)))
// One device removed is not an identity change. Treating it as one
@@ -180,7 +180,7 @@ class TrustTest {
}
/**
* 🔴 The attack. A relay adds ONE key of its own alongside the real ones.
* 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
* person, while a device the attacker controls receives a copy of
@@ -257,10 +257,10 @@ class TrustTest {
}
/**
* ⚠️ An unreachable relay must not pin "no address".
* An unreachable relay must not pin "no address".
*
* Baking that in would be permanent — `rememberIdentity` does nothing once
* an address is pinned — and the next successful lookup would then look
* Baking that in would be permanent, `rememberIdentity` does nothing once
* an address is pinned, and the next successful lookup would then look
* like the address changing, which is the alarm.
*/
@Test
@@ -276,7 +276,7 @@ class TrustTest {
}
/**
* 🔴 A different address is a different account key, so a different person.
* A different address is a different account key, so a different person.
*
* Not a new device, whatever the relay presents it as: the fnid is derived
* from the account key.
@@ -343,7 +343,7 @@ class TrustTest {
}
/**
* 🔴 A key the relay serves but never logged must be refused.
* 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
* whatever key it likes, and the client's job is to notice that no such
@@ -357,7 +357,7 @@ class TrustTest {
trust().enforceKeyLog(bundle("d1", key(1)), 7)
}
assertEquals("d1", e.deviceId)
// Nothing logged at all — this is the shape a substitution takes, and
// Nothing logged at all, this is the shape a substitution takes, and
// it must not be confused with a subject we could not compute.
assertFalse(e.hadEntries)
}
@@ -380,10 +380,10 @@ class TrustTest {
}
/**
* ⚠️ The relay must not be able to pass by echoing its own bytes back.
* The relay must not be able to pass by echoing its own bytes back.
*
* Here the log lists an entry and the inclusion response is internally
* consistent — but for a leaf that is not the one this bundle implies.
* consistent, but for a leaf that is not the one this bundle implies.
*/
@Test
fun `an inclusion proof for someone else's leaf is refused`() {
@@ -422,7 +422,7 @@ class TrustTest {
}
/**
* ⚠️ An unreachable relay is not evidence of anything.
* An unreachable relay is not evidence of anything.
*
* Crying fork whenever the network is down trains everyone to ignore the
* warning, which is worse than not having it.
@@ -12,8 +12,8 @@ import kotlin.test.assertTrue
/**
* The update check for an app nothing else updates.
*
* 🔴 The case worth pinning hardest is the one that is not about versions at
* all: **the download URL must come from the relay's own origin**, whatever the
* The case worth pinning hardest is the one that is not about versions at
* all: the download URL must come from the relay's own origin, whatever the
* manifest says. A relay that could name the APK host could hand its users a
* different app.
*/