Second pass with the avoid-ai-writing detector
The skill's detector scored the README, DESIGN.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 3 out of 100 across the repo.
One sweep rule mangled two comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.
Comments and strings only. 764 XCTest and 55 Swift Testing pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f911d13baf
commit
2585c4e9f9
+2
-2
@@ -10,14 +10,14 @@ HEAD, and that switch was thrown three times in twelve hours
|
||||
(flamenet-relay#56), once producing a deadlock with no way out from inside
|
||||
either repository. Its `.e2e-ref.md` has the full account.
|
||||
|
||||
**A pin alone is worse than HEAD, though.** The argument for HEAD is real: an
|
||||
A pin alone is worse than HEAD, though. The argument for HEAD is real: an
|
||||
app tested against a pinned old harness is tested against a pinned old spec. If
|
||||
the vectors move and nothing says so, `ProtocolVectorTests` quietly goes on
|
||||
checking last month's protocol and reports green, which is precisely the
|
||||
failure that suite exists to prevent, reintroduced one level up.
|
||||
|
||||
That is what `vectors-head` is for, and why it is `allow_failure: true`.
|
||||
**Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.**
|
||||
Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.
|
||||
|
||||
## Taking an update
|
||||
|
||||
|
||||
+4
-4
@@ -12,8 +12,8 @@ unit-tests:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
# **The obfs4 transport is a submodule, and the runner skips those by
|
||||
# default.** Without this the job fails at package resolution with
|
||||
# The obfs4 transport is a submodule, and the runner skips those by
|
||||
# default. Without this the job fails at package resolution with
|
||||
# "the package manifest at Packages/Obfs4/Package.swift cannot be
|
||||
# accessed", which reads as a missing file rather than a missing clone.
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
@@ -35,8 +35,8 @@ unit-tests:
|
||||
# ever broke on CI. It broke on 2026-08-31 when upstream MOVED the
|
||||
# `151.0.0` tag of stasel/WebRTC, a prebuilt XCFramework, from
|
||||
# 19aa8c1f to 3e43fa9b. The pin is what noticed.
|
||||
# **The protocol vectors are copied into `Tests/Vectors`, so check the
|
||||
# copy is current.** They are copied rather than resolved from a sibling
|
||||
# The protocol vectors are copied into `Tests/Vectors`, so check the
|
||||
# copy is current. They are copied rather than resolved from a sibling
|
||||
# checkout because that is exactly how Android lost twenty conformance tests
|
||||
# its engine looks for `../../../flamenet-e2e/...`, which exists on a
|
||||
# developer's machine and never in a build directory, so its suite failed
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
|
||||
|
||||
That unit suite is the merge gate and runs on the project's own macOS runner.
|
||||
|
||||
**UI tests are not in the gate**, on purpose: `FlamenetMessengerUITests` needs a
|
||||
UI tests are not in the gate, on purpose: `FlamenetMessengerUITests` needs a
|
||||
local relay listening on port 18810 and, for the conversation tests, two booted
|
||||
simulators. Without the relay the suite **skips rather than fails**: a green
|
||||
run that tested nothing looks identical to a green run that tested everything,
|
||||
@@ -42,7 +42,7 @@ so check the executed count, not the tick.
|
||||
|
||||
Two traps that will cost you an hour each:
|
||||
|
||||
- **The suite occasionally reports `TEST FAILED` with no failing test.** A clean
|
||||
- The suite occasionally reports `TEST FAILED` with no failing test. A clean
|
||||
rerun passes. Rerun before believing it.
|
||||
- **Two test runners against one simulator** produce what looks like an app
|
||||
crash and is not. One runner per device.
|
||||
|
||||
@@ -4,12 +4,12 @@ Extracted from `Messenger App V2.sketch` (Sketch 71, 120 artboards, ~280
|
||||
symbols) by reading the file's JSON directly. Every number below is measured
|
||||
from a symbol master, not eyeballed from a render.
|
||||
|
||||
**The layout is theirs; the colour is ours.** Flamenet orange `#FF5A1F`
|
||||
The layout is theirs; the colour is ours. Flamenet orange `#FF5A1F`
|
||||
stays. The kit's primary is blue `#2298FF` and adopting it would mean the app
|
||||
no longer looks like the product it belongs to. Everywhere the kit says
|
||||
"primary", read Flamenet orange.
|
||||
|
||||
**The typeface is not adopted, only the scale.** The kit is set in DM Sans.
|
||||
The typeface is not adopted, only the scale. The kit is set in DM Sans.
|
||||
Bundling it means shipping font files and an OFL notice, which is a licensing
|
||||
decision rather than a layout one, so the sizes, weights and line heights
|
||||
below are applied to the system face. Swapping the face in later changes
|
||||
@@ -58,7 +58,7 @@ The 64 is not a gap of 64, it is the 48pt avatar plus a 16pt gutter.
|
||||
|
||||
## Message bubbles
|
||||
|
||||
**The corners are asymmetric, and that is the whole character of them.**
|
||||
The corners are asymmetric, and that is the whole character of them.
|
||||
Every corner is radius 16 except one, which is radius 2 and points at its
|
||||
author: **bottom-right** on a message you sent, **top-left** on one you
|
||||
received. Rounding all four equally throws away the only thing that
|
||||
@@ -105,7 +105,7 @@ nearby; profile follow/following.
|
||||
|
||||
## Settings and list screens
|
||||
|
||||
**The kit does not use cards here either.** Rows are grouped into sections,
|
||||
The kit does not use cards here either. Rows are grouped into sections,
|
||||
and sections are separated by a **full-bleed 16pt band filled `#F3F3F3`**: a
|
||||
thick grey spacer spanning the whole width, not a hairline and not a gap
|
||||
between floating surfaces. Inside a section the rows butt up against each
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Export compliance, v0.1 (decided 2026-08-19)
|
||||
|
||||
**Answer on the App Store Connect encryption questions:**
|
||||
Answer on the App Store Connect encryption questions:
|
||||
|
||||
- *Does your app use encryption?*, **Yes**
|
||||
- *Does it qualify for any of the exemptions?*, **No** (it implements its own
|
||||
|
||||
+5
-5
@@ -51,9 +51,9 @@ is there, it carries no sender attribution across the boundary, a relay off
|
||||
the allowlist is refused, and an unknown address is reported rather than
|
||||
silently dropped.
|
||||
|
||||
**Re-run 2026-08-28 against the live fleet: 11 passed, 0 failed.** Unchanged.
|
||||
Re-run 2026-08-28 against the live fleet: 11 passed, 0 failed. Unchanged.
|
||||
|
||||
**iOS now has the client path; Android still does not.** `E2EClient` gained
|
||||
iOS now has the client path; Android still does not. `E2EClient` gained
|
||||
`remoteDevices`, `remoteBundles` and `sendRemote`, and `E2EInbox` gained
|
||||
`transmitRemote`, `deliverRemote` and a separate `remoteThreads` keyed on
|
||||
`fnid@relay`. Android calls none of it, it still reads the `federation` field
|
||||
@@ -105,7 +105,7 @@ Live and on **production keys**: `sk_live` / `pk_live` confirmed on the
|
||||
box. Stripe.js loads, the form is present, and the card fields appear after
|
||||
Continue by design.
|
||||
|
||||
**Not exercised: a payment taken end to end.** Doing so means creating a
|
||||
Not exercised: a payment taken end to end. Doing so means creating a
|
||||
real PaymentIntent on the live connected account, so it was left alone. Closed
|
||||
on the owner's call 2026-08-27. Recorded here rather than dropped, because "we
|
||||
verified the donation page" and "we watched a payment complete" are different
|
||||
@@ -121,7 +121,7 @@ received. iOS never had this. Fixed, with a regression test proved failing
|
||||
without the guard, and both platforms now hold undelivered messages in an
|
||||
outbox and retry rather than dropping them.
|
||||
|
||||
**Android → iOS calls do not connect, cause unknown.** Ruled out: wire
|
||||
Android → iOS calls do not connect, cause unknown. Ruled out: wire
|
||||
shapes match, iOS hooks `onCallSignal` eagerly at sign-in, Android sets no
|
||||
`from_addr` so the federated fork is not swallowing it, and every clock agrees
|
||||
within a second so the 45s staleness check is not misfiring. The real defect
|
||||
@@ -129,7 +129,7 @@ found was that Android discarded the send result entirely, so an offer that
|
||||
reached nobody was indistinguishable from one that rang. Both sides are now
|
||||
instrumented; the next attempt will say where it stopped.
|
||||
|
||||
**iOS conversation history had been written and never merged.** The
|
||||
iOS conversation history had been written and never merged. The
|
||||
encrypted store sat alone on a parked branch while main moved 174 commits past
|
||||
it, so the app discarded every conversation on quit. Restored and now covered
|
||||
by five tests.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
## The one thing this constraint costs
|
||||
|
||||
**On iOS, a closed app cannot be woken without APNs. There is no alternative.**
|
||||
On iOS, a closed app cannot be woken without APNs. There is no alternative.
|
||||
|
||||
iOS suspends a backgrounded process and tears down its sockets. A long-lived
|
||||
connection, a NATS client, a foreground service, none of these exist on iOS.
|
||||
@@ -52,7 +52,7 @@ GET /e2e/poll?after=<cursor> # server holds the request open ~30–60s,
|
||||
| requests per device | ~12/min | ~1–2/min |
|
||||
| battery | a wake every 5s | one idle socket |
|
||||
|
||||
**This is strictly better on every axis and involves nobody.** It is the
|
||||
This is strictly better on every axis and involves nobody. It is the
|
||||
single highest-value change here, it is a relay + client change only, and it
|
||||
makes the app feel like it pushes whenever it is open, which covers the whole
|
||||
iOS story and most of the Android one.
|
||||
|
||||
@@ -28,7 +28,7 @@ public struct AccountsClient: Sendable {
|
||||
///
|
||||
/// An unknown handle gets a deterministic fake, so this cannot be used to
|
||||
/// discover who has an account, and this client cannot tell the
|
||||
/// difference, which is the point. Do not add a "handle available?" check
|
||||
/// difference. Do not add a "handle available?" check
|
||||
/// on top of it.
|
||||
public func params(handle: String) async throws -> Wire.KDFParams {
|
||||
let lowered = handle.lowercased()
|
||||
|
||||
@@ -369,7 +369,7 @@ public actor FilesSession {
|
||||
///
|
||||
/// `createShare`/`openShare` minted a `/s/<id>#<secret>` URL: the secret
|
||||
/// rode in the fragment, which browsers never transmit, so the server
|
||||
/// genuinely could not open what it served. Cryptographically that was
|
||||
/// could not open what it served. Cryptographically that was
|
||||
/// fine. What was not fine is what the link *is*, a bearer token for a
|
||||
/// classified document, pasteable into any chat, mailable to anybody,
|
||||
/// forwardable for ever, and with nothing on the far end that knows or
|
||||
|
||||
@@ -265,15 +265,15 @@ public actor LocalVault {
|
||||
|
||||
/// Turn a reference into an encrypted copy, in place.
|
||||
///
|
||||
/// **The bridge between the two ways of holding a file, and the reason
|
||||
/// both exist.** Referencing avoids duplicating anything; vaulting is what
|
||||
/// The bridge between the two ways of holding a file, and the reason
|
||||
/// both exist. Referencing avoids duplicating anything; vaulting is what
|
||||
/// makes a classification a control rather than a note. Most files want the
|
||||
/// first and a few want the second, and nobody knows which is which until
|
||||
/// after they have added them, so this promotes one without re-picking it.
|
||||
///
|
||||
/// It does NOT delete the person's original: that is their file, and
|
||||
/// removing it is a separate decision the interface asks about afterwards.
|
||||
/// Until they do, this genuinely is two copies, the honest trade for
|
||||
/// Until they do, this is two copies, the honest trade for
|
||||
/// having the encrypted one.
|
||||
///
|
||||
/// Returns the URL the original was read from, so a caller can offer that.
|
||||
@@ -306,7 +306,7 @@ public actor LocalVault {
|
||||
updated.storage = .vault
|
||||
// The bookmark goes. Keeping it would leave a vaulted row still able
|
||||
// to read through to a plaintext original, so the encrypted copy would
|
||||
// not be the only way in, which is the entire point of vaulting it.
|
||||
// not be the only way in, which is the point of vaulting it.
|
||||
updated.bookmark = nil
|
||||
updated.wrappedContentKey = try Keys.wrapContentKey(contentKey, nodeKey: nodeKey,
|
||||
label: node.label)
|
||||
|
||||
@@ -7,8 +7,8 @@ import Foundation
|
||||
/// device and is nobody's business. A balance is different: it lives on a
|
||||
/// public ledger, and the only way to read one is to ask a node.
|
||||
///
|
||||
/// **Which undoes some of what fresh addresses buy, and that has to be
|
||||
/// said.** A new address per receive stops anyone reading the chain from
|
||||
/// Which undoes some of what fresh addresses buy, and that has to be
|
||||
/// said. A new address per receive stops anyone reading the chain from
|
||||
/// grouping your receipts. Ask one endpoint about all of them from one
|
||||
/// connection and that endpoint can group them anyway, it now knows those
|
||||
/// addresses belong to one person, even though the ledger does not. The chain
|
||||
|
||||
@@ -470,8 +470,8 @@ final class LocalVaultTests: XCTestCase {
|
||||
|
||||
// MARK: - Walking the tree (ios#66) and the send picker (ios#68)
|
||||
|
||||
/// **A cycle cannot be built through this API, and the index is still a
|
||||
/// file on disk that something else could write.** The old bound was a step
|
||||
/// A cycle cannot be built through this API, and the index is still a
|
||||
/// file on disk that something else could write. The old bound was a step
|
||||
/// counter, it terminated and then handed back ten thousand copies of the
|
||||
/// same two ids, which the "delete this folder and everything in it" screen
|
||||
/// would have offered to act on.
|
||||
|
||||
@@ -4,8 +4,8 @@ import XCTest
|
||||
|
||||
/// Submitting a transaction, and finding out what became of it.
|
||||
///
|
||||
/// The rule these exist to pin: **a send that appears to fail is resolved by
|
||||
/// looking up its signature, never by sending again.** A blockhash stays valid
|
||||
/// The rule these exist to pin: a send that appears to fail is resolved by
|
||||
/// looking up its signature, never by sending again. A blockhash stays valid
|
||||
/// for about two minutes, so a submission that timed out may still be accepted
|
||||
/// afterwards, and a blind retry sends the money twice.
|
||||
final class ChainSendTests: XCTestCase {
|
||||
|
||||
@@ -8,7 +8,7 @@ import Foundation
|
||||
/// These vectors were produced by the JavaScript engine that runs in the
|
||||
/// browser. Nothing here checks that this code is self-consistent, it checks
|
||||
/// that it agrees with the implementation people actually use. A framing,
|
||||
/// authenticated-data or key-hierarchy mistake fails here, which is the point.
|
||||
/// authenticated-data or key-hierarchy mistake fails here.
|
||||
final class InteropTests: XCTestCase {
|
||||
private struct Vectors: Decodable {
|
||||
let version: Int
|
||||
|
||||
@@ -4,8 +4,8 @@ import XCTest
|
||||
|
||||
/// The bytes a signature covers.
|
||||
///
|
||||
/// **These tests decode the message back rather than comparing it to a
|
||||
/// string this file also wrote.** A serialiser that agrees with its own
|
||||
/// These tests decode the message back rather than comparing it to a
|
||||
/// string this file also wrote. A serialiser that agrees with its own
|
||||
/// expectations and disagrees with the chain is exactly the failure worth
|
||||
/// guarding against, so the reader below is written independently of the
|
||||
/// writer, it walks the documented layout and asserts what it finds.
|
||||
@@ -15,7 +15,7 @@ final class SolanaTransactionTests: XCTestCase {
|
||||
|
||||
/// Published boundary values. A plain byte works for every length below
|
||||
/// 128 and then silently produces a different message, so the boundary is
|
||||
/// the whole point of the test.
|
||||
/// the point of the test.
|
||||
func testCompactU16MatchesTheDocumentedEncoding() {
|
||||
XCTAssertEqual(Array(SolanaTransaction.compactU16(0)), [0x00])
|
||||
XCTAssertEqual(Array(SolanaTransaction.compactU16(1)), [0x01])
|
||||
|
||||
@@ -27,7 +27,7 @@ pipeline:
|
||||
| [flamenet-relay](https://gitlab.com/paulhitt/flamenet-relay) | The server |
|
||||
| [flamenet-messenger-android](https://gitlab.com/paulhitt/flamenet-messenger-android) | The Android app |
|
||||
|
||||
**Until 2026-08-28 those three lived here as subtrees**, and the public
|
||||
Until 2026-08-28 those three lived here as subtrees, and the public
|
||||
repos were rsync extracts of them, refreshed by hand. The extracts drifted for
|
||||
days at a time. They are standalone now, split out with their history, work on
|
||||
each product happens in its own repository, and a patch to a subtree here has
|
||||
@@ -43,7 +43,7 @@ nowhere to go because the subtrees are gone.
|
||||
|
||||
## The three engines
|
||||
|
||||
The protocol is implemented three times, and that is the point rather than an
|
||||
The protocol is implemented three times rather than an
|
||||
accident. Two implementations that agree may agree because one was written
|
||||
while reading the other; a third, written against the specification and the
|
||||
published vectors, is what finds the places where the spec says less than the
|
||||
|
||||
+2
-2
@@ -101,7 +101,7 @@ These block a build reaching anyone, no matter what the app contains.
|
||||
| 6 | **No export pipeline.** No fastlane, no exportOptions plist. | Fine for a first release, the Xcode Organizer does it. Automate later |
|
||||
| 7 | **Landing page.** A `TODO` waiting on the TestFlight link. | Still waiting on the link |
|
||||
|
||||
**A prerequisite that did not exist when this was written:** the app had no
|
||||
A prerequisite that did not exist when this was written: the app had no
|
||||
backend. It defaulted to `https://flamenet.io`, whose plugin was removed on
|
||||
2026-08-20. It is pinned to `messenger.flamenet.io` now, which means the relay
|
||||
has to be deployed before a tester can do anything at all. DNS for that name
|
||||
@@ -115,7 +115,7 @@ than no build.
|
||||
|
||||
## Recommended v1
|
||||
|
||||
**Three tabs. Encrypted 1:1 messaging, a member directory, and slash commands.**
|
||||
Three tabs. Encrypted 1:1 messaging, a member directory, and slash commands.
|
||||
Described honestly as a private beta of an encrypted messenger, not as a Signal
|
||||
alternative, and without repeating the "we can't read your messages" claim that is false
|
||||
for the web client.
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ It exits non-zero on a HIGH finding.
|
||||
|
||||
## Why these checks and not others
|
||||
|
||||
**Every check here has a SILENT failure mode.** A feature missing from one
|
||||
Every check here has a SILENT failure mode. A feature missing from one
|
||||
app is visible the moment somebody opens the screen. These are not:
|
||||
|
||||
- **Envelope kinds.** A kind one app sends and the other cannot parse is
|
||||
@@ -16,7 +16,7 @@ app is visible the moment somebody opens the screen. These are not:
|
||||
`shareBridge` shipped that way the same day: you could receive a bridge and
|
||||
had no way to send one.
|
||||
|
||||
**A scrub that cries wolf is worse than no scrub.** The first version of the
|
||||
A scrub that cries wolf is worse than no scrub. The first version of the
|
||||
unwired check reported 26 findings, of which almost all were wired perfectly
|
||||
well. Kotlin passes these as method references (`run = model::hangUp`), which
|
||||
the pattern missed. Anything this reports should be worth reading. If it starts
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
# signing up on the live relay leaves a real account behind and spends the
|
||||
# account service's three-per-hour-per-IP budget.
|
||||
#
|
||||
# **Three traps, all of which cost me an hour on 2026-09-05, all encoded
|
||||
# below. None is obvious and every one fails SILENTLY.**
|
||||
# Three traps, all of which cost me an hour on 2026-09-05, all encoded
|
||||
# below. None is obvious and every one fails SILENTLY.
|
||||
#
|
||||
# 1. `input text` drops characters on longer strings. A 21-character
|
||||
# passphrase arrived as 11. Nothing reports it; the sign-in simply fails
|
||||
|
||||
@@ -37,7 +37,7 @@ log() { printf '\n=== %s\n' "$*"; }
|
||||
# 2026-09-01: this gate built and ran two simulators at once and drove the load
|
||||
# average to 591, it starved the owner's own emulator and simulator out of
|
||||
# existence mid-session, and any result it produced at that load would have been
|
||||
# noise anyway. The two sides genuinely have to run CONCURRENTLY (each is the
|
||||
# noise anyway. The two sides have to run CONCURRENTLY (each is the
|
||||
# other's peer, and the rendezvous is a retrying lookup), so the concurrency
|
||||
# cannot be traded away, which leaves refusing to start.
|
||||
#
|
||||
@@ -51,7 +51,7 @@ fi
|
||||
|
||||
# --- two simulators -----------------------------------------------------------
|
||||
#
|
||||
# Two DIFFERENT devices, not one device twice. The whole point is two
|
||||
# Two DIFFERENT devices, not one device twice. The point is two
|
||||
# Keychains, two device identities and two ratchets; one simulator running the
|
||||
# test twice would share all three and pass while proving nothing.
|
||||
runtime="$(xcrun simctl list runtimes -j | /usr/bin/python3 -c '
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ run() { # name, command...
|
||||
|
||||
# Like `run`, but refuses to call a run that executed nothing a pass.
|
||||
#
|
||||
# **xcodebuild exits 0 and prints "passed" for a suite in which zero tests
|
||||
# ran.** Observed 2026-08-25 after a runner crashed and relaunched:
|
||||
# xcodebuild exits 0 and prints "passed" for a suite in which zero tests
|
||||
# ran. Observed 2026-08-25 after a runner crashed and relaunched:
|
||||
#
|
||||
# Test Suite 'TwoSimulatorUITests' passed at 15:51:02
|
||||
# Executed 0 tests, with 0 failures (0 unexpected) in 0.000 seconds
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# emulator was killed by memory pressure four times in one session, each time
|
||||
# mid-check. This takes seconds and needs no device.
|
||||
#
|
||||
# The QR check is macOS-only: it decodes with CoreImage, which is the point
|
||||
# The QR check is macOS-only: it decodes with CoreImage
|
||||
# an implementation that shares no code with our encoder. On another host the
|
||||
# screenshots still render and this step is skipped.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -41,7 +41,7 @@ fi
|
||||
# Known test fixtures, allowlisted by their exact literal value.
|
||||
#
|
||||
# A gate that is always red is a gate everybody learns to ignore, so the
|
||||
# false positives that are genuinely fixtures get named here, narrowly, by
|
||||
# false positives that are fixtures get named here, narrowly, by
|
||||
# value, and never by directory. Excluding `UITests/` wholesale would be the
|
||||
# easy fix and the wrong one: it is a place a real credential could then hide.
|
||||
# Adding to this list is a deliberate act; if you cannot write the literal out
|
||||
|
||||
@@ -301,8 +301,8 @@ final class AppEnvironment: ObservableObject {
|
||||
+ "registers it as a new device."
|
||||
}
|
||||
|
||||
// **Registration is per session, because the token is per device
|
||||
// AND the relay stores it against a user.** Registering before there
|
||||
// Registration is per session, because the token is per device
|
||||
// AND the relay stores it against a user. Registering before there
|
||||
// is somewhere to send it means a token that arrives, finds no
|
||||
// uploader, and is dropped, the device then never gets woken and
|
||||
// nothing says why.
|
||||
@@ -535,7 +535,7 @@ final class AppEnvironment: ObservableObject {
|
||||
/// ends the session and leaves the device intact.
|
||||
///
|
||||
/// Signing out used to cost every one of your contacts a prompt. It
|
||||
/// wiped the identity, so signing back in was a genuinely NEW device: every
|
||||
/// wiped the identity, so signing back in was a NEW device: every
|
||||
/// contact had the old key pinned, saw a safety-number change, and could
|
||||
/// not message you until they accepted it. For someone stepping away for an
|
||||
/// afternoon that is a strange price, and it is the reason signing out and
|
||||
@@ -589,8 +589,8 @@ final class AppEnvironment: ObservableObject {
|
||||
// Bounded, and its failure cannot block step 3.
|
||||
await inbox.retireThisDevice()
|
||||
|
||||
// 3. **Erase the conversation and the keys, not just the
|
||||
// credential.** This used to clear the app password and stop
|
||||
// 3. Erase the conversation and the keys, not just the
|
||||
// credential. This used to clear the app password and stop
|
||||
// there, so a signed-out, or DELETED, account left
|
||||
// `history-<uid>.bin` on disk with its key still in the
|
||||
// Keychain, alongside identity keys, ratchet state and pinned
|
||||
@@ -689,7 +689,7 @@ final class AppEnvironment: ObservableObject {
|
||||
cfg.supportsCommands = hasCommands
|
||||
store.config = cfg
|
||||
// Rebuild ONLY when the prefix moved. Every URL in the client graph
|
||||
// is derived from the prefix, so that genuinely needs a new client
|
||||
// is derived from the prefix, so that needs a new client
|
||||
// but `supportsCommands` is a flag the views read, and rebuilding
|
||||
// for it tore the object graph out from under whatever was on
|
||||
// screen. That is how tapping "Create an account" after typing a
|
||||
|
||||
@@ -4,8 +4,8 @@ import SwiftUI
|
||||
|
||||
/// Face ID / Touch ID / passcode in front of the app.
|
||||
///
|
||||
/// **What this defends against, stated plainly, because the alternative is
|
||||
/// implying more:** somebody who picks up your unlocked phone. That is the
|
||||
/// What this defends against, stated plainly, because the alternative is
|
||||
/// implying more: somebody who picks up your unlocked phone. That is the
|
||||
/// threat people actually have, and it is worth something.
|
||||
///
|
||||
/// What it does not defend against: anyone with the device and the tools to
|
||||
@@ -46,8 +46,8 @@ final class AppLock: ObservableObject {
|
||||
|
||||
// MARK: - Backing off
|
||||
//
|
||||
// **`nonisolated` on both constants below, because `delay(afterFailures:)`
|
||||
// is nonisolated and reads them.** The class is `@MainActor`, which isolates
|
||||
// `nonisolated` on both constants below, because `delay(afterFailures:)`
|
||||
// is nonisolated and reads them. The class is `@MainActor`, which isolates
|
||||
// its statics too, so under Swift 6 a nonisolated function cannot see them.
|
||||
// Making the *function* main-actor instead would be the wrong repair: it is
|
||||
// pure arithmetic over its argument, and callers work out the wait before
|
||||
@@ -84,8 +84,8 @@ final class AppLock: ObservableObject {
|
||||
|
||||
/// When the next attempt is allowed, or nil if one is allowed now.
|
||||
///
|
||||
/// **Wall-clock, and somebody who can change the device clock can skip
|
||||
/// the wait.** Said rather than hidden: this slows a person holding the
|
||||
/// Wall-clock, and somebody who can change the device clock can skip
|
||||
/// the wait. Said rather than hidden: this slows a person holding the
|
||||
/// phone, and it is not a defence against somebody with the device and the
|
||||
/// patience to work at it. What actually protects the messages is the
|
||||
/// Keychain and file protection, exactly as the note at the top says.
|
||||
@@ -102,7 +102,7 @@ final class AppLock: ObservableObject {
|
||||
return max(0, Int(until.timeIntervalSinceNow.rounded(.up)))
|
||||
}
|
||||
|
||||
/// Only a genuine failure counts. Dismissing the prompt is not an
|
||||
/// Only a failure counts. Dismissing the prompt is not an
|
||||
/// attempt, counting a cancel would let the owner back themselves into a
|
||||
/// wait by changing their mind twice.
|
||||
private func recordFailure() {
|
||||
|
||||
@@ -12,8 +12,8 @@ final class FlamenetNotificationDelegate: NSObject, UNUserNotificationCenterDele
|
||||
|
||||
/// What to show for a notification that arrives while the app is OPEN.
|
||||
///
|
||||
/// **This is the only funnel, and it used to say "banner" to
|
||||
/// everything.** Suppressing banners inside `postIncomingMessage` fixed
|
||||
/// This is the only funnel, and it used to say "banner" to
|
||||
/// everything. Suppressing banners inside `postIncomingMessage` fixed
|
||||
/// nothing visible, because the banners people actually see are not posted
|
||||
/// by the app at all, the relay pushes them. A push arrives here having
|
||||
/// gone nowhere near `NotificationManager`, and this method waved it
|
||||
@@ -241,12 +241,12 @@ final class NotificationManager {
|
||||
//
|
||||
// Only that conversation, and only while the app is active. On
|
||||
// another tab, another chat, or in the background, the banner is the
|
||||
// whole point and still fires.
|
||||
// point and still fires.
|
||||
let reading = UIApplication.shared.applicationState == .active
|
||||
&& Self.visibleConversation == thread
|
||||
|
||||
// **The tone obeys the same rules as the banner, and it used to obey
|
||||
// none of them.** It was played before every guard below, so a muted
|
||||
// The tone obeys the same rules as the banner, and it used to obey
|
||||
// none of them. It was played before every guard below, so a muted
|
||||
// conversation, a thread already on screen, and an account with
|
||||
// notifications switched off all still chimed on each arrival. Two
|
||||
// phones a foot apart pinging on every message, with no banner to
|
||||
|
||||
@@ -4,8 +4,8 @@ import UserNotifications
|
||||
|
||||
/// APNs registration and token upload.
|
||||
///
|
||||
/// **This is the one part of the product that puts a third party on the
|
||||
/// delivery path.** iOS suspends a backgrounded process and tears down its
|
||||
/// This is the one part of the product that puts a third party on the
|
||||
/// delivery path. iOS suspends a backgrounded process and tears down its
|
||||
/// sockets; Apple provides exactly one mechanism for waking it, and this is it.
|
||||
/// Android does not have that limit and uses `MessengerService` instead, with
|
||||
/// nobody in the middle. The asymmetry is real and belongs on the privacy page.
|
||||
@@ -13,8 +13,8 @@ import UserNotifications
|
||||
/// What Apple learns is metadata, not content. The push carries no
|
||||
/// message and no sender, the relay holds ciphertext it cannot read, so there
|
||||
/// is nothing to put in one. Apple sees that a device was woken and when.
|
||||
/// **Do not later "improve" the banner by putting the message in the push
|
||||
/// payload.** The relay cannot supply it, and anything that could would mean
|
||||
/// Do not later "improve" the banner by putting the message in the push
|
||||
/// payload. The relay cannot supply it, and anything that could would mean
|
||||
/// the relay had read the message.
|
||||
final class PushRegistrar: NSObject {
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ final class VoiceNotePlayer: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
stopTicking()
|
||||
isPlaying = false
|
||||
} else {
|
||||
// **The same main-thread block the recorder had, on the path
|
||||
// taken far more often.** Routing audio is a synchronous wait on
|
||||
// The same main-thread block the recorder had, on the path
|
||||
// taken far more often. Routing audio is a synchronous wait on
|
||||
// CoreAudio, and AVAudioSession warns about it by name; on a
|
||||
// contested route it is tapping play and watching the whole app
|
||||
// stop. Playback starts when the route is up, back on the main
|
||||
|
||||
@@ -120,8 +120,8 @@ final class VoiceRecorder: NSObject, ObservableObject {
|
||||
return recorder
|
||||
}
|
||||
|
||||
// **A deadline on opening the route, for the same reason as the
|
||||
// one on the permission ask.** Moving these calls off the main
|
||||
// A deadline on opening the route, for the same reason as the
|
||||
// one on the permission ask. Moving these calls off the main
|
||||
// actor stopped them freezing the UI, but it did not make them
|
||||
// finish: `setActive(true)` waits on an audio route that a
|
||||
// simulator may never bring up, so `start()` returned neither true
|
||||
|
||||
@@ -195,12 +195,12 @@ final class AuthService {
|
||||
/// Exchange a shared-account session for a relay session.
|
||||
///
|
||||
/// The account service authenticates a person; the relay still has to know
|
||||
/// which of its own accounts that is. The token carries a **product-scoped
|
||||
/// pseudonym** and deliberately not the handle, so the relay learns an
|
||||
/// which of its own accounts that is. The token carries a product-scoped
|
||||
/// pseudonym and deliberately not the handle, so the relay learns an
|
||||
/// opaque id and nothing that links it to any other FlameNet product.
|
||||
///
|
||||
/// Not implemented on the relay yet. Failing here with a specific message
|
||||
/// beats a generic one: the account half genuinely succeeded, and somebody
|
||||
/// beats a generic one: the account half succeeded, and somebody
|
||||
/// debugging this should be told which half did not.
|
||||
private func linkSharedSession(_ session: FlamenetAccountsClient.Session,
|
||||
username: String,
|
||||
|
||||
@@ -144,7 +144,7 @@ enum FlamenetAccountKDF {
|
||||
/// separates one derived secret from another.
|
||||
static func derive(passphrase: String, salt: Data, context: String,
|
||||
length: Int = 32, params: Params = .pbkdf2V1) throws -> Data {
|
||||
// The master key is the expensive secret, the whole point of the
|
||||
// The master key is the expensive secret, the point of the
|
||||
// stretch above, and it is needed for exactly one HKDF call. Wiped on
|
||||
// the way out, including out of a throw. See `SecureBytes` for what
|
||||
// this does and does not buy.
|
||||
@@ -202,7 +202,7 @@ enum FlamenetAccountKDF {
|
||||
///
|
||||
/// PBKDF2 at 650,000 iterations is what stands between that and a recovered
|
||||
/// passphrase, and against a weak passphrase it is not enough. This is a
|
||||
/// genuine reduction against the shared-account path, taken deliberately:
|
||||
/// reduction against the shared-account path, taken deliberately:
|
||||
/// the self-hoster's alternative is not "a random salt", it is "no portable
|
||||
/// identity at all".
|
||||
///
|
||||
|
||||
@@ -101,7 +101,7 @@ struct FlamenetAccountsClient {
|
||||
///
|
||||
/// Two round trips rather than one, because the salt and iteration count
|
||||
/// are per account, the service cannot derive the secret itself, which is
|
||||
/// the entire point.
|
||||
/// the point.
|
||||
/// A session, and the account key derived alongside it.
|
||||
///
|
||||
/// The key comes back here rather than being fetched later because this is
|
||||
|
||||
@@ -54,7 +54,7 @@ enum RelayDiscovery {
|
||||
"flamenet.io", "www.flamenet.io", "messenger.flamenet.io",
|
||||
// The same service under a second name, not a second service. Listed
|
||||
// explicitly: resolving it from what it advertises would give the right
|
||||
// answer today, and the entire point of pinning is not relying on that.
|
||||
// answer today, and the point of pinning is not relying on that.
|
||||
"relay.flamenet.io",
|
||||
]
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
|
||||
/// The contact whose keys need reviewing, when THAT is why the call failed.
|
||||
///
|
||||
/// **The call screen used to name the problem and leave you to find the
|
||||
/// cure.** "Their security code changed, open their chat to review it" is
|
||||
/// The call screen used to name the problem and leave you to find the
|
||||
/// cure. "Their security code changed, open their chat to review it" is
|
||||
/// accurate and it is also a scavenger hunt: the control that fixes it lives
|
||||
/// in the conversation, and nothing on the call screen went there. Reported
|
||||
/// as having to hunt for where to accept new keys, which is exactly right.
|
||||
@@ -188,8 +188,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
// **An offer older than the caller's own patience is not a
|
||||
// call.** The caller gives up after `dialTimeout` and moves on; an
|
||||
// An offer older than the caller's own patience is not a
|
||||
// call. The caller gives up after `dialTimeout` and moves on; an
|
||||
// offer that sat in the queue while this app was closed would
|
||||
// otherwise ring minutes later for a call nobody is on the other
|
||||
// end of, and answering it connects to nothing.
|
||||
@@ -437,7 +437,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
)
|
||||
let description: RTCSessionDescription = try await withCheckedThrowingContinuation { cont in
|
||||
// `@Sendable` to match what WebRTC declares. It calls back on
|
||||
// its own signalling thread, so the closure genuinely does cross
|
||||
// its own signalling thread, so the closure does cross
|
||||
// threads; the only thing captured is the continuation, which is
|
||||
// Sendable by design.
|
||||
let handler: @Sendable (RTCSessionDescription?, Error?) -> Void = { sdp, error in
|
||||
@@ -490,8 +490,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
fastPollTask = Task { [weak self] in
|
||||
while let self, !Task.isCancelled {
|
||||
switch self.state {
|
||||
// **`.active` belongs here and its absence was the hang-up
|
||||
// lag.** Fast polling stopped the moment the call connected, so
|
||||
// `.active` belongs here and its absence was the hang-up
|
||||
// lag. Fast polling stopped the moment the call connected, so
|
||||
// the one signal still to come, the other side hanging up,
|
||||
// fell back to the ordinary five-second cycle. Ending a call
|
||||
// looked like a button that did nothing, on whichever device
|
||||
|
||||
@@ -11,8 +11,8 @@ import PushKit
|
||||
/// pushes at all. There is no "receive it and decide later", which is why the
|
||||
/// call is reported first and identified afterwards.
|
||||
///
|
||||
/// **Sealed sender means the name is not known when the phone starts
|
||||
/// ringing.** The push carries nothing: the relay holds ciphertext and could
|
||||
/// Sealed sender means the name is not known when the phone starts
|
||||
/// ringing. The push carries nothing: the relay holds ciphertext and could
|
||||
/// not name the caller even if that were wanted. So the first frame says
|
||||
/// "Flamenet call", and the name is filled in by `identify(caller:)` once the
|
||||
/// offer has actually been decrypted. Showing a name we have not verified would
|
||||
|
||||
@@ -12,8 +12,8 @@ import Foundation
|
||||
/// half is the address. No server issues it, so no server can take it away.
|
||||
///
|
||||
/// The practical consequence is the one worth stating: sign in with the same
|
||||
/// passphrase at a relay that has never heard of you and you get **the same
|
||||
/// address**. Contacts who accepted you before still see the same person. A
|
||||
/// passphrase at a relay that has never heard of you and you get the same
|
||||
/// address. Contacts who accepted you before still see the same person. A
|
||||
/// relay shutting down costs a mailbox, not an identity.
|
||||
struct AccountIdentityService {
|
||||
|
||||
@@ -76,8 +76,7 @@ struct AccountIdentityService {
|
||||
/// Delivering the record everywhere is what keeps them from disagreeing.
|
||||
///
|
||||
/// `serial` must exceed whatever is already published. It is not a version
|
||||
/// for humans, it is what stops a relay replaying an older, genuinely
|
||||
/// signed list that happens to name only itself.
|
||||
/// for humans, it is what stops a relay replaying an older, /// signed list that happens to name only itself.
|
||||
///
|
||||
/// Takes a client per home, because publishing needs an authenticated
|
||||
/// session at each one.
|
||||
@@ -135,7 +134,7 @@ struct AccountIdentityService {
|
||||
/// The highest home-record serial this device has accepted for an address.
|
||||
///
|
||||
/// This is what makes the serial mean anything on the reading side.
|
||||
/// An older home record is *genuinely signed*, the account holder
|
||||
/// An older home record is *signed*, the account holder
|
||||
/// published it, before they added a second relay, so a relay that wants
|
||||
/// to be somebody's only mailbox does not have to forge anything: it serves
|
||||
/// the record it saw first, that record verifies perfectly, and the sender
|
||||
@@ -172,7 +171,7 @@ struct AccountIdentityService {
|
||||
/// lookup. Only strictly lower is a rollback.
|
||||
///
|
||||
/// A refusal is not proof of an attack, and the caller must not say
|
||||
/// it is. A relay that has genuinely fallen behind serves an old record for
|
||||
/// it is. A relay that has fallen behind serves an old record for
|
||||
/// honest reasons; what the client can say is that it has seen a newer one.
|
||||
static func acceptHomes(_ record: E2EHomesResponse, floor: Int?) -> Bool {
|
||||
if let floor, record.serial < floor { return false }
|
||||
|
||||
@@ -3,8 +3,8 @@ import Foundation
|
||||
|
||||
/// An instruction to erase what a lost device holds (spec §6.26, ios#38).
|
||||
///
|
||||
/// **Signed by the account key, because the relay must not be able to cause
|
||||
/// a wipe.** The obvious implementation erases when the relay says the device
|
||||
/// Signed by the account key, because the relay must not be able to cause
|
||||
/// a wipe. The obvious implementation erases when the relay says the device
|
||||
/// is gone, a status code on the next poll, which hands an untrusted server
|
||||
/// the power to destroy every user's history at will, irreversibly. It cannot
|
||||
/// read anything and it must not be able to delete anything either. See ios#60,
|
||||
|
||||
@@ -365,7 +365,7 @@ enum E2EError: LocalizedError {
|
||||
/// The outbox retries forever by design, and that is right for a contact
|
||||
/// who is merely offline or has no device yet: they get the message in a
|
||||
/// month if it takes a month, and timing out would mean deleting something
|
||||
/// somebody wrote in order to tidy up a label.
|
||||
/// somebody wrote to tidy up a label.
|
||||
///
|
||||
/// It is wrong for a refusal no amount of waiting resolves. A send
|
||||
/// blocked because the contact's identity keys changed will be blocked
|
||||
|
||||
@@ -286,8 +286,8 @@ enum E2EAttachment {
|
||||
let at: Double?
|
||||
/// Marks control traffic a client may not know the kind of.
|
||||
///
|
||||
/// **Forward compatibility for every control kind added after this
|
||||
/// one.** Control messages ride the same channel as chat, and this
|
||||
/// Forward compatibility for every control kind added after this
|
||||
/// one. Control messages ride the same channel as chat, and this
|
||||
/// build recognises them by name, `delivery_key`, `kt_head`, `avatar`.
|
||||
/// A control kind from a NEWER peer matches none of those, falls through
|
||||
/// to the attachment path, and surfaces in the conversation as
|
||||
@@ -554,8 +554,8 @@ enum E2EAttachment {
|
||||
/// about that message" and would have to be added to two clients, an
|
||||
/// engine and a spec before anything worked.
|
||||
///
|
||||
/// **`re.author` is not evidence and the receiver must not read it as
|
||||
/// evidence.** Anyone in a group can put any user id in that field. The
|
||||
/// `re.author` is not evidence and the receiver must not read it as
|
||||
/// evidence. Anyone in a group can put any user id in that field. The
|
||||
/// receiver decides whether an edit is allowed from the copy of the message
|
||||
/// it already holds, never from what the envelope claims about itself.
|
||||
static func encodeEdit(_ newText: String, of mid: String,
|
||||
|
||||
@@ -159,7 +159,7 @@ final class E2EClient {
|
||||
/// Prekey bundles for a remote address.
|
||||
///
|
||||
/// POPS a one-time prekey on the far side, exactly as the local
|
||||
/// `/e2e/keys` route does. Ask only when there is genuinely a session to
|
||||
/// `/e2e/keys` route does. Ask only when there is a session to
|
||||
/// open, never to inspect who somebody is.
|
||||
func remoteBundles(fnid: String, relay: String) async throws -> E2EKeysResponse {
|
||||
let path = "/e2e/remote/\(relay)/bundles/\(fnid)"
|
||||
@@ -557,8 +557,8 @@ final class E2EClient {
|
||||
|
||||
/// §6.25, file a report with the operator.
|
||||
///
|
||||
/// **This is the one call that deliberately sends readable content to
|
||||
/// the server.** Everything else this client sends is sealed; a report is
|
||||
/// This is the one call that deliberately sends readable content to
|
||||
/// the server. Everything else this client sends is sealed; a report is
|
||||
/// not, because a relay that cannot read the reported story cannot act on
|
||||
/// an accusation about it. The caller is required to have obtained explicit
|
||||
/// consent naming that consequence, see `StoryReportSheet`.
|
||||
|
||||
@@ -5,7 +5,7 @@ import Foundation
|
||||
/// A history backup the platform cannot read.
|
||||
///
|
||||
/// The relay keeps no history, that is the product's whole promise, which
|
||||
/// means a new device genuinely starts empty. This is the designed remedy: the
|
||||
/// means a new device starts empty. This is the designed remedy: the
|
||||
/// same snapshot `E2EHistoryStore` seals under a device-only key, re-sealed
|
||||
/// under a key derived from a passphrase the user chooses, so the resulting
|
||||
/// file is safe to park anywhere (a drive, a cloud folder) and useless to
|
||||
|
||||
@@ -37,7 +37,7 @@ struct E2EHistoryStore {
|
||||
/// Optional on purpose. Swift's synthesised decoding does not
|
||||
/// apply a property's default for a missing key, it throws, so a
|
||||
/// non-optional field added here would make every snapshot written
|
||||
/// before it existed fail to decode, and the whole point of this file
|
||||
/// before it existed fail to decode, and the point of this file
|
||||
/// is not losing history.
|
||||
var calls: [E2ECallEntry]? = nil
|
||||
/// Optional for the same reason as `calls`.
|
||||
@@ -91,8 +91,8 @@ struct E2EHistoryStore {
|
||||
var groupNames: [String: String]? = nil
|
||||
/// Per-group tags and keys (§8.9b).
|
||||
///
|
||||
/// **Without these a restored device loses its sealed groups for
|
||||
/// good.** The relay cannot say what you are a member of, that is the
|
||||
/// Without these a restored device loses its sealed groups for
|
||||
/// good. The relay cannot say what you are a member of, that is the
|
||||
/// point of sealing, so the tag is the only way to fetch a group and
|
||||
/// the key the only way to read it. A backup carrying the messages but
|
||||
/// not these restores a device that can see old conversations and can
|
||||
@@ -106,7 +106,7 @@ struct E2EHistoryStore {
|
||||
|
||||
/// Half-written messages, keyed by conversation (see `E2EInbox.draftKey`).
|
||||
///
|
||||
/// In here rather than in a plist, and that is the whole point. A
|
||||
/// In here rather than in a plist. A
|
||||
/// draft is plaintext the person has not decided to send yet, often
|
||||
/// the most sensitive text in the app, because it is the thing they are
|
||||
/// still thinking about. `UserDefaults` would write it unencrypted,
|
||||
|
||||
+49
-49
@@ -70,7 +70,7 @@ struct E2EMessage: Identifiable, Hashable, Codable {
|
||||
/// non-optional `Bool` with a default reads as safe and is not: synthesised
|
||||
/// decoding throws on a missing key rather than using the property's
|
||||
/// default, so it would make every snapshot written before today fail to
|
||||
/// decode, and the whole point of the history file is not losing history.
|
||||
/// decode, and the point of the history file is not losing history.
|
||||
/// This was written as a plain `Bool` first; the test that catches it is
|
||||
/// `ForwardWithoutProvenanceTests`.
|
||||
var isForwarded: Bool? = nil
|
||||
@@ -142,8 +142,8 @@ struct E2EOutboxEntry: Hashable, Codable {
|
||||
var gid: String? = nil
|
||||
/// The already-encoded content envelope.
|
||||
///
|
||||
/// **Because the queue was text-only and quietly wrong for anything
|
||||
/// else.** Replay re-encoded `body` as text, so a sticker put through it
|
||||
/// Because the queue was text-only and quietly wrong for anything
|
||||
/// else. Replay re-encoded `body` as text, so a sticker put through it
|
||||
/// would have arrived as a message whose words were its id. Rather than
|
||||
/// grow a discriminator per kind, and get it wrong again for the next one
|
||||
/// the entry now carries the exact plaintext that was going to be sent.
|
||||
@@ -1169,8 +1169,8 @@ final class E2EInbox: ObservableObject {
|
||||
roster.append(entry)
|
||||
}
|
||||
if imported.rosterOffServer == true { rosterOffServer = true }
|
||||
// **Group names ride the backup, because nothing else can supply
|
||||
// them any more.** The relay stopped holding them, so a restored device
|
||||
// Group names ride the backup, because nothing else can supply
|
||||
// them any more. The relay stopped holding them, so a restored device
|
||||
// that did not read them here would show every group as "Group" until
|
||||
// some other member happened to send a message.
|
||||
//
|
||||
@@ -1261,7 +1261,7 @@ final class E2EInbox: ObservableObject {
|
||||
// can drop one and keep the other. Rather than try to keep two stores in
|
||||
// step, tie the id to the identity: no identity, new id.
|
||||
//
|
||||
// Only when the identity is genuinely ABSENT. A loaded identity keeps
|
||||
// Only when the identity is ABSENT. A loaded identity keeps
|
||||
// its device id, or every launch would look like a new device to every
|
||||
// contact.
|
||||
let existingIdentity = store.loadIdentity()
|
||||
@@ -1318,8 +1318,8 @@ final class E2EInbox: ObservableObject {
|
||||
client.useRelayAuth(deviceID: fresh, identity: identity.ikSig)
|
||||
await inbox.registerDeviceIfNeeded()
|
||||
|
||||
// **Retire the id we just abandoned, or it poisons every
|
||||
// contact.** Rotation used to leave the old row registered for
|
||||
// Retire the id we just abandoned, or it poisons every
|
||||
// contact. Rotation used to leave the old row registered for
|
||||
// ever, and the directory is what senders fan out over: they went
|
||||
// on addressing a mailbox this device no longer reads, and the
|
||||
// relay reported every one of those copies delivered. Worse, the
|
||||
@@ -1417,8 +1417,8 @@ final class E2EInbox: ObservableObject {
|
||||
// nothing at all.
|
||||
await self?.refreshSealedGroups()
|
||||
while let self, !Task.isCancelled {
|
||||
// **How long to wait afterwards depends on whether the relay
|
||||
// held the request.** It now supports long-polling: `wait=20`
|
||||
// How long to wait afterwards depends on whether the relay
|
||||
// held the request. It now supports long-polling: `wait=20`
|
||||
// asks it to answer the moment something lands, and to sit on
|
||||
// an empty inbox until then. When it does, a message shows up
|
||||
// in about as long as the network takes rather than up to five
|
||||
@@ -1452,8 +1452,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Remove devices the account publishes that are stale copies of THIS one.
|
||||
///
|
||||
/// **The other half of the identity/device split, and it cost a whole
|
||||
/// evening twice.** The device id lives in UserDefaults and the identity in
|
||||
/// The other half of the identity/device split, and it cost a whole
|
||||
/// evening twice. The device id lives in UserDefaults and the identity in
|
||||
/// the Keychain, so they are lost independently. `recoverFromDeviceMismatch`
|
||||
/// covers "new identity, old device id", it rotates and retires. This
|
||||
/// covers the reverse: the device id is gone but the identity survives, so
|
||||
@@ -1835,8 +1835,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Quote a string for JSON the way `org.json`'s `JSONObject.quote` does.
|
||||
///
|
||||
/// **Not `JSONSerialization`, because it escapes `/` as `\/` and Android
|
||||
/// does not.** Both forms are valid JSON and both decode to the same
|
||||
/// Not `JSONSerialization`, because it escapes `/` as `\/` and Android
|
||||
/// does not. Both forms are valid JSON and both decode to the same
|
||||
/// string, so nothing would have broken, but a bridge `cert=` is base64,
|
||||
/// base64 is full of `/`, and the two engines would have put different
|
||||
/// bytes on the wire for every real line ever shared. A cross-engine test
|
||||
@@ -1964,8 +1964,8 @@ final class E2EInbox: ObservableObject {
|
||||
/// act means rotating for everybody and redistributing to the rest, which is
|
||||
/// why in practice it never happens.
|
||||
///
|
||||
/// **This moves them back onto the shared key; it does not silence
|
||||
/// them.** A complete cut-off is this AND `rotateDeliveryKey`, every other
|
||||
/// This moves them back onto the shared key; it does not silence
|
||||
/// them. A complete cut-off is this AND `rotateDeliveryKey`, every other
|
||||
/// contact recovers from that on its own now, because the new key rides the
|
||||
/// same prelude a batch does. Two doors, and closing one does not close the
|
||||
/// other.
|
||||
@@ -2064,7 +2064,7 @@ final class E2EInbox: ObservableObject {
|
||||
///
|
||||
/// Returns the PQ prekey to encapsulate to, or nil for a v1 session.
|
||||
/// Throws `E2EError.protocolDowngrade` when a device we have already seen
|
||||
/// prove v2 comes back without it, that is the entire point of the pin, and
|
||||
/// prove v2 comes back without it, that is the point of the pin, and
|
||||
/// it must reach the caller rather than degrade quietly.
|
||||
private func negotiatePQ(
|
||||
bundle: E2EKeysResponse.Bundle,
|
||||
@@ -2222,7 +2222,7 @@ final class E2EInbox: ObservableObject {
|
||||
///
|
||||
/// Never gives up. There is no expiry and nothing is discarded: a
|
||||
/// contact who reinstalls in a month gets it in a month. Timing out would
|
||||
/// mean deleting something somebody wrote in order to tidy up a label.
|
||||
/// mean deleting something somebody wrote to tidy up a label.
|
||||
func flushOutbox() async {
|
||||
guard !outbox.isEmpty else { return }
|
||||
var stillWaiting: [E2EOutboxEntry] = []
|
||||
@@ -2268,7 +2268,7 @@ final class E2EInbox: ObservableObject {
|
||||
// deliberate: a contact who is merely offline, or who has not
|
||||
// registered a device yet, gets the message whenever they
|
||||
// return. Giving up on those would mean deleting something
|
||||
// somebody wrote in order to tidy up a label.
|
||||
// somebody wrote to tidy up a label.
|
||||
if let e2e = error as? E2EError, !e2e.waitingCanFix {
|
||||
await DeliveryDiagnostics.shared.sendFailed(
|
||||
e2e.shortReason, to: entry.to)
|
||||
@@ -2486,7 +2486,7 @@ final class E2EInbox: ObservableObject {
|
||||
/// for THIS device (§6.26). Erases local data.
|
||||
@Published private(set) var deviceRevoked = false
|
||||
|
||||
/// Honour a wipe instruction, if it is genuinely ours (§6.26, ios#38).
|
||||
/// Honour a wipe instruction, if it is ours (§6.26, ios#38).
|
||||
///
|
||||
/// Verified against the account key before anything is destroyed.
|
||||
/// The relay carries the instruction and cannot produce one: the fnid this
|
||||
@@ -2876,7 +2876,7 @@ final class E2EInbox: ObservableObject {
|
||||
throw E2EError.identityChanged
|
||||
}
|
||||
|
||||
// Only spend a prekey when there is genuinely a session to open.
|
||||
// Only spend a prekey when there is a session to open.
|
||||
var bundles: [String: E2EKeysResponse.Bundle] = [:]
|
||||
if devices.contains(where: { loadRatchet(remoteDeviceID: $0.device_id) == nil }) {
|
||||
for bundle in try await client.remoteBundles(fnid: card.fnid, relay: card.relay).devices {
|
||||
@@ -3099,7 +3099,7 @@ final class E2EInbox: ObservableObject {
|
||||
throw E2EError.identityChanged
|
||||
}
|
||||
|
||||
// Only spend a prekey when there is genuinely a session to open.
|
||||
// Only spend a prekey when there is a session to open.
|
||||
var bundles: [String: E2EKeysResponse.Bundle] = [:]
|
||||
if devices.contains(where: { loadRatchet(remoteDeviceID: $0.device_id) == nil }) {
|
||||
// Kept, not discarded. The reply carries the contact's
|
||||
@@ -3182,7 +3182,7 @@ final class E2EInbox: ObservableObject {
|
||||
}
|
||||
|
||||
// §19.4, gossip our view of the key log to this contact, once per
|
||||
// session. Inside the ratchet, which is the entire point: the relay is
|
||||
// session. Inside the ratchet: the relay is
|
||||
// the party being audited, so it must not see which head we hold nor be
|
||||
// able to rewrite it in transit.
|
||||
//
|
||||
@@ -3530,8 +3530,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// How this device registers at a relay that is only holding mail for it.
|
||||
///
|
||||
/// **The same capability block as the home relay, and that is not
|
||||
/// cosmetic.** A sender fetches a bundle from ONE of the recipient's
|
||||
/// The same capability block as the home relay, and that is not
|
||||
/// cosmetic. A sender fetches a bundle from ONE of the recipient's
|
||||
/// mailboxes; if a second home advertised this device as v1 while the first
|
||||
/// advertised v2, whether a conversation got post-quantum protection would
|
||||
/// depend on which mailbox the sender happened to ask, a silent downgrade
|
||||
@@ -3601,7 +3601,7 @@ final class E2EInbox: ObservableObject {
|
||||
after: store.lastInboxID)
|
||||
} catch MessengerError.httpStatus(410) {
|
||||
// 410 means "no device row", which is TWO different things.
|
||||
// Either this device was genuinely revoked from another phone, or
|
||||
// Either this device was revoked from another phone, or
|
||||
// it was never accepted in the first place. Treating both as
|
||||
// revocation is what turned a failed registration into an
|
||||
// unbreakable sign-in loop that wiped the account each time round.
|
||||
@@ -3715,7 +3715,7 @@ final class E2EInbox: ObservableObject {
|
||||
store.rememberEnvelope(id)
|
||||
}
|
||||
// Advancing means this envelope is finished with, however that came
|
||||
// about, delivered, discarded, or given up on. Only a genuine hold
|
||||
// about, delivered, discarded, or given up on. Only a hold
|
||||
// keeps the stall counter, so it can never leak onto the next message.
|
||||
let advanced = await ingest(inc)
|
||||
if advanced { store.clearInboxStall() }
|
||||
@@ -3859,7 +3859,7 @@ final class E2EInbox: ObservableObject {
|
||||
}
|
||||
|
||||
// Sealed rows arrive with from_user 0 and from_device "" -- the relay
|
||||
// genuinely does not know. Open the seal to learn where to route this.
|
||||
// does not know. Open the seal to learn where to route this.
|
||||
//
|
||||
// The identity inside is UNAUTHENTICATED: anyone holding our public
|
||||
// key can forge it. It selects a ratchet and nothing more; the message
|
||||
@@ -4067,8 +4067,8 @@ final class E2EInbox: ObservableObject {
|
||||
// That is exactly what happened here: envelope 979 was re-collected
|
||||
// every five seconds and nothing behind it could ever land.
|
||||
guard let r = ratchet else {
|
||||
// **The last silent discard, and the one that was eating
|
||||
// messages.** We hold a session for this device, the envelope is
|
||||
// The last silent discard, and the one that was eating
|
||||
// messages. We hold a session for this device, the envelope is
|
||||
// not a prekey, and neither the live chain nor the archived one
|
||||
// opened it, so the two sides are on different chains and every
|
||||
// further message in this direction is undeliverable. It returned
|
||||
@@ -4905,7 +4905,7 @@ final class E2EInbox: ObservableObject {
|
||||
store.pinIdentities(offered, forUser: userID)
|
||||
store.setVerified(false, userID: userID)
|
||||
// Re-bind this user's devices to them (audit C1/C2). Accepting a change
|
||||
// is the moment a genuine reinstall's new device ids become theirs; a
|
||||
// is the moment a reinstall's new device ids become theirs; a
|
||||
// binding left over from before must not outlive the acceptance, or a
|
||||
// legitimately re-registered device would look like an impersonator.
|
||||
for device in devices {
|
||||
@@ -5108,7 +5108,7 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Seal a group that already exists in the clear, keeping its gid.
|
||||
///
|
||||
/// The whole point of the feature for anybody who already has groups.
|
||||
/// The point of the feature for anybody who already has groups.
|
||||
/// The migration is additive by design: the relay keeps a plaintext roster
|
||||
/// until a client replaces it, so a group made before sealing stays readable
|
||||
/// in the database for ever unless something does this. Nothing did, which
|
||||
@@ -5212,14 +5212,14 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Add or remove members of a sealed group.
|
||||
///
|
||||
/// **Owner only, and the relay checks a signature rather than an
|
||||
/// account.** We hold the owner's private key iff we created the group; a
|
||||
/// Owner only, and the relay checks a signature rather than an
|
||||
/// account. We hold the owner's private key iff we created the group; a
|
||||
/// member who is not the owner has nothing to sign with, and the relay
|
||||
/// refuses the write.
|
||||
///
|
||||
/// The version must increase. A roster signature stays valid for ever,
|
||||
/// so a stale roster could otherwise be replayed to re-add somebody who was
|
||||
/// just removed, with a signature that genuinely verifies.
|
||||
/// just removed, with a signature that verifies.
|
||||
func updateSealedGroupMembers(gid: String, add: [Int], remove: [Int]) async throws {
|
||||
guard let secrets = store.groupSecrets(gid),
|
||||
let ownerRaw = secrets.ownerPrivate.flatMap({ Data(base64Encoded: $0) }),
|
||||
@@ -5333,8 +5333,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Rename a group, and tell the other members.
|
||||
///
|
||||
/// **The only way a group gets its name back after the relay stopped
|
||||
/// holding one.** Names were immutable before, set at creation, served by
|
||||
/// The only way a group gets its name back after the relay stopped
|
||||
/// holding one. Names were immutable before, set at creation, served by
|
||||
/// the relay for ever, so no client needed this. Now that the server has
|
||||
/// dropped them, every group that existed before the change shows the
|
||||
/// placeholder on every device, and there is nobody left to ask: the relay
|
||||
@@ -5369,7 +5369,7 @@ final class E2EInbox: ObservableObject {
|
||||
guard let name = store.groupName(gid), let mine = store.deliveryKey else { return }
|
||||
let payload = DeliveryKey(raw: mine)
|
||||
.contentEnvelopeJSON(tokens: [], gid: gid, groupName: name)
|
||||
// Our own account is included, and that is the point. Fan-out
|
||||
// Our own account is included. Fan-out
|
||||
// excludes us everywhere else, so a second phone we own would be told
|
||||
// by nobody: the relay has no name to serve it and the members only
|
||||
// send to each other. `transmit` drops this device from the list, so
|
||||
@@ -5537,8 +5537,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Post a story to a group (§6.24).
|
||||
///
|
||||
/// **The group's roster is the audience, and `storyAudience` does NOT
|
||||
/// apply.** Those are two different lists making two different statements,
|
||||
/// The group's roster is the audience, and `storyAudience` does NOT
|
||||
/// apply. Those are two different lists making two different statements,
|
||||
/// who may see this account's stories, and who is in a conversation, and
|
||||
/// they will disagree. The roster wins: a story addressed to a group that
|
||||
/// silently dropped some members would be a group message part of the group
|
||||
@@ -5692,8 +5692,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Whether this story may be saved off the device (ios#34).
|
||||
///
|
||||
/// **Only ever your own, and the rule lives here rather than in the
|
||||
/// button.** Saving somebody else's story keeps a copy of something they
|
||||
/// Only ever your own, and the rule lives here rather than in the
|
||||
/// button. Saving somebody else's story keeps a copy of something they
|
||||
/// were told would disappear, which is the opposite of what the feature
|
||||
/// promises them, and no amount of UI wording makes it acceptable. A guard
|
||||
/// in the view is one refactor away from being lost; this one is tested.
|
||||
@@ -5783,8 +5783,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// File a report about a MESSAGE (§6.25, ios#12).
|
||||
///
|
||||
/// **The relay has always accepted `kind: "message"`; nothing ever sent
|
||||
/// one.** Reporting existed only on stories, so the surface where
|
||||
/// The relay has always accepted `kind: "message"`; nothing ever sent
|
||||
/// one. Reporting existed only on stories, so the surface where
|
||||
/// unsolicited contact, spam and harassment actually arrive had no way to
|
||||
/// tell anybody, a person's only recourse was Block, which protects them
|
||||
/// and tells the operator nothing, so the same account moves on to the
|
||||
@@ -6082,7 +6082,7 @@ final class E2EInbox: ObservableObject {
|
||||
head = try await client.logHead()
|
||||
store.rememberKeyLogServed(byRelay: client.relayHost)
|
||||
} catch let error as MessengerError {
|
||||
// Only a route that is genuinely not there. A timeout, a 500 or a
|
||||
// Only a route that is not there. A timeout, a 500 or a
|
||||
// 429 is a relay having a bad day, and must not read as one without
|
||||
// transparency at all, that would make enforcement skippable by
|
||||
// anyone able to make a request fail.
|
||||
@@ -6123,8 +6123,8 @@ final class E2EInbox: ObservableObject {
|
||||
|
||||
/// Pin the relay's own identity, separately from the key log (ios#19).
|
||||
///
|
||||
/// **TLS says the certificate is valid for this name. It does not say
|
||||
/// this is the same relay.** A mis-issued certificate, a hijacked DNS
|
||||
/// TLS says the certificate is valid for this name. It does not say
|
||||
/// this is the same relay. A mis-issued certificate, a hijacked DNS
|
||||
/// record, or somebody who obtains the domain each produce a server with a
|
||||
/// perfectly good certificate and a different identity behind it.
|
||||
///
|
||||
@@ -6256,8 +6256,8 @@ final class E2EInbox: ObservableObject {
|
||||
// head, and the qualified form has to match that relay's own name or
|
||||
// nothing recomputes.
|
||||
//
|
||||
// **Both subject forms are accepted, and the reason is a migration
|
||||
// rather than a weakness.** A leaf names its subject as `fnid:` once
|
||||
// Both subject forms are accepted, and the reason is a migration
|
||||
// rather than a weakness. A leaf names its subject as `fnid:` once
|
||||
// that account has claimed an address and `local:<audience>/<id>`
|
||||
// before, and the leaf hash committed to whichever string was true at
|
||||
// the time it was written. So a contact who registered a device, then
|
||||
|
||||
@@ -152,8 +152,8 @@ struct E2EMessagesSendRequest: Encodable {
|
||||
var delivery_token: String?
|
||||
/// Time-critical: ask the relay to ring rather than merely wake (§6.14).
|
||||
///
|
||||
/// **The one thing this client tells the relay about content, and only
|
||||
/// for call signalling.** A locked iPhone can be rung only by a VoIP push,
|
||||
/// The one thing this client tells the relay about content, and only
|
||||
/// for call signalling. A locked iPhone can be rung only by a VoIP push,
|
||||
/// which Apple requires to produce a real call, and the relay cannot tell
|
||||
/// a call from a message, because it holds ciphertext. The cost is that it
|
||||
/// learns this envelope is time-critical. Setting it on ordinary messages
|
||||
@@ -203,7 +203,7 @@ struct E2EMessagesPollResponse: Decodable {
|
||||
let messages: [Incoming]
|
||||
let cursor: Int
|
||||
|
||||
/// For a sealed row the relay genuinely does not know the sender:
|
||||
/// For a sealed row the relay does not know the sender:
|
||||
/// `from_user` is 0 and `from_device` is empty. The real values live inside
|
||||
/// the payload and are UNAUTHENTICATED until the inner envelope opens.
|
||||
struct Incoming: Decodable {
|
||||
@@ -316,8 +316,8 @@ struct E2EPrekeysResponse: Decodable {
|
||||
/// themselves are ordinary sealed envelopes the server can't tell apart.
|
||||
struct E2EGroup: Decodable, Identifiable, Hashable {
|
||||
let gid: String
|
||||
/// **Whatever the relay sends here is a placeholder, and is overwritten
|
||||
/// before this reaches the UI.** The server stopped storing group names,
|
||||
/// Whatever the relay sends here is a placeholder, and is overwritten
|
||||
/// before this reaches the UI. The server stopped storing group names,
|
||||
/// it never read them, and "Chemotherapy support" in a column is a thing a
|
||||
/// server can be made to hand over. It now answers with the same generic
|
||||
/// word for every group, purely so builds that predate the change do not
|
||||
@@ -604,7 +604,7 @@ struct E2ESendRemoteResponse: Decodable {
|
||||
let ok: Bool
|
||||
let accepted: Int
|
||||
let unknown: [String]
|
||||
/// With several homes, "sent" is genuinely partial. A client that cannot
|
||||
/// With several homes, "sent" is partial. A client that cannot
|
||||
/// see which copies landed cannot decide whether to retry or to warn.
|
||||
let delivered_to: [String]?
|
||||
let unreachable: [String]?
|
||||
|
||||
@@ -108,7 +108,7 @@ final class KeychainE2EStore {
|
||||
///
|
||||
/// Keeping them together removes the third state: a reinstall either keeps
|
||||
/// both, in which case nothing changed, or loses both, in which case this is
|
||||
/// genuinely a new device and everything downstream already handles that.
|
||||
/// a new device and everything downstream already handles that.
|
||||
///
|
||||
/// The UserDefaults value is still READ, once, to migrate. An install
|
||||
/// that predates this has a perfectly good id there, and minting a new one
|
||||
@@ -147,7 +147,7 @@ final class KeychainE2EStore {
|
||||
/// `deviceID` has a side effect, and teardown must not trigger it.
|
||||
/// Reading `deviceID` after a wipe finds both stores empty and registers a
|
||||
/// brand-new cryptographic device as a consequence of being *read*. Sign-out
|
||||
/// did exactly that: it asked for the id in order to tell the relay to
|
||||
/// did exactly that: it asked for the id to tell the relay to
|
||||
/// forget the push token, the ask happened after the wipe, and so it minted
|
||||
/// an id, wrote it to the Keychain, and then told the relay to forget a
|
||||
/// device that had never existed, leaving the real one still registered and
|
||||
@@ -164,8 +164,8 @@ final class KeychainE2EStore {
|
||||
|
||||
/// Abandon this device id and mint a new one.
|
||||
///
|
||||
/// **For one situation only: the relay says the device id and the
|
||||
/// identity key do not belong together.** That happens because the two live
|
||||
/// For one situation only: the relay says the device id and the
|
||||
/// identity key do not belong together. That happens because the two live
|
||||
/// in different stores with different lifetimes, the device id in
|
||||
/// UserDefaults, the identity in the Keychain, so a reinstall, a partial
|
||||
/// wipe, or a restore can leave an id pointing at an identity that no
|
||||
@@ -582,7 +582,7 @@ final class KeychainE2EStore {
|
||||
/// What this device holds for one sealed group.
|
||||
///
|
||||
/// Lose these and the group is gone. The relay cannot tell you what
|
||||
/// you are a member of, that is the entire point of sealing, so the tag
|
||||
/// you are a member of, that is the point of sealing, so the tag
|
||||
/// is the only way to fetch the group and the key is the only way to read
|
||||
/// it. Both ride the encrypted history export and the group announcement,
|
||||
/// the same paths the group name already uses.
|
||||
@@ -909,7 +909,7 @@ final class KeychainE2EStore {
|
||||
return UInt8(clamping: max(current, Int(proven)))
|
||||
}
|
||||
|
||||
/// Support-only escape hatch for a device that genuinely regressed (a
|
||||
/// Support-only escape hatch for a device that regressed (a
|
||||
/// downgraded build, a restored backup). Deliberately not wired to any UI:
|
||||
/// clearing a pin accepts losing post-quantum protection for that device, so
|
||||
/// it must be a considered act, never a button beside a scary warning.
|
||||
@@ -929,8 +929,8 @@ final class KeychainE2EStore {
|
||||
|
||||
/// Whether the relay has ever CONFIRMED this device id.
|
||||
///
|
||||
/// **This is what separates "revoked" from "never accepted", and the two
|
||||
/// were indistinguishable.** `/e2e/messages` answers 410 whenever the
|
||||
/// This is what separates "revoked" from "never accepted", and the two
|
||||
/// were indistinguishable. `/e2e/messages` answers 410 whenever the
|
||||
/// device row is absent, which covers both, and the client treated every
|
||||
/// 410 as a revocation: purge history, wipe the keychain, sign out. So a
|
||||
/// registration that merely failed (a timeout, a 429, a relay blip; the
|
||||
@@ -1060,7 +1060,7 @@ final class KeychainE2EStore {
|
||||
}
|
||||
|
||||
/// Forget a device's owner binding, used when a peer's identity change is
|
||||
/// accepted, since a genuine reinstall re-registers new device ids anyway
|
||||
/// accepted, since a reinstall re-registers new device ids anyway
|
||||
/// and a stale binding must never outlive an accepted key change.
|
||||
func clearDeviceOwner(forDevice deviceID: String) {
|
||||
suite.removeObject(forKey: "devowner.\(deviceID)")
|
||||
@@ -1070,7 +1070,7 @@ final class KeychainE2EStore {
|
||||
///
|
||||
/// Reads the bindings written on first proven contact (audit C1/C2), so it
|
||||
/// answers from what this device has actually seen rather than asking the
|
||||
/// relay, which is the point: a safety number must not depend on a
|
||||
/// relay: a safety number must not depend on a
|
||||
/// directory the relay controls.
|
||||
func devices(ownedBy userID: Int) -> [String] {
|
||||
suite.dictionaryRepresentation().compactMap { key, value in
|
||||
@@ -1377,8 +1377,8 @@ final class KeychainE2EStore {
|
||||
|
||||
/// Whether this message has already been read from another mailbox.
|
||||
///
|
||||
/// **The whole point of several mailboxes is that a message is
|
||||
/// delivered to all of them.** Without this the person reads it once per
|
||||
/// The point of several mailboxes is that a message is
|
||||
/// delivered to all of them. Without this the person reads it once per
|
||||
/// home, and the ratchet would refuse the second copy anyway, so it would
|
||||
/// arrive as a duplicate on a good day and as a dropped envelope on a bad
|
||||
/// one. The sender stamps one id per MESSAGE, which is what makes the
|
||||
|
||||
@@ -192,8 +192,8 @@ struct MailboxHoming {
|
||||
|
||||
/// Stop keeping a mailbox at `relay`.
|
||||
///
|
||||
/// **Publishing the shorter list comes first, and the local credential
|
||||
/// goes only if that worked.** Forgetting locally while senders are still
|
||||
/// Publishing the shorter list comes first, and the local credential
|
||||
/// goes only if that worked. Forgetting locally while senders are still
|
||||
/// told to deliver there leaves mail arriving at a mailbox this device no
|
||||
/// longer reads, which is indistinguishable, from the sender's side, from
|
||||
/// delivery. A publish that fails leaves everything exactly as it was.
|
||||
|
||||
@@ -8,9 +8,9 @@ import Foundation
|
||||
/// people leave a group over. A mention that never broke through would make
|
||||
/// muting a busy group mean missing the one message that was actually for you.
|
||||
///
|
||||
/// So the split is: **the sender decides a message is addressed to somebody;
|
||||
/// So the split is: the sender decides a message is addressed to somebody;
|
||||
/// the person receiving it decides whether being addressed is worth a
|
||||
/// notification.** Nothing a sender can put in an envelope changes that half,
|
||||
/// notification. Nothing a sender can put in an envelope changes that half,
|
||||
/// see `NotificationManager.shouldNotify`.
|
||||
enum Mentions {
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import UIKit
|
||||
///
|
||||
/// `.addOnly` authorisation, never full access. Saving needs permission
|
||||
/// to add a photo and nothing else; asking for read access as well would let
|
||||
/// this app enumerate somebody's entire library in order to perform a write.
|
||||
/// this app enumerate somebody's entire library to perform a write.
|
||||
/// The narrower prompt is also the honest one, it is what the feature does.
|
||||
enum PhotoLibrary {
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
|
||||
/// Which relay this is, beyond which certificate it presented.
|
||||
///
|
||||
/// **TLS answers "is this host who DNS says it is". It does not answer "is
|
||||
/// this the same relay I have been talking to".** A mis-issued certificate, a
|
||||
/// TLS answers "is this host who DNS says it is". It does not answer "is
|
||||
/// this the same relay I have been talking to". A mis-issued certificate, a
|
||||
/// hijacked DNS record, or a hostile party who obtains the domain all produce a
|
||||
/// server with a perfectly valid certificate for the right name, and every
|
||||
/// one of them is a different relay wearing the same address.
|
||||
|
||||
@@ -2,15 +2,15 @@ import Foundation
|
||||
|
||||
/// Device revocations, remembered locally once seen in the key log (relay#24).
|
||||
///
|
||||
/// **Clients decide "is this device live" from the DIRECTORY, which the
|
||||
/// relay controls freely.** The key log records every departure permanently and
|
||||
/// Clients decide "is this device live" from the DIRECTORY, which the
|
||||
/// relay controls freely. The key log records every departure permanently and
|
||||
/// cannot be quietly rewritten, but nothing on the client side was reading it
|
||||
/// for that. So a relay could keep a revoked device reachable in the directory
|
||||
/// while showing the revocation to anyone auditing the log, and both answers
|
||||
/// would look consistent to their respective audiences.
|
||||
///
|
||||
/// **A revocation is a one-way latch, and that is the answer to the
|
||||
/// interesting attack.** The ticket names it: a relay that stops serving the log
|
||||
/// A revocation is a one-way latch, and that is the answer to the
|
||||
/// interesting attack. The ticket names it: a relay that stops serving the log
|
||||
/// it used to serve is a downgrade that looks like an outage. Latching makes
|
||||
/// that not matter for anything already seen, the relay can go quiet, but it
|
||||
/// cannot un-say what it already said. Silence stops new revocations being
|
||||
@@ -39,7 +39,7 @@ enum RevocationLatch {
|
||||
|
||||
/// Which of a directory's devices may still be used.
|
||||
///
|
||||
/// The directory is filtered, never trusted. This is the whole point
|
||||
/// The directory is filtered, never trusted. This is the point
|
||||
/// of relay#24: a device the log says is gone stays gone even while the
|
||||
/// relay keeps listing it.
|
||||
static func usable<T>(_ devices: [T], revoked: Set<String>,
|
||||
|
||||
@@ -11,8 +11,8 @@ import CryptoKit
|
||||
/// Comparing this number over a channel the server doesn't control (in person,
|
||||
/// on the phone) is what closes that hole.
|
||||
///
|
||||
/// > **This mirrors `SafetyNumber` in the web engine (`assets/js/e2e.js`),
|
||||
/// > which is the reference implementation**, it shipped first, so it defines
|
||||
/// > This mirrors `SafetyNumber` in the web engine (`assets/js/e2e.js`),
|
||||
/// > which is the reference implementation, it shipped first, so it defines
|
||||
/// > the format. Both sides must produce identical digits or two people
|
||||
/// > comparing numbers learn nothing. Verified equal against the web engine for
|
||||
/// > fixed keys; if you change one, change both and re-run that check.
|
||||
|
||||
@@ -170,8 +170,8 @@ struct DeliveryKey {
|
||||
/// The control message handing a contact our key (§14.4) and, since §21.4,
|
||||
/// a batch of single-use capabilities alongside it.
|
||||
///
|
||||
/// **One envelope, not two, and that is a compatibility decision rather
|
||||
/// than tidiness.** A receiver recognises control traffic by KIND first and
|
||||
/// One envelope, not two, and that is a compatibility decision rather
|
||||
/// than tidiness. A receiver recognises control traffic by KIND first and
|
||||
/// falls back to the `ctl` flag only for kinds it has never heard of, and
|
||||
/// the Android build in the field reads that flag with `optBoolean`, which
|
||||
/// does not coerce the number §6.10 specifies. A new `delivery_tokens` kind
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
|
||||
/// Zeroing key material once it has been used.
|
||||
///
|
||||
/// **This narrows a window. It does not remove the material, and claiming
|
||||
/// otherwise would be the whole value of the feature spent on a lie.** What it
|
||||
/// This narrows a window. It does not remove the material, and claiming
|
||||
/// otherwise would be the whole value of the feature spent on a lie. What it
|
||||
/// buys is that a derived key does not sit in the process for the lifetime of
|
||||
/// the app after the one moment it was needed, which matters for a memory
|
||||
/// dump, a crash report, or a debugger attached later. It buys nothing against
|
||||
@@ -59,7 +59,7 @@ public final class SecureBytes {
|
||||
try body(UnsafeRawBufferPointer(buffer))
|
||||
}
|
||||
|
||||
/// A copy, for the callers that genuinely need one.
|
||||
/// A copy, for the callers that need one.
|
||||
///
|
||||
/// The copy is ordinary memory and this type stops being responsible for
|
||||
/// it. Every use of this is a decision to leave a second copy behind.
|
||||
|
||||
@@ -10,8 +10,8 @@ enum MessengerError: LocalizedError, Equatable {
|
||||
case httpStatus(Int)
|
||||
/// A 401 the relay explained.
|
||||
///
|
||||
/// **The reason matters, because one of them is recoverable and the rest
|
||||
/// are not.** `token device does not match the registered identity key`
|
||||
/// The reason matters, because one of them is recoverable and the rest
|
||||
/// are not. `token device does not match the registered identity key`
|
||||
/// means this install is presenting a device id and an identity key that do
|
||||
/// not belong together, the relay is right to refuse, and retrying with a
|
||||
/// freshly minted token changes nothing, because the token is minted from
|
||||
@@ -49,8 +49,8 @@ enum MessengerError: LocalizedError, Equatable {
|
||||
case .notAuthenticated: raw = "You need to sign in again."
|
||||
case .nonceUnavailable: raw = "Couldn't obtain a security token from the site."
|
||||
case .transport(let m): raw = "Network error: \(m)"
|
||||
// **451 is the geo-gate, and it needs a sentence rather than a
|
||||
// number.** The edge answers 451 for a country the service is not
|
||||
// 451 is the geo-gate, and it needs a sentence rather than a
|
||||
// number. The edge answers 451 for a country the service is not
|
||||
// offered in, and the download site is deliberately NOT gated, so
|
||||
// somebody can read the page, install the app, choose a password, tap
|
||||
// sign up, and be shown "Server returned HTTP 451." Nothing in that
|
||||
|
||||
@@ -2,8 +2,8 @@ import Foundation
|
||||
|
||||
/// Whether relay traffic goes through an obfs4 bridge, and which one.
|
||||
///
|
||||
/// **Off unless the user configures a bridge, and no bridge ships by
|
||||
/// default.** obfs4 is for reaching the relay on a network that blocks or
|
||||
/// Off unless the user configures a bridge, and no bridge ships by
|
||||
/// default. obfs4 is for reaching the relay on a network that blocks or
|
||||
/// fingerprints TLS to it. Turning it on for everyone would change the network
|
||||
/// path for every user to solve a problem most of them do not have, and where
|
||||
/// a bridge shares the relay's address, it does not even help against IP
|
||||
@@ -19,7 +19,7 @@ public enum Obfs4Settings {
|
||||
|
||||
/// What is wrong with a bridge line, specifically enough to say so.
|
||||
///
|
||||
/// The reason is the entire point of this type. Parsing used to
|
||||
/// The reason is the point of this type. Parsing used to
|
||||
/// return `nil` and the caller fell back to a direct connection, so a
|
||||
/// mistyped line behaved exactly like no line at all, which is the worst
|
||||
/// possible outcome for the one person who pasted a bridge *because* direct
|
||||
@@ -265,7 +265,7 @@ public final class Obfs4Proxy {
|
||||
/// No bridge configured. The ordinary case.
|
||||
case off
|
||||
/// A bridge is stored but nothing has needed the network yet, so
|
||||
/// whether it works is genuinely not known.
|
||||
/// whether it works is not known.
|
||||
case notStartedYet(host: String, port: UInt16)
|
||||
case active(host: String, port: UInt16)
|
||||
/// The local listener could not be opened, so traffic is going direct.
|
||||
@@ -332,8 +332,8 @@ extension Obfs4Proxy {
|
||||
|
||||
/// What a reachability check found.
|
||||
///
|
||||
/// **A valid line and a working bridge are different questions, and
|
||||
/// people conflate them.** `Obfs4Settings.validate` proves the line is
|
||||
/// A valid line and a working bridge are different questions, and
|
||||
/// people conflate them. `Obfs4Settings.validate` proves the line is
|
||||
/// well-formed; it cannot tell whether anything is listening at that
|
||||
/// address or whether the key in the line is the key the bridge holds. Only
|
||||
/// a real connection settles that, so the screen offers one.
|
||||
@@ -358,7 +358,7 @@ extension Obfs4Proxy {
|
||||
return "This phone wouldn't let the app set up the connection. Restarting "
|
||||
+ "the app usually clears it."
|
||||
case .noAnswer:
|
||||
// Says both possibilities because it genuinely cannot tell, and
|
||||
// Says both possibilities because it cannot tell, and
|
||||
// guessing would send somebody off to fix the wrong thing.
|
||||
return "No answer from the bridge. That means either the line isn't quite "
|
||||
+ "right, or nothing is running at that address, a bridge stays quiet "
|
||||
|
||||
@@ -23,7 +23,7 @@ import UIKit
|
||||
/// The simulator trips this and is never jailbroken. Measured, not
|
||||
/// assumed: of the checks below exactly one fires there, `/usr/sbin/sshd`
|
||||
/// exists in the runtime root, where on a stock iPhone it does not. (The
|
||||
/// sandbox genuinely holds on the simulator, and no package manager answers.)
|
||||
/// sandbox holds on the simulator, and no package manager answers.)
|
||||
/// One false positive is enough: it would put the warning on every
|
||||
/// development run and every screenshot, which is the fastest way to teach
|
||||
/// somebody to ignore it.
|
||||
|
||||
@@ -21,8 +21,8 @@ import UIKit
|
||||
/// One palette, two apps. Android's `Theme.kt` carries the same values
|
||||
/// under the same names. Its comment once claimed the two were already matched
|
||||
/// while iOS was indigo and Android was ember, nothing checks the claim, so it
|
||||
/// was quietly false. **Change a value here and change it there in the same
|
||||
/// commit.**
|
||||
/// was quietly false. Change a value here and change it there in the same
|
||||
/// commit.
|
||||
enum Theme {
|
||||
|
||||
// MARK: - Brand
|
||||
|
||||
@@ -72,8 +72,8 @@ struct BuddyListView: View {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add a contact")
|
||||
// **`home.addContact`, because `home.add` is the Add
|
||||
// button on a LOOKUP RESULT further down this file.** Both
|
||||
// `home.addContact`, because `home.add` is the Add
|
||||
// button on a LOOKUP RESULT further down this file. Both
|
||||
// carried `home.add`, and two different actions under one
|
||||
// name is not only a test problem: anything reading the
|
||||
// screen by identifier hits the same collision, VoiceOver
|
||||
@@ -201,8 +201,8 @@ struct BuddyListView: View {
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
// **Home is two things now, on purpose: your people, and their
|
||||
// stories.** The conversation rows, the groups and the remote threads
|
||||
// Home is two things now, on purpose: your people, and their
|
||||
// stories. The conversation rows, the groups and the remote threads
|
||||
// moved to the Messages tab, this screen is the kit's social front
|
||||
// page: the Recent circles, then everyone's stories. The roster is
|
||||
// still managed here: the search box looks a handle up, and the
|
||||
|
||||
@@ -38,8 +38,8 @@ struct BuddyProfileView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
// **The kit's contact detail: a cover, a name plate, and a
|
||||
// rail of actions floating at the seam between them.** This
|
||||
// The kit's contact detail: a cover, a name plate, and a
|
||||
// rail of actions floating at the seam between them. This
|
||||
// was a centred avatar, a full-width filled button, and two
|
||||
// small quick-action tiles, three separate ways of offering
|
||||
// an action stacked on top of each other. The rail puts them
|
||||
@@ -481,7 +481,7 @@ struct BuddyProfileView: View {
|
||||
// Which guarantee these digits actually cover (audit H3).
|
||||
// A number covering only the classical key must not look
|
||||
// identical to one that also covers the post-quantum
|
||||
// identity, the difference is the whole point of the
|
||||
// identity, the difference is the point of the
|
||||
// second key.
|
||||
if inbox.safetyNumberIsPostQuantum(for: buddy.id) {
|
||||
Label("Covers their post-quantum identity too",
|
||||
|
||||
@@ -775,8 +775,8 @@ struct ChatView: View {
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
// **Only on messages somebody else sent
|
||||
// (ios#12).** Reporting your own message is not
|
||||
// Only on messages somebody else sent
|
||||
// (ios#12). Reporting your own message is not
|
||||
// a thing anybody means to do, and offering it
|
||||
// makes the menu read as a moderation panel
|
||||
// rather than a way to get help.
|
||||
@@ -828,8 +828,8 @@ struct ChatView: View {
|
||||
systemImage: isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
}
|
||||
// **The same label for both, because the
|
||||
// action is the same (ios#57).** This said
|
||||
// The same label for both, because the
|
||||
// action is the same (ios#57). This said
|
||||
// "Unsend" on your own messages and did nothing
|
||||
// of the kind: it removes the message from this
|
||||
// device and the recipient's copy stays exactly
|
||||
@@ -985,7 +985,7 @@ struct ChatView: View {
|
||||
|
||||
/// Remove one of my own messages from THIS device.
|
||||
///
|
||||
/// Identical to `deleteLocal`, and that is the point (ios#57). The
|
||||
/// Identical to `deleteLocal` (ios#57). The
|
||||
/// two were separate because the buttons above claimed to do different
|
||||
/// things, "Unsend" and "Delete for me", while calling the same three
|
||||
/// lines. Kept as two named functions rather than collapsed into one so
|
||||
|
||||
@@ -67,8 +67,8 @@ enum ImagePrep {
|
||||
|
||||
/// Redraw at the target size and encode as JPEG.
|
||||
///
|
||||
/// **This is also where the photo's metadata goes, and that is a
|
||||
/// guarantee rather than a side effect.** A camera-roll picture carries an
|
||||
/// This is also where the photo's metadata goes, and that is a
|
||||
/// guarantee rather than a side effect. A camera-roll picture carries an
|
||||
/// EXIF block, and on a phone that block contains the GPS coordinates the
|
||||
/// shot was taken at, so sending one unaltered tells the recipient, and
|
||||
/// anyone who later gets the file, where the sender lives. Drawing into a
|
||||
|
||||
@@ -75,7 +75,7 @@ enum MessageText {
|
||||
/// The matching close for a marker, or nil if this one is just a character.
|
||||
///
|
||||
/// Two rules, both there to stop ordinary punctuation becoming formatting:
|
||||
/// the span may not be empty (`**` is two asterisks, not empty bold), and it
|
||||
/// the span may not be empty (`` is two asterisks, not empty bold), and it
|
||||
/// may not run past the end of the line, an unclosed `*` in "2 * 3 = 6"
|
||||
/// would otherwise swallow the rest of the message.
|
||||
private static func closingIndex(of token: Character, in body: String,
|
||||
|
||||
@@ -5,8 +5,8 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// Strips metadata from an image on its way out, whichever door it left by.
|
||||
///
|
||||
/// **A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
|
||||
/// not** (ios#69). The picker path redraws into a fresh graphics context, so its
|
||||
/// A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
|
||||
/// not (ios#69). The picker path redraws into a fresh graphics context, so its
|
||||
/// output is pixels with no container to carry EXIF. The vault path handed the
|
||||
/// stored bytes straight to `sendFile`. Somebody who had learned "Flamenet
|
||||
/// strips location from my photos" had learned something true of one button and
|
||||
|
||||
@@ -2,8 +2,8 @@ import SwiftUI
|
||||
|
||||
/// A timer for the next message only (§6.17).
|
||||
///
|
||||
/// **Separate from `DisappearingPicker`, and worded so nobody confuses
|
||||
/// them.** The conversation setting is a fact about the room that both people
|
||||
/// Separate from `DisappearingPicker`, and worded so nobody confuses
|
||||
/// them. The conversation setting is a fact about the room that both people
|
||||
/// see; this is a decision about one message that only the sender makes.
|
||||
/// Somebody who mistakes one for the other either keeps something they meant to
|
||||
/// lose, or loses a whole conversation they meant to keep.
|
||||
|
||||
@@ -8,8 +8,8 @@ import SwiftUI
|
||||
/// rests on the page. `TabView`'s bar is a full-bleed strip pinned to the
|
||||
/// bottom edge, and no amount of tinting turns one into the other.
|
||||
///
|
||||
/// **Replacing `TabView` costs real things, and they are paid for here
|
||||
/// rather than lost.** The system bar gives you the safe-area inset, the
|
||||
/// Replacing `TabView` costs real things, and they are paid for here
|
||||
/// rather than lost. The system bar gives you the safe-area inset, the
|
||||
/// double-tap-to-pop-to-root gesture, VoiceOver's tab-bar rotor and automatic
|
||||
/// badge placement. The inset and the badges are below; the rotor comes from
|
||||
/// the accessibility traits; scroll-to-top is the one that does not come back,
|
||||
@@ -118,8 +118,8 @@ struct PillTabBar<Content: View>: View {
|
||||
content(selection)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.pillTabBarVisibility, visibility)
|
||||
// **The other half of replacing `TabView`, and it was
|
||||
// missing.** The header above names the safe-area inset as one
|
||||
// The other half of replacing `TabView`, and it was
|
||||
// missing. The header above names the safe-area inset as one
|
||||
// of the things the system bar gives you and says "the inset
|
||||
// and the badges are below". The badges were; the inset was
|
||||
// not, anywhere in `Sources/`.
|
||||
|
||||
@@ -10,8 +10,8 @@ import SwiftUI
|
||||
/// is dismissed. Same shape as the empty state that used to swallow the Groups
|
||||
/// section, in the one place it is hardest to notice.
|
||||
///
|
||||
/// **Its own view, holding `@ObservedObject`, rather than a function on the
|
||||
/// list.** `AppEnvironment` publishes the inbox REFERENCE; a parent that reads
|
||||
/// Its own view, holding `@ObservedObject`, rather than a function on the
|
||||
/// list. `AppEnvironment` publishes the inbox REFERENCE; a parent that reads
|
||||
/// `inbox.remoteCorrespondents` inside its own body is redrawn when the inbox
|
||||
/// is swapped and never when a message arrives in it. Deciding "is this section
|
||||
/// empty" up there would have made the section appear only after something
|
||||
|
||||
@@ -9,8 +9,8 @@ import LocalAuthentication
|
||||
/// a backup feature adds are as reachable as the chat list. Somebody handed an
|
||||
/// unlocked phone for a moment can read a safety number, or remove a device.
|
||||
///
|
||||
/// **Friction on screens people rarely visit, which is the right place for
|
||||
/// it.** Nothing here gates a conversation. If this ever guards something
|
||||
/// Friction on screens people rarely visit, which is the right place for
|
||||
/// it. Nothing here gates a conversation. If this ever guards something
|
||||
/// people open daily, it is in the wrong place.
|
||||
struct SensitiveScreen<Content: View>: View {
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ struct StorageFullBanner: View {
|
||||
|
||||
/// Present tense and concrete. "An error occurred" would tell somebody
|
||||
/// nothing they can act on, and this is the one storage failure they can
|
||||
/// genuinely fix themselves in about a minute.
|
||||
/// fix themselves in about a minute.
|
||||
static func title(for failure: E2EHistoryStore.WriteFailure) -> String {
|
||||
switch failure {
|
||||
case .outOfSpace: return "This iPhone is out of space"
|
||||
|
||||
@@ -411,8 +411,8 @@ private struct StoryViewer: View {
|
||||
|
||||
/// Write your own story out to the photo library (ios#34).
|
||||
///
|
||||
/// **The saved copy leaves the disappearing model, and the confirmation
|
||||
/// says so.** "Saved" on its own would be true and useless: the point of
|
||||
/// The saved copy leaves the disappearing model, and the confirmation
|
||||
/// says so. "Saved" on its own would be true and useless: the point of
|
||||
/// the message is that this one copy now outlives the story, and where it
|
||||
/// went. Anything vaguer would let somebody believe the expiry still
|
||||
/// applied to it.
|
||||
|
||||
@@ -27,7 +27,7 @@ struct StoryStreamGate: View {
|
||||
.font(.title2)
|
||||
Text("Live stream")
|
||||
.font(.headline)
|
||||
// The host is the whole point of the disclosure: "somewhere on
|
||||
// The host is the point of the disclosure: "somewhere on
|
||||
// the internet" is not something anyone can make a decision
|
||||
// about.
|
||||
Text("Playing connects you to \(host), which will see your IP "
|
||||
|
||||
@@ -83,8 +83,8 @@ struct FilesView: View {
|
||||
#endif
|
||||
private var folder: LocalVault.Node? { breadcrumb.last }
|
||||
|
||||
/// **There is no ceiling to state on a local vault, and claiming one was
|
||||
/// worse than saying nothing.**
|
||||
/// There is no ceiling to state on a local vault, and claiming one was
|
||||
/// worse than saying nothing.
|
||||
///
|
||||
/// The first version of this screen computed a "you can read up to X" from
|
||||
/// the rows on screen. That is circular, the device derives a label key
|
||||
|
||||
@@ -8,8 +8,8 @@ typealias SecurityLabel = FilesEngine.Label
|
||||
|
||||
/// A file's label, shown.
|
||||
///
|
||||
/// **The classification is the whole point of this system and the browser
|
||||
/// was not showing it.** A list of file names in an enclave is a list of file
|
||||
/// The classification is the point of this system and the browser
|
||||
/// was not showing it. A list of file names in an enclave is a list of file
|
||||
/// names; what makes a row meaningful is the sensitivity it carries and the
|
||||
/// areas it belongs to, and somebody deciding whether to send a document
|
||||
/// needs that visible on the row, not one tap away.
|
||||
|
||||
@@ -4,8 +4,8 @@ import SwiftUI
|
||||
|
||||
/// Choose the classification a new file or folder is filed at.
|
||||
///
|
||||
/// **Everything was landing Unclassified because nothing could say
|
||||
/// otherwise.** The vault has taken a `Label` since it was written, `add`,
|
||||
/// Everything was landing Unclassified because nothing could say
|
||||
/// otherwise. The vault has taken a `Label` since it was written, `add`,
|
||||
/// `reference` and `createFolder` all require one, but every call site passed
|
||||
/// `SecurityLabel(classification: 0)`, so the browser rendered a classification
|
||||
/// chip on every row that could only ever read the same thing. A system whose
|
||||
|
||||
@@ -107,8 +107,8 @@ struct SendFileSheet: View {
|
||||
+ "phone, it doesn't stop them reading it.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
// **The photo button strips location; this one does not
|
||||
// (ios#69).** A picture attached with the camera button is
|
||||
// The photo button strips location; this one does not
|
||||
// (ios#69). A picture attached with the camera button is
|
||||
// redrawn through `ImagePrep`, which produces pixels and no
|
||||
// metadata container. A file goes byte for byte, EXIF and all
|
||||
// which is what "send this file" ought to mean, and is also
|
||||
|
||||
@@ -122,8 +122,7 @@ struct ForgotPasswordSheet: View {
|
||||
private var redeemStep: some View {
|
||||
VStack(spacing: 14) {
|
||||
// Says "if" rather than "we sent one": the server answers the same
|
||||
// way whether or not the account exists, so this screen genuinely
|
||||
// does not know, and claiming otherwise would be the one place the
|
||||
// way whether or not the account exists, so this screen // does not know, and claiming otherwise would be the one place the
|
||||
// app leaks what the server refused to.
|
||||
Text("If that account exists and has an email address, a code is on its way. "
|
||||
+ "Paste it below with a new password.")
|
||||
|
||||
@@ -206,8 +206,8 @@ struct ProfileView: View {
|
||||
) {
|
||||
Button("Sign out", role: .destructive) { environment.signOut() }
|
||||
.accessibilityIdentifier("profile.signOut.confirm")
|
||||
// **The one that erases is its own button, and is not the
|
||||
// default.** Both used to be the same button: signing out for
|
||||
// The one that erases is its own button, and is not the
|
||||
// default. Both used to be the same button: signing out for
|
||||
// an afternoon destroyed this phone's identity, so coming back
|
||||
// made every contact accept a safety-number change first. The
|
||||
// erase is a real thing people need, handing a phone on, but
|
||||
|
||||
@@ -96,8 +96,8 @@ struct DevicesView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// **A visible button, because swipe-to-delete was the only way
|
||||
// to reach this and nobody found it.** This screen is the one
|
||||
// A visible button, because swipe-to-delete was the only way
|
||||
// to reach this and nobody found it. This screen is the one
|
||||
// remedy for the three-device cap, so a control you have to already
|
||||
// know about makes "the limit is 3" a support ticket, which is the
|
||||
// exact outcome the doc comment above says it exists to prevent.
|
||||
|
||||
@@ -4,8 +4,8 @@ import SwiftUI
|
||||
/// Your address, and the relays that hold mail for it.
|
||||
///
|
||||
/// The screen exists to make one distinction legible, because getting it wrong
|
||||
/// is how somebody loses messages: **your address is yours, your mailbox is
|
||||
/// your relay's.** The address comes from your passphrase, so it survives any
|
||||
/// is how somebody loses messages: your address is yours, your mailbox is
|
||||
/// your relay's. The address comes from your passphrase, so it survives any
|
||||
/// server. A mailbox does not, and mail only reaches the mailboxes you added
|
||||
/// *before* a relay went away, because after it is gone there is nothing left
|
||||
/// to tell your contacts where you went.
|
||||
@@ -223,7 +223,7 @@ struct MailboxesView: View {
|
||||
// survive, and showing it unchecked would make the screen a place
|
||||
// that quietly confirms the attack.
|
||||
// And not older than one already seen. An old record is
|
||||
// genuinely signed, published before a second home was added, so
|
||||
// signed, published before a second home was added, so
|
||||
// a relay wanting to be somebody's only mailbox does not have to
|
||||
// forge anything; it serves the record it saw first. Checking the
|
||||
// signature alone accepts that, which is what the serial exists to
|
||||
@@ -242,7 +242,7 @@ struct MailboxesView: View {
|
||||
}
|
||||
// Raised only after the record was ACCEPTED. Recording the serial
|
||||
// of one that failed verification would let a hostile relay raise
|
||||
// the floor with a forgery and lock out every later genuine record.
|
||||
// the floor with a forgery and lock out every later record.
|
||||
AccountIdentityService.rememberHomeSerial(address, record.serial)
|
||||
homes = record.relays
|
||||
problem = nil
|
||||
|
||||
@@ -85,8 +85,8 @@ struct PrivacySettingsView: View {
|
||||
Text(Self.sealingFooter)
|
||||
}
|
||||
|
||||
// **These three were named for what people assume they do, on a
|
||||
// screen called Privacy & Security, and they do something else.**
|
||||
// These three were named for what people assume they do, on a
|
||||
// screen called Privacy & Security, and they do something else.
|
||||
//
|
||||
// "Show last seen" and "Show online status" hide other people's
|
||||
// status from THIS phone. They publish nothing differently: turn
|
||||
|
||||
@@ -16,8 +16,8 @@ enum SettingsKeys {
|
||||
static let showOnlineStatus = "settings.privacy.onlineStatus"
|
||||
/// Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
|
||||
///
|
||||
/// **Off by default, and that is the honest default rather than the
|
||||
/// lazy one.** An unsealed send is still end-to-end encrypted; what leaks
|
||||
/// Off by default, and that is the honest default rather than the
|
||||
/// lazy one. An unsealed send is still end-to-end encrypted; what leaks
|
||||
/// is that this account sent something to that one. Refusing turns a
|
||||
/// metadata leak into an undelivered message, which for most people is the
|
||||
/// worse outcome, so the default degrades and SAYS SO, and the people for
|
||||
|
||||
@@ -165,8 +165,8 @@ struct SignUpView: View {
|
||||
field: .email, next: .password)
|
||||
}
|
||||
|
||||
// **`.password`, NOT `.newPassword`, and this is a usability
|
||||
// fix rather than a preference.** `.newPassword` turns on iOS's
|
||||
// `.password`, NOT `.newPassword`, and this is a usability
|
||||
// fix rather than a preference. `.newPassword` turns on iOS's
|
||||
// automatic strong password, which slides a panel over the form
|
||||
// and takes the field the moment it gets focus, so typing a
|
||||
// passphrase by hand gets one character in and then loses it,
|
||||
|
||||
@@ -66,7 +66,7 @@ enum FilesPassphraseStore {
|
||||
q[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUISkip
|
||||
let status = SecItemCopyMatching(q as CFDictionary, nil)
|
||||
// `interactionNotAllowed` is the success case here: the item is there
|
||||
// and refused to open without authentication, which is the point.
|
||||
// and refused to open without authentication.
|
||||
return status == errSecSuccess || status == errSecInteractionNotAllowed
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -56,11 +56,11 @@ Username: flamenetreview2
|
||||
Password: <the same passphrase>
|
||||
```
|
||||
|
||||
**The passphrase is deliberately not written here.** This repository is
|
||||
The passphrase is deliberately not written here. This repository is
|
||||
public and these accounts are live; committing the value hands them to anyone.
|
||||
The tests read it from `FN_DEMO_PASSPHRASE`.
|
||||
|
||||
**The previous pair was burned and these replace them.** The old passphrase
|
||||
The previous pair was burned and these replace them. The old passphrase
|
||||
was committed and later removed from the tree, but removing a secret from the
|
||||
tree does not remove it from history, and it remained readable in three commits
|
||||
on `origin/main` of this **public** repository. Because an fnid is derived from
|
||||
@@ -112,7 +112,7 @@ xcodebuild test -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
|
||||
-only-testing:FlamenetMessengerUITests/DemoAccountSetupUITests
|
||||
```
|
||||
|
||||
**Never "verify" these with curl against relay.flamenet.io.** That host is
|
||||
Never "verify" these with curl against relay.flamenet.io. That host is
|
||||
**pinned**, so the app does not use the relay's own sign-in, it authenticates
|
||||
against the shared FlameNet account service (`accounts.flamenet.io`) with a
|
||||
*handle*. An account made on the relay answers **200 to curl** and **"handle not
|
||||
@@ -124,7 +124,7 @@ path and carry **no password at all**.
|
||||
|
||||
### Notes ← the field that decides whether this passes
|
||||
|
||||
> **Please read, the first account will look empty, and that is by design.**
|
||||
> Please read, the first account will look empty, and that is by design.
|
||||
>
|
||||
> This is an end-to-end encrypted messenger. Messages are encrypted to a
|
||||
> specific *device*, not to an account. When you sign in on your device you
|
||||
@@ -132,7 +132,7 @@ path and carry **no password at all**.
|
||||
> it cannot decrypt them. We cannot pre-populate a conversation for you; if we
|
||||
> could, the encryption would not be working.
|
||||
>
|
||||
> **The order matters, please start with the second account.**
|
||||
> The order matters, please start with the second account.
|
||||
>
|
||||
> 1. Sign in as **flamenetreview2** (password above). Wait for the Home tab to
|
||||
> load, then sign out from the Account tab. This registers this device for
|
||||
@@ -148,7 +148,7 @@ path and carry **no password at all**.
|
||||
> appears when a contact signs in on a new device, which is exactly what step 5
|
||||
> does. Accept it to continue.
|
||||
>
|
||||
> **Push notifications are not implemented in this build.** The app shows local
|
||||
> Push notifications are not implemented in this build. The app shows local
|
||||
> notifications only while it is open. This is unbuilt, not broken.
|
||||
>
|
||||
> **Voice and video calls** need two live devices; with one test device the call
|
||||
@@ -189,7 +189,7 @@ Recurring: the annual **BIS self-classification report**, due each February.
|
||||
|
||||
## 5. Before you hit submit
|
||||
|
||||
- [x] **Both demo accounts verified signing in through the app UI** (2026-08-26).
|
||||
- [x] Both demo accounts verified signing in through the app UI (2026-08-26).
|
||||
- [ ] Walk the five steps above once on a real iPhone. Sign-in is proven; the
|
||||
full send-and-read round trip on one physical device is not.
|
||||
- [ ] `sudo xcode-select -s /Applications/Xcode.app`, you are pointed at
|
||||
|
||||
@@ -3,8 +3,8 @@ import XCTest
|
||||
|
||||
/// The Privacy & Security screen must not promise what the app does not do.
|
||||
///
|
||||
/// **Three toggles were named for what people assume, and did something
|
||||
/// else.** "Show last seen" and "Show online status" hide other people's status
|
||||
/// Three toggles were named for what people assume, and did something
|
||||
/// else. "Show last seen" and "Show online status" hide other people's status
|
||||
/// from this phone and publish nothing differently, turn both off and your
|
||||
/// contacts are still told you are online. "Read receipts" governed whether
|
||||
/// opening an alert marked it read on the relay; the app sends no read receipts
|
||||
|
||||
@@ -60,8 +60,8 @@ final class AppLockBackoffTests: XCTestCase {
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// **The obvious bypass for a back-off held in memory is to force-quit
|
||||
/// the app.** So the count and the deadline are on disk, and a fresh
|
||||
/// The obvious bypass for a back-off held in memory is to force-quit
|
||||
/// the app. So the count and the deadline are on disk, and a fresh
|
||||
/// `AppLock` reads them back.
|
||||
func testAWaitSurvivesARelaunch() {
|
||||
defaults.set(4, forKey: SettingsKeys.appLockFailures)
|
||||
|
||||
@@ -6,8 +6,8 @@ import XCTest
|
||||
|
||||
/// §19 enforcement with no setting to find first.
|
||||
///
|
||||
/// **This used to be opt-in and off, so the protection shipped and almost
|
||||
/// nobody had it.** The argument for a switch, a relay predating §19 keeps no
|
||||
/// 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 §19 keeps no
|
||||
/// log, and refusing every peer there is worse than the attack, was an
|
||||
/// argument for skipping relays that *cannot* answer, never for skipping the
|
||||
/// ones that can. These tests pin that distinction, because it is the whole
|
||||
@@ -18,7 +18,7 @@ final class AutomaticKeyLogTests: XCTestCase {
|
||||
/// A relay whose transparency log can be turned on, off, or made to fail.
|
||||
final class LogRelay: URLProtocol {
|
||||
|
||||
/// nil means the head route is genuinely absent, a deployment from
|
||||
/// nil means the head route is absent, a deployment from
|
||||
/// before §19. `.some` means it answers.
|
||||
nonisolated(unsafe) static var head: String? = nil
|
||||
/// A status to return for every log route, for the "having a bad day"
|
||||
@@ -92,7 +92,7 @@ final class AutomaticKeyLogTests: XCTestCase {
|
||||
let store = KeychainE2EStore(userId: userID)
|
||||
store.wipe()
|
||||
// Off explicitly. Every assertion below has to hold with the switch
|
||||
// in its default position, which is the entire point of the change.
|
||||
// in its default position, which is the point of the change.
|
||||
UserDefaults.standard.set(false, forKey: "fnmsg.requireKeyLog")
|
||||
let client = E2EClient(siteBaseURL: URL(string: "https://relay.example")!,
|
||||
basicAuth: ("user\(userID)", "pw"),
|
||||
@@ -271,8 +271,8 @@ final class AutomaticKeyLogTests: XCTestCase {
|
||||
|
||||
// MARK: - How the question is asked
|
||||
|
||||
/// **The head is asked for separately, and that is what makes the skip
|
||||
/// safe.** Enforcement reads the head, then the device's leaves, then an
|
||||
/// The head is asked for separately, and that is what makes the skip
|
||||
/// safe. Enforcement reads the head, then the device's leaves, then an
|
||||
/// inclusion proof, so a relay wanting to skip the check would otherwise
|
||||
/// only have to 404 the *middle* one to make a substituted key look like a
|
||||
/// deployment without transparency. Here the device listing 404s while the
|
||||
|
||||
@@ -8,7 +8,7 @@ struct DeliveryDiagnosticsTests {
|
||||
|
||||
// MARK: - ios#22, the give-up rule
|
||||
|
||||
/// The whole point. A refusal waiting cannot fix must stop, or the
|
||||
///A refusal waiting cannot fix must stop, or the
|
||||
/// bubble goes on promising "will send" for ever.
|
||||
@Test("a changed identity is not something waiting fixes")
|
||||
func identityChangeStopsRetrying() {
|
||||
@@ -21,7 +21,7 @@ struct DeliveryDiagnosticsTests {
|
||||
/// And the other half, which matters just as much: a contact who is
|
||||
/// offline or has not registered a device yet still gets the message
|
||||
/// whenever they come back. Giving up on those would delete something
|
||||
/// somebody wrote in order to tidy up a label.
|
||||
/// somebody wrote to tidy up a label.
|
||||
@Test("being offline or unregistered still retries for ever")
|
||||
func transientKeepsRetrying() {
|
||||
#expect(E2EError.prekeyMissing.waitingCanFix)
|
||||
@@ -45,8 +45,8 @@ struct DeliveryDiagnosticsTests {
|
||||
|
||||
// MARK: - ios#21, what must never reach the screen
|
||||
|
||||
/// **A diagnostic screen that leaks what it is diagnosing is worse than
|
||||
/// none.** These reasons are written for a log and several name an id:
|
||||
/// A diagnostic screen that leaks what it is diagnosing is worse than
|
||||
/// none. These reasons are written for a log and several name an id:
|
||||
/// "device claims 41 but is bound to 12". The reading is meant to be safe
|
||||
/// to screenshot and send to somebody.
|
||||
@Test("identifiers are stripped from drop reasons before they are counted")
|
||||
|
||||
@@ -22,7 +22,7 @@ final class DeviceWipeTests: XCTestCase {
|
||||
accountKey: account.publicKey))
|
||||
}
|
||||
|
||||
/// The whole point. A relay inventing an instruction has no account key,
|
||||
///A relay inventing an instruction has no account key,
|
||||
/// so its signature does not verify.
|
||||
func testAnInstructionFromAnyoneElseIsRefused() throws {
|
||||
let impostor = Curve25519.Signing.PrivateKey()
|
||||
@@ -32,7 +32,7 @@ final class DeviceWipeTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// Checks the device id as well as the signature. A genuine
|
||||
/// instruction for another device is still a genuine signature, verifying
|
||||
/// instruction for another device is still a signature, verifying
|
||||
/// that alone would let one device's wipe erase every device that saw it.
|
||||
func testAGenuineInstructionForAnotherDeviceIsRefused() throws {
|
||||
let wipe = try DeviceWipe.issue(device: "dev-2", at: at, accountKey: account)
|
||||
|
||||
@@ -354,8 +354,8 @@ final class E2EInboxConversationTests: XCTestCase {
|
||||
return (200, #"{"ok":true,"remaining":\#(tokens.count)}"#)
|
||||
}
|
||||
if path.hasSuffix("/account/token") { return (404, "{}") } // stay on Basic
|
||||
// **404, not 503, and the difference is the whole point of
|
||||
// `enforceKeyLogIfServed`.** A relay from before §19 does not have
|
||||
// 404, not 503, and the difference is the point of
|
||||
// `enforceKeyLogIfServed`. A relay from before §19 does not have
|
||||
// this route; a relay having a bad day answers 5xx. Only the first
|
||||
// is allowed to skip key checking, reading a 503 as "no log here"
|
||||
// would make enforcement skippable by anyone able to make one
|
||||
@@ -843,8 +843,8 @@ extension E2EInboxConversationTests {
|
||||
XCTAssertEqual(seen, "Book club", "an unproven device renamed the group")
|
||||
}
|
||||
|
||||
/// **We never encrypt to our own device, and the announcement relies on
|
||||
/// that.** Group names are announced to our own ACCOUNT as well as to the
|
||||
/// We never encrypt to our own device, and the announcement relies on
|
||||
/// that. Group names are announced to our own ACCOUNT as well as to the
|
||||
/// members, because fan-out excludes us everywhere else and a second phone
|
||||
/// we own would otherwise be told by nobody. That is only safe because
|
||||
/// `transmit` drops this device from every recipient list: without the
|
||||
@@ -874,8 +874,8 @@ extension E2EInboxConversationTests {
|
||||
|
||||
// MARK: - Sealed groups (§8.9b)
|
||||
|
||||
/// **The property the whole change exists for: no account id reaches the
|
||||
/// relay.** What it stored before was `gid -> user_id`, one readable row per
|
||||
/// The property the whole change exists for: no account id reaches the
|
||||
/// relay. What it stored before was `gid -> user_id`, one readable row per
|
||||
/// membership, seven of them on production, saying exactly who was in which
|
||||
/// group. Asserted against every request body, not against the relay's own
|
||||
/// state, because what it chose to keep is a weaker question than what it
|
||||
@@ -901,8 +901,8 @@ extension E2EInboxConversationTests {
|
||||
/// The check the test above is really making, done on VALUES rather than on
|
||||
/// the raw text.
|
||||
///
|
||||
/// **A substring search over the whole body was flaky, and it failed in
|
||||
/// the direction that gets a security test ignored.** `gid` is a hex
|
||||
/// A substring search over the whole body was flaky, and it failed in
|
||||
/// the direction that gets a security test ignored. `gid` is a hex
|
||||
/// digest of random material, so roughly one run in a thousand contains
|
||||
/// "2421" by chance. CI caught exactly that on 2026-09-06, in
|
||||
/// `...bf62421208...`. A test that cries wolf at that rate teaches people
|
||||
@@ -1048,8 +1048,8 @@ extension E2EInboxConversationTests {
|
||||
XCTAssertNotNil(StubRelay.groups[group.gid], "the roster was replaced anyway")
|
||||
}
|
||||
|
||||
/// **One entry point for membership, routed by whether the group is
|
||||
/// sealed.** Sending a sealed group down the plaintext route asks the relay
|
||||
/// One entry point for membership, routed by whether the group is
|
||||
/// sealed. Sending a sealed group down the plaintext route asks the relay
|
||||
/// to edit a table it no longer has: the call would appear to succeed and
|
||||
/// the member would not be there. The screens call `updateGroupMembers` for
|
||||
/// both kinds, so this is what stops that.
|
||||
@@ -1080,8 +1080,8 @@ extension E2EInboxConversationTests {
|
||||
"carol was never actually added")
|
||||
}
|
||||
|
||||
/// **The gain sealing is supposed to buy: the membership gate stops
|
||||
/// asking the server.**
|
||||
/// The gain sealing is supposed to buy: the membership gate stops
|
||||
/// asking the server.
|
||||
///
|
||||
/// Clients refuse group messages from non-members (§6.7). That check read
|
||||
/// `groups`, and `groups` was assigned outright from `GET /e2e/groups`,
|
||||
|
||||
@@ -21,8 +21,7 @@ final class E2EInboxMailboxTests: XCTestCase {
|
||||
/// A second relay, holding its own copies.
|
||||
///
|
||||
/// A separate `URLProtocol` subclass rather than a flag on the first,
|
||||
/// because the point of the test is that the two mailboxes are genuinely
|
||||
/// separate: their own ids, their own cursor, their own failures. A shared
|
||||
/// because the point of the test is that the two mailboxes are /// separate: their own ids, their own cursor, their own failures. A shared
|
||||
/// store would let a bug that reads one relay's cursor against the other's
|
||||
/// ids pass.
|
||||
final class SecondHome: URLProtocol {
|
||||
@@ -35,7 +34,7 @@ final class E2EInboxMailboxTests: XCTestCase {
|
||||
/// test would then be asserting on a fixture bug rather
|
||||
/// than on the code. (0 is right for a FEDERATED
|
||||
/// deposit, which is sealed and where the relay
|
||||
/// genuinely was not told; that is a different path.)
|
||||
/// was not told; that is a different path.)
|
||||
let fromUser: Int }
|
||||
|
||||
nonisolated(unsafe) static var queue: [Queued] = []
|
||||
@@ -143,7 +142,7 @@ final class E2EInboxMailboxTests: XCTestCase {
|
||||
|
||||
// MARK: - Reading the other mailbox
|
||||
|
||||
/// 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.
|
||||
func testAMessageWaitingAtAnotherRelayIsRead() async throws {
|
||||
let (alice, aliceStore) = try await inbox(userID: 201)
|
||||
@@ -163,8 +162,8 @@ final class E2EInboxMailboxTests: XCTestCase {
|
||||
"got: \(received.map(\.text))")
|
||||
}
|
||||
|
||||
/// **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 copy at every home is the design, so a copy read twice is the
|
||||
/// failure. And it would not present as a duplicate: the ratchet deletes
|
||||
/// a message key once used, so the second copy fails to open and is logged
|
||||
/// as a dropped envelope, a message that looks lost rather than repeated.
|
||||
func testTheSameMessageAtTwoRelaysIsReadOnce() async throws {
|
||||
@@ -212,8 +211,8 @@ final class E2EInboxMailboxTests: XCTestCase {
|
||||
|
||||
// MARK: - When a backup mailbox goes wrong
|
||||
|
||||
/// **A 410 from a BACKUP home is that mailbox ending, not this account
|
||||
/// ending.** The primary's 410 wipes the install, correctly, this device
|
||||
/// A 410 from a BACKUP home is that mailbox ending, not this account
|
||||
/// ending. The primary's 410 wipes the install, correctly, this device
|
||||
/// was removed from the account. Treating a backup the same way would purge
|
||||
/// somebody's messages because one spare relay dropped a registration.
|
||||
func testARevokedBackupMailboxDoesNotWipeTheAccount() async throws {
|
||||
|
||||
@@ -12,7 +12,7 @@ import XCTest
|
||||
///
|
||||
/// These tests are mostly about what does NOT travel, because that is the half
|
||||
/// a future change can quietly break: a field added to `E2EMessage` that is
|
||||
/// copied into a forward "for completeness" would undo the whole point.
|
||||
/// copied into a forward "for completeness" would undo the point.
|
||||
final class ForwardWithoutProvenanceTests: XCTestCase {
|
||||
|
||||
private let sent = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
@@ -138,8 +138,8 @@ final class ForwardWithoutProvenanceTests: XCTestCase {
|
||||
|
||||
// MARK: - The trap this file exists to remember
|
||||
|
||||
/// **`isForwarded` was written as a plain `Bool` first, and that would
|
||||
/// have wiped everybody's history.** Synthesised decoding throws on a
|
||||
/// `isForwarded` was written as a plain `Bool` first, and that would
|
||||
/// have wiped everybody's history. Synthesised decoding throws on a
|
||||
/// missing key rather than using the property's default, so a non-optional
|
||||
/// field makes every snapshot written before today fail to decode, and the
|
||||
/// history file exists precisely so messages are not lost.
|
||||
|
||||
@@ -4,8 +4,8 @@ import XCTest
|
||||
|
||||
/// Stories posted to a group (spec §6.24, ios#55).
|
||||
///
|
||||
/// **The group's roster is the audience, and the sender's story audience
|
||||
/// does not apply.** Two different lists making two different statements, who
|
||||
/// The group's roster is the audience, and the sender's story audience
|
||||
/// does not apply. Two different lists making two different statements, who
|
||||
/// may see this account's stories, and who is in a conversation. They will
|
||||
/// disagree, and the roster wins.
|
||||
final class GroupStoryTests: XCTestCase {
|
||||
|
||||
@@ -12,7 +12,7 @@ import XCTest
|
||||
/// Start one with your buddies."
|
||||
///
|
||||
/// The fixture below is the SERVER's shape, copied from `RoutesGroups.swift`,
|
||||
/// not the shape the client would like. That is the whole point: a decoder
|
||||
/// not the shape the client would like. That is the point: a decoder
|
||||
/// asking for more than the server sends fails closed and silently.
|
||||
final class GroupWireDecodeTests: XCTestCase {
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import XCTest
|
||||
|
||||
/// Conversation history survives being written and read back.
|
||||
///
|
||||
/// **This store was written on 2026-08-26 and did not reach `main` for a
|
||||
/// day**, it sat alone on a parked branch while main moved 174 commits past
|
||||
/// This store was written on 2026-08-26 and did not reach `main` for a
|
||||
/// day, it sat alone on a parked branch while main moved 174 commits past
|
||||
/// it, so the app went on discarding every conversation long after that had
|
||||
/// been decided against. It came back with no test at all, and "it builds" was
|
||||
/// the only evidence for it. That is exactly the state it was in when it got
|
||||
@@ -130,7 +130,7 @@ final class HistoryAtRestTests: XCTestCase {
|
||||
/// The file is unreadable without the key it was sealed under.
|
||||
///
|
||||
/// Losing the device means losing the key, and that is the design, but
|
||||
/// it only holds if a different key genuinely cannot open the blob.
|
||||
/// it only holds if a different key cannot open the blob.
|
||||
func testAnotherKeyCannotOpenTheFile() {
|
||||
let real = store(900_004)
|
||||
real.save(threads: [1: [E2EMessage(id: 1, isMine: true, text: "secret", date: Date())]],
|
||||
|
||||
@@ -4,8 +4,8 @@ import XCTest
|
||||
|
||||
/// A home record older than one already seen must be refused (§20.4).
|
||||
///
|
||||
/// **A signature alone does not stop a rollback, and both clients checked
|
||||
/// only the signature.** An older home record is *genuinely signed*, the
|
||||
/// A signature alone does not stop a rollback, and both clients checked
|
||||
/// only the signature. An older home record is *signed*, the
|
||||
/// account holder published it, before they added a second relay. So a relay
|
||||
/// that wants to be somebody's only mailbox does not have to forge anything: it
|
||||
/// serves the record it saw first, that record verifies perfectly, and the
|
||||
@@ -80,7 +80,7 @@ final class HomeRecordRollbackTests: XCTestCase {
|
||||
|
||||
/// The floor rises only for a record that verified. Otherwise a
|
||||
/// hostile relay could hand over an unsigned record carrying a huge serial,
|
||||
/// have the client raise its floor to it, and lock out every genuine record
|
||||
/// have the client raise its floor to it, and lock out every record
|
||||
/// for ever, turning the rollback defence into a denial of service against
|
||||
/// the account holder.
|
||||
func testAForgedRecordCannotRaiseTheFloor() {
|
||||
|
||||
@@ -35,7 +35,7 @@ final class KeyChangeDescriptionTests: XCTestCase {
|
||||
|
||||
/// Audit H3: the safety number must change when a post-quantum identity is
|
||||
/// substituted, that swap was previously invisible to the two people reading
|
||||
/// digits aloud, which defeated the whole point of the ML-DSA identity.
|
||||
/// digits aloud, which defeated the point of the ML-DSA identity.
|
||||
final class SafetyNumberPQTests: XCTestCase {
|
||||
|
||||
private let aliceIK = Data(repeating: 0x11, count: 32)
|
||||
|
||||
@@ -6,8 +6,8 @@ import XCTest
|
||||
|
||||
/// The signed-prekey ring and the one-time prekey pool.
|
||||
///
|
||||
/// **The ring exists because a single slot was not enough, and the failure
|
||||
/// it caused is invisible.** Rotation is weekly, so one slot holds last week's
|
||||
/// The ring exists because a single slot was not enough, and the failure
|
||||
/// it caused is invisible. Rotation is weekly, so one slot holds last week's
|
||||
/// key and the week before is simply gone, while the server keeps an
|
||||
/// undelivered envelope for THIRTY days. A sender fetches a bundle, the
|
||||
/// recipient is offline for a fortnight, and by the time the envelope is polled
|
||||
|
||||
@@ -243,8 +243,8 @@ final class MailboxHomingTests: XCTestCase {
|
||||
|
||||
// MARK: - Dropping one
|
||||
|
||||
/// **The shorter list is published first and the credential goes only if
|
||||
/// that worked.** Forgetting locally while senders are still told to
|
||||
/// The shorter list is published first and the credential goes only if
|
||||
/// that worked. Forgetting locally while senders are still told to
|
||||
/// deliver there leaves mail arriving at a mailbox this device no longer
|
||||
/// reads, which, from the sender's side, is indistinguishable from
|
||||
/// delivery.
|
||||
@@ -293,7 +293,7 @@ final class MailboxHomingTests: XCTestCase {
|
||||
|
||||
/// The serial has to move forward or the relay refuses the record as a
|
||||
/// rollback, by the very check that stops an attacker replaying an old,
|
||||
/// genuinely signed list that happens to name only itself.
|
||||
/// signed list that happens to name only itself.
|
||||
func testEachPublishedRecordCarriesAHigherSerial() async throws {
|
||||
let (inbox, store) = try await inbox(userID: 409)
|
||||
defer { store.wipe() }
|
||||
@@ -312,8 +312,8 @@ final class MailboxHomingTests: XCTestCase {
|
||||
|
||||
// MARK: - The record a sender checks
|
||||
|
||||
/// **A relay serving a truncated list is the exact failure multi-homing
|
||||
/// exists to survive.** Drop a home from the record and it stops being
|
||||
/// A relay serving a truncated list is the exact failure multi-homing
|
||||
/// exists to survive. Drop a home from the record and it stops being
|
||||
/// signed, so a sender that checks, rather than trusts, delivers
|
||||
/// everywhere the real record names.
|
||||
func testAShortenedOrLengthenedListDoesNotVerify() throws {
|
||||
|
||||
@@ -4,8 +4,8 @@ import XCTest
|
||||
|
||||
/// Addressing one person inside a group (§6.18).
|
||||
///
|
||||
/// **The rule that decides whether this feature is worth having is what a
|
||||
/// mention may do to a mute.** Always overriding hands every member of a group
|
||||
/// The rule that decides whether this feature is worth having is what a
|
||||
/// mention may do to a mute. Always overriding hands every member of a group
|
||||
/// a way to buzz you whenever they like, which is exactly how mentions become
|
||||
/// the thing people leave a group over. Never overriding makes muting a busy
|
||||
/// group mean missing the one message that was actually for you.
|
||||
@@ -88,7 +88,7 @@ final class MentionsTests: XCTestCase {
|
||||
let group = NotificationManager.muteKey(for: .group("g1"))
|
||||
XCTAssertNotEqual(group, NotificationManager.muteKey(for: 7))
|
||||
XCTAssertNotEqual(group, NotificationManager.muteKey(for: 9))
|
||||
// Two members of the same group share one mute, which is the point.
|
||||
// Two members of the same group share one mute.
|
||||
XCTAssertEqual(group, NotificationManager.muteKey(for: .group("g1")))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import XCTest
|
||||
|
||||
/// A photo sent as a file must lose its location, like one sent as a photo.
|
||||
///
|
||||
/// **The bug this pins was found by audit, not by a test, because no test
|
||||
/// went down the file path at all** (ios#69). The picker path was checked and
|
||||
/// The bug this pins was found by audit, not by a test, because no test
|
||||
/// went down the file path at all (ios#69). The picker path was checked and
|
||||
/// correct; the vault path handed stored bytes straight to `sendFile`. The two
|
||||
/// doors disagreed, and the safer one was the one that looked like a shortcut.
|
||||
final class MetadataScrubTests: XCTestCase {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user