50 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5.5 11fb7aed76 Store shots: default to the iPhone 16 Pro Max for the 6.9 inch slot
ci / unit-tests (push) Failing after 1h1m39s
ci / ui-tests (push) Failing after 41m37s
ci / parity-scrub (push) Successful in 17s
ci / vectors-head (push) Successful in 22s
ci / secret-sweep (push) Successful in 1m34s
App Store Connect refused the 1284x2778 set on 2026-10-20 and took
1320x2868 in the 6.9 inch slot. The header now names the iPad device
type for 2048x2732 too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:57:50 -04:00
Paul HittandClaude Opus 5.5 67408e02ae String catalogs as Xcode writes them after a build
ci / ui-tests (push) Failing after 41m36s
ci / vectors-head (push) Successful in 9s
ci / parity-scrub (push) Successful in 1m5s
ci / secret-sweep (push) Successful in 16s
ci / unit-tests (push) Failing after 1h1m36s
Xcode reformats both catalogs on every build. Beyond the formatting:
"Send" gains a second translator comment, "Search contacts" and
"Sam Rivera" are marked stale, and InfoPlist gains one extracted key.
No translation changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:38:37 -04:00
Paul HittandClaude Opus 5.5 dda92223d6 Store shots: pass the relay to the app when signing in to an account
ci / vectors-head (push) Successful in 9s
ci / secret-sweep (push) Successful in 24s
ci / parity-scrub (push) Successful in 31s
ci / unit-tests (push) Failing after 1h1m40s
ci / ui-tests (push) Failing after 41m52s
Test launches default to http://127.0.0.1:9 so no suite reaches
production by accident. The shot test set -FNMSGServer only for a
throwaway sign-up, so signing in to a named account hit the dead
loopback and failed with "Could not connect to the server".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:34:55 -04:00
Paul HittandClaude Opus 5.5 abd2a557e9 Clear four compiler warnings in E2EInbox and TodayStore
ci / vectors-head (push) Successful in 10s
ci / secret-sweep (push) Successful in 28s
ci / parity-scrub (push) Successful in 34s
ci / unit-tests (push) Failing after 1h1m38s
ci / ui-tests (push) Failing after 41m35s
Three @Published properties carried internal(set) on an internal
property, which changes nothing. forgetOnThisPhone defaulted its store
to .shared, evaluated outside the main actor; it now takes an optional
and resolves .shared inside the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:21:38 -04:00
Paul HittandClaude Opus 5.5 a50d12722f Hide the wallet on iOS for App Store guideline 3.1.5
ci / vectors-head (push) Successful in 10s
ci / secret-sweep (push) Successful in 30s
ci / parity-scrub (push) Successful in 33s
ci / unit-tests (push) Failing after 1h2m10s
ci / ui-tests (push) Failing after 42m22s
App Review rejected 0.32.0 under 3.1.5: crypto wallets are allowed only
from developers enrolled as an organization, and this account is an
individual one. WalletAvailability.hidden removes the Wallet tab, the
Home header button and the Privacy setting. The store shot test no
longer captures a wallet screen. Android and F-Droid keep the wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:50:03 -04:00
Paul HittandClaude Opus 5 d04c398f69 Build and test are two calls now, and three more assertions race no more
ci / secret-sweep (pull_request) Successful in 15s
ci / vectors-head (pull_request) Successful in 11s
ci / parity-scrub (pull_request) Successful in 15s
ci / unit-tests (pull_request) Failing after 1h1m45s
ci / ui-tests (pull_request) Failing after 4m33s
The 2026-10-16 run gave the first real diagnostic in six attempts.
ci-test.log reached 889,595 bytes and ended at
PruneExplicitPrecompiledModules, which is the end of a clean build, and
then xcodebuild sat for 150 minutes without printing "Testing started".
The watchdog killed it and the gate printed that tail. So on pauls-imac
the build succeeds and the test phase never begins; the same commit
builds and runs 1289 XCTest plus 578 Swift Testing tests in about 7
minutes on an M3.

One xcodebuild call cannot tell a slow compile from a test phase that
never starts. build-for-testing and test-without-building each run under
their own cap, and the log says which one stopped.

Running test-without-building against a warm build exposed a second race
in BridgeFailoverTests, of the same kind as the first: onWorked and
onAllFailed append to a plain `var` from the transport's queue while the
test reads it, which races the callback and shares a variable across two
threads. Three assertions read a locked box on a 5-second deadline now.

Verified: build-for-testing reports ** TEST BUILD SUCCEEDED **,
test-without-building runs all 1289, and BridgeFailoverTests passed 11 of
11 three times in a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 15:14:53 -04:00
Paul HittandClaude Opus 5 0e949b38ac The unit job was collecting a sysdiagnose, not running tests
ci / vectors-head (pull_request) Successful in 8s
ci / secret-sweep (pull_request) Successful in 17s
ci / parity-scrub (pull_request) Successful in 15s
ci / unit-tests (pull_request) Failing after 2h30m47s
ci / ui-tests (pull_request) Failing after 1h40m38s
unit-tests and ui-tests have failed on all five CI runs since the move to
Gitea Actions, every one of them with "context deadline exceeded" and no
test summary. Both causes are here.

The hang. One test fails, and on a failure xcodebuild's default
-collect-test-diagnostics on-failure runs `simctl diagnose`: a sysdiagnose
plus 8 minutes of simulator logs, 600 seconds of timeout, before it
reports anything. Reproduced on an M3 by watching the pid: the suite
finished 1289 tests in 286 seconds, then xcodebuild sat on `simctl
diagnose` for over 10 minutes with the log frozen at the last line. On the
runner that is what burned the 180-minute deadline. Set to `never`.

The silence. The in-job cap was `command -v gtimeout || command -v
timeout`, and the runner that printed "no timeout command, xcodebuild runs
uncapped" is the one that then hit the job deadline, where a cancelled
step runs no gate and prints no tail. The cap is a shell watchdog now,
holding xcodebuild's own pid because the log is written by a redirect
rather than a pipe, and `tail -f` keeps the progress lines in the job log.

The failing test. BridgeFailoverTests.testTheStatusNamesTheLineThatCarried
Traffic read Obfs4Proxy.shared.state once, immediately after reading the
HTTP body. onWorked fires on the transport's queue when the tunnel ends,
not when the body arrives, so the read raced it and saw waiting(lines: 2).
The client socket is closed first and the state is read on a 5-second
deadline. The same race was in the noneReachable assertion.

Verified on this machine, Xcode 27.0 (27A266a): BridgeFailoverTests 11 of
11, then the whole suite, 1289 XCTest and 578 Swift Testing tests, zero
failures, ** TEST SUCCEEDED **.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:45:04 -04:00
Paul HittandClaude Opus 5 b2d6230a0a ci: boot the simulator and wait for it, because xcodebuild waits forever
ci / vectors-head (pull_request) Successful in 8s
ci / secret-sweep (pull_request) Successful in 17s
ci / parity-scrub (pull_request) Successful in 17s
ci / unit-tests (pull_request) Failing after 3h0m3s
ci / ui-tests (pull_request) Failing after 2h0m2s
Two runs were cancelled at their timeout with no error, and raising the
number was treating a hang as slowness. The build was finishing both
times.

The evidence is in the artifact. ci-test.log from the 90-minute run was
889,539 bytes and from the 180-minute run 889,544, five bytes apart. A
slow build produces more output, not the same amount. Both end at
`PruneExplicitPrecompiledModules`, which is the last line of a
successful build, immediately before xcodebuild hands off to the
simulator.

So the suite compiles and then xcodebuild waits for `iPhone 17` to come
up. The job shut that device down before building and left xcodebuild to
boot it, and nothing reported when it did not.

`xcrun simctl bootstatus "iPhone 17" -b` boots the device and blocks
until it is usable. It exits 0 on success and non-zero when the device
does not exist, checked both ways on an M-series Mac: 0 after a
shutdown, 148 for a name that is not there. On failure the job now
prints the available device list and stops, so a runner whose simulator
will not boot says so in seconds.

A cap also goes INSIDE each job, 150 minutes for the unit suite and 100
for the UI suite, under job timeouts of 180 and 120. A hang now reaches
the gate below and prints the tail of the log instead of being cancelled
with nothing. `coreutils` is not guaranteed on a workstation runner, so
the cap resolves gtimeout, then timeout, and otherwise runs uncapped
with a note rather than failing.

Both jobs get both changes. Every `run:` block still passes `bash -n`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 21:06:08 -04:00
Paul HittandClaude Opus 5 907bce9d87 ci: let these jobs be re-run without a commit
ci / vectors-head (pull_request) Successful in 6s
ci / secret-sweep (pull_request) Successful in 14s
ci / parity-scrub (pull_request) Successful in 14s
ci / unit-tests (pull_request) Failing after 3h0m3s
ci / ui-tests (pull_request) Failing after 2h0m3s
The unit job can spend 90 minutes before it prints anything, and when it
times out the only way to try again was to push another commit. Two of
today's three runs were exactly that.

workflow_dispatch costs nothing and makes a re-run a button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 14:23:59 -04:00
Paul HittandClaude Opus 5 dc483d5e22 ci: 180 minutes for the iOS unit suite, 120 for the UI suite
90 was not enough. The run on 50ce3c86 spent all 90 minutes inside
xcodebuild and printed no `Executed` line at all, so the cold compile
alone outlasts the limit on pauls-imac. The artifact it uploaded held
889 KB of build output and no test result.

The same commit builds and runs the whole suite in about 37 minutes on
an M-series Mac:

  Executed 1289 tests, with 0 failures
  ** TEST SUCCEEDED **

So the suite is green and the limit was measuring an Intel compile. 180
leaves room for a cold build; the workspace on that runner now holds a
partial ci-build, so a warm run should come in far under it.

If 180 is not enough either, the answer is the runner rather than the
number: move these two jobs to an Apple Silicon runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 14:11:40 -04:00
Paul HittandClaude Opus 5 50ce3c8610 ci: 90 minutes for the iOS unit suite, 60 for the UI suite
ci / unit-tests (pull_request) Failing after 1h30m4s
ci / secret-sweep (pull_request) Successful in 15s
ci / vectors-head (pull_request) Successful in 7s
ci / parity-scrub (pull_request) Successful in 15s
ci / ui-tests (pull_request) Failing after 1h0m2s
The first run of this workflow died at exactly 30 minutes with "context
deadline exceeded", fifteen seconds past the vector gate and still inside
xcodebuild. 30 was chosen when the suite was 741 tests; the 0.32 merge makes
it 1289, and a cold `ci-build` compiles the app, its tests and the obfs4
package before any of them run.

pauls-imac has capacity 1, so ui-tests waits for unit-tests and then builds
from its own derived data; 60 covers that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 12:22:27 -04:00
Paul HittandClaude Opus 5.5 9099b6866c ci: move CI to Gitea Actions
ci / vectors-head (pull_request) Successful in 11s
ci / secret-sweep (pull_request) Successful in 22s
ci / parity-scrub (pull_request) Failing after 13s
ci / unit-tests (pull_request) Failing after 30m2s
ci / ui-tests (pull_request) Failing after 30m2s
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it.
Every gate is kept: secret sweep, unit tests with the vector pin check and
the 700-test floor, UI tests with the 10-ran floor, vectors-head (advisory),
parity scrub.

Xcode jobs run on the pauls-imac runner; the sweep, vectors-head and the
scrub run on mac1/mac2. Builds and tests only, no signing.

The Obfs4 submodule and the sibling clones now come from
git.extant2000.com/extant2000, as do the README links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:03:09 -04:00
Paul HittandClaude Opus 5 b3c47c9926 Spanish for the 79 strings the 0.32 features added
`Scripts/check-spanish-catalog.py` reported 1466 keys in code against 1391 in
the catalog. The 79 without Spanish were all from the release branch: greeting
cards, events, games, the Today tab, the small-web directory, story
take-downs, and the two scanner refusal lines added by this merge. It now
reports 0 missing.

Terms follow docs/i18n/glossary-es.md and, where Android already shipped the
same label, Android's wording: Today is "Hoy", Card "Tarjeta", Event "Evento",
Game "Juego", story "historia", take down "retirar", directory "directorio",
bridge line "línea del puente". Every entry is `needs_review`, like the rest
of the es column: nothing ships until the owner or a Localization Lab
volunteer has read it.

The old parental-controls footer is deleted. The merge replaced it with the
longer text that also covers Today and the directory, so its key was dead.
"Attach photo" and "Search contacts" are still reported unused, as on main:
both reach the catalog through a `String` parameter the compiler does not
record, and "Search contacts" has three live call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 11:02:23 -04:00
Paul HittandClaude Opus 5 21c23852c8 Merge release/0-32-0 into main: the 0.32 features meet the Spanish strings
32 conflict hunks across 21 files. The two sides are the 0.32 feature branch
(cards, events, games, Today, the small-web directory, hello links, story
take-downs, stickers) and main (the Spanish catalog, attachment padding, the
stage 2-B request gate, bridge offers). Neither side is the answer anywhere
the conflict is about what the app does.

What the resolutions keep, and why:

Info.plist and project.yml: BOTH URL types. One claims `flamenet` for contact
links, the other `flamenetmessenger` for hello links. Taking either side alone
would have stopped the other kind of link opening the app at all.

CardScanner: git had already merged the controller to the union, with both the
injected `accepts` predicate and the new `onRefused` callback. The call sites
now match it. `accepts` is the only gate, because hard-coding `ScannedCode`
there would have refused every code the Devices screen scans. The address
sheet's default is `ScannedCode.card`, never `ContactCard.decode`, so an older
card's `?u=` username is still never trusted (DESIGN 32).

E2EInbox: `seedAccountStateFromDeviceIfNeeded()` moved to the factory rather
than into the release branch's new `connect()`. It reads this device's own
store and never the relay, and the offline launch path passes `connect: false`,
which would otherwise show every existing conversation as a request until the
relay answered. `deliverRemote` keeps both gates: main's `remoteMailShows` and
the release branch's control-traffic cases.

E2EEnvelopeRoute: bridgeOffer, rsvp and unpost all three, in the enum, in
`all`, and in the shape test.

MainTabView and PillTabBar: the extracted `PillTabItem` and `tabItems()` are
kept, with the `identifier` field put back and every title through
`String(localized:)`. `title` is a plain String, so the release branch's bare
literals would have left every tab English in every language.

ParentalControlsView: the release branch's fuller footer, written as ONE
literal. `Text` localizes a literal and not a `+` chain, so the concatenated
form would have taken that footer out of the catalog.

Eleven more places take the union with the localized string kept: the
attachment and sticker previews in E2EInbox, GroupsSection, BuddyListView,
ChatView, ChatViewModel and StoriesFeedView.

Also here: 35 spec references renumbered. `a3b05c3` in flamenet-e2e moved
anonymous reads and contact links to §8.13 and §8.14 to make room for the push
token and the Today file at §8.11 and §8.12, so every reference in this tree to
"8.11, anonymous reads" or "8.12, contact links" now pointed at the wrong
section. The Today and push references were already right and are untouched.

.e2e-ref is pinned to 4b4042f72880758ee2dea7713f7a2cb63fa12f73, the unified
spec, which is also what Android pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:21:32 -04:00
Paul Hitt 01b969d12f Merge branch 'i18n/es' into 'main'
Bridge sharing (friend to friend) and Spanish (draft, needs native review)

See merge request extant2000/flamenet-messenger-ios!240
2026-10-10 16:12:11 +00:00
Paul HittandClaude Opus 5.5 7110f11472 Reading the bridge settings writes nothing: the wallet send sheet hung
WalletUITests failed 2 of 8 in CI (!240, pipeline 2933642198): after
"Send" opened its sheet, the app's main thread was busy for 30 s and every
UI query timed out. A sample showed WalletView.body running without end.

Cause: Obfs4Keychain.migrate ran on every read of the bridge settings and
always removed its old UserDefaults key, even when it was not there. That
posts UserDefaults.didChangeNotification. WalletView reads
AroundTheBridge.active in body and holds an @AppStorage value, so each
change re-rendered it, which read the settings, which posted again.

Fix: migrate only when the old key exists. A unit test counts
UserDefaults changes across 20 rounds of reads: 100 before, 0 after.
WalletUITests: 8 of 8 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:50 -04:00
Paul HittandClaude Opus 5.5 d325bb5e43 Spanish (es) draft for the bridge screens
DRAFT, needs native review.

Rebased onto bridge/f2f-apps (4b92274a). NetworkSettingsView.swift and
Obfs4Settings.swift were taken from the bridge branch whole and wrapped
again, so every bridge behaviour stays as that branch wrote it.

- String(localized:) on the bridge list, failover, offer, sharing and
  scanning text, the Obfs4 line problems and Obfs4Proxy check results, and
  the four AroundTheBridge notices (link previews, calls, wallet, live
  streams). Sentences joined with + are one literal; English is unchanged.
- 24 new catalog keys with Spanish, "%lld bridges saved, not used yet" with
  plural forms. check-spanish-catalog.py: 1,390 keys in code, 0 missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:48 -04:00
Paul HittandClaude Opus 5.5 f207a35b81 Spanish screens photographed on a local relay; "Appear offline" shortened
SpanishScreensUITests signs up two accounts against a relay on this machine
(it refuses any other), then relaunches the app with -AppleLanguages
(es-MX) and attaches 16 screenshots: sign-in, home, add contact, chat,
contact profile with the safety number, Settings, Privacy and the contact
links screen. Off unless TEST_RUNNER_FNMSG_ES_SHOTS=1.

The run found one cut-off label: "Aparecer desconectado" showed as
"Aparecer..." on the Account status buttons. It is "Invisible" now and fits.

DRAFT, needs native review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:11:29 -04:00
Paul HittandClaude Opus 5.5 a2177e1a97 Spanish (es) draft: string catalogs, every UI string made localizable
DRAFT, needs native review. Nothing in the es column ships until the owner
or a Localization Lab volunteer has read it.

- project.yml: developmentLanguage en, SWIFT_EMIT_LOC_STRINGS and
  LOCALIZATION_PREFERS_STRING_CATALOGS on; xcodegen adds es to knownRegions
  from the catalogs.
- Resources/Localizable.xcstrings: 1,366 keys from the compiler, 1,356 with
  Spanish (the rest are punctuation and placeholders, marked not
  translatable), 23 with plural variations. Every es value is
  "needs_review".
- Resources/InfoPlist.xcstrings: the four permission prompts in Spanish.
- String(localized:) on 729 changed lines across 115 files, so status, error and
  warning text held in a String reaches the catalog. Sentences built with +
  are one literal now. English output is unchanged.
- Integer identifiers (user ids, HTTP and CallKit codes, signals) are
  interpolated as String, because String(localized:) groups digits: a
  CallKit code 9999 read "9,999".
- SpanishCatalogTests: every key has Spanish, identical placeholders, the
  draft marker is present; short labels over 1.6x are listed, not failed.
- Scripts/check-spanish-catalog.py: every key the compiler extracts is in
  the catalog (xcodebuild does not add new keys by itself).
- Source-reading tests updated to the String(localized:) form.
- docs/i18n/glossary-es.md: the terms, matched to the /es/ site pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:11:29 -04:00
Paul HittandClaude Opus 5.5 4b92274aa3 Vectors: pin flamenet-e2e 4b85000 (SPEC-only change on bridge/f2f-followups)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:43 -04:00
Paul HittandClaude Opus 5.5 8fbfbc45c0 Vectors: pin flamenet-e2e 4e36d5a (bridge offers in three encodings); keep the interop-driver files free of bridge code
The interop driver compiles E2EAttachment, LinkPreview and RelayDiscovery
alone, with no networking code. The bridge gate for link previews and the
bridged default for relay discovery move to app-only extensions, and the
offer size limit lives in E2EAttachment. The vector test reads all three
encodings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:25:28 -04:00
Paul HittandClaude Opus 5.5 00c7f21883 Nothing goes around the bridge: previews, calls, the wallet and live streams are off while one is saved
SPEC §24.6: a client with bridges turned on must not connect directly. Four
features connect to a host other than the Flamenet edge a bridge forwards
to, so while a line is saved each is off and says why in plain words:

- Link previews: the fetch returns nothing and makes no connection; the
  setting is greyed with a note.
- Calls: refused at start, at answer, and where the peer connection is built.
- The wallet: the chain client refuses before connecting; the screen says so.
- Live streams in stories: the player is replaced by a note naming the host.

The Files package's own sessions now go through the bridges
(FilesTransport, set at launch). Nothing in Sources or the Files client uses
URLSession.shared any more; a test reads the sources to keep it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:14:34 -04:00
Paul HittandClaude Opus 5.5 a30dcafd03 Bridges: a screen before sign-in, several lines with failover, offers from contacts (SPEC §24)
- A bridge screen on the sign-in and sign-up screens: paste or scan a line,
  see the live status, remove it. Offers and sharing appear once signed in.
- Everything before sign-in goes through the bridges when one is set: relay
  discovery, the sign-up proof of work, the account service and the REST
  probe. Clients made before a bridge was set follow it (BridgedSession).
- Up to 8 lines, tried one at a time, the one that last worked first. A line
  fails on no handshake within 15 s, a refused connection, or nothing coming
  back through it; the unanswered request is sent again through the next
  line. Every line failing never falls back to a direct connection, and a
  local proxy that cannot start blocks traffic instead of sending it direct.
- The dialler takes IPv6 addresses and names, every address of a name.
- Offers: received offers route to their own branch and are kept for review
  (proven senders only, never from this account or a federated address);
  the Network screen shows them with accept and dismiss, and sends this
  phone's line to one contact after saying what they learn.
- §24.9 gaps: certs decode with or without padding; offers with a gid or a
  line over 1024 bytes are dropped and never built; an envelope whose known
  key has the wrong type is consumed or shown as its caption or the
  placeholder, never as raw JSON; the bytes test compares against both
  writers in the vectors.
- Held to bridge-offer-v1.json; .e2e-ref moves to flamenet-e2e c75b861.
- Citations of §6.19 for the bridge offer now say §24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:56:22 -04:00
Paul Hitt d9c5c5a64d Merge branch 'release/0.32.0' into 'main'
FlameNet Messenger 0.32.0 (iOS): padding, pairs 2-B, multi-device, sealed first contact, Unlisted and contact links

See merge request extant2000/flamenet-messenger-ios!239
2026-10-10 02:18:58 +00:00
Paul HittandClaude Opus 5.5 f2123f063e Vectors: pin flamenet-e2e 50fcc99, the merged 0.32 spec with §7's PQ number
e2e!66 merged; protocol-v1.json gains `safetyNumberPQ` and nothing else
changed. ProtocolVectorTests "testSafetyNumberCoversThePostQuantumIdentities":
red against the old copy (no vector), green with the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:49 -04:00
Paul HittandClaude Opus 5.5 180d8efd5d Contact links from the shipped relays open the app (universal links)
SPEC 8.12: https://<relay>/add#... opened Safari. The entitlements now
claim applinks:relay.flamenet.io and applinks:relay1.flamenet.io; the
relay serves the matching apple-app-site-association (team M5923Y84P5,
/add*). A universal link arrives as a web-browsing activity and goes to the
same handler as the flamenet: scheme (AppEnvironment.open).

Signing needs the Associated Domains capability on the
net.flamenet.messenger App ID before an archive will sign.

AContactLinkOpensTheAppTests: 3/3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:49 -04:00
Paul HittandClaude Opus 5.5 646d5f32dc Safety number: the answering side reads the bundle once, so both screens match
SPEC §7 (flamenet-e2e 50fcc99, the owner's decision): the number covers
both post-quantum identities when a device holds both. A device pins a
contact's ik_pq only when it reads their bundle, which only the side that
opened the session does; the side that answered showed the Ed25519 number
while the other showed the covered one, and the digits never matched.

pinPeerPQIdentityIfNeeded reads the contact's bundles once, for devices
whose IK_sig is already pinned, and checks and pins ik_pq (§20.5). The
profile runs it when the safety number is shown and reads the number
again. It spends one prekey per contact device, once.

The test stub relay now carries ik_pq and spk_pq_sig in bundles, as the
relay does. FirstContactTests "testBothScreensShowTheSameCoveredNumber":
red (no pin, no coverage), then green; full suite 1074 XCTest and 408
swift-testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:49 -04:00
Paul HittandClaude Opus 5.5 17e54acb98 A refused first message names the person plainly
Against a local relay, a one-time code used a second time was refused as
it should be, but the words read "Could not send. nora1009 (not verified
yet) needs to message you or send you a new link.": the refusal took the
conversation title, which carries the unverified mark. It now takes the
name without it (nameForRefusal), at all three call sites.

FirstContactTests "testTheRefusalNamesThePersonPlainly": red on the call
sites, then 15/15 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:05:22 -04:00
Paul HittandClaude Opus 5.5 71709d55fd A first message by contact link opens its conversation
Found on the iPhone-to-Android pair against a local relay: a first message
sent through an Android account's one-time link reached that account's
Requests, but the iPhone opened nothing and Home said "No buddies yet".
The deep link opened a conversation only with somebody on the relay's
contact list, and the person behind a link is not on it. Android opens the
conversation either way.

BuddyListView.deepLinkTarget opens the buddy, or the person by the name
the inbox holds ("luke (not verified yet)" until their certificate).

AFirstMessageByLinkOpensItsConversationTests: 2 red, then 3/3 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:27:33 -04:00
Paul HittandClaude Opus 5.5 3c1a6cfce4 Numeric linking: the confirmed device asks for the account's state
Found linking an Android emulator to this iPhone's account by the numeric
fallback (17.3) against a local relay. The existing device sends its
snapshot right behind the offer; the new device, still waiting for the
person to compare the number, drops it as traffic from a device it has not
linked. Confirming then linked a device with none of the contacts,
acceptances or flags. Both apps had it.

confirmPendingNumericOffer now asks the offering device for its state
(sync_state with want), which both apps answer with a snapshot.
adoptLinkOffer says whether anything was linked. History sent behind a
numeric offer is still dropped; only the QR path backfills it.

LinkedDevicesTests "testTheNumericFallbackStillCarriesTheState": red
without the change (the contact list and acceptance did not cross), green
with it; 15/15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:46:58 -04:00
Paul HittandClaude Opus 5.5 7274896c80 Vectors: pin flamenet-e2e 5a5b7e7 and check all eleven copies
Tests/Vectors already held the 0.32 vectors (padding, sync-merge,
handle-cert, links, poll-tally), byte-identical to flamenet-e2e 5a5b7e7,
the commit Android pins. The pin still named 3564030, which has six vector
files, and CI diffed only those six, so the five new copies were checked
against nothing. The pin now matches Android's and both loops (the pinned
gate and vectors-head) diff all eleven.

5a5b7e7 is on flamenet-e2e's privacy/sweep-2026-10, not yet pushed: the
clone in CI fails until it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:04:20 -04:00
Paul HittandClaude Opus 5.5 632653674d Finding you: show each contact link as text as well as a code
The one-time code was a QR and a share button, and a reusable link was a
share button with six characters of its capability on it. Neither showed
the link itself, so it could not be read, checked or copied by hand. Each
now shows its https form, selectable, under the code. Android shows the
code and shares the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:04:14 -04:00
Paul HittandClaude Opus 5.5 132ec7d71b Contact links: an unverified name says so; a link for another server says which
Section 8.12: a one-time link involves no introduction, so nothing vouches
for its `h` until the recipient's first reply carries their certificate.
The sheet said so, but the conversation it opened showed the bare name.
It now shows "bob (not verified yet)", the words Android's chat title uses,
until `adoptHandleCertificate` writes the certified name over it.

A link made on another relay was refused as "already used or turned off".
It now says "That link is for another server (<relay>).", as Android does.

TheListedSettingReachesTheRelayTests pins the Listed setting's wire: GET
and PUT/POST of `flamenet/v1/messenger/me` with `discoverable`, the field
RoutesAccount.updateMe reads, and nil from a relay that does not say.

Tests first: FirstContactTests two red, then green; 14/14, 36/36, 4/4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:29 -04:00
Paul HittandClaude Opus 5.5 8dde239834 Build and pass 0.32 on Xcode 27, after main's fixes
The release branch had never been through a full test run. On Xcode 27:

- E2EInbox.transmit called `bundles(of:features:)` while a local named
  `bundles` was in scope, which does not compile. Now `self.bundles`.
- FirstContactTests awaited inside XCTUnwrap's autoclosure and read a
  main-actor property from a nonisolated test.
- Seven source-reading tests still looked for 0.31 shapes: adoptStrangers
  (now nameStrangers, DC2), transmit's old one-line signature, two
  functions that lost `private` for multi-device, and the Snapshot fields
  that exist only for an export (16.2.1) or are carried as their projection.
- LinkedDevicesTests added a contact without accepting it, which no
  production path does (ContactAdd accepts), so acceptance had nothing to
  carry across.
- ABlockedSenderStaysOutTests (from main) now checks the block survives a
  relaunch through the inbox, since the block is the `blocked/` register,
  and purges a history file a previous run sealed under a wiped key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:29 -04:00
Paul HittandClaude Opus 5.5 74260197f8 Copy: say what blocking and replacing the key do now
Run through the avoid-ai-writing audit. Blocking now replaces the shared
delivery key and the app pushes the new one to every other contact
(section 14.4, DC6), so the Privacy footers that said a gap was left and
that contacts lose sealed delivery until messaged again were no longer
true, and neither was the "contacts can reach you sealed again once you
next message them" result. Three negation tails rewritten as plain
statements (web search, reusable links, the backup banner), and a curly
quote in code made straight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 5db98ae7c6 Web search through the relay, when it offers it
A Web search screen (Account), shown only when the relay's descriptor says
`features.webSearch` (relay branch privacy/web-search, unmerged and
undeployed). POST /api/flamenet/v1/search with the member's credential,
the query in the body and never the URL, nothing logged; results open in
the browser, up to three pages; "search isn't working right now" (the
relay's `unavailable`) is told apart from "nothing found", and too many
searches is said as such. The copy says what the relay can and cannot see.

Tests: WebSearchTests (4). Written while builds were paused; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 4f5902e777 First contact that names nobody: certificates, introductions, anonymous reads, links
Stages C2, C3, D2 and B3 (SPEC 14.5, 8.11, 8.12). Every part waits for
the relay's descriptor to say it can (`introductions`, `anonymousReads`,
`contactLinks`, `unlisted`); the relay code is on privacy/wave0-relay,
unmerged and undeployed, and does not advertise these switches yet, so on
every relay today the app behaves exactly as 0.31 did.

C2, handle certificates: a first message's delivery-key prelude carries
the sender's own certificate (`hc`, from GET /e2e/handle-cert); the
recipient keeps it only if it verifies against the pinned relay key, for
this relay's audience, about the sender the ratchet proved, before exp.
Requests and the call screen use the certified name, and with the switch
on the relay is no longer asked who user N is.

C3, sealed first contact: with no token, key or intro for somebody, the
app asks POST /e2e/intro once (by the reusable link the conversation
started from, or a listed handle after the proof of work), verifies the
answer is about that account, and sends sealed under the intro using the
bundles it carried. A refused key or token tries one fresh intro. With the
switch on nothing goes out named: no way to seal means the send is
refused with "Could not send. <name> needs to message you or send you a
new link." A batch of delivery tokens is now split into disjoint slices,
one per recipient device, so two devices never spend the same token.

D2, anonymous reads: directory, bundles and presence for other people go
to POST /e2e/anon/* with the target in the body, through a separate
ephemeral HTTP client (no credential, cookies, cache or shared pool, a
generic User-Agent), metered by the presence token the person issued;
presence in random batches of at most eight with up to 20 s before each;
own devices from GET /e2e/devices/me.

B3, Unlisted and contact links: "Finding you" (Privacy) has the Listed
switch (worded as the spec requires: nobody can find or add you by your
username, which is not secret), one-time QR codes (a delivery token, works
on today's relays) and up to five reusable links, each shareable and
revocable; sign-up offers the choice (Listed by default) and applies it
once the relay can. Contact links open from the flamenet: scheme (now
registered), the address scanners, and send one sealed first message to
the person's Requests, the name shown as unchecked until they reply.

Also here: MessengerAPI.webSearch, used by the next commit.

Tests: FirstContactTests (14) on the stub relay, which now answers the
wave 0b routes. Written while builds were paused; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 6565b4ca7a Multi-device: linking, sync, sent copies and history (SPEC 17, A2 to A4)
Stage A2, linking (section 17.3). A device signed in with the password is
on the account and nothing more. A person links it from a device that
already is: the new device shows flamenet:link?d=&k=&s= as a QR code (ten
minutes, one use, never shared as text); the existing device checks the
relay lists that device on this account under exactly that key and that the
key log holds no revoke or expire for it, asks, links it locally, and sends
it a link_offer (secret, linked set, account delivery key) through an
ordinary session, then a snapshot of every register, then history if the
person wants it. The new device adopts only an offer whose secret matches
the code it is showing, from a sender in the set under the key its session
proved, and links each member only if the relay lists it under the key the
offer names. Numeric fallback: both screens show the two devices' safety
number and the new device holds the offer until the person confirms there
too (LinkOfferWatcher, from any tab). The linked set follows the relay:
a device retired in the key log, no longer listed, or listed under another
key is unlinked and the person is told which.

Stage A3, sync (sections 17.2, 17.6). Every register write goes to the
linked devices, batched (one op as `sync`, several as `sync_state`), sealed
to this account under the account delivery key. Incoming ops merge by the
vector-checked rule and are applied: acceptance, blocks, flags and read
marks, and the mirrored families (contacts, muted authors, group names and
keys, pins, story audience, contacts' delivery keys, issued token batches,
presence tokens issued and held, the account delivery key, the linked set,
each link checked against the relay first). State older than the registers
is written as registers once, so a snapshot carries all of it. A device
back after a week asks for snapshots; after 90 days it keeps only links.
Everything a person sends to someone (text, photo, file, sticker, reaction,
edit, pin, timer) and every group message once is copied to the linked
devices as `sent` and drawn there as outgoing, never notified; calls,
stories, avatars, polls and anything off the record are not (DA5). Only a
linked own device, under the key it was linked with, may send any of this;
a link_offer is the one kind taken from an unlinked one.

Stage A4, history (section 17.4): newest first, at most 10,000 messages,
16 KiB chunks, attachments by reference, off-the-record threads left out;
merged by message id in time order, a partial backfill tolerated.

UI: Devices has Link a new device (scanner), Show this device's link code,
Link (numeric) and Unlink per device, and the notices; the scanner takes a
filter so it can read link codes. Pending content envelopes from
content-v1.json are written to the byte (the sync_state chunk by content).

Tests: LinkedDevicesTests (15, two real devices of one account plus a
contact). Builds were paused by the owner while CI runs, so these were
written but not yet run; they run in the next build window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 2b11f1e33c Pairs stage 2-B: acceptance, blocks, flags and Requests held on the device
Section 8.9d and 14.5 (decision DC2). What the relay kept as pairs of
account ids now lives on the device as sync registers (section 17.6), so
linked devices can merge them later (stage A3):

- Acceptance (`acc/`): seeded once from the contact list and from every
  thread this device wrote in, then once from the relay's buddy list;
  writing to somebody, adding them, or accepting a request accepts them;
  removing a contact takes it back.
- Requests: a first message from somebody not accepted waits in a Requests
  folder (RequestsView: accept, block, delete) and never notifies. The app
  no longer adds strangers to the contact list on its own; `adoptStrangers`
  is now `nameStrangers`, which only puts a name to them, and the test that
  pinned the old adoption is replaced.
- Blocks (`blocked/`): seeded once from the relay; a block is the relay
  call (still enforced there until 2-E), the local register, the token
  revoke and a delivery-key rotation, and anything from a blocked person
  that still arrives is dropped on the device. The blocked list shows both.
- Star and archive (`conv/`): local only, flags written only when true, a
  register deleted when both are off; the Messages list shows starred
  first, keeps archived chats in an Archived folder, and /star and
  /archive toggle locally instead of posting to the relay.
- Read marks (`read/`): coalesced to one op per thread per 30 seconds.
- Adding a contact goes through ContactAdd everywhere: by lookup onto the
  sealed list when the relay no longer keeps one, never around it.
- A rotated delivery key is pushed at once, prelude only, to every contact
  that held the old one and is not blocked (section 14.4, DC6).
- A contact's device with no session yet is handed the delivery-key
  prelude with the presence token they already hold.
- Legacy groups this account owns are moved to sealed groups on refresh.
- Backups carry `acc`, `blocked`, `convFlags`, `dk`,
  `deliveryTokensIssued` and `read` (section 16.2.1 pending); a restore is
  a union and never unblocks anybody; `dk` is adopted only by a device
  with no key of its own.
- A banner asks for a backup when nothing was exported for 30 days.

Multi-device envelopes get their own route (`.multiDevice`), handled by a
stub until stage A2.

Tests: PairsStageTwoBTests (13, real inboxes on the stub relay and the
pending backup vector), the route order and a branch test for every
multi-device kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 1609ee6630 Sync registers, link codes and handle certificates, held to the vectors
The pieces sections 17.6, 17.3, 8.12 and 14.5 have to agree on with every
other engine, before anything uses them:

- SyncRegisters.swift: the register merge (later at wins, a block wins a
  tie, then the greater device id as text by UTF-16 unit, read marks only
  move forward, ops a day ahead dropped, malformed ops ignored whole, unknown
  keys kept as registers), tombstones forgotten after 90 days, snapshot
  registers merged one by one, and chunking at 16 KiB. JSONValue keeps a
  register this build does not understand exactly as it came.
- LinkCodes.swift: the device-link QR (flamenet:link?d=&k=&s=), both forms
  of a contact link (https://<relay>/add#... and flamenet:<fnid>@<relay>?...),
  base64url, and the intro proof of work (smallest nonce, zero BITS).
- HandleCertificate.swift: fnhandle-v1 verification against the pinned
  relay key, for this relay, for the account it is about, before exp, with
  `|` and non-integer ids refused before any signature check.

MultiDeviceVectorTests checks sync-merge-v1 in every order of every case's
ops and snapshot registers, each order also delivered twice; handle-cert-v1
valid and invalid; links-v1 encode, decode and refuse for both kinds. The
three files are copied from flamenet-e2e 5a5b7e7 (privacy/sweep-2026-10),
with content-v1 and backup-payload-v1 refreshed from the same commit (only
their new pending sections changed); PROVENANCE.md says why they are not
in the CI diff list yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:45 -04:00
Paul HittandClaude Opus 5.5 48b0f205f4 Trust a contact's second device; check a handoff by key (SPEC 17.3, 6.30)
Two bugs part 1 found.

Receiving, one key from one envelope was classified against everything
pinned for the sender. A contact with two pinned devices whose phone opened
a session read as "a device was removed" and was re-pinned down to the
phone, and the laptop's next first message then read as a new key beside a
missing one: a substitution, held behind "accept new keys". With nothing
pinned, the second device to write was held the same way.
`recordInboundTrust` now checks the one key: pinned is this person and
nothing is re-pinned; unpinned asks the directory (no prekey spent) and
classifies the whole listed set as a send does; unlisted, or a directory
that cannot be read, is refused. Android's Trust.recordInbound is the same
rule.

A call handoff from an own device was checked by device id alone, a name
the relay hands out. It is now checked against the identity key the
session that opened the envelope proved (the half of the X3DH associated
data that is not ours), via `isFromLinkedOwnDevice`, which the sync and
sent-copy kinds will use too.

Tests: ATwoDeviceContactIsNotAKeyChangeTests (4) and
AHandoffIsCheckedByKeyTests (3), with two real devices of one account in
one process: KeychainE2EStore and E2EHistoryStore take a `slot` (nil, and
unchanged, for every real install). The stub relay now hands back the
sending device on an unsealed row, as the real one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:45 -04:00
Paul HittandClaude Opus 5.5 fcff16bccd A group message too long to send is refused, not shown as delivered
SPEC 23.7 says a refusal is surfaced and never swallowed. The group
fan-out caught every error per member, so a group text over the top step
read as a send that reached nobody, and sendGroupText recorded it as
delivered. Now the fan-out checks the plaintext once against the largest
header an envelope can carry before any member is sent anything, so a
message is refused whole or sent whole, and a Padding failure from any
member is rethrown rather than caught.

The padded envelope and seal inner JSON are written by hand in a fixed
key order. A new test holds both to what JSONEncoder writes, key for key,
in every header shape, so a field added to the envelope later cannot be
dropped from every send without a red test.

Tests on My Mac (Designed for iPad): EverySendIsPaddedTests 6,
PaddingPropertyTests 8, ProtocolVectorTests 16, OwnDeviceTraffic 7,
TheDeliveryKeyIsRepublished 4, E2EInboxConversationTests 42, all green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 c12ecbdcd5 Every send padded, refused visibly above the top step; poll honours more
Padding stage 1.2 in the inbox (SPEC 23.7, 23.9, 8.5).

Both wrap points pad now: the local `wrap` (unsealed envelopes with
`pad` beside `ct`, sealed ones through SealedSender.sealedPayload) and
the federated seal. That covers text, control, group fan-out, the
delivery-key prelude, call signalling, own-device copies and federated
sends, each envelope on its own. A payload that is not exactly a ladder
step throws and is never sent; nothing falls back to unpadded.

A message too long for the top step is refused before anything is
spent: for a body over 16 KiB each device's envelope is built once on
an unsaved copy of its session and measured in the sealed form, before
any token is taken, batch minted or ratchet step saved. The person sees
"Too long to send as a message. Send it as a file." Both composers show
how much is left in the last thousand characters, counted as the wire
counts them, and keep the send button disabled past the limit (20,000).

Attachments are sealed through sealAttachment, padded only when the
relay's descriptor says features.padAttachments; absent, false or an
unreadable descriptor means off. The descriptor's switches are cached
for an hour, and a failed read for a minute.

A poll the relay cut short at its byte cap ("more": true) is followed
at once from the new cursor, up to 16 pages, on the home relay and on
every extra mailbox.

Tests on My Mac (Designed for iPad): EverySendIsPaddedTests 5 (every
payload a ladder length, sealed and unsealed; the refusal spends no
token, batch or session step; the composer limit is sendable; the
attachment switch both ways, opened at the other end; a capped poll
finished in one call). Six sabotages, each red, each restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 1fb722cfa0 Own-device traffic to linked devices only; per-device digests (A1)
Two of the live bugs in the compete designs, findings 1 and 3.

Finding 1. transmit(_, to: myUserID) sent to every device on the
account, so a device signed in by anybody holding the password received
story copies, group keys and names, and the offer to take over a call.
The store now keeps a linked-device set (device id and the IK_sig it was
linked under), in the Keychain and wiped with the account. It starts
empty, and nothing in this release adds to it: linking is stage A2. Every
send to this account goes only to devices in it, matched on id AND key,
so a relay listing a linked id under another key is not believed. The
one exception is by design: a call-end saying answered-elsewhere or
declined-elsewhere still goes to every device, because the caller rang
them all. Receiving, a call handoff from an own device that is not linked
is dropped (SPEC 6.30).

Consequence until A2 ships: call handoff, story copies and group names or
keys no longer reach a second device of the same account. That is what
SPEC 17.3 asks for at A1.

Finding 3. When the relay's descriptor says features.perDeviceDigests,
the device publishes its delivery-key digest once more, once per relay
(a flag per relay host, set only when the relay accepts it), and sends
device_id with its roster tags. Without the republish, a device whose
digest was overwritten under the old one-per-account rule would never
publish again: the app remembered that it had.

Tests on My Mac (Designed for iPad), no simulator runtime on this Mac:
OwnDeviceTrafficGoesOnlyToLinkedDevicesTests 7, TheDeliveryKeyIs-
RepublishedOncePerRelayTests 4, E2EInboxConversationTests 42 (the story
test now links its second device first). Nine sabotages, each red, each
restored and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 ef83cd8f70 PaddingPropertyTests: walk the lengths off the main thread
In a debug build the two walks take about a minute each, and the test
host's main thread not answering for that long ended the host mid-walk,
which xcodebuild reports as "Restarting after unexpected exit, crash, or
test timeout" rather than as a slow test. They now run detached and the
walk is thinner past 256 bytes (every 401st length instead of every
211th); every length near a step change is still checked.

On My Mac (Designed for iPad): 6 passed, 78 s and 56 s for the walks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 3603113dc1 Padding: every payload a ladder length, attachments and rosters too (SPEC 23)
Sources/E2E/Padding.swift holds section 23's arithmetic: the message
ladder {16384, 65536}, the targets derived from it (never written out),
the plan, the pad drawn uniformly from [A-Za-z0-9] by rejection sampling,
and the length check on the bytes after the fact. A payload that misses
its step throws `wrongLength` and is never sent; one that does not fit
the top step throws `tooLong`, "Too long to send as a message. Send it as
a file."

SealedSender.sealedPayload pads the inner JSON and writes the outer blob
by template in the canonical form. JSONEncoder escapes every `/` in the
base64, about one in 32, so the length would miss the step; the vector's
escaped-slash case shows exactly that. The envelope and the inner JSON
are written in one fixed key order, because JSONEncoder's order changes
from run to run (measured) and the vector is byte-exact.

Attachments: `open` honours `clen` now, digest first, range-checked
before anything is decrypted; a `clen` that is not an integer keeps the
message and refuses only the attachment. `seal(pad:)` adds the random
tail to the attachment ladder, for use only when the relay says so.
Sealed rosters are padded to 8192 bytes or the next multiple.

Tests: padding-v1.json copied from flamenet-e2e privacy/sweep-2026-10
(7036115) and every case asserted in ProtocolVectorTests, the sealed
payload reproduced to the byte; PaddingPropertyTests walks 0 to 70,000
bytes in four header shapes, sealed with and without a return address
and unsealed, plus the composer limit, the pad's distribution and roster
sizes. Run on macOS against the app's own sources: 22 passed. Eleven
sabotages, each red, each restored and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 88562ec07d Version 0.32.0, build 32
0.31.0 is in App Review. Everything new from here goes into 0.32.0,
the privacy batch: padded payloads (SPEC section 23) and own-device
traffic held to linked devices (section 17.3). Build 32 keeps the
number lined up with the release, as every earlier one is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul Hitt 0c3ba09615 Merge branch 'crossplat/ios-cross-platform-fixes' into 'main'
Keep blocked contacts out of the inbox; call this account "You" in its groups

See merge request extant2000/flamenet-messenger-ios!238
2026-10-09 11:19:39 +00:00
Paul HittandClaude Opus 5.5 0dfb2692fe Call this account "You" in its own groups
Every group header on the iPhone read "xpa6698, Member #7", and 7 was the
iPhone's own account. The group screen names each member through the
buddy list and then the roster. Nobody is their own buddy and the relay's
roster carries no names, so the one member present in every group fell
through to the placeholder.

Found on the iPhone-to-Android pair against a local relay.

E2EGroup.displayName(of:me:known:) answers "You" for this account, then a
buddy's name, then the placeholder, and every name on the group screen
goes through it: the header, the roster, and the mention picker.
AGroupNamesYouAsYouTests: 3 red before, 5 green after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:46:34 -04:00
Paul HittandClaude Opus 5.5 a0a886b3fa Keep a blocked contact out of the inbox
The relay refuses a blocked sender only on an identified send. A sealed
send is authorised by the recipient's delivery key and does not say who
sent it, so a blocked contact who already holds that key keeps getting
through. Revoking delivery tokens does not close it: a refused token
falls back to the delivery key by design. The recipient learns the sender
when it unseals, so the recipient has to refuse.

Found on the iPhone-to-Android pair against a local relay: after this app
blocked the emulator, the emulator's next message went sealed, the relay
accepted it with the block row present, and this app showed it. Checked
twice, the second time minutes later with the block still in place.

The blocked list is kept on the device (KeychainE2EStore.blockedSenders),
written on every block and unblock and whenever the relay's list is
fetched, and deliver() drops anything from a blocked sender, calls
included. ABlockedSenderStaysOutTests: 2 red before, 4 green after;
BlockingTakesPresenceWithItTests and KeychainStoreWipeTests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:09:19 -04:00
215 changed files with 31460 additions and 2451 deletions
+1 -1
View File
@@ -1 +1 @@
d07ed14ac4daace8fb959187b2df181f8f6152be
4b4042f72880758ee2dea7713f7a2cb63fa12f73
+2 -2
View File
@@ -1,6 +1,6 @@
# Why this repository pins flamenet-e2e
`.e2e-ref` is the commit of [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e)
`.e2e-ref` is the commit of [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e)
that the copies in `Tests/Vectors/` are checked against. See
`Tests/Vectors/PROVENANCE.md` for why they are copies at all.
@@ -16,7 +16,7 @@ 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`.
That is what `vectors-head` is for, and why it is `continue-on-error: true` in `.gitea/workflows/ci.yml`.
Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.
## Taking an update
+355
View File
@@ -0,0 +1,355 @@
# CI for the iOS app on Gitea Actions. Translated from .gitlab-ci.yml on
# 2026-10-13; every gate it had is kept.
#
# Builds and tests only. TestFlight and App Store uploads stay manual on the
# MacBook Air and the iMac; no signing material is in CI.
#
# Runners: the Xcode jobs need `pauls-imac` (host mode, Xcode 27, capacity 1,
# so they run one after the other). The three jobs that only read source run
# on mac1/mac2 (`ubuntu-latest`) to keep the iMac free.
name: ci
on:
push:
branches: [main]
pull_request:
# A timed-out iOS job is the normal failure here, and re-running it needed a
# commit because there was no other trigger. The unit job can take 90
# minutes before it says anything, so "push something to try again" is an
# expensive way to ask a question.
workflow_dispatch:
env:
GITEA_BASE: https://git.extant2000.com/extant2000
jobs:
# The repository is public, so this sweep is the last thing between a pasted
# key and the internet. It was red for nine days in 2026-09 because nobody
# ran it. No submodule fetch: a sweep of this repo is about this repo.
secret-sweep:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: bash Scripts/secret-sweep.sh
unit-tests:
runs-on: pauls-imac
# 90, not 30. The first run of this workflow died at exactly 30 minutes
# with "context deadline exceeded", 15 seconds past the vector gate and
# still inside xcodebuild. The suite is 1289 tests since the 0.32 merge,
# against 741 when 30 was chosen, and a cold `ci-build` adds a full
# compile of the app, its tests and the obfs4 package on top.
#
# 90 was not enough either: the 2026-10-14 run spent all 90 minutes
# inside xcodebuild and printed no `Executed` line at all, so the cold
# compile alone outlasts it on this runner. The same commit builds and
# runs all 1289 tests in about 37 minutes on an M-series Mac, so this is
# the Intel compile, not the suite. 180 leaves room for a cold build;
# a warm `ci-build` in the reused workspace should come in far under it.
timeout-minutes: 180
steps:
# The obfs4 transport is the Packages/Obfs4 submodule
# (flamenet-elligator2, public on Gitea). Without it package resolution
# fails with "the package manifest at Packages/Obfs4/Package.swift
# cannot be accessed".
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Unit tests
shell: bash
run: |
set -euo pipefail
# The selected Xcode, never a hardcoded beta path (see
# xcode-is-the-release-now). Printed so the log says what built.
export DEVELOPER_DIR="$(xcode-select -p)"
# 🔴 Boot the simulator HERE, and wait for it, because xcodebuild
# waits for it forever.
#
# Shutting the device down and leaving xcodebuild to boot it is what
# burned two runs: the suite compiled, xcodebuild handed off to a
# device that never came up, and the step was cancelled at the
# timeout with no error. The 90-minute and the 180-minute run both
# produced ci-test.log at 889,54x bytes, ending at
# `PruneExplicitPrecompiledModules`, which is the end of a
# successful build. Raising the timeout twice was treating a hang as
# slowness.
#
# `simctl bootstatus -b` boots the device and blocks until it is
# usable, and exits non-zero when it cannot. A device that will not
# boot is now a message rather than three silent hours.
xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
if ! xcrun simctl bootstatus "iPhone 17" -b; then
echo "GATE: the iPhone 17 simulator would not boot on this runner"
xcrun simctl list devices available
exit 1
fi
xcodebuild -version
# Vectors are copied into Tests/Vectors and pinned by .e2e-ref.
# Check the copy against the pinned flamenet-e2e commit.
# `vectors-head` below is the advisory half that watches HEAD.
FLAMENET_E2E_PIN=$(cat .e2e-ref)
rm -rf ci-vectors
git clone -q "$GITEA_BASE/flamenet-e2e.git" ci-vectors
git -C ci-vectors checkout -q "$FLAMENET_E2E_PIN"
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 \
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1 \
content-kinds-v1 today-v1 hello-link-v1; do
if ! diff -q "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
echo "GATE: Tests/Vectors/$f.json differs from flamenet-e2e@$FLAMENET_E2E_PIN."
echo "GATE: the vectors moved. Read what changed, then refresh the copy:"
echo "GATE: cp ci-vectors/test/vectors/*.json Tests/Vectors/"
diff "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -20 || true
exit 1
fi
done
echo "GATE: protocol vectors match flamenet-e2e@$FLAMENET_E2E_PIN"
# Scripts/generate.sh, never bare `xcodegen generate`: only the script
# copies the pinned Package.resolved into the generated project.
Scripts/generate.sh
# Keep the ~700 MB build output out of Spotlight; indexing it drove
# the runner host to a load average in the hundreds.
mkdir -p ci-build && touch ci-build/.metadata_never_index
# Build and test are two xcodebuild calls, each under its own cap, so
# the log says which phase stopped. The 2026-10-16 run proved why:
# ci-test.log reached 889,595 bytes and ended at
# PruneExplicitPrecompiledModules, which is the end of a clean build,
# and then xcodebuild sat for 150 minutes without printing "Testing
# started". One call could not tell a slow compile from a test phase
# that never begins. Two can.
#
# The watchdog needs nothing but the shell: coreutils is on one Mac
# runner and not the other, and the run that printed "runs uncapped"
# burned the whole 180-minute deadline and uploaded a log nobody
# read. It holds xcodebuild's own pid because the log is a redirect
# rather than a pipe.
#
# `-collect-test-diagnostics never`: the default is on-failure, and
# on a failure xcodebuild runs `simctl diagnose`, a sysdiagnose plus
# 8 minutes of simulator logs with a 600s timeout each, before it
# reports anything. Reproduced on an M3 by watching that pid.
capped() {
local limit=$1 label=$2; shift 2
"$@" >> ci-test.log 2>&1 &
local xcb=$!
( sleep "$limit"
kill -0 "$xcb" 2>/dev/null || exit 0
echo "GATE: $label gave no result after ${limit}s, killing xcodebuild"
kill -TERM "$xcb" 2>/dev/null
sleep 20
kill -KILL "$xcb" 2>/dev/null ) &
local dog=$!
wait "$xcb"; local rc=$?
kill "$dog" 2>/dev/null || true
return $rc
}
XC=(xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17"
-collect-test-diagnostics never -derivedDataPath ci-build)
: > ci-test.log
# Progress in the job log while it runs; ci-test.log keeps everything.
( tail -f ci-test.log 2>/dev/null \
| grep --line-buffered -E "Executed|TEST (SUCCEEDED|FAILED)|TEST BUILD" ) &
TAIL=$!
capped 5400 build-for-testing "${XC[@]}" build-for-testing || true
if ! grep -q '\*\* TEST BUILD SUCCEEDED \*\*' ci-test.log; then
kill "$TAIL" 2>/dev/null || true
echo "GATE: the test build did not succeed"
tail -40 ci-test.log; exit 1
fi
echo "GATE: test build succeeded, running FlamenetMessengerTests"
capped 3600 test-without-building "${XC[@]}" test-without-building -only-testing:FlamenetMessengerTests || true
kill "$TAIL" 2>/dev/null || true
# Assert on the LOG, never on grep's exit status: a failing run
# prints "TEST FAILED", grep matches it and exits 0.
grep -q '\*\* TEST SUCCEEDED \*\*' ci-test.log || {
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
tail -40 ci-test.log; exit 1; }
grep -qE 'Executed [1-9][0-9]* tests, with 0 failures' ci-test.log || {
echo "GATE: no run of at least one test with zero failures"
tail -40 ci-test.log; exit 1; }
# Floor below today's count, so a suite that drops from 1289 to 12
# cannot pass. Raise it when it stops meaning anything.
COUNT=$(grep -oE 'Executed [0-9]+ tests, with 0 failures' ci-test.log \
| tail -1 | grep -oE '[0-9]+' | head -1)
if [ "${COUNT:-0}" -lt 1150 ]; then
echo "GATE: only ${COUNT:-0} tests ran; the suite is not all there"
tail -40 ci-test.log; exit 1
fi
echo "GATE: Executed $COUNT tests, with 0 failures"
- if: failure()
uses: actions/upload-artifact@v3
with:
name: ci-test-log
path: ci-test.log
retention-days: 7
# The 14 UI tests that need no live relay (69 more skip without one).
# Gating since 2026-09-25. Its own job so a UI failure reads as one.
ui-tests:
runs-on: pauls-imac
# pauls-imac has capacity 1, so this waits for unit-tests and then builds
# again from its own derived data. Raised with the unit job for the same
# reason: the cold compile on this runner is the cost, not the tests.
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: UI tests
shell: bash
run: |
set -euo pipefail
export DEVELOPER_DIR="$(xcode-select -p)"
# 🔴 Boot the simulator HERE, and wait for it, because xcodebuild
# waits for it forever.
#
# Shutting the device down and leaving xcodebuild to boot it is what
# burned two runs: the suite compiled, xcodebuild handed off to a
# device that never came up, and the step was cancelled at the
# timeout with no error. The 90-minute and the 180-minute run both
# produced ci-test.log at 889,54x bytes, ending at
# `PruneExplicitPrecompiledModules`, which is the end of a
# successful build. Raising the timeout twice was treating a hang as
# slowness.
#
# `simctl bootstatus -b` boots the device and blocks until it is
# usable, and exits non-zero when it cannot. A device that will not
# boot is now a message rather than three silent hours.
xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
if ! xcrun simctl bootstatus "iPhone 17" -b; then
echo "GATE: the iPhone 17 simulator would not boot on this runner"
xcrun simctl list devices available
exit 1
fi
Scripts/generate.sh
mkdir -p ci-build && touch ci-build/.metadata_never_index
# Build and test are two xcodebuild calls, each under its own cap, so
# the log says which phase stopped. The 2026-10-16 run proved why:
# ci-test.log reached 889,595 bytes and ended at
# PruneExplicitPrecompiledModules, which is the end of a clean build,
# and then xcodebuild sat for 150 minutes without printing "Testing
# started". One call could not tell a slow compile from a test phase
# that never begins. Two can.
#
# The watchdog needs nothing but the shell: coreutils is on one Mac
# runner and not the other, and the run that printed "runs uncapped"
# burned the whole 180-minute deadline and uploaded a log nobody
# read. It holds xcodebuild's own pid because the log is a redirect
# rather than a pipe.
#
# `-collect-test-diagnostics never`: the default is on-failure, and
# on a failure xcodebuild runs `simctl diagnose`, a sysdiagnose plus
# 8 minutes of simulator logs with a 600s timeout each, before it
# reports anything. Reproduced on an M3 by watching that pid.
capped() {
local limit=$1 label=$2; shift 2
"$@" >> ci-ui.log 2>&1 &
local xcb=$!
( sleep "$limit"
kill -0 "$xcb" 2>/dev/null || exit 0
echo "GATE: $label gave no result after ${limit}s, killing xcodebuild"
kill -TERM "$xcb" 2>/dev/null
sleep 20
kill -KILL "$xcb" 2>/dev/null ) &
local dog=$!
wait "$xcb"; local rc=$?
kill "$dog" 2>/dev/null || true
return $rc
}
XC=(xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17"
-collect-test-diagnostics never -derivedDataPath ci-build)
: > ci-ui.log
# Progress in the job log while it runs; ci-ui.log keeps everything.
( tail -f ci-ui.log 2>/dev/null \
| grep --line-buffered -E "Executed|TEST (SUCCEEDED|FAILED)|TEST BUILD" ) &
TAIL=$!
capped 4200 build-for-testing "${XC[@]}" build-for-testing || true
if ! grep -q '\*\* TEST BUILD SUCCEEDED \*\*' ci-ui.log; then
kill "$TAIL" 2>/dev/null || true
echo "GATE: the test build did not succeed"
tail -40 ci-ui.log; exit 1
fi
echo "GATE: test build succeeded, running FlamenetMessengerUITests"
capped 2400 test-without-building "${XC[@]}" test-without-building -only-testing:FlamenetMessengerUITests || true
kill "$TAIL" 2>/dev/null || true
grep -q '\*\* TEST SUCCEEDED \*\*' ci-ui.log || {
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
tail -60 ci-ui.log; exit 1; }
# A floor on what actually RAN. Both summary shapes are read:
# Executed 83 tests, with 69 tests skipped and 0 failures ...
# Executed 14 tests, with 0 failures ...
RAN=$(grep -oE 'Executed [0-9]+ tests' ci-ui.log | tail -1 | grep -oE '[0-9]+' || true)
SKIPPED=$(grep -oE '[0-9]+ tests skipped' ci-ui.log | tail -1 | grep -oE '[0-9]+' || true)
ACTUAL=$(( ${RAN:-0} - ${SKIPPED:-0} ))
if [ "$ACTUAL" -lt 10 ]; then
echo "GATE: only ${ACTUAL} UI test(s) actually ran; the rest skipped"
tail -60 ci-ui.log; exit 1
fi
echo "GATE: ${ACTUAL} UI tests ran, ${SKIPPED:-0} skipped for want of a live relay"
- if: failure()
uses: actions/upload-artifact@v3
with:
name: ci-ui-log
path: ci-ui.log
retention-days: 7
# The pin's conscience: compares the carried vectors with flamenet-e2e HEAD.
# Allowed to fail. A red run means an update is waiting; see .e2e-ref.md.
vectors-head:
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Compare vectors with flamenet-e2e HEAD
run: |
rm -rf ci-vectors-head
git clone --depth 1 "$GITEA_BASE/flamenet-e2e.git" ci-vectors-head
echo "pinned: $(cat .e2e-ref)"
echo "at HEAD: $(git -C ci-vectors-head rev-parse HEAD)"
behind=0
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 \
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1 \
content-kinds-v1 today-v1 hello-link-v1; do
if ! diff -q "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
echo "ADVISORY: $f.json has moved at HEAD:"
diff "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -30 || true
behind=1
fi
done
if [ "$behind" = "1" ]; then
echo "ADVISORY: the published vectors have changed. This is not a failure of"
echo "ADVISORY: this branch; it means an update is waiting. See .e2e-ref.md."
exit 1
fi
echo "ADVISORY: vectors are current with flamenet-e2e HEAD"
# The weekly scrub, on every change. Its checks are the silent ones: an
# envelope kind one app sends and the other cannot parse shows nothing at
# either end. The siblings are public, so they clone with no credentials.
parity-scrub:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Parity scrub
run: |
rm -rf ci-scrub && mkdir -p ci-scrub
git clone --depth 1 "$GITEA_BASE/flamenet-messenger-android.git" ci-scrub/android
git clone --depth 1 "$GITEA_BASE/flamenet-relay.git" ci-scrub/relay
# Non-zero on a HIGH, which is the whole contract.
python3 Scripts/parity-scrub.py --ios . --android ci-scrub/android --relay ci-scrub/relay
-390
View File
@@ -1,390 +0,0 @@
# T4: the Mac is in the merge path. Runs on the project's own macOS runner
# (tag mac-local), the unit suite that used to run only when someone
# remembered to. UI tests stay manual: they need a local relay and a booted
# pair of simulators.
stages: [test]
# The repository is public, so this sweep is the last thing between a pasted
# key and the internet. Until 2026-09-17 it only ran when somebody remembered
# to, and it had been red for nine days without anyone noticing, because a
# formatter carried an allow marker off the line it covered. A check nobody
# runs protects nothing; here it runs on every merge request and every push
# to main, and it finishes in seconds.
# No submodule fetch: the obfs4 transport is a private repository with its
# own tree, and a sweep of this one is about this one.
secret-sweep:
stage: test
tags: [mac-local]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
script:
- bash Scripts/secret-sweep.sh
timeout: 5m
unit-tests:
stage: test
tags: [mac-local]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- 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 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
# And this, because the submodule is a PRIVATE repo. It rewrites the
# submodule's URL to use `CI_JOB_TOKEN`, which is the only credential this
# job has. It also requires flamenet-elligator2 to allow this project in its
# CI job-token allowlist, a setting on the *other* project, so a failure
# here looks like a permissions problem with no local cause.
GIT_SUBMODULE_FORCE_HTTPS: "true"
GIT_SUBMODULE_DEPTH: "1"
script:
# The selected Xcode, not a hardcoded beta path. The beta became the
# release on 2026-09-15 and /Applications/Xcode-beta.app went with it,
# and since `git` on a Mac is an xcrun shim, this job then died before its
# first real command, on every pipeline for a day, with nothing in the
# summary but "failed". The version is printed so the log says what built.
- export DEVELOPER_DIR="$(xcode-select -p)"
# Shut the target simulator down before xcodebuild asks for it.
#
# A cancelled pipeline can leave CoreSimulator holding a booted, wedged
# `iPhone 17`, and the NEXT job then waits on a device that never becomes
# usable. That is not a hypothetical: on 2026-09-25 this job sat for
# thirty minutes and was killed by the job timeout, having printed no test
# output at all, five minutes after a pipeline was cancelled mid-build.
# Its normal run is under four minutes.
#
# `shutdown all` would be the usual line and is deliberately NOT used: this
# runner is a workstation, and a simulator somebody is debugging in is not
# this job's to close. Only the device the job is about to drive.
- xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
- xcodebuild -version
# `Scripts/generate.sh`, never bare `xcodegen generate`. `.gitignore`
# excludes the whole `.xcodeproj`, and SwiftPM keeps `Package.resolved`
# INSIDE it, so the pin git can actually see lives in `Scripts/`, and only
# that script copies it into the generated workspace. Running xcodegen
# directly here meant CI resolved every dependency fresh against the
# network while local builds were correctly pinned, which is why this only
# ever broke on CI. It broke 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
# 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
# here from the day it was written without anyone seeing
# it. A copy in the test bundle cannot be absent. What a copy CAN do is go
# stale, so this is the other half of that bargain.
#
# Pinned, not tip. flamenet-relay#56 is the write-up of what tip costs: a
# merge in flamenet-e2e turned that repo's `main` red in a pipeline nobody
# was watching and blocked six MRs, and was misdiagnosed as runner
# starvation. The commit lives in `.e2e-ref`; `.e2e-ref.md` says how to move
# it.
#
# A pin ALONE is worse than tip, which is what the first version of
# this got wrong. Pinning silently means the vectors can move and
# `ProtocolVectorTests` goes on checking last month's protocol, reporting
# green, the very failure that suite exists to prevent, reintroduced one
# level up. `vectors-head` below is the other half.
- FLAMENET_E2E_PIN=$(cat .e2e-ref)
- rm -rf ci-vectors
- git clone -q https://gitlab.com/extant2000/flamenet-e2e.git ci-vectors
- git -C ci-vectors checkout -q "$FLAMENET_E2E_PIN"
- |
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 content-kinds-v1 today-v1 hello-link-v1; do
if ! diff -q "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
echo "GATE: Tests/Vectors/$f.json differs from flamenet-e2e@$FLAMENET_E2E_PIN."
echo "GATE: the vectors moved. Read what changed, then refresh the copy:"
echo "GATE: cp ci-vectors/test/vectors/*.json Tests/Vectors/"
diff "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -20
exit 1
fi
done
# Inside the block, not a bare `- echo "GATE: ..."` list item. An
# unquoted `: ` in a plain scalar makes YAML read the item as a MAPPING,
# and the whole file becomes invalid, which GitLab reports as a pipeline
# that "failed" with zero jobs and no yaml_errors on the pipeline object.
# Nothing in that says "your CI file is malformed". `ci/lint` does.
echo "GATE: protocol vectors match flamenet-e2e@$FLAMENET_E2E_PIN"
- Scripts/generate.sh
# Keep the build output out of Spotlight.
#
# `ci-build` reaches ~700 MB and is rewritten every job. Indexing it drives
# this host, which is the mac-local runner and therefore in the merge path
# for both app repos, to a load average in the hundreds: `mds_stores` and
# `mdworker_shared` sit at the top of CPU while no `xcodebuild` process is
# near it. That makes every job slower and the machine unpleasant to use
# while CI runs.
#
# The marker has to be written HERE rather than committed, because the
# directory it belongs in is created by the build and removed between runs.
- mkdir -p ci-build && touch ci-build/.metadata_never_index
- |
# `|| true`, deliberately: the log is the gate, not this pipeline's
# exit status, and the checks below need to run so they can print the
# tail of the log when something goes wrong.
set -o pipefail
xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17" \
-derivedDataPath ci-build test -only-testing:FlamenetMessengerTests 2>&1 \
| tee ci-test.log \
| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || true
- |
# Assert on the LOG, never on grep's exit status. This job used to
# end in `| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || exit 1`, and a
# pipeline exits with the status of its LAST command. A failing test run
# prints " TEST FAILED ", grep matched it, grep exited 0, and the
# job went green on a red suite. Every pass this job ever reported was
# unverified. It only ever went red when the build died before printing
# anything at all, which is failing for the wrong reason.
grep -q '\*\* TEST SUCCEEDED \*\*' ci-test.log || {
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
tail -40 ci-test.log; exit 1; }
# `[1-9][0-9]*`, so a suite that ran nothing cannot pass. A tree that
# silently stops building tests otherwise reports the same green as one
# that runs them all.
grep -qE 'Executed [1-9][0-9]* tests, with 0 failures' ci-test.log || {
echo "GATE: no run of at least one test with zero failures"
tail -40 ci-test.log; exit 1; }
# A floor, because "at least one test" is not a gate. Twice now a
# green pipeline here has had to be checked by hand against main's count
# ios!133 and ios!134 both printed a truncated log with no sign of the
# tests that had just been added. A suite that silently dropped from 741
# to 12 passes the check above and looks identical in the log.
#
# Set below today's count, not equal to it: this must not need editing
# every time somebody adds a test. Raise it when it stops meaning
# anything.
COUNT=$(grep -oE 'Executed [0-9]+ tests, with 0 failures' ci-test.log \
| tail -1 | grep -oE '[0-9]+' | head -1)
if [ "${COUNT:-0}" -lt 700 ]; then
echo "GATE: only ${COUNT:-0} tests ran; the suite is not all there"
tail -40 ci-test.log; exit 1
fi
echo "GATE: Executed $COUNT tests, with 0 failures"
artifacts:
when: on_failure
paths: [ci-test.log]
expire_in: 1 week
timeout: 30m
# The rules engines for the games in a chat (§6.33), a local SwiftPM package
# with its own tests and the vectors in games-v1.json, which Android's `:games`
# module runs too. `swift test` needs no simulator; a cold run here took 12
# seconds. Its own job, so a rules failure reads as one.
games-engine:
stage: test
tags: [mac-local]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
script:
- export DEVELOPER_DIR="$(xcode-select -p)"
# Keep the build output out of Spotlight, as in `unit-tests`.
- mkdir -p Packages/FlamenetGames/.build && touch Packages/FlamenetGames/.build/.metadata_never_index
- swift test --package-path Packages/FlamenetGames
timeout: 15m
# The 83 tests in `UITests/` that nothing ran.
#
# `unit-tests` above passes `-only-testing:FlamenetMessengerTests`, so the UI
# target was compiled by nobody and run by nobody on the way to main. That is
# the surface a person actually touches: sign-up, placing a call, the files
# vault, the iPad layout.
#
# Most of it cannot run here and is not meant to. 69 of the 83 gate on a live
# relay and real credentials, `XCTSkipUnless(relayIsUp(...))`, and skip cleanly
# without them; those exist to be pointed at a real deployment by hand, which
# is what `Scripts/` has drivers for. The remaining 14 need none of that and
# take about three and a half minutes.
#
# Its own job rather than folded into `unit-tests`, so that a UI failure reads
# as a UI failure. A swipe that lands early is a different kind of news from a
# unit test going red, and the floor logic in that job counts XCTest cases,
# which UI runs would muddle.
#
# GATING, since 2026-09-25. It did not for a while, and the reason is worth
# keeping: two local runs of this target produced three failures, all of them
# `app.launch()` timing out with "Error getting main window", in tests nobody
# had touched, while the box sat at a load average of 115. That was the
# machine rather than the tests, and it is the same host CI runs on, so
# gating then would have handed the merge path to whatever else was running.
#
# The flag came off on evidence: three consecutive CI runs at 4.6, 5.0 and
# 5.2 minutes, the last of them immediately after an unrelated job on this
# host hung for thirty minutes, so it was not a quiet machine that earned it.
# Those three were BRANCH pipelines, which is all this job used to run in;
# see the note on `rules` below for why that matters.
#
# A caution for whoever reads this next, because it nearly bought the wrong
# fix: two mac-local runners are registered, which LOOKS like this job and
# `unit-tests` can collide over one simulator. Their start and end times say
# they have always run strictly one after the other. The thirty-minute hang
# above was a wedged simulator left by a cancelled pipeline, which is what the
# `simctl shutdown` line in both jobs now clears. Check the timestamps before
# believing in a race.
#
# One real flake WAS found and fixed on the way:
# `FilesVaultUITests.testAFileCanBeDeletedOnItsOwn` failed one run in three by
# swiping a row the list had not finished settling. A swipe that lands early
# does nothing and leaves no trace, so waiting longer cannot help. It now waits
# for the row and retries the gesture once.
ui-tests:
stage: test
tags: [mac-local]
# Without these, this job is not in a merge request pipeline AT ALL.
#
# A job with no `rules` and no `only` runs on branch pipelines and is left
# out of merge request ones, and every other job in this file carries the
# two conditions below. So for as long as it had neither, `allow_failure`
# on this job was decorative: removing the flag changed nothing, because
# the job was never in the pipeline that gates a merge. Its clean runs were
# all branch pipelines, main and hand-triggered, none of them in anybody's
# merge path.
#
# Caught by reading the job list of the merge request pipeline rather than
# the YAML: the flag was gone and the job was absent.
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- export DEVELOPER_DIR=$(xcode-select -p)
# Shut the target simulator down before xcodebuild asks for it.
#
# A cancelled pipeline can leave CoreSimulator holding a booted, wedged
# `iPhone 17`, and the NEXT job then waits on a device that never becomes
# usable. That is not a hypothetical: on 2026-09-25 this job sat for
# thirty minutes and was killed by the job timeout, having printed no test
# output at all, five minutes after a pipeline was cancelled mid-build.
# Its normal run is under four minutes.
#
# `shutdown all` would be the usual line and is deliberately NOT used: this
# runner is a workstation, and a simulator somebody is debugging in is not
# this job's to close. Only the device the job is about to drive.
- xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
- Scripts/generate.sh
- mkdir -p ci-build && touch ci-build/.metadata_never_index
- |
set -o pipefail
xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17" \
-derivedDataPath ci-build test -only-testing:FlamenetMessengerUITests 2>&1 \
| tee ci-ui.log \
| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || true
- |
# Assert on the LOG, never on grep's exit status, for the reason spelled
# out in `unit-tests`: a failing run PRINTS " TEST FAILED ", grep matches
# it, grep exits 0, and the job goes green on a red suite.
grep -q '\*\* TEST SUCCEEDED \*\*' ci-ui.log || {
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
tail -60 ci-ui.log; exit 1; }
# A floor on what actually RAN, not on what was scheduled. Every one of
# these can skip, so "0 executed, 0 failures" is a perfectly green log
# for a target that did nothing, and that is the failure this job exists
# to not have.
# Both shapes of the summary line, because the skipped clause is only
# there when something skipped:
# Executed 83 tests, with 69 tests skipped and 0 failures ...
# Executed 14 tests, with 0 failures ...
# Reading only the first shape would make a run with NOTHING skipped
# come out as zero and fail this job for being healthy.
RAN=$(grep -oE 'Executed [0-9]+ tests' ci-ui.log | tail -1 | grep -oE '[0-9]+')
SKIPPED=$(grep -oE '[0-9]+ tests skipped' ci-ui.log | tail -1 | grep -oE '[0-9]+')
ACTUAL=$(( ${RAN:-0} - ${SKIPPED:-0} ))
# Set below today's 14, not equal to it, so adding a test that needs a
# relay does not need this number edited. Raise it when it stops meaning
# anything.
if [ "$ACTUAL" -lt 10 ]; then
echo "GATE: only ${ACTUAL} UI test(s) actually ran; the rest skipped"
tail -60 ci-ui.log; exit 1
fi
echo "GATE: ${ACTUAL} UI tests ran, ${SKIPPED:-0} skipped for want of a live relay"
artifacts:
when: on_failure
paths: [ci-ui.log]
expire_in: 1 week
timeout: 30m
# The pin's conscience. Compares the vectors this repo carries against
# flamenet-e2e's actual HEAD, and is allowed to fail. Without it, `.e2e-ref` is
# permission to fall behind forever with a green board.
#
# Deliberately does NOT build the app. The question it answers, "did the
# published vectors move?", needs a clone and a diff, and nothing else. A job
# that took twenty minutes to say "you are two commits behind" would be turned
# off within a week.
#
# A red run here means an update is waiting. Take it per `.e2e-ref.md`.
vectors-head:
stage: test
tags: [mac-local]
allow_failure: true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
script:
- rm -rf ci-vectors-head
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-e2e.git ci-vectors-head
- |
echo "pinned: $(cat .e2e-ref)"
echo "at HEAD: $(git -C ci-vectors-head rev-parse HEAD)"
behind=0
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 content-kinds-v1 today-v1 hello-link-v1; do
if ! diff -q "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
echo "ADVISORY: $f.json has moved at HEAD:"
diff "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -30
behind=1
fi
done
if [ "$behind" = "1" ]; then
echo "ADVISORY: the published vectors have changed. This is not a failure of"
echo "ADVISORY: this branch; it means an update is waiting. See .e2e-ref.md."
exit 1
fi
echo "ADVISORY: vectors are current with flamenet-e2e HEAD"
timeout: 10m
# The weekly scrub, on every merge request instead of weekly.
#
# `Scripts/SCRUB.md` describes it as something to run with the three checkouts
# side by side, and its own docstring says the checks it carries are the ones
# whose failure mode is SILENT: an envelope kind one app sends and the other
# cannot parse shows nothing at either end and tells the sender nothing.
#
# A check that only runs when remembered stops running. On 2026-09-20 it had
# been reporting four HIGHs that were all wrong -- it knew two of the four
# shapes iOS uses to decide what an envelope is -- which is the state its own
# documentation says is worse than not having it, because people learn to skim
# a scrub that cries wolf.
#
# All three repositories are public, so the two siblings clone with no
# credentials, the same way `vectors-head` above takes flamenet-e2e. Shallow:
# this reads source, not history.
parity-scrub:
stage: test
tags: [mac-local]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
GIT_DEPTH: "1"
script:
- rm -rf ci-scrub
- mkdir -p ci-scrub
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-messenger-android.git ci-scrub/android
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-relay.git ci-scrub/relay
# Non-zero on a HIGH, which is the whole contract.
- python3 Scripts/parity-scrub.py --ios . --android ci-scrub/android --relay ci-scrub/relay
timeout: 10m
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "Packages/Obfs4"]
path = Packages/Obfs4
url = https://gitlab.com/extant2000/flamenet-elligator2.git
url = https://git.extant2000.com/extant2000/flamenet-elligator2.git
+1 -1
View File
@@ -44,7 +44,7 @@ Repeatable via `Scripts/android-signup.sh` and `RelayTypesUITests`.
## 3, cross-relay messaging
**The relays do it.** `test/federation.mjs` (in [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e) since the split) against the live fleet:
**The relays do it.** `test/federation.mjs` (in [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e) since the split) against the live fleet:
**11 passed, 0 failed**: relay1 fetches bob's devices from relay2, sends
across the boundary, bob's relay accepts exactly one envelope, he polls and it
is there, it carries no sender attribution across the boundary, a relay off
@@ -1,5 +1,11 @@
import Foundation
/// How the sessions this package builds are configured. The app sets it
/// once, at launch, so they go through its bridges like every other session.
public enum FilesTransport {
nonisolated(unsafe) public static var configure: @Sendable (URLSessionConfiguration) -> Void = { _ in }
}
/// A very small HTTP layer.
struct HTTP: Sendable {
let session: URLSession
@@ -13,6 +19,8 @@ struct HTTP: Sendable {
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.httpCookieStorage = nil
configuration.timeoutIntervalForRequest = 30
// The host app's transport: a bridge when one is set (SPEC §24.6).
FilesTransport.configure(configuration)
self.session = URLSession(configuration: configuration)
}
+4 -4
View File
@@ -27,9 +27,9 @@ pipeline:
| Repo | What it is |
|---|---|
| [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e) | The protocol: JS engine, specification, published vectors, the interop driver |
| [flamenet-relay](https://gitlab.com/extant2000/flamenet-relay) | The server |
| [flamenet-messenger-android](https://gitlab.com/extant2000/flamenet-messenger-android) | The Android app |
| [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e) | The protocol: JS engine, specification, published vectors, the interop driver |
| [flamenet-relay](https://git.extant2000.com/extant2000/flamenet-relay) | The server |
| [flamenet-messenger-android](https://git.extant2000.com/extant2000/flamenet-messenger-android) | The Android app |
those three lived here as subtrees, and the public
repos were rsync extracts of them, refreshed by hand. The extracts drifted for
@@ -97,7 +97,7 @@ tick.
The server is its own repository now:
```bash
git clone https://gitlab.com/extant2000/flamenet-relay.git
git clone https://git.extant2000.com/extant2000/flamenet-relay.git
cd flamenet-relay
cp .env.example .env # then fill in the secrets it asks for
docker compose up -d
+8
View File
@@ -7,5 +7,13 @@
breaks debug builds, which then never receive a token. -->
<key>aps-environment</key>
<string>development</string>
<!-- Section 8.14: https://<relay>/add#... opens the app. The relay serves
the matching apple-app-site-association (team M5923Y84P5, /add*).
The App ID needs the Associated Domains capability to sign. -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:relay.flamenet.io</string>
<string>applinks:relay1.flamenet.io</string>
</array>
</dict>
</plist>
+8
View File
@@ -20,6 +20,14 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>io.flamenet.messenger.contact-link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flamenet</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
+108
View File
@@ -0,0 +1,108 @@
{
"sourceLanguage" : "en",
"strings" : {
"CFBundleDisplayName" : {
"comment" : "App name under the icon. Brand, not translated.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet"
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet"
}
}
}
},
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "FlamenetMessenger"
}
}
}
},
"NSCameraUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet uses the camera to scan a contact's address code. Nothing is recorded and no image leaves your device."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet usa la cámara para escanear el código de dirección de un contacto. No se graba nada y ninguna imagen sale de tu dispositivo."
}
}
}
},
"NSFaceIDUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet can require Face ID before opening the app, so somebody holding your unlocked phone cannot read your conversations."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet puede pedir Face ID antes de abrir la aplicación, para que nadie que tenga tu teléfono desbloqueado en la mano pueda leer tus conversaciones."
}
}
}
},
"NSMicrophoneUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet uses the microphone for voice notes and voice calls, which are end-to-end encrypted before they leave your device."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet usa el micrófono para las notas de voz y las llamadas de voz, que se cifran de extremo a extremo antes de salir de tu dispositivo."
}
}
}
},
"NSPhotoLibraryAddUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet can save a story you posted yourself to your photos, so you keep it after it disappears for everyone else. It only ever adds; it cannot see the photos you already have."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet puede guardar en tus fotos una historia que publicaste tú, para que la conserves cuando desaparezca para todos los demás. Solo puede agregar; no puede ver las fotos que ya tienes."
}
}
}
}
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Every string the compiler extracts must be in Resources/Localizable.xcstrings.
Spanish (es) is a DRAFT, needs native review.
xcodebuild compiles the catalog but does not add new keys to it; only Xcode's
editor does that. So a SwiftUI literal or String(localized:) added from the
command line reaches the app with no entry, and it shows in English in every
language with nothing failing. SpanishCatalogTests can only check the keys the
catalog already has. This closes the other half: it reads the .stringsdata the
Swift compiler writes (SWIFT_EMIT_LOC_STRINGS=YES in project.yml) and compares.
xcodebuild ... -derivedDataPath build/dd build
Scripts/check-spanish-catalog.py build/dd
Exit 1 when a key in the code is missing from the catalog. Keys in the catalog
that the code no longer uses are listed, not failed.
"""
import glob
import json
import os
import re
import sys
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
derived = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, 'build')
catalog = json.load(open(os.path.join(here, 'Resources', 'Localizable.xcstrings'), encoding='utf-8'))['strings']
code = {}
seen = set()
for f in glob.glob(os.path.join(derived, '**', '*.stringsdata'), recursive=True):
d = json.load(open(f))
src = d.get('source', '')
rel = os.path.relpath(os.path.realpath(src), os.path.realpath(here))
if rel in seen or not rel.startswith('Sources' + os.sep):
continue
seen.add(rel)
for e in d.get('tables', {}).get('Localizable', []):
code.setdefault(e['key'], '%s:%s' % (rel, e.get('location', {}).get('startingLine', '?')))
if not code:
sys.exit('no .stringsdata found under %s; build first with SWIFT_EMIT_LOC_STRINGS=YES' % derived)
missing = sorted(k for k in code if k not in catalog)
unused = sorted(k for k, v in catalog.items() if k not in code and v.get('extractionState') != 'manual')
print('%d keys in code, %d in the catalog, %d missing, %d unused' % (len(code), len(catalog), len(missing), len(unused)))
for k in missing:
print('MISSING %s (%s)' % (k, code[k]))
for k in unused:
print('unused %s' % k)
sys.exit(1 if missing else 0)
+5 -3
View File
@@ -4,8 +4,10 @@
# FN_SHOT_HANDLE=… FN_SHOT_PASSPHRASE=… Scripts/store-shots.sh [device type]
#
# One simulator, one sign-in, one shot per tab. The device decides the pixel
# size, so the default is an iPhone 13 Pro Max: 1284 x 2778, which is a size
# App Store Connect accepts. Results land in build/store-shots/ and the script
# size, so the default is an iPhone 16 Pro Max: 1320 x 2868, the 6.9 inch
# slot. Since 2026-10-20 App Store Connect refuses 1284 x 2778 there. For
# iPad pass com.apple.CoreSimulator.SimDeviceType.iPad-Pro-12-9-inch-6th-generation-8GB
# (2048 x 2732). Results land in build/store-shots/ and the script
# prints the size of each, so a wrong device is visible without opening one.
#
# The conversation shot is not here. It needs a second phone to talk to, so it
@@ -13,7 +15,7 @@
set -uo pipefail
cd "$(dirname "$0")/.."
DEVICE="${1:-com.apple.CoreSimulator.SimDeviceType.iPhone-13-Pro-Max}"
DEVICE="${1:-com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro-Max}"
RELAY="${FN_SHOT_RELAY:-https://relay1.flamenet.io}"
HANDLE="${FN_SHOT_HANDLE:-}"
PASS="${FN_SHOT_PASSPHRASE:-}"
+167 -90
View File
@@ -53,6 +53,10 @@ final class AppEnvironment: ObservableObject {
/// search box picks it up.
@Published var pendingAddress: String?
/// Section 8.14: a contact link somebody opened, waiting for the person
/// to write a first message.
@Published var pendingContactLink: ContactLink?
/// The unlocked Files vault, while there is one.
@Published var filesVault: LocalVault?
@@ -206,9 +210,17 @@ final class AppEnvironment: ObservableObject {
}
/// Block or unblock somebody, and take their sealed capabilities with it.
///
/// Section 8.9d: a block is held on this device (and its linked devices),
/// where it drops anything that still arrives, AND on the relay, which
/// refuses their unsealed messages until stage 2-E. The relay is told
/// first: if it cannot be, nothing changes and the caller says so.
func setBlocked(_ blocked: Bool, userID: Int) async throws {
guard let api else { throw MessengerError.notAuthenticated }
try await api.setBlocked(blocked, userID: userID)
// Kept on this device as well, as the `blocked/` register: the relay
// cannot refuse a SEALED message from them, so the inbox has to.
e2eInbox?.setSender(userID, blocked: blocked)
guard blocked else { return }
// After the block has landed, and never allowed to fail it: the block
// itself is the safety action, and failing it because a follow-up call
@@ -234,11 +246,95 @@ final class AppEnvironment: ObservableObject {
"blocked %{public}d, but revoking their delivery tokens failed: %{public}@",
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
type: .error, userID, String(describing: error))
lastError =
"Blocked. Their sealed-delivery capability could not be revoked right now, "
+ "so a message they already prepared may still arrive. Blocking again "
+ "retries it."
lastError = String(
localized:
"Blocked. Their sealed-delivery capability could not be revoked right now, so a message they already prepared may still arrive. Blocking again retries it."
)
}
// The third part of a block: a new delivery key, so the old one they
// hold stops opening this inbox. Pushed to everybody else at once.
if let inbox = e2eInbox { Task { await inbox.rotateDeliveryKey() } }
}
// MARK: - Contact links (section 8.14)
/// Open a link: a contact link goes to the first-message sheet. Anything
/// else is not ours and is ignored.
@discardableResult
func open(_ url: URL) -> Bool {
guard let link = ContactLink(text: url.absoluteString) else { return false }
pendingContactLink = link
return true
}
func applyPendingListingChoice() async {
guard ListingChoice.pendingUnlisted, let api, let inbox = e2eInbox,
await inbox.relayFeatures().unlisted == true
else { return }
do {
try await api.setDiscoverable(false)
ListingChoice.pendingUnlisted = false
} catch {
os_log(
"the Unlisted choice made at sign-up was not applied yet: %{public}@",
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
type: .error, String(describing: error))
}
}
// MARK: - Requests and contacts (section 14.5, 8.9d)
/// Accept somebody whose first message is in Requests: they become a
/// conversation and a contact.
///
/// Acceptance itself is held on the device. The contact is added to the
/// sealed list when the relay no longer keeps one, and otherwise to the
/// relay's, which is also where a device still on 0.31 reads acceptance.
/// Throws when the contact could not be added; the acceptance stands.
func acceptRequest(_ id: Int) async throws {
guard let inbox = e2eInbox else { throw MessengerError.notAuthenticated }
inbox.accept(id)
let name = await nameFor(stranger: id)
if inbox.rosterOffServer {
inbox.rosterAdd(id: id, handle: name.handle ?? "", displayName: name.display)
} else if let api, let handle = name.handle, !handle.isEmpty {
try await api.addBuddy(username: handle, fromPagePath: siteConfig.messengerPagePath)
try? await api.acceptConversation(buddyID: id)
if let fresh = try? await api.fetchBuddies(fromPagePath: siteConfig.messengerPagePath) {
knownBuddies = fresh
knownBuddyIDs = fresh.map(\.id)
}
} else {
// No name to add them under. Kept on the sealed list rather than
// lost; the relay's list picks them up when a name is known.
inbox.rosterAdd(id: id, handle: "", displayName: name.display)
}
}
/// Delete a request without blocking: the conversation goes, and a later
/// message from them is a request again.
func deleteRequest(_ id: Int) {
e2eInbox?.clearThread(for: id)
}
/// A name for somebody who is not a contact, for Requests and the call
/// screen. From a handle certificate when one arrived (section 14.5,
/// stage C2); otherwise the relay is asked, which is the question C2
/// retires.
func nameFor(stranger id: Int) async -> (handle: String?, display: String) {
if let certified = e2eInbox?.certifiedHandle(for: id) {
return (certified, certified)
}
if let known = knownBuddies.first(where: { $0.id == id }) {
return (known.screenName, known.displayName)
}
// Stage C2: a relay that signs handle certificates is never asked who
// somebody is; the name comes from their certificate or not at all.
let retired = await e2eInbox?.relayFeatures().introductions == true
if !retired, let info = (try? await api?.userInfo(id: id)) ?? nil {
return (info.username, info.username ?? info.displayName)
}
return (nil, e2eInbox?.displayName(for: id) ?? String(localized: "Someone"))
}
private var registrationWatch: AnyCancellable?
@@ -294,18 +390,20 @@ final class AppEnvironment: ObservableObject {
inbox.restoreExtraHomes(restPrefix: config.restPrefix)
inbox.start()
self.e2eInbox = inbox
// Section 8.14: an Unlisted choice made at sign-up, applied once the
// relay can take it.
Task { await self.applyPendingListingChoice() }
self.callManager = CallManager(inbox: inbox)
// Who is calling, when they are not a contact. The relay can answer an
// id with a handle, and adopting them puts that name on the call
// screen, in the call log, and on the conversation that follows.
self.callManager?.resolveCaller = { [weak self] id in
guard let self else { return nil }
await self.adoptStrangers(including: [id])
if let known = self.knownBuddies.first(where: { $0.id == id }) {
return known.displayName
}
guard let info = (try? await self.api?.userInfo(id: id)) ?? nil else { return nil }
return info.username ?? info.displayName
// Named, not added: a stranger who calls is not a contact
// (section 14.5).
let name = await self.nameFor(stranger: id)
return name.handle == nil && self.knownBuddies.first(where: { $0.id == id }) == nil
? nil : name.display
}
// Accepting a changed identity anywhere ends the call screen's review,
@@ -341,9 +439,10 @@ final class AppEnvironment: ObservableObject {
.sink { [weak self] _ in
guard let self else { return }
self.signOut()
self.lastError =
"This device was removed from the account, so it has "
+ "signed out. Your messages on this phone were left alone."
self.lastError = String(
localized:
"This device was removed from the account, so it has signed out. Your messages on this phone were left alone."
)
}
revocationWatch = inbox.$deviceRevoked
@@ -357,10 +456,10 @@ final class AppEnvironment: ObservableObject {
self.e2eInbox?.purgeLocalHistory()
self.signOut()
store?.wipe()
self.lastError =
"This device was removed from the account, so "
+ "its local messages were cleared. Signing in again "
+ "registers it as a new device."
self.lastError = String(
localized:
"This device was removed from the account, so its local messages were cleared. Signing in again registers it as a new device."
)
}
// Registration is per session, because the token is per device AND the
@@ -401,16 +500,30 @@ final class AppEnvironment: ObservableObject {
: told
return copy
}
// Section 14.5: the usernames this device may introduce
// itself by when a send has nothing else to seal with.
self.e2eInbox?.updateKnownHandles(
Dictionary(
merged.compactMap { b in b.screenName.map { (b.id, $0) } },
uniquingKeysWith: { a, _ in a }))
await MainActor.run {
self.knownBuddies = merged
// Something worked, so an older run of refusals is over.
self.noteCredentialAccepted()
}
// A stranger's first message arrives as a thread and
// nothing else. Adopting here rather than from a screen is
// what makes their chat appear while you are reading a
// different tab.
await self.adoptStrangers()
// Stage 2-B: seed acceptance and blocks from the relay's
// answer, once, then stop asking.
if let inbox = self.e2eInbox, !inbox.store.accountStateSeededFromRelay,
let blocked = try? await api.fetchBlocked()
{
inbox.seedAccountStateFromRelayIfNeeded(
acceptedBuddies: fetched.filter(\.accepted).map(\.id),
blocked: blocked.map(\.id))
}
// A stranger's first message lands in Requests. Naming
// them here, rather than from a screen, is what puts a
// name on it while you are reading a different tab.
await self.nameStrangers()
os_log(
"presence refreshed: %{public}@",
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
@@ -447,11 +560,10 @@ final class AppEnvironment: ObservableObject {
.first()
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.lastError =
"Could not finish setting this device up on "
+ "the relay, so it is not receiving messages yet. Your "
+ "account and messages are untouched, this usually clears "
+ "on its own; check your connection and try again."
self?.lastError = String(
localized:
"Could not finish setting this device up on the relay, so it is not receiving messages yet. Your account and messages are untouched, this usually clears on its own; check your connection and try again."
)
}
PushRegistrar.shared.upload = { [weak inbox] token in
@@ -520,70 +632,32 @@ final class AppEnvironment: ObservableObject {
VoIPCallReceiver.shared.start()
}
/// People this device has a thread with who are not on the contact list.
///
/// The contact list is one-directional and sealed sender means the relay
/// cannot annotate anything, so somebody who adds you and writes puts
/// nothing in your list. Their thread is the only trace, and adopting them
/// is what turns it into a conversation with a name on it.
private var adoptingStrangers: Set<Int> = []
/// Requests this session has already looked up a name for.
private var namedStrangers: Set<Int> = []
/// Put whoever just contacted us onto the contact list, properly named.
/// Put a name to everybody in Requests, without adding anybody.
///
/// Runs wherever the person is, not from one screen's `.task`, so a
/// first message from somebody new is adopted whichever tab is open.
/// This used to ADOPT them: anybody whose first message arrived was added
/// to the contact list by the app itself, which made a stranger's first
/// message a contact. Section 14.5 (decision DC2) ends that: they wait in
/// Requests until the person accepts or blocks. Only the name is fetched,
/// so the Requests list says who wrote.
@discardableResult
/// - Parameter including: ids to adopt whether or not they have written,
/// which is how somebody who only CALLED gets a name.
func adoptStrangers(including extra: Set<Int> = []) async -> Bool {
guard let api, let inbox = e2eInbox else { return false }
func nameStrangers(including extra: Set<Int> = []) async -> Bool {
guard let inbox = e2eInbox else { return false }
let known = Set(knownBuddies.map(\.id))
let strangers =
Set(
inbox.threads
.filter { !$0.value.isEmpty }
.keys
)
.union(extra)
.filter { !known.contains($0) && !adoptingStrangers.contains($0) }
let strangers = Set(inbox.requestIDs).union(extra)
.filter { !known.contains($0) && !namedStrangers.contains($0) }
guard !strangers.isEmpty else { return false }
adoptingStrangers.formUnion(strangers)
// Both the lookup and the add are idempotent, so a failure here is
// retried on the next pass rather than remembered as done.
defer { adoptingStrangers.subtract(strangers) }
// No block list, no adopting. This was `(try? ...) ?? []`, so a
// request that failed produced an EMPTY set of blocked people and
// every stranger passed the check below -- including the ones the user
// had blocked, who were then added back to the contact list by the app
// itself, silently, because a fetch timed out.
//
// Skipping costs nothing: this whole pass is idempotent and runs again
// on the next message, next tab switch and next launch. Adopting
// somebody who was blocked costs them noticing and undoing it, if they
// notice.
guard let blockedUsers = try? await api.fetchBlocked() else { return false }
let blocked = Set(blockedUsers.map(\.id))
var added = false
var names: [Int: String] = [:]
for id in strangers {
guard !blocked.contains(id) else { continue }
guard let info = (try? await api.userInfo(id: id)) ?? nil,
let username = info.username, !username.isEmpty
else { continue }
// `auto: true`, this add is the app's doing, not the user's.
if (try? await api.addBuddy(
username: username,
fromPagePath: siteConfig.messengerPagePath, auto: true)) != nil
{
added = true
}
}
guard added else { return false }
if let fresh = try? await api.fetchBuddies(
fromPagePath: siteConfig.messengerPagePath)
{
knownBuddies = fresh
knownBuddyIDs = fresh.map(\.id)
let name = await nameFor(stranger: id)
guard name.handle != nil else { continue }
names[id] = name.display
namedStrangers.insert(id)
}
guard !names.isEmpty else { return false }
inbox.updateBuddyNames(names)
return true
}
@@ -648,9 +722,10 @@ final class AppEnvironment: ObservableObject {
// which. Repairing in a loop against it is how an app comes up looking
// signed in and silently unable to do anything.
guard refusal != .deviceLimitReached else {
lastError =
"This account already has as many devices as it allows. Remove one "
+ "in Settings, Devices, then sign in here again."
lastError = String(
localized:
"This account already has as many devices as it allows. Remove one in Settings, Devices, then sign in here again."
)
lastErrorCode = "device_limit"
return
}
@@ -832,8 +907,10 @@ final class AppEnvironment: ObservableObject {
case .relayOnly, .sharedAccountRemoved:
return nil
case .sharedAccountFailed(let why):
return
"Your messenger account is deleted. Your Flamenet account is still there. \(why) Sign in at the account service to remove it."
return String(
localized:
"Your messenger account is deleted. Your Flamenet account is still there. \(why) Sign in at the account service to remove it."
)
}
}
@@ -980,7 +1057,7 @@ final class AppEnvironment: ObservableObject {
.appendingPathComponent("flamenet/v1")
var req = URLRequest(url: probe)
req.timeoutInterval = 8
guard let (data, resp) = try? await URLSession.shared.data(for: req),
guard let (data, resp) = try? await BridgedSession.preSignIn.session.data(for: req),
let http = resp as? HTTPURLResponse,
(200...499).contains(http.statusCode), http.statusCode != 404
else { continue }
+9 -9
View File
@@ -129,8 +129,8 @@ final class AppLock: ObservableObject {
let seconds = max(1, Int(until.timeIntervalSinceNow.rounded(.up)))
failureMessage =
seconds >= 60
? "Too many attempts. Try again in \(Int((Double(seconds) / 60).rounded(.up))) minutes."
: "Too many attempts. Try again in \(seconds) seconds."
? String(localized: "Too many attempts. Try again in \(Int((Double(seconds) / 60).rounded(.up))) minutes.")
: String(localized: "Too many attempts. Try again in \(seconds) seconds.")
return
}
authenticating = true
@@ -138,11 +138,11 @@ final class AppLock: ObservableObject {
defer { authenticating = false }
let context = LAContext()
context.localizedCancelTitle = "Cancel"
context.localizedCancelTitle = String(localized: "Cancel")
do {
let ok = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Unlock Flamenet Messenger")
localizedReason: String(localized: "Unlock Flamenet Messenger"))
if ok {
isLocked = false
leftAt = nil
@@ -158,7 +158,7 @@ final class AppLock: ObservableObject {
case .userFallback:
failureMessage = nil
case .biometryLockout:
failureMessage = "Too many attempts. Use your device passcode."
failureMessage = String(localized: "Too many attempts. Use your device passcode.")
case .passcodeNotSet:
// The switch should not have been available, but a passcode can
// be removed after the fact -- and refusing to open the app
@@ -172,13 +172,13 @@ final class AppLock: ObservableObject {
recordFailure()
failureMessage =
secondsToWait > 0
? "Too many attempts. Try again in \(secondsToWait) seconds."
: "That didn't match. Try again."
? String(localized: "Too many attempts. Try again in \(secondsToWait) seconds.")
: String(localized: "That didn't match. Try again.")
default:
failureMessage = "Couldn't unlock. Try again."
failureMessage = String(localized: "Couldn't unlock. Try again.")
}
} catch {
failureMessage = "Couldn't unlock. Try again."
failureMessage = String(localized: "Couldn't unlock. Try again.")
}
}
+8
View File
@@ -65,6 +65,8 @@ struct FlamenetMessengerApp: App {
@State private var linkNotice: LinkNotice?
init() {
// The Files package builds its own sessions; they follow the bridges.
Obfs4Proxy.shared.routeFilesClient()
// A UI test asks for a clean install without reinstalling the app.
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-FNMSGResetState") {
@@ -168,6 +170,12 @@ struct FlamenetMessengerApp: App {
.preferredColorScheme(
(AppearanceMode(rawValue: appearanceRaw) ?? .system).colorScheme
)
// Section 8.14: a contact link opened from outside the app.
.onOpenURL { url in environment.open(url) }
// The same link as a universal link (associated domains).
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let webURL = activity.webpageURL { _ = environment.open(webURL) }
}
.onReceive(NotificationCenter.default.publisher(for: .fnmsgOpenChat)) { note in
if let id = note.userInfo?["buddyID"] as? Int {
environment.deepLinkBuddyID = id
+4 -5
View File
@@ -117,8 +117,8 @@ final class NotificationManager {
content.title = "Flamenet"
content.body =
preview && name != nil
? "Still no reply from \(name!)"
: "Still no reply"
? String(localized: "Still no reply from \(name!)")
: String(localized: "Still no reply")
content.sound = .default
content.userInfo = ["buddyID": buddyID]
@@ -148,10 +148,9 @@ final class NotificationManager {
// Not while the app is in front.
guard UIApplication.shared.applicationState != .active else { return }
let kind = video ? "video call" : "call"
let content = UNMutableNotificationContent()
content.title = preview ? fromName : "Flamenet"
content.body = "Incoming \(kind)"
content.body = video ? String(localized: "Incoming video call") : String(localized: "Incoming call")
content.sound = .defaultRingtone
content.userInfo = ["buddyID": buddyID, "call": true]
@@ -220,7 +219,7 @@ final class NotificationManager {
let content = UNMutableNotificationContent()
// The name is part of the preview.
content.title = preview ? fromName : "Flamenet"
content.body = preview ? body : "New message"
content.body = preview ? body : String(localized: "New message")
content.sound = quiet ? nil : .default
content.userInfo = ["buddyID": buddyID]
+2 -2
View File
@@ -48,9 +48,9 @@ enum FlamenetAccountKDF {
var errorDescription: String? {
switch self {
case .unsupportedKDF(let id):
return "This app doesn't know how to derive keys with method \(id). Update the app."
return String(localized: "This app doesn't know how to derive keys with method \(String(id)). Update the app.")
case .derivationFailed(let status):
return "Key derivation failed (\(status))."
return String(localized: "Key derivation failed (\(String(Int(status)))).")
}
}
}
+11 -10
View File
@@ -5,7 +5,8 @@ import Foundation
struct FlamenetAccountsClient {
let baseURL: URL
var session: URLSession = .shared
/// Through the bridges when any is set, like everything before sign-in.
var session: URLSession = BridgedSession.preSignIn.session
/// The product name this app authenticates as.
static let product = "messenger"
@@ -59,17 +60,17 @@ struct FlamenetAccountsClient {
var errorDescription: String? {
switch self {
case .badCredentials: return "That handle or passphrase isn't right."
case .badCredentials: return String(localized: "That handle or passphrase isn't right.")
case .sessionExpired:
return "Your session expired. Sign out and back in to keep going."
return String(localized: "Your session expired. Sign out and back in to keep going.")
case .rateLimited(let reason):
return reason ?? "Too many attempts. Wait a little and try again."
return reason ?? String(localized: "Too many attempts. Wait a little and try again.")
// "Username", because that is the word on the field somebody just
// typed into. The account service calls it a handle; making the
// reader translate is the app's job, not theirs.
case .handleTaken: return "That username is already taken, try another."
case .service(let c): return "The account service returned an error (\(c))."
case .transport(let m): return "Couldn't reach the account service: \(m)"
case .handleTaken: return String(localized: "That username is already taken, try another.")
case .service(let c): return String(localized: "The account service returned an error (\(c)).")
case .transport(let m): return String(localized: "Couldn't reach the account service: \(m)")
}
}
}
@@ -109,7 +110,7 @@ struct FlamenetAccountsClient {
let status = salt.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, $0.count, $0.baseAddress!)
}
guard status == errSecSuccess else { throw Error.transport("no secure randomness") }
guard status == errSecSuccess else { throw Error.transport(String(localized: "no secure randomness")) }
let params = FlamenetAccountKDF.Params.pbkdf2V1
let secret = try FlamenetAccountKDF.authSecret(
@@ -202,7 +203,7 @@ struct FlamenetAccountsClient {
decoder.dataDecodingStrategy = .base64
decoder.dateDecodingStrategy = .iso8601
do { return try decoder.decode(T.self, from: data) } catch {
throw Error.transport("unreadable response")
throw Error.transport(String(localized: "unreadable response"))
}
}
@@ -219,7 +220,7 @@ struct FlamenetAccountsClient {
}
guard let http = response as? HTTPURLResponse else {
throw Error.transport("not an HTTP response")
throw Error.transport(String(localized: "not an HTTP response"))
}
switch http.statusCode {
case 200..<300: return data
+2 -6
View File
@@ -11,16 +11,12 @@ enum PassphraseRules {
guard !pass.isEmpty, !user.isEmpty else { return nil }
if pass == user {
return "your passphrase can't be your username, your address is "
+ "derived from it, so anyone who knows your username could work "
+ "out your account"
return String(localized: "your passphrase can't be your username, your address is derived from it, so anyone who knows your username could work out your account")
}
// Wrapping the username in a few characters leaves a space somebody can
// simply run through.
if pass.contains(user), pass.count - user.count < 8 {
return "your passphrase can't be your username with a little added, "
+ "your address is derived from it, so that is only a few guesses "
+ "away from your username"
return String(localized: "your passphrase can't be your username with a little added, your address is derived from it, so that is only a few guesses away from your username")
}
return nil
}
+8
View File
@@ -0,0 +1,8 @@
import Foundation
extension RelayDiscovery {
/// Fetch a relay's descriptor, through the bridges when one is saved.
static func describe(_ baseURL: URL) async -> RelayDescriptor? {
await describe(baseURL, session: BridgedSession.preSignIn.session)
}
}
+5 -1
View File
@@ -56,7 +56,11 @@ enum RelayDiscovery {
}
/// Fetch a relay's descriptor.
static func describe(_ baseURL: URL, session: URLSession = .shared) async -> RelayDescriptor? {
///
/// The app calls the one-argument form in `RelayDiscovery+Bridge.swift`,
/// which passes the bridged session. This file is also compiled into
/// flamenet-e2e's interop driver, which has no bridge code.
static func describe(_ baseURL: URL, session: URLSession) async -> RelayDescriptor? {
let url = baseURL.appendingPathComponent(".well-known/flamenet/relay")
var request = URLRequest(url: url)
request.timeoutInterval = 10
+2 -2
View File
@@ -26,7 +26,7 @@ enum SignupProofOfWork {
// MARK: - Asking
/// Ask what work this server wants, if any.
static func fetch(from restURL: URL, session: URLSession = .shared) async -> Challenge? {
static func fetch(from restURL: URL, session: URLSession = BridgedSession.preSignIn.session) async -> Challenge? {
var request = URLRequest(
url: restURL.appendingPathComponent("flamenet/v1/messenger/challenge"))
request.httpMethod = "GET"
@@ -86,7 +86,7 @@ enum SignupProofOfWork {
// MARK: - Both at once
/// Fetch and solve, for the caller that just wants fields to send.
static func satisfy(for restURL: URL, session: URLSession = .shared) async -> Solution? {
static func satisfy(for restURL: URL, session: URLSession = BridgedSession.preSignIn.session) async -> Solution? {
guard let issued = await fetch(from: restURL, session: session) else { return nil }
guard issued.bits > 0 else { return nil }
// Off the cooperative pool's main thread and off the main actor: this
+44 -28
View File
@@ -121,7 +121,11 @@ final class CallManager: NSObject, ObservableObject {
// not just where the button is drawn, a deep link or a retry path must
// hit the same wall the UI does.
if ParentalControls.callsDisabled {
state = .ended("Calls are turned off on this device")
state = .ended(String(localized: "Calls are turned off on this device"))
return
}
if let refusal = Self.bridgeRefusal() {
state = .ended(refusal)
return
}
wasConnected = false
@@ -182,9 +186,9 @@ final class CallManager: NSObject, ObservableObject {
// Name the trust gate when it is the trust gate.
if case E2EError.identityChanged = error {
identityReviewPeer = buddyID
finish(reason: "Their security code changed", notifyPeer: false)
finish(reason: String(localized: "Their security code changed"), notifyPeer: false)
} else {
finish(reason: "Couldn't start the call", notifyPeer: false)
finish(reason: String(localized: "Couldn't start the call"), notifyPeer: false)
}
}
}
@@ -243,7 +247,7 @@ final class CallManager: NSObject, ObservableObject {
case .offer:
// Supervision: a device with calls off does not ring, the caller is
// answered busy, same as any other unavailable state.
if ParentalControls.callsDisabled {
if ParentalControls.callsDisabled || Self.bridgeRefusal() != nil {
ignored("calls disabled by supervision", signal.callID)
noteEnded(signal.callID)
Task {
@@ -336,7 +340,7 @@ final class CallManager: NSObject, ObservableObject {
clearDeclineWanted()
clearAnswerWanted()
tellOwnDevices("declined-elsewhere")
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
finish(reason: String(localized: "Declined"), notifyPeer: true, endReason: "declined")
return
}
@@ -359,7 +363,7 @@ final class CallManager: NSObject, ObservableObject {
RTCSessionDescription(type: .answer, sdp: sdp))
// The delegate flips to .active when ICE actually connects.
} catch {
finish(reason: "Couldn't connect", notifyPeer: true)
finish(reason: String(localized: "Couldn't connect"), notifyPeer: true)
}
}
@@ -434,14 +438,14 @@ final class CallManager: NSObject, ObservableObject {
}
let label: String
switch signal.reason {
case "declined": label = "Declined"
case "busy": label = "Busy"
case "timeout": label = "No answer"
case "declined": label = String(localized: "Declined")
case "busy": label = String(localized: "Busy")
case "timeout": label = String(localized: "No answer")
// They cannot answer until they have looked at the security code
// that changed. Saying so is the difference between somebody
// re-dialling for ten minutes and somebody sending a message.
case "keys": label = "They need to check your security code first"
default: label = "Call ended"
case "keys": label = String(localized: "They need to check your security code first")
default: label = String(localized: "Call ended")
}
finish(reason: label, notifyPeer: false)
}
@@ -515,7 +519,7 @@ final class CallManager: NSObject, ObservableObject {
// The other device still has the call, so the peer is told
// nothing at all: an end here would hang up a call that is
// still running somewhere else.
finish(reason: "Couldn't take the call", notifyPeer: false)
finish(reason: String(localized: "Couldn't take the call"), notifyPeer: false)
}
}
}
@@ -554,7 +558,7 @@ final class CallManager: NSObject, ObservableObject {
"renegotiation failed: %{public}@",
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
type: .error, String(describing: error))
finish(reason: "Call ended", notifyPeer: true)
finish(reason: String(localized: "Call ended"), notifyPeer: true)
}
}
}
@@ -590,7 +594,7 @@ final class CallManager: NSObject, ObservableObject {
return ignored("a handoff taken for another call", signal.callID)
}
clearHandoffOffer()
finish(reason: "Moved to another device", notifyPeer: false)
finish(reason: String(localized: "Moved to another device"), notifyPeer: false)
return
}
// An offer that is gone before anybody took it.
@@ -606,11 +610,11 @@ final class CallManager: NSObject, ObservableObject {
}
if signal.reason == "declined-elsewhere" {
finish(
reason: "Declined on another device", notifyPeer: false,
reason: String(localized: "Declined on another device"), notifyPeer: false,
endReason: "declined-elsewhere")
} else {
finish(
reason: "Answered on another device", notifyPeer: false,
reason: String(localized: "Answered on another device"), notifyPeer: false,
endReason: "answered-elsewhere")
}
}
@@ -697,6 +701,10 @@ final class CallManager: NSObject, ObservableObject {
guard case .ringing = state, let pending = pendingOffer,
let sdp = pending.signal.sdp
else { return }
if let refusal = Self.bridgeRefusal() {
state = .ended(refusal)
return
}
// First, and synchronously: the other devices are ringing NOW, and
// every moment between picking up here and their going quiet is a
// moment somebody is listening to a phone that has already been
@@ -754,10 +762,10 @@ final class CallManager: NSObject, ObservableObject {
// which is a thing they already know; all this says is
// that this end has not accepted them yet.
finish(
reason: "Their security code changed",
reason: String(localized: "Their security code changed"),
notifyPeer: true, endReason: "keys")
} else {
finish(reason: "Couldn't connect", notifyPeer: true)
finish(reason: String(localized: "Couldn't connect"), notifyPeer: true)
}
}
}
@@ -804,11 +812,11 @@ final class CallManager: NSObject, ObservableObject {
func declineIncomingCall() {
guard case .ringing = state else { return }
tellOwnDevices("declined-elsewhere")
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
finish(reason: String(localized: "Declined"), notifyPeer: true, endReason: "declined")
}
func hangUp() {
finish(reason: "Call ended", notifyPeer: true)
finish(reason: String(localized: "Call ended"), notifyPeer: true)
}
/// End the call because the system asked, from the lock screen or from
@@ -839,9 +847,9 @@ final class CallManager: NSObject, ObservableObject {
}
case .ringing:
tellOwnDevices("declined-elsewhere")
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
finish(reason: String(localized: "Declined"), notifyPeer: true, endReason: "declined")
case .dialing, .connecting, .active:
finish(reason: "Call ended", notifyPeer: true)
finish(reason: String(localized: "Call ended"), notifyPeer: true)
}
}
@@ -891,7 +899,15 @@ final class CallManager: NSObject, ObservableObject {
// MARK: - Peer connection plumbing
/// Why a call cannot connect now, or nil. A call's media goes to the
/// other person or a TURN server, never through a bridge (§24.6).
nonisolated static func bridgeRefusal() -> String? {
AroundTheBridge.active ? AroundTheBridge.callsOff : nil
}
private func makePeerConnection(video: Bool = false) async throws -> RTCPeerConnection {
// The one place every call path builds its connection.
if let refusal = Self.bridgeRefusal() { throw AroundTheBridge.Refused(message: refusal) }
let config = RTCConfiguration()
config.sdpSemantics = .unifiedPlan
// Non-trickle (§6.8): gather once, send the complete SDP.
@@ -1059,7 +1075,7 @@ final class CallManager: NSObject, ObservableObject {
try? await Task.sleep(nanoseconds: UInt64(45 * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
if case .dialing = self.state {
self.finish(reason: "No answer", notifyPeer: true, endReason: "timeout")
self.finish(reason: String(localized: "No answer"), notifyPeer: true, endReason: "timeout")
}
}
}
@@ -1087,7 +1103,7 @@ final class CallManager: NSObject, ObservableObject {
// call was answered on another one, since nothing tells it, and
// a timeout end sent from here reached the caller while it was
// mid-call with that other device and hung the call up.
self.finish(reason: "Missed call", notifyPeer: false)
self.finish(reason: String(localized: "Missed call"), notifyPeer: false)
}
}
}
@@ -1121,7 +1137,7 @@ final class CallManager: NSObject, ObservableObject {
// `notifyPeer` stays true: this end believes the call is over,
// and if the far end is still on it, it should hear so. A
// duplicate end costs nothing, a missing one costs the call.
self.finish(reason: "Call ended", notifyPeer: true)
self.finish(reason: String(localized: "Call ended"), notifyPeer: true)
}
}
}
@@ -1137,7 +1153,7 @@ final class CallManager: NSObject, ObservableObject {
try? await Task.sleep(nanoseconds: UInt64(30 * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
if case .connecting = self.state {
self.finish(reason: "Couldn't connect", notifyPeer: true, endReason: "timeout")
self.finish(reason: String(localized: "Couldn't connect"), notifyPeer: true, endReason: "timeout")
}
}
}
@@ -1260,7 +1276,7 @@ final class CallManager: NSObject, ObservableObject {
func teardown() {
if case .idle = state {
} else {
finish(reason: "Call ended", notifyPeer: true)
finish(reason: String(localized: "Call ended"), notifyPeer: true)
}
inbox.onCallSignal = nil
state = .idle
@@ -1341,7 +1357,7 @@ extension CallManager: RTCPeerConnectionDelegate {
}
case .failed, .closed:
self.cancelMediaGone()
self.finish(reason: "Call ended", notifyPeer: true)
self.finish(reason: String(localized: "Call ended"), notifyPeer: true)
case .disconnected:
// The far end hanging up looks exactly like this, and so does a
// lift doorway. WebRTC calls `.disconnected` transient and only
+7 -7
View File
@@ -294,7 +294,7 @@ final class VoIPCallReceiver: NSObject {
let spare = UUID()
let spent = CXCallUpdate()
spent.hasVideo = false
spent.localizedCallerName = "Flamenet call"
spent.localizedCallerName = String(localized: "Flamenet call")
spent.remoteHandle = CXHandle(type: .generic, value: "Flamenet")
provider.reportNewIncomingCall(with: spare, update: spent) { [weak self] _ in
Task { @MainActor in
@@ -309,7 +309,7 @@ final class VoIPCallReceiver: NSObject {
currentCallID = id
let update = CXCallUpdate()
update.hasVideo = false
update.localizedCallerName = "Flamenet call"
update.localizedCallerName = String(localized: "Flamenet call")
update.remoteHandle = CXHandle(type: .generic, value: "Flamenet")
provider.reportNewIncomingCall(with: id, update: update) { [weak self] error in
if let error {
@@ -339,16 +339,16 @@ final class VoIPCallReceiver: NSObject {
switch CXErrorCodeIncomingCallError.Code(rawValue: code) {
case .filteredByDoNotDisturb:
return
"A Focus mode silenced an incoming call. The call can still be answered from inside the app."
String(localized: "A Focus mode silenced an incoming call. The call can still be answered from inside the app.")
case .filteredByBlockList:
return "An incoming call was blocked by this phone's block list."
return String(localized: "An incoming call was blocked by this phone's block list.")
case .callUUIDAlreadyExists:
return
"An incoming call could not be shown because a call with the same id was already up."
String(localized: "An incoming call could not be shown because a call with the same id was already up.")
case .unentitled:
return "This build is not entitled to show calls on the lock screen."
return String(localized: "This build is not entitled to show calls on the lock screen.")
default:
return "CallKit refused an incoming call (code \(code))."
return String(localized: "CallKit refused an incoming call (code \(String(code))).")
}
}
}
+17 -17
View File
@@ -40,9 +40,9 @@ enum BuddyCommand: String, CaseIterable {
switch self {
case .help, .back, .here, .invisible, .buddies, .star, .archive:
return nil
case .me: return "does something"
case .away, .status: return "message"
case .add, .remove, .block, .unblock, .whois: return "username"
case .me: return String(localized: "does something")
case .away, .status: return String(localized: "message", comment: "Noun: the status message a command takes")
case .add, .remove, .block, .unblock, .whois: return String(localized: "username")
}
}
@@ -65,20 +65,20 @@ enum BuddyCommand: String, CaseIterable {
/// Only these search as you type.
var placeholder: String {
switch self {
case .help: return "list the commands"
case .me: return "an action, in the third person"
case .away: return "mark yourself away"
case .back, .here: return "come back online"
case .invisible: return "go invisible"
case .status: return "set or clear your status message"
case .add: return "add to your buddy list"
case .remove: return "take off your buddy list"
case .block: return "block (defaults to this conversation)"
case .unblock: return "unblock"
case .whois: return "who somebody is"
case .buddies: return "how many buddies you have"
case .star: return "star this conversation"
case .archive: return "archive this conversation"
case .help: return String(localized: "list the commands")
case .me: return String(localized: "an action, in the third person")
case .away: return String(localized: "mark yourself away")
case .back, .here: return String(localized: "come back online")
case .invisible: return String(localized: "go invisible")
case .status: return String(localized: "set or clear your status message")
case .add: return String(localized: "add to your buddy list")
case .remove: return String(localized: "take off your buddy list")
case .block: return String(localized: "block (defaults to this conversation)")
case .unblock: return String(localized: "unblock")
case .whois: return String(localized: "who somebody is")
case .buddies: return String(localized: "how many buddies you have")
case .star: return String(localized: "star this conversation")
case .archive: return String(localized: "archive this conversation")
}
}
+12 -12
View File
@@ -30,11 +30,11 @@ final class AppDiagnostics: NSObject, MXMetricManagerSubscriber {
var title: String {
switch self {
case .crash: return "Crash"
case .hang: return "Frozen screen"
case .diskWrite: return "Heavy disk use"
case .cpuException: return "Heavy CPU use"
case .launch: return "Slow launch"
case .crash: return String(localized: "Crash")
case .hang: return String(localized: "Frozen screen")
case .diskWrite: return String(localized: "Heavy disk use")
case .cpuException: return String(localized: "Heavy CPU use")
case .launch: return String(localized: "Slow launch")
}
}
}
@@ -111,20 +111,20 @@ final class AppDiagnostics: NSObject, MXMetricManagerSubscriber {
found.append(
report(
kind: .hang, began: began, ended: ended, from: hang,
summary: "The screen was unresponsive for "
+ Self.seconds(hang.hangDuration)))
summary: String(
localized: "The screen was unresponsive for \(Self.seconds(hang.hangDuration))")))
}
for cpu in payload.cpuExceptionDiagnostics ?? [] {
found.append(
report(
kind: .cpuException, began: began, ended: ended, from: cpu,
summary: "The app used enough processor time for iOS to complain"))
summary: String(localized: "The app used enough processor time for iOS to complain")))
}
for disk in payload.diskWriteExceptionDiagnostics ?? [] {
found.append(
report(
kind: .diskWrite, began: began, ended: ended, from: disk,
summary: "The app wrote enough to disk for iOS to complain"))
summary: String(localized: "The app wrote enough to disk for iOS to complain")))
}
}
guard !found.isEmpty else { return }
@@ -153,14 +153,14 @@ final class AppDiagnostics: NSObject, MXMetricManagerSubscriber {
return reason
}
if let signal = crash.signal {
return "The app was stopped by the system (signal \(signal))"
return String(localized: "The app was stopped by the system (signal \(String(signal.intValue)))")
}
return "The app stopped unexpectedly"
return String(localized: "The app stopped unexpectedly")
}
private static func seconds(_ measurement: Measurement<UnitDuration>) -> String {
let s = measurement.converted(to: .seconds).value
return String(format: "%.1f seconds", s)
return String(localized: "\(String(format: "%.1f", s)) seconds")
}
// MARK: - Storage
+146
View File
@@ -0,0 +1,146 @@
import CryptoKit
import Foundation
/// Section 8.13, stage D2: reads about OTHER people that name nobody.
///
/// Every read here is a POST with its target in the body, never in the URL,
/// so the target is in no proxy's access log; and it goes out on its own
/// HTTP client: no `Authorization` header, no cookies, no cache, its own
/// connection pool, a generic User-Agent. A connection that carried an
/// authenticated request never carries one of these.
///
/// What the relay still sees, and the app says so where it matters: the IP
/// address, which is usually the one this device polls from seconds apart.
/// What changes is what is stored.
final class AnonymousRelayClient {
private let base: URL
/// Follows the bridge setting (`BridgedSession`).
private let sessions: BridgedSession
var session: URLSession { sessions.session }
/// - Parameter base: the relay's `…/flamenet/v1` root.
init(base: URL, protocolClasses: [AnyClass]? = nil) {
self.base = base
let cfg = URLSessionConfiguration.ephemeral
cfg.httpCookieStorage = nil
cfg.httpShouldSetCookies = false
cfg.httpCookieAcceptPolicy = .never
cfg.urlCache = nil
cfg.urlCredentialStorage = nil
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
cfg.timeoutIntervalForRequest = 25
cfg.timeoutIntervalForResource = 120
cfg.httpAdditionalHeaders = ["User-Agent": "FlameNet"]
if let protocolClasses { cfg.protocolClasses = protocolClasses }
self.sessions = BridgedSession { cfg.copy() as! URLSessionConfiguration }
}
/// The most tokens one presence request may carry (decision DD3).
static let presenceBatch = 8
/// Presence for up to eight tokens, keyed by token.
func presence(tokens: [String]) async throws -> [String: String] {
precondition(tokens.count <= Self.presenceBatch)
struct Row: Decodable {
let token: String
let presence: String
}
struct Answer: Decodable { let presence: [Row] }
let answer: Answer = try await post("/e2e/anon/presence", ["tokens": tokens])
return Dictionary(
answer.presence.map { ($0.token, $0.presence) }, uniquingKeysWith: { a, _ in a })
}
/// Identity keys for an account, no times. `cap` is a presence token the
/// account issued this one, which the relay meters by instead of the IP.
func devices(of userID: Int, cap: String?) async throws -> E2EDevicesResponse {
var body: [String: Any] = ["user_id": userID]
if let cap { body["cap"] = cap }
return try await post("/e2e/anon/devices", body)
}
/// Prekey bundles for an account, popping one prekey per device.
func bundles(of userID: Int, cap: String?) async throws -> E2EKeysResponse {
var body: [String: Any] = ["user_id": userID]
if let cap { body["cap"] = cap }
return try await post("/e2e/anon/bundles", body)
}
func introChallenge() async throws -> E2EIntroChallenge {
var req = URLRequest(url: base.appendingPathComponent("/e2e/intro/challenge"))
req.httpMethod = "GET"
req.setValue("application/json", forHTTPHeaderField: "Accept")
return try await perform(req)
}
/// An introduction by handle (after the proof of work) or by a reusable
/// link (section 14.5). Nil when the relay answers "no such account",
/// which is also its answer for an unlisted handle and a revoked link.
func intro(handle: String, challenge: String, nonce: String) async throws -> E2EIntroAnswer? {
try await intro(["handle": handle, "challenge": challenge, "nonce": nonce])
}
func intro(link: Data) async throws -> E2EIntroAnswer? {
try await intro(["link": Base64URL.encode(link)])
}
private func intro(_ body: [String: Any]) async throws -> E2EIntroAnswer? {
var req = URLRequest(url: base.appendingPathComponent("/e2e/intro"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw MessengerError.transport("Non-HTTP response")
}
if http.statusCode == 404 { return nil }
if http.statusCode == 429 {
throw MessengerError.rateLimited(
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
}
guard (200..<300).contains(http.statusCode),
let o = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let uid = JSONValue(any: o["user_id"])?.intValue,
let introB64 = o["intro"] as? String, let intro = Data(base64Encoded: introB64),
intro.count == 32,
let exp = JSONValue(any: o["exp"])?.intValue,
let rawDevices = o["devices"]
else { throw MessengerError.httpStatus(http.statusCode) }
let devices = try JSONDecoder().decode(
[E2EKeysResponse.Bundle].self,
from: JSONSerialization.data(withJSONObject: rawDevices))
return E2EIntroAnswer(
userID: Int(uid), fnid: o["fnid"] as? String, intro: intro, exp: Int(exp),
handleCert: (o["handle_cert"] as? [String: Any]).flatMap(HandleCertificate.init(json:)),
devices: devices)
}
private func post<T: Decodable>(_ path: String, _ body: [String: Any]) async throws -> T {
var req = URLRequest(url: base.appendingPathComponent(path))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await perform(req)
}
private func perform<T: Decodable>(_ req: URLRequest) async throws -> T {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw MessengerError.transport("Non-HTTP response")
}
if http.statusCode == 429 {
throw MessengerError.rateLimited(
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
}
guard (200..<300).contains(http.statusCode) else {
throw MessengerError.httpStatus(http.statusCode)
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw MessengerError.decoding(error.localizedDescription)
}
}
}
+16 -5
View File
@@ -54,11 +54,22 @@ public enum DeviceClock {
public static func warning(now: Date = Date()) -> String? {
guard let skew = usableSkew(now: now), abs(skew) > suspectThreshold else { return nil }
let minutes = Int((abs(skew) / 60).rounded())
let direction = skew > 0 ? "behind" : "ahead"
return "This phone's clock is about \(minutes) minutes \(direction). "
+ "Stories and disappearing messages may vanish early or linger, "
+ "because both are timed. Setting the date and time automatically "
+ "in Settings fixes it."
if skew > 0 {
return String(
localized: """
This phone's clock is about \(minutes) minutes behind. \
Stories and disappearing messages may vanish early or linger, \
because both are timed. Setting the date and time automatically \
in Settings fixes it.
""")
}
return String(
localized: """
This phone's clock is about \(minutes) minutes ahead. \
Stories and disappearing messages may vanish early or linger, \
because both are timed. Setting the date and time automatically \
in Settings fixes it.
""")
}
/// The time to judge an expiry against.
+7 -5
View File
@@ -42,9 +42,11 @@ struct DeviceWipe: Codable, Equatable {
}
/// What a client may honestly say about it.
static let honestDescription =
"The instruction is stored for that device and it erases what it holds the "
+ "next time it connects. If it never connects, switched off, or offline, "
+ "nothing happens to it. This stops things getting worse; it is not proof "
+ "that the messages are gone."
static let honestDescription = String(
localized: """
The instruction is stored for that device and it erases what it holds the \
next time it connects. If it never connects, switched off, or offline, \
nothing happens to it. This stops things getting worse; it is not proof \
that the messages are gone.
""")
}
+25 -20
View File
@@ -330,35 +330,40 @@ enum E2EError: LocalizedError {
/// A short, contentless reason for the diagnostics reading and the bubble.
var shortReason: String {
switch self {
case .identityChanged: return "their security keys changed"
case .protocolDowngrade: return "the contact's app answered with weaker encryption"
case .peerLacksPQ: return "the contact's app cannot do the encryption you require"
case .sealingRequired: return "this could not be sent sealed, and you require sealing"
case .prekeyMissing: return "the contact has no key available to receive it"
case .noSession: return "no secure session with the contact yet"
case .ratchetState: return "the secure session needs repair"
case .crypto: return "encryption failed"
case .identityChanged: return String(localized: "their security keys changed")
case .protocolDowngrade: return String(localized: "the contact's app answered with weaker encryption")
case .peerLacksPQ: return String(localized: "the contact's app cannot do the encryption you require")
case .sealingRequired: return String(localized: "this could not be sent sealed, and you require sealing")
case .prekeyMissing: return String(localized: "the contact has no key available to receive it")
case .noSession: return String(localized: "no secure session with the contact yet")
case .ratchetState: return String(localized: "the secure session needs repair")
case .crypto: return String(localized: "encryption failed")
}
}
var errorDescription: String? {
switch self {
case .ratchetState(let m): return "Ratchet state: \(m)"
case .crypto(let m): return "Crypto: \(m)"
case .prekeyMissing: return "No prekey available for the recipient device."
case .noSession: return "No E2E session established yet."
case .ratchetState(let m): return String(localized: "Ratchet state: \(m)")
case .crypto(let m): return String(localized: "Crypto: \(m)")
case .prekeyMissing: return String(localized: "No prekey available for the recipient device.")
case .noSession: return String(localized: "No E2E session established yet.")
case .identityChanged:
return
"This contact's security keys changed. Verify their safety number before sending."
return String(
localized:
"This contact's security keys changed. Verify their safety number before sending.")
case .protocolDowngrade:
return
"This contact's device stopped offering post-quantum encryption. This can mean the connection is being tampered with."
return String(
localized:
"This contact's device stopped offering post-quantum encryption. This can mean the connection is being tampered with.")
case .peerLacksPQ:
return "This contact's device does not support post-quantum encryption."
return String(localized: "This contact's device does not support post-quantum encryption.")
case .sealingRequired:
return "Not sent. This message could not be sealed, so the server would "
+ "have seen that you sent it, and you asked Flamenet to refuse "
+ "rather than send anyway. It will seal again once they message you."
return String(
localized: """
Not sent. This message could not be sealed, so the server would \
have seen that you sent it, and you asked Flamenet to refuse \
rather than send anyway. It will seal again once they message you.
""")
}
}
}
+160 -26
View File
@@ -35,6 +35,13 @@ struct E2EAttachmentRef: Hashable, Codable {
/// File only: the label it was filed under, travelling with it.
var classification: Int16 = 0
var compartments: [Int] = []
/// Section 23.9, how many bytes of the blob to open; the rest is a random
/// tail that pads the blob to a ladder step. Nil for a blob with no tail,
/// which is every blob sent before section 23. A `clen` that arrived as
/// something other than an integer is held as -1, so it fails the range
/// check in `open` and the attachment is refused before decrypting.
var clen: Int? = nil
}
/// One envelope belonging to a poll (§6.29): the question, one voter's share,
@@ -94,7 +101,7 @@ enum E2EAttachment {
/// Set iff this envelope belongs to a poll (§6.29).
var poll: PollPart? = nil
/// Set iff a contact offered a bridge line (§6.19).
/// Set iff a contact offered a bridge line that §24.5 keeps.
var bridgeLine: String? = nil
/// Set iff this is the sender's profile picture.
var isAvatar: Bool = false
@@ -252,7 +259,7 @@ enum E2EAttachment {
let cls: Int16?
let cmp: [Int]?
/// §6.19, a bridge line a contact is offering.
/// §24.3, a bridge line a contact is offering.
let line: String?
/// Mentioned user ids.
@@ -412,6 +419,39 @@ enum E2EAttachment {
let w: Int
let h: Int
let dur: Double? // audio only
/// Section 23.9, present only on a padded blob.
var clen: Length? = nil
}
/// `clen` as it arrived. Anything but an integer decodes as `.invalid`
/// rather than failing the whole envelope: the message still shows,
/// and only the attachment is refused.
enum Length: Codable, Equatable {
case bytes(Int)
case invalid
init(from decoder: any Decoder) throws {
let c = try decoder.singleValueContainer()
if let n = try? c.decode(Int.self) {
self = .bytes(n)
} else {
self = .invalid
}
}
func encode(to encoder: any Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .bytes(let n): try c.encode(n)
case .invalid: try c.encodeNil()
}
}
/// What `E2EAttachmentRef.clen` holds.
var value: Int {
switch self {
case .bytes(let n): return n
case .invalid: return -1
}
}
}
}
@@ -419,22 +459,68 @@ enum E2EAttachment {
/// Seal media bytes for upload: fresh random key per attachment,
/// AES-256-GCM, blob = `nonce || ct || tag` (CryptoKit's `combined`).
static func seal(_ data: Data) throws -> (blob: Data, key: Data, digest: Data) {
let key = SymmetricKey(size: .bits256)
guard let blob = try AES.GCM.seal(data, using: key).combined else {
///
/// With `pad`, a random tail takes the blob to the next step of the
/// attachment ladder, `clen` says where the sealed bytes end, and the
/// digest covers the whole blob, tail included (section 23.9). `pad` is the
/// relay's `features.padAttachments` and nothing else: a receiver that
/// predates `clen` opens the whole blob and fails. Over 10 MiB is refused
/// either way, as the relay would. `key`, `nonce` and `tailSource` exist
/// for the known-answer vector only.
static func seal(
_ data: Data, pad: Bool = false,
key: SymmetricKey? = nil, nonce: AES.GCM.Nonce? = nil,
tailSource: (Int) -> Data = Padding.randomTail
) throws -> (blob: Data, key: Data, digest: Data, clen: Int?) {
let key = key ?? SymmetricKey(size: .bits256)
guard let sealed = try AES.GCM.seal(data, using: key, nonce: nonce ?? AES.GCM.Nonce()).combined
else {
throw E2EError.crypto("attachment seal produced no combined box")
}
let clen = sealed.count
var blob = sealed
if pad {
let step = try Padding.attachmentStep(clen: clen)
let tail = tailSource(step - clen)
guard tail.count == step - clen else {
throw Padding.Failure.wrongLength(want: step - clen, got: tail.count)
}
blob.append(tail)
guard blob.count == step else {
throw Padding.Failure.wrongLength(want: step, got: blob.count)
}
} else if clen > Padding.attachmentLadder[Padding.attachmentLadder.count - 1] {
throw Padding.Failure.attachmentTooLarge(clen)
}
let digest = Data(SHA256.hash(data: blob))
return (blob, key.withUnsafeBytes { Data($0) }, digest)
return (blob, key.withUnsafeBytes { Data($0) }, digest, pad ? clen : nil)
}
/// Open a downloaded blob.
static func open(blob: Data, ref: E2EAttachmentRef) throws -> Data {
guard Data(SHA256.hash(data: blob)) == ref.digest else {
throw E2EError.crypto("attachment digest mismatch")
try open(blob: blob, key: ref.key, digest: ref.digest, clen: ref.clen)
}
/// Section 23.9: the digest over the whole blob first, then only the first
/// `clen` bytes opened. No `clen` opens the whole blob, the format before
/// section 23. A `clen` under the AES-GCM overhead or past the end of the
/// blob is refused before anything is decrypted, never clamped.
static func open(blob: Data, key: Data, digest: Data, clen: Int?) throws -> Data {
guard Data(SHA256.hash(data: blob)) == digest else {
throw E2EError.crypto(String(localized: "attachment digest mismatch"))
}
let box = try AES.GCM.SealedBox(combined: blob)
return try AES.GCM.open(box, using: SymmetricKey(data: ref.key))
var end = blob.count
if let clen {
guard clen >= Padding.attachmentOverhead, clen <= blob.count else {
throw E2EError.crypto("attachment length is not valid")
}
end = clen
}
guard end >= Padding.attachmentOverhead else {
throw E2EError.crypto("attachment length is not valid")
}
let box = try AES.GCM.SealedBox(combined: blob.prefix(end))
return try AES.GCM.open(box, using: SymmetricKey(data: key))
}
// MARK: - Content envelope codec (plaintext layer)
@@ -460,7 +546,8 @@ enum E2EAttachment {
id: ref.id, key: ref.key.e2eBase64, digest: ref.digest.e2eBase64,
bytes: ref.bytes, mime: ref.mime, w: ref.width, h: ref.height,
// Audio always; a story carries one when it is a video.
dur: ref.duration > 0 ? ref.duration : nil)
dur: ref.duration > 0 ? ref.duration : nil,
clen: ref.clen.map { .bytes($0) })
// A file envelope ALWAYS carries a caption, even when the sender wrote
// none.
let effectiveCaption: String? = {
@@ -489,7 +576,7 @@ enum E2EAttachment {
let att = Envelope.Att(
id: ref.id, key: ref.key.e2eBase64, digest: ref.digest.e2eBase64,
bytes: ref.bytes, mime: ref.mime, w: ref.width, h: ref.height,
dur: nil)
dur: nil, clen: ref.clen.map { .bytes($0) })
return try assemble(
kind: "avatar", caption: nil, exp: nil, gid: nil,
att: att, mid: nil, re: nil)
@@ -793,13 +880,56 @@ enum E2EAttachment {
return men.filter { seen.insert($0).inserted }.prefix(maximumMentions).map { $0 }
}
/// §24.4: the longest line an offer may carry, in UTF-8 bytes. Here and
/// not in `Obfs4Settings` because this file is also compiled, alone, into
/// flamenet-e2e's interop driver, which has no networking code.
static let maximumBridgeLineBytes = 1024
/// §24.4 and §24.5: a line worth keeping as an offer.
static func isOfferable(_ line: String) -> Bool {
!line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& line.utf8.count <= maximumBridgeLineBytes
}
/// Kinds that never become a bubble, whatever else an envelope carries.
private static let controlKinds: Set<String> = [
"react", "edit", "pin", "shot", "ttl", "otr", "bridge", "avatar", "delivery_key",
"kt_head", "poll", "pshare", "psum",
]
/// §6.6: a plaintext that starts with the marker and parses as JSON with an
/// integer `fnc` is an envelope, even when a key this build knows has the
/// wrong type and the full decode fails. It used to fall back to text,
/// which put the raw JSON in a bubble. Control traffic is consumed
/// silently; anything else shows its caption if it has one, or the
/// placeholder an unknown kind gets.
private static func undecodable(_ plaintext: String, data: Data) -> Content {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let fnc = object["fnc"] as? NSNumber,
CFGetTypeID(fnc) != CFBooleanGetTypeID(),
fnc.doubleValue == 1
else { return Content(text: plaintext, attachment: nil, gid: nil) }
let kind = object["kind"] as? String ?? ""
let ctl = object["ctl"].map { ($0 as? NSNumber)?.boolValue ?? false } ?? false
if ctl || controlKinds.contains(kind) || CallSignal.Action(rawValue: kind) != nil
|| MultiDeviceEnvelope.kinds.contains(kind)
{
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
}
return Content(
text: object["caption"] as? String ?? "[unsupported attachment]",
attachment: nil, gid: object["gid"] as? String)
}
/// Interpret ratchet plaintext.
static func parse(plaintext: String) -> Content {
guard plaintext.hasPrefix(marker),
let data = plaintext.data(using: .utf8),
let env = try? JSONDecoder().decode(Envelope.self, from: data),
env.fnc == 1
else { return Content(text: plaintext, attachment: nil, gid: nil) }
guard plaintext.hasPrefix(marker), let data = plaintext.data(using: .utf8) else {
return Content(text: plaintext, attachment: nil, gid: nil)
}
guard let env = try? JSONDecoder().decode(Envelope.self, from: data) else {
return undecodable(plaintext, data: data)
}
guard env.fnc == 1 else { return Content(text: plaintext, attachment: nil, gid: nil) }
// A reaction is not a message and must never become a bubble.
if env.kind == "react", let re = env.re {
@@ -842,9 +972,10 @@ enum E2EAttachment {
let key = Data(e2eBase64: att.key), key.count == 32,
let digest = Data(e2eBase64: att.digest), digest.count == 32
else { return Content(text: "", attachment: nil, gid: nil, isAvatar: true) }
let ref = E2EAttachmentRef(
var ref = E2EAttachmentRef(
kind: .image, id: att.id, key: key, digest: digest, bytes: att.bytes,
mime: att.mime, width: att.w, height: att.h, duration: 0, expires: 0)
ref.clen = att.clen?.value
return Content(text: "", attachment: ref, gid: nil, isAvatar: true)
}
@@ -931,12 +1062,14 @@ enum E2EAttachment {
offTheRecordIsEcho: env.echo?.on == true)
}
// §6.19, a contact offering a bridge.
if env.kind == "bridge", let line = env.line,
!line.trimmingCharacters(
in: .whitespacesAndNewlines
).isEmpty
{
// §24.5, a contact offering a bridge. The kind alone makes it one, so
// an offer §24.5 drops is consumed silently, never shown: no `gid`
// (offers go to one person), a line that is not blank, and no more
// than 1024 bytes of it.
if env.kind == "bridge" {
guard env.gid == nil, let line = env.line, isOfferable(line) else {
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
}
return Content(text: "", attachment: nil, gid: nil, bridgeLine: line)
}
@@ -993,7 +1126,7 @@ enum E2EAttachment {
let digest = Data(e2eBase64: att.digest), digest.count == 32
else {
return Content(
text: env.caption ?? "[unsupported attachment]",
text: env.caption ?? String(localized: "[unsupported attachment]"),
attachment: nil, gid: env.gid, mid: env.mid, re: env.re)
}
@@ -1005,7 +1138,8 @@ enum E2EAttachment {
// A filename from a PEER.
filename: env.fn ?? "",
classification: env.cls ?? 0,
compartments: env.cmp ?? []
compartments: env.cmp ?? [],
clen: att.clen?.value
)
return Content(
text: env.caption ?? "", attachment: ref, gid: env.gid,
+65
View File
@@ -171,6 +171,31 @@ enum E2EBackupPayload {
root["polls"] = out
}
// Section 16.2.1's account-state keys (stage 2-B, and section 17's
// account delivery key and issued batches). Each written only when
// there is something to say.
if let acc = snapshot.acc, !acc.isEmpty { root["acc"] = acc.sorted() }
if let blocked = snapshot.blocked, !blocked.isEmpty { root["blocked"] = blocked.sorted() }
if let flags = snapshot.convFlags {
var out: [String: [String: Bool]] = [:]
for (key, f) in flags {
var one: [String: Bool] = [:]
if f.starred { one["starred"] = true }
if f.archived { one["archived"] = true }
if !one.isEmpty { out[key] = one }
}
if !out.isEmpty { root["convFlags"] = out }
}
if let dk = snapshot.dk, Data(base64Encoded: dk)?.count == 32 { root["dk"] = dk }
if let issued = snapshot.deliveryTokensIssued {
let out = Dictionary(
uniqueKeysWithValues: issued.filter { !$0.value.isEmpty }.map { ("\($0.key)", $0.value) })
if !out.isEmpty { root["deliveryTokensIssued"] = out }
}
if let read = snapshot.read, !read.isEmpty {
root["read"] = read.mapValues { NSNumber(value: $0) }
}
// Sorted keys so the same history seals to the same bytes twice, which
// is what makes a vector pinnable at all.
return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])
@@ -625,6 +650,46 @@ enum E2EBackupPayload {
if !out.isEmpty { snapshot.polls = out }
}
// Section 16.2.1, the account-state keys. An entry that does not parse
// is dropped, never half adopted.
let ids: (Any?) -> [Int]? = { raw in
guard let a = raw as? [Any] else { return nil }
let out = a.compactMap { JSONValue(any: $0)?.intValue }.filter { $0 > 0 }.map(Int.init)
return out.isEmpty ? nil : out.sorted()
}
snapshot.acc = ids(root["acc"])
snapshot.blocked = ids(root["blocked"])
if let flags = root["convFlags"] as? [String: Any] {
var out: [String: E2EInbox.ConversationFlags] = [:]
for (key, v) in flags {
guard let o = v as? [String: Any] else { continue }
let f = E2EInbox.ConversationFlags(
starred: (o["starred"] as? Bool) == true,
archived: (o["archived"] as? Bool) == true)
if f.starred || f.archived { out[key] = f }
}
if !out.isEmpty { snapshot.convFlags = out }
}
if let dk = root["dk"] as? String, Data(base64Encoded: dk)?.count == 32 {
snapshot.dk = dk
}
if let issued = root["deliveryTokensIssued"] as? [String: Any] {
var out: [Int: [String]] = [:]
for (k, v) in issued {
guard let id = Int(k), id > 0, let tokens = v as? [String] else { continue }
let good = tokens.filter { Data(base64Encoded: $0)?.count == 32 }
if !good.isEmpty { out[id] = good }
}
if !out.isEmpty { snapshot.deliveryTokensIssued = out }
}
if let read = root["read"] as? [String: Any] {
var out: [String: Int64] = [:]
for (k, v) in read {
if let ms = JSONValue(any: v)?.intValue, ms >= 0 { out[k] = ms }
}
if !out.isEmpty { snapshot.read = out }
}
return snapshot
}
+66 -7
View File
@@ -24,7 +24,9 @@ final class E2EClient {
private(set) var protocolClasses: [AnyClass]?
private let restPrefix: String
private let auth: (username: String, appPassword: String)
private let session: URLSession
/// Follows the bridge setting (`BridgedSession`).
private let sessions: BridgedSession
private var session: URLSession { sessions.session }
/// - Parameter protocolClasses: a test seam, and nothing else.
init(
@@ -52,8 +54,8 @@ final class E2EClient {
cfg.httpAdditionalHeaders = [
"User-Agent": "FlamenetMessenger-E2E/\(Self.appVersion) (iOS)"
]
Obfs4Proxy.shared.apply(to: cfg)
self.session = URLSession(configuration: cfg)
// A copy per build: `BridgedSession` sets the proxy on what it is given.
self.sessions = BridgedSession { cfg.copy() as! URLSessionConfiguration }
}
/// Adopt token auth for subsequent requests.
@@ -162,6 +164,45 @@ final class E2EClient {
path: "/e2e/log/consistency")
}
/// Section 8.13: the separate client for reads that name nobody. Its own
/// session, so nothing here ever shares a connection with it.
private(set) lazy var anonymous = AnonymousRelayClient(
base: endpoint(""), protocolClasses: protocolClasses)
/// Section 8.13: this account's own devices, with their registration and
/// last-poll times, from the route that answers about the caller only.
func ownDevicesWithTimes() async throws -> E2EDevicesResponse {
try await getJSON(endpoint("/e2e/devices/me"), path: "/e2e/devices/me")
}
/// Section 14.5, stage C2: this account's own handle certificate.
func handleCertificate() async throws -> HandleCertificate {
var req = URLRequest(url: endpoint("/e2e/handle-cert"))
req.httpMethod = "GET"
req.setValue("application/json", forHTTPHeaderField: "Accept")
await authorize(&req, path: "/e2e/handle-cert")
let raw: RawJSON = try await performAuthed(req, path: "/e2e/handle-cert")
guard let cert = HandleCertificate(json: raw.object) else {
throw MessengerError.decoding("handle certificate")
}
return cert
}
/// Section 8.14, stage B2: add and revoke reusable contact links, by their
/// SHA-256 only. Answers how many the account now holds.
@discardableResult
func setContactLinks(add: [String], revoke: [String]) async throws -> Int {
struct Body: Encodable {
let add: [String]
let revoke: [String]
}
struct Answer: Decodable { let links: Int }
let answer: Answer = try await postJSON(
endpoint("/e2e/contact-links"), body: Body(add: add, revoke: revoke),
path: "/e2e/contact-links")
return answer.links
}
func listDevices(of userID: Int) async throws -> E2EDevicesResponse {
let url = endpoint("/e2e/devices/\(userID)")
return try await getJSON(url, path: "/e2e/devices/\(userID)")
@@ -328,7 +369,7 @@ final class E2EClient {
DeviceClock.observe(header: http.value(forHTTPHeaderField: "Date"))
if let maxBytes, data.count > maxBytes {
throw MessengerError.transport(
"Attachment is larger than it claimed to be, refusing it.")
String(localized: "Attachment is larger than it claimed to be, refusing it."))
}
guard (200..<300).contains(http.statusCode) else {
if http.statusCode == 429 {
@@ -468,6 +509,18 @@ final class E2EClient {
private struct EmptyAck: Decodable {}
/// Any JSON object, for answers read field by field.
private struct RawJSON: Decodable {
let object: [String: Any]
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer().decode(JSONValue.self)
guard case .object = value, let o = value.any as? [String: Any] else {
throw MessengerError.decoding("expected an object")
}
object = o
}
}
/// The relay's error shape.
private struct WireErrorBody: Decodable {
let code: String?
@@ -539,7 +592,13 @@ final class E2EClient {
// MARK: - §8.9c, the contact list as tokens
private struct PublishRosterTagsBody: Encodable { let digests: [String] }
private struct PublishRosterTagsBody: Encodable {
let digests: [String]
/// Section 17.6: on a relay that keys roster tags by device, the set
/// this device publishes replaces its own and nobody else's. Omitted
/// otherwise, which is the account-wide set older relays keep.
let device_id: String?
}
private struct OKBody: Decodable { let ok: Bool? }
private struct PresenceBody: Encodable { let tokens: [String] }
private struct PresenceAnswer: Decodable {
@@ -560,10 +619,10 @@ final class E2EClient {
/// refusal is swallowed and presence simply never leaves the roster path.
/// That is how this shipped and how it was found: the relay log had the
/// refusals, the app had no idea.
func publishRosterTags(digests: [String]) async throws {
func publishRosterTags(digests: [String], deviceID: String? = nil) async throws {
let _: OKBody = try await postJSONBasic(
endpoint("messenger/roster/tags"),
body: PublishRosterTagsBody(digests: digests))
body: PublishRosterTagsBody(digests: digests, device_id: deviceID))
}
/// A POST to the mobile surface, which authenticates with Basic only.
+12
View File
@@ -22,6 +22,10 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
case deliveryKey
/// A contact gossiping their view of the key log (§19.4).
case keyLogHead
/// Sections 17.2 to 17.6: link offers, sync ops and snapshots, sent
/// copies, history, and rekey requests. One branch, one handler, which
/// checks who may send each.
case multiDevice
/// The other side says they screenshotted something timed (§6.27).
case screenshotNotice
/// Off the record, on or off, or the echo answering it (§6.28).
@@ -30,6 +34,8 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
case disappearTimer
/// A poll, a piece of somebody's vote, or a published sum (§6.29).
case poll
/// A contact's bridge offer that §24.5 keeps, stored for review.
case bridgeOffer
/// Somebody's answer to an event (§6.32). Control traffic, folded onto
/// the event's card and never notified.
case rsvp
@@ -89,10 +95,12 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
.remote,
.deliveryKey,
.keyLogHead,
.multiDevice,
.screenshotNotice,
.offTheRecord,
.disappearTimer,
.poll,
.bridgeOffer,
.rsvp,
.unpost,
.unknownControl,
@@ -159,6 +167,8 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
// The handler needs both fields, so an envelope missing either is
// not this branch's and must fall through rather than vanish.
return e.controlKind == "kt_head" && Self.hasKeyLogHead(e.text)
case .multiDevice:
return e.controlKind.map(MultiDeviceEnvelope.kinds.contains) ?? false
case .screenshotNotice:
return content.screenshotAt != nil
case .offTheRecord:
@@ -167,6 +177,8 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
return content.disappearSeconds != nil
case .poll:
return content.poll != nil
case .bridgeOffer:
return content.bridgeLine != nil
case .rsvp:
return content.rsvp != nil
case .unpost:
+6 -4
View File
@@ -33,12 +33,14 @@ enum E2EHistoryExport {
var errorDescription: String? {
switch self {
case .notAnExport:
return "That file is not a Flamenet history export."
return String(localized: "That file is not a Flamenet history export.")
case .wrongPlatform(let p):
return
"This backup was made on \(p == "android" ? "Android" : p), import it on a device of the same kind."
let platform = p == "android" ? "Android" : p
return String(
localized:
"This backup was made on \(platform), import it on a device of the same kind.")
case .wrongPassphrase:
return "That passphrase doesn't open this backup."
return String(localized: "That passphrase doesn't open this backup.")
}
}
}
+36 -11
View File
@@ -106,6 +106,24 @@ struct E2EHistoryStore {
/// buys nothing: a phone that is closed between the arrival and the
/// acceptance is exactly the case this exists for.
var heldEnvelopes: HeldEnvelopeBook? = nil
/// Section 17.6: this account's state about other people as sync
/// registers (acceptance, blocks, starred and archived conversations,
/// read marks, and from stage A3 everything a linked device mirrors).
/// The source of truth for the four that left the relay at stage 2-B.
var syncRegisters: SyncState? = nil
/// Section 16.2.1's `acc`, `blocked`, `convFlags`, `dk`,
/// `deliveryTokensIssued` and `read`, filled only when a snapshot is
/// built for an export or read from one. The sealed store keeps the
/// first four as `syncRegisters` and the last two in the keychain, so
/// these stay nil on disk.
var acc: [Int]? = nil
var blocked: [Int]? = nil
var convFlags: [String: E2EInbox.ConversationFlags]? = nil
var dk: String? = nil
var deliveryTokensIssued: [Int: [String]]? = nil
var read: [String: Int64]? = nil
}
/// One contact, as the sealed store keeps it.
@@ -116,6 +134,10 @@ struct E2EHistoryStore {
}
private let userID: Int
/// Which file this is, `"\(userID)"` for every real account. A test that
/// stands up two devices of one account in one process gives each its own
/// slot, so the two do not share a file. See `KeychainE2EStore.slot`.
private let fileTag: String
private let keyProvider: () -> SymmetricKey?
/// Why the last save did not happen, or nil if it did (ios#46).
@@ -148,8 +170,9 @@ struct E2EHistoryStore {
nonmutating set { status.onChange = newValue }
}
init(userID: Int, keyProvider: @escaping () -> SymmetricKey?) {
init(userID: Int, slot: String? = nil, keyProvider: @escaping () -> SymmetricKey?) {
self.userID = userID
self.fileTag = slot.map { "\(userID)-\($0)" } ?? "\(userID)"
self.keyProvider = keyProvider
}
@@ -206,7 +229,7 @@ struct E2EHistoryStore {
at: dir, withIntermediateDirectories: true,
attributes: [.protectionKey: Self.protection])
}
let url = dir.appendingPathComponent("history-\(userID).bin")
let url = dir.appendingPathComponent("history-\(fileTag).bin")
Self.relaxProtectionIfNeeded(dir: dir, file: url)
return url
}
@@ -220,15 +243,15 @@ struct E2EHistoryStore {
/// save path has no way to tell "this account has no history yet" from
/// "its history did not open a moment ago", and those want opposite
/// behaviour.
private static var unreadable = Set<Int>()
private static var unreadable = Set<String>()
var historyIsUnreadable: Bool { Self.unreadable.contains(userID) }
var historyIsUnreadable: Bool { Self.unreadable.contains(fileTag) }
func load() -> Snapshot? {
guard let url = fileURL else {
// No path at all, so nothing can be said about what is on disk.
// Refusing to write is the only safe reading of that.
Self.unreadable.insert(userID)
Self.unreadable.insert(fileTag)
return nil
}
@@ -247,7 +270,7 @@ struct E2EHistoryStore {
guard exists else {
// Genuinely nothing here. An empty history is the truth, and
// writing it is safe.
Self.unreadable.remove(userID)
Self.unreadable.remove(fileTag)
return nil
}
@@ -264,10 +287,10 @@ struct E2EHistoryStore {
// phone is locked -- and this cannot tell those apart, which is
// exactly why it must assume the file is worth more than what is
// in memory. Writing is refused until a load succeeds.
Self.unreadable.insert(userID)
Self.unreadable.insert(fileTag)
return nil
}
Self.unreadable.remove(userID)
Self.unreadable.remove(fileTag)
return snapshot
}
@@ -292,7 +315,7 @@ struct E2EHistoryStore {
if FileManager.default.fileExists(atPath: url.path) {
guard (try? FileManager.default.removeItem(at: url)) != nil else { return false }
}
Self.unreadable.remove(userID)
Self.unreadable.remove(fileTag)
record(nil)
return true
}
@@ -321,7 +344,8 @@ struct E2EHistoryStore {
addrThreads: [String: [E2EMessage]] = [:],
seenStoryIDs: Set<String> = [],
unreadPeers: [Int: Int] = [:],
heldEnvelopes: HeldEnvelopeBook = HeldEnvelopeBook()
heldEnvelopes: HeldEnvelopeBook = HeldEnvelopeBook(),
syncRegisters: SyncState = SyncState()
) {
guard let url = fileURL, let key = keyProvider() else { return }
// The history on disk did not open. Writing now would seal whatever is
@@ -351,7 +375,8 @@ struct E2EHistoryStore {
addrThreads: addrThreads.isEmpty ? nil : addrThreads,
seenStoryIDs: seenStoryIDs.isEmpty ? nil : seenStoryIDs.sorted(),
unreadPeers: unreadPeers.isEmpty ? nil : unreadPeers,
heldEnvelopes: heldEnvelopes.isEmpty ? nil : heldEnvelopes)
heldEnvelopes: heldEnvelopes.isEmpty ? nil : heldEnvelopes,
syncRegisters: syncRegisters.registers.isEmpty ? nil : syncRegisters)
guard let plain = try? JSONEncoder().encode(snapshot),
let box = try? AES.GCM.seal(plain, using: key),
let combined = box.combined
+238
View File
@@ -0,0 +1,238 @@
import Foundation
import os
/// Section 8.9d (pairs stage 2-B) and 17.6: what this account has decided about
/// other people, held on the device rather than on the relay.
///
/// Who was accepted, who is blocked, which conversations are starred or
/// archived, and how far each has been read are sync registers
/// (`syncState`). On a device with no linked devices they are simply local
/// state; with linked ones every write is also an op the others merge
/// (stage A3). The relay used to keep each of these as a pair of account ids,
/// and a dump of those tables was a partial contact graph.
extension E2EInbox {
// MARK: - Reading
/// Contacts whose first message was accepted, or who this account wrote
/// to or added (writing to somebody accepts them, section 14.5).
var acceptedIDs: Set<Int> { Set(syncState.members(of: "acc").compactMap(Int.init)) }
/// Contacts this account has blocked.
var blockedIDs: Set<Int> { Set(syncState.members(of: "blocked").compactMap(Int.init)) }
func isAccepted(_ id: Int) -> Bool { syncState.contains("acc/\(id)") }
func isBlocked(_ id: Int) -> Bool { syncState.contains("blocked/\(id)") }
struct ConversationFlags: Equatable, Codable {
var starred = false
var archived = false
/// As `conv/…` and `convFlags` carry it: each flag only when true.
var json: JSONValue? {
var o: [String: JSONValue] = [:]
if starred { o["starred"] = .bool(true) }
if archived { o["archived"] = .bool(true) }
return o.isEmpty ? nil : .object(o)
}
init(starred: Bool = false, archived: Bool = false) {
self.starred = starred
self.archived = archived
}
init(_ value: JSONValue?) {
let o = value?.objectValue ?? [:]
starred = o["starred"]?.boolValue == true
archived = o["archived"]?.boolValue == true
}
}
func flags(forThread key: String) -> ConversationFlags {
guard syncState.contains("conv/\(key)") else { return ConversationFlags() }
return ConversationFlags(syncState["conv/\(key)"]?.val)
}
/// Epoch milliseconds of the newest message read in a thread, if marked.
func readMark(forThread key: String) -> Int64? { syncState["read/\(key)"]?.val?.intValue }
/// Section 14.5, decision DC2: people who wrote first and have not been
/// accepted. Their conversation is in a Requests folder, not the
/// conversation list, and nothing in it notifies more loudly than a
/// quiet badge. Newest first. Blocked people are not here: their
/// messages are dropped on arrival.
var requestIDs: [Int] {
threads
.filter { id, msgs in
!msgs.isEmpty && id != myUserID && !isAccepted(id) && !isBlocked(id)
}
.sorted { ($0.value.last?.date ?? .distantPast) > ($1.value.last?.date ?? .distantPast) }
.map(\.key)
}
// MARK: - Writing
/// Write one register of this account's state and, from stage A3, tell
/// the linked devices.
///
/// `at` is this device's clock, moved on past anything the register
/// already holds so a write made here always takes: a linked device whose
/// clock is a minute fast would otherwise win every register it touched
/// for that minute, and the person's own tap would do nothing. Section
/// 17.6 forbids writing one key twice in a millisecond, which this also
/// covers.
@discardableResult
func writeSync(_ op: String, key: String, val: JSONValue? = nil, now: Date = Date())
-> SyncOp?
{
let nowMs = SyncMerge.nowMs(now)
let at = max(nowMs, (syncState[key]?.at ?? -1) + 1)
let o = SyncOp(op: op, key: key, val: SyncMerge.isRemoval(op) ? nil : val, at: at,
dev: store.deviceID)
guard o.isValid else { return nil }
var state = syncState
guard state.merge(o, now: max(nowMs, at)) else { return nil }
syncState = state
flushHistory()
syncWritten(o)
return o
}
/// Accept somebody: they leave Requests and become a conversation.
func accept(_ id: Int) {
guard id > 0, id != myUserID, !isAccepted(id) else { return }
writeSync("add", key: "acc/\(id)")
}
/// Take somebody's acceptance back, as removing a contact does: a later
/// message from them is a request again.
func unaccept(_ id: Int) {
guard isAccepted(id) else { return }
writeSync("rm", key: "acc/\(id)")
}
/// The local half of a block (section 8.9d). The relay is told separately
/// and still enforces it on unsealed messages until stage 2-E; tokens are
/// revoked and the delivery key rotated by `AppEnvironment.setBlocked`.
func setBlockedLocally(_ blocked: Bool, userID: Int) {
guard userID > 0, userID != myUserID, isBlocked(userID) != blocked else { return }
writeSync(blocked ? "add" : "rm", key: "blocked/\(userID)")
}
func setStarred(_ on: Bool, thread key: String) {
var f = flags(forThread: key)
guard f.starred != on else { return }
f.starred = on
writeConversationFlags(f, thread: key)
}
func setArchived(_ on: Bool, thread key: String) {
var f = flags(forThread: key)
guard f.archived != on else { return }
f.archived = on
writeConversationFlags(f, thread: key)
}
private func writeConversationFlags(_ f: ConversationFlags, thread key: String) {
if let val = f.json {
writeSync("set", key: "conv/\(key)", val: val)
} else {
writeSync("del", key: "conv/\(key)")
}
}
/// Record that a thread has been read through `ms`.
///
/// Coalesced (decision DA6): at most one op per thread per 30 seconds, and
/// the latest once the window passes, because reading a long thread is
/// otherwise an op per message. The register only moves forward.
func noteRead(thread key: String, through ms: Int64) {
guard ms > (readMark(forThread: key) ?? -1) else { return }
let last = readWrittenAt[key] ?? .distantPast
let wait = Double(SyncMerge.readCoalesceMs) / 1000 - Date().timeIntervalSince(last)
if wait <= 0 {
readWrittenAt[key] = Date()
writeSync("max", key: "read/\(key)", val: .int(ms))
return
}
readPending[key] = max(readPending[key] ?? 0, ms)
guard readFlushTasks[key] == nil else { return }
readFlushTasks[key] = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
guard let self else { return }
self.readFlushTasks[key] = nil
guard let pending = self.readPending.removeValue(forKey: key),
pending > (self.readMark(forThread: key) ?? -1)
else { return }
self.readWrittenAt[key] = Date()
self.writeSync("max", key: "read/\(key)", val: .int(pending))
}
}
// MARK: - Seeding (stage 2-B)
/// Seed acceptance from what this device already knows, once: everybody
/// on the contact list, and everybody this device has written to. Without
/// it every existing conversation would land in Requests the day this
/// build arrives.
func seedAccountStateFromDeviceIfNeeded() {
guard !store.accountStateSeededFromDevice else { return }
var ids = Set(roster.map(\.id))
for (id, msgs) in threads where msgs.contains(where: \.isMine) { ids.insert(id) }
for id in ids.sorted() where id > 0 && id != myUserID && !isAccepted(id) {
writeSync("add", key: "acc/\(id)")
}
store.accountStateSeededFromDevice = true
}
/// Seed from the relay's answer, once (section 8.9d: "a client seeds it
/// once from the relay's answer, then stops asking"): contacts it says
/// were accepted, and everybody it says this account blocked. Only a
/// complete answer seeds; a failed fetch is retried by the caller.
func seedAccountStateFromRelayIfNeeded(acceptedBuddies: [Int], blocked: [Int]) {
guard !store.accountStateSeededFromRelay else { return }
for id in acceptedBuddies.sorted() where id > 0 && id != myUserID && !isAccepted(id) {
writeSync("add", key: "acc/\(id)")
}
for id in blocked.sorted() where id > 0 && id != myUserID && !isBlocked(id) {
writeSync("add", key: "blocked/\(id)")
}
store.accountStateSeededFromRelay = true
}
// MARK: - Backup keys (section 16.2.1)
/// `acc`, `blocked`, `convFlags` and `read` as a backup carries them.
func accountStateForBackup() -> (
acc: [Int], blocked: [Int], convFlags: [String: ConversationFlags], read: [String: Int64]
) {
var flags: [String: ConversationFlags] = [:]
for key in syncState.members(of: "conv") {
let f = self.flags(forThread: key)
if f.starred || f.archived { flags[key] = f }
}
var read: [String: Int64] = [:]
for key in syncState.members(of: "read") {
if let ms = readMark(forThread: key) { read[key] = ms }
}
return (acceptedIDs.sorted(), blockedIDs.sorted(), flags, read)
}
/// Merge a backup's account state into this device's: a union for `acc`,
/// `blocked` and the flags, a maximum for read marks. A restore never
/// unblocks anybody and never clears a flag (the same fall-narrow rule as
/// the story audience).
func mergeAccountStateFromBackup(
acc: [Int], blocked: [Int], convFlags: [String: ConversationFlags], read: [String: Int64]
) {
for id in acc where id > 0 && id != myUserID { accept(id) }
for id in blocked where id > 0 && id != myUserID { setBlockedLocally(true, userID: id) }
for (key, f) in convFlags {
if f.starred { setStarred(true, thread: key) }
if f.archived { setArchived(true, thread: key) }
}
for (key, ms) in read where ms > (readMark(forThread: key) ?? -1) {
writeSync("max", key: "read/\(key)", val: .int(ms))
}
}
}
+339
View File
@@ -0,0 +1,339 @@
import CryptoKit
import Foundation
import os
/// Sections 14.5, 8.11 and 8.12, stages C2, C3, D2 and B3: first contact that
/// names nobody, reads that name nobody, and contact links.
///
/// Every part of this waits for the relay to say it can: the descriptor's
/// `introductions`, `anonymousReads` and `contactLinks` switches. A relay that
/// does not advertise them gets exactly the 0.31 behaviour, which is what
/// every relay deployed today is.
extension E2EInbox {
private static let firstContactLog = OSLog(
subsystem: "io.flamenet.messenger", category: "first-contact")
/// How the first message to somebody is authorised when this account holds
/// none of their capabilities.
enum FirstContact {
/// A one-time contact link's delivery token (section 8.14).
case token(Data)
/// An introduction from `POST /e2e/intro` (section 14.5).
case intro(E2EIntroAnswer)
}
// MARK: - D2, reads that name nobody
/// Somebody's devices: anonymously, with the presence token they issued
/// this account as the meter, when the relay offers it; this account's own
/// from the route that answers about the caller only.
func directory(of userID: Int, features: E2ERelayDescriptor.Features)
async throws -> [E2EDevicesResponse.Device]
{
guard features.anonymousReads == true else {
return try await client.listDevices(of: userID).devices
}
if userID == myUserID { return try await client.ownDevicesWithTimes().devices }
return try await client.anonymous.devices(
of: userID, cap: presenceTokensHeld[userID]
).devices
}
func bundles(of userID: Int, features: E2ERelayDescriptor.Features)
async throws -> E2EKeysResponse
{
guard features.anonymousReads == true, userID != myUserID else {
return try await client.fetchKeys(for: userID)
}
return try await client.anonymous.bundles(of: userID, cap: presenceTokensHeld[userID])
}
/// Presence by token in random batches of at most eight, each after a
/// random wait of up to `maxDelay` seconds (decision DD3), so one request
/// is not this account's whole contact list.
func anonymousPresence(
_ tokens: [String], maxDelay: Double = 20
) async -> [String: String] {
var out: [String: String] = [:]
var shuffled = tokens.shuffled()
while !shuffled.isEmpty {
let batch = Array(shuffled.prefix(AnonymousRelayClient.presenceBatch))
shuffled.removeFirst(batch.count)
if maxDelay > 0 {
try? await Task.sleep(nanoseconds: UInt64(Double.random(in: 0...maxDelay) * 1e9))
}
if let answer = try? await client.anonymous.presence(tokens: batch) {
out.merge(answer) { a, _ in a }
}
}
return out
}
// MARK: - C2, handle certificates
/// The name a verified certificate gave for `id`.
func certifiedHandle(for id: Int) -> String? { store.certifiedHandle(for: id)?.h }
/// This account's own certificate, for the prelude of a first message.
/// Fetched when a relay offers them, kept while it has an hour to run.
func ownCertificateForPrelude() async -> HandleCertificate? {
guard await relayFeatures().introductions == true else { return nil }
let soon = Int(Date().timeIntervalSince1970) + 3600
if let held = ownHandleCert, held.exp > soon { return held }
do {
ownHandleCert = try await client.handleCertificate()
} catch {
os_log(
"own handle certificate not fetched: %{public}@", log: Self.firstContactLog,
type: .error, String(describing: error))
}
return ownHandleCert
}
/// Verify a certificate against the relay's pinned key, for this relay,
/// for the account it must be about, before it expires. No pinned key, or
/// a relay whose key changed and was not accepted, verifies nothing.
func verifies(_ cert: HandleCertificate, about userID: Int) async -> Bool {
_ = await relayFeatures()
guard !store.relayIdentityChanged, let audience = relayAudience else { return false }
return cert.verifies(issuerKey: store.relayIdentityPin, audience: audience, userID: userID)
}
/// A prelude carried `hc`: keep it if it verifies, about the sender the
/// ratchet proved.
func adoptHandleCertificate(_ json: [String: Any], from userID: Int) async {
guard let cert = HandleCertificate(json: json) else {
_ = Self.dropped("prelude carried a malformed handle certificate", from: "")
return
}
guard await verifies(cert, about: userID) else {
_ = Self.dropped("prelude carried a handle certificate that does not verify", from: "")
return
}
store.setCertifiedHandle(cert, for: userID)
updateBuddyNames([userID: cert.h])
knownHandles[userID] = cert.h
}
// MARK: - C3, introductions
/// The usernames this device knows, for introductions.
func updateKnownHandles(_ handles: [Int: String]) {
knownHandles.merge(handles.filter { !$0.value.isEmpty }) { _, new in new }
}
/// One fresh introduction to `userID`, from the reusable link the
/// conversation started from, or its handle (listed accounts only, with
/// the proof of work). Nil when there is neither, or the relay knows
/// nothing by them; the answer must be about `userID`.
func introduce(_ userID: Int) async -> E2EIntroAnswer? {
guard await relayFeatures().introductions == true else { return nil }
if let link = store.startedFromLink(userID),
let answer = try? await introduce(link: link, expecting: userID)
{
return answer
}
let handle = knownHandles[userID] ?? roster.first { $0.id == userID }?.handle
?? certifiedHandle(for: userID)
guard let handle, !handle.isEmpty else { return nil }
return try? await introduce(handle: handle, expecting: userID)
}
/// An introduction by handle: a challenge, the work, the intro. The
/// certificate must name the account the intro answered for; a mismatch
/// is refused rather than shown.
func introduce(handle: String, expecting userID: Int? = nil) async throws -> E2EIntroAnswer? {
let challenge = try await client.anonymous.introChallenge()
let nonce = await Task.detached(priority: .userInitiated) {
IntroProofOfWork.solve(challenge: challenge.challenge, bits: challenge.bits)
}.value
guard let nonce else { return nil }
guard
let answer = try await client.anonymous.intro(
handle: handle, challenge: challenge.challenge, nonce: nonce)
else { return nil }
return try await accepted(answer, expecting: userID)
}
func introduce(link: Data, expecting userID: Int? = nil) async throws -> E2EIntroAnswer? {
guard let answer = try await client.anonymous.intro(link: link) else { return nil }
return try await accepted(answer, expecting: userID)
}
/// Keep an intro only if it is about the account expected and its
/// certificate, when it carries one, verifies for that account.
private func accepted(_ answer: E2EIntroAnswer, expecting userID: Int?) async throws
-> E2EIntroAnswer
{
if let userID, answer.userID != userID { throw FirstContactRefusal.wrongAccount }
if let cert = answer.handleCert {
guard await verifies(cert, about: answer.userID) else {
throw FirstContactRefusal.certificateDoesNotVerify
}
store.setCertifiedHandle(cert, for: answer.userID)
updateBuddyNames([answer.userID: cert.h])
knownHandles[answer.userID] = cert.h
}
return answer
}
enum FirstContactRefusal: LocalizedError, Equatable {
case wrongAccount
case certificateDoesNotVerify
case notSealable(String)
case linkUnusable
/// A link made on a relay this account is not on.
case otherServer(String)
var errorDescription: String? {
switch self {
case .wrongAccount:
return String(
localized:
"The link doesn't belong to the account the server answered for, so nothing was sent.")
case .certificateDoesNotVerify:
return String(
localized: "The server's answer about this person didn't check out, so nothing was sent.")
case .notSealable(let name):
return String(localized: "Could not send. \(name) needs to message you or send you a new link.")
case .linkUnusable:
return String(localized: "That link has already been used or was turned off. Ask for a new one.")
case .otherServer(let relay):
return String(localized: "That link is for another server (\(relay)).")
}
}
}
// MARK: - Safety numbers (SPEC §7)
/// Before showing a safety number this device could not cover, read the
/// contact's bundles once and pin their post-quantum identities (§20.5),
/// so the digits match the ones their device shows. Only for devices whose
/// `IK_sig` is the one already pinned, and only when this device has a
/// post-quantum identity of its own. True when a pin was added. It spends
/// one prekey per device, once.
@discardableResult
func pinPeerPQIdentityIfNeeded(_ userID: Int) async -> Bool {
guard store.pqIdentitySeed != nil, !safetyNumberIsPostQuantum(for: userID) else {
return false
}
let trusted = Set(store.pinnedIdentities(forUser: userID).map { $0.e2eBase64 })
guard !trusted.isEmpty,
let served = try? await bundles(of: userID, features: await relayFeatures())
else { return false }
var pinned = false
for bundle in served.devices where trusted.contains(bundle.ik_sig) {
guard store.pqPin(forDevice: bundle.device_id) == nil else { continue }
if (try? enforcePQIdentity(bundle: bundle)) != nil,
store.pqPin(forDevice: bundle.device_id) != nil
{
pinned = true
}
}
return pinned
}
// MARK: - B3, contact links
/// The name a refusal uses: as the person called themselves, without the
/// "not verified yet" the conversation title carries.
func nameForRefusal(_ id: Int) -> String {
let shown = displayName(for: id)
let marked = Self.unverifiedName("")
return shown.hasSuffix(marked) ? String(shown.dropLast(marked.count)) : shown
}
/// A name from a link's `h`, which nothing has vouched for yet.
nonisolated static func unverifiedName(_ handle: String) -> String {
String(localized: "\(handle) (not verified yet)")
}
/// A one-time link (section 8.14): a delivery token this account mints
/// for it and uploads among its others, so the relay holds nothing that
/// marks it as a link. Whoever scans it can send one sealed first
/// message. Works on any relay that takes delivery tokens.
func makeOneTimeLink(handle: String?) async throws -> ContactLink {
let token = ContactLink.freshCapability()
try await client.setDeliveryTokens(
add: [Data(SHA256.hash(data: token)).e2eBase64], revoke: [])
store.oneTimeLinkTokens = store.oneTimeLinkTokens + [token.e2eBase64]
return ContactLink(
relay: client.relayHost, userID: myUserID, token: token, handle: handle,
fnid: myAddress.flatMap { $0.split(separator: "@").first.map(String.init) })
}
/// Turn off every one-time link not yet used. One revoke for all of them.
func revokeOneTimeLinks() async throws {
let held = store.oneTimeLinkTokens.compactMap { Data(e2eBase64: $0) }
guard !held.isEmpty else { return }
try await client.setDeliveryTokens(
add: [], revoke: held.map { Data(SHA256.hash(data: $0)).e2eBase64 })
store.oneTimeLinkTokens = []
}
/// A reusable link (section 8.14, stage B2): good until revoked, at most
/// five. The relay stores only its SHA-256.
func makeReusableLink(handle: String?) async throws -> ContactLink {
let capability = ContactLink.freshCapability()
try await client.setContactLinks(add: [ContactLink.digest(capability)], revoke: [])
store.reusableLinks = store.reusableLinks + [capability.base64EncodedString()]
return reusableLink(capability, handle: handle)
}
func reusableLink(_ capability: Data, handle: String?) -> ContactLink {
ContactLink(
relay: client.relayHost, userID: myUserID, link: capability, handle: handle,
fnid: myAddress.flatMap { $0.split(separator: "@").first.map(String.init) })
}
func revokeReusableLink(_ capability: Data) async throws {
try await client.setContactLinks(add: [], revoke: [ContactLink.digest(capability)])
store.reusableLinks = store.reusableLinks.filter {
Data(base64Encoded: $0) != capability
}
}
var heldReusableLinks: [Data] { store.reusableLinks.compactMap { Data(base64Encoded: $0) } }
var heldOneTimeLinkCount: Int { store.oneTimeLinkTokens.count }
/// Open somebody's link and send them a first message: sealed, under the
/// link's token or an introduction by it, landing in their Requests.
/// Returns the account written to.
@discardableResult
func sendFirstMessage(_ text: String, via link: ContactLink) async throws -> Int {
guard link.relay == client.relayHost.lowercased() else {
throw FirstContactRefusal.otherServer(link.relay)
}
let first: FirstContact
if let token = link.token {
first = .token(token)
} else if let capability = link.link {
guard await relayFeatures().introductions == true,
let answer = try await introduce(link: capability, expecting: link.userID)
else { throw FirstContactRefusal.linkUnusable }
store.setStartedFromLink(capability, for: link.userID)
first = .intro(answer)
} else {
throw FirstContactRefusal.linkUnusable
}
if let handle = link.handle, !handle.isEmpty, certifiedHandle(for: link.userID) == nil {
// Shown as unverified until their first reply carries a
// certificate (section 8.14), which `adoptHandleCertificate`
// writes over this. The same words as Android's chat title.
updateBuddyNames([link.userID: Self.unverifiedName(handle)])
}
let wire = try E2EAttachment.encodeText(text, mid: UUID().uuidString)
do {
try await transmit(wire, to: link.userID, firstContact: first)
} catch MessengerError.refused(.capabilityRevoked, _) {
throw FirstContactRefusal.linkUnusable
}
appendToThread(
link.userID,
message: E2EMessage(
id: nextLocalID(), isMine: true, text: text, date: Date(),
mid: E2EAttachment.parse(plaintext: wire).mid, delivery: .delivered))
return link.userID
}
}
+905
View File
@@ -0,0 +1,905 @@
import CryptoKit
import Foundation
import os
/// Sections 17.2 to 17.6: the private channel between one person's linked
/// devices, and how a device joins it.
///
/// A device signed in with the password is a device of the account and
/// nothing more. It joins the linked set only when a person points an already
/// linked device at it (QR, or comparing a safety number on both screens), and
/// only linked devices receive the account delivery key, the state snapshot,
/// history, sent copies and sync ops. The relay brokers none of it.
extension E2EInbox {
private static let log = OSLog(subsystem: "io.flamenet.messenger", category: "link")
/// What a person is told about a change to the linked set (section 17.3:
/// a client MUST say which it was).
struct LinkNotice: Identifiable, Equatable {
let id = UUID()
let deviceID: String
let text: String
}
/// The other devices this one is linked with: device id to IK_sig.
var otherLinkedDevices: [String: String] {
store.linkedDevices.filter { $0.key != store.deviceID }
}
var hasLinkedDevices: Bool { !otherLinkedDevices.isEmpty }
// MARK: - The new device: the code it shows (section 17.3, step 1)
/// A fresh code, replacing any shown before. Good for ten minutes and one
/// link; the secret leaves this device only as light.
@discardableResult
func freshLinkCode(now: Date = Date()) -> DeviceLinkCode {
let code = DeviceLinkCode(
deviceID: store.deviceID, ikSig: identity.ikSigPub,
secret: DeviceLinkCode.freshSecret())
pendingLinkCode = (code, now.addingTimeInterval(DeviceLinkCode.lifetime))
return code
}
// MARK: - The existing device: checking a code (step 2)
enum LinkRefusal: LocalizedError, Equatable {
case notALinkedDevice
case isThisDevice
case notOnThisAccount
case keyDiffers
case retired
case directoryUnreadable
var errorDescription: String? {
switch self {
case .notALinkedDevice:
return String(
localized:
"Only a device that's already linked can link another. This one isn't linked yet: show its code to one that is.")
case .isThisDevice:
return String(localized: "That's this device's own code.")
case .notOnThisAccount:
return String(
localized: "That device isn't signed in to your account. Sign in on it first, then show its code again.")
case .keyDiffers:
return String(
localized: """
The server lists that device with a different key than its code \
shows. That's what a substituted device would look like, so it \
wasn't linked.
""")
case .retired:
return String(localized: "The server's key log says that device was removed, so it can't be linked.")
case .directoryUnreadable:
return String(localized: "Couldn't check that device with the server. Nothing was linked. Try again.")
}
}
}
/// Whether this device may link others: it is linked itself, or it is
/// the account's first device and nobody has linked anything yet.
var mayLinkOthers: Bool {
hasLinkedDevices || store.linkedDevices.isEmpty
}
/// Check a scanned code against the relay before anything is sent: the
/// device is this account's in the relay's own list, under exactly the key
/// the code shows, and the key log holds no `revoke` or `expire` for it.
func verifyLinkCode(_ code: DeviceLinkCode) async throws -> E2EDevicesResponse.Device {
guard code.deviceID != store.deviceID else { throw LinkRefusal.isThisDevice }
guard mayLinkOthers else { throw LinkRefusal.notALinkedDevice }
return try await verifyOwnDevice(code.deviceID, key: code.ikSig)
}
func verifyOwnDevice(_ deviceID: String, key: Data) async throws -> E2EDevicesResponse.Device {
let listed: [E2EDevicesResponse.Device]
do {
listed = try await directory(of: myUserID, features: await relayFeatures())
} catch {
throw LinkRefusal.directoryUnreadable
}
guard let device = listed.first(where: { $0.device_id == deviceID }) else {
throw LinkRefusal.notOnThisAccount
}
guard Data(e2eBase64: device.ik_sig) == key else { throw LinkRefusal.keyDiffers }
await refreshRevocations(for: [deviceID])
guard !store.revokedDeviceIDs.contains(deviceID) else { throw LinkRefusal.retired }
return device
}
/// The safety number of this device and another of this account's
/// devices (section 7, which works unchanged for two devices of one
/// account). The numeric fallback compares it on both screens.
func linkSafetyNumber(withKey key: Data) -> String {
SafetyNumber.compute(identity.ikSigPub, key)
}
// MARK: - The existing device: linking (step 3)
/// Link `device` and hand it the account: a `link_offer` with the QR's
/// secret (nil in the numeric fallback), the linked set and the account
/// delivery key; then a snapshot of every register; then, if the person
/// wants it, history (section 17.4).
///
/// The device is linked HERE first, which is what lets the offer reach it:
/// own-device traffic goes to linked devices only.
func linkDevice(
_ device: E2EDevicesResponse.Device, secret: Data?, withHistory: Bool,
now: Date = Date()
) async throws {
guard mayLinkOthers else { throw LinkRefusal.notALinkedDevice }
guard let key = Data(e2eBase64: device.ik_sig) else { throw LinkRefusal.keyDiffers }
if store.deliveryKey == nil { await ensureDeliveryKey() }
guard let dk = store.deliveryKey else { throw E2EError.crypto("no account delivery key") }
// This device is in its own set, and so is every device it links.
store.link(deviceID: store.deviceID, ikSig: identity.ikSigPub.e2eBase64)
store.link(deviceID: device.device_id, ikSig: key.e2eBase64)
seedMirrorRegistersIfNeeded()
writeSync("add", key: "link/\(store.deviceID)", val: .string(identity.ikSigPub.e2eBase64))
writeSync("add", key: "link/\(device.device_id)", val: .string(key.e2eBase64))
let set = store.linkedDevices.sorted { $0.key < $1.key }.map {
MultiDeviceEnvelope.Member(deviceID: $0.key, ikSig: $0.value)
}
let offer = MultiDeviceEnvelope.linkOffer(
secret: secret, set: set, dk: dk, at: SyncMerge.nowMs(now))
try await transmit(offer.json, to: myUserID, onlyDevices: [device.device_id])
await sendSnapshot(to: device.device_id, want: false)
if withHistory { await sendHistory(to: device.device_id) }
os_log(
"linked device %{public}@ (%{public}@)", log: Self.log, type: .info,
String(device.device_id.prefix(8)), secret == nil ? "numeric" : "code")
}
// MARK: - The new device: an offer arrives (step 4)
private func receiveLinkOffer(
_ offer: MultiDeviceEnvelope, fromDevice: String, senderKey: Data?
) async {
guard case .linkOffer(let secret, let set, _, _) = offer, let senderKey else { return }
// The offer must name its sender, under the key the session proved.
guard set.contains(where: {
$0.deviceID == fromDevice && Data(base64Encoded: $0.ikSig) == senderKey
}) else {
_ = Self.dropped("link offer whose set does not hold its sender", from: fromDevice)
return
}
if let secret {
// A wrong or stale secret is dropped without a word.
guard let pending = pendingLinkCode, Date() < pending.expires,
DeviceLinkCode.sameSecret(secret, pending.code.secret)
else {
_ = Self.dropped("link offer with a wrong or stale secret", from: fromDevice)
return
}
pendingLinkCode = nil
await adoptLinkOffer(offer)
return
}
// The numeric fallback: held until the person confirms the same number
// on this device too. A confirmation on one side links nothing.
pendingNumericOffer = PendingLinkOffer(
offer: offer, fromDevice: fromDevice, senderKey: senderKey,
safetyNumber: linkSafetyNumber(withKey: senderKey))
}
/// The person compared the number and it matched.
func confirmPendingNumericOffer() async {
guard let pending = pendingNumericOffer else { return }
pendingNumericOffer = nil
guard await adoptLinkOffer(pending.offer) else { return }
// The device that offered sent its snapshot right behind the offer,
// while this one was still waiting for the person, and it was dropped
// as traffic from a device not yet linked. Ask for it again (section
// 17.6): the answer is a snapshot like the one at link time.
await sendSnapshot(to: pending.fromDevice, want: true)
}
/// The number did not match, or the person did not start this.
func rejectPendingNumericOffer() {
pendingNumericOffer = nil
}
/// Adopt an offer: each member of the set only if the relay lists it on
/// this account under the key the offer names, and the account delivery
/// key.
@discardableResult
private func adoptLinkOffer(_ offer: MultiDeviceEnvelope) async -> Bool {
guard case .linkOffer(_, let set, let dk, _) = offer else { return false }
guard let listed = try? await directory(of: myUserID, features: await relayFeatures()) else {
linkNotices.append(
LinkNotice(
deviceID: "",
text: String(
localized:
"A link was offered but the server's device list couldn't be read, so nothing was linked. Show the code again.")))
return false
}
await refreshRevocations(for: set.map(\.deviceID))
var adopted = 0
for member in set where member.deviceID != store.deviceID {
guard
listed.contains(where: {
$0.device_id == member.deviceID
&& Data(e2eBase64: $0.ik_sig) == Data(base64Encoded: member.ikSig)
}),
!store.revokedDeviceIDs.contains(member.deviceID)
else { continue }
store.link(deviceID: member.deviceID, ikSig: member.ikSig)
adopted += 1
}
guard adopted > 0 else {
_ = Self.dropped("link offer named no device the relay lists", from: "")
return false
}
store.link(deviceID: store.deviceID, ikSig: identity.ikSigPub.e2eBase64)
// The account's delivery key, so a contact holding it reaches this
// device sealed too (section 17.3, decision DA4).
if store.deliveryKey != dk {
store.deliveryKey = dk
store.deliveryKeyPublished = false
await ensureDeliveryKey()
}
for (id, key) in store.linkedDevices {
writeSync("add", key: "link/\(id)", val: .string(key))
}
linkNotices.append(
LinkNotice(deviceID: "", text: String(localized: "This device is now linked to your account.")))
return true
}
// MARK: - Unlinking, and the set following the key log (section 17.3)
/// Take a device out of the linked set. It stays signed in; it stops
/// receiving copies, ops and handoffs.
func unlinkDevice(_ deviceID: String, notice: String? = nil) {
guard deviceID != store.deviceID, store.linkedDevices[deviceID] != nil else { return }
store.unlink(deviceID: deviceID)
writeSync("rm", key: "link/\(deviceID)")
if let notice { linkNotices.append(LinkNotice(deviceID: deviceID, text: notice)) }
}
/// Remove one of this account's devices (section 8.3a), saying so.
func removeOwnDevice(_ deviceID: String) async throws {
try await revokeOwnDevice(id: deviceID)
let short = String(deviceID.prefix(8))
unlinkDevice(deviceID, notice: String(localized: "You removed device \(short)."))
}
/// Drop linked devices the relay has retired or stopped listing, and say
/// which it was: the key log's `revoke` or `expire`, or neither.
func reconcileLinkedDevices() async {
let linked = otherLinkedDevices
guard !linked.isEmpty,
let listed = try? await directory(of: myUserID, features: await relayFeatures())
else { return }
await refreshRevocations(for: Array(linked.keys))
let listedIDs = Set(listed.map(\.device_id))
for (id, key) in linked {
let short = String(id.prefix(8))
if store.revokedDeviceIDs.contains(id) {
unlinkDevice(
id,
notice: String(
localized: """
The server retired device \(short): it was removed from the \
account, or signed out for inactivity or to make room for a new \
sign-in. It no longer gets your messages.
"""))
} else if !listedIDs.contains(id) {
unlinkDevice(
id,
notice: String(
localized:
"The server stopped listing device \(short) without saying why in its key log, so it was unlinked."))
} else if let row = listed.first(where: { $0.device_id == id }), row.ik_sig != key {
unlinkDevice(
id,
notice: String(
localized: """
The server now lists device \(short) under a different key, so \
it was unlinked. If you didn't reinstall it, someone may be \
posing as it.
"""))
}
}
}
// MARK: - Sync ops out (section 17.6, stage A3)
/// A register this device just wrote goes to the linked devices, batched
/// for half a second: one op as a `sync`, several as one `sync_state`.
func syncWritten(_ o: SyncOp) {
guard !applyingSync, !suppressSyncBroadcast, hasLinkedDevices else { return }
syncOutbox.append(o)
guard syncFlushTask == nil else { return }
syncFlushTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
await self?.flushSyncOutbox()
}
}
func flushSyncOutbox() async {
syncFlushTask = nil
let ops = syncOutbox
syncOutbox = []
guard !ops.isEmpty, hasLinkedDevices else { return }
let envelopes: [String]
if ops.count == 1 {
envelopes = [MultiDeviceEnvelope.sync(ops[0]).json]
} else {
envelopes = Self.snapshotEnvelopes(ops, want: false)
}
for body in envelopes {
do {
try await transmit(body, to: myUserID)
} catch {
// Not lost for good: the next snapshot carries the register.
os_log(
"sync ops not sent to linked devices: %{public}@", log: Self.log,
type: .error, String(describing: error))
}
}
}
/// Registers as `sync_state` chunks of at most 16 KiB of registers each.
static func snapshotEnvelopes(_ registers: [SyncOp], want: Bool) -> [String] {
let sorted = registers.sorted { $0.key < $1.key }
let items = sorted.map { r -> [String: Any] in [r.key: r.registerJSON] }
let chunks = SyncMerge.chunk(items)
let byKey = Dictionary(sorted.map { ($0.key, $0) }, uniquingKeysWith: { a, _ in a })
return chunks.enumerated().map { i, chunk in
let regs = chunk.compactMap { $0.keys.first.flatMap { byKey[$0] } }
return MultiDeviceEnvelope.syncState(
seq: i + 1, of: chunks.count, want: want && i == 0, registers: regs
).json
}
}
/// Every register, to one linked device or (nil) to all of them.
func sendSnapshot(to device: String?, want: Bool) async {
seedMirrorRegistersIfNeeded()
let registers = Array(syncState.registers.values)
guard !registers.isEmpty else { return }
for body in Self.snapshotEnvelopes(registers, want: want) {
do {
try await transmit(body, to: myUserID, onlyDevices: device.map { [$0] })
} catch {
os_log(
"snapshot chunk not sent: %{public}@", log: Self.log, type: .error,
String(describing: error))
}
}
}
/// Section 17.6: a device that has not polled for a week asks its linked
/// devices for their snapshots (and sends its own); one away 90 days or
/// more first forgets its own registers, keeping only `link/…`, because
/// a removal it missed may already be forgotten elsewhere.
func catchUpAfterBeingAway(lastPoll: Date?, now: Date = Date()) async {
guard let lastPoll, hasLinkedDevices else { return }
let away = SyncMerge.nowMs(now) - SyncMerge.nowMs(lastPoll)
guard away >= SyncMerge.snapshotAfterMs else { return }
if away >= SyncMerge.tombstoneMs {
var state = syncState
state.keepOnlyLinks()
syncState = state
flushHistory()
// Offer only the links until the others' snapshots arrive.
let links = Array(syncState.registers.values)
for body in Self.snapshotEnvelopes(links, want: true) {
try? await transmit(body, to: myUserID)
}
return
}
await sendSnapshot(to: nil, want: true)
}
// MARK: - Sync ops in
/// Merge ops or snapshot registers from a linked device, and apply every
/// register that changed.
func mergeIncoming(_ ops: [SyncOp], now: Date = Date()) async {
let nowMs = SyncMerge.nowMs(now)
var state = syncState
var changed: [String] = []
for o in ops where state.merge(o, now: nowMs) { changed.append(o.key) }
guard !changed.isEmpty else { return }
syncState = state
state.collectTombstones(now: nowMs)
syncState = state
applyingSync = true
defer { applyingSync = false }
for key in changed { await applyRegister(key) }
flushHistory()
}
/// Bring this device's state in line with one register.
private func applyRegister(_ key: String) async {
let family = SyncMerge.family(of: key)
let rest = String(key.dropFirst(family.count + 1))
let reg = syncState[key]
let live = reg.map { !SyncMerge.isRemoval($0.op) } ?? false
let val = live ? reg?.val : nil
switch family {
case "acc":
return // read straight from the registers
case "blocked":
if live, let id = Int(rest) { discardHeldEnvelopes(for: id) }
case "conv":
return
case "read":
if let ms = val?.intValue { applyReadMark(thread: rest, through: ms) }
case "link":
guard rest != store.deviceID else { return }
if live, let k = val?.stringValue, let keyData = Data(base64Encoded: k) {
// A device another linked device vouched for is linked here
// only if the relay lists it on this account under that key.
guard (try? await verifyOwnDevice(rest, key: keyData)) != nil else { return }
store.link(deviceID: rest, ikSig: k)
} else if !live {
store.unlink(deviceID: rest)
}
case "roster":
guard let id = Int(rest) else { return }
if live, let o = val?.objectValue {
rosterAdd(
id: id, handle: o["h"]?.stringValue ?? "",
displayName: o["n"]?.stringValue ?? o["h"]?.stringValue ?? "")
} else if !live {
rosterRemove(id: id)
}
case "muted":
if let id = Int(rest) { setStoryMuted(live, authorID: id) }
case "gname":
if let name = val?.stringValue, !name.isEmpty {
store.setGroupName(name, for: rest)
renameGroupsFromStore()
}
case "gsecret":
guard let o = val?.any as? [String: Any],
let data = try? JSONSerialization.data(withJSONObject: o),
let secrets = try? JSONDecoder().decode(
KeychainE2EStore.GroupSecrets.self, from: data)
else { return }
if let held = store.groupSecrets(rest), held.version >= secrets.version { return }
store.setGroupSecrets(secrets, for: rest)
await refreshSealedGroups()
case "pin":
if live, let o = val?.objectValue, let mid = o["mid"]?.stringValue,
let at = o["at"]?.intValue, let by = o["by"]?.intValue
{
applyPin(mid, key: rest, by: Int(by), at: Date(timeIntervalSince1970: Double(at) / 1000))
} else if !live, let current = pins[rest] {
applyPin("", key: rest, by: current.by, at: Date())
}
case "audience":
guard let o = val?.objectValue, let mode = o["mode"]?.stringValue,
let parsed = E2EStoryAudience.Mode(rawValue: mode)
else { return }
let ids = (o["ids"].flatMap { v -> [Int]? in
guard case .array(let a) = v else { return nil }
return a.compactMap { $0.intValue.map(Int.init) }
}) ?? []
storyAudience = E2EStoryAudience(mode: parsed, userIDs: Set(ids))
case "pdk":
guard let id = Int(rest) else { return }
if let b64 = val?.stringValue, let key = Data(e2eBase64: b64), key.count == 32 {
store.setPeerDeliveryKey(key, for: id)
} else if !live {
store.clearPeerDeliveryKey(for: id)
}
case "tok":
guard let id = Int(rest) else { return }
if case .array(let a)? = val {
store.setIssuedTokens(a.compactMap(\.stringValue), for: id)
} else if !live {
store.setIssuedTokens([], for: id)
}
case "ptok":
// `ptok/i/<id>` (issued) and `ptok/h/<id>` (held).
let parts = rest.split(separator: "/")
guard parts.count == 2, let id = Int(parts[1]) else { return }
let token = val?.stringValue.flatMap { Data(e2eBase64: $0)?.count == 32 ? $0 : nil }
if parts[0] == "i" {
presenceTokensIssued[id] = token
await publishPresenceTags()
} else if parts[0] == "h" {
presenceTokensHeld[id] = token
}
case "dk":
guard let b64 = val?.stringValue, let key = Data(e2eBase64: b64), key.count == 32,
key != store.deliveryKey
else { return }
store.deliveryKey = key
store.deliveryKeyPublished = false
await ensureDeliveryKey()
default:
return // kept and passed on, never acted on
}
}
/// Section 17.3: at link time every register goes in the snapshot, so the
/// state that predates the registers is written as registers once.
func seedMirrorRegistersIfNeeded() {
guard !store.mirrorRegistersSeeded else { return }
suppressSyncBroadcast = true
defer { suppressSyncBroadcast = false }
for entry in roster where !syncState.contains("roster/\(entry.id)") {
writeSync(
"add", key: "roster/\(entry.id)",
val: .object(["h": .string(entry.handle), "n": .string(entry.displayName)]))
}
for id in mutedStoryAuthors where !syncState.contains("muted/\(id)") {
writeSync("add", key: "muted/\(id)")
}
for (gid, name) in store.allGroupNames() where syncState["gname/\(gid)"] == nil {
writeSync("set", key: "gname/\(gid)", val: .string(name))
}
for gid in store.allGroupSecrets().keys where syncState["gsecret/\(gid)"] == nil {
syncGroupSecrets(gid)
}
for (key, pin) in persistableKeyed(pins) where syncState["pin/\(key)"] == nil {
syncPin(key: key, pin: pin)
}
if syncState["audience"] == nil { syncAudience() }
for (id, tokens) in store.allIssuedTokens() where syncState["tok/\(id)"] == nil {
writeSync("set", key: "tok/\(id)", val: .array(tokens.map { .string($0) }))
}
for (id, token) in presenceTokensIssued where syncState["ptok/i/\(id)"] == nil {
writeSync("set", key: "ptok/i/\(id)", val: .string(token))
}
for (id, token) in presenceTokensHeld where syncState["ptok/h/\(id)"] == nil {
writeSync("set", key: "ptok/h/\(id)", val: .string(token))
}
for id in store.deliveryKeySentIDs + roster.map(\.id) {
if syncState["pdk/\(id)"] == nil, let key = store.peerDeliveryKey(for: id) {
writeSync("set", key: "pdk/\(id)", val: .string(key.e2eBase64))
}
}
if syncState["dk"] == nil, let dk = store.deliveryKey {
writeSync("set", key: "dk", val: .string(dk.e2eBase64))
}
store.mirrorRegistersSeeded = true
}
// MARK: - Mirrored writes (stage A3): one call at each place state changes
func syncGroupSecrets(_ gid: String) {
guard let secrets = store.groupSecrets(gid),
let data = try? JSONEncoder().encode(secrets),
let any = try? JSONSerialization.jsonObject(with: data),
let val = JSONValue(any: any)
else { return }
writeSync("set", key: "gsecret/\(gid)", val: val)
}
func syncGroupName(_ gid: String) {
guard let name = store.groupName(gid) else { return }
writeSync("set", key: "gname/\(gid)", val: .string(name))
}
func syncPin(key: String, pin: E2EPin?) {
guard !applyingSync else { return }
if let pin {
writeSync(
"set", key: "pin/\(key)",
val: .object([
"mid": .string(pin.mid), "at": .int(SyncMerge.nowMs(pin.at)),
"by": .int(Int64(pin.by)),
]))
} else {
writeSync("del", key: "pin/\(key)")
}
}
func syncAudience() {
guard !applyingSync else { return }
writeSync(
"set", key: "audience",
val: .object([
"mode": .string(storyAudience.mode.rawValue),
"ids": .array(storyAudience.userIDs.sorted().map { .int(Int64($0)) }),
]))
}
/// A mirrored family's write, skipped while a linked device's op is
/// being applied (that op is already everywhere it needs to be).
func syncMirror(_ op: String, key: String, val: JSONValue? = nil) {
guard !applyingSync else { return }
writeSync(op, key: key, val: val)
}
// MARK: - Sent copies (section 17.2)
/// Whether a plaintext sent to somebody is copied to this account's other
/// linked devices. Every kind a person sends is, except: control preludes
/// and key-log gossip, this section's own traffic, call signalling (the
/// handoff of section 6.30 covers calls), off-the-record switches, avatars,
/// stories (section 6.22.1 mirrors those itself), screenshot notices, and
/// poll traffic, whose pieces are secret to each member. A group message
/// is copied once, by the fan-out, not once per member.
static func isCopiedToOwnDevices(_ body: String, forGroup: Bool = false) -> Bool {
guard !body.isEmpty else { return false }
guard body.hasPrefix(E2EEnvelopeRoute.controlPrefix) else { return !forGroup }
if let kind = E2EEnvelopeRoute.controlKind(of: body),
kind == "delivery_key" || kind == "kt_head" || MultiDeviceEnvelope.kinds.contains(kind)
{
return false
}
let c = E2EAttachment.parse(plaintext: body)
if c.isUnknownControl || c.call != nil || c.offTheRecord != nil || c.isAvatar
|| c.textStoryExpires != nil || c.attachment?.kind == .story
|| c.screenshotAt != nil || c.poll != nil
{
return false
}
return forGroup == (c.gid != nil)
}
/// Copy something just sent to `to` (or to group `gid`) to the linked
/// devices. Never for an off-the-record conversation: it exists on the
/// two devices that held it and nowhere else (decision DA5).
func copyToLinkedDevices(_ body: String, to: Int? = nil, gid: String? = nil) {
guard hasLinkedDevices, (to == nil) != (gid == nil) else { return }
if let to {
guard to != myUserID, !offTheRecord(for: to),
Self.isCopiedToOwnDevices(body)
else { return }
} else {
guard Self.isCopiedToOwnDevices(body, forGroup: true) else { return }
}
let mid = E2EAttachment.parse(plaintext: body).mid
let copy = MultiDeviceEnvelope.sent(
to: to, gid: gid, body: body, at: SyncMerge.nowMs(), mid: mid)
Task { [weak self] in
guard let self else { return }
do {
try await self.transmit(copy.json, to: self.myUserID)
} catch {
os_log(
"sent copy not delivered to linked devices: %{public}@", log: Self.log,
type: .error, String(describing: error))
}
}
}
/// Render a copy as an outgoing message, exactly as the sending device
/// did, and never notify on it.
private func applySentCopy(to: Int?, gid: String?, body: String, at: Int64, mid: String?) {
let content = E2EAttachment.parse(plaintext: body)
let when = Date(timeIntervalSince1970: Double(at) / 1000)
if let to {
if let emoji = content.reaction, let re = content.re {
applyReaction(emoji, mid: re.mid, from: myUserID, in: to,
private: content.reactionIsPrivate)
return
}
if let target = content.editOf {
applyEdit(content.text, mid: target, byBuddy: false, at: when, in: to)
return
}
if let pinned = content.pins {
applyPin(pinned, key: Self.pinKey(buddyID: to), by: myUserID, at: when)
return
}
if let seconds = content.disappearSeconds {
store.setDisappearSeconds(seconds, for: to)
return
}
let id = mid ?? content.mid
if let id, threads[to]?.contains(where: { $0.mid == id }) == true { return }
appendToThread(
to,
message: E2EMessage(
id: nextLocalID(), isMine: true, text: content.text, date: when,
attachment: content.attachment, mid: id, replyTo: content.re,
replyToStory: content.repliesToStory ? true : nil,
sticker: content.sticker, link: content.link, delivery: .delivered,
isForwarded: content.isForwarded ? true : nil,
lifetimeSeconds: content.lifetimeSeconds))
} else if let gid {
if let emoji = content.reaction, let re = content.re {
applyGroupReaction(emoji, mid: re.mid, from: myUserID, in: gid,
private: content.reactionIsPrivate)
return
}
let id = mid ?? content.mid
if let id, groupThreads[gid]?.contains(where: { $0.mid == id }) == true { return }
appendToGroupThread(
gid,
message: E2EGroupMessage(
id: nextLocalID(), authorID: myUserID, isMine: true, text: content.text,
date: when, attachment: content.attachment, sticker: content.sticker,
delivery: .delivered, mid: id, replyTo: content.re))
}
}
// MARK: - History backfill (section 17.4, stage A4)
/// Everything this device holds, newest first, at most 10,000 messages,
/// in chunks of at most 16 KiB of items. Attachments go by reference.
/// Off-the-record conversations are not sent.
func historyItems() -> [MultiDeviceEnvelope.HistoryItem] {
var items: [MultiDeviceEnvelope.HistoryItem] = []
for (peer, msgs) in persistable(threads) {
for m in msgs {
items.append(
.init(
thread: Self.pinKey(buddyID: peer), mid: m.mid,
at: SyncMerge.nowMs(m.date), outgoing: m.isMine,
from: m.isMine ? nil : peer, body: Self.historyBody(m)))
}
}
for (gid, msgs) in groupThreads {
for m in msgs {
items.append(
.init(
thread: Self.pinKey(gid: gid), mid: m.mid,
at: SyncMerge.nowMs(m.date), outgoing: m.isMine,
from: m.isMine ? nil : m.authorID,
body: Self.historyBody(text: m.text, attachment: m.attachment,
sticker: m.sticker, mid: m.mid, gid: gid)))
}
}
return Array(
items.sorted { $0.at > $1.at }.prefix(SyncMerge.historyMaxItems))
}
static func historyBody(_ m: E2EMessage) -> String {
historyBody(text: m.text, attachment: m.attachment, sticker: m.sticker, mid: m.mid, gid: nil)
}
/// The plaintext a message was, as near as this device can rebuild it: a
/// content envelope when it carried anything but words.
static func historyBody(
text: String, attachment: E2EAttachmentRef?, sticker: E2EAttachment.StickerRef?,
mid: String?, gid: String?
) -> String {
if let sticker {
return (try? E2EAttachment.encodeSticker(
pack: sticker.pack, id: sticker.id, gid: gid, mid: mid)) ?? text
}
if let attachment {
return (try? E2EAttachment.encodeMessage(
ref: attachment, caption: text, gid: gid, mid: mid)) ?? text
}
if let gid {
return (try? E2EAttachment.encodeGroupText(text, gid: gid, mid: mid)) ?? text
}
if mid == nil { return text }
return (try? E2EAttachment.encodeText(text, mid: mid)) ?? text
}
func sendHistory(to device: String) async {
let items = historyItems()
guard !items.isEmpty else { return }
let rendered = items.map { item -> [String: Any] in
var o: [String: Any] = ["t": item.thread, "at": item.at, "body": item.body]
if let mid = item.mid { o["mid"] = mid }
return o
}
let chunks = SyncMerge.chunk(rendered)
var start = 0
for (i, chunk) in chunks.enumerated() {
let slice = Array(items[start..<(start + chunk.count)])
start += chunk.count
let env = MultiDeviceEnvelope.history(seq: i + 1, of: chunks.count, items: slice)
do {
try await transmit(env.json, to: myUserID, onlyDevices: [device])
} catch {
// Best effort: a partial backfill is tolerated by design.
os_log(
"history chunk %{public}d of %{public}d not sent: %{public}@", log: Self.log,
type: .error, i + 1, chunks.count, String(describing: error))
return
}
}
}
/// Merge a chunk of history: each item once (by `mid`), in time order.
private func applyHistory(_ items: [MultiDeviceEnvelope.HistoryItem]) {
for item in items {
let when = Date(timeIntervalSince1970: Double(item.at) / 1000)
let content = E2EAttachment.parse(plaintext: item.body)
if item.thread.hasPrefix("u:"), let peer = Int(item.thread.dropFirst(2)) {
if let mid = item.mid, threads[peer]?.contains(where: { $0.mid == mid }) == true {
continue
}
insertHistory(
E2EMessage(
id: nextLocalID(), isMine: item.outgoing, text: content.text, date: when,
attachment: content.attachment, mid: item.mid, sticker: content.sticker,
delivery: item.outgoing ? .delivered : nil),
peer: peer)
} else if item.thread.hasPrefix("g:") {
let gid = String(item.thread.dropFirst(2))
if let mid = item.mid, groupThreads[gid]?.contains(where: { $0.mid == mid }) == true {
continue
}
insertGroupHistory(
E2EGroupMessage(
id: nextLocalID(), authorID: item.outgoing ? myUserID : (item.from ?? 0),
isMine: item.outgoing, text: content.text, date: when,
attachment: content.attachment, sticker: content.sticker,
delivery: item.outgoing ? .delivered : nil, mid: item.mid),
gid: gid)
}
}
flushHistory()
}
// MARK: - Dispatch
/// Every multi-device kind arrives here (`E2EEnvelopeRoute.multiDevice`).
/// A `link_offer` is taken from any own device (it proves itself with the
/// secret or the number); everything else only from a linked one, under
/// the key it was linked with (section 17.6). `rekey_request` comes from
/// contacts and is stage A5.
func handleMultiDevice(
_ text: String, fromUser: Int, fromDevice: String,
senderProven: Bool, senderKey: Data?, sentAt: Date
) async {
guard let envelope = MultiDeviceEnvelope(text: text) else {
_ = Self.dropped("multi-device envelope that does not parse", from: fromDevice)
return
}
if case .rekeyRequest = envelope {
_ = Self.dropped("rekey request: not acted on before stage A5", from: fromDevice)
return
}
guard senderProven, fromUser == myUserID else {
_ = Self.dropped("multi-device envelope from another account", from: fromDevice)
return
}
if case .linkOffer = envelope {
await receiveLinkOffer(envelope, fromDevice: fromDevice, senderKey: senderKey)
return
}
guard isFromLinkedOwnDevice(fromUser: fromUser, fromDevice: fromDevice, senderKey: senderKey)
else {
_ = Self.dropped("multi-device envelope from an own device that is not linked", from: fromDevice)
return
}
switch envelope {
case .sync(let o):
await mergeIncoming([o])
case .syncState(_, _, let want, let registers):
await mergeIncoming(registers)
if want {
// Answered with this device's own, without asking back.
Task { [weak self] in await self?.sendSnapshot(to: fromDevice, want: false) }
}
case .sent(let to, let gid, let body, let at, let mid):
applySentCopy(to: to, gid: gid, body: body, at: at, mid: mid)
case .history(_, _, let items):
applyHistory(items)
case .linkOffer, .rekeyRequest:
return
}
}
}
/// A numeric-fallback link offer waiting for the person to compare the number
/// on this device too.
struct PendingLinkOffer: Equatable {
let offer: MultiDeviceEnvelope
let fromDevice: String
let senderKey: Data
let safetyNumber: String
}
extension E2EInbox {
/// Whether a device of this account is in this device's linked set.
func isLinkedHere(_ deviceID: String) -> Bool { store.linkedDevices[deviceID] != nil }
/// The key the relay lists for one of this account's devices, for the
/// numeric fallback's number.
func ownDeviceKey(_ device: E2EDevicesResponse.Device) -> Data? {
Data(e2eBase64: device.ik_sig)
}
}
+978 -149
View File
File diff suppressed because it is too large Load Diff
+84 -9
View File
@@ -126,6 +126,9 @@ struct E2EMessagesSendRequest: Encodable {
var delivery_key: String?
/// §21.4, a single-use capability, spent on acceptance.
var delivery_token: String?
/// Section 14.5: an introduction from `POST /e2e/intro`, good for one
/// sealed submission to one account within ten minutes.
var intro: String? = nil
/// Time-critical: ask the relay to ring rather than merely wake (§6.14).
var urgent: Bool?
/// Deliver without announcing: protocol traffic, not a message.
@@ -181,6 +184,9 @@ struct E2EMessagesSendResponse: Decodable {
struct E2EMessagesPollResponse: Decodable {
let messages: [Incoming]
let cursor: Int
/// Section 8.5: the relay stopped early at its byte cap and has more
/// waiting. Present only then, never `false`. Absent from older relays.
var more: Bool? = nil
/// For a sealed row the relay does not know the sender: `from_user` is 0
/// and `from_device` is empty.
@@ -302,7 +308,16 @@ struct E2EGroup: Decodable, Identifiable, Hashable {
func memberName(_ userID: Int) -> String {
let member = members.first { $0.user_id == userID }
if let name = member?.name, !name.isEmpty { return name }
return "Member #\(userID)"
return String(localized: "Member #\(String(userID))")
}
/// What a screen calls one member: "You" for this account, a buddy's
/// name when there is one, otherwise the roster's placeholder. Nobody is
/// their own buddy and the relay's roster carries no names, so without
/// the first rule this account was "Member #N" in every group it was in.
func displayName(of userID: Int, me: Int, known: String?) -> String {
if userID == me { return "You" }
return known ?? memberName(userID)
}
}
@@ -412,6 +427,37 @@ struct E2ELogDeviceEntries: Decodable {
struct E2ERelayDescriptor: Decodable {
let audience: String
let issuerPublicKey: String
/// Switches a client reads before using something older clients cannot
/// handle. Absent on relays that predate them, which means every switch
/// is off.
var features: Features? = nil
struct Features: Decodable, Equatable {
/// Section 23.9: senders may pad attachments, because every receiver
/// still in use reads `clen`. Absent or false means off.
var padAttachments: Bool? = nil
/// Section 17.6: the relay keys delivery-key digests and roster tags
/// by account and device rather than by account alone.
var perDeviceDigests: Bool? = nil
/// Section 14.5, stage C1: `POST /e2e/intro`, its proof-of-work
/// challenge, and `GET /e2e/handle-cert`. Turns on handle
/// certificates (C2) and sealed first contact with no named fallback
/// (C3).
var introductions: Bool? = nil
/// Section 8.13, stage D1: `/e2e/anon/presence|devices|bundles` and
/// `GET /e2e/devices/me`. Turns on reads that name nobody (D2).
var anonymousReads: Bool? = nil
/// Section 8.14, stage B2: `POST /e2e/contact-links`, reusable links.
var contactLinks: Bool? = nil
/// Section 8.14, stage B1: the Listed / Unlisted setting on
/// `/messenger/me` (`discoverable`).
var unlisted: Bool? = nil
/// `POST /api/flamenet/v1/search`: web search the relay asks of its
/// own index on a member's behalf and never keeps.
var webSearch: Bool? = nil
static let off = Features()
}
}
struct E2ELogInclusion: Decodable {
@@ -442,14 +488,14 @@ struct E2EWipeOrderResponse: Decodable {
struct KeyNotLoggedError: LocalizedError {
let deviceID: String
var errorDescription: String? {
"The keys offered for this contact are not in the server's public key log."
String(localized: "The keys offered for this contact are not in the server's public key log.")
}
}
/// §19, the log no longer extends the one we were shown before.
struct KeyLogForkError: LocalizedError {
var errorDescription: String? {
"The server's key log no longer matches what it published before."
String(localized: "The server's key log no longer matches what it published before.")
}
}
@@ -459,8 +505,9 @@ struct KeyLogForkError: LocalizedError {
struct PQDowngradeError: LocalizedError {
let deviceID: String
var errorDescription: String? {
"This contact's device stopped offering a post-quantum identity. "
+ "This can mean the connection is being tampered with."
String(
localized:
"This contact's device stopped offering a post-quantum identity. This can mean the connection is being tampered with.")
}
}
@@ -468,8 +515,9 @@ struct PQDowngradeError: LocalizedError {
struct PQVerificationError: LocalizedError {
let deviceID: String
var errorDescription: String? {
"This contact's post-quantum signature did not verify. "
+ "The keys offered for them cannot be trusted."
String(
localized:
"This contact's post-quantum signature did not verify. The keys offered for them cannot be trusted.")
}
}
@@ -477,8 +525,8 @@ struct PQVerificationError: LocalizedError {
struct PQIdentityChangedError: LocalizedError {
let deviceID: String
var errorDescription: String? {
"This contact's post-quantum identity key changed. "
+ "Compare safety numbers before continuing."
String(
localized: "This contact's post-quantum identity key changed. Compare safety numbers before continuing.")
}
}
@@ -557,3 +605,30 @@ struct PushTokenRequest: Encodable {
struct PushTokenResponse: Decodable {
let ok: Bool
}
/// Section 14.5, what `POST /e2e/intro` answers: the account, its handle
/// certificate, its bundles (one prekey popped per device), and one
/// capability good for one sealed submission before `exp`.
struct E2EIntroAnswer {
let userID: Int
let fnid: String?
let intro: Data
let exp: Int
let handleCert: HandleCertificate?
let devices: [E2EKeysResponse.Bundle]
/// The bundles as the directory would list them, for the trust check.
var asDirectory: [E2EDevicesResponse.Device] {
devices.map {
E2EDevicesResponse.Device(
device_id: $0.device_id, ik_dh: $0.ik_dh, ik_sig: $0.ik_sig,
proto: $0.proto, caps_sig: $0.caps_sig, created_at: nil)
}
}
}
/// Section 8.13, the challenge an introduction by handle has to answer.
struct E2EIntroChallenge: Decodable {
let challenge: String
let bits: Int
}
+94
View File
@@ -0,0 +1,94 @@
import CryptoKit
import Foundation
/// Section 14.5, a relay's signature over an account's handle:
/// `fnhandle-v1|<audience>|<user id>|<handle>|<exp>`, Ed25519 under the key the
/// relay signs its log heads with, which this app already pins.
///
/// It replaces asking the relay "who is user 12?", a question the relay
/// answers to anyone and can log. A name is shown from one only after it
/// verifies against the pinned key, for this relay, for the account it is
/// about, before it expires.
struct HandleCertificate: Codable, Equatable {
let v: Int
let aud: String
let uid: Int
let h: String
let exp: Int
let sig: String
/// The exact bytes the relay signs, built by concatenation so every
/// verifier reproduces them without agreeing on a JSON encoder.
static func canonical(audience: String, userID: Int, handle: String, exp: Int) -> Data {
Data("fnhandle-v1|\(audience)|\(userID)|\(handle)|\(exp)".utf8)
}
/// Whether the fields can be signed at all: no field holds the separator,
/// and the id and expiry are positive. Checked before any signature.
var isWellFormed: Bool {
guard v == 1, !aud.isEmpty, !aud.contains("|") else { return false }
guard !h.isEmpty, h.utf16.count <= 64, !h.contains("|") else { return false }
return uid > 0 && exp > 0
}
/// Verify against the relay's pinned key.
///
/// - Parameters:
/// - issuerKey: the relay's pinned Ed25519 public key, 32 bytes. None
/// pinned means nothing can be verified.
/// - audience: the relay this app is talking to.
/// - userID: the account the certificate must name: for a sender, the
/// one the ratchet proved; for an intro, the one the intro answered.
func verifies(
issuerKey: Data?, audience: String, userID: Int, now: Date = Date()
) -> Bool {
guard isWellFormed, let issuerKey, issuerKey.count == 32 else { return false }
guard aud == audience, uid == userID else { return false }
guard Double(exp) > now.timeIntervalSince1970.rounded(.down) else { return false }
guard let signature = Data(base64Encoded: sig), signature.count == 64,
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: issuerKey)
else { return false }
return key.isValidSignature(
signature,
for: Self.canonical(audience: aud, userID: uid, handle: h, exp: exp))
}
/// From a JSON object as a prelude or an intro carries it. `uid` and
/// `exp` must be integers; anything else is not a certificate.
init?(json: [String: Any]) {
guard let v = JSONValue(any: json["v"])?.intValue,
let aud = json["aud"] as? String,
let uid = JSONValue(any: json["uid"])?.intValue,
let h = json["h"] as? String,
let exp = JSONValue(any: json["exp"])?.intValue,
let sig = json["sig"] as? String,
uid <= SyncMerge.maxSafeInteger, exp <= SyncMerge.maxSafeInteger
else { return nil }
self.v = Int(v)
self.aud = aud
self.uid = Int(uid)
self.h = h
self.exp = Int(exp)
self.sig = sig
}
init(v: Int = 1, aud: String, uid: Int, h: String, exp: Int, sig: String) {
self.v = v
self.aud = aud
self.uid = uid
self.h = h
self.exp = exp
self.sig = sig
}
var json: [String: Any] { ["v": v, "aud": aud, "uid": uid, "h": h, "exp": exp, "sig": sig] }
/// The certificate as a prelude carries it: `v, aud, uid, h, exp, sig`,
/// and `/` in the signature left alone.
var orderedJSON: String {
OrderedJSON.object([
("v", String(v)), ("aud", OrderedJSON.string(aud)), ("uid", String(uid)),
("h", OrderedJSON.string(h)), ("exp", String(exp)), ("sig", OrderedJSON.string(sig)),
])
}
}
+161 -3
View File
@@ -58,10 +58,19 @@ final class KeychainE2EStore {
/// and the history key rides the backup next to the file it unlocks.
static let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
init(userId: Int) {
/// Nil for every real install. A test that needs two devices of ONE
/// account in one process (linking, sent copies, sync) gives each a slot,
/// which keeps their keys, suite and history file apart; without it the
/// second device would read the first one's identity and be the same
/// device.
let slot: String?
init(userId: Int, slot: String? = nil) {
self.userID = userId
self.slot = slot
let suffix = slot.map { ".s\($0)" } ?? ""
self.suite =
UserDefaults(suiteName: "io.flamenet.messenger.e2e.u\(userId)")
UserDefaults(suiteName: "io.flamenet.messenger.e2e.u\(userId)\(suffix)")
?? .standard
migrateAccessibilityIfNeeded()
}
@@ -102,6 +111,11 @@ final class KeychainE2EStore {
// device on an account that no longer has it registered, which is the
// mismatch this store exists to keep straight.
"device_id",
// Section 17.3. Which of this account's other devices a person linked
// to this one; everything this device sends to its own account goes
// to these and no others, so a set left behind would be trusted by
// whoever signs in next.
"linked_devices",
]
/// Re-write the long-term items once, so items created before the switch to
@@ -441,6 +455,64 @@ final class KeychainE2EStore {
set { suite.set(newValue, forKey: "delivery_key_published") }
}
/// Relays this device has republished its delivery-key digest to since
/// they began keying digests by device (section 17.6). Once per relay:
/// a device whose digest was overwritten under the old behaviour has no
/// other reason ever to publish it again.
func hasRepublishedDeliveryKey(forRelay relay: String) -> Bool {
(suite.stringArray(forKey: "per_device_digest_relays") ?? []).contains(relay)
}
func markDeliveryKeyRepublished(forRelay relay: String) {
var relays = suite.stringArray(forKey: "per_device_digest_relays") ?? []
guard !relays.contains(relay) else { return }
relays.append(relay)
suite.set(relays, forKey: "per_device_digest_relays")
}
// MARK: - Linked devices (section 17.3)
/// This account's other devices that a person has linked to this one: the
/// device id, and the base64 `IK_sig` it was linked under.
///
/// Starts empty, and nothing in this release adds to it: linking itself
/// arrives later (stage A2). Until then a device that merely signed in
/// with the password is a device of the account and nothing more, and
/// gets none of what this device sends to its own account, except the
/// signal that stops a ring. In the Keychain rather than the defaults
/// suite, because it decides who receives copies of everything.
var linkedDevices: [String: String] {
get {
guard let blob = readKeychain("linked_devices"),
let obj = try? JSONSerialization.jsonObject(with: blob) as? [String: String]
else { return [:] }
return obj
}
set {
guard let blob = try? JSONSerialization.data(withJSONObject: newValue) else { return }
writeKeychain("linked_devices", data: blob)
}
}
/// Whether a device is linked under exactly this key. A device id the
/// relay now lists under a different key is a substitution, not the
/// device that was linked.
func isLinked(deviceID: String, ikSig: String) -> Bool {
linkedDevices[deviceID] == ikSig
}
func link(deviceID: String, ikSig: String) {
var set = linkedDevices
set[deviceID] = ikSig
linkedDevices = set
}
func unlink(deviceID: String) {
var set = linkedDevices
set[deviceID] = nil
linkedDevices = set
}
/// Delivery keys contacts have sent us.
private func loadPeerDeliveryKeys() -> [String: String] {
guard let blob = readKeychain("peer_delivery_keys"),
@@ -605,6 +677,92 @@ final class KeychainE2EStore {
// MARK: - §21.4 single-use delivery capabilities
/// Tokens we ISSUED, by contact.
/// Every batch this account issued, keyed by contact (section 16.2.1's
/// `deliveryTokensIssued`).
func allIssuedTokens() -> [Int: [String]] {
let all = (suite.dictionary(forKey: "issued_tokens") as? [String: [String]]) ?? [:]
var out: [Int: [String]] = [:]
for (k, v) in all { if let id = Int(k), !v.isEmpty { out[id] = v } }
return out
}
/// Stage 2-B seeding, once from this device's own history and once from
/// the relay. In the suite, so a wipe or sign-out starts it again.
var accountStateSeededFromDevice: Bool {
get { suite.bool(forKey: "account_state_seeded_device") }
set { suite.set(newValue, forKey: "account_state_seeded_device") }
}
// MARK: - First contact (section 14.5, 8.12)
/// Handle certificates that verified, as JSON, keyed by account id. The
/// name a Requests row shows when the relay vouched for it.
func certifiedHandle(for userID: Int) -> HandleCertificate? {
guard let all = suite.dictionary(forKey: "handle_certs") as? [String: [String: Any]],
let o = all["\(userID)"]
else { return nil }
return HandleCertificate(json: o)
}
func setCertifiedHandle(_ cert: HandleCertificate, for userID: Int) {
var all = (suite.dictionary(forKey: "handle_certs") as? [String: [String: Any]]) ?? [:]
all["\(userID)"] = cert.json
suite.set(all, forKey: "handle_certs")
}
/// One-time contact links this account made and has not revoked: the
/// delivery tokens they carry (section 8.14), base64.
var oneTimeLinkTokens: [String] {
get { suite.stringArray(forKey: "one_time_links") ?? [] }
set { suite.set(newValue, forKey: "one_time_links") }
}
/// Reusable contact links this account made: the 32-byte capabilities,
/// base64. The relay holds only their SHA-256.
var reusableLinks: [String] {
get { suite.stringArray(forKey: "reusable_links") ?? [] }
set { suite.set(newValue, forKey: "reusable_links") }
}
/// The reusable link a conversation was started from, keyed by account,
/// so a refused send can ask for one fresh introduction (section 14.5).
func startedFromLink(_ userID: Int) -> Data? {
(suite.dictionary(forKey: "started_from_link") as? [String: String])?["\(userID)"]
.flatMap { Data(base64Encoded: $0) }
}
func setStartedFromLink(_ link: Data, for userID: Int) {
var all = (suite.dictionary(forKey: "started_from_link") as? [String: String]) ?? [:]
all["\(userID)"] = link.base64EncodedString()
suite.set(all, forKey: "started_from_link")
}
/// When a poll last reached the relay (section 17.6: a device away a
/// week or more asks its linked devices for snapshots).
var lastPollSucceededAt: Date? {
get {
let t = suite.double(forKey: "last_poll_succeeded_at")
return t > 0 ? Date(timeIntervalSince1970: t) : nil
}
set { suite.set(newValue?.timeIntervalSince1970 ?? 0, forKey: "last_poll_succeeded_at") }
}
/// Section 17.3: state that predates the registers has been written as
/// registers once, so a snapshot carries all of it.
var mirrorRegistersSeeded: Bool {
get { suite.bool(forKey: "mirror_registers_seeded") }
set { suite.set(newValue, forKey: "mirror_registers_seeded") }
}
var accountStateSeededFromRelay: Bool {
get { suite.bool(forKey: "account_state_seeded_relay") }
set { suite.set(newValue, forKey: "account_state_seeded_relay") }
}
/// Contacts this account has handed its delivery key, so a rotation can
/// push the new one to each (section 14.4, decision DC6).
var deliveryKeySentIDs: [Int] {
suite.array(forKey: "delivery_key_sent_ids") as? [Int] ?? []
}
func issuedTokens(for userID: Int) -> [String] {
(suite.dictionary(forKey: "issued_tokens") as? [String: [String]])?["\(userID)"] ?? []
}
@@ -1192,7 +1350,7 @@ final class KeychainE2EStore {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "\(account).u\(userID)",
kSecAttrAccount as String: "\(account).u\(userID)\(slot.map { ".s\($0)" } ?? "")",
]
}
+261
View File
@@ -0,0 +1,261 @@
import CryptoKit
import Foundation
/// Base64url without padding, the spelling every link and code in sections
/// 8.12 and 17.3 uses.
enum Base64URL {
static func encode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// Nil for anything outside `[A-Za-z0-9_-]`, padded input included, or a
/// length no byte string has.
static func decode(_ s: String) -> Data? {
guard s.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") })
else { return nil }
let remainder = s.count % 4
guard remainder != 1 else { return nil }
let padded =
s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ String(repeating: "=", count: remainder == 0 ? 0 : 4 - remainder)
return Data(base64Encoded: padded)
}
}
/// Section 17.3, the code a NEW device shows so an existing linked device can
/// link it:
/// `flamenet:link?d=<device id>&k=<IK_sig, base64url>&s=<secret, base64url>`.
struct DeviceLinkCode: Equatable {
let deviceID: String
let ikSig: Data
let secret: Data
/// How long a shown code is good for.
static let lifetime: TimeInterval = 10 * 60
var text: String {
"flamenet:link?d=\(deviceID)&k=\(Base64URL.encode(ikSig))&s=\(Base64URL.encode(secret))"
}
/// A fresh secret: 16 random bytes, replaced each time the code is shown.
static func freshSecret() -> Data {
var bytes = Data(count: 16)
_ = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!)
}
return bytes
}
/// Parse a scanned code, or nil. The key must be 32 bytes and the secret
/// 16 to 32; anything else is not a link code and is refused whole.
init?(text raw: String) {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard s.lowercased().hasPrefix("flamenet:link?") else { return nil }
let query = String(s.dropFirst("flamenet:link?".count))
var q: [String: String] = [:]
for pair in query.split(separator: "&", omittingEmptySubsequences: false) {
guard let eq = pair.firstIndex(of: "="), eq > pair.startIndex else { return nil }
let k = String(pair[..<eq])
guard q[k] == nil else { return nil }
q[k] = String(pair[pair.index(after: eq)...])
}
let device = (q["d"] ?? "").lowercased()
guard Self.isDeviceID(device),
let key = q["k"].flatMap(Base64URL.decode), key.count == 32,
let secret = q["s"].flatMap(Base64URL.decode), (16...32).contains(secret.count)
else { return nil }
self.deviceID = device
self.ikSig = key
self.secret = secret
}
init(deviceID: String, ikSig: Data, secret: Data) {
self.deviceID = deviceID
self.ikSig = ikSig
self.secret = secret
}
static func isDeviceID(_ s: String) -> Bool {
let parts = s.split(separator: "-", omittingEmptySubsequences: false)
guard parts.map(\.count) == [8, 4, 4, 4, 12] else { return false }
return s.allSatisfy { $0 == "-" || ("0"..."9").contains($0) || ("a"..."f").contains($0) }
}
/// Constant-time comparison of two secrets of equal length.
static func sameSecret(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count, !a.isEmpty else { return false }
var diff: UInt8 = 0
for (x, y) in zip(a, b) { diff |= x ^ y }
return diff == 0
}
}
/// Section 8.14, a contact link: how somebody reaches an account without a
/// username search. One-time links carry a delivery token (`t`), reusable
/// ones a link capability (`l`); exactly one of the two.
struct ContactLink: Equatable {
let relay: String
let userID: Int
/// A one-time link's delivery token (section 21.4), 32 bytes.
let token: Data?
/// A reusable link's capability, 32 bytes.
let link: Data?
/// A hint, unverified until a handle certificate vouches for it.
let handle: String?
let fnid: String?
var isOneTime: Bool { token != nil }
/// Both forms, or nil when a field cannot be written (a relay that is not
/// a hostname, a capability of the wrong size).
func encoded() -> (https: String, flamenet: String)? {
let host = relay.lowercased()
guard Self.isHost(host), userID > 0, (token == nil) != (link == nil) else { return nil }
let cap = token ?? link ?? Data()
guard cap.count == 32 else { return nil }
var parts = ["u=\(userID)"]
parts.append(token != nil ? "t=\(Base64URL.encode(cap))" : "l=\(Base64URL.encode(cap))")
if let handle, !handle.isEmpty { parts.append("h=\(Self.encodeComponent(handle))") }
let q = parts.joined(separator: "&")
return ("https://\(host)/add#\(q)", "flamenet:\(fnid ?? "")@\(host)?\(q)")
}
init(relay: String, userID: Int, token: Data? = nil, link: Data? = nil,
handle: String? = nil, fnid: String? = nil) {
self.relay = relay.lowercased()
self.userID = userID
self.token = token
self.link = link
self.handle = handle
self.fnid = fnid
}
/// Parse either form, or nil. Unknown keys are skipped; both `t` and `l`,
/// neither, a capability that is not 32 bytes, a relay that is not a
/// hostname, or a repeated key refuse the whole link.
init?(text raw: String) {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
var relay: String
var fnid: String?
var query: String
let lower = s.lowercased()
if lower.hasPrefix("https://"), let hash = s.firstIndex(of: "#") {
let authorityAndPath = s[s.index(s.startIndex, offsetBy: 8)..<hash]
guard let slash = authorityAndPath.firstIndex(of: "/"),
authorityAndPath[slash...].lowercased() == "/add"
else { return nil }
relay = String(authorityAndPath[..<slash])
guard !relay.contains("?"), !relay.contains("#") else { return nil }
query = String(s[s.index(after: hash)...])
} else if lower.hasPrefix("flamenet:"), let at = s.firstIndex(of: "@"),
let qmark = s[at...].firstIndex(of: "?")
{
let id = s[s.index(s.startIndex, offsetBy: 9)..<at]
guard !id.contains("?"), !id.contains("#") else { return nil }
fnid = id.isEmpty ? nil : String(id)
relay = String(s[s.index(after: at)..<qmark])
guard !relay.isEmpty, !relay.contains("#") else { return nil }
query = String(s[s.index(after: qmark)...])
} else {
return nil
}
relay = relay.lowercased()
guard Self.isHost(relay) else { return nil }
var q: [String: String] = [:]
for pair in query.split(separator: "&", omittingEmptySubsequences: true) {
guard let eq = pair.firstIndex(of: "="), eq > pair.startIndex else { return nil }
let k = String(pair[..<eq])
guard q[k] == nil else { return nil }
q[k] = String(pair[pair.index(after: eq)...])
}
guard let u = q["u"], Self.isUserID(u), let userID = Int(u) else { return nil }
guard (q["t"] == nil) != (q["l"] == nil) else { return nil }
guard let cap = (q["t"] ?? q["l"]).flatMap(Base64URL.decode), cap.count == 32 else {
return nil
}
var handle: String?
if let h = q["h"] {
guard let decoded = h.removingPercentEncoding else { return nil }
handle = decoded
}
self.relay = relay
self.userID = userID
self.fnid = fnid
self.handle = handle
self.token = q["t"] != nil ? cap : nil
self.link = q["l"] != nil ? cap : nil
}
static func isUserID(_ s: String) -> Bool {
guard let first = s.first, ("1"..."9").contains(first), s.count <= 16 else { return false }
return s.allSatisfy { ("0"..."9").contains($0) }
}
/// A hostname with at least one dot, lower case, no port.
static func isHost(_ s: String) -> Bool {
let labels = s.split(separator: ".", omittingEmptySubsequences: false)
guard labels.count >= 2 else { return false }
for label in labels {
guard let first = label.first, let last = label.last else { return false }
let ok: (Character) -> Bool = { c in
("a"..."z").contains(c) || ("0"..."9").contains(c)
}
guard ok(first), ok(last), label.allSatisfy({ ok($0) || $0 == "-" }) else {
return false
}
}
return true
}
/// `encodeURIComponent`: everything but `A-Z a-z 0-9 - _ . ! ~ * ' ( )`.
static func encodeComponent(_ s: String) -> String {
var allowed = CharacterSet()
allowed.insert(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()")
return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s
}
/// A fresh 32-byte capability.
static func freshCapability() -> Data {
var bytes = Data(count: 32)
_ = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
return bytes
}
/// What the relay stores for a reusable link: SHA-256, base64.
static func digest(_ capability: Data) -> String {
Data(SHA256.hash(data: capability)).base64EncodedString()
}
}
/// Section 8.13, the proof of work on an introduction by handle: the smallest
/// decimal nonce for which SHA-256(`<challenge>.<nonce>`) starts with at least
/// `bits` zero bits.
enum IntroProofOfWork {
static func leadingZeroBits(_ bytes: [UInt8]) -> Int {
var n = 0
for b in bytes {
if b == 0 {
n += 8
continue
}
return n + b.leadingZeroBitCount
}
return n
}
/// Nil when nothing under `ceiling` works, or the task was cancelled.
static func solve(challenge: String, bits: Int, ceiling: Int = 50_000_000) -> String? {
guard bits > 0 else { return "0" }
for n in 0..<ceiling {
if n % 65_536 == 0, Task.isCancelled { return nil }
let digest = SHA256.hash(data: Data("\(challenge).\(n)".utf8))
if leadingZeroBits(Array(digest)) >= bits { return String(n) }
}
return nil
}
}
+11
View File
@@ -0,0 +1,11 @@
import Foundation
extension LinkPreviewFetcher {
/// A preview, unless a bridge is saved: a bridge cannot reach the linked
/// site, and going around it is the one thing a bridge user must not have
/// happen (SPEC §24.6).
static func fetchUnlessBridged(_ url: URL) async -> LinkPreview? {
guard !AroundTheBridge.active else { return nil }
return await fetch(url)
}
}
+18 -1
View File
@@ -41,6 +41,21 @@ enum LinkPreviewFetcher {
}
/// Fetch and parse Open Graph tags.
///
/// Connects to the linked site directly. The app calls it only through
/// `fetchUnlessBridged`, which refuses while a bridge is saved (§24.6).
/// This file is also compiled into flamenet-e2e's interop driver, which
/// has no bridge code, so the check lives beside it, not in it.
/// Stand-in protocol handlers, for a test that watches whether this fetch
/// happens at all.
///
/// The session below is `.ephemeral` on purpose, so a preview fetch
/// carries no cookies, cache or credentials to the linked site. A session
/// built from a configuration does not see `URLProtocol.registerClass`,
/// which registers only for the shared session, so without a seam here a
/// test cannot observe the fetch either way. Nil in the app.
nonisolated(unsafe) static var protocolClasses: [AnyClass]?
static func fetch(_ url: URL, timeout: TimeInterval = 5) async -> LinkPreview? {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
@@ -49,7 +64,9 @@ enum LinkPreviewFetcher {
// site that wants to refuse these should be able to.
request.setValue("FlamenetLinkPreview/1.0", forHTTPHeaderField: "User-Agent")
guard let (data, response) = try? await URLSession.shared.data(for: request),
let configuration = URLSessionConfiguration.ephemeral
if let protocolClasses { configuration.protocolClasses = protocolClasses }
guard let (data, response) = try? await URLSession(configuration: configuration).data(for: request),
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode)
else { return nil }
+20 -15
View File
@@ -27,17 +27,19 @@ struct MailboxHoming {
var errorDescription: String? {
switch self {
case .notAHostname:
return "That does not look like a relay's address. It is a hostname, "
+ "like relay.example.com, no https:// and no path."
return String(
localized:
"That does not look like a relay's address. It is a hostname, like relay.example.com, no https:// and no path.")
case .alreadyAHome:
return "That relay already holds a mailbox for you."
return String(localized: "That relay already holds a mailbox for you.")
case .noAddress:
return "This device has not worked out your address yet, so there is "
+ "nothing to publish. Signing in again should fix it."
return String(
localized:
"This device has not worked out your address yet, so there is nothing to publish. Signing in again should fix it.")
case .signIn(let why):
return why
case .rejected(let relay, let reason):
return "\(relay) refused the mailbox: \(reason)"
return String(localized: "\(relay) refused the mailbox: \(reason)")
}
}
}
@@ -151,12 +153,13 @@ struct MailboxHoming {
guard relay != primary.relay else {
throw Failure.rejected(
relay: relay,
reason: "this is the relay you are signed in to, so it cannot be removed "
+ "while you are using it")
reason: String(
localized:
"this is the relay you are signed in to, so it cannot be removed while you are using it"))
}
let remaining = remainingHomes.filter { $0 != relay }
guard !remaining.isEmpty else {
throw Failure.rejected(relay: relay, reason: "an account needs at least one mailbox")
throw Failure.rejected(relay: relay, reason: String(localized: "an account needs at least one mailbox"))
}
var byRelay: [String: E2EClient] = [primary.relay: primary.client]
@@ -242,20 +245,22 @@ struct MailboxHoming {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw Failure.signIn("That relay did not answer.")
throw Failure.signIn(String(localized: "That relay did not answer."))
}
guard (200..<300).contains(http.statusCode) else {
// Says which of the two things went wrong.
throw Failure.signIn(
http.statusCode == 401 || http.statusCode == 403
? "That relay did not accept those details. Sign up there first, "
+ "with the same passphrase, that is what gives you the same address."
: "That relay refused the sign-in (\(http.statusCode)).")
? String(
localized:
"That relay did not accept those details. Sign up there first, with the same passphrase, that is what gives you the same address."
)
: String(localized: "That relay refused the sign-in (\(String(http.statusCode)))."))
}
guard let decoded = try? JSONDecoder().decode(Response.self, from: data) else {
throw Failure.signIn(
"That relay answered with something this app "
+ "could not read. It may not be a Flamenet relay.")
String(
localized: "That relay answered with something this app could not read. It may not be a Flamenet relay."))
}
return AccountAtRelay(
userID: decoded.user_id, username: decoded.username,
+237
View File
@@ -0,0 +1,237 @@
import Foundation
/// JSON written in a fixed key order, for envelopes a vector pins to the byte.
///
/// `JSONSerialization` neither keeps key order nor, by default, leaves `/`
/// alone, and base64 is full of `/`. Strings and nested values go through it
/// one at a time with `.withoutEscapingSlashes`; the objects around them are
/// joined here, in the order the caller gives.
enum OrderedJSON {
static func string(_ s: String) -> String {
guard
let data = try? JSONSerialization.data(
withJSONObject: s, options: [.fragmentsAllowed, .withoutEscapingSlashes])
else { return "\"\"" }
return String(decoding: data, as: UTF8.self)
}
/// A value whose own keys have no order to keep (a register's `val`):
/// sorted, so one value is one string.
static func value(_ v: JSONValue) -> String {
switch v {
case .null: return "null"
case .bool(let b): return b ? "true" : "false"
case .int(let i): return String(i)
case .double(let d):
let data = try? JSONSerialization.data(withJSONObject: d, options: [.fragmentsAllowed])
return data.map { String(decoding: $0, as: UTF8.self) } ?? "0"
case .string(let s): return string(s)
case .array(let a): return "[" + a.map(value).joined(separator: ",") + "]"
case .object(let o):
return "{"
+ o.keys.sorted().map { string($0) + ":" + value(o[$0]!) }.joined(separator: ",")
+ "}"
}
}
/// An object from already-rendered values, in this order.
static func object(_ pairs: [(String, String)]) -> String {
"{" + pairs.map { string($0.0) + ":" + $0.1 }.joined(separator: ",") + "}"
}
}
/// Sections 17.2 to 17.6: the envelopes that travel between one person's own
/// linked devices, and `rekey_request`, which goes to contacts.
///
/// Every one is control traffic (`ctl: 1`), so a build that does not know the
/// kind consumes it rather than drawing a bubble (section 6.10). The writers
/// keep the key order `content-v1.json`'s `pending` cases pin.
enum MultiDeviceEnvelope: Equatable {
/// One device of the linked set, as a `link_offer` lists it.
struct Member: Equatable {
let deviceID: String
/// Standard base64 of the device's IK_sig.
let ikSig: String
}
case linkOffer(secret: Data?, set: [Member], dk: Data, at: Int64)
case sync(SyncOp)
case syncState(seq: Int, of: Int, want: Bool, registers: [SyncOp])
/// A copy of something this account sent: to a person (`to`) or a group
/// (`gid`), never both. `body` is the exact plaintext they received.
case sent(to: Int?, gid: String?, body: String, at: Int64, mid: String?)
case history(seq: Int, of: Int, items: [HistoryItem])
case rekeyRequest(device: String, at: Int64)
/// One message of a backfill (section 17.4).
struct HistoryItem: Equatable {
/// The thread key, as section 16.2.1 spells it (`u:12`, `g:<gid>`).
let thread: String
let mid: String?
let at: Int64
/// True for this account's own message; otherwise `from` is the author.
let outgoing: Bool
let from: Int?
let body: String
}
static let kinds: Set<String> = [
"link_offer", "sync", "sync_state", "sent", "history", "rekey_request",
]
// MARK: - Writing
var json: String {
let head: [(String, String)] = [("fnc", "1")]
switch self {
case .linkOffer(let secret, let set, let dk, let at):
var pairs = head + [("kind", OrderedJSON.string("link_offer")), ("ctl", "1"), ("v", "1")]
if let secret { pairs.append(("s", OrderedJSON.string(Base64URL.encode(secret)))) }
let members = set.map {
OrderedJSON.object([
("d", OrderedJSON.string($0.deviceID)), ("k", OrderedJSON.string($0.ikSig)),
])
}
pairs.append(("set", "[" + members.joined(separator: ",") + "]"))
pairs.append(("dk", OrderedJSON.string(dk.base64EncodedString())))
pairs.append(("at", String(at)))
return OrderedJSON.object(pairs)
case .sync(let o):
var pairs = head + [
("kind", OrderedJSON.string("sync")), ("ctl", "1"),
("op", OrderedJSON.string(o.op)), ("key", OrderedJSON.string(o.key)),
]
if let val = o.val { pairs.append(("val", OrderedJSON.value(val))) }
pairs.append(("at", String(o.at)))
pairs.append(("dev", OrderedJSON.string(o.dev)))
return OrderedJSON.object(pairs)
case .syncState(let seq, let of, let want, let registers):
var pairs = head + [
("kind", OrderedJSON.string("sync_state")), ("ctl", "1"),
("seq", String(seq)), ("of", String(of)),
]
if want { pairs.append(("want", "1")) }
let regs = registers.map { r -> (String, String) in
var fields: [(String, String)] = [("op", OrderedJSON.string(r.op))]
if let val = r.val { fields.append(("val", OrderedJSON.value(val))) }
fields.append(("at", String(r.at)))
fields.append(("dev", OrderedJSON.string(r.dev)))
return (r.key, OrderedJSON.object(fields))
}
pairs.append(("regs", OrderedJSON.object(regs)))
return OrderedJSON.object(pairs)
case .sent(let to, let gid, let body, let at, let mid):
var pairs = head + [("kind", OrderedJSON.string("sent")), ("ctl", "1")]
if let to { pairs.append(("to", String(to))) }
if let gid { pairs.append(("gid", OrderedJSON.string(gid))) }
pairs.append(("body", OrderedJSON.string(body)))
pairs.append(("at", String(at)))
if let mid { pairs.append(("mid", OrderedJSON.string(mid))) }
return OrderedJSON.object(pairs)
case .history(let seq, let of, let items):
let rendered = items.map { item -> String in
var f: [(String, String)] = [("t", OrderedJSON.string(item.thread))]
if let mid = item.mid { f.append(("mid", OrderedJSON.string(mid))) }
f.append(("at", String(item.at)))
if item.outgoing {
f.append(("out", "true"))
} else if let from = item.from {
f.append(("from", String(from)))
}
f.append(("body", OrderedJSON.string(item.body)))
return OrderedJSON.object(f)
}
return OrderedJSON.object(
head + [
("kind", OrderedJSON.string("history")), ("ctl", "1"),
("seq", String(seq)), ("of", String(of)),
("items", "[" + rendered.joined(separator: ",") + "]"),
])
case .rekeyRequest(let device, let at):
return OrderedJSON.object(
head + [
("kind", OrderedJSON.string("rekey_request")), ("ctl", "1"),
("dev", OrderedJSON.string(device)), ("at", String(at)),
])
}
}
// MARK: - Reading
/// Parse one of these kinds, or nil. A field of the wrong shape makes the
/// whole envelope nil: it is consumed and ignored, never half applied.
init?(text: String) {
guard text.hasPrefix(E2EEnvelopeRoute.controlPrefix),
let data = text.data(using: .utf8),
let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let kind = o["kind"] as? String, Self.kinds.contains(kind)
else { return nil }
let int: (String) -> Int64? = { JSONValue(any: o[$0])?.intValue }
switch kind {
case "link_offer":
guard int("v") == 1, let at = int("at"),
let dkB64 = o["dk"] as? String, let dk = Data(base64Encoded: dkB64), dk.count == 32,
let rawSet = o["set"] as? [[String: Any]]
else { return nil }
var secret: Data?
if let s = o["s"] {
guard let str = s as? String, let bytes = Base64URL.decode(str),
(16...32).contains(bytes.count)
else { return nil }
secret = bytes
}
var members: [Member] = []
for m in rawSet {
guard let d = (m["d"] as? String)?.lowercased(), DeviceLinkCode.isDeviceID(d),
let k = m["k"] as? String, Data(base64Encoded: k)?.count == 32
else { return nil }
members.append(Member(deviceID: d, ikSig: k))
}
guard !members.isEmpty else { return nil }
self = .linkOffer(secret: secret, set: members, dk: dk, at: at)
case "sync":
guard let op = SyncOp(json: o) else { return nil }
self = .sync(op)
case "sync_state":
guard let seq = int("seq"), let of = int("of"), seq >= 1, of >= seq,
let regs = o["regs"] as? [String: Any]
else { return nil }
self = .syncState(
seq: Int(seq), of: Int(of), want: int("want") == 1,
registers: SyncState.ops(fromRegisters: regs).sorted { $0.key < $1.key })
case "sent":
let to = int("to").map(Int.init)
let gid = o["gid"] as? String
guard (to == nil) != (gid == nil), let body = o["body"] as? String,
let at = int("at")
else { return nil }
if let to, to <= 0 { return nil }
self = .sent(to: to, gid: gid, body: body, at: at, mid: o["mid"] as? String)
case "history":
guard let seq = int("seq"), let of = int("of"), seq >= 1,
let raw = o["items"] as? [[String: Any]]
else { return nil }
var items: [HistoryItem] = []
for i in raw {
guard let t = i["t"] as? String, let body = i["body"] as? String,
let at = JSONValue(any: i["at"])?.intValue
else { continue } // one bad item costs that item, not the chunk
let out = (i["out"] as? Bool) == true
let from = JSONValue(any: i["from"])?.intValue.map(Int.init)
guard out || from != nil else { continue }
items.append(
HistoryItem(
thread: t, mid: i["mid"] as? String, at: at, outgoing: out,
from: out ? nil : from, body: body))
}
self = .history(seq: Int(seq), of: Int(of), items: items)
case "rekey_request":
guard let dev = o["dev"] as? String, !dev.isEmpty, let at = int("at") else {
return nil
}
self = .rekeyRequest(device: dev, at: at)
default:
return nil
}
}
}
+339
View File
@@ -0,0 +1,339 @@
import Foundation
import Security
/// Length hiding, spec section 23.
///
/// Every payload this app hands the relay is made exactly one of two lengths,
/// every attachment blob one of nine, and every sealed group roster a multiple
/// of 8192 bytes. The arithmetic has to come out exact: a payload one byte off
/// a ladder step is the very signal padding exists to remove, and no receiver
/// would notice, because receivers ignore the pad. So the length is checked on
/// the bytes, after the fact, and a payload that misses is never sent.
enum Padding {
// MARK: - Constants (section 23.3, 23.9, 23.10)
/// The first message step, in characters of the `payload` string. Named
/// because it may move to 24576 if the relay's histogram says so; every
/// other message number here is derived from the ladder, never written out.
static let messageStep1 = 16384
/// The second and top message step. Above it a message is refused.
static let messageStep2 = 65536
static let messageLadder = [messageStep1, messageStep2]
/// Attachment blob lengths in bytes: 64 KiB doubling to 8 MiB, then the
/// relay's 10 MiB cap.
static let attachmentLadder: [Int] = (0..<8).map { 65536 << $0 } + [10 * 1024 * 1024]
/// A sealed roster's plaintext is padded to this, or the next multiple.
static let rosterTarget = 8192
/// The only characters a pad may contain. None of them is escaped by any
/// JSON writer, so a pad's serialized length is its character count.
static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
/// What `,"pad":""` costs before its value.
static let padKeyOverhead = 9
/// `{"s":1,"eph":"` + 44 + `","ct":"` + `"}`, the canonical outer form.
static let sealedOuterOverhead = 68
/// AES-GCM nonce and tag around an attachment.
static let attachmentOverhead = 12 + 16
// MARK: - What a person can type (section 23.7)
/// The longest text the composer lets through, counted as `composerCost`
/// counts it. About 20,000 characters of plain text, as the spec puts it,
/// and chosen so that it fits the top step with the largest header there
/// is: a first message carrying every post-quantum block, sealed, with a
/// return address and a reply quote (`PaddingPropertyTests`).
static let composerLimit = 20_000
/// What a text costs inside a content envelope: its length as a JSON
/// string, so a quote mark or a control character counts for what it
/// will take on the wire rather than for one.
static func composerCost(_ text: String) -> Int {
guard let json = try? encoder().encode(text) else { return text.utf8.count }
return max(0, json.count - 2)
}
/// A plaintext up to this many bytes fits the top step whatever header it
/// travels with, so a send that size needs no dry run before it spends
/// anything (`PaddingPropertyTests` holds this to the largest header).
static let alwaysFitsPlaintextBytes = 16384
// MARK: - Errors
enum Failure: LocalizedError, Equatable {
/// Section 23.7, the unpadded object does not fit the top step.
case tooLong
/// The bytes did not come out the length the arithmetic aimed at.
/// A bug in this sender; the payload is never sent.
case wrongLength(want: Int, got: Int)
/// Section 23.9, a sealed attachment over the 10 MiB cap.
case attachmentTooLarge(Int)
/// Only a JSON object with at least one key can take a pad.
case notAnObject
var errorDescription: String? {
switch self {
case .tooLong:
return String(localized: "Too long to send as a message. Send it as a file.")
case .wrongLength:
return String(localized: "This message could not be made the right size, so it was not sent.")
case .attachmentTooLarge:
return String(localized: "This file is too large to send. The limit is 10 MB.")
case .notAnObject:
return String(localized: "This message could not be made the right size, so it was not sent.")
}
}
}
// MARK: - The arithmetic (section 23.5, 23.6, 23.7)
enum Kind {
/// The seal's inner JSON (section 23.5).
case sealed
/// An unsealed ratchet envelope's JSON (section 23.6).
case envelope
}
struct Targets: Equatable {
/// Characters of the `payload` string.
let payload: Int
/// UTF-8 length an unsealed envelope's JSON must have, and the length
/// of a sealed payload's canonical outer form.
let envelopeJSON: Int
/// UTF-8 length a sealed payload's inner JSON must have.
let sealedInner: Int
}
/// The lengths that make a `payload` exactly `step` characters.
static func targets(step: Int) -> Targets {
// A step that is not a multiple of 16 leaves `=` padding in one of the
// base64 layers and the arithmetic stops being exact.
precondition(step > 0 && step % 16 == 0, "a ladder step must be a multiple of 16")
let envelopeJSON = step / 4 * 3
let ctChars = envelopeJSON - sealedOuterOverhead
return Targets(
payload: step, envelopeJSON: envelopeJSON, sealedInner: ctChars / 4 * 3 - 16)
}
struct Plan: Equatable {
let step: Int
let target: Int
/// Characters of pad.
let n: Int
}
/// The step and pad length for an object whose unpadded serialization is
/// `len0` UTF-8 bytes. Throws `.tooLong` above the top step.
static func plan(len0: Int, kind: Kind, ladder: [Int] = messageLadder) throws -> Plan {
for step in ladder {
let t = targets(step: step)
let target = kind == .sealed ? t.sealedInner : t.envelopeJSON
let n = target - len0 - padKeyOverhead
if n >= 0 { return Plan(step: step, target: target, n: n) }
}
throw Failure.tooLong
}
// MARK: - The pad (section 23.4)
/// `n` characters drawn uniformly from `alphabet` with the system's
/// cryptographic random source. Rejection sampling: 62 does not divide
/// 256, and `byte % 62` alone would favour the first eight characters.
static func randomPad(_ n: Int) -> String {
var out: [Character] = []
out.reserveCapacity(n)
while out.count < n {
let want = min(65536, n - out.count + 16)
var bytes = [UInt8](repeating: 0, count: want)
let status = SecRandomCopyBytes(kSecRandomDefault, want, &bytes)
precondition(status == errSecSuccess, "the system random source failed")
for b in bytes where b < 248 && out.count < n {
out.append(alphabet[Int(b) % 62])
}
}
return String(out)
}
/// Whether a string is a pad this sender may write.
static func isValidPad(_ pad: String) -> Bool {
pad.unicodeScalars.allSatisfy { $0.isASCII && alphabet.contains(Character($0)) }
}
// MARK: - Serializing with a pad
/// The writer every padded object goes through. `/` is not escaped, so the
/// base64 inside an envelope costs what it looks like it costs.
static func encoder() -> JSONEncoder {
let e = JSONEncoder()
e.outputFormatting = [.withoutEscapingSlashes]
return e
}
/// `json` with `,"pad":"<pad>"` written as its last key.
///
/// Equivalent to serializing the object again with the key added last, as
/// section 23.7 step 4 says, and exact by construction: the bytes before
/// the closing brace are the ones that were measured.
static func appending(pad: String, to json: Data) throws -> Data {
guard json.count > 2, json.first == UInt8(ascii: "{"), json.last == UInt8(ascii: "}")
else { throw Failure.notAnObject }
var out = json.dropLast()
out.append(contentsOf: Array(",\"pad\":\"".utf8))
out.append(contentsOf: Array(pad.utf8))
out.append(contentsOf: Array("\"}".utf8))
return Data(out)
}
/// Section 23.7 steps 1 to 4 and the length check on the result: `json` is
/// the object serialized without a pad, by the writer that writes it.
static func padded(
_ json: Data, kind: Kind,
ladder: [Int] = messageLadder,
padSource: (Int) -> String = randomPad
) throws -> (json: Data, plan: Plan) {
let plan = try plan(len0: json.count, kind: kind, ladder: ladder)
let pad = padSource(plan.n)
guard pad.utf8.count == plan.n, isValidPad(pad) else {
throw Failure.wrongLength(want: plan.n, got: pad.utf8.count)
}
let out = try appending(pad: pad, to: json)
guard out.count == plan.target else {
throw Failure.wrongLength(want: plan.target, got: out.count)
}
return (out, plan)
}
/// An unsealed ratchet envelope as the `payload` field, padded to a ladder
/// step and checked to be exactly that long (section 23.6).
static func envelopePayload(
_ env: E2EEnvelopeJSON,
ladder: [Int] = messageLadder,
padSource: (Int) -> String = randomPad
) throws -> String {
let bare = try env.wireJSON()
let (json, plan) = try padded(bare, kind: .envelope, ladder: ladder, padSource: padSource)
let payload = json.base64EncodedString()
guard payload.count == plan.step else {
throw Failure.wrongLength(want: plan.step, got: payload.count)
}
return payload
}
/// Section 23.7 for a fan-out: throws `.tooLong` when a plaintext this
/// long would miss the top step under the largest header an envelope can
/// carry (a first message with X3DH, the PQX3DH ciphertext and a waiting
/// post-quantum ratchet step), sealed.
///
/// A group message goes to each member on its own session, and whether it
/// fits depends on that session's header. Checked once against the worst
/// header before the first member is sent anything, a message is refused
/// whole or sent whole, never delivered to some members and refused for
/// the rest. Below `alwaysFitsPlaintextBytes` nothing is too long.
static func refuseUnlessItFitsEveryHeader(
plaintextBytes: Int, fromUser: Int, fromDevice: String
) throws {
guard plaintextBytes > alwaysFitsPlaintextBytes else { return }
let inner = try SealedSender.innerJSON(
envelope: worstCaseEnvelope(plaintextBytes: plaintextBytes),
fromUser: fromUser, fromDevice: fromDevice)
_ = try plan(len0: inner.count, kind: .sealed)
}
/// An envelope as long as one around `plaintextBytes` can be: every
/// optional block present, every number at its widest. The bytes are
/// stand-ins; only their lengths matter.
static func worstCaseEnvelope(plaintextBytes: Int) -> E2EEnvelopeJSON {
func b64(_ n: Int) -> String { Data(count: n).base64EncodedString() }
var header = E2EEnvelopeJSON.Header(
dh: b64(32), pn: Int(Int32.max), n: Int(Int32.max))
header.pqdh = b64(PQKEM.publicKeyBytes)
header.pqct = b64(PQKEM.ciphertextBytes)
return E2EEnvelopeJSON(
v: 3, type: "prekey", header: header,
x3dh: .init(
ik_dh: b64(32), ik_sig: b64(32), ek: b64(32),
spk_id: UInt32.max, opk_id: Int(Int32.max)),
pq: .init(kind: "opk", id: UInt32.max, ct: b64(PQKEM.ciphertextBytes)),
ct: b64(plaintextBytes + 16))
}
/// Whether a `payload` string is a ladder length. For checks and tests;
/// receivers never look (section 23.8).
static func isLadderLength(_ payload: String) -> Bool {
messageLadder.contains(payload.utf8.count)
}
// MARK: - Attachments (section 23.9)
/// The step an attachment of `clen` sealed bytes is padded to.
static func attachmentStep(clen: Int, ladder: [Int] = attachmentLadder) throws -> Int {
guard let step = ladder.first(where: { $0 >= clen }) else {
throw Failure.attachmentTooLarge(clen)
}
return step
}
/// `n` bytes from the cryptographic random source. Zeros would show
/// anybody holding the blob where the ciphertext ends.
static func randomTail(_ n: Int) -> Data {
var out = Data(count: n)
guard n > 0 else { return out }
let status = out.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, n, $0.baseAddress!)
}
precondition(status == errSecSuccess, "the system random source failed")
return out
}
// MARK: - Sealed rosters (section 23.10)
/// A roster's plaintext JSON padded to 8192 bytes, or to the next multiple
/// of 8192 when a long name does not fit. Never refused.
static func rosterPlaintext(
_ json: Data, padSource: (Int) -> String = randomPad
) throws -> Data {
var target = rosterTarget
while target - json.count - padKeyOverhead < 0 { target += rosterTarget }
let n = target - json.count - padKeyOverhead
let pad = padSource(n)
guard pad.utf8.count == n, isValidPad(pad) else {
throw Failure.wrongLength(want: n, got: pad.utf8.count)
}
let out = try appending(pad: pad, to: json)
guard out.count == target else { throw Failure.wrongLength(want: target, got: out.count) }
return out
}
}
// MARK: - One writer for the envelope
extension E2EEnvelopeJSON {
/// This envelope's JSON, written in one fixed key order.
///
/// `JSONEncoder` writes a struct's keys in whatever order its dictionary
/// comes out, which differs from run to run. The length is the same either
/// way, so padding would still be right, but the bytes could never be
/// compared with the published vector, and a padded payload that cannot be
/// reproduced cannot be checked. The order is the declaration order, which
/// is also the reference engine's. Receivers decode by key and do not care.
func wireJSON() throws -> Data {
func str(_ s: String) throws -> String {
String(decoding: try Padding.encoder().encode(s), as: UTF8.self)
}
var json = "{\"v\":\(v),\"type\":\(try str(type))"
json += ",\"header\":" + String(decoding: canonicalHeaderBytes, as: UTF8.self)
if let x = x3dh {
json += ",\"x3dh\":{\"ik_dh\":\(try str(x.ik_dh)),\"ik_sig\":\(try str(x.ik_sig))"
json += ",\"ek\":\(try str(x.ek)),\"spk_id\":\(x.spk_id),\"opk_id\":\(x.opk_id)}"
}
if let p = pq {
json += ",\"pq\":{\"kind\":\(try str(p.kind)),\"id\":\(p.id),\"ct\":\(try str(p.ct))}"
}
json += ",\"ct\":\(try str(ct))}"
return Data(json.utf8)
}
}
+9 -6
View File
@@ -29,11 +29,14 @@ public enum RelayIdentityPin {
/// What to tell somebody when it changed.
public static func warning(host: String) -> String {
"The server at \(host) is presenting a different identity than the one "
+ "this app recorded the first time it connected. That happens when the "
+ "server's keys are rotated on purpose, and it also happens when "
+ "something else is answering in its place. Nothing here can tell those "
+ "apart. If you were not expecting a change, stop and check with "
+ "whoever runs it before signing in again."
String(
localized: """
The server at \(host) is presenting a different identity than the one \
this app recorded the first time it connected. That happens when the \
server's keys are rotated on purpose, and it also happens when \
something else is answering in its place. Nothing here can tell those \
apart. If you were not expecting a change, stop and check with \
whoever runs it before signing in again.
""")
}
}
+8 -4
View File
@@ -113,12 +113,16 @@ struct RelayRepair {
// doing everything in turn.
guard credentialOK else {
return .needsPerson(
"This device is signed in but the server is not accepting it. "
+ "Signing out and back in will fix it.")
String(
localized:
"This device is signed in but the server is not accepting it. Signing out and back in will fix it."
))
}
return .needsPerson(
"This device could not register with the server. Check the "
+ "connection and try again in a moment.")
String(
localized:
"This device could not register with the server. Check the connection and try again in a moment."
))
}
return .repaired
+4 -3
View File
@@ -26,7 +26,8 @@ enum RevocationLatch {
}
/// What a client may say when the log cannot be reached.
static let logUnavailableNote =
"The key log could not be reached, so new device changes cannot be checked "
+ "right now. Devices already known to be revoked stay revoked."
static let logUnavailableNote = String(
localized:
"The key log could not be reached, so new device changes cannot be checked right now. Devices already known to be revoked stay revoked."
)
}
+11 -2
View File
@@ -27,8 +27,17 @@ enum SealedRoster {
}
/// Seal a roster for storage on the relay.
static func seal(_ contents: Contents, key: Data) throws -> String {
let plain = try JSONEncoder().encode(contents)
///
/// The plaintext is padded to 8192 bytes, or the next multiple of 8192
/// for a name too long to fit (section 23.10), so the blob is the same
/// length for every group within the 32-member cap. Readers ignore `pad`:
/// `Contents` has no such field. `padSource` exists for tests.
static func seal(
_ contents: Contents, key: Data,
padSource: (Int) -> String = Padding.randomPad
) throws -> String {
let plain = try Padding.rosterPlaintext(
try JSONEncoder().encode(contents), padSource: padSource)
guard let box = try AES.GCM.seal(plain, using: SymmetricKey(data: key)).combined else {
throw E2EError.crypto("roster seal produced no combined box")
}
+97 -3
View File
@@ -35,7 +35,11 @@ enum SealedSender {
return (SymmetricKey(data: raw.prefix(32)), nonce)
}
/// Wrap an envelope so the relay cannot see who sent it.
/// Wrap an envelope so the relay cannot see who sent it, WITHOUT padding.
///
/// What a sender before section 23 wrote. Every send path in the app uses
/// `sealedPayload` instead; this stays for tests that stand in for an
/// older sender, which receivers must go on reading.
/// - Parameter fromAddress: the sender's `fnid@relay`, for a message that
/// crosses a relay boundary.
static func seal(
@@ -65,6 +69,85 @@ enum SealedSender {
ct: (box.ciphertext + box.tag).e2eBase64)
}
/// Seal an envelope and return the `payload` string, padded to a ladder
/// step and checked to be exactly that long (section 23.5, 23.7).
///
/// The inner JSON carries `pad` as its last key, under the seal's
/// encryption. The outer blob is written by template in its canonical form,
/// never by a JSON library: `JSONEncoder` writes every `/` in the base64 as
/// `\/`, about one character in 32, and the length misses the step.
///
/// Throws `Padding.Failure.tooLong` above the top step, and `.wrongLength`
/// if the bytes and the arithmetic disagree. Either way nothing comes back,
/// so nothing can be sent. `ephemeral` and `padSource` exist for the
/// known-answer vector; production callers pass neither.
static func sealedPayload(
envelope: E2EEnvelopeJSON,
fromUser: Int,
fromDevice: String,
fromAddress: String? = nil,
toDevice: String,
recipientIKdh: Data,
ladder: [Int] = Padding.messageLadder,
ephemeral: Curve25519.KeyAgreement.PrivateKey? = nil,
padSource: (Int) -> String = Padding.randomPad
) throws -> String {
let bare = try innerJSON(
envelope: envelope, fromUser: fromUser, fromDevice: fromDevice,
fromAddress: fromAddress)
let (inner, plan) = try Padding.padded(
bare, kind: .sealed, ladder: ladder, padSource: padSource)
let blob = try seal(
inner: inner, toDevice: toDevice, recipientIKdh: recipientIKdh,
ephemeral: ephemeral)
let payload = Data(canonicalOuterJSON(blob).utf8).base64EncodedString()
guard payload.count == plan.step else {
throw Padding.Failure.wrongLength(want: plan.step, got: payload.count)
}
return payload
}
/// The inner JSON without a pad, as the padded seal writes it. Its length
/// is what decides the step (section 23.7 step 1).
///
/// Written in a fixed key order, `from_user`, `from_device`, `from_addr`
/// when present, `envelope`, for the reason on `E2EEnvelopeJSON.wireJSON`.
static func innerJSON(
envelope: E2EEnvelopeJSON, fromUser: Int, fromDevice: String,
fromAddress: String? = nil
) throws -> Data {
func str(_ s: String) throws -> String {
String(decoding: try Padding.encoder().encode(s), as: UTF8.self)
}
var json = "{\"from_user\":\(fromUser),\"from_device\":\(try str(fromDevice))"
if let fromAddress { json += ",\"from_addr\":\(try str(fromAddress))" }
json += ",\"envelope\":"
return Data(json.utf8) + (try envelope.wireJSON()) + Data("}".utf8)
}
/// The canonical outer form of section 23.5, byte for byte: keys in this
/// order, no spaces, `/` never escaped.
static func canonicalOuterJSON(_ blob: Blob) -> String {
"{\"s\":1,\"eph\":\"\(blob.eph)\",\"ct\":\"\(blob.ct)\"}"
}
/// Encrypt already-serialized inner JSON.
private static func seal(
inner: Data, toDevice: String, recipientIKdh: Data,
ephemeral: Curve25519.KeyAgreement.PrivateKey?
) throws -> Blob {
let eph = ephemeral ?? Curve25519.KeyAgreement.PrivateKey()
let peer = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: recipientIKdh)
let shared = try eph.sharedSecretFromKeyAgreement(with: peer)
guard let (key, nonce) = derive(shared: shared) else {
throw E2EError.crypto("sealed sender: key derivation failed")
}
let ephPub = eph.publicKey.rawRepresentation
let aad = Data(toDevice.utf8) + ephPub
let box = try AES.GCM.seal(inner, using: key, nonce: nonce, authenticating: aad)
return Blob(s: 1, eph: ephPub.e2eBase64, ct: (box.ciphertext + box.tag).e2eBase64)
}
/// Open a sealed blob with this device's `IK_dh`.
static func open(
blob: Blob,
@@ -89,6 +172,8 @@ enum SealedSender {
tag: ctTag.suffix(16)
)
let plain = try AES.GCM.open(box, using: key, authenticating: aad)
// `pad`, when present, is ignored (section 23.8): `Inner` has no such
// field, and synthesized decoding skips keys it does not know.
let inner = try JSONDecoder().decode(Inner.self, from: plain)
return Opened(
claimedFromUser: inner.from_user,
@@ -138,9 +223,18 @@ struct DeliveryKey {
/// The control message handing a contact our key (§14.4) and, since §21.4,
/// a batch of single-use capabilities alongside it.
func contentEnvelopeJSON(tokens: [String], presenceToken: String? = nil) -> String {
contentEnvelopeJSON(
func contentEnvelopeJSON(
tokens: [String], presenceToken: String? = nil,
handleCert: HandleCertificate? = nil
) -> String {
let base = contentEnvelopeJSON(
tokens: tokens, gid: nil, groupName: nil, presenceToken: presenceToken)
// Section 14.5: on a first message, the sender's own handle
// certificate, so the recipient can show a name the relay vouched for
// without asking the relay who this is. Last, after the tokens, as
// `content-v1.json`'s `prelude-first-contact` pins it.
guard let handleCert else { return base }
return String(base.dropLast()) + ",\"hc\":" + handleCert.orderedJSON + "}"
}
/// The same envelope, optionally also telling this contact what a group is
+11 -6
View File
@@ -50,15 +50,20 @@ struct SealingRecord {
/// The sentence shown to the user.
var summary: String {
guard let share = unsealedShare else {
return "Nothing sent from this device yet."
return String(localized: "Nothing sent from this device yet.")
}
if unsealed == 0 {
return "All \(sealed) messages sent from this device were sealed, "
+ "the server was never told who sent them."
return String(
localized:
"All \(sealed) messages sent from this device were sealed, the server was never told who sent them."
)
}
let percent = Int((share * 100).rounded())
return "\(unsealed) of \(total) messages sent from this device (\(percent)%) "
+ "went unsealed. Those were still end-to-end encrypted, the server "
+ "could see who sent them, not what they said."
return String(
localized: """
\(unsealed) of \(total) messages sent from this device (\(percent)%) \
went unsealed. Those were still end-to-end encrypted, the server \
could see who sent them, not what they said.
""")
}
}
+385
View File
@@ -0,0 +1,385 @@
import Foundation
/// A JSON value, kept exactly as it arrived so a register this build does not
/// understand can be stored and passed on unchanged (section 17.6).
///
/// Integers are kept apart from other numbers: `at` and read marks are epoch
/// milliseconds, and a value that went through a `Double` and came back as
/// `1.7999999999e12` would compare wrong and travel wrong.
indirect enum JSONValue: Equatable, Hashable, Codable {
case null
case bool(Bool)
case int(Int64)
case double(Double)
case string(String)
case array([JSONValue])
case object([String: JSONValue])
/// From whatever `JSONSerialization` produced. Nil for something that is
/// not JSON at all.
init?(any value: Any?) {
guard let value else {
self = .null
return
}
switch value {
case is NSNull: self = .null
case let n as NSNumber:
if CFGetTypeID(n) == CFBooleanGetTypeID() {
self = .bool(n.boolValue)
} else if CFNumberIsFloatType(n) {
let d = n.doubleValue
if d.rounded() == d, abs(d) < 9_007_199_254_740_992 {
self = .int(Int64(d))
} else {
self = .double(d)
}
} else {
self = .int(n.int64Value)
}
case let s as String: self = .string(s)
case let a as [Any]:
var out: [JSONValue] = []
for item in a {
guard let v = JSONValue(any: item) else { return nil }
out.append(v)
}
self = .array(out)
case let o as [String: Any]:
var out: [String: JSONValue] = [:]
for (k, item) in o {
guard let v = JSONValue(any: item) else { return nil }
out[k] = v
}
self = .object(out)
default: return nil
}
}
/// What `JSONSerialization` writes back.
var any: Any {
switch self {
case .null: return NSNull()
case .bool(let b): return b
case .int(let i): return NSNumber(value: i)
case .double(let d): return d
case .string(let s): return s
case .array(let a): return a.map(\.any)
case .object(let o): return o.mapValues(\.any)
}
}
var intValue: Int64? {
if case .int(let i) = self { return i }
return nil
}
var stringValue: String? {
if case .string(let s) = self { return s }
return nil
}
var objectValue: [String: JSONValue]? {
if case .object(let o) = self { return o }
return nil
}
var boolValue: Bool? {
if case .bool(let b) = self { return b }
return nil
}
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() {
self = .null
} else if let b = try? c.decode(Bool.self) {
self = .bool(b)
} else if let i = try? c.decode(Int64.self) {
self = .int(i)
} else if let d = try? c.decode(Double.self) {
self = .double(d)
} else if let s = try? c.decode(String.self) {
self = .string(s)
} else if let a = try? c.decode([JSONValue].self) {
self = .array(a)
} else {
self = .object(try c.decode([String: JSONValue].self))
}
}
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case .bool(let b): try c.encode(b)
case .int(let i): try c.encode(i)
case .double(let d): try c.encode(d)
case .string(let s): try c.encode(s)
case .array(let a): try c.encode(a)
case .object(let o): try c.encode(o)
}
}
}
/// Section 17.6, the register merge that every device of one account runs.
///
/// A function of the registers alone: two devices that have seen the same ops,
/// in any order and any number of times, hold the same registers. Ported from
/// the reference engine's `sync.js` and checked against
/// `test/vectors/sync-merge-v1.json`, every case in every order of its ops,
/// because a tie two engines break differently is a conversation archived on
/// one phone and not the other, for ever, with nothing to say why.
enum SyncMerge {
static let dayMs: Int64 = 24 * 60 * 60 * 1000
/// How long a removal is remembered.
static let tombstoneMs: Int64 = 90 * dayMs
/// How far ahead of the receiver's clock an op may claim to be.
static let futureSkewMs: Int64 = dayMs
/// Offline this long or longer and a device asks for snapshots.
static let snapshotAfterMs: Int64 = 7 * dayMs
/// Read marks: at most one op per thread per window.
static let readCoalesceMs: Int64 = 30 * 1000
/// What one `history` or `sync_state` chunk may carry, in bytes of JSON.
static let chunkBytes = 16 * 1024
/// The most messages a backfill carries (section 17.4).
static let historyMaxItems = 10_000
enum Rule: String { case register, setElement = "set-element", max }
/// Which families take which rule. Anything else is an ordinary register,
/// kept and passed on by a device that does not understand it.
static let families: [String: Rule] = [
"link": .setElement, "roster": .setElement, "blocked": .setElement,
"acc": .setElement, "muted": .setElement, "read": .max,
]
static func family(of key: String) -> String {
guard let slash = key.firstIndex(of: "/") else { return key }
return String(key[..<slash])
}
static func rule(for key: String) -> Rule { families[family(of: key)] ?? .register }
static func ops(for rule: Rule) -> Set<String> {
switch rule {
case .register: return ["set", "del"]
case .setElement: return ["add", "rm"]
case .max: return ["max"]
}
}
static func isRemoval(_ op: String) -> Bool { op == "del" || op == "rm" }
/// The largest integer every engine holds exactly.
static let maxSafeInteger: Int64 = 9_007_199_254_740_991
}
/// One op, or one register (a register is the last op a device accepted for
/// its key).
struct SyncOp: Equatable, Codable {
var op: String
var key: String
var val: JSONValue?
var at: Int64
var dev: String
/// Whether the op is well formed for its key. A malformed op is ignored,
/// never half applied.
var isValid: Bool {
guard !key.isEmpty, key.utf16.count <= 512, !dev.isEmpty else { return false }
guard at >= 0, at <= SyncMerge.maxSafeInteger else { return false }
let rule = SyncMerge.rule(for: key)
guard SyncMerge.ops(for: rule).contains(op) else { return false }
if op == "max" {
guard let v = val?.intValue, v >= 0, v <= SyncMerge.maxSafeInteger else {
return false
}
}
return true
}
/// From one JSON object, as `sync` carries it, or nil. `at` that is not an
/// integer is not an op.
init?(json: [String: Any], key overriding: String? = nil) {
guard let op = json["op"] as? String,
let key = overriding ?? (json["key"] as? String),
let dev = json["dev"] as? String,
let atValue = JSONValue(any: json["at"]), case .int(let at) = atValue
else { return nil }
self.op = op
self.key = key
self.dev = dev
self.at = at
if json.keys.contains("val") {
guard let v = JSONValue(any: json["val"]) else { return nil }
self.val = v
} else {
self.val = nil
}
}
init(op: String, key: String, val: JSONValue? = nil, at: Int64, dev: String) {
self.op = op
self.key = key
self.val = val
self.at = at
self.dev = dev
}
/// The op as `sync` writes it.
var json: [String: Any] {
var out: [String: Any] = ["op": op, "key": key, "at": NSNumber(value: at), "dev": dev]
if let val { out["val"] = val.any }
return out
}
/// The register as `sync_state` writes it, without the key.
var registerJSON: [String: Any] {
var out: [String: Any] = ["op": op, "at": NSNumber(value: at), "dev": dev]
if let val { out["val"] = val.any }
return out
}
}
/// Every register one device holds, keyed by register key.
struct SyncState: Equatable, Codable {
private(set) var registers: [String: SyncOp] = [:]
init(registers: [String: SyncOp] = [:]) { self.registers = registers }
subscript(key: String) -> SyncOp? { registers[key] }
/// Whether `incoming` replaces `current` (section 17.6). Later `at` wins;
/// on equal `at` a block beats an unblock; otherwise the greater device id,
/// compared as text. Equal `at` and device is the same op seen twice.
private static func replaces(_ incoming: SyncOp, _ current: SyncOp?) -> Bool {
guard let current else { return true }
if incoming.at != current.at { return incoming.at > current.at }
if SyncMerge.family(of: incoming.key) == "blocked", incoming.op != current.op {
return incoming.op == "add"
}
if incoming.dev != current.dev { return Self.textGreater(incoming.dev, current.dev) }
return false
}
/// Text comparison by UTF-16 code unit, which is what the reference
/// engine's `>` on strings does; Swift's `String` `>` compares by
/// Unicode scalar and can disagree outside the Basic Multilingual Plane.
static func textGreater(_ a: String, _ b: String) -> Bool {
let x = Array(a.utf16)
let y = Array(b.utf16)
for i in 0..<min(x.count, y.count) where x[i] != y[i] { return x[i] > y[i] }
return x.count > y.count
}
/// Merge one op. Returns whether anything changed. `now` is the
/// receiver's clock in milliseconds.
@discardableResult
mutating func merge(_ o: SyncOp, now: Int64) -> Bool {
guard o.isValid else { return false }
// A clock a year fast would otherwise win every register it touched
// until the year came round.
guard o.at <= now + SyncMerge.futureSkewMs else { return false }
var reg = o
if SyncMerge.isRemoval(o.op) { reg.val = nil }
let current = registers[o.key]
if SyncMerge.rule(for: o.key) == .max {
// A read mark only moves forward, whatever the clocks say.
let incoming = reg.val?.intValue ?? 0
if let current, let held = current.val?.intValue {
if held > incoming { return false }
if held == incoming, !Self.replaces(reg, current) { return false }
}
registers[o.key] = reg
return true
}
guard Self.replaces(reg, current) else { return false }
registers[o.key] = reg
return true
}
@discardableResult
mutating func merge(_ ops: [SyncOp], now: Int64) -> Bool {
var changed = false
for o in ops where merge(o, now: now) { changed = true }
return changed
}
/// A snapshot's `regs` object as ops. A register that is not an op is
/// skipped, exactly as a malformed op would be.
static func ops(fromRegisters regs: [String: Any]) -> [SyncOp] {
regs.compactMap { key, value in
guard let o = value as? [String: Any] else { return nil }
return SyncOp(json: o, key: key)
}
}
/// Forget removals older than the tombstone window.
mutating func collectTombstones(now: Int64) {
registers = registers.filter { _, r in
!(SyncMerge.isRemoval(r.op) && r.at < now - SyncMerge.tombstoneMs)
}
}
/// What the registers say: each live key and its value (`true` for a set
/// element that carries none). Removed keys are absent.
var view: [String: JSONValue] {
var out: [String: JSONValue] = [:]
for (key, r) in registers where !SyncMerge.isRemoval(r.op) {
out[key] = r.val ?? .bool(true)
}
return out
}
/// Whether a set element is present (`add` beats nothing).
func contains(_ key: String) -> Bool {
guard let r = registers[key] else { return false }
return !SyncMerge.isRemoval(r.op)
}
/// The live keys of one family, with the family prefix stripped.
func members(of family: String) -> [String] {
let prefix = family + "/"
return registers.compactMap { key, r in
guard key.hasPrefix(prefix), !SyncMerge.isRemoval(r.op) else { return nil }
return String(key.dropFirst(prefix.count))
}.sorted()
}
/// Keep only `link/…` registers (section 17.6, a device away 90 days or
/// more starts again from its linked devices' snapshots).
mutating func keepOnlyLinks() {
registers = registers.filter { key, _ in SyncMerge.family(of: key) == "link" }
}
/// The registers as a `sync_state` `regs` object.
var registersJSON: [String: Any] { registers.mapValues(\.registerJSON) }
}
extension SyncMerge {
/// Split JSON-able items into chunks of at most `limit` bytes of item
/// JSON, keeping order. An item larger than the limit travels alone.
static func chunk(_ items: [[String: Any]], limit: Int = chunkBytes) -> [[[String: Any]]] {
var chunks: [[[String: Any]]] = []
var current: [[String: Any]] = []
var size = 0
for item in items {
let n =
((try? JSONSerialization.data(withJSONObject: item, options: [.sortedKeys]))?
.count ?? 0) + 1
if !current.isEmpty, size + n > limit {
chunks.append(current)
current = []
size = 0
}
current.append(item)
size += n
}
if !current.isEmpty { chunks.append(current) }
return chunks
}
/// Milliseconds since the epoch, as every op carries it.
static func nowMs(_ date: Date = Date()) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
}
+47 -8
View File
@@ -9,10 +9,10 @@ struct VerificationRecord: Codable, Equatable {
var label: String {
switch self {
case .inPerson: return "in person"
case .qrCode: return "by scanning their code"
case .call: return "over a call"
case .other: return "some other way"
case .inPerson: return String(localized: "in person", comment: "How a safety number was compared")
case .qrCode: return String(localized: "by scanning their code")
case .call: return String(localized: "over a call")
case .other: return String(localized: "some other way")
}
}
}
@@ -30,11 +30,50 @@ struct VerificationRecord: Codable, Equatable {
func summary(now: Date = Date()) -> String {
let when = verifiedAt.formatted(date: .abbreviated, time: .omitted)
guard let brokenAt else {
return "You verified them \(method.label) on \(when), on this device."
// One whole sentence per method, so a translation never has to
// splice a phrase into the middle of another one.
switch method {
case .inPerson:
return String(localized: "You verified them in person on \(when), on this device.")
case .qrCode:
return String(
localized: "You verified them by scanning their code on \(when), on this device.")
case .call:
return String(localized: "You verified them over a call on \(when), on this device.")
case .other:
return String(localized: "You verified them some other way on \(when), on this device.")
}
}
let broke = brokenAt.formatted(date: .abbreviated, time: .omitted)
return "You verified them \(method.label) on \(when). Their keys changed "
+ "on \(broke), so that check no longer applies, it was made against "
+ "the keys they had then."
switch method {
case .inPerson:
return String(
localized: """
You verified them in person on \(when). Their keys changed \
on \(broke), so that check no longer applies, it was made against \
the keys they had then.
""")
case .qrCode:
return String(
localized: """
You verified them by scanning their code on \(when). Their keys changed \
on \(broke), so that check no longer applies, it was made against \
the keys they had then.
""")
case .call:
return String(
localized: """
You verified them over a call on \(when). Their keys changed \
on \(broke), so that check no longer applies, it was made against \
the keys they had then.
""")
case .other:
return String(
localized: """
You verified them some other way on \(when). Their keys changed \
on \(broke), so that check no longer applies, it was made against \
the keys they had then.
""")
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import Foundation
/// Adding somebody to the contact list, in one place, as `ContactRemoval` is
/// for taking them off.
///
/// Section 8.9d: with the contact list off the relay, a contact is added by
/// the lookup that found them and onto the sealed list, never through the
/// relay's add-a-buddy route, which would write the very pair the setting
/// exists to avoid. Four surfaces add (the Home search, the add sheet, the
/// Messages add sheet, `/add`), and three of them went to the relay whatever
/// the setting said. Adding somebody also accepts them (section 14.5).
enum ContactAdd {
/// Add `person`, and return what went wrong, or nil.
@MainActor
@discardableResult
static func perform(
_ person: BuddyProfile, inbox: E2EInbox?, api: MessengerAPI?, pagePath: String
) async -> String? {
if inbox?.rosterOffServer == true {
inbox?.accept(person.id)
inbox?.rosterAdd(id: person.id, handle: person.slug, displayName: person.name)
return nil
}
guard let api else { return String(localized: "Not connected to a site.") }
do {
try await api.addBuddy(username: person.slug, fromPagePath: pagePath)
inbox?.accept(person.id)
return nil
} catch {
return (error as? MessengerError)?.errorDescription ?? error.localizedDescription
}
}
/// Add by username alone, as `/add` does: looked up first, so the
/// off-relay path has an id to put on the sealed list.
@MainActor
static func perform(
username: String, inbox: E2EInbox?, api: MessengerAPI?, pagePath: String
) async -> String? {
guard let api else { return String(localized: "Not connected to a site.") }
do {
guard let person = try await api.lookupMember(username: username) else {
return String(localized: "Nobody here is called \(username).")
}
return await perform(person, inbox: inbox, api: api, pagePath: pagePath)
} catch {
return (error as? MessengerError)?.errorDescription ?? error.localizedDescription
}
}
}
+4 -1
View File
@@ -15,6 +15,9 @@ enum ContactRemoval {
@discardableResult
static func perform(id: Int, inbox: E2EInbox?, api: MessengerAPI?) async -> String? {
inbox?.rosterRemove(id: id)
// A removed contact is no longer accepted: a later message from them
// is a request again rather than a conversation that reopens itself.
inbox?.unaccept(id)
// And the presence pair, which the roster does not carry. Taking
// somebody off the list left them holding a token that still answered
// for this account: the relay goes on saying "online" to whoever
@@ -30,7 +33,7 @@ enum ContactRemoval {
// Off the server there is no relay row to delete, and asking for one
// is an error the person did not cause.
if inbox?.rosterOffServer == true { return nil }
guard let api else { return "Not connected to a site." }
guard let api else { return String(localized: "Not connected to a site.") }
do {
try await api.removeBuddy(buddyID: id)
return nil
+21
View File
@@ -0,0 +1,21 @@
import Foundation
/// Section 8.14, Listed and Unlisted (decision DB1), and the choice offered at
/// sign-up (DB2).
///
/// The words matter and the spec pins them: Unlisted means people cannot
/// find or ADD you by your username. It never means the username is secret,
/// because anyone trying to sign up with it is told it is taken.
enum ListingChoice {
static let pendingKey = "fnmsg.signupChoseUnlisted"
static let explanation =
String(localized: "If you turn this off, nobody can find or add you by your username. People reach you through a link or QR code you give them. Your username isn't secret, though: anyone who tries to sign up with it is told it's taken.")
/// A choice made at sign-up and not yet applied, kept until the relay
/// can take it.
static var pendingUnlisted: Bool {
get { UserDefaults.standard.bool(forKey: pendingKey) }
set { UserDefaults.standard.set(newValue, forKey: pendingKey) }
}
}
+12 -12
View File
@@ -35,10 +35,10 @@ enum Presence: String, Codable, CaseIterable {
/// What the picker says.
var label: String {
switch self {
case .online: return "Online"
case .away: return "Away"
case .busy: return "Busy"
case .invisible: return "Appear offline"
case .online: return String(localized: "Online")
case .away: return String(localized: "Away")
case .busy: return String(localized: "Busy")
case .invisible: return String(localized: "Appear offline")
}
}
@@ -46,23 +46,23 @@ enum Presence: String, Codable, CaseIterable {
var explanation: String {
switch self {
case .online:
return "Contacts see you as online while the app has been used recently."
return String(localized: "Contacts see you as online while the app has been used recently.")
case .away:
return "Contacts see you as away. Messages still arrive normally."
return String(localized: "Contacts see you as away. Messages still arrive normally.")
case .busy:
return "Contacts see you as busy. Nothing is blocked, it is a hint, not a filter."
return String(localized: "Contacts see you as busy. Nothing is blocked, it is a hint, not a filter.")
case .invisible:
return "Contacts see you as offline. You still receive messages."
return String(localized: "Contacts see you as offline. You still receive messages.")
}
}
}
var label: String {
switch self {
case .online: return "Online"
case .away: return "Away"
case .busy: return "Busy"
case .offline: return "Offline"
case .online: return String(localized: "Online")
case .away: return String(localized: "Away")
case .busy: return String(localized: "Busy")
case .offline: return String(localized: "Offline")
}
}
+39
View File
@@ -0,0 +1,39 @@
import Foundation
/// Features that connect to a host other than the Flamenet server, and so
/// cannot go through a bridge.
///
/// A bridge forwards to the edge in front of the Flamenet server and nowhere
/// else. A link preview fetches the linked site, a call connects to the other
/// person or a TURN server, the wallet asks a public chain node, a live stream
/// plays from wherever it is hosted. Each of those would be a direct
/// connection, and SPEC §24.6 says a client with bridges turned on must not
/// make one. So each is off while a line is saved, and says so in words.
enum AroundTheBridge {
/// A line is saved, so these features are off.
static var active: Bool { Obfs4Settings.isEnabled }
static let linkPreviewsOff =
String(localized: "Link previews are off while you use a bridge. Making one would connect to the linked website directly, around the bridge.")
static let callsOff =
String(localized: "Calls are off while you use a bridge. A call connects around the bridge, so the network you're on could see it.")
static let walletOff =
String(localized: "The wallet is off while you use a bridge. It talks to a public chain node directly, and a bridge only reaches your Flamenet server.")
static func streamOff(host: String) -> String {
String(localized: "Live streams are off while you use a bridge. Playing this one would connect to \(host) directly, around the bridge.")
}
/// Thrown where a connection would have been made.
struct Refused: LocalizedError, Equatable {
let message: String
var errorDescription: String? { message }
}
/// For a fetch from a site that is not the Flamenet server, once the
/// caller has checked `active`. Ephemeral, not the shared session, so
/// nothing it holds is reused by anything else.
static let thirdParty = URLSession(configuration: .ephemeral)
}
+71 -2
View File
@@ -60,7 +60,7 @@ final class MessengerAPI {
guard (200..<300).contains(http.statusCode) else {
throw Self.restError(status: http.statusCode, body: data)
}
return (try? decoder.decode(MessageOnlyResponse.self, from: data).message) ?? "Done."
return (try? decoder.decode(MessageOnlyResponse.self, from: data).message) ?? String(localized: "Done.")
}
// MARK: - Buddies
@@ -243,6 +243,23 @@ final class MessengerAPI {
// MARK: - Display name
/// Section 8.14, stage B1: whether people can find this account by its
/// username. Nil from a relay that does not say.
func discoverable() async throws -> Bool? {
let url = client.restURL.appendingPathComponent("flamenet/v1/messenger/me")
let (data, http) = try await client.getJSON(at: url)
guard (200..<300).contains(http.statusCode) else {
throw Self.restError(status: http.statusCode, body: data)
}
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return object?["discoverable"] as? Bool
}
/// Listed (true) or Unlisted (false).
func setDiscoverable(_ listed: Bool) async throws {
try await putMe(["discoverable": listed])
}
func updateDisplayName(_ name: String) async throws {
try await putMe(["name": name])
}
@@ -340,6 +357,36 @@ final class MessengerAPI {
}
}
// MARK: - Web search
/// `POST /api/flamenet/v1/search`: the relay asks its own index on this
/// member's behalf and keeps nothing. The query goes in the body, never
/// the URL, so it is in no access log; it is never logged here either.
func webSearch(_ query: String, page: Int = 1) async throws -> WebSearchPage {
var request = URLRequest(
url: client.restURL.appendingPathComponent("flamenet/v1/search"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let h = client.basicAuthHeader {
request.setValue(h, forHTTPHeaderField: "Authorization")
}
request.httpBody = try JSONSerialization.data(withJSONObject: ["q": query, "page": page])
let (data, http) = try await client.sendUnauthenticated(request: request)
if http.statusCode == 429 {
throw MessengerError.rateLimited(
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
}
guard (200..<300).contains(http.statusCode) else {
throw Self.restError(status: http.statusCode, body: data)
}
do {
return try decoder.decode(WebSearchPage.self, from: data)
} catch {
throw MessengerError.decoding(error.localizedDescription)
}
}
// MARK: - Member lookup
/// Find one member by their exact username.
@@ -445,7 +492,7 @@ private struct BuddyListResponse: Decodable {
if !display.isEmpty { return display }
let handle = (screen_name ?? "").trimmingCharacters(in: .whitespaces)
if !handle.isEmpty { return handle }
return "User #\(id)"
return String(localized: "User #\(String(id))")
}
func toBuddy() -> Buddy {
@@ -465,8 +512,30 @@ struct BlockedUser: Decodable, Identifiable {
let id: Int
let name: String
let slug: String
init(id: Int, name: String, slug: String) {
self.id = id
self.name = name
self.slug = slug
}
}
private struct BlockedEnvelope: Decodable {
let blocked: [BlockedUser]
}
/// What `POST /api/flamenet/v1/search` answers. `unavailable` means the index
/// is down or slow, which is not the same as finding nothing.
struct WebSearchPage: Decodable, Equatable {
struct Result: Decodable, Equatable, Identifiable {
let title: String
let url: String
let host: String
let snippet: String
let source: String
var id: String { url }
}
let results: [Result]
let sources: [String]
let unavailable: Bool
}
+30 -23
View File
@@ -5,7 +5,12 @@ final class MessengerClient {
let baseURL: URL
let restPrefix: String
private let session: URLSession
/// Follows the bridge setting: a client made before a bridge was set
/// must not go on connecting directly.
private let sessions: BridgedSession
private var session: URLSession { sessions.session }
/// The session requests go out on now, for tests.
var transportSession: URLSession { session }
let cookieStorage: HTTPCookieStorage
private(set) var basicAuthHeader: String?
private(set) var basicAuthUsername: String?
@@ -20,26 +25,25 @@ final class MessengerClient {
self.restPrefix = restPrefix
let storage = HTTPCookieStorage()
let config = URLSessionConfiguration.default
config.httpCookieStorage = storage
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.requestCachePolicy = .reloadIgnoringLocalCacheData
// The policy above only skips the cache on READ.
config.urlCache = nil
config.timeoutIntervalForRequest = 20
config.httpAdditionalHeaders = [
"User-Agent": "FlamenetMessenger/1.0 (iOS)",
"Accept": "application/json, text/html;q=0.9, */*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
]
if let protocolClasses { config.protocolClasses = protocolClasses }
self.cookieStorage = storage
// Unconditional by design: a no-op unless a bridge is configured, which
// it is not by default.
Obfs4Proxy.shared.apply(to: config)
self.session = URLSession(configuration: config)
self.sessions = BridgedSession {
let config = URLSessionConfiguration.default
config.httpCookieStorage = storage
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.requestCachePolicy = .reloadIgnoringLocalCacheData
// The policy above only skips the cache on READ.
config.urlCache = nil
config.timeoutIntervalForRequest = 20
config.httpAdditionalHeaders = [
"User-Agent": "FlamenetMessenger/1.0 (iOS)",
"Accept": "application/json, text/html;q=0.9, */*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
]
if let protocolClasses { config.protocolClasses = protocolClasses }
// `BridgedSession` applies the bridges, a no-op unless one is set.
return config
}
}
// MARK: - Endpoints
@@ -98,7 +102,7 @@ final class MessengerClient {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw MessengerError.transport("Non-HTTP response")
throw MessengerError.transport(String(localized: "Non-HTTP response"))
}
return (data, http)
} catch let error as MessengerError {
@@ -107,9 +111,12 @@ final class MessengerClient {
// The common cause is not a bad certificate: it is `https://` typed
// (or defaulted) against a relay serving plain HTTP, which fails in
// the handshake and reports itself as a security problem.
let host = request.url?.host ?? String(localized: "the server")
throw MessengerError.transport(
"Couldn't establish a secure connection to \(request.url?.host ?? "the server"). "
+ "If that address serves plain HTTP, enter it with an http:// prefix.")
String(
localized:
"Couldn't establish a secure connection to \(host). If that address serves plain HTTP, enter it with an http:// prefix."
))
} catch let error as URLError where error.code == .cancelled {
// A cancelled request is not a failed one. It is what happens when
// a screen goes away mid-fetch, a newer load supersedes this one,
+55 -47
View File
@@ -79,41 +79,44 @@ enum MessengerError: LocalizedError, Equatable {
// the CAPTCHA.
let raw: String
switch self {
case .notConfigured: raw = "No site has been configured yet."
case .invalidURL: raw = "The site address is not a valid URL."
case .notAuthenticated: raw = "You need to sign in again."
case .spentProof: raw = "That request had already been sent; signing it again."
case .nonceUnavailable: raw = "Couldn't obtain a security token from the site."
case .transport(let m): raw = "Network error: \(m)"
case .notConfigured: raw = String(localized: "No site has been configured yet.")
case .invalidURL: raw = String(localized: "The site address is not a valid URL.")
case .notAuthenticated: raw = String(localized: "You need to sign in again.")
case .spentProof: raw = String(localized: "That request had already been sent; signing it again.")
case .nonceUnavailable: raw = String(localized: "Couldn't obtain a security token from the site.")
case .transport(let m): raw = String(localized: "Network error: \(m)")
// 451 is the geo-gate, and it needs a sentence rather than a number.
case .httpStatus(451):
raw =
"Flamenet isn't available in your country yet. "
+ "This isn't a problem with your account or your connection."
case .httpStatus(let code): raw = "Server returned HTTP \(code)."
raw = String(
localized:
"Flamenet isn't available in your country yet. This isn't a problem with your account or your connection."
)
case .httpStatus(let code): raw = String(localized: "Server returned HTTP \(String(code)).")
case .unauthorized:
// Deliberately not the relay's wording.
raw = "This device needs to reconnect to the server. Trying again…"
raw = String(localized: "This device needs to reconnect to the server. Trying again…")
case .refused(let refusal, let reason):
// Each of these says what is actually wrong and whether waiting
// helps, because "Trying again…" was said for an hour to somebody
// whose sends could never have succeeded by being tried again.
switch refusal {
case .deliveryKeyRejected, .capabilityRevoked, .tokenExpired:
raw = "Reconnecting to the server…"
raw = String(localized: "Reconnecting to the server…")
case .credentialRejected:
raw =
"This device is signed in but the server is not accepting "
+ "it. Sign out and back in to fix it."
raw = String(
localized:
"This device is signed in but the server is not accepting it. Sign out and back in to fix it."
)
case .deviceLimitReached:
// The relay's own sentence, because it knows the numbers and
// it names the one screen that fixes it. Anything vaguer here
// leaves somebody with an app that looks signed in and does
// nothing.
raw = reason.isEmpty
? "This account has too many devices registered. Remove one "
+ "from the Devices screen on a signed-in device, then sign "
+ "in again here."
? String(
localized:
"This account has too many devices registered. Remove one from the Devices screen on a signed-in device, then sign in again here."
)
: reason
}
case .rateLimited(let seconds):
@@ -121,9 +124,9 @@ enum MessengerError: LocalizedError, Equatable {
let mins = max(1, (seconds + 59) / 60)
raw = "Too many attempts. Try again in about \(mins) minute\(mins == 1 ? "" : "s")."
} else {
raw = "Too many attempts. Wait a few minutes."
raw = String(localized: "Too many attempts. Wait a few minutes.")
}
case .decoding(let m): raw = "Couldn't read the server's response (\(m))."
case .decoding(let m): raw = String(localized: "Couldn't read the server's response (\(m)).")
case .ajaxFailure(let m): raw = mapAjaxMessage(m)
}
return Self.cleanupHTML(raw)
@@ -165,52 +168,57 @@ enum MessengerError: LocalizedError, Equatable {
private func mapAjaxMessage(_ raw: String) -> String {
switch raw {
case "not_logged_in": return "Your session expired, please sign in again."
case "invalid": return "That message couldn't be sent (invalid recipient or empty body)."
case "save_failed": return "The server failed to save your message."
case "bad_credentials": return "Incorrect username or password."
case "not_logged_in": return String(localized: "Your session expired, please sign in again.")
case "invalid": return String(localized: "That message couldn't be sent (invalid recipient or empty body).")
case "save_failed": return String(localized: "The server failed to save your message.")
case "bad_credentials": return String(localized: "Incorrect username or password.")
case "no_rest_api":
return
"This server doesn't answer at /api/. Check the address, and that it is a Flamenet relay."
case "login_failed": return "Sign in didn't complete. The server didn't return a session."
case "add_notfound": return "No Flamenet member has that username."
case "add_self": return "You can't add yourself."
case "add_duplicate": return "They're already on your buddy list."
case "add_nonce": return "Security check failed. Pull to refresh and try again."
case "add_unknown": return "Couldn't add that buddy. The server didn't say why."
case "signup_username_taken": return "That username is already taken, try another."
case "signup_email_taken": return "An account with that email already exists."
String(localized: "This server doesn't answer at /api/. Check the address, and that it is a Flamenet relay.")
case "login_failed": return String(localized: "Sign in didn't complete. The server didn't return a session.")
case "add_notfound": return String(localized: "No Flamenet member has that username.")
case "add_self": return String(localized: "You can't add yourself.")
case "add_duplicate": return String(localized: "They're already on your buddy list.")
case "add_nonce": return String(localized: "Security check failed. Pull to refresh and try again.")
case "add_unknown": return String(localized: "Couldn't add that buddy. The server didn't say why.")
case "signup_username_taken": return String(localized: "That username is already taken, try another.")
case "signup_email_taken": return String(localized: "An account with that email already exists.")
// Not "letters, numbers, and . _ - @": the server rejects punctuation,
// because OSCAR cannot carry `.`, `_` or `-` in a screen name and
// reports it as "service unreachable" at first sign-on.
case "signup_bad_username":
return "Usernames can only contain lowercase letters and numbers."
case "signup_bad_email": return "That doesn't look like a valid email address."
case "signup_weak_password": return "Your password needs to be at least 8 characters."
return String(localized: "Usernames can only contain lowercase letters and numbers.")
case "signup_bad_email": return String(localized: "That doesn't look like a valid email address.")
case "signup_weak_password": return String(localized: "Your password needs to be at least 8 characters.")
// Says what to DO.
case "signup_username_confusable":
return "That username looks too much like one that already exists. "
+ "Changing a digit won't help, try a different word."
return String(
localized:
"That username looks too much like one that already exists. Changing a digit won't help, try a different word."
)
// Explains the reason rather than sounding like a judgement about their
// email provider.
case "signup_disposable_email":
return "That email provider isn't accepted. Use an address you'll still "
+ "be able to read later, it's how you get back in."
return String(
localized:
"That email provider isn't accepted. Use an address you'll still be able to read later, it's how you get back in."
)
// Says where to look and what it is for.
case "email_unverified":
return "Check your email for a code, then enter it to finish setting up "
+ "your account."
case "signup_closed": return "New accounts are not being accepted right now."
case "no_app_passwords": return "This server isn't set up to issue session tokens."
return String(
localized: "Check your email for a code, then enter it to finish setting up your account."
)
case "signup_closed": return String(localized: "New accounts are not being accepted right now.")
case "no_app_passwords": return String(localized: "This server isn't set up to issue session tokens.")
// Neither of these blames the person, because neither is their doing.
case "signup_challenge_required":
return
"This server asks new accounts to do a quick check first, and this version of the app can't do it yet. Update the app and try again."
String(localized: "This server asks new accounts to do a quick check first, and this version of the app can't do it yet. Update the app and try again.")
case "signup_challenge_failed":
return
"That check took too long. Tap Create account again and it will start a fresh one."
default: return "Request failed: \(raw)"
String(localized: "That check took too long. Tap Create account again and it will start a fresh one.")
default: return String(localized: "Request failed: \(raw)")
}
}
}
+7
View File
@@ -72,7 +72,14 @@ enum Obfs4Keychain {
/// and they stay in every backup taken from here on unless something
/// removes them. This runs once per key and removes the old copy whether
/// or not the new one was written, because leaving it is the bug.
///
/// Only when the old key is there. Removing a key that is not there still
/// posts `UserDefaults.didChangeNotification`, and this runs on every read
/// of the bridge settings, which SwiftUI views make in `body`: every
/// `@AppStorage` view re-rendered, read again, and posted again, and the
/// wallet's send sheet hung its main thread (WalletUITests).
static func migrate(from defaultsKey: String, to account: String) {
guard UserDefaults.standard.object(forKey: defaultsKey) != nil else { return }
defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }
guard read(account) == nil else { return }
if let data = UserDefaults.standard.data(forKey: defaultsKey) {
+247
View File
@@ -0,0 +1,247 @@
import FilesClient
import Foundation
/// The one transport instance the app shares.
public final class Obfs4Proxy {
public static let shared = Obfs4Proxy()
private var transport: Obfs4Transport?
private let lock = NSLock()
private init() {}
/// Point the Files package's sessions at the bridges.
public func routeFilesClient() {
FilesTransport.configure = { Obfs4Proxy.shared.apply(to: $0) }
}
/// What is actually happening, as opposed to what is stored (§24.6).
public enum State: Equatable {
/// No line stored: the app connects directly.
case off
/// Lines are stored and nothing has gone through any of them yet.
case waiting(lines: Int)
/// The last connection went through this bridge.
case active(host: String, port: UInt16)
/// The last connection tried every line and none answered. The app
/// keeps trying, and does not connect directly.
case noneReachable(lines: Int)
/// The local listener could not be opened. Traffic is blocked, not
/// sent directly.
case couldNotStart
}
/// Bumped when bridges turn on or off, or the transport is rebuilt, so a
/// session made before knows to make a new one (`BridgedSession`).
public var generation: Int {
lock.lock()
defer { lock.unlock() }
return bumped
}
private var bumped = 0
private var failedToStart = false
private var carried: Obfs4Transport.Bridge?
private var everyLineFailed = false
/// A test seam: the next start fails as if the port could not be opened.
var failNextStartForTesting = false
public var state: State {
let lines = Obfs4Settings.attemptOrder()
let stored = Obfs4Settings.lines.count
lock.lock()
defer { lock.unlock() }
guard stored > 0 else { return .off }
if failedToStart { return .couldNotStart }
if let carried, lines.contains(where: { $0.bridge == carried }) {
return .active(host: carried.host, port: carried.port)
}
if everyLineFailed { return .noneReachable(lines: stored) }
return .waiting(lines: stored)
}
/// Drops the running transport, so the next session builds one from the
/// current settings.
public func reset() {
lock.lock()
defer { lock.unlock() }
transport?.stop()
transport = nil
failedToStart = false
carried = nil
everyLineFailed = false
bumped += 1
}
/// The stored list changed. Every open tunnel closes, so nothing keeps
/// travelling through a line that was removed (§24.7). Turning bridges on
/// or off also makes every bridged session rebuild.
func linesChanged(wasOn: Bool, isOn: Bool) {
lock.lock()
defer { lock.unlock() }
transport?.closeTunnels()
carried = nil
everyLineFailed = false
if !isOn {
transport?.stop()
transport = nil
failedToStart = false
}
if wasOn != isOn { bumped += 1 }
}
private func worked(_ bridge: Obfs4Transport.Bridge) {
lock.lock()
carried = bridge
everyLineFailed = false
lock.unlock()
if let line = Obfs4Settings.attemptOrder().first(where: { $0.bridge == bridge })?.line {
Obfs4Settings.markWorked(line)
}
}
private func failedEveryLine() {
lock.lock()
carried = nil
everyLineFailed = true
lock.unlock()
}
/// Where a session goes when bridges are on and the local listener could
/// not be opened: a port nothing listens on, so requests fail rather than
/// go direct (§24.6).
static let nowhere: [AnyHashable: Any] = [
"SOCKSEnable": 1, "SOCKSProxy": "127.0.0.1", "SOCKSPort": 1, "SOCKSVersion": 5,
]
/// Points a session configuration at the bridges, starting the local proxy
/// on first use. Returns false, and leaves the configuration alone, only
/// when no line is stored.
@discardableResult
public func apply(to configuration: URLSessionConfiguration) -> Bool {
guard Obfs4Settings.isEnabled else { return false }
lock.lock()
defer { lock.unlock() }
if transport == nil && !failedToStart {
if failNextStartForTesting {
failNextStartForTesting = false
failedToStart = true
} else {
let started = Obfs4Transport(
bridges: { Obfs4Settings.attemptOrder().map(\.bridge) },
attemptTimeout: 15,
onWorked: { [weak self] in self?.worked($0) },
onAllFailed: { [weak self] in self?.failedEveryLine() })
do {
try started.start()
transport = started
} catch {
failedToStart = true
}
}
}
configuration.connectionProxyDictionary = transport?.proxyConfiguration ?? Self.nowhere
return true
}
}
extension Obfs4Proxy {
/// What a reachability check found.
public enum CheckResult: Equatable {
case working
/// The local proxy could not be opened.
case couldNotStartLocally
/// Deliberately does not say which.
case noAnswer
/// The bridge answered and the relay behind it did not, so the bridge
/// itself is fine.
case bridgeWorkedRelayDidNot(String)
public var message: String {
switch self {
case .working:
return String(localized: "Connected through the bridge.")
case .couldNotStartLocally:
return String(localized: "This phone wouldn't let the app set up the connection. Restarting the app usually clears it.")
case .noAnswer:
// Says both possibilities because it cannot tell, and guessing
// would send somebody off to fix the wrong thing.
return String(localized: "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 in both cases, so there's no way to tell them apart from here. Worth checking the line with whoever gave it to you.")
case .bridgeWorkedRelayDidNot(let detail):
return String(localized: "The bridge answered, so the line is good. What's behind it didn't reply: \(detail)")
}
}
}
/// Opens a real connection through the bridges to `target`.
public func check(target: URL) async -> CheckResult {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 60
guard apply(to: config) else { return .couldNotStartLocally }
if state == .couldNotStart { return .couldNotStartLocally }
var request = URLRequest(url: target)
request.httpMethod = "HEAD"
let session = URLSession(configuration: config)
defer { session.finishTasksAndInvalidate() }
do {
let (_, response) = try await session.data(for: request)
// Any HTTP status at all means the whole path worked, the handshake
// completed, the bridge proxied, something answered.
_ = response
return .working
} catch let error as URLError {
// The default is to blame nothing.
switch error.code {
case .secureConnectionFailed, .serverCertificateUntrusted,
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid, .badServerResponse, .cannotParseResponse:
return .bridgeWorkedRelayDidNot(error.localizedDescription)
default:
return .noAnswer
}
} catch {
return .noAnswer
}
}
}
/// A URLSession that follows the bridge setting.
///
/// A configuration cannot change once a session is made from it, so a client
/// that made its session before a bridge was set would go on connecting
/// directly. This rebuilds the session whenever `Obfs4Proxy.generation` moves.
/// The old session is dropped, not invalidated: a task already holding it
/// finishes, and making a task on an invalidated session raises.
final class BridgedSession {
private let make: () -> URLSessionConfiguration
private var built: URLSession?
private var builtFor = -1
private let lock = NSLock()
init(_ make: @escaping () -> URLSessionConfiguration) { self.make = make }
var session: URLSession {
lock.lock()
defer { lock.unlock() }
let generation = Obfs4Proxy.shared.generation
if let built, builtFor == generation { return built }
let configuration = make()
Obfs4Proxy.shared.apply(to: configuration)
let fresh = URLSession(configuration: configuration)
built = fresh
builtFor = Obfs4Proxy.shared.generation
return fresh
}
/// For everything before sign-in: relay discovery, the sign-up proof of
/// work and the account service.
static let preSignIn = BridgedSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 30
return configuration
}
}
+199 -193
View File
@@ -1,12 +1,22 @@
import Foundation
/// Whether relay traffic goes through an obfs4 bridge, and which one.
/// Whether relay traffic goes through obfs4 bridges, and which ones.
///
/// SPEC §24. Up to eight lines, in the order they were added (§24.6). A line
/// the person pasted and a line accepted from a contact's offer are stored
/// alike; who offered it is kept only to show beside it.
public enum Obfs4Settings {
private static let bridgeLineKey = "net.flamenet.obfs4.bridgeLine"
/// Keychain accounts. The legacy keys above are now only read once, by
/// Keychain accounts. The legacy keys are now only read once, by
/// `Obfs4Keychain.migrate`, and removed.
private static let bridgeLineAccount = "bridgeLine"
private static let linesAccount = "bridgeLines"
/// §24.6: a client MAY hold up to 8 lines.
public static let maximumLines = 8
/// §24.4: the longest line an offer may carry, in UTF-8 bytes.
public static let maximumLineBytes = E2EAttachment.maximumBridgeLineBytes
/// What is wrong with a bridge line, specifically enough to say so.
public enum Problem: Error, Equatable {
@@ -23,69 +33,196 @@ public enum Obfs4Settings {
public var message: String {
switch self {
case .empty:
return "Paste the line your bridge provider gave you."
return String(localized: "Paste the line your bridge provider gave you.")
case .noAddress:
return "This doesn't have an address in it. A bridge line has one near the "
+ "start, like 198.51.100.7:4444."
return String(localized: "This doesn't have an address in it. A bridge line has one near the start, like 198.51.100.7:4444.")
case .badPort(let port):
return "\"\(port)\" isn't a port number. It should be digits, after the "
+ "colon in the address."
return String(localized: "\"\(String(port))\" isn't a port number. It should be digits, after the colon in the address.")
case .noCert:
return "The part starting cert= is missing. That's the piece that proves "
+ "you're talking to the right bridge, so it's worth going back for "
+ "the whole line."
return String(localized: "The part starting cert= is missing. That's the piece that proves you're talking to the right bridge, so it's worth going back for the whole line.")
case .certNotBase64:
return "The part after cert= has something in it that doesn't belong, "
+ "often a stray space or a line break picked up while copying. Try "
+ "copying it again."
return String(localized: "The part after cert= has something in it that doesn't belong, often a stray space or a line break picked up while copying. Try copying it again.")
case .certWrongLength(let got):
// The likeliest real failure by a distance, and the one worth
// spending words on.
return got < 52
? "This line looks cut short. They're long enough that most apps wrap "
+ "them onto a second line, and it's easy to copy only the first, "
+ "check you've got all of it."
: "There's more after cert= than belongs there. Something extra was "
+ "probably picked up while copying."
? String(localized: "This line looks cut short. They're long enough that most apps wrap them onto a second line, and it's easy to copy only the first, check you've got all of it.")
: String(localized: "There's more after cert= than belongs there. Something extra was probably picked up while copying.")
}
}
}
/// The configured bridge line, in the standard form: obfs4 <host>:<port>
/// <FINGERPRINT> cert=<base64> iat-mode=0 Stored as the operator's own line
/// rather than parsed fields, so what the user pasted can be shown back to
/// them exactly.
public static var bridgeLine: String? {
get {
Obfs4Keychain.migrate(from: bridgeLineKey, to: bridgeLineAccount)
return Obfs4Keychain.read(bridgeLineAccount).map {
String(decoding: $0, as: UTF8.self)
/// Why a line was not added to the list.
public enum Refusal: Error, Equatable {
case unreadable(Problem)
case tooMany
case alreadySaved
public var message: String {
switch self {
case .unreadable(let problem):
return problem.message
case .tooMany:
return String(localized: "You can keep up to 8 bridges. Remove one before adding another.")
case .alreadySaved:
return String(localized: "This bridge is already in your list.")
}
}
set {
Obfs4Keychain.migrate(from: bridgeLineKey, to: bridgeLineAccount)
let trimmed = newValue?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmed, !trimmed.isEmpty {
Obfs4Keychain.write(Data(trimmed.utf8), to: bridgeLineAccount)
}
/// One stored line.
public struct StoredLine: Codable, Equatable, Identifiable {
/// The line as it was pasted or received, trimmed.
public let line: String
public let addedAt: Double
/// Who offered it, for display. Nil for a line the person pasted.
public let offeredBy: String?
/// When it last carried a connection that reached the relay.
public var lastWorked: Double?
public var id: String { line }
}
/// Serialises read-modify-write of the stored list.
private static let lock = NSLock()
/// The stored lines, in the order they were added.
public static var lines: [StoredLine] {
lock.lock()
defer { lock.unlock() }
return readLines()
}
private static func readLines() -> [StoredLine] {
if let data = Obfs4Keychain.read(linesAccount),
let decoded = try? JSONDecoder().decode([StoredLine].self, from: data)
{
return decoded
}
// A one-line build stored the line on its own. Take it, and remove the
// old copy whether or not the new one was written.
Obfs4Keychain.migrate(from: bridgeLineKey, to: bridgeLineAccount)
guard let old = Obfs4Keychain.read(bridgeLineAccount) else { return [] }
defer { Obfs4Keychain.delete(bridgeLineAccount) }
let text = String(decoding: old, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return [] }
let migrated = [StoredLine(line: text, addedAt: 0, offeredBy: nil, lastWorked: nil)]
writeLines(migrated)
return migrated
}
private static func writeLines(_ all: [StoredLine]) {
if all.isEmpty {
Obfs4Keychain.delete(linesAccount)
} else if let data = try? JSONEncoder().encode(all) {
Obfs4Keychain.write(data, to: linesAccount)
}
}
/// Change the list and tell the transport, which closes what a removed line
/// was carrying (§24.7).
private static func mutate(_ change: (inout [StoredLine]) -> Void) {
lock.lock()
var all = readLines()
let before = all
change(&all)
if all != before { writeLines(all) }
lock.unlock()
if all.map(\.line) != before.map(\.line) {
Obfs4Proxy.shared.linesChanged(wasOn: !before.isEmpty, isOn: !all.isEmpty)
}
}
/// Bridges are on when any line is stored. Off is the person's choice, and
/// the one case where the app connects directly (§24.6).
public static var isEnabled: Bool { !lines.isEmpty }
/// Add a line at the end of the list.
@discardableResult
public static func add(
_ line: String, offeredBy: String? = nil,
at now: Double = Date().timeIntervalSince1970
) -> Result<Obfs4Transport.Bridge, Refusal> {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
let bridge: Obfs4Transport.Bridge
switch validate(trimmed) {
case .failure(let problem): return .failure(.unreadable(problem))
case .success(let parsed): bridge = parsed
}
var refusal: Refusal?
mutate { all in
if all.contains(where: { parse($0.line).map { sameBridge($0, bridge) } ?? false }) {
refusal = .alreadySaved
} else if all.count >= maximumLines {
refusal = .tooMany
} else {
Obfs4Keychain.delete(bridgeLineAccount)
all.append(
StoredLine(line: trimmed, addedAt: now, offeredBy: offeredBy, lastWorked: nil))
}
cached = nil
Obfs4Proxy.shared.reset()
}
if let refusal { return .failure(refusal) }
return .success(bridge)
}
/// Two lines name one bridge when they point at the same place.
private static func sameBridge(_ a: Obfs4Transport.Bridge, _ b: Obfs4Transport.Bridge) -> Bool {
a.host.lowercased() == b.host.lowercased() && a.port == b.port
}
/// Remove a line. Connections it carried close at once (§24.7).
public static func remove(_ line: String) {
mutate { all in all.removeAll { $0.line == line } }
}
/// Record that a line carried a connection that reached the relay.
public static func markWorked(_ line: String, at now: Double = Date().timeIntervalSince1970) {
mutate { all in
guard let i = all.firstIndex(where: { $0.line == line }) else { return }
all[i].lastWorked = now
}
}
public static var isEnabled: Bool { bridge != nil }
/// The order to try lines in (§24.6): the one that last completed a
/// connection first, then the rest in stored order.
public static func attemptOrder() -> [(line: String, bridge: Obfs4Transport.Bridge)] {
let all = lines
let readable = all.compactMap { stored in parse(stored.line).map { (stored.line, $0) } }
guard
let latest = all.filter({ $0.lastWorked != nil })
.max(by: { $0.lastWorked! < $1.lastWorked! })
else { return readable }
return readable.filter { $0.0 == latest.line } + readable.filter { $0.0 != latest.line }
}
private static var cached: Obfs4Transport.Bridge??
/// The first stored line. Setting it replaces the whole list with that one
/// line, and setting nil removes every line. Kept for the callers and
/// tests written when there was only one.
public static var bridgeLine: String? {
get { lines.first?.line }
set {
let trimmed = newValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
mutate { all in
all =
trimmed.isEmpty
? []
: [StoredLine(
line: trimmed, addedAt: Date().timeIntervalSince1970, offeredBy: nil,
lastWorked: nil)]
}
}
}
/// The parsed bridge, or `nil` when none is configured or the line is
/// malformed.
public static var bridge: Obfs4Transport.Bridge? {
if let cached { return cached }
let parsed = bridgeLine.flatMap(parse)
cached = parsed
return parsed
/// The first stored line, parsed, or nil.
public static var bridge: Obfs4Transport.Bridge? { bridgeLine.flatMap(parse) }
/// A cert decodes with or without its padding, and only in the standard
/// alphabet (§24.2).
static func decodeCert(_ cert: String) -> Data? {
var bare = Substring(cert)
while bare.hasSuffix("=") { bare = bare.dropLast() }
if bare.count % 4 == 1 { return nil }
let padded = bare + String(repeating: "=", count: (4 - bare.count % 4) % 4)
return Data(base64Encoded: String(padded))
}
/// Parses a bridge line, saying what is wrong when it cannot.
@@ -102,7 +239,9 @@ public enum Obfs4Settings {
let host = String(address[address.startIndex..<colon])
let portText = String(address[address.index(after: colon)...])
guard !host.isEmpty else { return .failure(.noAddress) }
guard let port = UInt16(portText), port > 0 else { return .failure(.badPort(portText)) }
guard !portText.isEmpty, portText.allSatisfy({ $0.isASCII && $0.isNumber }),
let port = UInt16(portText), port > 0
else { return .failure(.badPort(portText)) }
guard let certField = fields.first(where: { $0.hasPrefix("cert=") })
else { return .failure(.noCert) }
@@ -110,18 +249,19 @@ public enum Obfs4Settings {
// The two ways a cert fails are told apart here rather than in
// `Bridge.init?`, which can only say "no".
guard let raw = Data(base64Encoded: cert + "==") else { return .failure(.certNotBase64) }
guard let raw = decodeCert(cert) else { return .failure(.certNotBase64) }
guard raw.count == 52 else { return .failure(.certWrongLength(raw.count)) }
guard let bridge = Obfs4Transport.Bridge(host: host, port: port, cert: cert)
else { return .failure(.certNotBase64) }
return .success(bridge)
return .success(
Obfs4Transport.Bridge(
host: host, port: port,
nodeID: Array(raw.prefix(20)), identityPublic: Array(raw.suffix(32))))
}
private static let offersKey = "net.flamenet.obfs4.offers"
private static let offersAccount = "offers"
/// A bridge line a contact offered, and who offered it (§6.19).
/// A bridge line a contact offered, and who offered it (§24.5).
public struct Offer: Codable, Equatable, Identifiable {
public let fromUser: Int
public let fromName: String
@@ -157,13 +297,16 @@ public enum Obfs4Settings {
saveOffers(offers.filter { $0.fromUser != fromUser })
}
/// Accept an offer, which is the ONLY path from one to a route.
/// Accept an offer, which is the ONLY path from one to a route. The line
/// joins the list (§24.6) and the offer goes.
@discardableResult
public static func accept(_ offer: Offer) -> Result<Obfs4Transport.Bridge, Problem> {
let result = validate(offer.line)
if case .success = result {
bridgeLine = offer.line
public static func accept(_ offer: Offer) -> Result<Obfs4Transport.Bridge, Refusal> {
let result = add(offer.line, offeredBy: offer.fromName)
switch result {
case .success, .failure(.alreadySaved):
dismissOffer(fromUser: offer.fromUser)
case .failure:
break
}
return result
}
@@ -175,10 +318,7 @@ public enum Obfs4Settings {
let mode = fields.first(where: { $0.hasPrefix("iat-mode=") })?
.dropFirst("iat-mode=".count), mode != "0"
else { return nil }
return "This bridge asks for extra timing camouflage (iat-mode \(mode)) that this "
+ "app doesn't do yet. It will still connect, and the traffic still doesn't "
+ "look like anything in particular, the gaps between messages just aren't "
+ "disguised as well."
return String(localized: "This bridge asks for extra timing camouflage (iat-mode \(String(mode))) that this app doesn't do yet. It will still connect, and the traffic still doesn't look like anything in particular, the gaps between messages just aren't disguised as well.")
}
/// The non-reporting form, for call sites that only need the bridge.
@@ -186,137 +326,3 @@ public enum Obfs4Settings {
try? validate(line).get()
}
}
/// The one transport instance the app shares.
public final class Obfs4Proxy {
public static let shared = Obfs4Proxy()
private var transport: Obfs4Transport?
private let lock = NSLock()
private init() {}
/// What is actually happening, as opposed to what is stored.
public enum State: Equatable {
/// No bridge configured.
case off
/// A bridge is stored but nothing has needed the network yet, so
/// 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.
case couldNotStart
}
public var state: State {
lock.lock()
defer { lock.unlock() }
guard let bridge = Obfs4Settings.bridge else { return .off }
if failedToStart { return .couldNotStart }
guard transport != nil else { return .notStartedYet(host: bridge.host, port: bridge.port) }
return .active(host: bridge.host, port: bridge.port)
}
private var failedToStart = false
/// Drops the running transport so the next call builds one from the current
/// settings.
public func reset() {
lock.lock()
defer { lock.unlock() }
transport?.stop()
transport = nil
failedToStart = false
}
/// Applies the configured bridge to a session configuration, starting the
/// local proxy on first use.
@discardableResult
public func apply(to configuration: URLSessionConfiguration) -> Bool {
guard let bridge = Obfs4Settings.bridge else { return false }
lock.lock()
defer { lock.unlock() }
if transport == nil {
let started = Obfs4Transport(bridge: bridge)
do {
try started.start()
transport = started
failedToStart = false
} catch {
// Falling back to a direct connection is deliberate: a failure
// to open a loopback port must not make the app unusable.
failedToStart = true
return false
}
}
guard let transport else { return false }
configuration.connectionProxyDictionary = transport.proxyConfiguration
return true
}
}
extension Obfs4Proxy {
/// What a reachability check found.
public enum CheckResult: Equatable {
case working
/// The local proxy could not be opened.
case couldNotStartLocally
/// Deliberately does not say which.
case noAnswer
/// The bridge answered and the relay behind it did not, so the bridge
/// itself is fine.
case bridgeWorkedRelayDidNot(String)
public var message: String {
switch self {
case .working:
return "Connected through the bridge."
case .couldNotStartLocally:
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 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 "
+ "in both cases, so there's no way to tell them apart from here. "
+ "Worth checking the line with whoever gave it to you."
case .bridgeWorkedRelayDidNot(let detail):
return "The bridge answered, so the line is good. What's behind it didn't "
+ "reply: \(detail)"
}
}
}
/// Opens a real connection through the bridge to `target`.
public func check(target: URL) async -> CheckResult {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 25
guard apply(to: config) else { return .couldNotStartLocally }
var request = URLRequest(url: target)
request.httpMethod = "HEAD"
let session = URLSession(configuration: config)
defer { session.finishTasksAndInvalidate() }
do {
let (_, response) = try await session.data(for: request)
// Any HTTP status at all means the whole path worked, the handshake
// completed, the bridge proxied, something answered.
_ = response
return .working
} catch let error as URLError {
// The default is to blame nothing.
switch error.code {
case .secureConnectionFailed, .serverCertificateUntrusted,
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid, .badServerResponse, .cannotParseResponse:
return .bridgeWorkedRelayDidNot(error.localizedDescription)
default:
return .noAnswer
}
} catch {
return .noAnswer
}
}
}
+258 -80
View File
@@ -8,9 +8,14 @@ import Obfs4
/// Carries relay traffic inside obfs4, for networks that block or fingerprint
/// TLS to the relay.
///
/// A local SOCKS5 listener that URLSession is pointed at. Each connection it
/// accepts is carried through one bridge, chosen by trying the stored lines one
/// at a time (SPEC §24.6). The SOCKS target is ignored: a bridge forwards to the
/// relay's edge, which routes by the TLS name inside the tunnel.
public final class Obfs4Transport {
public struct Bridge {
public struct Bridge: Equatable {
public let host: String
public let port: UInt16
public let nodeID: [UInt8]
@@ -25,21 +30,59 @@ public final class Obfs4Transport {
}
/// `cert=` as it appears in a bridge line: base64 of `nodeID ‖
/// identityPublic` with its trailing `==` stripped.
/// identityPublic`, with or without its trailing `==`.
public init?(host: String, port: UInt16, cert: String) {
guard let raw = Data(base64Encoded: cert + "=="), raw.count == 52 else { return nil }
guard let raw = Obfs4Settings.decodeCert(cert), raw.count == 52 else { return nil }
self.init(
host: host, port: port,
nodeID: Array(raw.prefix(20)), identityPublic: Array(raw.suffix(32)))
}
/// The host as the resolver wants it: an IPv6 literal without its
/// brackets.
var dialHost: String {
host.hasPrefix("[") && host.hasSuffix("]")
? String(host.dropFirst().dropLast()) : host
}
}
private let bridge: Bridge
private let bridges: () -> [Bridge]
private let attemptTimeout: TimeInterval
private let onWorked: (Bridge) -> Void
private let onAllFailed: () -> Void
private var listener: Int32 = -1
private let queue = DispatchQueue(label: "net.flamenet.obfs4", attributes: .concurrent)
public private(set) var localPort: UInt16 = 0
public init(bridge: Bridge) { self.bridge = bridge }
/// Sockets of tunnels that are open now, so a settings change can close
/// them at once (§24.7).
private var open: Set<Int32> = []
private let openLock = NSLock()
/// - Parameters:
/// - bridges: the lines to try, in order, read afresh for every
/// connection so a change in settings applies to the next one.
/// - attemptTimeout: how long one line gets to complete a handshake, and
/// then to answer through it (§24.6: 15 seconds).
/// - onWorked: a line carried a connection that something answered.
/// - onAllFailed: every line was tried for one connection and none
/// answered.
public init(
bridges: @escaping () -> [Bridge], attemptTimeout: TimeInterval = 15,
onWorked: @escaping (Bridge) -> Void = { _ in },
onAllFailed: @escaping () -> Void = {}
) {
self.bridges = bridges
self.attemptTimeout = attemptTimeout
self.onWorked = onWorked
self.onAllFailed = onAllFailed
}
/// One fixed bridge.
public convenience init(bridge: Bridge) {
self.init(bridges: { [bridge] })
}
public enum Failure: Error {
case cannotListen
@@ -65,7 +108,7 @@ public final class Obfs4Transport {
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bound == 0, Darwin.listen(fd, 8) == 0 else {
guard bound == 0, Darwin.listen(fd, 16) == 0 else {
close(fd)
throw Failure.cannotListen
}
@@ -84,6 +127,8 @@ public final class Obfs4Transport {
var plen = socklen_t(MemoryLayout<sockaddr>.size)
let conn = accept(self.listener, &peer, &plen)
if conn < 0 { return }
var one: Int32 = 1
setsockopt(conn, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
self.queue.async { self.serve(conn) }
}
}
@@ -95,6 +140,28 @@ public final class Obfs4Transport {
close(listener)
listener = -1
}
closeTunnels()
}
/// Close every tunnel that is open now. The next connection is carried by
/// whatever the settings say then.
public func closeTunnels() {
openLock.lock()
let all = open
openLock.unlock()
for fd in all { shutdown(fd, SHUT_RDWR) }
}
private func track(_ fd: Int32) {
openLock.lock()
open.insert(fd)
openLock.unlock()
}
private func untrack(_ fd: Int32) {
openLock.lock()
open.remove(fd)
openLock.unlock()
}
/// The proxy dictionary to hand a `URLSessionConfiguration`.
@@ -108,39 +175,62 @@ public final class Obfs4Transport {
// MARK: - One connection
private func serve(_ client: Int32) {
defer { close(client) }
guard let target = readSocksRequest(client) else { return }
guard let upstream = try? dialBridge() else {
// SOCKS reply 0x04 = host unreachable.
sendSocksReply(client, status: 0x04)
return
track(client)
defer {
untrack(client)
close(client)
}
sendSocksReply(client, status: 0x00)
pump(client: client, upstream: upstream, target: target)
guard readSocksRequest(client) else { return }
// Bytes the client sent before anything answered, sent again through
// the next line if this one turns out to reach nothing.
var unanswered: [UInt8] = []
var replied = false
for bridge in bridges() {
guard let upstream = dial(bridge) else { continue }
track(upstream)
defer {
untrack(upstream)
close(upstream)
}
guard let session = handshake(on: upstream, with: bridge) else { continue }
if !replied {
sendSocksReply(client, status: 0x00)
replied = true
}
switch pump(client: client, upstream: upstream, session: session, unanswered: &unanswered) {
case .answered:
onWorked(bridge)
return
case .clientLeft:
return
case .reachedNothing:
continue
}
}
// §24.6: no direct fallback. Host unreachable, or a closed tunnel.
if !replied { sendSocksReply(client, status: 0x04) }
onAllFailed()
}
/// A deliberately minimal SOCKS5 server: no authentication, CONNECT only.
private func readSocksRequest(_ fd: Int32) -> (host: String, port: UInt16)? {
/// Reads a SOCKS5 greeting and CONNECT. The target is read and ignored.
private func readSocksRequest(_ fd: Int32) -> Bool {
var tv = timeval(tv_sec: 10, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var buf = [UInt8](repeating: 0, count: 512)
var n = recv(fd, &buf, buf.count, 0)
guard n >= 3, buf[0] == 0x05 else { return nil }
guard n >= 3, buf[0] == 0x05 else { return false }
// Offer "no authentication".
var greeting: [UInt8] = [0x05, 0x00]
_ = send(fd, &greeting, greeting.count, 0)
n = recv(fd, &buf, buf.count, 0)
guard n >= 10, buf[0] == 0x05, buf[1] == 0x01 else { return nil }
guard n >= 10, buf[0] == 0x05, buf[1] == 0x01 else { return false }
switch buf[3] {
case 0x01: // IPv4
let host = "\(buf[4]).\(buf[5]).\(buf[6]).\(buf[7])"
return (host, UInt16(buf[8]) << 8 | UInt16(buf[9]))
case 0x03: // domain name
let length = Int(buf[4])
guard n >= 5 + length + 2 else { return nil }
let host = String(decoding: buf[5..<(5 + length)], as: UTF8.self)
return (host, UInt16(buf[5 + length]) << 8 | UInt16(buf[6 + length]))
default:
return nil
case 0x01: return n >= 10 // IPv4
case 0x03: return n >= 5 + Int(buf[4]) + 2 // a name
case 0x04: return n >= 22 // IPv6
default: return false
}
}
@@ -149,69 +239,154 @@ public final class Obfs4Transport {
_ = send(fd, &reply, reply.count, 0)
}
private func dialBridge() throws -> Int32 {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw Failure.cannotListen }
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = bridge.port.bigEndian
addr.sin_addr.s_addr = inet_addr(bridge.host)
let ok = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
/// Connects to a bridge at an IPv4 address, an IPv6 address or a name,
/// trying each address the name has, within the attempt's time limit.
private func dial(_ bridge: Bridge) -> Int32? {
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_STREAM
hints.ai_protocol = IPPROTO_TCP
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(bridge.dialHost, String(bridge.port), &hints, &result) == 0,
let first = result
else { return nil }
defer { freeaddrinfo(result) }
let deadline = Date().addingTimeInterval(attemptTimeout)
var cursor: UnsafeMutablePointer<addrinfo>? = first
while let ai = cursor {
cursor = ai.pointee.ai_next
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
if fd < 0 { continue }
var one: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
let flags = fcntl(fd, F_GETFL)
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
let rc = Darwin.connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
if rc != 0 && errno != EINPROGRESS {
close(fd)
continue
}
if rc != 0 {
let left = Int32(max(0, deadline.timeIntervalSinceNow) * 1000)
var p = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0)
var err: Int32 = 0
var len = socklen_t(MemoryLayout<Int32>.size)
guard poll(&p, 1, left) == 1,
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == 0, err == 0
else {
close(fd)
continue
}
}
_ = fcntl(fd, F_SETFL, flags)
return fd
}
guard ok == 0 else {
close(fd)
throw Failure.bridgeDidNotAnswer
}
// Without this a rejected handshake hangs forever, because the bridge's
// correct behaviour is to say nothing.
var tv = timeval(tv_sec: 20, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
return fd
return nil
}
private func pump(client: Int32, upstream: Int32, target: (host: String, port: UInt16)) {
defer { close(upstream) }
guard let session = handshake(on: upstream) else { return }
private enum Outcome { case answered, reachedNothing, clientLeft }
/// Moves bytes both ways until one side closes.
///
/// "Answered" means plaintext came back through the bridge: something
/// behind it, the relay's TLS, replied. A bridge that closes, or stays
/// quiet past the time limit, with the client's bytes unanswered reached
/// nothing (§24.6), and those bytes are handed back for the next line.
private func pump(
client: Int32, upstream: Int32,
session: (plane: Obfs4DataPlane, leftover: [UInt8]),
unanswered: inout [UInt8]
) -> Outcome {
var plane = session.plane
var answered = false
var clientOpen = true
var buf = [UInt8](repeating: 0, count: 16384)
// Bridge → client.
let reader = DispatchWorkItem { [leftover = session.leftover] in
var carried = leftover
var buf = [UInt8](repeating: 0, count: 16384)
while true {
if !carried.isEmpty {
_ = carried.withUnsafeBufferPointer {
send(client, $0.baseAddress, $0.count, 0)
}
carried = []
}
if !session.leftover.isEmpty {
answered = true
_ = session.leftover.withUnsafeBufferPointer { send(client, $0.baseAddress, $0.count, 0) }
}
if !unanswered.isEmpty {
guard let framed = try? plane.send(unanswered), sendAll(upstream, framed) else {
return .reachedNothing
}
}
var waitingSince = unanswered.isEmpty ? nil : Date()
while true {
var fds = [
pollfd(fd: upstream, events: Int16(POLLIN), revents: 0),
pollfd(fd: client, events: clientOpen ? Int16(POLLIN) : 0, revents: 0),
]
var timeout: Int32 = -1
if !answered, let since = waitingSince {
let left = attemptTimeout - Date().timeIntervalSince(since)
if left <= 0 { return .reachedNothing }
timeout = Int32(left * 1000) + 1
}
let ready = poll(&fds, 2, timeout)
if ready < 0 {
if errno == EINTR { continue }
return answered ? .answered : .reachedNothing
}
if ready == 0 { continue }
if fds[0].revents != 0 {
let n = recv(upstream, &buf, buf.count, 0)
if n <= 0 { break }
guard let out = try? plane.receive(Array(buf[0..<n]), isClient: true) else { break }
if n <= 0 {
if answered {
shutdown(client, SHUT_RDWR)
return .answered
}
return clientOpen ? .reachedNothing : .clientLeft
}
guard let out = try? plane.receive(Array(buf[0..<n]), isClient: true) else {
return answered ? .answered : .reachedNothing
}
if !out.isEmpty {
_ = out.withUnsafeBufferPointer { send(client, $0.baseAddress, $0.count, 0) }
answered = true
unanswered = []
if !sendAll(client, out) { return .answered }
}
}
if clientOpen, fds[1].revents != 0 {
let n = recv(client, &buf, buf.count, 0)
if n <= 0 {
clientOpen = false
shutdown(upstream, SHUT_WR)
if !answered { return .clientLeft }
continue
}
let chunk = Array(buf[0..<n])
if !answered {
unanswered += chunk
if waitingSince == nil { waitingSince = Date() }
}
guard let framed = try? plane.send(chunk), sendAll(upstream, framed) else {
return answered ? .answered : .reachedNothing
}
}
shutdown(client, SHUT_RDWR)
}
queue.async(execute: reader)
// Client → bridge.
var buf = [UInt8](repeating: 0, count: 16384)
while true {
let n = recv(client, &buf, buf.count, 0)
if n <= 0 { break }
guard let framed = try? plane.send(Array(buf[0..<n])) else { break }
_ = framed.withUnsafeBufferPointer { send(upstream, $0.baseAddress, $0.count, 0) }
}
shutdown(upstream, SHUT_RDWR)
reader.wait()
}
private func handshake(on fd: Int32) -> (plane: Obfs4DataPlane, leftover: [UInt8])? {
private func sendAll(_ fd: Int32, _ bytes: [UInt8]) -> Bool {
var offset = 0
while offset < bytes.count {
let n = bytes[offset...].withUnsafeBufferPointer {
send(fd, $0.baseAddress, $0.count, 0)
}
if n <= 0 { return false }
offset += n
}
return true
}
/// The obfs4 handshake, within the attempt's time limit. A bridge that
/// cannot verify the handshake says nothing, so silence is the failure.
private func handshake(on fd: Int32, with bridge: Bridge)
-> (plane: Obfs4DataPlane, leftover: [UInt8])?
{
var priv = [UInt8]()
var pub = [UInt8]()
var repr = [UInt8]()
@@ -232,11 +407,14 @@ public final class Obfs4Transport {
let padLength = Int.random(in: Obfs4Handshake.clientMinPadLength...1200)
let padding = (0..<padLength).map { _ in UInt8.random(in: 0...255) }
let opening = handshake.opening(padding: padding)
_ = opening.withUnsafeBufferPointer { send(fd, $0.baseAddress, $0.count, 0) }
guard sendAll(fd, handshake.opening(padding: padding)) else { return nil }
let deadline = Date().addingTimeInterval(attemptTimeout)
var buf = [UInt8](repeating: 0, count: 16384)
for _ in 0..<16 {
let left = Int32(max(0, deadline.timeIntervalSinceNow) * 1000)
var p = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
guard left > 0, poll(&p, 1, left) == 1 else { return nil }
let n = recv(fd, &buf, buf.count, 0)
if n <= 0 { return nil }
+2 -8
View File
@@ -80,15 +80,9 @@ enum DeviceIntegrity {
// MARK: - What to say about it
static let title = "This iPhone appears to be jailbroken"
static let title = String(localized: "This iPhone appears to be jailbroken")
/// Says what actually changes, and does not overstate it.
static let explanation =
"Flamenet relies on iOS keeping other apps out of its data, that is what "
+ "protects your keys and your history on this phone. On a jailbroken "
+ "iPhone that separation is no longer guaranteed, so software running here "
+ "may be able to read them.\n\n"
+ "Messages are still encrypted in transit and the people you talk to are "
+ "unaffected. Nothing is being blocked, and this check can be wrong, "
+ "developer devices and some security tools look the same from here."
String(localized: "Flamenet relies on iOS keeping other apps out of its data, that is what protects your keys and your history on this phone. On a jailbroken iPhone that separation is no longer guaranteed, so software running here may be able to read them.\n\nMessages are still encrypted in transit and the people you talk to are unaffected. Nothing is being blocked, and this check can be wrong, developer devices and some security tools look the same from here.")
}
+4 -2
View File
@@ -393,8 +393,10 @@ final class TodayStore: ObservableObject {
/// other traces. The file is public; the picks say where somebody cares
/// about, and the next person to use this phone has no business with
/// either.
static func forgetOnThisPhone(_ store: TodayStore = .shared, defaults: UserDefaults = .standard) {
store.forgetEverything()
/// `store` is optional because a default of `.shared` is evaluated
/// outside the main actor, which Swift 6 refuses.
static func forgetOnThisPhone(_ store: TodayStore? = nil, defaults: UserDefaults = .standard) {
(store ?? .shared).forgetEverything()
defaults.removeObject(forKey: TodayPlaces.key)
}
}
+14 -9
View File
@@ -74,20 +74,25 @@ final class BuddyListViewModel: ObservableObject {
/// Add without touching the relay, the off-server path (P1).
func addLocalOnly(id: Int, handle: String, displayName: String) {
inbox?.accept(id)
inbox?.rosterAdd(id: id, handle: handle, displayName: displayName)
buddies = inbox?.rosterBuddies() ?? buddies
}
/// Add a buddy by account username.
func addBuddy(username: String) async -> String? {
guard let api else { return "Not connected to a site." }
do {
try await api.addBuddy(username: username, fromPagePath: pagePath)
await load()
return nil
} catch {
return (error as? MessengerError)?.errorDescription ?? error.localizedDescription
/// Add somebody the lookup found: onto the sealed list when the relay no
/// longer keeps one, otherwise the relay's (`ContactAdd`).
func addBuddy(_ person: BuddyProfile) async -> String? {
if let problem = await ContactAdd.perform(
person, inbox: inbox, api: api, pagePath: pagePath)
{
return problem
}
if rosterIsOffServer {
buddies = inbox?.rosterBuddies() ?? buddies
} else {
await load()
}
return nil
}
var rosterIsOffServer: Bool { inbox?.rosterOffServer ?? false }
+53 -28
View File
@@ -41,16 +41,20 @@ final class ChatViewModel: ObservableObject {
message: (command == .back || command == .here) ? "" : (query.isEmpty ? nil : query)
)
if command == .away {
return query.isEmpty ? "You are away." : "You are away: \(query)"
return query.isEmpty ? String(localized: "You are away.") : String(localized: "You are away: \(query)")
}
if command == .invisible { return "You are invisible." }
return "You are back online."
if command == .invisible { return String(localized: "You are invisible.") }
return String(localized: "You are back online.")
case .status:
_ = try await api.setStatus(nil, message: query)
return query.isEmpty ? "Status message cleared." : "Status set: \(query)"
return query.isEmpty ? String(localized: "Status message cleared.") : String(localized: "Status set: \(query)")
case .add:
try await api.addBuddy(username: query, fromPagePath: "")
return "\(query) added to your buddy list."
if let problem = await ContactAdd.perform(
username: query, inbox: inbox, api: api, pagePath: "")
{
throw MessengerError.ajaxFailure(problem)
}
return String(localized: "\(query) added to your buddy list.")
case .remove:
// Off the server the sealed roster IS the list, so asking the relay
// who is on it answers for an account that no longer keeps one, and
@@ -62,22 +66,22 @@ final class ChatViewModel: ObservableObject {
$0.displayName.lowercased() == wanted || $0.handle.lowercased() == wanted
})
else {
throw MessengerError.ajaxFailure("\(query) is not on your buddy list.")
throw MessengerError.ajaxFailure(String(localized: "\(query) is not on your buddy list."))
}
await ContactRemoval.perform(id: entry.id, inbox: inbox, api: api)
return "\(entry.displayName) removed from your buddy list."
return String(localized: "\(entry.displayName) removed from your buddy list.")
}
let match = try await api.fetchBuddies(fromPagePath: "")
.first { $0.displayName.lowercased() == query.lowercased() }
guard let match else {
throw MessengerError.ajaxFailure("\(query) is not on your buddy list.")
throw MessengerError.ajaxFailure(String(localized: "\(query) is not on your buddy list."))
}
if let failure = await ContactRemoval.perform(
id: match.id, inbox: inbox, api: api)
{
throw MessengerError.ajaxFailure(failure)
}
return "\(match.displayName) removed from your buddy list."
return String(localized: "\(match.displayName) removed from your buddy list.")
case .block, .unblock:
let target: (id: Int, name: String)
if query.isEmpty {
@@ -86,7 +90,7 @@ final class ChatViewModel: ObservableObject {
let match = try await api.fetchBuddies(fromPagePath: "")
.first { $0.displayName.lowercased() == query.lowercased() }
guard let match else {
throw MessengerError.ajaxFailure("\(query) is not on your buddy list.")
throw MessengerError.ajaxFailure(String(localized: "\(query) is not on your buddy list."))
}
target = (match.id, match.displayName)
}
@@ -95,13 +99,26 @@ final class ChatViewModel: ObservableObject {
if command == .block {
_ = try? await inbox?.revokeDeliveryTokens(for: target.id)
}
return "\(target.name) \(command == .block ? "blocked." : "unblocked.")"
return command == .block
? String(localized: "\(target.name) blocked.")
: String(localized: "\(target.name) unblocked.")
case .whois:
return query.isEmpty
? "\(buddy.displayName), user #\(buddy.id)"
: "\(query), look them up in the member directory."
? String(localized: "\(buddy.displayName), user #\(String(buddy.id))")
: String(localized: "\(query), look them up in the member directory.")
case .star, .archive:
return try await api.conversationAction(command.rawValue, buddy: buddy.id)
// Section 8.9d: kept on this device (and its linked devices), never
// on the relay, which stored both and read neither. The command
// toggles.
guard let inbox else { throw MessengerError.notAuthenticated }
let key = E2EInbox.pinKey(buddyID: buddy.id)
let flags = inbox.flags(forThread: key)
if command == .star {
inbox.setStarred(!flags.starred, thread: key)
return flags.starred ? String(localized: "Unstarred.") : String(localized: "Starred.")
}
inbox.setArchived(!flags.archived, thread: key)
return flags.archived ? String(localized: "Moved back to your chats.") : String(localized: "Archived.")
case .buddies:
let count = try await api.fetchBuddies(fromPagePath: "").count
return count == 1 ? "You have 1 buddy." : "You have \(count) buddies."
@@ -121,7 +138,7 @@ final class ChatViewModel: ObservableObject {
guard commandsEnabled else { return false }
guard let (command, query) = BuddyCommand.parse(draft) else { return false }
guard let api = commandAPI else {
errorMessage = "Commands aren't available right now."
errorMessage = String(localized: "Commands aren't available right now.")
return true
}
// Answered on the device; nothing is sent.
@@ -143,7 +160,7 @@ final class ChatViewModel: ObservableObject {
// /me is a real message, just written in the third person.
if command.kind == .emote {
guard !query.isEmpty else {
errorMessage = "Usage: /me does something"
errorMessage = String(localized: "Usage: /me does something")
return true
}
draft = "/me " + query
@@ -152,7 +169,7 @@ final class ChatViewModel: ObservableObject {
if command.kind == .action {
if command.requiresArgument && query.isEmpty {
errorMessage = "Usage: /\(command.rawValue) \(command.argumentLabel ?? "")"
errorMessage = String(localized: "Usage: /\(command.rawValue) \(command.argumentLabel ?? "")")
return true
}
runningCommand = command
@@ -228,7 +245,7 @@ final class ChatViewModel: ObservableObject {
static func present(_ m: E2EMessage, name: String) -> Message {
Message(
id: m.id,
who: m.isMine ? "Me" : name,
who: m.isMine ? String(localized: "Me") : name,
body: m.text,
timeText: timeFormatter.string(from: m.date),
isMine: m.isMine,
@@ -271,8 +288,13 @@ final class ChatViewModel: ObservableObject {
var canSend: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending
&& !draftTooLong
}
/// Section 23.7, longer than the largest message step holds. The send
/// button stays, disabled, and the composer says why.
var draftTooLong: Bool { DraftLengthNotice.isTooLong(draft) }
/// Always true in practice now that the plaintext transport is gone; kept
/// as the single place the UI asks, rather than reading `inbox` directly.
var supportsAttachments: Bool { inbox != nil }
@@ -518,10 +540,10 @@ final class ChatViewModel: ObservableObject {
/// The choices offered.
static let messageLifetimes: [(label: String, seconds: Int)] = [
("1 minute", 60),
("5 minutes", 300),
("1 hour", 3600),
("1 day", 86_400),
(String(localized: "1 minute"), 60),
(String(localized: "5 minutes"), 300),
(String(localized: "1 hour"), 3600),
(String(localized: "1 day"), 86_400),
]
/// Only E2E messages can be replied to with a resolvable target, but a
@@ -532,7 +554,10 @@ final class ChatViewModel: ObservableObject {
// A card is quoted by its front, never by the words inside.
let preview =
target.kindSummary
?? target.attachment.map { $0.kind == .audio ? "🎤 Voice note" : "📷 Photo" }
?? target.attachment.map {
$0.kind == .audio
? String(localized: "🎤 Voice note") : String(localized: "📷 Photo")
}
?? String(target.body.prefix(140))
return E2EAttachment.Reference(
mid: target.mid ?? "",
@@ -649,10 +674,10 @@ final class ChatViewModel: ObservableObject {
do {
// No plaintext path, by design.
guard let inbox else {
errorMessage =
"This conversation isn't encrypted yet. "
+ "Reopen the app to finish setting up encryption, "
+ "nothing is sent unencrypted."
errorMessage = String(
localized:
"This conversation isn't encrypted yet. Reopen the app to finish setting up encryption, nothing is sent unencrypted."
)
return
}
try await inbox.send(
+4 -4
View File
@@ -20,9 +20,9 @@ struct AboutView: View {
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 0) {
aboutRow("Encryption", "Signal-style X3DH + Double Ratchet")
aboutRow(String(localized: "Encryption"), String(localized: "Signal-style X3DH + Double Ratchet"))
Divider().padding(.leading, 16)
aboutRow("Wire AEAD", "AES-256-GCM")
aboutRow(String(localized: "Wire AEAD"), "AES-256-GCM")
Divider().padding(.leading, 16)
}
.background(
@@ -45,7 +45,7 @@ struct AboutView: View {
let info = Bundle.main.infoDictionary
let v = info?["CFBundleShortVersionString"] as? String ?? "1.0"
let b = info?["CFBundleVersion"] as? String ?? "1"
return "Version \(v) (\(b))"
return String(localized: "Version \(v) (\(b))")
}
private func aboutRow(_ title: String, _ value: String) -> some View {
@@ -60,7 +60,7 @@ struct AboutView: View {
private var links: some View {
VStack(spacing: 8) {
link("Open flamenet.io", url: URL(string: "https://flamenet.io")!)
link(String(localized: "Open flamenet.io"), url: URL(string: "https://flamenet.io")!)
}
.padding(.top, 4)
}
+39 -38
View File
@@ -19,6 +19,16 @@ struct BuddyListView: View {
/// On a phone it is pushed onto this stack. On an iPad it belongs in the
/// right-hand column, and pushing it here would bury the list it was
/// chosen from inside the left one.
/// Who a deep link opens a conversation with: the buddy, or, for
/// somebody reached through a contact link (section 8.14), who is not on
/// the relay's contact list, the person by the name the inbox holds.
/// Writing to them accepted them, so the conversation is theirs either way.
static func deepLinkTarget(_ id: Int, buddies: [Buddy], name: String?) -> Buddy? {
if let buddy = buddies.first(where: { $0.id == id }) { return buddy }
guard let name, !name.isEmpty else { return nil }
return Buddy(id: id, displayName: name, accepted: true)
}
private func openConversation(_ buddy: Buddy) {
if pane.isWide {
pane.buddy = buddy
@@ -46,13 +56,13 @@ struct BuddyListView: View {
/// of the screen and the things that act on it share a row.
private var header: some View {
PortalHeader(
title: "Home",
title: String(localized: "Home"),
subtitle: environment.headerSubtitle,
actions: {
// Small, and first: it is the least-used of the three, so it
// sits furthest from the thumb rather than displacing the add
// button people reach for.
MiniWallet()
if !WalletAvailability.hidden { MiniWallet() }
// The explicit way in for people who don't think to type into
// a SEARCH box to add somebody.
Button {
@@ -74,7 +84,7 @@ struct BuddyListView: View {
},
accessory: {
PortalSearchField(
prompt: "Search or add by username", text: $searchText,
prompt: String(localized: "Search or add by username"), text: $searchText,
onSubmit: { Task { await runLookup() } })
})
}
@@ -105,7 +115,9 @@ struct BuddyListView: View {
}
// A scan fills the search box and stops.
.sheet(isPresented: $scanning) {
CardScanner { card in searchText = card.address }
CardScanner(
onFound: { card in searchText = card.address },
onLink: { environment.pendingContactLink = $0 })
}
// Another tab handed Home an address (Messages' scanner does), same
// landing as a scan here: the search box, then the address path
@@ -128,18 +140,9 @@ struct BuddyListView: View {
// Off-server roster (P1): the add is this device's to
// make, the relay only resolved the handle, and that
// lookup was transient.
if model.rosterIsOffServer {
model.addLocalOnly(
id: person.id, handle: person.slug,
displayName: person.name)
environment.knownBuddies = model.buddies
environment.knownBuddyIDs = model.buddies.map(\.id)
return nil
}
if let problem = await model.addBuddy(username: person.slug) {
if let problem = await model.addBuddy(person) {
return problem
}
await model.load()
environment.knownBuddies = model.buddies
environment.knownBuddyIDs = model.buddies.map(\.id)
return nil
@@ -154,11 +157,11 @@ struct BuddyListView: View {
environment.e2eInbox?.loadAvatars(for: model.buddies.map(\.id))
pushNamesToInbox()
}
// A stranger's thread appeared, resolve who they are and add them,
// so the row upgrades from "Buddy <id>" to a real contact.
// A stranger's thread appeared (a request), resolve who they are
// so the row says a name rather than "Buddy <id>". Nobody is added.
.task(id: unlistedCorrespondents.map(\.id)) {
guard !unlistedCorrespondents.isEmpty else { return }
guard await environment.adoptStrangers() else { return }
guard await environment.nameStrangers() else { return }
await model.load()
environment.knownBuddyIDs = model.buddies.map(\.id)
environment.knownBuddies = model.buddies
@@ -184,7 +187,9 @@ struct BuddyListView: View {
}
.onChange(of: environment.deepLinkBuddyID) { _, newValue in
guard let id = newValue,
let buddy = model.buddies.first(where: { $0.id == id })
let buddy = Self.deepLinkTarget(
id, buddies: model.buddies,
name: environment.e2eInbox?.displayName(for: id))
else { return }
openConversation(buddy)
environment.deepLinkBuddyID = nil
@@ -275,8 +280,7 @@ struct BuddyListView: View {
Text("That's your own address.")
.font(.subheadline.weight(.semibold))
Text(
"Show this code to somebody else so they can add you, "
+ "your Address screen has it."
"Show this code to somebody else so they can add you, your Address screen has it."
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -295,10 +299,12 @@ struct BuddyListView: View {
Text("They're on your relay.")
.font(.subheadline.weight(.semibold))
Text(
"This address is on \(card.relay), the same server you are on. "
+ "People here are added by username, search for theirs above, "
+ "and you get a normal conversation rather than one that has to "
+ "cross between servers."
"""
This address is on \(card.relay), the same server you are on. \
People here are added by username, search for theirs above, \
and you get a normal conversation rather than one that has to \
cross between servers.
"""
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -324,8 +330,7 @@ struct BuddyListView: View {
.foregroundStyle(.secondary)
// Says where it goes.
Text(
"Messages to this address travel from your relay to theirs. "
+ "Compare safety numbers with them before you trust it."
"Messages to this address travel from your relay to theirs. Compare safety numbers with them before you trust it."
)
.font(.caption2)
.foregroundStyle(.secondary)
@@ -396,8 +401,7 @@ struct BuddyListView: View {
// relay once it has signed in to Messenger at least
// once.
Text(
"Usernames have to match exactly. Someone with a FlameNet "
+ "account shows up here once they have used Messenger."
"Usernames have to match exactly. Someone with a FlameNet account shows up here once they have used Messenger."
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -480,11 +484,10 @@ struct BuddyListView: View {
addingID = person.id
defer { addingID = nil }
// `addBuddy` returns a message on failure and nil on success.
if let problem = await model.addBuddy(username: person.slug) {
if let problem = await model.addBuddy(person) {
lookupState = .failed(problem)
return
}
await model.load()
// Clearing the field is what makes the add visible: the list comes back
// unfiltered with the new buddy in it.
searchText = ""
@@ -659,9 +662,9 @@ struct BuddyRow: View {
let body =
last.card?.summary ?? last.event?.event.summary ?? last.game?.summary
?? (last.text.isEmpty
? (last.attachment != nil ? "Photo" : "")
? (last.attachment != nil ? String(localized: "Photo") : "")
: last.text)
if !body.isEmpty { return last.isMine ? "You: \(body)" : body }
if !body.isEmpty { return last.isMine ? String(localized: "You: \(body)") : body }
}
if let sn = buddy.screenName, !sn.isEmpty { return sn }
return buddy.presence.label
@@ -728,7 +731,7 @@ struct AddContactSheet: View {
var body: some View {
if ParentalControls.contactsGated && !supervisionUnlocked {
ParentalPINPrompt(title: "Add contact") { supervisionUnlocked = true }
ParentalPINPrompt(title: String(localized: "Add contact")) { supervisionUnlocked = true }
} else {
addForm
}
@@ -748,8 +751,7 @@ struct AddContactSheet: View {
.accessibilityIdentifier("add.lookup")
} footer: {
Text(
"The username they signed up with, or a flamenet address "
+ "from their Address screen.")
"The username they signed up with, or a flamenet address from their Address screen.")
}
if let found {
@@ -775,8 +777,7 @@ struct AddContactSheet: View {
.accessibilityIdentifier("add.confirm")
} footer: {
Text(
"Check their safety number once you start talking, a "
+ "lookup only says the name exists on this server.")
"Check their safety number once you start talking, a lookup only says the name exists on this server.")
}
}
@@ -816,7 +817,7 @@ struct AddContactSheet: View {
if let person = try await api.lookupMember(username: trimmed) {
found = person
} else {
message = "No account called \"\(trimmed)\" here."
message = String(localized: "No account called \"\(trimmed)\" here.")
}
} catch {
message =
+45 -33
View File
@@ -12,6 +12,8 @@ struct BuddyProfileView: View {
var onCall: (() -> Void)? = nil
@State private var isPinned = false
/// Bumped when a post-quantum pin lands, so the number is read again.
@State private var numberChecked = 0
@State private var isMuted = false
@State private var isBlocked = false
@@ -31,15 +33,15 @@ struct BuddyProfileView: View {
// The kit's contact detail: a cover, a name plate, and a rail
// of actions floating at the seam between them.
coverAndRail
section("Account") {
row("Username", buddy.screenName ?? ", ")
row("User ID", "\(buddy.id)")
row("Status", buddy.presence.label)
section(String(localized: "Account")) {
row(String(localized: "Username"), buddy.screenName ?? ", ")
row(String(localized: "User ID"), "\(buddy.id)")
row(String(localized: "Status"), buddy.presence.label)
}
encryptionSection
// The way back.
if let inbox = environment.e2eInbox {
section("Stories") {
section(String(localized: "Stories")) {
Toggle(
"Show \(buddy.displayName)'s stories",
isOn: Binding(
@@ -50,9 +52,7 @@ struct BuddyProfileView: View {
.accessibilityIdentifier("profile.stories.toggle")
// Says what muting is NOT.
Text(
"Turning this off hides their stories on this "
+ "phone. They aren't told, and their messages "
+ "still arrive as usual."
"Turning this off hides their stories on this phone. They aren't told, and their messages still arrive as usual."
)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
@@ -92,9 +92,7 @@ struct BuddyProfileView: View {
Button("Cancel", role: .cancel) {}
} message: {
Text(
"Both of you should be reading the same number. Comparing it in "
+ "person or by scanning is stronger than reading it over a call, "
+ "which could itself be intercepted.")
"Both of you should be reading the same number. Comparing it in person or by scanning is stronger than reading it over a call, which could itself be intercepted.")
}
.confirmationDialog(
"Block \(buddy.displayName)?",
@@ -148,6 +146,12 @@ struct BuddyProfileView: View {
}
private func refreshBlocked() async {
// This device's list first (section 8.9d), then the relay's, which
// may still hold a block made on a device that predates 0.32.
if environment.e2eInbox?.isBlocked(buddy.id) == true {
isBlocked = true
return
}
guard let api = environment.api else { return }
if let list = try? await api.fetchBlocked() {
isBlocked = list.contains { $0.id == buddy.id }
@@ -163,9 +167,8 @@ struct BuddyProfileView: View {
isBlocked = true
} catch {
// Do NOT flip the toggle.
blockFailure =
"Couldn't block \(buddy.displayName). "
+ "They have NOT been blocked, try again."
blockFailure = String(
localized: "Couldn't block \(buddy.displayName). They have NOT been blocked, try again.")
}
}
}
@@ -178,9 +181,8 @@ struct BuddyProfileView: View {
blockFailure = nil
isBlocked = false
} catch {
blockFailure =
"Couldn't unblock \(buddy.displayName). "
+ "They are still blocked, try again."
blockFailure = String(
localized: "Couldn't unblock \(buddy.displayName). They are still blocked, try again.")
}
}
}
@@ -245,17 +247,17 @@ struct BuddyProfileView: View {
/// 60x240 rail at radius 30.
private var actionRail: some View {
VStack(spacing: 0) {
railButton("bubble.left.fill", "Message", id: "profile.message") {
railButton("bubble.left.fill", String(localized: "Message"), id: "profile.message") {
startChat()
}
if let onCall {
railDivider
railButton("phone.fill", "Call", id: "profile.call", action: onCall)
railButton("phone.fill", String(localized: "Call"), id: "profile.call", action: onCall)
}
railDivider
railButton(
isMuted ? "bell.slash.fill" : "bell.fill",
isMuted ? "Unmute" : "Mute", id: "profile.mute"
isMuted ? String(localized: "Unmute") : String(localized: "Mute"), id: "profile.mute"
) {
isMuted.toggle()
NotificationManager.shared.setMuted(isMuted, buddyID: buddy.id)
@@ -355,7 +357,7 @@ struct BuddyProfileView: View {
private var quickActions: some View {
HStack(spacing: 12) {
actionButton(
title: isPinned ? "Pinned" : "Pin",
title: isPinned ? String(localized: "Pinned") : String(localized: "Pin"),
systemImage: isPinned ? "pin.fill" : "pin",
active: isPinned
) {
@@ -363,7 +365,7 @@ struct BuddyProfileView: View {
BuddyPrefs.setPinned(isPinned, buddyID: buddy.id)
}
actionButton(
title: isMuted ? "Muted" : "Mute",
title: isMuted ? String(localized: "Muted") : String(localized: "Mute"),
systemImage: isMuted ? "bell.slash.fill" : "bell",
active: isMuted
) {
@@ -398,7 +400,7 @@ struct BuddyProfileView: View {
private var encryptionSection: some View {
if let inbox = environment.e2eInbox {
let state = inbox.trustState(for: buddy.id)
section("Encryption") {
section(String(localized: "Encryption")) {
switch state {
case .unknown:
Text("No encrypted session yet. Send a message and their keys will be pinned.")
@@ -434,11 +436,13 @@ struct BuddyProfileView: View {
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text(
"Their existing keys are unchanged, so this is what buying a "
+ "second phone looks like. It is also what somebody adding a "
+ "device to their account looks like, nothing breaks either "
+ "way. Ask them whether they did, and compare the number "
+ "below if you want to be sure."
"""
Their existing keys are unchanged, so this is what buying a \
second phone looks like. It is also what somebody adding a \
device to their account looks like, nothing breaks either \
way. Ask them whether they did, and compare the number \
below if you want to be sure.
"""
)
.font(.footnote)
.foregroundStyle(.secondary)
@@ -449,10 +453,12 @@ struct BuddyProfileView: View {
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Text(
"One of their phones is no longer on this account. The keys "
+ "that are left are the same ones you already had, so "
+ "nothing about your conversation changes and there is "
+ "nothing for you to accept."
"""
One of their phones is no longer on this account. The keys \
that are left are the same ones you already had, so \
nothing about your conversation changes and there is \
nothing for you to accept.
"""
)
.font(.footnote)
.foregroundStyle(.secondary)
@@ -465,7 +471,7 @@ struct BuddyProfileView: View {
.foregroundStyle(state == .verified ? .green : .secondary)
}
if let number = inbox.safetyNumber(for: buddy.id) {
if let number = inbox.safetyNumber(for: buddy.id), numberChecked >= 0 {
VStack(alignment: .leading, spacing: 4) {
ForEach(SafetyNumber.displayRows(number), id: \.self) { line in
Text(line)
@@ -474,6 +480,12 @@ struct BuddyProfileView: View {
}
.padding(.vertical, 6)
.accessibilityLabel("Safety number \(number)")
// SPEC §7: read their bundle once if this device cannot
// cover their post-quantum identity yet, so the digits
// match the ones on their screen.
.task(id: buddy.id) {
if await inbox.pinPeerPQIdentityIfNeeded(buddy.id) { numberChecked += 1 }
}
Text(
"Read this aloud with \(buddy.displayName). Both devices must show the same 60 digits."
+11 -7
View File
@@ -47,19 +47,23 @@ struct CallIdentityReview: View {
// far the likelier, and the other one anyway, because it is
// the one that matters.
Text(
"\(peerName)'s keys are not the ones you accepted before, so the "
+ "call was not set up. That happens after a reinstall or a new "
+ "phone, but it is also what someone reading your messages "
+ "would look like."
"""
\(peerName)'s keys are not the ones you accepted before, so the \
call was not set up. That happens after a reinstall or a new \
phone, but it is also what someone reading your messages \
would look like.
"""
)
.font(.callout)
safetyNumber
Text(
"Accepting means this call, and everything you send after it, goes "
+ "to whoever holds the new keys. Read the number to \(peerName) "
+ "on a line you already trust, and accept only if it matches."
"""
Accepting means this call, and everything you send after it, goes \
to whoever holds the new keys. Read the number to \(peerName) \
on a line you already trust, and accept only if it matches.
"""
)
.font(.footnote)
.foregroundStyle(.secondary)
+13 -13
View File
@@ -125,11 +125,11 @@ struct CallScreen: View {
case .ringing:
return [
.init(
id: "decline", symbol: "phone.down.fill", label: "Decline",
id: "decline", symbol: "phone.down.fill", label: String(localized: "Decline"),
tint: Theme.danger, iconColor: .white
) { manager.declineIncomingCall() },
.init(
id: "accept", symbol: "phone.fill", label: "Accept",
id: "accept", symbol: "phone.fill", label: String(localized: "Accept"),
isPrimary: true, tint: Theme.success, iconColor: .white
) {
manager.acceptIncomingCall()
@@ -138,7 +138,7 @@ struct CallScreen: View {
case .dialing, .connecting:
return [
.init(
id: "cancel", symbol: "phone.down.fill", label: "Cancel",
id: "cancel", symbol: "phone.down.fill", label: String(localized: "Cancel"),
isPrimary: true, tint: Theme.danger, iconColor: .white
) { manager.hangUp() }
]
@@ -146,19 +146,19 @@ struct CallScreen: View {
return [
.init(
id: "mute", symbol: manager.isMuted ? "mic.slash.fill" : "mic.fill",
label: "Mute",
label: String(localized: "Mute"),
tint: manager.isMuted ? Theme.primary : Theme.pillFill,
iconColor: manager.isMuted ? .white : Theme.ink
) { manager.toggleMute() },
.init(
id: "speaker", symbol: "speaker.wave.2.fill", label: "Speaker",
id: "speaker", symbol: "speaker.wave.2.fill", label: String(localized: "Speaker"),
tint: manager.isSpeakerOn ? Theme.primary : Theme.pillFill,
iconColor: manager.isSpeakerOn ? .white : Theme.ink
) {
manager.toggleSpeaker()
},
.init(
id: "end", symbol: "phone.down.fill", label: "End",
id: "end", symbol: "phone.down.fill", label: String(localized: "End", comment: "Call button: hang up"),
isPrimary: true, tint: Theme.danger, iconColor: .white
) { manager.hangUp() },
]
@@ -169,7 +169,7 @@ struct CallScreen: View {
? [
CallScreenBody.Action(
id: "handoff", symbol: "ipad.and.iphone",
label: "Move",
label: String(localized: "Move", comment: "Call button: move the call to another of your devices"),
tint: Theme.pillFill, iconColor: Theme.ink
) { manager.offerHandoff() }
] : [])
@@ -179,7 +179,7 @@ struct CallScreen: View {
return [
.init(
id: "review", symbol: "exclamationmark.shield.fill",
label: "Review keys",
label: String(localized: "Review keys"),
isPrimary: true, tint: Theme.primary, iconColor: .white
) {
// Stay on the call screen. Sending somebody to the
@@ -188,7 +188,7 @@ struct CallScreen: View {
reviewingKeys = true
},
.init(
id: "close", symbol: "xmark", label: "Close",
id: "close", symbol: "xmark", label: String(localized: "Close"),
tint: Theme.pillFill, iconColor: Theme.ink
) {
manager.acknowledgeEnd()
@@ -197,7 +197,7 @@ struct CallScreen: View {
}
return [
.init(
id: "close", symbol: "xmark", label: "Close",
id: "close", symbol: "xmark", label: String(localized: "Close"),
isPrimary: true, tint: Theme.pillFill, iconColor: Theme.ink
) {
manager.acknowledgeEnd()
@@ -216,9 +216,9 @@ struct CallScreen: View {
private var statusLine: String {
switch manager.state {
case .idle: return ""
case .dialing: return "Calling…"
case .ringing: return "Incoming call"
case .connecting: return "Connecting…"
case .dialing: return String(localized: "Calling…")
case .ringing: return String(localized: "Incoming call")
case .connecting: return String(localized: "Connecting…")
case .active(let since): return elapsed(since: since)
case .ended(let reason): return reason
}
+16 -10
View File
@@ -16,7 +16,7 @@ struct CallsView: View {
// read as a coloured band with a button floating in it.
NavigationStack {
VStack(spacing: 0) {
PortalHeader("Calls") {
PortalHeader(String(localized: "Calls")) {
// The way IN.
Button {
showingNewCall = true
@@ -110,13 +110,13 @@ struct CallsView: View {
.contentShape(Rectangle())
.onTapGesture { place(buddy, video: false) }
callGlyph(
"phone", label: "Call \(buddy.displayName)",
"phone", label: String(localized: "Call \(buddy.displayName)"),
id: "calls.new.\(buddy.id)"
) {
place(buddy, video: false)
}
callGlyph(
"video", label: "Video call \(buddy.displayName)",
"video", label: String(localized: "Video call \(buddy.displayName)"),
id: "calls.new.video.\(buddy.id)"
) {
place(buddy, video: true)
@@ -155,9 +155,11 @@ struct CallsView: View {
Text("No calls yet")
.font(.system(size: 16, weight: .medium))
Text(
"Calls you place and receive appear here. Start one from a "
+ "conversation, they go directly between the two phones, so "
+ "this list is the only record of them and it is on this device."
"""
Calls you place and receive appear here. Start one from a \
conversation, they go directly between the two phones, so \
this list is the only record of them and it is on this device.
"""
)
.font(.system(size: 14))
.foregroundStyle(Theme.secondaryText)
@@ -177,7 +179,7 @@ struct CallsView: View {
let name =
buddy?.displayName
?? inbox.knownName(for: entry.peerID)
?? "user \(entry.peerID)"
?? String(localized: "user \(String(entry.peerID))")
return HStack(spacing: Theme.Kit.gutter) {
AvatarView(name: name, picture: inbox.avatars[entry.peerID], inbox: inbox)
@@ -241,8 +243,12 @@ struct CallsView: View {
}
private func description(_ entry: E2ECallEntry) -> String {
var parts = [entry.incoming ? "Incoming" : "Outgoing"]
if !entry.connected { parts.append("missed") }
var parts = [
entry.incoming
? String(localized: "Incoming", comment: "Call log: a call they placed to you")
: String(localized: "Outgoing", comment: "Call log: a call you placed")
]
if !entry.connected { parts.append(String(localized: "missed", comment: "Call log: the call was not answered")) }
parts.append(RowStamp.text(entry.at))
return parts.joined(separator: " · ")
}
@@ -257,7 +263,7 @@ enum RowStamp {
if cal.isDateInToday(at) {
f.dateFormat = "hh:mm a"
} else if cal.isDateInYesterday(at) {
return "Yesterday"
return String(localized: "Yesterday")
} else if let d = cal.dateComponents([.day], from: at, to: Date()).day, d < 7 {
f.dateFormat = "EEE"
} else {
+2 -2
View File
@@ -60,11 +60,11 @@ struct ChangePasswordSheet: View {
private func save() {
guard let api = environment.api, !isWorking else { return }
if newPassword != confirm {
localError = "Passwords don't match."
localError = String(localized: "Passwords don't match.")
return
}
if newPassword.count < 8 {
localError = "Password must be at least 8 characters."
localError = String(localized: "Password must be at least 8 characters.")
return
}
isWorking = true
+113 -58
View File
@@ -64,9 +64,12 @@ struct ChatScreen: View {
.font(.headline)
.multilineTextAlignment(.center)
Text(
"Flamenet won't send messages unencrypted, so this conversation "
+ "stays closed until encryption is set up. Nothing has been sent "
+ "in the clear."
String(
localized: """
Flamenet won't send messages unencrypted, so this conversation \
stays closed until encryption is set up. Nothing has been sent \
in the clear.
""")
)
.font(.subheadline)
.foregroundStyle(.secondary)
@@ -139,7 +142,8 @@ struct ChatView: View {
/// only thing that used it. Six outcomes use it now, so reporting a
/// message and blocking somebody both announced themselves as "Files".
private struct Notice {
let title: String
/// A key, so every title literal below is picked up for translation.
let title: LocalizedStringKey
let body: String
}
@@ -421,7 +425,11 @@ struct ChatView: View {
showingSearch.toggle()
if !showingSearch { searchQuery = "" }
}
Button(isMuted ? "Unmute notifications" : "Mute notifications") {
Button(
isMuted
? String(localized: "Unmute notifications")
: String(localized: "Mute notifications")
) {
toggleMute()
}
// Reporting lived only behind a press and hold on a
@@ -445,8 +453,8 @@ struct ChatView: View {
}
Button(
model.offTheRecord
? "Stop holding this off the record"
: "Hold this off the record"
? String(localized: "Stop holding this off the record")
: String(localized: "Hold this off the record")
) {
Task { await model.setOffTheRecord(!model.offTheRecord) }
}
@@ -484,21 +492,25 @@ struct ChatView: View {
reason: reason,
includeContent: includeContent,
note: note)
notice = Notice(title: "Reported", body: "Reported to whoever runs this server.")
notice = Notice(
title: "Reported",
body: String(localized: "Reported to whoever runs this server."))
} catch {
// A relay may decline reports entirely
// (`reports_not_accepted`, 501).
notice = Notice(
title: "Report not sent",
body: (error as? MessengerError)?.errorDescription
?? "That report could not be sent.")
?? String(localized: "That report could not be sent."))
}
},
onBlock: {
// Same call the stories path uses.
Task {
try? await environment.setBlocked(true, userID: model.buddy.id)
notice = Notice(title: "Blocked", body: "\(model.buddy.displayName) blocked.")
notice = Notice(
title: "Blocked",
body: String(localized: "\(model.buddy.displayName) blocked."))
}
})
}
@@ -574,19 +586,25 @@ struct ChatView: View {
/// "Not in your contacts", with the two things worth offering.
@ViewBuilder
private var strangerBanner: some View {
if !model.buddy.accepted, !acceptedStranger, !blockedStranger {
// Section 14.5: acceptance is held on the device now, not on the relay.
if !(environment.e2eInbox?.isAccepted(model.buddy.id) ?? model.buddy.accepted),
!acceptedStranger, !blockedStranger
{
VStack(alignment: .leading, spacing: 6) {
Text("\(model.buddy.displayName) isn't in your contacts")
.font(.subheadline.weight(.medium))
// Says what happened rather than warning about it.
Text(
"They messaged you, so they were added automatically. "
+ "You can keep them or stop them reaching you."
String(
localized: """
This is a message request. Accept to add them to your contacts, \
or block them so they can't reach you.
""")
)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button("Keep as contact") { acceptBuddy() }
Button("Accept") { acceptBuddy() }
.buttonStyle(.portalProminent)
.accessibilityIdentifier("accept-stranger")
Button("Block", role: .destructive) { blockBuddy() }
@@ -698,8 +716,9 @@ struct ChatView: View {
if visibleMessages.isEmpty {
Text(
showingSearch && !searchQuery.isEmpty
? "No messages match \"\(searchQuery)\"."
: "No messages yet. Say hello to \(model.buddy.displayName)!"
? String(localized: "No messages match \"\(searchQuery)\".")
: String(
localized: "No messages yet. Say hello to \(model.buddy.displayName)!")
)
.font(.footnote)
.foregroundStyle(.secondary)
@@ -790,7 +809,9 @@ struct ChatView: View {
Task { await model.setPin(isPinned ? "" : mid) }
} label: {
Label(
isPinned ? "Unpin" : "Pin",
isPinned
? String(localized: "Unpin")
: String(localized: "Pin", comment: "Verb"),
systemImage: isPinned ? "pin.slash" : "pin")
}
}
@@ -873,18 +894,18 @@ struct ChatView: View {
}
private func acceptBuddy() {
guard let api = environment.api else { return }
// Optimistic, then confirmed.
// Accepted on the device at once (section 14.5); adding them as a
// contact may still fail, and is said so if it does.
acceptedStranger = true
Task {
do {
try await api.acceptConversation(buddyID: model.buddy.id)
try await environment.acceptRequest(model.buddy.id)
} catch {
// Put it back rather than pretending.
acceptedStranger = false
model.errorMessage =
"Couldn't save that. \(model.buddy.displayName) "
+ "has NOT been added to your contacts, try again."
model.errorMessage = String(
localized: """
Accepted, but \(model.buddy.displayName) couldn't be added to \
your contacts just now. Try adding them again later.
""")
}
}
}
@@ -898,17 +919,18 @@ struct ChatView: View {
// offering to block somebody who had just been blocked, with
// nothing on screen having changed, which is indistinguishable
// from the button not working and is what it was taken for.
blockedNotice =
"\(model.buddy.displayName) is blocked. They cannot send you "
+ "messages. You can undo this on their profile."
blockedNotice = String(
localized: """
\(model.buddy.displayName) is blocked. They cannot send you \
messages. You can undo this on their profile.
""")
blockedStranger = true
} catch {
// Blocking is a safety action, so a silent no-op is the worst
// possible failure: the person believes they are protected and
// the server was never told.
model.errorMessage =
"Couldn't block \(model.buddy.displayName). "
+ "They have NOT been blocked, try again."
model.errorMessage = String(
localized: "Couldn't block \(model.buddy.displayName). They have NOT been blocked, try again.")
}
}
}
@@ -1018,13 +1040,16 @@ struct ChatView: View {
.fill(Theme.primary)
.frame(width: 3, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text(target.isMine ? "Replying to yourself" : "Replying to \(target.who)")
Text(
target.isMine
? String(localized: "Replying to yourself")
: String(localized: "Replying to \(target.who)"))
.font(.caption2.weight(.semibold))
.foregroundStyle(Theme.primary)
Text(
target.kindSummary
?? (target.attachment != nil && target.body.isEmpty
? "Attachment" : target.body)
? String(localized: "Attachment") : target.body)
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -1091,7 +1116,7 @@ struct ChatView: View {
Text(
pinned.kindSummary
?? (pinned.body.isEmpty && pinned.attachment != nil
? "Pinned attachment" : pinned.body)
? String(localized: "Pinned attachment") : pinned.body)
)
.font(.caption)
.foregroundStyle(Theme.secondaryText)
@@ -1142,7 +1167,7 @@ struct ChatView: View {
private static func timerLabel(_ seconds: Int) -> String {
ChatViewModel.messageLifetimes.first { $0.seconds == seconds }?.label
?? "\(seconds) seconds"
?? String(localized: "\(seconds) seconds")
}
@ViewBuilder
@@ -1150,7 +1175,10 @@ struct ChatView: View {
if recorder.isRecording {
recordingBar
} else {
composerRow
VStack(spacing: 0) {
DraftLengthNotice(draft: model.draft)
composerRow
}
}
}
@@ -1297,7 +1325,8 @@ struct ChatView: View {
.foregroundStyle(Theme.primary)
.frame(width: 30, height: 44)
}
.accessibilityLabel(showingEmoji ? "Hide emoji" : "Emoji")
.accessibilityLabel(
showingEmoji ? String(localized: "Hide emoji") : String(localized: "Emoji"))
.accessibilityIdentifier("chat.emoji")
// A second control, not a tab inside the first.
@@ -1312,7 +1341,9 @@ struct ChatView: View {
.foregroundStyle(Theme.primary)
.frame(width: 30, height: 44)
}
.accessibilityLabel(showingStickers ? "Hide stickers" : "Stickers")
.accessibilityLabel(
showingStickers
? String(localized: "Hide stickers") : String(localized: "Stickers"))
.accessibilityIdentifier("chat.stickers")
HStack(spacing: 4) {
@@ -1342,19 +1373,27 @@ struct ChatView: View {
// Familiar messenger pattern: an empty draft offers the mic, a non-
// empty one offers send.
if model.supportsAttachments && !model.canSend {
// A draft too long to send keeps the send button, disabled, rather
// than turning into the microphone.
if model.supportsAttachments && !model.canSend && !model.draftTooLong {
Button {
Task {
if await recorder.start() == false {
model.errorMessage =
switch recorder.lastFailure {
case .couldNotOpen:
"This device would not open the microphone, so there is "
+ "nothing to record with. Nothing is wrong with your "
+ "settings."
String(
localized: """
This device would not open the microphone, so there is \
nothing to record with. Nothing is wrong with your \
settings.
""")
default:
"Microphone access is off. Enable it in Settings to send "
+ "voice notes."
String(
localized: """
Microphone access is off. Enable it in Settings to send \
voice notes.
""")
}
}
}
@@ -1404,8 +1443,8 @@ struct ChatView: View {
if let kind = scrubbed.strippedKind {
notice = Notice(
title: "Sent",
body: "The \(kind.rawValue.uppercased()) had its location and "
+ "camera details removed first.")
body: String(
localized: "The \(kind.rawValue.uppercased()) had its location and camera details removed first."))
}
try await inbox.sendFile(
scrubbed.bytes,
@@ -1416,7 +1455,8 @@ struct ChatView: View {
caption: nil,
to: model.buddy.id)
} catch {
notice = Notice(title: "Couldn't send that file", body: error.localizedDescription)
notice = Notice(
title: "Couldn't send that file", body: error.localizedDescription)
}
}
@@ -1424,15 +1464,18 @@ struct ChatView: View {
guard let vault = environment.filesVault, let inbox = model.inbox else { return }
do {
let bytes = try await inbox.loadAttachment(ref)
let name = ref.filename.isEmpty ? "Received file" : ref.filename
let name = ref.filename.isEmpty ? String(localized: "Received file") : ref.filename
_ = try await vault.add(
bytes, named: name,
label: SecurityLabel(
classification: ref.classification,
compartments: Set(ref.compartments)))
notice = Notice(title: "Saved to Files", body: "Saved \(name) to Files.")
notice = Notice(
title: "Saved to Files",
body: String(localized: "Saved \(name) to Files."))
} catch {
notice = Notice(title: "Couldn't save that file", body: error.localizedDescription)
notice = Notice(
title: "Couldn't save that file", body: error.localizedDescription)
}
}
@@ -1510,10 +1553,13 @@ private struct UnsealedSendNotice: View {
Text("The server was told you sent this")
.font(.caption.weight(.semibold))
Text(
"\(buddy.displayName)'s delivery key had gone stale, so this "
+ "message could not be sent sealed. It is encrypted either "
+ "way; what changed is that the server knows it came from "
+ "you. It usually sorts itself out once they write back."
String(
localized: """
\(buddy.displayName)'s delivery key had gone stale, so this \
message could not be sent sealed. It is encrypted either \
way; what changed is that the server knows it came from \
you. It usually sorts itself out once they write back.
""")
)
.font(.caption)
.foregroundStyle(Theme.secondaryText)
@@ -1555,9 +1601,14 @@ private struct IdentityWarning: View {
if let waiting = inbox.heldEnvelopeCounts[buddy.id], waiting > 0 {
Text(
waiting == 1
? "One message has arrived since and is being held. Accepting shows it."
: "\(waiting) messages have arrived since and are being held. "
+ "Accepting shows them."
? String(
localized:
"One message has arrived since and is being held. Accepting shows it."
)
: String(
localized:
"\(waiting) messages have arrived since and are being held. Accepting shows them."
)
)
.font(.caption.weight(.semibold))
.accessibilityIdentifier("chat.heldMessages")
@@ -1620,7 +1671,11 @@ private struct ScreenshotNoticeBanner: View {
// The disclaimer is not optional decoration. It was written to
// say exactly what a notice cannot mean, and it existed with
// nowhere to appear.
Button(showingLimits ? "Hide" : "What this can and cannot tell you") {
Button(
showingLimits
? String(localized: "Hide")
: String(localized: "What this can and cannot tell you")
) {
showingLimits.toggle()
}
.font(.caption2.weight(.semibold))
@@ -22,7 +22,7 @@ struct AttachmentFileView: View {
.padding(.top, 2)
VStack(alignment: .leading, spacing: 5) {
Text(ref.filename.isEmpty ? "A file" : ref.filename)
Text(ref.filename.isEmpty ? String(localized: "A file") : ref.filename)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isMine ? .white : Theme.ink)
.lineLimit(2)
@@ -5,7 +5,8 @@ struct SnoozeMenu: View {
@ObservedObject var model: ChatViewModel
static let choices: [(label: String, seconds: Int)] = [
("1 hour", 3_600), ("8 hours", 28_800), ("1 day", 86_400),
(String(localized: "1 hour"), 3_600), (String(localized: "8 hours"), 28_800),
(String(localized: "1 day"), 86_400),
]
var body: some View {
@@ -25,7 +26,8 @@ struct ReplyReminderMenu: View {
@ObservedObject var model: ChatViewModel
static let choices: [(label: String, seconds: Int)] = [
("In 1 hour", 3_600), ("In 3 hours", 10_800), ("Tomorrow", 86_400),
(String(localized: "In 1 hour"), 3_600), (String(localized: "In 3 hours"), 10_800),
(String(localized: "Tomorrow"), 86_400),
]
var body: some View {
@@ -45,11 +47,12 @@ enum AttentionSummary {
static func line(isSnoozed: Bool, reminderAt: Date?) -> String? {
switch (isSnoozed, reminderAt) {
case (true, .some):
return "Snoozed, and you'll be reminded if there's no reply."
return String(localized: "Snoozed, and you'll be reminded if there's no reply.")
case (true, .none):
return "Snoozed, messages still arrive, this chat just stops asking."
return String(localized: "Snoozed, messages still arrive, this chat just stops asking.")
case (false, .some):
return "You'll be reminded if there's no reply. Cancelled the moment they answer."
return String(
localized: "You'll be reminded if there's no reply. Cancelled the moment they answer.")
case (false, .none):
return nil
}
@@ -61,7 +64,8 @@ struct ScheduleSendMenu: View {
@ObservedObject var model: ChatViewModel
static let choices: [(label: String, seconds: Int)] = [
("In 1 hour", 3_600), ("In 3 hours", 10_800), ("Tomorrow", 86_400),
(String(localized: "In 1 hour"), 3_600), (String(localized: "In 3 hours"), 10_800),
(String(localized: "Tomorrow"), 86_400),
]
var body: some View {
@@ -84,7 +88,8 @@ enum ScheduledSummary {
f.timeStyle = .short
let when = f.string(from: next)
return count == 1
? "1 message waiting to send, \(when), when the app next runs."
: "\(count) messages waiting to send, next \(when), when the app next runs."
? String(localized: "1 message waiting to send, \(when), when the app next runs.")
: String(
localized: "\(count) messages waiting to send, next \(when), when the app next runs.")
}
}
@@ -0,0 +1,76 @@
import SwiftUI
/// Section 8.9d and 17.5 (decision DA9): ask people to export a backup.
///
/// Once the contact list and conversation state leave the relay (stage 2-D),
/// a phone lost with no backup and no other linked device takes the contacts
/// with it. The banner shows when nothing has been exported for 30 days, and
/// "Not now" quiets it for another 30.
enum BackupNudge {
static let lastExportKey = "fnmsg.lastHistoryExportAt"
static let snoozedUntilKey = "fnmsg.backupNudgeSnoozedUntil"
static let interval: TimeInterval = 30 * 24 * 60 * 60
static func noteExported(at date: Date = Date()) {
UserDefaults.standard.set(date.timeIntervalSince1970, forKey: lastExportKey)
}
static func snooze(from date: Date = Date()) {
UserDefaults.standard.set(
date.addingTimeInterval(interval).timeIntervalSince1970, forKey: snoozedUntilKey)
}
/// Whether to ask now. Nothing to back up, nothing to ask.
static func isDue(now: Date = Date(), hasHistory: Bool, defaults: UserDefaults = .standard)
-> Bool
{
guard hasHistory else { return false }
let snoozed = defaults.double(forKey: snoozedUntilKey)
if snoozed > 0, now.timeIntervalSince1970 < snoozed { return false }
let last = defaults.double(forKey: lastExportKey)
return last == 0 || now.timeIntervalSince1970 - last >= interval
}
}
struct BackupNudgeBanner: View {
@ObservedObject var inbox: E2EInbox
@State private var exporting = false
@State private var dismissed = false
var body: some View {
if !dismissed,
BackupNudge.isDue(hasHistory: inbox.threads.values.contains { !$0.isEmpty })
{
VStack(alignment: .leading, spacing: Theme.Space.xs) {
Text("Back up your chats")
.font(.subheadline.weight(.semibold))
Text(
"Your messages and contacts are stored only on your devices. If you lose this phone, a backup is the only way to get them back."
)
.font(.caption)
.foregroundStyle(Theme.secondaryText)
HStack(spacing: Theme.Space.sm) {
Button("Export a backup") { exporting = true }
.buttonStyle(.portalProminent)
.accessibilityIdentifier("backup-nudge.export")
Button("Not now") {
BackupNudge.snooze()
dismissed = true
}
.buttonStyle(.portal)
.accessibilityIdentifier("backup-nudge.later")
}
.controlSize(.small)
}
.padding(Theme.Space.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.avatarFill)
.accessibilityIdentifier("backup-nudge")
.sheet(isPresented: $exporting, onDismiss: {
if !BackupNudge.isDue(hasHistory: true) { dismissed = true }
}) {
HistoryExportSheet()
}
}
}
}
@@ -16,7 +16,7 @@ struct CallScreenBody: View {
let peerName: String
let status: String
/// Said plainly and always, because it is the reason to use this at all.
var reassurance: String? = "End-to-end encrypted"
var reassurance: String? = String(localized: "End-to-end encrypted")
let actions: [Action]
/// Drawn just above the controls, where the call screen puts its reaction
/// row. Absent in previews and in the screenshot harness.
+110 -13
View File
@@ -7,6 +7,9 @@ import SwiftUI
struct CardScanner: View {
/// Called once, with the first thing that parses.
let onFound: (ContactCard) -> Void
/// Section 8.14: a contact link scanned here goes to the first-message
/// sheet rather than the address path, which is for other relays.
var onLink: ((ContactLink) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var denied = false
@@ -25,8 +28,7 @@ struct CardScanner: View {
Text("Flamenet cannot use the camera.")
.font(.headline)
Text(
"Allow it in Settings → Flamenet → Camera, or paste "
+ "their address into the search box instead."
"Allow it in Settings → Flamenet → Camera, or paste their address into the search box instead."
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -35,15 +37,26 @@ struct CardScanner: View {
.padding()
.accessibilityIdentifier("scan.denied")
} else {
ScannerViewController.Representable { text in
guard let card = ScannedCode.card(from: text) else { return }
dismiss()
onFound(card)
} onRefused: {
refused = true
} onDenied: {
denied = true
}
ScannerViewController.Representable(
onFound: { text in
if let onLink, let link = ContactLink(text: text) {
dismiss()
onLink(link)
return
}
// ScannedCode, never ContactCard.decode: an older
// card's `?u=` names a username anybody can claim
// (DESIGN 32), and this sheet binds by fnid alone.
guard let card = ScannedCode.card(from: text) else { return }
dismiss()
onFound(card)
},
onRefused: { refused = true },
onDenied: { denied = true },
accepts: { text in
ScannedCode.card(from: text) != nil
|| (onLink != nil && ContactLink(text: text) != nil)
})
.ignoresSafeArea()
.overlay(alignment: .bottom) {
// A code that is not an address gets a plain answer
@@ -85,6 +98,9 @@ final class ScannerViewController: UIViewController {
/// Fires for a code that is not an address, and scanning carries on.
var onRefused: (() -> Void)?
var onDenied: (() -> Void)?
/// Which codes count. An address card by default; a device-link code or a
/// contact link where a screen scans for those.
var accepts: (String) -> Bool = { ScannedCode.card(from: $0) != nil }
private let session = AVCaptureSession()
private var preview: AVCaptureVideoPreviewLayer?
@@ -168,7 +184,10 @@ extension ScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let string = object.stringValue
else { return }
guard ScannedCode.card(from: string) != nil else {
// `accepts` is the only gate, and it is injected. The address sheet
// wants an address; the Devices sheet wants its own linking code.
// Hard-coding ScannedCode here would refuse every code Devices scans.
guard accepts(string) else {
onRefused?()
return
}
@@ -181,14 +200,19 @@ extension ScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
extension ScannerViewController {
struct Representable: UIViewControllerRepresentable {
let onFound: (String) -> Void
let onRefused: () -> Void
/// Called for a code `accepts` turns down, so a screen can say so.
/// Defaults to nothing: a sheet that has no notice to show need not
/// say so at every call site.
var onRefused: () -> Void = {}
let onDenied: () -> Void
var accepts: (String) -> Bool = { ScannedCode.card(from: $0) != nil }
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.onFound = onFound
controller.onRefused = onRefused
controller.onDenied = onDenied
controller.accepts = accepts
return controller
}
@@ -196,6 +220,79 @@ extension ScannerViewController {
}
}
/// A scanner for one kind of code, with a box to paste it into for a device
/// with no camera (or a code sent as text).
struct CodeScannerSheet: View {
let title: String
let hint: String
var allowsPaste = true
let accepts: (String) -> Bool
let onFound: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var denied = false
@State private var pasted = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if denied {
Text(
allowsPaste
? "Flamenet can't use the camera. Allow it in Settings, or paste the code below."
: "Flamenet can't use the camera. Allow it in Settings.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding()
.frame(maxHeight: .infinity)
} else {
ScannerViewController.Representable(
onFound: { text in
dismiss()
onFound(text)
},
onDenied: { denied = true },
accepts: accepts
)
.overlay(alignment: .bottom) {
Text(hint)
.font(.caption)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: Theme.Radius.small))
.padding(.bottom, 16)
}
}
if allowsPaste {
HStack {
TextField("Or paste the code", text: $pasted)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityIdentifier("scan.paste")
Button("Use") {
let text = pasted.trimmingCharacters(in: .whitespacesAndNewlines)
dismiss()
onFound(text)
}
.disabled(!accepts(pasted.trimmingCharacters(in: .whitespacesAndNewlines)))
.accessibilityIdentifier("scan.paste.use")
}
.padding()
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
PortalToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
}
/// What the in-app scanner accepts, and what it hands on.
enum ScannedCode {
/// A hello link, an address card from this app or an older one
@@ -6,18 +6,18 @@ struct DisappearingPicker: View {
/// Presets, not a free-form duration.
static let choices: [(label: String, seconds: Int)] = [
("Off", 0),
("1 hour", 3_600),
("1 day", 86_400),
("1 week", 604_800),
(String(localized: "Off"), 0),
(String(localized: "1 hour"), 3_600),
(String(localized: "1 day"), 86_400),
(String(localized: "1 week"), 604_800),
]
/// How the banner on the thread names the current setting.
static func summary(_ seconds: Int) -> String? {
switch seconds {
case 3_600: return "Messages disappear after an hour"
case 86_400: return "Messages disappear after a day"
case 604_800: return "Messages disappear after a week"
case 3_600: return String(localized: "Messages disappear after an hour")
case 86_400: return String(localized: "Messages disappear after a day")
case 604_800: return String(localized: "Messages disappear after a week")
default: return nil
}
}
@@ -0,0 +1,41 @@
import SwiftUI
/// Section 23.7: the composer shows the length limit before somebody reaches
/// it, rather than refusing a message they have finished writing.
///
/// Nothing until the last thousand, then how much is left, then the reason the
/// send button stopped working.
struct DraftLengthNotice: View {
let draft: String
/// What is left, counted as the wire will count it.
static func remaining(_ draft: String) -> Int {
Padding.composerLimit - Padding.composerCost(draft)
}
static func isTooLong(_ draft: String) -> Bool { remaining(draft) < 0 }
/// The words, or nil when there is nothing worth saying yet.
static func text(for draft: String) -> String? {
let left = remaining(draft)
if left < 0 { return Padding.Failure.tooLong.errorDescription }
if left <= 1000 {
return left == 1
? String(localized: "1 character left")
: String(localized: "\(left) characters left")
}
return nil
}
var body: some View {
if let text = Self.text(for: draft) {
Text(text)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Self.isTooLong(draft) ? Theme.danger : Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.horizontal, 16)
.padding(.top, 6)
.accessibilityIdentifier("composer.length")
}
}
}
+17 -10
View File
@@ -12,18 +12,25 @@ struct EmojiPicker: View {
}
/// Grouped the way people think about them, not the way Unicode does.
private static let groups: [(String, [String])] = [
/// `key` stays English: it names the accessibility identifier.
private static let groups: [(key: String, title: String, emoji: [String])] = [
(
"Smileys",
"Smileys", String(localized: "Smileys"),
[
"😀", "😂", "🙂", "😉", "😍", "🤔", "😅", "😭", "😡", "🥳",
"😴", "🤯", "🫠", "😬", "🙃", "😎",
]
),
("Gestures", ["👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"]),
("Hearts", ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"]),
(
"Things",
"Gestures", String(localized: "Gestures"),
["👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"]
),
(
"Hearts", String(localized: "Hearts"),
["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"]
),
(
"Things", String(localized: "Things"),
[
"✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉",
"🎂", "🚀", "🌧️", "☀️", "🌙", "⭐",
@@ -34,10 +41,10 @@ struct EmojiPicker: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if !recents.isEmpty {
section("Recent", recents)
section("Recent", String(localized: "Recent"), recents)
}
ForEach(Self.groups, id: \.0) { name, emoji in
section(name, emoji)
ForEach(Self.groups, id: \.key) { group in
section(group.key, group.title, group.emoji)
}
}
.padding(.horizontal, 16)
@@ -46,7 +53,7 @@ struct EmojiPicker: View {
.background(Theme.incomingBubble.opacity(0.6))
}
private func section(_ title: String, _ emoji: [String]) -> some View {
private func section(_ key: String, _ title: String, _ emoji: [String]) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption2.weight(.semibold))
@@ -65,7 +72,7 @@ struct EmojiPicker: View {
// Scoped by section on purpose: the same emoji can sit
// in Recent AND in its own group, and ChatView's quick-
// reaction bar shows some of them too.
.accessibilityIdentifier("emoji.\(title).\(e)")
.accessibilityIdentifier("emoji.\(key).\(e)")
}
}
.padding(.vertical, 2)
+6 -5
View File
@@ -14,7 +14,7 @@ struct GroupsSection: View {
// two sections of one list look like two different kinds of
// heading.
ZStack(alignment: .trailing) {
PortalBand("Groups")
PortalBand(String(localized: "Groups"))
Button {
showingNewGroup = true
} label: {
@@ -92,16 +92,17 @@ struct GroupsSection: View {
let body =
last.card?.summary ?? last.event?.event.summary
?? (last.attachment != nil
? (last.attachment?.kind == .audio ? "🎤 Voice note" : "📷 Photo")
? (last.attachment?.kind == .audio
? String(localized: "🎤 Voice note") : String(localized: "📷 Photo"))
: last.text)
let author =
last.isMine
? "You"
? String(localized: "You")
: (inbox.knownName(for: last.authorID) ?? group.memberName(last.authorID))
return "\(author): \(body)"
return String(localized: "\(author): \(body)", comment: "Group list preview: sender name, then the last message")
}
let count = group.members.count
return count == 1 ? "Just you" : "\(count) members"
return count == 1 ? String(localized: "Just you") : String(localized: "\(count) members")
}
}
+10 -5
View File
@@ -40,7 +40,9 @@ struct MessageBubble: View {
RoundedRectangle(cornerRadius: Theme.Radius.small)
.fill(Theme.primary)
.frame(width: 3)
Text(quoted.preview.isEmpty ? "Message" : quoted.preview)
Text(
quoted.preview.isEmpty
? String(localized: "Message", comment: "Noun") : quoted.preview)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -81,6 +83,7 @@ struct MessageBubble: View {
// Whose side of it this is: the person who sent it sees who it
// went to, the author sees that nobody else was told.
let fromMe = inbox.map { who.contains($0.myUserID) } ?? false
let side: LocalizedStringKey = fromMe ? "only them" : "only you"
HStack(spacing: 3) {
Text(emoji).font(.system(size: 13))
if who.count > 1 {
@@ -88,7 +91,7 @@ struct MessageBubble: View {
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(fromMe ? "only them" : "only you")
Text(side)
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -174,8 +177,8 @@ struct MessageBubble: View {
if message.replyToStory == true {
Text(
message.isMine
? "You replied to their story"
: "Replied to your story"
? String(localized: "You replied to their story")
: String(localized: "Replied to your story")
)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(
@@ -505,7 +508,9 @@ private struct AttachmentAudioView: View {
}
}
}
.accessibilityLabel(player.isPlaying ? "Pause voice note" : "Play voice note")
.accessibilityLabel(
player.isPlaying
? String(localized: "Pause voice note") : String(localized: "Play voice note"))
GeometryReader { geo in
ZStack(alignment: .leading) {
@@ -58,8 +58,9 @@ struct MyAddressSection: View {
.buttonStyle(.plain)
.accessibilityIdentifier("profile.address")
Text(
"Anyone, on any relay, can reach you with this. Scan the code, or paste "
+ "the address into their app's search."
// The reworded form the release branch carried is not in
// Localizable.xcstrings and has no Spanish. This one does.
"Anyone, on any relay, can reach you with this, scan it, or paste it into their app's search."
)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
+12 -19
View File
@@ -74,7 +74,7 @@ struct NewStoryButton: View {
width: ready.width, height: ready.height))
}
guard !prepared.isEmpty else {
postError = "None of those pictures could be prepared."
postError = String(localized: "None of those pictures could be prepared.")
return
}
setDrafts = prepared
@@ -165,8 +165,7 @@ struct NewStoryButton: View {
let recipients = audience.recipients(contacts: buddies.map(\.id))
guard !recipients.isEmpty else {
postError =
"Nobody is in your story audience, so this would go "
+ "to no one. Change who can see your stories on Account."
String(localized: "Nobody is in your story audience, so this would go to no one. Change who can see your stories on Account.")
return
}
let items = setDrafts.map {
@@ -176,13 +175,12 @@ struct NewStoryButton: View {
items, caption: caption.isEmpty ? nil : caption, to: recipients)
if result.failed > 0 {
postError =
"Posted, but \(result.failed) of the sends did not reach "
+ "a device. The pictures that did are on their way."
String(localized: "Posted, but \(result.failed) of the sends did not reach a device. The pictures that did are on their way.")
}
} catch {
postError =
(error as? MessengerError)?.errorDescription
?? (error as? E2EError).map { _ in "None of your buddies can receive stories yet." }
?? (error as? E2EError).map { _ in String(localized: "None of your buddies can receive stories yet.") }
?? error.localizedDescription
}
}
@@ -192,20 +190,18 @@ struct NewStoryButton: View {
let recipients = audience.recipients(contacts: buddies.map(\.id))
guard !recipients.isEmpty else {
postError =
"Nobody is in your story audience, so this would go "
+ "to no one. Change who can see your stories on Account."
String(localized: "Nobody is in your story audience, so this would go to no one. Change who can see your stories on Account.")
return
}
let result = try await inbox.postTextStory(text, to: recipients)
if result.failed > 0 {
postError =
"Posted, but \(result.failed) of your buddies couldn't "
+ "receive it (no registered device)."
String(localized: "Posted, but \(result.failed) of your buddies couldn't receive it (no registered device).")
}
} catch {
postError =
(error as? MessengerError)?.errorDescription
?? (error as? E2EError).map { _ in "None of your buddies can receive stories yet." }
?? (error as? E2EError).map { _ in String(localized: "None of your buddies can receive stories yet.") }
?? error.localizedDescription
}
}
@@ -226,11 +222,10 @@ struct NewStoryButton: View {
duration: draft.duration,
caption: caption.isEmpty ? nil : caption, gid: gid)
if result.reached == 0 && result.queued == 0 {
postError = "Nobody in that group could be reached."
postError = String(localized: "Nobody in that group could be reached.")
} else if result.queued > 0 {
postError =
"Posted. \(result.queued) of the group could not be "
+ "reached yet, so it goes out when they can be."
String(localized: "Posted. \(result.queued) of the group could not be reached yet, so it goes out when they can be.")
}
return
}
@@ -239,8 +234,7 @@ struct NewStoryButton: View {
let recipients = audience.recipients(contacts: buddies.map(\.id))
guard !recipients.isEmpty else {
postError =
"Nobody is in your story audience, so this would go "
+ "to no one. Change who can see your stories on Account."
String(localized: "Nobody is in your story audience, so this would go to no one. Change who can see your stories on Account.")
return
}
let result = try await inbox.postStory(
@@ -254,13 +248,12 @@ struct NewStoryButton: View {
)
if result.failed > 0 {
postError =
"Posted, but \(result.failed) of your buddies couldn't "
+ "receive it (no registered device)."
String(localized: "Posted, but \(result.failed) of your buddies couldn't receive it (no registered device).")
}
} catch {
postError =
(error as? MessengerError)?.errorDescription
?? (error as? E2EError).map { _ in "None of your buddies can receive stories yet." }
?? (error as? E2EError).map { _ in String(localized: "None of your buddies can receive stories yet.") }
?? error.localizedDescription
}
}
+5 -2
View File
@@ -58,6 +58,9 @@ struct PillTabItem: Identifiable {
let title: String
let symbol: String
var badge: Int = 0
/// The English name behind `tab.<name>`, so the identifier stays the
/// same when `title` is translated. Falls back to the title.
var identifier: String? = nil
}
struct PillTabBar<Content: View>: View {
@@ -108,7 +111,7 @@ struct PillTabBar<Content: View>: View {
tab(item)
}
.buttonStyle(.plain)
.accessibilityIdentifier("tab.\(item.title.lowercased())")
.accessibilityIdentifier("tab.\(item.identifier ?? item.title.lowercased())")
.accessibilityAddTraits(
selection == item.id ? [.isSelected, .isButton] : .isButton)
}
@@ -189,7 +192,7 @@ struct PillTabBar<Content: View>: View {
// aloud rather than dropped, because it is the reason somebody looks.
.accessibilityElement(children: .ignore)
.accessibilityLabel(
item.badge > 0 ? "\(item.title), \(item.badge) unread" : item.title
item.badge > 0 ? String(localized: "\(item.title), \(item.badge) unread") : item.title
)
.frame(maxWidth: .infinity)
.frame(height: PillTabBarMetrics.barHeight)
+33 -18
View File
@@ -52,7 +52,8 @@ struct PollCard: View {
/// vote and sees the tally, and subtracting one from the other gives it,
/// with nobody to collude with. The word is not offered for a pair.
private var anonymityLabel: String {
poll.members.count > 2 ? "Anonymous poll" : "Poll"
poll.members.count > 2
? String(localized: "Anonymous poll") : String(localized: "Poll", comment: "Noun")
}
private var choices: some View {
@@ -92,8 +93,8 @@ struct PollCard: View {
case .waitingForVotes(let who):
Text(
poll.myChoice == nil
? "Nobody sees a count until everyone has voted."
: "Waiting for \(list(who)) to vote."
? String(localized: "Nobody sees a count until everyone has voted.")
: String(localized: "Waiting for \(list(who)) to vote.")
)
.font(.system(size: 13))
.foregroundStyle(.secondary)
@@ -139,20 +140,28 @@ struct PollCard: View {
/// The guarantee, stated as it actually is.
private var footnote: String {
poll.members.count > 2
? "Nobody was sent anybody's vote. Everyone else here would have to "
+ "work together to know how you voted."
: "Nobody was sent your vote, but in a poll of two the other person "
+ "can work it out from the result."
? String(
localized: """
Nobody was sent anybody's vote. Everyone else here would have to \
work together to know how you voted.
""")
: String(
localized: """
Nobody was sent your vote, but in a poll of two the other person \
can work it out from the result.
""")
}
private func list(_ ids: [Int]) -> String {
let names = ids.map(name)
switch names.count {
case 0: return "nobody"
case 0: return String(localized: "nobody")
case 1: return names[0]
case 2: return "\(names[0]) and \(names[1])"
case 2: return String(localized: "\(names[0]) and \(names[1])")
default:
return names.dropLast().joined(separator: ", ") + " and " + (names.last ?? "")
let head = names.dropLast().joined(separator: ", ")
let last = names.last ?? ""
return String(localized: "\(head) and \(last)")
}
}
}
@@ -223,16 +232,22 @@ struct NewPollSheet: View {
/// is deciding on.
private var guarantee: String {
if members < 2 {
return "A poll needs somebody else in the group."
return String(localized: "A poll needs somebody else in the group.")
}
if members == 2 {
return "In a group of two this is not anonymous: the other person "
+ "can work out your vote from the result. It is still a poll."
return String(
localized: """
In a group of two this is not anonymous: the other person \
can work out your vote from the result. It is still a poll.
""")
}
return "Nobody is sent anybody's vote, not even you as the person "
+ "asking. Everyone other than the voter would have to work "
+ "together to know how one person voted. Everyone has to vote "
+ "before there is a result."
return String(
localized: """
Nobody is sent anybody's vote, not even you as the person \
asking. Everyone other than the voter would have to work \
together to know how one person voted. Everyone has to vote \
before there is a result.
""")
}
private func ask() {
@@ -244,7 +259,7 @@ struct NewPollSheet: View {
options: filledOptions, gid: gid)
dismiss()
} catch {
failure = "That poll could not be sent."
failure = String(localized: "That poll could not be sent.")
sending = false
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ struct PresenceDot: View {
ring ?? .white.opacity(0.6),
lineWidth: ring != nil ? ringWidth : (presence == .offline ? 1 : 0))
)
.accessibilityLabel(showOnlineStatus ? presence.label : "Status hidden")
.accessibilityLabel(showOnlineStatus ? presence.label : String(localized: "Status hidden"))
}
private var color: Color {
@@ -26,7 +26,7 @@ struct ProfilePictureSection: View {
// The portal's bone face, the same one every other
// button in the app wears. A flat pale fill here was
// the last stock-looking control on the screen.
Text(hasAvatar ? "Change" : "Choose a picture")
Text(hasAvatar ? String(localized: "Change") : String(localized: "Choose a picture"))
.font(Theme.Font.headline)
.foregroundStyle(Theme.ink)
.padding(.horizontal, Theme.Space.lg)
@@ -66,9 +66,7 @@ struct ProfilePictureSection: View {
}
Text(
"Sent to your contacts encrypted, the same way a photo is, this "
+ "server stores it and cannot open it. People who have never "
+ "messaged you see your initials instead."
"Sent to your contacts encrypted, the same way a photo is, this server stores it and cannot open it. People who have never messaged you see your initials instead."
)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
@@ -86,12 +84,12 @@ struct ProfilePictureSection: View {
picked = nil
}
guard let data = try? await item.loadTransferable(type: Data.self) else {
note = "That image could not be read."
note = String(localized: "That image could not be read.")
return
}
// Downscaled before it is sealed.
guard let prepared = ImagePrep.prepare(data) else {
note = "That image could not be encoded."
note = String(localized: "That image could not be encoded.")
return
}
do {
@@ -101,7 +99,7 @@ struct ProfilePictureSection: View {
to: buddyIDs)
note =
result.failed > 0
? "Saved, but \(result.failed) of \(buddyIDs.count) contacts could not be told, check their keys."
? String(localized: "Saved, but \(result.failed) of \(buddyIDs.count) contacts could not be told, check their keys.")
: nil
} catch {
note = (error as? E2EError)?.errorDescription ?? error.localizedDescription
@@ -60,15 +60,19 @@ struct RelayIdentityBanner: View {
///
/// Both readings are true and the app cannot tell them apart, so it says
/// both rather than picking the comfortable one.
static let detail =
"This device remembered the key your server signs with, and the server "
+ "is now signing with a different one. That happens when a server is "
+ "rebuilt or moved. It also happens when something has got in between "
+ "you and it. Your messages stay end-to-end encrypted either way, but "
+ "until this is settled the server's word about anyone's keys is worth "
+ "less than it was."
static let detail = String(
localized: """
This device remembered the key your server signs with, and the server \
is now signing with a different one. That happens when a server is \
rebuilt or moved. It also happens when something has got in between \
you and it. Your messages stay end-to-end encrypted either way, but \
until this is settled the server's word about anyone's keys is worth \
less than it was.
""")
static let confirmation =
"Ask whoever runs the server whether they changed it, on a channel this "
+ "app does not carry. Accepting pins the new key and stops the warning."
static let confirmation = String(
localized: """
Ask whoever runs the server whether they changed it, on a channel this \
app does not carry. Accepting pins the new key and stops the warning.
""")
}

Some files were not shown because too many files have changed in this diff Show More