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