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:
Paul Hitt
2026-09-08 19:20:01 -04:00
co-authored by Claude Fable 5.1
parent f911d13baf
commit 2585c4e9f9
137 changed files with 444 additions and 447 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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])
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+7 -7
View File
@@ -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() {
+5 -5
View File
@@ -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 -4
View File
@@ -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 {
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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".
///
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
]
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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
+5 -6
View File
@@ -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 }
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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,
+3 -3
View File
@@ -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`.
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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]?
+12 -12
View File
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 -2
View File
@@ -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.
+5 -5
View File
@@ -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>,
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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.
+4 -4
View File
@@ -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
+7 -7
View File
@@ -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 "
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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",
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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.
+4 -4
View File
@@ -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"
+2 -2
View File
@@ -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 "
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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.")
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 -4
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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 -6
View File
@@ -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
+4 -4
View File
@@ -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")
+2 -2
View File
@@ -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)
+12 -12
View File
@@ -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`,
+7 -8
View File
@@ -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 {
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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())]],
+3 -3
View File
@@ -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() {
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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 {
+3 -3
View File
@@ -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")))
}
+2 -2
View File
@@ -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