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
Paul HittandClaude Opus 5.5 4afa76403c CI: pin flamenet-e2e at the 0.32 spec and gate its new vectors
.e2e-ref moves to d07ed14 (docs/spec-0-32), the commit the copies in
Tests/Vectors were taken from. The unit-tests gate and vectors-head now
also compare content-kinds-v1, today-v1 and hello-link-v1, which the
0.32 loader suites read.

The pinned commit has to be on the remote before this pipeline runs, so
the spec branch is pushed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:59:48 -04:00
Paul HittandClaude Opus 5.5 697ea2542b Test launches never reach production by default
With no site stored, every launch probes its relay, and the default is
relay.flamenet.io: the unit-test host did so on every unit run, and so
did every UI suite that did not name a server, previews included.

A DEBUG test launch now resolves its site through
SiteConfig.forThisLaunch. A test launch is the unit-test host
(XCTestConfigurationFilePath) or the app launched by a UI suite, which
says so in FNMSG_TEST_LAUNCH. For one, the default site is a closed port
on 127.0.0.1, and a stored production site left by an earlier suite is
replaced by it as well, unless a UI launch named that very host with
-FNMSGServer, the way an opt-in live suite says it means it. The unit
host has no way to name one. Release builds and ordinary launches are
untouched: the shipped default is still relay.flamenet.io.

Every UI suite now builds the app through XCUIApplication.underTest(),
which sets FNMSG_TEST_LAUNCH. UITestSafetyTests pins it: no suite builds
an XCUIApplication directly; a test launch with nothing stored, or a
production site stored, resolves to loopback; production only when a
UI launch names it, never in the unit host; an ordinary launch is
unchanged; and this unit-test host itself resolves off production.

Suites that signed in on production by relying on the default must now
pass -FNMSGServer with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:34:53 -04:00
Paul HittandClaude Opus 5.5 79adfe0195 Test: the Today vectors as the spec has them now (d07ed14)
The copy merged from test/vectors-0-32 predates the spec's 86def06,
which took lat and lon out of the "cc, tz, lat and lon" case, and its
d07ed14 adds a case for an envelope version written as a string,
unknown-version, for 34 cases in all. Refreshed from the spec branch at
d07ed14; the loader's pinned count moves to 34. The other in-tree
vector files already match it byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:29:36 -04:00
Paul HittandClaude Opus 5.5 080827e804 The card and composer UI suites stay on this machine
UITestSafetyTests requires every UI suite that launches the app to be
able to skip or to stay on the device, and the two new suites did
neither. Both run a DEBUG preview needing no account, but the app still
probes its relay at start-up, and with no site stored that relay is the
production one. Each suite now points the app at a closed port on
127.0.0.1 with -FNMSGServer, and the safety test counts their two
preview flags as local, beside Files and the wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:26:46 -04:00
Paul HittandClaude Opus 5.5 0cc2ea10c4 Merge branch 'test/vectors-0-32' into 'release/0-32-0'
The 0.32 shared vectors, loaded by the app's own code: content kinds,
the Today file, hello links and the refreshed backup payload (TEST-A).
Test-only. Merged after the Today refusal reasons, which its Today
loader expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:16:52 -04:00
Paul HittandClaude Opus 5.5 66cb22b2c1 Today: a v that is there and not exactly 1 is unknown-version
DESIGN decision 48 sets one list of refusal reasons for both apps and
the vectors: unknown-version is a v present but not exactly the integer
1, in the envelope or the body. iOS called a body v of 1.4 or 1.5
bad-body, and an envelope v of 1.4, 1.0 or true malformed, because a v
that was not an exact integer failed the integer check before the
version was compared.

Presence and value are now asked apart. No v at all is still the shape
being wrong: malformed for the envelope, bad-body for the body. A JSON
null counts as no v.

The version test now expects unknown-version for 1.4, 1.0, 0.6, 1e0,
"1" and true in the body and 1.4, 1.0 and true in the envelope, plus the
two missing-v cases; it failed on every one before the change. The
malformed-file test's boolean envelope v moves to unknown-version with
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:16:43 -04:00
Paul HittandClaude Opus 5.5 e0def50749 An offline start opens to the chats, and connects behind them
DESIGN decision 47 (owner). The app signed in only once the relay had
answered who the stored credential belonged to, so with no network at
launch a signed-in phone opened on the sign-in screen.

An account signed in on this phone now opens from what is on it: the
credential is presented without a request, the inbox is built from its
own keys and history without registering, and the phase is signed in,
so the chats and the saved Today page are there at once. Behind them the
app asks the relay who this is, waiting 1, 2, 4 seconds and so on up to
a minute between tries; the first answer finishes connecting (device
registration, delivery key) and fills in the account's names. Only a
relay that answers and refuses the credential goes back to sign-in, as
before. No stored credential is signed out, network or not.

Opening without the relay needs the account's user id, which the relay
used to supply; it is now kept beside the username at sign-in, sign-up
and every restore, and cleared at sign-out. An install updated from an
earlier build learns it on its first launch with a network.

The test plays the relay with a real socket on 127.0.0.1: closed at
launch, the app is signed in with an inbox within seconds; opened on the
same port, the app asks for the account and connects, still on the
chats. A relay answering 401 still means sign-in, and no credential is
signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:11:44 -04:00
Paul HittandClaude Opus 5.5 b8ec413028 Story composers draw their words in the page's own ink
The test pass found the picture composer's audience line barely
readable on the pale page and, once, the typed caption invisible. The
Stories tab opens its composers from a button in its header, and the
header hands its buttons white text and a white tint so they read on
the navy; a sheet inherits both from the button it is opened from, so
the composers drew near-white words on a light page.

All three composers (a picture, a sequence of pictures, and words) now
put the theme's ink and tint back at their root, and their secondary
lines use the theme's secondary text colour, which is chosen for the
page in light and in dark, rather than a shade of whatever was handed
down.

The UI test opens the picture and the words composer from a header
button exactly as the tab does, in light and in dark, and reads the
pixels of the audience line, the typed caption and the typed words:
each must reach WCAG's 4.5:1 between text and page. Before the change
they measured 1.05, 1.39 and 1.22 to 1. The DEBUG stories preview now
follows the appearance setting, as the app proper does, so the test can
ask for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:09:19 -04:00
Paul HittandClaude Opus 5.5 3c0db18179 A sealed card on screen opens at its time
DESIGN decision 49. The test pass watched a sealed card stay sealed 25
seconds past its moment, through scrolling, opening only when the chat
was left and opened again. The bubble drew its face inside a
TimelineView whose content never read its context's date, and that
tick did not redraw it.

The face is now drawn for a time held in state. A task waits for the
moment the card opens, a quarter second past it, and sets the time, and
a state change always redraws. The task runs again whenever the card
comes back on screen, so one that opened while away is drawn open.

The UI test launches a DEBUG preview, a scrolling thread with one card
sealed for eight seconds, and touches nothing: the card has to open by
itself. Before the change it was still sealed after 20 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:06:09 -04:00
Paul HittandClaude Opus 5.5 915ff5b6e1 Test: the 0.32 shared vectors, loaded by the app's own code
Copies content-kinds-v1, today-v1 and hello-link-v1, and the extended
backup-payload-v1 canonical set, from flamenet-e2e docs/spec-0-32 at
cbe2226 into Tests/Vectors, and adds a loader suite for each new file:

- content kinds: every written envelope parsed and written back by the
  app's encoders, byte for byte; every envelope through parse() and the
  route deliver() takes, checked field by field against the file.
- Today: TodayFile.verify on the sample, every case and the 512 KB edge;
  the sequence through TodayStore; If-None-Match through TodayPolicy.
- hello links: the link cases through HelloLink.address(inText:) and the
  scan cases through HelloLink.scannedAddress.

Test files only. The copied backup-payload-v1.json differs from the
flamenet-e2e commit in .e2e-ref, so the CI vector gate reads red here
until .e2e-ref moves to the spec commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:14 -04:00
Paul HittandClaude Opus 5.5 ec1912d1db A snooze holds the incoming-call banner too, as mute does
Mute holds the banner the app posts for a call CallKit is not showing;
a snooze did not, so a snoozed chat still asked when its caller rang.
postIncomingCall takes a required snoozed flag and folds it into mute,
and the call manager passes whether the caller's chat is snoozed right
now, so the banner is held exactly as mute holds it and comes back when
the snooze ends. CallKit's own ringing is not touched.

The tests ring a call manager with a fresh offer, as the inbox hands
one over, and read a new debug hook for the banners that the settings,
mute and a snooze let through, ahead of the app-in-front check a test
host always fails. Snoozed, the offer rings and no banner gets through;
with the snooze run out, the same banner does. Each test hangs the call
up, so no ring or poll outlives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:14:35 -04:00
Paul HittandClaude Opus 5.5 fe89b90a71 A profile picture and a delivery key sent alone are submitted silent
§8.4 submits silent the traffic that is not a message to a person, and
names delivery keys among it; §6.10 counts avatar as control with them.
Both went out announced. A delivery key sent on its own, as a session
repair or carrying a sealed group's key or a group's name, parses as an
unsupported attachment, and a profile picture carries an attachment, so
warrantsAlert counted either as something to see. Creating or renaming
a group, adding a member, or changing a picture buzzed the other phones
with "New message" and raised the icon badge, with nothing new to read.

warrantsAlert now answers no for a delivery_key and for an avatar. A
delivery key riding ahead of a message is unchanged: it shares the
message's request and takes the message's flag. Android already sends
both silent.

The new test reads the flag off the request for a group's name at
creation and on a rename, a sealed group's key, a new profile picture,
and a repair sent after the receiving side lost its session. The table
test moves the four kinds from their reported list to the silent one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:11:33 -04:00
Paul HittandClaude Opus 5.5 2aea4127ea A snoozed chat holds its notices until the snooze ends
A snoozed thread says "Snoozed, messages still arrive, this chat just
stops asking", and nothing on the way to a notification read the
snooze: it was stored and drawn, and every notice was posted as before.

postIncomingMessage takes a required snoozed flag and folds it into
mute, so a snoozed chat is held exactly as a muted one is, sound
included, and only while the snooze lasts. Every notice the inbox
raises passes it: messages, cards, events and their changes, and game
notices. Only a one-to-one chat can be snoozed, so group and federated
threads pass false. Reply reminders still fire, as the snoozed thread's
own line says, and the incoming-call banner is not changed.

The tests listen at both ends of the notification path, through a new
debug hook for what is handed to the system. In a snoozed chat a
message, a card, an event, its revision, a game invitation and a move
all arrive, all six reach the path, and none is posted; once the snooze
runs out, or "Wake now" is chosen, the chat is heard again; the same
person in a group, and somebody else one to one, are heard throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:46 -04:00
Paul HittandClaude Opus 5.5 72067284e3 Edits, reactions and pins are submitted silent
An edit carries its new wording in the text field, so warrantsAlert
counted it as something to see and the relay was asked to announce it:
the other phone got a second alert, sound and badge for a corrected
typo. §6.15 says an edit MUST NOT raise a notification, as §6.9 and
§6.19 say of reactions and pins.

warrantsAlert now answers no for any edit, reaction (to a message or a
story) and pin. Reactions, story reactions, pins and story caption edits
already went out silent, because their text is empty or they were
special-cased; now they do by rule.

The first two tests read the flag off the POST /e2e/messages request: a
one-to-one edit, reaction, reaction taken back, pin and pin taken down;
a group reaction, a private group reaction, a story reaction and a
story's words changing. An ordinary message and a group message are
the announced controls. The third pins the whole table of kinds as this
app sends them, including four that go out announced against §8.4 (a
profile picture, and a delivery key sent on its own as a repair, a
sealed group's key or a group's name), reported and not changed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:37:05 -04:00
Paul HittandClaude Opus 5.5 d7d0c83e7f Test: a ctl written as the string "0" or "false" is control
As on Android: only the number 0, false and null leave an envelope as
anything but control. The strings "0", "false" and "null", and an empty
string, are values like any other, so an unknown kind carrying one is
dropped and a card carrying one is not drawn. iOS already read them this way
after 9d1612f5; this pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:30:46 -04:00
Paul HittandClaude Opus 5.5 12712e910b Merge branch 'feature/0-32-stories' into 'release/0-32-0'
Stories: take one down for everyone, fix its caption, and the whole photo,
never cut (DESIGN items 25 to 28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:29:59 -04:00
Paul HittandClaude Opus 5.5 9291274b04 Cards, events and games ignore a per-message timer
DESIGN decision 46. A §6.17 secs on a card, an event or a game set that
message's own timer; in 0.32 it is ignored, and the chat's disappearing
timer still applies to them. A text keeps its timer. The test failed on the
old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 073c703dd5 Events: an all-day event runs 1 to 31 days
DESIGN decision 45. days was clamped to 1 to 366 on arrival and the composer
sent up to 366; now above 31 shows as 31, below 1 as 1, and the composer
sends at most 31. The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 9d1612f5e5 Any ctl but 0, false or null makes an envelope control
DESIGN decision 43, §6.10's "carrying ctl". ctl was read as a flag, so a
value it did not know, "1" written as a string among them, read as off and
the envelope was drawn: an unknown kind as the unsupported placeholder, and
a card or an event as a bubble although ctl wins over both. Now any value
other than 0, false or null is control. The other flags keep their own
reading. The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 cf4fe8ade3 Today: notes, dates, made, country and zone, as DESIGN 42 has them
At most five notes of 200 characters each (the app kept ten of 300). A
weather day whose date is not a real yyyy-MM-dd is dropped, where it was cut
to ten characters and kept. A fractional made refuses the file, where it was
rounded. A country is two ASCII letters or is ignored. A zone over 64
characters is ignored rather than cut, and a zone the phone does not know
shows the days as the file gives them, where they were cut by the phone's
own date. Market figures were already capped at 24, and lat and lon are not
read at all. Each new test failed on the old code first, the 24-character
one excepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 b9a63ecb93 Hello links: ASCII hosts lowercased by ASCII rules, and no spaces
DESIGN decision 41. The host was lowercased with Unicode rules before it was
checked, so a Kelvin sign became a "k" and one host read as another. The
address is now checked to be ASCII first and lowercased by ASCII rules
alone; a pasted address gets the same check. The scanner read codes through
the paste path, which trims round the at-sign for somebody typing, so
"fnid:… @ relay" was taken; it now reads the code itself and refuses any
space in the address. A slash after the app link's hi was already refused.
The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:07:28 -04:00
Paul HittandClaude Opus 5.5 5105b18bb1 Story composers: the feed's page colour behind the preview
The preview's bars were white on the white sheet, so a narrow photo did not
look the way the feed will draw it. Both composers now sit on the feed's
own page colour. The debug preview's fixture photos keep their labels
inside the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:55:47 -04:00
Paul HittandClaude Opus 5.5 3699d02135 Stories: an unpost naming both a story and a set is dropped
An `unpost` names exactly one of `sid` and `set`. One naming both is now
dropped, like one naming neither, instead of taking down both. A per-message
`secs` (§6.17) on an `unpost` or a caption edit plays no part; a test pins
both rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:55:47 -04:00
Paul HittandClaude Opus 5.5 c744a2b0bb Stories feed: the whole photo, never cut; take-down and caption controls
The feed card drew every photo in a fixed 240pt band, filled and cropped,
so the top of a tall photo was cut off. The card now takes the photo's own
shape between half and one and a quarter times its width (180 to 451pt on
a 393pt iPhone, 204 to 510pt on a 440pt Pro Max, at most 560pt anywhere),
with plain bars beside or above a photo narrower or wider than that. The
composer previews the photo with the same view at the same width, so what
is posted is what friends see. Nothing was cropped on upload: ImagePrep and
VideoPrep both scale to fit.

My own story has Edit caption and Take down on the card, in the long-press
menu, in the full-screen viewer, and Take down in the archive. The take-down
asks first, in the words both apps use. Small thumbnails in the sequence
composer centre on the top third. `-FNMSGStoriesPreview` (debug builds only)
shows the feed, the confirmation, an edited caption, the composer and the
caption editor with fixture photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:43 -04:00
Paul HittandClaude Opus 5.5 e7114480e1 Stories: take one down for everyone, and change its caption
Taking a story down (§6.35) removes my copy at once: the strip, the archive,
the decrypted bytes, the reactions and seen marks it carried, and any copy
still queued for somebody who could not be reached. Then an `unpost` goes to
the people the story went to and to my other devices, as silent control
traffic: `{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}`, or
`"set":"<set id>"` for a sequence, which comes down whole. A story now
records who it was sent to when it is posted; one posted before that, or
restored from a backup, goes to today's audience, or the group's roster
today. The relay has no way for an uploader to delete a blob, so the blob
waits for the retention purge.

A caption change is §6.15's edit with `st`, naming the story by the id
replies use, and a text story changes its words the same way. It is shown
as edited, refused when empty, ordered by the relay's time, sent silently,
and never notifies.

A receiver applies either only to a story it holds from the sender of the
envelope, and only a proven device of my own can take down or change a
story of mine. An unknown id is dropped. `set` is read as an object on a
story and as a string on an `unpost`, and a `set` or `sid` of any other
shape no longer costs the whole envelope. The backup marks an edited
caption with "edited".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:43 -04:00
Paul HittandClaude Opus 5.5 0fa0354762 Today: a lowercase kid, versions that are exactly 1, markets with a figure
DESIGN decision 38. The kid is lowercase hex, the form the app computes; the
app lowercased whatever arrived, so A9A94E74 was taken for a9a94e74. Both
versions, the envelope's and the body's, must be the JSON integer 1: the
lenient integer reader rounded, so 1.4 and 0.6 were taken for 1. A market
needs a name and its last figure, the symbol staying optional; an empty
figure was kept. Each test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:30 -04:00
Paul HittandClaude Opus 5.5 5dc7872aa5 Today: never follow a redirect
DESIGN decision 38: the file comes from the home relay and nowhere else.
URLSession follows redirects by default, so a relay answering 302 sent the
request on to wherever it pointed. The session now has a delegate that
refuses every redirect. The 3xx comes back as the relay's own answer, which
counts as a relay without a file right now: the last good copy stays and the
page says so.

The test points a loopback relay's 302 at a second loopback server holding a
good file. It failed on the old code first, the second server receiving the
request; now the fetch reports 302 and the second server hears nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:31:03 -04:00
Paul HittandClaude Opus 5.5 2adea2699d Games: a draw offer before the invitee's first move does not count
DESIGN decision 37. Until the invitee has moved, the invitation is still
open: the invitee can decline it and the starter can cancel it, and there is
no game yet to call a draw. This phone's own button already held back, but an
offer from the other phone in that window was taken. Now it is ignored. The
test failed on the old code first; its control, the same offer after the
invitee's first move, counts.

The other parts of decision 37 already held here: a gmove with no log or an
empty log and a gend with no log are dropped when read, and a draw-accept
counts only at the full known log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:39 -04:00
Paul HittandClaude Opus 5.5 3fe70b0890 Hello links: the heads exactly, with no query and no trailing slash
DESIGN decision 35. Only https://flamenetmessenger.com/hi#, /es/hi# and
flamenetmessenger://hi# are hello links: no query string, the path exactly
/hi or /es/hi in its own case with no trailing slash, the scheme and host in
any case. The app took /hi/# and /hi?ref=x#, which the other app and the
relay's page do not. The test that now refuses them failed on the old code
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:39 -04:00
Paul HittandClaude Opus 5.5 9f32a14c20 Cite the Today file as SPEC §8.12
DESIGN decision 34: §8.11 is push-token registration, which older code
already cites, and the spec gains that section to match. The Today file is
§8.12. Every 0.32 citation of §8.11 for it changes; the push-token ones stay.
A test reads every Today file and the tab bar for a stray §8.11, with the
push-token citation as its control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:28 -04:00
Paul HittandClaude Opus 5.5 64218c5270 AFailedRetirementIsRetriedTests waits for the app, not the stub
The test waited until the stub relay had recorded the revoke request, then
checked that the app had cleared its note. The stub records the request
before it answers, and the app clears the note only once the answer reaches
it on the main actor, so on a busy host the check could land in between and
fail an app that was about to do the right thing. It now waits for the note
itself, the app's own record of the reply, and then checks the relay was
asked.

Measured with the two waits side by side while the main actor was kept busy
in 40 ms stretches, ten rounds each: the old wait failed 10 of 10 and the new
one passed 10 of 10. The committed test also ran 30 times in a row with the
machine at a load average over 160: 30 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:18 -04:00
Paul HittandClaude Opus 5.5 0b1969ddcf Search in Forward and Send a file can be read again
On iOS 26 and later `.searchable` puts its field at the foot of a sheet, on
a light ground, while the app styles every search field's text white for the
navy bar, so what somebody typed was white on near-white. Both sheets now put
the search box on a strip of the chrome under their own bar,
PortalSearchStrip, which carries on from the bar's gradient. The same thing
was seen and fixed on the 0.32 weather and send pickers.

Nothing in the app uses `.searchable` now, and a test keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 6632094948 Scanners bind by fnid; an older card's username is ignored
DESIGN decision 32. Both places the scanner is opened, Home and Messages,
already handed on the address alone, and the card the scanner returns has no
place for the `?u=` an older card carries. A test now holds both: a code with
a username comes back as the same card without it, and every scanner call
site passes the address on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 aff420b180 Sign-out and a lost-device wipe clear the Today file and weather picks
DESIGN decision 32. signOut now clears the kept Today file and the cities
picked for weather, and every way out of an account goes through it: the
sign-out screen, a device removed from the account, and a lost-device wipe.
A request already on its way when the phone is cleared is dropped when it
returns, rather than putting the file back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 168c2fdb24 Parental controls hide Today and the directory
DESIGN decision 31, the owner's: while supervision is on, the Today tab is
not in the bar and its screen is not drawn even if it was the tab last open,
and the small-web directory, reached only from Today, goes with it. Both lead
straight out to the open web. It is not one of the gates a parent can
release; the supervision screen says so.

Tested beside the other supervision tests, which share the one process-wide
PIN: Today is in the bar without supervision, leaves when it is switched on,
stays hidden with every gate released, and comes back when it is switched
off. A source check confirms the screen sits behind the same rule and that
nothing but Today opens the directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 2134efe05e Games: tic-tac-toe is never turned round, and a test says so
DESIGN decision 30. Chess, checkers and reversi turn round for the second
player; tic-tac-toe's cells keep their places on both phones. The board view
already did this inside a private property nothing could test. The rule and
the cell mapping are now GameBoardView.turnsRound and boardCell, tested for
every game and both sides, with chess and reversi as the control that a
turning game does turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 8800186062 Games: an invitation counts as unread, a move does not
DESIGN decision 29. The invitation already read "Sam invited you to play
chess" and came with a sound. The unread rule did not hold: every game
message that arrived as a new bubble added to unread, and a move whose
invitation was lost builds a new bubble from its log (rule 1), so a lost
invitation turned the first move into an unread one. Now only an invitation
adds to unread.

Tested between two inboxes on a stub relay: an invitation is unread and said
out loud; a first move whose invitation was dropped on the way builds the
board, is said quietly and adds nothing to unread; a later move adds nothing
either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:56 -04:00
Paul HittandClaude Opus 5.5 626dcfe318 Version 0.32.0, build 32
The 0.32 release: stickers in FlameNet's art, cards with sealed until,
events with answers, games in a chat, the Today tab, the small-web
directory, and hello links. Build number follows the release, per the rule
above it in this file.

Checked: after a regenerate, project.pbxproj carries MARKETING_VERSION =
0.32.0 and CURRENT_PROJECT_VERSION = 32. No archive and no upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:01:37 -04:00
Paul HittandClaude Opus 5.5 c1e58ad632 Ship the directory as a folder, as stickers and cards are
The two branches met in project.yml without a textual conflict: the
stickers and cards folders became folder references so the bundle keeps the
paths both apps name, while directory/directory-v1.json was still copied to
the bundle root by the Resources group. It now keeps its path too. The
loader looks in directory/ first and at the root second, so either layout
reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:01:30 -04:00
Paul HittandClaude Opus 5.5 d1432a378f Merge branch 'feature/0-32-today-directory-hello' into 'release/0-32-0'
The Today tab, the small-web directory shipped in the app, and hello links
(DESIGN sections 4 to 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:34 -04:00
Paul HittandClaude Opus 5.5 0cc118d1f4 Merge branch 'feature/0-32-portal-kinds' into 'release/0-32-0'
Stickers in FlameNet's art, cards with sealed until, events with answers,
and games in a chat, with the games engine (DESIGN sections 1 to 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:28 -04:00
Paul HittandClaude Opus 5.5 a9bf45388a Today preview: start with no weather cities when the UI suite asks
The picks are kept in the phone's settings, so a run inherited whatever the
run before it had picked, and tapping a city an earlier run had picked took
it away again. -FNMSGTodayNoPlaces clears them when the preview opens; the
suite passes it and checks each city is picked after its tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:47:47 -04:00
Paul HittandClaude Opus 5.5 59d529c1bc Game card: Pass shares the button row; the marks read in the dark
The lone Pass button sat on a row of its own above Resign, and buttons that
appear together belong in one row. Tic-tac-toe's O was drawn in the channel
red, which is hard to read on the dark board, so both marks now have colours
of their own for each appearance, and the dots marking where a move can go
are smaller on the big tic-tac-toe cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:27:28 -04:00
Paul HittandClaude Opus 5.5 09a8a1bf84 Hello links: the host rule the relay's /hi page uses, word for word
The /hi page checks the host as letters, digits, hyphens and dots with at
least one dot, which is DESIGN decision 21 as written, and caps the fragment
at 400 characters. The app also refused empty labels, a leading or trailing
hyphen and labels over 63 characters, so a link the page offered to open
could be refused by the app it opened. Now both apply the same rule, and the
app takes the same 400-character cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:13:29 -04:00
Paul HittandClaude Opus 5.5 88ae6340f4 A DEBUG preview of the new screens, and a UI suite that drives it
-FNMSGTodayPreview opens the Today tab, the directory or the address card
with no account, because simctl can launch the app but cannot sign in. The
Today screen is the real one: the store, the request, the signature check and
the copy on disk, against whatever relay is named on the command line, with a
folder of its own per state name so the app's own store is never touched.
DEBUG builds only.

TodayScreensUITests drives it: the page with a file, picking two cities, the
foot of the page, the same page with the relay gone, a relay that does not
offer the file, browsing and searching the directory, and the hello link card.
The Today cases need TEST_RUNNER_FN_TODAY_SERVER and
TEST_RUNNER_FN_TODAY_UNOFFERED pointing at relays on 127.0.0.1 and skip
without them; screenshots go to TEST_RUNNER_FN_TODAY_SHOTS when it is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:12:06 -04:00
Paul HittandClaude Opus 5.5 41da4f1588 Hello links: on the address card, opened by the app, read by the scanner
A hello link (SPEC §6.34) is https://flamenetmessenger.com/hi#<address>.
The address is in the fragment, which a browser never sends, so the page's
web server never receives it.

The address card gains "Hello link": the link, Copy link and Share in one
row, and what it does and does not tell anybody. The QR code now holds the
hello link rather than flamenet:<address>?u=<handle>, so a phone camera with
no Flamenet app lands on a page that explains it; the code no longer carries
the username.

The app registers the flamenetmessenger scheme. Opening
flamenetmessenger://hi#<address> hands the address to Home's search box, the
landing a scanned code has, and does nothing else: the person reads who it is
and decides, first contact goes the way it always does, and nothing is sent
until they send it. Signed out, the address waits for sign-in and the person
is told. A link that is not one, or holds no address, gets a plain message.
Home now also goes back to its list first, so the address is seen even when a
chat was open.

Parsing follows DESIGN decision 21: the https link (/hi or /es/hi), the
app link, and bare or flamenet:-prefixed addresses; the fragment decoded
once; fnid: plus 52 characters of the FNID alphabet; the host lowercased,
letters, digits, hyphens and dots, with at least one dot. ContactCard.decode
reads a pasted hello link as its address and refuses one that holds none,
and no longer takes a URL with an at-sign in it for an address. The scanner
accepts only what passes the strict form, says so when a code is not an
address, and keeps scanning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:58 -04:00
Paul HittandClaude Opus 5.5 3389187590 The Today tab, and the directory's screens
Today sits in the tab bar right after Messages, as on Android: Home,
Messages, Today, Calls, Stories, Files, Wallet, Account. The file is asked
for when the tab opens and when the app comes back to the front, within the
hourly rule. The sections run in SPEC §8.11's order: headlines with their
source and the host each link goes to, markets under "Prices are delayed.
Not advice.", weather for the cities picked on this phone in °C or °F as the
phone's region and temperature setting say, the site of the day with the
file's note about its description, the blog post when there is one, then the
way into the directory and the credits. Every link opens in the browser on a
tap and not before. The header says when the file was made; offline or
refused, the kept copy shows with one plain line saying why. A relay that
answers 404 gets only "Today's page is not offered by this relay." and the
directory.

The directory browses by category and searches on the phone; the paper plane
on a site opens "Send to a chat", which lists 1:1 contacts and shows what will
be sent. Both pickers draw their search box on the chrome: a .searchable
field sits at the foot of a sheet on this system, light, where the app's
white search text cannot be read.

Eight tabs is one more than the bar was drawn for. On a 375pt phone each tab
has 46pt and "Messages" is 48pt wide, 51 in bold, so tab labels may shrink to
75 percent rather than lose their ends. A test measures every label against
that width. The tab list moved into MainTabView.tabItems so the screenshot
preview draws the same bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:38 -04:00
Paul HittandClaude Opus 5.5 4a3598da6c The small-web directory, shipped in the app, and what a share sends
directory/directory-v1.json is FlameNet's export of public, approved
listings: 2,964 sites in 90 categories. It is read from the app bundle, so
browsing and searching it ask nothing of any server and work with no
connection. Search folds case, accents and width, needs every word typed, and
looks at the title, the host and the description. Sites with no category are
found by search only, as on the site. A description is optional, and the
file's note about who wrote the descriptions is kept to be shown once.

"Send to a chat" sends an ordinary 1:1 text holding the link, with a §6.12
preview made from the directory itself, so nobody fetches the site, not the
sender and not the receiver. The preview carries the title only: the
descriptions were drafted with software and a preview reads as the site's own
words (DESIGN decisions 6 and 18). desc is present and empty, because iOS
builds up to 0.31 cannot read a preview without it. With link previews
turned off the link goes alone.

E2EInbox.send takes the preview as an optional argument; when given, it is
sent as it is and the fetcher is not called.

Checked between two inboxes on a stub relay: the receiver gets the link and
the title-only preview, and a trap on the shared URL session saw no request,
while the same link sent the ordinary way was fetched, so the trap works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:27 -04:00
Paul HittandClaude Opus 5.5 f114710564 Today: the signed file, the keys it is checked against, and the hourly fetch
The Today tab shows one public file from FlameNet (SPEC §8.11), served by
the home relay at /today.json and the same for everybody. This is the part
underneath the screen.

The file is shown only when its Ed25519 signature checks out against a key
compiled into the app, chosen by the kid the app computes for each pinned
key. The development key is pinned in DEBUG builds only; the production list
is empty until the release step creates that key, so a build without it
verifies nothing. Anything over 512 KB, unparseable, of another version,
signed by an unknown or wrong key, or without a valid date is refused and the
last good copy stays. Lists are cut to their bounds, strings are cut and
flattened, only http(s) links survive, and an older file never replaces a
newer one.

The request carries nothing about the person: its own ephemeral session with
no cookie store, no credential and no cache, a fixed User-Agent and
Accept-Language, no query string, and the obfs4 bridge applied as the other
relay clients apply it. It goes out at most once an hour, counted from the
relay's last answer, and five minutes after a connection that got none
(DESIGN decision 19). If-None-Match is sent only when the stored ETag is the
SHA-256 of the copy held, so no relay can tag a phone. The last good
envelope is kept on disk, excluded from backups, and checked again when it is
read back.

Tests sign their own files with keys they make, plus one fixture signed by
the development key with OpenSSL. The request is checked twice: as built, and
on the wire through a loopback server, with a control proving that server
sees a cookie when one is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:18 -04:00
Paul HittandClaude Opus 5.5 cdccfce895 Group sends: a queued bubble leaves pending once it goes
A group message that reached nobody at first, because no member had a
device yet, said "pending" for ever. Its queued copies carried an empty
`mid`, and the restate after they went matched only a bubble with no `mid`
at that moment, while every group send gives its bubble one. The queued
copies of group text, photos, stickers, cards and events now name their
bubble, and the restate after a flush, or after a refusal waiting cannot
fix, finds it by that id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:04:15 -04:00
Paul HittandClaude Opus 5.5 60b6139991 Group stickers: send them to a group, and file them in the group
A sticker that named a group went into the sender's one-to-one thread, and
a group had no way to send one. A sticker with `gid` is now a group message,
delivered and notified like a group text, and the group composer has the
same sticker control as the one-to-one composer. The group envelope carries
`gid` and the group's name in `gname`, with one `mid` for the whole fan-out,
and members with no device yet get it from the queue like any group send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:04:11 -04:00
Paul HittandClaude Opus 5.5 bc03ecadd7 Federated threads: draw cards and events
A card or an event from someone on another relay was kept on the message
but the screen drew only its text, which is empty for both, so it showed
as an empty bubble. The thread now draws the card, or the event card
without buttons, since nothing there can answer or change one, and the
conversation list shows the same summary line as other chats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:41:32 -04:00
Paul HittandClaude Opus 5.5 7a1a2a2ac1 Game screens: the board, the card, starting a game
Game is in the one-to-one attach menu: pick chess, checkers, reversi or
tic-tac-toe and who goes first. The card shows the board as the engine
draws it, turned round for the second player (tic-tac-toe keeps its
numbers in place), whose move it is, and the moves so far under a fold.

Tap a piece and the squares it can reach are marked; tap one to move. A
pawn reaching the far rank asks what it becomes. A checkers jump is entered
landing by landing and sent once it is a whole move. Reversi marks its
legal squares and shows a lone Pass button when that is the only move.
Every square reads out its name and what stands on it.

The buttons sit in one row: Decline or Cancel invitation while the
invitation is open, Resign and Offer a draw after it, Accept draw and
Decline on an offer, and Start a new game once the boards disagree, which
sends a fresh game of the same kind with the same sides. A game is never
offered for forwarding.

`-FNMSGKindsPreview` (debug builds only) has a mode for each board, a
finished game, an invitation, the out-of-step card and the new-game sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:41:32 -04:00
Paul HittandClaude Opus 5.5 7a7045a8d4 Games: the envelopes, the rules, the inbox and the backup
A game is played inside a one-to-one chat (§6.33). `game` starts it and is
the message its board lives on. `gmove` carries the whole log after each
move and `gend` an ending or a draw offer, both as control traffic. Each
phone rebuilds the board from the log with the engine's capped replay and
checks that every new ply is the sender's and legal. A log that disagrees,
a move for this person's side or a log past the cap stops the game as out
of step, and nothing more is taken for it.

A draw is offered only by the side that has just moved and accepted only
against that pending offer. The invitee can decline and the starter can
cancel until the invitee's first move, judged on the log the gend carries,
so a cancel that crosses that move ends the game on both phones. A move for
a game this phone never saw rebuilds it from the log. A move whose send
fails is taken back with an error and never queued; the start is queued
like a card.

Moves and endings are announced, and the app says them itself: with no
sound for "Your move: chess with Sam" and "Sam offers a draw: Chess", with
one for an invitation and for "Chess with Sam: They resigned. You won".
Nothing is said for this person's own moves or a turned-down offer.

A group's game draws as unsupported and its moves are dropped; a start
from another relay draws as unsupported too. The game lives on its start
message, so history, the disappearing timer, delete and off the record
cover it. The backup carries the game object both apps read: id, t, me,
log, and r with rby when a gend ended it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:41:32 -04:00
Paul HittandClaude Opus 5.5 0d57c3c888 CI: run the games engine's own tests
A games-engine job runs `swift test --package-path Packages/FlamenetGames`
on the mac-local runner for merge requests and main, the same rules as the
other jobs. It needs no simulator; a cold run on this Mac took 12 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:56:16 -04:00
Paul HittandClaude Opus 5.5 254b8fbe10 Build the app against FlamenetGames
The app target depends on the local games package from the merge before
this, so the chat code can replay a game from its log. Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:56:16 -04:00
Paul HittandClaude Opus 5.5 4cb4807203 Merge the games engine (FlamenetGames) into the 0.32 chat kinds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:48:08 -04:00
Paul HittandClaude Opus 5.5 60e1687293 STICKERS.md: several packs, pictures, and what a sticker draws
Describes the packs as they ship: every manifest under `Resources/stickers/`
is a pack, a sticker is named by pack and id together, `image` lives in a
folder named for the pack, what a sticker draws when something is missing,
and one picker section per pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:47:59 -04:00
Paul HittandClaude Opus 5.5 245155afe6 Events: answers by the second, cancel wording, the organizer's weekday
Brings events in line with the Android app:

- An answer's `n` is the unix second it was given, or one past the last `n`
  this device sent for the event when that is later. The highest `n` wins,
  and on an equal `n` the answer that sorts higher as a string (`yes`, then
  `no`, then `maybe`), so every phone settles on the same answer whatever
  order they arrive in. This also covers one person answering from two
  devices.
- A cancellation notifies "<title> was cancelled" and the card reads
  "Cancelled: <title>". A revision notifies "<title> changed" by the title
  the reader had before. A rev 1 that settles a provisional organizer, a
  duplicate and a stale revision say nothing.
- An event id only has to be a non-empty string.
- The organizer's time line names the weekday when the organizer's date
  differs from the reader's ("Wed 7:00 PM New York time").
- The backup writes a card or an event with empty `text`, whatever the
  message held, so a sealed card's words stay inside `card`.

Code and test names now cite §6.31 (greeting cards) and §6.32 (events and
answers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:47:59 -04:00
Paul HittandClaude Opus 5.5 b220b9e7a9 Cards: show a sealed card's line on first draw; event links in readable blue
A sealed card read the time from its timeline's context, and the first draw
can be handed the scheduled opening time itself, so a card still sealed drew
as open: no "Opens on" under it and no "Sealed until" under the sender's
copy. It now reads the clock each time it draws and is woken a second after
it opens. Add to calendar and Edit take the deeper blue, which stays
readable on the dark card. The preview shows both sealed lines on one screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:36 -04:00
Paul HittandClaude Opus 5.5 d948e038cc Sticker picker: key each cell by pack and sticker id
The core pack and FlameNet's both have a `star`, a `cake` and a `coffee`.
The picker keyed its cells by sticker id alone, so SwiftUI drew the second
of each pair as an empty cell. `Sticker.key` is the pack and the id together,
the picker uses it, and a test checks the shipped keys are unique. The picker
also takes a height, for the debug preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:36 -04:00
Paul HittandClaude Opus 5.5 86ce6cbcd0 Add FlamenetGames, the rules engines for games in chats
A local SwiftPM package with no dependencies: chess, checkers, reversi
and tic-tac-toe as DESIGN.md section 2 describes them. Moves are strings
in the wire notation, positions are immutable values, and apply accepts
exactly the strings legalMoves returns, so a log replays the same way on
both apps. Replay refuses a log over the game's cap before playing it and
names the index of the first move it cannot play.

The tests run games-v1.json, which the Android :games module runs byte
for byte: the perft counts from DESIGN.md and 147 cases. Deeper published
perft counts run with FLAMENET_GAMES_DEEP=1.

Not wired into project.yml or the app target yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:02:32 -04:00
Paul HittandClaude Opus 5.5 7f80e1346c Card and event screens: compose, sealed until, answers, Add to calendar
Card and Event join Photo and File in a one-to-one chat's attach menu, and a
group's paperclip becomes the same menu. A card is one of the nine
flamenet.io fronts, shipped as `cards/<design>.png` with its title drawn as
text, and can stay sealed until a day up to 400 days ahead. The receiver sees
the front and "Opens on <date>" until then, and the card opens by itself on
the day; the sender's copy says "Sealed until <date> for <name>". Copy,
reply, forward, report, the conversation list and the notification never
show the words of a card that is still sealed.

An event card shows when and where, the organizer's time when their clock
reads differently, Going, Maybe and Can't go with counts and names, and
"Everyone in this chat sees your answer." The organizer can change or cancel
it. Add to calendar opens the system's own add-event sheet, which on iOS 17
and later needs no calendar access and no Info.plist key; a test presents it
and checks that nothing asked.

`-FNMSGKindsPreview` (debug builds only) shows the new screens with sample
data, for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:46:27 -04:00
Paul HittandClaude Opus 5.5 8d32368b3c Cards, events and answers: the envelopes, the inbox and the backup
Three content kinds that travel inside the ratchet, so the relay sees nothing
new: `card`, a greeting card that can stay sealed until a date; `event`, an
invitation its organizer can revise or cancel under a higher `rev`; and
`rsvp`, an answer, marked `ctl` so older builds drop it.

Both apps write the same bytes: compact JSON, keys in the shared order,
slashes left unescaped. Reading is lenient: unknown fields are ignored, flags
take 1 or true, a number with a fraction is read as a whole number, a field
of the wrong type is absent, and a card or event that carries `ctl` is
consumed as control.

A card or an event lives on the message it arrived on, so history, the
disappearing timer, delete and off the record (held in memory, filtered from
every save) apply with no new store. A revision folds onto the card it
revises: the highest `rev` wins, and only the sender of rev 1 may revise. An
answer folds by the highest `n` per person, never notifies, and goes to every
member of a group; one that arrives before its event waits in memory for it.
With no sent copies yet, a proven envelope from this account's own device is
placed as its own in a group and dropped in a one-to-one chat.

The backup carries `card`, `ev`, `org` and `rsvp` on each message, in the
shape both apps read. The field test now reads the whole type, so the fields
declared after a computed property on a message are checked too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:46:16 -04:00
Paul HittandClaude Opus 5.5 a13c7831f5 Stickers: load every shipped pack and draw its art
The picker shows each pack under `stickers/` as its own section: the core
pack first, then FlameNet's 24 drawings (`flamenet.retro`). A sticker draws
its image when it has one, its glyph when it has none or the image will not
load, and its pack's fallback when this build does not know the id. A pack
this build does not ship draws the core fallback, as docs/STICKERS.md
describes.

`Resources/stickers` is now a folder in the bundle, not a group, so each
pack's PNGs sit in a folder named for the pack and two drawings with the same
file name cannot collide at the bundle root. The SVG sources and the render
script are in `Art/stickers/`; the PNGs are the bytes the Android app ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:46:01 -04:00
Paul Hitt 3666a1b771 Merge branch 'docs/roadmap-refresh-2026-09-29' into 'main'
Roadmap: recheck every item against main, 2026-09-29

See merge request extant2000/flamenet-messenger-ios!236
2026-09-29 20:19:49 +00:00
Paul HittandClaude Opus 5.5 db16e82592 MIGRATION.md: record what is built; roadmap entry to match
Steps 1 and 2 are built on both apps (Mailboxes in Settings), step 4 partly
(publishes first, but does not delete the account left behind and cannot
remove the relay you are signed in to), step 3 not at all. The roadmap entry
said most of this was still to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:05:25 -04:00
Paul HittandClaude Opus 5.5 b8d930d3b9 Roadmap: recheck every item against main, 2026-09-29
Most of the queue has shipped. Moved those to a Done section with where each
lives. Open: quiet delivery (the wire flag exists, the choice does not),
relay migration steps 2-4, groups off the relay, per-contact identity,
local-network delivery, multi-device on the apps, passphrase floor,
key-log default, web client, duress, attachment tokens, credential
rotation, age-off and tombstones, the group-outbox recipient test. Held:
bridge distribution (OTF IFF concept note) and protocol-shape changes until
the audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:01:52 -04:00
Paul Hitt f05f43dc84 Merge branch 'docs/export-renotification-2026-09-28' into 'main'
Export compliance: record the 2026-09-28 re-notification

See merge request extant2000/flamenet-messenger-ios!235
2026-09-28 19:32:00 +00:00
Paul HittandClaude Opus 5.5 4d3b95eb7f Export compliance: record the 2026-09-28 re-notification
The source moved to gitlab.com/extant2000 and flamenet-elligator2 was
published, so BIS and NSA were notified again. The first entry is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:12:07 -04:00
Paul Hitt 3e623e1fdd Merge branch 'chore/move-to-extant2000-group' into 'main'
Point links at the extant2000 GitLab group

See merge request extant2000/flamenet-messenger-ios!234
2026-09-28 19:08:28 +00:00
Paul HittandClaude Opus 5.5 371e6b4a59 Point links at the extant2000 GitLab group
The messenger repositories move from gitlab.com/paulhitt to
gitlab.com/extant2000. Old web and git URLs redirect; the relay's container
image path does not, so docker-compose.yml names the new registry path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:38:57 -04:00
Paul HittandClaude Opus 5.5 7b45ca7acc Merge branch 'fix/pin-the-ios-packages' into 'main'
Pin the six packages Obfs4 brings in, to the versions it proved

See merge request paulhitt/flamenet-messenger-ios!233

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:50:38 -04:00
Paul HittandClaude Opus 5.5 900cde30e9 Pin the six packages Obfs4 brings in, to the versions it proved
Scripts/Package.resolved had `"pins": []`. It emptied correctly when WebRTC
moved into the local Packages/WebRTCBinary (b66d2991), and stayed empty when
the Obfs4 submodule added swift-crypto and swift-nio from GitHub. So every
build resolved those against the network, and a build with
-disableAutomaticPackageResolution failed:

  an out-of-date resolved file was detected ... dependencies were added:
  'swift-crypto'

Now pinned to exactly what Packages/Obfs4/Package.resolved pins, the set its
lyrebird interop suite runs against: swift-crypto 3.15.1, swift-nio 2.102.0,
swift-asn1 1.7.2, swift-atomics 1.3.1, swift-collections 1.6.0,
swift-system 1.8.1. A fresh `generate.sh --update` would have taken newer
asn1, collections and nio than Obfs4 has tested.

Checked: after generate.sh, xcodebuild with -disableAutomaticPackageResolution
builds and reports those exact versions; the old empty file fails the same
strict resolve with the error above. iOS unit tests on the pinned set:
1371 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:36:33 -04:00
Paul Hitt 7601d46982 Merge branch 'fix/donations-are-not-in-the-app' into 'main'
No Donate row, and version 0.31.0

See merge request paulhitt/flamenet-messenger-ios!232
2026-09-25 22:24:35 +00:00
Paul HittandClaude Opus 5 634a881ac5 No Donate row, and version 0.31.0
App Review rejected 0.29.0 under guideline 3.1.1 on 2026-09-25: the
Profile row opening the project's donate page counts as a donation
toward the app's own development, which that guideline requires to go
through In-App Purchase whether or not anything is unlocked. The old
comment argued the opposite, citing 3.2.1, and was wrong. Opening the
page outside the app does not avoid it either.

So the row is gone. The choice 3.1.1 leaves is In-App Purchase or
nothing, and this app does not put a payment flow in front of people to
fund itself. The page is still served at /donate for anybody who looks.

`SiteConfig.donateURL` stays, unlinked, with a comment saying why it is
not dead code: the address is still right, and a constant explaining
itself is a better answer to "why is there no Donate row" than an empty
file and a git log.

`DonatingGoesToTheProjectTests` is deleted. It existed to assert the row
was present and pointed at the project's own site.

Version 0.31.0, build 31. Not 0.30.0: that was tagged before this fix and
would archive with the row still in it, so the tag is left alone and the
release moves up rather than being rewritten under itself.

One thing worth leaving a marker for. The first attempt at this comment
named the host in prose, and `ThePolicyIsReachableFromTheAppTests`
forbids that string ANYWHERE in this file, comments included, so a
self-hosted relay's users cannot be sent to somebody else's policy. It
reads raw source and cannot tell prose from code, which is the right
kind of blunt. It surfaced as "964 tests, 0 failures" followed by
"** TEST FAILED **", because that count is XCTest's and the guard is a
Swift Testing expectation, reported separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:04:49 -04:00
Paul Hitt 1f288a566d Merge branch 'release/0-30-0' into 'main'
Version 0.30.0, build 30

See merge request paulhitt/flamenet-messenger-ios!231
2026-09-25 18:53:50 +00:00
Paul HittandClaude Opus 5 24d2823578 Version 0.30.0, build 30
Seven source commits have landed since releases.json recorded 0.29.0 on
2026-09-22, none of them in any build: off-the-record threads no longer
reaching the disk, bridge lines out of backups, the parental PIN
stretched, the sealed-send fallback said at the time, one list of
keychain accounts, and an unproven key replacement naming itself.

Build number follows the release per the rule above it in this file, so
0.30.0 is build 30. The generated project picks both up from here, which
is checked: project.pbxproj carries MARKETING_VERSION = 0.30.0 and
CURRENT_PROJECT_VERSION = 30 after a regenerate, and Info.plist resolves
them by substitution at build time rather than holding literals.

No archive and no upload. The changelog entry in flamenet-relay's
Resources/releases.json is separate and deliberately not in this commit:
two commits dated 2026-09-22 cannot be attributed to a build from the
repo alone, because nothing marks what 0.29.0 contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 14:33:00 -04:00
Paul Hitt 5a910a72d2 Merge branch 'fix/off-the-record-threads-reach-the-disk' into 'main'
Off-the-record threads reached the disk, and came back

See merge request paulhitt/flamenet-messenger-ios!228
2026-09-25 17:55:57 +00:00
Paul Hitt 1fbb443913 Merge branch 'fix/bridge-lines-do-not-travel-in-backups' into 'main'
Bridge lines do not travel in backups

See merge request paulhitt/flamenet-messenger-ios!227
2026-09-25 17:48:13 +00:00
Paul Hitt 20041c65f8 Merge branch 'test/the-identity-and-fork-refusals-have-tests' into 'main'
Put tests behind the identity and fork refusals

See merge request paulhitt/flamenet-messenger-ios!224
2026-09-25 17:47:49 +00:00
Paul HittandClaude Opus 5 b7e8c498a3 The off-the-record guard was pinned to the bug
`OffTheRecordTests` asserted that `persistable(threads)` appeared
exactly twice, under the comment "Both call sites, and no third one that
skipped the filter".

It cannot catch that, and it caught the reverse. A site that SKIPS the
filter adds no occurrence, so the count stays at two and the test
passes, which is what it did for as long as `flushHistory` wrote
off-the-record threads to disk. The only thing that failed it was adding
a third FILTERED site, which is to say the fix.

So the suite written to protect §6.28 was, in that line, holding the
defect in place. Counting the good case cannot find the bad one. It now
looks for the bad one: any `threads:` argument that did not go through
the filter. Reverting the fix fails it.

Found because the run reported "948 tests, 0 failures" and TEST FAILED
at the same time. XCTest and Swift Testing both run here and the XCTest
summary is blind to the other 384, so a reader of the first number would
have called the suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:12:06 -04:00
Paul HittandClaude Opus 5 ea7d16e339 Off-the-record threads reached the disk, and came back
The landing page says a conversation held off the record is gone when
the app closes. It was not. It was sealed to the history file and read
straight back on the next launch.

`flushHistory` passed `threads` through unfiltered while the two
debounced paths passed `persistable(threads)`. Twenty-four ordinary
actions flush, adopting a roster and scheduling a send among them, so
this was not an edge case, and the load side does not filter either:
`self.threads = restored.threads`. The conversation reappeared.

The comment on `persistable` read "This is the one filter every path to
disk goes through". It was a description of an intention rather than of
the code, and it is the reason this was easy to miss. It now says what
is true and why the sentence used to be false.

Four suites already tested the off-the-record filters and all four
passed throughout. They test the helpers. The defect was a call site
that never reached one, which no amount of testing the helper can find.

So the new test asserts the invariant instead: no path hands a thread
dictionary to disk without filtering it. It reads the source, which is
unusual and is the only level at which "every path" can be checked at
all, because `threads` is `private(set)` and a test cannot put a message
into one from outside. It carries a control that fails if the file is
not actually read, since that is how a test like this rots into passing
vacuously.

Reverting the fix fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:12:06 -04:00
Paul HittandClaude Opus 5 7a1acbe557 Bridge lines do not travel in backups
The configured bridge line and every pending offer lived in
`UserDefaults`, which iTunes and iCloud back up. An offer carries
`fromUser`, `fromName` and the line, so a backup held a contact's name
in the clear beside the address of a bridge they use.

That is the wrong place twice over. It is a contact graph, naming
somebody who trusted this person enough to hand them a way through, and
it is circumvention metadata: the bridge's host, port and fingerprint,
and the fact that this person went looking for one. obfs4 is here for
people who expect the device to be taken and read. A feature built for
that threat cannot keep its state somewhere a backup carries off the
phone.

Both now sit in the Keychain under
`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, the attribute the
account credential already uses: never in an iCloud keychain, never in a
backup, and readable after first unlock so a bridge can still be dialled
before anybody has touched the screen.

Moving the store is not enough on its own. A phone that has already run
an older build keeps the old copy, and every backup taken from here on
carries it. `migrate` takes the value and removes the old one whether or
not the new write succeeded, because leaving it is the bug.

Android never persisted offers at all, so this was the iOS half of a
problem the other app did not have.

Four tests. One walks the whole UserDefaults dictionary asserting the
contact name and the bridge address appear nowhere, which tests the
property rather than the implementation. Two mutations, two dead:
leaving the old copy behind, and writing the line back to UserDefaults.

Found by row 2 of the audit readiness sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:12:05 -04:00
Paul HittandClaude Opus 5 e6e6e0cca3 Put tests behind the identity and fork refusals
Mutation testing found these unguarded: disabling any one of five refusals
left all 384 tests green, so the checks were implemented and unproven.

§20.1 and §20.5, in a new PQIdentityRefusalTests: an identity that
disappears from a device that had one, a signature over bytes other than
the prekey it is served with, and a substituted ML-DSA key on a device
already pinned. The bundles are decoded from wire JSON rather than built
in memory, so a change to what a relay actually serves fails here instead
of passing against a shape only the test believes in.

§19.4, appended to AutomaticKeyLogTests: a head smaller than the pin, and
two roots at one size. Every existing test in that file was about a log
being ABSENT; none pinned a head and then served a contradicting one.

Each group carries a positive control, because three refusal tests also
pass against a client that refuses everything. Five mutations, five dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:11:37 -04:00
Paul Hitt 988f98ddae Merge branch 'ci/simulator-reset-and-gate-ui-tests' into 'main'
Clear a wedged simulator before each test job, and gate ui-tests

See merge request paulhitt/flamenet-messenger-ios!230
2026-09-25 16:26:11 +00:00
Paul HittandClaude Opus 5 d363cb5d69 Put ui-tests in the merge request pipeline, which it was never in
Removing allow_failure in the previous commit accomplished nothing, and
this is the correction.

A job with no `rules` and no `only` runs on branch pipelines and is left
OUT of merge request ones. Every other job in this file carries the two
conditions; this one carried neither. So the job was never in the
pipeline that gates a merge, and the flag that supposedly stopped it
blocking anything was decorative either way.

The pipeline on !230 is what said so: a real merge_request_event on
refs/merge-requests/230/head with four jobs in it, secret-sweep,
unit-tests, vectors-head and parity-scrub, and no ui-tests. The previous
commit was verified by checking that allow_failure was gone from the
YAML, which it was, rather than by checking the job ran, which it did
not.

It also revises the evidence in that commit. The three clean runs at
4.6, 5.0 and 5.2 minutes were branch pipelines, main and one triggered
by hand, so none of them was ever in anybody's merge path. The comment
above the job now says that too.

`unit-tests` is unaffected and the other half of the previous commit is
good: with the simulator shut down first it ran in 3.1 minutes, against
the thirty-minute timeout that prompted it.

`ci/lint` reports the file valid. Whether this worked is readable off
the next pipeline's job list rather than off this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 12:12:24 -04:00
Paul HittandClaude Opus 5 9dd0a207ac Clear a wedged simulator before each test job, and gate ui-tests
Two changes, one of them a correction to a diagnosis.

`ui-tests` no longer carries allow_failure. It earned that on evidence,
not on an estimate: three consecutive CI runs at 4.6, 5.0 and 5.2
minutes, the last of them immediately after an unrelated job on the same
host hung for half an hour, so it was not a quiet machine that earned it.

Both simulator jobs now shut the target device down before invoking
xcodebuild. On 2026-09-25 `unit-tests` 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
2.6 to 3.8 minutes. A cancelled xcodebuild can leave CoreSimulator
holding a booted, unusable device, and the next job waits on it forever.

`shutdown all` is the usual line for this and is deliberately not used.
This runner is a workstation, and a simulator somebody is debugging in
is not a CI job's to close.

The correction: two mac-local runners are registered, which looks like
these two jobs can collide over one simulator, and that was very nearly
fixed with a resource_group. Their start and end times say they have
never once overlapped, in either the healthy pipelines or the broken
one: 18:03:19-18:07:07 then 18:08:08-18:13:10, and 12:44:33-13:14:37
then 13:15:28-13:20:05. There was no race. The comment above the job now
says so, because the next person to read those two runner entries will
reach for the same wrong fix.

The stale half of that comment is gone too. It said allow_failure stays
"until it has shown a run of clean results on an idle runner, then delete
the flag", which would have been a lie about a flag that is no longer
there.

`ci/lint` reports the file valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 10:26:00 -04:00
Paul Hitt 7b30859ba3 Merge branch 'audit/at-rest-and-call-log' into 'main'
At-rest claims, the parental PIN, mute flags, and two things that were silent

See merge request paulhitt/flamenet-messenger-ios!229
2026-09-24 18:02:53 +00:00
Paul HittandClaude Opus 5 3b6778e7c7 Read the poll tally vectors on this side too
The other half of the cross-engine check. Both engines carry a comment
saying the integer width is load-bearing, both were unit-tested on their
own side, and nothing checked that they agree, while a mixed
iPhone-and-Android group poll is the ordinary case. A disagreement
surfaces as a wrong count rather than as an error.

Reads test/vectors/poll-tally-v1.json from flamenet-e2e, the same file
the Kotlin engine reads. Three checks: the fixture loaded and contains
at least one case that distinguishes the widths, every pinned tally is
what this engine computes, and the cases marked as needing wrapping
really do need it, verified by adding them up in Int and requiring the
WRONG answer. The last one pins the reason rather than only the result:
if it ever stops holding, the vectors have stopped testing what they
were made for.

936 XCTest, 410 Swift Testing in 84 suites, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:47:07 -04:00
Paul HittandClaude Opus 5 85ddb1cc92 E2: stretch the parental PIN, and stop mute flags naming the conversation
Two row-2 findings under claim E2, "nothing that identifies a contact or
a conversation is written outside the sealed store".

The parental PIN was salted and then hashed with ONE round of SHA-256,
with both the hash and the salt in UserDefaults. The salt stopped a
rainbow table and nothing else: four digits is ten thousand candidates
and a single SHA-256 is nanoseconds, so whoever held the hash held the
PIN, and UserDefaults rides iTunes and iCloud backups, so holding the
hash never required holding the phone. It now stretches with
FlamenetAccountKDF's PBKDF2-SHA256 at 650,000 iterations, the same
function the account passphrase already uses, and lives in the Keychain
under AfterFirstUnlockThisDeviceOnly. Verification is constant-time.

An existing PIN still opens, and is rewritten in the new format the
first time the PIN is in hand, which is the only moment it can be: the
old value cannot be upgraded without it. The gates are restored as the
parent left them rather than as `enable` sets them, because `enable`
turns everything on and a parent who had released the calls gate would
find it back.

Mute flags were keyed by the conversation itself:
settings.muted.buddy.4211, settings.muted.group.<gid>, and
settings.muted.address.<fnid@relay>. The backup therefore carried a list
of contact ids, group ids and full cross-relay addresses in the clear.

Moving them into the sealed store would have broken them: the flag is
read while deciding whether to raise a notification, and a push can
arrive with the app cold and the history not yet open. So the VALUE
stays in UserDefaults and the KEY becomes an HMAC-SHA256 under a device
key kept in the Keychain and not in the backup. A backup now carries
opaque keys and no key to read them with; what it reveals is how many
conversations are muted and nothing about which. MuteKeys.migrate runs
once at launch and rewrites existing flags, including the mention-break
flags that are keyed off them.

A Keychain that cannot answer reads as NOT muted, deliberately. The
other default silently stops a call ringing.

One of my own tests was deleted rather than kept. It asserted that
feeding the old digest as the PIN fails, and putting the one-round
scheme back made it PASS: that digest is not the PIN under either
scheme, so it proved nothing while reading as though it proved the
stretch. The cost test is what distinguishes them and it failed against
the mutant as it should.

MentionsTests lost its assertion that the key equals
"settings.muted.buddy.7". Its comment was right that changing the key
loses every existing mute, and that concern is answered by the
migration rather than by keeping a name that was the defect.

936 XCTest, 407 Swift Testing in 83 suites, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:26:41 -04:00
Paul HittandClaude Opus 5 e030d63c90 F2 and F5: say it at the time, and stop a substituted identity being silent
F2, and SPEC section 14.5: "Clients SHOULD surface which mode a
conversation is using, and a user who has enabled sealed sending SHOULD
be told when a message could not be sent sealed."

A sealed send falls back to an identified one when the recipient's
cached delivery key has gone stale. The message is still encrypted per
device; what changes is that the relay is told which account and which
device sent it. SealingRecord counted that from the beginning and
Privacy Settings showed the running share, which answers "how often"
and never "did this one". Somebody who believed a particular message
went sealed had to go and read a counter to find out it had not, and a
counter cannot say which message.

The conversation now says so, once, on that conversation. In memory
rather than in the sealed store: the fallback is a fact about a moment,
the notice exists to be seen at that moment, and persisting it would
mean a new per-contact record to answer a question the running total
already answers across restarts. It reads as a note rather than an
alarm, because nothing is wrong and the usual cause is the other person
reinstalling.

F5, the half that does not need the salt moved. The account identity is
PBKDF2(passphrase, salt) expanded, and the salt is held by the account
service, handed back at every sign-in, and never written to the device.
So that service can serve a different salt: the client derives a
different account key, a different fnid and a different authSecret, and
the same service verifies the result. Sign-in succeeds and the person is
now a different identity.

Nothing compared the two. savedMyAddress() was read to validate wipe
orders and to display, and the newly derived address overwrote it
without a glance, so the substitution left no trace on the device at
all. Silence is the whole value of that attack. It is compared before it
is saved now, and a difference surfaces in Profile beside the address it
replaced, naming both the ordinary cause and the other one.

What this does NOT fix, and the register says so: the salt still lives
on one service, and losing that service still loses the identity. That
is custody, and custody is a design change.

One of my own tests failed and was right to. It searched the whole of
ChatView for words the notice must not use, and ChatView already
carries "Flamenet won't send messages unencrypted... in the clear" in a
different notice, where those are the correct words. A search for
forbidden wording has to know whose wording it is reading. Bounded to
the notice now.

934 XCTest, 402 Swift Testing in 82 suites, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 10:52:05 -04:00
Paul HittandClaude Opus 5 f6578e5921 F9 and F11: one list of keychain accounts, and run the UI tests
F9. KeychainE2EStore had two lists of the same accounts and only one was
maintained. wipe() named thirteen; migrateAccessibilityIfNeeded() named
the four it was written with and never grew. Accessibility is fixed at
write time, so an item an older build stored keeps its class for ever
unless something rewrites it, and that migration is the only thing that
does. Nine accounts sat outside the one mechanism that exists to stop
them being backup-eligible.

What it cost was small, and by luck rather than design: of the nine,
only device_id predates the 2026-08-11 switch to ThisDeviceOnly, and a
device id opens nothing. history_key, which unlocks the entire
conversation history, was introduced two weeks AFTER the switch. Two
weeks the other way and this would have been an at-rest break instead
of a tidiness finding.

So: one list, fixedAccounts, read by both. Rewriting an item that is
already ThisDeviceOnly costs one read and one write, once per install,
which is the price of not having to decide per account whether it is
old enough to need this and getting it wrong silently.
OneListOfKeychainAccountsTests keeps it one list and fails if either
caller grows a literal of its own. Falsified by restoring the old
four-name array.

F11. UITests/ holds 83 tests and the pipeline ran none of them: the iOS
job passes -only-testing:FlamenetMessengerTests and stops. 69 of the 83
gate on a live relay and skip cleanly without one, by design. The other
14 need nothing and were being left on the floor.

The new ui-tests job runs the target and gates on the log rather than
on grep's exit status, for the reason unit-tests already spells out. Its
floor counts what ACTUALLY ran rather than what was scheduled, because
every one of these can skip and "0 executed, 0 failures" is a perfectly
green log for a target that did nothing. The arithmetic is checked
against all three shapes of the summary line; an earlier draft would
have failed a run with nothing skipped, for being healthy.

It does NOT gate yet, and the flag is the honest part. The case for
gating rested on an estimate of "about three and a half minutes, one
flaky test". Measured: on an idle machine, 211 seconds, 14 ran, zero
failures, which is the estimate. On the same machine at a load average
of 115, nineteen minutes and three failures, every one of them
app.launch() timing out with "Error getting main window", two in tests
nothing had touched. That is the host, and CI shares it. allow_failure
comes off when a run of clean results on an idle runner says it can.

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:
nothing re-sends the gesture. It waits for the row now, and retries the
swipe once.

Unit target: 934 XCTest, 392 Swift Testing in 80 suites, no failures.
UI target on an idle machine: 83 tests, 69 skipped, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 10:23:09 -04:00
Paul HittandClaude Opus 5 d6ca0e1ef3 Test the two at-rest claims that rested on one constant and one line
Claim E1, from the landing page: "Messages are locked on your phone."
HistoryAtRestTests proves the file does not open under a different key,
which is a weaker statement than the sentence makes: a store that sealed
the message bodies and wrote the participants into a plaintext header
would pass it. MessagesAreLockedOnYourPhoneTests asks what somebody
holding the file actually sees. It saves a snapshot carrying a message,
a contact handle and a display name, then searches the raw bytes of the
file the store actually wrote for all three.

Two controls, because every assertion in it is a search for ABSENCE and
a search of nothing always succeeds: the file must exist and be of a
plausible size, and the byte search must find the same string when it IS
present. Falsified by writing the snapshot unsealed, which trips all
three needles.

It also pins KeychainE2EStore.accessibility, which is why the constant
is no longer private. The whole answer to a stolen backup is that the
history file rides it and the key does not, and that rests on
ThisDeviceOnly and nothing else.

Claim F4: a call moved between devices produces one log entry, not two.
The peer's half is covered, recordCall files nothing new for a call id
it has already filed. The half that makes the peer see one call at all
is that the device TAKING the call re-offers under the ORIGINAL call id,
and that is one argument on one line of CallManager.takeHandoff. Mint a
new id there and all eleven tests in HandingACallOverTests still pass:
they encode a signal with a hardcoded "c1" and check it survives the
round trip, so they test the encoder rather than the manager's choice.
The person handing the call over sees nothing wrong either, because
their own log is right. Only the peer gets two rows.

AMovedCallIsStillOneCallTests pins it, with a control for the source
being read, and states the rule twice so that renaming an argument label
cannot quietly retire the check.

Unit target: 934 XCTest and 388 Swift Testing in 79 suites, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 08:56:12 -04:00
Paul Hitt a663c4e239 Merge branch 'fix/decrypted-media-does-not-outlive-its-use' into 'main'
Decrypted media stops outliving the moment it is needed

See merge request paulhitt/flamenet-messenger-ios!226
2026-09-22 19:20:53 +00:00
Paul Hitt ba9f29bccb Merge branch 'fix/an-unproven-key-replacement-says-so' into 'main'
An unproven key replacement says so

See merge request paulhitt/flamenet-messenger-ios!225
2026-09-22 19:20:48 +00:00
Paul HittandClaude Opus 5 1d39732b96 An unproven key replacement says so
The relay separates a device key replacement the old key signed for,
`rotate`, from one that arrived with no signature at all,
`rotate_unproven`, and keeps them apart in the log on purpose.
`IdentityRotation.report` says why: "a client watching the log can tell
this apart from a rotation the previous key vouched for."

No client could. Both kinds fell to `default` here and read "Their key
set changed", the same sentence a routine change gets, so the
distinction the relay records reached nobody. The unproven case is the
one that matters: it is exactly what somebody who cannot sign with the
old key would produce.

Both kinds now name themselves, and the unproven one carries a warning
that says what to do about it rather than only that something happened.
The warning is a separate sentence after the date, so the clause naming
the day stays intact and the existing exact-string tests are unchanged.

Four new tests. One is the control: a test that only checked the
unproven wording would pass if `rotate` were given the identical
warning, so the two are asserted to differ.

Android carries the same change, with a test pinning the two sentences
to each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:42:28 -04:00
Paul HittandClaude Opus 5 20053ca2a3 Decrypted media stops outliving the moment it is needed
Five flows wrote a plaintext copy of something encrypted everywhere
else into the temporary directory, and not one of them removed it: a
story saved to Photos, a story played back, a picked video, the
transcode of one, and every file opened from the vault. Nothing swept
them either, so `eraseLocalData` erased the history and the keys and
left the pictures. Stories were the sharpest case, the viewer treating
them as ephemeral while the media outlived them by an unbounded margin.

`VoiceRecorder` already had the answer, removing its file on teardown
and sweeping stranded ones on init, with a comment naming this exact
risk. Five sites did not follow it. Android deletes in MediaScrub,
VoiceNote and StoryMedia and sweeps orphaned attachments off the poll
loop, so this was the iOS half of a problem the other app had solved.

`TempMedia` is one swept directory rather than a filename prefix. The
Files browser writes under the name the person filed the file under, and
a prefix sweep would be deciding from a filename whether somebody else's
temporary file is ours to delete.

Per-site discards where the lifetime is unambiguous, sweep at launch and
in `eraseLocalData` as the floor for a kill mid-flow. The transcode is
deliberately sweep-only: it becomes `previewURL` and the composer plays
it until dismissed, so discarding it there would break the preview.

Seven tests, including a control proving the sweep does not reach
outside its own directory. Three mutations, three dead: dropping the
sweep from erase, making discard a no-op, and pointing the sweep at the
whole temporary directory each fail exactly the test that should catch
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:58:08 -04:00
Paul Hitt 9a533138b6 Merge branch 'docs/no-em-dash-in-the-notice-test' into 'main'
No em dash in the notice test's failure message

See merge request paulhitt/flamenet-messenger-ios!223
2026-09-22 14:25:13 +00:00
Paul HittandClaude Opus 5 8aad2112a9 No em dash or bold in the notice test
The em dash was the last one in Swift outside a markdown table, and it
reads out when the test fails, so it is text somebody sees.

The bold was the only one left inside a code comment in any of the four
repositories, in the sentence naming the dialog title the bug produced.
Quotation marks say the same thing and are what the rest of the file
uses.

Counted while here: zero bold inside comments across all four now, and
the em dashes that remain are markdown table cells, which are reference
content rather than prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:53:01 -04:00
Paul Hitt e7a87698b7 Merge branch 'fix/off-the-record-outbox' into 'main'
Keep off-the-record messages out of the outbox on disk

See merge request paulhitt/flamenet-messenger-ios!222
2026-09-22 12:26:41 +00:00
Paul HittandClaude Opus 5 aa19204d4c Keep off-the-record messages out of the outbox on disk
An outbox entry carries `to`, `body` and `wire`: the message text and the
encoded content envelope. That is the conversation, not metadata about
it, and it was passed to the debounced save and the flush raw. A send
queues there when the recipient has no registered device, so an
off-the-record message ended up in the sealed file and came back at the
next launch. §6.28 says it lives in memory while the app is open and is
gone when it closes.

`persistableOutbox` sits with the other two filters and is applied at
both save sites. `currentHistorySnapshot`, which feeds the backup, has
always passed an empty outbox, so backups were never carrying these.

Dropping a queued send across a restart is the promise rather than the
cost of keeping it: the thread is not restored either, so a message that
survived would arrive into a conversation the app no longer has. It
still retries for as long as the app is open.

Falsified: with the filter returning its input, three of the four tests
go red, including the one that asserts the dropped entry's text is not
among what is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:56:34 -04:00
Paul Hitt 494f94b3dc Merge branch 'fix/off-the-record-unread-counts' into 'main'
Keep off-the-record unread counts off disk too

See merge request paulhitt/flamenet-messenger-ios!221
2026-09-22 11:25:19 +00:00
Paul HittandClaude Opus 5 c1a7491384 Keep off-the-record unread counts off disk too
`unreadPeers` is `[Int: Int]`, a count per peer, incremented in
`deliver` for every message that arrives with no off-the-record check,
and passed to all three save sites raw. An off-the-record conversation
left the peer's id and a running count in the sealed file, and it came
back on the next launch. §6.28 says the conversation lives in memory
while the app is open and is gone when it closes.

Same fault as the drafts and pins one, on a dictionary keyed by user id
rather than by `u:<id>`, so `persistableKeyed`'s prefix check never
looked at it. `persistableByUser` sits beside it and is applied at the
debounced save, the flush and the sign-out write.

Filtering costs nothing here. The badge it feeds is read from memory
during the session, and an off-the-record thread is not restored at
launch anyway, so there is nothing for the saved count to badge.

Falsified: with the filter returning its input unchanged, the two tests
that assert an off-the-record peer is dropped go red and the two that
assert nothing else is touched stay green.

Left alone deliberately: `heldEnvelopes` is keyed by sender and also
unfiltered, but it holds ciphertext for a peer whose identity change has
not been accepted, and dropping it would lose exactly the messages that
file exists to keep. That is a decision about which promise wins, not a
missing filter. `addrThreads` already carries its own note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:55:57 -04:00
Paul Hitt c7e72bf1af Merge branch 'fix/deleting-an-account-tells-the-account-service' into 'main'
Tell the account service when an account is deleted

See merge request paulhitt/flamenet-messenger-ios!217
2026-09-21 23:55:47 +00:00
Paul Hitt 70965acd42 Merge branch 'fix/a-success-is-not-an-error' into 'main'
A success is not an error, and a report is not Files

See merge request paulhitt/flamenet-messenger-ios!219
2026-09-21 23:14:34 +00:00
Paul HittandClaude Opus 5 30530fe4ec A success is not an error, and a report is not Files
Seen on a TestFlight build on 2026-09-21: reporting a message raised a
dialog titled "Files". The report itself had worked. It was filed on the
relay, emailed, and delivered; the only thing wrong was what the screen
called it.

Two places, one shortcut. A piece of state was named and titled for the
first outcome that used it, then reused for others.

ChatView had `.alert("Files", ...)` over a bare `saveNotice: String?`.
That heading was written when saving an attachment was the only thing
that went through it. Six outcomes do now, so reporting a message,
blocking somebody, and failing to send a file all announced themselves
as "Files". The notice now carries its own title and each outcome
supplies one.

GroupChatView put "Reported to whoever runs this server." and
"<name> blocked." into `errorMessage`, which renders in a red
ErrorBanner. Both of those SUCCEEDED. They now go to a `NoticeBanner` in
a colour that does not claim something went wrong, and it takes itself
away after four seconds rather than sitting there until the person
happens to send something, which is right for a failure and wrong for
this.

The tests read the source. What is being asserted is a literal that a
later edit can quietly reintroduce, and a rendering test would not notice
a title that is merely wrong. Putting either bug back turns three of the
five red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 19:09:33 -04:00
Paul Hitt 0fd822d054 Merge branch 'chore/audit-sweep' into 'main'
Four em dashes that crept back in

See merge request paulhitt/flamenet-messenger-ios!218
2026-09-21 22:58:42 +00:00
Paul HittandClaude Opus 5 e8df944e63 Four em dashes that crept back in
The 2026-09-08 sweep took the em dashes in these repos to zero. Twelve
were back here, and the four outside markdown tables are now commas or
colons: three in README and one in a test's doc comment.

The other eight are table cells in DESIGN.md, SCOPING-v1.md and
docs/BUGS-2026-09-01.md, and they stay. A dash in a table cell is
typography, and in several of them it IS the content: the empty-cell
marker in a comparison row. Rewriting those to satisfy a prose rule
would damage the table to fix nothing a reader would notice.

Measured across all four public repos while checking: emoji 0,
comment density 18 to 26 percent of non-blank source lines, down from
the 30 to 38 percent recorded before the September sweep. The structure
is in good shape; this was the residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 18:51:55 -04:00
Paul HittandClaude Opus 5 75fcf183fd Drop the Flamenet Files half of the account delete
Files was folded into the messenger, so the account service issues
pseudonyms for one product and a delete can no longer half-succeed.

The rule this branch was built around, keep the shared account alive
while Flamenet Files still holds an identity, can never fire now.
`deleteAccount` returns nothing instead of a flag saying which of two
outcomes happened, `AccountDeletion` loses its `sharedAccountKept` case,
and the sheet no longer offers to explain that somebody's Flamenet
account survived because Files was using it. The endpoint half is in
flamenet-files!10.

What stays is what was right for other reasons: the passphrase is proven
before anything is deleted, so a typo costs nothing; the relay goes
first, because reversed a failure in between leaves an account nobody can
finish deleting; and `sharedAccountFailed` still tells somebody plainly
that their Flamenet account is standing when it is.

Copy: the sheet said the Flamenet account is the one you also sign into
Flamenet Files with. It says it works across relays, which is the thing
that is actually true and the only reason the shared service exists.

Packages/FlamenetFiles stays. It is the engine that was rolled in, and
flamenet-e2e's interop build reads Label.swift out of it.

Full suite: 927 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:36:43 -04:00
Paul HittandClaude Opus 5 31e31fa089 Tell the account service when an account is deleted
The same gap Android had, closed the same way. Deleting an account
removed the relay's copy and stopped there. On a relay using the shared
accounts that is half the account: the row at accounts.flamenet.io
stayed, the handle stayed taken, and the person stayed registered with a
service they believed they had left.

Found on 2026-09-21 by creating an account against production and
deleting it. The relay row went; the account service row had to be
removed by hand.

The endpoint is new, in flamenet-files, and has to be deployed before a
build carrying this ships or the second call answers 404.

Three things decide the shape, and they are the Android ones:

The passphrase is proven FIRST, by signing in, and nothing is deleted
until it is. A typo is the likeliest thing to go wrong, and finding out
after the relay call would mean the messenger account is already gone,
the Flamenet account is not, and the person has been signed out of the
app that was offering to remove either. `deleteAccount` therefore throws
only when NOTHING was deleted; a returned value always means the relay
account is gone.

Then the relay, then the account service. Reversed, a failure in between
leaves an account that can no longer sign in, because a shared account
signs in AT the account service, and so an account nobody can ever finish
deleting.

A shared account that Flamenet Files still holds an identity on is kept
on purpose, and the person is told so rather than being told their
account is gone when it is not. `notice(for:)` is pure so that wording
can be pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:59:31 -04:00
Paul HittandClaude Opus 5 0718267cb5 Pin the e2e spec at the corrected section 14
afd0e4f..3564030 is three commits touching SELF-HOSTING.md and docs/SPEC.md
and nothing else. The six vector files are byte identical across the range, so
the gate cannot move; verified by running it locally against both ends.

What it buys: the spec this repo pins no longer says sealed sender is DRAFT /
proposed while section 15, and the published security page, both record it
wired end to end on the server, the web client, iOS and Android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:16:20 -04:00
Paul HittandClaude Opus 5 45194792de Wait for the revoke to land, not for two seconds
The retry test polled 40 times at 50ms and then asserted, so it had a
two second budget for a fire-and-forget task. This suite runs on the
machine that is also the mac-local runner; at load 17 the background
task got none of it and main went red on a version bump. Poll to a
30 second deadline instead. A healthy run leaves in 0.2s, faster than
before, because the granularity is now 20ms.

Falsified: with the note never written, the test fails at 30.7s with
"the relay was never asked to revoke the dead device". The deadline is
real and the assertion still bites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:06:14 -04:00
Paul HittandClaude Opus 5 9f87f1a9a3 0.29.0
Everything since 0.28.0 went to Apple: held envelopes replayed when a
peer's keys are finally accepted, so a reinstall no longer loses the
messages sent in between; remote erase for a lost phone, both
platforms; group and multi-picture stories; the blocked list no longer
emptied by a failed fetch; screenshot notices shown rather than only
received.

project.yml is the only place the version lives. The .xcodeproj is
gitignored and regenerated, which is how the repo once said 1.0 while
TestFlight had 0.11.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:10:09 -04:00
Paul HittandClaude Opus 5 c2d0895eb4 Wire group stories and multi-picture stories (§6.24, §6.23)
Both were built and neither was reachable.

§6.24, group stories. The receive side has been complete for a long
time -- `.groupStory` is routed ahead of `.mediaStory` in ingest, with
a comment saying that ordering is what stops a group story being filed
as the author's own -- and `postGroupStory` had no callers, so nothing
could ever produce one. Both composers offer the groups this account is
in as destinations now: the Stories tab's button and the Home strip,
because a feature wired to one of them is a feature half the people
never find.

A group REPLACES the audience rather than filtering it. Running a story
audience over a group's roster would quietly drop the members somebody
excluded from their contact stories, which is not what posting to a
group means, and the composer says so where the audience picker would
otherwise be.

§6.23, a sequence posted as one story. Every piece of this existed and
none of it was connected: `postStorySet` had no callers so a set could
not be made, and `grouped` and `setExpiry` had none outside their own
tests, so a set that arrived from elsewhere was drawn as N separate
stories from the same person, one after another. The model carried
`set` the whole time.

The feed groups through `E2EInbox.grouped` and the card says how many
pictures it stands for. The composer takes up to ten pictures, shows
them in the order they will be seen, and puts the caption on the first
only: a sequence has one thing to say and repeating it under every
picture reads as a stutter.

Ten, not unbounded: each item is sealed, uploaded and sent to every
recipient separately, so a set of forty is forty uploads and forty
fan-outs, and the person waiting has no way to tell it from a stuck app.

921 XCTest and 376 swift-testing, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:21:21 -04:00
Paul HittandClaude Opus 5 dd0b52e5ad Say why forgetProtoPin has no callers
The bug hunt listed it among the dead functions with the note "keep or
document", and it is the one on that list that must not be wired up.

The protocol pin exists so a peer cannot be talked DOWN to an older
version. Anything on a screen that clears it is a button for undoing
that protection, and whoever tapped it would not be the person who
wanted it undone. It is a debugger's hatch and the comment now says so,
along with the condition for actually deleting it: when the protocol
stops having a version to be pinned to, not when a sweep notices it is
unreferenced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:11:29 -04:00
Paul HittandClaude Opus 5 d5c6271f3f Point the obfs4 submodule at a commit that exists on its remote
The iOS pipeline has failed at checkout since 2026-09-18. Not a test,
not a compile: `git submodule update` could not fetch
38a2af32, because four commits in flamenet-elligator2 were
made locally and never pushed. Every run since said

    fatal: remote error: upload-pack: not our ref 38a2af32...

and every run since has been red. Three pipelines, two days, nobody
looked. That is the failure mode PUSHED-IS-NOT-GREEN is about, and the
only reason it surfaced now is that a push today was read by API
instead of assumed.

The four commits are de-dashing: comments, README, NOTICE, the
Dockerfile and the CI file. No logic. Pushed after running the
submodule's own suite -- 68 of 73 pass and the 5 that do not are the
lyrebird interop cases, which need a binary CI supplies and a laptop
does not.

Merged rather than rebased on purpose: the remote had moved on by two
commits, and a rebase would have rewritten 38a2af32 out of existence
and left this pointer dangling all over again. A merge keeps it
reachable, so this bump is only the pointer moving forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:55:46 -04:00
Paul HittandClaude Opus 5 a103e1f55a Remote erase, connected at last (§6.26)
The relay has served a wipe order since it was written and iOS has had
the DeviceWipe type and its validator just as long. Nothing ever
fetched an order or called the validator, and Android had none of it,
so a lost or stolen phone could not be erased although the code to do
it existed on both ends. It was the first finding of the bug hunt,
ordered by consequence to a person holding the phone.

The receiving half: the poll asks the relay once a session whether
this device has been told to erase itself, and feeds whatever comes
back to the validator rather than to the disk. A relay that carries no
orders answers 501 and one too old to know the route answers 404; both
mean "nothing waiting" and neither is worth a word.

The issuing half is on the Devices screen and asks for the passphrase,
because the account key is derived from it and is stored nowhere. That
is the whole security of this: the relay cannot mint an order, and
neither can somebody holding an unlocked phone. A passphrase that does
not derive this account's published address is refused here rather than
leaving an order on the relay that every device would ignore.

The screen shows DeviceWipe.honestDescription rather than a promise. An
erase happens the next time that device connects, and if it never
connects nothing happens at all; the sentence saying so existed with
nowhere to appear.

A cross-platform vector pins the two implementations together: a
signature Kotlin produced verifies here, and the canonical string
"fnwipe:v1:<device>:<seconds>" is asserted as a literal on both sides.
Not a signature comparison -- CryptoKit's Ed25519 signing is randomised
and Kotlin's is not, so the two never sign the same bytes the same way.
That test was written the wrong way round first and said so loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:55:36 -04:00
Paul HittandClaude Opus 5 e5e735f9ff Run the parity scrub in CI instead of weekly
Its own documentation says a check that only runs when remembered stops
running, and this one had: it spent long enough reporting four HIGHs
that were all wrong that reading it was a waste of a minute, which is
exactly the state SCRUB.md warns about two paragraphs further down.

All three repositories are public, so the job clones the two siblings
with no credentials, the same way vectors-head already takes
flamenet-e2e. Shallow, because the scrub reads source and not history.

Verified by running the job's exact command against fresh clones of
published main: 0 findings, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:15:04 -04:00
Paul HittandClaude Opus 5 1462eeca4e The parity scrub was crying wolf four times a run
Its own docstring says a scrub that cries wolf gets ignored, and that
this is worse than no scrub. It reported four HIGHs on every run --
delivery_key, poll, pshare and psum, "iOS has no branch for it, it is
swallowed silently" -- and iOS handles all four.

It knew two of the four shapes iOS uses to decide what an envelope is.
It now knows all four: a comparison on `.kind` or on the control-kind
accessor E2EEnvelopeRoute uses, the same through a decoded dictionary,
membership of a literal list (which is how the poll family is gated),
and the arms of a switch over a kind, bounded so an unrelated string
switch cannot grant parsing.

iOS parses 21 kinds by this reckoning, not 11. The run is clean.

Checked by hand before changing it, rather than assuming the tool was
wrong: E2EEnvelopeRoute routes delivery_key at :138, and
E2EAttachment gates the poll family at :499 and switches on it at :505.

And checked the other way after: renaming each handler in turn brings
its kind straight back as a HIGH, one shape at a time. A scrub that is
quiet because it stopped looking is the failure this was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:13:28 -04:00
Paul HittandClaude Opus 5 66b66d1a29 Count cross-relay sends in the sealing summary
ios#16 puts a sentence on the Privacy screen saying what share of your
messages the server could attribute to you. It is computed from
SealingRecord, and one send path never reached it.

A cross-relay send is always sealed -- transmitRemote seals
unconditionally and says so in a comment -- and it set
lastSendWasSealed instead, a published property nothing had read since
SealingRecord replaced it. SealingRecord's own call site three hundred
lines down is commented "every send's outcome, not just the most recent
one", which is precisely the replacement, and this path was left on the
old one.

So somebody whose contacts are mostly on other relays saw a share
computed from a fraction of what they had sent. One unsealed local send
among forty sealed federated ones reported 100%. The number was wrong
in the direction that would make a person change how they use the app.

lastSendWasSealed goes with it: four writes, no readers, and a fifth
path setting it instead of recording would be this bug again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:56:26 -04:00
Paul HittandClaude Opus 5 ee11ffb613 Show the screenshot notice this app has been sending all along
§6.27. This device posts a notice whenever iOS reports a screenshot in
a conversation that has a timer on it, and has done since the feature
landed -- it is in the build on the App Store. The receiving half put
every notice it was handed into a published array that no view read.

So one iPhone told the other and the other showed nothing. That is
worse than not having the feature: somebody who knows the app has it
reasonably concludes nobody screenshotted them, and the app is the only
thing that could have said otherwise.

There is a banner in the conversation now, scoped to that contact,
showing the most recent notice. It is OBSERVED rather than read through
the environment, because the notice arrives while the thread is already
open, which is the point of it.

And the disclaimer appears. It was written to say exactly what a notice
cannot mean -- another phone photographing the screen, a screen
recorder, a modified app, none of which leave a trace -- and it existed
with nowhere to be shown, which is the sentence that keeps the feature
from being a false promise. A test pins the three cases it names.

Android is a separate question and deliberately untouched: it neither
sends nor shows one, and treats an arriving notice as control traffic,
which is at least consistent with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:42:14 -04:00
Paul HittandClaude Opus 5 53d2cd7db7 A block list that could not be fetched is not an empty block list
The app adds people to the contact list on the user's behalf, and has
to: the 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.

Which makes the block check the only thing between that mechanism and
the app undoing a block by itself. It read the list as
`(try? await api.fetchBlocked()) ?? []`, so a request that failed -- a
timeout, a flaky connection, a 500 -- produced an EMPTY set of blocked
people, every stranger passed the check, and somebody the user had
blocked was added back to their contacts by the app. Silently, and
they would have to notice to undo it.

It gives up on the pass instead. That costs nothing: the whole thing is
idempotent and runs again on the next message, the next tab switch and
the next launch, which the comment two lines above already said about
the other failures in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:28:39 -04:00
Paul HittandClaude Fable 5.1 d52ed34e47 Invite a friend, as a link
The relay has minted invite codes since relay#43; the app had no door to
them. Account now has "Invite a friend": it asks the relay for a code,
shows it large, and shares flamenetmessenger.com/join/<code>, the page
that shows who sent it, the store buttons and the code to type if sign-up
asks for one. A code read down a phone still works; the link is the same
thing with fewer steps.

SiteConfig.inviteURL repairs what a person did to a code on the way
(lowercase, spaces, a lost dash) so the link carries what the relay
hashed. MessengerAPI.mintInvite posts to account/vouch on the relay root
with the app credential; a 429 surfaces as the relay's own sentence.

Five tests: the address, the repair, the refusal of non-codes, the request
shape, and the 429. The project file is generated, so the two new files
are picked up by Scripts/generate.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 11:46:52 -04:00
Paul HittandClaude Opus 5 12e42f1fbc Hold refused envelopes instead of destroying them
When a contact reinstalls, every message they send before the recipient
taps "Accept new keys" was destroyed, silently, on both ends. Observed
live on 2026-09-18: Android reinstalls and registers a new device key,
sends three messages, iOS fetches them, and the fetch acks -- so the
relay marks them delivered and forgets them. Ingest then refuses them on
the gate that says a session may not be built on an identity the person
holding the phone has not accepted, and dropped them. Accepting the
banner brought nothing back because there was nothing left anywhere to
bring. The sender saw three delivered messages; the recipient saw an
empty thread under a security warning; the two never meet.

The gate is right and stays. The discard is what was wrong.

A refused envelope is now set aside as it arrived, still ciphertext,
sealed with the history so it survives the phone being closed -- which
is the ordinary way this happens. Accepting the identity replays the
hold, oldest first, through ingest rather than the de-duplicating
wrapper: the envelope ids were recorded as seen on the way in, so
ingestIfNew would refuse every one and deliver nothing while reporting
success.

Holding costs no secrecy: there is no session until the identity is
accepted, which is the whole point. It is bounded at a hundred per
sender and thirty days, because an unbounded hold is a way for somebody
who will never be accepted to keep writing, and blocking or removing a
contact drops what is held for them rather than keeping a removed
contact's messages.

The banner says what is waiting, which nothing did. An empty thread
under a warning reads as "they have not written".

Not carried by the backup, and it could not be: a held envelope is
addressed to this device's prekeys, which live in the keychain and do
not travel, so on a restored phone it would be undecryptable for ever.
BackupCarriesEveryFieldTests catches exactly that omission and now
records the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:22:07 -04:00
Paul HittandClaude Opus 5 ad445245ed Delete eleven functions nothing calls
Each verified by bare-word grep across Sources, Tests and UITests, not
by a counter: framework delegate callbacks and functions passed by
reference (action: confirmDelete, .onSubmit(addArea)) show zero call
sites and are not dead. PreviewChain.answering looked dead to a grep
for "answering(" and is called with a trailing closure, which is the
kind of thing that makes a scan like this worth checking twice.

  resetConfiguration     SignInView switches servers via applySiteIfChanged
  reportOutgoing         superseded by reportOutgoingStarted / Connected
  clearSendFailure
  resolveIdentity        a client wrapper with no caller
  canSeal / canSealTo    the send path reads store.peerDeliveryKey directly
  isCallSignal
  flamenetSectionLabel   superseded by PortalBand
  settingsRow
  acceptCorrespondent    duplicated by ChatView.acceptBuddy

Removing noteRefusalUnrepaired left unrepairedRefusals incremented by
nobody and noteRefusalRepaired resetting a counter that never moved, so
those went too. That escalation is not lost: AppEnvironment's
noteCredentialRefused counts the same three refusals in a row and runs
the same RelayRepair, and it is the one that is wired.

postStorySet (§6.23) and postGroupStory (§6.24) are unreferenced by the
same measure and are staying. They are implementations of spec features
that were never given a screen, so taking them out drops the feature
rather than tidying up after it; that is a decision to make on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:52:22 -04:00
Paul HittandClaude Opus 5 d7178e81f3 Say out loud when the relay's identity changes
ios#19 pins the key the relay signs with on first contact, so that a
server quietly swapping it is something this device can notice. It did
notice. It set a flag, stored the flag, and showed nobody: no view read
either half of the pair, and acceptRelayIdentityChange, the way back
out, had no callers at all. The one case the pin exists to catch
resolved to a boolean in UserDefaults.

There is a banner now, on Messages and on Home, saying what changed and
what both readings of it mean, with the accept behind a confirmation
that tells you to ask the server's operator on a channel this app does
not carry.

The warning also survives a relaunch. The store always kept it and the
published property was born false, so a warning raised on Monday was off
the screen on Tuesday. The check does raise it again, but only once a
session and only if the descriptor fetch succeeds, so a phone that
starts up offline said nothing about a change it already knew about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:47:39 -04:00
Paul HittandClaude Opus 5 5fd1abf1ff Blocking and removing take the presence token with them
A per-contact presence token is a capability: whoever holds one can ask
the relay whether this account is online, and the relay answers for as
long as the published digest set still carries it. Dropping somebody from
the roster never touched that set, so a blocked or removed contact went
on watching this account come and go, which is the part of a block people
actually notice.

Both paths now forget the pair and republish the set without its digest,
which is the only revocation the design has. Block does it before the
delivery-token revoke, since it needs no network of its own beyond that
publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:28:53 -04:00
Paul HittandClaude Opus 5 47ea8ce652 The call screen's avatar plate is round too
It was the one square left in a composition of two concentric circles
and a row of round controls, so it read as the piece that had not been
finished rather than as the squared house style.

Nothing else on that screen changes. The local video preview stays a
rectangle, because video is one; the plate only ever draws initials, so
there is no photo to re-clip.

856 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:17:41 -04:00
Paul HittandClaude Opus 5 d1c3c72be2 The call controls are circles
The restyle squared 37 shapes and left the call screen's two animated
rings alone, because the kit draws a ring there on purpose. The controls
kept the squared face, so they sat inside those circles looking like
they had come from a different screen. A call's controls are round
everywhere a person has ever answered a phone, and this screen already
had the circles for them to sit in.

Only the controls. The avatar plate in the middle of the rings is still
squared, so it is now the one square left in that composition; that is a
separate call to make and it was not the one asked for.

The rest of the app stays squared: this is the second deliberate
exception, after the message bubbles, and both say so where they are.

856 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:06:05 -04:00
Paul HittandClaude Opus 5 37733bd6ad Reproduce the double call row, and stop a test sharing the app's receiver
The double row is no longer a guess. A call that connected is filed, the
offer for it is seen a second time, and by then it is older than the
caller's own patience, so the stale-offer branch files it as missed.
`ACallIsFiledOnceTests` drives exactly that through `onCallSignal` and
needs no WebRTC to do it: with the merge removed it produces two rows
and the survivor says missed for a call that connected, which is the
screen the owner photographed.

`endedCallIDs` would have dropped the second sight of that offer, but it
is memory only and bounded to 200, so a fresh CallManager -- which is
what a relaunch produces -- starts with none of it. What makes the offer
arrive twice is still not established; what it does when it arrives now
is.

The second case is the one that keeps the fix honest: a stale offer for
a call this device never had is still a missed call, and is still filed.

AColdLaunchPushIsNotDropped flaked because seven suites reach for
`VoIPCallReceiver.shared` and six of them are swift-testing ones that
run in PARALLEL with this XCTest class. They all assign the same `wake`,
and the replay `pendingWake` drives is consumed by whichever assignment
lands first, so a case could watch its own signal be taken by another
suite and fail having done nothing wrong. Raising the timeout would have
hidden that rather than fixed it. `init` is no longer private and the
class owns an instance; nothing here was a singleton by necessity.

856 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:48:26 -04:00
Paul HittandClaude Opus 5 e4b4607304 Hold a story and a conversation to a width you can read across
The design canvas is 375pt wide and nothing stopped a screen using all
1024 of an iPad's. A story became a band the width of the slab with its
caption alone at the far left and the unseen dot a thousand points from
the name; a conversation put one message hard against each edge with a
lake of empty between them, because the thread takes whatever width it
is given and the bubbles align to its sides.

Three screens had noticed and each capped itself at 440 for a form.
`portalReadableColumn` is that, said once, at 620 for content rather
than a form: a ceiling and a centring, so a phone is unaffected because
a phone is narrower than the ceiling.

`testStoriesWithBar` could not have caught this. It builds its own
composition out of a StoryCard, so it renders the same on a phone and
an iPad. The new case renders the feed the app actually shows. A test
inbox cannot be given a story -- `stories` is private(set) -- so the
live view only draws its empty state, and the card is rendered
separately in the column the feed puts it in. Checked at 2048x2732.

The settings and file lists are left alone: a row spanning the width is
what a list does on a tablet, and the two surfaces here are content.

854 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:29:42 -04:00
Paul HittandClaude Opus 5 1042f9e8cf One call is one row in the log
A call answered and talked on appeared twice on the Calls screen, once
received and once missed, on both phones.

Two paths file a call: the call ending, and the missed-call note for a
ring nothing answered. The log had no notion of "the same call" at all
-- rows are keyed on their timestamp -- so nothing stopped both from
running for one call and nothing could tell afterwards that they had.

Entries carry the call id now. A call already filed is not filed again,
and a connected row replaces a missed one for the same call rather than
joining it. The field is optional because rows written before this exist
and an old log still has to decode; two of those are still two calls.

This belongs in the log rather than in the guard that already exists for
it. `endedCallIDs` drops a signal for a call this process has finished,
but it is memory only and bounded to 200, so it forgets across a
relaunch and under churn. The call log is the only thing that remembers
a call afterwards, which makes it the only place the question "have we
already filed this one" can actually be answered.

What this does NOT do is explain why a second file happens at all.
Neither path that files a missed call should run for a call that
connected, and I have not reproduced it. The owner has since said the
two devices are separate accounts, which rules out the multi-device
fan-out I first suspected; the likeliest remaining cause is the offer
envelope being processed a second time after the call is over, across a
relaunch, where the in-memory guard is empty and the stale-offer branch
files a missed call. Not yet proven.

853 unit tests. The new case fails, with two rows, when the merge is
taken out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:19:54 -04:00
Paul HittandClaude Opus 5 a38304090a The last three tabs get the header, and Files gets a way back
Files, Wallet and Account kept the system large title while the other
four moved, which is the rest of "the headers are inconsistent": four
tabs drawn one way and three another. All seven wear the portal header
now. Account says "Account" rather than "Profile and Settings", because
a header that disagrees with the tab you tapped is one you read twice.

Converting Files turned up something worse than a header. It walks into
a folder by appending to its own `breadcrumb` rather than pushing, so
there is no system back button to inherit, and nothing in the app ever
removed from that breadcrumb: no back, no breadcrumb bar, no gesture.
Opening a folder was one way, and the only way out was leaving the tab.

So the header carries a back control when there is somewhere to go
back to, and the trail as its subtitle so you can see where you are.
`testAFolderInFilesCanBeLeft` makes a folder, opens it and comes back
out; it is the case that would have caught this.

850 unit tests, and the header test now walks all seven tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:04:36 -04:00
Paul HittandClaude Opus 5 0fb22fe6a1 Off the Liquid Glass, and two things it was hiding
The sweep. iOS 26 gives every ToolbarItem a shared glass background, a
translucent capsule. On this app's navy bars that is pale blue on blue,
barely there, and a capsule is the one shape this design never draws.
It also takes width, which had pushed a sheet title into "Report me...".

`sharedBackgroundVisibility(.hidden)` is the only way off it and it
lives on ToolbarContent, so it has to be said at every item rather than
once on the view. PortalToolbarItem says it once; 51 items across 27
files now go through it. Checked in both appearances and all three
states: white enabled, red destructive, grey disabled. Delete had to
state its colour on the button rather than around it, because `.tint`
does not beat a foreground style on a plain button and the sweep says
white for the navy bar.

Two more surfaces were the system's, and both were reported as buttons
you could not see:

- `.textFieldStyle(.roundedBorder)`, on six fields, is drawn as glass:
  on a dark page an empty outline with nothing in it. The unlock screens
  on Files and Wallet are mostly that field, which is why they looked
  like a different app. They wear the portal's own chrome now.
- Disabled controls faded to 0.45 everywhere. Against a light page that
  reads as dim; against near-black it reads as absent, so Files and
  Wallet looked like they were missing the Unlock rather than waiting
  for a passphrase. 0.62 in dark, unchanged in light.

And two defects from the phone:

- A call to a contact added a moment ago was logged as "user 123". The
  Calls list looked the name up only in the roster array it was handed,
  which a new contact is not in yet, and fell through to the number
  while the inbox already knew who they were.
- "Network error: cancelled". A cancelled request is not a failed one:
  it is a screen going away mid-fetch or a newer load superseding this
  one. The catch-all turned it into `transport(...)` and the roster
  loader put it on screen in red. Cancellation is reported as
  cancellation now, and a superseded load says nothing.

OffTheRecordTests asked whether the words `offTheRecordPeers` appeared
in the view. They did, in the line that never fired, so it was green
while the echo could not reach the screen. It checks the subscription
now, scoped to the watcher, and fails when that is taken away.

850 unit tests. AColdLaunchPushIsNotDropped flaked once in this batch
and passed alone and on re-run; it is a timing race in a test around a
shared singleton, not a break, and it is worth fixing separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:43:00 -04:00
Paul HittandClaude Opus 5 9e0bbb1e10 The sign-in logo stops being the icon crop, and two lists get room
BrandMark is the square emblem the app icon and the favicons are cut
from, and that crop stops at y=528 because the wordmark's F begins at
530 and the flame passes behind it. There is no empty row between the
emblem and the word, so the rectangle takes a piece of the emblem: the
bottom edge cuts straight across the globe and both speech bubbles. On
an icon that flat edge passes for a base. At 72pt on the sign-in screen
it reads as a logo with its bottom sliced off, because it is one.

BrandLogo is the whole artwork, trimmed to its own alpha bounds rather
than to a rectangle somebody chose, so there is no cut edge in it. It
is generated by make-brand-assets.py beside everything else; a mark made
by hand is a mark that stops matching, which is how the old one got cut
in the first place.

Re-running that generator was a clean no-op for every existing asset in
all three repos, which is what rules out a stale export and puts the
remaining crop where it actually is: the master itself is cut at its
right edge, 44 rows of the flame's outer arc running flat into x=1098.
No export can restore pixels that were never in the file. That one needs
new artwork.

The taller logo exposed a second thing: the Form section was handing the
description two lines and truncating it at "whichever server...", which
is the sentence losing its point. It wraps now.

Also, room between a section band and the first row under it on
Messages, and under the header on the Calls list. Butted together, a
heading read as part of the first row rather than as the label for all
of them.

846 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:06:38 -04:00
Paul HittandClaude Opus 5 2b1ac58de8 0.28.0, build 28
The header work and the rounded bubbles, for archiving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:37:01 -04:00
Paul HittandClaude Opus 5 09c7cfd64f One header on every tab, and a title on the two that had none
Four tab roots wore four different headers. Home and Messages had a
system large title with a search drawer; Calls and Stories had a
`.navigationTitle` that reserved about 120pt of navy and drew no text
in it at all, so each was a tall coloured band with one button
floating in it. That is what "large and inconsistent, some have titles
and searches, others are just solid colours" was describing, and the
Calls screenshot shows it plainly.

PortalHeader is the counterpart of Android's, and the shape the store
screenshots have drawn all along: FLAMENET over the screen's own name,
the subtitle where one belongs, and the actions on the title's row
rather than above it. That last part is the alignment complaint: the
buttons sat on the toolbar row and the title on a line of its own
underneath, so they could not line up.

Search could not simply go with the title. `.searchable` lives in the
navigation bar's drawer, and typing a username into Home's box is how
somebody who is not already a contact is found at all, so
PortalSearchField takes it on the chrome. It carries
`.accessibilityAddTraits(.isSearchField)` because nine live UI suites
find that field with `app.searchFields`, which a plain TextField is
not. Without the trait all nine go red at once.

Two things the header exposed: the story button painted itself
`Theme.primary`, a navy on navy that read as switched off, and the
search prompt defaulted to the secondary LABEL colour, near-black on
the chrome. Both now take the header's white.

FNMSG_TAB opens the app on a given tab, because simctl can install and
launch but cannot tap, and these four screens had to be looked at.

PortalHeaderUITests covers the titles and both search fields. 846 unit
tests green; the new test passes and fails when the trait is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:00:56 -04:00
Paul HittandClaude Opus 5 bab75ad4b0 Three more long blocks, same treatment
The urgent-push rule, the vault key store and the fast-poll loop. Each
keeps the trap and the reason; each loses the retelling of the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:00:36 -04:00
Paul HittandClaude Opus 5 f0db5379b4 State what the code does, not what it was before
Four more long blocks: the file-protection class, the roster-adoption
rule, the navy bar trap and the poll guarantee. Each keeps the reason
and the trap; each drops the account of the bug that produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:59:58 -04:00
Paul HittandClaude Opus 5 7984ff218e Shorten the longest doc blocks, and reattach one that had drifted
Six of the longest comments in Sources were essays: the fact, then the
history of the bug, then a closing line shaped like an aphorism. Each
keeps what it knew and loses the narration. E2EBackupPayload 26 to 18,
RelayRepair 23 to 15, E2EHistoryStore 23 to 15, AppDiagnostics 21 to 13,
the key-set classifier 12 to 7.

One was not only long. `restoreWhenUnlocked`'s doc had come unstuck and
run into `discardUnreadableHistory`'s, so a reader of the second met two
openings that contradicted each other ("the conversation is on screen"
then "stop waiting for a history that will not open") and
`restoreWhenUnlocked` had none at all. Split, and each is on its own
function again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:58:58 -04:00
Paul HittandClaude Opus 5 c58053e812 Two test comments the sweep missed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:47:39 -04:00
Paul HittandClaude Opus 5 6c1296504b No bold in code comments
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:44:47 -04:00
Paul HittandClaude Opus 5 e87fc00c1e De-dash the architecture doc's prose
Diagram labels keep theirs: inside an SVG <text> a dash is typography,
and changing the string shifts a middle-anchored label. Table rows in
BUGS-2026-09-01.md keep theirs for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:43:39 -04:00
Paul HittandClaude Opus 5 c49318a580 Obfs4 scripts and reference generators de-dashed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:43:14 -04:00
Paul HittandClaude Opus 5 4c0d17c400 Obfs4 docs de-dashed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:19:11 -04:00
Paul HittandClaude Opus 5 5faa2985a7 Comments say what and why, not how the code got that way
History of a change belongs in the commit message. These comments
narrated their own corrections instead: "this used to run first", "this
used to live on the Home tab's view model", "which is what this used to
be". A reader without the commit history gets archaeology rather than a
description of the code in front of them, and the style is one of the
tells that gets a public repo dismissed unread.

Each one keeps the reason it carried, stated as the property it is
rather than the change it was. The Obfs4 submodule moves with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:18:17 -04:00
Paul HittandClaude Opus 5 d543f2d731 Make the history-recovery case find the control, and the story case flush
Three things this got wrong, each of which reported a green or a red
that was about the test rather than about the app.

Two elements carry `history.discardUnreadable`: SwiftUI puts the
identifier on the accessibility CONTAINER for the whole banner as well
as on the button inside it. The container is full width, 158pt tall and
reports itself hittable, so tapping "the first match" landed on the
banner's background and nothing happened, which reads exactly like a
dead button. The label matches only the real control.

The confirmation is a SwiftUI `.alert`, which is its own hierarchy, so
`app.buttons["Start fresh"]` found nothing at all.

And the earlier version printed FNMSG_IOS_HISTORY_OK whenever it could
not find the button, which it reported at the same moment a screenshot
showed the banner on screen. Absence is not proof. It checks for the
BANNER first and fails loudly if the banner is up and the way out cannot
be reached.

The story case now backgrounds the app before finishing. The history
write is debounced and XCUITest kills the app the instant a test ends,
so a story posted in the last second of a test could be gone from the
next launch for a reason no person would ever hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:50:13 -04:00
Paul HittandClaude Opus 5 9c6fc59740 Cross-platform cases for calls and groups
Three more, all driving the account already on the simulator:

  * a call placed on the other platform rings this one, and declining it
    clears the screen
  * place a call at the other platform and hold it ringing, so the other
    side can be watched while it does
  * a group made on the other platform, with a message in it, arrives

The ring case needed a settle window and the reason is worth keeping.
`CallManager.finish` tells the caller through a detached, fire-and-forget
`Task`, and the call screen clears from local state the instant the
button is tapped. So the test could finish, and XCUITest kill the app,
before that task reached the relay: the caller then rang forever and it
looked like the product had lost the decline. It had not. With twenty
seconds of life after the tap the `call-end` reaches the other side in
about three. Worth remembering that the send is `try?`, so a real
network failure there strands the caller just as silently.

Both directions now connect: Android answering an iOS call reaches
`ice connection -> CONNECTED` and stays up until the caller ends it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 10:03:39 -04:00
Paul HittandClaude Opus 5 c574d8e19c Cross-platform cases for stories, and the way out of an unopenable history
Three tests, all driving the account already on the simulator rather than
signing up a throwaway one:

  * a story posted on the other platform reaches this one's feed
  * post a story here for the other platform to go and look for
  * discard a history this device can no longer open

Stories fan out per contact like a message does, but they land on a
different screen and are read by different code, so a thread that works
says nothing about whether the feed does.

The first two immediately found something. A story is visible in the
feed of the session that posted or received it, and gone from the next
launch: posted, confirmed on screen, relaunched thirty seconds later,
"No stories yet". Thirty seconds is not expiry, and the default lifetime
is a day, which is what the other platform shows for the same story. The
only place stories are dropped is the `isActive` filter on load, and
expiries are written with `Date()` and judged with
`DeviceClock.expirySeconds()`, which is not the same clock. Not yet
proven to be the cause; the loss is reproducible either way.

android-signin.sh hits trap 2 one field earlier than its own header
describes: filling the handle leaves the keyboard up, the form is inside
an imePadding(), so the scroll viewport ends above the passphrase field,
its label is never laid out, and it is absent from the accessibility
tree. "FAIL no passphrase field" on a screen that plainly has one.
KEYCODE_ESCAPE does not close the IME; KEYCODE_BACK does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:31:50 -04:00
Paul HittandClaude Opus 5 13f1c99fd5 The reply case answers the key-change banner, and dumps the screen when it does not arrive
A peer that reinstalls comes back with a new device key, so the thread
sits behind the safety-number check until somebody answers it. The test
waited ninety seconds at a banner nobody was going to tap and reported
"no reply arrived", which names the wrong thing: the reply had arrived,
the relay had marked it delivered, and the screen was the only place
that said so. It taps the banner the way a person would, and a failure
now prints the screen instead of a verdict.

That turned up something worth knowing. Envelopes collected BEFORE the
banner is accepted do not come back after it: ingest drops them at

    guard recordTrust(userID: fromUser, offered: [aliceIK_sig]).isUsable
    else { return Self.dropped("identity key not accepted for this sender", ...) }

and the fetch that carried them has already acked, so the relay is done
with them too. Three messages sent by a freshly reinstalled Android peer
were gone for good; the next message, sent after acceptance, arrived in
thirty-one seconds. The gate is right, discarding the envelope is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:02:30 -04:00
Paul HittandClaude Opus 5 adb40d45f1 A send test that uses the account already on the simulator
Every other send in CrossPlatformUITests signs up a throwaway handle
first, which proves the relay accepts a brand-new pair and nothing about
an account somebody actually carries. There was no way to drive a real
account from a test, because a real account's passphrase is not going to
be pasted into a test runner.

This one takes no credentials: it launches without -FNMSGResetState,
refuses to continue if that lands on the sign-in screen, and sends from
whatever session is on the device.

It earned its place immediately. Run against fnreviewalpha it failed,
and the screen dump carried the reason: "The server's key log no longer
matches what it published before." Six leaves had been deleted out of
the relay's transparency log, the log shrank, and every client holding a
pin above the new size refused to send to anyone. Nothing reached the
relay at all, so the envelope table looked like a client that had simply
gone quiet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 08:05:37 -04:00
Paul HittandClaude Opus 5 ab9b640622 Correct the evidence cited for the previous commit
The comment said a real iPhone had forty-two envelopes stacked up over
twenty hours while collecting none of them. That was wrong, and I wrote
it into the source and the commit message before checking.

`envelopes` keeps rows after delivery, because this relay does not delete
on ack; they sit until retention sweeps them. I counted the rows without
filtering on `delivered` and read a healthy phone as a stuck one. Split
properly that device was 42 delivered and 0 undelivered, which is a phone
working exactly as it should.

The fix it was offered as evidence for still stands on its own: a history
whose key is gone never opens, the refusal to write over it never lifts,
and before this there was no way out but restarting the phone and hoping.
That is reached from the code and from a simulator genuinely in the state.
The claim about a device in the wild is withdrawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:08:37 -04:00
Paul HittandClaude Opus 5 ddd1a823bf A history that will never open can be let go
Refusing to write over a sealed history that did not open is right, and on
its own it is a trap. If the key is genuinely gone the file never opens,
so the refusal never lifts: the app saves nothing, acknowledges nothing,
and the relay's queue for that device grows without end while the app
looks signed in and perfectly well.

That is not hypothetical. A real iPhone on this account sat with
forty-two envelopes stacked up over twenty hours, polling the whole time,
collecting none of them, while the only advice on screen was that
restarting the phone was "worth trying first". When that does not work
there was nothing else, and nobody is going to guess what happened.

So there is a way to stop waiting, and it is a person's decision rather
than something automatic: the other readings of an unopenable file, a
locked phone or a later build that can open it, both repair themselves,
and only someone who has waited can say it is time. The file is
unopenable by definition, so nothing legible is destroyed.

The test caught me out and is worth reading. It passed with the file
deliberately left behind, because `save()` writes over it and `load()`
reads that back happily, so it proved nothing about the removal. It asks
the question properly now: after letting go, and BEFORE anything is
saved, a store holding the key that COULD open the old file must find
nothing there. That version fails when the removal is taken out, and
passes when it is restored.

376 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:24:09 -04:00
Paul HittandClaude Opus 5 32587da6fe The devices screen stops printing a cap it does not know
It said "N of 3 registered". The cap is the relay's `FN_MAX_DEVICES`, not
the app's: production runs six and a self-hoster picks their own, so an
account at the limit read "6 of 3 registered" on the one screen somebody
goes to when they are being told they have too many devices.

It states the count it actually has. The relay publishes the cap on
/metadata if this ever wants to show it properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:41:20 -04:00
Paul HittandClaude Opus 5 11a1357bc1 Calls and Stories get a header, and four bar bugs you can actually see
Calls and Stories drew their own 24pt title inside the content and had no
NavigationStack at all, so they were the two tabs with no navy header on
them. Both have one now, with their action in the toolbar.

The tab strip ignored the bottom safe area for the whole bar rather than
just its background, so the icons and labels sat down in the home
indicator and in the display's rounded corners. The navy runs to the glass;
the row stays above it.

Two more came from the bar forcing the dark colour scheme so the system
draws light text on it. That flips the environment for everything in the
bar, so appearance-aware tokens answered for the wrong appearance:

  The plus and scan buttons took `Theme.primary`, which resolves dark to
  0x0A5C92, within a shade of the bar's own top stop. They looked switched
  off and were perfectly live.

  The search field took `Theme.surface`, which resolves dark to a near
  black, so the same field was black on Messages and pale blue on Home.

Both state their colours now. The bar is navy in either appearance, so
anything sitting on it has to.

And the search placeholder came up empty until SwiftUI reapplied it a
moment later, which was my own doing: colouring the placeholder through
the appearance proxy sets its TEXT too, and I passed an empty string.

846 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:48:23 -04:00
Paul HittandClaude Opus 5 3bf22f38cd The Messages list gets bands instead of two kinds of heading
One list had two sections headed two different ways: Chats as a small
grey uppercase label, Groups as a `.headline`. Both are the portal's
ruled band now, with New chat and New group sitting on the band itself,
which is where Android already put them.

`SectionHeading` draws the band and takes the trailing action, so the
HStack-with-a-Spacer dance at each call site is gone.

846 unit tests green, and the shot run walks all seven tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:15:57 -04:00
Paul HittandClaude Opus 5 3ce7b78aaa The search field stops being navy on navy, and Stories loses its last ring
The `.searchable` placeholder was drawing its light-mode grey on the navy
bar, which read as an empty control rather than a prompt. An appearance
proxy does not reach SwiftUI's search field in any containment spelling,
so the screens that have one state the bar themselves.

Asking for the dark toolbar scheme alone made it worse and was backed
out first: the bar then dropped the background image the global
appearance gives it, went transparent, and left a white title on the pale
page ground. The background has to be supplied in the same breath, which
is what the conversation's maroon bar was already doing and why it never
hit this.

The Stories empty state kept `circle.dashed` after the tab icon moved off
it. Both are `photo.stack` now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:42:48 -04:00
Paul HittandClaude Opus 5 c62bc453ce Clear what the attention tests write, and say what is still unexplained
`testNothingIsSnoozedByDefault` failed once on CI (pipeline 2859118649)
while the whole 846 test suite passes locally. The suite isolated itself
with a random user id out of 100_000 values, and
`testSnoozeIsPerConversation` leaves a snooze for conversation 42 behind,
so that isolation was odds rather than isolation. Clearing the rows this
suite writes, before and after each test, costs nothing and removes the
question.

It does NOT explain the failure, and the comment says so rather than
implying a fix. Pinning the id and running the suite twice in a row
passes both times, so rows are not surviving between runs and a collision
cannot be the cause. The failing job also logged a 600 second timeout
collecting diagnostics from the simulator, and reproducing this locally
crashed the test host before it finished bootstrapping. That points at
the runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:32:13 -04:00
Paul HittandClaude Opus 5 59c78ae0e6 The portal identity reaches the screens, and a release asset stops lying
The iOS half of the 0.27 restyle, matching the Android commit.

PortalChrome.swift paints the system bars once at launch rather than per
screen: applied per screen it would be right on the ones somebody
remembered and stock on the rest, and the ones nobody remembers are
sheets and pushed views. iOS keeps NavigationStack rather than growing a
hand drawn header, because the bar carries large titles, searchable,
toolbars and swipe back, and replacing it would cost all of that to gain
a gradient.

The tab bar is drawn here, so it is ours: a full width navy strip ruled
along its top, the tab you are on marked in gold. It was a floating pill
inset by a margin, which is the one shape this design does not have.

Both button styles forced full width, so every control that sits beside
another control stayed on the system style. There are inline styles now,
and the 27 call sites on .bordered and .borderedProminent take them. The
segmented pickers become portal chips; a grey capsule with a sliding
white thumb is unmistakably stock iOS and it sat in the middle of the
settings screen contradicting everything around it.

37 round shapes are squared. The two the call screen animates are left
alone, because the kit draws a ring there on purpose.

Three defects found by looking at renders rather than at code:

  BubbleShape defaulted to a 16pt radius, which kept a conversation
  reading as a stock messenger however the colours moved.

  The conversation bar runs toolbarColorScheme(.dark) so the system
  draws light text on the maroon. That flips the environment for
  everything inside it, so an appearance aware token answered with its
  dark value and the avatar tile came out navy on a maroon bar. What
  sits on that bar is stated now, not derived.

  The tab cell draws its title as well as its icon, and a visible Text
  beside an accessibilityLabel stops the cell being one element carrying
  that label. VoiceOver read the badge and the name as separate stops and
  buttons["Home"] stopped resolving.

Scripts/conversation-shot.sh could hand back last month's screenshot.
It never cleared build/conversation-shot/attachments, xcresulttool will
not export into a directory that already holds something, and the
refusal went to /dev/null. The extractor then found a conversation
attachment in the STALE manifest, copied it, printed a size and exited 0.
That is the picture on the front page of the website. store-shots.sh
already cleared its directory; this one never did.

AppStoreShotsUITests can capture on a throwaway account now. The release
set still wants a real handle, but looking at the screens is the commoner
reason to run it, and needing somebody's credentials meant that during a
restyle the only screen anybody saw was the sign-in screen, which is the
one screen the design work does not touch.

846 unit tests green, which is the baseline exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:24:16 -04:00
Paul HittandClaude Opus 4.8 78b1fbe2e1 Back out the phase-3 openChat rewrite; it regressed phase 1
The 30s wait for a recent-strip button that does not exist during
first-contact turned phase 1 into a dead wait and destabilized it, so the
gate got worse, not better. Reverted to the openChat that clears phases 1
and 2 and stops at the phase-3 composer. The per-side result bundle stays,
it fixed a real xcresult-collision crash.

Honest state: the gate runs the whole sign-out and sign-in sequence and
fails only re-opening the chat afterward, which is post-sign-in restore
timing in the harness, not an app defect. Messaging is proven green by
MessagingFeaturesUITests. Left here deliberately rather than chased
further.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 13:59:19 -04:00
Paul HittandClaude Opus 4.8 decb5e2b4e The gate reaches its own assertion: phase-3 opens the restored chat
Two more restorations, both the same shape as the rest, the app moved and
the harness did not:

- Phase 3 re-opens the chat with a contact whose buddy list was just
  restored from the relay after signing back in. It is a `recent.<id>`
  button in the Home strip now; the bare name label is not the hittable
  element, and the strip item can report "not hittable" while present, so
  tap the button, coordinate-tap if the frame says otherwise, and wait
  for the buddy list to repopulate first.
- Each simulator writes its own result bundle. Both sides shared one
  derived-data path, so their xcresult writes collided and crashed one
  side with a CASTree fatal that had nothing to do with the test.

With the earlier five, the gate now builds, signs up, crosses a message,
reaches Account, signs out and back in, and re-opens the chat to run the
assertion it exists for: that a plain sign-out and sign-in is not seen as
a key change. Remaining flake is the post-sign-in restore timing on the
phone that only sleeps, not an app defect; the app's messaging is proven
green by MessagingFeaturesUITests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 13:50:57 -04:00
Paul HittandClaude Opus 4.8 7b31ab0f66 0.26.0, build 26
0.25.0 build 25 is on TestFlight, so the build number goes up, as the
rule in project.yml says: it tracks the release it belongs to and is
bumped once the previous one is uploaded, not when the next is archived.
Builds clean for the simulator at the new version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 13:16:17 -04:00
Paul HittandClaude Opus 4.8 d478328450 The lifecycle gate runs again, through five broken steps
The gate has been unrunnable since 2026-09-05. It exercises a real
sequence, two simulators, sign out, sign back in, and prove a contact was
never asked to accept new keys, and every step it touched had rotted with
the app underneath it:

- It signs up its own throwaway accounts, so it needs
  TEST_RUNNER_FN_ALLOW_SIGNUP=1 (ios#62). It never set it and timed out on
  refused sign-ups, the same break rounds.sh had.
- The two gate simulators are reused every run and carried the previous
  run's signed-in account, so the sign-up screen never appeared. The one
  initial launch resets state now; the in-session sign-out/sign-in it
  checks is unaffected.
- openAccountTab is shared with the UI suites and now pops out of a chat
  first, because the floating tab bar is hidden inside a pushed detail.
- The sign-out confirm is a confirmationDialog button that matches twice
  on this runtime; .firstMatch.

With these the gate now builds, signs up, crosses a message in phase 1,
reaches Account, signs out, signs back in, and re-opens the chat. It
still fails one step short: openChat taps the peer by name, which since
the Home redesign also appears in the story strip, so the re-opened
thread has no composer. That is the last choreography fix, and it is
test navigation, not an app defect. The app's own messaging is proven by
MessagingFeaturesUITests, which passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-17 13:12:04 -04:00
Paul HittandClaude Fable 5.1 3809eed386 The UI suites reach the Account tab, and rows are scrolled clear of the bar
Three Account cases failed on every run and a fourth with the identical
preamble passed: a tap on the bar in the second after sign-up is dropped
while the signed-in view settles, and the tab stays on Home. The tests
now go through openAccountTab, which taps and waits for the tab's own
bar, "Profile and Settings", and taps again if it is not there.

scrollUntilExists returned the moment a row existed, which on a lazy list
is the bottom edge, where the floating tab bar sits; a tap there landed on
the bar and "Chat Settings" never pushed. It keeps nudging until the row
is hittable.

rounds.sh passes TEST_RUNNER_FN_ALLOW_SIGNUP=1. Since ios#62 a UI test
refuses to create an account unless told it may, and every case in the
suites rounds.sh runs signs up a throwaway handle; without the flag the
script failed every case in five seconds, from 2026-09-05 until today.

Against relay1 on an iPhone 17 Pro simulator: SignInFlow, MessagingFeatures,
AccountFunctions, AppSurface and LiveSignUp, 32 passed, 0 failed, 2 skipped
by design (the live sign-ups on the managed relay opt in by touching
/tmp/fn-live-signup).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 08:37:36 -04:00
Paul HittandClaude Fable 5.1 6470999b3e The Mac gate runs again: the selected Xcode, and the e2e pin catches up
Every pipeline since 2026-09-15 21:39 failed in three seconds. The job
exported a hardcoded /Applications/Xcode-beta.app, the beta had just
become the release and the path went with it, and because git on a Mac
is an xcrun shim the job died on its first git command. The lifecycle
gate defaulted to the same path. Both now take the selected Xcode, and
the job prints the version so the log says what built.

Behind that, the vector gate would have failed next: Tests/Vectors was
refreshed to flamenet-e2e HEAD today while .e2e-ref still named a commit
from before the backup payload widened. The pin moves to that HEAD; the
copies already match it, checked file by file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 07:38:03 -04:00
Paul HittandClaude Fable 5.1 d4233adc25 The secret sweep runs in CI, on every merge request and push to main
It ran by hand until now, and a by-hand gate is one that rots: the e2e
copy sat red for nine days after a formatter moved a marker, and nothing
said so. The job is the same script the release runbook already names,
called from the repository root, and it takes seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 07:26:45 -04:00
Paul HittandClaude Fable 5.1 dc3901da15 A device the relay did not register uploads no prekeys
Registration was tried three times under try?, and when all three failed
the code read the relay's prekey count as ?? 0: "I do not know how many it
holds" became "it holds none", the low-pool guard passed, the local pool
was refilled to a hundred, and all hundred went up for a device the relay
had no row for. Every failed registration anywhere in the fleet left a
hundred orphan prekeys behind. The relay's orphan count grew from 43 to 53
in a day, about 5,300 dead prekeys, and nothing on either side said so.

No confirmed registration, no upload. The stub relay learned to refuse a
registration and to count prekey uploads, so the test can prove there were
none. Falsified by removing the guard: exactly one upload, and the test
fails naming it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 06:44:22 -04:00
Paul HittandClaude Fable 5.1 939018cf9f Off-the-record drafts and pins stay off disk, and the two-paths guard grows with the code
`persistable` kept off-the-record THREADS out of the sealed file, and
nothing kept out the draft typed in one or the pin set in one. Both are
separate dictionaries keyed by conversation, passed to every save raw, so
a half-typed message in an off-the-record chat sat on disk under a key
naming the person. The Android engine had the pin half of the same fault,
fixed in the same session. One keyed filter, applied at all three saves.

The guard that checks the debounced write and the full write carry the
same fields read a fixed 2200-character window and `polls:` had moved to
+2213. Worse, the three fields added today sat at +2715 and beyond, so the
guard had silently stopped checking them. It is bounded by the code now,
the debounced block up to `flushHistory` and that function to its own
end, and it checks fourteen fields across both paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 06:20:05 -04:00
Paul HittandClaude Fable 5.1 b5be706157 A sign-out that could not revoke its device says so, and finishes the job next time
`retireThisDevice` swallowed the revoke and then logged "retired device"
at info level regardless, so a failed sign-out read as a clean one in the
one place anyone would look. What it leaves behind is a dead device row
the relay goes on fanning envelopes out to and every sender goes on
spending a prekey for. One was found this morning with 42 undelivered.

The outcome is logged as what it was. On failure the device id is kept
for the next sign-in to the same account, and kept in the standard
defaults rather than the per-user store, because sign-out wipes that
store three lines after the retirement returns and a note left there
would not survive to be read. `make` retries it off the launch path and
clears the note only when the relay accepts.

`setBlocked` had the same swallow with a reasoned comment: the block must
not fail because the revoke did. Kept, and no longer silent: a revoke
that fails leaves the blocked person able to deliver sealed, which is the
opposite of what the screen now says, so it is logged and shown.

The stub relay learned to answer a DELETE with the shape the client
decodes. Its first answer did not, the client threw on a 200, the app
correctly kept its note, and the test blamed the app for the harness.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 06:19:58 -04:00
Paul HittandClaude Fable 5.1 28c4fb0a37 The refusal notice replays too, so no closure on the receiver is the odd one out
`callRefused` was added earlier tonight as a plain optional, the shape the
three cold-launch closures had just been found to be wrong in. The log
line is unconditional, so a refusal was never lost entirely; only its
on-screen notice was, on exactly the launch a push causes, before
`AppEnvironment` had wired anything.

It gets the same `pending` plus `didSet` replay as the rest. Small on its
own; the reason to do it now is that an odd one out in a file is the
version that gets copied next.

Falsified by restoring the old drop-when-nil: the new test fails alone and
the other five stay green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:47:07 -04:00
Paul HittandClaude Fable 5.1 67fa3c9afc A cold-launch push, and a lock-screen Answer, wait for the wiring instead of being dropped
`VoIPCallReceiver.shared.start()` runs at app init, so on a cold launch
PushKit delivers the pending push before `AppEnvironment` has assigned
`wake`, `endCall` and `answerCall`. All three were plain optionals called
with `?()`, which on exactly the launch a push causes made them no-ops.
The call was reported and rang, nothing fetched the offer behind it, and
a push that was really the END of a call was never taken back. Tapping
Answer on that lock screen fulfilled the action and accepted nothing: the
ring stopped, and the caller heard a phone picked up and then silence.
That is the 09-13 "lock-screen answering" item.

The token upload had this exact race and a `pending` plus `didSet` replay.
The other three get the same: a request made before the closure exists is
kept and run the moment it is assigned, and a served request does not run
again on re-assignment. Assignment order is wake, end, answer, so by the
time a pending answer replays the offer has been fetched.

The first version of the test could not tell this fix from the fault. It
assigned `wake` on the same actor turn as the request, so the old
fire-and-forget task was still queued, found the new closure, and passed
with the bug in place. It yields first now, so a dropped request is
dropped, and the falsification fails on that one test and no other.

One source-reading test moved with the code it reads: the answer-first
ordering it guards now lives in `requestAnswer()`, and it is asserted
there, with a new check that the handler still reaches it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:30:17 -04:00
Paul HittandClaude Fable 5.1 f5e3c982b7 A CallKit refusal says why, instead of looking like a call that rang
When `reportNewIncomingCall` failed, the app cleared the call id and said
nothing: no log line, nothing on screen. A call filtered by a Focus mode
was then indistinguishable, on the phone and in every log, from one that
had rung. Tonight's report was exactly that shape, "nothing on the lock
screen, works once the app is open", and the relay could prove the push
was accepted by Apple and fetched by the phone eight seconds later, and
could prove nothing after that, because the phone kept the reason to
itself.

The refusal is logged at error level with CallKit's code, mapped to words
a person can act on, and shown on the app's own error line. The two
filters matter most: a Focus mode or a block list means the phone was told
not to ring and nothing in the app is at fault, which is the difference
between a bug report and a settings change. An unknown code still carries
its number so it can be looked up.

This does not fix a lock-screen call that should have rung and did not.
It makes the next one diagnosable from the phone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:10:18 -04:00
Paul HittandClaude Fable 5.1 3a75d8f31d Unread badges survive a restart, in both directions
`unreadPeers` is the Home badge and every per-row count, and it lived in
memory. Kill the app with three unread conversations and every badge was
gone at relaunch. Sixth feature to reset itself this way.

Two halves, and the second is the one the debounced writer could never
have caught. Receipt happened to be sealed, because delivering also
appends to `threads`, which the writer watches. The CLEAR touched nothing
it watches, so a thread read and then the app killed had its badge back
the next morning. `markThreadRead` flushes on change now, only on change,
since it runs on every view of a thread and sealing re-encrypts the whole
history.

Not carried by the export, on purpose and with the reason in the guard's
exemption list: a new phone has shown nothing yet, so every restored
thread is genuinely unread there, and a count from the old phone would
understate it.

Tested through the real receive path, a sealed envelope from a second
account rather than a poke at the dictionary, because the increment lives
inside `deliver`. Falsified both ways: delete the restore and the restart
assertion fails along with the read-direction guard naming the field;
delete the flush and the clear assertion fails alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:10:12 -04:00
Paul HittandClaude Fable 5.1 97316bcc86 Muted stories stay muted, and the guard now checks the restore direction
Muting a contact's stories (ios#33) reset at every launch. Not because it
was never saved: setStoryMuted flushed on every change and the set was on
disk the whole time. The load path never read it back. A field written
and not restored is lost exactly as thoroughly as one never written.

That is the fifth feature to reset itself, and the guard added for the
first four could not see this one. It proved every Snapshot field reaches
save; it never asked whether anything reads it at launch. It does now,
from the name the load path binds the snapshot to, with the keychain-
backed fields exempt because the inbox reads those from the store and
restoring the snapshot's copy over the keychain would be the wrong
direction.

Falsified by deleting the restore line: the behaviour test fails, the
write-direction guard correctly stays green, and the read-direction guard
fails naming mutedStoryAuthors. Restore verified by diff.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:38:11 -04:00
Paul HittandClaude Opus 5 f433d293af Refresh the backup payload vector: reStory is canonical now
The other engine was not persisting whether a reply was about a story. It
parsed the field, unit tested it, and read it nowhere, so a story reply
arriving there quoted a `mid` that matched no message: a story key is not
a message id. This app has carried `reStory` all along, which is why the
divergence listed it as ours alone.

Both sides carry it now, so it moves into canonical where a byte-exact
round trip is the bar, and the divergence entry splits: `mentionsMe` is
still genuinely one-sided and keeps its own entry.

A copy, per Tests/Vectors/PROVENANCE.md, byte-identical to the authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:31:44 -04:00
Paul HittandClaude Opus 5 fd4b90d675 Refresh the backup payload vector: the watched mark is canonical now
This app had the unread mark on a story and wrote it nowhere, so every
ring relit at each launch and a backup taken here lost which stories had
been watched. The other engine had been writing it all along and its
comment said the field was ours alone, which is why nothing caught it.

Both write it now, so `stories[].seen` moves out of the divergence list
and into the canonical set, where a byte-exact round trip is the bar.

A copy, per Tests/Vectors/PROVENANCE.md, byte-identical to the authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:20:04 -04:00
Paul HittandClaude Opus 5 2c120127b0 Watched stories stay watched, and a guard so the next field cannot be missed
The mark saying a story had been read existed and was never written down.
`seenStoryIDs` was set by `markStorySeen` and read by `hasUnseenStories`,
and the ring around every contact's picture relit at each launch.

That is the fourth feature to ship this way, and the four are one bug. The
only automatic save path is

    Publishers.CombineLatest3($threads, $groupThreads, $callLog)

so anything added since inherits the assumption that saving is handled. It
is not: everything else is in memory until `flushHistory` is called by
hand. Story reactions, conversations on other relays, these marks, and
most of the encrypted export all missed it, and each was reported as its
own small annoyance.

So the fix is two parts. The marks are sealed and exported, per story in
the shared shape the way the other engine already writes them, gathered
back into a set on the way in. Writing happens only when the mark actually
changed: paging a strip calls this per story and sealing re-encrypts the
whole history, so an unconditional flush would re-seal everything on a
swipe.

And `TheSealedStoreCarriesEveryFieldTests` reads the Snapshot declaration
and fails when a stored property is not named by BOTH the store's `save`
and the inbox that calls it. Both halves, because a parameter nobody
passes defaults to empty and writes the field away on the next message.
Falsified by adding an unwired field, which is exactly how the four
arrived; the message tells the author what to wire up. `version` and
`outbox` are exempt with reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:15:18 -04:00
Paul HittandClaude Opus 5 018407a7c4 Federated conversations survive a restart, and are in the backup
`remoteThreads` held every conversation with somebody on another relay in
a published dictionary with no write to the sealed store and no read back.
They were gone at the next launch. Nothing said so, because an empty
thread looks exactly like somebody who has not written to you.

The vector's divergence list recorded this as a field only the Android
engine kept, which understated it: this app kept them nowhere at all. They
now go into the sealed snapshot and the encrypted export under
`addrThreads`, the name the other engine has used all along, so a restore
brings them back and a move between phones carries them.

The tests are worth a word, because the first set was useless. Three of
them encode a Snapshot and decode it, and they passed with the fix removed
and would have passed every day the bug existed: the bug was never in the
codec. Two more drive a real inbox, flush, and read the file back the way
a launch does, and those fail when the restore line goes. The codec suite
now says in its own header that it is not sufficient.

`appendToRemoteThread` is reachable from tests for that reason, stated at
the definition rather than left looking incidental.

Those two live in XCTest with every other inbox test, not in a parallel
Testing suite. And their setup purges the history FILE as well as wiping
the key: a wipe deliberately leaves the file, the next launch finds one it
cannot decrypt and refuses every save after that, which is correct and is
why the tests passed once and failed every run after. Both real sign-out
paths pair the two calls; so do these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:27:28 -04:00
Paul HittandClaude Opus 5 9839e10fc9 A story is identified by its key, never by this device's row number
This app numbers a story locally in `id` and keeps the identity that means
something on another device in `key`. The Android engine put that identity
straight in `id`. One name, two meanings, so a story travelling between
the two phones came back unrecognisable and the import filed it as a
second copy of itself.

`key` is written always now, falling back to the post time rather than
letting a story travel with no way to recognise it, and `id` is not
written at all. A local handle has no business in a shared shape.

On the way in, the local number is minted here rather than taken from the
file, and `id` is accepted as the key only when it is a string: a numeric
one is the writing device's row handle and names nothing here.

That was the last divergence holding stories out of the vector's canonical
set, which is eighteen keys now and covers both `stories` and
`storyArchive`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:49:00 -04:00
Paul HittandClaude Opus 5 4361b1f9ee Refresh the backup payload vector: canonical is sixteen keys now
flamenet-e2e widened the canonical set from eight keys to sixteen. The
story audience, muted authors, story reactions, drafts, pins, polls and
both 8.9c presence token maps are fields this app already reads and
writes, so they now have to survive a byte-exact round trip rather than
sitting outside the comparison.

That is where the drift was. The audience mode had become an enum name on
Android and lower case here, and because the field was outside canonical
nothing compared them: an audience crossing between the two phones was
dropped in silence and restored as EVERYONE, which shows stories to the
people somebody had left out. Inside canonical it is a failing test.

A copy, per Tests/Vectors/PROVENANCE.md, byte-identical to the authority.
A vector moving is a protocol change and this app is one of the two ends
that has to agree about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:41:20 -04:00
Paul HittandClaude Opus 5 e8509ecfff Polls ride the backup, and an unreadable audience fails narrow
Two holes in the export, both found by writing SPEC 16.3 down and then
checking the code against it.

A poll is counted only once every member has voted and published, so a
restore without the pieces already gathered leaves a question nobody can
finish answering. Polls travel now, under the field names the Android
engine archives under, so one in flight survives a move between the two
phones and not only a restore onto the same kind. An entry missing its
member set or its options is dropped rather than half restored: the member
set fixes which index each member's piece sits at, and Android drops the
same entries for the same reason.

The audience read demanded an exact lower case mode and skipped the whole
block when it did not parse. Skipping it restores an account on EVERYONE,
which shows stories to exactly the people somebody had left out, with
nothing on screen to say it happened. The case is normalised now, and a
mode that still will not parse falls to the narrow reading rather than the
wide one. The Android engine keeps this as an enum name and had shipped it
upper case, so this was not hypothetical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:28:52 -04:00
Paul HittandClaude Opus 5 7eaab9be85 Stories reach your own devices, and removing a contact is findable and asked about
Three things, all reported from a real session.

A story went to its audience and was then saved locally. Nothing reached
this account's other devices, so a story posted on the phone did not exist
on the tablet at all. Reactions DO go to every device the author has, so
the tablet received all of them and had no story to attach them to. The
person saw a story drawing no response, and the only way out they found
was to stop using the second device. Each posted story is now mirrored to
this account as well, carrying the same story key, best effort and outside
the audience count. On the way in, a story from this account is filed as
ours only when the sender is proven, or an unproven envelope claiming our
id could put a stranger's story on our own strip.

Removing a contact existed only as a long press on a 56pt circle in Home's
recents, with no confirmation and nothing saying it differs from blocking.
It is on the Messages rows now, which is the list people actually scroll,
and both surfaces ask first in the words Android already uses. The rule
itself moved into ContactRemoval, because three callers were restating it
and the /remove command had drifted: off the server it asked the relay who
was on a list the relay no longer keeps, and reported removals it had not
made.

The encrypted export carried threads, groups, calls, stories and the
roster, and silently dropped everything else the sealed store holds:
story reactions, the presence tokens, drafts, pins, polls, group names and
secrets, muted authors and the story archive. Restoring on a new phone
lost the lot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:03:47 -04:00
Paul HittandClaude Opus 5 0a0645cd92 Story reactions survive a restart, and a stale token stops reporting people offline
Two faults from tonight's reports.

A contact showed offline while online. 8.9c gives two ways to be granted a view,
the relay's contact graph and holding a token, and while both exist they can
disagree: a token that has gone stale answers offline for somebody the graph
says is online, and yesterday's merge let the token win outright. It takes the
more visible answer now, because either grant is a grant and a stale token is
not somebody going offline. Once the relay keeps no graph the token stands
alone, including when it says offline, which is revocation working rather than
a disagreement.

Story reactions were never persisted, on either platform. They went into a
dictionary in memory and were gone at the next launch, so an author watched a
tally empty itself overnight and a reactor watched their own reaction vanish,
neither of which is anything the other person did. They are in the sealed
snapshot now and carried by the encrypted export, which the backup test caught
me leaving out, exactly as it exists to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:31:35 -04:00
Paul HittandClaude Opus 5 d8cca17cd1 Believe the relay about its own roster, including an empty one
Yesterday's guard refused every empty list, because an empty list could mean
the relay had stopped keeping the contact graph. It could also mean the account
had no contacts, and refusing both left somebody who removed their last contact
still seeing them on every other device.

The relay says which now, so the rule gets simpler rather than more clever: a
relay that keeps no graph is not a source of one and nothing from it is
adopted; a relay that does keep it is the source and its answer stands, empty
included.

fetchBuddies keeps its shape for callers that only want the list. fetchRoster
is the one that also says who is holding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:15:25 -04:00
Paul HittandClaude Opus 5 36483f44c3 The roster guard is a decision that can be run, not grepped
I had proved this by reading the source for the guard, which is not proof of
anything for a rule whose failure mode is somebody's contact list. shouldAdopt
is its own function now and the test asks it: empty never replaces held, empty
onto empty is allowed, and anything non-empty always lands. That last case
matters because a guard that refused everything would pass the first two and
break the app.

Written this way because the end-to-end version cannot safely be run. Verifying
it against production means setting FN_ROSTER_ON_SERVER=false there, and twelve
of thirteen devices polling that relay are on builds without this guard, so
they would take the empty list literally and overwrite their stored rosters.
That is up to five accounts' contacts for the length of a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:45:34 -04:00
Paul HittandClaude Opus 5 95f3618938 Report the real app version to the relay
The User-Agent said 1.0 and had said 1.0 since it was written, so a relay
asking "is every client new enough yet" got the same answer for ever. It sends
CFBundleShortVersionString now, the marketing version only, with no build
number, device model or OS version alongside it.

This is what the relay needs before the contact graph can be removed: an older
client reads its contact list from the server, and taking that away while one
is still polling empties it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:14:13 -04:00
Paul HittandClaude Opus 5 c1c7312f23 An empty roster from the relay never erases the one held
8.9c ends with the relay keeping no contact graph, and a relay that has stopped
answers the buddies route with an empty list. Taken literally that erases
somebody's contacts on the first poll after an operator throws that switch, and
on any transient fault that answers empty.

adoptRoster now refuses an empty list over a held one, and both screens fall
back to the sealed roster rather than showing the empty answer. Guarding the
store alone would have left a screen that reads as having lost your contacts
while the data sat intact underneath.

Empty onto empty is still allowed, or a new account could never take its first
contact from the server.

One source-reading test re-anchored: it pinned the line that assigned the fetch
result, and the result goes to a local now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:04:33 -04:00
Paul HittandClaude Opus 5 ec214ba875 The review notes name accounts that exist
This file handed Apple flamenetreview1 and flamenetreview2. Neither is an
account on the relay, and the users table says the only review accounts are
fnreviewalpha and fnreviewbeta, which are also the pair verified end to end
against the live accounts service on 2026-09-04. A reviewer typing a handle
that does not exist cannot get past the sign-in screen, which is the same
failure that had 0.4.0 rejected under Guideline 2.1.

The walkthrough was wrong in a second way. It sent the reviewer to a Community
tab to add the other account. There is no Community tab, and has not been: the
tabs are Home, Messages, Calls, Stories, Files, Wallet and Account, and adding
somebody happens from the field at the top of Home. The two accounts are
already contacts, so the step is not needed at all and the walkthrough is four
steps now rather than five.

Two checklist lines added. One for the Sign-In Information field, which is the
one thing a reviewer cannot work around. One for re-running generate.sh after a
version bump, because the version lives in project.yml and the Xcode project is
generated from it, so a bump alone archives the old number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:39:37 -04:00
Paul HittandClaude Opus 5 4f89b74f76 0.25.0
Build 25, matching the version as every release since 13 has.

Carries 8.9c, the envelope routing rework, and the group story fix, none of
which are in the 0.24.0 archive now with Apple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:30:39 -04:00
Paul HittandClaude Opus 5 9681b9767d The roster routes carry the credential they actually accept, and retry
Two faults, both found by running it rather than by any test, and both of a
kind a test could not have caught without the live pairing.

The routes were sent with the e2e bearer. authorize prefers that whenever a
relay session exists, which is right for /e2e/* and wrong for these two: they
live on the mobile surface, whose caller reads the Basic header and nothing
else. The relay refused both as credential_rejected. My comment in the first
commit claimed authorize picks whichever the deployment accepts; it picks
whichever the e2e surface accepts, which is a different sentence.

Publishing was attempted exactly once, at the moment a token was minted, and
the refusal was swallowed because it is best effort. So the token was stored,
nothing asked again, and that contact would never have seen a presence. The set
is now compared against what the relay last ACCEPTED and republished until it
agrees. The route replaces rather than merges, which is what makes repeating it
free.

Verified on the live relay: a digest appears for the sending account, both
directions publish their own, and with the buddy row deleted the contact still
reads online, so the token is what answered rather than the graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:18:25 -04:00
Paul HittandClaude Opus 5 2689ad58d6 Presence without handing the relay a contact graph
Spec 8.9c, the client half. Each contact gets a random 32-byte token, sent over
the encrypted channel on the delivery-key envelope, which is already the
per-contact one. The relay is given only SHA-256 of it. Presenting the token is
what reads a presence, so the server no longer needs to hold who knows whom.

Per pair rather than one shared secret, which is what makes taking somebody off
the list cost the others nothing. That is the difference from the sealed-sender
delivery key, where cutting one person off replaces a key everybody holds.

Where a token is held its answer wins; a contact with no token yet keeps
whatever the roster said, so a half-migrated pair does not read as offline
while both ends catch up.

Tokens live in the sealed snapshot and are carried by the encrypted export. The
backup test caught that they were not, which is exactly the hole it exists for:
a new phone without them would have lost every contact's presence and minted
fresh tokens nobody holds.

The field is absent rather than empty when no token has been issued, so an
older build parsing this envelope never sees something it would treat as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:28:42 -04:00
Paul HittandClaude Opus 5 794b281c87 ios#11: which branch consumes an envelope is data now
deliver() was twenty sequential if branches whose correctness depended entirely
on their order, with nothing enforcing or recording that order except the
comments between them. Both of the silent message-loss bugs that file produced
in one day were ordering bugs rather than logic bugs: a control-traffic guard
before appendToThread ate every text message, and the same guard one branch too
early ate every incoming call.

Not a rewrite, which is what the issue asked for. E2EEnvelopeRoute.Order.all is
the sequence, written down once, and route() is a pure function of an envelope:
no inbox, no network, no handler runs. deliver() switches on the result, so a
new kind is a compiler error until it is handled, and every handler body is the
one that was there.

THE BUG THIS FOUND

A group story carries an attachment of kind .story AND a gid. The top-level
story branch came first and took every one of them, filing it as the author's
own story with no group on it. The 6.24 branch that handles group stories sat
below, unreachable, looking like it worked. GroupStoryTests never caught it
because it builds an E2EStory directly and never goes through deliver.

groupStory now sits above mediaStory, and a test asserts that ordering rather
than the outcome, because the outcome is what stops being checkable the moment
somebody moves a line. Putting the old order back fails three tests, which is
how I know they would have caught it.

Two source-reading tests were anchored on the ifs and now anchor on the case
labels. They were reporting the refactor as a lost feature.

Also, a username somebody already has says so in the same words on both kinds
of server. The shared account service's refusal said "handle", which is not the
word on the field somebody just typed into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:16:15 -04:00
Paul HittandClaude Opus 5 e5662ac86a ios#24: a crash report now has somewhere to go
The capture half of that issue was already built and I did not know it until I
looked: MetricKit, on the device, no SDK, no network call, no identifier, kept
by AppDiagnostics and read in DiagnosticsView, with the screen saying plainly
that nothing has been sent anywhere.

What was missing was the other half. The detail screen offered a share sheet
holding raw JSON and named nowhere to send it, so somebody who wanted to help
had a blob and no address. It names support@flamenetmessenger.com now, with a
tap to copy, and shares a file rather than a string: a mailto cannot carry a
payload this size and clients truncate it without saying so.

The project's address rather than the signed-in relay's, for the same reason
the donate link is the project's site. A crash is a defect in this app and a
relay operator did not write it.

A test now refuses URLSession, URLRequest and dataTask anywhere in either file.
The promise on that screen is that nothing uploads on its own, and that is the
kind of promise a later change breaks by accident.

Android has no crash visibility at all, which is the half of ios#24 that is
still open. ApplicationExitInfo is the same shape of answer as MetricKit and is
not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:30:18 -04:00
Paul HittandClaude Opus 5 58fc7c4bef ios#67 again: the tab bar really does cover the last row
Sign out is the last row of the Account list and the pill sits on it. That was
called fixed in eb177ce by giving PillTabBar a safeAreaInset, and it was not
fixed.

Measured rather than argued about. A 40 row List inside the bar draws to the
last pixel of the canvas, rows running under the capsule and past it. The same
inset applied directly to the List works and stops the content 350px up. So the
inset reaches a ScrollView and does not reach a List, and the tabs built from a
scroll view came right while Account, the one screen built from a list and the
one the bug was reported against, went on running its rows under the bar.

Padding instead. It is coarser: content stops above the bar rather than
scrolling under it, so the bar loses its float over content. It also works on
every kind of child rather than on some of them, and a control nobody can reach
costs more than a floating bar earns.

The UI test carried a swipe-up rescue with a comment naming ios#67, so the
workaround was the only thing making the test pass while the screen stayed
unusable. It asserts hittability now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:22:28 -04:00
Paul HittandClaude Opus 5 edd54a2a4f 0.24.0: a Donate row, and the Guideline 1.2 position in writing
The account screen gets a Donate row, matching Android. It opens
flamenetmessenger.com/donate in Safari. Nothing is taken in the app, nothing is
unlocked by giving and nothing is withheld without it, which is the line
guideline 3.2.1 draws.

The address lives on SiteConfig rather than in the view. It is the project's
site, not the signed-in relay, which is the opposite of the rule the privacy
and terms links follow: those open the server you are on, because a self-hosted
relay's operator writes the policy that binds its users. A donation is not
that. Keeping the literal out of ProfileView also leaves
ThePolicyIsReachableFromTheAppTests able to go on refusing that host anywhere
in the file, which is what stops a policy link being hardcoded to it.

ios#12 asked for one remaining thing: the filtering position stated in the App
Review notes rather than left looking like an oversight. TESTFLIGHT.md section
2b answers all four of guideline 1.2 in one place, with filtering answered as a
position, the relay holds ciphertext and has no key to anything, and with what
replaces it written out. The response time 1.2 asks for is marked as unset
rather than invented: it has to be a number the operator will meet, and the
same words have to go on the safety page.

Two things in that file were stale enough to be pasted into a submission as
written. Push notifications are not unimplemented any more, and the export
compliance answers still described the mass-market route rather than 740.13(e).

ios#7 is closed: the owner verified the locked-phone ring on a real handset.
The hazard it leaves behind is on the checklist instead, because the APNs
environment is per-relay and the wrong one looks like a call bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:10:46 -04:00
Paul HittandClaude Opus 5 5dce7029f8 Three custom product pages, with their copy and their shots
Custom product pages override screenshots, previews and promotional text and
nothing else, so each one is a different set of pictures and one different
sentence. Three, because a page is a destination and this project has three
links to point at one: the site's download button, the self-hosting page, and
whatever ads or posts carry the no-phone-number claim.

Promotional text in both languages, all six under the 170 character limit. No
comparison to another messenger, nothing about audits, same rule as the
description.

The sign-up screen gets a fixture of its own. ios-signin was standing in for
it and its field is labelled "Username or email", which argues against the
caption above it. SignUpView takes a defaulted username and birth year so the
shot is a form somebody is partway through rather than one showing a list of
what is still missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:53:44 -04:00
Paul HittandClaude Opus 5 7fd9a5ef28 The iPad set shows the Calls split too
A second iPad shot on the Calls tab: the call log in the sidebar, the
conversation still open beside it. That is what MainTabView draws, because
the detail column belongs to the conversation rather than to the tab, so
moving to the call log does not clear what was on the right.

The call itself stays a full width shot. CallScreen is a fullScreenCover and
covers both columns, so a split picture of it would be a layout the app never
draws.

The sidebar goes from ideal 380 to 420, inside MainTabView's own 320...460,
because a missed call's stamp needs the width to sit beside the two ring-back
buttons. The fixture rows now carry the lineLimit(1) that CallsView has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:47:44 -04:00
Paul HittandClaude Opus 5 fc9d7ce066 The iPad screenshots show the iPad layout
Every iPad shot was a phone layout stretched to iPad pixels, and the split
view collapsed to a single column with a sidebar button. The renderer
hardcoded a 3x scale, which is right for every iPhone size the store
accepts and wrong for the iPad: 2048x2732 is 2x of 1024x1366 points, so
the canvas came out 682 points wide. That is phone width, so SwiftUI laid
it out as a phone and NavigationSplitView had no room for a second column.
The scale is an environment variable now, and the script passes it.

MainTabView gives a second column to Home, Messages and Calls and to
nothing else, so one fixture renders that split and the other screens stay
full width, which is what an iPad actually shows. The messages list and the
conversation are shared properties rather than a second copy of each.

The caption band was sized off the canvas WIDTH, so the same fraction gave
a 1284-wide phone and a 2048-wide iPad type that differed by 60 percent and
squeezed the iPad screen into the bottom half of its own screenshot. It is
sized off height now, and both formats get the same proportion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:40:42 -04:00
Paul HittandClaude Opus 5 02f0e4e642 The encryption notification is sent
Both agencies accepted it on 2026-09-15 at 17:37 UTC: BIS returned 250
through its gateway, NSA returned 250 with a message number. The document
now carries the text as sent and the acceptance lines, because the date is
what a later question about this would turn on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:38:21 -04:00
Paul HittandClaude Opus 5 e1d62a43b3 Export compliance takes the publicly available source route
This document used to choose the mass-market route: self-classified
5D992.c, an encryption registration through SNAP-R, and an annual
self-classification report every February. That route needs an Encryption
Registration Number, because registration is what issues one. It also
cited the reporting rule for License Exception ENC rather than the one for
mass market.

All four repositories are public and the licence is MIT, which is what
EAR §740.13(e) is for: publicly available encryption source code, not
subject to an express agreement for a licensing fee. The obligation is one
notification to BIS and one to NSA naming where the source lives, and
nothing annual. Nothing had been filed under the old route, so the switch
costs nothing.

The notification is drafted here, addressed and ready, and has not been
sent. One question is flagged for a lawyer rather than assumed: whether
the binary Apple distributes is the object code corresponding to that
published source, given it also links CryptoKit and WebRTC. The App Store
Connect answers change too, because the exemption question now has an
accurate yes where it used to have a no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:35:18 -04:00
Paul HittandClaude Opus 5 c0c0f31243 The app icon and the sign-in mark fit their boxes
Both came from a crop of the source artwork that started after the flame
arc began and ended before it finished, so the icon showed a globe clipped
on the left and a flame sheared off at both ends. The crop is the whole
emblem now.

The sign-in mark was not generated by the brand script at all, which is why
it had drifted: it was made by hand from the old crop and never revisited.
It comes out of the same generator as everything else now, so the icon, the
favicons, the social card and this cannot disagree again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:16:29 -04:00
Paul HittandClaude Fable 5.1 9229373bbc Store screens for the listing, and the screenshot script finds its runtime
The renderer's screens check layouts, with placeholder text about
bubble corners. The App Store listing needs screens somebody would
recognise as a conversation, so six store screens sit beside them on the
same renderer: a full message list, a chat with a reaction, an edit and
a disappearing timer, a call, three stories, files with a label, and the
account rows including App Diagnostics and the policy link.

The script pinned a runtime id that no longer exists on this machine,
so `simctl create` said "Invalid runtime" and an iPad set rendered
nothing. It picks the newest installed iOS runtime unless told
otherwise, and takes FNMSG_DEVICE_NAME so an iPad simulator can sit
beside the iPhone one rather than replacing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:41:53 -04:00
Paul HittandClaude Fable 5.1 36c423e519 The screenshot script names the sizes the store asks for now
6.9-inch iPhone and 13-inch iPad, with the device type and size for each,
in place of a list that stopped at the 6.5-inch phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:26:43 -04:00
Paul HittandClaude Fable 5.1 c5b821f0c1 0.23.0, build 23
The first build after 0.22.0 that a stranger will install. It carries the
lock-screen first-call fix, App Diagnostics, call audio in the background,
the policy links, the dead commands removed, and the suspension sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:26:10 -04:00
Paul HittandClaude Fable 5.1 f8576643a1 A suspended account is told so
The relay can now suspend an account, and it refuses one with a single
sentence at every door. On the E2E paths that arrived as a bare 403, which
the screens render as a generic failure. A 403 whose body names
account_suspended now surfaces the relay's sentence as the unauthorized
reason the screens already show, on both the JSON and the blob path. The
profile API already passed the relay's message through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:02:38 -04:00
Paul HittandClaude Fable 5.1 a3fe3f71bb The chat commands that called routes the relay never had are gone
Twenty-four slash commands in the composer, /search, /video, /file,
/photo, /forum, /jobs, /rooms, /radio and the rest, were the site's
features from when the messenger lived inside it. They called
flamenet/v1/search, /videos and /commands on the relay, which serves
none of them, so every one answered "Something went wrong" after a
round trip. /clear, /encrypt and /plain asked for a plaintext mode the
relay no longer has, and /tier read a constant. All of them are gone,
with the result picker, the photo upload path, the search response
types, and the relay's command dispatcher and tier route they were the
only callers of. What stays is what works: status, presence, buddies,
blocking, whois, star and archive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 08:36:50 -04:00
Paul HittandClaude Fable 5.1 3ee6a341c0 A call keeps its audio when the screen locks
The background modes were remote-notification and voip: the first wakes
the app for a message, the second delivers the push that reports a call
to CallKit. Neither covers the call itself. CallKit activates the audio
session, but the capture and playback are WebRTC's, and without the
audio mode iOS suspends them when the screen locks or the person
switches apps mid-call: the far end hears silence while both screens
say the call is connected. Apple's own CallKit sample declares both
modes. The built Info.plist now carries all three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 08:10:37 -04:00
Paul HittandClaude Fable 5.1 2ba5a0a462 The privacy policy and terms are reachable from the Account screen
App Store Review Guideline 5.1.1 wants the privacy policy reachable from
inside the app, not only from the listing, and there was no link to it
anywhere. Two rows under About open the policy and the terms.

They open the pages the signed-in relay serves, at that relay's own
origin. A self-hosted relay serves its operator's policy at /privacy, so
a hardcoded address would send those users to read somebody else's. A
source-reading test pins both rows to the relay origin and refuses a
hardcoded host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:48:06 -04:00
Paul HittandClaude Fable 5.1 9828248a9f Imports in the call receiver are in the order the formatter wants
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:03:25 -04:00
Paul HittandClaude Fable 5.1 624dc69ae8 The first call rings a locked phone, and a crash leaves a report on the phone
Two things, both about what happens when nobody is watching the screen.

The first call to a phone that was not already running never reached the
lock screen. The second one did. Reported iPad to iPhone: both calls rang
with the app open, only the second rang the locked phone, and the relay
had accepted every attempt, so the fault was on the device. It was the
same mistake in two places: work that has to happen now, written as
`Task { @MainActor in ... }`. Neither hop crossed a thread, because
didFinishLaunching runs on the main thread and the PushKit registry is
built with `queue: .main`. All the hop did was defer the work to the next
main-actor turn, behind everything a cold launch has already queued. The
registry was built after the push that launched the app needed it, and
the call was reported after iOS had stopped waiting, so iOS killed the
app instead of showing the call. On a warm app the queue is empty and
nobody notices, which is why the second call worked. Both are now
`MainActor.assumeIsolated`, inline. A source-reading test pins both,
ignoring comments so that quoting the old hop in an explanation cannot
fail the assertion that it is gone. The simulator cannot receive a push,
so the fix still needs one cold-start call on a real device.

The second is MetricKit. Until now a crash was something a person had to
tell us about. The app now subscribes at launch and keeps the last twenty
crash, hang, CPU and disk reports on the device. Nothing is uploaded, not
behind a setting and not on a timer: a messenger that promises the server
cannot read messages cannot also mail itself diagnostics about the phone.
A person opens Account, App Diagnostics, reads a report, and shares it
deliberately through the share sheet if they want to. Performance
metrics are ignored on purpose. The file uses
completeUntilFirstUserAuthentication for the same reason the history
store had to, because MetricKit delivers whenever it pleases, including
to an app woken in the background by a call. The subscription happens
inline at launch for the same reason PushKit now does: iOS hands over
what it has been holding shortly after a subscriber exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 06:51:33 -04:00
Paul HittandClaude Opus 5 20b1bf9590 A person can act on what the app tells them
Six things, and they are one thing: the app detected a problem in more places
than it let anybody solve it in.

SIGNING A PHONE OUT LOOKED LIKE AN IMPOSTOR

`classify` returned `.changed` the moment anything pinned went missing, so
removing a device made every contact's app refuse to send until they accepted
"new keys" that were not new. The Devices screen offers that sign-out. The app
was booby-trapping its own feature, and nobody would ever connect the two
events.

A key that GOES AWAY can only shrink the set of devices able to read a
message; it cannot let a stranger read one. Only a key that ARRIVES can, and a
new key alongside a missing one is still `.changed`. So removal is its own
reading now, benign, and it re-pins the smaller set so it settles instead of
being noticed on every send.

A REFUSED CALL TOLD THE CALLER NOTHING

The answering side raised its own review and finished with `notifyPeer: false`.
The caller sat on "Calling..." for forty-five seconds and was then told "No
answer", which is not what happened and sends them to look in the wrong place.
Measured on a real pair of phones: eight calls in five minutes, not one
envelope coming back. It says "They need to check your security code first"
now. Naming it leaks nothing -- the caller's own keys are what changed.

ACCEPTING WAS IN ONE PLACE AND WARNING WAS IN THREE

The profile showed the key-change warning, carried the safety number somebody
had just been told to compare, and then said to go and accept "in the chat".
It accepts there now.

And accepting in the chat left the banner on screen until you navigated away
and back. THIRD time this exact mistake: the view read live state through
`environment`, and `trust` is `@Published` on the INBOX, a nested observable
SwiftUI does not forward. The state was right every time; nothing told the
screen. The banner observes the inbox now, like the call buttons do.

THE REST

Story Report sits beside the reactions, where the safety page says it is
rather than in an overflow menu at the top of the screen.

The wallet's receive index was `@State`, so it reset to zero on every launch:
somebody who tapped "New address" precisely so as not to reuse one was handed
the old one again next time, silently. A privacy feature that undoes itself is
worse than one never offered, because the person believes they are covered.

THREE TESTS PINNED THE OLD BEHAVIOUR

Two asserted what was being fixed, which is what a good test does when the
behaviour was wrong. One was mine, matching its own doc comment -- the comment
quotes the old expression to say what was wrong, so the test now reads code
and ignores comments.

308 tests in 62 suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:54:02 -04:00
Paul HittandClaude Opus 5 e816c915b9 Reporting a story is beside the reactions, where the page says it is
The safety page says: "A story. Open it. Report is on the story itself,
beside the reactions." On Android that is exactly what it is, a tap in the
same row as the quick reactions. On iPhone it was in an overflow menu at the
TOP of the screen, next to Save and Close. A different place entirely, and
anybody following that sentence was looking in the wrong half of the screen.

Stories are the surface a stranger is most likely to reach somebody through,
which is the reason the affordance was built at all. Putting it where nobody
looks wasted it.

The feed owns one sheet rather than each card owning its own: a sheet per card
in a lazy list is a sheet that can be torn down while it is on screen.

AND THE TESTS I WROTE TODAY WERE NOT BEING RUN

This project has no filesystem-synchronised groups, so a new file is invisible
to Xcode until `Scripts/generate.sh` runs. Three test files added today --
the findable-reporting ones, the group-report ones, and these -- were never
compiled, and the suite happily reported success without them.

The tell was there and I read past it three times: the count stayed at exactly
281 while eight tests were added. A number that does not move when you add to
it is not a passing suite, it is a suite that cannot see you.

Regenerated: 292 tests in 58 suites, and now that includes the ones written to
catch these very bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:48:30 -04:00
Paul HittandClaude Opus 5 59d0f553c5 Reporting is findable, and a group can be reported in at all
A group had no Report. The context menu offered reactions, Copy and Delete,
while the safety page told people to press and hold a message and choose
Report -- and a group is where somebody is MOST likely to need it, because it
is the surface on which a person who is not your contact can reach you. It
reports the author now, which is the only thing that makes sense: a group has
no single other party, and reporting "the group" would name an account that
wrote nothing.

Reporting a person is in the conversation menu too, beside Block. It lived
only behind a press and hold on a message. That is the right place to report
ONE message and it stays there, but nobody finds it by looking: the person
who wrote this app went looking, following the safety page, and could not
find it. Nobody upset is going to do better.

TWO TESTS WENT RED AND THE CODE WAS RIGHT

Both matched inside a guessed number of characters after a `case` -- 400 and
3000 -- and both branches had since grown past it. `cancelMediaGone()` sat at
606, and the decline latch at 4577 still correctly preceded the answer latch
at 4983.

Widening the numbers would have been the wrong repair. They are bounded by
the next `case` now, because a test that fails when a comment is added
teaches people to widen windows instead of reading failures, and the next
real regression is the one they will widen past.

281 tests in 55 suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:24:38 -04:00
Paul HittandClaude Opus 5 8d62d60a96 An envelope that arrives twice is recognised as the same envelope
Both clients already refused an envelope id they had seen before. Neither
ever sent one on the ordinary route: the field existed only on the federated
send, so every envelope reached the relay with a null id and the check on the
other side was asked about nothing. Dead on both platforms, in both
directions, since the day it was written.

It matters because envelopes do arrive twice. Two loops poll one mailbox and
race on the inbox cursor. Measured across a real iPhone and a real Android
build rather than reasoned about: ONE call offer written to the relay at
17:52:15, delivered to Android at 17:52:22 and again at 17:52:24. The second
copy landed while that phone was ringing for the first, fell into "one call at
a time", and answered busy -- to the very call it was ringing for. The caller
was told the callee was busy by a phone that was ringing at them.

Calls made it visible. Messages and call log entries have been doubling the
same way all along and more quietly, which is worse.

So every ordinary send now carries an id, on both platforms, and Android's
carries a default so a new call site cannot forget one.

Two guards at the call layer as well, because a signal arriving twice has to
be harmless whatever the transport does: an offer for the call this phone is
already on is not a second caller, and it says which call it is on when it
does answer busy.

CLOSING THE APP DURING A CALL

The far end works it out when the media stops, which is now a few seconds
rather than the half minute it was. A few seconds of a dead call is still
somebody holding a phone to their ear saying hello into it, so both platforms
now say so outright: iOS on `willTerminate`, Android on `onTaskRemoved`, which
is what fires when the app is swiped out of Recents.

Android's runs on a thread with a bounded join rather than a coroutine. The
scope is cancelled a moment later and a launched coroutine would be killed
before the envelope left; the bound is so a relay that is not answering cannot
hold up the teardown. Neither is the guarantee -- iOS promises nothing at all
for a force quit -- which is why the media-path fallback stays and has a test
saying so.

0.21.0 build 21. 264 tests in 51 suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:13:23 -04:00
Paul HittandClaude Opus 5 514bc2fa83 A call ends when somebody ends it, and history survives a locked phone
Three things, all of them the same shape: something that worked only because
the app had never run on a locked phone, and the first build that can receive
a push on one found them all at once.

HISTORY COULD NOT BE SAVED ON A LOCKED PHONE

The history file was written with `.complete` protection, which means what it
says: the file cannot be opened at all while the phone is locked, to read or
to write. Survivable only for as long as this app never ran locked, which
until a build could take a VoIP push it never did. Now it wakes for a call,
takes delivery of messages, cannot open its own history, and loses them at
the next launch. "Messages still arrive but they cannot be saved."

It is now `.completeUntilFirstUserAuthentication`, the same condition the E2E
keychain items already use, so the key and the file it protects are reachable
under the same rules instead of contradictory ones. The contents are AES-GCM
sealed with that key either way, so this is the outer of two layers: a file
lifted off a powered-down phone is still ciphertext. Files already on disk
keep the class they were created with, so the attribute is rewritten in place
on the way past -- without that, every phone that has ever saved history goes
on failing exactly as before.

That change alone would have been worse than the bug. `load()` treated a read
that threw as "no history here", cleared the latch that refuses to write over
a file it could not read, and would have sealed an empty snapshot over a full
one. Nothing came of it before only because the file was equally unopenable
for writing at the same moments. What decides whether writing is safe is now
whether the file EXISTS, not whether it could be read.

And a load that fails because the phone is locked no longer poisons the whole
session: `restoreWhenUnlocked` runs when protected data comes back, restores
what is still empty, keeps everything that arrived meanwhile, and flushes.

A CALL ENDED ON ONE PHONE RAN ON FOR HALF A MINUTE ON THE OTHER

Two waits, stacked.

The loop that exists to make a call feel immediate was calling `pollOnce()`
with the default holding of twenty seconds, so it spent its life inside a
twenty-second request, one second of sleep at a time. Mine, from the change
that added the parameter. It asks for nothing held now.

Worse, because it does not depend on a signal arriving at all: the far end
hanging up shows up locally as ICE `.disconnected`, and that was treated as
nothing on the grounds that only `.failed` is terminal. WebRTC takes fifteen
to thirty seconds to say `.failed`. So the call sat there, by design, waiting
out a timeout -- which is not something anybody sits through; they decide the
app is broken. `.disconnected` now starts a four-second window, long enough
to ride out a wi-fi handover and short enough that hanging up feels like it,
and `.closed` is terminal at once. The media path and the end signal are
independent now, and whichever arrives first ends the call.

HANGING UP FROM THE NOTIFICATION TOLD NOBODY

The VoIP push carries no caller: PushKit demands a reported call before there
is time for a round trip and a decrypt, so the phone rings as "Flamenet call"
and works out who afterwards. Hang up inside that second or two -- and the
lock screen is exactly where somebody declines a call fast -- and the decline
arrived with nothing to decline, returned, and left the caller ringing. The
answer case was already held for the offer it belonged to; a decline is now
held the same way, bounded to fifteen seconds, and read first, because if
some sequence of taps wants both then hanging up is the safer one to honour.

264 tests in 51 suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:41:12 -04:00
Paul HittandClaude Opus 5 15462390c5 A refusal is worked on before anybody is asked about it
Five things the relay can refuse a request for now have names on the wire,
and until this the app read two of them. The other three arrived, meant
something precise, and were handled as an unexplained failure -- including
the one that says the account is full, which the app answered by retrying
into it for ever while looking perfectly signed in.

So there is one ladder, walked in order, and a person is asked only once
every rung has been tried:

  1. Forget what is cached about this peer, when the refusal named one.
  2. Fetch a working credential. Present the stored one again; if the relay
     still refuses it, exchange the account session for a new app password.
     This runs FIRST, because every step under it authenticates: registering
     under a refused credential re-registers into the same refusal and the
     repair reads as having failed when it never had a fair try.
  3. Register the device again.
  4. Send both halves of the push token again.

Two of these were already written. What was missing is that `run` returned
`.repaired` unconditionally: `needsPerson` was declared and never returned,
so the sentence explaining a refused credential could not reach anybody, and
the app went on looking signed in and silently unable to send. A test caught
it. And when a person does have to be told, they are now told which of the
two failures it was -- a credential only they can replace, or a registration
that failed on a good one -- because "sign out and back in" and "check the
connection" are not interchangeable advice.

A named credential refusal repairs at once rather than on the third one. The
count of three exists because a single bare 401 across a token refresh is
ordinary and an app that shouts about it is one people learn to ignore; a
code that says exactly what happened is not ordinary and needs no quorum.

Also here, the call buttons in a chat. They went grey after one call and
stayed grey, and leaving the conversation and coming back brought them back,
which was the tell: the state was right, the screen never heard it had
changed. `AppEnvironment` publishes the call manager, not the manager's
insides, and SwiftUI does not pass on a nested object's changes. They watch
the manager themselves now.

The worse half of the same bug: a call refused by the trust gate parks in
`.ended` until its key review is let go, and only dismissing the review
sheet let it go. Accepting the same keys from the conversation banner -- the
obvious place to accept them, since that is where the banner is -- left the
review pending for ever, and the buttons grey for ever with it. Accepting an
identity anywhere now reaches the call.

0.20.0 build 20: the first build a locked phone can be rung on at all. Every
call proved so far was proved with the app open, because a build installed
from Xcode holds a sandbox push token and the relay sends to production. It
refused every push, forgot the token, and the app registered another sandbox
one on the next launch. Nothing in the app was wrong and nothing could have
been learned by testing it that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:36:07 -04:00
Paul HittandClaude Opus 5 8216921bd7 Finishing the key review lets the call screen go
Nothing ever cleared `identityReviewPeer`. That did not show while the
tombstone dismissed itself after two seconds and took the "Review keys"
button with it, which was the bug reported first. Making the screen wait --
which it has to, or the button cannot be reached -- exposed the other half:
accepting the keys left "Review keys" on the screen for ever.

Waiting and never letting go are the same bug wearing opposite faces, so the
test pins both ends.

Closing the review is the end of the call screen's business with it either
way. Somebody who wanted to think about it still has the same banner in the
conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 10:13:55 -04:00
Paul HittandClaude Opus 5 3aa38ac4b0 Build 20
19 was cut before the last of the call fixes landed, so 20 is the first
build carrying all of them. The version stays 0.19.0: same release, working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:41:59 -04:00
Paul HittandClaude Opus 5 16ddbeddeb An end wakes a phone; it does not ring one
This is the ghost ringing, and the four fixes before it all worked
downstream of it: take the reported call back, check which call CallKit is
ending, keep a call you cannot judge. Each was a real bug. None stopped the
ghost being made.

iOS gives no choice about how it is made. Every VoIP push must report a call
to CallKit or the app is killed. Ends were marked urgent, urgent means a VoIP
push, so every hangup, every busy and every end for a call long over arrived
on the other phone as an incoming call. From the owner, on a real handset: "I
place a call, it calls me back." Cleaning up afterwards is not a fix, because
somebody has already seen it.

Ends were marked for a real reason, yesterday morning in 6ee32af: hanging up
before the other phone was picked up left it ringing for a caller who had
gone. What that missed is that the phone which needs to hear a hangup is, by
definition, ringing -- and therefore awake. It has a reported call, iOS keeps
it running for one, `startFastPolling` has it asking, and the relay answers a
held poll through Redis in under half a second. The end arrives on its own. A
phone that never rang has nothing to stop.

If that reasoning is wrong the failure is bounded and immediately visible: a
ring that carries on after the caller hangs up, until it times out at
forty-five seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:33:30 -04:00
Paul HittandClaude Opus 5 7ecd3ffbaf A call screen waiting on somebody waits for them
A call refused by the trust gate ends with "Review keys" as its primary
action and a Close beside it, put there deliberately so nobody has to go and
work out for themselves why their call failed. Then the tombstone cleared
after two seconds and took both buttons with it: "the dialog is so fast when
it ends I can't tap anything."

The flow was right, the timer was not. An ordinary end still clears itself,
or every finished call would leave its screen up.

Worth having found before review. A reviewer meeting the trust gate saw a
security warning go past with no way to act on it and no obvious way back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:37:01 -04:00
Paul HittandClaude Opus 5 8c20b5be6b Write down what CallKit does that nothing warns you about
The query that settles whether an answer ever left a phone, the three
CallKit behaviours that cost today, and the bugs still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:54:48 -04:00
Paul HittandClaude Opus 5 0fa870331f CallKit was hanging up every outgoing call a second after it was placed
Found with the logging added this morning, in four lines:

    20:00:14.861  the system ended the call, state=dialing
    20:00:14.861  finish: Call ended (hangup) state=dialing tellPeer=yes
    20:00:14.886  transmit call-end to 123 (98 bytes)
    20:00:14.886  offer dropped: the call ended while it was being built

The app announced a call to CallKit before there was one. CallKit answers a
start action for a call that has not connected by ending it again a second or
two later, and nothing in an end action says whether it came from the system
or from somebody pressing hang up. So the app took the system's teardown for
a hang-up, told the far end so, and the call was over before it was placed.

This is the cause of the `call-end` that was arriving one millisecond ahead
of every offer. That was the symptom.

Three changes:

- An outgoing call is announced when it CONNECTS, not when it is dialled. A
  call nobody answered is in the app's own log; it does not need to be in
  Recents badly enough to cost this.
- The end action asks WHICH call. It asked nothing and hung up whatever was
  in progress, so CallKit ending any call at all -- including a throwaway the
  push handler is required to report -- ended the person's.
- The reconcile keeps a call it cannot judge. `callManager?.hasLiveCall ==
  true` is false both when there is no call and when there is no call
  manager, and a push can arrive while the transport is still coming up.
  Treating the second as the first ended a call that was ringing perfectly
  well.

Verified end to end against the owner's iPhone. Offer out at 00:05:44, their
answer back at 00:06:00 -- the first envelope from that phone all day -- and
the caller went `state=active`. Four calls before this produced nothing but
the caller's own offer and its timeout.

And the lock screen, reported straight after: answering there ran CallKit's
answer action, which called `acceptIncomingCall`, which asked CallKit to
answer the call it was already answering. That request fails, and the failure
path reports the call ended, so answering from the lock screen hung up
instantly. It only asks when the app is the one answering now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:22:05 -04:00
Paul HittandClaude Opus 5 5caa98e72b 0.19.0
0.18.0 went to TestFlight with calls broken: an answer never left the
phone, because CallKit answering its own call fetched the waiting offer and
stopped there. Proved four times against a real handset, the relay logging
the offer arriving and nothing coming back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:41:23 -04:00
Paul HittandClaude Opus 5 c648ddf2ac Make a pull regenerate the Xcode project, so nobody opens a stale one
`FlamenetMessenger.xcodeproj` is generated by xcodegen and is not in git. So
a pull updates `project.yml` and leaves the one file Xcode actually reads
exactly as it was. Everything on disk is correct and the project is a day
old, which is how a stale version number reaches an archive, and how the
owner came to say "i am always looking at a stale project".

`post-merge`, `post-checkout` and `post-rewrite` run the generator, and
`generate.sh` points `core.hooksPath` at them every time it runs, so a fresh
clone picks this up the first time anybody generates rather than needing a
setup step nobody remembers.

It does not help when Xcode already has the project open, because that is
Xcode's cache rather than the file. Closing and reopening is still the answer
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:39:52 -04:00
Paul HittandClaude Opus 5 c8846d1149 Two opt-in helpers for driving a simulator against a real phone
`SimulatorSignInUITests` signs a simulator in and stops. It now also grants
the notification prompt, which belongs to SpringBoard rather than the app and
so cannot be reached through `XCUIApplication()`; an incoming call needs it
and nobody is sitting there to tap Allow.

`PlaceOneCallUITests` places one call from a simulator that is already signed
in. It does not reset, because resetting mints another device on the account
and an account at MAX_DEVICES signs in, reaches the shell, and quietly has no
device at all.

Known limitation, stated rather than discovered later: it adds the contact
through the app's own flow, and the add lands on the server but the new-chat
picker reads `knownBuddies`, which is refreshed on a twenty-second loop. So
the first run after an add finds the picker empty and the second finds it
fine. That staleness is worth fixing in the app rather than worked around
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:38:21 -04:00
Paul HittandClaude Opus 5 6c04faa08a One call is drawn once, not twice with one button that does nothing
A push reports the call to CallKit, which draws the full-screen
answer-or-decline. The app posted a notification banner for the same call on
top of that. One call, two screens, and two buttons that do different
things: CallKit's Answer answers it, the banner opens the app and answers
nothing at all. Tapping the banner left somebody in an app showing no call
while the system's call screen was still up behind it.

From the owner, on a real phone: "the call screen appears but when the app
auto opens it doesn't show a call while the call screen is up". Answering
from the system's own screen is the whole point of reporting the call to it,
and the app should not be competing for that tap.

The banner still exists for the case it is actually for, a call the system
knows nothing about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:36:58 -04:00
Paul HittandClaude Opus 5 fd92f460d9 Answering a call answers it now, and stops waiting twenty seconds first
Three defects, all seen on a real handset this morning.

**Answering did nothing for twenty seconds.** The lock-screen answer path
fetched before it accepted, on the reasoning that the offer might not have
landed. It usually has: the push woke the app and the fetch that followed
brought it with it. Asking again means asking the relay for something new
when there is nothing new, and the relay HOLDS such a request open for up
to twenty seconds by design -- that hold is how a message arrives without
polling every second. So pressing Answer sat there, and the caller gave up
before anything happened. It now accepts at once when there is a call to
accept, and only fetches when there is not, without letting the relay hold
the request.

`pollOnce` takes how long the relay may hold it. The steady loop still
wants twenty; anything a person is waiting on wants nought.

**A call that was answered sent nothing at all.** Proved three times
against the owner's iPhone: the relay logged the offer arriving and not one
envelope coming back. Fixed on main last night; this is the latency half of
the same path.

**A missed call left no trace.** An end whose call id this process had
never seen was remembered and dropped. The app is suspended between a ring
and the caller giving up on it forty-five seconds later, so that end
routinely lands in a process with no memory of the offer: no ring, no
entry, nothing to show anybody had rung. It now files a missed call, guarded
so a call that did ring here is not filed twice.

Build 19. Same release, corrected: 18 went to Apple with calls broken.

Four source-reading tests needed correcting rather than loosening. One
pinned the old fetch-then-answer order and now pins the new one, with the
reason. The other three read a fixed number of characters after a marker,
and a comment added inside the branch pushed the asserted line out of the
window -- a failure that looks exactly like the behaviour being gone. Those
windows are wider now. Worth remembering that these tests assert structure,
not behaviour: they would not have caught the interaction that broke calls
in 0.17.0, and they will not catch the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:17:43 -04:00
Paul Hitt 2fb1cb4531 Merge branch 'fix/never-mint-a-new-history-key-over-a-good-one' into 'main'
Never mint a new history key over a good one

See merge request paulhitt/flamenet-messenger-ios!216
2026-09-13 02:23:32 +00:00
Paul HittandClaude Opus 5 2ca877836f Leave a way to sign a simulator in, and write down what went wrong
`SimulatorSignInUITests` signs a simulator in and stops there. No reset, no
account creation, no messages: running it again on a simulator that is
already signed in leaves it alone, because resetting is what mints a second
device on an account that has a perfectly good one.

`docs/TESTING-CALLS.md` is the evening's traps, which cost more time than
the bugs did. The worst by a distance: an account at MAX_DEVICES signs in,
mints a device locally, logs it, reaches the signed-in shell -- and the
relay refused to register it. That install has no inbox and no contacts and
says nothing about it. It looks exactly like a working app, and four
attempts to place a test call died on it before the cause was clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:18:46 -04:00
Paul HittandClaude Opus 5 d23d862e6a Fix the calls, starting with the hangup every call sent ahead of itself
A trace of one real call showed the whole of the outgoing signalling:

    20:46:12.244  transmit call-end       to 123 (98 bytes)
    20:46:12.245  transmit call-offer+sdp to 123 (3651 bytes)

A hangup one millisecond before the offer it belongs to. `finish` resumes
the ICE-gathering wait, so a call ended while its offer was still being
built came back out of that await and sent the offer anyway. The person
being called got both, for the same call id, in whichever order the relay
handed them over: the ring appeared and vanished, or never appeared at
all because the end had already marked the call over. A race, which is
why none of the reported behaviour was consistent.

Six more, all seen on real devices this evening:

- Answering inside the app told CallKit nothing. The system rang on over
  the conversation and, because a call CallKit believes it owns also owns
  the audio session, the app never brought its own audio up; CallKit then
  ended the call it thought nobody had taken. "Answer it on the iPad and
  it never connects."
- Every VoIP push has to report a call, and most are not a ring: the end
  of a call, a busy, a signal for a call long over. Nothing took those
  back, so the phone rang for a call that did not exist. "I place a call,
  it calls me back." They are now taken back as soon as the fetch says
  what the push was.
- Answering from the lock screen fetched the offer and never accepted it,
  and an answer that beat the offer to the phone was discarded. It now
  waits fifteen seconds for the offer it belongs to, then lapses.
- The ICE-connected handler was two sequential `if`s and the second read
  the state the first had just changed, so a call that reached
  `connecting` before ICE finished -- nearly all of them -- never told
  CallKit it had connected. The placing phone sat on "connecting".
- `tellOwnDevices("answered-elsewhere")` had been moved below the answer,
  leaving the other device ringing until a peer connection was built, and
  for ever if any of it threw.
- A caller who is not a contact showed as "Buddy 128". Sealed sender means
  the push carries no name and the relay cannot add one, but it has had an
  id-to-handle route since first-contact adoption; the call path never
  asked it. It does now, and adopts them, so the call log and the
  conversation after it carry the name too.

And two outside the call path:

- The Messages list was drawn from the contact list alone, so a first
  message from somebody new was decrypted, filed and counted in the badge
  with no conversation anywhere. Adopting now runs from the app rather
  than from whichever tab happens to be on screen, which is why leaving
  the app and coming back was what made a new chat appear.
- The alerts badge and the alerts list counted separately, so reading an
  alert cleared the row and left the badge standing until the next launch.

`transmit` now logs the KIND of what it sends, and `finish` logs why a
call ended and whether the peer was told. Neither logs content. The trace
above is what they are for, and what took all evening to obtain without
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:09:08 -04:00
Paul HittandClaude Opus 5 92cdbd8747 Never mint a new history key over a good one
Somebody lost every message they had. This is how.

`historyKey()` read the key from the keychain and, on any failure at all,
minted a fresh random one and wrote it over the old. `readKeychain` returned
nil for every non-success status, so "the keychain cannot answer right now"
was indistinguishable from "there is no key yet". The keychain item is
`AfterFirstUnlockThisDeviceOnly`, and the app is woken in the background by
VoIP pushes, so a call arriving before the first unlock after a reboot is
enough. One unreadable moment and the old key is gone.

What follows is worse than losing the key. The sealed history can no longer
be opened, `load()` returns nil, the app starts with empty threads, and the
next arriving message seals that emptiness over the file. Every step is
individually reasonable. Together they destroy the history and there is
nothing left to recover it with.

Three changes, because any one alone still loses the messages.

`readKeychain` now says whether the item was absent or merely unreadable.
`historyKey()` mints a replacement only on `errSecItemNotFound` and returns
nil otherwise, so the app does without rather than overwriting. And the store
refuses to write at all over a history it could not open: a file that exists
and will not decrypt marks the account unwritable until a load succeeds, so
an empty snapshot can never replace a full one even if a key goes missing
some other way.

The refusal is visible rather than silent, and the copy says the thing that
matters: the old messages are still there and are deliberately not being
written over. The other failure cases mean data is being lost, and telling
somebody the same thing here would send them to reinstall and destroy the
file this is protecting.

Five tests, including the whole sequence end to end. This bug has been in
every build since history at rest was added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:05:37 -04:00
Paul Hitt 89f4453f9c Merge branch 'chore/take-the-e2e-spec-update' into 'main'
Take the e2e spec update

See merge request paulhitt/flamenet-messenger-ios!215
2026-09-12 20:19:25 +00:00
Paul HittandClaude Opus 5 32ddba7265 Take the e2e spec update
The pin was nine commits behind, all of them spec. flamenet-e2e gained the
text for what 0.18 shipped: off the record, anonymous polls, handing a call
to another device, the private-reaction flag and the call-reaction kind, and
a call answered on one device stopping the others. Plus the harness fix that
reads a page's stylesheet href rather than naming the file.

No vector moved. Copying test/vectors over Tests/Vectors changed nothing, so
this accepts no protocol change: the app was already implementing these
features against a spec that had not yet described them.

flamenet-messenger-android pins the same commit and moves in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:14:54 -04:00
Paul Hitt 0e42bee287 Merge branch 'chore/ios-0-18-0' into 'main'
Version 0.18.0

See merge request paulhitt/flamenet-messenger-ios!214
2026-09-12 18:29:58 +00:00
Paul HittandClaude Opus 5 186d201a5b Version 0.18.0
Polls nobody can attribute, handing a call to another of your own devices,
Face ID opening Files and the wallet, the password per area, and the call
fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:23:46 -04:00
Paul Hitt 77d364ed32 Merge branch 'fix/a-poll-survives-a-restart' into 'main'
A poll survives a restart, and a group appears when you are invited

See merge request paulhitt/flamenet-messenger-ios!213
2026-09-12 17:48:29 +00:00
Paul HittandClaude Opus 5 262f4fb303 A poll survives a restart, and a group appears when you are invited
Two bugs behind one report: a group arrived on the other phone only after
the app was restarted, and when it did, it was empty.

The poll was never written down. `pollBook` was passed to the save call, so
the code read as though it worked, but the history writer watches threads,
group threads, calls, outbox, stories and drafts, and a poll changes none of
them. Nothing triggered a write, so the poll lived in memory until the app
closed. A poll waits for every member to vote and then to publish, which is
not a thing that finishes in one sitting, so it has to cross restarts or it
never completes at all. `pollBook` is now in the writer rather than merely
carried by it.

And accepting an invitation stored the key that opens the group's roster,
dropped the group from the list because the relay's view of it stops
counting once it is sealed, and then fetched nothing. Only `start()` ever
pulled it back, which is why a group somebody made for you did not appear
until you next opened the app.

Four tests. The round trip with a vote, a piece and a published sum; the
extremes of a share, which are the normal case and not an edge; a snapshot
written before polls existed still decoding; and the one that would have
caught this, that a poll arriving is enough to trigger a write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:05:20 -04:00
Paul Hitt 68609fbfc5 Merge branch 'feat/password-per-area' into 'main'
Ask for your password as well as the lock, per area

See merge request paulhitt/flamenet-messenger-ios!207
2026-09-12 16:54:44 +00:00
Paul HittandClaude Opus 5 fb9c165ff2 The gate guard asks the question the new design answers
`SensitiveScreenTests` asserted that `SensitiveScreen(` appears only on
rarely-visited screens, and the per-area password deliberately puts it in
front of messages, Files and the wallet. Three files, three failures, and the
guard was right to fire: a lock on something somebody opens twenty times a
day is a lock they turn off, and then it protects nothing.

What changed is the reason those three are allowed to carry it. They are not
rare, so they answer to the other rule instead: nobody meets that gate unless
they asked for it. The test now enforces that rather than widening the
allowlist into a list of exceptions. Each of the three must reference its own
preference and the app lock, and any other screen that grows a gate still has
to justify itself as one people rarely visit.

It also fails if one of the three loses its gate, which the old shape could
not have noticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:46:51 -04:00
Paul HittandClaude Opus 5 928428b7fb Ask for your password as well as the lock, per area
Three switches under the lock, for messages, files and the wallet. The
lock is the requirement; this makes the password a second step on top of
it, and it is the passphrase that already opens Files rather than a new
secret to remember.

Where it is on, the kept key stands down: files and the wallet ask for
the passphrase again, because a second factor the first factor can skip
past is decoration. For messages there is no unlock of its own, so the
tab is what the gate wraps.

The password is checked by deriving the key with it. A wrong one cannot
open the vault, so it cannot open the gate either, and there is no string
comparison to get past.

What it is worth differs by place, and the footer says so. For files and
the wallet the password is the key itself. For messages it guards the
screen: those messages are already readable on this phone, so it keeps
out somebody holding it and adds no encryption. An account that has never
set a passphrase is let through rather than shut out of its own messages
by a setting it cannot satisfy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:20:39 -04:00
Paul Hitt 6b5c978eb6 Merge branch 'feat/face-id-opens-the-vault' into 'main'
The app lock opens Files and the wallet

See merge request paulhitt/flamenet-messenger-ios!206
2026-09-12 16:20:26 +00:00
Paul HittandClaude Opus 5 9e1bbe54e8 The app lock opens Files and the wallet
With the app lock on, unlocking with your passphrase keeps the key it
derives, and after that a face, a finger or your passcode opens both. With
the lock off nothing is kept and the passphrase is required, as before.
Turning the lock off forgets the key, and a sweep at launch makes that
true even though the switch lives in Settings.

This existed once and was removed because it never worked: the biometric
requirement sat on the keychain item, so the read prompted from inside
itself and returned nothing, twice patched and still broken. The two
halves are separate here. LocalAuthentication proves who is holding the
phone, through the same call the app lock already makes, and only then is
an ordinary keychain item read. A read that cannot prompt cannot deadlock
against its own prompt, and a test pins that order.

What is kept is the derived key, never the passphrase: that is the one
you sign in with. The unlock screens now say which of the two states they
are in, because claiming the passphrase is never kept while keeping the
key it derives would be true only by a technicality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:08:47 -04:00
Paul Hitt 7cfb154ffb Merge branch 'feat/hand-a-call-to-another-device' into 'main'
Hand a call to another of your devices

Closes #74

See merge request paulhitt/flamenet-messenger-ios!212
2026-09-12 16:08:19 +00:00
Paul HittandClaude Opus 5 e7f68c1207 Hand a call to another of your devices
On a call on the phone, walk to the desk, take it on the tablet, and keep
talking. The other person hears a short gap and nothing else.

This rides the channel that already exists: the device that answers a call
tells this account's others to stop ringing. Handoff is that channel carrying
two more signals. Nothing new reaches the relay.

Every rule here is about not breaking a call that is currently working.

The peer is never hung up on. No end is sent to them at any point in either
direction, including when taking the call fails, because the other device is
still carrying it until the moment the offer is away. An end would make the
far end drop the call and redial, which is the failure this replaces.

An offer nobody takes lapses at both ends rather than being withdrawn. A
withdrawal envelope can be lost and leave a device believing an offer still
stands; a timeout cannot, and either way the call stays exactly where it is.

And whether the far end can take a renegotiating offer is known before any of
this starts, from a flag both ends set during call setup. A build that
predates handoff sets nothing, absent reads as no, and the control is absent
rather than disabled. Finding out by dropping the call is the wrong way to
find out.

On the receiving side an offer for the call already in progress is answered
where it stands: no ring, no second entry in the call log, and the call
screen never goes away.

11 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:49:18 -04:00
Paul Hitt 8c6fc13389 Merge branch 'feat/polls-nobody-can-attribute' into 'main'
Polls where nobody can tell who voted for what

Closes #76

See merge request paulhitt/flamenet-messenger-ios!211
2026-09-12 15:49:00 +00:00
Paul HittandClaude Opus 5 197ebbaae7 Polls: the screens, and what they are allowed to claim
A card at the top of the group thread rather than a bubble in it: a poll
changes as pieces arrive and has to stay reachable while people are still
voting. Asking one is in the group menu.

The copy is the part with a test on it. The guarantee here is a collusion
bound, not secrecy, so the screens say everyone else would have to work
together to know how one person voted, and never that nobody can know. A poll
of two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why. The rule that everybody must vote before there is a result is said
before somebody votes rather than after.

A count renders from exactly one branch, the one where every member has
published. Counting what has arrived so far would show a number that is noise
rather than a partial answer, and it would look entirely plausible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:02:10 -04:00
Paul HittandClaude Opus 5 7c8eba7ba3 Polls: envelopes flow, and survive a restart
Asking a poll fans it out to the group. Voting does not: each piece is
addressed to the member it belongs to and sent to them alone. That is the one
group envelope in the app which is not the same bytes to everybody, and the
reason is the whole feature, so a test pins it. Fanning a share out would
hand every member the same piece, and any one of them could subtract it from
the tally to read the vote, with the counts still coming out right and
nothing at runtime to notice.

Publishing stays gated on the book: a sum sent before the last vote arrives
leaves noise in the total rather than a smaller count.

Polls are kept at rest, or one could never finish. It waits for every member,
and the pieces already gathered would be gone the first time the app closed.
They are deliberately not carried into a D4 export: a poll restored days
later belongs to a group that has moved on, and the thing somebody wants back
is the result, which is already a message in the thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:57:35 -04:00
Paul HittandClaude Opus 5 caf3b05c2c Polls: the sequencing, apart from the inbox
A book of every poll this device knows and what it owes each one. Held
separately because the ordering is the part that goes wrong and it is worth
testing without a relay.

The rule it exists to enforce: no sum is published until every member has
voted. Not "most", not "after a while". A member who has not voted has handed
nobody a piece, so nobody holds a complete set, and a sum published early is
noise rather than a partial answer. The standing it reports names who it is
waiting on, because "waiting" with nobody named is not something a person can
act on.

Redelivery is the other one. A piece or a sum counted twice is a wrong tally
and nothing downstream would notice, so both are idempotent, a second vote
sends nothing, and a redelivered poll does not reset the votes gathered
against it.

12 tests, including a whole three-person poll carried end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:53:03 -04:00
Paul HittandClaude Opus 5 81c7cd06db Polls: the arithmetic and the envelopes on iOS
The Android engine has had the secure-aggregation core since it was written;
iOS had nothing, so a poll could not have crossed between them. This is the
same scheme in Swift, plus the three envelope kinds on both sides of the
wire.

The ring is 32 bits, and that is the part worth stating: Kotlin's `Int` is 32
bits and wraps, Swift's is 64, so `Int32` with `&+` and `&-` here. Plain `+`
would trap on exactly the overflow the hiding depends on, and `Int` would
make the same shares add up to a different number, which the two engines
would experience as disagreeing about the result of a vote rather than as an
error.

19 tests. The tally ones ask what the scheme guarantees rather than what the
code looks like: the counts come out right, no piece and no proper subset of
pieces recovers a vote, and a member who never publishes gives noise rather
than a smaller count, which is the rule the interface has to obey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:50:17 -04:00
Paul Hitt d23f4b4ba6 Merge branch 'fix/a-dead-call-never-rings' into 'main'
A call that is over never rings again

See merge request paulhitt/flamenet-messenger-ios!210
2026-09-12 13:31:41 +00:00
Paul HittandClaude Opus 5 9886c5a769 A call that is over never rings again
Envelopes are fetched in batches, so the caller's hang-up is regularly
decrypted before their offer. The end was checked against the call in
progress and, when it matched nothing, dropped and forgotten. The offer
behind it then rang a moment later for a call that was already over.
Answering that ring did nothing, because nobody was on the other end, and
the next queued offer arrived behind it.

An end is now filed whether or not it matches the call in progress, and
every signal is checked against that before it can ring, connect, or wait
on anything. The call this device ends is filed the same way, so a late
signal from the peer cannot restart it.

The memory is bounded oldest-first at 200. A phone left running for weeks
must not keep every call id it has ever seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:13:57 -04:00
Paul Hitt 48ce8c3926 Merge branch 'fix/the-ring-stops-when-the-caller-hangs-up' into 'main'
The ring stops when the caller hangs up

See merge request paulhitt/flamenet-messenger-ios!209
2026-09-12 13:11:08 +00:00
Paul Hitt be1ecfa52a Merge branch 'chore/ios-0-17-0' into 'main'
Version 0.17.0

See merge request paulhitt/flamenet-messenger-ios!208
2026-09-12 13:07:47 +00:00
Paul HittandClaude Opus 5 1ede975eaf A missed call reaches the call log
It left a "Missed call" line in the conversation and nothing else. The
Calls screen is where somebody goes to find out who rang, and a call the
phone slept through was the one kind it never showed, so the log filled up
with outgoing calls alone.

It still appears in the conversation too. The two surfaces answer different
questions and one does not replace the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:07:03 -04:00
Paul HittandClaude Opus 5 6ee32afd89 The ring stops when the caller hangs up
Three things, all on the path between hanging up and the other phone
going quiet.

Only a call OFFER was marked time-critical, so only an offer got the VoIP
push that reaches a locked, suspended phone. Hanging up sent the end as
ordinary traffic, which a sleeping phone does not wake for, and it went on
ringing for a caller who had already gone. The end of a call is as
time-critical as its start, on both clients.

A push arriving for a call already on screen used to overwrite the id of
the ringing call. Nothing held that id afterwards, so nothing could end it
and it rang on alone. PushKit still demands a reported call out of that
handler, so report a throwaway and close it in the same breath.

And answering told this account's other devices before it told the caller,
putting a key fetch for every one of them in front of the answer on the
same actor. The caller sat on "Calling" through all of it. The person
waiting is the caller; the tablet in the next room can wait its turn.

The urgent rule is now tested for what it decides rather than for how it
is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:00:19 -04:00
Paul HittandClaude Opus 5 8f7e3f5498 Version 0.17.0
Matches the Android release going out with the same three features, so
one number describes both. Build 17 has never been uploaded under this
version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:45:59 -04:00
Paul Hitt fa8286f1a1 Merge branch 'fix/files-reuses-the-unlock' into 'main'
Files reuses an unlock the app already has, and the wallet Done button goes

See merge request paulhitt/flamenet-messenger-ios!205
2026-09-12 12:21:17 +00:00
Paul HittandClaude Opus 5 c71f632fd7 Two fixes from testing 0.16.0
Files asked for the passphrase every time the tab was opened. Unlocking
publishes the key so the wallet and saving a received document can use
it, and the wallet reuses it; Files set it and never looked, so it asked
for something the app was holding in memory. It now adopts that key, and
signing out still takes it away. Typing it once a launch is the design,
because the passphrase is stored nowhere; typing it on every visit was
not.

The wallet's Done button did nothing. That screen is a tab as well as a
sheet from Home, and in a tab there is nothing above it to close, so the
button called dismiss into the void. Removed: the sheet still closes by
swiping it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:17:34 -04:00
Paul Hitt 0bea1bcb38 Merge branch 'feat/off-the-record-ui' into 'main'
Off-the-record conversations: the control

Closes #77

See merge request paulhitt/flamenet-messenger-ios!204
2026-09-12 02:18:14 +00:00
Paul Hitt 1554bc61ac Merge branch 'feat/off-the-record' into 'main'
Off-the-record conversations: the mechanism

See merge request paulhitt/flamenet-messenger-ios!203
2026-09-12 02:14:31 +00:00
Paul HittandClaude Opus 5 7f605bba36 Off-the-record conversations: the control
A switch in the conversation's own menu, and a line under the header
saying so while it is on.

The switch asks; it does not assert. What it shows is what the other side
has echoed back. That echo arrives as control traffic which appends no
message, so nothing on screen would change when it lands: the setting is
published from the inbox the conversation already watches, rather than
re-read when the thread opens, which would leave the banner claiming the
old state until it was closed and opened again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:11:54 -04:00
Paul HittandClaude Opus 5 f6b338488c Off-the-record conversations: the mechanism
A conversation held in memory and written to disk by neither side. Close
the app and it is gone: no file to seize, no backup to leak.

It is a proposal, not a switch. Nothing is saved until the peer echoes
the envelope back, because a build that does not know the kind never
echoes and would go on writing while this side believed it had stopped.

Persistence here is reactive: the whole snapshot is written whenever the
state changes, so the gate is what goes into the snapshot rather than a
call that writes one message. Both paths to disk, the live writer and the
backup, pass through one filter, and a test counts those call sites so a
third cannot appear without going through it too.

The control for it comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:45:38 -04:00
Paul Hitt 55d4edd28d Merge branch 'feat/call-reactions' into 'main'
Reactions during a call

Closes #78

See merge request paulhitt/flamenet-messenger-ios!202
2026-09-12 01:40:54 +00:00
Paul HittandClaude Opus 5 4dd628d571 Reactions during a call
Tap a reaction while somebody is talking and it shows on their screen
for a moment. Agreement without interrupting, on a voice call as well as
video.

The signal rides the ratchet like every other call signal, is accepted
only for the call in progress and only once it is up, and is recorded
nowhere: when the call ends there is no trace of it. An older build
ignores an action it does not know, and the emoji key is absent on every
other kind, so nothing else on the wire changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:33:23 -04:00
Paul Hitt 05a84e98d9 Merge branch 'feat/private-reactions' into 'main'
Private reactions: react to one person inside a group

Closes #75

See merge request paulhitt/flamenet-messenger-ios!201
2026-09-12 01:22:48 +00:00
Paul HittandClaude Opus 5 6d7b76f056 Private reactions: react to one person inside a group
React to a message in a group and only its author sees it. The reaction
is sent to the author and to nobody else, which is the whole guarantee:
asking the other members' apps not to draw it would be a request, and a
request is not privacy. The flag on the envelope only lets the author's
app label what it received.

Private reactions are kept apart from the public tally, and the chip
reads from whichever side you are on: the sender is told who saw it, the
author is told that nobody else did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:13:44 -04:00
Paul HittandClaude Opus 5 e07c64749d Keep older history readable after adding a field to group messages
A synthesized decoder throws on a key that is not there rather than
falling back to the property's default, so giving group messages their
reactions made every snapshot written before today fail to decode. The
repo's own test for old snapshots caught it.

Stored optional and read through a computed property, the arrangement a
story already uses for its expiry and key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:12:46 -04:00
Paul HittandClaude Opus 5 bc6afe1c72 Reactions work in groups
Android has carried reactions on group messages since the beginning. On
this side the send path took a buddy id and nothing else, group messages
had nowhere to keep a reaction, and an arriving one was folded into the
one-to-one with whoever sent it, landing on the wrong message in the
wrong conversation or on none at all.

Group messages now carry reactions, the envelope names the group, an
arriving reaction is routed by that group id after the same membership
check group messages pass, and the backup carries them so a restore does
not drop them. The group thread gets the same row of emoji the one-to-one
menu has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:54:37 -04:00
Paul Hitt e264d9dc6f Merge branch 'fix/call-warnings' into 'main'
Clear the call warnings

See merge request paulhitt/flamenet-messenger-ios!199
2026-09-11 17:08:39 +00:00
Paul HittandClaude Opus 5 df891e7c71 Clear the call warnings
allowBluetooth is renamed allowBluetoothHFP, the same 0x4 flag,
available on every iOS this app supports. The outgoing call's handle is
built inside the main-actor closure instead of captured, since CXHandle
is not Sendable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:04:21 -04:00
Paul Hitt eadb9e80c7 Merge branch 'fix/answered-elsewhere' into 'main'
Stop the ring on this account's other devices when one answers; 0.16.0 (17)

See merge request paulhitt/flamenet-messenger-ios!198
2026-09-11 16:56:40 +00:00
Paul HittandClaude Opus 5 d8311cefc9 Center the Files and Wallet unlock forms on iPad
In the right-hand column the passphrase field ran edge to edge. Capped
at 440 points and centered; a phone is narrower than that, so iPhone is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:52:43 -04:00
Paul HittandClaude Opus 5 2275def5c9 Format AnsweredElsewhereTests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:51:42 -04:00
Paul HittandClaude Opus 5 f07385b6de Version 0.16.0, build 17
Build 16 was archived as 0.16.0 from Xcode and discarded; 17 keeps the
build number above anything already archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:51:29 -04:00
Paul HittandClaude Opus 5 34ffd591d4 Stop the ring on this account's other devices when one answers
The answer goes to the caller only, so an iPad on the same account kept
ringing for its full minute after the iPhone picked up, and then filed
the call as missed. The device that answers or declines, in the app or
from the lock screen, now sends call-end to its own account's other
devices, reason answered-elsewhere or declined-elsewhere. A device still
ringing for that call stops, tells CallKit it was answered or declined
elsewhere, and files nothing; one already on the call ignores it.

A send to this account's own devices carries no delivery key or key-log
prelude and is not refused for going unsealed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:51:29 -04:00
Paul Hitt 6d36fe3cc2 Merge branch 'chore/ios-0-15-0-build-16' into 'main'
iOS 0.15.0, build 16

See merge request paulhitt/flamenet-messenger-ios!197
2026-09-11 16:35:43 +00:00
Paul Hitt 3b6aede9e2 Merge branch 'fix/ipad-only-list-tabs-split' into 'main'
iPad: only the list tabs split into two columns

See merge request paulhitt/flamenet-messenger-ios!196
2026-09-11 16:31:49 +00:00
Paul HittandClaude Opus 5 325dffb543 iOS 0.15.0, build 16
Build 15 went up and is not for testers. It has two faults, both fixed
on main since:

- With one account on two devices, the second device's ring guard sent
  the caller a timeout when it expired, and the caller hung up the call
  that had been answered on the first device.
- On iPad, Files, Wallet and Account were squeezed into the list column
  beside a "Pick a conversation" placeholder.

Same release, corrected, so the version stays 0.15.0 and only the build
number moves, which is what App Store Connect needs to accept it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:28:15 -04:00
Paul Hitt 02b06d9d09 Merge branch 'fix/second-device-hangs-up-answered-call' into 'main'
A second device's ring no longer hangs up a call answered on the first

See merge request paulhitt/flamenet-messenger-ios!195
2026-09-11 16:27:25 +00:00
Paul HittandClaude Opus 5 12eb447feb iPad: only the list tabs split into two columns
Home, Messages and Calls are lists that lead to a conversation, which is
what a second column is for. Everything else is a single screen, and
splitting those went badly: Files and the wallet pushed their unlock
screen into a column a third of the width, and the other two thirds said
"Pick a conversation" on a tab with no conversations in it. Account did
the same. Found in the 13-inch screenshot set.

Those tabs now take the whole width, the way a settings screen does on a
tablet. Whether a row opens beside the list or on top of it follows
whether the right-hand column is actually showing, so a row on a
full-width tab cannot open into a column that is not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:22:03 -04:00
Paul HittandClaude Opus 5 a943902ec1 A second device's ring no longer hangs up a call answered on the first
With one account on two devices, an iPhone and an iPad now being the
ordinary case, both ring. Answer on one and the other keeps ringing,
because nothing tells it. When its ring guard fired it sent the caller
an end with reason "timeout", the caller matched only the call id, and
the call in progress was hung up.

The guard was added this week, so before it the second device simply
rang until the caller hung up, and the call survived. This is the fix
for a regression, not an old bug.

Two halves, both needed.

The ring guard releases the ring locally and sends nothing. The caller
gives up on its own 45 second timer long before this fires, so the
message was redundant, and on a shared account it was destructive.

Once a call has connected, the end handler ignores an end whose reason
means the ring was never picked up: declined, busy or timeout. Those can
only have come from another device that was still ringing, somebody
silencing their iPad, or an Android build already in people's hands that
still announces its timeout. A plain hang-up carries no such reason and
still ends the call. A ringing call still stops for any of them.

`RingTimesOutTests` asserted the old behaviour line for line; it now
guards the new one, bounded to the function each check is about so a
neighbouring function cannot fail or pass it. Its Android half only runs
with the two checkouts side by side under the expected names, so Android
guards the same fix in its own repo, where its CI runs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:22:01 -04:00
Paul Hitt 43fda10a98 Merge branch 'chore/ios-0-15-0' into 'main'
iOS 0.15.0, build 15

See merge request paulhitt/flamenet-messenger-ios!194
2026-09-11 16:14:32 +00:00
Paul HittandClaude Opus 5 b62722d04b iOS 0.15.0, build 15
0.14.0 build 14 is on TestFlight. This is the release that follows it,
and it carries everything merged since:

- The Face ID passphrase store is gone from Files and the wallet, and
  the passphrases it had saved are purged from the keychain at launch.
- A call nobody answers stops ringing on its own.
- The iPad: two columns, the lists beside the conversation, in every
  orientation App Store Connect requires.
- The backup no longer drops replies, stickers, link previews, drafts
  or story sets.
- The call screen offers the key review itself, and four UI fixes.

Build number follows the version, as it has since 0.13.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:57:18 -04:00
Paul Hitt 0f06a08c97 Merge branch 'feat/ipad-layout' into 'main'
iPad: two columns, lists on the left, the conversation on the right

See merge request paulhitt/flamenet-messenger-ios!193
2026-09-11 15:53:36 +00:00
Paul HittandClaude Opus 5 ac52500521 Photograph the iPad layout in every orientation it has to support
App Store Connect requires an iPad build to support all four
orientations unless it gives up Split View and Slide Over, so every
screen has to survive being wide and short. That was the one part of
the iPad change nobody had seen.

Rotating the simulator window from outside does not reach iOS at all.
The window turned; the device kept reporting portrait, the status bar
stayed on the short edge, and the app never received an orientation
change. So this rotates from inside, with `XCUIDevice.shared.orientation`,
which is the one rotation the app is guaranteed to get.

It signs in, photographs Home, Messages, Calls and Account both ways up,
then opens a conversation into the right-hand column and photographs it
in portrait, both landscapes, upside down, and in landscape with the
keyboard up, which is the case most likely to leave the transcript no
room. All of them hold: the list stays on the left, the conversation on
the right, and with the keyboard up the composer and header stay visible
while the transcript shrinks.

Credentials come from the environment only, since this repository is
public. It skips on a phone rather than photographing the phone layout
and calling it iPad.

`ipad-build/` is ignored alongside `ci-build/`, for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:48:45 -04:00
Paul HittandClaude Opus 5 6efe950649 iPad: every conversation opens in the right-hand column
Seeing it run found what reading it did not. Opening a chat from the
recents row put the conversation INSIDE the left column, squeezed into
the list, beside an empty right-hand side still saying "Pick a
conversation". That path set `openedRecent`, which pushes onto the
list's own stack, and in two columns that stack is the left one.

Checked every place a conversation is opened from rather than fixing
the one that was caught. There are seven, across three different
screens: a buddy on this relay, somebody on another relay, and a group.
Any list left out does not fail loudly; it pushes its screen into the
sidebar, which is exactly how the recents row looked fine in a diff.

So the selection now carries all three kinds, and the right-hand column
renders whichever it is. `SplitPane.open` answers the one question every
list needs asked, whether there is a column to open into, and returns
false on a phone so the caller pushes as it always has. The three
recents, search and new-chat sites go through one helper; groups and
federated conversations go through `ConversationLink`, which takes the
phone's destination as a closure because only the list knows how to
build it.

One slip on the way, caught before it ran: rewriting the call sites also
rewrote the helper's own body into a call to itself.

Verified on an iPad Pro 11-inch simulator, signed in as the review
account: the Messages list on the left, the conversation with
fnreviewbeta on the right with its header, transcript and composer. The
iPhone suite is unchanged at 126 tests in 24 suites.

Not verified: landscape. The built app declares all four iPad
orientations and the iPhone stays portrait, checked in the built
Info.plist, but the simulator panel cannot be rotated from here, so no
screen has been seen wide-and-short yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:41:54 -04:00
Paul HittandClaude Opus 5 430402eb25 iPad: two columns, the list beside the conversation (ios#10)
The app was iPhone-only by build setting. It now targets both, and an
iPad gets the layout a tablet is for: the lists on the left, the
conversation on the right, the way every messaging app on a tablet does
it. Nothing about the protocol, the storage or the call path changes.

The device family had to move at TARGET level, not project level. The
comment already in `project.yml` says why, and it is the reason an
archive once shipped as iPhone+iPad by accident: xcodegen emits a target
default of "1,2" that silently overrides whatever the project says.

Orientations are the part that is not free, and the issue said so
before this started. App Store Connect requires an iPad build to support
all four unless it declares `UIRequiresFullScreen`, and declaring that
opts out of Split View and Slide Over, which is most of what running on
an iPad means. So the iPad gets all four and the iPhone stays portrait,
because nothing about holding a phone sideways improves a chat.

The split itself is one decision in one place. `SplitPane` holds which
conversation the right-hand column is showing and whether there is room
for two columns at all, and `ChatLink` is the row that pushes on a phone
and fills the column on an iPad. That is what keeps the choice out of
every list: `MessagesListView` changes by one wrapper, `BuddyListView`
by routing its two open sites through one helper, and the phone's
behaviour is the branch that was already there.

It reads the SIZE CLASS, not the device. An iPad in Slide Over is as
narrow as a phone and wants the phone's layout, and something asking
`userInterfaceIdiom` would get that wrong and keep getting it wrong
while somebody dragged the divider. Narrowing also drops the selected
conversation, so a chat is never left open with nothing on screen
pointing at it.

Builds for iPad, and the iPhone suite is unchanged at 126 tests in 24
suites. The two-column layout itself is not yet verified running,
because driving the simulator needs device access this session does not
have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:32:30 -04:00
Paul Hitt c51967b231 Merge branch 'ops/keep-build-output-out-of-spotlight' into 'main'
Keep build output out of Spotlight (ios#5)

See merge request paulhitt/flamenet-messenger-ios!192
2026-09-11 14:12:43 +00:00
Paul HittandClaude Opus 5 cdc036ab0d Keep build output out of Spotlight (ios#5)
`ci-build` reaches ~700 MB and is rewritten every job, on a machine that
is also the mac-local runner for both app repos, so indexing it lands in
the merge path for everything. `mds_stores` and `mdworker_shared` sit at
the top of CPU while no `xcodebuild` process is near it, which is why
the load looked unexplainable.

Measured, same from-scratch build twice: load peaks at 12.5 with the
output excluded and over a hundred without, and the indexing stops
accumulating rather than trailing the build by minutes.

The marker is written by the job rather than committed, because the
directory it belongs in is created by the build and removed between
runs. The stable caches outside the repo were excluded on the host
itself: DerivedData, the Android SDK, the runner's builds directory and
the Gradle home, 11 GB between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:01:03 -04:00
Paul Hitt 9da96644b9 Merge branch 'test/content-envelope-vectors' into 'main'
Pin what this codec puts on the wire

See merge request paulhitt/flamenet-messenger-ios!191
2026-09-11 11:51:26 +00:00
Paul HittandClaude Opus 5 c336139555 Pin what this codec puts on the wire
The content layer had no vectors. Everything from section 6.6 onward was
guarded by round trips through this same codec, which would pass
unchanged if this app and Android disagreed about every key. That is how
SPEC 6.18 specified `ment` for as long as it existed while both clients
have always sent `men`: the only thing that could have caught it is a
conformance test written from the spec, and there was none.

`ContentVectorGenerator` builds fifteen envelopes with the shipped
encoders and, when asked, writes them out. That file is now the
published vector. Generating it by RUNNING the encoders is the point:
transcribing a wire format by reading it is exactly how `ment` got into
the spec.

Android reads the same file with a different codebase, which is the
independent half. This side checks drift: the vector records what these
encoders emit, so if somebody changes one, every implementation built
against that file is wrong from that moment, and this turns it into a
failed test rather than a report from somebody whose mentions stopped
working.

The comparison is structural, and it has to be. Nested objects here go
through `JSONEncoder`, which does not order its keys, so the same
message encodes as `{"mid":…,"author":…}` on one run and
`{"author":…,"mid":…}` on the next. That costs nothing on the wire,
where everyone parses, but it does mean a vector can pin what an
envelope says and never the bytes it says it in. Comparing strings
failed nine of fifteen cases on the first run, every one of them key
order, not one a difference any receiver could observe.

The drift gate did not cover `content-v1.json`, and had not covered
`backup-v1.json` either until yesterday. Both loops now list it.

Proven capable of failing: flipping `men` to `ment` in the pinned file
turns three tests red.

Full suite: 126 tests in 24 suites, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:46:33 -04:00
Paul Hitt f1ff9a1678 Merge branch 'test/backup-payload-vector' into 'main'
Check the backup payload against the published vector

See merge request paulhitt/flamenet-messenger-ios!190
2026-09-10 23:50:49 +00:00
Paul HittandClaude Opus 5 9cdc0389d9 Check the backup payload against the published vector
Both apps were taught one backup shape and nothing checked that they
agreed. Each side's tests asserted against what the OTHER was believed
to write, transcribed by hand from reading its source, which tests one
person's reading rather than interoperability.

`backup-payload-v1.json` is now the authority and the bar is exact:
decode the canonical payload, encode it again, and the result must equal
what is pinned. Android checks the same file, so the two are compared
against one document rather than against each other's source.

The drift gate did not cover `backup-v1.json` either, in both the pinned
job and the advisory one that runs at vectors HEAD, so the envelope
vector could have moved upstream without anything here noticing. Both
loops now cover it and the new payload vector. Verified that every
copied file matches the pinned commit before widening the gate, rather
than widening it and finding out from a red pipeline.

Full app suite: 121 tests in 22 suites, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:46:18 -04:00
Paul Hitt 66dde533d2 Merge branch 'fix/call-review-no-environment' into 'main'
Hand the call key review its inbox rather than the environment

See merge request paulhitt/flamenet-messenger-ios!183
2026-09-10 22:00:33 +00:00
Paul Hitt a24a2eedc4 Merge branch 'fix/backup-was-dropping-half-the-message' into 'main'
The backup was dropping half of every message

See merge request paulhitt/flamenet-messenger-ios!189
2026-09-10 21:53:47 +00:00
Paul Hitt 618ca299ab Merge branch 'fix/remove-biometric-passphrase-store' into 'main'
Remove the biometric passphrase store from Files and the wallet

See merge request paulhitt/flamenet-messenger-ios!187
2026-09-10 21:50:30 +00:00
Paul Hitt 25edbf5ebd Merge branch 'fix/ring-times-out' into 'main'
An unanswered ring releases itself

See merge request paulhitt/flamenet-messenger-ios!186
2026-09-10 21:46:41 +00:00
Paul HittandClaude Opus 5 9876506c84 Do not carry the outbox, and say why
Adding the outbox to the backup was wrong, and the source check is what
made me look at it rather than let it through.

`flushOutbox` runs on every poll cycle, a few seconds apart. Anything
restored into the outbox is therefore sent almost immediately. A backup
exists to be opened on a different phone, possibly months later, so
carrying queued sends would mean a restore quietly delivering messages
somebody wrote long ago to people no longer expecting them. A scheduled
send with a `notBefore` in the past would go out at once.

Losing them across a device move is the safer of the two failures, and
it is the one a person can actually see. Within a device nothing
changes: `E2EInbox` still restores the outbox from local history, so an
unsent message survives a relaunch exactly as before.

It is listed in `notCarried` with the reason rather than dropped
silently, which is the whole point of that list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:46:23 -04:00
Paul Hitt 64ddb64711 Merge branch 'fix/four-ui-failures' into 'main'
Four UI failures: two stale tests, two things the app got wrong

See merge request paulhitt/flamenet-messenger-ios!188
2026-09-10 21:43:22 +00:00
Paul HittandClaude Opus 5 1bdee885c8 Two assertions outlived the screen they described
The biometrics test kept two expectations from before the removal, both
requiring copy that went with it: the "is on" confirmation and the line
saying the passphrase still works. Renaming the test and replacing its
first assertion left those two behind, so the suite demanded the exact
thing the commit deleted.

They now assert the absence they should have from the start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:41:47 -04:00
Paul HittandClaude Opus 5 fadee75c6d A story was losing the set it belonged to
`E2EStory.set` says which item of a multi-part story this is, and the
backup never wrote it, so a set restored as loose pictures that no longer
knew they belonged together.

The source check now reads `E2EStory` as well, and skips computed
properties, which have nothing to carry and are derived from something
already on the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:39:20 -04:00
Paul HittandClaude Opus 5 b0809ddda6 And the same hole at the snapshot level
`Snapshot` declares fifteen properties and the encoder named eight, so
an export forgot the account's own settings as well as the contents of
its messages: unsent mail in the outbox, half-written drafts, who your
stories go to, who you had muted, your own story archive, and whether
the roster is being kept off the server.

Same defect as the message fields, one level up, and it survived for the
same reason: everything it drops is state you set once and rarely look
at, so a restore that lost it looks like an account that never had it.

The source check now reads `Snapshot` too. Its collections are encoded
by name rather than through an accessor, so they are listed as checked
by the round trips instead of by the source scan, with the reason
written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:36:24 -04:00
Paul HittandClaude Opus 5 fe44e63740 The backup was dropping half of every message
`E2EBackupPayload` builds a fresh JSON object out of typed properties, so
anything it does not name by hand is not carried. It named five things on
a one-to-one message and four on a group message, and `E2EMessage`
persists eleven.

So the cross-platform format shipped without replies, stickers, link
previews or delivery state, and group messages additionally lost their
message id and whether they mentioned you. Nothing failed. A backup that
restores a conversation with the replies flattened into loose messages
looks exactly like a backup of a conversation that had no replies, which
is why this survived a review and a merge.

The file's own doc comment claimed unknown keys survived a round trip
untouched. They do not, and never did in this implementation. That claim
is replaced with what is actually true, which is that every persisted
field has to be listed here by hand.

Keys are the ones both apps already use on the wire, `re`, `stk`, `lnk`
and `delivery`, so the Android half of this is a copy rather than a
translation. All of them are additive and optional, so a v1 file written
before today still opens, and one written today still opens on a build
from before it.

Two guards. Round-trip tests for each field, and a source check that
reads `E2EMessage` and `E2EGroupMessage` and fails if either grows a
property the encoder never mentions, so the next field added is either
carried or deliberately listed as left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:31:13 -04:00
Paul HittandClaude Opus 5 fcbcc9bff3 Remove the biometric passphrase store from Files and the wallet
Files and the wallet offered to keep your vault passphrase on the phone,
released by Face ID or Touch ID. On a real phone it reported success and
then did nothing: enrolling showed "Face ID is on", and every unlock
afterwards left you at the passphrase field with no prompt, no error and
no explanation. It has now been patched twice, once for the read
deadlocking against its own prompt and once for the write, and it still
does not work.

Part of why it was impossible to tell what had gone wrong is in the
caller. Both screens read it as:

    guard let stored = try await FilesPassphraseStore.load(...)
    else { return }

and `load` answered nil for three unrelated outcomes: the person
cancelled, the face was not recognised, and no item is stored at all.
Whichever happened, the screen returned silently, so the only symptom
available to anyone was "nothing happens".

Rather than patch it a third time, take it out. App Lock is untouched:
that is a separate mechanism on `.deviceOwnerAuthentication`, it falls
back to the passcode, and it works. Face ID still opens the app; it just
no longer holds a vault passphrase.

Removing the code alone would have been the wrong half of the job. Every
phone that accepted the offer has the passphrase itself in its keychain,
and with the store gone nothing would own it or release it. So the
launch path now deletes those items by service, naming no account so
every handle's goes rather than whichever one is signed in. Deletion
never raises a prompt, and deleting what is not there is a no-op, so it
is safe on every launch.

The two tests that pinned the old store to the main-actor fix are
replaced by one that pins the purge in place, so nobody removes it later
as unused code and leaves the abandoned secrets behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:57:12 -04:00
Paul HittandClaude Opus 5 c28d746d7f Four UI failures: two stale tests, two things the app got wrong
The suite had not actually run in a long time, so nobody knew.

The message menu asserted a thumb among the quick reactions. The set is
heart, cry-laugh, surprise, sad, fire, and has not had a thumb in it for
a long time; the thumb lives in the emoji picker. The menu was fine and
the expectation had rotted.

The app-lock check swept every static text with a predicate while the
screen was still settling, which is what "Timed out while evaluating UI
query" means. It takes one snapshot and searches that now, the same
lesson the recorder test had already learned and written down.

Account Settings was the only row in the profile without an
accessibility identifier, and the only row the suite could not find. Its
neighbours all have one. A label is whatever a row happened to compose
out of a title, an icon and sometimes a subtitle; an identifier is a
promise the view makes.

And the microphone. `start` returns false when permission was refused
AND when permission was given but the audio route would not open, and
the app blamed permission for both. That sends somebody to a Settings
switch which is already on, and it is why a recorder that cannot start
has never been diagnosable: the failure was misreported. The recorder
says which of the two it was now, and the app says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:06:47 -04:00
Paul HittandClaude Opus 5 28ca1a0b25 An unanswered ring releases itself
Dialing and connecting each had a timeout and ringing did not, so a call
nobody picked up stayed .ringing until the app was killed. Nothing
showed it: the banner can be dismissed while the state behind it
survives. The phone then answered the next caller busy while looking
completely idle, with nothing anywhere to explain it.

Sixty seconds, longer than the caller's own forty-five, so this end
gives up on its own only when their hang-up never arrived. It ends with
"timeout", which the far end already renders as "No answer"; an invented
reason would have shown a flat "Call ended".

Android had the identical hole and the identical fix. One of the tests
checks the sibling checkout for it, because if one platform's guard
moves without the other, a call between them behaves differently at
each end.

Found on Android hardware, where an offer at 12:51:07 released itself at
12:52:07 and told the caller, rather than refusing every call after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:56:07 -04:00
Paul Hitt 41b7c2d980 Merge branch 'feat/cross-platform-backup' into 'main'
One backup format, so a phone swap keeps the conversations

See merge request paulhitt/flamenet-messenger-ios!185
2026-09-10 18:47:33 +00:00
Paul HittandClaude Opus 5 9e2ab00804 One backup format, so a phone swap keeps the conversations
Section 16 pinned the envelope, the KDF and the canonical header used as
AAD, and said nothing at all about what goes inside it. So each app
sealed its own model and stamped the file with the platform that wrote
it, and each then refused the other's, in as many words: "import it on a
device of the same kind". Moving between an Android phone and an iPhone
lost every conversation on it.

This is the missing half: one payload shape, written by both and read by
both. It rides inside the envelope the vectors already pin, so the
crypto does not change.

Two decisions carry the weight. Times are epoch milliseconds as
integers, because `Date` encodes as seconds since 2001 while Kotlin
holds millis since 1970, and a file that disagreed about the epoch would
restore every message at the wrong time without failing anywhere. And
encoding is deterministic, sorted keys, because a vector cannot be
pinned against bytes that differ run to run.

Attachments travel whole, not just the fields the common case uses. A
voice note without its duration has no UI, and a document without its
filename and classification comes back nameless and unlabelled, which is
worse than not coming back.

Group secrets carry their roster version. The relay refuses a rewrite
whose version does not increase, so a restore that reset it to zero
would lock the device out of its own group.

Reading falls back to the older per-platform file, decided by shape
rather than by extension, so backups made before this build still open
and a file renamed on its way through a cloud drive is still the file it
was.

Android still writes only its own format, so this does not yet let an
Android backup open on an iPhone. Half the bridge.

0.14.0, build 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:28:06 -04:00
Paul Hitt 12cc5149c5 Merge branch 'feat/ios-callkit-owns-the-call' into 'main'
Let CallKit own the call, not just announce it

See merge request paulhitt/flamenet-messenger-ios!184
2026-09-10 16:12:06 +00:00
Paul HittandClaude Opus 5 4b0117b07d Let CallKit own the call, not just announce it
CallKit was a doorbell. It rang the phone when a VoIP push arrived and
took no further part, which left four things wrong.

Declining from the lock screen did not decline. The end action cleared
the local ring and fulfilled itself, and nothing told the call manager,
so the caller went on listening to a phone that had already said no.

The audio session was never handed over. There was no didActivate and
no didDeactivate, and manual audio was set only on the Simulator, so on
a device WebRTC raced CallKit to bring the session up. Doing that first
is the documented way to get a call that connects and carries no sound.
The unit is now off by default and exactly one thing turns it on:
CallKit for a call it owns, the manager for one placed or answered
inside the app.

Ending a call never told CallKit either, and a call left open there
holds the phone in an in-call state and refuses the next incoming one.

And outgoing calls were invisible to iOS: no Recents entry, no session
ownership, nothing stopping the phone ringing for something else in the
middle of one. They are reported now, connecting and connected.

⚠️ The audio path is reasoned from Apple's and WebRTC's contract and is
NOT observed. A Simulator cannot exercise it, which is why none of this
was noticed. iPhone call audio has never been proven on hardware, and
this is the most likely explanation; it is also the change most in need
of a real device before it is trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:30:32 -04:00
Paul HittandClaude Opus 5 8b67676ece Hand the call key review its inbox rather than the environment
The review is presented from a sheet inside a fullScreenCover, and it
was the only thing behind that cover reaching for an environment object.
An absent one is a crash, not a blank view, and the path it would crash
on is a call that has just failed, which is the worst moment to find
out.

Nothing proved it would. SwiftUI does carry the environment across a
presentation, and the unit suite says nothing either way because it
never builds the view hierarchy. That is the point: the only evidence
was that it ought to work, on a screen reached exactly when something
has already gone wrong.

So it takes the inbox as a plain value, handed down from the tab view
that already holds it. `CallScreenBody` next door does the same thing
for the same class of reason, which is what the convention here already
was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:13:10 -04:00
Paul Hitt 8d24942c5a Merge branch 'chore/next-ios-build' into 'main'
Next iOS build is 14

See merge request paulhitt/flamenet-messenger-ios!182
2026-09-10 14:09:10 +00:00
Paul HittandClaude Opus 5 2af3ce15b8 Next iOS build is 14
0.13.0 build 13 is on TestFlight. The build number moves now rather than
at the next archive, because a duplicate is rejected at the END of an
upload and finding out then costs the whole round trip.

MARKETING_VERSION deliberately stays where it is. It moves when a
release is called, not when a fix lands. Build 14 cannot collide
whatever it ends up carrying, and if that turns out to be a new version,
0.14.0 puts the two back in line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:01:39 -04:00
Paul Hitt f4369ef5e3 Merge branch 'fix/ios-73-call-key-review' into 'main'
Offer the key review on the call screen, not one screen further on

See merge request paulhitt/flamenet-messenger-ios!181
2026-09-10 13:29:30 +00:00
Paul HittandClaude Opus 5 0c155d532e Offer the key review on the call screen, not one screen further on
ios#73. A call refused by the trust gate already said so, and already
offered a way to the review. The way led to the conversation.

That is the same gap one screen along. The reason the conversation
banner does not cover this case is that a call started from the Calls
tab, or answered from a notification, never passes through a
conversation; being sent to one to find out why a call failed is the
complaint, not the cure.

So the review is presented in place. It carries the two readings the
banner carries, the ordinary one and the one that matters, and it puts
the safety number on the screen rather than behind another tap, because
comparing the number is the thing that actually settles the question.

This reverses a decision the previous pass wrote a test for. That test
argued a hurried decision is better made somewhere else, so the call
screen pointed at the conversation and offered no accept. The issue
argues the other way, and the issue is right: a decision taken in a
hurry is the reason to say what it means, not the reason to hide it,
and an extra screen in front of the same tap slows nobody down. The
requirement therefore moves rather than disappears, from "no accept
button" to "no accept button without the sentence saying what accepting
costs", and the test now holds it to that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:23:47 -04:00
Paul Hitt c377c48b1d Merge branch 'release/0.13.0' into 'main'
0.13.0, build 13

See merge request paulhitt/flamenet-messenger-ios!180
2026-09-10 13:07:13 +00:00
Paul HittandClaude Opus 5 c6f0caa20d 0.13.0, build 13
The build number now carries the release it belongs to rather than
counting uploads, so 0.13.0 is build 13. Set here and nowhere else: the
.xcodeproj is gitignored and regenerated, so a version typed into
Xcode's UI survives until the next generate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:00:53 -04:00
Paul Hitt be5504d56d Merge branch 'fix/spent-proof-retry' into 'main'
Sign a request again rather than throwing the token away

See merge request paulhitt/flamenet-messenger-ios!179
2026-09-10 12:44:40 +00:00
Paul HittandClaude Opus 5 3acf2133a0 Sign a request again rather than throwing the token away
A proof of possession is single use, and the request that spends one is
often not a request this app made. URLSession replays an idempotent GET
onto a new interface when the phone moves between wi-fi and cellular,
headers and all, so the relay sees the proof twice and refuses the
second, correctly.

Every refusal arrived as a bare `unauthorized`, and the sensible reading
of that is "your token is bad", so the app discarded a perfectly good
token and minted another before it could carry on. Measured on a real
iPhone: it flapped between wi-fi and cellular twice while a call was
being set up, and each flap put a token mint in front of the signalling.

A spent proof is now its own case: sign the request again, keep the
token. The relay names it, and a relay too old to name it still says so
in words, so this works against production as it stands today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:38:18 -04:00
Paul Hitt e5aaf92a55 Merge branch 'feat/area-picker' into 'main'
Areas can be chosen, and named

See merge request paulhitt/flamenet-messenger-ios!178
2026-09-10 11:56:39 +00:00
Paul HittandClaude Opus 5 1c89eb2200 Areas can be chosen, and named
The engine has always carried compartments and LabelChip already rendered
them, falling back to 'Area 3' for a bit it has no name for. No screen let
anybody set one, so a label was half-settable and the public page promised
both halves.

The bits travel with the file; the names are this device's. Nothing is named
for you, and forgetting a name does not free the bit: material already filed
under it still carries it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:56:07 -04:00
Paul Hitt 3363358506 Merge branch 'fix/push-and-biometric-write' into 'main'
A locked iPhone can be rung, and enrolling Face ID writes

See merge request paulhitt/flamenet-messenger-ios!177
2026-09-10 11:46:45 +00:00
Paul HittandClaude Opus 5 8a8cd6a735 A locked iPhone can be rung, and enrolling Face ID writes
Three faults, all found on a physical device.

PushKit was started inside the sign-in path. iOS launches the app in the
background to deliver a VoIP push and wants a call reported almost at once;
the registry did not exist yet, so the handler was never called, nothing was
reported, and iOS killed the app. The call appeared only when somebody opened
it by hand. It now starts at launch.

A token issued before the upload closure was wired was dropped, with no
second chance. Both registrars now hold it and send it when there is somewhere
to send it, and keep it pending if the upload fails.

Saving the Files passphrase ran on the main actor, which is where the prompt
it blocks on has to be drawn. The read was fixed and the write was not, which
is why enrolling still did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:46:11 -04:00
Paul Hitt 5a86a3ba09 Merge branch 'release/next-build-number' into 'main'
Next iOS upload is build 2

See merge request paulhitt/flamenet-messenger-ios!176
2026-09-10 00:01:10 +00:00
Paul HittandClaude Opus 5 e1d528a46b Next iOS upload is build 2
Build 1 of 0.12.0 is on TestFlight.

Bumped when the previous build goes up rather than when the next one is
archived: a duplicate build number is rejected at the END of an archive, so
the whole round trip is spent learning it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:00:52 -04:00
Paul Hitt 56a1fda0a7 Merge branch 'release/0-12-0' into 'main'
iOS is 0.12.0

See merge request paulhitt/flamenet-messenger-ios!175
2026-09-09 22:26:13 +00:00
Paul HittandClaude Opus 5 60c66e8935 iOS is 0.12.0
Build numbering starts fresh at 1: build numbers are unique within a
marketing version and 0.12.0 has never been uploaded, so this is not a step
backwards from 0.11.0's build 2.

The comment no longer claims to know whether 0.11.0 build 2 was uploaded.
It is not knowable from this repository, and the file should not assert it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:25:55 -04:00
Paul Hitt 3c09529ddd Merge branch 'feat/backup-swift-port' into 'main'
Encrypted backups in Swift, against the published vectors

See merge request paulhitt/flamenet-messenger-ios!174
2026-09-09 22:10:48 +00:00
Paul HittandClaude Opus 5 40de9e95ea Encrypted backups in Swift, against the published vectors
Spec §16, ported from the JavaScript engine and checked against pinned bytes
rather than against that engine's agreement.

Three things the JavaScript would not have surfaced:

WebCrypto appends the GCM tag to the ciphertext and CryptoKit keeps them
apart, so the two engines disagree on ct while each is individually correct
unless they are concatenated here.

The AAD is built by interpolation, not by an encoder. JSONEncoder is the
obvious choice and would silently disagree on key order, and the AAD is what
stops a file being edited into deriving a cheaper key.

The payload crosses the API as bytes, not as a model. Round-tripping through
a Swift type would reorder JSON keys and stop matching what another engine
sealed.

No UI yet: §16.2 requires the passphrase-strength warning and §16.1 requires
saying that peers will see a new device, and both are wording rather than
plumbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:58:17 -04:00
Paul Hitt a196d14533 Merge branch 'docs/link-the-site' into 'main'
Link the site from the README

See merge request paulhitt/flamenet-messenger-ios!173
2026-09-09 20:59:30 +00:00
Paul HittandClaude Opus 5 84cd4f7e1e Link the site from the README
None of the four repository READMEs linked to flamenetmessenger.com. The
only occurrence of the domain was inside a security contact address, which
is not a link and is not what somebody arriving at the repository needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:58:52 -04:00
Paul Hitt 65017c4f29 Merge branch 'shots/state-setup' into 'main'
Capture the screenshots that need the app to be in a state first

See merge request paulhitt/flamenet-messenger-ios!172
2026-09-09 20:00:13 +00:00
Paul Hitt 6d5fdddc1c Merge branch 'files/star-property-documented' into 'main'
Both halves of the model, and which one is enforced

See merge request paulhitt/flamenet-messenger-ios!171
2026-09-09 20:00:09 +00:00
Paul HittandClaude Opus 5 e231af461e Capture the screenshots that need the app to be in a state first
A fresh install has no contact, no story, no wallet and no vault, so the
plain tab set photographs empty screens. This signs up, builds each state
through the real screens, and captures.

Four things the existing tooling hid:

- Save Password? is a different panel from Use Strong Password?, and arrives
  after the form is submitted. Left up it covered every shot after sign-in.
- A safety number comes from the session, so a contact who has just been
  added has none. The profile renders a gap where the digits go.
- Wallet sits behind the same vault as Files. WalletUITests never meets that,
  because it launches with a preview flag that skips the lock.
- A filter matching no test class exits 0 with TEST SUCCEEDED. Both scripts
  now assert on the log that a case actually ran.

No literal passphrase anywhere: the suite skips without the variable and the
script generates one, because these repositories are public and the account
is created on the live relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:59:31 -04:00
Paul HittandClaude Opus 5 bf2528c21f Both halves of the model, and which one is enforced
Kotlin documented no-read-up and no-write-down and pinned the gap in a test.
Swift had only permits, undocumented, so the platform that is the source of
truth was the one saying nothing.

Swift now has mayRead and mayWrite with the same wording, and ClearanceTests
mirrors LabelTest, so enforcing it on one side fails the other and forces
both into the same commit.

The label picker gains the boundary in the place somebody is choosing a
label: it decides who can open a file, not where the file can be put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:59:22 -04:00
Paul Hitt 37a297e8bc Merge branch 'fix/one-reaction-set' into 'main'
One reaction set, offered everywhere reactions are

See merge request paulhitt/flamenet-messenger-ios!170
2026-09-09 18:53:09 +00:00
Paul HittandClaude Opus 5 4e69f679db One reaction set, offered everywhere reactions are
The chat had six of its own, the only place with a thumbs-up and the only
place without a flame. The stories feed and both Android screens already
agreed on five; the chat now agrees with them.

Five also fits the menu row, which the sixth did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:52:51 -04:00
Paul Hitt dd91b57ef8 Merge branch 'fix/device-only-defects' into 'main'
Four things that only go wrong on a phone, and a real screenshot

See merge request paulhitt/flamenet-messenger-ios!169
2026-09-09 15:29:30 +00:00
Paul HittandClaude Opus 5 a4cfdd52ec Capture the conversation screenshot from the running app
Two simulators, two accounts, a scripted exchange with each side waiting for
the other's line. One install talking to itself shares a keychain, a device
identity and a ratchet, so it cannot stand in for two people.

Also gives the thread a way to put the keyboard away. It had none: from the
first tap on the composer the keyboard covered half the messages until the
chat was left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:27:27 -04:00
Paul HittandClaude Opus 5 855f80f6c3 Three things that only go wrong on a phone
The lock screen asked for Face ID from a plain .task guarded on the scene
being active. Both a cold launch and a return from the background render it
while the scene is still inactive, so the guard took its early return and
nothing ever asked again: a lock screen showing a Face ID glyph and doing
nothing until tapped.

Reading the Files and wallet passphrase back blocks in SecItemCopyMatching
until the person answers the prompt, and it ran on the main actor, which is
where that prompt has to be drawn. The simulator has no Secure Enclave and
returns at once, so this only ever failed on a phone.

Six reactions in a ControlGroup wrap after five, putting the sixth alone on
a second line.

Also raises the relay health check timeout the UI suites gate on. Three
seconds is not enough on a loaded machine, and six suites skip while
reporting the relay as down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:53:15 -04:00
Paul Hitt 8c1cc80bde Merge branch 'fix/a-call-blocked-by-keys-offers-the-cure' into 'main'
Cover the call key-change path

See merge request paulhitt/flamenet-messenger-ios!165
2026-09-09 13:50:43 +00:00
Paul Hitt 81cddc01dc Merge remote-tracking branch 'origin/main' into fix/a-call-blocked-by-keys-offers-the-cure 2026-09-09 09:45:44 -04:00
Paul Hitt 7d24abe533 Merge branch 'docs/no-dated-self-corrections' into 'main'
Cut the dates and the was-then-now framing

See merge request paulhitt/flamenet-messenger-ios!168
2026-09-09 13:38:22 +00:00
Paul Hitt e1a82c91e3 Merge branch 'feat/screenshots-with-the-navbar' into 'main'
Screenshots of whole screens, with the bar

See merge request paulhitt/flamenet-messenger-ios!167
2026-09-09 13:37:34 +00:00
Paul HittandClaude Opus 5 15b9d5b44a Say what is true, without dating it
Comments and docs kept dating themselves: when a constraint was set,
when a pair was verified, what a limit was before some day, which day a
subtree was split out. A reader wants the current state, and the date
goes stale faster than the sentence around it.

Kept, because they are data rather than narration: ISO timestamps in
test fixtures, the certificate expiry in project.yml, which an operator
acts on, and docs/BUGS-2026-09-01.md, whose name is the record.

⚠️ The first pass collapsed indentation. A whitespace rule meant for
prose matched YAML list items and markdown continuation lines, and
project.yml stopped parsing: "did not find expected key: - path:
Sources". The tests still passed, because they ran against the project
generated before the break, which is exactly how this would reach
somebody else. Reverted and redone without touching leading whitespace,
then xcodegen was run to prove the file parses.

764 XCTest and 75 Swift Testing pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 09:27:20 -04:00
Paul HittandClaude Opus 5 8252054d0a Shoot whole screens, with the tab bar
The existing shots are components on a bare ground: bubbles, a story
card, the bar by itself. Good for looking at a control, wrong for a
store listing, where the bar is what makes a picture look like an app
rather than a mockup.

Five screens under the real PillTabBar with the right tab lit:
Messages, Stories, Files, Wallet, Account.

The items are built inside the helper rather than stored on the test.
`Item` is nested in a generic, so `PillTabBar<A>.Item` and
`PillTabBar<B>.Item` are different types and one property cannot be
handed to both.

Files uses the real LabelChip, so the classification on that row is the
component the app draws rather than a drawing of it.

764 XCTest and 80 Swift Testing pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:57:55 -04:00
Paul Hitt 013eb5266e Merge branch 'docs/app-store-listing' into 'main'
App Store listing, and screenshots at a size the store accepts

See merge request paulhitt/flamenet-messenger-ios!166
2026-09-09 12:48:15 +00:00
Paul HittandClaude Opus 5 8051d38009 App Store listing copy, and screenshots the store will accept
`Scripts/screenshots-ios.sh` rendered at 1125x2778... 1125x2436, the
375x812 design canvas at 3x. App Store Connect takes 1242x2688,
2688x1242, 1284x2778 or 2778x1284 and nothing else, so every shot was
rejected on size. The aspect ratios are close enough that rescaling
looks fine and is still not one of those numbers.

`FNMSG_SHOT_SIZE=1284x2778` now asks for a pixel size. Points are
derived from it at a pinned 3x, so the layout gets the room a device
that size really has rather than a scaled-up picture of a smaller one,
and the same request gives the same pixels on any simulator. Unset, the
design canvas is unchanged, which is what it is for.

⚠️ Getting the value into the test took three attempts, recorded in the
script so the next person skips them: `FOO=bar` on an xcodebuild line is
a build setting the test never sees, and the TEST_RUNNER_ prefix only
reaches a UI test's runner, not unit tests hosted in the app. Both
arrived unset. `simctl spawn launchctl setenv` works, and the script
unsets it when no size is asked for, or a previous sized run would
silently apply to the next one.

STORE-LISTING.md carries the rest of the App Store tab: promotional
text 149/170, description 2358/4000, keywords 97/100, support and
marketing URLs, copyright, and why the routing coverage file stays
empty. Every URL in it returns 200. It names no competitor, and says
plainly that the app has not been audited.

764 XCTest and 75 Swift Testing pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:43:21 -04:00
Paul HittandClaude Opus 5 a51879a8cb Cover the call blocked by a key change
ios#73 asked for a test on at least one platform. iOS already had the
behaviour: both call paths name the trust gate, the failure says "Their
security code changed", and the ended-call screen offers "Review keys",
which opens the conversation where the compare and accept banner lives.
None of it was covered.

Five tests, including one that pins the deliberate omission: accepting
keys is NOT a one-tap action on the call screen. It routes to the banner,
which words the decision to the standard the issue asks for; a second
accept button phrased on a call screen in a hurry is what that
requirement exists to prevent.

⚠️ The first version of that test asserted the screen contained no
`label: "Accept"`, which fails on the answer-the-call button. Tightened
to the key-accepting names.

764 XCTest and 80 Swift Testing pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:11:02 -04:00
Paul Hitt 7760ece24f Merge branch 'fix/restore-the-status-markers' into 'main'
Put the relay coverage table back

See merge request paulhitt/flamenet-messenger-ios!164
2026-09-09 02:13:19 +00:00
Paul HittandClaude Opus 5 b9bea0ece2 Put the relay coverage table's values back, as words
My emoji sweep emptied the iOS and Android columns of the "both
platforms, all four relays" table, so it recorded nothing. Restored as
words, including the one cell that was not a tick: relay3 was never
exercised from iOS and now says n/a rather than being blank like the
rest.

The other files that lost marks are unaffected in meaning: in
SCOPING-v1.md, TESTFLIGHT.md and ROADMAP.md every mark was followed by
its own sentence, so only the glyph is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:09:54 -04:00
Paul Hitt f003b73ff3 Merge branch 'feat/story-lifetime-has-a-default' into 'main'
Give the story lifetime a home in Settings

See merge request paulhitt/flamenet-messenger-ios!163
2026-09-09 01:25:33 +00:00
Paul HittandClaude Opus 5 a283daf2f7 Give the story lifetime a default, and somewhere to set it
The setting existed and worked, but only inside the story composer: the
audience sheet and the text composer each offered it while posting. So
there was no way to set a default, and no way to find out what the
current one was without starting a story and abandoning it.

It is now in Settings > Chat, reading through StoryLifetime rather than
the raw key, because an unset preference is 0 and 0 is not a lifetime.
The composer still overrides it for one story.

The footer says the expiry travels inside the story itself, which is
what makes it the poster's choice rather than a rule the relay is asked
to keep.

Android has the same gap and is not fixed here.

4 new tests, one of which fails if Settings and the two composers stop
sharing a single list of choices. 764 XCTest and 75 Swift Testing pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:20:59 -04:00
Paul Hitt 081c5d68c7 Merge branch 'chore/read-like-a-codebase' into 'main'
Read like a codebase

See merge request paulhitt/flamenet-messenger-ios!162
2026-09-09 00:28:21 +00:00
Paul HittandClaude Fable 5.1 c92513ee1d Merge main and re-derive the formatting pass
Conflicting files take main's version and the pipeline is re-run on
them. project.yml keeps main's 0.11.0 (2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:21:24 -04:00
Paul Hitt 2d1f6bf48b Merge main 2026-09-08 20:19:26 -04:00
Paul Hitt babdfc499b Merge branch 'fix/a-photo-must-not-turn-on-its-side' into 'main'
Prove iOS does not turn photos on their side

See merge request paulhitt/flamenet-messenger-ios!161
2026-09-09 00:09:23 +00:00
Paul Hitt 9af8e5b1b8 Merge branch 'fix/a-passphrase-that-is-the-username' into 'main'
A passphrase that is the username makes the identity public

See merge request paulhitt/flamenet-messenger-ios!160
2026-09-09 00:09:20 +00:00
Paul Hitt d2e94368d7 Merge branch 'fix/your-own-relay-is-not-a-foreign-one' into 'main'
Your own relay is not a foreign one

See merge request paulhitt/flamenet-messenger-ios!159
2026-09-09 00:09:17 +00:00
Paul Hitt 5348f8f863 Merge branch 'release/ios-1-0-build-2' into 'main'
iOS 0.11.0 (2), in step with Android v11

See merge request paulhitt/flamenet-messenger-ios!158
2026-09-09 00:09:14 +00:00
Paul HittandClaude Fable 5.1 55ef15ab9a API-style comments and swift-format
Every comment is reduced to its first sentence plus Parameter, Returns
and Throws tags. Comment lines: 20670 to 7626. Apple's swift-format
applied to every Swift file outside the Obfs4 submodule with a
committed .swift-format: four-space indentation, 100 columns, imports
ordered.

Three test expectations compared source text and depended on line
wrapping; they now compare whitespace-collapsed text.

Comments and formatting only. 764 XCTest and 55 Swift Testing pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:06:23 -04:00
Paul HittandClaude Fable 5.1 2585c4e9f9 Second pass with the avoid-ai-writing detector
The skill's detector scored the README, DESIGN.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 3 out of 100 across the repo.

One sweep rule mangled two comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.

Comments and strings only. 764 XCTest and 55 Swift Testing pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:20:01 -04:00
Paul HittandClaude Fable 5.1 f911d13baf Remove machine-writing tells from comments and docs
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, project.yml and
the markdown docs. Counted before and after:

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

Nothing about behaviour changed. The diff over Sources/ is comments and
string literals only, checked by filtering the diff for anything else,
and the unit suite passes before and after: 764 XCTest and 55 Swift
Testing.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:52:02 -04:00
Paul HittandClaude Opus 5 6bd06c33ad Pin that re-encoding a photo does not rotate it
A user reported a photo auto-rotating. The suspicion was this app's own
doing: every image sent is re-encoded to strip EXIF — right, because
that block carries the GPS coordinates the shot was taken at — and the
same block carries the Orientation tag. A phone camera rarely writes
upright pixels; it writes landscape pixels plus "turn this 90°". Strip
the tag without baking the rotation in and every portrait photo lands on
its side.

That is not what is happening here. Both re-encode paths draw through
UIKit, which applies imageOrientation, so the output is upright pixels
with no tag needed. The reporter has since confirmed iOS is fine.

Tests anyway, because the failure is invisible until somebody sends a
photo and only reproducible with a real camera file:

🔴 The fixture is built with ImageIO, not UIImage.jpegData(). That was
the first attempt and it made the suite vacuous — jpegData() BAKES the
rotation and writes Orientation = 1, so the bytes under test were
already upright and every assertion passed without exercising anything.
The tag has to be written deliberately. One test now asserts the fixture
itself carries Orientation = 6 over landscape pixels, so the suite fails
loudly if it ever stops testing what it claims to.

Also covers the mirror-image bug: an upright photo must not be rotated
BY the fix.

4 new tests; 764 XCTest + 65 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:47:30 -04:00
Paul HittandClaude Opus 5 05c3a82385 Refuse a passphrase that is the username
Reported by a user: the passphrase could be set to the username.

🔴 This is not the usual weak-password argument, and the usual
reasoning does not reach it. For a relay-local account the KDF salt is
HKDF(lowercased(username)) — a pure function of a name that is public by
design, because it is how people find each other and because a new
device has to derive it offline before it can talk to anything. The
account key is PBKDF2(passphrase, salt), and the address is that key's
public half.

So when the passphrase IS the username, every input is public. Deriving
the identity is one PBKDF2 run rather than a search: the 1.3 million
iterations buy about a second, once, against a guess that is already
correct. Whoever runs it can claim the address and sign as that person
— and the thing they take is the one this product says no server can
take away.

Refused at sign-up, through the same list that already explains why the
button is disabled, so the refusal cannot be silent. It gives the real
reason rather than generic password advice, because the real reason is
what makes it worth retyping.

⚠️ Near misses are refused too. "sunshine1" against the username
"sunshine" is ten guesses; a search space small enough to run over lunch
is not meaningfully different from none. It stops at 8 added characters,
so a memorable phrase that happens to contain the username is still
allowed — pushing people off passphrases toward worse ones would cost
more than it saves.

⚠️ Normalisation matches the KDF's exactly. The salt lowercases and
trims the handle, so "Sunshine" derives the same key as "sunshine"; a
check that disagreed would refuse the safe spelling and permit the
unsafe one.

🔴 KNOWN GAP, not fixed here: this is a client-side check and therefore
bypassable, and it does nothing for accounts that already exist. The
authoritative gate belongs in the signup handler, which is not in any
local checkout — flamenet-api has no messenger routes and
flamenet-accounts-hosting is deploy-only here. Android needs the same
rule. Both are follow-ups.

6 new tests; 764 XCTest + 61 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:41:20 -04:00
Paul HittandClaude Opus 5 663881ba91 Tell a scanned address on your own relay apart from a foreign one
Reported from a physical iPhone: scanning a QR code offered "Open
conversation", opened a cross-relay screen, and returned 403.

Nothing in the app ever compared the scanned relay to the one the device
is signed in to. So every scanned card took the federated path — which
the relay refuses unless federation is open — including an address on
the same relay as the scanner, which is the likeliest thing anybody
scans while testing with two accounts.

Three outcomes now, decided in this order:

- your own address says so, compared through canonical() so a
  difference in case or spacing is still yours;
- an address on your own relay says they are on your relay and that
  people here are added by username, because refusing with nothing to
  try is what the 403 already did;
- anything else takes the cross-relay path as before.

The host comparison is case-insensitive, as hostnames are — an exact
string compare would have sent RELAY.FLAMENET.IO abroad.

This does not open federation. A genuinely foreign address still needs
FN_FEDERATION, which is a policy decision; it stops a local one being
sent somewhere it never had to go.

6 new tests; 764 XCTest + 61 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:35:49 -04:00
Paul HittandClaude Opus 5 589b241038 Version iOS as 0.11.0, in step with Android
The repo said 1.0 while TestFlight had 0.11.0, and 0.11.0 has never
existed in this file — so the shipped version was typed into Xcode's
UI. The .xcodeproj is gitignored and regenerated by Scripts/generate.sh,
so that setting survived exactly until the next generate. The version
that shipped was not reproducible from the repo.

project.yml is now the only place it is set, and it says 0.11.0 to
match Android's versionName "0.11" (versionCode 13) — the same release,
so the same number. Build 2, because build 1 of 0.11.0 is already up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:19:15 -04:00
Paul Hitt 5f9171ce3a Merge branch 'feat/files-let-people-set-the-label' into 'main'
Files: choose the classification; confirm biometrics enrolled

See merge request paulhitt/flamenet-messenger-ios!157
2026-09-08 21:10:17 +00:00
Paul Hitt 26b4e26302 Merge branch 'fix/call-a-person-by-their-name' into 'main'
Call people by their name; don't lose a scan between tabs

See merge request paulhitt/flamenet-messenger-ios!156
2026-09-08 21:09:37 +00:00
Paul HittandClaude Opus 5 a5f305a570 Match Android: a folder's level does not constrain its contents
The first version of the picker made the parent folder's level a floor.
That is the ★-property reading and defensible on its own — but Android
has shipped the opposite since its level picker landed, and says so on
the screen in as many words: "A folder's level doesn't limit what you
put inside it — each file keeps its own."

Shipping the floor on iOS only would have left the two apps enforcing
different security rules while drawing the same chip, which is worse
than either rule. Whether the ★-property should be enforced at all is
ios#65 / android#15, open against both, and it should be answered for
both at once rather than by whichever screen was edited last.

Also matches Android's default of level 1 rather than 0. Starting every
item at Unclassified is how everything ended up Unclassified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:47:43 -04:00
Paul HittandClaude Opus 5 2fd77e3ff7 Files: let people set the label, and say when Face ID is on
Both reported from a physical iPhone.

**Everything was Unclassified and nothing could change it.** The vault
has taken a Label since it was written — add, reference and
createFolder all require one — but every call site passed
SecurityLabel(classification: 0). So the browser drew a classification
chip on every row that could only ever read the same word, and the
thing the whole system exists for could not be set.

There is now a picker, used when a folder is made and before files are
imported. It is asked BEFORE the importer opens: there is no way to ask
during a fileImporter, and asking afterwards would mean sealing at one
label and re-sealing at another.

🔴 The parent folder's level is a floor. A Confidential folder holding
an Unclassified file is not untidy filing — it is the label failing to
mean anything, because a reader refused the folder would be granted the
file inside it. Levels below the parent are not offered, and the footer
says why rather than silently dropping choices.

Relabelling an existing item is deliberately NOT here. The label is
bound into the sealed bytes as AAD, so changing it means re-encrypting,
and lowering one is a declassification — a decision with a policy
behind it, not a control on a sheet.

**"Use Face ID" appeared to do nothing, and it had every reason to
look that way.** Writing a keychain item guarded by biometry does not
prompt for a face — authentication happens on the read, next time. So
enrolment ran to success with no scan, no confirmation, and no visible
change, because the vault was already open and even the unlock button
it enables was not on screen. It now confirms, and says the passphrase
still works.

6 new tests; 764 XCTest + 51 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:43:26 -04:00
Paul HittandClaude Opus 5 fe3477d10f Say who is calling, and don't drop a scan between tabs
Both reported from a physical iPhone.

**"User #124 calling" instead of Sunshine.** When the relay serves an
empty display_name the client fell back to the account number — while
the username sat unread in the same row. That string is not a harmless
list placeholder: it is what CallKit announces. The fallback is now
display name, then username, then the number as a last resort, and
whitespace no longer counts as a name.

**Names reached the inbox only from Home's .onAppear.** So a phone woken
by a call before Home had ever been drawn — a cold start, a locked
phone, a fresh install, which is most of the cases a call arrives in —
had an empty name table and announced "Buddy 124". Publishing now
belongs to the roster load, both paths, and an empty roster no longer
wipes names already known.

**A scan from the Messages tab did nothing at all.** It set the address
and switched to Home, but Home observed it only through .onChange — and
a tab SwiftUI has not built yet cannot observe a change that already
happened. On the first scan of a launch the address sat in the
environment and nothing appeared. It reads exactly like a scanner that
failed. Home now also takes it on appear, and clears it as it consumes
it so returning later does not re-fill the box.

4 new tests; 764 XCTest + 49 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:35:57 -04:00
Paul Hitt 0994394c54 Merge branch 'fix/say-why-create-account-is-greyed-out' into 'main'
Say why Create account is greyed out

See merge request paulhitt/flamenet-messenger-ios!155
2026-09-08 20:25:39 +00:00
Paul HittandClaude Opus 5 d905f37efe Say why Create account cannot be pressed
Reported from TestFlight on a physical iPhone: the button would not
press and there was nothing on screen saying why. Five separate
conditions disabled it and none of them were named — the only signal
was the button's colour.

The one actually hit was a mistyped passphrase. Both fields are dots,
so there is nothing to compare by eye: the person could not have
diagnosed it from anything on the screen.

- canSubmit is now defined AS the list of what is outstanding being
  empty, so the two cannot drift. A sixth condition added later is
  named automatically rather than silently greying the button.
- The list sits under the button, where somebody who just tried to
  press it is already looking, phrased as what is still needed rather
  than what is wrong — nobody has made a mistake, they are part-way
  through a form.
- A mismatch is also flagged at the confirm field itself, once the
  second field has something in it so it does not accuse somebody
  mid-type.
- Too short, not typed again, and not matching are told apart. Saying
  "they don't match" when one is simply too short sends somebody to
  retype a field that was fine.

The year picker was the other trap, and worse than it looked: it is
the one required field that does not look empty when it is. It read
"Choose…", which parses as a value that has been chosen; it sat last
in a scrolling form where the keyboard covers it; and it moved further
off-screen when the passphrase note landed above it yesterday. It now
reads "Not chosen yet", is marked amber while unset, and comes before
the password fields instead of after them.

7 new tests; 764 XCTest + 45 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:13:46 -04:00
Paul Hitt b48093e802 Merge branch 'feat/say-when-the-device-is-jailbroken' into 'main'
Say plainly when the device is jailbroken

Closes #42

See merge request paulhitt/flamenet-messenger-ios!154
2026-09-08 20:07:32 +00:00
Paul Hitt d4a52f9328 Merge branch 'fix/a-handover-probe-is-not-a-test-failure' into 'main'
A handover probe with no peer is a skip, not a failure

See merge request paulhitt/flamenet-messenger-ios!153
2026-09-08 20:07:25 +00:00
Paul Hitt 3a84373e0c Merge branch 'fix/a-full-disk-is-not-silent' into 'main'
A full disk is not silent

Closes #46

See merge request paulhitt/flamenet-messenger-ios!152
2026-09-08 20:07:18 +00:00
Paul HittandClaude Opus 5 8dc72204dc Say when this iPhone is jailbroken, and never act on it
Closes #42.

The app's promises about keys and history at rest rest on iOS keeping
other apps out of its container. On a jailbroken phone that separation
is not guaranteed, and somebody deserves to know that before deciding
what to say here.

Three checks: jailbreak artifacts on disk (including /var/jb — a list
that only knows about Cydia has not been looked at since 2019), a
sandbox probe that tries to write outside the container, and whether a
package manager's URL scheme answers.

🔴 It is a notice, never a gate. Nothing branches on it — no refusal,
no degraded mode, no message withheld — and a test enforces that by
walking Sources/ and failing if anything other than the screen that
prints it so much as mentions DeviceIntegrity. Detection is defeatable
by anyone who wanted to defeat it, so the moment it became load-bearing
the app would be trusting a signal that lies to exactly the person it
was meant to stop.

The copy says what actually changes and no more: software on this phone
may be able to read the keys and history, messages are still encrypted
in transit, the people you talk to are unaffected, nothing is being
blocked, and this check can be wrong.

Two things the tests caught rather than my assumptions:

- LSApplicationQueriesSchemes was hand-added to Resources/Info.plist,
  which xcodegen regenerates — so it vanished at the next build. Without
  it canOpenURL answers false whatever is installed, and the check would
  report a clean phone it never examined. It now lives in project.yml.

- The doc comment claimed the simulator trips every check. Measured: it
  trips exactly one (/usr/sbin/sshd exists in the runtime root), the
  sandbox genuinely holds there, and no package manager answers. One
  false positive still justifies the simulator guard — it would put the
  warning on every development run — but the comment now says what was
  measured, and a test asserts the guard is still load-bearing so it
  gets removed deliberately rather than kept out of habit.

7 new tests; 764 XCTest + 28 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:05:27 -04:00
Paul HittandClaude Opus 5 7c2c3a93da Skip the handover probe when there is no peer to hand over to
ReadyForHandoverUITests is a remote control for a paired two-device
session, not a check of this build: the thread it looks for exists
because the OTHER device sent something through a running relay. With
no relay up there is nothing to drive, and it reported that as a
failing test.

So the iOS suite has been ending red on every local run for weeks —
two failures beside 51 honest skips — which is how a suite stops being
read. It now skips with the reason, like the 51 others, and the run
ends green when the app is actually fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:55:41 -04:00
Paul HittandClaude Opus 5 b61b31b494 Tell people when history has stopped saving
Closes #46.

`E2EHistoryStore.save()` wrote with `try?`. A write that could not
happen was discarded and the app carried on: the message stayed on
screen, everything looked normal, and every message since the last
successful save was gone at the next launch — with nobody told while
they could still have freed space.

The store now catches the write, distinguishes out-of-space
(NSFileWriteOutOfSpaceError, and POSIX ENOSPC for when the lower layer
is the one that noticed) from anything else, and publishes it through
E2EInbox.historyWriteFailure. The callback is edge-triggered, so it
fires when saving starts failing and again when it recovers, rather
than once per write — three failing saves a second must not raise
three alarms.

A bar appears at the top of Home and Messages saying that messages
still arrive but cannot be saved and will be gone at the next launch,
and how to fix it when it is space. The same condition is repeated in
Settings › Data & Storage, which is where somebody goes after seeing
the bar. It renders nothing when nothing is wrong.

The corrupt-store outcome the issue feared was already prevented by
`.atomic` — a failed write leaves the previous snapshot intact. There
is now a test pinning that, so nobody drops the option.

Tests drive the real write(to:) against an unwritable directory rather
than a mocked filesystem: a stub would have agreed with the old code,
since the bug was that the real call's failure was thrown away.
10 new tests; 764 XCTest + 31 Swift Testing green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:50:25 -04:00
Paul Hitt 015cafee75 Merge branch 'feat/say-what-the-passphrase-carries' into 'main'
Say what the passphrase carries, at sign-up (ios#13.6)

See merge request paulhitt/flamenet-messenger-ios!151
2026-09-08 19:03:00 +00:00
Paul HittandClaude Opus 5 59422f2686 Say what the passphrase carries, at sign-up (ios#13.6)
Sign-up stated the rule -- "at least 8 characters" -- and never the stakes.
This passphrase is not a password: the address is DERIVED from it, so it is
the identity rather than a way into an account, and with the encrypted
backup (ios#17) it is also the only key to that file.

⚠️ Not a strength meter. A bar that turns green teaches people to satisfy
the bar. Two lines instead: what it is for, and what a better one looks
like -- "a few unrelated words", the same words the backup sheet uses, with
a test that fails if the two screens ever drift apart. Somebody should not
read one story at sign-up and a different one when it matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:56:54 -04:00
Paul Hitt d106606e4c Merge branch 'feat/gate-key-material-screens' into 'main'
Gate key-material screens, and say the other half of the backup trade (ios#40, ios#17)

Closes #40

See merge request paulhitt/flamenet-messenger-ios!150
2026-09-08 18:52:00 +00:00
Paul HittandClaude Opus 5 3693a31ffa Say the other half of the backup trade (ios#17)
⚠️ The feature already existed. `E2EHistoryExport` seals the history snapshot
under a passphrase-derived key -- PBKDF2, random 16-byte salt, AES-256-GCM,
versioned envelope -- with import that merges rather than replaces, and it is
reachable from Settings > Data & Storage. The issue's premise that this
needed building was stale.

What was missing was the trade the issue actually names: "a weak passphrase
becomes a weak backup, and that must be said plainly rather than buried."

The sheet warned about FORGETTING the passphrase and said nothing about
somebody else guessing it. That is the wrong half to say alone: the file is
MEANT to leave the phone -- a drive, a cloud folder -- so whoever ends up
with it can guess at it offline, unlimited and unobserved, with nothing
watching and no rate limit. It now says so.

⚠️ The 6-character floor stays. Raising it would block somebody mid-typing
for a rule they cannot see. But "allowed" and "strong" are different things
and only one was being said, so anything under 16 now gets a plain note --
and names the shape of a better passphrase rather than scolding, because
"use a strong password" tells nobody anything they can act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:47:10 -04:00
Paul HittandClaude Opus 5 1c48af5e69 Gate the screens that show key material (ios#40)
App lock guards the app. It does not guard the screens inside it, and the
app stays open -- so somebody handed an unlocked phone for a moment can read
a safety number or remove a device.

`SensitiveScreen` covers a screen until the phone's owner proves it, and is
applied to exactly two places: the safety number (what somebody compares to
decide they are really talking to who they think) and the device list (the
one screen here with an action another person could take against the owner).

⚠️ Friction belongs on screens people rarely visit. A test enumerates every
use of the gate and fails on anything outside that allowlist -- if this ever
wraps a conversation, the friction is in the wrong place and people will
turn the whole idea off.

🔴 `.deviceOwnerAuthentication`, never `...WithBiometrics`: a phone with a
passcode and no Face ID must still open these screens. And a device that can
prove nothing at all is NOT gated -- gating it would hide somebody's own
device list from them for ever, and the phone is the weak link either way.
Same for a passcode removed after the fact: reveal rather than strand.

⚠️ Cancelling is a choice, not a failure. Telling somebody who tapped Cancel
that authentication FAILED reads as the app being broken.

The tests assert the policy, not the system prompt -- `LAContext` cannot be
driven from a unit test, and a test that pretended to would be testing a
mock of Apple's code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:41:09 -04:00
Paul Hitt d821cb850a Merge branch 'feat/say-why-delivery-failed' into 'main'
Say why delivery failed, instead of only logging it (ios#21, #22, #23)

See merge request paulhitt/flamenet-messenger-ios!149
2026-09-08 18:29:58 +00:00
Paul HittandClaude Opus 5 bc475162a7 Say why delivery failed, instead of only logging it (ios#21, #22, #23)
The client has always known this and only ever told the log. Diagnosing
2026-09-01 meant reading device logs over a shell, and yesterday a send
refused by the identity guard reached the person sending it as "something
went wrong reaching that server" -- which was neither true nor actionable.

**ios#22, the give-up rule.** `flushOutbox` retried everything for ever. That
is right for a contact who is merely offline or has not registered a device:
they get the message whenever they return, and timing out would mean
deleting something somebody wrote to tidy up a label. It is wrong for a
refusal waiting cannot resolve -- a changed identity is refused identically
on every retry until a person accepts the new keys, while the bubble goes on
promising "will send". `E2EError.waitingCanFix` draws that line; those stop,
mark the bubble `.failed`, and record why.

**ios#21, drop reasons.** The seventeen `dropped(...)` sites already carry a
reason; they now also count it against the conversation it came from.

**ios#23, repairs.** `requestSessionRepair` was invisible when it worked and
invisible when it did not. Counted per peer, with three or more flagged --
a client that repeatedly repairs is a symptom, not a cure.

🔴 **Shape only, and the tests enforce it.** Several drop reasons interpolate
an id ("device claims 41 but is bound to 12"). Storing those raw would put
identifiers into a reading meant to be safe to screenshot, and would give the
map unbounded cardinality. They are normalised first -- and the first attempt
missed short hex ids like `9f0e`, which the test caught.

⚠️ In memory, not persisted. This describes the session you are in; keeping
it would be a growing record of who you talked to and when.

⚠️ `Scripts/generate.sh` had to run: this project has no sync groups, so a
new file is not in the build until the project is regenerated. The first
BUILD SUCCEEDED here proved nothing, because the file was not compiled.

764 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:07:29 -04:00
Paul Hitt 88716357a0 Merge branch 'revert/pre-purple-theme' into 'main'
Revert the purple/dark theme, back to the pre-purple palette

See merge request paulhitt/flamenet-messenger-ios!148
2026-09-08 17:36:17 +00:00
Paul HittandClaude Opus 5 10b8a4c0ba Revert the purple/dark theme, back to the pre-purple palette
Owner's call after two days of iterating on it. Reverts the ten commits
that took the app from the blue light/dark palette to a purple-into-black
dark-only one: the purple ground, the darkening passes, the single dark
theme, the three-stop gradient, white text boxes, white icons and the white
header buttons.

The palette is `primary #2298FF` again, `Color(light:dark:)` throughout, and
`.preferredColorScheme(.dark)` is gone -- so the app follows the system
again instead of being locked dark.

Two things deliberately NOT reverted, because they rode in on theme commits
without being theme work:

  * `UITests/AppStoreShotsUITests.swift` -- the App Store screenshot
    harness, removed by reverting ed6fdd6 and restored from main. Without
    it the UITest target does not even build.
  * `PillTabBar`'s ground backstop and its safe-area inset. Painting the
    ground once, centrally, is what stopped Files and Wallet being born
    without one, and the inset is what stopped Account's Sign out sitting
    under the pill with no way to reach it. Kept, pointed at the flat
    `Theme.background` now that `backgroundGradient` is gone.

764 tests pass. Rendered and looked at: the profile screen is light with a
blue accent again, which is the state before any of this started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:28:36 -04:00
Paul Hitt 570ff7dcf8 Merge branch 'fix/vault-file-keeps-gps' into 'main'
Strip location from vault files, and make header icons legible

See merge request paulhitt/flamenet-messenger-ios!147
2026-09-08 11:52:43 +00:00
Paul HittandClaude Opus 5 9e74cc5829 The chat header's call buttons are white, not the accent
`FlamenetMessengerApp` already carries the reason, written out in full --
the accent draws a purple glyph on a dark blue bar, the least legible mark
on the screen, and it reads as already disabled. But the `.tint(Theme.onDark)`
that comment explains was applied only to the Files *preview* branch; the
real app tints with `Theme.primary`.

Per-button rather than on the subtree, so `.disabled` still dims the call
buttons while a call is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:35:27 -04:00
Paul Hitt 760033783a Merge branch 'fix/vault-file-keeps-gps' into 'main'
A photo sent as a file loses its location too (ios#69)

Closes #69

See merge request paulhitt/flamenet-messenger-ios!146
2026-09-07 19:41:29 +00:00
Paul Hitt 78706a2edb Merge branch 'ui/white-icons-and-initials' into 'main'
Icons and initials are white, not the accent

See merge request paulhitt/flamenet-messenger-ios!145
2026-09-07 19:41:10 +00:00
Paul HittandClaude Opus 5 3e8ac270bf A photo sent as a file loses its location too (ios#69)
A photo sent through the picker had its EXIF removed. The same photo saved into
Files and sent from the vault kept it — GPS, capture time, device serial.
Somebody who had learned "Flamenet strips location from my photos" had learned
something true of one button and false of the one beside it, and the safer one
looked like the shortcut.

🔴 **Sniffed by magic bytes, not by name.** The vault stores no MIME type, and a
file name is a guess in both directions: `holiday.txt` can be a JPEG and
`photo.jpg` can be text. JPEG, PNG, HEIC, TIFF, WebP and GIF are recognised from
their first bytes; anything else is not touched.

⚠️ **Only images.** "Send this exact file" is what a file send means, and
silently rewriting somebody's PDF or spreadsheet would be its own surprise. A
scrubber that mangles a document is worse than the leak it was fixing.

⚠️ **Re-encoded, not filtered.** Removing the EXIF block by hand leaves XMP,
IPTC, MakerNote and embedded thumbnails, several of which carry location
independently. Drawing into a fresh context and writing out again is the only
version with no list to keep up to date.

⚠️ **Undecodable bytes pass through rather than vanish.** A file that will not
decode is not necessarily a file with location in it, and losing somebody's data
to the scrubber is a worse failure than the one being fixed.

And it says so: sending a scrubbed image reports "the JPEG had its location and
camera details removed first", because the issue's real complaint was that the
two paths differed invisibly.

Five tests. 🔴 The first asserts the FIXTURE carries GPS before anything else
runs — a scrub test whose input never had location in it passes for the wrong
reason, and would have passed against the unfixed code. Falsified: making
`scrub` a passthrough turns the GPS test red on both assertions.

⚠️ The bug was found by an audit, not by a test, because no test went down the
file path at all. The picker path was checked and correct throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:08:19 -04:00
Paul HittandClaude Opus 5 693c195b50 Icons and initials are white, not the accent
🔴 **The accent was being used the wrong way round.** #7A3FF2 is a mid purple
chosen to be readable as a FILL with white text on top of it. Used as the INK
for a glyph on a dark ground it is a light purple mark on a dark blue field —
the least legible thing on the screen, and it reads as a disabled control
rather than a button.

`Theme.onDark` is white, and it is what a glyph or a set of initials drawn on a
dark surface uses. Twelve avatar circles, and the app's `.tint` — which is what
SwiftUI hands to toolbar buttons and bar items, so the + and the QR scanner in
the top-right pill came with it.

⚠️ The accent still FILLS: primary buttons, the selected tab, badges. The
distinction is ink versus fill, not "purple is gone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:57:38 -04:00
Paul Hitt 7db2f35825 Merge branch 'release/1-0' into 'main'
1.0, and the sign-in screen wears the product's mark

See merge request paulhitt/flamenet-messenger-ios!144
2026-09-07 18:11:48 +00:00
Paul HittandClaude Opus 5 e2ffcd456e White text boxes, and the band under the pill stops being black
## Text fields are white

An input is the one surface a person types into and reads back character by
character, and a dark field on a dark ground gave it the least contrast on the
screen at the moment it mattered most. `Theme.fieldFill` is white; typed text is
#0E1230 at 18.3:1; the placeholder is #6B7590 at 4.6:1, because a placeholder is
still text.

⚠️ **Its own colour, not `surfaceAlt`.** That fill is shared with incoming
bubbles and the tab pill, which stay dark — a white message bubble on this
ground would read as somebody else's app.

⚠️ **A white field needs light-mode system chrome.** The app is forced to dark,
so SwiftUI drew the placeholder and caret for a dark background: on white they
were invisible. `.environment(\.colorScheme, .light)` on the field fixes the
parts the app does not draw itself. Twelve fields.

Files and Wallet also dropped `.textFieldStyle(.roundedBorder)`, which draws the
system's own field — a dark box, for the same reason.

## The black band under the tab pill

🔴 Fixed by making the bar paint its own band. The `safeAreaInset` was
`Color.clear`, so whatever the screen put behind itself showed through — and a
`List` whose content is taller than the screen draws its own opaque background
right there. That is why Account had a black strip and Wallet did not.

⚠️ **Three wrong guesses before measuring.** A frame on the List, then the
ground outside the NavigationStack, then the ground behind the whole tab
container — none of them moved it. What settled it was sampling the pixel: pure
(0,0,0) against the gradient's darkest stop of (7,10,26). Nothing in the palette
is black, so it was never the palette, and a red-background probe then proved
the band did not belong to the view at all.

⚠️ And the first version of the fix painted the WHOLE gradient into a ~100pt
strip, which put the top of the sweep where the bottom belongs — a light purple
stripe across the base of every screen. It is `Theme.groundEnd`, the last stop
alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:57:22 -04:00
Paul HittandClaude Opus 5 09c75e6e30 Paint the ground once, behind every tab
Three screens ended up without it: Files and Wallet had none at all, and
Account's `List` left a black strip under the pill — a scroll view's background
stops at its own bounds, and the bar sits in a safe-area inset below them.

The ground goes behind the whole tab container now, so a new tab cannot be born
without one. Each screen keeping its own `pageBackground()` is harmless and
still right when one is presented on its own; this is the backstop.

⚠️ Honest note: this fixed Files and Wallet and did NOT fix the strip under the
pill on Account. Third attempt at that one specific band. It is a `List` with
`.scrollContentBackground(.hidden)` that still paints its own area below the
content, and the next thing to try is replacing the List with a ScrollView —
which is a bigger change than a background fix and not one to make while
iterating on colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:42:48 -04:00
Paul HittandClaude Opus 5 ed6fdd6986 Three stops — purple into blue into black — and a screenshot harness
The gradient has a middle now: #3A1E9E purple, #0B3080 blue, #070A1A black,
top down. Less purple overall than the two-stop version it replaces, because
the purple is one third of the sweep rather than half of it.

🔴 **One card colour against three stops has to lift off all three.** #0E1745
looked right against the purple and the blue and measured 1.15:1 against the
black end — a card that quietly disappears at the bottom of a long screen.
#141C52 lifts 1.41 / 1.33 / 1.23. The test checks every stop now; a three-stop
gradient checked only at its ends is two thirds measured.

⚠️ **Files and Wallet had no ground of their own**, so forcing the app to dark
left them on the system's black while every other tab carried the gradient.
`scrollContentBackground(.hidden)` is the half that is easy to miss: a `List`
paints its own background over anything behind it. And `pageBackground()` now
ignores the safe area, or a scroll view taller than its content shows black
below it — which is what the bottom third of the Account screen was.

## AppStoreShotsUITests

The screenshot set is a test now, not a person tapping through a simulator. It
signs in, walks the tabs, and attaches each screen, so a new set is one command
after any change — which matters when the palette has moved five times in an
afternoon and every set went stale with it.

🔴 **The variables need the `TEST_RUNNER_` prefix.** `xcodebuild` does not hand
its environment to the test runner; it forwards `TEST_RUNNER_<NAME>` and strips
the prefix. Without it the test skips — silently, and reported as a pass. The
first run did exactly that: green, and captured nothing.

    TEST_RUNNER_FNMSG_SHOT_HANDLE=… TEST_RUNNER_FNMSG_SHOT_PASSPHRASE=… \
      xcodebuild test -only-testing:FlamenetMessengerUITests/AppStoreShotsUITests …
    xcrun xcresulttool export attachments --path <result> --output-path <dir>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:37:33 -04:00
Paul HittandClaude Opus 5 3ee88a3c32 Bright, but still dark — a vivid gradient with recessed panels
The dark theme was readable and muddy: #241650 to #0B2140 sits at 0.72/0.83
saturation, which is a grey that happens to be purple. Now #3A0CA3 to #062C8C —
0.93/0.96 saturation, a real violet into a real blue, and no lighter: ink still
reads 10.4:1 at the top and 10.5:1 at the bottom.

🔴 **Cards go DARKER than the ground, not lighter, and that is the whole trick.**
The usual dark-theme move is to raise a panel by lightening it. That does not
work against a vivid ground: a card lighter than a saturated violet has to be
lighter still than that, and secondary text then falls below AA on it — every
candidate I measured did. Recessing inverts the problem. The gradient is the
bright thing; content sits in dark wells where text has all the contrast it
wants.

    ground        #3A0CA3 -> #062C8C   sat 0.93 / 0.96
    card          #190B4E   1.47:1 against the ground, ink 15.3:1
    field/bubble  #2A1875   1.24:1 above the card
    secondary     #B4BAD4   6.2 ground · 9.1 card · 7.4 field
    links         #B99BFF   5.2 ground · 7.7 card
    danger        #FF819F   was #FF6B92, which measured 4.45:1 on the ground —
                            under the line, found by checking rather than looking

Also on this pass: the section band. It was a 16pt full-bleed fill of
`surfaceVariant`, which on the light theme was a barely-there grey seam and on
the dark theme became a light purple slab drawing more attention than the
sections it divided. It is a gap now — the panels either side of it are the
separator, and a gap needs no colour of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:44:53 -04:00
Paul Hitt 51313c8734 Merge branch 'fix/video-call-is-visible' into 'main'
Video calling has a button, not just a gesture (ios#72)

Closes #72

See merge request paulhitt/flamenet-messenger-ios!143
2026-09-07 16:31:09 +00:00
Paul HittandClaude Opus 5 2eafcf6bfe One theme, and it is dark
The ground kept being asked to go darker, and `secondaryText` had to darken
with it at every step to keep clearing AA — until it was #1B1822 against `ink`
at #090710. Contrast passing, hierarchy gone: primary and secondary text had
become the same colour, and no ratio can see that.

A dark ground fixes it by putting secondary back in the middle where it belongs.

    ground       #241650 purple -> #0B2140 blue, top down
    card         #302F5C   lifts off the ground at 1.29:1
    field/bubble #3B3A6E   lifts off a card at 1.20:1
    ink          #EDEFF9   14.0 on the ground, 10.9 on a card
    secondary    #A8AEC9   7.3 on the ground, 5.7 card, 4.7 field
    links        #B99BFF   7.0 on the ground

Semantic colours were re-picked rather than carried over: #3BD5B2, #B8860B and
#ED497C were chosen against white. Now #4BE3C0, #E9B44C, #FF6B92.

🔴 The Light / Dark / Match-the-phone picker is gone from both apps. There is
one set of colours, so it changed nothing a person could see except the Material
and SwiftUI chrome, which would then fight everything the palette draws. A
setting that is still there and no longer works is worse than one that was
removed — somebody sets it, nothing happens, and they cannot tell which of the
two is broken.

⚠️ The test caught one on the way in: secondary text on the avatar fill was
4.48:1, under the line by two hundredths. #3E3C7A became #3B3974. That is the
kind of miss that only ever surfaces as somebody saying the text looks faint.

The palette test now covers every surface text is read on — ground, card, field,
avatar — not only the page, and asserts that cards and fields visibly lift off
what is behind them. A gradient that passes and a bubble that does not is still
an unreadable app, and the bubble is where the messages are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:26:30 -04:00
Paul HittandClaude Opus 5 378c9f377d Darker again — #C0A4EC to #5A8BCC
Ink 9.3:1 at the top and 5.7:1 at the bottom, secondary text 8.1:1 and 5.0:1,
white cards lifting off the bottom at 3.5:1.

🔴 **This is close to the floor for a light theme, and the next step down is not
another shade — it is a different design.** At a bottom stop of #477BC2, `ink`
itself measures 4.65:1 and secondary text cannot clear 4.5:1 even at pure
near-black. Darker than that and dark text on this ground fails outright. The
answer at that point is light text on a dark ground, which is a theme flip
rather than a colour change, and worth deciding deliberately rather than
arriving at by increments.

⚠️ **A second cost is already being paid, and no test catches it.**
`secondaryText` has had to darken at every step to keep clearing AA and is now
#1B1822 — very close to `ink` at #090710. Contrast is satisfied; hierarchy is
not. Primary and secondary text are nearly the same colour, which is a real
regression in readability that a contrast ratio cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:13:34 -04:00
Paul HittandClaude Opus 5 44d6e29b1f The ground shades from purple into a medium blue
Another step darker, and top-down into blue: #D6C2F4 to #82ABE0.

⚠️ **The blue end is not decoration.** The mark is a blue globe with an orange
flame, and a purple-only app left the icon as the odd one out — the one thing on
the screen from a different palette. Shading into blue puts the icon's own
colour on the page instead of beside it, which is a cheaper answer than
redrawing the logo.

🔴 **Every darkening step drags secondaryText with it**, and this is the third
time in a row. #413A4D read 4.6:1 on the new bottom stop, which is inside the
margin rather than clear of it; #332E3D reads 5.5:1. The bottom stop is always
the one that binds, and it is where most of the explanatory copy on these
screens sits.

Measured: ink 12.3:1 at the top and 8.4:1 at the bottom, secondary 8.1:1 and
5.5:1, white cards lifting off the bottom at 2.4:1 — which is what keeps a card
looking like a card rather than like a lighter patch of background.

Dark mode shades the same way, purple into deep blue: #1C1036 to #071426.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:54:08 -04:00
Paul HittandClaude Opus 5 f23628557c Darker ground, and the mark stops being cropped
Three things, all from looking at it on a device.

**Darker.** The page gradient goes #E9DDFB → #C6AFF2, a clear step down from the
whisper it was. 🔴 That forced a second change: secondary text at #505C69 read
5.0:1 on the old bottom stop and only **3.5:1** on the new one — below AA, and
most of the explanatory copy on these screens is set in it. It is #413A4D now,
8.4:1 at the top of the gradient and 5.6:1 at the bottom. Every step the ground
takes downward this has to take too, which is the sort of thing that gets
forgotten when a colour is chosen by eye.

**The mark was cropped, twice over.** `.aspectRatio(.fill)` in a square frame
crops a mark that is wider than it is tall, and the rounded clip then took
another bite out of the flame arc. It is `.fit` now, and the PNG carries its
own inset — the artwork is 78% of the tile, so nothing has to be cropped at all.

**The app icon had a 6% margin**, which is no margin: an icon grid expects
about 10%, and the squircle was clipping the arc. The mark is 80% of the icon's
width now. Measured rather than eyeballed — compositing the foreground under
both a circle and a squircle mask clips **0 artwork pixels**.

**Sign-up wears the mark too.** It had an SF Symbol in a tinted circle. The
second screen anybody sees is not where the app should stop looking like itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:21:19 -04:00
Paul HittandClaude Opus 5 4f85b03382 A purple ground, and buttons measured against it
The app was blue on near-white. It is purple now, on a gradient, with the
primary action carrying the same sweep.

**Every value was measured rather than picked.** "Still readable" is the whole
constraint, so the numbers are in the comments beside the colours:

- Button gradient `#7A3FF2 → #5B27C7`. White label is **5.50:1** at the light
  end and **8.20:1** at the dark end. 🔴 The LIGHT end is the one that decides —
  a gradient button is only as readable as its lightest stop, and checking the
  mean or the dark end is how a pretty button ships unreadable.
- Page gradient `#F8F5FF → #E4D8FB`. Ink **18.6:1** to **14.8:1**, secondary
  text **8.7:1** to **6.9:1**. Deliberately a whisper: cards are white and have
  to lift off it, and every line of body copy sits on it. A saturated purple
  would look bolder in a screenshot and fight both.
- Links and small text move with the ground — `#5B27C7` on light (8.2:1),
  `#B99BFF` on dark (8.3:1). One mid purple is unreadable on one of the two.

`Theme.background` stays as the gradient's mid-tone, because a gradient cannot
go where a single `Color` is required — a bar tint, a UIColor bridge — and two
surfaces a shade apart read as a seam.

⚠️ The sign-in screen had no ground of its own and kept the system's white. It
is the first screen anybody sees, which made it the one place the app looked
like a different app.

⚠️ `avatarFill` was a blue wash. On a purple ground that reads as a bug rather
than as a second accent; it was the last surface giving the old palette away.

⚠️ **The icon is still blue with an orange flame.** The app now matches the
website, which was already purple — the icon is the outlier, and whether it
follows is a brand decision rather than a CSS one.

Android carries the same values in the same commit, and there is finally a test
that checks it: `PaletteMatchesIOSTest` reads this file and compares the hex.
This file has opened with "one palette, two apps" since it was written, and its
own comment records that the claim was once quietly false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:08:50 -04:00
Paul HittandClaude Opus 5 5d52027d0a 1.0, and the sign-in screen wears the product's mark
Two things a first submission needs.

**Version.** `MARKETING_VERSION` was `0.1.0`. App Store Connect refuses a
version string that disagrees with the uploaded binary, so submitting as 1.0
means the project says 1.0. `CURRENT_PROJECT_VERSION` stays at 1 — it has to be
unique per version string and only ever go up, and 1.0 is a new string, so
build 1 is available again. Bump the build, not the version, if an upload is
rejected and re-sent.

**The mark.** The sign-in header was a blue tile with a bold "F" in it — a
placeholder from before there was a logo, and the first thing anybody sees.
It is the emblem now, from the same artwork as the app icon.

⚠️ Clipped to a rounded rectangle, and that is not decoration. The artwork's
background is painted rather than transparent — the space behind the globe is
part of the picture — so an unclipped mark is a hard-edged dark rectangle
sitting on a white screen. Rounded, it reads as the app's mark and matches the
icon on the home screen the person just tapped. Checked on the simulator both
ways rather than assumed.

⚠️ Two copies of one artwork now exist: `docs/marketing/` here and `brand/` in
the relay repo, which is where the generator reads from. They are byte-identical
(`9eb778eb…`) and the relay's copy is the one to change if the artwork ever does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:48:20 -04:00
Paul HittandClaude Opus 5 172da59d0e Video calling has a button, not just a gesture (ios#72)
The new-call picker drew one `phone` glyph per row and started an audio call on
tap. Video existed — as a swipe action, with nothing on screen suggesting a
swipe was available. A row showing a single glyph looks like a row that offers
one thing, so it was reported as video calling being missing, which is a fair
reading of a feature whose only entrance is a hidden gesture.

Two visible buttons now, each 44×44 so they are not two things people miss
between, each with its own accessibility label and identifier so the XCUITest
suite can drive either. The name takes the row's tap for the audio call — a
`Button` wrapping the whole row swallows the two inside it.

⚠️ The swipe action stays. It is a shortcut for people who know it; what it
must not be is the only way in.

One `place(_:video:)` behind all four entrances, so the row tap, the two
buttons and the swipe cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:54:41 -04:00
Paul Hitt f148ab5948 Merge branch 'feat/new-brand-icon' into 'main'
The app icon is the product's mark

See merge request paulhitt/flamenet-messenger-ios!142
2026-09-07 13:34:29 +00:00
Paul HittandClaude Opus 5 3b73970bac Track the marketing artwork that only existed on one laptop
The business card PDF, its two previews, and the transparent logo were sitting
untracked in the working tree. Nothing about them is generated and nothing
would recreate them, so an untracked copy is one disk failure from gone.

⚠️ `flamenet-messenger-logo-transparent.png` is byte-identical to
`brand/flamenet-messenger-logo.png` in the relay repo, which is where the icon
generator reads from. Two copies of one file is a thing that drifts; this one
is kept because the card was laid out against it, and the relay's copy is the
one to change if the artwork ever does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:28:32 -04:00
Paul HittandClaude Opus 5 523526230b The app icon is the product's mark
Generated from the one artwork by the relay repo's
`Scripts/make-brand-assets.py`, so the iPhone icon, the Android icon and the
site's favicon cannot drift apart. Do not edit the PNG in place — the script
overwrites it.

🔴 **No alpha channel.** App Store Connect rejects an icon that has one, and
the rejection arrives after the upload rather than during the build, so the
generator flattens onto the panel navy (#121A2E) rather than leaving the
artwork transparent.

⚠️ The wordmark is not in it. "FlameNet MESSENGER" is unreadable at 60pt and
worse at 40, and the emblem — globe, two speech bubbles, flame arc — is the
half that survives being small.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:28:32 -04:00
Paul Hitt cf65183e9b Merge branch 'fix/scrub-missed-a-real-one' into 'main'
The scrub missed a real one, twice, for two different reasons

See merge request paulhitt/flamenet-messenger-ios!141
2026-09-07 01:56:08 +00:00
Paul HittandClaude Opus 5 550c805822 The scrub missed a real one, twice, for two different reasons
`MessengerViewModel.reportStory` was called by no screen — Android could
not report a story at all, which Apple's Guideline 1.2 and Play's UGC
policy both treat as not having reporting. The unwired check did not flag
it. I found it by hand while working ios#12, which is the check failing
at its one job.

**Miss one: comments count as references.** Two doc comments mention
`reportStory` by name, so it looked used. A reference check that reads
prose can be talked out of its finding.

**Miss two, introduced while fixing miss one:** I stripped comments with
a `/* … */` regex over the concatenated sources. It ate 35% of the code —
an unbalanced marker anywhere swallows everything to the next one —
including `onClick = model::clearAvatar`, and the scrub then reported
three properly wired methods as unwired. A check that cries wolf gets
skimmed, which is the failure it exists to prevent, so that version was
worse than the bug.

Line-based now: a line is dropped only if it plainly begins a comment.
That errs toward keeping code, which errs toward fewer false alarms.

Verified against the tree that still HAD the bug rather than against a
fixed one, because a check that passes after the fix proves nothing about
whether it would have caught it. On the unfixed Android tree it reports
exactly three: `filesGoUp` and `refreshGroups`, both already verified
benign, and `reportStory`, the real one. No false positives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:46:14 -04:00
Paul Hitt 2e0745ff15 Merge branch 'fix/activity-toggles-say-what-they-do' into 'main'
Three toggles on the Privacy screen did not do what they said

See merge request paulhitt/flamenet-messenger-ios!140
2026-09-07 01:14:45 +00:00
Paul HittandClaude Opus 5 c4830eadbe Three toggles on the Privacy screen did not do what they said
Found by the parity scrub, from the other end: Android was missing these,
so I went to read what they do before copying them across.

"Read receipts" was the worst of the three. This app sends no read
receipts to anybody and never has — there is no such content kind and no
such envelope field. The toggle governs whether opening an alert in the
inbox marks it read on the relay. A real preference, and not remotely the
one its name promised. Somebody could have turned it OFF believing they
had stopped telling people when they read their messages, having never
told anyone anything.

"Show last seen" and "Show online status" hide other people's status from
THIS phone. They publish nothing differently: turn both off and the relay
still tells your contacts you are online, exactly as before. Every other
messenger uses those words for the reciprocal thing, and this is the
Privacy & Security screen, which makes the wrong reading the natural one.

Renamed rather than reimplemented, and the screen now says plainly that
these change only what you see, that your contacts are still told when
you are online whichever way they are set, and that hiding yourself is
not something this app can do yet. Making them mean what they said is a
feature worth building; leaving the labels up until then is a claim.

⚠️ Tests that assert on COPY, which is usually not worth doing. It is
here because the failure is silent by construction — a label that lies
compiles, renders and is wrong only to the person reading it. One of them
also asserts the underlying fact, so it fails the day somebody implements
read receipts, at which point the old label can come back and the test
should be rewritten rather than deleted.

758 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:58:28 -04:00
Paul Hitt 47104fa530 Merge branch 'tools/weekly-parity-scrub' into 'main'
A weekly scrub, and what the first one found

See merge request paulhitt/flamenet-messenger-ios!139
2026-09-07 00:52:43 +00:00
Paul HittandClaude Opus 5 4ecb625eb0 A weekly scrub, and what the first one found
The owner asked for a recurring pass over both apps and the relay: same
features on both sides, nothing broken. This is the repeatable half plus
the notes for the half that cannot be automated.

The script checks only things whose failure mode is SILENT, because
everything else is visible the moment somebody opens the screen:

  - an envelope kind one app sends and the other cannot parse, which is
    swallowed as unknown control and reported to nobody. Bridge sharing
    had that shape for a few hours today, between the two halves landing.
  - a view-model method no screen calls. `shareBridge` shipped that way
    this morning: you could receive a bridge and had no way to send one.

⚠️ The first version of the unwired check reported 26 findings, of which
almost all were wired perfectly well — Kotlin passes these as method
references (`run = model::hangUp`) and the pattern only matched
`model.hangUp()`. A scrub that cries wolf is worse than none, so the
check was widened until what it reports is worth reading.

Both surviving findings were verified by hand and neither is a break:
`filesGoUp()` is redundant because the breadcrumb navigates up, and
`refreshGroups()` is redundant because `createGroup` re-reads the list
inline. Dead code, reported as such.

SCRUB.md carries the manual half, because the most significant finding of
this run is one no source check here would have raised: eight preferences
exist on iOS with no Android counterpart — read receipts, last seen,
online status, send-on-return, timestamps, larger type, appearance, and
the whole notification group. Every one of them is wired to real
behaviour on iOS, so this is Android being behind rather than iOS
carrying decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:47:02 -04:00
Paul Hitt 0a4cc5c7fc Merge branch 'fix/sealed-group-test-flake' into 'main'
The sealed-group test cried wolf about one run in a thousand

See merge request paulhitt/flamenet-messenger-ios!138
2026-09-07 00:36:50 +00:00
Paul HittandClaude Opus 5 4da66a9aa3 The sealed-group test cried wolf about one run in a thousand
`testCreatingASealedGroupSendsNoMemberIDsToTheRelay` searched the whole
request body for "2421" as text. `gid` is a hex digest of random
material, so it contains any given four digits about once in a thousand
runs. CI caught exactly that today, in

    "gid":"058b96d9986cd1eaf7b93fe9bc0bc41772f8afcffbf62421208cac1e2edb25c9"
                                                  ^^^^

and reported "a member id went to the relay" about a request that
contained no member id at all.

That failure mode is worse than most, because of what this test guards.
It exists because the relay used to store `gid -> user_id`, one readable
row per membership, seven of them on production saying exactly who was in
which group. A test making that claim must not be the one people learn to
retry past.

The replacement walks the parsed JSON and refuses an id appearing as a
number or as a string anywhere in it, and pins the top-level keys. That
is STRONGER than the substring version, not weaker: a future field
smuggling an id now fails on the allowlist even if its value is encoded
in a shape the walk does not recognise. Opaque blobs are exempt from the
text search, because searching ciphertext for a decimal id is what
produced the false positive and cannot produce a true one. The group name
is not random material, so it is still searched as text.

Checked by simulating the regression rather than trusting the rewrite:
adding `member_ids` to the create request reddens it twice over — once on
the allowlist, once on the value walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:26:10 -04:00
Paul HittandClaude Opus 5 ef70a5b8ec Receive a bridge a contact offered (§6.19), and emit the same bytes Android does
The other half of the Android change. Without it an Android user sharing
a bridge with an iPhone gets silence — the envelope is swallowed as an
unknown control kind, correctly and uselessly, with nothing shown at
either end.

🔴 **An offer, never a setting.** A bridge line tells this phone to route
every relay connection through a machine somebody else chose. Applying
one on arrival would let a contact — or whoever has taken their account —
move another person's traffic onto a host they control. They could not
read it, because the ratchet does not care what carries it, but they
would learn who is online and when, and could cut them off. Two tests
hold that line: recording an offer must leave the configured bridge nil,
and must not disturb one already set.

A cross-engine test caught a real divergence, and it is the reason that
test exists rather than a comparison of the two builders by eye — which
would have shown nothing, because they look identical.

`JSONSerialization` escapes `/` as `\/`; `org.json` does not. Both are
valid JSON, both decode to the same string, and nothing would have
broken. But a bridge `cert=` is base64, base64 is full of `/`, and the
two engines would therefore have put different bytes on the wire for
every real bridge line ever shared. iOS now quotes the way `org.json`
does — Android is in the field, so its output is the wire — including
that library's one oddity of escaping `/` only after `<`, which exists to
keep `</script>` out of a page.

The test that found it holds the Android engine's ACTUAL bytes, captured
by running it, not a reconstruction of what it ought to emit. This app
has been caught by that distinction before: text envelopes where iOS
wrapped and Android sent bare, which broke iOS→iOS invisibly.

753 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:07:18 -04:00
Paul Hitt 4e0013eeff Merge branch 'ci/loud-advisory-at-e2e-head' into 'main'
Pair the e2e pin with a loud advisory at HEAD

See merge request paulhitt/flamenet-messenger-ios!136
2026-09-06 21:37:21 +00:00
Paul HittandClaude Opus 5 4e2413803a Pair the e2e pin with a loud advisory at HEAD
Same correction as the Android side. I pinned the vector clone earlier
today on flamenet-relay#56's evidence and stopped a step short.
flamenet-relay did not: it pins AND keeps `conformance-head` /
`doc-pages-head` running against HEAD with `allow_failure: true`, and its
`.e2e-ref.md` gives the reason in a sentence I had read without acting on
— "a relay tested against a pinned old harness is tested against a pinned
old spec, and that argument still holds."

A pin alone is worse than tip for exactly that reason. Tip couples this
board to another repository's merges; a bare pin means the vectors can
move and `ProtocolVectorTests` goes on checking last month's protocol and
reports green. That is the failure that suite was written to prevent,
reintroduced one level up — and it is the quieter of the two, which in a
conformance suite is the worse one.

So `.e2e-ref` holds the commit, the blocking check uses it, and
`vectors-head` diffs `Tests/Vectors` against HEAD and is allowed to fail.

⚠️ The advisory deliberately does not build the app. The question it
answers — did the published vectors move? — needs a clone and a diff and
nothing else, and a job that took twenty minutes to say "you are two
commits behind" would be switched off within a week.

Checked both directions rather than assuming: with the vectors current it
reports clean, and with one byte changed it fires. Pin and HEAD are the
same commit today, so the passing run alone would have proved nothing.

`.e2e-ref.md` records the procedure, including that Android pins the same
commit and should move in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:23:02 -04:00
Paul Hitt e6d5dbd3d7 Merge branch 'feat/ios-protocol-vector-conformance' into 'main'
Check this app against the protocol vectors, which only Android was doing

See merge request paulhitt/flamenet-messenger-ios!135
2026-09-06 20:31:48 +00:00
Paul HittandClaude Opus 5 3838f83bd8 Fix the CI file: an unquoted colon turned a script line into a mapping
`- echo "GATE: protocol vectors match ..."` is not a string to YAML. The
plain scalar contains `: `, so the list item parses as a MAPPING and the
whole file becomes invalid.

What that looks like from outside is worth writing down, because nothing
about it says "malformed CI file": the pipeline exists, reports `failed`,
lists ZERO jobs, and carries `yaml_errors: null` on the pipeline object.
The only thing that named the actual problem was POSTing the file to the
project's `ci/lint` endpoint, which said it in one line.

Folded into the literal block above it, where a colon is just a colon.
Both this file and the Android one now lint clean — the Android one was
already fine, but only because its GATE lines happen to live inside
blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:29:10 -04:00
Paul HittandClaude Opus 5 e11b197cc2 Check this app against the protocol vectors, which only Android was doing
`FlamenetAccountKDF` carries a comment saying its derivation is "pinned in
engine/test/vectors/account-kdf-v1.json and checked against the JavaScript
rather than merely reviewed". That test exists only in the Android engine.
The comment describes a guarantee this repository does not have, and its
own text says what the absence costs: a disagreement shows up as "handle
or passphrase is incorrect" for a passphrase that is perfectly correct.

The wider version of the same gap is that everything in this suite checks
both sides of a handshake against each other. That is a real test of
internal consistency and no test at all of conformance, because two
identically-wrong implementations agree perfectly.

Demonstrated rather than asserted. Swapping the order of the first two
Diffie-Hellman outputs in BOTH `X3DH.initiate` and `X3DH.respond`:

    X3DHTests            18 tests, 0 failures
    ProtocolVectorTests  1 failure

Under that change every iPhone talks to every other iPhone perfectly and
no Android device can decrypt a word, and the entire existing suite calls
it correct.

Ten tests now check this app against values it did not choose: the
account KDF on all five published cases, the safety number both ways
round, the fnid encoding and claim bytes, the key log's leaf encoding,
hash, tree shape and an inclusion proof at index 2 of 4, the ML-DSA-65
identity key, and the whole PQX3DH responder handshake down to `sk` and
`ad`.

Each was checked by breaking the implementation and watching it redden.
The tree-shape one is worth recording: substituting `n / 2` for the
unbalanced split still passes at sizes 1, 2 and 4 and fails at 3 and 5,
which is why that test walks every size rather than checking a final
root. A suite that happened to stop at four leaves would have called the
wrong tree correct — and I had written a comment claiming a halving split
"matches nobody past size 2", which the experiment corrected.

The vectors are copied into Tests/Vectors rather than resolved from a
sibling checkout, because a sibling checkout 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 in CI from the day it was written until 2026-09-06
without anyone seeing it. A copy in the test bundle cannot be absent.
What a copy can do is go stale, so CI diffs it against flamenet-e2e at a
pinned commit — pinned and not tip, for the reason flamenet-relay#56
documents.

Also gives this gate a count floor. Twice now a green pipeline here has
had to be checked by hand against main's count, because the log was
truncated and showed no sign of the tests that had just been added. A
suite that silently dropped from 741 to 12 passed the old check and
looked identical in the log.

741 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:27:53 -04:00
Paul Hitt d15c790c0f Merge branch 'feat/obfs4-bridge-settings-ui' into 'main'
A settings screen for the bridge line, which validates before it stores

See merge request paulhitt/flamenet-messenger-ios!134
2026-09-06 19:04:25 +00:00
Paul HittandClaude Opus 5 ebc02b6988 A settings screen for the bridge line, which validates before it stores
The obfs4 wiring shipped with no way to configure it, and the gap was
worse than "no UI": a malformed line parsed to nil, the transport reads
nil as "no bridge", and the app connected DIRECTLY. Somebody who pasted a
bridge line precisely because direct does not work would have been put
back on the exact path they were escaping, with their line still sitting
in settings looking configured.

So the line is validated before it is stored, and refused with a reason
if it cannot be used:

  - `Obfs4Settings.Problem` names which part is wrong. A boolean "valid"
    would have reintroduced the same failure one step later: telling
    somebody their line is broken without telling them what to fix.
  - A truncated cert is reported as truncated rather than as unreadable.
    That is the likeliest real mistake by a distance — bridge lines wrap
    in every chat app and terminal, and copying only the first line is
    the ordinary error. Swept across cut positions, because whether a cut
    reads as "short" or as "unreadable" depends on where it landed
    relative to base64's four-character groups.
  - `iat-mode` 1 and 2 are a caveat, NOT a rejection. They ask the client
    to space its writes out and the bridge does not enforce them, so such
    a line connects and works. Refusing it would take a working bridge
    away from somebody who has no other one.
  - No error message says obfs4, base64, or node id. Whoever reaches this
    screen was handed a line and told to paste it; the protocol's
    vocabulary is not theirs, and an error in it reads as "you are not
    qualified to fix this". There is a test for that.

Two bugs found while building it:

  - `bridge` memoised its parse, so storing a SECOND line kept returning
    the first until the app was force-quit. The screen would have shown
    the new bridge while traffic went to the old one. Setting the line
    now clears the cache and stops the running transport; removing either
    reddens 9 of the 14 tests, which is how they were checked.
  - `Obfs4Proxy` had no live state, so a screen could only report the
    stored preference. A stored bridge whose loopback listener failed to
    open is carrying nothing, and the preference cannot say so. `state`
    is what the screen reads, and the fallback to a direct connection is
    now visible rather than silent.

Saving proves the line is complete; it does not prove the bridge exists.
Those are different questions and people conflate them, so "Check it
works" opens a real connection. It refuses to guess: a bridge that
cannot verify a handshake stays silent, and so does a bridge that is not
there, so the result says both possibilities rather than picking one.

Filed under Network, not Privacy & Security. A bridge is about REACHING
the relay on a hostile network, not hiding from it — the relay sees
exactly what it saw before, and the screen says so twice.

731 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:00:22 -04:00
Paul Hitt 3c4bc690fb Merge branch 'feat/obfs4-ios-wiring' into 'main'
Carry relay traffic through obfs4, off by default

See merge request paulhitt/flamenet-messenger-ios!133
2026-09-06 18:34:55 +00:00
Paul HittandClaude Opus 5 0b5d2fdf28 CI: clone the obfs4 submodule, over the job token
The pipeline failed at package resolution: "the package manifest at
Packages/Obfs4/Package.swift cannot be accessed". The runner skips
submodules by default, so the directory was empty -- which reads as a
missing file rather than a missing clone.

GIT_SUBMODULE_STRATEGY brings it in. GIT_SUBMODULE_FORCE_HTTPS rewrites
the URL to use CI_JOB_TOKEN, which is the only credential the job has and
is needed because flamenet-elligator2 is private.

That also required allowing this project in flamenet-elligator2's CI
job-token allowlist -- a setting on the OTHER project, so a failure here
has no local cause and nothing in this repo would point at it. Noted in
the file for whoever hits it next.

This is the cost of the submodule, and it is worth paying: a vendored
copy would let the code this app ships drift from the code that carries
the interoperability tests, and nothing would report that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:24:25 -04:00
Paul HittandClaude Opus 5 f98216cf30 Carry relay traffic through obfs4, off by default
All three URLSession sites -- MessengerClient, E2EClient, MailboxHoming --
now call Obfs4Proxy.shared.apply(to:) unconditionally. That is a no-op
unless a bridge is configured, and none ships by default, so nothing
changes for anyone who does not ask for it. Unconditional on purpose:
adding a fourth session later is one line rather than a decision someone
has to remember to make.

One transport for the whole app, not one per session. Each instance would
open its own loopback port and its own connection to the bridge,
multiplying the handshakes a censor sees and the sockets the bridge holds
for no benefit.

The obfs4 package is a SUBMODULE, not vendored source. That repo carries
the vector suite and the live interoperability tests against a real
lyrebird bridge in both directions; a vendored copy would let the code
this app ships drift from the code that is proven to interoperate, with
nothing reporting it. Only the pure-Swift modules are linked -- Obfs4NIO
would drag SwiftNIO into the app, so the SOCKS shim is Foundation and
POSIX.

The end-to-end test needed three attempts and the first two were wrong in
ways that LOOKED right.

It began as a URLSession fetch of a plain-HTTP upstream and passed -- but
the URL pointed at that upstream, so a completely bypassed transport
returned the same body. Adding a completed-handshake counter at the
bridge showed the request had been going DIRECT the whole time. The same
flaw existed in the Android version and is fixed there too; removing the
socket factory now fails that test where it previously passed.

The cause was App Transport Security, which refuses cleartext BEFORE any
proxy is consulted. http://127.0.0.1 passed only because loopback is
ATS-exempt, which is precisely why it went direct; relay.invalid and
192.0.2.1 were both refused before the proxy. A diagnostic run showed the
proxy's accept loop never ran at all.

So the path is no longer joined in one test, because ATS stands between
its two halves. URLSession to the SOCKS proxy is proven over https by
SocksProxySupportTests; the proxy to the bridge to the upstream is proven
here by a raw SOCKS5 client with no URLSession involved. In production
the relay is https, so the joined path runs for real.

Two behaviours are pinned rather than left implicit. Off-by-default has
its own test, because every session calls apply unconditionally and a
default of true would silently move all relay traffic. And a malformed
bridge line yields nil, which falls back to a DIRECT connection -- right
for reachability, wrong for someone who pasted a bridge line precisely
because direct does not work, so the settings screen must validate on
entry. That requirement is written where it will be seen.

obfs4 hides traffic from the NETWORK, not from the relay operator. The
bridge proxies onward and the relay sees what it always saw. Said plainly
in the code so no user-facing copy claims otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:12:48 -04:00
Paul Hitt cbae8ae14a Merge branch 'feat/obfs4-socks-support-probe' into 'main'
Establish that iOS URLSession honours SOCKS

See merge request paulhitt/flamenet-messenger-ios!132
2026-09-06 18:09:39 +00:00
Paul HittandClaude Opus 5 7340acc8ce Establish that iOS URLSession honours SOCKS, and keep it established
iOS URLSession DOES honour a SOCKS proxy set through
connectionProxyDictionary. Verified on the Simulator, iOS 27: a SOCKS5
greeting arrived at a listener whose port number appears nowhere except
that dictionary, so nothing else could have sent it.

This decides the whole shape of the obfs4 integration. Because it holds,
obfs4 can be an in-process SOCKS5 shim that the three existing
URLSession clients point at -- a contained change. Had it come back
negative, every relay call would have had to move off URLSession onto a
transport we own, which is a far larger change to an app in review.
Published sources disagree on this, so it is settled here rather than
assumed.

Now asserted rather than printed: a future iOS release quietly dropping
SOCKS support must break a test, not a shipped app.

The first run said the opposite and was wrong. App Transport Security
refused the cleartext request before any proxy was consulted, so both the
test and its control reported "the proxy setting was ignored" -- which
reads as a clean negative answer. Only the control failing alongside it
showed the run was inconclusive. The URL is https now, which still proves
the point: through an HTTP proxy URLSession sends a plaintext CONNECT
first, and through SOCKS the SOCKS greeting, so the listener sees bytes
before TLS is attempted. An explicit ATS check now reports INCONCLUSIVE
rather than a verdict it did not earn.

The control is kept but is no longer load-bearing, and says so. It
existed to disambiguate a negative; a positive result validates itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:16:07 -04:00
Paul Hitt 13915829fe Merge branch 'fix/group-composer-addressable' into 'main'
The group composer had no identifier, so nothing could reach it

See merge request paulhitt/flamenet-messenger-ios!131
2026-09-05 19:39:29 +00:00
Paul Hitt 7b3ef6ef90 The group composer had no identifier, so nothing could reach it
The 1:1 composer has carried `chat.composer` since it was written. The
group screen is a different view and had neither that nor a name for its
send button — so nothing could address the field that sends a group
message. The two-simulator group phase asked for the 1:1 name, found
nothing, and reported "no composer in the group", which reads as a broken
screen and was nothing of the kind.

🔴 **Fourth instance today of the same root cause: something on screen
that nothing can address.** `home.add` was on two different buttons,
`Stories` matched a heading as well as the tab, group rows were keyed
only by a name drawn in three places, and this composer had no name at
all. Every one produced an error that blamed the wrong thing, and two of
them were originally diagnosed as machine load.

⚠️ Each is also a VoiceOver defect. An unlabelled text field between two
unlabelled buttons is announced as "text field" — so the send button gets
an `accessibilityLabel` here, not just an identifier.

With this, the two-simulator suite passes end to end on both simulators:
a message crosses in both directions, and a group created on one phone
arrives on the other and can be sent into. The member's half is the one
worth having — a group it was never told about has to arrive entirely out
of the creator's key distribution.
2026-09-05 15:27:43 -04:00
Paul Hitt ce8de25735 Merge branch 'test/group-phase-targets-the-row' into 'main'
The group phase taps the row, not a string that matches its name

See merge request paulhitt/flamenet-messenger-ios!130
2026-09-05 18:44:34 +00:00
Paul Hitt 7ec1977e63 The group phase taps the row, not a string that matches its name 2026-09-05 14:37:07 -04:00
Paul Hitt f41a2aa660 Merge branch 'fix/group-rows-addressable' into 'main'
Group rows had no identifier, so a test tapped their name instead

See merge request paulhitt/flamenet-messenger-ios!129
2026-09-05 18:36:50 +00:00
Paul Hitt 941d543f7e Merge branch 'diag/why-signin-fails' into 'main'
The journey gate now reaches the shell, and the pair delivers

See merge request paulhitt/flamenet-messenger-ios!128
2026-09-05 18:36:17 +00:00
Paul Hitt 42c66d88fc Group rows had no identifier, so a test tapped their name instead
The only handle on a group row was the group's NAME — which is also drawn
in the header of the group's own screen and in the sheet that creates it.
Three places, one string.

🔴 The two-simulator group phase tapped "the text matching QA_Crew",
landed on one of the other two, navigated nowhere, and the next assertion
reported "no composer in the group" — which reads as a broken group
screen and is nothing of the kind.

⚠️ Same shape as `home.add` being on two different buttons: one name,
several things, and a result that depends on which one the query happens
to resolve. VoiceOver has the same problem with the same cause.

Rows are `group.row.<name>` now.
2026-09-05 14:35:45 -04:00
Paul Hitt 19d13fc621 Merge branch 'fix/production-readiness-bugs' into 'main'
Sign out was unreachable, a 502 killed calls, and a file kept its GPS

See merge request paulhitt/flamenet-messenger-ios!127
2026-09-05 18:35:23 +00:00
Paul Hitt 02a7a0fa9e Merge branch 'test/the-journey-gate' into 'main'
A journey gate: sign up, sign in, message, call, story

See merge request paulhitt/flamenet-messenger-ios!126
2026-09-05 18:29:08 +00:00
Paul Hitt e26f5f0457 Report a message, not just a story (ios#12)
**The relay has always accepted `kind: "message"`. Nothing ever sent
one.** Reporting existed on stories only, so the surface where
unsolicited contact, spam and harassment actually arrive had no way to
tell anybody — and Apple's guideline 1.2 wants a report mechanism for
user-generated content, which messages plainly are.

🔴 **One sheet, not two.** `StoryReportSheet` becomes `ReportSheet` with
a `Subject`. The bargain, the disclosure and the endpoint are identical
for both; two sheets would be two places for the consent wording to drift
apart, and the consent is the part that matters.

⚠️ **Renaming a Swift file means editing `project.pbxproj`.** This
project lists every file explicitly — no synchronised groups — so the
build failed with "Build input file cannot be found", which reads like a
missing script phase. Six references needed updating by hand.

The per-message action satisfies the rule. **The stranger banner is where
it helps**: somebody just written to by a stranger is looking at exactly
that banner, and until now could only keep them or block them — and
blocking tells whoever runs the server nothing.

⚠️ Report is third and quietest there, below Keep and Block. Most first
messages are somebody you gave your address to, and leading with Report
would frame every one of them as a threat.

⚠️ Absent rather than disabled on your own messages. Reporting yourself
is not something anybody means to do, and offering it makes the menu read
as a moderation panel instead of a way to get help.

⚠️ One message travels, never the thread around it (§6.25). Block sits
beside the report and is never merged into it: one takes effect now and
is yours, the other is a request that may come to nothing.
2026-09-05 14:28:50 -04:00
Paul Hitt eb177ce387 Sign out was unreachable, a 502 killed calls, and a file kept its GPS
**ios#67 — there was no way to sign out.** Sign out is the last row of
`ProfileView`'s list and the floating pill sits on top of it, with
nothing below to scroll into view.

🔴 **Fixed at the root rather than on the reported screen.** `PillTabBar`
replaced `TabView` and its own header names the safe-area inset as one of
the things that costs — "the inset and the badges are below". The badges
were there. The inset was nowhere in `Sources/`. Four screens each paid
for their own clearance by hand and three did not: Account, Wallet and
Files — the two newest tabs among them. Clearance was opt-in, so every
tab added since was born broken. One `safeAreaInset` in the bar now, and
the four hand-rolled spacers are gone so nothing double-pads.

**ios#6 — a transient 502 on call accept killed the call outright.** The
offer arrived and decrypted; the accept POST hit the few seconds of 502
Traefik serves while the relay restarts, and that was the call. The
caller was still ringing. 502/503/504 now get one retry after 1.2s.

⚠️ Those three only, and only once. They mean "nothing handled this", so
repeating is safe; a 4xx is a real refusal — a changed identity, an
expired session — and retrying one turns a clear answer into a slow one.
`armConnectTimeout()` is already running, so the wait cannot hang.

**ios#69 — a photo sent as a FILE keeps its GPS.** The camera button
redraws through `ImagePrep` and no metadata container survives;
`sendVaultFile` hands the vault's bytes over untouched.

⚠️ Said rather than silently re-encoded. Rewriting somebody's document is
its own surprise and a vault is where people keep originals. What could
not stay is the difference being invisible — the safer path looked like
the shortcut. The send sheet now names it at the moment of choosing.

Also: `GroupsSection`'s doc comment said it lives on the Home list. It
has been on Messages for a while, and that stale sentence is what sent
the two-simulator group phase looking on the wrong screen.
2026-09-05 14:08:13 -04:00
Paul Hitt 548272b5c1 Three diagnostics that are errors under the Swift 6 language mode
`AppLock` is `@MainActor`, which isolates its statics too, so the
nonisolated `delay(afterFailures:)` could not read `attemptsBeforeDelay`
or `maximumDelay`. Both constants are `nonisolated` now.

⚠️ The constants rather than the function, deliberately. `delay` is
arithmetic over its argument and callers work out the wait BEFORE they
have anything on screen — making it main-actor would push a hop into the
one path that should not need one.

`FilesView` and `WalletScreen` both store
`FilesPassphraseStore.availability()`, whose tuple carries an
`LABiometryType`. A type used in a property declaration has to be
imported by the file DECLARING the property, not merely by the one that
vends it.

Also a stray `var mask` that is never mutated, in the Files engine.
2026-09-05 14:08:00 -04:00
Paul Hitt 5823198385 The journey gate now reaches the shell, and the pair delivers
Three defects, each of which made the suite report something other than
what happened.

🔴 **Sign-up created accounts under a password nobody chose.** iOS
autofills the password AND the confirm field together; our text appended
to both, so they still matched, the form submitted happily, and the
account existed with the wrong secret. Proven by API: the handle the test
had just made returned 409 on signup and 401 on signin. Both fields are
now checked by bullet count before the form is submitted.

🔴 **Sign-in sent autofill's suggestion with the passphrase glued on.**
The clear-before-type that was supposed to prevent this had been failing
silently for three runs, because nothing read the field back. It does now,
and says what it found when it cannot get the credentials in.

🔴 **`app.buttons["Stories"]` matched a heading, not the tab.** The tap
navigated nowhere and the failure surfaced one line later as "no way to
start a story" while the screen dump plainly said Home. Both tabs are
addressed by identifier now.

⚠️ The story step MOVED to the paired tier. A story is SENT to contacts,
so it needs a recipient with a published device — an account made over
HTTP is not one, and a fresh account has nobody at all. The solo tier now
says stories are not covered rather than pretending.

For the two-simulator pair: the group phase looks on the MESSAGES tab,
where `GroupsSection` actually lives, and the lookup retries by retyping
so a miss can be asked again.
2026-09-05 14:07:45 -04:00
Paul HittandClaude Opus 5 d530227716 A journey gate: sign up, sign in, message, call, story — in one run
Reported plainly: "i will always go to signin/signup/messages/calls/stories
and every time they all break." That was accurate, and nothing watched those
five together. `DemoRoundUITests` covers sign-in, receive, reply and groups —
no calls, no stories. Everything else guards a piece. So a whole day could go
green while the journey was broken end to end, and today it did.

🔴 **Two tiers, and it always says which one ran.** Solo proves what one
device can prove; paired needs somebody on the other end. The paired steps
FAIL when no peer is named rather than skipping — a gate that quietly
downgrades and reports success is the exact failure this file exists to stop.
`FN_JOURNEY_SOLO=1` says solo was the intent.

⚠️ Every send accepts a key change first, because that is the trap this was
written after (android#20): a peer that reinstalled makes the send path
refuse, and on a call that refusal looked like "it just doesn't connect".

⚠️ The call step asserts `call.end`, which exists only in ACTIVE, which is
entered only from WebRTC's ICE CONNECTED callback. There is no weaker thing
to assert that means anything.

It earned its keep on the first three runs:

  1. Sign out reported MISSING. It is the last row of a long List and SwiftUI
     does not put an off-screen row in the accessibility tree at all, so
     `waitForExistence` waited twenty seconds and then said the control was
     gone rather than below the fold. Now `scrollUntilExists`.
  2. `completeSignIn` never dismissed the strong-password panel, while
     `completeSignUp` twelve lines above dismisses it three times. Focusing a
     secure field is exactly when iOS offers one, and it swallows the typing.
     Fixed — though see below, it was not the cause here.
  3. 🔴 **Sign-up → sign-out → sign-in fails in the app.** The account and its
     device exist on the relay and hold exactly ONE app credential — the one
     minted at sign-up — so the sign-in never succeeded server-side. The same
     handle and passphrase sign in against that relay in a single request,
     HTTP 200 with a fresh credential. Filed; not yet separated into app bug
     versus harness typing, and this commit does not claim to know which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 10:58:11 -04:00
Paul Hitt 2f1d625a09 Merge branch 'tooling/android-signin-driver' into 'main'
An Android sign-in driver (ios#7)

See merge request paulhitt/flamenet-messenger-ios!125
2026-09-05 13:36:24 +00:00
Paul HittandClaude Opus 5 bd4818df9a An Android sign-in driver, and the three traps it exists to encode
The Android half of any cross-platform check has to be driven by something,
and `android-signup.sh` only signs UP — which on the live relay leaves a real
account behind and spends the account service's three-per-hour-per-IP budget.

🔴 **Three traps, all of which cost an hour today, all silent.**

  1. `input text` drops characters on longer strings. A 21-character
     passphrase arrived as 11. Nothing reports it; the sign-in just fails as
     though the password were wrong. So: chunks, and read the count back.

  2. The soft keyboard covers the button. A tap at the button's coordinates
     lands on a KEY — which types into the field you just filled, so the
     passphrase grew by one character every time I "pressed" sign in.

  3. Dismissing the keyboard MOVES the button, and on a slow emulator the
     layout is still settling when the tap goes out. ⚠️ This is not
     theoretical: it put the driver on the SIGN-UP form, one field from
     creating an account on production, with every coordinate correct at the
     moment it was read.

Trap 3 is why `tap_label` reads the bounds twice a second apart and refuses
to tap a control that is still moving — and why the sign-in button is found
by LABEL, never by index. An index picked "Create an account".

⚠️ It does not get all the way in yet. Filed separately: the app reaches the
sign-in screen with correct credentials, opens sockets, and then sits there
for four minutes showing nothing — no spinner that resolves, no error. TCP to
accounts.flamenet.io:443 and relay.flamenet.io:443 both succeed from inside
the emulator, and the same credentials sign in fine on iOS, so this is not
the network and not the credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 09:33:20 -04:00
Paul Hitt 62593e68b2 Merge branch 'test/call-harness-can-sign-in' into 'main'
Let the call harness sign in, not only sign up (ios#7)

See merge request paulhitt/flamenet-messenger-ios!124
2026-09-05 11:46:12 +00:00
Paul HittandClaude Opus 5 7c2750f357 Let the call harness sign in, not only sign up (ios#7)
`LiveCallUITests` is the only thing that can answer "do calls connect" — the
assertion is sound (`call.end` exists only in `.active`, entered only from
WebRTC's ICE CONNECTED callback) — and it could only ever be run by creating
a fresh account.

🔴 **That made it single-use.** On the live relay each run leaves a real
account behind, and it spends the account service's three-per-hour-per-IP
budget, so the second attempt in an hour fails with a 429 that reads like a
broken sign-up screen rather than a spent quota. A harness you can run once
an hour is a harness nobody runs.

`FN_CALL_EXISTING=1` signs in instead, so the two review accounts can drive
it repeatedly and leave nothing behind.

⚠️ Found while trying to answer whether calls work between two simulators.
They cannot be answered there — VoIP push has no token on a simulator — but
that is about the PUSH, not the call: two foregrounded apps need no push, and
this harness is exactly that shape. It could not be run because of account
creation, not because of anything to do with calling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 07:42:43 -04:00
Paul Hitt b32a8e70c7 Merge branch 'safety/signup-needs-permission' into 'main'
A UI test may not create an account without saying so (ios#62)

See merge request paulhitt/flamenet-messenger-ios!123
2026-09-05 11:25:00 +00:00
Paul HittandClaude Opus 5 452d863c71 Correct this guard's own explanation — I had the mechanism half wrong
The previous message said "FN_TEST_RELAY steers CrossPlatformUITests and
nothing else." That is false, and I found it while about to act on it.

Measured properly: **eight suites honour the variable** and pass it through
as `-FNMSGServer`; `AccountFunctionsUITests` is one of them. **Six ignore it
and sign up** on the server the app ships pointed at.

So there were two different mechanisms, not one:

- `rt88604662` — `SignupSigninRoundTripUITests` ignores the variable. That is
  the case the first message described, and it was right about that one.
- `uitest5841078` / `uitest5892792` — nobody's bug. I aimed a signing-up
  suite at the live relay and it did exactly what I asked.

⚠️ Correcting this because the wrong version made the gate look like a
workaround for one broken suite. It is not. A rule about variables would have
caught three of the six ignoring suites and none of the second case; a gate at
the call site catches all of them, and does not care which mechanism brought
you there.

No behaviour change — the guard already worked. Only the explanation was
wrong, and the explanation is what somebody reads before deciding whether to
delete it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 07:20:55 -04:00
Paul HittandClaude Opus 5 58a19ac5a1 A UI test may not create an account without saying so (ios#62)
Two accounts appeared on the PRODUCTION relay today from runs that believed
they were pointed at localhost. `rt88604662` and, after I thought I had
understood the first one, `uitest5841078` and `uitest5892792`.

🔴 **`FN_TEST_RELAY` steers `CrossPlatformUITests` and nothing else.** Every
other suite launches the app on the server it ships pointed at, which is
`relay.flamenet.io`. Setting the variable, reading the word "relay" in it,
and concluding the run is local is the obvious wrong inference, and I made it
twice.

⚠️ **The audit meant to prevent the second one was itself wrong.** I ran
`grep "signUp("`, which cannot match `completeSignUp(` — the S is capitalised.
It reported five suites that sign up. The real number is **thirteen of
twenty-one**. A guard that under-reports is worse than no guard, because it
gets believed; I believed mine and said so out loud.

So the gate goes at the one place all thirteen funnel through, rather than in
a list somebody has to keep correct. `completeSignUp` now requires
`TEST_RUNNER_FN_ALLOW_SIGNUP=1`.

⚠️ **It fails rather than skips.** A skip would be the same silence in a
different colour — the suite reports green having exercised nothing, which is
the exact failure `UITestSafetyTests` exists to prevent one file over.

Proven by running `AccountFunctionsUITests` against production with the
variable unset:

  tests that create an account  → failed, naming the variable
  tests that do not             → passed
  accounts created              → zero

⚠️ It also corrected a diagnosis. `testForgotPasswordOpensAndAsksForTheHandle`
failed in the unguarded production run and PASSES here, so that failure was a
knock-on from earlier sign-ups in the same run rather than a bug in password
reset. I was about to go looking for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 07:19:35 -04:00
Paul Hitt 14fe6568bc Merge branch 'fix/unsend-tells-the-truth' into 'main'
"Unsend" did not unsend (ios#57)

See merge request paulhitt/flamenet-messenger-ios!122
2026-09-05 10:59:34 +00:00
Paul HittandClaude Opus 5 fb3999be90 Fix the two UI tests the rename broke (ios#57)
`AppSurfaceUITests` drives the message menu by label, so renaming the button
turned both of these red. Caught by running the suite against a local relay,
not by review — I added a guard test for the label and did not grep the UI
tests for the label I was removing.

⚠️ **The second test's NAME was part of the problem.**
`testUnsendTakesTheMessageOutOfTheThread` quietly agreed with the button's
claim — that the message is gone — while the comment three lines down
already said it is gone from this device only. The title and the comment
disagreed, and the title is what shows up in a report. Now
`testDeleteForMeTakesTheMessageOutOfThisDevicesThread`.

Both still assert the same behaviour. Only the words changed, in the place
where the words were wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 06:56:25 -04:00
Paul HittandClaude Opus 5 e2e5ade5bc "Unsend" did not unsend. Say what the button does (ios#57)
`ChatView` and `GroupChatView` both offered "Unsend" on your own messages,
and both called a function that removes the message from THIS device. The
recipient's copy stayed exactly where it was. Somebody who sent to the wrong
person tapped it, watched the bubble vanish, and had no way to find out.

🔴 **Worse than an overclaiming page**, which is why this got a test and not
just an edit. A page that overclaims can be read sceptically. A button that
reports success cannot: the feedback IS the claim, and it arrives at the
moment the person is most relieved to believe it.

Both branches now say "Delete for me" — `GroupChatView` already had that as
the second half of its ternary, and it was always the accurate half.

⚠️ **The icon was half the promise.** `arrow.uturn.backward` is the
take-it-back glyph. Renaming the label and leaving the arrow would have kept
the claim in the picture, where it is read faster than the word. Now a trash
can, and a test guards that too.

`unsendLocal` and `deleteLocal` stay as two identical functions rather than
collapsing into one: the day a real retract exists, `unsendLocal` is the one
that grows an envelope and the other stays local. Collapsing now would make
that change look bigger than it is and invite doing it to both.

Both guards proved by breaking them, not by watching them pass:

  group label reverted to "Unsend"  → testNoButtonSaysUnsend FAILED,
                                      naming GroupChatView.swift line ~167
  label restored, icon reverted     → testTheDeleteAffordance… FAILED,
                                      naming ChatView.swift line ~605
  both restored                     → TEST SUCCEEDED

⚠️ Android has the identical mislabel at ChatScreens.kt:1956 over a
`vault.deleteLine` that sends no envelope. Same fix, separate change.

A real retract is a new `kind` on rails that already exist — §6.15's edit
machinery is implemented and tested on both clients. But even then it is a
request, not a guarantee: a client can ignore it and the message may already
have been read. Whatever the button eventually says must not promise more
than "we asked them to remove it."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:03:06 -04:00
Paul Hitt 2b6b9c16b4 Merge branch 'fix/sender-binding-flake' into 'main'
Harden SenderBindingTests' isolation (ios#59)

See merge request paulhitt/flamenet-messenger-ios!121
2026-09-05 00:14:37 +00:00
Paul HittandClaude Opus 5 e314012117 Harden SenderBindingTests' isolation (ios#59)
⚠️ **The flake itself was already fixed** — `setUp` clears the devices
before each test, and these tests clear exactly what they use, so a
colliding namespace stopped mattering. This is hardening on top, and the
commit says so rather than claiming a fix twice.

What changed:

- The namespace is unique per run instead of random out of 8,000. A random
  id is a birthday problem, not isolation: the simulator Keychain persists,
  so collisions start rare and become reliable once the suite has run
  enough times. Removing the collision means the guarantee no longer rests
  on the cleanup list staying complete.
- The device names live in one array and the tests reference it, so the
  next device added cannot quietly go uncleaned.
- ⚠️ Cleanup still matters, for the opposite reason to before: a unique
  namespace per run would otherwise leave another set of entries behind
  every time.

🔴 **A claim I made and then disproved, kept in the comments because the
next reader will think the same thing.** The cleanup lists `dev-A` and
`dev-B` while the suite also names `dev-C`, which looks exactly like a
leak. It is not: `dev-C` appears only in the test that binds user 0, that
binding is refused, and nothing is ever written for it. Checked by pinning
the namespace and running twice with the old cleanup — both passed, so the
"one device short of fixed" story was wrong and the comment now says which
way round it is.

Verified: 8 consecutive runs of the suite, 0 failures; 707 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:11:53 -04:00
Paul Hitt 04162b9c7c Merge branch 'test/ui-suites-are-local-or-skippable' into 'main'
Close the hole in the UI-suite safety guard (ios#62)

See merge request paulhitt/flamenet-messenger-ios!120
2026-09-05 00:01:22 +00:00
Paul HittandClaude Opus 5 9fa8a7d5da Close the hole in the UI-suite safety guard (ios#62)
ios#62 is fixed — I verified every suite: four reach
`UITestPrecondition.demoPassphrase()`, which throws `XCTSkip` when the
variable is unset, and two (`FilesVaultUITests`, `WalletUITests`) launch
into preview modes that render a local stub and never sign in.

⚠️ **But the guard that exists would not have caught a new one.** It is
scoped to suites calling `completeSignUp(` or `completeSignIn(` — right
for what it catches, and blind to a suite that simply launches the app on
a simulator that is already signed in and lets the poll loop talk to
`relay.flamenet.io`. No password typed, production reached anyway.

So this asserts the broader property: every suite that launches the app
either can skip, or is in a preview mode.

⚠️ Preview counts as safe only because those modes really are local —
`-FNMSGFilesPreview` renders `FilesBrowserPreview`, `-FNMSGWalletPreview`
renders a wallet against a stub node rather than Solana. If either gains a
network path it stops belonging on that list, and the comment above the
check is where that is written down.

Proved rather than assumed: a probe suite that launches, cannot skip and is
not in preview was dropped into `UITests/` and the guard failed with
exactly the right message and the right filename. ⚠️ The probe was not
added to the project — the scan reads the directory as text, so it needs no
compilation, which turns a ten-minute target rebuild into a ten-second
check.

708 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:59:07 -04:00
Paul Hitt 13657dd2a3 Merge branch 'fix/vault-descendants-and-picker-depth' into 'main'
Files: walk the whole tree, and stop counting steps (ios#66, ios#68)

See merge request paulhitt/flamenet-messenger-ios!119
2026-09-04 23:26:48 +00:00
Paul HittandClaude Opus 5 cf99760fd0 Files: walk the whole tree, and stop counting steps (ios#66, ios#68)
**ios#66 — `descendants(of:)` returned 10,000 duplicate ids on a cyclic
index.** It terminated by counting steps, and each pass appended a child,
so an A→B→A cycle produced ten thousand copies of two ids. The screen
calling it is the one offering "delete this folder and everything in it",
so a corrupt index turned that into an offer to delete 10,000 things.

Bounded by what has already been visited instead — which terminates AND
returns the right answer, and needs no magic number. A cycle cannot be
built through this API; the index is a file on disk, so the input is not
only ever our own writes.

**ios#68 — a file inside a folder could not be sent from a conversation.**
The picker listed `list(nil)`, the root only. Nothing said so: the file
was simply absent, which reads as "it isn't there", and the obvious next
move is to keep a second copy at the top level. `allFiles()` walks the
whole tree.

⚠️ It drops unreadable and missing rows rather than showing them disabled,
and that is the ONE place this class hides a row it cannot open —
everywhere else it insists on showing them, because hiding one leaves
somebody unable to see anything is there. A picker exists only for
choosing something to send, and a row that can never be chosen is
furniture.

Five tests, both fixes proved by reverting them: the step counter fails
the cycle test, the root-only filter fails the depth test.

⚠️ Two editing traps worth recording. `open(_:)` with an argument resolves
to the C library's `open()`, not the test helper. And this file ends with
a free function AFTER the class, so appending at the end of the file
splices tests into that function — braces balance, scope does not, and the
error reads as "cannot find 'self' in scope".

707 app tests, 88 engine, 35 client — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:24:49 -04:00
Paul Hitt 589db74ab6 Merge branch 'fix/home-record-rollback' into 'main'
Refuse a home record older than one already seen (§20.4)

See merge request paulhitt/flamenet-messenger-ios!116
2026-09-04 21:13:37 +00:00
Paul Hitt 4f205303cf Merge branch 'fix/tests-must-not-touch-production' into 'main'
A plain test run must not reach the live service (ios#62)

See merge request paulhitt/flamenet-messenger-ios!115
2026-09-04 21:13:33 +00:00
Paul Hitt 5a0301cc40 Merge branch 'feat/zero-key-material' into 'main'
Zero key material after use (ios#39)

See merge request paulhitt/flamenet-messenger-ios!113
2026-09-04 21:13:27 +00:00
Paul Hitt 5e37df4c0f Merge branch 'feat/app-lock-backoff' into 'main'
Back off on repeated app-lock attempts (ios#41)

See merge request paulhitt/flamenet-messenger-ios!111
2026-09-04 21:13:21 +00:00
Paul Hitt 78d1a02dd2 Merge branch 'feat/wallet-send' into 'main'
Send from the wallet

See merge request paulhitt/flamenet-messenger-ios!110
2026-09-04 21:13:18 +00:00
Paul Hitt e3e027be3c Merge branch 'reinstate/honour-log-revocations' into 'main'
Reinstate: honour revocations from the key log, not the directory

See merge request paulhitt/flamenet-messenger-ios!108
2026-09-04 21:13:10 +00:00
Paul Hitt 16cabe096a Merge branch 'reinstate/private-story-archive' into 'main'
Reinstate: keep your own stories in a private archive

See merge request paulhitt/flamenet-messenger-ios!107
2026-09-04 21:13:07 +00:00
Paul Hitt d86d86a18b Merge branch 'reinstate/report-or-block-from-a-story' into 'main'
Reinstate: report or block from a story

See merge request paulhitt/flamenet-messenger-ios!105
2026-09-04 21:12:58 +00:00
Paul Hitt 6bf38b59f7 Merge branch 'reinstate/multi-item-stories' into 'main'
Reinstate: post several pictures as one story sequence (§6.23)

Closes #29

See merge request paulhitt/flamenet-messenger-ios!104
2026-09-04 21:12:55 +00:00
Paul Hitt f3c690ad81 Merge branch 'reinstate/pin-a-message' into 'main'
Reinstate: pin a message in a conversation (§6.19)

Closes #28 and #50

See merge request paulhitt/flamenet-messenger-ios!102
2026-09-04 21:12:47 +00:00
Paul Hitt 82b271f2a1 Merge branch 'reinstate/jump-to-quoted-message' into 'main'
Reinstate: tap a reply's quote to reach the message it answers

Closes #48

See merge request paulhitt/flamenet-messenger-ios!100
2026-09-04 21:12:40 +00:00
Paul Hitt 7fd10ef6da Merge branch 'reinstate/group-stories' into 'main'
Reinstate: post a story to a group (§6.24)

See merge request paulhitt/flamenet-messenger-ios!95
2026-09-04 21:12:24 +00:00
Paul Hitt 29d8fdc51c Merge branch 'reinstate/group-messages-carry-a-mid' into 'main'
Reinstate: group messages carry an id of their own (§6.9)

Closes #26 and #58

See merge request paulhitt/flamenet-messenger-ios!93
2026-09-04 21:12:17 +00:00
Paul Hitt c5f6d3aeda Merge branch 'reinstate/signup-proof-of-work' into 'main'
Reinstate: solve the sign-up puzzle when a relay asks for one

See merge request paulhitt/flamenet-messenger-ios!90
2026-09-04 21:12:06 +00:00
Paul Hitt ef77f90cbc Merge branch 'reinstate/email-verification-ui' into 'main'
Reinstate: offer the code entry on the screen that refuses the sign-in

Closes #25 and #47

See merge request paulhitt/flamenet-messenger-ios!89
2026-09-04 21:12:02 +00:00
Paul Hitt ed9aef3c6d Merge branch 'revert/webrtc-m152-superseded' into 'main'
Revert the M152 bump: a better fix was already written

See merge request paulhitt/flamenet-messenger-ios!118
2026-09-04 21:11:46 +00:00
Paul HittandClaude Opus 5 1a6d920d67 Revert the M152 bump: a better fix was already written and waiting
🔴 **I bumped WebRTC to M152 to unblock the build. That was the wrong
call, and the right one already existed** — on the 31 open MRs, written
2026-08-31: `Packages/WebRTCBinary`, a local wrapper that declares the
binary target's URL and checksum itself.

The wrapper is strictly better than what I did:

- It keeps the **exact M151 build the app was tested against**, so calls
  are not sitting on an unexercised media stack.
- It points at the `151.0.1` ASSET, which downloads fine — the thing that
  404s is only the URL inside upstream's manifest. A wrapper obeys neither
  their manifest nor their tags.
- The checksum was verified independently and SwiftPM re-checks it on
  every fetch, so a substituted binary fails to build rather than shipping.
- And it removes the whole class of failure: a build a third party can
  break by deleting a release is not a build.

⚠️ I did not find it because I diagnosed the 404 and fixed it before
reading what the open branches contained. The stack's own `project.yml`
says *"see Packages/WebRTCBinary/Package.swift for why a version pin
stopped being a pin"* — the answer was one file away.

Reverting so the stack lands its version rather than fighting mine over
`project.yml` and `Scripts/Package.resolved`, which is the only thing all
31 MRs conflicted on.

⚠️ Main does not build between this commit and !86. That window is
deliberate and short: main did not build for days before today either, and
the alternative is thirty-one conflicts resolved by hand.

Verified on the stack tip with the wrapper in place: BUILD SUCCEEDED and
707 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:11:30 -04:00
Paul Hitt d814d60172 Merge branch 'fix/webrtc-pin-vanished-upstream' into 'main'
Fix: iOS could not be built at all — the WebRTC release vanished

See merge request paulhitt/flamenet-messenger-ios!117
2026-09-04 21:05:57 +00:00
Paul HittandClaude Opus 5 16724dfcdb Fix: iOS could not be built at all — the WebRTC release vanished
🔴 **`xcodebuild` failed before compiling a line, on every branch, for
everybody.** Upstream deleted the `151.0.0` release of stasel/WebRTC. A
binary target downloads its asset from the release URL, so resolution died
with `badResponseStatusCode(404)`. This is not a regression in any of our
work — the repository simply stopped building when a third party removed
a file.

⚠️ **`151.0.1` looks like the repair and is not.** Upstream re-tagged and
published a working `WebRTC-M151.xcframework.zip` under it — but the
`Package.swift` AT that tag still points at the deleted `151.0.0` URL, so
SwiftPM follows the manifest and 404s exactly as before. I tried it first;
the asset downloading by hand is not the same as the package resolving.

That left the self-consistent releases either side, so this is M152 — one
Chromium milestone forward. **A forced bump, not a chosen one.** The
alternative was vendoring a 43 MB binary into this repository, which is a
bigger and far less reversible decision than a milestone.

🔴 **Calls are what this library is for, and a compile is not a call.**
The bump has to be exercised device-to-device before any release; the note
in `project.yml` says so at the pin rather than in a commit nobody will
re-read.

⚠️ A stale precompiled module survives the bump and reports as
`RTCPeerConnection.h has been modified since the module file was built` —
DerivedData has to go, or the failure looks like a source error.

Verified: BUILD SUCCEEDED, and 342 unit tests pass with 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:05:38 -04:00
Paul HittandClaude Opus 5 64bfa8e181 Refuse a home record older than one already seen (§20.4)
🔴 A signature alone does not stop a rollback, and both clients checked only
the signature.

An older home record is GENUINELY SIGNED — the account holder published it,
before they added a second relay. So a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first,
that record verifies perfectly, and the sender delivers to one relay believing
it is delivering to all of them. That is precisely the attack multi-homing
exists to survive, and the serial is what was added to stop it.

The relay refuses a lower serial on WRITE. Nothing refused one on READ, on
either client — `verifiedRelays` on Android and `verifyHomes` here both checked
the signature and stopped. Found while closing the Android parity gap; fixed on
both, because it is the same hole.

The highest serial seen per address is now remembered and a lower one is
refused. Equal is fine — re-reading the same record is the ordinary case and
refusing it would break every second lookup; only strictly lower is a rollback.

🔴 The floor rises only for a record that VERIFIED. Otherwise a hostile relay
could hand over an unsigned record carrying `Int.max`, have the client raise
its floor to it, and lock out every later genuine record — turning the rollback
defence into a denial of service against the account holder. Tested.

⚠️ The refusal does not claim an attack. A relay that has genuinely fallen
behind serves an old record for honest reasons, so the message says both: that
this app has seen a newer list, and that this is also what it looks like when a
relay is trying to be your only mailbox. Nothing in the client can tell those
apart.

⚠️ Keyed by a hash of the address rather than the address itself — which
addresses this device has looked up is metadata worth not leaving in a defaults
plist — and each address has its own floor, so a busy contact's serial cannot
lock out a quiet one's record.

10 tests. 707 iOS unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:06:54 -04:00
Paul HittandClaude Opus 5 1cc048452f A plain test run must not reach the live service (ios#62)
`xcodebuild test -scheme FlamenetMessenger` runs every UI suite, and two of
them were reaching production on any machine without credentials.

🔴 The worse one had not been noticed. `RelayTypesUITests` did not sign in —
it **signed up**, on relay.flamenet.io, relay2 and relay1, on every plain run.
Three real accounts on the live service, created with the literal string
`set-FN_DEMO_PASSPHRASE` as the password, because that was the fallback when
the environment variable was missing. This repository is public, so every
account it ever made has a password anybody can read. It also spent the
account service's three-signups-per-hour-per-IP quota — the quota the sibling
provisioning suite already warns about locking a real person out of.

It is now opt-in on FN_RELAY_TYPES=1 AND requires a real passphrase, with no
fallback. Both gates: the opt-in says this is a provisioning tool rather than
a correctness check, and the passphrase requirement means it cannot proceed
with a value that is written down.

🔴 And a guard, because this bug class has now bitten twice — six unguarded
suites the first round, this one the second. Tests/UITestSafetyTests.swift
runs in the unit target (no simulator, no cost) and fails the build if a
credential falls back to a literal, or if a suite that signs in or signs up
has no way to skip.

The guard immediately found a third instance: LiveCallUITests fell back to
`?? "a-good-password-1"`. It is gated on a handle and a peer, so it only ran
deliberately — but somebody who set those and forgot the passphrase would have
created an account on the live relay with a published password. All three are
required together now.

⚠️ And it found a false positive in its own first version, which is fixed
rather than tolerated: it flagged an EMAIL default in UITestSupport, because
the word "password" appeared in a trailing comment on that line. Trailing
comments are stripped now. A guard that cries wolf gets switched off, and an
email default to a reserved non-resolvable TLD is fine.

Verified: both account-creating suites now skip in under 0.2s, the whole run
5.7s. 697 unit tests pass, both test targets compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:18:11 -04:00
Paul Hitt 78b5f68f5b Pin the relay's identity, not just its TLS (ios#19)
🔴 **TLS answers "is this host who DNS says it is". It does not answer
"is this the same relay I have been talking to".** A mis-issued
certificate, a hijacked DNS record, or somebody who obtains the domain
each produce a server with a perfectly valid certificate for the right
name and a different identity behind it.

The relay already publishes an issuer key, and it was already pinned —
but only inside the key-log path, and only when a signed head arrived.
A relay that serves none was never pinned at all, which is exactly the
deployment worth covering. The pin now happens on its own, once per
session, without waiting for anything.

⚠️ **A relay that publishes no key is a weaker deployment, not an
attack.** Somebody self-hosting an older build has nothing to pin, and
refusing to talk to them would be a worse failure than the one this
prevents. Unparseable is the same case: a relay answering with rubbish
is broken, and treating broken as hostile raises an alarm nobody can act
on.

⚠️ A change is recorded, never resolved. Nothing here can tell a
deliberate rotation from a substitution, so the warning names both and
claims neither — an alarm that asserts an attack when a rotation is
equally likely is one people learn to dismiss. Accepting re-pins to what
the relay presents NOW, not to what it presented when the alarm fired,
or the alarm returns immediately.

⚠️ Stored separately from `keyLogIssuerPin` even though both hold the
same key today. Folding them together would let the weaker path — the
one that only runs on a signed head — decide when pinning happens.

693 unit tests green, 7 new. No UI surface, so no UI run.
2026-09-04 05:21:47 -04:00
Paul Hitt 69c9e089cb Zero key material after use (ios#39)
🔴 **This narrows a window; it does not remove the material.** What it
buys is that a derived key stops sitting in the process for the lifetime
of the app after the one moment it was needed — which matters for a
memory dump, a crash report, or a debugger attached later. It buys
nothing against somebody reading memory while the secret is in use, and
claiming otherwise would spend the whole value of the feature on a lie.

Applied where it is worth most: the master key from the account KDF. It
costs 650,000 iterations of PBKDF2 to produce, is needed for exactly one
HKDF call, and was then left in memory. It is wiped on the way out,
including out of a throw — a derivation that failed half-way is exactly
where material gets forgotten.

The limits are stated in the type and asserted in the tests, because a
wipe that is believed to do more than it does is worse than no wipe: it
gets spent as an argument for holding secrets more loosely.

⚠️ A Swift `String` cannot be zeroed. Strings are immutable, copy on
assignment, and small ones live inline. A passphrase from a `SecureField`
is beyond reach, and that is a limit of the language rather than an
oversight.

⚠️ `Data` is copy-on-write, so a wipe reaches one copy. There is a test
that proves a copy taken beforehand survives, and it says in as many
words that if it ever starts passing the other way the comments are
wrong.

⚠️ `memset_s`, not `memset` — the compiler may delete a write whose
result is never read, and this one by definition never is.

⚠️ iOS does not page application memory to disk, so there is no swap
file in play. That is a property of the platform, not of this code.

686 unit tests green, 11 new. No UI surface, so no UI run.
2026-09-04 05:13:09 -04:00
Paul Hitt 1ec5dd59ce Handle a wrong clock (ios#45)
Nothing in this app read the server's `Date` header, so it had no way to
know its own clock was wrong — and almost everything time-dependent here
is a promise: a story disappears after an hour, a message clears itself
after a day.

🔴 **Those promises are made by the SENDER and enforced on the READER's
device.** So a reader whose clock runs behind keeps a message the sender
was told would be gone, and neither of them can tell. That asymmetry
decides the design: being wrong in that direction breaks somebody else's
expectation silently, while being wrong the other way costs the reader
something they were about to lose anyway.

So for expiry the rule is **the later of the device's clock and the
relay's**, never either alone. A device set back cannot extend a
message's life, and neither can a relay that lies about the time —
they would both have to be wrong in the same direction. The accepted
cost is that a clock set forward expires things early.

⚠️ The relay is a hint, not an authority. A hostile one would like to
move time backwards so disappearing messages linger; taking the later
of the two is exactly what makes that useless to it.

⚠️ The skew is learned from a header every response already carries, so
it costs no extra request. It is not persisted and it ages out after an
hour: a skew learned yesterday says nothing about today, because the
clock may have been corrected or the phone rebooted.

The warning names the symptom before the cause. Somebody whose stories
keep vanishing does not think "my clock is wrong" — they think the app
is broken — so it says what they are seeing, then how to fix it.

675 unit tests green, 14 new. Full UI suite: 48 passed, 19 skipped, 0
failed.
2026-09-03 22:48:02 -04:00
Paul Hitt 3782314d23 Back off on repeated app-lock attempts (ios#41)
Nothing for the first three failures — a mistyped passcode or a Face ID
that did not read is ordinary, and making the first slip cost a wait
punishes the owner far more often than anybody else. Then 30 seconds,
doubling, stopping at five minutes.

🔴 **The ceiling is not a nicety.** An unbounded back-off locks somebody
out of their own messages permanently: there is no reset, no support
desk, and the data is on their own phone. That is a worse outcome than
the one this defends against.

🔴 Persisted, because the obvious bypass for a back-off held in memory
is to force-quit the app. And it refuses BEFORE showing the system
prompt — letting the sheet appear and discarding the answer would teach
somebody that attempts are free.

Only a genuine wrong answer counts. A cancel is not an attempt, and
counting one would let the owner back themselves into a wait by changing
their mind twice. Switching the lock off clears it: that is done from
inside an unlocked app, so they have already proved who they are.

⚠️ Said rather than hidden: this is wall-clock, so somebody who can
change the device clock skips the wait. It slows a person holding the
phone. What protects the messages is the Keychain and file protection,
exactly as AppLock's own note has always said.

---

Two test defects fixed alongside, neither of them regressions.

**A flake I finally tripped.** `SenderBindingTests` isolated itself with
a random user id out of 8,000, but the simulator Keychain persists
between runs — so after dozens of runs today a namespace collided with
an old one's leftovers and `dev-A` was already bound to 42. It passed
alone and failed in the suite. It now clears the devices it uses rather
than hoping nobody has been there.

**Six UI tests reported red on any clean machine.** They need
FN_DEMO_PASSPHRASE or a session already signed in on this simulator;
without either they ran with the literal string "set-FN_DEMO_PASSPHRASE"
as a password and called that a failure of the demo accounts. They skip
now, loudly, naming the variable or the state — the mirror of the
Android bug fixed in 8c8f2c1, where a skipped live sign-up reported as
passed. A suite with six permanent reds is a suite whose colour nobody
reads, which is exactly how a real break survives a run.

661 unit tests green. Full UI suite: 48 passed, 19 skipped, 0 failed.
2026-09-03 22:00:30 -04:00
Paul Hitt 1a014e5260 Send from the wallet
The wallet could hold and receive; it can now spend. Everything here is
shaped by one fact: a Solana address carries no checksum, so a mistyped
one is very often still a perfectly valid address belonging to nobody,
and there is no reversal and no support desk.

So the screen leads with paste, shows the address back in blocks of four
on a second screen, and signs nothing until that screen has been read.
Review stays disabled while anything is wrong, and says which thing.

The serialiser is written out by hand rather than reflected over, and
the tests decode the message back with an independent reader rather than
comparing it to a string the same file wrote — a serialiser that agrees
with its own expectations and disagrees with the chain is the failure
worth guarding against. Account order, the header's category counts, the
four-byte discriminant, the length-prefixed payload and the signature's
coverage each have a test, and the signature is also checked NOT to
verify against a different amount.

Money never touches a `Double`. "0.1" parsed as one and multiplied gives
99,999,999 lamports, and there is a test for exactly that figure. More
decimal places than the chain has is refused rather than rounded.

Two reservations are named on screen rather than silently kept back: the
fee, and the rent-exempt minimum below which the runtime deletes the
account outright — so "send everything" means everything above that, and
sending to zero would close the wallet rather than empty it.

🔴 A failed send is never retried. A blockhash is good for about two
minutes, so a submission that timed out may still be accepted; the error
says so and points at the transaction list instead of offering a button
that would pay twice. `skipPreflight` stays false for the same reason —
simulation refuses the obvious failures before a fee is taken.

⚠️ **Not yet proven against a live chain.** The structure follows the
documented format and the tests pin every part of it, but no
transaction built by this code has been accepted by a validator. It
needs a devnet run with a real airdropped balance before it goes near
mainnet, which is what `isTestNetwork` detection was kept for.

653 unit tests and 8 wallet UI tests green, including two that assert
the refusals.
2026-09-03 18:56:38 -04:00
Paul Hitt 8d760f04cf Reinstate: mentions resolved against the roster, and a group you can mute
Takes the parts of ios!55 my earlier rebuild in this session did not
have. The picker and the carrying of ids were already back; these were
not, and the second one is a bug fix rather than a feature.

🔴 A group could not be muted at all. Muting was keyed on the AUTHOR of
an arriving message, so silencing a group meant muting every member one
at a time — and anyone who joined later went straight back to buzzing. A
federated thread was worse: every remote correspondent shares
`fromBuddyID: 0`, so muting one silenced all of them. Muting is per
conversation now. The 1:1 key is deliberately unchanged, because it is
what every existing mute on every phone is stored under.

🔴 It also corrects who decides what a mention may do. My rebuild had a
mention always override a mute, which hands every member of a group a
way to interrupt somebody whenever they like. The sender decides a
message is addressed to you; you decide whether being addressed is worth
a notification. Defaults to breaking the mute, with a way off.

`Mentions.resolve` reads the roster rather than the text, so typing
"@anyone" means nothing unless somebody by that name is in the group,
and a message cannot be crafted to address a person it should not reach.
`shouldNotify` is a free function so the rule can be tested at all.

⚠️ Two deliberate divergences from the original commit. The wire key
stays `men`, not `ment` — that is what ios!86 and the Android engine
already speak and were verified interoperating on, and switching it here
would break Android silently. And `maximumMentions` is applied at parse
rather than on the envelope shape, for the same reason it existed: a
hostile sender can name ten thousand ids and the cost lands on the
recipient.

644 tests green.
2026-09-03 18:15:22 -04:00
Paul HittandClaude Opus 4.8 883c2d3325 Honour revocations from the key log, not the directory
Clients decided "is this device live" from the directory, which the relay
controls freely. The key log records every departure permanently and
cannot be quietly rewritten — but nothing client-side was reading it for
that. So a relay could keep a revoked device reachable in the directory
while showing the revocation to anyone auditing the log, and both answers
would look consistent to their respective audiences.

The send path now filters the directory through what the log has said.

🔴 A revocation is a one-way latch, and that is the answer to the attack
the ticket names: a relay that stops serving the log it used to serve is
a downgrade that looks like an outage. Latching makes that not matter for
anything already seen. The relay can go quiet, but it cannot un-say what
it already said — silence stops new revocations being learned, it cannot
resurrect an old one.

⚠️ The set only ever grows, in both the fold and the store. A device
absent from a later answer has not been un-revoked; it has not been
mentioned, which a relay can arrange for any device at any time. Removing
on absence would hand back exactly the power this removes.

⚠️ Persisted rather than kept in memory. A latch that reset on launch
would give a quiet relay every revoked device back on the next start,
which is the same downgrade by a slower route.

⚠️ `expire` latches as well as `revoke`, matching the relay's own
`isRetired`. A device that aged out is as gone as one removed by hand,
and enforcing only explicit revocations would leave the commoner case
open.

⚠️ Filtering preserves the directory's order, which is the send order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 18:07:13 -04:00
Paul Hitt b91c43463f Keep your own stories in a private archive — and move the mute with it
Reinstates the archive, and with it the story mute's storage.

The mute is the part worth explaining. My rebuild of it earlier in this
session put it in `UserDefaults`; the reinstated code puts it in the
sealed snapshot, and its comment says why: a plist in the app container
is readable by anything that reaches the file, and who somebody has
muted is a fact about their social graph, not a display preference. That
argument is right, so the two implementations are unified on the sealed
one rather than left to disagree. `storiesMutedKey` and the
UserDefaults accessors are gone; `areStoriesMuted`/`setStoriesMuted`
stay as the names the views already call and forward to the snapshot.

The mute tests moved with the storage. They asserted on the plist key,
which no longer exists, so they now test `visibleStoryAuthors` — the
pure rule behind the behaviour, without needing an inbox, the same shape
`StoryArchiveTests` uses. That also let one more rule get a test it did
not have: your own stories are never hidden from you, even by a muted id
that reached the set some other way, because hiding them would read as
your posts having failed.

607 tests green.
2026-09-03 18:06:46 -04:00
Paul HittandClaude Opus 4.8 4e5b39bc7a Keep what was verified, when, and how — including after it breaks
Accepting a changed safety number was one tap that left nothing behind,
so a later change could not be weighed against a history.

🔴 The record survives the key change that invalidates it, and that is
the whole feature. `recordTrust` cleared the verified flag on a change,
which destroyed the history at the exact moment it becomes most useful:
"you compared this in person in March, and their keys changed today" is a
far stronger thing to show somebody than "not verified" — which is also
what a stranger looks like. The flag still clears; the record is marked
broken instead of deleted, and the profile shows it in both states.

⚠️ The first break wins. A second change later must not overwrite the
date of the one that actually invalidated the check, which is the date
worth weighing against.

⚠️ Un-ticking the toggle removes the record rather than marking it
broken. That is the user saying the comparison did not happen or did not
match — a retraction, not a history.

The method is asked for rather than assumed. The ways of comparing are
not equally strong — a number read over a call that was itself
intercepted is worth less than one read off a screen in a room — and a
record that guessed would be a record of the wrong thing.

⚠️ Every sentence says "on this device". Verification is somebody
comparing two numbers; there is nowhere else it could live, and a record
shown without that caveat invites the reasonable assumption that a new
phone will remember. It lives in the suite, which `wipe()` clears
wholesale, so it leaves with the account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:57:48 -04:00
Paul HittandClaude Opus 4.8 3b4056c798 Report or block from a story
Blocking existed in a conversation and nowhere near a story, which is the
surface a stranger is most likely to reach somebody through. Reporting
did not exist at all — not here, not in the relay.

🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
That is a defensible bargain and an invisible one, so the sheet states it
in those words before anything is sent: what leaves the device, who can
read it, and that it cannot be undone. Declining to include a copy is
offered, and says plainly that there may then be nothing to act on.

⚠️ `reportStory` sends only what it was passed. It does not reach into
the thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include, and a helpful "we also attached the
conversation" would be exactly the wrong kind of help.

⚠️ Blocking and reporting are offered together and never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else and nothing may come of it. The copy says which is
which, because a combined control would make one of the two invisible.

The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:55:29 -04:00
Paul Hitt 6ac9a361e1 Let a story sequence survive text stories
Three faults, none of which produced a conflict marker.

`postTextStory` lost its closing brace at the seam where git joined it
to `postStorySet`, swallowing everything below into it — the same damage
as the quote-jump reinstatement, and again only the build caught it.

`E2EStory.set` needed the treatment `gid` needed before it: a property,
an init parameter, and a coding key. The compiler asks for the first
two. Nothing asks for the third, and without it a sequence comes back
from a relaunch having forgotten it was one.

`setExpiry` mapped `\.ref.expires` over every item, which stopped
compiling once a story could be words with no attachment at all. It
compacts now — a text story has no blob expiry to contribute, and
letting one drag a set's shared expiry to zero would expire the whole
sequence immediately.

The tests built their refs through `item(...).ref`, so they were
unwrapping something they had just created. They now build the ref and
hand it over.

585 tests green.
2026-09-03 17:55:25 -04:00
Paul HittandClaude Opus 4.8 1f58cbc41c Post several pictures as one story sequence
Each item is an ordinary story envelope carrying a `set` that names the
sequence, its position and its size (spec §6.23).

🔴 One envelope per item rather than one carrying an array. The array is
the obvious encoding and degrades badly: a client predating §6.23
requires a single `att`, fails that guard, and renders the whole set as a
message in the conversation. Separate envelopes degrade to exactly what
they are — several stories from one person in order, which every existing
build already draws correctly.

⚠️ The expiry is computed once, before the loop, and shared by every
item. Computing it per item would stagger the set by however long the
uploads took. `setExpiry` then takes the EARLIEST of what actually
arrived: items are required to carry the same stamp, but a sender can
write different ones, and if the latest won the difference would keep
part of a sequence alive after the rest had gone. That decay is both
confusing to look at and a disclosure — the gaps reveal the order the
author added things.

⚠️ `grouped` renders what arrived. `n` is the sender's claim and any item
can fail to arrive, so a set is never held back or marked incomplete: a
missing item is indistinguishable from one never sent. Stories with no
set are keyed so two of them cannot collide into one group.

Only the first item carries the caption — a sequence has one thing to
say, and repeating it under every picture reads as a stutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:48:08 -04:00
Paul Hitt 752b95b74b Put the per-message timer on the message, not the pin
The same fault as 50a84c8, reproduced exactly: git's auto-merge placed
`lifetimeSeconds` inside `E2EPin` instead of `E2EMessage`, at a struct
boundary it had no way to reason about. There was no conflict marker on
it. The file parsed perfectly with the property on the wrong type.

`E2EPin` is back to the three fields it is documented as having — an id,
a stamp and who pinned it, deliberately never a copy of the message,
because storing the text would let a pin keep a message readable after
its own timer removed it.

Worth keeping the note from the original fix: a syntax check cannot
catch this, and neither can a conflict-marker sweep. Only building it
does. That is the argument for verifying each reinstatement against the
tree it lands in rather than the batch at the end.

575 tests green.
2026-09-03 17:46:43 -04:00
Paul HittandClaude Opus 4.8 0a2a26c7c4 A timer on one message, not just the conversation (§6.17)
🔴 The shorter timer always wins. A per-message timer may only take life
away, never add it — otherwise a sender defeats the recipient's
conversation timer by putting a long one on every message, turning a
setting the recipient chose on their own device into something the other
party overrules.

A duration, never a deadline. An absolute expiry would be the sender's
clock: they could backdate a message so it vanished on arrival, or
forward-date one so it never expired. Counted from local receipt, the
rule §6.11 already follows.

🔴 pruneDisappeared skipped any thread whose conversation timer was off,
so a timed message in an untimed conversation would have been kept for
ever. It prunes per message now.

The one-off timer disarms on send. A timer that stayed set would quietly
apply to everything typed after it, and the person would find out when a
message they meant to keep was gone.

⚠️ The backlog filed this with the trade "the relay learns a message is
timed". It does not — the content envelope is inside the ratchet, the
same place §6.11's announcement rides, and the relay stores only an
opaque payload. Recorded in the spec.

Closes #29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:42:32 -04:00
Paul HittandClaude Opus 4.8 d34372c900 Pin a message in a conversation
🔴 A pin is an id and a stamp, never a copy of the message. Storing the
text would keep a pinned message readable after its own timer had
removed it — pinning would become a way to defeat disappearing messages,
silently, from either side of a conversation. E2EPin has three fields and
none of them is text, so it cannot be added back by forgetting a line.
The pin resolves through the thread every time it is drawn, and shows
nothing when the message is gone.

Monotonic, like an edit, and so is an unpin: a relay orders envelopes by
receipt rather than authorship, so two people pinning at once can arrive
in either order. Unlike a message, a pin is shared state both people are
looking at, so an inconsistency here does not resolve itself — the two
phones would disagree for ever.

One pin, not a list. A list needs an ordering, a cap, and a rule for two
people adding the last one at once — three more pieces of shared state
across devices that cannot talk to each other directly.

⚠️ One-to-one only for now. A group pin is carried correctly on the wire
but cannot resolve until a group thread records a mid (#58, in review);
shipping one that could never resolve would be a permanently empty
banner.

Closes #50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:30:15 -04:00
Paul HittandClaude Opus 4.8 4b91c48ba8 Forward a message without passing on where it came from
The stripping is the feature, not a side effect. Copying by hand loses
the original sender and the thread it came from, which is what people
already expect a forward to do — so it is the default rather than a
setting to find. There is no "include sender" checkbox and the spec
deliberately has no field for one: a field would become a default, and a
forward that carries its author by default is not a forward.

🔴 A fresh mid, never the original's. A mid is unique within a
conversation, so the same one in two conversations is a handle the RELAY
can use to tie them together — the exact link a forward breaks.
Forwardable is a small type naming what travels, so a field added to
E2EMessage later is excluded by default rather than by somebody
remembering.

🔴 A photo is re-uploaded, never re-referenced. Sending the original blob
id and key would work and be far cheaper, and would let the relay watch
one blob fetched from two conversations. Costs a download and an upload,
and means forwarding a photo can fail where text cannot — reported
rather than papered over with a caption and no picture.

Shows "Forwarded" and never from whom. Second-hand words presented as
the sender's own is a small dishonesty; naming the author would be the
provenance this strips. Those are different and only the first is shown.

⚠️ isForwarded was written as a plain Bool first, which would have made
every existing snapshot fail to decode and wiped everyone's history —
synthesised decoding throws on a missing key rather than using the
default. Optional now, with the test that catches it.

Closes #28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:44:29 -04:00
Paul Hitt cede368abb Repair what the auto-merge did to ChatViewModel
Cherry-picking the quote-jump onto a tree that now has edit-a-message
produced a file git was happy with and the compiler was not:
`commitEdit` lost its closing brace, so everything below it — the poll
loop, the append helper — became local declarations inside it, and two
doc comments were fused so `react`'s description ended up on top of
`sendSticker`.

This is the same damage described in ios!73, and worth naming: git
resolved it silently. There were no conflict markers in this file. The
only thing that caught it was building.

Also reorders the `MessageBubble` call to match the declaration —
onQuoteTap and isFlashing are declared before onSaveFile, and a
memberwise init takes its arguments in declaration order however
defaulted they are. That is one of the three faults ios!85 lists by
name.

536 tests green.
2026-09-03 16:42:42 -04:00
Paul HittandClaude Opus 4.8 154db4ee7b Tap a reply's quote to reach the message it answers
The quote is tappable only when the message it names is actually on this
device, and inert otherwise — including to VoiceOver, which must not
offer "Activate" on something that will do nothing. A reply carries its
own preview precisely because the original may never have arrived here
(§6.9), so "nowhere to go" is the ordinary case, not a failure.

🔴 The target is matched on mid and never on the preview. §6.9 says the
preview is the replier's assertion and that they can lie about it, so
matching on text would let a crafted reply send the reader to a message
of the sender's choosing — invisibly, since the quote would look right.
Spec clarified to say so: flamenet-e2e!8.

The tap clears any active search first. The list is visibleMessages,
which search filters, and scrollTo an id that is not in the list does
nothing — a tap that appeared to work and did not, which is the exact
failure the tappable-only-when-resolvable rule exists to avoid.

Resolution is a free function on Message rather than a method on the
view model, for the same reason applyReaction is one: a ChatViewModel
needs a live inbox, which needs a network client and a Keychain.

⚠️ iOS only. Android's transcript stores no reply reference at all, so a
reply's quote is not rendered there and there is nothing to tap — see
paulhitt/flamenet-messenger-android#10, updated with that finding.

Closes #48

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:37:20 -04:00
Paul HittandClaude Opus 4.8 890202b3d7 Tell somebody when their disappearing message is screenshotted
A timer promises the message goes away; a screenshot breaks that promise
silently and the sender never learns. It cannot be prevented, so it is
reported (spec §6.27).

🔴 Most of the care here is in refusing to let the absence of a notice
mean anything. The device that took the screenshot is the device that
reports it, so a modified build, a second phone photographing the screen,
or the platform's own recorder all produce nothing at all. A notice means
one was probably taken; no notice means NOTHING. So the notices are not
counted anywhere — a running total would be read as a measurement, and
its zero would be read as "nobody screenshotted", which is precisely the
inference the section forbids. The disclaimer names the ways detection
fails rather than merely admitting that it can: "sometimes this doesn't
work" tells nobody what to worry about.

⚠️ Only where a timer is set. Everywhere else this would be a general
surveillance feature rather than the narrow one it is — the promise a
screenshot breaks is the one §6.11 made, and where no promise was made
there is nothing to report against. The timer check comes before the
rate-limit check, so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Bound to the thread being on screen, not to the app. A screenshot
taken on the settings screen is not a screenshot of their message.

⚠️ One notice per conversation per five seconds: one screenshot often
produces several platform events, and a person who took one should not
appear to have taken four.

⚠️ The last-notice times are session-only. Persisting them would be
storing who screenshotted whom — a record of exactly the thing this
reports — on disk, to enforce a window that is seconds long.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:30:44 -04:00
Paul HittandClaude Opus 4.8 c7a6196e6e Stop the relay being able to wipe a device, and verify a real instruction
🔴 A device that had registered once erased its local history and wiped
its Keychain whenever the relay answered a poll with 410. Nothing about
that was authenticated. The threat model says the relay is untrusted with
content — and it is, it still cannot read anything — but it could
DESTROY everything, for every user, irreversibly, by returning one status
code. That was never the intent (ios#60).

The unsigned path now signs out and stops polling, and leaves stored
messages exactly where they are. The device really is removed
server-side, so stopping is right; erasing is not something an unsigned
mechanism gets to ask for.

Erasing now requires a wipe instruction signed by the account key
(§6.26). The relay cannot produce one: the fnid this device saved at
sign-in IS the account public key, so an invented instruction fails the
signature. `deviceRevoked` still means "erase" and now has a signature
behind it; `deviceDeregistered` is the relay's unsigned word and takes
nothing with it.

⚠️ The device id is checked as well as the signature. A genuine
instruction for another device carries a genuine signature — verifying
that alone would let one device's wipe erase every device that saw it.

⚠️ The signature covers the device id and the timestamp, so a captured
instruction cannot be re-pointed or re-dated. The signed bytes are pinned
in a test: three implementations have to agree byte for byte, and a
format that drifts is a wipe that silently stops working.

⚠️ The account key is parsed from the address saved at sign-in, never
from anything the relay says now. A relay able to substitute the key it
is checked against could forge whatever it liked.

⚠️ Nothing here claims the data is gone. A device that is off or offline
never sees the instruction.

This is the honouring half of ios#38. Issuing one still needs a relay
route to carry it and a passphrase re-entry to sign it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:25:25 -04:00
Paul HittandClaude Opus 4.8 8f8333eb61 Tell a contact's new device apart from a substituted key
The ticket says adding a device is silent. It is not — it tripped
`.changed`, which is the loudest state there is and blocks sending. The
real defect was that the two were indistinguishable, and the doc comment
on `.changed` admitted it: "either they reinstalled / added a device, or
someone is trying to read the thread."

That was wrong in both directions at once. Adding a device — the more
realistic attack, because it reads as somebody buying a phone — was
invisible as a distinct event, and it also blocked sending on every
person who legitimately owns a tablet. Loud in the wrong way and silent
in the right one.

🔴 The split is on which key set moved. An attacker who substitutes a key
cannot produce the old one, so the old one disappears: a missing pinned
key is the substitution shape and still stops the send. Every pinned key
still present with more alongside is an addition — nothing breaks, which
is exactly why it needs saying out loud rather than blocking.

⚠️ Missing beats added when both are true. A set that dropped a key and
gained one is a substitution wearing an addition's clothes, and the safer
reading is the one that stops the send.

⚠️ An addition is pinned so the notice fires once. Left unpinned, every
bundle fetch would re-report the same new device for ever, and a warning
that never stops is one people learn to scroll past.

🔴 Verification does not survive an addition. What was compared in person
was compared against the keys they had then; a device added since was
never part of that, and carrying the tick over would let it inherit a
check nobody performed on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:19:55 -04:00
Paul HittandClaude Opus 4.8 bcaf4a1902 Count the sealed-to-unsealed fallback, and offer to refuse it
Sealing hides which account sent a message from the relay. When the
peer's delivery key or capability is missing, the send still goes —
end-to-end encrypted as always — but unsealed, and the relay learns the
sender. That degradation was silent: `lastSendWasSealed` held only the
most recent outcome and nothing accumulated, so nobody could answer "how
often does this happen to me".

Now every send's outcome is counted, and the privacy screen says what
share went unsealed. Zero sends reports as "nothing sent yet" rather than
a reassuring 0%, which is what a plain ratio would have rendered on a
fresh install.

🔴 The copy distinguishes sender from content, and there are tests
asserting it does. Somebody who reads "sealed" as "encrypted" makes the
wrong decision in both directions — relaxed when it is off, and alarmed
when it degrades on a message the server could never read anyway.

⚠️ Counted in aggregate only. Per-contact counts would be more useful and
would also build a record of who this account talks to and how often —
the precise thing sealing exists to keep off a server, kept instead on
the device.

Refusing rather than degrading is offered and is OFF by default. That is
the honest default rather than the lazy one: refusing turns a metadata
leak into an undelivered message, which for most people is the worse
outcome. The refusal has its own error case so it can explain itself — a
bare "couldn't send" reads as the app being broken, and the next thing
somebody does is turn the setting off without knowing that is what they
are doing.

⚠️ The gate is checked before the capability is spent. Refusing after
taking a token would burn one per attempt and drain the contact's batch
while nothing was delivered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:14:34 -04:00
Paul Hitt 5110f84c69 Carry a group story's audience through the init and into storage
The reinstated group story added `gid` to `E2EStory`, but text stories
had since given that type an explicit init and an explicit `CodingKeys`
— so the property existed and nothing could set it or save it.

Two separate faults, and the second is the quieter one. Adding `gid` to
the init fixes the compile error. Adding it to `CodingKeys` fixes
something the compiler would never have mentioned: a key left out of
that list is dropped on the way to storage, so a group story would come
back from a relaunch having forgotten its audience. That reads as the
feature not working rather than as a storage bug.

483 tests green.
2026-09-03 16:14:30 -04:00
Paul HittandClaude Opus 4.8 69a0130e7b Post a story to a group
An ordinary story envelope carrying `gid`, fanned out pairwise to the
members exactly as a group message is, and rendered in the story surface
(spec §6.24).

🔴 The group's roster is the audience and `storyAudience` does not apply.
Those are two different lists making two different statements — who may
see this account's stories, and who is in a conversation — and they will
disagree. The roster wins, because a story addressed to a group that
silently dropped some members would be a group message part of the group
cannot see, which is worse than the failure being avoided.

That makes the disclosure load-bearing rather than polite, so
`groupStoryReach` returns the overlap rather than a warning: which
members are excluded by this account's story audience, and which are not
contacts at all. The two are counted separately — a non-contact is not
"excluded from your stories", they were never on that list.

🔴 The inbound branch catches a story BEFORE the group-message path.
Falling through would render it as a message in the group's conversation:
a story that is a message, and one that never expires out of the thread.

⚠️ `exp` is the ordinary story lifetime. Group membership does not extend
it, and it is not kept merely because it is also addressed to a
conversation that keeps history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:08:31 -04:00
Paul Hitt e021a6663c Don't offer to save a story that has no picture
The reinstated save applied without a textual conflict and did not
compile: it was written when every story had a blob, and text stories
have since made `E2EStory.ref` optional.

The fix is the one the original author wrote into the comment beside it
— `isSaveable` has taken a `hasBlob` parameter since the day it was
written, and the caller passed `true` because at the time that was
always so. It now asks the real question, so the button is absent on a
story of words rather than present and failing.

Worth noting what caught it: nothing textual. A clean cherry-pick, a
type error at build. This is the class of thing the 2 September batch
merge could not have caught, because it verified once at the end.

475 tests green.
2026-09-03 16:08:18 -04:00
Paul HittandClaude Opus 4.8 338129f205 Save a story you posted yourself, before it expires
A story disappears from the author's own device too, so there was no way
to keep something you made. Saving your own before expiry is ordinary
everywhere else.

🔴 Only ever your own, and the rule is `E2EInbox.isSaveable` rather than
a condition on the button. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a view is one refactor away
from being lost. It is tested from both directions.

⚠️ The confirmation says the copy has left the disappearing model, and
where it went. "Saved" alone would be true and useless: the point is that
this one copy now outlives the story, and somebody who is not told that
may reasonably assume the expiry still covers it.

⚠️ `.addOnly` authorisation rather than full access. Saving needs
permission to add a photo and nothing else; asking for read access as
well would let this app enumerate somebody's whole library in order to
perform a write. The narrower prompt is also the honest one.

⚠️ `NSPhotoLibraryAddUsageDescription` had to be added. Without it iOS
does not warn — it terminates the app the first time somebody saves.

The helper already takes a `hasBlob` argument the text stories of §6.21
will need; on this branch every story has one, so only the call site
moves when that branch lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:02:22 -04:00
Paul HittandClaude Opus 4.8 a3a571bbb5 Group messages carry an id of their own
🔴 The wire always carried one and the model never stored it.
encodeGroupText has taken a mid: since §6.9 shipped and the parser reads
it back — E2EGroupMessage was where it stopped. So nothing in a group
could name an earlier message: no reply to jump to, no reaction to fold,
no edit to apply. Every one of those was "codec done" and none worked.

It also had a symptom visible all along. markGroupDelivered matched a
bubble by isMine && date == at, because there was no id to match on, so
two messages sent in the same millisecond restated the wrong one —
cheap to hit when a queued burst goes out at once. The rule now matches
on mid, and moved onto the model as a static so it could be tested at
all: it went months without a test because reaching it needed a network
client and a Keychain.

The timestamp path stays, and only for messages with no id. Those were
written before group ids existed, and dropping it would leave every one
of their pending bubbles saying "will send" for ever.

The group bubble's projection now carries mid and replyTo, so the quote
block the bubble already knew how to draw finally gets something to draw.

Closes #58

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:57:29 -04:00
Paul HittandClaude Opus 4.8 c3d6e97645 Edit a message that was already sent (spec §6.15)
An envelope naming an earlier mid replaces its text. Two things keep
that from being a forgery primitive, and both are enforced and tested
rather than assumed.

Only the author may edit. In a 1:1, isMine is the whole of authorship,
so an inbound edit may only touch a message the buddy wrote and a local
edit only one we wrote. Without it a single envelope naming any mid
rewrites what YOU said in your own transcript. re.author is never
consulted — anyone can put any id there — so authorship is decided from
the copy of the message we already hold.

The reader is always told. "Edited" shows on both sides and is not
behind the timestamp setting or any other, because an edit nobody can
detect puts new words in a bubble they already trusted.

Also: monotonic (relays order by receipt, not authorship, so two edits
can arrive backwards), never a bubble of its own, never a notification,
and it may not empty a message — retracting is a different operation
with different honesty requirements (ios#57).

Send routes through one submit() on the view model rather than the three
places in the view that call it, so the one that forgets an edit is in
progress cannot send a duplicate instead of a correction.

Closes #26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:50:20 -04:00
Paul Hitt 77b3581f16 Say why a sign-up was refused, for two new reasons
The relay gained two refusals alongside look-alike handle checking, and
`RoutesAccount` says why mapping them here is not optional: *"These codes
are not arbitrary: the shipped client switches on them to pick its own
error copy, and anything else falls through to a generic failure."* A
refusal somebody could act on, rendered as "something went wrong", is the
worst version of it — they retry the same thing and are refused again.

⚠️ **Both messages say what to DO.** "Too similar" on its own leaves
somebody retyping the same name with a different digit and being refused
again; the fix is to change a letter, so the copy says that. The
disposable-address one explains the consequence — that address is how
they get back in — rather than sounding like a judgement about their
email provider.

**`SignupErrorCopyTests` is new, and error copy had no coverage at all.**
It pins every sign-up refusal to its own words, fails if two share a
message, and fails if a code renders as itself — which is what falling
through the switch looks like from the outside.

⚠️ It also pins the sign-in refusal: `bad_credentials` must not reveal
which half was wrong. The relay answers identically on purpose, and a
message here that told them apart would undo that at the last step.

347 tests pass, up from 342.

Pairs with flamenet-relay!11.
2026-09-03 15:45:01 -04:00
Paul HittandClaude Opus 4.8 cf305e510e Solve the sign-up puzzle when a relay asks for one
The relay can charge a few seconds of arithmetic before an account
exists — cheap for one person, expensive for a farm, and unlike a
CAPTCHA it hands nothing to a third party. This is the client half:
ask what work is wanted, solve it off the main actor, send the answer
beside the sign-up fields.

Every failure path ends in "sign up anyway". An older relay, a
WordPress-backed site, a request that times out — none of those are
reasons somebody cannot create an account, so the fetch returns nil for
all of them and the fields are simply absent.

Two refusals get their own sentences: an app too old to answer, and an
answer that did not hold up (nearly always a challenge that expired
while somebody was typing). Sharing one message would send half these
people to the App Store for nothing.

The bit counting is pinned to fixed vectors, not checked against our own
arithmetic. A byte-counting implementation still SATISFIES a 9-bit
difficulty — it overshoots to 16 bits and does 230x the work — so an
assertion about "at least 9 leading zeros" cannot see the bug. The
first draft of these tests had exactly that hole and caught nothing.

Refs flamenet-relay#39

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:38:46 -04:00
Paul Hitt 27c5bde2f3 Offer the code entry on the screen that refuses the sign-in
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.

🔴 **The remedy cannot live inside the app.** An unverified account
cannot get a relay token — that is what unverified means — so it never
reaches the app at all. A verification screen behind the door is one this
person can never open. `VerifyEmailSheet` is presented from the SIGN-IN
screen, and the sign-in error offers a button straight to it.

That needed the refusal's code kept, not just its sentence. `lastErrorCode`
now sits beside `lastError`: matching on the words would break the first
time the copy was edited, and that breakage locks somebody out silently
rather than loudly.

⚠️ **The copy must not read as a credential problem.** Somebody told
their sign-in failed will change a passphrase that was never wrong — and
here the passphrase is the identity, so that is not a harmless retry. A
test asserts "password", "passphrase", "incorrect" and "wrong" never
appear in it.

⚠️ The code field turns off autocapitalisation and autocorrect and trims
whitespace. A code pasted out of a mail client arrives with a newline
more often than not, and refusing it for that looks exactly like a wrong
code.

Four tests, 346 pass.
2026-09-03 15:33:27 -04:00
Paul HittandClaude Opus 4.8 bbb62d71c2 Keep a half-written message across a relaunch
Sealed in the history file, not a plist — and that is the whole point. A
draft is plaintext the person has not decided to send, very often the
most sensitive text in the app because it is the thing they are still
weighing. UserDefaults would write it unencrypted, leave it readable
while the device is locked, and carry it into every backup. In the
sealed snapshot it inherits the Keychain key, the file-protection class,
and purge() on sign-out.

All three composers: 1:1, group, and federated. The federated one needs
its own key because remoteThreads has no local id — without it every
remote correspondent would share one, and a draft written to somebody on
another relay would appear in the composer for the next person.

Drafts ride the existing 1.5s history debounce rather than getting their
own writer: they change on every keystroke, and a separate one would
seal the whole file per character typed.

Whitespace is not a draft — restoring "   " gives a composer that looks
empty and reports itself as having content, which here means an enabled
Send button that sends nothing. But text is stored as typed, not
trimmed: somebody who left a trailing space was mid-word.

Closes #47

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:27:25 -04:00
Paul HittandClaude Opus 4.8 5d5a95584a Search inside messages, across every conversation
The list only ever searched NAMES, so a word somebody said found
nothing.

🔴 There is no index, and that is a decision rather than an omission. An
index derived from the decrypted history is plaintext by another name:
it would have to be sealed as carefully as the history, purged with it,
and kept consistent with it — a second copy of everything anybody ever
wrote, with its own ways to go wrong. It is not needed, because the
inbox already restores the whole history into memory at launch. A scan
over it creates nothing new at rest, so there is nothing new to protect.

MessageSearch names the condition under which that stops being true: if
history is ever paged out of memory or outgrows a linear scan, an index
becomes necessary — and must then be sealed with the history key and
purged by E2EHistoryStore.purge(), or the app will have quietly acquired
an unencrypted transcript.

⚠️ The query lives in view state and nowhere else — not beside drafts,
not in UserDefaults, not in the snapshot. What somebody searched for is
often more revealing than what they found.

An empty query finds nothing, never everything: dumping the whole
history onto one screen the moment the search box is focused is the
worst possible default in the place most likely to be read over a
shoulder.

Accent-insensitive, because somebody typing "cafe" means "café" and a
search that knows the difference quietly fails in every language with
accents. Plain substring, never a regex — a stray bracket typed into a
search box is a crash or a hang.

Closes #25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:24:40 -04:00
Paul HittandClaude Opus 4.8 6e37d5776b A balance and a list of transactions, with somewhere to read them from
The wallet had a Balance card that appeared only when there was a figure
to put in it and an Activity card that appeared only when there was
history — so the state almost everybody sees first, a wallet with
neither, showed neither, and read as a screen that had failed to load.
Zero is an answer. Both sections are always there now: "0.00 SOL" with
"nothing has arrived at this address", and "No transactions yet" saying
what will show up there, including the attempts that failed.

An em dash, not a zero, when we genuinely do not know — still asking, or
could not ask. Writing 0.00 there would be stating a balance we have not
been told.

The endpoint defaults to Solana's public mainnet node. It used to start
blank so the wallet contacted nobody until asked, which is the more
careful default and produced a balance that said it was not pointed
anywhere plus a setup step before the screen did anything. The trade is
stated on the screen instead: the node is named next to the balance, and
the line about what it learns stays. Nothing is contacted until there is
a wallet and an address to ask about. `http://` is refused and falls
back rather than leaving the wallet pointed nowhere.

A wallet that exists now opens showing its first address and that
address's balance, rather than a button that fetches one.

The recovery phrase moved ABOVE the new sections. Adding them pushed it
off the bottom of the screen, and a phrase somebody has to go looking
for is a phrase that does not get written down. That also fixes the UI
test that had started reporting "the phrase is not 24 words" about a
phrase that was perfectly correct.

`WalletView` takes its chain client as a parameter so the preview and
its UI tests run against a stub. Asserting "No transactions yet" against
Solana would be a test of somebody else's uptime.

399 unit tests and all 6 wallet UI tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:20:07 -04:00
Paul HittandClaude Opus 4.8 36fedd6c7d One list of files, and a node you can actually pick
🔴 **The two-section Files screen was my mistake.** "On this phone" and
"Encrypted" read as two places, and the first one read as a browser of the
phone's own files — so an empty list looked broken rather than empty,
which is exactly how it was reported. Wrong shape: whether a file is
encrypted is a PROPERTY of that file, not a destination.

One list now. Locked files are marked on their own row, and the choice
between adding a reference and adding a locked copy lives in the + menu,
at the moment it is actually a decision — rather than as two tabs somebody
has to interpret before they have added anything at all. The capability is
unchanged; the concept is gone from the interface.

The wallet's node field was the same failure in miniature: a blank box and
"point it at a node" is not an actionable instruction. It now suggests
Solana's public mainnet endpoint — verified to answer `getHealth` and to
return a balance in the shape ChainRPC parses — alongside "use my own",
and each suggestion says who runs it, because "pick a node" is not a
decision anybody can make without that.

⚠️ Test networks are no longer suggested but are still RECOGNISED. Anybody
pointing their own node at devnet needs the balance labelled, or play
money shown like real money invites them to believe they have been paid.

397 unit tests green; the Files UI tests are rewritten for one list and
assert the old section control is gone rather than merely unused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:35:45 -04:00
Paul HittandClaude Opus 4.8 f5625d3806 Wallet and Files are two tabs, and a chat can send a file
Two destinations, because they are two different things a person comes to
the app to do. The wallet was reachable only through Files, on the grounds
that they share an unlock — true, and an implementation detail nobody
outside this code should have to know. What it produced was somebody
pressing "wallet" and arriving at a file list. The shared key stays
shared: unlocking either still unlocks the other.

⚠️ The wallet route out of Files is deleted rather than left in place. It
had no caller, and an unreachable navigation destination is something the
next reader has to disprove.

The paperclip is a menu now. It went straight to the camera roll, so
sending a document meant leaving the conversation, opening Files, finding
the file, swiping, and choosing the person you were already talking to.
The file was sendable; it was just not sendable from where anybody would
look.

⚠️ Picking a file needs Files unlocked, and the sheet SAYS so rather than
asking for a passphrase. A chat that prompts for one is a chat that has
started collecting it, and Files goes out of its way not to hold it.

391 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:59:17 -04:00
Paul HittandClaude Opus 4.8 c1bd590998 The wallet can read the chain
Balance and activity, from a node you choose.

🔴 The endpoint is a privacy decision, not a setting, and the screen says
so. A fresh address per receive stops anyone reading the ledger from
grouping your receipts — but ask one node about all of them from one
connection and that node can group them anyway, because it learns those
addresses belong to one person even though the chain does not. Nothing
about the chain fixes that. The endpoint can, so the recommendation on
screen is to point it at a node you run.

⚠️ Nothing is contacted until somebody says where. A wallet that quietly
started talking to a stranger's node the first time it opened would be
doing the one thing this product does not do anywhere else. https only:
the request carries WHICH addresses you are asking about, which is the
part worth protecting — the balances themselves are already public.

🔴 A failed transaction still appears in an address's history, and is
marked failed. Showing it like any other row would have somebody believe a
payment went through when it did not.

⚠️ A JSON-RPC error arrives with HTTP 200. Reading only the status code
takes a refusal for an answer and throws away the node's own explanation,
which is the only account of why it refused.

⚠️ Balances are formatted with integer arithmetic, never a Double. Binary
floating point cannot hold 0.1, and a wallet that renders money through
one shows people a figure that is very slightly not theirs.

Tests run against canned node answers rather than a network: a wallet test
that depends on somebody else's node fails for unrelated reasons and gets
switched off, and this is the layer where being wrong costs money.

Sending is not built. That is a bigger and more dangerous piece of work
and wants its own change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:53:11 -04:00
Paul HittandClaude Opus 4.8 8612a07152 Reply to a story, and stop seeing somebody's
The last two of the five features the 2 September revert took out.

A REPLY goes to the author and lands in your ordinary conversation with
them, because that is the only place a reply to a story can go — a story
is not a place, and there is no shared thread under it. Which is also why
it is private: the other people who saw the story have nowhere to read it.

🔴 A short piece of the story travels WITH the reply. Stories expire, so
by the time somebody reads "replied to your story" the story itself may be
gone — and a quote of something that no longer exists is a message that
cannot be understood. The bubble labels it too, or an unlabelled quote
reads as a reply to a message the reader cannot find.

MUTING somebody's stories is deliberately NOT muting their messages, and
uses its own key. Wanting somebody's news to stop is not wanting to stop
hearing from them, and folding the two together would make "I don't need
their holiday photos" cost a missed message. It is also not blocking:
nothing is told to the other person and nothing is refused.

⚠️ Muting happens on the story, where somebody decides they have had
enough — but a mute you can only turn ON is a trap, so the way back is a
toggle on their profile that also says what muting is not.

Two compiler surprises worth recording, both "unable to type-check this
expression in reasonable time": the chat presenter and the story feed card
each tipped over on ONE more argument. Both are extracted now. Neither had
anything wrong with it, which is what makes that failure mode expensive.

384 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:45:01 -04:00
Paul HittandClaude Opus 4.8 12d5d0bed2 Mentions, and reacting to a story
Two more of the five features the 2 September revert took out, done one at
a time rather than as a batch — which is the whole lesson of that revert.

MENTIONS travel as a user ID, never the name in the text. Matching on a
display name would mean anybody could be reached by typing their name, and
somebody who renamed themselves could never be reached at all. The id is
recorded when picked from the @ list and filtered at send time against
what is still in the message, so picking somebody and then deleting their
name does not ring them about a message that no longer mentions them.

🔴 A mention overrides a mute. That is the entire point: muting a group
means "do not tell me about the chatter", never "do not tell me when
somebody needs me". It deliberately does NOT override the global push
switch or a conversation already on screen — those are a different kind of
off, and neither wants to be shouted at.

STORY REACTIONS go to the author alone, as an ordinary sealed message. A
story is sent to each viewer rather than hosted, so there is no shared
object to attach a reaction to and nobody else to tell — which is a
privacy property rather than a limitation: other viewers never learn who
reacted, because there is nowhere for them to look.

⚠️ Both a message reaction and a story reaction are `kind: "react"`; only
an `st` flag separates them. Without it a story reaction would try to fold
its emoji onto a message with that id — and if one ever matched, onto the
wrong thing entirely. There is a test for each direction.

⚠️ Older builds: a story reaction reads as an ordinary one, finds no
message with that id, and does nothing. Asserted rather than assumed,
because "silently does nothing" is only correct when it is deliberate.

The quick row is five emoji, not a picker. A story is glanced at and
scrolled past; anything needing two taps to say "nice" will not be used.

376 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:28:04 -04:00
Paul HittandClaude Opus 4.8 dd6271dcef A story can be words
Not a missing button. `E2EStory.ref` was non-optional and every path —
model, storage, feed card, viewer, dedupe, seen-tracking — assumed a
picture, so there was nowhere for a text story to be carried, kept or
drawn. And the way in was a `PhotosPicker`, so pressing "new story" opened
the camera roll immediately with no way out to anything else.

The attachment is optional now, stories carry their own lifetime and their
own identity key, and the button is a menu: photo or video, or just words.

🔴 The identity key matters more than it looks. A story arrives once per
device the author sent from, and the blob token was what made those copies
one story — it was also what "seen" was keyed on. A text story has no
blob, so it carries an id the sender minted. Without it, one text story
would appear as many times as the sender has phones.

⚠️ `expires` and `key` are optional IN STORAGE, falling back to the
attachment. A Codable default does not make a key optional — the same
mistake made in the files vault this morning — and here it would have
meant every story already on a phone failing to decode, which presents as
the feed losing everything. There is a test that strips both fields from
an encoded story and decodes it.

⚠️ Older builds are unaffected and still legible: a story envelope with no
attachment is exactly what they treat as unsupported, and the words are in
the caption, which is what they show.

362 unit tests green, including one asserting a picture story parses
exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:10:28 -04:00
Paul HittandClaude Opus 4.8 3062bb97f2 Tapping the wallet opens the wallet
The mini wallet in Home's bar switched to the Files tab, because the key
that opens a wallet is the Files identity key. True, and an implementation
detail nobody outside this code should have to know — what it produced was
somebody pressing "wallet" and arriving at a file list, which reads as a
bug even when it is about to work.

The wallet now asks for the passphrase itself, with the same identity and
the same Face ID opt-in Files uses, and opens straight through when Files
is already unlocked. Unlocking either one now unlocks the other, which is
what sharing a key was supposed to buy in the first place.

Also: call-signalling interop tests, written while chasing calls that
turned out to be broken by a missing database grant rather than by
anything here. They earn their place anyway — they rule out the file
fields having disturbed call envelopes, and they pin a constraint that
until now lived only in a comment:

🔴 `fnc` must be the FIRST key in an envelope. Detection is a prefix test,
so any other order is not an envelope at all — it becomes plain text, and
what somebody sees is raw JSON in their chat or, for a call, nothing
whatsoever. That is why both engines hand-assemble these strings rather
than encoding a dictionary: JSONEncoder and JSONObject are both free to
reorder keys, and the day one of them does, calls stop arriving with no
error anywhere. The test asserts a reordered envelope is NOT recognised,
so the constraint fails somewhere rather than only being described.

⚠️ My first version of that test asserted the opposite — that order did
not matter — and it was the test that was wrong, not the parser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:48:44 -04:00
Paul HittandClaude Opus 4.8 c0c8faf47a Public share links are gone; sharing is inside the apps
createShare/openShare minted a /s/<id>#<secret> URL. The secret rode in
the fragment, which browsers never transmit, so the server genuinely could
not open what it served — cryptographically that was fine. What was not
fine is what the link *is*: a bearer token for a classified document,
pasteable into any chat, mailable to anybody, forwardable for ever, with
nothing on the far end that knows or cares what the file was labelled.

Sharing is in the apps now — a document goes to a named contact through
the ratchet, carrying its label, and the sender is told who will be able
to open it. Worse for handing something to a stranger; much better for a
system built on knowing who has what.

⚠️ Nothing can mint a new link any more. Existing links stay the server's
business until that route is retired, which is a deployment step and not
this commit.

Also corrected a comment that had gone stale within the hour: it claimed
FilesSession was the only WalletKeys implementation that ships. The app
uses LocalIdentityStore now. The conformance stays, because the enclave
client is still the library the retired Files apps were built on and
dropping it is a separate decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:23:52 -04:00
Paul HittandClaude Opus 4.8 06c5f1c390 Receive addresses, derived so they cannot point back at anybody
SLIP-0010 ed25519 derivation, asserted against every step of both
published vectors — 12 in all, checked into
Tests/FilesEngineTests/vectors/. A tree that gets the master key right and
one child wrong derives a wallet nobody else can reach, and the phrase
would still look valid; the only way to notice is somebody else's numbers.

🔴 Nothing identifying is an input. The seed is fresh entropy and the only
thing that varies a key, so no address can be computed from a Flamenet
identity and no identity from an address. There is a test that says so,
which is what should stop anybody later threading a username through here.

🔴 A fresh address per receive, as the ordinary action rather than a
setting. Reusing one address hands anybody reading a public ledger the
whole history of what somebody received — and unlike almost everything
else about a public chain, that is a linkability the wallet can prevent.
Older addresses keep working; they all come from the same phrase, so this
costs no extra backup material.

⚠️ The screen says, in those words, that there is no network connection
and nothing can show a balance or send anything. An address next to a
"0.00" would be claiming a chain client that does not exist.

Hardened derivation only, and an unhardened path is REFUSED rather than
silently hardened: SLIP-0010 defines no unhardened derivation for this
curve, and quietly returning a different key than every other wallet gives
from `m/44` is worse than an error. secp256k1 chains need curve arithmetic
CryptoKit does not provide — a dependency decision, not something to
approximate.

Base58 caught its own classic bug in test: seeded with a zero digit, a
single 0x00 byte encoded as "11", so every address with a leading zero
came out a character too long.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:17:31 -04:00
Paul HittandClaude Opus 4.8 0333c6783a Send a file to somebody, and save one they sent
Files gains a Send action; conversations gain a document bubble with
"Save to Files". A referenced file becomes ciphertext for the first time
at this moment, sealed under its label by the vault before the transport
seals it again for the relay.

🔴 The send sheet IS the enforcement, and is written as such. It names the
recipient and says, in those words, that they will be able to open it and
that the classification does not stop them. A picker that only listed
names would let somebody hand over a Restricted document without ever
seeing either fact — and once the envelope goes, the content key goes with
it and no server will refuse on the way.

A received document saves under the SENDER's classification, never
Unclassified: dropping the label on arrival would quietly declassify
everything anybody sent, which is the failure the marking exists to
prevent. It saves as an encrypted copy, not a reference — there is no file
on this phone to point at.

The unlocked vault is published on AppEnvironment so a conversation can
save without ever asking for a passphrase, and is cleared on sign-out —
left behind, the next account's chat could write into the previous
account's files. While Files is locked the button is absent rather than
present-and-failing.

⚠️ Caught before shipping: `saveNotice` was set on both the success and
failure paths and rendered nowhere, so saving a file — or failing to —
happened in complete silence. The alert now hangs off the body that owns
the state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 09:20:47 -04:00
Paul HittandClaude Opus 4.8 7e0c564302 A file is an attachment that remembers what it was filed as
Sending a document rides the ordinary encrypted attachment path — same
blob upload, same key inside the ratchet, same relay endpoint. No server
changes anywhere, which is the whole reason to do it this way.

What is new is in the plaintext envelope: the document's name, and the
label it was filed under.

🔴 The label travels as a MARKING, not a permission. The content key is in
the same envelope, so whoever can read the message can read the file — the
label does not gate that and the interface must never imply it does. What
it does is tell the recipient's Files what to file it as, and tell the
sender what they are about to hand over. The enforcement is the decision
to send, which is what "keys are the enforcement" means in practice.

Compatibility with the shipped Android build, asserted rather than
assumed: `fn`/`cls`/`cmp` are additive keys an older parser ignores, a
photo envelope still goes out without them, and a `file` envelope ALWAYS
carries a caption naming the document — because builds that predate this
kind show the caption and drop the rest, so a captionless one would arrive
as "[unsupported attachment]" with no clue what was sent.

⚠️ The filename comes from a peer. It is display text and a name inside
the vault, never a path; there is a test saying so, because the vault
addresses blobs by UUID and nobody should later join this onto a
directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 09:14:50 -04:00
Paul HittandClaude Opus 4.8 4c388f5a7c Files live on the phone now, two ways, and you can delete them
Local-first. The Files screen no longer talks to a server: files are held
on the device, the identity that opens them is generated and protected
with the passphrase in the Keychain, and there is nothing to sign in to.

⚠️ What that changed, stated where it matters: read-down used to be a
Postgres predicate — session_dominates() would not return a row above your
clearance whatever the client asked. There is no database now. Labels
still gate decryption completely, but nothing refuses to hand a file over,
so the interface stopped claiming a ceiling it cannot enforce.

Two ways to hold a file, because they are two different promises:

  On this phone  a bookmark; nothing copied, nothing encrypted by us,
                 encrypted at the moment it is sent. The row says so, and
                 says "Moved or deleted on your phone" when the file goes.
  Encrypted      a copy under the label key. A stolen container yields
                 ciphertext and the classification is a control.

"Encrypt a copy" promotes one to the other, and drops the bookmark when it
does — keeping it would leave a vaulted row still able to read through to
the plaintext, which is the entire thing being avoided. The confirm says
out loud that this is the second copy, because it is.

Deleting works singly (swipe) and in bulk (select). A folder whose
contents were not all selected is KEPT and reported: a bulk gesture that
silently took rows nobody reviewed would take ones filed above what this
device can read. Deleting a referenced row never touches the person's own
file.

🔴 Two bugs the UI tests caught that unit tests structurally could not:

- "Encrypt a copy" did nothing at all, silently. SwiftUI clears a
  confirmationDialog's isPresented binding BEFORE the button action runs,
  so the state the action read back was already nil. Now uses presenting:.
- The clearance banner announced "you can read up to Unclassified" over a
  list of Confidential rows — arithmetic wrong, and the concept circular
  on a vault whose every label key it derives itself. Replaced with the
  count of rows this phone holds no key for, and hidden when that is none.

And one the vault tests caught: a Codable default does not make a key
optional, so `storage: Storage = .vault` made every index written before
referencing fail to decode — a vault that has lost everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 09:09:47 -04:00
Paul HittandClaude Opus 4.8 fa3ed9cdd0 A UI test that had been asserting a screen the app stopped having
testStoriesAndGroupsAppearOnceThereIsABuddy checked that Home carried a
Groups section and a stories strip. The Home/Messages redesign (bc6f9a5)
moved GroupsSection to the Messages tab and replaced the strip with
StoryCardsList, and the test went on failing across every commit since —
which is worse than not having it, because a suite with a permanently red
case is a suite nobody reads, and the two real failures it could have
caught in that window would have looked exactly like the noise.

It now asserts where each thing actually is: the roster circles and the
stories section on Home, Groups on the Messages tab.

⚠️ StoriesStrip has no call sites anywhere. Left alone here rather than
deleted in a test commit, but it is dead.

The other red case in the same run — the voice-note recorder — was the
machine, not the app: "Failed to send signal 19 to process", an unsaveable
result bundle, and a load average near 10 from running suites in parallel.
It passes on its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:53:35 -04:00
Paul HittandClaude Opus 4.8 24d231870b Labels on every row, Face ID for the unlock, a wallet in the bar
The Files browser was a list of names. In a system whose entire point is a
Bell-LaPadula lattice, the classification is the thing you need before you
decide to open, move or send a row — so every row now carries its level
and its areas, in the deployment's own vocabulary rather than a guessed-at
government scale, and the list is headed by the ceiling this session can
actually read to. Colour is a second channel and never the only one.

Two rows in the preview are unreadable for different reasons — one is in
an area this account is not in, one is above its ceiling — because a
preview where everything opens never shows the state that matters.

Face ID / Touch ID, off by default and per device. 🔴 It stores the
passphrase, which is the opposite of what this screen has always said, so
the prompt says that in those words rather than offering "faster unlock".
The item is .biometryCurrentSet and WhenPasscodeSetThisDeviceOnly: no
passcode fallback (a shoulder-surfed four digits should not open files), no
backup, and it is destroyed the moment a face or finger is enrolled. The
offer comes only after a passphrase has been shown to work, and there is
an off switch on the screen itself rather than buried in Settings.

The mini wallet sits in Home's bar. It shows whether a wallet exists on
this phone and nothing else — there is no chain client, and a figure there
would be invented. It routes through the Files unlock, because that is
where the identity key that opens the wallet comes from.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:14:25 -04:00
Paul HittandClaude Opus 4.8 b66d29917c A wallet in Messenger, and a WebRTC pin that is ours
BIP-39 ported to Swift and asserted against the published vectors, so a
phrase written down on one platform opens the same wallet on the other —
the JavaScript's all-zero seed, c55257c3…e7463b04, byte for byte. A
record wrapped by the browser and checked in here opens too, which is the
assertion that catches a divergence in the AAD context or the wrap
version before somebody finds it by restoring to an empty account.

The seed is not derived from the passphrase (§8.1): a passphrase can be
changed, and addresses that stop being derivable are coins that are gone.
The wrapping key comes off the Files identity key instead, which survives
a passphrase change — so the record never needs re-wrapping, and the
recovery code opens the wallet too. Rekeying does change it, hence
Wallet.rewrap.

The screen takes a WalletKeys rather than a FilesSession, so it can be
driven without an account, a passphrase or a network — and it now is, by
three UI tests that create, restore, and refuse a mistyped phrase. Files
also gains the NavigationStack its title and toolbar were already written
against.

WebRTC is wrapped locally. Upstream deleted the 151.0.0 release, moved
its tag, and shipped a 151.0.1 whose manifest points at the deleted URL,
so no version of that package resolves today. The replacement binary was
downloaded and hashed here and matches upstream's declared checksum; the
URL and that checksum now live in Packages/WebRTCBinary.

⚠️ The wallet record is on the device only. There is no server endpoint
for it yet, so a new phone restores from the phrase — which the screen
says out loud rather than implying a sync that is not happening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:02:42 -04:00
Paul Hitt 6cca4a410e A Files tab in Messenger
The screen, on top of the engine that came across in the previous commit.
Lists, uploads and downloads at the session's own label — the vertical slice,
not the whole Files app. Sharing, folders, labels and regrade are still only
in the app being retired.

🔴 It asks for the passphrase, and that is the design rather than a gap.
Messenger deliberately does not keep it — its own comment says so, "the fnid
comes off the passphrase, and the passphrase is not stored" — and a Files
tab that opened silently would mean Messenger had started holding the one
secret it goes out of its way not to.

⚠️ A row whose name will not decrypt is shown as "Unreadable at your
clearance" rather than hidden or blank. The engine already made that choice
— `Entry.name` is optional precisely so a row visible to the database but
not to this session stays visible — and tapping one says why instead of
downloading bytes that would not open either.

Clearance is read from the token rather than asked for, which is the same
reasoning the server side gives: the token is the entire interface, so what a
client needs about its own permissions is already in it.

Messenger's 342 tests unchanged.
2026-09-03 07:00:34 -04:00
Paul Hitt f4b8e61298 Bring the Files engine into Messenger
First step of retiring the separate Files apps: the crypto and wire layer now
live here, under Packages/, and the app target builds against them.

🔴 Copied, not referenced. Pointing at flamenet-files-ios would leave the
repository that is going away as a build dependency of the one that is not.

The package split is kept exactly as it was — FilesEngine with no
dependencies, FilesClient on top of it — because it was right for the reason
its own manifest gives: the engine is a package rather than app code so
`swift test` can run the crypto from a command line. Requiring Xcode to run
those tests would mean they get run rarely, which is how the bugs that matter
in a key hierarchy survive.

Verified both halves: 19 Files engine tests pass inside this repository, and
Messenger's own 342 are untouched. Nothing is wired to a screen yet — this is
the foundation, committed on its own so the thing that follows is reviewable
as UI rather than as UI plus a package move.
2026-09-03 06:56:16 -04:00
Paul Hitt 9b04110261 Merge branch 'revert/back-to-last-working' into 'main'
Put the app back to the last state that worked

See merge request paulhitt/flamenet-messenger-ios!85
2026-09-02 23:05:47 +00:00
Paul Hitt 20ac112c1d Put the app back to the last state that worked
Restores Sources, Tests and UITests to bd7b7c9 — this morning's tree, before
tonight's merges. 342 tests, the original baseline, green.

Tonight's twenty-eight merges left sign-in, messages, calls and stories
broken. Those features are not independent: a message, a call signal, a
story and a story reply are the same content envelope with a different kind,
so they all run through E2EAttachment and E2EInbox. Every one of those MRs
was written against an older main and had never existed alongside the
others until they were merged in one evening.

The resulting conflicts were resolved mechanically, and that produces code
which compiles and passes tests while being wrong — lifetimeSeconds landed
on E2EPin instead of E2EMessage, mentions on an extension instead of the
group message, arguments in an order the memberwise init would not take.
Those three were caught by the compiler. Nothing caught the ones it cannot
see, and a wrong term in a kind check is exactly that: it compiles, it
passes, and a call stops connecting.

The unit suite went from 342 to 622 green while the product broke, because
it tests units. "encodeText produces this JSON" is a unit test. "Android
displays what iOS sent" is not, and nothing ran it.

Nothing is lost — every MR is a merged commit with its branch still on the
remote. They re-land in small batches once a test exists that signs in,
sends a message between two devices, connects a call and posts a story.
2026-09-02 19:05:38 -04:00
Paul Hitt 56fc20f38b Merge branch 'fix/story-composer-sheet-swallowed' into 'main'
Stop the story composer being swallowed by a second sheet

See merge request paulhitt/flamenet-messenger-ios!84
2026-09-02 22:32:03 +00:00
Paul Hitt cc6a04eb6c Stop the story composer being swallowed by a second sheet
Picking a photo closed the picker and nothing came back: the composer never
appeared, and the strip just sat there.

The text composer (§6.21) hung its own `.sheet` off `addButton` — the view
that HOLDS the PhotosPicker, and a descendant of the view presenting the
photo composer. Two sheets on one presentation branch is a race, and
dismissing the picker lost the parent's sheet on the way through. My merge
put it there; it belongs beside the others at the top level, and now is.

Also: a picture that failed to decode did a bare `return`. No composer, no
message, nothing — indistinguishable from a dead tap, on both the strip and
the Stories tab button. Both now say so.

622 tests, 0 failures.
2026-09-02 18:31:49 -04:00
Paul Hitt bf882eea8b Merge branch 'feat/story-reactions' into 'main'
React to a story with one tap

Closes #52

See merge request paulhitt/flamenet-messenger-ios!61
2026-09-02 21:41:49 +00:00
Paul HittandClaude Opus 4.8 bf62f68f2a React to a story with one tap
The lighter half of §6.20's reply: a sealed direct message to the author
carrying an emoji and the story it answers (spec §6.22).

🔴 Decoded before §6.9's reaction path, which requires `re`. A story
reaction carries `sre` instead — a story is not in the conversation and
has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave
it unhandled and drawn as an empty bubble. Tested both ways round,
including that an ordinary thread reaction is untouched.

⚠️ Its own disclosure, and its own settings key. A reaction reveals that
you watched exactly as a reply does; what differs is the deliberation
behind it. A reply is typed. A reaction is one tap on a story already
filling the screen — so an account told about replies has been warned
about the considered case and not about the careless one.

⚠️ The row sits above the reply field and well inside the frame. The tap
zones that advance and dismiss a story cover the rest of the screen, and
an acknowledgement delivered by a mis-tap is a disclosure the person did
not make.

Branched from feat/story-replies: this reuses §6.20's `sre`, so it is
stacked rather than duplicating that plumbing. Targeted at that branch so
it re-targets to main when it merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:41:32 -04:00
Paul HittandClaude Opus 4.8 cf870a7cf3 Reply to a story, as a sealed direct message
Stories have no comment thread and no viewer list, and this deliberately
does not give them one. A reply is an ordinary direct message to the
author carrying `sre.sid` — the story's id — and it lands in the existing
conversation like anything else (spec §6.20).

🔴 The reference carries the id and nothing else: no caption, no
thumbnail, no blob. This is where §6.20 diverges from §6.9's `re`, and
the reason is worth keeping in view. A quoted message may be one the
recipient never had, so §6.9 sends a preview. A story's author had the
story by definition — the only way they no longer hold it is that their
own timer removed it. Sending a copy back would resurrect exactly that,
at a moment they did not choose, which is the trap §6.19 documents for
pins arriving through a different door. A test asserts the encoded JSON
has no key but `sid`.

The disclosure is the other half. Replying tells the author you watched,
which stories otherwise never reveal, and there is no way to answer
somebody without their knowing — so it is allowed, but it has to be a
decision. The alert is shown before the FIRST reply the account sends,
names what is disclosed, and says where the reply goes. Nothing
automatic is ever sent when a story is merely opened.

`storyReplyID` is optional on E2EMessage for the usual reason: Swift's
synthesised decoding throws on a missing key rather than applying a
default, so a non-optional field would make every existing snapshot fail
to decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:38:17 -04:00
Paul Hitt 40112fbff8 Merge branch 'feat/private-story-archive' into 'main'
Keep your own stories in a private archive

Closes #54

See merge request paulhitt/flamenet-messenger-ios!62
2026-09-02 21:33:57 +00:00
Paul HittandClaude Opus 4.8 4e99102613 Keep your own stories in a private archive
Expired stories were gone from the author's device too, so there was no
way to look back at what you posted.

🔴 This keeps something the interface told everybody would disappear, so
the shape of the feature is the argument for it being acceptable: it is
off unless switched on, it is labelled as what it is rather than as a
feature, and it only ever holds stories this account posted. An archive
of other people's expiring stories is the exact opposite of what they
were promised, and "it is private to you" does not repair that.

`E2EInbox.archive` refuses anything not the author's own. The caller
filters as well; this is the guard that survives the caller being
rewritten, and it is tested from both directions.

Archiving happens on the restore path, which is the only moment it can:
a story past its expiry is never loaded into `stories`, so nothing
downstream ever sees it. That path runs on every launch, so the fold is
idempotent — otherwise a story that expired weeks ago would be appended
again each time.

⚠️ The debounced history writer carries the archive though it does not
trigger on it. Omitting it there would write nil on the next ordinary
message and empty somebody's archive silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:33:44 -04:00
Paul Hitt 9fa9c906b3 Merge branch 'feat/honour-log-revocations' into 'main'
Honour revocations from the key log, not the directory

Closes flamenet-relay#24

See merge request paulhitt/flamenet-messenger-ios!71
2026-09-02 21:31:37 +00:00
Paul HittandClaude Opus 4.8 ae428a4dcf Honour revocations from the key log, not the directory
Clients decided "is this device live" from the directory, which the relay
controls freely. The key log records every departure permanently and
cannot be quietly rewritten — but nothing client-side was reading it for
that. So a relay could keep a revoked device reachable in the directory
while showing the revocation to anyone auditing the log, and both answers
would look consistent to their respective audiences.

The send path now filters the directory through what the log has said.

🔴 A revocation is a one-way latch, and that is the answer to the attack
the ticket names: a relay that stops serving the log it used to serve is
a downgrade that looks like an outage. Latching makes that not matter for
anything already seen. The relay can go quiet, but it cannot un-say what
it already said — silence stops new revocations being learned, it cannot
resurrect an old one.

⚠️ The set only ever grows, in both the fold and the store. A device
absent from a later answer has not been un-revoked; it has not been
mentioned, which a relay can arrange for any device at any time. Removing
on absence would hand back exactly the power this removes.

⚠️ Persisted rather than kept in memory. A latch that reset on launch
would give a quiet relay every revoked device back on the next start,
which is the same downgrade by a slower route.

⚠️ `expire` latches as well as `revoke`, matching the relay's own
`isRetired`. A device that aged out is as gone as one removed by hand,
and enforcing only explicit revocations would leave the commoner case
open.

⚠️ Filtering preserves the directory's order, which is the send order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:31:23 -04:00
Paul Hitt 1b02989ace Merge branch 'feat/verification-that-persists' into 'main'
Keep what was verified, when, and how — including after it breaks

Closes #14

See merge request paulhitt/flamenet-messenger-ios!68
2026-09-02 21:31:10 +00:00
Paul HittandClaude Opus 4.8 864523a5ce Keep what was verified, when, and how — including after it breaks
Accepting a changed safety number was one tap that left nothing behind,
so a later change could not be weighed against a history.

🔴 The record survives the key change that invalidates it, and that is
the whole feature. `recordTrust` cleared the verified flag on a change,
which destroyed the history at the exact moment it becomes most useful:
"you compared this in person in March, and their keys changed today" is a
far stronger thing to show somebody than "not verified" — which is also
what a stranger looks like. The flag still clears; the record is marked
broken instead of deleted, and the profile shows it in both states.

⚠️ The first break wins. A second change later must not overwrite the
date of the one that actually invalidated the check, which is the date
worth weighing against.

⚠️ Un-ticking the toggle removes the record rather than marking it
broken. That is the user saying the comparison did not happen or did not
match — a retraction, not a history.

The method is asked for rather than assumed. The ways of comparing are
not equally strong — a number read over a call that was itself
intercepted is worth less than one read off a screen in a room — and a
record that guessed would be a record of the wrong thing.

⚠️ Every sentence says "on this device". Verification is somebody
comparing two numbers; there is nowhere else it could live, and a record
shown without that caveat invites the reasonable assumption that a new
phone will remember. It lives in the suite, which `wipe()` clears
wholesale, so it leaves with the account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:30:57 -04:00
Paul Hitt b815b32647 Merge branch 'feat/text-and-link-stories' into 'main'
Post a story that is only words, or a link

Closes #32

See merge request paulhitt/flamenet-messenger-ios!59
2026-09-02 21:30:36 +00:00
Paul HittandClaude Opus 4.8 9dbee89833 Post a story that is only words, or a link
A story required a photo or a video. A sentence is often the whole
thought, so `att` becomes optional and the caption becomes the story
(spec §6.21). A link story is not a new kind: it is a text story
carrying a §6.12 preview, which the SENDER fetches — drawing the card
receiver-side would tell the site the address of every viewer, and for a
story that is the whole audience rather than one correspondent.

🔴 The decode for this sits BEFORE the attachment guard, and that
placement is the feature. The guard requires `att` for a story; a text
story failing it falls through to the ordinary-message path and appears
as a chat bubble in the conversation that never expires — both of the
things §6.6 says a story must not be. A test asserts a text story is
never an ordinary message.

`E2EStory.ref` is now optional rather than carrying a sentinel, so
nothing can read a blob id off a story that has no blob. That forced a
`key` for seen-marks and dedup: with no blob id, a text story is named by
what cannot repeat for one author. `loadImage` on the card became
optional for the same reason — "nothing to load" is not a failure, and a
broken-image state on a story that never had one looks like a bug to the
person who posted it.

⚠️ A text story has no blob, so nothing dies at the server's retention
purge. Client-enforced expiry is the whole mechanism rather than a
backstop to one, and the composer's footnote says so instead of implying
more.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:30:23 -04:00
Paul Hitt 09a9d6a66d Merge branch 'feat/mute-a-contacts-stories' into 'main'
Mute one contact's stories, without blocking them

Closes #33

See merge request paulhitt/flamenet-messenger-ios!57
2026-09-02 21:27:39 +00:00
Paul HittandClaude Opus 4.8 429c0c687d Mute one contact's stories, without blocking them
The options were see everything or block the person. Muting one
contact's stories while the conversation carries on is the ordinary
middle, and it needs no protocol: nothing is sent, and the author is
never told.

Their stories still arrive and are still stored. Discarding them on
receipt would be cheaper, but then unmuting would show an empty strip
until their next post — so muting would quietly mean something other
than what the button says.

The list lives in the sealed snapshot rather than UserDefaults. Who
somebody has muted is a fact about their social graph, and a plist in
the app container is readable by anything that reaches the file, which
is the threat the sealed store exists for. It also means the choice
survives a restore, like the audience list beside it.

Two traps. The field is optional, because Swift's synthesised decoding
throws on a missing key rather than applying a default — a non-optional
addition would make every existing snapshot fail to decode and take the
user's history with it. And the debounced history writer had to carry
the list even though it does not trigger on it: omitting it there would
have written nil on the next ordinary message and silently unmuted
everybody.

Your own stories are never hidden, even if your id reaches the muted set
from a restored backup. Hiding somebody's own posts from them reads as
the posts having failed to send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:27:26 -04:00
Paul Hitt feb43e3a45 Merge branch 'feat/story-replies' into 'main'
Reply to a story, as a sealed direct message

See merge request paulhitt/flamenet-messenger-ios!80
2026-09-02 21:25:25 +00:00
Paul HittandClaude Opus 4.8 ebf8141453 Reply to a story, as a sealed direct message
Stories have no comment thread and no viewer list, and this deliberately
does not give them one. A reply is an ordinary direct message to the
author carrying `sre.sid` — the story's id — and it lands in the existing
conversation like anything else (spec §6.20).

🔴 The reference carries the id and nothing else: no caption, no
thumbnail, no blob. This is where §6.20 diverges from §6.9's `re`, and
the reason is worth keeping in view. A quoted message may be one the
recipient never had, so §6.9 sends a preview. A story's author had the
story by definition — the only way they no longer hold it is that their
own timer removed it. Sending a copy back would resurrect exactly that,
at a moment they did not choose, which is the trap §6.19 documents for
pins arriving through a different door. A test asserts the encoded JSON
has no key but `sid`.

The disclosure is the other half. Replying tells the author you watched,
which stories otherwise never reveal, and there is no way to answer
somebody without their knowing — so it is allowed, but it has to be a
decision. The alert is shown before the FIRST reply the account sends,
names what is disclosed, and says where the reply goes. Nothing
automatic is ever sent when a story is merely opened.

`storyReplyID` is optional on E2EMessage for the usual reason: Swift's
synthesised decoding throws on a missing key rather than applying a
default, so a non-optional field would make every existing snapshot fail
to decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:25:12 -04:00
Paul Hitt 3303c09727 Merge branch 'feat/report-or-block-from-a-story' into 'main'
Report or block from a story

See merge request paulhitt/flamenet-messenger-ios!82
2026-09-02 21:22:47 +00:00
Paul HittandClaude Opus 4.8 aaafdae65e Report or block from a story
Blocking existed in a conversation and nowhere near a story, which is the
surface a stranger is most likely to reach somebody through. Reporting
did not exist at all — not here, not in the relay.

🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
That is a defensible bargain and an invisible one, so the sheet states it
in those words before anything is sent: what leaves the device, who can
read it, and that it cannot be undone. Declining to include a copy is
offered, and says plainly that there may then be nothing to act on.

⚠️ `reportStory` sends only what it was passed. It does not reach into
the thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include, and a helpful "we also attached the
conversation" would be exactly the wrong kind of help.

⚠️ Blocking and reporting are offered together and never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else and nothing may come of it. The copy says which is
which, because a combined control would make one of the two invisible.

The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:22:34 -04:00
Paul Hitt 8ae9f913ca Merge branch 'feat/multi-item-stories' into 'main'
Post several pictures as one story sequence

See merge request paulhitt/flamenet-messenger-ios!81
2026-09-02 21:22:18 +00:00
Paul HittandClaude Opus 4.8 57b2e4a9e3 Post several pictures as one story sequence
Each item is an ordinary story envelope carrying a `set` that names the
sequence, its position and its size (spec §6.23).

🔴 One envelope per item rather than one carrying an array. The array is
the obvious encoding and degrades badly: a client predating §6.23
requires a single `att`, fails that guard, and renders the whole set as a
message in the conversation. Separate envelopes degrade to exactly what
they are — several stories from one person in order, which every existing
build already draws correctly.

⚠️ The expiry is computed once, before the loop, and shared by every
item. Computing it per item would stagger the set by however long the
uploads took. `setExpiry` then takes the EARLIEST of what actually
arrived: items are required to carry the same stamp, but a sender can
write different ones, and if the latest won the difference would keep
part of a sequence alive after the rest had gone. That decay is both
confusing to look at and a disclosure — the gaps reveal the order the
author added things.

⚠️ `grouped` renders what arrived. `n` is the sender's claim and any item
can fail to arrive, so a set is never held back or marked incomplete: a
missing item is indistinguishable from one never sent. Stories with no
set are keyed so two of them cannot collide into one group.

Only the first item carries the caption — a sequence has one thing to
say, and repeating it under every picture reads as a stutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:22:05 -04:00
Paul Hitt 1d138f6348 Merge branch 'feat/mentions-in-groups' into 'main'
Mentions in groups, and a mute that a group can actually have

Closes #49

See merge request paulhitt/flamenet-messenger-ios!78
2026-09-02 21:21:32 +00:00
Paul HittandClaude Opus 4.8 81ce85ee2c Mentions in groups, and a mute that a group can actually have
🔴 The rule that makes mentions safe to have is what they may do to a
mute. Always overriding hands every member of a group a way to interrupt
somebody whenever they like — how mentions become the thing people leave
a group over. Never overriding makes muting a busy group mean missing
the one message that was for you.

So the sender decides a message is addressed to somebody, and the person
receiving it decides whether being addressed is worth a notification.
Nothing in an envelope changes that half. Defaults to breaking the mute,
with a way off in the group's menu.

🔴 Which turned up that a group could not be muted AT ALL. Muting was
keyed on the AUTHOR of an arriving message, so silencing a group meant
muting every member one at a time, and anyone who joined later went
straight back to buzzing. A federated thread was worse: every remote
correspondent shares fromBuddyID 0, so muting one silenced all of them.
Muting is per conversation now. The 1:1 key is deliberately unchanged —
it is what every existing mute on every phone is stored under, and a new
scheme would quietly unmute everything somebody had silenced.

Mentions resolve against the roster, never the text alone: typing
"@anyone" means nothing unless somebody by that name is in the group.
You cannot name yourself. The list is bounded and deduplicated on
receipt, because a sender can name ten thousand ids and the cost lands
on the recipient.

shouldNotify moved onto the type as a testable function — it was four
clauses inline in a method needing UIApplication, which is how a
notification rule that now decides mute overrides ends up untested.

Closes #49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:21:19 -04:00
Paul Hitt 32c5fe7f6a Merge branch 'fix/lifetimeseconds-landed-in-the-wrong-struct' into 'main'
Put the per-message timer on the message, not the pin

See merge request paulhitt/flamenet-messenger-ios!83
2026-09-02 21:18:06 +00:00
Paul Hitt 50a84c8be2 Put the per-message timer on the message, not the pin
The §6.17 merge landed `lifetimeSeconds` inside `E2EPin` instead of
`E2EMessage` — a conflict resolved at the wrong struct boundary, which
reads fine and parses fine and is simply wrong. main did not compile:
'value of type E2EMessage has no member lifetimeSeconds'.

Worth naming the gap: a syntax check cannot catch this. The file parsed
perfectly with the property on the wrong type. Only building it does, which
is why the build stays the gate for every merge rather than every few.

E2EPin is back to the three fields it is documented as having — an id, a
stamp and who pinned it, deliberately never a copy of the message.

523 tests, 0 failures.
2026-09-02 17:17:51 -04:00
Paul Hitt cb6d120fc5 Merge branch 'feat/per-message-timers' into 'main'
A timer on one message, not just the conversation (§6.17)

Closes #29

See merge request paulhitt/flamenet-messenger-ios!77
2026-09-02 21:14:41 +00:00
Paul HittandClaude Opus 4.8 38f9e524b4 A timer on one message, not just the conversation (§6.17)
🔴 The shorter timer always wins. A per-message timer may only take life
away, never add it — otherwise a sender defeats the recipient's
conversation timer by putting a long one on every message, turning a
setting the recipient chose on their own device into something the other
party overrules.

A duration, never a deadline. An absolute expiry would be the sender's
clock: they could backdate a message so it vanished on arrival, or
forward-date one so it never expired. Counted from local receipt, the
rule §6.11 already follows.

🔴 pruneDisappeared skipped any thread whose conversation timer was off,
so a timed message in an untimed conversation would have been kept for
ever. It prunes per message now.

The one-off timer disarms on send. A timer that stayed set would quietly
apply to everything typed after it, and the person would find out when a
message they meant to keep was gone.

⚠️ The backlog filed this with the trade "the relay learns a message is
timed". It does not — the content envelope is inside the ratchet, the
same place §6.11's announcement rides, and the relay stores only an
opaque payload. Recorded in the spec.

Closes #29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:14:28 -04:00
Paul Hitt 56e7c0be60 Merge branch 'feat/pin-a-message' into 'main'
Pin a message in a conversation

Closes #50

See merge request paulhitt/flamenet-messenger-ios!79
2026-09-02 21:13:54 +00:00
Paul HittandClaude Opus 4.8 0a32f3c9b3 Pin a message in a conversation
🔴 A pin is an id and a stamp, never a copy of the message. Storing the
text would keep a pinned message readable after its own timer had
removed it — pinning would become a way to defeat disappearing messages,
silently, from either side of a conversation. E2EPin has three fields and
none of them is text, so it cannot be added back by forgetting a line.
The pin resolves through the thread every time it is drawn, and shows
nothing when the message is gone.

Monotonic, like an edit, and so is an unpin: a relay orders envelopes by
receipt rather than authorship, so two people pinning at once can arrive
in either order. Unlike a message, a pin is shared state both people are
looking at, so an inconsistency here does not resolve itself — the two
phones would disagree for ever.

One pin, not a list. A list needs an ordering, a cap, and a rule for two
people adding the last one at once — three more pieces of shared state
across devices that cannot talk to each other directly.

⚠️ One-to-one only for now. A group pin is carried correctly on the wire
but cannot resolve until a group thread records a mid (#58, in review);
shipping one that could never resolve would be a permanently empty
banner.

Closes #50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:13:39 -04:00
Paul Hitt 11b938f9a8 Merge branch 'feat/forward-without-provenance' into 'main'
Forward a message without passing on where it came from

Closes #28

See merge request paulhitt/flamenet-messenger-ios!76
2026-09-02 21:13:27 +00:00
Paul HittandClaude Opus 4.8 5eeda7033e Forward a message without passing on where it came from
The stripping is the feature, not a side effect. Copying by hand loses
the original sender and the thread it came from, which is what people
already expect a forward to do — so it is the default rather than a
setting to find. There is no "include sender" checkbox and the spec
deliberately has no field for one: a field would become a default, and a
forward that carries its author by default is not a forward.

🔴 A fresh mid, never the original's. A mid is unique within a
conversation, so the same one in two conversations is a handle the RELAY
can use to tie them together — the exact link a forward breaks.
Forwardable is a small type naming what travels, so a field added to
E2EMessage later is excluded by default rather than by somebody
remembering.

🔴 A photo is re-uploaded, never re-referenced. Sending the original blob
id and key would work and be far cheaper, and would let the relay watch
one blob fetched from two conversations. Costs a download and an upload,
and means forwarding a photo can fail where text cannot — reported
rather than papered over with a caption and no picture.

Shows "Forwarded" and never from whom. Second-hand words presented as
the sender's own is a small dishonesty; naming the author would be the
provenance this strips. Those are different and only the first is shown.

⚠️ isForwarded was written as a plain Bool first, which would have made
every existing snapshot fail to decode and wiped everyone's history —
synthesised decoding throws on a missing key rather than using the
default. Optional now, with the test that catches it.

Closes #28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:13:14 -04:00
Paul Hitt 2c3df554de Merge branch 'feat/jump-to-quoted-message' into 'main'
Tap a reply's quote to reach the message it answers

Closes #48

See merge request paulhitt/flamenet-messenger-ios!75
2026-09-02 21:10:21 +00:00
Paul HittandClaude Opus 4.8 55a7ef4079 Tap a reply's quote to reach the message it answers
The quote is tappable only when the message it names is actually on this
device, and inert otherwise — including to VoiceOver, which must not
offer "Activate" on something that will do nothing. A reply carries its
own preview precisely because the original may never have arrived here
(§6.9), so "nowhere to go" is the ordinary case, not a failure.

🔴 The target is matched on mid and never on the preview. §6.9 says the
preview is the replier's assertion and that they can lie about it, so
matching on text would let a crafted reply send the reader to a message
of the sender's choosing — invisibly, since the quote would look right.
Spec clarified to say so: flamenet-e2e!8.

The tap clears any active search first. The list is visibleMessages,
which search filters, and scrollTo an id that is not in the list does
nothing — a tap that appeared to work and did not, which is the exact
failure the tappable-only-when-resolvable rule exists to avoid.

Resolution is a free function on Message rather than a method on the
view model, for the same reason applyReaction is one: a ChatViewModel
needs a live inbox, which needs a network client and a Keychain.

⚠️ iOS only. Android's transcript stores no reply reference at all, so a
reply's quote is not rendered there and there is nothing to tap — see
paulhitt/flamenet-messenger-android#10, updated with that finding.

Closes #48

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:10:13 -04:00
Paul Hitt c6e755a93a Merge branch 'feat/email-verification-ui' into 'main'
Offer the code entry on the screen that refuses the sign-in

See merge request paulhitt/flamenet-messenger-ios!74
2026-09-02 21:09:19 +00:00
Paul Hitt d91e1feb13 Offer the code entry on the screen that refuses the sign-in
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.

🔴 **The remedy cannot live inside the app.** An unverified account
cannot get a relay token — that is what unverified means — so it never
reaches the app at all. A verification screen behind the door is one this
person can never open. `VerifyEmailSheet` is presented from the SIGN-IN
screen, and the sign-in error offers a button straight to it.

That needed the refusal's code kept, not just its sentence. `lastErrorCode`
now sits beside `lastError`: matching on the words would break the first
time the copy was edited, and that breakage locks somebody out silently
rather than loudly.

⚠️ **The copy must not read as a credential problem.** Somebody told
their sign-in failed will change a passphrase that was never wrong — and
here the passphrase is the identity, so that is not a harmless retry. A
test asserts "password", "passphrase", "incorrect" and "wrong" never
appear in it.

⚠️ The code field turns off autocapitalisation and autocorrect and trims
whitespace. A code pasted out of a mail client arrives with a newline
more often than not, and refusing it for that looks exactly like a wrong
code.

Four tests, 346 pass.
2026-09-02 17:08:56 -04:00
Paul Hitt 9f6fca725b Merge branch 'fix/restore-main-to-last-green' into 'main'
Restore main to the last state that built

See merge request paulhitt/flamenet-messenger-ios!73
2026-09-02 20:24:32 +00:00
Paul Hitt 864269cf4c Restore main to the last state that built
Nine merges (ios!46, !49, !51, !52, !55, !56, !58, !63, !65) were resolved by
a script that got the conflict boundaries wrong, and main was left with
conflict markers committed in nine source files. It did not build.

My mistake, and the cause is worth writing down: the merges were resolved in
a batch and verified once at the end, so the first bad resolution was not
caught before eight more were stacked on top of it. The check that was
supposed to stop it looked for markers only in the files git had listed as
conflicted, and missed markers that a partial resolution left behind.

This restores Sources, Tests and UITests to bd8ba69 — the last commit that
was actually verified green, 471 tests. No history is rewritten and nothing
is force-pushed; the nine features are not lost, their original commits are
safe (5bdc4185, 288dd2b7, 5548ff03, 507fb2f4, 6c014ceb, 742f343c, 22f85941,
daa1baff, 8080b8bb) and each will be redone one at a time.

Verified: 471 tests, 0 failures, TEST SUCCEEDED.
2026-09-02 16:24:23 -04:00
Paul Hitt ff8bdfe945 Merge branch 'feat/report-or-block-from-a-story' into 'main'
Report or block from a story

Closes #56

See merge request paulhitt/flamenet-messenger-ios!65
2026-09-02 20:12:49 +00:00
Paul HittandClaude Opus 4.8 0d351352e4 Report or block from a story
Blocking existed in a conversation and nowhere near a story, which is the
surface a stranger is most likely to reach somebody through. Reporting
did not exist at all — not here, not in the relay.

🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
That is a defensible bargain and an invisible one, so the sheet states it
in those words before anything is sent: what leaves the device, who can
read it, and that it cannot be undone. Declining to include a copy is
offered, and says plainly that there may then be nothing to act on.

⚠️ `reportStory` sends only what it was passed. It does not reach into
the thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include, and a helpful "we also attached the
conversation" would be exactly the wrong kind of help.

⚠️ Blocking and reporting are offered together and never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else and nothing may come of it. The copy says which is
which, because a combined control would make one of the two invisible.

The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:12:36 -04:00
Paul Hitt 033f5aed6c Merge branch 'feat/multi-item-stories' into 'main'
Post several pictures as one story sequence

Closes #53

See merge request paulhitt/flamenet-messenger-ios!63
2026-09-02 20:12:33 +00:00
Paul HittandClaude Opus 4.8 dd106fcc31 Post several pictures as one story sequence
Each item is an ordinary story envelope carrying a `set` that names the
sequence, its position and its size (spec §6.23).

🔴 One envelope per item rather than one carrying an array. The array is
the obvious encoding and degrades badly: a client predating §6.23
requires a single `att`, fails that guard, and renders the whole set as a
message in the conversation. Separate envelopes degrade to exactly what
they are — several stories from one person in order, which every existing
build already draws correctly.

⚠️ The expiry is computed once, before the loop, and shared by every
item. Computing it per item would stagger the set by however long the
uploads took. `setExpiry` then takes the EARLIEST of what actually
arrived: items are required to carry the same stamp, but a sender can
write different ones, and if the latest won the difference would keep
part of a sequence alive after the rest had gone. That decay is both
confusing to look at and a disclosure — the gaps reveal the order the
author added things.

⚠️ `grouped` renders what arrived. `n` is the sender's claim and any item
can fail to arrive, so a set is never held back or marked incomplete: a
missing item is indistinguishable from one never sent. Stories with no
set are keyed so two of them cannot collide into one group.

Only the first item carries the caption — a sequence has one thing to
say, and repeating it under every picture reads as a stutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:12:20 -04:00
Paul Hitt 34b19b7546 Merge branch 'feat/story-replies' into 'main'
Reply to a story, as a sealed direct message

Closes #30

See merge request paulhitt/flamenet-messenger-ios!58
2026-09-02 20:12:09 +00:00
Paul HittandClaude Opus 4.8 c7e19fbba9 Reply to a story, as a sealed direct message
Stories have no comment thread and no viewer list, and this deliberately
does not give them one. A reply is an ordinary direct message to the
author carrying `sre.sid` — the story's id — and it lands in the existing
conversation like anything else (spec §6.20).

🔴 The reference carries the id and nothing else: no caption, no
thumbnail, no blob. This is where §6.20 diverges from §6.9's `re`, and
the reason is worth keeping in view. A quoted message may be one the
recipient never had, so §6.9 sends a preview. A story's author had the
story by definition — the only way they no longer hold it is that their
own timer removed it. Sending a copy back would resurrect exactly that,
at a moment they did not choose, which is the trap §6.19 documents for
pins arriving through a different door. A test asserts the encoded JSON
has no key but `sid`.

The disclosure is the other half. Replying tells the author you watched,
which stories otherwise never reveal, and there is no way to answer
somebody without their knowing — so it is allowed, but it has to be a
decision. The alert is shown before the FIRST reply the account sends,
names what is disclosed, and says where the reply goes. Nothing
automatic is ever sent when a story is merely opened.

`storyReplyID` is optional on E2EMessage for the usual reason: Swift's
synthesised decoding throws on a missing key rather than applying a
default, so a non-optional field would make every existing snapshot fail
to decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:11:56 -04:00
Paul Hitt 1551bdb554 Merge branch 'feat/pin-a-message' into 'main'
Pin a message in a conversation

Closes #50

See merge request paulhitt/flamenet-messenger-ios!56
2026-09-02 20:11:53 +00:00
Paul HittandClaude Opus 4.8 60518e5e85 Pin a message in a conversation
🔴 A pin is an id and a stamp, never a copy of the message. Storing the
text would keep a pinned message readable after its own timer had
removed it — pinning would become a way to defeat disappearing messages,
silently, from either side of a conversation. E2EPin has three fields and
none of them is text, so it cannot be added back by forgetting a line.
The pin resolves through the thread every time it is drawn, and shows
nothing when the message is gone.

Monotonic, like an edit, and so is an unpin: a relay orders envelopes by
receipt rather than authorship, so two people pinning at once can arrive
in either order. Unlike a message, a pin is shared state both people are
looking at, so an inconsistency here does not resolve itself — the two
phones would disagree for ever.

One pin, not a list. A list needs an ordering, a cap, and a rule for two
people adding the last one at once — three more pieces of shared state
across devices that cannot talk to each other directly.

⚠️ One-to-one only for now. A group pin is carried correctly on the wire
but cannot resolve until a group thread records a mid (#58, in review);
shipping one that could never resolve would be a permanently empty
banner.

Closes #50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:11:37 -04:00
Paul Hitt 99bd7710a9 Merge branch 'feat/mentions-in-groups' into 'main'
Mentions in groups, and a mute that a group can actually have

Closes #49

See merge request paulhitt/flamenet-messenger-ios!55
2026-09-02 20:11:35 +00:00
Paul HittandClaude Opus 4.8 a26f3f41a5 Mentions in groups, and a mute that a group can actually have
🔴 The rule that makes mentions safe to have is what they may do to a
mute. Always overriding hands every member of a group a way to interrupt
somebody whenever they like — how mentions become the thing people leave
a group over. Never overriding makes muting a busy group mean missing
the one message that was for you.

So the sender decides a message is addressed to somebody, and the person
receiving it decides whether being addressed is worth a notification.
Nothing in an envelope changes that half. Defaults to breaking the mute,
with a way off in the group's menu.

🔴 Which turned up that a group could not be muted AT ALL. Muting was
keyed on the AUTHOR of an arriving message, so silencing a group meant
muting every member one at a time, and anyone who joined later went
straight back to buzzing. A federated thread was worse: every remote
correspondent shares fromBuddyID 0, so muting one silenced all of them.
Muting is per conversation now. The 1:1 key is deliberately unchanged —
it is what every existing mute on every phone is stored under, and a new
scheme would quietly unmute everything somebody had silenced.

Mentions resolve against the roster, never the text alone: typing
"@anyone" means nothing unless somebody by that name is in the group.
You cannot name yourself. The list is bounded and deduplicated on
receipt, because a sender can name ten thousand ids and the cost lands
on the recipient.

shouldNotify moved onto the type as a testable function — it was four
clauses inline in a method needing UIApplication, which is how a
notification rule that now decides mute overrides ends up untested.

Closes #49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:11:21 -04:00
Paul Hitt 99e7196538 Merge branch 'feat/per-message-timers' into 'main'
A timer on one message, not just the conversation (§6.17)

Closes #29

See merge request paulhitt/flamenet-messenger-ios!52
2026-09-02 20:11:18 +00:00
Paul HittandClaude Opus 4.8 838bfa5894 A timer on one message, not just the conversation (§6.17)
🔴 The shorter timer always wins. A per-message timer may only take life
away, never add it — otherwise a sender defeats the recipient's
conversation timer by putting a long one on every message, turning a
setting the recipient chose on their own device into something the other
party overrules.

A duration, never a deadline. An absolute expiry would be the sender's
clock: they could backdate a message so it vanished on arrival, or
forward-date one so it never expired. Counted from local receipt, the
rule §6.11 already follows.

🔴 pruneDisappeared skipped any thread whose conversation timer was off,
so a timed message in an untimed conversation would have been kept for
ever. It prunes per message now.

The one-off timer disarms on send. A timer that stayed set would quietly
apply to everything typed after it, and the person would find out when a
message they meant to keep was gone.

⚠️ The backlog filed this with the trade "the relay learns a message is
timed". It does not — the content envelope is inside the ratchet, the
same place §6.11's announcement rides, and the relay stores only an
opaque payload. Recorded in the spec.

Closes #29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:11:05 -04:00
Paul Hitt b13d5679ea Merge branch 'feat/forward-without-provenance' into 'main'
Forward a message without passing on where it came from

Closes #28

See merge request paulhitt/flamenet-messenger-ios!51
2026-09-02 20:11:02 +00:00
Paul HittandClaude Opus 4.8 20c2824ed3 Forward a message without passing on where it came from
The stripping is the feature, not a side effect. Copying by hand loses
the original sender and the thread it came from, which is what people
already expect a forward to do — so it is the default rather than a
setting to find. There is no "include sender" checkbox and the spec
deliberately has no field for one: a field would become a default, and a
forward that carries its author by default is not a forward.

🔴 A fresh mid, never the original's. A mid is unique within a
conversation, so the same one in two conversations is a handle the RELAY
can use to tie them together — the exact link a forward breaks.
Forwardable is a small type naming what travels, so a field added to
E2EMessage later is excluded by default rather than by somebody
remembering.

🔴 A photo is re-uploaded, never re-referenced. Sending the original blob
id and key would work and be far cheaper, and would let the relay watch
one blob fetched from two conversations. Costs a download and an upload,
and means forwarding a photo can fail where text cannot — reported
rather than papered over with a caption and no picture.

Shows "Forwarded" and never from whom. Second-hand words presented as
the sender's own is a small dishonesty; naming the author would be the
provenance this strips. Those are different and only the first is shown.

⚠️ isForwarded was written as a plain Bool first, which would have made
every existing snapshot fail to decode and wiped everyone's history —
synthesised decoding throws on a missing key rather than using the
default. Optional now, with the test that catches it.

Closes #28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:10:49 -04:00
Paul Hitt 7f322fe51b Merge branch 'feat/jump-to-quoted-message' into 'main'
Tap a reply's quote to reach the message it answers

Closes #48

See merge request paulhitt/flamenet-messenger-ios!49
2026-09-02 20:10:47 +00:00
Paul HittandClaude Opus 4.8 4537200f71 Tap a reply's quote to reach the message it answers
The quote is tappable only when the message it names is actually on this
device, and inert otherwise — including to VoiceOver, which must not
offer "Activate" on something that will do nothing. A reply carries its
own preview precisely because the original may never have arrived here
(§6.9), so "nowhere to go" is the ordinary case, not a failure.

🔴 The target is matched on mid and never on the preview. §6.9 says the
preview is the replier's assertion and that they can lie about it, so
matching on text would let a crafted reply send the reader to a message
of the sender's choosing — invisibly, since the quote would look right.
Spec clarified to say so: flamenet-e2e!8.

The tap clears any active search first. The list is visibleMessages,
which search filters, and scrollTo an id that is not in the list does
nothing — a tap that appeared to work and did not, which is the exact
failure the tappable-only-when-resolvable rule exists to avoid.

Resolution is a free function on Message rather than a method on the
view model, for the same reason applyReaction is one: a ChatViewModel
needs a live inbox, which needs a network client and a Keychain.

⚠️ iOS only. Android's transcript stores no reply reference at all, so a
reply's quote is not rendered there and there is nothing to tap — see
paulhitt/flamenet-messenger-android#10, updated with that finding.

Closes #48

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:10:33 -04:00
Paul Hitt b58c70f3d8 Merge branch 'feat/email-verification-ui' into 'main'
Offer the code entry on the screen that refuses the sign-in

See merge request paulhitt/flamenet-messenger-ios!46
2026-09-02 20:10:31 +00:00
Paul Hitt 557db092e3 Offer the code entry on the screen that refuses the sign-in
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.

🔴 **The remedy cannot live inside the app.** An unverified account
cannot get a relay token — that is what unverified means — so it never
reaches the app at all. A verification screen behind the door is one this
person can never open. `VerifyEmailSheet` is presented from the SIGN-IN
screen, and the sign-in error offers a button straight to it.

That needed the refusal's code kept, not just its sentence. `lastErrorCode`
now sits beside `lastError`: matching on the words would break the first
time the copy was edited, and that breakage locks somebody out silently
rather than loudly.

⚠️ **The copy must not read as a credential problem.** Somebody told
their sign-in failed will change a passphrase that was never wrong — and
here the passphrase is the identity, so that is not a harmless retry. A
test asserts "password", "passphrase", "incorrect" and "wrong" never
appear in it.

⚠️ The code field turns off autocapitalisation and autocorrect and trims
whitespace. A code pasted out of a mail client arrives with a newline
more often than not, and refusing it for that looks exactly like a wrong
code.

Four tests, 346 pass.
2026-09-02 16:10:18 -04:00
Paul Hitt bd8ba69cd9 Merge branch 'fix/uitests-must-not-sign-into-production' into 'main'
Stop a plain test run signing into the production relay

See merge request paulhitt/flamenet-messenger-ios!72
2026-09-02 19:46:36 +00:00
Paul Hitt d17c70b971 Expire the live sign-up opt-in, so a forgotten one is harmless
The documented workflow ends in `rm /tmp/fn-live-signup`, and a run that
crashes never gets there. macOS does not clear /tmp between reboots, so the
file sits for days and every ordinary test run after it quietly creates real
accounts on a real relay.

That is not hypothetical either: a sentinel dated three days earlier was
found on this machine still arming the suite, and today's run created
accounts against relay2.flamenet.io before anybody noticed.

The sentinel now has to have been touched within the hour — long enough for
any deliberate session, short enough that a forgotten file is harmless by
the next morning. The `rm` stays the right habit; it is just no longer the
only thing standing between a routine run and a live registration.

The file-not-env-var reasoning above it is unchanged and still correct.
2026-09-02 15:46:23 -04:00
Paul Hitt f41d6a4c9e Stop a plain test run signing into the production relay
`xcodebuild test -scheme FlamenetMessenger` was sending failed sign-in
attempts for a live account to relay.flamenet.io, then hanging. Six UITest
suites had no skip guard, and two read the demo passphrase as

    env["FN_DEMO_PASSPHRASE"] ?? "set-FN_DEMO_PASSPHRASE"

With the variable unset that placeholder was used as a real password against
the app's default server, which is production. FreshPairUITests then slept
30 x 10s waiting for a message that could not arrive, so a run blocked for
minutes per test with xcodebuild at 0% CPU. Observed: ~25 minutes parked on
"That handle or passphrase isn't right." for flamenetdemo2.

The relay locks an account after five failed sign-ins per IP and username in
fifteen minutes, and the accounts being hammered are the demo accounts that
exist for store review. A routine test run must not be able to lock those.

LiveDemoGate.passphrase() skips unless FN_DEMO_PASSPHRASE is set, so no live
sign-in ever runs with a placeholder — applied to FreshPair, DemoRound,
DemoAccountSetup and RelayTypes. Both placeholder fallbacks are gone.

LiveDemoGate.drivenSession() covers ReadyForHandover and DriveCall, which do
not sign in but assume a session paired by hand. They skipped as failures
before, which trains people to ignore red.

Verified: the suite now runs to completion, 50 of 52 skipped, no live
sign-in attempted.

Closes ios#62.
2026-09-02 15:46:23 -04:00
Paul Hitt 705fdbec01 Merge branch 'feat/screenshot-notice' into 'main'
Tell somebody when their disappearing message is screenshotted

Closes #36

See merge request paulhitt/flamenet-messenger-ios!70
2026-09-02 19:46:20 +00:00
Paul HittandClaude Opus 4.8 29daf4131b Tell somebody when their disappearing message is screenshotted
A timer promises the message goes away; a screenshot breaks that promise
silently and the sender never learns. It cannot be prevented, so it is
reported (spec §6.27).

🔴 Most of the care here is in refusing to let the absence of a notice
mean anything. The device that took the screenshot is the device that
reports it, so a modified build, a second phone photographing the screen,
or the platform's own recorder all produce nothing at all. A notice means
one was probably taken; no notice means NOTHING. So the notices are not
counted anywhere — a running total would be read as a measurement, and
its zero would be read as "nobody screenshotted", which is precisely the
inference the section forbids. The disclaimer names the ways detection
fails rather than merely admitting that it can: "sometimes this doesn't
work" tells nobody what to worry about.

⚠️ Only where a timer is set. Everywhere else this would be a general
surveillance feature rather than the narrow one it is — the promise a
screenshot breaks is the one §6.11 made, and where no promise was made
there is nothing to report against. The timer check comes before the
rate-limit check, so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Bound to the thread being on screen, not to the app. A screenshot
taken on the settings screen is not a screenshot of their message.

⚠️ One notice per conversation per five seconds: one screenshot often
produces several platform events, and a person who took one should not
appear to have taken four.

⚠️ The last-notice times are session-only. Persisting them would be
storing who screenshotted whom — a record of exactly the thing this
reports — on disk, to enforce a window that is seconds long.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:46:07 -04:00
Paul Hitt d7ba6c3b96 Merge branch 'feat/signed-device-wipe' into 'main'
Stop the relay being able to wipe a device, and verify a real instruction

Closes #60

See merge request paulhitt/flamenet-messenger-ios!69
2026-09-02 19:46:05 +00:00
Paul HittandClaude Opus 4.8 89e4b7882f Stop the relay being able to wipe a device, and verify a real instruction
🔴 A device that had registered once erased its local history and wiped
its Keychain whenever the relay answered a poll with 410. Nothing about
that was authenticated. The threat model says the relay is untrusted with
content — and it is, it still cannot read anything — but it could
DESTROY everything, for every user, irreversibly, by returning one status
code. That was never the intent (ios#60).

The unsigned path now signs out and stops polling, and leaves stored
messages exactly where they are. The device really is removed
server-side, so stopping is right; erasing is not something an unsigned
mechanism gets to ask for.

Erasing now requires a wipe instruction signed by the account key
(§6.26). The relay cannot produce one: the fnid this device saved at
sign-in IS the account public key, so an invented instruction fails the
signature. `deviceRevoked` still means "erase" and now has a signature
behind it; `deviceDeregistered` is the relay's unsigned word and takes
nothing with it.

⚠️ The device id is checked as well as the signature. A genuine
instruction for another device carries a genuine signature — verifying
that alone would let one device's wipe erase every device that saw it.

⚠️ The signature covers the device id and the timestamp, so a captured
instruction cannot be re-pointed or re-dated. The signed bytes are pinned
in a test: three implementations have to agree byte for byte, and a
format that drifts is a wipe that silently stops working.

⚠️ The account key is parsed from the address saved at sign-in, never
from anything the relay says now. A relay able to substitute the key it
is checked against could forge whatever it liked.

⚠️ Nothing here claims the data is gone. A device that is off or offline
never sees the instruction.

This is the honouring half of ios#38. Issuing one still needs a relay
route to carry it and a passphrase re-entry to sign it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:45:52 -04:00
Paul Hitt 04d1eb6596 Merge branch 'feat/notice-a-new-device' into 'main'
Tell a contact's new device apart from a substituted key

Closes #35

See merge request paulhitt/flamenet-messenger-ios!67
2026-09-02 19:45:49 +00:00
Paul HittandClaude Opus 4.8 886d04a76d Tell a contact's new device apart from a substituted key
The ticket says adding a device is silent. It is not — it tripped
`.changed`, which is the loudest state there is and blocks sending. The
real defect was that the two were indistinguishable, and the doc comment
on `.changed` admitted it: "either they reinstalled / added a device, or
someone is trying to read the thread."

That was wrong in both directions at once. Adding a device — the more
realistic attack, because it reads as somebody buying a phone — was
invisible as a distinct event, and it also blocked sending on every
person who legitimately owns a tablet. Loud in the wrong way and silent
in the right one.

🔴 The split is on which key set moved. An attacker who substitutes a key
cannot produce the old one, so the old one disappears: a missing pinned
key is the substitution shape and still stops the send. Every pinned key
still present with more alongside is an addition — nothing breaks, which
is exactly why it needs saying out loud rather than blocking.

⚠️ Missing beats added when both are true. A set that dropped a key and
gained one is a substitution wearing an addition's clothes, and the safer
reading is the one that stops the send.

⚠️ An addition is pinned so the notice fires once. Left unpinned, every
bundle fetch would re-report the same new device for ever, and a warning
that never stops is one people learn to scroll past.

🔴 Verification does not survive an addition. What was compared in person
was compared against the keys they had then; a device added since was
never part of that, and carrying the tick over would let it inherit a
check nobody performed on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:45:36 -04:00
Paul Hitt 204f03fc21 Merge branch 'feat/refuse-unsealed-sending' into 'main'
Count the sealed-to-unsealed fallback, and offer to refuse it

Closes #16

See merge request paulhitt/flamenet-messenger-ios!66
2026-09-02 19:45:33 +00:00
Paul HittandClaude Opus 4.8 d480a9f0b6 Count the sealed-to-unsealed fallback, and offer to refuse it
Sealing hides which account sent a message from the relay. When the
peer's delivery key or capability is missing, the send still goes —
end-to-end encrypted as always — but unsealed, and the relay learns the
sender. That degradation was silent: `lastSendWasSealed` held only the
most recent outcome and nothing accumulated, so nobody could answer "how
often does this happen to me".

Now every send's outcome is counted, and the privacy screen says what
share went unsealed. Zero sends reports as "nothing sent yet" rather than
a reassuring 0%, which is what a plain ratio would have rendered on a
fresh install.

🔴 The copy distinguishes sender from content, and there are tests
asserting it does. Somebody who reads "sealed" as "encrypted" makes the
wrong decision in both directions — relaxed when it is off, and alarmed
when it degrades on a message the server could never read anyway.

⚠️ Counted in aggregate only. Per-contact counts would be more useful and
would also build a record of who this account talks to and how often —
the precise thing sealing exists to keep off a server, kept instead on
the device.

Refusing rather than degrading is offered and is OFF by default. That is
the honest default rather than the lazy one: refusing turns a metadata
leak into an undelivered message, which for most people is the worse
outcome. The refusal has its own error case so it can explain itself — a
bare "couldn't send" reads as the app being broken, and the next thing
somebody does is turn the setting off without knowing that is what they
are doing.

⚠️ The gate is checked before the capability is spent. Refusing after
taking a token would burn one per attempt and drain the contact's batch
while nothing was delivered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:45:20 -04:00
Paul Hitt 4f678ac56f Merge branch 'feat/group-stories' into 'main'
Post a story to a group

Closes #55

See merge request paulhitt/flamenet-messenger-ios!64
2026-09-02 19:45:17 +00:00
Paul HittandClaude Opus 4.8 b7f3347e60 Post a story to a group
An ordinary story envelope carrying `gid`, fanned out pairwise to the
members exactly as a group message is, and rendered in the story surface
(spec §6.24).

🔴 The group's roster is the audience and `storyAudience` does not apply.
Those are two different lists making two different statements — who may
see this account's stories, and who is in a conversation — and they will
disagree. The roster wins, because a story addressed to a group that
silently dropped some members would be a group message part of the group
cannot see, which is worse than the failure being avoided.

That makes the disclosure load-bearing rather than polite, so
`groupStoryReach` returns the overlap rather than a warning: which
members are excluded by this account's story audience, and which are not
contacts at all. The two are counted separately — a non-contact is not
"excluded from your stories", they were never on that list.

🔴 The inbound branch catches a story BEFORE the group-message path.
Falling through would render it as a message in the group's conversation:
a story that is a message, and one that never expires out of the thread.

⚠️ `exp` is the ordinary story lifetime. Group membership does not extend
it, and it is not kept merely because it is also addressed to a
conversation that keeps history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:45:04 -04:00
Paul Hitt 53210aa90d Merge branch 'feat/save-your-own-story' into 'main'
Save a story you posted yourself, before it expires

Closes #34

See merge request paulhitt/flamenet-messenger-ios!60
2026-09-02 19:45:01 +00:00
Paul HittandClaude Opus 4.8 ba5c1947dd Save a story you posted yourself, before it expires
A story disappears from the author's own device too, so there was no way
to keep something you made. Saving your own before expiry is ordinary
everywhere else.

🔴 Only ever your own, and the rule is `E2EInbox.isSaveable` rather than
a condition on the button. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a view is one refactor away
from being lost. It is tested from both directions.

⚠️ The confirmation says the copy has left the disappearing model, and
where it went. "Saved" alone would be true and useless: the point is that
this one copy now outlives the story, and somebody who is not told that
may reasonably assume the expiry still covers it.

⚠️ `.addOnly` authorisation rather than full access. Saving needs
permission to add a photo and nothing else; asking for read access as
well would let this app enumerate somebody's whole library in order to
perform a write. The narrower prompt is also the honest one.

⚠️ `NSPhotoLibraryAddUsageDescription` had to be added. Without it iOS
does not warn — it terminates the app the first time somebody saves.

The helper already takes a `hasBlob` argument the text stories of §6.21
will need; on this branch every story has one, so only the call site
moves when that branch lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:44:48 -04:00
Paul Hitt ff3a5e4046 Merge branch 'feat/group-messages-carry-a-mid' into 'main'
Group messages carry an id of their own

Closes #58

See merge request paulhitt/flamenet-messenger-ios!54
2026-09-02 19:44:46 +00:00
Paul HittandClaude Opus 4.8 46da123ab7 Group messages carry an id of their own
🔴 The wire always carried one and the model never stored it.
encodeGroupText has taken a mid: since §6.9 shipped and the parser reads
it back — E2EGroupMessage was where it stopped. So nothing in a group
could name an earlier message: no reply to jump to, no reaction to fold,
no edit to apply. Every one of those was "codec done" and none worked.

It also had a symptom visible all along. markGroupDelivered matched a
bubble by isMine && date == at, because there was no id to match on, so
two messages sent in the same millisecond restated the wrong one —
cheap to hit when a queued burst goes out at once. The rule now matches
on mid, and moved onto the model as a static so it could be tested at
all: it went months without a test because reaching it needed a network
client and a Keychain.

The timestamp path stays, and only for messages with no id. Those were
written before group ids existed, and dropping it would leave every one
of their pending bubbles saying "will send" for ever.

The group bubble's projection now carries mid and replyTo, so the quote
block the bubble already knew how to draw finally gets something to draw.

Closes #58

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:44:33 -04:00
Paul Hitt 3fc1cf1df3 Merge branch 'feat/search-across-conversations' into 'main'
Search inside messages, across every conversation

Closes #25

See merge request paulhitt/flamenet-messenger-ios!53
2026-09-02 19:44:30 +00:00
Paul HittandClaude Opus 4.8 f21ce53476 Search inside messages, across every conversation
The list only ever searched NAMES, so a word somebody said found
nothing.

🔴 There is no index, and that is a decision rather than an omission. An
index derived from the decrypted history is plaintext by another name:
it would have to be sealed as carefully as the history, purged with it,
and kept consistent with it — a second copy of everything anybody ever
wrote, with its own ways to go wrong. It is not needed, because the
inbox already restores the whole history into memory at launch. A scan
over it creates nothing new at rest, so there is nothing new to protect.

MessageSearch names the condition under which that stops being true: if
history is ever paged out of memory or outgrows a linear scan, an index
becomes necessary — and must then be sealed with the history key and
purged by E2EHistoryStore.purge(), or the app will have quietly acquired
an unencrypted transcript.

⚠️ The query lives in view state and nowhere else — not beside drafts,
not in UserDefaults, not in the snapshot. What somebody searched for is
often more revealing than what they found.

An empty query finds nothing, never everything: dumping the whole
history onto one screen the moment the search box is focused is the
worst possible default in the place most likely to be read over a
shoulder.

Accent-insensitive, because somebody typing "cafe" means "café" and a
search that knows the difference quietly fails in every language with
accents. Plain substring, never a regex — a stray bracket typed into a
search box is a crash or a hang.

Closes #25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:44:18 -04:00
Paul Hitt befdebfd66 Merge branch 'feat/drafts-that-survive' into 'main'
Keep a half-written message across a relaunch

Closes #47

See merge request paulhitt/flamenet-messenger-ios!50
2026-09-02 19:44:15 +00:00
Paul HittandClaude Opus 4.8 c83c9dd526 Keep a half-written message across a relaunch
Sealed in the history file, not a plist — and that is the whole point. A
draft is plaintext the person has not decided to send, very often the
most sensitive text in the app because it is the thing they are still
weighing. UserDefaults would write it unencrypted, leave it readable
while the device is locked, and carry it into every backup. In the
sealed snapshot it inherits the Keychain key, the file-protection class,
and purge() on sign-out.

All three composers: 1:1, group, and federated. The federated one needs
its own key because remoteThreads has no local id — without it every
remote correspondent would share one, and a draft written to somebody on
another relay would appear in the composer for the next person.

Drafts ride the existing 1.5s history debounce rather than getting their
own writer: they change on every keystroke, and a separate one would
seal the whole file per character typed.

Whitespace is not a draft — restoring "   " gives a composer that looks
empty and reports itself as having content, which here means an enabled
Send button that sends nothing. But text is stored as typed, not
trimmed: somebody who left a trailing space was mid-word.

Closes #47

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:44:03 -04:00
Paul Hitt f338a045b4 Merge branch 'feat/edit-a-sent-message' into 'main'
Edit a message that was already sent (§6.15)

Closes #26

See merge request paulhitt/flamenet-messenger-ios!48
2026-09-02 19:44:00 +00:00
Paul HittandClaude Opus 4.8 619435c11c Edit a message that was already sent (spec §6.15)
An envelope naming an earlier mid replaces its text. Two things keep
that from being a forgery primitive, and both are enforced and tested
rather than assumed.

Only the author may edit. In a 1:1, isMine is the whole of authorship,
so an inbound edit may only touch a message the buddy wrote and a local
edit only one we wrote. Without it a single envelope naming any mid
rewrites what YOU said in your own transcript. re.author is never
consulted — anyone can put any id there — so authorship is decided from
the copy of the message we already hold.

The reader is always told. "Edited" shows on both sides and is not
behind the timestamp setting or any other, because an edit nobody can
detect puts new words in a bubble they already trusted.

Also: monotonic (relays order by receipt, not authorship, so two edits
can arrive backwards), never a bubble of its own, never a notification,
and it may not empty a message — retracting is a different operation
with different honesty requirements (ios#57).

Send routes through one submit() on the view model rather than the three
places in the view that call it, so the one that forgets an edit is in
progress cannot send a duplicate instead of a correction.

Closes #26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:43:47 -04:00
Paul Hitt 8cd3cf765f Merge branch 'feat/signup-proof-of-work' into 'main'
Solve the sign-up puzzle when a relay asks for one

See merge request paulhitt/flamenet-messenger-ios!47
2026-09-02 19:43:44 +00:00
Paul HittandClaude Opus 4.8 594ccb985d Ask for the puzzle only when the relay refuses without one
The first version fetched a challenge before every sign-up. That put a
second round trip on the one screen where somebody is already waiting,
for a feature that is off by default and off on every relay running
today — and the full test run caught it, because five existing
RelayAccounts tests suddenly saw an extra request.

Now the first attempt goes out plain and only a challenge_required
refusal sends us to solve anything. A relay with this off sees exactly
one request, unchanged. The refused attempt costs one of fifty per hour
from that address, which is the right trade for not charging everybody
else a round trip.

The fetch also moves onto the app's own transport rather than
URLSession.shared: the app's session has the disk cache turned off and a
second session built here would quietly not.

An expired check is reported rather than silently re-solved — grinding a
second puzzle would double the wait before saying the same thing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:43:31 -04:00
Paul HittandClaude Opus 4.8 c727030207 Solve the sign-up puzzle when a relay asks for one
The relay can charge a few seconds of arithmetic before an account
exists — cheap for one person, expensive for a farm, and unlike a
CAPTCHA it hands nothing to a third party. This is the client half:
ask what work is wanted, solve it off the main actor, send the answer
beside the sign-up fields.

Every failure path ends in "sign up anyway". An older relay, a
WordPress-backed site, a request that times out — none of those are
reasons somebody cannot create an account, so the fetch returns nil for
all of them and the fields are simply absent.

Two refusals get their own sentences: an app too old to answer, and an
answer that did not hold up (nearly always a challenge that expired
while somebody was typing). Sharing one message would send half these
people to the App Store for nothing.

The bit counting is pinned to fixed vectors, not checked against our own
arithmetic. A byte-counting implementation still SATISFIES a 9-bit
difficulty — it overshoots to 16 bits and does 230x the work — so an
assertion about "at least 9 leading zeros" cannot see the bug. The
first draft of these tests had exactly that hole and caught nothing.

Refs flamenet-relay#39

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:43:31 -04:00
Paul Hitt ecc3ba744a Merge branch 'feat/signup-validation-errors' into 'main'
Say why a sign-up was refused, for two new reasons

See merge request paulhitt/flamenet-messenger-ios!45
2026-09-02 19:43:29 +00:00
Paul Hitt 6407448af2 Say why a sign-up was refused, for two new reasons
The relay gained two refusals alongside look-alike handle checking, and
`RoutesAccount` says why mapping them here is not optional: *"These codes
are not arbitrary: the shipped client switches on them to pick its own
error copy, and anything else falls through to a generic failure."* A
refusal somebody could act on, rendered as "something went wrong", is the
worst version of it — they retry the same thing and are refused again.

⚠️ **Both messages say what to DO.** "Too similar" on its own leaves
somebody retyping the same name with a different digit and being refused
again; the fix is to change a letter, so the copy says that. The
disposable-address one explains the consequence — that address is how
they get back in — rather than sounding like a judgement about their
email provider.

**`SignupErrorCopyTests` is new, and error copy had no coverage at all.**
It pins every sign-up refusal to its own words, fails if two share a
message, and fails if a code renders as itself — which is what falling
through the switch looks like from the outside.

⚠️ It also pins the sign-in refusal: `bad_credentials` must not reveal
which half was wrong. The relay answers identically on purpose, and a
message here that told them apart would undo that at the last step.

347 tests pass, up from 342.

Pairs with flamenet-relay!11.
2026-09-01 20:43:56 -04:00
Paul Hitt bd7b7c96e4 Write down the bugs of 2026-09-01
Seven faults across four repositories in one day. Three of them were
shipping, two were self-inflicted inside the same session, and one was
the reason the Android download had been pulled a day earlier.

Recorded because the reasons they were hard to find outlast the fixes:

  - asymmetric clients hide bugs — Android sends text as bare bytes and
    iOS wraps it in an envelope, so the bench (iOS↔Android) never
    exercised the pairing that was broken (iPhone↔iPhone);
  - a change with two halves must land as one — sign-out keeping the
    device shipped without sign-in loading it, and the result was not a
    half-working feature but an account that destroyed itself once per
    sign-in, with every symptom looking unrelated;
  - strings in a binary prove nothing, because comments do not survive
    compilation — `nm -gU` is the check that means something;
  - os_log from an XCTest host never reaches `simctl log show`;
  - establish a baseline with a worktree at the last good commit BEFORE
    carrying "unknown whether I caused this" for hours.

Open work is in GitLab rather than here: iOS #6–#10, Android #6–#7,
relay #13. The table at the end says what guards each fault now, and
says plainly which guard has never completed a run.
2026-09-01 18:23:08 -04:00
Paul Hitt dca4a08211 Merge branch 'fix/cross-platform-delivery' into 'main'
Cross-platform delivery, notifications, and media privacy

See merge request paulhitt/flamenet-messenger-ios!44
2026-09-01 22:09:00 +00:00
Paul Hitt a2cc5095ec A gate for the lifecycle, which is where the bugs actually live
Every unit suite here runs inside one process. Every bug that reached the
owner today lived between two of them, over time: sign-in minting a new
identity instead of loading the stored one; the key-log check asking for
the wrong subject and accusing a contact of tampering; the relay
announcing protocol traffic as messages. None reachable from a unit test,
none caught by sending one message by hand.

`Scripts/lifecycle-gate.sh` runs the sequence instead: two simulators
sign up, exchange a message, one signs OUT and back in, and then —
without anybody accepting anything — messages must cross again and a call
must not be refused. The assertion the whole file exists for is a
negative one: "Security keys changed" must NOT be on screen.

🔴 One `xcodebuild` invocation per side. A second reinstalls the app and
takes the container with it, and state SURVIVING is the entire point.

🔴 A skip exits 2, never 0. A run that silently did nothing looks exactly
like a passing one.

⚠️ **It refuses to start above load 20, and that is not caution.**
Measured today: it drove this machine to a load average of 591 and
starved the owner's own emulator and simulator out of existence
mid-session. The two sides must run concurrently — each is the other's
peer — so the concurrency cannot be traded away, which leaves refusing.

Also adds `chat.voicecall`, which the video button beside it has always
had. Without it the gate finds the call button by its label, which is the
string a translator changes.

⚠️ NOT yet proven able to fail. It has not had a clean run on a quiet
machine, so it is committed as a tool, not as evidence.
2026-09-01 17:19:10 -04:00
Paul Hitt 6b031f3695 Say which envelopes are worth interrupting somebody for
The relay announced every envelope as "New message" because it holds
ciphertext and cannot tell one from another. It now takes `silent` from
the sender, and this is the sender.

Marked silent: call answers, call ends, delivery keys, key-log gossip,
reactions, avatars, disappearing-timer changes — everything that is a
layer talking to a layer. A call offer still announces itself, and so
does anything a person would see in the thread.

⚠️ Decided from the CONTENT rather than passed in by each send path,
same as `isCallSignal` beside it: a flag threaded through six callers is
a flag one caller forgets, and the failure is an extra banner nobody
traces back to a missing argument.

⚠️ A plain string is a message. Text from an older build carries no
envelope at all, and the safe reading of something unrecognised is that
a person meant to say it.

🔴 **Also narrows `urgent` to the OFFER.** It was every call signal, so
an answer and a hang-up each sent a VoIP push as well. A VoIP push is a
promise: Apple requires the app to report an incoming call to CallKit the
moment one arrives, and an app that takes them without doing so is
terminated and eventually loses the entitlement. Two thirds of the VoIP
pushes this app asked for had no call to report — and on a device with no
VoIP token they fell back to an ordinary alert, which is where the
banners during calls were coming from.

Also drops a binding left dead by the EXIF fix: the picked item's content
type must NOT be read any more, because `ImagePrep` re-encodes to JPEG
whatever came in, and naming the file after the original would label a
JPEG as a HEIC.

342 tests green.
2026-09-01 15:59:06 -04:00
Paul Hitt 7b0c17c98b Stop banners the app never posted
🔴 **The banners were not coming from this app, which is why nothing I
did to the posting side changed them.** `NotificationManager` is the only
thing here that creates a notification, and the log shows it did not fire
once during a session full of them. The relay pushes them. A push arrives
at `willPresent` having gone nowhere near the suppression added earlier,
and `willPresent` said `[.banner, .sound, .badge]` to everything.

🔴 **And the relay pushes per ENVELOPE, not per message.** Call
signalling, delivery keys and log gossip are all envelopes, so placing one
call or sending one message produced a burst of "New message" banners for
things nobody sent and nobody can act on. From the client they are
indistinguishable from real mail — same payload shape — so the rule has
to be about the app, not the payload.

While the app is in front: no banner. The screen already shows what
arrived and the unread badge already marks it; a banner over a
conversation the reader is looking at is noise by construction. Badges
still update, and the moment the app backgrounds this method is not
called at all, so notifications behave exactly as before — which is the
case they exist for.

A call in the foreground now gets nothing at all, not even a sound. The
ring screen is already filling the display, and the banner was landing at
the exact moment someone is trying to hit Answer.

⚠️ The earlier `visibleConversation` work is still right and still
needed — it governs what this app posts itself. It was just never the
thing on screen.
2026-09-01 15:42:33 -04:00
Paul Hitt 84538f06fc Put the cure on the screen that names the problem
A call refused by the trust gate said "Their security code changed —
open their chat to review it", and stopped there. Accurate, and a
scavenger hunt: the control that fixes it lives in the conversation and
nothing on the call screen went there. Reported as having to hunt for
where to accept new keys, which is exactly what it asked for.

The chat already carries the whole banner — what changed, why it might
be innocent, compare the safety number, accept the new keys. So the
ended-call screen now offers "Review keys" when that is the reason, and
it opens that conversation. Cleared when a call starts, so a stale one
cannot offer a review for the wrong contact.
2026-09-01 15:28:06 -04:00
Paul Hitt dc3d7eca25 A photo stops saying where it was taken, and a story can outlast a day
**Location.** Neither app has a location feature — no permission, no
CLLocation, nothing. What travelled was metadata riding inside the media,
and two iOS paths carried it out:

  - A story VIDEO. `AVAssetExportSession` copies the source asset's
    metadata unless told otherwise, and a clip from the camera roll
    carries the capture location — so posting a story handed every
    viewer the coordinates of wherever the person was, with nothing on
    screen to suggest it. `export.metadata = []`: emptied rather than
    filtered, because an allow-list of "safe" keys is a list somebody
    has to keep correct for ever and a story needs none of them.
  - The legacy photo upload. It sent the file exactly as the picker
    handed it over, EXIF and all, and it is the path that talks to the
    PLAIN server — so the weakest route was also the least protected.
    It goes through `ImagePrep` now like every other.

Chat photos, group photos, avatars and story photos were already safe,
but only as a CONSEQUENCE of being redrawn to resize. That protection
disappears the first time somebody adds a reasonable fast path for
pictures already small enough to send. `E2EAttachmentPrivacyTests` turns
the side effect into a promise: it builds a JPEG carrying real GPS
coordinates, asserts the FIXTURE is dangerous before asserting the
output is clean, and covers the small-photo case separately because that
is exactly what such an optimisation would target.

**Stories.** The lifetime was a constant, so the one thing people want
to change about a story — how long it lasts — was the one thing they
could not. 1 hour / 6 hours / 24 hours / 3 days / 7 days, defaulting to
what it has always done, in the screen that already governs stories.

⚠️ Read at post time rather than captured once: a `static let` would
hand every story whatever the preference was when the app launched.

⚠️ `StoryLifetime.current` falls back rather than trusting the stored
number. `UserDefaults` answers 0 for a key never written, and a story
that expires zero hours after posting is one nobody ever sees — the
failure would present as the story never sending.

The expiry rides inside the sealed envelope (§6.6), so this asks nothing
of the relay: every copy carries the number its poster chose. Both
footers say what it does NOT promise — a viewer who screenshots a story
has it regardless.

342 tests green.
2026-09-01 14:58:08 -04:00
Paul Hitt f489330cc9 Stop swallowing every message this app sends
The control-traffic guard added this morning tested
`text.hasPrefix("{\"fnc\":")`. An ordinary text message IS an `fnc`
envelope — iOS has sent text that way since messages gained ids, so
they can be replied to and reacted to — and the guard sits immediately
before `appendToThread`. So it consumed every message instead of
delivering it: 338 tests, 19 red, including
`testTwoAccountsExchangeAMessage`, which the file itself describes as
the path every other test assumes works.

It was also redundant. Control traffic marks itself with `ctl: 1` and
`content.isUnknownControl` already consumes it one screen further up. I
added a second, blunter guard for a job that was already done, and the
blunt version could not tell a message from a protocol frame.

What is left for it to catch is the envelope `parse` could not decode at
all — malformed, truncated, or written by a build whose shape this one
does not know. `parse` reports that by handing back the raw plaintext as
the message text, so comparing the two identifies it exactly, with no
guess about what any other kind means.

🔴 **Manual testing could not have found this.** The Android client
sends plain text as bare bytes when there is no reply, link or id, so
Android-to-iOS never carried the prefix and kept working all afternoon —
while iPhone-to-iPhone delivered nothing at all. That is the pairing
that was about to go to TestFlight.

The new test pins all three cases apart rather than only the one that
broke, and was proven able to fail: restoring the old guard turns it red
on "a text envelope must reach the thread, got: []".

Baseline established with a worktree at 55943eb — 6/6 there against 3/6
on the branch — rather than left as a suspicion, which is how this sat
unexplained for most of the day.
2026-09-01 14:07:33 -04:00
Paul Hitt 2138032990 Tell people about messages they are not already looking at
Three separate things were raising a "New message" banner for something
the reader could already see, and together they read as the banner
firing on everything.

  * A group chat on screen never suppressed anything. The check was a
    buddy id, and a buddy id cannot name a group — the group notifier
    passes the AUTHOR's id, so "am I reading this thread" compared a
    group against whoever had just spoken in it and was false every
    time. A thread is now identified by what it is: a buddy, a group,
    or a federated address.

  * The in-app tone was played BEFORE every guard, so a muted
    conversation, a thread already open, and an account with
    notifications switched off all still chimed on each arrival. Two
    handsets a foot apart pinging on every message, with no banner to
    explain it, is most of what this sounded like.

  * The 1:1 suppression was written but had never shipped: the running
    build carried no `visibleConversation` symbol at all.

Also drops the call-stack trace from the notify log — scaffolding that
found the above; walking the stack on every notification is not
something to leave in.

Signing out no longer leaves wreckage behind either, which is the same
class of bug and shares a file. It did three things in an order where
each was wrong:

  * The credential was cleared LAST. Killed anywhere in the middle, the
    app relaunched holding a valid app password and no identity, signed
    itself back in, minted a fresh identity and registered a SECOND
    device — so every contact's pinned key stopped matching. Reachable
    by nothing worse than the system reclaiming memory at a bad moment.
    Now: signed out locally first and synchronously, so every state
    this can be interrupted in reads as signed out.

  * The relay was told on a detached Task that ran after the wipe, so
    it read a device id out of an emptied store — MINTING one, because
    that getter registers a device as a side effect of being read — and
    asked the relay to forget an id that never existed while the real
    device stayed registered and kept being woken. Teardown now reads a
    non-minting `existingDeviceID`.

  * Nothing ever retired the device. The account went on publishing a
    reader that would never read again, and fan-out is per device, so
    contacts kept addressing copies to a dead mailbox while the relay
    reported them all delivered. `retireThisDevice()` forgets the push
    token and revokes the device, bounded at six seconds so a bad
    network cannot hold the person or leave them half-wiped.
2026-09-01 12:38:02 -04:00
Paul HittandClaude Opus 4.8 680b88a3f7 Keep the device id in the Keychain, beside the identity it belongs to
A device's identity is one fact and was stored as two: the id in
UserDefaults, the key in the Keychain. Anything that replaced the app
container — a reinstall, an Xcode rebuild, `simctl install` — kept one
and lost the other, and every combination produced a different failure
that blamed something which was not wrong:

  - identity lost, id kept → the relay refuses the pair and the app reads
    its own 401 storm as a credential problem
  - id lost, identity kept → a SECOND device is registered on the account,
    contacts fan out to a mailbox nobody reads, and their pin sees an
    account publishing two identities, which is what a planted device
    looks like — so they stop sending and ask about safety numbers
  - either → peers key sessions by our id, so ours become unfindable and
    the far side reports "identity keys changed" about an identity that
    never moved

Together they cannot diverge. A reinstall either keeps both, in which
case nothing changed, or loses both, in which case this genuinely is a
new device and every path downstream already handles that. The third
state is gone, and with it this whole class of bug.

⚠️ The UserDefaults value is still READ, once, to migrate. An install
that predates this has a perfectly good id there, and minting a new one
would inflict the exact failure this removes on every existing user at
the moment they update. Verified against a live install: same device id
across the change, no re-registration, sessions intact.

⚠️ Written to BOTH, and the mirror is not belt-and-braces. If a Keychain
write fails — no entitlement, a locked device, a test host without one —
the read fails too, and the getter would mint a fresh id on every access:
a new device per call, sessions dropped underneath the app. The Keychain
is authoritative; the suite keeps the value stable when it is not there.

`wipe()` clears it, or signing out would leave an id that comes back as
the same device on an account no longer holding it.

Also here: a banner is no longer posted for the conversation already on
screen. The app presents banners while frontmost, so a message arriving
in the open thread dropped a banner on top of the bubble it was
announcing — every message, in a chat the person was actively reading.
Only that conversation and only while active; on another tab, another
chat, or in the background it still fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 12:15:50 -04:00
Paul HittandClaude Opus 4.8 8e79a9202b A new device id means new sessions — actually drop them
`rotateDeviceID` carried this comment:

    Ratchet state is keyed by OUR device id on the peer's side, so a new
    id means new sessions. Leaving the old ones would have us decrypting
    with keys the peer no longer sends to.

and then cleared only `published_proto`. Every ratchet stayed.

A peer looks a session up by the device id we send FROM. Once ours
changes, none of our sessions can be found at the far end — but this
device still holds them, so it goes on sending ordinary `msg` envelopes
on chains the peer has no entry for. The peer cannot match them, falls
through to its handshake branch, finds no handshake in an ordinary
message, and reports the only thing that branch can report: "identity
keys changed". Nothing about the identity changed. Messages stop
arriving in one direction and every diagnosis goes looking for a trust
problem that does not exist.

Both paths that change the id now drop sessions: `rotateDeviceID`, and
the getter that mints one when the id is missing — which is the case
that actually bites, because the id lives in UserDefaults and the
identity in the Keychain, so a reinstall loses one and keeps the other.
The archived copies go too; they are keyed the same way and are equally
unreachable.

The next send then has no session, runs X3DH, and arrives as a prekey
envelope the peer can adopt — which is the only thing that rebuilds the
pairing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:53:23 -04:00
Paul HittandClaude Opus 4.8 d3f912864b Retire stale copies of this device, not just rotated ones
The device id lives in UserDefaults and the identity in the Keychain, so
they are lost independently — and only ONE of the two directions was
handled. `recoverFromDeviceMismatch` covers "new identity, old device id":
it rotates and retires the abandoned row. This covers the reverse, which
happens whenever the app's container is replaced while the Keychain
survives: the device id is gone, the identity is not, so the app mints a
new device id, registers it, and cannot retire the old one because
nothing remembers it existed.

The account then publishes two devices. A contact fans out over the
directory and sends half of everything to a mailbox nobody reads, while
the relay reports every one of those copies delivered — and the peer's
identity check sees an account whose published keys no longer match what
it pinned, so it refuses to send at all:

    REFUSED by guardIdentity: device f70f82fd has published keys, but
    none match what this relay served

🔴 **The identity key is what makes this safe rather than a heuristic.**
Every real device has its own identity key, so a second phone never
matches. A device publishing OUR identity key that is not our device id
can only be a copy of this install that lost its id — there is no other
way to hold that key. It cannot delete a legitimate second device.

⚠️ Runs only after the replacement registration is confirmed. Deleting
anything before that risks an account with no device at all.

Found by causing it: `simctl uninstall` during a "clean cache" redeploy
replaced the container, kept the Keychain, and reproduced the whole
failure — messages refused in both directions and calls unanswerable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:35:01 -04:00
Paul HittandClaude Opus 4.8 1ef182fe90 Export compliance: declare the post-quantum algorithms we actually ship
The encryption description pasted into App Store Connect listed X25519,
Ed25519, HKDF, HMAC and AES-GCM — and stopped there. The app has shipped
hybrid post-quantum key agreement (ML-KEM-768, FIPS 203) and hybrid
post-quantum identity signatures (ML-DSA-65, FIPS 204) since §12 and §20,
and neither appeared anywhere in this file.

The ANSWER does not change: both are NIST standards reached through
CryptoKit, so "no proprietary or non-standard algorithms" is still
correct. But a declaration that omits an algorithm the binary contains is
inaccurate, and this is the text that goes to Apple and underpins the
annual BIS self-classification report.

⚠️ Anything added to the protocol from here belongs in this list on the
same commit that ships it. This drifted precisely because updating it was
nobody's job at the moment the algorithm landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:30:26 -04:00
Paul HittandClaude Opus 4.8 c95d6fbe31 Say which notification was posted, and for whom
A banner appearing at a moment nobody expects one is impossible to trace
when the only thing that knows it fired is the banner itself. Both paths
now log: the buddy it is for, and — for a message — how long the body was.

⚠️ The LENGTH, never the body. The whole point of the preview setting is
that message text does not leak to places it was not meant to go, and a
log line is one of those places.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:29:17 -04:00
Paul HittandClaude Opus 4.8 96d65c9915 A call announces itself as a call, and control traffic stops posing as one
Two changes, one of which is a bug I introduced and caught in the same
sitting.

**Calls get their own notification.** The app had one banner for
everything that arrived, so a call — the one thing that stops mattering
if you look thirty seconds later — announced itself as "New message" and
gave no reason to hurry. `postIncomingCall` says "Incoming call" or
"Incoming video call", uses the ringtone sound, and clears when the call
finishes: a banner still saying "Incoming call" after the caller hung up
sends somebody back to an app with nothing ringing in it.

⚠️ Not posted while the app is in front. This app presents banners even
when active, so a foreground call would put a notification on top of the
full-screen ringing UI it duplicates. The notification is for the person
who is NOT looking at the app.

⚠️ Governed by the existing message toggle and per-buddy mute rather than
a new switch. A second control nobody knows to look for is how somebody
silences messages and still gets rung at midnight.

**Control traffic that does not parse is consumed, not shown.** Every
control branch matches on a KIND and then requires its fields, so an
envelope that is malformed, truncated, or written by a newer build with a
different shape falls past all of them and is delivered as an ordinary
message — raw JSON in a conversation, announced by a "New message"
banner, for something no person sent.

🔴 The first version of this guard sat one branch too early and ate every
incoming call. `call-offer` is an `fnc` envelope like any other, so a
catch-all placed in front of the call branch catches calls: offers were
logged as "control traffic we could not read" and the phone never rang.
Caught within minutes because the log said exactly what it had dropped,
which is the whole reason those lines exist. It now sits after every
handler, and the ordering is the correctness argument.

Verified after the fix: an offer from Android reaches `call signal in`
and the ringing screen appears, 1.1s from the caller pressing the button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:14:22 -04:00
Paul HittandClaude Opus 4.8 24b6a8ca95 Keep polling fast while a call is ACTIVE, not just while it rings
The previous commit added a second fast-poll mechanism to the inbox
without noticing the one already in `CallManager`, which has polled every
second since calls shipped. This reverts that and fixes the real defect,
which was one word in a switch:

    case .dialing, .ringing, .connecting:   // .active was missing

Fast polling stopped the moment a call connected, so the one signal still
to come — the other side hanging up — dropped back to the ordinary
five-second cycle. Ending a call looked like a button that did nothing,
on whichever device did not press it. Adding `.active` is the whole fix,
and it keeps one mechanism instead of two overlapping ones polling the
relay twice a second between them.

Measured on a live pair, Android to iPhone: offer seen in 1.0s, hang-up
in 1.2s. Both were previously bounded by the five-second poll.

⚠️ What remains is on the SENDER: ~1.2s of ICE gathering before the offer
is transmitted at all, because the SDP is sent only once gathering is
COMPLETE. That is a separate change (trickle ICE) and is not attempted
here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:03:46 -04:00
Paul HittandClaude Opus 4.8 8afd75686c Poll like a call is happening, while one is
A call is three round trips a person is watching — the offer, the answer,
the hang-up — and each waits for the far side to poll. iOS polled every
five seconds regardless, so each leg could sit for that long: up to
fifteen seconds of a ringing phone and buttons that appear not to work.
Android has had a faster cadence during call setup since calls shipped
(`CALL_SETUP_POLL_MS`), which is why the lag was one-sided and why it
showed up as "iOS is slow to connect and slow to hang up".

The inbox now drops to a one-second gap while call signalling is moving,
and returns to five afterwards. Marked from BOTH directions on purpose:
the caller is waiting on the answer, the callee is waiting on the
hang-up, and watching only our own sends would leave whichever side is
receiving slow exactly when the other is waiting on it. Marked before
the send, so the very next poll is already the fast one.

⚠️ Ninety seconds, then back to normal. Long enough to cover ringing and
the hang-up after it; short enough that a missed call does not leave the
app polling five times harder for ever. A relay that long-polls is
already faster than this and keeps its own 0.5s gap.

Measured on a live pair, Android to iPhone: hang-up seen in 0.43s where
the same signal previously waited on a five-second poll. The offer took
3.0s end to end, of which ~1.2s is the SENDER waiting for ICE gathering
to complete before it transmits anything — a separate cost on the
Android side, not addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 10:56:20 -04:00
Paul HittandClaude Opus 4.8 2302d807bd Drop an await that never suspended
`E2EClient` is a plain class and `useRelayAuth` is a synchronous setter,
so the await on it was noise — flagged by the compiler as "no async
operations occur within this await expression". Removing it changes no
behaviour and leaves the build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 10:49:22 -04:00
Paul HittandClaude Opus 4.8 e25510198a Audio: stop routing the session on the main thread
AVAudioSession says it itself — "This method can lead to UI
unresponsiveness if called on the main thread" — and both remaining
callers were on it.

The recorder's teardown. `setActive(true)` was moved off the main actor
when a tap on the mic button cost sixty seconds of a frozen app;
`setActive(false)` was left exactly where it was, on the path taken
every single time a recording stops or is cancelled. Deactivation waits
on the route being torn down and on other apps being notified, which is
the same block in the other direction. Fire-and-forget: the file is
already read, the state is already cleared, and the next start()
activates again regardless.

The player, which matters more because it runs far more often — every
voice note anyone plays. Activation now happens off the main actor and
playback starts when the route is up, back on the main actor, so
ordering is preserved and only the waiting moves. `isPlaying` is set
first so the control responds immediately, and re-checked before play()
because routing takes long enough that somebody can tap pause in the
gap — starting playback then leaves a bubble playing under a paused
control.

Unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:35:32 -04:00
Paul HittandClaude Opus 4.8 9cb67b5b11 Show every presence state, and keep it up to date
Two separate faults, reported together as "away and busy do nothing".

Away and busy rendered NOTHING. Four call sites each hand-rolled a green
circle gated on `presence == .online` — the contact row, both places on
the profile, and the chat header — while `PresenceDot`, which has had the
colours for all four states the whole time, went unused outside its own
preview. So a contact who set themselves away or busy lost their dot
entirely and became indistinguishable from offline, which reads as the
status never having arrived. All four now use the component; it gained a
ring parameter so each keeps the border that lifts it off the avatar.

Presence never updated. It rides the buddy list, and that list was
reloaded only when the Home tab appeared, on pull-to-refresh, or after
adding somebody — so a contact's status was whatever it had been when
you last opened that one screen. `ChatViewModel` was worse: it captures
`buddy.presence` at init and never touches it again, under a comment
saying the poll loop refreshes it, which described the WordPress client
this replaced.

`AppEnvironment` now refreshes the roster on a timer and both views read
their dot from it, falling back to their own snapshot. Deliberately slow
at 20s: presence is the least urgent thing the app shows, and matching
the message loop's cadence would multiply that traffic for a coloured
dot. The refresh says what it saw and says when it fails — a refresh
that fails silently looks exactly like a contact who never changes
status, which is the bug it exists to fix. The task is cancelled on sign
out, or a signed-out app goes on asking the relay who is online.

Verified against a live pair: Android to away at 09:30:45 read as Away at
09:30:52, back to online at 09:31:16 read as Online at 09:31:34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:32:05 -04:00
Paul HittandClaude Opus 4.8 d0b2591e85 Fix the post-quantum bug that made Android-to-iOS delivery impossible
An established v3 session refused every message a peer sent after the
first, and said nothing about it anywhere. The relay reported those
envelopes delivered; the app showed an empty conversation.

`ingest` decodes `pqdh`/`pqct` from the header and then passed them to
only ONE of its three decrypt attempts. The live session and the
archived session were both called without them, so both defaulted to
nil — and a v3 session correctly refuses a ratchet step carrying no
post-quantum material (§13.4). Only the handshake path passed them,
which is exactly why first contact always worked and everything after
it died. iOS to Android was unaffected, because the Android engine
reads the header properly; Android to iOS broke the moment the
conversation turned its first DH ratchet.

Three silent discards are now reported. Between them they swallowed
every failure in this file: an unopenable envelope looked identical to
one that was never sent, from either end, which is what made this take
so long to find.

  - the live chain's refusal, thrown away by `try?`
  - the AEAD failure, a bare `return true`
  - "we hold a session but nothing opens this", also a bare `return true`

The last of those now also REBUILDS. Advancing is right — a chain
mismatch cannot be retried — but leaving it there means every later
message from that contact is lost too, so the peer is asked to
re-establish. The repair drops the dead ratchet first: `encrypt` reuses
any session it finds and only runs X3DH when there is none, so
repairing while holding the broken chain sends the repair down the
broken chain.

Also here:

  - A cursor held for a missing session is now BOUNDED. It returned
    false for as long as the session was missing and the poll loop
    breaks on false, so one envelope whose prekey never arrived stopped
    delivery of every later message from every contact, for ever.
  - A rotated device is RETIRED rather than orphaned. The old row stayed
    registered, so senders went on addressing a mailbox this device no
    longer read, and the account advertised two identity keys — which is
    indistinguishable from a planted device, so contacts stopped sending
    and asked whether to accept new keys.
  - The repair guard is a cooldown, not a latch. As a boolean it was set
    on the first attempt and cleared only on success, so a repair that
    did NOT work — the case it exists for — disabled every later one.
  - Startup logs which device and identity the process is running as.
    Not knowing that is where most of this went sideways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:12:09 -04:00
Paul Hitt 55943eb6f0 Merge branch '1-wire-sealed-groups' into 'main'
Sealed groups reach the screens, and old groups can be sealed

Closes #1

See merge request paulhitt/flamenet-messenger-ios!43
2026-08-31 15:02:26 +00:00
Paul HittandClaude Opus 4.8 e90ba1b0b1 Sealed groups reach the screens, and old groups can be sealed
The relay half shipped and deployed. No screen called any of it, so production
stayed at four groups, four of them plaintext, and seven readable membership
rows. Sealing protected nobody.

Making a group now seals it. The relay is handed a blob it has no key for and
one opaque tag per member instead of a `gid -> user_id` row each.

Existing groups needed their own answer, because the migration is additive on
purpose: the relay keeps a plaintext roster until a client replaces it, so a
group made before sealing stays readable for ever unless something does it.
`upgradeGroupToSealed` keeps the gid — a new one would strand the thread, the
history, and everyone else's copy — reads the members out of the roster we
already hold rather than asking for them, and is owner-only. The owner gets a
button for it, worded to say what it does and does not buy: it protects the list
if the database is stolen or handed over, and does not hide the group from
whoever runs the server day to day.

`updateGroupMembers` now routes on whether the group is sealed, the way
`leaveGroup` already did. Without that the screens would have asked the relay to
edit a membership table it no longer has: the call returns fine and the member
is not there. One entry point, so no view has to know which kind it holds.

The startup refresh also asks for sealed groups. `refreshGroups` cannot return
them — the relay holds no rows to answer with — so they were invisible until
something else happened to refresh them, which was nothing.

Three tests. The fixture now deletes the plaintext rows when a group is sealed,
as the relay does in the same transaction; without that an upgrade test passes
while the readable roster it exists to remove is still sitting there.

Executed 338 tests, with 0 failures.

Closes #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 10:56:48 -04:00
Paul Hitt 145aa2f327 Merge branch '3-ci-honours-the-pin' into 'main'
CI could not fail a red test suite, and ignored the package pin

Closes #3

See merge request paulhitt/flamenet-messenger-ios!42
2026-08-31 14:45:12 +00:00
Paul HittandClaude Opus 4.8 78e8299be7 The blob-reveals-nothing test was a coin flip
The repaired gate's first act was to fail this MR on a real test failure, which
is the gate working. The test it caught is the one at fault.

`testTheSealedBlobRevealsNothing` sealed member ids 41 and 42 and then searched
the BASE64 text for "41" and "42". Two characters out of a 64-symbol alphabet:
about one chance in 4096 at any position, and across a blob this size that is
roughly a fourteen percent chance of a match that means nothing at all.
Measured, not guessed:

    ciphertext 228B -> base64 304 chars : 14.06% of runs contain '41' or '42'

So it passed locally twice and failed on CI, on a build that changed nothing
about sealing. Under the old gate it would have gone green either way, which is
why a one-in-seven test has been sitting here unnoticed.

Nine-digit ids and a real-sounding name make a chance match impossible rather
than merely unlikely. The search now also covers the decoded bytes, which is
where the property actually lives — the base64 could in principle hide a
plaintext run that the raw bytes reveal, and it was never checked.

Executed 8 tests, with 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 10:33:49 -04:00
Paul HittandClaude Opus 4.8 a7056e4910 CI could not fail a red test suite, and ignored the package pin
Two faults in one job, and the second is the serious one.

The pin. `.gitignore` excludes the whole `.xcodeproj`, and SwiftPM keeps
`Package.resolved` inside it, so the pin git can see lives in `Scripts/` and
only `Scripts/generate.sh` copies it into the generated workspace. This job ran
bare `xcodegen generate`, so CI resolved every dependency fresh against the
network while local builds stayed pinned — which is why it 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, and the pinned
commit is still fetchable, so honouring it needs no decision about the new one.

The gate. The job ended in:

    | grep -E "Executed|TEST (SUCCEEDED|FAILED)" || exit 1

A pipeline exits with the status of its last command. A failing run prints
"** TEST FAILED **", grep matched it and exited 0, and the job went green on a
red suite. Every pass this job has ever reported was unverified. It went red
today only because the build died before printing anything to match — failing
for the wrong reason, which is what made it look like a dependency problem
rather than a gate that never worked.

So the log is now the gate: "** TEST SUCCEEDED **" must be present, and a run of
at least one test with zero failures. `[1-9][0-9]*` because a suite that ran
nothing is not a pass — a tree that silently stops building tests otherwise
reports the same green as one that runs them all.

Checked against a wiped project, exactly as the runner's git clean leaves it:
the pin lands at 19aa8c1f and `Executed 335 tests, with 0 failures`. The gate
was exercised on all four shapes first — genuine pass, failing suite, empty
suite, dead build — and only the first passes.

Closes #3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 10:08:52 -04:00
Paul Hitt 39a0fdbdd8 Merge branch '2-membership-from-blob' into 'main'
The membership check asked the relay who was in a sealed group

Closes #2

See merge request paulhitt/flamenet-messenger-ios!41
2026-08-31 14:02:59 +00:00
Paul HittandClaude Opus 4.8 fc78c116bd The membership check asked the relay who was in a sealed group
Clients refuse group messages from non-members (§6.7). That check reads
`groups`, and `refreshGroups` assigned `groups` outright from GET /e2e/groups
— a route with no signature on it. So one registry refresh after
`refreshSealedGroups` replaced every decrypted roster with whatever the server
sent, and the gate then enforced the server's answer.

Encrypting the roster bought nothing while that line stood. A relay could name
anyone it liked as a member and have their messages accepted, which is the
attack sealing exists to close.

The negative control is the part worth keeping: with the fix reverted, the
relay's forged roster [2450, 2451, 2452] replaced the blob's [2450, 2451] and
Mallory's message landed in the thread. Both assertions are needed — checking
only the roster would pass with the gate broken.

It also broke against an HONEST relay, which holds no membership rows for a
sealed group at all: a routine refresh silently emptied the roster, and the
group vanished from the list. That is the second test.

Three changes. `refreshGroups` filters out any gid we hold secrets for and
keeps the decrypted entry. The receive path resolves those gids through the
sealed refresh instead of asking the relay. And a gid becoming sealed drops any
row the relay supplied earlier — the one ordering left where a server-supplied
roster could survive, since the sealed refresh only overwrites gids whose blob
actually opened.

One residual, documented rather than papered over: for a gid we hold no secrets
for there is nothing to check against and the relay stays authoritative. That is
the unsealed model, unchanged, and it cannot reach an existing sealed group —
for those we always hold secrets and take the other branch.

Closes #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 09:59:14 -04:00
Paul HittandClaude Opus 4.8 cdba5cf9bc Sealed group membership changes, and secrets that survive a reinstall
Adding and removing members rewrites the roster, signed by the owner key
we hold iff we created the group. A member who is not the owner has
nothing to sign with and is refused locally rather than sent to be
rejected.

🔴 Removal takes the TAG with it. The blob is what members read, but the
tag is what the relay enforces — a removed member whose tag survived could
still fetch every future roster. The test asks the relay directly with the
tag the removed client still holds, because that client has no way to know
it has been revoked.

Existing members keep their tags across a rewrite; only newcomers get
fresh ones. Reusing a tag for a new member would hand them a token
somebody else had held.

And the encrypted history export now carries the tags and keys. Without
them a restored device can see its old messages and can never rejoin the
groups they came from — the relay cannot say what you are a member of,
which is the whole point, so there is nobody to ask. Gaps only on import,
like the names.

⚠️ This makes the export the most sensitive thing this app writes: a group
key opens every roster it ever sealed. It is already encrypted under a
passphrase-derived key, which is what makes carrying it defensible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 09:02:09 -04:00
Paul HittandClaude Opus 4.8 5ddaca9285 Sealed group invitations, leaving, and the end-to-end tests
The invitation carries the tag and the group key over the pairwise
ratchet, riding `delivery_key` like the group name does. It is the only
way a member ever gets in: the relay holds neither, by construction, so
somebody who is not told cannot fetch the group at all — there is nobody
left to ask.

🔴 An invitation is accepted only for a group we hold no secrets for.
Without that, anyone able to reach us could overwrite the key for a group
we are really in, and every later roster would fail to open — a working
conversation turned into one that silently has no members. Negative-checked.

Leaving a sealed group surrenders the tag. There is no account row to
remove; the tag IS the membership.

The tests assert on what the relay was HANDED, not on what it chose to
keep — what it kept is the weaker question. No member id and no group name
appears in the create request. And the read-back goes through the relay
rather than the local list `create` appended, which would have passed with
the fetch, the tag lookup and the blob all broken.

The stub relay models a sealed group as a blob, a version, an owner key
and opaque tags, with no member list — a fixture holding one would be
modelling a relay we did not build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:46:23 -04:00
Paul HittandClaude Opus 4.8 79bbed5e22 Sealed group create and fetch, and put iOS 26 where it survives
The client half of §8.9b: create mints a gid, a group key, an owner
signing key and one random tag per member, seals the roster and hands the
relay a blob. No member id leaves the device. Fetch asks by TAG — never
"what groups am I in", because a sealed relay cannot answer that.

Reachability moved here from the relay, which used to ask "does this
account have a device" and needed to know who the members were to do it.
The client is the party that suffers an unreachable member anyway, since
fan-out is per device.

🔴 **`project.yml` now carries the iOS 26 deployment target.** It had been
raised in `project.pbxproj`, which is GENERATED and gitignored — so
post-quantum key agreement compiled on the machine that made the change
and reverted to an iOS 16 floor for anyone who ran `xcodegen generate`,
which is the documented way to add a file. I hit exactly that adding
SealedRoster.swift: the build failed on `MLKEM768` availability, pointing
at the crypto rather than at the project file that had quietly reset.

A blob that will not open is skipped, never treated as an empty group —
dropping every member from a conversation that still exists is worse than
showing it stale. Sealed and unsealed groups merge rather than replace, so
groups that have not been upgraded do not vanish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:32:09 -04:00
Paul HittandClaude Opus 4.8 0e67df93d1 Sealed roster primitives, and a vector proving the engines agree
§8.9b's client half: the group key, the member tag, and the blob itself.

The key is random and travels with the invitation rather than being
derived from the gid or the member list — a derived key changes when the
list does, and every older blob stops opening.

Tags are random for a sharper reason: anything derived from the account
is a pseudonym the relay can link across groups, which is the same social
graph wearing a hash.

🔴 The test that matters is the last one: a roster sealed by the KOTLIN
engine, opened here. Every other test in the file seals and opens with the
same code, which proves the two halves agree with each other and nothing
about whether this engine agrees with the other one — and every real group
has members on both platforms.

Only that direction is pinned, deliberately. Decoders do not care about
key order, and nothing re-encodes a roster to check a signature: the owner
signs the blob it was handed, so differing key orders cannot break
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:04:53 -04:00
Paul HittandClaude Opus 4.8 f2ef90e63c Test that we never encrypt to our own device
Group names are announced to our own ACCOUNT as well as to the members,
because fan-out excludes us everywhere else and a second phone we own
would otherwise be told by nobody. That is only safe because `transmit`
drops this device from every recipient list — without the filter,
addressing our own account opens a ratchet session with ourselves and
posts an envelope into our own inbox.

Nothing checked the filter. It does now, as an absence: no queued envelope
is addressed to this device. That is the only shape available with one
device in the fixture, and it is the shape that regresses — remove the
filter and the test fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 06:17:51 -04:00
Paul HittandClaude Opus 4.8 237a0f6460 Teach the live sign-up test about the age gate
The gate landed on 2026-08-29 and `canSubmit` has required a year of birth
ever since. The test was never taught to pick one, so it failed on "submit
stayed disabled" — and the live sign-up path, the one thing that proves a
new person can actually get in, went unverified from then until now. The
app was fine the whole time; the check was not.

Two things it got wrong on the way, both worth keeping in the comments:
the picker's options are not buttons in every OS version, so the year is
matched by label across element types; and the list runs 1900…this year in
reverse, about 127 entries, of which a menu renders only what is on screen
— a year thirty rows down is not in the accessibility tree at all, and the
failure reads as "the list never opened".

It now picks the youngest year the gate accepts, which is near the top of
the list and is also the boundary worth exercising.

Verified against production: passes, creating a real account through the
real UI and ending signed in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 22:20:12 -04:00
Paul HittandClaude Opus 4.8 9534e273df An unproven device may not name a group
The announcement has always been refused from an unproven sender (audit
C2). An ordinary group envelope carrying `gname` was not — so the weaker
of the two routes decided what a group is called.

On an unproven device `fromUser` is a CLAIM: the binding says that device
belongs to somebody else. The membership check then asks "is the person
they are pretending to be in this group", which answers yes for any member
the attacker cares to name — and a group heading in someone's chat list is
a good place to put a lie, because the reader has no reason to doubt it.

The test needed a seam onto `deliver`: a real sender cannot make its own
claim fail to match its binding, so the only way to exercise a refusal is
to hand the receive path the shape an attacker would produce. Removing the
gate fails it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 21:39:15 -04:00
Paul HittandClaude Opus 4.8 3dfa67ae30 Group names survive a reinstall, and reach our own other devices
Two gaps the group-name change left behind.

The encrypted history export carried the messages but not the names. That
was harmless while the relay answered with them — a reinstalled device got
them back for free. It holds none now, so this device's copy is the only
copy, and restoring a backup would have produced a chat list of groups all
called "Group": exactly the loss the export exists to prevent. Names ride
the snapshot (optional, for the decode-compatibility reason already noted
on `calls`), and only fill gaps on import — a backup is older than the
device by definition, so a rename made since must not be undone.

And fan-out excludes our own account, so a second phone we own was told by
nobody. `transmit` now drops our own device from every recipient list — a
no-op for anyone else, since their directory cannot contain our device id —
which makes it safe to address the announcement to our own account.

DemoRoundUITests no longer asserts the old group name: the relay deleted
it, so a fresh install has been told nothing, and the round is about the
group being reachable rather than what it is called.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 21:24:00 -04:00
Paul HittandClaude Opus 4.8 0f54109266 Let people rename a group
Names used to be fixed at creation and served by the relay for ever, so
no client ever needed this. The relay has now dropped them — and that
makes every group created before the change read as the placeholder on
every phone, with nobody left to ask what it used to be called. The relay
deleted its copy and no client had ever kept one.

Without a rename, those groups are called "Group" permanently. This is
the repair, and it is the first time a group's name has been changeable
at all.

Stored first, announced second: the name is ours the moment we set it, so
a member who cannot be reached now does not stop us seeing the group
under the name we chose. They pick it up from the next message, which
carries it. A blank rename is a no-op — the field is pre-filled, so an
accidental clear-and-save would otherwise leave a group unnamed with
nothing left to restore it from.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 20:50:56 -04:00
Paul HittandClaude Opus 4.8 60084d3633 Group names live on this device, not the relay
The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder every group shares. The real name
is held in the keychain and travels between members: on every group
envelope, and as an announcement riding the `delivery_key` control kind
at creation and on adding a member.

`delivery_key` rather than a new kind because a new one would arrive on
every phone running v5 as a bubble full of raw JSON — those builds read
§6.10's `ctl` flag with `optBoolean`, which does not coerce the number
the spec writes.

A name is adopted only from someone the roster says is in the group.
Without that check anyone who could reach you could rename any group in
your list by guessing a gid — an entry in a chat list that says whatever
a stranger wants, where the reader has no reason to doubt it.

`refreshGroups` substitutes the local name into `E2EGroup.name` rather
than teaching every view where names come from now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 20:26:28 -04:00
Paul HittandClaude Opus 4.8 a89778e8b9 Blocking somebody now cancels their sealed passes too
§21.4 gave a lever to cut off one contact and nothing pulled it — a
capability built and left unreachable, which is the failure this project
has been fixing all day elsewhere.

Blocking is where it belongs, and for a stronger reason than
convenience. The relay CANNOT enforce a block against a sealed sender:
the request carries no sender to compare against a block list, and the
send route's own comment says so. So blocking stopped the identified
path and nothing else, and a blocked person's sealed messages were
dropped by this app after decryption rather than refused at the door.

All four call sites go through one place now — the profile screen, the
chat menu, the unblock path and the `/block` command — because a fifth
one would otherwise forget.

The copy changes with it. "Buddies you block can't send you messages"
was not true, and a safety screen is the last place to overclaim. It now
says what blocking does and names the gap: the shared key everybody
holds is not that person's alone, so it takes replacing to close
completely, with the cost said in the same breath rather than on another
screen. The sealed-sender section no longer calls replacing the key "the
only way" to cut somebody off, which was true when written and is not
now.

307 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:38:45 -04:00
Paul HittandClaude Opus 4.8 4082a27aaf Issue, spend and revoke delivery capabilities on iOS (§21.4)
A recipient mints a batch of random tokens per contact, uploads only
their hashes, and hands the tokens over inside the ratchet; a sender
spends one per message; revoking one contact is dropping one batch.

The batch rides the existing `delivery_key` envelope rather than a new
control kind, because a receiver recognises control traffic by KIND
first and falls back to the `ctl` flag only for kinds it has never heard
of — and the Android build in the field reads that flag with
`optBoolean`, which does not coerce the number §6.10 calls for. A new
kind would have arrived on those phones as a bubble full of raw JSON.

Replenishment is counted rather than requested, for the same reason. The
issuer counts what it receives, because every message from a contact
spent one of their capabilities, and mints afresh at half a batch — the
count is a lower bound, so it runs behind on purpose.

A refused capability means the batch was revoked, so it is discarded and
the same envelopes re-sent on the shared key; they are addressed to the
device, not to the authorisation. Not a way around revocation: revoking
moves a sender back onto §14.4's key rather than silencing them, and the
code says so where somebody changing it would read it.

The receive branch no longer requires a valid key to bind before it will
look at the batch. The Kotlin engine had the same shape for a different
reason and it was a live bug there — a device that had not published a
key of its own dropped everything in the envelope. Two engines diverging
on which halves of an envelope survive is exactly the class of
disagreement neither one's own tests can see, so both now handle the
batch first and independently.

307 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:27:43 -04:00
Paul HittandClaude Opus 4.8 e903bd1e40 Deploy to iOS 26, and take the post-quantum gates out
Apple first ships ML-KEM and ML-DSA in iOS 26. The app deployed to iOS
16, so both sat behind `#available` — and an iPhone on 16 through 25 got
no post-quantum protection at all. The only other way to close that was
to bundle our own implementation of a lattice KEM, which is a serious
thing to take on for a messenger and worse than asking for a newer OS.

So the floor moves and the gates go. `PQKEM.isSupported`,
`PQSign.isAvailable` and `E2EProtocol.canAdvertiseV2` are gone rather
than left returning true: a constant that every call site still branches
on reads like a decision when it is not one. The v2 block and the
post-quantum identity are now attached unconditionally.

⚠️ The floor is load-bearing. Lowering `IPHONEOS_DEPLOYMENT_TARGET`
without restoring the gates would not fail to compile — `MLKEM768` would
just be missing at runtime on the devices that gained support, which is
a crash rather than a downgrade. Said so where the code is.

`pqMode` stays `optional`. Every iPhone running this build does
post-quantum key agreement, but a session is only post-quantum when both
ends are, and Android builds already installed register `proto: 1`.
Requiring v2 would make those contacts unreachable rather than
classically protected.

Three X3DH tests were skipping on the old gate. A skipped test is not a
passing one — on a simulator below the floor the hybrid handshake was
never exercised and the suite still reported green. They run now.

The `onChange(of:perform:)` calls went with it. Their comments named the
iOS 16 floor as the reason for the one-parameter form, so leaving them
would have left twelve files asserting something false about the
project. Migrated to the two-parameter form; no deprecation warnings
left from them.

307 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 18:04:13 -04:00
Paul HittandClaude Opus 4.8 672c580687 Refuse a second at-sign, and stop caching a credential
Two things found while writing the tests.

**A relay name could contain an `@`.** `decode` splits on the first one
with `maxSplits: 1` and then refuses `/` and `:` but not `@` — so
`fn1abc@a@b.example.com` parsed as a relay of `a@b.example.com`, a
"hostname" no host is called. That string is parsed from input nobody
authenticated: typed, pasted, scanned, and read out of a sealed envelope
written by a stranger on another relay. The Kotlin engine refused it and
this one did not, which is exactly the kind of disagreement neither
engine's own tests can see — it took writing the Kotlin suite to mirror
this one.

**Signing in at a second relay went through `URLSession.shared`.** Its
responses go through the shared on-disk URL cache, and that response
carries an app password — a credential written into `Cache.db` and
outliving every screen that could remove it. It uses an ephemeral
session now, with the same refusal to cache that `E2EClient` already
makes for ciphertext. That also gives the request an interception seam,
so the one call here that handles a credential is reachable from a test
at all.

New: 11 tests for adding and dropping a mailbox — including that a relay
refusing the sign-in or the record leaves nothing stored, that both
homes are told the same list and each record verifies, that the relay
being dropped is not asked to accept its own removal, and that a relay
serving a shortened or lengthened list is caught by arithmetic. Plus 4
for the control flag in both spellings.

307 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:54:42 -04:00
Paul HittandClaude Opus 4.8 531f523b59 Claim the address, read every mailbox, check every key
Four things this app could describe and not do, and two bugs found
proving it.

**The address was derived and never claimed.** Every account worked out
its `fnid` on sign-in, showed it on a card, and sealed it into federated
envelopes — and never registered it. A peer relay resolving `fnid@here`
asks this relay, and until the claim lands the answer is "no such
address", so 0 of 47 accounts were reachable at an address they could
all display. Claimed now on startup, detached and swallowed: a relay
that is down, too old for the route, or slow must not keep somebody out
of their own messages.

**The mailboxes screen existed and nothing opened it.** No way to read
your own address, no way to add a second mailbox. Linked from the
profile, and it falls back to the stored address — the account key only
exists in the session that stretched the passphrase, so a restored one
showed "sign in to see your address" to somebody who was signed in.

**Adding a mailbox was a stub that said so.** Now it signs in at the
new relay, registers this device, claims the same address, and publishes
the record at EVERY home rather than only the new one — a home that does
not know it is one still accepts mail, but a sender asking it gets a
stale list and skips the others. Removal publishes the shorter list
first and forgets the credential only if that worked; the reverse leaves
mail arriving where this device no longer reads. The registration
carries the same capability block as the home relay, because a sender
fetches a bundle from ONE mailbox and a second one advertising v1 would
make post-quantum protection depend on which it asked.

**Key transparency was a switch almost nobody found.** Checking is
automatic on any relay that serves a log; the switch survives as the
stricter question underneath. The head is fetched separately from
enforcement, so a relay cannot 404 the middle request to make a
substituted key look like a deployment without transparency, and a relay
that has answered before cannot stop.

Two bugs, both found by writing the tests rather than by reading:

A disappearing-messages timer never reached the other side. It went out
through `send`, which wraps a body in a TEXT envelope with a message id
so it can be replied to — so the control envelope arrived buried in a
caption, the ttl branch was never reached, and the timer was set on the
phone that set it and nowhere else. It goes through `transmit` now.

And `ctl` decoded as `Int?`, which the Android engine wrote as `true`.
That did not fail to set the flag: it failed the whole envelope's
decode, so an Android user turning on disappearing messages sent an
iPhone a bubble of raw JSON. Both spellings are accepted now.

State that belongs to one relay is keyed by relay: the head pin and the
"has this relay served a log" flag were both global, and a second relay
would have been reported as a fork on the strength of the first one's
root.

New: 6 tests for reading several mailboxes, 10 for automatic key-log
enforcement. Full suite green at 291.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:30:20 -04:00
Paul HittandClaude Opus 4.8 3fbbeb87a8 Two iOS accounts hold a real conversation, with no network
E2EInbox was at 16% — 2,546 lines, over half the E2E subsystem.
Everything beneath it is well covered and proven in isolation; this is the
first thing that proves the pieces were assembled correctly, which is what
the Android side calls "the test the whole stack exists to pass" — and on
Android that test only runs against a live fleet.

The relay here is a stub URLProtocol holding envelopes in a dictionary,
which is why E2EClient gained a `protocolClasses` parameter alongside
MessengerClient's. The session stays built inside the client because its
configuration carries a privacy decision — responses are never written to
the on-disk URL cache, since envelope traffic persisting in Cache.db after
a chat is deleted is what "deleted means deleted" forbids — so only
protocol classes are injectable. Everything below the transport is real:
real identities, real X3DH, a real ratchet, real sealed blobs.

Writing the stub surfaced its own lesson. My first version answered
from_user: 0 for every envelope, where a real relay records the
AUTHENTICATED sender for an unsealed send. Every incoming message was
then filed under thread 0 — and the history test still passed, because it
flattened every thread and only asked whether the text existed somewhere.
The stub now reads the Basic credential exactly as the relay does, and the
history assertion is keyed by sender so it cannot pass that way again.

The privacy claim is checked against what an operator would actually
hold: the stub keeps every payload it was handed, and none of them
contains the message text.

E2EInbox 16.3% -> 46.5%; iOS E2E subsystem 42.5% -> 60.5%. 265 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 12:12:58 -04:00
Paul HittandClaude Opus 4.8 8edb44d6f6 Test sealed sender
SealedSender.swift was at 0%, and it is the feature that decides what the
relay can see. Every property here is one an attacker would probe.

🔴 The seal is confidentiality only, never authentication. Anyone holding
the recipient's public IK_dh can produce a well-formed seal claiming any
sender, so the claim is worth nothing until the inner envelope opens.
That is asserted from the outside: a seal made by a stranger is still a
valid seal, and what stops it mattering is the ratchet and the pin, not
this file.

The AAD binding gets the test its comment asks for. The destination
device is authenticated, so a relay cannot replay one user's sealed blob
into another user's inbox — without it a seal is portable between
mailboxes and sealed sender becomes a delivery primitive for anyone who
can read one. Swapping the ephemeral key or touching a byte of ciphertext
is likewise refused rather than yielding different plaintext.

The privacy claim itself is checked directly: a seal carrying user
424242, device "secret-device-id" and address "whoami@peer.example" is
serialised to the wire and asserted to contain none of them, nor anything
of the inner envelope.

Two seals of the same message to the same recipient are asserted
unlinkable — a reused ephemeral would let a relay group a sender's
messages without ever learning who they are, which is most of what
sealing was for.

And isSealed is pinned in both directions, since the receive path
branches on it: a misclassification either skips the seal or tries to
open a plain envelope as one.

SealedSender 0% -> 83.1%; iOS E2E subsystem 41.3% -> 42.5%. 258 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 12:05:12 -04:00
Paul HittandClaude Opus 4.8 e7ec798557 Test the signed-prekey ring and the one-time pool
The ring exists because a single slot was not enough, and the failure it
caused is invisible. Rotation is weekly, so one slot holds last week's key
and the week before is simply gone — while the server keeps an
undelivered envelope for thirty days. A sender fetches a bundle, the
recipient is offline for a fortnight, and by the time the envelope is
polled the key that would open it has been overwritten twice. What the
user sees is an AEAD error on the first message, indistinguishable from
tampering, which nobody would read as a rotation problem.

So the property under test is the ring's depth in TIME, not its size: a
key retired three weeks ago still opens, retention is asserted to exceed
the server's own window, and seven weeks of ordinary weekly rotation are
all still openable at once.

The cap is tested for what it is — a backstop, not the bound. Forty
rotations in a loop grow the blob no further than sixteen entries, and
the NEWEST survive, because an old key is the one least likely to still
be named by a queued envelope. Retiring the same key twice does not stack
duplicates, which would fill the ring with one key and evict the rest.

For the one-time pool: consuming a prekey removes it and it cannot be
taken again, since a one-time prekey handed out twice is not one-time and
the forward secrecy it exists for is gone with no symptom. The private
halves are checked to survive the round trip, not just the ids — a pool
that keeps ids and loses key material fails only when somebody actually
opens a session.

And the post-quantum seed is asserted stable across reopens: it is
derived once at sign-in from a passphrase that is never kept, so a seed
that does not persist cannot be recomputed and the account silently loses
its PQ identity on relaunch.

KeychainE2EStore 23.7% -> 70.6%; iOS E2E subsystem 39.8% -> 41.3%.
225 -> 247 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 12:00:42 -04:00
Paul HittandClaude Opus 4.8 7ca2dccabc Test what the iOS client decodes off the relay
MessengerAPI was at 13%. Its Android counterpart shipped three separate
field-name bugs — the block list reading user_id where the relay sends
id, notifications reading kind/body/read which exist nowhere, and the
roster guessing the user_id shape — and every one failed SILENTLY,
producing an empty list indistinguishable from "nothing to show yet". The
iOS models happen to use the right names. Nothing was checking.

So each decode is pinned against the relay's real shape, and the
Android-era names are asserted NOT to decode, which is what makes the
pairing load-bearing rather than decorative.

Two behaviours are about refusing to guess. A malformed roster surfaces
as an error rather than an empty list, because "you have no contacts" is
a lie a user would act on. And an undecodable status response is an error
rather than an assumed "online" — claiming online because a response
would not parse is the same failure this route already had on the server,
reporting a state nobody stored.

MessengerClient gains a `protocolClasses` parameter, and only that. The
session is still built inside the client because its configuration
carries a privacy decision — urlCache = nil, so messages are never
written to Cache.db — and a caller passing its own session could drop
that without noticing. ⚠️ URLSessionConfiguration.default does not pick
up classes registered with URLProtocol.registerClass, so a custom session
cannot be intercepted globally; without this seam the file is unreachable
from a test.

MessengerAPI 13.2% -> 35.7%, MessengerClient 0% -> 63.3%;
iOS E2E-and-networking subsystem 34.0% -> 39.8%. 194 -> 225 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 11:56:30 -04:00
Paul HittandClaude Opus 4.8 4abc24a454 Prove wipe() actually empties the account
wipe() had no test, and it had recently gained five secrets it used to
miss. The one that mattered most is the delivery key: it authorises
submission to the inbox on its own, with no token, so a copy left behind
after account deletion is a working key to a mailbox its owner believes
is gone. The address pins were the subtlest — Keychain cannot enumerate
accounts by prefix, so `pinfn.*` items are reachable only through the
index kept for that purpose, and without it they survive every wipe,
invisibly, and are inherited by whoever signs in next.

The obvious test would write those five things, wipe, and assert they are
gone. It would pass forever while saying nothing about the sixth secret
someone adds next year — which is precisely how all five were missed.

So the load-bearing test is generic: populate one of everything, wipe,
then enumerate the Keychain for this account and the account's
UserDefaults persistent domain and assert both are empty. Deleting
`delivery_key` from wipe() makes it fail with
`these Keychain items survived wipe(): ["delivery_key.u909930"]`, which
is the message a future reader needs rather than a count mismatch.

⚠️ The suite's own keys come from `persistentDomain(forName:)`.
`dictionaryRepresentation()` composes the global domain in, so it reports
AppleLanguages and friends and can never be empty — my first version
asserted on it and failed for reasons that had nothing to do with the
code under test.

Round trips for the rest of the store alongside, so wipe is proven to be
removing something real: identity, device id, history key, peer delivery
keys, pins per peer and per address, and the protocol pin's ratchet
(it must never go backwards, or a relay that once proved v2 could
quietly serve v1 again).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:41:03 -04:00
Paul HittandClaude Opus 4.8 7e4cef1bed Test X3DH, the handshake that seeds every session
X3DH.swift was at 0% line coverage while DoubleRatchet sat at 72%. The
tests exercised the ratchet given a root key and never the step that
produces one. If the two sides derived different secrets, or the one-time
prekey contributed nothing to the KDF, or the associated data disagreed
by direction, every existing ratchet test would still have passed.

Eighteen tests, all of which run BOTH sides, because the property that
matters is not "does it return 32 bytes" but "do two parties, given only
what the wire carries, arrive at the same 32 bytes".

Covers the audit fix that had no test: the one-time prekey is the single
bundle field carrying no signature, so a relay can swap it freely, and a
low-order point yields an all-zero shared secret that CryptoKit does not
reject. The handshake then desynchronises and surfaces as messages to a
targeted contact silently vanishing — indistinguishable from that person
being offline. Every RFC 7748 small-order point is checked, along with
the other half nobody writes: that fifty genuine keys are NOT flagged,
since a false positive there makes a contact permanently unreachable.

The post-quantum tests skip rather than fail where ML-KEM is unavailable;
a v1-only device is a supported configuration. They ran here, so §12.5's
version separation is exercised: without the KDF info change, v1's
DH1..DH4 and v2's DH1..DH3||SS are both 128 bytes and would be
indistinguishable inputs.

X3DH.swift 0% -> 100%, KeyLog.swift 0% -> 98.9%.
E2E subsystem 30% -> 34.0%; the app-wide figure barely moves because
13,412 of its 29,806 lines are SwiftUI views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:20:54 -04:00
Paul HittandClaude Opus 4.8 2ee49a8af4 Test the client's key-log verifier against RFC 6962
KeyLog.swift was at 0% line coverage. It is the code that decides whether
a key the relay served is the key the log committed to — the whole
substance of the transparency claim — and nothing executed a line of it.

Its correctness was not hypothetical. The relay's own mirror verifier
read the audit path in the opposite order and was wrong for 15 of the 36
leaf positions in a tree of eight, while verifying happily at index 0 at
every size. This file is right where that one was wrong, and it was
right by inspection only.

Twenty tests. Expected roots are the published RFC 6962 vectors rather
than values recorded from this implementation, because a golden file
taken from the code under test proves only that it agrees with itself.
Every leaf position at every size from 1 to 8, substituted keys, tampered
paths, proofs replayed at another index, wrong-length proofs, consistency
across every prefix pair, rewritten history, shrinking logs, heads signed
by the wrong key, and heads lifted onto another tree size.

KeyLog verifies but cannot build a tree, deliberately — a client that can
construct a proof can be argued into believing one it made itself. So the
suite builds the tree, and the RFC roots are what anchor that builder to
the standard instead of to our own opinion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:16:21 -04:00
Paul HittandClaude Opus 4.8 0644201dd9 Deleting your account now actually deletes it from the phone
The delete sheet told people "all messages on this device will be removed."
They were not. `signOut()` cleared the credential and the app password and
stopped there, so a deleted account left `history-<uid>.bin` on disk WITH ITS
KEY still in the Keychain, alongside identity keys, ratchet state and pinned
identities. Anyone who later unlocked the phone could read conversations from
an account its owner believed was gone. `E2EHistoryStore` has documented all
along that deletion and sign-out must purge; nothing called it.

Sign-out and deletion now both erase, through one method on the type that owns
both halves so the ordering cannot be got wrong at the call site — history
first, then the store, because the wipe removes the key the history is found
by.

`wipe()` was also incomplete. It missed `delivery_key`, `peer_delivery_keys`,
`proto_pins`, `pq_identity_seed`, `pq_opk_pool`, and every `pinfn.<fnid>`
address pin. The delivery key is the one that matters: anyone holding it can
reach the inbox without authenticating, so leaving it behind left a working key
to a mailbox. Address pins had no index, and Keychain accounts cannot be
enumerated by prefix, so they were invisible to any wipe — there is an index
now, written as each pin is.

⚠️ And sign-out is genuinely destructive now, so it says so. Android has
carried that warning all along; the iOS button was bare, and making it erase
without a word would be destroying conversations at the moment somebody
expected to just step away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 07:54:45 -04:00
Paul HittandClaude Opus 4.8 e2098c4470 Show when a thread is with someone you never added
Same change as Android, same reasoning: adoption keeps a stranger's first
message visible, which is why it exists, but it also puts someone in your
contacts because they wrote to you. Adoption now sends `auto: true`, and the
thread says so with Keep as contact / Block in it.

Delivery is untouched. This is a label and a choice, not a gate — holding first
messages back is the design that already shipped here as a bug.

Accepting is optimistic and reverts on failure: `Buddy` is a value this screen
only holds a copy of, so the banner clears at the tap and comes back if the
relay never recorded it. A safety control that silently does nothing is the
worst outcome, which is the same rule blockBuddy already follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:47:37 -04:00
Paul HittandClaude Opus 4.8 44d2bb02a5 Ignore device-build too, not just ci-build
The signed device build writes derived data to `device-build`, which the
`build/` rule does not match — that matches a directory named exactly "build".
Same trap that staged 3,500 files of index cache once already, one spelling
along.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:08:42 -04:00
Paul HittandClaude Opus 4.8 ce1362f0e2 The status control was there and nobody could find it
Two problems, and the second is why the first mattered.

🔴 **It showed a guess.** `chosen` started at `.online` and nothing ever asked
the relay, so the control displayed a default rather than what contacts could
actually see — including after a relaunch, when "appear offline" had been chosen
and forgotten. It reads the stored value now. Omitting `status` on that route is
a READ: it only writes when the field is present and answers with the stored
value either way, so this needs no endpoint of its own and cannot accidentally
set anything.

⚠️ The read is guarded against its own `onChange`. Assigning `chosen` looks
identical to a user picking, so without the guard, reading the status back POSTs
it straight to the relay again on every appearance — a write per screen visit
that can only ever set what was already set.

**And it was unlabelled.** A small grey "Status" footnote over a bare segmented
control, in a screen full of rows. Now a real heading, which is the difference
between a feature existing and a feature being findable.

iOS unit suite 156/156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:22:05 -04:00
Paul HittandClaude Opus 4.8 78916b25c7 The secret sweep could not see a new file
`git ls-files` lists only TRACKED files. A brand-new one — which is exactly what
a dropped credential looks like — was invisible to this gate, while `git add -A`
would stage it happily. A planted `sk_live_…` in a new file passed.

It now scans `--cached --others --exclude-standard`: the same set `git add -A`
would stage.

🔴 **My earlier "verified it still fails on a planted key" was testing the wrong
thing.** That probe was `git add -N`'d first, which made it tracked, so it
exercised the path that already worked and reported confidence in the path that
did not. A verification that quietly changes the conditions is worse than none —
it is why this survived being checked.

Also allowlists the two VoIP test fixtures (`alert-token`, `voip-token`) by
literal value. A gate that is always red is a gate everybody learns to ignore,
which is the failure this one was one commit away from.

Verified in all four repositories: an untracked planted key now fails the gate,
and all four are clean without it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:12:12 -04:00
Paul HittandClaude Opus 4.8 c52c2dd6c7 Actually let you attach a live stream to a story
§6.13 shipped the wire format, the poster-plus-URL model, the spec and the
viewer's consent gate — and no way to enter a URL. The receiving half had
nothing to receive. Reported as done; it was not.

Adds the field to the composer on both platforms, with the disclosure said at
the moment of attaching rather than only at the moment of watching: the picture
becomes the cover, and anyone who taps play connects to that site, which sees
their IP address.

⚠️ Post is disabled for a URL that could not be disclosed. A destination with no
host cannot be named, and a viewer cannot consent to one they cannot be shown —
so it is refused at the point of writing rather than silently dropped later.

⚠️ `streamNote` is extracted rather than inlined: a conditional string inside an
already-large ViewBuilder defeated the SwiftUI type-checker outright, the same
way the disappearing picker did.

iOS unit suite 156/156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:10:21 -04:00
Paul HittandClaude Opus 4.8 826529846c CallKit and PushKit: ring a locked iPhone (§6.14)
`remote-notification` wakes a suspended app briefly to fetch, which is right for
a message and useless for a call — by the time a background fetch has polled,
decrypted and decided, the caller has given up. Ringing needs a VoIP push, so
`voip` joins UIBackgroundModes and PushKit is registered alongside the alert
registrar.

🔴 **A VoIP push is a promise, not a notification.** iOS requires the app to
report an incoming call to CallKit *before the push handler returns*, terminates
it if it does not, and revokes VoIP entirely from repeat offenders. So the call
is reported FIRST and identified afterwards: everything slow — the poll, the
decrypt, working out who it is — happens after the report, because iOS is
watching for the report and not for the reason.

⚠️ **Sealed sender means the name is unknown when the phone starts ringing.**
The push carries nothing; the relay holds ciphertext and could not name the
caller even if that were wanted. The first frame says "Flamenet call", and
`identify(caller:)` fills the name in once the offer has actually decrypted.
Showing an unverified name would be worse than showing none — it is exactly the
surface a spoofed caller would want.

⚠️ **Two tokens, one row.** The VoIP token comes from a different registry than
the alert token and either can arrive first, so each upload carries both and the
relay leaves an absent field alone. A registration carrying one half must not
blank the other, or ordinary messages silently stop waking the app.

🔴 `urgent` is derived from the message CONTENT rather than threaded through
every send path. A flag six callers must remember is one a caller forgets, and
the failure is silent: the call does not ring and nothing says why.

Also stops ringing on sign-out — CallKit holds a call the system believes is
live, and tearing the session down under it leaves the phone ringing for an
account that no longer exists.

iOS unit suite 156/156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:59:55 -04:00
Paul HittandClaude Opus 4.8 306faf26a2 Embedded live streams in stories, behind a disclosure (§6.13)
Every other media path in this app is a sealed blob — the relay stores
ciphertext and nobody else is involved. A live stream is not that: it is
continuous and hosted elsewhere, so there is nothing to seal, and watching one
means this device connects to that host, which learns the viewer's IP address
and that they watched.

🔴 **§6.12's answer does not transfer.** A link preview works because the sender
can fetch it once and put the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.

The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a story that started playing on its own would make that
disclosure after the fact, which is not a disclosure.

⚠️ The blob is the POSTER, not the stream. Keeping a real attachment means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.

⚠️ The host comes from the URL, never the caption — a story captioned
"youtube.com" pointing somewhere else is the phishing shape link previews
already guard against. Non-http(s) URLs and URLs with no host are refused: a
`file:` URL arriving from another person is not something to hand a media
player, and a destination nobody can name is one nobody can consent to.

iOS unit suite 151/151.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:10:44 -04:00
Paul HittandClaude Opus 4.8 eb652befae Link previews the recipient never fetches (§6.12)
🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away the thing it was
protecting.

So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.

⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address, before the message goes. That is what the
setting is for, and the copy says exactly who learns what rather than implying
the whole thing is free.

🔴 **The host is shown separately and derived from the URL.** Title and
description come from a page the sender did not write, so a card claiming one
destination while the link points at another is a phishing primitive. The host
is the part that cannot lie, and there is a test asserting it comes from the URL
rather than the page.

⚠️ Both fields are bounded on the way IN, not only at render, so the cap does
not depend on every future renderer remembering one. Parsed with a bounded
regex over the first 64 KiB rather than a real HTML parser: a full parser for
two attributes is a dependency and an attack surface, and giving up is the
correct failure for a decoration.

⚠️ Fetching is time-boxed and best-effort. A preview must never be able to delay
or fail a send — every unusual case ends as a message with a plain URL in it,
which is a perfectly good thing to be.

iOS unit suite 143/143.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:39:17 -04:00
Paul HittandClaude Opus 4.8 b2789edf51 Scheduled send, held on this phone rather than at the relay
The relay could hold a scheduled message and deliver it on time even with the
phone off. It would then know that a message exists, who it is for, and exactly
when it was meant to land — a richer piece of metadata than anything else this
design gives it, for a convenience. So it waits here, and the UI states the cost
rather than hiding it: "when the app next runs".

⚠️ The banner deliberately does not promise a delivery time. A phone that is off
at the appointed minute has not sent anything, and a line implying otherwise is
exactly the one people would rely on and be let down by.

Rides the existing outbox, so a scheduled message inherits the retry behaviour
instead of getting a second, less-tested delivery path. The flush treats "not
due yet" as keep, never as failed — that loop is also the retry queue, and
conflating the two would drop the message entirely.

⚠️ `notBefore` is optional, so an entry queued by a build that predates
scheduling still sends immediately rather than being held for a date it has no
value for.

iOS unit suite 134/134.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:28:36 -04:00
Paul HittandClaude Opus 4.8 98109c13c9 The triage pair on iOS: snooze, and "remind me if no reply"
Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on. Both local to this device on purpose: "I am ignoring this
person until Tuesday" and "remind me if they have not answered" are statements
about a relationship, and the relay has no business holding either. Neither
follows you to a new phone, which is the better half of that trade.

🔴 **A reply cancels its own reminder, on the arrival path.** Done where every
inbound message lands rather than on a screen, so no view has to remember. A
nudge that still fired after they answered is worse than none — it teaches
people to ignore reminders.

Reminders fire from the poll cycle, deliberately AFTER ingest, so a reply that
just arrived has already cancelled its own. Cleared as it fires: a reminder is
one-shot, and leaving it set would re-notify on every cycle for ever.

⚠️ Menus of buttons, not Pickers. A picker needs the current state to be one of
its options and neither of these has that property — a snooze with nine minutes
left is not "1 hour", and labelling it so would be a worse lie than showing
nothing. The live state is a banner on the thread, which can say what is true.

⚠️ The reminder notification honours the preview setting (who you are waiting on
is as sensitive as what they said) but NOT the per-buddy mute: muting silences
what *they* send, while this is a reminder the user asked for about their own
message. Swallowing it would make one control quietly disable another.

iOS unit suite 129/129.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:24:12 -04:00
Paul HittandClaude Opus 4.8 5479eaef3c Full-quality photos, and a Remove button you can actually see
**Full quality media.** iOS always downscaled to 2048px at JPEG 0.8 with no way
to ask for better — Signal's most-voted open request, and the same limit here.
Now a setting, bounded honestly: the relay refuses a blob over its cap, so
"full quality" means *as good as will fit* rather than "whatever the camera
produced", and `ImagePrep` steps down through size and quality until the bytes
are under budget. A photo that failed to send would be worse than a slightly
softer one, so no path here can produce one the server will reject.

⚠️ The budget is deliberately under the server's cap: the blob is encrypted
before upload and the AEAD tag rides on top of these bytes, so sizing to the
exact limit produces a file that passes locally and is refused remotely.

🔴 The chat composer carried its OWN copy of the downscale-and-encode routine,
identical to `ImagePrep` and one edit from disagreeing with it. Only one of the
two would have honoured the new setting — and the one people use most for
photos is the copy. Deleted; there is one now.

**Devices.** Removing a device was reachable ONLY by swiping the row, with full
swipe disabled. This screen is the single remedy for the three-device cap — its
own comment says it exists so "the limit is 3" is not a support ticket — and a
control you have to already know about is not a remedy. There is a visible
Remove button now; the swipe still works for people who expect it.

Verified the relay side rather than assuming: a device revoking ANOTHER device
on the same account, which is what this screen actually does, had no coverage
at all. The harness only ever tested a device revoking itself. Added, passing.

⚠️ The first ImagePrep test helper killed the test runner outright — four
million Core Graphics fill calls per image. It builds a raw pixel buffer now.

iOS unit suite 119/119.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:07:05 -04:00
Paul HittandClaude Opus 4.8 1113270c2b Say "not available in your country" instead of "Server returned HTTP 451"
The edge geo-gate answers 451 for a country the service is not offered in,
before the request reaches the relay. Nothing here knew what 451 meant, so it
fell through to the generic branch and showed the number.

⚠️ **The download site is deliberately not gated**, which makes this worse than
a bad error string: someone can read the page, install the app, choose a
password, tap sign up, and be told "Server returned HTTP 451". Nothing in that
mentions their country, it is not a failure they can retry past, and the
reasonable reading is that the app is broken.

The message also says what it is NOT — not the account, not the connection —
because someone who has just typed a password will otherwise assume they got it
wrong and try again.

iOS unit suite 113/113.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:40:06 -04:00
Paul HittandClaude Opus 4.8 889b81c76d Disappearing messages, symmetric and from send time (§6.11)
No client could set a per-conversation timer, and for E2E the relay's own
lifetime field would not have helped: it expires rows in the legacy plaintext
table, not the envelopes these apps exchange. So the timer is client-side,
which is also the only place it can be honest.

Symmetric: either party sets it, the other is told inside the ratchet and
adopts it. A timer only the setter honoured would be worse than none, because
they would believe their words were gone from a phone still holding them. The
relay never learns a conversation is timed, let alone for how long.

⚠️ **The timer is parsed BEFORE the generic `ctl` swallow.** This is a control
kind the build handles, and falling into the bucket for kinds it does not
handle would consume it perfectly correctly while acting on nothing — a silent
no-op with no error anywhere. There is a test pinning that ordering.

Expiry runs at launch and on every poll cycle, not only when a thread is
opened: a message still in the store because nobody looked at it is a
disappearing message that did not disappear.

⚠️ Ages come from the date this device recorded — never a clock the peer
supplies. A sender who chose the timestamp could backdate a message so it
vanished from the recipient's phone immediately, or forward-date one so it
never expired at all.

The picker is two taps in the overflow menu and the thread carries a banner
whenever a timer is on. "Buried" was the complaint, and a timer nobody can see
is one nobody trusts — the banner says "on both phones", because that is a fact
about the conversation rather than a preference on this device.

⚠️ `DisappearingPicker` is its own view because inline the SwiftUI type-checker
gave up — a Picker with a computed Binding inside an already-large ViewBuilder.
Extracting it is the fix; shrinking the menu would have moved the problem.

iOS unit suite 110/110.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:31:13 -04:00
Paul HittandClaude Opus 4.8 8da5e7259a Consume control kinds this build has never heard of (§6.10)
Control envelopes are matched by name here — `delivery_key`, `kt_head`,
`avatar` — and a control kind from a newer peer matches none of them. It fell
through to the attachment path and surfaced as an "[unsupported attachment]"
bubble: a message the user can neither read nor act on, for something addressed
to a layer rather than a person. Every control kind added from here would have
shipped that to every older install.

Envelopes carrying `ctl: 1` are now consumed whatever their kind.

⚠️ Unknown *content* is still shown with its placeholder, unchanged. The two
rules pull against each other and the receiver cannot guess which applies, so
the sender's flag decides — dropping a real message silently is the one failure
a messenger cannot make.

iOS unit suite 103/103.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:07:36 -04:00
Paul HittandClaude Opus 4.8 b4c3d9a20e Give iOS a way to set your status at all
There was none. Presence was reachable only through the chat slash-commands
(`/away`, `/invisible`) — which you have to already know exist — while Android
has offered a row of chips for months. The two apps disagreed about whether this
was a feature.

Adds a picker to Profile, next to your identity, since presence is a claim about
you. Four states matching the relay's vocabulary exactly, each with a line
saying what it does to other people: "Appear offline" rather than "Invisible",
because what is being chosen is what contacts see, not the name of a flag.

`busy` is new here in every sense — it did not exist on this platform. Adding it
to `Presence` broke two exhaustive switches, which is the compiler doing its job:
the presence dot needed a colour (red, distinct from away's amber — two shades
of the same hue read as one state), and the buddy sort needed a rank. Busy sorts
with the reachable states above away, because someone marked busy is present and
reading; sorting them down beside offline would say the opposite.

Also removes `?? "online"` from `setStatus`. Claiming online because a response
would not decode is the same failure the server side of this route already had —
reporting a state nobody stored. It throws now.

⚠️ A custom status *message* is still deliberately unimplemented. The relay
does not store one on purpose: it would be a server-readable line about your
whereabouts attached to your account, which is the metadata this project keeps
removing. It stays out until it can be encrypted to the buddy list.

PresenceTests pins the vocabulary to the relay's — a cross-repository contract
no compiler checks, and the mismatch was the whole bug.

iOS unit suite 100/100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:00:20 -04:00
Paul HittandClaude Opus 4.8 ad94e09ccc Never send in the clear: remove the plaintext fallback
`startE2E` returns early when `E2EInbox.make` cannot produce a signed prekey,
leaving `e2eInbox == nil`. `ChatScreen` then built the legacy transport, which
POSTs the message body IN THE CLEAR to `flamenet/v1/messenger/messages`, where
the relay seals it with its own key. The server can read those messages. They
are not end-to-end encrypted in any sense.

Nothing told the user. There is no encryption indicator on the chat screen, so
a signed-in person could not tell the two apart — the app silently stopped being
the one thing it promises to be. This is the iMessage green-bubble failure, and
encryption is the product, so the fallback is gone rather than labelled.

Removed the whole path, not just the branch: the legacy `ChatViewModel` init,
its `api` send and poll, `MessengerAPI.send`/`poll`, and the `SendResponse` /
`PollResponse` / `MessagePayload` types that decoded it. A deprecated plaintext
send is one autocomplete away from being a live one. The route still exists on
the relay for other callers; this app no longer has a way to reach it.

⚠️ **A refusal has to be visible or it is just a different silence.** With no
model to build, `ChatScreen` fell through to a bare `ProgressView` — a spinner
that never resolves, which is the same failure wearing a nicer coat. It now
shows what happened, states plainly that nothing was sent unencrypted, and
offers a retry: `retryEncryptionSetup()` re-runs setup from stored credentials,
because the failure is local and often transient (CryptoKit under memory
pressure, a Keychain not yet unlocked after cold boot).

This is safe to remove because the fallback was never for old relays. `make`
returns nil on exactly one condition — local signed-prekey generation failing —
and `Phase` has no plaintext mode. Device registration and delivery-key
publication are already best-effort and do not gate it.

Also adds SystemImageTests. `Image(systemName:)` renders NOTHING for a name
that does not exist — no warning, no placeholder — and the screens most likely
to name an unusual symbol are error states, which nobody sees until something
has already gone wrong. The new refusal screen is exactly that kind of screen.

iOS unit suite 96/96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 10:24:55 -04:00
Paul HittandClaude Opus 4.8 218c0fb206 Remove the WordPress-era transport primitives, and the prose they left behind
`postForm` and `getHTML` were the `admin-ajax.php` and page-scrape primitives.
Nothing has called them since the app was decoupled from WordPress on
2026-08-20; `formEncode` and `escape` existed only to serve `postForm` and go
with them. Their doc comments still asserted "Used by every admin-ajax.php
action", which is the worst state for dead code to be in — it reads as load
bearing.

The file header was worse. The wp- purge did a find/replace over it and left
prose that had stopped parsing as English: "a single the relay site", "the
relay core's `determine_current_user` filter", "No `a session cookie` cookie
needed". This is a public repository, and that paragraph is the first thing
someone reads about how the app authenticates. Rewritten to describe what
actually happens, with a note saying what it used to say and why — the history
is the useful part.

iOS unit suite 95/95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 10:06:28 -04:00
Paul HittandClaude Opus 4.8 827df1f8c8 Actually send the key-log head we gossip about, and drop a dead twin
`keyLogHeadPayload` built the §19.4 gossip envelope and nothing called it. The
receiving half was fully wired — a peer's head is compared against our pin, a
consistency proof is demanded, `forkDetected` is set — so transparency
reported working while the mechanism was inert.

That is the failure that looks like success. §19.4 catches a forked relay only
when two clients COMPARE heads; a client that never offers one is a client no
fork can be detected against, and each side goes on confirming the relay is
consistent *with itself* — precisely what a relay serving two self-consistent
branches wants. Only the JS reference sent it, so gossip worked web→app and in
no other direction. Android had the identical dead function and is fixed in the
same pass.

The send path carried a single optional prelude; it now carries a list, so the
delivery key and the head can both ride the first message to a contact. Each
prelude marks only its own bookkeeping — conflating them would mark the
delivery key as delivered on a send that only gossiped. Once per contact per
session, tracked in memory to match the JS reference: a device that pins a new
head after a restart has to be able to mention it.

Also removes `clearAllThreads`. Its own comment claimed "Used by Data & Storage
→ Clear chat cache", and that was false — the button calls `clearAllCaches`,
which is a strict superset (threads, remote threads, group threads, stories,
attachments, the sealed history file, call log, outbox, notifications). Two
functions both presenting as the clear-cache implementation, one wired, is how
a later fix lands in the half nothing runs.

iOS unit suite 95/95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 10:01:26 -04:00
Paul HittandClaude Opus 4.8 6ad5dda748 Give iOS the sealed-sender remedy Android already had
`rotateDeliveryKey` has existed in E2EInbox since sealed sender shipped, with a
doc comment calling it "the only way to cut off a sealed spammer" — and nothing
called it. Android wired the same function into its settings screen; iOS left
it unreachable, so the one lever §14.4 provides against sealed abuse was, on
this platform, not a lever at all. There is no sender named in a sealed
envelope, so blocking is not available as a fallback.

Adds the control to Privacy & Security next to key transparency, mirroring
Android's placement and its wording, behind a confirmation because the cost is
real and irreversible: every contact loses the ability to reach you sealed
until you next message them. The footer says that in the UI rather than
burying it.

Reports the publish outcome honestly. `rotateDeliveryKey` discards the old key
locally and then tries to publish the new digest; a failed publish is retried
next launch. So a rotation whose publish did not land still cut the spammer
off, and the app says the key will publish on the next connection and that
messages go out unsealed until it does — rather than claiming it is done.
`deliveryKeyPublished` is exposed read-only for that, since the store is
private.

Also ignores `ci-build/`. The test command this repo documents passes
`-derivedDataPath ci-build`, and the existing `build/` rule does not match it
— 3,500 files of Xcode index and compilation cache staged themselves on the
first commit after running it. Drops the `server/`, `android/` and `engine/`
rules, which have pointed at subtrees that do not exist since the split.

iOS unit suite 95/95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:49:49 -04:00
Paul HittandClaude Opus 4.8 7037213126 Fix a README that documented directories this repo no longer has
Everything from "The three engines" down still described the pre-split layout:
it pointed at `engine/src/`, `android/engine/` and `server/`, and told a reader
to run `cd server && swift build`, `cd ../engine && npm test`. None of those
paths have existed since 2026-08-28. The build instructions on the front page
of a public repository could not be followed.

Also wrong, and worse: "What is published, and what is not" claimed this app is
not published and is mirrored out by an allowlist. All four repositories are
public — verified against the API, not assumed — so that section described a
privacy property the project does not have. It now says so plainly, and states
the constraint that follows: no keystores, no passphrases, ever, history
included.

Smaller corrections: the XcodeGen link pointed at a user who does not host it;
the Kotlin engine is a complete engine now, not "safety numbers and key
transparency only"; and `server/DEPLOY.md` is `docs/HOSTING.md` in its own repo.

Adds CONTRIBUTING, and the secret-sweep gate the other three repositories have
and this one did not — with the three UITest password fixtures allowlisted by
literal value. Named individually and not by directory: excluding UITests/
wholesale is the easy fix and makes the sweep blind to a real credential
sitting there. Verified it still fails on a planted key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:45:14 -04:00
Paul HittandClaude Opus 4.8 dc8d0d5f86 Avatars merge instead of replacing on refresh
loadAvatars assigned a freshly built dictionary, so a refresh made while
the roster was momentarily empty — a fetch in flight, or the contact list
loading from the sealed store — wiped every picture from the UI at once
while the refs sat safely on disk. Absent from the store means 'not
loaded yet', never 'deleted'; only setAvatar(nil) removes one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:34:08 -04:00
Paul HittandClaude Opus 4.8 e481831368 Audit LOW: an empty offer is not agreement; validate ids; refuse degenerate prekeys
allSatisfy on an empty array is vacuously true, so a directory answering
with NO keys for a pinned contact read as 'unchanged' — the strongest
verdict — when it had said nothing. It reports unknown now.

The attachment id arrives from a peer and is interpolated into a URL
path; the relay refuses anything but 64 hex and the client did not.

And the one-time prekey is the only bundle field carrying no signature:
an all-zero or small-order X25519 point yields an all-zero shared secret
that CryptoKit accepts, desynchronising the handshake into a silent AEAD
failure — messages to a targeted contact vanishing, indistinguishable
from them being offline. Refused explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:13:23 -04:00
Paul HittandClaude Opus 4.8 ede9eea792 Audit H3: the safety number covers the post-quantum identity too
The 60 digits are the one check that does not trust the relay, and they
covered only the Ed25519 key. An adversary able to forge Ed25519 — the
exact adversary the ML-DSA identity was added to stop — could keep the
real signing key so the number still matched, while substituting the PQ
identity at first contact. The PQ authentication was defeated invisibly.

Both keys are now folded under their own label when BOTH sides publish a
PQ identity; a classical pair keeps the number they have already
compared. The profile says which guarantee the digits cover, because a
PQ number and a classical one must not look alike. The exact value is
pinned against an independent implementation of the rule, so iOS and
Android cannot drift into showing two people different numbers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:02:04 -04:00
Paul HittandClaude Opus 4.8 fdd5eff256 Audit M1: verify the signed log head against a pinned issuer key
The relay signs every head, and nobody checked it — so a fork caught by
gossip was one party's word rather than a statement bound to the relay's
own key. The verifier already existed and was never called, and no
client held a key to call it with.

The issuer key is pinned on first sight, the way identity keys beside it
are: taking it fresh from the same relay each time would let that relay
serve whichever key matches what it just signed. A head whose signature
fails under the pin is a fork. A relay that publishes no key is treated
as weaker, not hostile — refusing it outright would be a worse failure
than the one being prevented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:57:36 -04:00
Paul HittandClaude Opus 4.8 222cea9ffe Audit M5: an attachment may not exceed the size it promised
The envelope carries the blob's size and it arrived through the ratchet,
so the relay cannot inflate it — which makes it usable as a ceiling. The
download read whatever the relay sent and only checked the digest once
the whole thing was resident, so a malicious relay answered a photo
request with a multi-gigabyte body and killed the app before the check
it would have failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:44:24 -04:00
Paul HittandClaude Opus 4.8 498dbf30bc Audit H1+H2: group senders must be members; key-transparency actually runs
H1: a group message was filed after checking only that WE were in the
group — so anyone we share a session with could post into it, and a
member we removed kept posting, their 1:1 session outliving membership.
The sender must be on the roster too.

H2: enforceKeyLog had zero call sites — the transparency log constrained
nothing on iOS, because nobody ever asked it for a proof. It now runs on
the bundle-fetch path behind a persisted Privacy toggle, with the fork
short-circuit Android already had, and the setting explains its own cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:35:31 -04:00
Paul HittandClaude Opus 4.8 da24d6a054 Audit C2: bind a device to its user, and check the claim against it
The ratchet proves which DEVICE wrote a message; the user id inside a
sealed envelope is a claim its author chose, and nothing bound the two.
A contact could encrypt on their own session and stamp any other user's
id on it — the message filed and named as that person, and worse, their
delivery key overwritten, which forces the next send UNSEALED and hands
the relay the sender sealing exists to hide.

Devices now bind first-use-wins (the pattern already used for federated
addresses), the claim is checked against the binding on every decrypt,
and control traffic — delivery key, avatar — is refused from a sender
whose claim did not match. Accepting an identity change re-binds, so a
genuine reinstall is never mistaken for an impersonator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:32:48 -04:00
Paul HittandClaude Opus 4.8 aa40b62996 Delete individual messages, in 1:1 and group chats
Long-press a message for Delete for me (Unsend on your own) — it removes
the message from this device's sealed history and persists the removal.
Group chats gain the context menu they lacked entirely. Leaving a group
already existed and stays. Delete for me is honest about its scope: the
other person's copy is theirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:07:19 -04:00
Paul HittandClaude Opus 4.8 0577295f7f S4: transparency that acts — schedule the check, name the change
The poll loop now runs a throttled key-log consistency sweep (one head
an hour, best-effort), so a relay that forks or rewinds its log between
bundle fetches is caught on an established conversation instead of never.
And the changed-keys banner names what happened — 'A new device key was
added on <date>' — read from the log, which already knew. The formatter
is pure and unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:01:32 -04:00
Paul HittandClaude Opus 4.8 fadfb0431c T4: the unit suite runs before main moves
A project runner on the development Mac, tag mac-local, concurrency one.
UI tests stay manual — they need a relay and simulators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 07:36:07 -04:00
Paul HittandClaude Opus 4.8 74e0214e4d P1: the contact list can live only on your devices
The roster mirrors into the sealed history snapshot (riding the D4
export), and Privacy & Security gains the one-way door: 'Take my
contact list off the server' deletes the relay's copy, after which adds
and removes are local-only. The costs are stated where the button is —
presence goes dark, and a new device restores contacts from your
encrypted backup rather than the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 06:33:13 -04:00
Paul HittandClaude Opus 4.8 86f37b4154 Badges, video stories, and parental controls
Notifications: the icon badge reflects unread on backgrounding and
clears on return — without APNs that is the honest ceiling; the push
path updates it live once the relay has a key.

Video stories: the picker takes movies, AVAssetExportSession brings them
to 960x540 mp4 under the attachment cap, capped at 30 seconds, and the
viewer plays them for their own length. The wire's dur field now travels
for any attachment that has one, not just voice notes.

Parental controls: a parent's PIN guards adding contacts, the stories
surface, and calls — enforced at the manager and the tab bar, not just
the buttons; incoming calls answer busy. An age gate at sign-up checks a
birth year on-device and forgets it. None of it touches the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 21:36:42 -04:00
Paul HittandClaude Opus 4.8 c9969bb160 A call that cannot connect says so within 30 seconds
Answered, but ICE never found a path — no TURN, hostile NAT — used to
leave both screens in Connecting until someone force-quit. The connect
timeout mirrors the dial timeout: give up out loud, tell the peer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 21:11:53 -04:00
Paul HittandClaude Opus 4.8 061f2b6d63 D4: history the user can carry — encrypted export and import
The relay keeps no history, so a new device starts empty; this is the
designed remedy rather than a workaround. The snapshot the store already
seals per-device is re-sealed under a passphrase-derived key (PBKDF2
600k rounds, AES-256-GCM) into a file the user parks wherever they like.
Import merges — dedupe by content identity, never row id, so the same
backup twice adds nothing and nothing already on the device is lost.
Merge logic is pure and unit-tested; the passphrase is stored nowhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 20:29:14 -04:00
Paul HittandClaude Opus 4.8 0d698c0d25 A simulator call must not abort the app
The Simulator's audio stack times out bringing up the voice-processing
unit and AudioToolbox answers with abort() — so a call, either direction,
killed the app. On simulator builds WebRTC now runs manual audio that is
never enabled: calls connect silently there. Devices are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 18:44:49 -04:00
Paul HittandClaude Opus 4.8 4870d87c65 A proxy's 404 is not the relay's answer: un-latch token minting on 401
The authenticator concluded 'this deployment has no token endpoint' from
a single 404 — but a reverse proxy answers 404 for every route while the
relay behind it is down, so an outage latched the client into sending no
Authorization header at all, forever. invalidate() now clears that
conclusion too: the 401 that triggers it is proof tokens are required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 18:41:56 -04:00
Paul HittandClaude Fable 5 bebaf6086f A revoked device wipes itself
The poll's 410 (device_revoked) stops polling, purges the sealed
history file, wipes the keychain store — the device id is dead
server-side, so the next sign-in registers a fresh device — and signs
out with a sentence that says what happened. Without this, a device
removed from the Devices screen sat on stale ciphertext, polling a
mailbox that no longer exists, and its 401s eventually told the user
to "sign in again" as though nothing had been decided about it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:52:06 -04:00
Paul HittandClaude Fable 5 a213126a2a Re-tell the delivery key when a told peer writes unsealed
An UNSEALED envelope from a contact we have told our delivery key means
their copy is gone — a reinstall wiped it. Sealing to us requires that
key, and a peer who holds it seals everything; until they get it back,
the relay sees their sender on every message they write us. The ingest
path now un-marks such a peer (the sender on an unsealed row is
relay-vouched, so acting before decrypt is safe) and the next send
re-tells the key. Same staleness class, and same cure, as the avatar
marker. Worst case: one redundant control envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:44:04 -04:00
Paul HittandClaude Fable 5 7792ddc050 429s say when to come back
MessengerError grows rateLimited(retryAfterSeconds:) — the E2E client
reads the relay's new Retry-After on 429 and the copy becomes "Too many
attempts. Try again in about N minutes." instead of "Server returned
HTTP 429." A refusal with a clock is the difference between a person
who waits and one who hammers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:37:31 -04:00
Paul HittandClaude Fable 5 e87e7b5e88 Registered devices screen — list, date, and remove them
Account → Registered devices: every device on the relay's roster for
this account, oldest first (the forgotten reinstall is the one worth
removing), each dated from the directory's new created_at and the
current install badged. Swipe removes any device except the one in
hand — that is Sign Out, which also wipes local keys — behind a
confirmation that says what deletion means: undelivered mail gone, no
undo. The relay caps accounts at three devices, so this screen is the
only way off the cap when registration refuses a fourth.

E2EClient grows revokeDevice (DELETE /e2e/devices/{id}); the device
wire row decodes created_at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:05:09 -04:00
Paul HittandClaude Fable 5 d20604a922 Rows observe the inbox, and chats open at the newest message
- BuddyRow held the inbox as a plain let: SwiftUI compared the row's
  inputs — same Buddy value, same object REFERENCE — and skipped the
  body, so the Messages list re-rendered around rows memoized stale. A
  new message updated nothing until navigating away and back rebuilt
  them. (The avatar always updated live because AvatarView observes the
  inbox itself — that asymmetry is the tell.) @ObservedObject now.
- ChatView, GroupChatView and RemoteChatScreen scroll to the last
  message on APPEAR, not only on arrival — onChange fires when the
  count changes, so a thread with history opened at the top with the
  newest message below the fold. The first jump is unanimated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:23:49 -04:00
Paul HittandClaude Fable 5 b69b900c38 Unread that means unread, and a per-story audience in the composer
- The Messages list badges E2E arrivals now (inbox.unreadPeers): the
  server's unread count only knows the legacy plaintext table, so
  encrypted messages never marked anything new. An open chat clears its
  peer immediately, and again as messages land while it is on screen.
  Session-local by design — a relaunch starts clean.
- StoryComposerSheet grows a visibility picker under the caption:
  Everyone / Only… / All except…, with a member checklist for the two
  narrow modes. Seeded from the account-level audience and never written
  back to it — one photo for close friends must not silently change who
  sees everything after it. Both entry points (strip, Stories tab) pass
  the chosen audience to the post.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:13:58 -04:00
Paul HittandClaude Fable 5 066b6dd27b Parity + display round: story ids, group names, live Messages tab, add/scan everywhere
- Seed localIDCursor past restored STORY ids too, and heal persisted
  collisions — a story received after relaunch could reuse a restored
  story's id, and the older card vanished when the feed re-diffed.
- Group chats name authors from the buddy list (knownName) instead of the
  relay roster, which carries no names — "Member #54" is gone from the
  thread, the member line, the info sheet, previews and notifications.
- Messages tab observes the inbox (MessagesListContent) — it read the
  nested ObservableObject through the environment and never re-rendered,
  so new messages moved no row and updated no preview.
- avatarShared marker stamps the peer's device set, not just the picture:
  a bare ref.id marker outlived their reinstall and they saw initials
  for ever. Old markers mismatch once and self-heal with one resend.
- Your own story card carries your own picture (myAvatar).
- Home toolbar gains + (AddContactSheet: look up, then add; an address
  hands off to the search path); Messages toolbar gains the same + and
  scan pair, routed to Home via pendingAddress so the cross-relay
  warnings are never skipped. Sheet gets an opaque ground.
- Groups heading printed twice on Messages — GroupsSection's own header
  stands alone now. New-chat picker beside the Chats heading.
- QR payload appends ?u=<handle> so a same-relay scanner can resolve the
  account through the ordinary username lookup; decode ignores it.
- Air between story cards, carried on the card so Home and the tab match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:08:53 -04:00
Paul HittandClaude Opus 4.8 3371507cea Return sends by default, and actually works: single-line composer when on (a vertical-axis field never fires onSubmit); groups too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:35:01 -04:00
Paul HittandClaude Opus 4.8 6bfb7f49b6 repair persisted duplicate message ids on restore
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:32:11 -04:00
Paul HittandClaude Opus 4.8 11b3a51484 seed the local message-id cursor past restored history — new messages reused restored ids and duplicate identities would not render
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:31:33 -04:00
Paul HittandClaude Opus 4.8 07a3293067 Messages tab: navigationDestination out of the lazy container (pushed chats froze); Chats/Groups headings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:28:50 -04:00
Paul HittandClaude Opus 4.8 1bd3dc944e persist the derived address for restored sessions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:21:23 -04:00
Paul HittandClaude Opus 4.8 c13bb4fa16 video calls: capture/render/signal + video buttons; address card on Account; story card sizing; calls badge; About cleanup; sealed-key eviction; stories persistence
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:11:58 -04:00
Paul HittandClaude Opus 4.8 bc6f9a506a checkpoint: calls/trust/stories fixes + Home/Messages redesign (unverified)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 14:58:44 -04:00
Paul HittandClaude Opus 5 27894ed489 The migration flagship, surveyed: the server half is done and proven
docs/MIGRATION.md replaces the roadmap's hand-wave with what the code and the
live fleet actually show. Identity claim, signed home records with a serial
that must advance (rollback is the attack a signature alone does not stop),
the publishing-relay-must-be-listed guard, verifiable reads, and sender-side
fan-out with duplicate collapse - all implemented, and multihoming.mjs proves
the lot against the live fleet: 12 passed, 0 failed, run today.

The insight that shrinks the feature: a phone that moves relays keeps its
device identity, so contacts' pinned keys still match and the identity-changed
alarm does not fire. Migration collapses to sign-in-elsewhere + claim + publish
an updated record. What remains is client work only, ordered: publish homes
(neither app can), "add a relay", resolution fallback, "leave a relay".

Also states what migration is NOT before marketing does: a new phone is a
device event and must keep tripping TOFU; key rotation stays refused until a
verifiable migration record exists; and "your account survives your server" is
not claimable until the client half ships - the same overstatement discipline
as the federation row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:03:19 -04:00
Paul HittandClaude Opus 5 36ba04017e The demo accounts that actually work: flamenetreview1/2, verified at every layer
The rotation's first attempt created accounts against the RELAY - the mistake
DemoAccountSetupUITests' own header warns about, made a third time while that
very file was open. relay.flamenet.io is a pinned host: the app authenticates
against the shared account service with a passphrase-derived secret and never
calls the relay's /signin, so those accounts answered 200 to curl and "handle
not found" to a person holding a phone. A reviewer would have hit that wall.

The replacement pair burned two more handles to a second cause found only by
peeling layers in order: XCUITest was dropping characters while typing into
SecureFields, so UI-provisioned accounts stored passphrases nobody knows. What
finally isolated it, each proven while the UI run still failed: the KDF
(byte-compared against an independent python implementation - identical), the
service (200 with the derived secret), and the app's own client stack (a
unit-test probe, passphrase as bytes - 200). Then the UI suite ALSO went four
consecutive runs green before going reliably red with no relevant change,
which pins the one remaining link: keyboard input on the iOS-27-beta
simulator. A reviewer types by hand and is unaffected. Documented at the point
of future confusion, so the next red does not restart this investigation.

flamenetreview1 and flamenetreview2 are registered on the account service with
the real passphrase (provisioned deterministically, not via the flaky
keyboard), verified server-side, via the app's stack, and through the app's
real screens. New gated tests: DemoAccountProvisionUITests (opt-in, creates
real accounts) and SignupSigninRoundTripUITests (opt-in diagnostic that
separates "stale account" from "broken app" in one run).

Also fixed on the way: the second-iteration failure that wore five disguises -
relaunching XCUIApplication inside one test leaks system state into the next
iteration, so the setup test is now one account per test method.

Diagnostic tests that embedded live or throwaway credentials are deleted, not
committed. The demo passphrase appears nowhere in this tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:34:02 -04:00
Paul HittandClaude Opus 5 26358f3c84 rounds.sh: the JS-Kotlin interop lives in flamenet-e2e now
The last dead subtree path in the pre-merge ritual. FNMSG_E2E_DIR overrides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:36:03 -04:00
Paul HittandClaude Opus 5 1de92aa72c Roadmap: group outbox done on both platforms
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:25:04 -04:00
Paul HittandClaude Opus 5 271004955c Port the group outbox to iOS - fanOut reported, not decided
fanOut swallowed per-member failures and threw only when nobody was reached.
That is BOTH failure shapes the outbox exists to close, in one function: a
group whose members were all signed out lost the message silently - nothing
appended, nothing queued, the sender's own thread showing they never spoke -
and a partial send never retried the members it missed.

Android's rules, ported: fanOut returns who it reached and which members are
queueable; exactly the unreached members are queued, each entry carrying the
gid whose bubble it restates; and the bubble flips to delivered only when the
LAST queued member is reached. Only prekeyMissing is queueable - no registered
device is cured by coming back, while an identity or trust failure is not
cured by waiting, and retrying it forever would hammer a door locked for a
reason.

Group images take the same path, with the blob uploaded once and the queued
entries carrying the reference, not the bytes. The group bubble renders the
pending label for free: it projects into the same MessageBubble the 1:1 path
uses, and the projection was dropping delivery (and sticker) on the floor.

Restating matches on the timestamp the send stamped, as Android does, because
group messages carry no mid. New snapshot fields are optional like every one
before them; the old-entry test now also asserts a missing gid reads as a 1:1
entry rather than a broken one.

71 iOS tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:24:50 -04:00
Paul HittandClaude Opus 5 e12db46890 Roadmap: group outbox done on Android, iOS owes parity
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:21:20 -04:00
Paul HittandClaude Opus 5 a059e943b7 Add the keep-in-step note the Android commit already claimed was here
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:57:22 -04:00
Paul HittandClaude Opus 5 16b8493a23 Roadmap: group sends need a thread-aware outbox
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:55:40 -04:00
Paul HittandClaude Opus 5 5a7b87457c Item 8 is green: production matches flamenet-relay HEAD by checksum
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:50:34 -04:00
Paul HittandClaude Opus 5 267fbf19a6 Point the last doc reference at the split repo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:29:37 -04:00
Paul HittandClaude Opus 5 53f8b9481f Complete the split: this repository is now only the iOS app
android/, server/ and engine/ are gone - 234 files. Each already lives in its
own repository with its own history (the subtree splits preserved it), its own
pipeline, and now every fix that had landed on a subtree after its split was
cut. There were three such races, found by diffing the final subtree state
against each standalone repo and by actually running the cross-repo suites in
the new layout rather than assuming the split was clean:

- flamenet-e2e was missing the harness half of the signup-limit fix, the
  interop always-rebuild, and the RelayDiscovery build dependency
- flamenet-relay was missing the server half of the signup-limit fix - the
  conformance suite failed against it exactly the way CI failed this morning
- flamenet-messenger-android was missing the README rewrite, so it still
  opened with "Nothing here is an app yet"

All three ported, all three verified: interop 92/0 and conformance 253/0 run
from sibling checkouts, which is the shape they run in from now on.

The pipeline here shrinks from seven jobs to the two that genuinely need a Mac
- both still declared `when: never` so their absence from CI stays visible.
The relay's §8 conformance moved to flamenet-relay's own pipeline rather than
dying with the split. Scripts that drove the Android checkout now find it as a
sibling (FNMSG_ANDROID_DIR overrides). The mirror jobs are gone: there is
nothing left to mirror, and a patch to a subtree here has nowhere to go
because the subtrees are gone.

Untracked leftovers (the local server/.env, build output) were moved to
Code/_to_delete/flamenetmessenger-subtrees-20260828, not deleted.

⚠️ Operational consequence: the production deploy used to rsync server/ from
this checkout to /srv/prod/messenger/src. Future deploys rsync from a
flamenet-relay checkout instead - same command, different source directory.

The iOS app builds without the subtrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:27:40 -04:00
Paul HittandClaude Opus 5 94535e7af1 Explain the interop driver where a self-hoster meets it
The code sweep of the split repos turned up one apparent anomaly:
flamenet-e2e contains a Swift file (test/interop/main.swift). It is not a leak -
it is the interop harness, deliberately published so a self-hoster can check
the JS engine against the real iOS one rather than take agreement on trust.

But it cannot build from that repository alone: build.sh compiles it against
the iOS app's Sources/E2E, which is a different repo. The behaviour is correct
- interop.mjs skips cleanly without the app, and build.sh exits with a clear
message - and the top-level README documents it. The gap is only that someone
who clones flamenet-e2e and opens test/interop/ meets Swift with no explanation
until they read two pages in.

A README in that folder closes it: what the driver is, that it builds from the
app repo not this one, and that skipping is deliberate rather than broken. No
code change - the harness was already right.

Sweep result otherwise clean: android is Kotlin-only, relay is Swift-only,
e2e is JS plus this one intentional harness. Verified from fresh clones of all
three, not the working copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:42:49 -04:00
Paul HittandClaude Opus 5 e3a8b011b5 Interop: rebuild the driver every run - a stale one wasted a morning
The suite built the Swift driver only when the binary was missing, so a
five-day-old interop-swift ran on every invocation since. Today it failed
loudly - a harness command whose arguments had moved, erroring on a field name
the current source does not even contain - but it would just as happily have
PASSED against five-day-old engine code, which is the worse outcome: a
tiebreaker between three engines that silently tests an old one of the three is
not a tiebreaker.

Now it always rebuilds (~20s), and the build gained
Sources/Auth/RelayDiscovery.swift, which SiteConfig grew a dependency on since
the harness was last compiled.

With a fresh driver the full suite is green: 92 passed, 0 failed, including the
§19 cross-engine key-transparency checks that appeared to be failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:33 -04:00
Paul HittandClaude Opus 5 f2b0d5c117 Update every doc that described the repository as it used to be
Prompted by one stale row - SELF-HOSTING.md calling flamenet-messenger-android
"The Kotlin engine. Not an app yet" - and the sweep found the same staleness in
five files, some much worse:

android/README.md opened with "Nothing here is an app yet" above a tree
containing the finished Compose app, and listed X3DH, the Double Ratchet,
sealed sender, the relay client and "the app itself" as Not started - all of
which shipped. It described the repository as it was when the engine landed and
nothing updated it as the app grew around it. Rewritten to point at PARITY.md
as the definition of done rather than duplicating a status table that rots.

engine/README.md said "The server is here too, in server/" - describing a
directory that no longer exists in the flamenet-e2e mirror since the server
moved to its own repository this morning. AUDIT-SCOPE.md and SPEC.md pointed
auditors at server/ paths in a repo that no longer carries them. All now point
at flamenet-relay.

Root README: android/ is the app, and the published-repo table lists all three
mirrors rather than two. server/README.md's opening sentence was also circular
("as a standalone service, so the relay is a standalone service") and now says
what the thing is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:23:54 -04:00
Paul HittandClaude Opus 5 34beb76e9f Make the signup limit configurable — a fixed 5 broke the relay suite
The relay job passed on every commit before my push and failed on it. My fault,
and a foreseeable one: relay.mjs drives §8 against a relay it spawns and creates
EIGHTEEN accounts doing it. A hard limit of five per IP turned the sixth signup
into a 429, and the suite died at a poll that found no message — a failure that
reads as a protocol regression rather than as the gate I had just added.

Every other limit in Config is already an env var and this one was not. That is
the whole of the mistake. FN_SIGNUP_MAX_PER_HOUR, default 5, and the harness
raises it for itself: the limit is real and worth having, so the test that needs
more accounts than a person does says so rather than the production default
bending to it.

AccountRoutes takes the value rather than the whole Config — threading the
config in would make every future limit look like it belongs there too.

Verified by running the suite that failed: 253 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:06:41 -04:00
Paul HittandClaude Opus 5 24c16dcee8 Deploy the relay, and fix what became visible once it could be seen
Production had no signup rate limit at all until now. Deployed: the limit, the
password-reset token sweep, and the retention logging. Verified live - five
signups succeed and the sixth returns 429.

🔴 Correcting myself: moving the retention log from .info to .notice did NOT
make it visible, and I said it would. swift-log orders notice BELOW error, and
production runs FN_LOG_LEVEL=error, so the line was still suppressed. The level
is now `notice`, which is the useful setting rather than the loudest one -
`debug` logs request URLs and SQL, and a protocol URL contains a device id.

And the moment the line could be read it showed a bug in itself:

  retention swept [attachments: 0, delivered: (), messages: 0, undelivered: ()]

Fluent's delete() returns Void, so the two numbers that matter most were empty
tuples. The message paths already counted before deleting and the envelope
paths did not - invisible for exactly as long as the line itself was invisible,
which is the whole argument for logging it at a level somebody reads. Now
reports real counts.

⚠️ Verifying the rate limit created five real accounts on production. Deleted,
along with their credentials. A pre-deploy database backup was taken first,
matching the existing pattern in that directory, and the hand-made .bak source
copies on the box were excluded from the sync rather than swept away by
--delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:50:45 -04:00
Paul HittandClaude Opus 5 ce901843bf Delete the two accounts the burned passphrase actually opened
⚠️ Two, not six. I said six. The leaked value opened only appreview1demo and
appreview2demo - the fndemo* and fnreview* pairs use a different passphrase and
were never reachable with it. Recording the correction rather than quietly
narrowing the claim: the first count was wrong in the alarming direction, and an
overstated finding costs credibility on the next one.

Removed through the app's own DELETE /messenger/account route rather than by
hand in SQL, because that route walks a graph: devices before prekeys, so a
crash cannot leave a device advertising prekeys that no longer exist, then
envelopes in both directions, groups owned and joined, and the buddy edges
pointing back at the account. A DELETE by hand gets some of that and looks
finished. Verified: the old passphrase now returns 401 on both.

Four superseded review accounts remain. They are cruft rather than exposure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:44:23 -04:00
Paul HittandClaude Opus 5 39d1863fe9 CI for all four repos, and unblock the gate that was stopping publication
🔴 The find: the secret sweep was failing closed on false positives, so the
mirror job refused to publish. flamenet-e2e has been stale since 2026-08-21 and
flamenet-relay since 08-25, and this is why. A gate that blocks every release on
nothing is a gate somebody switches off, and then it is not a gate.

Two exclusions, neither weakening the shape probes. Interpolated values -
`let token = "\(selector).\(secret)"` builds a string from variables and has no
literal to leak. And `sweep:allow`, an explicit annotation for genuine
exceptions, deliberately visible so an exception is written on the line it
applies to rather than hidden in the sweep script.

⚠️ It must be on the SAME line as the match. I put one on the line above first
and it silently did nothing - documented, because the next person will do the
same.

The test fixture that tripped it sixteen times is now one named constant, which
is better code regardless: one place to change instead of sixteen.

Pipelines for the three mirrored repos, each of which is a mirror and therefore
needs its own - a green pipeline upstream proves the code passes THERE, next to
siblings it does not ship with, and says nothing about whether what landed is
complete. Each has a `complete` job asserting the shape of the tree, because
what a mirror gets wrong is missing files, not failing logic: a test that cannot
import a module is a test that does not run, and a partial copy passes
everything.

And the mirror job whose absence let flamenet-messenger-android rot. It was a
hand-made extract carrying only :engine, from before :client, :app and :interop
existed; its settings.gradle.kts still declared one module while this tree
declared four. Its `complete` job now fails on exactly that drift.

Upstream also runs :client:test, which was never in CI - the module where the
silent message loss lived, whose regression test is worth nothing unless
something runs it. And the server is no longer copied into flamenet-e2e as
well: two public copies of one server means one of them is stale with no way to
tell which.

All four validate against GitLab's linter. Engine offline tests: 87, 32 and 124
passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:42:06 -04:00
Paul HittandClaude Opus 5 c49f029902 Replace the burned demo accounts, and correct two load-bearing wrong comments
The demo passphrase was committed, later removed from the tree, and remained
readable in three commits on origin/main of this repository - which is PUBLIC.
Because an fnid is derived from the passphrase, that value was not a password
anybody could reset: it WAS those accounts, on every relay, permanently, and
they are the accounts Apple's reviewers use.

Replaced rather than rotated, on the owner's call. Rotating changes the
passphrase and therefore the fnid, which leaves the pair unable to recognise
each other and showing "identity changed" - the state that has already made a
contact permanently unmessageable on Android once. flamenetdemo1 and
flamenetdemo2 are created, signed in, and verified against the live relay; the
new passphrase exists only in the password store and CI.

Also fixes drift nobody had noticed: TESTFLIGHT.md named fndemo1/fndemo2 while
the tests used fnreview1demo/fnreview2demo. Two documents describing "the demo
account" and disagreeing about which one. There are eight such accounts on the
relay across four generations; the six older ones are listed in TESTFLIGHT.md
for deletion, since the burned passphrase still opens them.

And the comment in .gitlab-ci.yml said the iOS app "stays in this private
repo". It is not private and has not been for some time. That comment was
load-bearing - the allowlist's private-by-default reasoning rested on it - and
believing it is how a secret gets committed here on the theory that only the
mirror publishes. A secret committed here is leaked at the moment of the
commit.

⚠️ My own contribution: I wrote the burned credential verbatim into
docs/ROADMAP.md while documenting the need to rotate it, putting it into three
more commits. Redacted, and that section now says why the value is deliberately
absent. The secret sweep would NOT have caught it - it matches shapes
(sk_live_, glpat-, assigned password = "...") and a credential in prose matches
none of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:34:16 -04:00
Paul HittandClaude Opus 5 7d1a96038a Re-check the nine items, 45 commits after the baseline
Three rows moved, and two of them moved because of work done since the tag
rather than because the tag was wrong.

Item 3 - cross-relay. The relay half re-run against the live fleet today: 11
passed, 0 failed, unchanged. The client half is no longer "neither app" - iOS
gained remoteDevices, remoteBundles, sendRemote, transmitRemote, deliverRemote
and a separate remoteThreads keyed on fnid@relay. Android still calls none of
it. Marked "built, not proven": the iOS path compiles and its tests pass but has
never been driven across a real relay boundary from the app, and the previous
row looked better than it was for exactly that reason - a Node harness passing
is not a product working.

Item 8 - deployed. The running container was started before the day's relay
work, so production does not have the signup rate limit, the reset-token sweep
or the retention logging. Also recorded: production runs FN_LOG_LEVEL=error,
which suppresses the hourly retention line completely. Retention IS working -
verified by querying the database and finding zero rows past any window - but
that cannot be seen from the logs, which is the same unverifiable claim the
retention code says it exists to avoid.

New section for what turned up after the baseline: the Android silent message
loss and its fix, the Android to iOS call failure with what has been ruled out
and what was actually found, and the iOS history store that had been written
and never merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:11:42 -04:00
Paul HittandClaude Opus 5 4c403fbffa Android app lock, matching iOS
The last of the four privacy features Android was missing that iOS already had.
The sealed history file protects a phone that is off or locked; it does nothing
about the phone somebody just handed to a friend. iOS asked for Face ID; on
Android anyone holding an unlocked handset read every conversation.

Biometrics OR the device credential, deliberately - a phone with a PIN and no
fingerprint reader should still be able to use this, the same reason iOS asks
for deviceOwnerAuthentication rather than biometrics specifically. Offered only
when the device can actually authenticate: a switch that turns on and then
cannot show a prompt locks somebody out of their own messages with no way back.

Two traps worth naming. MainActivity had to become a FragmentActivity -
BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently
cannot show and the callback never fires, which looks like a broken lock rather
than a missing base class. And the gate wraps Root rather than drawing over it:
composed underneath, the message list still renders for a frame, and a frame is
all a screenshot or a shoulder needs.

The lock screen deliberately says nothing - no sender, no preview, no count. A
lock screen reporting "3 new messages from Dana" has given away the two facts
that usually matter most.

The setting states what it does NOT do. This stops someone who picks up your
unlocked phone; it adds no encryption, because the vault and ratchet keys are
already sealed by the keystore either way. Claiming more would be selling a
screen as if it were a key.

Android engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:21:03 -04:00
Paul HittandClaude Opus 5 a86a726ad9 Verify the previous commit, and fix the model name it got wrong
The machine is free (load 584 -> 6 once the idle iOS-27 simulator was shut
down), so the work committed unbuilt is now actually built.

One real error surfaced: Retention referenced `PasswordReset`, and the model is
`PasswordResetToken`. That commit could not have compiled - which is exactly
why it said so rather than claiming otherwise.

All three components green:
  Android  builds, engine + client + app suites pass
  iOS      builds, 71 tests pass
  relay    builds, 16 tests in 5 suites pass

⚠️ Correcting myself twice on the relay suite: `swift test` under /usr/bin/swift
fails to build Vapor's own VaporTesting ("no such module 'Testing'"), and under
the Xcode toolchain it reports "Executed 0 tests" - both of which look like a
missing suite. Neither is. The tests are swift-testing style, so the XCTest
runner counts none of them; the real result is 16 passing. Worth knowing before
somebody concludes the relay is untested and writes a second suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:18:15 -04:00
Paul HittandClaude Opus 5 d7e42e6333 Android FLAG_SECURE, iOS notification privacy, and the padding design
⚠️ NOT BUILT. The machine has been at load 550-640 for three hours with 180
iOS-27 simulator processes on it; xcodebuild was killed rather than failing on
its merits. Committed so the work is not lost, and must be compiled before it
is trusted.

FLAG_SECURE on Android. Blocks screenshots and recording, but the half that
matters is the recents thumbnail: without it Android captures the open
conversation and WRITES IT TO DISK, so a thread existed as a plaintext bitmap
outside the ratchet, the sealed history file and the device-only key. iOS has
had the equivalent since app lock landed; Android had nothing.

iOS notification previews now default to OFF, and the sender is hidden too.
Correcting what I told the owner earlier: Android does NOT leak notification
content - it shows "New message" and never the sender or body. I misread the
count string for the message text. iOS was the leaky one, and worse than it
looked: content.title was set to the sender unconditionally, so turning
previews off hid what was said and still announced who said it. On a lock
screen that is often the more sensitive half, and the setting was labelled
"Show previews".

No incognito-keyboard flag, deliberately: Compose has no stable API for it, and
FLAG_SECURE already makes most keyboards including Gboard suppress
personalised learning. A second mechanism that might be a no-op is worse than
the note.

docs/PADDING.md designs envelope padding rather than implementing it. It is a
wire-format change on a live protocol; the group-decode mismatch in this
repository's history is what that looks like when it goes wrong. Includes the
three-stage rollout, why stripping cannot be inferred, and why the relay needs
no change at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 00:38:42 -04:00
Paul HittandClaude Opus 5 95f20abead Prove history at rest, instead of asserting it
The store came back from a parked branch with no test at all, and "it builds"
was the only evidence for it - which is the state it was in when it got lost
the first time.

Five tests. A thread written and read back keeps its text, its sender and its
delivery state. Calls, the outbox and the story audience all survive, which is
what makes the outbox promise true across a relaunch rather than only while the
app is running. Purge removes the file rather than only the memory. A snapshot
does not open under a key it was not sealed with - the "lose the device, lose
the key" design only holds if that is actually true.

And the one that mattered most: a snapshot in the OLD shape, with calls, outbox
and storyAudience absent entirely, still decodes. Three commit messages claimed
those fields had to be optional because synthesised decoding throws on a
missing key rather than using a default. If that reasoning had been wrong,
every snapshot written before those fields existed would fail to decode and the
history would be silently discarded on the next launch. Now checked.

⚠️ What this does NOT cover: that E2EInbox restores from the store on init. The
store round-trips; the wiring around it is still only read, not exercised.

65 iOS tests were already green before this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:24:06 -04:00
Paul HittandClaude Opus 5 7434784de8 Make a call that goes nowhere say so
Android's Session.sendCallSignal called the send path and threw the result
away. That result is the same Sent every other send returns - how many devices
took it, which failed - so an offer that reached NOBODY was indistinguishable
from one that rang. The caller watched "Calling..." for a signal that had gone
nowhere, and neither device had anything to say so. The same shape as the
message-loss bug fixed earlier today, on the one path where the UI never shows
a delivery banner because a call is placed from inside a WebRTC callback.

Also instruments the drops. An incoming offer can be discarded in five places
in iOS's CallManager - already-ended, older than the dial timeout, busy, wrong
state, unknown id - and every one was a bare `return`. Working out which of the
five happened costs more than the logging does. Same treatment E2EInbox.dropped
already gives ingest, and for the same reason.

Not a fix for "Android calls iOS and nothing happens" - I have not reproduced
that. It rules out and instruments: the wire shapes match, iOS builds its
CallManager eagerly at sign-in so onCallSignal is always hooked, Android sets no
from_addr so the federated fork is not swallowing it, and every clock (mac,
host, relay, db) agrees within a second so the staleness check is not
misfiring. The next attempt will say where it stopped instead of nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:20:16 -04:00
Paul HittandClaude Opus 5 3f4620215f Remove Connected Services from the iOS app
The AIM/ICQ bridge moved to its own product; this screen was the last of it
here. Gone: both views, the settings row, the three API calls, and the two
response types.

Buddy.screenName stays. The relay still sends it, the buddy list still searches
on it and falls back to it as a preview line - removing the connect screen does
not remove the field.

The relay routes are untouched: other clients may still call them, and deleting
a server route because one app stopped using it is somebody else's outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:15:16 -04:00
Paul HittandClaude Opus 5 1a7f1d2b7a Story audiences on iOS
Same three modes as Android, same storage posture, same enforcement: the
audience decides who is SENT the bytes. A story is sealed per recipient through
the ratchet, so somebody outside the list is never sent it - there is no copy
on the relay for them to be denied.

The saved list is intersected with the live contact list in every branch,
including "only chosen", so an id left behind after that person was removed as
a contact cannot resurrect them as a recipient. The screen says what will
actually happen, resolved the same way, and a post that would reach nobody is
refused rather than quietly succeeding at nothing.

⚠️ CombineLatest tops out at four publishers, so the audience is folded in with
the outbox rather than adding a fifth - noted in the code because the shape
looks arbitrary otherwise.

The snapshot field is optional like the two before it: synthesised decoding
throws on a missing key rather than using a default, so a non-optional field
would make every snapshot written before it fail to decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:12:51 -04:00
Paul HittandClaude Opus 5 acc0832bed Story audiences (Android): all contacts, only chosen, or all except
Three modes, stored in the vault, applied at post time.

🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is
sealed to each recipient's devices through the ratchet, so somebody outside the
list is never sent it - there is no copy on the relay for them to be denied.
Every mainstream product holds one server-side copy and decides who is shown
it; this cannot work that way even if somebody wanted it to, and that is the
feature rather than a limitation.

Which also answers "make sure stories are only visible to friends": they
already were, structurally. There is no public audience and there cannot be
one - a story has to be encrypted to somebody in particular.

⚠️ The saved list is intersected with the live contact list in every branch,
including "only chosen". An id left behind after that person was removed as a
contact must not resurrect them as a recipient.

The screen says what will actually happen - "your next story goes to 3
contacts" - resolved the same way, and refuses a post that would reach nobody
rather than silently succeeding at nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:11:20 -04:00
Paul HittandClaude Opus 5 bf2a531b84 Stickers on iOS, from the same manifest
Send, receive, file, and draw bare - no bubble, for the same reason as Android:
a glyph inside a grey rounded rectangle reads as a broken image. The pack is
read from the shared manifest rather than hard-coded, so neither client can
drift from the other about what exists.

A second composer control rather than a tab inside the emoji one, and the two
are mutually exclusive. An emoji goes INTO the draft, so its panel stays open
on pick; a sticker IS the message, so its panel closes. Behind one button they
would imply they behave the same way.

Two things found while doing it:

iOS already HAD an emoji picker, with a recents list and sensible grouping -
better than the flat grid I had just written for Android. My duplicate compiled
and would silently have shadowed the nicer one. Deleted; Android's is the side
that now wants recents and grouping.

A sticker that reaches nobody is marked pending but is NOT put in the outbox:
that queue replays text, and replaying a sticker through it would send the id
as a message body. Marked rather than silently passing for sent, and noted as
unfinished rather than half-built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:08:37 -04:00
Paul HittandClaude Opus 5 94a01e6fcd Stickers and emoji in the composer (Android), and a written queue
Stickers send, arrive, file into the right thread - group messages under the
group, not scattered across one thread per member - and draw bare. No bubble:
the kit draws none, and a glyph inside a grey rounded rectangle reads as a
broken image. The timestamp and avatar stay, because that is the line saying
who and when.

Two controls, not one, because they are two different things. An emoji goes
INTO the draft and is indistinguishable from any other character once sent -
nothing on the receiving end needs to understand it. A sticker is its own
message kind that is never rendered as text. Behind one button they would imply
they behave the same way. Hence also: the emoji panel stays open on tap, since
picking three in a row should put three characters in the box, while a sticker
sends and closes because it IS the message.

The emoji set is a short curated list, said plainly in the code: a full picker
is search, skin tones, categories and recents - a real feature the system
keyboard already ships. This is what a thumb can reach.

docs/ROADMAP.md now holds the queue, including the security and privacy work.
Written down because this project has already lost a finished feature to a
parked branch. Two items there are real and current: Android spends a one-time
prekey on EVERY message via fetchKeys, silently costing initial forward secrecy
once the pool drains - iOS fixed this and Android has the unused listDevices
sitting next to it - and a passphrase IS the account, with no second factor and
no recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:04:54 -04:00
Paul HittandClaude Opus 5 6d0fe731b4 Teach both engines the sticker wire
A sticker is a content envelope carrying an id, never an image: sending the
picture would cost an upload and a download of bytes both ends already ship
with, on every sticker. This is about sixty bytes.

Recognised before the text path on both sides, because a sticker is renderable
but is NOT text - it draws as a glyph with no bubble, and falling through would
render it as an empty message. An unknown pack or id has to degrade to the
pack's fallback rather than break, which is what the envelope format exists for.

The pack is one JSON manifest read by both clients, so there is a single source
of truth for what exists. docs/STICKERS.md says plainly what the first cut is:
the glyphs are Unicode, because real sticker art is a design job, not a code
one. The protocol, picker, rendering, fallback and tests are real and finished;
swapping in artwork is a change to the manifest plus an image loader, not to
any of that. It is not a claim to have drawn a sticker set.

Emoji are deliberately NOT this. They go into the text of an ordinary message
and are indistinguishable from any other character once sent - no new kind, no
new field, nothing for the receiver to understand. That picker is a keyboard
convenience, not a protocol feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:50:48 -04:00
Paul HittandClaude Opus 5 1d3d097d7c Give iOS an outbox, and bound how long a transfer can run
Two of the four remaining items.

OUTBOX. iOS threw on a recipient with no registered device, so it never lost
the message the way Android did - but it did not hold it either, leaving the
sender to remember, notice their friend was back, and retype it. Now the same
shape as Android: queued, retried on every poll, never expiring, and the bubble
says "Undelivered - will send". Only prekeyMissing is caught; everything else
still throws.

THE STORY SPINNER. timeoutIntervalForRequest is an INACTIVITY timeout - it
fires when no bytes move for 25 seconds. timeoutIntervalForResource bounds the
whole transfer and was never set, so it defaulted to seven days. An upload
crawling up a saturated uplink never idles for 25 consecutive seconds, so
nothing fired; and because E2EInbox is @MainActor and merely awaiting, the main
actor stayed free and the spinner kept spinning. No error, no end. Now capped
at 180s.

Android was never exposed to this: OkHttp's callTimeout(25s) is a TOTAL call
timeout, so it fails rather than hangs. That asymmetry is why the spin only
ever appeared on iOS.

Correcting an earlier claim: the 4MB upload probe that "took 0.64s" returned
401 - the server refused it before reading the body. It proved the route
exists, not that large uploads complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:48:24 -04:00
Paul HittandClaude Opus 5 51c302ee8b Add the Calls tab to iOS
Matches the Android tab added alongside it, and the kit's 08 Calls-Listing:
48pt avatar, 16pt name, a direction mark before a 12pt line, and one call
action. No video icon, though the kit's row has one - there is no video path in
CallManager.

The log rides in the sealed history file with the messages. Its field is
optional, which is not cosmetic: Swift's synthesised decoding does not apply a
property default for a missing key, it throws, so a non-optional field added to
Snapshot would make every snapshot written before it fail to decode - and not
losing history is the entire point of that file.

Filed once at teardown, before the state changes, reading a flag captured while
the call was still up. Asking the connection afterwards always says no, which
would record every answered call as missed.

The timestamp formatter is now RowStamp, used by both the chat row and the call
row. It had already been written twice within the hour - the drift its own
comment warned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:41:52 -04:00
Paul HittandClaude Opus 5 5c4253bd56 Add a Calls tab, and the call log it needed
The kit's bottom bar is four icons and one of them is a phone; its
"08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call
notification from the relay, and no history at all - so "who rang me yesterday"
had no answer anywhere.

So the log had to exist first. It lives in the vault with everything else: who
somebody called and when is precisely the metadata this app exists to keep off
a server, and encrypting the words while leaving the call log in the clear
would be an odd place to stop. Bounded at 500, because the whole log is
re-sealed on every write.

Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING ->
ACTIVE on the way, and recording each transition would turn one call into five
rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever
connected is captured BEFORE the manager is torn down - asking afterwards
always says no, which would file every answered call as missed.

The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp
direction icon before a 12pt line. Missed calls carry the kit's pink so the row
somebody is actually looking for is the one that stands out, and the tab badges
missed incoming calls only - an answered call is not news.

No video icon, though the kit's row has one at x=255: there is no video path in
either CallManager.

relativeTime is shared with the chat list rather than reimplemented - two
screens formatting times differently is drift nobody files a bug about and
everybody notices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:38:21 -04:00
Paul HittandClaude Opus 5 e2c7d1822f Match the kit's chat bar and message box
Measured out of "Light Mode - iOS/07 Messages-Details 1" rather than eyeballed:
the App Bar is 88 tall with back at x=24, title 16pt Medium over a 12pt line
with a dot beside it, and Call at x=279. The Message Box is 84 tall: plus at
x=16, an F3F3F3 pill from x=56, camera at x=287, more at x=335.

The composer is one row now. Voice recording was a chip hanging underneath it -
the last thing on the screen that did not belong to that row - and the mic is
an icon in the row, with the permission plumbing hoisted to where it is used.
Send appears only when there is something to send; at rest the row is the kit's.

iOS tab bar drops its labels, matching the kit's "Bottom Bar/Without Label" and
Android. Titles survive as accessibility names - dropping text from the screen
must not drop it from VoiceOver.

PresenceDot was still an olive, a mustard and a rust: the previous brand's
palette, left behind when the accent changed. Now teal, blue and pink from the
kit.

Two things the kit draws that this deliberately does not: a video-call button
and an emoji key. Neither CallManager has any video path at all, and there is
no emoji picker to open - the system keyboard already carries one. A mockup can
draw a feature; the app cannot draw one it does not have, and this codebase has
already shipped one dead call button once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:30:07 -04:00
Paul HittandClaude Opus 5 4410ba64e2 Restore iOS conversation history at rest
E2EHistoryStore was written on 2026-08-26 and never reached main. It sat alone
on wip/message-formatting-park-20260826 while main moved 174 commits past it,
so the app went on discarding every conversation when the process died - months
after that was explicitly decided against, and while the inbox still carried the
comment saying threads are memory-only.

Brought back narrowly rather than by merging the parked branch, which is 174
commits behind and touches 19 files: the store itself, historyKey() on the
Keychain store, and the wiring in E2EInbox re-applied against current main.

Two things had drifted in those 174 commits and needed fixing to compile:
E2EMessage and E2EGroupMessage had gained fields and lost their Codable
conformance. E2EAttachmentRef and E2EAttachment.Reference were already Codable.

clearAllCaches now purges the sealed file too - without it, "clear" cleared only
what was on screen and the next launch restored what it had just said it removed.

Builds. Not yet verified surviving a real relaunch on device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:25:27 -04:00
Paul HittandClaude Opus 5 a58edcf2f4 Hold an undelivered message and send it when they come back
Asked: how does the sender know to try again when the other person has no
registered device - especially since they are friends. They should not have to.
Marking it undelivered and stopping there moves the problem onto the person who
did nothing wrong: they would have to remember, notice their friend was back,
and retype it.

So the message goes into an outbox and is retried on every poll tick. Nothing
expires and nothing is discarded - a contact who reinstalls in a month gets it
in a month. Timing out would mean deleting something somebody wrote in order to
tidy up a label.

The queue has to live on the sender's phone, and that is forced by the
encryption rather than chosen: every envelope is sealed to a particular
device's keys, so somebody with no device has no keys to seal to, and the relay
could only queue plaintext. The consequence is that it goes out the next time
THIS app runs and polls, not the instant the recipient signs in - so the label
says "Undelivered - will send" rather than implying it is in flight.

Delivery is now a tri-state. PENDING is quiet and grey because it is the normal
thing that happens when a friend is signed out and there is nothing to do about
it; only FAILED is a warning, and the outbox never produces one.

OutboxTest covers it end to end, including delivery against a real second
identity rather than a hand-written bundle: queued while away, sent when they
register, and the bubble restated - a version that delivered the message but
left it reading "undelivered" would pass a test that only checked the queue.

Engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:06:19 -04:00
Paul HittandClaude Opus 5 4e77d1136e Every message carries whether it was delivered, and says so
Asked: how do we prevent ghost messages. Fixing the one cause does not, so
this fixes the thing that makes any cause invisible.

A message could only be a ghost because a delivered bubble and an undelivered
one were the same picture. The code had one bit - record it or do not - and
both answers were wrong, and the app shipped one of each:

  1:1 text recorded only on success, so a message sent while the other person
  was signed out vanished out of the box with no bubble and no error. That is
  why it took sending twice to get one to appear.

  Group text recorded unconditionally, so it left a bubble that looked exactly
  like a delivered one and had reached nobody.

Now every send records, carrying the outcome, and a bubble nobody received is
marked. Null stays null for received messages and for anything written before
the field existed - absence of a claim, not a claim of failure - so old
threads do not sprout warnings.

Correcting the previous commit message: it said the lost 1:1 message was
written into the sender's own thread. It was not; nothing was written at all.
The ghost bubble was the group path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:57:58 -04:00
Paul HittandClaude Opus 5 769d2c992c A message nobody can receive must not report success
Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.

Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.

iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.

Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".

SendToNobodyTest pins it; verified failing with the guard removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:54:12 -04:00
Paul HittandClaude Opus 5 a38e00b25c Draw the author's picture beside a received bubble
Reported: profile pictures do not appear in the chat boxes. They did not.

iOS had an avatar slot in MessageBubble that hand-rolled only the initials
fallback - it never had the user id or the inbox, so it could not reach the
picture that AvatarView already downloads and decrypts everywhere else.
Android reserved the kit's 40dp received inset for an avatar and drew nothing
in it at all.

Both now pass the author and the picture through. Sent bubbles get none: you
know who you are, and a picture on both sides halves the width a thread has.

Also gives the iOS list row a real last-message preview and a timestamp,
matching the Android row - it was showing a screen name and the word 'Online',
a second copy of the dot already on the avatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:40:40 -04:00
Paul HittandClaude Opus 5 08506856d1 Give the Android list row a preview, a time and a badge
The kit's row is four things - avatar, name, last message, time - plus an
unread count on the right. Ours was a name and the word 'online', which is
most of why a two-contact screen read as empty.

The preview comes from the local vault, not the relay: the relay sends none
and asking it to would leak which conversations are active, while the history
is already decrypted on this device. Presence moves to the dot on the avatar,
where the kit draws it and where it costs no line.

Section headings now appear only when both contacts and groups are on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:36:44 -04:00
Paul HittandClaude Opus 5 bab2e1ceab Drop the overflow menu from the iOS chat list
It held Refresh and Sign Out. The list is already .refreshable, so pulling it
down is the gesture people reach for, and signing out lives on Account with
the rest of the account — where it also gets a confirmation the menu item
never had. The kit's Messages bar carries compose and contacts, nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:29:38 -04:00
Paul HittandClaude Opus 5 0e7dbcb8c0 Draw icons instead of typing them
Every icon in the Android app was a text character or an emoji — +, →, ‹, ⋯,
☎️, 🎤, ⊞ — and material-icons-extended had been a dependency the whole time
with zero Icon() calls against it. Reported three times as bare/text-links;
each time I adjusted spacing and type, because the structure genuinely did
match the kit and the code read correctly.

Adds KitIcons.kt as the vocabulary and migrates the tab bar, bar actions, row
overflow, composer, call controls, contact rail and alert glyphs. The tab bar
loses its text labels to match the kit's 'Bottom Bar/Without Label' artboard
and keeps them as accessibility names.

Also fixes the iOS composer sitting under the tab pill: HidesPillTabBar was a
PreferenceKey, and preferences do not travel out of a NavigationStack
destination, so the chat screen's request never reached the bar watching for
it. Now an observable with balanced retain/release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:23:47 -04:00
Paul HittandClaude Opus 5 0937ee48aa iOS stories feed, scrollable, matching Android's
A tab rather than a strip, for the same reason: a column of full-width
cards cannot sit above the chat list without pushing it off the phone.
The strip on that screen stays as the way in.

🔴 The card takes plain values and two closures, not an `E2EInbox`. The
first version took the inbox — which needs a keychain, a client and a
registered device — so the harness could not render the feed at all and
the only thing a screenshot produced was the "transport is not up"
fallback. That is the THIRD screen to learn this after the call screen
and Android's: presentation that takes plain values can be looked at, a
state machine cannot. It is now cheap enough to be the default.

⚠️ No like-or-comment bar, as on Android. The kit's post card has two
emoji reactions and a share icon; a story here has no comment thread,
no reaction and nothing to share it to, and drawing the bar wired to
nothing would be a lie told in pixels. The deadline sits in the slot
the kit gives a description — the one fact about a story that changes
while you are looking at it.

⚠️ The feed shows the deadline, not the post time. "2 hours ago" is the
wrong fact about something that vanishes.

"Image unavailable" in the render is correct: the harness has no bytes
to hand it, and the card says which half failed rather than a generic
"could not load", which would cover a network error, a wrong key and a
relay serving different bytes alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:41:27 -04:00
Paul HittandClaude Opus 5 1dc844f512 iOS gets profile pictures too, and its unverified fixes are verified
The avatar work mirrors Android exactly, because two answers to "how
does a face travel" would be worse than either. An avatar is an
attachment, not a field on the account: the relay stores the same
opaque blob it stores for a photo, and the key reaches each contact
inside the ratchet as the delivery key already does. A JPEG the server
serves per user would hand it everybody's face on a product whose whole
claim is that it cannot see what you send.

Same three things that would have been bugs, forked at the same points
for the same reasons:

🔴 `avatar` is parsed WITH its attachment ref and consumed before
anything renders it. Without that fork it arrives in the conversation
as though somebody had sent you their face as a photo.

⚠️ Sharing is keyed on the blob id, not a flag. A boolean says "told"
for ever and a new picture never reaches anybody who had the old one.

⚠️ Contacts are told when a conversation OPENS, not when the picture is
chosen. Somebody added afterwards would otherwise see initials for ever
with nothing on any screen to explain it.

`E2EAttachmentRef` became `Codable` so a reference can be persisted —
the reference, never the decrypted bytes. A face on disk in the clear
is the one thing a stolen phone should not hand over about somebody's
contacts.

Initials are documented as the NORMAL case rather than a placeholder
waiting for bytes: a picture only exists once somebody has set one AND
messaged you, so most avatars will be letters.

Also verified, now that Xcode builds again, the two fixes committed
blind earlier: the contact detail's cover is full-bleed and its
presence indicator is on the name's own line rather than behind the
action rail. Both confirmed by render. 63 iOS tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:08:06 -04:00
Paul HittandClaude Opus 5 c6cfa374b6 Profile pictures, end to end, and the chat bar's second line
🔴 **An avatar is an attachment, not a field on the account.** The
obvious design — upload a JPEG and let the relay serve one per user —
hands the server everybody's face, on a product whose whole claim is
that it cannot see what you send. This rides the same encrypted blob
path as a photo message: the relay stores ciphertext, and the key
travels to each contact inside the ratchet exactly as the delivery key
already does.

⚠️ **The cost, stated on the screen rather than discovered.** It is per
contact. Somebody who has never messaged you cannot see your picture,
and changing it means telling every contact again. Sealed sender
already makes this bargain; a different answer for avatars would mean
two answers to the same question.

The pieces: a new `avatar` control kind, which is parsed WITH its
attachment ref rather than falling through the CONTROL early-return —
otherwise a profile picture arrives in the conversation as though
somebody had sent you their face as a photo. Encrypted at rest in the
vault like everything else, because a face on disk in the clear is the
one thing a stolen phone should not hand over about your contacts.
Sharing is keyed on the blob id, not a boolean: a flag would say "told"
for ever and a new picture would never reach anyone who had the old one.

⚠️ Contacts are told on OPENING a conversation, not when the picture is
chosen. Somebody added afterwards would otherwise never be told, would
see initials for ever, and nothing on any screen would explain why.

⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told"
is the truth when two contacts have a changed key, and silence would
let somebody believe it had gone out.

Also: the chat bar gained the kit's second line — a presence dot and a
12sp fact, or a member count for a group, which shows no dot because a
group has many presences and one dot cannot speak for them. And the
reassuring identity states dropped to 12sp grey; they were as loud as
the title bar, which is the wrong weight for "nothing has happened".
The CHANGED state is untouched and stays loud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:58:58 -04:00
Paul HittandClaude Opus 5 cdad7f8c84 Android gets a contact screen
🔴 **There was none.** Everything you could do to a person was an inline
row that unfolded under their list entry — Block, Clear chat, Remove,
three text buttons in a line — with nowhere to explain what any of them
did and nowhere to put a safety number. iOS has the kit's version; this
is the same screen: a cover, a name plate, and a rail of actions
floating at the seam between them.

⚠️ The rail carries only actions that do something. The kit's has a
"personal page" icon; there is no such page here, and a fourth control
that opens nothing is worse than a rail of three.

⚠️ Presence sits on the name's own line, not the plate's trailing edge.
The rail floats over the right-hand third of the plate, so anything put
there is behind it — a thing only visible once rendered, and it was
caught on iOS the same way an hour earlier.

Removing a contact confirms in place rather than in a dialog, matching
the Account screen: a dialog is dismissed reflexively; a row that
changes to say what it is about to do is read.

🔴 The on-device test asserted `block-$id`, `clear-$id`, `remove-$id`,
which no longer exist — those actions moved off the row and are not
keyed by peer id any more. Updated to follow them, plus an assertion
that there is a way back, or the screen is a trap. That suite needs a
device and cannot run here, so it would have failed silently until
somebody next ran it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:50:14 -04:00
Paul HittandClaude Opus 5 f5c941738c Android gets a real call screen; iOS gets its tab bar out of the way
🔴 **Android had no call screen — it had a banner.** A line of text and
two TextButtons stacked above whatever you happened to be on, for the
single most attention-demanding thing this app does. iOS has had a full
screen throughout and the kit draws one. Same split as iOS: the look
takes plain values so the screenshot harness can photograph it, because
a call screen nobody can look at is a call screen nobody has checked.

⚠️ The privacy prompt stays a banner deliberately. It is a question
asked BEFORE a call exists, and a full screen for it would look like a
call already happening.

🔴 **And a bug I introduced: the floating tab pill was sitting on the
message box.** Reported from the iOS app. The pill overlaps content by
design — that is what makes it float rather than reserve a strip — and
the composer is exactly where it lands, so the field could not be
tapped. `TabView` hid its own bar on a push and the replacement had
nothing to do that with. Both conversation screens now declare
`hidesPillTabBar()`, which is what the kit's own Messages-Details
artboard shows: a composer at the bottom and no tab bar at all.

⚠️ The iOS fix is written but NOT yet rendered — the machine is pinned
copying Xcode out of iCloud Drive and I am not running builds into
that. It compiles; it has not been looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:45:12 -04:00
Paul HittandClaude Opus 5 1aec5adddd The call screen, in the kit's grammar and finally renderable
🔴 Split the look out of the state machine first. `CallManager` needs a
live `E2EInbox` — a keychain, a client, a registered device — so the
call screen was the one surface the harness could not photograph, and
therefore the one being restyled blind. `CallScreenBody` takes plain
values; `CallScreen` maps the state machine onto it and does nothing
else.

Light, not a dark gradient. The kit's Calling screen sits on the same
white ground as everything else, with the caller at 20 bold over a 14
status line and the controls arranged around a 72pt primary. This was a
full-bleed dark gradient with a row of equal-sized buttons — a
different app for the duration of a call, and the one place a person is
most likely to be looking for something familiar.

⚠️ The kit draws a 400pt animated ring. A static ring pair stands in
and says so: an animation that never resolves is worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:03:37 -04:00
Paul HittandClaude Opus 5 897fd19e7e iOS sign-in takes the kit's Welcome masthead too
Both platforms now open on the same thing: a 48pt mark, a 40pt
greeting, and one line saying what this is. iOS had an 88pt tinted
circle with a glyph in it, a 20pt "Welcome back" and a subtitle about
buddies -- a login card rather than a masthead.

The submit button is 240 wide and centred, as the kit draws it, on both
platforms now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:00:06 -04:00
Paul HittandClaude Opus 5 1f2a702100 Stories as a feed you scroll, which is the kit's Timeline
A screen, not a strip. Stories were a horizontal row of circles on the
Messages screen -- enough to tell you something exists and nothing
more. The kit's Timeline is a column of full-width cards: author over a
description line, the image, the caption. A card feed cannot sit above
a contact list without pushing it off the phone, so it gets a tab; the
strip stays on Messages as the entry point.

⚠️ No like-or-comment bar, and the kit's post card has one -- two emoji
reactions and a share icon. A story here has none of those: no comment
thread, no reaction, nothing to share it to. Drawing the bar and wiring
it to nothing would be a lie told in pixels. What a story DOES have
that the kit's post does not is a deadline, so that is what sits in the
slot the kit gives a description -- the one fact about a story that
changes while you are looking at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:58:30 -04:00
Paul HittandClaude Opus 5 45aa5b470c The kit's palette, not just its layout
I kept Flamenet orange and defended it as brand continuity. That was
wrong on the facts: the orange had already been retired and the public
pages carry none of it, so the apps were the last thing wearing a brand
colour the brand had dropped — on the authority of a comment in
Theme.kt that nobody had rechecked. The comment is corrected in place
so nobody restores it for the same stale reason.

The values are measured out of the Sketch file rather than sampled from
a render. The two text colours are the ones that actually appear in its
symbols — #090710 on 165 text layers, #505C69 on 164 — and the accent
#2298FF is what it uses at 12pt as well as on buttons, so it is their
link colour and not only a fill.

The constants were called Ember and held orange. They are Accent now:
a constant named for a colour it no longer holds is how a palette
drifts back.

⚠️ Recorded because it will come up: #2298FF on white is about 3.3:1 —
fine for a bold button label, not for small text. The small text on
these screens is grey, and it needs to stay that way.

Also in this commit, the Android sign-in takes the kit's Welcome
layout: a logo mark, a 40pt greeting and one line saying what this is,
centred with room above them. It was a 24pt product name jammed against
a Server field, which is the shape of a login dialog rather than the
first thing anyone sees of the app. The submit button is 240 wide and
centred, as the kit draws it — a button spanning the phone reads as the
bottom of a form; one that does not reads as a choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:54:34 -04:00
Paul HittandClaude Opus 5 522bab868d An iOS harness, and what it showed the moment it existed
Every claim about iOS in the last several commits was "it builds", not
"it looks right" — there was no way to look. Now there is:
`Scripts/screenshots-ios.sh`, one simulator boot for every screen
instead of one boot, one install and one tap sequence per check.

⚠️ Unlike Android's, this DOES need a simulator: UIKit has no headless
renderer. Two things had to be right and both fail misleadingly.

🔴 Not `ImageRenderer`. It skips the UIKit layout pass a `List` needs,
so every settings screen came out an empty rectangle — which reads as
"the screen is broken" rather than "the renderer never laid it out".
Hosting the view in a real window and calling `drawHierarchy` is the
ceremony that avoids it. And one runloop turn before drawing, because
SwiftUI commits its first real layout on the next tick; without it the
PNG is a plausible-looking half-laid-out screen rather than an obvious
failure.

What it found immediately:

The Profile header was half the screen — a 110pt avatar stacked over
the name, over the handle, over a full-width filled button, four
centred elements before a single setting on a screen whose job is the
list below them. The kit puts an 80pt avatar on the leading edge with a
secondary button beside it, on one row. Seven settings fit now where
four did.

Bubble timestamps sat BELOW the bubble on iOS and above on Android, so
the two platforms drew the same conversation differently and the kit
agreed with neither of them — it is above. A received avatar was
bottom-aligned and drifted down the side of a long message, reading as
belonging to whatever came next.

⚠️ Also worth recording: the disk cleanup left simulator device
definitions whose data directories were gone, and `simctl` does NOT
recreate them — it lists the device and refuses to boot it with
"cannot be located on disk". I said earlier they would rebuild on first
boot; they do not. The script now creates a working device when it
finds none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:48:17 -04:00
Paul HittandClaude Opus 5 0eb44bb3a6 Design pass with the screens actually in front of me
The JVM harness renders whole screens now, not just parts. The screens
take their state as `phase` and only touch the view model when
something is tapped, so an unstarted model is enough — it opens the
Keystore vault in `init`, which fails inside a runCatching on a
background dispatcher under Robolectric. That is why this works rather
than by design; if it starts hanging, look there first.

With the screens visible at the kit's own 375x812 canvas, four things
were obvious that were not from inside the code:

🔴 The large title said "fnreviewalpha" over a relay and a device id.
The kit's title names the SCREEN — "Messages". Three lines about who
you are, at the top of a list about other people, and it dominated
because a handle is longer than a title. Identity belongs on Account,
where it already was.

Contact rows read "fnreviewbeta / fnreviewbeta · online" — the name
twice and a status — because the handle is usually the same string as
the display name. It is dropped when it matches.

"No groups yet. Start one with your buddies." was a line of prose in
the middle of a list, with a section label above it, for the ordinary
state of a new account: two lines of furniture for zero rows. Both go
when the section is empty AND loaded. The failure case still speaks,
because the reason that message existed is real — an empty list
rendering nothing gave no way to tell "you have none" from "it did not
load".

Alerts offered "Mark all read" on an empty screen, where its only
possible effect was nothing, and it was the most prominent thing there.
"Nothing new." sat against the heading like a subtitle of it. Proper
empty state now, and the button appears only when something is unread.
Alert rows drew initials of the KIND, so "contact request" became "CR"
— two letters that look like a person's initials and name nobody.

Address card: the QR was the largest thing on the Account screen and
pushed everything below the fold, and the address wrapped mid-hostname
as "…@rela / y.flamenet.io". A relay name split across a line break is
the kind of thing somebody retypes wrong, so the host is its own line
now. Two fields whose placeholder repeated the label above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:32:34 -04:00
Paul HittandClaude Opus 5 784e2d4f3e Render Android screens without an emulator
Verifying a layout meant booting an 8 GB AVD, installing an APK,
tapping to a screen and pulling a screencap — and the emulator was
killed by memory pressure four times in one session, each time
mid-check. Robolectric in native-graphics mode draws the same Compose
pixels on the JVM. `Scripts/screenshots.sh` now renders the screens in
about four seconds with no device.

Two things had to be got right and both fail in a way that reads as
something else:

`isIncludeAndroidResources` is not optional — without it the theme
cannot resolve and every render throws on a missing resource, which
looks like a Compose problem rather than a configuration one.

🔴 And the capture. `captureToImage()` goes through PixelCopy and waits
for a frame-commit callback Robolectric never delivers, so every render
died as "Condition still not satisfied after 2000 ms" — which reads as
a hung composition and is nothing of the sort. Drawing the decor view
into a software canvas produces the same pixels with no window
involved. `createComposeRule()` has the same problem for the same
reason; it needs the Activity rule.

⚠️ These are output, not assertions: they write files and pass unless
something throws. A screenshot test that diffs against a stored
baseline is a different thing, worth adding once the design stops
moving — right now it is moving, and a baseline would be re-blessed on
every change, which proves nothing.

This immediately paid for itself. The address card had only ever been
seen in its empty state, because the emulator's stored session predates
the fnid and a fresh sign-in erases that phone's keys. It renders
populated here, and the QR in it — encoded by our own Kotlin encoder,
drawn by the app's own Compose code — decodes back to the exact address
through CoreImage's detector, which shares no line with any of it.

Rendered at the kit's own 375x812 canvas, so a PNG can be put beside
the Sketch artboard rather than mentally rescaled from a 1080x2400
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:24:50 -04:00
Paul HittandClaude Opus 5 be09aa46fb Controls, not a page of orange words
Reported as "looks like a bunch of text links", with the honest caveat
of not knowing whether that is normal for Android. It is not, and it is
not the kit either: almost every action in the app was a bare
TextButton -- Back, Call, Safety number, Record a voice note, Mark all
read, Look up, Leave, Create group, and the four presence states -- all
the same undifferentiated orange text with nothing around it. That is
not an Android convention; it is the absence of one.

The kit ships three buttons, all 64 tall at radius 32 with a 14 medium
label: a filled primary, an outlined secondary, a grey tertiary. Plus a
small chip for something sitting in a row of its peers.

Presence was the worst of it -- four orange words that gave no sign of
being a single choice with one answer; the selected one was merely
bold. Chips now. Accepting a changed identity key is the most
consequential tap in the app and sat as matching orange text beside
"Show number", so the two read as interchangeable; it is an outlined
danger button now and the other is a chip.

Bar actions became glyphs in tappable circles with real accessibility
labels, rather than words. No icon set is pulled in for eight buttons.

Also in this pass: section headings shrank to 12sp labels and their
buttons moved into the app bar as icons. Two heading rows -- "Groups /
New" and "Contacts / Add" -- took the top third of a screen whose whole
content was two rows, and the kit's list has no headings at all.

iOS gets the same treatment: the tab bar is the kit's floating pill
rather than a TabView strip (PillTabBar records what the system bar was
doing for us that now has to be done by hand, including the one thing
that does not come back -- scroll-to-top), the list screen lost its
cards, and settings moved off .insetGrouped, which draws every section
as a floating card, onto plain rows separated by the kit's 16pt band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:15:11 -04:00
Paul HittandClaude Opus 5 504544e6bb Android: the layout, not just the parts inside it
The last pass changed row internals and left the screen architecture
alone, and the screen architecture is what makes the kit look like the
kit. Reported back as "looks exactly the same and nothing like the
sketch", which was accurate.

Four cards -- a presence picker, a paragraph about what stories are,
contacts, groups -- each on its own white surface with a heading. The
kit's messages screen is one flat list under a title, a search box and
a strip of stories. No amount of restyling the rows inside those cards
was going to get there; the cards were the thing.

🔴 The type scale was the other half, and it was invisible from inside
a single screen. Material 3's defaults are considerably larger than the
kit's, so a section heading rendered at 24sp where the kit says 16 and
every screen inherited it. Set once in the theme, which fixes screens
nobody has touched.

Stories was a heading, a Post button, and -- with nothing posted, which
is the normal state -- three lines of prose explaining what a story is.
Prose where the design has a row of circles is the loudest possible
signal that this is not the same app. It is a strip of faces now, with
the add-circle always first so there is always somewhere to tap.

The tab bar is the kit's floating pill rather than a row of text
buttons; it is the most recognisable single element in the design.
Group rows carry the same avatar and the same 72dp pitch as contact
rows, so the two read as one list rather than two that follow each
other. The presence picker moved to Account, where the kit would put
it, instead of being dropped with the card it lived in.

The buddy list is plain rows in the page's own scroll now. It was a
lazy list bounded at 320dp -- a small scrolling window inside a
scrolling page, which is exactly what a flat list is not. The bound
existed because a lazy list in a scrolling Column is measured with
infinite height and crashes; rendering rows directly sidesteps that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:58:08 -04:00
Paul HittandClaude Opus 5 0d2d0abf4d Take the design kit's layout, keep Flamenet orange
Measured out of `Messenger App V2.sketch` by reading the file's JSON
rather than eyeballing a render, and written down in DESIGN.md so both
apps cite one source instead of drifting.

The layout is theirs; the colour is ours. The kit's primary is blue and
adopting it would mean the app no longer looks like the product it
belongs to, so everywhere the kit says primary, read Flamenet orange.
Only #F3F3F3 is taken directly, as the received-bubble and pill fill.
The typeface is not adopted either -- bundling DM Sans is a licensing
decision, not a layout one -- so the sizes, weights and line heights
land on the system face.

🔴 The bubble corners are asymmetric and that is their whole character:
radius 16 everywhere except one corner at 2, which points at its author
-- bottom-right on a message you sent, top-left on one you received.
Rounding all four equally is what makes a chat app look like every
other chat app, and it is exactly what gets lost when a spec is taken
from a screenshot. Only reading the per-corner radii out of the JSON
found it; the symbol's fixedRadius reports a single number and is
misleading.

Conversation rows now have the kit's 48pt avatar, 16pt gutter and
16/14 type. Android had no avatar at all -- a coloured dot beside two
lines of text -- and no amount of adjusting the type would have made
that read like the design.

The Android chat screen needed more than bubbles. Back and the name sat
on one line, Call on a line of its own beneath, and the identity panel
under that, so a third of the screen was chrome before a message. One
bar now. The composer was an outlined field with two labelled buttons
beside it, which was the last part of the app that looked like a form;
it is the kit's pill, built on BasicTextField because a Material text
field reserves room for a floating label and will not go below 56dp
whatever shape it is given.

The timestamp moved outside the bubble, where the kit puts it. Inside,
a one-word message became a tall two-line box -- "sup" rendered taller
than it was wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:49:37 -04:00
Paul HittandClaude Opus 5 b513936cc1 Android can show an address, and the Account screen looks like the app
Two gaps, one in each direction.

Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.

The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.

A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.

The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:36:58 -04:00
Paul HittandClaude Opus 5 3c32016e2f A reply from another relay has somewhere to land
Closes the two pieces cross-relay messaging was missing, and three bugs
found while testing it.

Inbound threading. The sender's address now travels INSIDE the seal.
It has to: the receiving relay records no sender for a federated
envelope, and the user id in the envelope is a row number on the
SENDER's relay. Filing that in `threads` does not merely lose the
message, it files a stranger's message under whichever local buddy
happens to hold that integer. Remote conversations get their own
keyspace, keyed by `fnid@relay`, and their own section in the list --
without which a message from someone our relay has never heard of
decrypts, notifies, and then leads nowhere. Address is bound to device
first-use-wins: the ratchet proves the device, the address is only a
claim, so a device cannot later move the conversation.

Scanning. Fills the search box rather than opening a chat, so it lands
on the card carrying the "this crosses relays, compare safety numbers"
warning instead of routing around it.

Stories, photos and voice notes were 401. `uploadAttachment` and
`downloadAttachment` were the only two /e2e/* routes that hand-rolled
their request, and set the Application Password directly -- which the
relay refuses once the client holds a token. Text and polling go
through the helpers and kept working, so it read as "stories are
broken" rather than as auth. Android was never affected.

Presence read offline both ways. `MobileRoutes.caller` has always
recorded liveness, but that is the legacy /messenger/* mount. The
app's real sign of life is the long-poll on /e2e/messages, which
authenticates elsewhere and touched nothing -- so somebody who opened
the app and sat in a conversation went offline after five minutes
while their device polled throughout. Fixed on the relay: both apps,
no client release, no new traffic.

Connected services could not be read. The relay encodes `screen_name`
as null and says so in a comment; iOS declared it non-optional and
threw on a well-formed response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:20:32 -04:00
Claude fbc102a99f Cross-relay send, as far as it actually goes
Item 3 through the app: paste an address, open a conversation, send. Where
it stops is recorded honestly below.

Works, driven through the UI on relay1 against a live peer on relay2:
  - a pasted `fnid@relay` is recognised as an address rather than sent to
    the username lookup, which would ask OUR relay about somebody it has
    never heard of and answer "no such user" — true and useless
  - the remote directory and bundles are fetched through our own relay
  - the client REFUSES a bundle whose SPK signature does not verify. Found
    for real: the federation harness registers placeholder keys because it
    only checks envelope plumbing, and a real client rightly threw
    "Invalid SPK signature on recipient bundle" at it
  - with a peer holding real keys, the envelope is sealed and relay1
    accepts it

🔴 Does not work: nothing arrives in the peer's inbox on relay2.
relay1 reports envelopes accepted, relay2's poll returns none. The
relay-to-relay path itself is fine — engine/test/federation.mjs is 11/11
against these same two relays — so something differs between the envelopes
that harness builds and the ones the client does. Not yet found.

And a bug of my own that this caught: transmitRemote checked `unknown` and
`unreachable` and never `accepted`, so a send where the relay took NOTHING
reported success. The app said sent, the inbox stayed empty, and nothing
anywhere said otherwise — the exact failure this codebase keeps producing.
`accepted == 0` is now a failure.

RemoteChatScreen is deliberately separate from ChatScreen: that screen is
built around a row number this relay assigned, with presence and a
server-side buddy list behind it, none of which exists for somebody homed
elsewhere.

55 unit tests passing.
2026-08-27 11:55:41 -04:00
Claude 8c7cd79f13 Send across a relay boundary from the client
Item 3. The relays have federated since the fleet went up — 11/11 against
it — and no client had ever sent a byte across a boundary.

transmitRemote is a PARALLEL path and local transmit is untouched. The
local one is keyed on a row number this relay assigned; a remote peer has
an fnid and a home. Folding them together would mean editing the code where
a mistake loses a message silently, on the path that already works.
Ratchets need no change at all — they are keyed by device id, which is
globally unique.

🔴 Always sealed, and here that is not an improvement but a requirement. A
relay accepting a federated envelope stores it with no sender — it was not
told and must not invent one — so an unsealed envelope arrives
unattributable and is dropped.

Trust gets its own keyspace, keyed by address. Two relays hand out the same
row numbers to different people, so folding a remote peer into the integer
keyspace would let one relay's user 7 inherit another's pins.

⚠️ And I had the model wrong first time round, which is worth recording
because the mistake is easy to repeat. Three things were conflated: sealing
uses the recipient DEVICE's ik_dh, which the remote directory returns; the
delivery key is a separate anti-abuse capability the local relay checks
before accepting unattributed mail; and SendRemoteRequest carries no
delivery key at all. I had put it in the address, which published a
secret-shaped thing for nothing and doubled the length of every address.
The address is just fnid@relay.

One envelope id per MESSAGE, not per device, or a recipient polling several
homes sees the same message once per mailbox. And `unknown` and
`unreachable` are surfaced rather than reduced to a boolean, because with
several homes "sent" is genuinely partial.

55 unit tests passing.
2026-08-27 11:38:39 -04:00
Claude 1a228429a6 Your address as a QR code, and put Redis in the compose files
**The address**

Across a relay boundary the receiving relay stores an envelope with no
sender at all — "we were not told who sent it and must not invent an
attribution" — so a federated message can only be attributed if the sender
sealed it, and sealing needs the recipient's delivery key. Locally that key
is handed over on first contact, riding the ratchet. There is no first
contact to ride across a boundary.

So the address has to carry it: flamenet:<fnid>@<relay>?k=<deliveryKey>.
That is far too long to read aloud or retype, and an address that is nearly
right addresses somebody else — hence a QR code, with the string underneath
because a scanner is not always to hand.

Six tests. The ones that earn their place: base64 contains + and /, and
without percent-encoding the / ends the host and the + decodes as a space;
and a relay is a hostname, so a card naming a URL is refused rather than
letting it point the client at a path on somebody else's host. The screen
also says when a card has no delivery key, rather than handing over one
that silently cannot receive a first message from another relay.

⚠️ Error correction M, not H: at H the module count grows enough that the
code stops scanning from a phone held at arm's length, which is the only
way anyone will use it. And .interpolation(.none), or the modules blur and
it will not scan at all.

**The compose files**

Postgres was in both; Redis was in neither, and docker-compose.prod.yml had
drifted from what actually runs — it was missing FN_REDIS_URL,
FN_DELETE_ON_ACK and the five APNs variables too. A self-hoster following
the documented compose got no Redis at all.

Both now carry it, marked optional and deliberately unpersisted: a queued
token only says "look now", the poll cursor is the truth, so losing every
token on a restart costs one poll and nothing else.
2026-08-27 11:32:31 -04:00
Paul Hitt 93bbc914b8 Merge branch 'clean/nine-items' into 'main'
Document the nine, test Android sign-up, correct the federation claim

See merge request paulhitt/flamenet-messenger-ios!40
2026-08-27 15:14:36 +00:00
Claude f8d52c8c75 Document the nine, test Android sign-up, and correct the federation claim
api-docs described the protocol thoroughly and never said how to start a
relay. It now has a compose file, every variable with whether it is
actually required, and curl checks. The parts worth reading twice, each of
which has bitten somebody: FN_RELAY_AUDIENCE must be the hostname clients
reach, because a mismatch fails as a signature error rather than a name
error; FN_TURN_HOST must stay unproxied in DNS or every relayed call fails
while the record looks healthy; FN_DELETE_ON_ACK is Postgres-only; and
FN_LOG_LEVEL must never be debug in production, because a protocol URL
contains a device id.

Android sign-up had never been driven. It now passes against all four
relays. Two silent faults in the driving, both of which reported a broken
sign-up for a form that was never filled: `adb shell input text` drops
characters on longer strings — "@example.test" arrived as "@exs" — and the
first fix used `fold | while read`, which swallows the last chunk when
there is no trailing newline. Every field is typed in chunks and read back.

And the comparison page claimed "Servers can talk to each other — Yes"
beside Signal's No. True of the relay — 11/11 against the live fleet — and
false of the product somebody holds, because neither app calls a single
federation route. It now says "Server-to-server", that the apps do not use
it yet, and that we would rather say so than let you find out.

NINE-ITEMS.md records what was verified and how, including that no payment
was ever taken end to end.

⚠️ The demo passphrase is deliberately absent: this repository is public
and those are live accounts. Scripts/android-signup.sh reads
FN_DEMO_PASSPHRASE and refuses to run without it.
2026-08-27 11:03:21 -04:00
946 changed files with 127055 additions and 57646 deletions
+1
View File
@@ -0,0 +1 @@
4b4042f72880758ee2dea7713f7a2cb63fa12f73
+31
View File
@@ -0,0 +1,31 @@
# Why this repository pins 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.
**The pin exists because a merge in that repository could stop all work in
this one.** flamenet-relay learned it the hard way, its jobs cloned `main` at
HEAD, and that switch was thrown three times in twelve hours
(flamenet-relay#56), once producing a deadlock with no way out from inside
either repository. Its `.e2e-ref.md` has the full account.
A pin alone is worse than HEAD, though. The argument for HEAD is real: an
app tested against a pinned old harness is tested against a pinned old spec. If
the vectors move and nothing says so, `ProtocolVectorTests` quietly goes on
checking last month's protocol and reports green, which is precisely the
failure that suite exists to prevent, reintroduced one level up.
That is what `vectors-head` is for, and why it is `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
One MR, all parts together:
1. Put the new e2e commit in `.e2e-ref`.
2. `cp <e2e>/test/vectors/*.json Tests/Vectors/`
3. Read what changed before accepting it. A moved vector is a protocol change,
and this app is one of the two ends that has to agree about it, the other
is `flamenet-messenger-android`, which pins the same commit and should move
in step.
+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
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Regenerate the Xcode project after anything that can change project.yml.
#
# `FlamenetMessenger.xcodeproj` is generated by xcodegen and is not in git, so
# a pull updates project.yml and leaves the project Xcode opens exactly as it
# was. That is how a stale version number gets archived: everything on disk is
# correct and the one file Xcode reads is a day old.
#
# Installed by Scripts/generate.sh, which sets core.hooksPath to this
# directory. Silent when nothing changed.
set -u
cd "$(git rev-parse --show-toplevel)" || exit 0
[ -f project.yml ] || exit 0
./Scripts/generate.sh >/dev/null 2>&1 && echo "xcodegen: project regenerated"
exit 0
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Regenerate the Xcode project after anything that can change project.yml.
#
# `FlamenetMessenger.xcodeproj` is generated by xcodegen and is not in git, so
# a pull updates project.yml and leaves the project Xcode opens exactly as it
# was. That is how a stale version number gets archived: everything on disk is
# correct and the one file Xcode reads is a day old.
#
# Installed by Scripts/generate.sh, which sets core.hooksPath to this
# directory. Silent when nothing changed.
set -u
cd "$(git rev-parse --show-toplevel)" || exit 0
[ -f project.yml ] || exit 0
./Scripts/generate.sh >/dev/null 2>&1 && echo "xcodegen: project regenerated"
exit 0
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Regenerate the Xcode project after anything that can change project.yml.
#
# `FlamenetMessenger.xcodeproj` is generated by xcodegen and is not in git, so
# a pull updates project.yml and leaves the project Xcode opens exactly as it
# was. That is how a stale version number gets archived: everything on disk is
# correct and the one file Xcode reads is a day old.
#
# Installed by Scripts/generate.sh, which sets core.hooksPath to this
# directory. Silent when nothing changed.
set -u
cd "$(git rev-parse --show-toplevel)" || exit 0
[ -f project.yml ] || exit 0
./Scripts/generate.sh >/dev/null 2>&1 && echo "xcodegen: project regenerated"
exit 0
+18 -14
View File
@@ -6,6 +6,19 @@ xcuserdata/
*.xcscmblueprint
*.xccheckout
# Derived data from the documented test command, which passes
# `-derivedDataPath ci-build`. Note this does NOT match the `build/` rule above
# — that matches a directory named exactly "build" — and 3,500 files of index
# and compilation cache were staged once because of it.
ci-build/
# The iPad simulator build used to photograph the two-column layout. The same
# derived-data shape as ci-build, and just as large.
ipad-build/
# Same trap, second spelling: the signed device build uses
# `-derivedDataPath device-build`. Any new derived-data path needs a line here.
device-build/
# XcodeGen output — regenerate with `xcodegen generate`.
# Comment the next line out if you prefer to commit the project file.
FlamenetMessenger.xcodeproj/
@@ -17,25 +30,16 @@ FlamenetMessenger.xcodeproj/
.build/
.swiftpm/
# Swift build products
server/.build/
# Secrets. `.env` holds the database password, the at-rest key, the issuer
# signing seed and the Stripe secret — none of which belong in git, and the
# issuer seed in particular cannot be rotated without invalidating every token
# already issued.
.env
server/.env
*.pem
# Android / Gradle build products
android/.gradle/
android/build/
android/*/build/
# Machine-specific SDK path.
android/local.properties
# ⚠️ The `server/`, `android/` and `engine/` rules that used to be here went
# with the 2026-08-28 split — those subtrees are separate repositories now and
# each carries its own ignores. Re-adding a rule here does not affect them.
# The compiled Swift interop driver. A macOS arm64 binary, rebuilt by
# engine/test/interop/build.sh — committing it put an unrunnable file in front
# of the Linux CI runner, which reported it as a shell syntax error.
engine/test/interop/interop-swift
# CI clones the pinned flamenet-e2e here to check Tests/Vectors is current.
ci-vectors*/
-174
View File
@@ -1,174 +0,0 @@
# Flamenet Messenger — one repo, three implementations.
#
# The iOS app, the Swift relay, and the JS engine that both are checked against
# now live together. This pipeline runs what a Linux runner can run, publishes
# the engine to its public mirror, and is explicit about the two things it
# cannot run rather than letting a green pipeline imply they passed.
#
# Everything runs on our own runner (docker executor on the OVH box), so it
# costs zero GitLab SaaS minutes. Untagged jobs fall through to GitLab's shared
# runners and burn the shared quota, which is why `default: tags` is set once
# here rather than per job.
stages: [test, publish]
default:
tags: [vps]
variables:
GIT_DEPTH: "20"
.rules: &rules
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# ---------------------------------------------------------------------------
# test
# ---------------------------------------------------------------------------
# The engine suites.
#
# Node 22, because the engine needs WebCrypto X25519/Ed25519 (>= 20). Node 25
# additionally has native ML-KEM-768, which lets conformance cross-check the
# vendored backend against it; on 22 that section reports as skipped rather
# than silently passing, which is the honest default for CI.
#
# `crosslayer.mjs` is gone with the WordPress plugin it checked; `interop.mjs`
# and `relay.mjs` have their own jobs below. What is left is the three suites a
# bare Linux node image can actually run.
engine:
<<: *rules
stage: test
image: node:22
script:
- cd engine
- node test/conformance.mjs
- node test/ratchet.mjs
- node test/client.mjs
# The Kotlin engine, against the same published vectors as the other two.
#
# Runs on a plain JDK image with no Android SDK, which is the point: the engine
# is a Kotlin/JVM library and nothing in the protocol is Android-specific, so a
# third implementation can be gated in CI on Linux rather than on somebody's
# laptop with an emulator.
android-engine:
<<: *rules
stage: test
image: gradle:9-jdk21
script:
- cd android
- gradle :engine:test --no-daemon --console=plain
# The relay: built, then driven over real HTTP against SPEC.md §8.
#
# This is the job that makes the relay's spec-conformance a gate rather than a
# claim. The image is pinned — an unpinned toolchain has broken every deploy in
# this account before, and a compiler that changes underneath you fails in ways
# that look like your own regression.
#
# 6.1, not 6.0: postgres-kit 2.16.1 declares swift-tools-version 6.1, and a 6.0
# toolchain refuses to resolve it. The Dockerfile is on 6.1 for the same reason,
# and the two must not drift.
relay:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [server/.build/]
before_script:
# Node 22, not the distribution's.
#
# Ubuntu Noble ships Node 18, where `globalThis.crypto` is not exposed by
# default. The engine's own job pins node:22 for that reason and this one
# quietly did not — which stayed invisible until §20 landed, because
# ML-DSA signing is the first code path to reach for
# `crypto.getRandomValues` at run time rather than at import.
- apt-get update -qq
- apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y -qq nodejs
- node -e "if (!globalThis.crypto?.getRandomValues) { console.error('no WebCrypto on ' + process.version); process.exit(1) }"
- node --version
script:
- cd server && swift build
- cd ../engine && node test/relay.mjs
timeout: 30 minutes
# Cross-engine interop — the JS engine against the iOS app's OWN Swift sources.
#
# Cannot run here: the driver builds from Sources/E2E/*.swift against CryptoKit,
# which is Apple-only. Declared rather than omitted, because it is the
# tiebreaker between three implementations and "CI is green" must not be read as
# "interop was checked". Run it on a Mac before merging anything under engine/src/
# or Sources/E2E/.
interop:
stage: test
rules: [{ when: never }]
script:
- cd engine && node test/interop.mjs
# The iOS app itself. Needs Xcode; there is no Linux path to it at all. Same
# reasoning as interop: named so its absence is visible.
ios:
stage: test
rules: [{ when: never }]
script:
- xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger build
# ---------------------------------------------------------------------------
# publish
# ---------------------------------------------------------------------------
# Mirror the engine subtree to the PUBLIC repository.
#
# `engine/` and `server/` are published to the root of
# gitlab.com/paulhitt/flamenet-e2e (MIT). The
# iOS app is NOT: it stays in this private repo, and the mirror is built from a
# path allowlist rather than an ignore list, so a new top-level directory here
# is private by default instead of published by accident.
#
# MANUAL on purpose. Everything else in this pipeline is reversible; a push to a
# public repository is not — a key that lands there is leaked at the moment of
# the push, whatever the next commit says. The runner does the work; a person
# decides when. Requires MIRROR_TOKEN: a project access token on flamenet-e2e
# with write_repository, set masked in CI/CD variables.
mirror:
stage: publish
image: alpine:3.20
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
variables:
MIRROR_REPO: gitlab.com/paulhitt/flamenet-e2e.git
script:
- apk add --no-cache git bash rsync
# The gate. It reads what is about to be published, not what is in HEAD.
- bash engine/scripts/secret-sweep.sh engine
- bash engine/scripts/secret-sweep.sh server
- |
set -eu
test -n "${MIRROR_TOKEN:-}" || { echo "MIRROR_TOKEN is not set"; exit 1; }
git clone --depth 1 "https://oauth2:${MIRROR_TOKEN}@${MIRROR_REPO}" /tmp/mirror
# Allowlist, not `rsync --delete` of everything: an explicit list is the
# difference between publishing the engine and publishing whatever
# happened to be in the directory. `Sources/` — the iOS app — is not on
# it, and cannot be added by accident.
for path in src test docs vendor scripts \
LICENSE README.md SECURITY.md SELF-HOSTING.md package.json; do
[ -e "engine/$path" ] || { echo "missing from source: engine/$path"; exit 1; }
rsync -a --delete "engine/$path" /tmp/mirror/
done
# The Swift server ships with the engine: publishing the protocol and
# withholding the thing that speaks it is the gap the notes call out in
# Signal, and it costs nothing to not repeat.
rsync -a --delete --exclude='.build/' server /tmp/mirror/
cd /tmp/mirror
git config user.email "ci@flamenet.io"
git config user.name "Flamenet CI"
git add -A
git diff --cached --quiet && { echo "mirror already current"; exit 0; }
git commit -m "Sync engine and relay from flamenetmessenger@${CI_COMMIT_SHORT_SHA}"
git push origin HEAD:main
+3
View File
@@ -0,0 +1,3 @@
[submodule "Packages/Obfs4"]
path = Packages/Obfs4
url = https://git.extant2000.com/extant2000/flamenet-elligator2.git
+81
View File
@@ -0,0 +1,81 @@
{
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"indentBlankLines": false,
"indentConditionalCompilationBlocks": true,
"indentSwitchCaseLabels": false,
"indentation": {
"spaces": 4
},
"lineBreakAroundMultilineExpressionChainComponents": false,
"lineBreakBeforeControlFlowKeywords": false,
"lineBreakBeforeEachArgument": false,
"lineBreakBeforeEachGenericRequirement": false,
"lineBreakBetweenDeclarationAttributes": false,
"lineLength": 100,
"maximumBlankLines": 1,
"multiElementCollectionTrailingCommas": true,
"multilineTrailingCommaBehavior": "keptAsWritten",
"noAssignmentInExpressions": {
"allowedFunctions": [
"XCTAssertNoThrow"
]
},
"orderedImports": {
"includeConditionalImports": false,
"shouldGroupImports": true
},
"prioritizeKeepingFunctionOutputTogether": false,
"reflowMultilineStringLiterals": "never",
"respectsExistingLineBreaks": true,
"rules": {
"AllPublicDeclarationsHaveDocumentation": false,
"AlwaysUseLiteralForEmptyCollectionInit": false,
"AlwaysUseLowerCamelCase": true,
"AmbiguousTrailingClosureOverload": true,
"AvoidRetroactiveConformances": true,
"BeginDocumentationCommentWithOneLineSummary": false,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
"FullyIndirectEnum": true,
"GroupNumericLiterals": true,
"IdentifiersMustBeASCII": true,
"NeverForceUnwrap": false,
"NeverUseForceTry": false,
"NeverUseImplicitlyUnwrappedOptionals": false,
"NoAccessLevelOnExtensionDeclaration": true,
"NoAssignmentInExpressions": true,
"NoBlockComments": false,
"NoCasesWithOnlyFallthrough": true,
"NoEmptyLinesOpeningClosingBraces": false,
"NoEmptyTrailingClosureParentheses": true,
"NoLabelsInCasePatterns": true,
"NoLeadingUnderscores": false,
"NoParensAroundConditions": true,
"NoPlaygroundLiterals": true,
"NoVoidReturnOnFunctionSignature": true,
"OmitExplicitReturns": false,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"ReplaceForEachWithForLoop": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"TypeNamesShouldBeCapitalized": true,
"UseEarlyExits": false,
"UseExplicitNilCheckInConditions": true,
"UseLetInEveryBoundCaseVariable": true,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": false,
"ValidateDocumentationComments": false
},
"spacesAroundRangeFormationOperators": false,
"spacesBeforeEndOfLineComments": 2,
"tabWidth": 8,
"version": 1
}
+3
View File
@@ -0,0 +1,3 @@
FlameNet sticker pack `flamenet.retro`: 24 original drawings in `svg/`, rendered to the 288x288 transparent PNGs the app ships in `Resources/stickers/flamenet.retro/`, in the picker order of `Resources/stickers/flamenet.retro.json`. The Android app ships the same PNG bytes.
To re-render after editing an SVG, run this from this folder with Node 22 or later: `MANIFEST=../../Resources/stickers/flamenet.retro.json OUT=../../Resources/stickers/flamenet.retro PW_DIR=<folder with node_modules/playwright-core> CHROME=<Chromium binary> node render.mjs`. It also writes two contact sheets here, which are not shipped.
+191
View File
@@ -0,0 +1,191 @@
// Renders the FlameNet sticker pack: svg/<id>.svg -> flamenet.retro/<id>.png
// (288x288, transparent, 8-bit palette PNG) and the two contact sheets.
// Picker order and names come from flamenet.retro.json.
//
// Needs Node 22 or later and playwright-core with a Chromium build:
// PW_DIR=<folder whose node_modules has playwright-core> \
// CHROME=<chromium or chrome-headless-shell binary> node render.mjs
// Without PW_DIR it imports playwright-core from here; without CHROME it
// uses the browser Playwright installed. MANIFEST and OUT point it at other
// copies of flamenet.retro.json and the PNG folder; both default to here.
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SIZE = 288;
const SHEET = 96;
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.PW_DIR
? require.resolve("playwright-core", { paths: [process.env.PW_DIR] })
: "playwright-core");
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST || path.join(HERE, "flamenet.retro.json"), "utf8"));
// The sources have to render the same everywhere: no ids, no references,
// no fonts, one canvas size.
function check(id, svg) {
const bad = [
[/\sid\s*=/, "an id"],
[/href|url\(/, "a reference"],
[/<(text|tspan|image|style|script|foreignObject)\b|font-/, "text, fonts or embedded content"],
].find(([re]) => re.test(svg));
if (bad) throw new Error(`${id}.svg has ${bad[1]}`);
if (!/^<svg [^>]*viewBox="0 0 200 200"/.test(svg)) throw new Error(`${id}.svg needs viewBox="0 0 200 200"`);
}
// 8-bit palette PNG.
// Colours compared premultiplied, so faint edge pixels count for little.
const unpack = k => [k >>> 24, (k >>> 16) & 255, (k >>> 8) & 255, k & 255];
const pm = k => { const [r, g, b, a] = unpack(k); return [r * a / 255, g * a / 255, b * a / 255, a]; };
const pack = (r, g, b, a) => ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
// Splits the leftover edge colours into n boxes, each time cutting the box
// with the largest squared error along its widest channel.
function medianCut(entries, n) {
const sse = box => {
let w = 0; const s = [0, 0, 0, 0], q = [0, 0, 0, 0];
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) { s[d] += p.v[d] * p.c; q[d] += p.v[d] * p.v[d] * p.c; } }
const per = q.map((x, d) => x - s[d] * s[d] / w);
return { total: per.reduce((a, b) => a + b, 0), dim: per.indexOf(Math.max(...per)), w };
};
let boxes = [entries.map(([k, c]) => ({ k, c, v: pm(k) }))].filter(b => b.length);
while (boxes.length && boxes.length < n) {
let best = -1, bestErr = 0, dim = 0;
boxes.forEach((b, i) => {
if (b.length < 2) return;
const e = sse(b);
if (e.total > bestErr) { bestErr = e.total; best = i; dim = e.dim; }
});
if (best < 0) break;
const box = boxes[best].slice().sort((a, b) => a.v[dim] - b.v[dim] || a.k - b.k);
const half = box.reduce((a, p) => a + p.c, 0) / 2;
let acc = 0, cut = 1;
for (let i = 0; i < box.length - 1; i++) { acc += box[i].c; if (acc >= half) { cut = i + 1; break; } }
boxes.splice(best, 1, box.slice(0, cut), box.slice(cut));
}
return boxes.map(box => {
let w = 0; const s = [0, 0, 0, 0];
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) s[d] += p.v[d] * p.c; }
const a = Math.round(s[3] / w);
if (a === 0) return 0;
const ch = d => Math.max(0, Math.min(255, Math.round((s[d] / w) * 255 / (s[3] / w))));
return pack(ch(0), ch(1), ch(2), a);
});
}
function chunk(type, data) {
const head = Buffer.alloc(4); head.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body) >>> 0);
return Buffer.concat([head, body, crc]);
}
function encodePalettePNG(rgba, w, h) {
const n = w * h, keys = new Uint32Array(n), hist = new Map();
for (let i = 0; i < n; i++) {
const a = rgba[4 * i + 3];
const k = a === 0 ? 0 : pack(rgba[4 * i], rgba[4 * i + 1], rgba[4 * i + 2], a);
keys[i] = k;
hist.set(k, (hist.get(k) || 0) + 1);
}
const byCount = [...hist.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
let palette;
if (byCount.length <= 256) {
palette = byCount.map(e => e[0]);
} else {
// Flat fills keep their exact colour; only edge blends are merged.
const fixed = byCount.filter(e => e[1] >= 24).slice(0, 128).map(e => e[0]);
const taken = new Set(fixed);
palette = [...new Set(fixed.concat(medianCut(byCount.filter(e => !taken.has(e[0])), 256 - fixed.length)))];
}
// Translucent entries first, so tRNS can stop at the last one.
palette.sort((a, b) => ((a & 255) === 255) - ((b & 255) === 255) || (a & 255) - (b & 255) || a - b);
const pv = palette.map(pm);
const index = new Map();
for (const k of hist.keys()) {
const v = pm(k);
let best = 0, bd = Infinity;
for (let j = 0; j < pv.length; j++) {
const d = (v[0] - pv[j][0]) ** 2 + (v[1] - pv[j][1]) ** 2 + (v[2] - pv[j][2]) ** 2 + 2 * (v[3] - pv[j][3]) ** 2;
if (d < bd) { bd = d; best = j; }
}
index.set(k, best);
}
const raw = Buffer.alloc(h * (w + 1));
for (let y = 0; y < h; y++) {
raw[y * (w + 1)] = 0;
for (let x = 0; x < w; x++) raw[y * (w + 1) + 1 + x] = index.get(keys[y * w + x]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 3;
const plte = Buffer.alloc(palette.length * 3), alpha = [];
palette.forEach((k, j) => {
const [r, g, b, a] = unpack(k);
plte[3 * j] = r; plte[3 * j + 1] = g; plte[3 * j + 2] = b;
if (a < 255) alpha.push(a);
});
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", ihdr),
chunk("PLTE", plte),
...(alpha.length ? [chunk("tRNS", Buffer.from(alpha))] : []),
chunk("IDAT", zlib.deflateSync(raw, { level: 9, memLevel: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Render.
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
const page = await browser.newPage({ viewport: { width: SIZE, height: SIZE }, deviceScaleFactor: 1 });
const outDir = process.env.OUT || path.join(HERE, "flamenet.retro");
fs.mkdirSync(outDir, { recursive: true });
const pngs = {};
for (const s of manifest.stickers) {
const svg = fs.readFileSync(path.join(HERE, "svg", `${s.id}.svg`), "utf8").trim();
check(s.id, svg);
if (s.image !== `${s.id}.png`) throw new Error(`${s.id}: image should be ${s.id}.png`);
const sized = svg.replace("<svg ", `<svg width="${SIZE}" height="${SIZE}" `);
const b64 = await page.evaluate(async ({ sized, size }) => {
const img = new Image();
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(sized);
await img.decode();
const canvas = new OffscreenCanvas(size, size);
const g = canvas.getContext("2d");
g.drawImage(img, 0, 0, size, size);
const d = g.getImageData(0, 0, size, size).data;
let bin = "";
for (let i = 0; i < d.length; i += 0x8000) bin += String.fromCharCode.apply(null, d.subarray(i, i + 0x8000));
return btoa(bin);
}, { sized, size: SIZE });
const png = encodePalettePNG(Buffer.from(b64, "base64"), SIZE, SIZE);
fs.writeFileSync(path.join(outDir, s.image), png);
pngs[s.id] = png;
console.log(`${s.image.padEnd(22)} ${String(png.length).padStart(6)} bytes`);
}
// Contact sheets, 96 px on white and on black.
const esc = t => t.replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`);
for (const [name, bg, fg] of [["sheet-light.png", "#ffffff", "#333333"], ["sheet-dark.png", "#000000", "#e8e8e8"]]) {
const cells = manifest.stickers.map(s =>
`<figure><img src="data:image/png;base64,${pngs[s.id].toString("base64")}" width="${SHEET}" height="${SHEET}" alt=""><figcaption>${esc(s.name)}</figcaption></figure>`).join("");
await page.setViewportSize({ width: 6 * 124 + 24, height: 200 });
await page.setContent(`<!doctype html><html><head><style>
body { margin: 0; padding: 12px; background: ${bg}; color: ${fg}; font: 12px -apple-system, Helvetica, Arial, sans-serif; }
main { display: grid; grid-template-columns: repeat(6, 124px); }
figure { margin: 0; padding: 8px 0 10px; text-align: center; }
img { display: block; margin: 0 auto 4px; }
</style></head><body><main>${cells}</main></body></html>`);
await page.screenshot({ path: path.join(HERE, name), fullPage: true });
console.log(`${name} written`);
}
await browser.close();
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-8 100 100)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z"/>
<path d="M92 150 L100 139 L108 150 Z"/>
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none"/>
</g>
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round"/>
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z" fill="#FF3B3B" stroke="#333" stroke-width="2"/>
<path d="M92 150 L100 139 L108 150 Z" fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<ellipse cx="76" cy="50" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(20 76 50)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 938 B

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 108)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<circle cx="100" cy="24" r="7"/>
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke-width="17"/>
<rect x="24" y="64" width="152" height="88" rx="10"/>
</g>
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke="#6B3E1F" stroke-width="3" stroke-linecap="round"/>
<circle cx="100" cy="24" r="7" fill="#B8B8B8" stroke="#333" stroke-width="2"/>
<rect x="24" y="64" width="152" height="88" rx="10" fill="#FFCC00" stroke="#333" stroke-width="2"/>
<rect x="31" y="71" width="138" height="74" rx="6" fill="none" stroke="#E6A800" stroke-width="2"/>
<circle cx="44" cy="68" r="3" fill="#6B3E1F"/>
<circle cx="156" cy="68" r="3" fill="#6B3E1F"/>
<g fill="#003399" fill-rule="evenodd">
<path transform="translate(46.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
<path transform="translate(84.5 0)" d="M0 131 V85 H18 C26 85 30 90.5 30 98 C30 104 27 108 22.5 110 L31 131 H19.5 L12 112.5 H11 V131 Z M11 94 H17 C19.5 94 20 96 20 98.5 C20 101 19.5 103.5 17 103.5 H11 Z"/>
<path transform="translate(122.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<ellipse cx="100" cy="174" rx="80" ry="12"/>
<rect x="26" y="92" width="148" height="80" rx="10"/>
<rect x="57" y="56" width="10" height="40"/>
<rect x="95" y="56" width="10" height="40"/>
<rect x="133" y="56" width="10" height="40"/>
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<ellipse cx="100" cy="174" rx="80" ry="12" fill="#CCE6FF"/>
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="#FF99BB"/>
<rect x="30" y="146" width="140" height="8" fill="#FFFDF5" stroke-width="1.5"/>
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="none"/>
<path d="M26 102 Q26 92 36 92 H164 Q174 92 174 102 V112 C174 120 160 120 160 112 C160 108 152 108 150 112 V124 C150 132 136 132 136 124 V114 C136 108 124 108 120 114 C118 120 108 120 106 114 C104 108 96 108 94 114 V128 C94 136 80 136 80 128 V114 C80 108 70 108 66 114 C64 120 54 120 52 114 C50 108 42 108 40 114 V120 C40 128 26 128 26 120 Z" fill="#FFFDF5"/>
<rect x="57" y="62" width="10" height="30" fill="#3366FF"/>
<rect x="95" y="62" width="10" height="30" fill="#FFCC00"/>
<rect x="133" y="62" width="10" height="30" fill="#33AA33"/>
</g>
<g stroke="#333" stroke-width="2.5" stroke-linecap="round">
<path d="M62 62 V55 M100 62 V55 M138 62 V55"/>
</g>
<g fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
</g>
<g fill="#FFCC00">
<ellipse cx="62" cy="46" rx="3.5" ry="5"/>
<ellipse cx="100" cy="46" rx="3.5" ry="5"/>
<ellipse cx="138" cy="46" rx="3.5" ry="5"/>
</g>
<g>
<rect x="46" y="135" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(30 50 137)"/>
<rect x="112" y="136" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-25 116 138)"/>
<rect x="152" y="134" width="9" height="4" rx="2" fill="#FFFFFF" transform="rotate(40 156 136)"/>
<rect x="60" y="160" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-30 64 162)"/>
<rect x="96" y="161" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(15 100 163)"/>
<rect x="136" y="160" width="9" height="4" rx="2" fill="#33AA33" transform="rotate(-35 140 162)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z"/>
<path d="M100 60 V74"/>
<rect x="64" y="72" width="72" height="100" rx="5"/>
<ellipse cx="100" cy="176" rx="62" ry="14"/>
</g>
<path d="M100 60 V74" stroke="#333" stroke-width="3.5" stroke-linecap="round"/>
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z" fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M100 30 C109 43 109 54 100 60 C91 54 91 43 100 30 Z" fill="#FFCC00"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<ellipse cx="100" cy="177" rx="62" ry="13" fill="#E6A800"/>
<ellipse cx="100" cy="171" rx="62" ry="13" fill="#FFCC00"/>
<rect x="64" y="72" width="72" height="100" rx="5" fill="#FFF6CC"/>
<path d="M64 77 Q64 72 69 72 H131 Q136 72 136 77 V92 C136 98 127 98 127 92 C127 87 120 87 120 93 V104 C120 113 108 113 108 104 V93 C108 87 99 87 97 93 C95 99 86 99 86 93 C86 88 79 87 77 93 V98 C77 105 64 105 64 98 Z" fill="#FFFFFF"/>
</g>
<rect x="73" y="112" width="8" height="50" rx="4" fill="#fff" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z"/>
<ellipse cx="90" cy="66" rx="50" ry="10"/>
<path d="M140 86 C178 84 180 138 140 136" fill="none" stroke-width="35"/>
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" fill="none" stroke-width="19"/>
</g>
<g fill="none" stroke-linecap="round">
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" stroke="#8C9BB0" stroke-width="5"/>
<path d="M140 86 C178 84 180 138 140 136" stroke="#333" stroke-width="21"/>
<path d="M140 86 C178 84 180 138 140 136" stroke="#CC0000" stroke-width="17"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z" fill="#CC0000"/>
<ellipse cx="90" cy="66" rx="50" ry="10" fill="#CC0000"/>
<ellipse cx="90" cy="67" rx="43" ry="6.5" fill="#6B3E1F"/>
</g>
<rect x="40" y="88" width="100" height="10" fill="#fff"/>
<path d="M40 70 V146 Q40 176 70 176 H110 Q140 176 140 146 V70" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M40 88 H140 M40 98 H140" stroke="#333" stroke-width="1.5"/>
<rect x="50" y="106" width="8" height="52" rx="4" fill="#fff" opacity=".4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="24" y="20" width="152" height="126" rx="16"/>
<path d="M80 144 H120 L126 160 H74 Z"/>
<rect x="46" y="158" width="108" height="20" rx="9"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M80 144 H120 L126 160 H74 Z" fill="#D6CFB6"/>
<rect x="46" y="158" width="108" height="20" rx="9" fill="#E8E2CC"/>
<rect x="24" y="20" width="152" height="126" rx="16" fill="#E8E2CC"/>
<rect x="40" y="34" width="120" height="88" rx="14" fill="#003399"/>
<rect x="50" y="129" width="18" height="8" rx="3" fill="#D6CFB6" stroke-width="1.5"/>
<circle cx="148" cy="133" r="4.5" fill="#33AA33" stroke-width="1.5"/>
</g>
<rect x="50" y="42" width="28" height="6" rx="3" fill="#fff" opacity=".35"/>
<g fill="#FFCC00">
<rect x="73" y="56" width="12" height="21" rx="6"/>
<rect x="115" y="56" width="12" height="21" rx="6"/>
</g>
<path d="M72 90 Q100 114 128 90" fill="none" stroke="#FFCC00" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<circle cx="46" cy="38" r="13"/>
<circle cx="154" cy="38" r="13"/>
<rect x="40" y="46" width="12" height="130"/>
<rect x="148" y="46" width="12" height="130"/>
<rect x="28" y="168" width="36" height="12" rx="3"/>
<rect x="136" y="168" width="36" height="12" rx="3"/>
<rect x="24" y="66" width="152" height="80"/>
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22" stroke-width="17" stroke-linecap="round"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="40" y="50" width="12" height="122" fill="#E6E6E6"/>
<rect x="148" y="50" width="12" height="122" fill="#E6E6E6"/>
<rect x="28" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
<rect x="136" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
<rect x="24" y="66" width="152" height="32" fill="#FFCC00"/>
<rect x="24" y="116" width="152" height="30" fill="#FFCC00"/>
</g>
<g fill="#333">
<polygon points="24,88 24,72 30,66 46,66"/>
<polygon points="46,98 30,98 62,66 78,66"/>
<polygon points="78,98 62,98 94,66 110,66"/>
<polygon points="110,98 94,98 126,66 142,66"/>
<polygon points="142,98 126,98 158,66 174,66"/>
<polygon points="158,98 176,80 176,96 174,98"/>
<polygon points="24,138 24,122 30,116 46,116"/>
<polygon points="48,146 32,146 62,116 78,116"/>
<polygon points="80,146 64,146 94,116 110,116"/>
<polygon points="112,146 96,146 126,116 142,116"/>
<polygon points="144,146 128,146 158,116 174,116"/>
<polygon points="160,146 176,130 176,146"/>
</g>
<g fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="24" y="66" width="152" height="32"/>
<rect x="24" y="116" width="152" height="30"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="38" y="44" width="16" height="8" rx="2" fill="#555"/>
<rect x="146" y="44" width="16" height="8" rx="2" fill="#555"/>
<path d="M34 44 A12 12 0 0 1 58 44 Z" fill="#FF9900"/>
<path d="M142 44 A12 12 0 0 1 166 44 Z" fill="#FF9900"/>
</g>
<g fill="#fff" opacity=".7">
<ellipse cx="41" cy="37" rx="2.5" ry="4"/>
<ellipse cx="149" cy="37" rx="2.5" ry="4"/>
</g>
<g stroke="#FF9900" stroke-width="3" stroke-linecap="round">
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#FF5A1F" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M100 180 C76 180 62 162 62 140 C62 114 84 96 106 58 C102 84 138 108 138 140 C138 162 122 180 100 180 Z" fill="#FF9900"/>
<path d="M100 176 C86 176 78 166 78 152 C78 134 92 124 102 104 C110 124 122 136 122 152 C122 166 114 176 100 176 Z" fill="#FFCC00"/>
<ellipse cx="100" cy="160" rx="9" ry="11" fill="#FFF3B0"/>
</svg>

After

Width:  |  Height:  |  Size: 924 B

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 100)">
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#003399"/>
<rect x="58" y="28" width="80" height="48" fill="#C9D1DE"/>
<rect x="110" y="37" width="16" height="30" rx="2" fill="#003399"/>
<rect x="46" y="96" width="108" height="76" rx="4" fill="#FFFFFF"/>
<path d="M46 106 V100 Q46 96 50 96 H150 Q154 96 154 100 V106 Z" fill="#FF3B3B"/>
</g>
<g fill="#B8C8F0">
<rect x="56" y="120" width="88" height="5" rx="2.5"/>
<rect x="56" y="136" width="88" height="5" rx="2.5"/>
<rect x="56" y="152" width="58" height="5" rx="2.5"/>
</g>
<rect x="34" y="38" width="6" height="46" rx="3" fill="#fff" opacity=".35"/>
<rect x="64" y="34" width="5" height="36" rx="2.5" fill="#fff" opacity=".6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<circle cx="100" cy="48" r="30"/>
<circle cx="62" cy="70" r="30"/>
<circle cx="138" cy="70" r="30"/>
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)"/>
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)"/>
<path d="M62 90 L100 112 L138 90 Z"/>
<path d="M40 106 H160 L104 188 H96 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)" fill="#43A047"/>
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)" fill="#43A047"/>
</g>
<g stroke="#2E7D32" stroke-width="5" stroke-linecap="round">
<path d="M62 92 L88 110 M100 70 V110 M138 92 L112 110"/>
</g>
<g stroke="#333" stroke-width="2">
<g fill="#FFFFFF">
<circle cx="100" cy="31" r="13"/><circle cx="114.7" cy="39.5" r="13"/><circle cx="114.7" cy="56.5" r="13"/><circle cx="100" cy="65" r="13"/><circle cx="85.3" cy="56.5" r="13"/><circle cx="85.3" cy="39.5" r="13"/>
</g>
<circle cx="100" cy="48" r="9" fill="#FF9900"/>
<g fill="#FF7AA2">
<circle cx="62" cy="53" r="13"/><circle cx="76.7" cy="61.5" r="13"/><circle cx="76.7" cy="78.5" r="13"/><circle cx="62" cy="87" r="13"/><circle cx="47.3" cy="78.5" r="13"/><circle cx="47.3" cy="61.5" r="13"/>
</g>
<circle cx="62" cy="70" r="9" fill="#FFCC00"/>
<g fill="#B388FF">
<circle cx="138" cy="53" r="13"/><circle cx="152.7" cy="61.5" r="13"/><circle cx="152.7" cy="78.5" r="13"/><circle cx="138" cy="87" r="13"/><circle cx="123.3" cy="78.5" r="13"/><circle cx="123.3" cy="61.5" r="13"/>
</g>
<circle cx="138" cy="70" r="9" fill="#FFCC00"/>
</g>
<path d="M40 106 H160 L104 188 H96 Z" fill="#CCE6FF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<path d="M112 106 H160 L104 188 Z" fill="#B3D6FA"/>
<path d="M40 106 H160 L104 188 H96 Z" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<g fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M97 152 L87 173 L95 171 L100 157 Z"/>
<path d="M103 152 L113 173 L105 171 L100 157 Z"/>
<ellipse cx="86" cy="147" rx="14" ry="8" transform="rotate(-20 86 147)"/>
<ellipse cx="114" cy="147" rx="14" ry="8" transform="rotate(20 114 147)"/>
<circle cx="100" cy="150" r="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="38" y="96" width="124" height="84" rx="4"/>
<rect x="30" y="72" width="140" height="28" rx="4"/>
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z"/>
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="38" y="96" width="124" height="84" rx="4" fill="#3366FF"/>
<g fill="#fff" stroke="none" opacity=".45">
<circle cx="58" cy="120" r="6"/><circle cx="70" cy="156" r="6"/><circle cx="132" cy="134" r="6"/><circle cx="148" cy="164" r="6"/>
</g>
<rect x="88" y="100" width="24" height="80" fill="#FFCC00"/>
<rect x="30" y="72" width="140" height="28" rx="4" fill="#5C85FF"/>
<rect x="86" y="72" width="28" height="28" fill="#FFCC00"/>
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z" fill="#FFCC00"/>
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z" fill="#FFCC00"/>
<path d="M96 69 C86 56 70 54 69 62 C69 68 84 70 96 69 Z" fill="#E6A800" stroke="none"/>
<path d="M104 69 C114 56 130 54 131 62 C131 68 116 70 104 69 Z" fill="#E6A800" stroke="none"/>
<ellipse cx="100" cy="70" rx="10" ry="9" fill="#FFCC00"/>
</g>
<rect x="36" y="76" width="40" height="5" rx="2.5" fill="#fff" opacity=".45"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 100)">
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#E0245E" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<ellipse cx="48" cy="52" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(-35 48 52)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 561 B

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(8 100 100)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="38" y="18" width="124" height="18" rx="6"/>
<rect x="38" y="164" width="124" height="18" rx="6"/>
<rect x="46" y="36" width="9" height="128"/>
<rect x="145" y="36" width="9" height="128"/>
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z"/>
</g>
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="#E6F2FF"/>
<path d="M76 72 H124 C116 82 106 88 104 98 H96 C94 88 84 82 76 72 Z" fill="#FFCC00"/>
<path d="M100 98 V150" stroke="#FFCC00" stroke-width="3"/>
<path d="M68 163 C70 147 88 139 100 137 C112 139 130 147 132 163 Z" fill="#FFCC00"/>
<path d="M73 44 C73 60 80 70 88 78" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="none"/>
<rect x="46" y="36" width="9" height="128" fill="#A0632F"/>
<rect x="145" y="36" width="9" height="128" fill="#A0632F"/>
<rect x="38" y="18" width="124" height="18" rx="6" fill="#C8874A"/>
<rect x="38" y="164" width="124" height="18" rx="6" fill="#C8874A"/>
</g>
<g fill="#fff" opacity=".35">
<rect x="46" y="22" width="40" height="4" rx="2"/>
<rect x="46" y="168" width="40" height="4" rx="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-5 100 100)">
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<g fill="#CC0000" fill-rule="evenodd">
<polygon transform="translate(44 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
<path transform="translate(79 0)" d="M18 59 H24 A18 18 0 0 1 42 77 V91 A18 18 0 0 1 24 109 H18 A18 18 0 0 1 0 91 V77 A18 18 0 0 1 18 59 Z M19 71 H23 A7 7 0 0 1 30 78 V90 A7 7 0 0 1 23 97 H19 A7 7 0 0 1 12 90 V78 A7 7 0 0 1 19 71 Z"/>
<polygon transform="translate(128 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 933 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-8 100 108)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<rect x="22" y="56" width="156" height="104" rx="8"/>
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="22" y="56" width="156" height="104" rx="8" fill="#FFE0EC"/>
<path d="M25 156 L84 112 M175 156 L116 112" fill="none" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22 64 Q22 56 30 56 H170 Q178 56 178 64 L100 120 Z" fill="#FFC9DC"/>
<path d="M79.6 103.8 C79.6 90.2 100 90.2 100 103.8 C100 90.2 120.4 90.2 120.4 103.8 C120.4 117.4 100 127.6 100 134.4 C100 127.6 79.6 117.4 79.6 103.8 Z" fill="#E0245E"/>
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z" fill="#FF99BB"/>
</g>
<ellipse cx="88" cy="100" rx="3" ry="5" fill="#fff" opacity=".6" transform="rotate(-30 88 100)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z"/>
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
</g>
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z" fill="#FFE680" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<ellipse cx="30" cy="100" rx="5" ry="20" fill="#fff" opacity=".6" transform="rotate(8 30 100)"/>
<g fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round">
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="translate(-15.3 -15.3) scale(1.15)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
<rect x="73" y="54" width="10" height="96"/>
<rect x="149" y="38" width="10" height="96"/>
<polygon points="73,54 159,38 159,62 73,78"/>
</g>
<g fill="#333" stroke="#333" stroke-width="3.5" stroke-linejoin="round">
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
<rect x="73" y="54" width="10" height="96"/>
<rect x="149" y="38" width="10" height="96"/>
<polygon points="73,54 159,38 159,62 73,78"/>
</g>
<g fill="#5B2C8F">
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
<rect x="73" y="54" width="10" height="96"/>
<rect x="149" y="38" width="10" height="96"/>
<polygon points="73,54 159,38 159,62 73,78"/>
</g>
<g fill="#fff" opacity=".55">
<ellipse cx="56" cy="145" rx="7" ry="3.5" transform="rotate(-22 56 145)"/>
<ellipse cx="132" cy="129" rx="7" ry="3.5" transform="rotate(-22 132 129)"/>
<polygon points="80,58 152,45 152,49 80,62"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-10 100 100)">
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<g fill="#CC0000">
<polygon transform="translate(37.5 0)" points="0,121 0,79 10.4,79 22,101.6 22,79 32,79 32,121 21.6,121 10,98.4 10,121"/>
<polygon transform="translate(73.5 0)" points="0,79 24,79 24,88 10,88 10,95.5 21,95.5 21,104.5 10,104.5 10,112 24,112 24,121 0,121"/>
<polygon transform="translate(101.5 0)" points="0,79 11,79 14.9,99.3 17.5,91 28.5,91 31.1,99.3 35,79 46,79 38,121 27,121 23,108.4 19,121 8,121"/>
<polygon transform="translate(151.5 0)" points="0,79 11,79 9,107 2,107"/>
<rect x="152" y="111" width="10" height="10" rx="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<polygon points="186,18 30,84 104,122"/>
<polygon points="186,18 104,122 130,110"/>
<polygon points="104,122 130,110 112,156"/>
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke-width="18.5"/>
</g>
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke="#3366FF" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="8 8"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<polygon points="104,122 130,110 112,156" fill="#8FA8E0"/>
<polygon points="186,18 104,122 130,110" fill="#B8C8F0"/>
<polygon points="186,18 30,84 104,122" fill="#EEF2FF"/>
</g>
<path d="M164 32 L56 80" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 914 B

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-10 100 112)">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z"/>
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z" fill="#FFCC00"/>
<g fill="#FFE680" stroke="none">
<circle cx="56" cy="80" r="4.5"/><circle cx="146" cy="76" r="3.5"/><circle cx="108" cy="158" r="3.5"/><circle cx="98" cy="72" r="3"/>
</g>
<circle cx="72" cy="90" r="13" fill="#CC0000"/>
<circle cx="126" cy="92" r="13" fill="#CC0000"/>
<circle cx="99" cy="130" r="12" fill="#CC0000"/>
<g fill="#33AA33" stroke-width="1.5">
<ellipse cx="100" cy="98" rx="7" ry="3.5" transform="rotate(30 100 98)"/>
<ellipse cx="80" cy="120" rx="6" ry="3" transform="rotate(-40 80 120)"/>
<ellipse cx="116" cy="114" rx="6" ry="3" transform="rotate(50 116 114)"/>
</g>
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z" fill="#E0A050"/>
</g>
<g fill="#fff" opacity=".5">
<circle cx="67" cy="85" r="3.5"/><circle cx="121" cy="87" r="3.5"/><circle cx="94" cy="125" r="3"/>
</g>
<path d="M44 54 Q100 40 156 54" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g transform="rotate(-6 100 106)">
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
<polygon points="100,60 112.9,86.2 141.8,90.4 120.9,110.8 125.9,139.6 100,126 74.1,139.6 79.1,110.8 58.2,90.4 87.1,86.2" fill="#FFE066"/>
<ellipse cx="93" cy="50" rx="4.5" ry="13" fill="#fff" opacity=".6" transform="rotate(25 93 50)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 730 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="none" stroke-linecap="round">
<circle cx="100" cy="100" r="62" fill="#fff" stroke="#fff" stroke-width="14"/>
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#fff" stroke-width="27"/>
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#333" stroke-width="13"/>
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#FF9900" stroke-width="9"/>
</g>
<circle cx="100" cy="100" r="52" fill="#FFCC00" stroke="#333" stroke-width="2"/>
<ellipse cx="74" cy="68" rx="11" ry="5.5" fill="#fff" opacity=".6" transform="rotate(-38 74 68)"/>
<circle cx="70" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
<circle cx="130" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
<ellipse cx="83" cy="92" rx="5" ry="8" fill="#333"/>
<ellipse cx="117" cy="92" rx="5" ry="8" fill="#333"/>
<path d="M78 110 Q100 130 122 110" fill="none" stroke="#333" stroke-width="4.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="16" stroke-linejoin="round">
<rect x="18" y="96" width="40" height="88" rx="6"/>
<rect x="50" y="92" width="70" height="88" rx="18"/>
<rect x="92" y="88" width="68" height="26" rx="13"/>
<rect x="92" y="112" width="74" height="26" rx="13"/>
<rect x="92" y="136" width="70" height="24" rx="12"/>
<rect x="92" y="158" width="62" height="22" rx="11"/>
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
</g>
<g fill="#333" stroke="#333" stroke-width="4" stroke-linejoin="round">
<rect x="50" y="92" width="70" height="88" rx="18"/>
<rect x="92" y="88" width="68" height="26" rx="13"/>
<rect x="92" y="112" width="74" height="26" rx="13"/>
<rect x="92" y="136" width="70" height="24" rx="12"/>
<rect x="92" y="158" width="62" height="22" rx="11"/>
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
</g>
<g fill="#FFCC00">
<rect x="50" y="92" width="70" height="88" rx="18"/>
<rect x="92" y="88" width="68" height="26" rx="13"/>
<rect x="92" y="112" width="74" height="26" rx="13"/>
<rect x="92" y="136" width="70" height="24" rx="12"/>
<rect x="92" y="158" width="62" height="22" rx="11"/>
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
</g>
<g fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
<path d="M108 113 H152 M108 137 H155 M108 159 H149"/>
<path d="M66 100 C76 104 90 103 98 96"/>
</g>
<ellipse cx="73" cy="52" rx="4" ry="13" fill="#fff" opacity=".55" transform="rotate(18 73 52)"/>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<rect x="18" y="96" width="40" height="88" rx="6" fill="#3366FF"/>
<path d="M46 96 H52 Q58 96 58 102 V178 Q58 184 52 184 H46 Z" fill="#003399"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" fill="none" stroke-width="29"/>
<rect x="44" y="24" width="112" height="12" rx="5"/>
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z"/>
<path d="M92 118 H108 L110 140 H90 Z"/>
<rect x="78" y="138" width="44" height="10" rx="4"/>
<rect x="56" y="148" width="88" height="34" rx="5"/>
</g>
<g fill="none" stroke-linecap="round">
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#333" stroke-width="15"/>
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#FFCC00" stroke-width="11"/>
</g>
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
<path d="M92 118 H108 L110 140 H90 Z" fill="#E6B800"/>
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z" fill="#FFCC00"/>
<rect x="44" y="24" width="112" height="12" rx="5" fill="#FFE066"/>
<rect x="78" y="138" width="44" height="10" rx="4" fill="#FFCC00"/>
<rect x="56" y="148" width="88" height="34" rx="5" fill="#003399"/>
<rect x="76" y="157" width="48" height="15" rx="3" fill="#FFCC00" stroke-width="1.5"/>
<polygon points="100,53 105,63.1 116.2,64.7 108.1,72.6 110,83.8 100,78.5 90,83.8 91.9,72.6 83.8,64.7 95,63.1" fill="#FFFFFF" stroke-width="1.5"/>
</g>
<ellipse cx="66" cy="66" rx="5.5" ry="20" fill="#fff" opacity=".5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+80
View File
@@ -0,0 +1,80 @@
# Contributing
The iOS client for Flamenet Messenger, in Swift and SwiftUI. This repository is
the **source of truth** for the app *and* for the encryption engine, the
JavaScript reference implementation published as `flamenet-e2e` is mirrored out
of this tree, not the other way round.
## Security bugs do not go in an issue
Email **security@flamenetmessenger.com**. See [SECURITY.md](SECURITY.md).
## Building
Xcode with an iOS 17+ SDK, and:
```bash
xcodegen generate
```
**`FlamenetMessenger.xcodeproj/project.pbxproj` is gitignored and generated.**
Do not hand-edit it and do not commit it. Files and build settings are declared
in `project.yml`; adding a source file means adding it there (or dropping it
into an existing group's directory) and re-running `xcodegen generate`. Editing
the pbxproj directly produces a project that works on your machine and on
nobody else's, and a truncated one, which is a fun afternoon.
## Testing
```bash
xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17" \
test -only-testing:FlamenetMessengerTests
```
That unit suite is the merge gate and runs on the project's own macOS runner.
UI tests are not in the gate, on purpose: `FlamenetMessengerUITests` needs a
local relay listening on port 18810 and, for the conversation tests, two booted
simulators. Without the relay the suite **skips rather than fails**: a green
run that tested nothing looks identical to a green run that tested everything,
so check the executed count, not the tick.
Two traps that will cost you an hour each:
- The suite occasionally reports `TEST FAILED` with no failing test. A clean
rerun passes. Rerun before believing it.
- **Two test runners against one simulator** produce what looks like an app
crash and is not. One runner per device.
## What a change needs
- **Touching `Sources/E2E/`?** You are editing the reference implementation.
Read `CONTRIBUTING.md` in `flamenet-e2e` as well, spec first, vectors second,
code third, and keep the JS mirror in agreement. Cross-platform interop is a
claim this project makes, so it has to actually be run.
- **Touching the wire?** Android and the relay have to move with you. A field
the client requires and the relay never sends breaks a feature 100% of the
time while every unit test stays green, that has happened here, to groups.
**Diff the wire types first.**
- **Touching state that arrives incrementally**: avatars, rosters, device
lists, merge, never replace. Absent from a fetch means "not loaded yet", not
"deleted". Both platforms have shipped this bug.
- **Adding a user-visible security claim?** It must be true of *this* client
specifically. See `SECURITY.md`; several true-of-the-app statements are false
of the web client, and the distinction is not optional.
## House rules
- Fail closed. An unverifiable signature, an unknown version, an unpinned
identity are refusals, and the user is told which.
- Never log message contents, keys, or tokens.
- No secrets, keystores or passphrases in the repository. It is public.
- Match the surrounding style. Comments explain why a line exists, most often,
which attack it prevents.
## Comments and prose
Plain text. No emoji or symbol markers, no markdown bold inside code comments,
no em-dashes. Say what the code does and why, briefly; the history of how it
got that way belongs in the commit message, not in the file.
+132
View File
@@ -0,0 +1,132 @@
# Layout spec
Extracted from `Messenger App V2.sketch` (Sketch 71, 120 artboards, ~280
symbols) by reading the file's JSON directly. Every number below is measured
from a symbol master, not eyeballed from a render.
The layout is theirs; the colour is ours. Flamenet orange `#FF5A1F`
stays. The kit's primary is blue `#2298FF` and adopting it would mean the app
no longer looks like the product it belongs to. Everywhere the kit says
"primary", read Flamenet orange.
The typeface is not adopted, only the scale. The kit is set in DM Sans.
Bundling it means shipping font files and an OFL notice, which is a licensing
decision rather than a layout one, so the sizes, weights and line heights
below are applied to the system face. Swapping the face in later changes
nothing about the measurements.
Canvas is 375pt wide (iPhone X). Treat the horizontal numbers as insets from
the edges rather than fixed widths, or the layout breaks on every other phone.
## Grid
| | |
|---|---|
| Page margin | 24 |
| Content width | 327 (375 − 48) |
| List row pitch | 72 (48pt row + 24pt gap) |
## Bars
**Large title**: 96 tall. Title 24 bold at (24, 52). Trailing icons 24×24 at
x = 279 and 327, y = 60, i.e. 24 from the right edge, 48 apart.
**Conversation bar**: 88 tall. Back chevron 24×24 at (24, 54). Title 16 medium
at (72, 45). Beneath it at (72, 69): a 12×12 presence dot, then the subtitle at
12 regular, 16 from the dot's origin. Trailing icons at x = 279, 327, y = 54.
**Tab bar**: 84 tall, and it is a **floating pill**, not a full-width strip:
a 328×50 rounded rectangle at x = 24 with radius 25 on `#F3F3F3`, holding four
82×50 tab items. This is the single biggest visual difference from what both
apps do today.
**Composer**: 84 tall. A 50-tall pill with radius 25 on `#F3F3F3`, text inset
16, placeholder at 14 regular. Leading `+` at (16, 12); trailing camera at
x = 287 and overflow at x = 335, all 24×24.
## Conversation row (327×48)
| element | position | type |
|---|---|---|
| Avatar | 0, 0 — 48×48 | |
| Name | x 64, y 0 | 16 medium, 24 line |
| Preview | x 64, y 24 | 14 regular, 24 line |
| Timestamp | right aligned, y 5 | 12 regular |
| Unread badge | 24×16 at y 28 | |
The 64 is not a gap of 64, it is the 48pt avatar plus a 16pt gutter.
## Message bubbles
The corners are asymmetric, and that is the whole character of them.
Every corner is radius 16 except one, which is radius 2 and points at its
author: **bottom-right** on a message you sent, **top-left** on one you
received. Rounding all four equally throws away the only thing that
distinguishes these bubbles from every other chat app's.
| | sent | received |
|---|---|---|
| Fill | Flamenet orange (kit: `#2298FF`) | `#F3F3F3` |
| Square corner | bottom-right | top-left |
| Text | 16 regular | 16 regular |
| Padding | 12 horizontal, 12 vertical | 12 horizontal, 12 vertical |
| Avatar | none | 32×32, leading, top-aligned |
| Bubble inset | — | 40 from the leading edge |
| Timestamp | 10 regular, above, trailing | 10 regular, above, leading |
**Day divider**: 14 regular, centred.
## Type scale
| name | size | weight |
|---|---|---|
| H1 | 40 | bold |
| H2 | 32 | bold |
| H3 | 24 | bold |
| H4 | 20 | bold |
| Sub 1 / 2 / 3 | 12 / 14 / 16 | medium |
| Body 1 / 2 | 14 / 16 | regular |
| Description 1 / 2 | 12 / 10 | regular |
## Kit palette, for reference
`#4FACFE` primary · `#2298FF` bubble · `#2F2F2F` ink · `#B3BEC9` muted ·
`#F9F9F9` background · `#F3F3F3` surface · `#3BD5B2` success · `#ED497C` alert
Only `#F3F3F3` is adopted directly, as the received-bubble and pill fill. The
rest map onto the existing Flamenet palette.
## What was not taken
Phone number entry and SMS verification (Flamenet is handle + passphrase, and
being phone-number-free is a point against Signal rather than an omission);
wallet and money transfer; the timeline, post and comment social feed; people
nearby; profile follow/following.
## Settings and list screens
The kit does not use cards here either. Rows are grouped into sections,
and sections are separated by a **full-bleed 16pt band filled `#F3F3F3`**: a
thick grey spacer spanning the whole width, not a hairline and not a gap
between floating surfaces. Inside a section the rows butt up against each
other, divided only by a hairline that starts after the leading icon.
**`Item/48`, 56 tall**
| element | position |
|---|---|
| Leading icon | 24×24 at x 0, y 16 |
| Label | x 40, y 16 — 14 medium |
| Trailing chevron | 24×24, right aligned |
| Hairline | y 55, inset 36 from the leading edge |
**`Item/48 no icon-description`, 93 tall**, for a row that needs a sentence
under it: label 14 medium at (24, 16), description 12 regular at (24, 44),
switch 44×28 on the trailing edge, hairline at the full 24 inset.
The description is 12pt and sits under the label it belongs to, not a
paragraph floating between controls. A setting whose explanation is longer than
the section it lives in is a setting that needs a shorter explanation.
**Sign out** is an icon plus "Sign Out" at 14 medium, on the same row grammar
as everything else, not a full-width filled button.
+171 -35
View File
@@ -1,42 +1,178 @@
# Export compliance — v0.1 (decided 2026-08-19)
# Export compliance, v0.2
**Answer on the App Store Connect encryption questions:**
**This is a record of a decision, not legal advice.** The authorities are the
Bureau of Industry and Security and Apple. Where this document is unsure it
says so rather than sounding certain.
- *Does your app use encryption?* — **Yes**
- *Does it qualify for any of the exemptions?* — **No** (it implements its own
end-to-end messaging protocol, not just HTTPS/ATS)
- *Does it implement proprietary or non-standard algorithms?* — **No.**
Standard, published algorithms only: X25519 (key agreement), Ed25519
(signatures), HKDF-SHA256, HMAC-SHA256, AES-256-GCM (AEAD), SHA-256/512.
The protocols are the published Signal specifications (X3DH and Double
Ratchet), implemented over Apple CryptoKit primitives.
- Classification: **mass-market, self-classified 5D992.c** (ECCN), License
Exception ENC favorable treatment not required for mass-market.
## The route: publicly available source code
**Plist key note (learned 2026-08-19):** do NOT set
`ITSAppUsesNonExemptEncryption` alone — ASC rejects the upload with "Invalid
Export Compliance Code" because a non-exempt declaration in the plist must be
accompanied by an `ITSEncryptionExportComplianceCode` that matches compliance
documentation ASC has issued. Workflow: answer the questions interactively in
App Store Connect for the first build; ASC then issues the code; add
`ITSEncryptionExportComplianceCode: <code>` to project.yml so later uploads
skip the questionnaire.
Flamenet Messenger takes the **publicly available encryption source code**
route, License Exception TSU, EAR §740.13(e). The obligation is one email to
BIS and one to NSA naming where the source lives. There is no encryption
registration, no Encryption Registration Number, and no annual
self-classification report.
**Crypto description (paste when asked):**
Two facts make it eligible, and both are checkable rather than asserted:
> Flamenet Messenger is an end-to-end encrypted instant messenger. It uses
> the published Signal protocol constructions — X3DH asynchronous key
> agreement and the Double Ratchet — implemented with Apple CryptoKit using
> standard algorithms only: Curve25519 (X25519 key agreement, Ed25519
> signatures), HKDF-SHA256, HMAC-SHA256, and AES-256-GCM authenticated
> encryption. Media attachments are encrypted client-side with per-file
- **All four repositories are public.** `flamenet-messenger-ios`,
`flamenet-messenger-android`, `flamenet-relay` and `flamenet-e2e` all answer
an anonymous request. Every line of the protocol, the client and the server
is readable by anyone.
- **The licence is MIT.** §740.13(e) excludes source code that is subject to
an express agreement for the payment of a licensing fee or royalty for
commercial production or sale. MIT charges neither.
### What this replaced
v0.1 of this document chose the other route: mass-market, self-classified
5D992.c, with the annual BIS self-classification report and encryption
registration through SNAP-R. That route needs an ERN, because registration is
what issues one. It also cited §740.17(e)(3), which is the reporting rule for
License Exception ENC rather than for mass market.
The two routes are alternatives, not steps. Nothing had been filed under the
old one, so switching costs nothing.
## The notification: sent 2026-09-15
Sent to both addresses on 2026-09-15 at 17:37 UTC, from
`security@flamenetmessenger.com`, and accepted by both:
- BIS, `crypt@bis.doc.gov`: `250 2.6.0 ... Queued mail for delivery`
- NSA, `enc@nsa.gov`: `250 ok: Message 151032564 accepted`
Accepted is not the same as read. Keep an eye on that mailbox for a reply, and
keep these lines: they are the evidence that the notification went, and the
date is what a later question would turn on.
The text as sent:
> Subject: Notification under EAR 740.13(e): publicly available encryption
> source code
>
> Extant 2000 LLC notifies BIS and NSA, under EAR §740.13(e), of the internet
> location of publicly available encryption source code.
>
> Submitter
> Extant 2000 LLC
> 7051 Brookfield Plz, PO Box 41, Springfield, VA 22150, USA
> security@flamenetmessenger.com
>
> Product
> Flamenet Messenger, an end-to-end encrypted instant messenger for iPhone
> and Android, with its relay server and its protocol engine.
>
> Source code locations
> https://gitlab.com/paulhitt/flamenet-messenger-ios
> https://gitlab.com/paulhitt/flamenet-messenger-android
> https://gitlab.com/paulhitt/flamenet-relay
> https://gitlab.com/paulhitt/flamenet-e2e
>
> The source at those locations is public and free to read. It is published
> under the MIT licence and is not subject to an express agreement for the
> payment of a licensing fee or royalty for commercial production or sale.
>
> Algorithms: X25519 key agreement, Ed25519 signatures, ML-KEM-768 (FIPS 203),
> ML-DSA-65 (FIPS 204), HKDF-SHA256, HMAC-SHA256, AES-256-GCM, SHA-256 and
> SHA-512.
>
> Protocols: the published X3DH key agreement and the Double Ratchet.
> ML-KEM-768 is combined with X25519 for key agreement, and ML-DSA-65
> accompanies Ed25519 on identity keys. The implementation contains no
> proprietary or non-standard cryptography.
Re-notify if the source moves to a different location. Notification is about
where the code is, so a new commit needs nothing; a new URL does.
## The re-notification: sent 2026-09-28
On 28 September 2026 the source moved from `gitlab.com/paulhitt` to
`gitlab.com/extant2000`. On the same day `flamenet-elligator2`, which holds the
obfs4 bridge and the iPhone app's obfs4 transport, was published under MIT.
Both changes call for a new notification, so one was sent.
Sent to both addresses on 2026-09-28 at 19:10 UTC, in one message from
`security@flamenetmessenger.com`, and accepted by both:
- BIS, `crypt@bis.doc.gov`: `250 2.6.0 ... Queued mail for delivery`
- NSA, `enc@nsa.gov`: `250 ok: Message 199757828 accepted`
The locations it gave:
https://gitlab.com/extant2000/flamenet-messenger-ios
https://gitlab.com/extant2000/flamenet-messenger-android
https://gitlab.com/extant2000/flamenet-relay
https://gitlab.com/extant2000/flamenet-e2e
https://gitlab.com/extant2000/flamenet-elligator2
It also listed the four earlier locations above and said they now redirect.
The algorithm list repeats the one in the first notification, and adds what
the obfs4 transport uses: the Elligator2 encoding of Curve25519 public keys,
the ntor handshake (Curve25519 with HMAC-SHA256), XSalsa20-Poly1305 (NaCl
secretbox) and SipHash-2-4.
The same rule applies from here: a new commit needs nothing, and a new
location or a newly published repository with cryptography in it needs
another notification.
## Open question worth an hour of a lawyer
Whether the binary Apple distributes counts as the object code corresponding
to that published source. The iOS app, the engine and the relay are all
public, so the honest reading is that it does, but the binary also links Apple
CryptoKit and WebRTC, which are not ours and not published by us. If that
reading is wrong, the fallback is the mass-market route this document used to
describe, and then an ERN is needed after all. Worth asking before relying on
it for years rather than before the first submission.
## App Store Connect
Apple's questionnaire is a separate obligation from the BIS one, and it still
has to be answered on the submission.
- *Does your app use encryption?* **Yes.**
- *Does it qualify for any of the exemptions?* Apple's list includes an option
for apps whose encryption source code is publicly available. **Read the
options on the screen and pick that one if it is offered**, because it is now
the accurate answer. v0.1 answered **No** here, which was accurate under the
mass-market route and is not under this one.
- *Does it implement proprietary or non-standard algorithms?* **No.** Standard
published algorithms only, listed above.
Whether `ITSAppUsesNonExemptEncryption` should be `false` follows from that
exemption answer rather than being decided here: if Apple treats the app as
exempt, the plist can say so and later uploads skip the questionnaire. Do not
guess it. Setting a non-exempt declaration without a matching
`ITSEncryptionExportComplianceCode` from documentation Apple has issued
rejects the upload with "Invalid Export Compliance Code", which is how this
was found the first time.
**Crypto description, to paste when asked:**
> Flamenet Messenger is an end-to-end encrypted instant messenger. It uses the
> published Signal protocol constructions: X3DH asynchronous key agreement and
> the Double Ratchet, implemented with Apple CryptoKit using standard
> algorithms only, Curve25519 (X25519 key agreement, Ed25519 signatures),
> HKDF-SHA256, HMAC-SHA256, and AES-256-GCM authenticated encryption. Key
> agreement and identity signatures are hybrid post-quantum: ML-KEM-768 (FIPS
> 203) is combined with X25519 so that recorded traffic stays protected
> against a future quantum adversary, and ML-DSA-65 (FIPS 204) accompanies
> Ed25519 on identity keys so an attacker must defeat both rather than
> whichever is weaker. Both are NIST-standard algorithms used through Apple
> CryptoKit. Media attachments are encrypted client-side with per-file
> AES-256-GCM keys conveyed inside the encrypted channel. Voice calls use
> WebRTC's standard DTLS-SRTP with the certificate fingerprints
> authenticated through the same encrypted channel. TLS (HTTPS) protects
> all client–server transport. No proprietary or non-standard cryptography
> is implemented.
> WebRTC's standard DTLS-SRTP with the certificate fingerprints authenticated
> through the same encrypted channel. TLS (HTTPS) protects all client-server
> transport. No proprietary or non-standard cryptography is implemented.
**Recurring obligation:** file the annual **BIS self-classification report**
(due each February for the prior calendar year) covering this app —
encryption registration via SNAP-R/email per EAR §740.17(e)(3). Add a
calendar reminder.
## Keeping this true
The algorithm list above is part of a declaration, so it has to match the
binary. The post-quantum algorithms were added after v0.1 was written and were
missing from it, and a declaration that omits an algorithm the binary contains
is inaccurate. Anything added to the protocol from here belongs in this list on
the same commit that ships it.
The repositories being public is the whole basis of this route. If any of them
is ever made private, this document stops being true and the mass-market route,
with its registration and its annual report, comes back.
+143
View File
@@ -0,0 +1,143 @@
# The original nine
Verified by driving the apps and the relays, not by reading code. Where
something is unproven it says so.
**Re-checked** 45 commits after the baseline. Three rows
moved. What was re-run this time is marked; anything not marked is carried
forward from the baseline and has not been re-driven.
| # | Item | Status |
|---|---|---|
| 1 | Sign-up on managed / shared self-hosted / local self-hosted, iOS **and** Android | **done**, with one caveat |
| 2 | Sign-up against each type, both platforms | **done** |
| 3 | Messages **between** relay types | **server yes, iOS built, Android no** |
| 4 | Everything wired and usable, repeat rounds | **done** on the managed relay |
| 5 | iOS → Android message | **done** |
| 6 | api-docs self-hosting documentation | **done** |
| 7 | Public / donation / comparison pages | **done** |
| 8 | Relay deployed to relay.flamenet.io | **deployed and current** |
| 9 | Self-hosted accounts. Files optional | **done** |
## 1 & 2, sign-up across relay types
Both platforms, all four relays.
| relay | kind | iOS | Android |
|---|---|---|---|
| `relay.flamenet.io` | managed (`accounts: shared`) | yes | yes |
| `relay2.flamenet.io` | shared self-hosted | yes | yes |
| `relay1.flamenet.io` | local self-hosted (`accounts: local`) | yes | yes |
| `relay3.flamenet.io` | local self-hosted | n/a | yes |
**"Three clean rounds" cannot be run back to back against a shared account
service, and that is correct.** It allows 3 new accounts per hour per IP;
rounds 2 and 3 exhaust it and the service answers `429 Too many accounts
created from here recently`, which the app shows verbatim. The suites report
that as SKIP, not FAIL, a suite that fails for a service working properly
teaches everyone to ignore it.
Only **local self-hosted** survives repeated rounds, because it keeps its
own accounts and has no such quota.
Repeatable via `Scripts/android-signup.sh` and `RelayTypesUITests`.
## 3, cross-relay messaging
**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
the allowlist is refused, and an unknown address is reported rather than
silently dropped.
Re-run against the live fleet: 11 passed, 0 failed. Unchanged.
iOS now has the client path; Android still does not. `E2EClient` gained
`remoteDevices`, `remoteBundles` and `sendRemote`, and `E2EInbox` gained
`transmitRemote`, `deliverRemote` and a separate `remoteThreads` keyed on
`fnid@relay`. Android calls none of it, it still reads the `federation` field
from the descriptor and never acts on it.
**Built is not proven.** The iOS path compiles and its unit tests pass; it
has NOT been driven across a real relay boundary from the app. The distinction
matters here more than anywhere else on this list, because the Node harness
passing is exactly what made the previous row look better than it was.
**The comparison page still overstates it.** "Servers can talk to each other
Yes" is now true of the relay and of one client out of two. Reword the row or
finish Android; a row that is half true reads as false to the half it is false
for.
## 8, deployed
**Deployed and current, verified by checksum.** Re-checked after
two deploys: `Config.swift`, `RoutesAccount.swift` and `Retention.swift` on the
box are md5-identical to `flamenet-relay` HEAD. Production has the signup rate
limit (verified live: five signups pass, the sixth is 429), the reset-token
sweep, and retention logging at a level that is actually visible.
Since the split, deploys rsync from a `flamenet-relay` checkout,
`flamenetmessenger/server/` no longer exists.
Production also runs `FN_LOG_LEVEL=error`, which suppresses the hourly
retention line entirely. Retention *is* working, verified by
querying the database directly and finding zero rows past any window, oldest
envelope hours old, but there is no way to see that from the logs, which is
the same unverifiable claim the retention code says it exists to avoid.
## 4, wired and usable
Three consecutive demo rounds on the managed relay, both directions, with the
group visible each time, `DemoRoundUITests` plus the Android half. Not run
against the other relay types.
## 6, documentation
`api-docs.relay.flamenet.io` now carries a compose file, every environment
variable with whether it is actually required, and curl commands to check a
relay is up. It previously documented the protocol thoroughly and never said
how to start one.
## 7, public pages
Live and on **production keys**: `sk_live` / `pk_live` confirmed on the
box. Stripe.js loads, the form is present, and the card fields appear after
Continue by design.
Not exercised: a payment taken end to end. Doing so means creating a
real PaymentIntent on the live connected account, so it was left alone. Closed
on the owner's call. Recorded here rather than dropped, because "we
verified the donation page" and "we watched a payment complete" are different
claims and only one of them is true.
## Found after the baseline
**Android silently lost messages.** A send to somebody with no registered
device, anyone signed out, looped over an empty device list, made no request,
and returned an empty failure map, which every caller reads as success. 1:1
text wrote nothing to the thread at all; group text wrote a bubble nobody
received. iOS never had this. Fixed, with a regression test proved failing
without the guard, and both platforms now hold undelivered messages in an
outbox and retry rather than dropping them.
Android → iOS calls do not connect, cause unknown. Ruled out: wire
shapes match, iOS hooks `onCallSignal` eagerly at sign-in, Android sets no
`from_addr` so the federated fork is not swallowing it, and every clock agrees
within a second so the 45s staleness check is not misfiring. The real defect
found was that Android discarded the send result entirely, so an offer that
reached nobody was indistinguishable from one that rang. Both sides are now
instrumented; the next attempt will say where it stopped.
iOS conversation history had been written and never merged. The
encrypted store sat alone on a parked branch while main moved 174 commits past
it, so the app discarded every conversation on quit. Restored and now covered
by five tests.
## Known gaps at this tag
- iOS push is built and **never observed**: needs a `.p8` and a real device.
- Call audio has never been heard by anyone.
- No third-party audit, no reproducible builds, no post-quantum below iOS 26.
- `FN_DELETE_ON_ACK` is **off** and must stay off until it has an end-to-end
test. It deleted a message on production.
+18 -18
View File
@@ -1,22 +1,22 @@
# Delivery without Google or Apple in the middle
> **Constraint set by the owner, 2026-08-26:** no third party on the delivery
> **Constraint set by the owner:** no third party on the delivery
> path. That rules out FCM and rules out APNs. It does not rule out fast
> delivery — but it does rule out one thing completely, and that is worth
> delivery, but it does rule out one thing completely, and that is worth
> stating before anything else.
---
## The one thing this constraint costs
🔴 **On iOS, a closed app cannot be woken without APNs. There is no alternative.**
On iOS, a closed app cannot be woken without APNs. There is no alternative.
iOS suspends a backgrounded process and tears down its sockets. A long-lived
connection, a NATS client, a foreground service — none of these exist on iOS.
connection, a NATS client, a foreground service, none of these exist on iOS.
Apple provides exactly one wake-up mechanism for a suspended app and it is APNs.
So "no Apple in the middle" means, precisely: **on iPhone, messages arrive when
the user opens the app.** Not late — immediately on open — but not while it is
the user opens the app.** Not late, immediately on open, but not while it is
closed. That is an OS decision, not something the relay can engineer around.
The honest options are:
@@ -25,7 +25,7 @@ The honest options are:
product whose pitch is that no third party is involved, that sentence is an
asset rather than an apology.
- **Use APNs anyway** for a content-free wake-up. Apple learns *that* a device
has mail and when — not from whom, not what. That is a smaller leak than FCM
has mail and when, not from whom, not what. That is a smaller leak than FCM
on Android (Google correlates far more), but it is still a third party.
**Android has no such limit.** A foreground service can hold a connection with
@@ -35,7 +35,7 @@ that will sever it anyway.
---
## What actually fixes "I had to hit refresh" — and needs no third party
## What actually fixes "I had to hit refresh", and needs no third party
Today `E2EInbox` polls every **5 seconds** while the app is open
(`pollInterval: 5_000_000_000`). That is both slow and wasteful. Replace it with
@@ -52,12 +52,12 @@ GET /e2e/poll?after=<cursor> # server holds the request open ~30–60s,
| requests per device | ~12/min | ~1–2/min |
| battery | a wake every 5s | one idle socket |
🔴 **This is strictly better on every axis and involves nobody.** It is the
This is strictly better on every axis and involves nobody. It is the
single highest-value change here, it is a relay + client change only, and it
makes the app feel like it pushes whenever it is open — which covers the whole
makes the app feel like it pushes whenever it is open, which covers the whole
iOS story and most of the Android one.
⚠️ Held requests need care: a timeout below the proxy's idle timeout (Traefik
Held requests need care: a timeout below the proxy's idle timeout (Traefik
and Cloudflare both cut long connections), a cursor so nothing is missed between
requests, and a cap on concurrent held requests per device.
@@ -67,7 +67,7 @@ network transitions with far less code.
---
## Where NATS fits — and where it does not
## Where NATS fits, and where it does not
**NATS does not deliver to phones.** It is server-side plumbing. A phone still
needs its own live connection, so NATS changes nothing about the iOS constraint
@@ -77,22 +77,22 @@ above. Adding it as "the push server" would be solving the wrong half.
requests, something has to notice a new message and wake exactly the right ones.
Three ways, in ascending cost:
1. **Poll Postgres from each held request.** Simplest, and fine at this scale —
1. **Poll Postgres from each held request.** Simplest, and fine at this scale,
a held request checking once a second is still 5× less DB load than the
current client polling, for far better latency.
2. **Postgres `LISTEN`/`NOTIFY`.** The send issues `NOTIFY user_42`; held
requests wake instantly. **No new infrastructure at all** — you already run
Postgres. ⚠️ Notifications are not durable: a listener that is not connected
requests wake instantly. **No new infrastructure at all**: you already run
Postgres. Notifications are not durable: a listener that is not connected
misses them, so the cursor query on reconnect stays load-bearing.
3. **NATS.** Proper pub/sub, durable with JetStream, scales across relays and
fits the federation work already in the tree. It is also **another service to
run, monitor, secure and back up** on a box that has had one intrusion and a
volume-prune incident.
🔴 **Recommendation: 2, not 3, today.** `LISTEN`/`NOTIFY` gets the same felt
**Recommendation: 2, not 3, today.** `LISTEN`/`NOTIFY` gets the same felt
result with zero new moving parts. Reach for NATS when there are enough
concurrent connections that one Postgres cannot hold them, or when relay-to-relay
fan-out needs it — not before.
fan-out needs it, not before.
---
@@ -102,7 +102,7 @@ fan-out needs it — not before.
loop. Biggest win, no third party, no new infrastructure.
2. **`LISTEN`/`NOTIFY`** behind it so held requests wake instantly instead of
polling the database.
3. **Android foreground service** holding the long-poll while backgrounded —
3. **Android foreground service** holding the long-poll while backgrounded,
opt-in, with the persistent notification explained honestly.
4. **iOS**: decide between "no background delivery, and we say so" and
"content-free APNs". Nothing else exists.
@@ -119,5 +119,5 @@ an engineering one.
- The crypto, the ratchet, and the wire format are untouched.
- `NotificationManager` already draws the banner; only the wake-up changes.
- The relay still holds ciphertext it cannot read, so any notification it
triggers is content-free by construction — the device decrypts and writes the
triggers is content-free by construction, the device decrypts and writes the
real banner locally.
+31
View File
@@ -0,0 +1,31 @@
// swift-tools-version:6.0
import PackageDescription
/// The Files crypto and wire layer, vendored into Messenger.
let package = Package(
name: "FlamenetFiles",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.library(name: "FilesEngine", targets: ["FilesEngine"]),
.library(name: "FilesClient", targets: ["FilesClient"]),
],
targets: [
.target(
name: "FilesEngine",
// The BIP-39 word list ships with the app and is verified on every
// load, a check that only ran at build would not notice one that
// arrived corrupted.
resources: [.copy("Resources/english.txt")]
),
.target(name: "FilesClient", dependencies: ["FilesEngine"]),
.testTarget(
name: "FilesEngineTests",
dependencies: ["FilesEngine"],
resources: [.copy("vectors")]
),
// Tests/FilesClientTests existed with no target declared, so its files
// were compiled by nothing and run by nothing, `swift test` reported a
// clean pass over a directory it was not looking at.
.testTarget(name: "FilesClientTests", dependencies: ["FilesClient"]),
]
)
@@ -0,0 +1,197 @@
import CryptoKit
import FilesEngine
import Foundation
/// The shared FlameNet account service.
public struct AccountsClient: Sendable {
public let baseURL: String
let http = HTTP()
public init(baseURL: String) { self.baseURL = baseURL }
func url(_ path: String) throws -> URL { try .endpoint(baseURL, path) }
func authHeaders(_ token: String?) -> [String: String] {
token.map { ["Authorization": "Bearer \($0)"] } ?? [:]
}
// MARK: credentials
/// Stretch parameters for a handle.
public func params(handle: String) async throws -> Wire.KDFParams {
let lowered = handle.lowercased()
let escaped =
lowered.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? lowered
let (data, _) = try await http.request("GET", try url("/accounts/" + escaped + "/params"))
return try http.decode(Wire.KDFParams.self, from: data)
}
struct RegisterBody: Encodable {
let handle: String
let authSecret: Data
let kdfSalt: Data
let kdfIterations: Int
let recoveryAuthSecret: Data?
let identity: Wire.NewIdentity?
}
/// Register an account and its first identity in one call.
public func register(
handle: String, passphrase: String, recoveryEntropy: Data?, identity: Wire.NewIdentity?
) async throws {
let salt = Random.bytes(16)
let authSecret = try KDF.authSecret(passphrase: passphrase, salt: salt)
_ = try await http.request(
"POST", try url("/accounts"),
json: RegisterBody(
handle: handle.lowercased(),
authSecret: authSecret,
kdfSalt: salt,
kdfIterations: KDF.Parameters.v1.iterations,
recoveryAuthSecret: recoveryEntropy.map(KDF.recoveryAuthSecret(entropy:)),
identity: identity
))
}
struct SessionBody: Encodable {
let handle: String
let authSecret: Data
let product: String
}
/// Sign in.
public func signIn(handle: String, passphrase: String, product: String = "files")
async throws -> Wire.Session
{
let params = try await params(handle: handle)
let authSecret = try KDF.authSecret(
passphrase: passphrase, salt: params.kdfSalt,
parameters: .init(id: Int(params.kdfID), iterations: params.kdfIterations))
do {
let (data, _) = try await http.request(
"POST", try url("/sessions"),
json: SessionBody(
handle: handle.lowercased(), authSecret: authSecret, product: product))
return try http.decode(Wire.Session.self, from: data)
} catch ClientError.http(status: 401, _) {
throw ClientError.wrongPassphrase
}
}
struct RotateBody: Encodable {
let handle: String
let recoveryAuthSecret: Data
let newAuthSecret: Data
let newKdfSalt: Data
let newKdfIterations: Int
let newRecoveryAuthSecret: Data
}
/// Prove a recovery code and set a new passphrase.
public func rotate(
handle: String, entropy: Data, newPassphrase: String, nextEntropy: Data
) async throws {
let salt = Random.bytes(16)
let newAuthSecret = try KDF.authSecret(passphrase: newPassphrase, salt: salt)
_ = try await http.request(
"POST", try url("/accounts/rotate"),
json: RotateBody(
handle: handle.lowercased(),
recoveryAuthSecret: KDF.recoveryAuthSecret(entropy: entropy),
newAuthSecret: newAuthSecret,
newKdfSalt: salt,
newKdfIterations: KDF.Parameters.v1.iterations,
newRecoveryAuthSecret: KDF.recoveryAuthSecret(entropy: nextEntropy)
))
}
struct ChangePassphraseBody: Encodable {
let handle: String
let currentAuthSecret: Data
let newAuthSecret: Data
let newKdfSalt: Data
let newKdfIterations: Int
}
public func changePassphrase(
handle: String, currentPassphrase: String, newPassphrase: String
) async throws {
let params = try await params(handle: handle)
let current = try KDF.authSecret(
passphrase: currentPassphrase, salt: params.kdfSalt,
parameters: .init(id: Int(params.kdfID), iterations: params.kdfIterations))
let salt = Random.bytes(16)
let next = try KDF.authSecret(passphrase: newPassphrase, salt: salt)
do {
_ = try await http.request(
"POST", try url("/accounts/passphrase"),
json: ChangePassphraseBody(
handle: handle.lowercased(), currentAuthSecret: current,
newAuthSecret: next, newKdfSalt: salt,
newKdfIterations: KDF.Parameters.v1.iterations))
} catch ClientError.http(status: 401, _) {
throw ClientError.wrongPassphrase
}
}
// MARK: identities
// These live here rather than on the file server.
public func identity(token: String) async throws -> Wire.Identity? {
let (data, response) = try await http.request(
"GET", try url("/identity"), headers: authHeaders(token))
// 204: the account exists but has no identity for this product yet,
// which is how the app decides between "unlock" and "create".
if response.statusCode == 204 { return nil }
return try http.decode(Wire.Identity.self, from: data)
}
struct EnrollBody: Encodable {
let publicKey: Data
let protectedRecord: String
let recoveryRecord: String?
}
public func enroll(
token: String, publicKey: Data, protectedRecord: String, recoveryRecord: String?
) async throws {
_ = try await http.request(
"POST", try url("/identity"), headers: authHeaders(token),
json: EnrollBody(
publicKey: publicKey, protectedRecord: protectedRecord,
recoveryRecord: recoveryRecord))
}
struct RekeyBody: Encodable {
let protectedRecord: String
let recoveryRecord: String?
}
struct ResolveBody: Encodable, Sendable {
let handle: String
let classification: Int16
let compartments: [Int]
}
/// Resolve a handle to the principal the file server knows, but only if
/// they are cleared for the area being asked about.
public func resolveRecipient(token: String, handle: String, label: Label) async throws
-> Wire.Recipient
{
let (data, _) = try await http.request(
"POST", try url("/clearance/check"), headers: authHeaders(token),
json: ResolveBody(
handle: handle.lowercased(),
classification: label.classification,
compartments: label.compartments.sorted()))
return try http.decode(Wire.Recipient.self, from: data)
}
public func rekey(token: String, protectedRecord: String, recoveryRecord: String?)
async throws
{
_ = try await http.request(
"POST", try url("/identity/rekey"), headers: authHeaders(token),
json: RekeyBody(protectedRecord: protectedRecord, recoveryRecord: recoveryRecord))
}
}
@@ -0,0 +1,169 @@
import FilesEngine
import Foundation
/// The file server.
public actor FilesAPI {
public let baseURL: String
public private(set) var token: String
private var label: Label?
private let http = HTTP()
public init(baseURL: String, token: String) {
self.baseURL = baseURL
self.token = token
}
public func setLabel(_ label: Label?) { self.label = label }
/// What a server says about itself.
public static func config(baseURL: String) async throws -> Wire.ServerConfig {
let http = HTTP()
let (data, _) = try await http.request("GET", try .endpoint(baseURL, "/api/config"))
return try http.decode(Wire.ServerConfig.self, from: data)
}
private var headers: [String: String] { headers(for: label) }
/// Headers for one request, at a label that need not be the session's.
private func headers(for label: Label?) -> [String: String] {
var headers = ["Authorization": "Bearer \(token)"]
if let label {
headers["X-Enclave-Classification"] = String(label.classification)
headers["X-Enclave-Compartments"] =
label.compartments.sorted().map(String.init).joined(separator: ",")
}
return headers
}
func get<T: Decodable>(_ type: T.Type, _ path: String) async throws -> T? {
let (data, response) = try await http.request(
"GET", try .endpoint(baseURL, path), headers: headers)
if response.statusCode == 204 { return nil }
return try http.decode(type, from: data)
}
@discardableResult
func post<T: Decodable>(
_ type: T.Type, _ path: String, _ body: some Encodable & Sendable,
at oneOff: Label? = nil
)
async throws -> T?
{
let (data, response) = try await http.request(
"POST", try .endpoint(baseURL, path),
headers: headers(for: oneOff ?? label), json: body)
if response.statusCode == 204 || data.isEmpty { return nil }
return try? http.decode(type, from: data)
}
func delete(_ path: String) async throws {
_ = try await http.request("DELETE", try .endpoint(baseURL, path), headers: headers)
}
// MARK: nodes
public func roots() async throws -> [Wire.Node] {
try await get([Wire.Node].self, "/api/nodes/roots") ?? []
}
public func children(of folderID: UUID) async throws -> [Wire.Node] {
try await get([Wire.Node].self, "/api/nodes/\(folderID.uuidString)/children") ?? []
}
struct CreateNodeBody: Encodable {
let parentID: UUID?
let nameDigest: Data
let nameCiphertext: Data
let isFolder: Bool
let wrappedKey: Data
let contentKey: Data?
let blobID: UUID?
let sizeBytes: Int64
}
func create(_ body: CreateNodeBody) async throws -> Wire.Node? {
try await post(Wire.Node.self, "/api/nodes", body)
}
// MARK: keys
public func labelGrants() async throws -> [Wire.LabelGrant] {
try await get([Wire.LabelGrant].self, "/api/keys/labels") ?? []
}
struct GrantBody: Encodable {
let classification: Int16
let compartments: [Int]
let wrappedKey: Data
}
func storeGrant(label: Label, wrappedKey: Data) async throws {
// Sent at the grant's own label without disturbing the session's.
_ = try await post(
Wire.LabelGrant.self, "/api/keys/labels",
GrantBody(
classification: label.classification,
compartments: label.compartments.sorted(), wrappedKey: wrappedKey),
at: label)
}
struct DelegateBody: Encodable, Sendable {
let handle: String
let principalID: UUID
let wrappedKey: Data
}
func delegateGrant(_ body: DelegateBody) async throws {
_ = try await post(Wire.LabelGrant.self, "/api/keys/labels/delegate", body)
}
func nodeKey(_ nodeID: UUID) async throws -> Wire.NodeKey {
guard let key = try await get(Wire.NodeKey.self, "/api/keys/nodes/\(nodeID.uuidString)")
else { throw ClientError.http(status: 404, detail: "") }
return key
}
// MARK: blobs
func putBlob(_ ciphertext: Data) async throws -> UUID {
let (data, _) = try await http.request(
"POST", try .endpoint(baseURL, "/api/blobs"), headers: headers, body: ciphertext)
return try http.decode(Wire.BlobCreated.self, from: data).id
}
func getBlob(_ blobID: UUID) async throws -> Data {
let (data, _) = try await http.request(
"GET", try .endpoint(baseURL, "/api/blobs/\(blobID.uuidString)"), headers: headers)
return data
}
// MARK: vocabulary
public func vocabulary() async throws -> Wire.Vocabulary {
try await get(Wire.Vocabulary.self, "/api/vocabulary")
?? Wire.Vocabulary(levels: [], compartments: [], canEdit: false)
}
// MARK: shares
struct CreateShareBody: Encodable {
let nodeID: UUID
let wrappedContentKey: Data
let wrappedName: Data
let passwordSalt: Data?
let passwordIterations: Int?
let expiresInHours: Int
}
public func share(_ shareID: UUID) async throws -> Wire.Share? {
try await get(Wire.Share.self, "/api/shares/\(shareID.uuidString)")
}
public func shares(for nodeID: UUID) async throws -> [Wire.Share] {
try await get([Wire.Share].self, "/api/shares/for/\(nodeID.uuidString)") ?? []
}
public func revokeShare(_ shareID: UUID) async throws {
try await delete("/api/shares/\(shareID.uuidString)")
}
}
@@ -0,0 +1,394 @@
import CryptoKit
import FilesEngine
import Foundation
/// An unlocked working session.
public actor FilesSession {
public struct Entry: Sendable, Identifiable, Hashable {
public let node: Wire.Node
/// `nil` when this session holds no key for the row's label: visible to
/// the database, unreadable here.
public let name: String?
public var id: UUID { node.id }
public var label: Label { node.label }
public var isFolder: Bool { node.isFolder }
}
public let api: FilesAPI
public let accounts: AccountsClient
public let token: String
public let clearance: Clearance
public private(set) var label: Label
/// Set only when a code was just issued, and never persisted.
public private(set) var recoveryCode: String?
private let privateKey: Curve25519.KeyAgreement.PrivateKey
private let publicKey: Curve25519.KeyAgreement.PublicKey
private var labelKeys: [Label: Data] = [:]
init(
api: FilesAPI, accounts: AccountsClient, token: String, clearance: Clearance,
privateKey: Curve25519.KeyAgreement.PrivateKey, recoveryCode: String? = nil
) {
self.api = api
self.accounts = accounts
self.token = token
self.clearance = clearance
self.label = clearance.minimum
self.privateKey = privateKey
self.publicKey = privateKey.publicKey
self.recoveryCode = recoveryCode
}
public func clearRecoveryCode() { recoveryCode = nil }
// MARK: opening
/// Unlock an existing identity.
public static func open(
api: FilesAPI, accounts: AccountsClient, token: String,
passphrase: String, clearance: Clearance,
recoveryEntropy: Data? = nil
) async throws -> FilesSession {
await api.setLabel(clearance.minimum)
guard let record = try await accounts.identity(token: token),
let protectedRecord = record.protectedRecord
else {
// No identity for this product yet.
return try await enrol(
api: api, accounts: accounts, token: token,
passphrase: passphrase, clearance: clearance, recoveryEntropy: recoveryEntropy)
}
let decoded = try Identity.Record.parse(protectedRecord)
let privateKey: Curve25519.KeyAgreement.PrivateKey
do {
privateKey = try Identity.unlock(decoded, passphrase: passphrase)
} catch {
// The only realistic cause is a wrong passphrase: the record is
// authenticated, so tampering and a bad key look identical here.
throw ClientError.wrongPassphrase
}
return FilesSession(
api: api, accounts: accounts, token: token,
clearance: clearance, privateKey: privateKey)
}
/// Create and enrol a keypair for an account that has none.
static func enrol(
api: FilesAPI, accounts: AccountsClient, token: String,
passphrase: String, clearance: Clearance, recoveryEntropy: Data?
) async throws -> FilesSession {
let entropy = recoveryEntropy ?? Recovery.generate().entropy
let key = Identity.generate()
try await accounts.enroll(
token: token,
publicKey: Identity.exportPublicKey(key.publicKey),
protectedRecord: try Identity.protect(key, passphrase: passphrase).json,
recoveryRecord: try Identity.protectWithRecovery(key, entropy: entropy).json)
return FilesSession(
api: api, accounts: accounts, token: token, clearance: clearance,
privateKey: key, recoveryCode: try Recovery.encode(entropy))
}
/// Adopt a keypair this device just created during registration.
public static func enrolled(
api: FilesAPI, accounts: AccountsClient, token: String,
privateKey: Curve25519.KeyAgreement.PrivateKey, clearance: Clearance,
recoveryCode: String?
) async -> FilesSession {
await api.setLabel(clearance.minimum)
return FilesSession(
api: api, accounts: accounts, token: token, clearance: clearance,
privateKey: privateKey, recoveryCode: recoveryCode)
}
/// Unlock with a recovery code and set a new passphrase.
public static func recover(
api: FilesAPI, accounts: AccountsClient, token: String,
code: String, newPassphrase: String, clearance: Clearance, nextEntropy: Data
) async throws -> FilesSession {
await api.setLabel(clearance.minimum)
guard let record = try await accounts.identity(token: token),
let recoveryRecord = record.recoveryRecord
else { throw ClientError.badServer("This account has no recovery code on file.") }
let entropy = try Recovery.decode(code)
let decoded = try Identity.Record.parse(recoveryRecord)
let privateKey = try Identity.unlockWithRecovery(decoded, entropy: entropy)
let next = try Identity.protect(privateKey, passphrase: newPassphrase)
let nextRecovery = try Identity.protectWithRecovery(privateKey, entropy: nextEntropy)
try await accounts.rekey(
token: token,
protectedRecord: next.json,
recoveryRecord: nextRecovery.json)
return FilesSession(
api: api, accounts: accounts, token: token, clearance: clearance,
privateKey: privateKey,
recoveryCode: try Recovery.encode(nextEntropy))
}
// MARK: label keys
/// Load and unwrap every label key granted to this principal.
@discardableResult
public func loadLabelKeys() async throws -> [Label] {
for grant in try await api.labelGrants() {
do {
labelKeys[grant.label] = try Keys.acceptLabelKey(
grant.wrappedKey, label: grant.label, privateKey: privateKey)
} catch {
// A grant that will not unwrap is not fatal: it may have been
// wrapped to a superseded identity key.
continue
}
}
return Array(labelKeys.keys)
}
/// Ensure a label key exists for `label`, creating and self-granting one if
/// not.
@discardableResult
func ensureLabelKey(_ wanted: Label) async throws -> Data {
guard clearance.permits(wanted) else { throw ClientError.notCleared }
if let existing = labelKeys[wanted] { return existing }
// Stored at the wanted label without moving the session's, so a request
// that overlaps this one still goes out under the area the caller is
// actually in.
let key = Random.bytes(32)
try await api.storeGrant(
label: wanted,
wrappedKey: try Keys.grantLabelKey(key, label: wanted, to: publicKey))
labelKeys[wanted] = key
return key
}
/// Switch to an area.
public func use(_ wanted: Label) async throws {
guard clearance.permits(wanted) else { throw ClientError.notCleared }
label = wanted
await api.setLabel(wanted)
}
/// The key for the current label, minting one if this is the first write.
@discardableResult
func requireLabelKey() async throws -> Data {
try await ensureLabelKey(label)
}
var currentLabelKey: Data {
get throws {
guard let key = labelKeys[label] else { throw ClientError.noKeyForArea }
return key
}
}
/// The key that names a folder's children.
func folderKey(_ folderID: UUID?) async throws -> Data {
guard let folderID else { return try currentLabelKey }
let record = try await api.nodeKey(folderID)
return try Keys.unwrapNodeKey(
record.wrappedKey, labelKey: try currentLabelKey, label: label)
}
// MARK: browsing
/// List a folder, decrypting each visible child's name locally.
public func list(_ folderID: UUID?) async throws -> [Entry] {
let rows =
folderID.map { id in { try await self.api.children(of: id) } }
?? { try await self.api.roots() }
let nodes = try await rows()
// Inside a folder every child is named under that folder's node key,
// which the caller necessarily holds to have got here at all.
let folderNodeKey = folderID == nil ? nil : try await folderKey(folderID)
return nodes.map { node in
let key = folderNodeKey ?? labelKeys[node.label]
var name: String?
if let key {
name = try? Names.decrypt(
folderKey: key, blob: node.nameCiphertext,
digest: node.nameDigest, label: node.label)
}
return Entry(node: node, name: name)
}
}
// MARK: writing
@discardableResult
public func createFolder(_ name: String, in parentID: UUID? = nil) async throws -> Wire.Node? {
try await requireLabelKey()
let folderKey = try await folderKey(parentID)
let nodeKey = Random.bytes(32)
let named = try Names.encrypt(folderKey: folderKey, name: name, label: label)
return try await api.create(
.init(
parentID: parentID,
nameDigest: named.digest, nameCiphertext: named.ciphertext,
isFolder: true,
wrappedKey: try Keys.wrapNodeKey(
nodeKey, labelKey: try currentLabelKey, label: label),
contentKey: nil, blobID: nil, sizeBytes: 0))
}
/// Encrypt then upload.
public private(set) var maxUploadBytes: Int?
public func setMaxUploadBytes(_ limit: Int?) { maxUploadBytes = limit }
@discardableResult
public func upload(_ bytes: Data, named name: String, in parentID: UUID? = nil)
async throws -> Wire.Node?
{
if let limit = maxUploadBytes, bytes.count > limit {
throw ClientError.tooLarge(limit: limit, actual: bytes.count)
}
try await requireLabelKey()
let folderKey = try await folderKey(parentID)
let nodeKey = Random.bytes(32)
let contentKey = Random.bytes(32)
let ciphertext = try Content.encrypt(bytes, contentKey: contentKey, label: label)
let blobID = try await api.putBlob(ciphertext)
let named = try Names.encrypt(folderKey: folderKey, name: name, label: label)
return try await api.create(
.init(
parentID: parentID,
nameDigest: named.digest, nameCiphertext: named.ciphertext,
isFolder: false,
wrappedKey: try Keys.wrapNodeKey(
nodeKey, labelKey: try currentLabelKey, label: label),
contentKey: try Keys.wrapContentKey(contentKey, nodeKey: nodeKey, label: label),
blobID: blobID, sizeBytes: Int64(bytes.count)))
}
public func download(_ node: Wire.Node) async throws -> Data {
guard let blobID = node.blobID, let wrappedContentKey = node.contentKey else {
throw ClientError.badServer("That entry has no contents.")
}
let record = try await api.nodeKey(node.id)
let nodeKey = try Keys.unwrapNodeKey(
record.wrappedKey, labelKey: try currentLabelKey, label: node.label)
let contentKey = try Keys.unwrapContentKey(
wrappedContentKey, nodeKey: nodeKey, label: node.label)
return try Content.decrypt(
try await api.getBlob(blobID),
contentKey: contentKey, label: node.label)
}
// MARK: sharing an area
/// Give someone else the key to the area you are working in.
@discardableResult
public func shareArea(with handle: String) async throws -> UUID {
// Not `requireLabelKey`: giving away a key you have never used would
// mint one on the spot and share an empty area, which is not what the
// person meant and would collide with whatever the recipient holds.
guard let key = labelKeys[label] else {
throw ClientError.badServer(
"You don't have a key for this area yet. Put something in it first.")
}
let recipient = try await accounts.resolveRecipient(
token: token, handle: handle, label: label)
let publicKey = try Curve25519.KeyAgreement.PublicKey(
rawRepresentation: recipient.publicKey)
try await api.delegateGrant(
.init(
handle: handle.lowercased(),
principalID: recipient.pseudonym,
wrappedKey: try Keys.grantLabelKey(key, label: label, to: publicKey)))
return recipient.pseudonym
}
// MARK: shares, removed
/// Public share links are gone, deliberately.
}
public struct Clearance: Sendable, Hashable {
public let maximum: Label
public let minimum: Label
public init(maximum: Label, minimum: Label) {
self.maximum = maximum
self.minimum = minimum
}
/// Both bounds, always: a label must sit inside the range, not merely below
/// the top of it.
public func permits(_ label: Label) -> Bool {
maximum.dominates(label) && label.dominates(minimum)
}
/// No read up: nothing above the clearance, and nothing outside its areas.
public func mayRead(_ label: Label) -> Bool { permits(label) }
/// Whether a write to `label` is allowed, which is the same range check.
///
/// The second rule of the model, that somebody who can read above a level
/// must not write below it, is deliberately NOT enforced. A person cleared
/// high can file something in a low area, and nothing stops them putting
/// high material in it. The label still decides who can open the file.
///
/// Enforced on one client and not the other, this would be worse than the
/// gap: a file that saves on one phone and is refused on the other, with
/// neither person able to tell which rule they met, reads as a bug and
/// gets worked around. So both engines document it and both pin it in a
/// test, and they change together or not at all. See ios#65, android#15.
public func mayWrite(_ label: Label) -> Bool { permits(label) }
}
/// The wallet, on the identity this session already unlocked.
extension FilesSession {
/// A new wallet.
public func createWallet(words: [String], entropyBytes: Int = 32)
throws -> (phrase: String, record: Data)
{
try Wallet.create(
walletKEK: Wallet.kek(identity: privateKey),
words: words, entropyBytes: entropyBytes)
}
/// Adopt a phrase from another wallet.
public func importWallet(phrase: String, words: [String]) throws -> Data {
try Wallet.import(
phrase: phrase,
walletKEK: Wallet.kek(identity: privateKey), words: words)
}
/// The phrase again, for somebody writing it down properly this time.
public func walletPhrase(record: Data, words: [String]) throws -> String {
try Wallet.phrase(
record: record,
walletKEK: Wallet.kek(identity: privateKey), words: words)
}
/// The next receive address, and the path it came from.
public func nextReceiveKey(record: Data, words: [String], index: UInt32) throws
-> (address: String, path: String)
{
let seed = try Wallet.seed(
record: record, walletKEK: Wallet.kek(identity: privateKey),
words: words)
let account = HDAccount(seed: seed, coinType: 501)
let key = try account.key(index: index)
return (Base58.encode(try key.publicKey()), account.path(index: index))
}
/// The 64-byte seed.
public func walletSeed(record: Data, words: [String], passphrase: String = "") throws -> Data {
try Wallet.seed(
record: record, walletKEK: Wallet.kek(identity: privateKey),
words: words, passphrase: passphrase)
}
}
@@ -0,0 +1,106 @@
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
init() {
let configuration = URLSessionConfiguration.ephemeral
// Nothing here should be written to a shared cache: responses carry
// wrapped keys and, for the identity endpoint, the protected private
// key record.
configuration.urlCache = nil
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)
}
/// JSON in, JSON out.
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
// Vapor encodes `Data` as base64, and `Data`'s synthesised Codable
// conformance expects an array of bytes.
decoder.dataDecodingStrategy = .base64
return decoder
}()
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.dataEncodingStrategy = .base64
return encoder
}()
func request(
_ method: String, _ url: URL, headers: [String: String] = [:],
json: (some Encodable & Sendable)? = Optional<String>.none, body: Data? = nil
) async throws -> (Data, HTTPURLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
if let json {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try Self.encoder.encode(json)
} else if let body {
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpBody = body
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw ClientError.badServer("That address did not answer as a web server.")
}
guard (200..<300).contains(http.statusCode) else {
throw ClientError.http(
status: http.statusCode,
detail: Self.reason(from: data))
}
return (data, http)
}
/// Vapor puts a human-readable message in `reason`.
static func reason(from data: Data) -> String {
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let reason = object["reason"] as? String
{
return reason
}
let text = String(data: data, encoding: .utf8) ?? ""
return text.count <= 200 ? text : ""
}
func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do { return try Self.decoder.decode(type, from: data) } catch {
throw ClientError.badServer(
"The server sent something this version cannot read. It may be newer than the app.")
}
}
}
extension URL {
/// Joins a path onto a base that may or may not have a trailing slash, and
/// refuses anything that is not http(s), a `file:` base would otherwise
/// silently "work" and read the device's own disk.
static func endpoint(_ base: String, _ path: String) throws -> URL {
var trimmed = base.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { throw ClientError.badServer("Enter a server address.") }
if !trimmed.contains("://") { trimmed = "https://" + trimmed }
while trimmed.hasSuffix("/") { trimmed.removeLast() }
guard let url = URL(string: trimmed + path),
let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
else { throw ClientError.badServer("That doesn't look like a server address.") }
return url
}
}
@@ -0,0 +1,445 @@
import CryptoKit
import FilesEngine
import Foundation
/// Files that live on this phone.
public actor LocalVault {
/// Where a row's bytes actually are.
public enum Storage: String, Codable, Sendable {
case vault
case reference
}
/// A row.
public struct Node: Codable, Sendable, Identifiable, Hashable {
public let id: UUID
public var parentID: UUID?
public var isFolder: Bool
/// In the clear, as it was in the database: a label is not a secret,
/// and it has to be readable to be shown on a row you cannot open.
public var classification: Int16
public var compartments: [Int]
public var wrappedNodeKey: Data
public var nameDigest: Data
public var wrappedName: Data
public var wrappedContentKey: Data?
public var byteCount: Int
public var addedAt: Date
/// Optional in storage, non-optional in use.
private var storageRaw: Storage?
public var storage: Storage {
get { storageRaw ?? .vault }
set { storageRaw = newValue }
}
/// Set only for `.reference`.
public var bookmark: Data?
private enum CodingKeys: String, CodingKey {
case id, parentID, isFolder, classification, compartments
case wrappedNodeKey, nameDigest, wrappedName, wrappedContentKey
case byteCount, addedAt, bookmark
case storageRaw = "storage"
}
public var label: Label {
Label(classification: classification, compartments: Set(compartments))
}
}
struct Index: Codable {
var version: Int = 1
var nodes: [Node] = []
}
/// What a screen shows: the row, plus the name if this device can read it.
public struct Entry: Sendable, Identifiable, Hashable {
public let node: Node
public let name: String?
/// True when this is a reference whose file is no longer where it was.
public let isMissing: Bool
public var id: UUID { node.id }
public var label: Label { node.label }
public var isFolder: Bool { node.isFolder }
public var byteCount: Int { node.byteCount }
public var storage: Storage { node.storage }
}
private let root: URL
private let identity: Curve25519.KeyAgreement.PrivateKey
private var index = Index()
public init(root: URL, identity: Curve25519.KeyAgreement.PrivateKey) {
self.root = root
self.identity = identity
}
// MARK: - Keys
/// The key for a label, derived rather than fetched.
func labelKey(_ label: Label) -> Data {
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: identity.rawRepresentation),
salt: label.bytes,
info: Data("flamenet/files/local-label/v1".utf8),
outputByteCount: 32
)
return key.withUnsafeBytes { Data($0) }
}
/// Whether this device can read a label at all.
private func canRead(_ node: Node) -> Bool {
(try? name(of: node)) != nil
}
private func name(of node: Node) throws -> String {
let key = labelKey(node.label)
let nodeKey = try Keys.unwrapNodeKey(node.wrappedNodeKey, labelKey: key, label: node.label)
return try Names.decrypt(
folderKey: nodeKey, blob: node.wrappedName,
digest: node.nameDigest, label: node.label)
}
// MARK: - Storage
private var indexURL: URL { root.appendingPathComponent("index.json") }
private func blobURL(_ id: UUID) -> URL {
root.appendingPathComponent("blobs", isDirectory: true)
.appendingPathComponent(id.uuidString)
}
public func load() throws {
try FileManager.default.createDirectory(
at: root.appendingPathComponent("blobs", isDirectory: true),
withIntermediateDirectories: true)
guard let data = try? Data(contentsOf: indexURL) else { return }
index = try JSONDecoder().decode(Index.self, from: data)
}
/// Written to a temporary file and moved into place.
private func save() throws {
let data = try JSONEncoder().encode(index)
let temporary = root.appendingPathComponent("index.json.new")
try data.write(to: temporary, options: .atomic)
_ = try FileManager.default.replaceItemAt(indexURL, withItemAt: temporary)
}
// MARK: - Reading
public func list(_ parentID: UUID?) -> [Entry] {
index.nodes
.filter { $0.parentID == parentID }
.map {
Entry(
node: $0, name: try? name(of: $0),
isMissing: $0.storage == .reference && resolve($0) == nil)
}
.sorted {
// Folders first, then by name; unreadable rows last, where they
// are least in the way of the ones you can act on.
if $0.isFolder != $1.isFolder { return $0.isFolder }
switch ($0.name, $1.name) {
case (let a?, let b?):
return a.localizedCaseInsensitiveCompare(b) == .orderedAscending
case (nil, _): return false
case (_, nil): return true
}
}
}
// MARK: - References
/// Re-establish access to a referenced file, or nil if it has gone.
func resolve(_ node: Node) -> URL? {
guard node.storage == .reference, let bookmark = node.bookmark else { return nil }
var stale = false
#if os(macOS)
let options: URL.BookmarkResolutionOptions = [.withSecurityScope]
#else
let options: URL.BookmarkResolutionOptions = []
#endif
guard
let url = try? URL(
resolvingBookmarkData: bookmark,
options: options,
relativeTo: nil,
bookmarkDataIsStale: &stale)
else { return nil }
// Existence is checked inside the scope: for a provider-backed file the
// path is not readable until access is started.
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
/// File a document that stays where it is.
@discardableResult
public func reference(
_ url: URL, named name: String, label: Label,
parentID: UUID? = nil
) throws -> Node {
#if os(macOS)
let bookmark = try url.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil)
#else
let bookmark = try url.bookmarkData(
options: [],
includingResourceValuesForKeys: nil,
relativeTo: nil)
#endif
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
var node = try makeNode(
named: name, label: label, parentID: parentID,
isFolder: false, byteCount: size)
node.storage = .reference
node.bookmark = bookmark
index.nodes.append(node)
try save()
return node
}
/// Turn a reference into an encrypted copy, in place.
@discardableResult
public func encrypt(_ id: UUID) throws -> URL? {
guard let position = index.nodes.firstIndex(where: { $0.id == id }) else {
throw ClientError.badServer("That file isn't here any more.")
}
let node = index.nodes[position]
guard node.storage == .reference else { return nil }
guard let origin = resolve(node) else {
throw ClientError.badServer(
"That file isn't where it was, it may have been moved or deleted.")
}
let plaintext = try contents(of: node)
let key = labelKey(node.label)
let nodeKey = try Keys.unwrapNodeKey(node.wrappedNodeKey, labelKey: key, label: node.label)
var contentKey = Data(count: 32)
contentKey.withUnsafeMutableBytes {
_ = SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
// Bytes before index, as everywhere else here: a crash between the two
// leaves an unreferenced blob, not a row pointing at nothing.
try Content.encrypt(plaintext, contentKey: contentKey, label: node.label)
.write(to: blobURL(node.id), options: .atomic)
var updated = node
updated.storage = .vault
// The bookmark goes.
updated.bookmark = nil
updated.wrappedContentKey = try Keys.wrapContentKey(
contentKey, nodeKey: nodeKey,
label: node.label)
updated.byteCount = plaintext.count
index.nodes[position] = updated
try save()
return origin
}
/// The bytes, encrypted for sending, whichever way they are stored.
public func sealedForSending(_ node: Node) throws -> (blob: Data, contentKey: Data) {
let plaintext = try contents(of: node)
var contentKey = Data(count: 32)
contentKey.withUnsafeMutableBytes {
_ = SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
return (
try Content.encrypt(plaintext, contentKey: contentKey, label: node.label),
contentKey
)
}
public func contents(of node: Node) throws -> Data {
guard !node.isFolder else { throw ClientError.badServer("That is a folder.") }
if node.storage == .reference {
guard let url = resolve(node) else {
throw ClientError.badServer(
"That file isn't where it was, it may have been moved or deleted.")
}
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
return try Data(contentsOf: url)
}
guard let wrappedContentKey = node.wrappedContentKey else {
throw ClientError.badServer("That file has no contents.")
}
let key = labelKey(node.label)
let nodeKey = try Keys.unwrapNodeKey(node.wrappedNodeKey, labelKey: key, label: node.label)
let contentKey = try Keys.unwrapContentKey(
wrappedContentKey, nodeKey: nodeKey, label: node.label)
let blob = try Data(contentsOf: blobURL(node.id))
return try Content.decrypt(blob, contentKey: contentKey, label: node.label)
}
// MARK: - Writing
@discardableResult
public func add(
_ bytes: Data, named name: String, label: Label,
parentID: UUID? = nil
) throws -> Node {
let node = try makeNode(
named: name, label: label, parentID: parentID,
isFolder: false, byteCount: bytes.count)
let key = labelKey(label)
let nodeKey = try Keys.unwrapNodeKey(node.wrappedNodeKey, labelKey: key, label: label)
var contentKey = Data(count: 32)
contentKey.withUnsafeMutableBytes {
_ = SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
// Bytes to disk BEFORE the index.
try Content.encrypt(bytes, contentKey: contentKey, label: label)
.write(to: blobURL(node.id), options: .atomic)
var stored = node
stored.wrappedContentKey = try Keys.wrapContentKey(
contentKey, nodeKey: nodeKey, label: label)
index.nodes.append(stored)
try save()
return stored
}
@discardableResult
public func createFolder(_ name: String, label: Label, parentID: UUID? = nil) throws -> Node {
let node = try makeNode(
named: name, label: label, parentID: parentID,
isFolder: true, byteCount: 0)
index.nodes.append(node)
try save()
return node
}
private func makeNode(
named name: String, label: Label, parentID: UUID?,
isFolder: Bool, byteCount: Int
) throws -> Node {
var nodeKey = Data(count: 32)
nodeKey.withUnsafeMutableBytes {
_ = SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
let key = labelKey(label)
let named = try Names.encrypt(folderKey: nodeKey, name: name, label: label)
return Node(
id: UUID(), parentID: parentID, isFolder: isFolder,
classification: label.classification,
compartments: label.compartments.sorted(),
// Wrapped to the node's OWN label key, never the parent's.
wrappedNodeKey: try Keys.wrapNodeKey(nodeKey, labelKey: key, label: label),
nameDigest: named.digest,
wrappedName: named.ciphertext,
wrappedContentKey: nil,
byteCount: byteCount,
addedAt: Date()
)
}
/// Removes a node and its bytes.
public func remove(_ id: UUID) throws {
guard let position = index.nodes.firstIndex(where: { $0.id == id }) else { return }
if index.nodes[position].isFolder,
index.nodes.contains(where: { $0.parentID == id })
{
throw ClientError.badServer("That folder isn't empty.")
}
let wasVaulted = index.nodes[position].storage == .vault
index.nodes.remove(at: position)
try save()
// Never the person's own file, see the note in `remove(ids:)`.
if wasVaulted { try? FileManager.default.removeItem(at: blobURL(id)) }
}
/// What a bulk delete actually did.
public struct Removal: Sendable {
public var removed: [UUID] = []
/// Folders left alone because something inside them was not selected.
public var keptNonEmpty: [UUID] = []
public var isCompletelyDone: Bool { keptNonEmpty.isEmpty }
}
/// Delete several things at once.
@discardableResult
public func remove(ids: [UUID]) throws -> Removal {
var result = Removal()
var removedVaultIDs: [UUID] = []
let wanted = Set(ids)
// Depth first: a child must go before the parent that contains it.
func depth(_ id: UUID) -> Int {
var steps = 0
var current = index.nodes.first { $0.id == id }?.parentID
while let parent = current, steps < 64 {
steps += 1
current = index.nodes.first { $0.id == parent }?.parentID
}
return steps
}
let ordered = ids.filter { id in index.nodes.contains { $0.id == id } }
.sorted { depth($0) > depth($1) }
for id in ordered {
guard let position = index.nodes.firstIndex(where: { $0.id == id }) else { continue }
let node = index.nodes[position]
if node.isFolder {
// Anything left inside that was not itself selected?
let survivors = index.nodes.filter { $0.parentID == id && !wanted.contains($0.id) }
if !survivors.isEmpty {
result.keptNonEmpty.append(id)
continue
}
}
if node.storage == .vault { removedVaultIDs.append(id) }
index.nodes.remove(at: position)
result.removed.append(id)
}
// One save for the whole batch, after the index is consistent.
try save()
// Only ever our own blobs.
for id in removedVaultIDs {
try? FileManager.default.removeItem(at: blobURL(id))
}
return result
}
/// Every id in a subtree, so a screen can offer "and everything in it".
public func descendants(of id: UUID) -> [UUID] {
var found: [UUID] = []
var seen: Set<UUID> = [id]
var frontier = [id]
while let next = frontier.popLast() {
for child in index.nodes where child.parentID == next {
guard seen.insert(child.id).inserted else { continue }
found.append(child.id)
frontier.append(child.id)
}
}
return found
}
/// Every file in the whole tree, for a picker that has no business showing
/// folders.
public func allFiles() -> [Entry] {
index.nodes
.filter { !$0.isFolder }
.map {
Entry(
node: $0, name: try? name(of: $0),
isMissing: $0.storage == .reference && resolve($0) == nil)
}
.filter { $0.name != nil && !$0.isMissing }
.sorted {
($0.name ?? "").localizedCaseInsensitiveCompare($1.name ?? "") == .orderedAscending
}
}
public func node(_ id: UUID) -> Node? { index.nodes.first { $0.id == id } }
public var count: Int { index.nodes.count }
}
@@ -0,0 +1,52 @@
import FilesEngine
import Foundation
/// The clearance claims, read out of the token the account service minted.
public struct TokenClaims: Sendable {
public let pseudonym: UUID?
public let tenant: String
public let clearance: Clearance
public let expiresAt: Date?
private struct Payload: Decodable {
let sub: String?
let exp: Double?
let ten: String?
let mxc: Int16?
let mxk: Int64?
let mnc: Int16?
let mnk: Int64?
}
public init(token: String) throws {
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3 else {
throw ClientError.badServer("The server issued a token this app cannot read.")
}
var body = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
body += String(repeating: "=", count: (4 - body.count % 4) % 4)
guard let data = Data(base64Encoded: body),
let payload = try? JSONDecoder().decode(Payload.self, from: data)
else { throw ClientError.badServer("The server issued a token this app cannot read.") }
self.pseudonym = payload.sub.flatMap(UUID.init(uuidString:))
self.tenant = payload.ten ?? "default"
self.expiresAt = payload.exp.map { Date(timeIntervalSince1970: $0) }
self.clearance = Clearance(
maximum: Label(
classification: payload.mxc ?? 0,
compartments: Self.bits(payload.mxk ?? 0)),
minimum: Label(
classification: payload.mnc ?? 0,
compartments: Self.bits(payload.mnk ?? 0)))
}
/// Postgres has no unsigned 64-bit integer, so the mask travels as a signed
/// one and bit 63 arrives negative.
static func bits(_ pattern: Int64) -> Set<Int> {
let mask = UInt64(bitPattern: pattern)
return Set((0..<64).filter { mask & (1 << UInt64($0)) != 0 })
}
}
@@ -0,0 +1,188 @@
import FilesEngine
import Foundation
/// The wire types, spelled out rather than decoded loosely.
public enum Wire {
/// What a server says about itself, before anyone has signed in.
public struct ServerConfig: Codable, Sendable {
public let accountsURL: String
public let tenant: String
public let siteURL: String
public let sourceURL: String
/// The largest ciphertext this deployment accepts.
public let maxUploadBytes: Int?
public let protocolVersion: Int
}
public struct KDFParams: Codable, Sendable {
public let kdfSalt: Data
public let kdfID: Int16
public let kdfIterations: Int
}
public struct Session: Codable, Sendable {
public let token: String
public let pseudonym: UUID
public let expiresAt: Date
}
public struct Identity: Codable, Sendable {
public let principalID: UUID
public let publicKey: Data
public let protectedRecord: String?
public let recoveryRecord: String?
}
public struct NewIdentity: Codable, Sendable {
public let product: String
public let publicKey: Data
public let protectedRecord: String
public let recoveryRecord: String?
public init(
product: String = "files", publicKey: Data,
protectedRecord: String, recoveryRecord: String?
) {
self.product = product
self.publicKey = publicKey
self.protectedRecord = protectedRecord
self.recoveryRecord = recoveryRecord
}
}
/// A node as the server sees it: a label, two opaque name blobs, and
/// nothing it can read.
public struct Node: Codable, Sendable, Identifiable, Hashable {
public let id: UUID
public let parentID: UUID?
public let classification: Int16
public let compartments: [Int]
public let nameDigest: Data
public let nameCiphertext: Data
public let isFolder: Bool
public let blobID: UUID?
public let contentKey: Data?
public let sizeBytes: Int64?
public var label: Label {
Label(classification: classification, compartments: Set(compartments))
}
}
public struct LabelGrant: Codable, Sendable {
public let classification: Int16
public let compartments: [Int]
public let wrappedKey: Data
public var label: Label {
Label(classification: classification, compartments: Set(compartments))
}
}
/// A recipient the account service confirmed is cleared for a label.
public struct Recipient: Codable, Sendable {
public let pseudonym: UUID
public let publicKey: Data
}
public struct NodeKey: Codable, Sendable {
public let wrappedKey: Data
}
public struct BlobCreated: Codable, Sendable {
public let id: UUID
}
/// What this deployment calls its levels and areas.
public struct Vocabulary: Codable, Sendable {
public struct Level: Codable, Sendable, Hashable {
public let ordinal: Int16
public let name: String
public init(ordinal: Int16, name: String) {
self.ordinal = ordinal
self.name = name
}
}
public struct Compartment: Codable, Sendable, Hashable {
public let bit: Int
public let name: String
public init(bit: Int, name: String) {
self.bit = bit
self.name = name
}
}
public let levels: [Level]
public let compartments: [Compartment]
public let canEdit: Bool?
public init(levels: [Level] = [], compartments: [Compartment] = [], canEdit: Bool? = nil) {
self.levels = levels
self.compartments = compartments
self.canEdit = canEdit
}
public func name(for label: Label) -> String {
let level =
levels.first { $0.ordinal == label.classification }?.name
?? "Level \(label.classification)"
let areas = label.compartments.sorted().compactMap { bit in
compartments.first { $0.bit == bit }?.name ?? "Area \(bit)"
}
return areas.isEmpty ? level : "\(level) // \(areas.joined(separator: ", "))"
}
}
public struct Share: Codable, Sendable {
public let id: UUID
public let expiresAt: Date?
public let classification: Int16?
public let compartments: [Int]?
public let wrappedContentKey: Data?
public let wrappedName: Data?
public let passwordSalt: Data?
public let passwordIterations: Int?
public let needsPassword: Bool?
public let blobID: UUID?
}
}
/// What went wrong, in terms the interface can actually say out loud.
public enum ClientError: LocalizedError, Equatable {
case http(status: Int, detail: String)
case notCleared
/// Not the same thing as being uncleared, and saying so matters: the first
/// is a permission the server decides, the second is a key this device has
/// simply not minted yet, which happens to every account before it first
/// writes anywhere.
case noKeyForArea
case badServer(String)
case wrongPassphrase
/// Refused before reading, not after uploading.
case tooLarge(limit: Int, actual: Int)
public var errorDescription: String? {
switch self {
case .tooLarge(let limit, let actual):
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return "That file is \(formatter.string(fromByteCount: Int64(actual))). "
+ "This server accepts up to \(formatter.string(fromByteCount: Int64(limit)))."
case .wrongPassphrase:
return "That handle and passphrase don't match."
case .notCleared:
return "You aren't cleared for this."
case .noKeyForArea:
return "You don't have a key for this area yet. Put something in it, "
+ "or ask somebody who works there to give you theirs."
case .badServer(let why):
return why
case .http(let status, let detail):
// 404 is deliberate on this server: a row you are not cleared to
// see is missing, not forbidden.
if status == 404 { return "Not found." }
if status == 401 { return "Your session has expired. Sign in again." }
if status == 403 { return "That's outside your clearance." }
return detail.isEmpty ? "The server returned \(status)." : detail
}
}
}
@@ -0,0 +1,125 @@
import CryptoKit
import Foundation
/// BIP-39 mnemonics, for the wallet only.
public enum BIP39 {
public enum Failure: Error, LocalizedError, Equatable {
case badEntropySize(Int)
case badPhraseLength(Int)
case unknownWord(String)
case checksumFailed
case wordlistDigestMismatch(String)
case wordlistWrongLength(Int)
public var errorDescription: String? {
switch self {
case .badEntropySize(let n):
return "Entropy must be 16, 20, 24, 28 or 32 bytes, not \(n)."
case .badPhraseLength(let n):
return "A recovery phrase is 12, 15, 18, 21 or 24 words, not \(n)."
case .unknownWord(let w):
return "“\(w)” is not a recovery phrase word."
case .checksumFailed:
return "That phrase does not check out, look for a mistyped word."
case .wordlistDigestMismatch:
return "The word list does not match its published fingerprint."
case .wordlistWrongLength(let n):
return "A word list has exactly 2048 words, not \(n)."
}
}
}
/// The published SHA-256 of the BIP-39 English word list.
public static let englishDigest =
"2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda"
/// The whole mitigation.
public static func verify(_ words: [String], digest: String = englishDigest) throws {
guard words.count == 2048 else { throw Failure.wordlistWrongLength(words.count) }
let canonical = words.joined(separator: "\n") + "\n"
let hex = SHA256.hash(data: Data(canonical.utf8))
.map { String(format: "%02x", $0) }.joined()
guard hex == digest else { throw Failure.wordlistDigestMismatch(hex) }
}
private static let permitted: [Int: Int] = [16: 12, 20: 15, 24: 18, 28: 21, 32: 24]
/// Entropy to phrase.
public static func phrase(from entropy: Data, words: [String]) throws -> String {
guard permitted[entropy.count] != nil else {
throw Failure.badEntropySize(entropy.count)
}
var bits = entropy.map { String($0, radix: 2).leftPadded(to: 8) }.joined()
bits += SHA256.hash(data: entropy)
.map { String($0, radix: 2).leftPadded(to: 8) }.joined()
bits = String(bits.prefix(entropy.count * 8 + entropy.count / 4))
var out: [String] = []
var index = bits.startIndex
while index < bits.endIndex {
let end = bits.index(index, offsetBy: 11)
out.append(words[Int(bits[index..<end], radix: 2)!])
index = end
}
return out.joined(separator: " ")
}
/// Phrase back to entropy, refusing anything that does not check out.
public static func entropy(from phrase: String, words: [String]) throws -> Data {
let given = phrase.precomposedStringWithCompatibilityMapping
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isWhitespace).map(String.init)
guard [12, 15, 18, 21, 24].contains(given.count) else {
throw Failure.badPhraseLength(given.count)
}
var lookup: [String: Int] = [:]
for (i, w) in words.enumerated() { lookup[w] = i }
var bits = ""
for word in given {
guard let at = lookup[word] else { throw Failure.unknownWord(word) }
bits += String(at, radix: 2).leftPadded(to: 11)
}
let entropyBits = given.count * 11 * 32 / 33
var entropy = Data()
var index = bits.startIndex
for _ in 0..<(entropyBits / 8) {
let end = bits.index(index, offsetBy: 8)
entropy.append(UInt8(bits[index..<end], radix: 2)!)
index = end
}
let expected = SHA256.hash(data: entropy)
.map { String($0, radix: 2).leftPadded(to: 8) }.joined()
guard String(bits.suffix(from: index)) == String(expected.prefix(entropyBits / 32)) else {
throw Failure.checksumFailed
}
return entropy
}
/// Phrase to the 64-byte seed everything derives from.
public static func seed(from phrase: String, passphrase: String = "") throws -> Data {
try KDF.pbkdf2SHA512(
password: Data(phrase.precomposedStringWithCompatibilityMapping.utf8),
salt: Data(("mnemonic" + passphrase).precomposedStringWithCompatibilityMapping.utf8),
iterations: 2048, length: 64)
}
/// The vendored list, read from the bundled resource.
public static func englishWordlist() throws -> [String] {
guard let url = Bundle.module.url(forResource: "english", withExtension: "txt"),
let text = try? String(contentsOf: url, encoding: .utf8)
else { throw Failure.wordlistWrongLength(0) }
let words = text.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: "\n").map(String.init)
try verify(words)
return words
}
}
extension String {
fileprivate func leftPadded(to width: Int) -> String {
count >= width ? self : String(repeating: "0", count: width - count) + self
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Base58, the alphabet every ed25519 chain prints addresses in.
public enum Base58 {
static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
/// Not the Bitcoin alphabet's cousins: `0`, `O`, `I` and `l` are absent
/// deliberately, so an address read aloud or copied by hand cannot become a
/// different valid one.
static let values: [Character: UInt8] = {
var map: [Character: UInt8] = [:]
for (index, character) in alphabet.enumerated() { map[character] = UInt8(index) }
return map
}()
public static func encode(_ data: Data) -> String {
guard !data.isEmpty else { return "" }
// Starts EMPTY, not [0].
var digits: [UInt8] = []
for byte in data {
var carry = Int(byte)
for position in digits.indices {
carry += Int(digits[position]) << 8
digits[position] = UInt8(carry % 58)
carry /= 58
}
while carry > 0 {
digits.append(UInt8(carry % 58))
carry /= 58
}
}
// One '1' per leading zero byte, see the note above.
let leadingZeros = data.prefix { $0 == 0 }.count
return String(repeating: "1", count: leadingZeros)
+ String(digits.reversed().map { alphabet[Int($0)] })
}
/// Nil for any character outside the alphabet, rather than a best guess: a
/// mistyped address must fail here, not resolve to somebody else's.
public static func decode(_ string: String) -> Data? {
guard !string.isEmpty else { return Data() }
var bytes: [UInt8] = []
for character in string {
guard let value = values[character] else { return nil }
var carry = Int(value)
for position in bytes.indices {
carry += Int(bytes[position]) * 58
bytes[position] = UInt8(carry & 0xff)
carry >>= 8
}
while carry > 0 {
bytes.append(UInt8(carry & 0xff))
carry >>= 8
}
}
let leadingOnes = string.prefix { $0 == "1" }.count
return Data(repeating: 0, count: leadingOnes) + Data(bytes.reversed())
}
}
@@ -0,0 +1,48 @@
import Foundation
enum Bytes {
static func u32be(_ value: UInt32) -> Data {
Data([
UInt8(truncatingIfNeeded: value >> 24), UInt8(truncatingIfNeeded: value >> 16),
UInt8(truncatingIfNeeded: value >> 8), UInt8(truncatingIfNeeded: value),
])
}
static func readU32be(_ data: Data, at offset: Int) -> UInt32 {
let b = [UInt8](data[data.startIndex + offset..<data.startIndex + offset + 4])
return (UInt32(b[0]) << 24) | (UInt32(b[1]) << 16) | (UInt32(b[2]) << 8) | UInt32(b[3])
}
static func random(_ count: Int) -> Data {
var data = Data(count: count)
_ = data.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!)
}
return data
}
}
/// Random bytes, from the system CSPRNG.
public enum Random {
public static func bytes(_ count: Int) -> Data { Bytes.random(count) }
}
public enum EngineError: Error, Equatable, LocalizedError {
case unsupportedVersion(UInt8)
case malformed(String)
case notCleared
/// Without this every one of these reaches a person as "The operation
/// couldn't be completed.
public var errorDescription: String? {
switch self {
case .unsupportedVersion(let version):
return
"This was written in a newer format (version \(version)) than this app understands."
case .malformed(let why):
return why
case .notCleared:
return "You aren't cleared for this."
}
}
}
@@ -0,0 +1,205 @@
import Foundation
/// Reading a public chain, without handing it your whole wallet.
public struct ChainRPC: Sendable {
public let endpoint: URL
/// Injected so tests drive it without a network, and so the app can hand it
/// a session with its own configuration.
private let perform: @Sendable (URLRequest) async throws -> (Data, URLResponse)
public init(
endpoint: URL,
perform: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = {
try await URLSession.shared.data(for: $0)
}
) {
self.endpoint = endpoint
self.perform = perform
}
public enum Failure: LocalizedError, Equatable {
case http(Int)
case node(String)
case malformed(String)
public var errorDescription: String? {
switch self {
case .http(let code):
return "The node answered with \(code)."
case .node(let message):
// The node's own words.
return message
case .malformed:
return "That node's answer wasn't something this app understands."
}
}
}
// MARK: - Wire
struct Request: Encodable {
let jsonrpc = "2.0"
let id = 1
let method: String
let params: [AnyEncodable]
}
/// Minimal type erasure.
struct AnyEncodable: Encodable {
private let encode: (Encoder) throws -> Void
init<T: Encodable>(_ value: T) { encode = { try value.encode(to: $0) } }
func encode(to encoder: Encoder) throws { try encode(encoder) }
}
func call(_ method: String, _ params: [AnyEncodable]) async throws -> Any {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(Request(method: method, params: params))
let (data, response) = try await perform(request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw Failure.http(http.statusCode)
}
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw Failure.malformed("not an object")
}
// A JSON-RPC error arrives with HTTP 200.
if let error = object["error"] as? [String: Any] {
throw Failure.node((error["message"] as? String) ?? "The node refused.")
}
guard let result = object["result"] else { throw Failure.malformed("no result") }
return result
}
// MARK: - Reading
/// The balance in the chain's smallest unit.
public func balance(of address: String) async throws -> UInt64 {
let result = try await call("getBalance", [AnyEncodable(address)])
guard let wrapper = result as? [String: Any],
let value = wrapper["value"] as? NSNumber
else {
throw Failure.malformed("balance")
}
return value.uint64Value
}
/// What has happened to an address, newest first.
public func history(for address: String, limit: Int = 25) async throws -> [Signature] {
let result = try await call(
"getSignaturesForAddress",
[
AnyEncodable(address),
AnyEncodable(["limit": limit]),
])
guard let rows = result as? [[String: Any]] else { throw Failure.malformed("history") }
return rows.compactMap(Signature.init(json:))
}
// MARK: - Sending
/// A blockhash to build a transaction against, and the height past which it
/// can no longer be accepted.
public struct Blockhash: Sendable, Hashable {
public let value: Data
public let lastValidBlockHeight: UInt64
}
public func latestBlockhash() async throws -> Blockhash {
let result = try await call("getLatestBlockhash", [])
guard let wrapper = result as? [String: Any],
let value = wrapper["value"] as? [String: Any],
let text = value["blockhash"] as? String,
let raw = Base58.decode(text), raw.count == 32
else {
throw Failure.malformed("blockhash")
}
let height = (value["lastValidBlockHeight"] as? NSNumber)?.uint64Value ?? 0
return Blockhash(value: raw, lastValidBlockHeight: height)
}
/// Submit a signed transaction.
public func send(transaction: Data) async throws -> String {
// A declared struct rather than a dictionary: the options are
// heterogeneous, and a `[String: Any]` is not `Encodable`, reaching for
// one anyway is how the encoding silently reverts to the node's base58
// default and every send comes back reported as malformed.
struct SendOptions: Encodable {
let encoding = "base64"
let skipPreflight = false
let preflightCommitment = "confirmed"
}
let result = try await call(
"sendTransaction",
[
AnyEncodable(transaction.base64EncodedString()),
AnyEncodable(SendOptions()),
])
guard let signature = result as? String else { throw Failure.malformed("signature") }
return signature
}
/// What became of a transaction, by signature.
public func status(of signature: String) async throws -> Status? {
struct StatusOptions: Encodable { let searchTransactionHistory = true }
let result = try await call(
"getSignatureStatuses",
[
AnyEncodable([signature]),
AnyEncodable(StatusOptions()),
])
guard let wrapper = result as? [String: Any],
let values = wrapper["value"] as? [Any]
else {
throw Failure.malformed("status")
}
guard let first = values.first, !(first is NSNull),
let row = first as? [String: Any]
else { return nil }
let failed = !(row["err"] is NSNull) && row["err"] != nil
let confirmations = (row["confirmations"] as? NSNumber)?.intValue
let level = row["confirmationStatus"] as? String
return Status(failed: failed, confirmations: confirmations, level: level)
}
public struct Status: Sendable, Hashable {
/// A transaction can be ON the chain and have failed.
public let failed: Bool
/// Nil once it is rooted, which means finalised, not unknown.
public let confirmations: Int?
public let level: String?
public var isFinal: Bool { level == "finalized" || confirmations == nil }
}
/// One entry in an address's history.
public struct Signature: Sendable, Hashable, Identifiable {
public let signature: String
public let slot: UInt64
/// Absent while a transaction is very new, the node has it but has not
/// dated it yet.
public let blockTime: Date?
/// A FAILED transaction still appears in an address's history.
public let failed: Bool
public var id: String { signature }
init?(json: [String: Any]) {
guard let signature = json["signature"] as? String else { return nil }
self.signature = signature
self.slot = (json["slot"] as? NSNumber)?.uint64Value ?? 0
if let seconds = json["blockTime"] as? NSNumber {
self.blockTime = Date(timeIntervalSince1970: seconds.doubleValue)
} else {
self.blockTime = nil
}
self.failed = json["err"] is [String: Any] || json["err"] is [Any]
}
public init(signature: String, slot: UInt64, blockTime: Date?, failed: Bool) {
self.signature = signature
self.slot = slot
self.blockTime = blockTime
self.failed = failed
}
}
}
@@ -0,0 +1,91 @@
import CryptoKit
import Foundation
/// File contents.
public enum Content {
static let magic = Data("ENCL".utf8)
static let version: UInt8 = 1
static let tagBytes = 16
static let headerBytes = 17
public static let defaultChunkSize = 64 * 1024
static func nonce(base: Data, index: UInt32) throws -> AES.GCM.Nonce {
try AES.GCM.Nonce(data: base + Bytes.u32be(index))
}
static func aad(label: Label, index: UInt32, isLast: Bool) -> Data {
label.bytes + Bytes.u32be(index) + Data([isLast ? 1 : 0])
}
public static func encrypt(
_ plaintext: Data, contentKey: Data, label: Label,
chunkSize: Int = defaultChunkSize
) throws -> Data {
guard chunkSize > 0, chunkSize <= Int(UInt32.max) else {
throw EngineError.malformed("chunk size out of range")
}
let key = SymmetricKey(data: contentKey)
let baseNonce = Bytes.random(8)
// An empty file is still one chunk, so it too carries an authenticated
// end marker and cannot be confused with one truncated to nothing.
let count = max(1, (plaintext.count + chunkSize - 1) / chunkSize)
var out = magic + Data([version]) + Bytes.u32be(UInt32(chunkSize)) + baseNonce
for index in 0..<count {
let start = plaintext.startIndex + index * chunkSize
let end = min(start + chunkSize, plaintext.endIndex)
let sealed = try AES.GCM.seal(
plaintext[start..<end], using: key,
nonce: try nonce(base: baseNonce, index: UInt32(index)),
authenticating: aad(label: label, index: UInt32(index), isLast: index == count - 1)
)
out += sealed.ciphertext + sealed.tag
}
return out
}
public static func decrypt(_ blob: Data, contentKey: Data, label: Label) throws -> Data {
guard blob.count >= headerBytes else { throw EngineError.malformed("truncated header") }
let bytes = [UInt8](blob)
guard Data(bytes[0..<4]) == magic else {
throw EngineError.malformed("not an Enclave content blob")
}
guard bytes[4] == version else { throw EngineError.unsupportedVersion(bytes[4]) }
let chunkSize = Int(Bytes.readU32be(blob, at: 5))
guard chunkSize > 0 else { throw EngineError.malformed("invalid chunk size") }
let baseNonce = Data(bytes[9..<17])
let body = Data(bytes[headerBytes...])
let stored = chunkSize + tagBytes
let remainder = body.count % stored
// A remainder of exactly the tag length is a legitimate empty final
// chunk.
guard remainder == 0 || remainder >= tagBytes else {
throw EngineError.malformed("malformed final chunk")
}
let count = body.count / stored + (remainder == 0 ? 0 : 1)
guard count > 0 else { throw EngineError.malformed("no chunks") }
let key = SymmetricKey(data: contentKey)
var out = Data()
for index in 0..<count {
let start = body.startIndex + index * stored
let end = min(start + stored, body.endIndex)
let slice = body[start..<end]
// Authentication fails here on truncation, reordering or
// relabelling.
let box = try AES.GCM.SealedBox(
nonce: try nonce(base: baseNonce, index: UInt32(index)),
ciphertext: slice.dropLast(tagBytes),
tag: slice.suffix(tagBytes)
)
out += try AES.GCM.open(
box, using: key,
authenticating: aad(
label: label, index: UInt32(index),
isLast: index == count - 1))
}
return out
}
}
@@ -0,0 +1,135 @@
import CryptoKit
import Foundation
/// Hierarchical key derivation from a wallet seed.
public struct HDKey: Sendable, Equatable {
/// 32 bytes.
public let privateKey: Data
/// 32 bytes.
public let chainCode: Data
/// SLIP-0010 fixes this string; changing it changes every key.
static let curve = Data("ed25519 seed".utf8)
/// Anything at or above this is hardened.
public static let hardened: UInt32 = 0x8000_0000
public enum Failure: LocalizedError, Equatable {
case seedOutOfRange(Int)
case unhardened(UInt32)
case malformedPath(String)
public var errorDescription: String? {
switch self {
case .seedOutOfRange:
// Not a user-facing situation: a seed always comes from BIP-39
// here, which cannot produce a wrong length.
return "That wallet seed is the wrong size."
case .unhardened:
return "This wallet only derives hardened keys."
case .malformedPath(let path):
return "\(path) isn't a derivation path."
}
}
}
/// The master key.
public static func master(seed: Data) throws -> HDKey {
// The standard's own bound.
guard (16...64).contains(seed.count) else { throw Failure.seedOutOfRange(seed.count) }
let digest = HMAC<SHA512>.authenticationCode(
for: seed, using: SymmetricKey(data: curve))
let bytes = Data(digest)
// ed25519 needs no retry loop.
return HDKey(privateKey: bytes.prefix(32), chainCode: bytes.suffix(32))
}
/// One hardened step.
public func derive(_ index: UInt32) throws -> HDKey {
let hardenedIndex = index >= Self.hardened ? index : index | Self.hardened
guard hardenedIndex >= Self.hardened else { throw Failure.unhardened(index) }
// 0x00 || private key || index, big-endian.
var data = Data([0x00])
data.append(privateKey)
data.append(contentsOf: [
UInt8(truncatingIfNeeded: hardenedIndex >> 24),
UInt8(truncatingIfNeeded: hardenedIndex >> 16),
UInt8(truncatingIfNeeded: hardenedIndex >> 8),
UInt8(truncatingIfNeeded: hardenedIndex),
])
let digest = HMAC<SHA512>.authenticationCode(
for: data, using: SymmetricKey(data: chainCode))
let bytes = Data(digest)
return HDKey(privateKey: bytes.prefix(32), chainCode: bytes.suffix(32))
}
/// Walk a path such as `m/44H/501H/0H/0H`.
public func derive(path: String) throws -> HDKey {
var components = path.split(separator: "/").map(String.init)
if let first = components.first, first == "m" || first == "M" {
components.removeFirst()
}
var key = self
for component in components {
guard
component.hasSuffix("H") || component.hasSuffix("h")
|| component.hasSuffix("'")
else {
throw Failure.unhardened(0)
}
let digits = String(component.dropLast())
guard let index = UInt32(digits), index < Self.hardened else {
throw Failure.malformedPath(path)
}
key = try key.derive(index)
}
return key
}
public static func master(seed: Data, path: String) throws -> HDKey {
try master(seed: seed).derive(path: path)
}
/// The 32-byte ed25519 public key.
public func publicKey() throws -> Data {
try Curve25519.Signing.PrivateKey(rawRepresentation: privateKey)
.publicKey.rawRepresentation
}
/// The public key as SLIP-0010 prints it: `0x00 || key`.
public func slip10PublicKey() throws -> Data { Data([0x00]) + (try publicKey()) }
}
/// An account's receive keys.
public struct HDAccount: Sendable {
public let seed: Data
/// SLIP-44 coin type. 501 is Solana's; ed25519 chains each have their own.
public let coinType: UInt32
public let account: UInt32
public private(set) var nextIndex: UInt32
public init(seed: Data, coinType: UInt32, account: UInt32 = 0, nextIndex: UInt32 = 0) {
self.seed = seed
self.coinType = coinType
self.account = account
self.nextIndex = nextIndex
}
/// BIP-44 shape, every level hardened as this curve requires.
public func path(index: UInt32) -> String {
"m/44H/\(coinType)H/\(account)H/0H/\(index)H"
}
public func key(index: UInt32) throws -> HDKey {
try HDKey.master(seed: seed, path: path(index: index))
}
/// The next unused receive key, advancing the counter.
public mutating func next() throws -> (index: UInt32, key: HDKey) {
let index = nextIndex
let key = try self.key(index: index)
nextIndex = index + 1
return (index, key)
}
}
@@ -0,0 +1,146 @@
import CryptoKit
import Foundation
/// The identity keypair.
public enum Identity {
/// RFC 8410 PrivateKeyInfo for X25519: version, algorithm id, and an OCTET
/// STRING wrapping a 32-byte OCTET STRING.
static let pkcs8Prefix = Data([
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x6E, 0x04, 0x22, 0x04, 0x20,
])
public static func rawFromPKCS8(_ pkcs8: Data) throws -> Data {
guard pkcs8.count == 48, pkcs8.prefix(16) == pkcs8Prefix else {
throw EngineError.malformed("not an X25519 PKCS#8 private key")
}
return Data(pkcs8.suffix(32))
}
public static func pkcs8FromRaw(_ raw: Data) throws -> Data {
guard raw.count == 32 else {
throw EngineError.malformed("X25519 private key must be 32 bytes")
}
return pkcs8Prefix + raw
}
/// The stored record, as written by any FlameNet client.
public struct Record: Codable, Sendable {
public let v: Int
public let kdf: Int
public let iterations: Int
public let context: String
public let salt: String
public let nonce: String
public let privateKey: String
/// The stored form.
public var json: String {
String(decoding: (try? JSONEncoder().encode(self)) ?? Data(), as: UTF8.self)
}
public static func parse(_ text: String) throws -> Record {
guard let data = text.data(using: .utf8),
let record = try? JSONDecoder().decode(Record.self, from: data)
else { throw EngineError.malformed("identity record is not valid JSON") }
return record
}
public init(
v: Int, kdf: Int, iterations: Int, context: String,
salt: String, nonce: String, privateKey: String
) {
self.v = v
self.kdf = kdf
self.iterations = iterations
self.context = context
self.salt = salt
self.nonce = nonce
self.privateKey = privateKey
}
}
public static func generate() -> Curve25519.KeyAgreement.PrivateKey {
Curve25519.KeyAgreement.PrivateKey()
}
/// Raw 32 bytes, which is what every FlameNet server stores and what a
/// label key is wrapped to.
public static func exportPublicKey(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
key.rawRepresentation
}
/// Encrypt a private key for storage.
public static func protect(
_ privateKey: Curve25519.KeyAgreement.PrivateKey,
passphrase: String,
parameters: KDF.Parameters = .v1,
context: String = KDF.Context.filesIdentity
) throws -> Record {
let salt = Bytes.random(16)
let kek = try KDF.derive(
passphrase: passphrase, salt: salt, context: context, parameters: parameters)
let nonce = Bytes.random(12)
// PKCS#8, not raw: the browser writes this format and both must read
// each other's records.
let sealed = try AES.GCM.seal(
try pkcs8FromRaw(privateKey.rawRepresentation),
using: SymmetricKey(data: kek),
nonce: try AES.GCM.Nonce(data: nonce),
authenticating: Data(context.utf8)
)
return Record(
v: 1, kdf: parameters.id, iterations: parameters.iterations, context: context,
salt: salt.base64EncodedString(),
nonce: nonce.base64EncodedString(),
// Ciphertext and tag concatenated: WebCrypto's AES-GCM output is
// the two joined, while CryptoKit keeps them apart.
privateKey: (sealed.ciphertext + sealed.tag).base64EncodedString()
)
}
/// Wrap the same private key under a recovery code.
public static func protectWithRecovery(
_ privateKey: Curve25519.KeyAgreement.PrivateKey,
entropy: Data,
parameters: KDF.Parameters = .v1
) throws -> Record {
try protect(
privateKey, passphrase: Recovery.kdfSecret(entropy),
parameters: parameters, context: KDF.Context.filesRecovery)
}
public static func unlockWithRecovery(_ record: Record, entropy: Data) throws
-> Curve25519.KeyAgreement.PrivateKey
{
try unlock(record, passphrase: Recovery.kdfSecret(entropy))
}
/// Unlock a record produced by this engine or by the browser.
public static func unlock(_ record: Record, passphrase: String) throws
-> Curve25519.KeyAgreement.PrivateKey
{
guard record.v == 1 else { throw EngineError.unsupportedVersion(UInt8(record.v)) }
guard let salt = Data(base64Encoded: record.salt),
let sealedKey = Data(base64Encoded: record.privateKey)
else { throw EngineError.malformed("identity record is not valid base64") }
let kek = try KDF.derive(
passphrase: passphrase, salt: salt, context: record.context,
parameters: .init(id: record.kdf, iterations: record.iterations)
)
// The record stores the nonce separately; CryptoKit's `combined` form
// expects it prepended to the ciphertext.
guard let nonce = Data(base64Encoded: record.nonce) else {
throw EngineError.malformed("identity nonce is not valid base64")
}
// The context is the authenticated data as well as the KDF separator,
// so a record made for one product will not open as another's.
let pkcs8 = try AES.GCM.open(
try AES.GCM.SealedBox(combined: nonce + sealedKey),
using: SymmetricKey(data: kek),
authenticating: Data(record.context.utf8)
)
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: try rawFromPKCS8(pkcs8))
}
}
@@ -0,0 +1,105 @@
import CommonCrypto
import CryptoKit
import Foundation
/// Key derivation.
public enum KDF {
public struct Parameters: Sendable, Equatable {
public let id: Int
public let iterations: Int
public static let v1 = Parameters(id: 1, iterations: 650_000)
public init(id: Int = 1, iterations: Int) {
self.id = id
self.iterations = iterations
}
}
/// Per-product context strings.
public enum Context {
public static let filesIdentity = "flamenet/files/identity/v1"
public static let filesAuth = "flamenet/files/auth/v1"
public static let filesRecovery = "flamenet/files/recovery/v1"
/// The account-side half of a recovery code.
public static let filesAuthRecovery = "flamenet/files/auth-recovery/v1"
public static let messengerIdentity = "flamenet/messenger/identity/v1"
/// The wallet seed is NOT derived from the passphrase, see SPEC §8.1.
public static let walletSeed = "flamenet/wallet/seed/v1"
/// The key the wallet record is wrapped under.
public static let walletKEK = "flamenet/wallet/kek/v1"
}
/// Stage one.
static func master(passphrase: String, salt: Data, parameters: Parameters) throws -> Data {
let passwordBytes = Array(passphrase.utf8)
var derived = [UInt8](repeating: 0, count: 32)
let status = salt.withUnsafeBytes { saltBuffer -> Int32 in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes.map { Int8(bitPattern: $0) }, passwordBytes.count,
saltBuffer.bindMemory(to: UInt8.self).baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(parameters.iterations),
&derived, derived.count
)
}
guard status == kCCSuccess else { throw EngineError.malformed("PBKDF2 failed") }
return Data(derived)
}
/// PBKDF2-HMAC-SHA512, for BIP-39 seeds only.
public static func pbkdf2SHA512(
password: Data, salt: Data, iterations: Int, length: Int
) throws -> Data {
let passwordBytes = Array(password)
var derived = [UInt8](repeating: 0, count: length)
let status = salt.withUnsafeBytes { saltBuffer -> Int32 in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes.map { Int8(bitPattern: $0) }, passwordBytes.count,
saltBuffer.bindMemory(to: UInt8.self).baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512),
UInt32(iterations),
&derived, derived.count
)
}
guard status == kCCSuccess else { throw EngineError.malformed("PBKDF2-SHA512 failed") }
return Data(derived)
}
/// Stage two: raw bytes for a context.
public static func derive(
passphrase: String, salt: Data, context: String,
parameters: Parameters = .v1, length: Int = 32
) throws -> Data {
let secret = SymmetricKey(
data: try master(passphrase: passphrase, salt: salt, parameters: parameters))
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: secret,
salt: Data(),
info: Data(context.utf8),
outputByteCount: length
)
return key.withUnsafeBytes { Data($0) }
}
/// The secret sent to the account service.
public static func authSecret(passphrase: String, salt: Data, parameters: Parameters = .v1)
throws -> Data
{
try derive(
passphrase: passphrase, salt: salt, context: Context.filesAuth, parameters: parameters)
}
/// The account-side proof of a recovery code.
public static func recoveryAuthSecret(entropy: Data) -> Data {
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: Data(Recovery.kdfSecret(entropy).utf8)),
salt: Data(),
info: Data(Context.filesAuthRecovery.utf8),
outputByteCount: 32
)
return key.withUnsafeBytes { Data($0) }
}
}
@@ -0,0 +1,38 @@
import Foundation
/// The security lattice.
public struct Label: Hashable, Sendable {
public var classification: Int16
/// Bit positions 0...63.
public var compartments: Set<Int>
public init(classification: Int16, compartments: Set<Int> = []) {
precondition(
compartments.allSatisfy { (0...63).contains($0) },
"compartment bits must be 0...63")
self.classification = classification
self.compartments = compartments
}
public var mask: UInt64 {
compartments.reduce(into: UInt64(0)) { $0 |= (1 << UInt64($1)) }
}
/// `self ⊒ other`: at least as high, and a superset of areas.
public func dominates(_ other: Label) -> Bool {
classification >= other.classification && compartments.isSuperset(of: other.compartments)
}
/// int16be classification || uint64be compartment mask.
public var bytes: Data {
var out = Data(capacity: 10)
out.append(UInt8(truncatingIfNeeded: classification >> 8))
out.append(UInt8(truncatingIfNeeded: classification))
let mask = self.mask
for shift in stride(from: 56, through: 0, by: -8) {
out.append(UInt8(truncatingIfNeeded: mask >> UInt64(shift)))
}
_ = mask
return out
}
}
@@ -0,0 +1,49 @@
import CryptoKit
import Foundation
/// Filenames.
public enum Names {
static let version: UInt8 = 1
/// Unicode normalisation only.
static func normalize(_ name: String) -> Data {
Data(name.precomposedStringWithCanonicalMapping.utf8)
}
public static func digest(folderKey: Data, name: String) -> Data {
var mac = HMAC<SHA256>(key: SymmetricKey(data: folderKey))
mac.update(data: normalize(name))
return Data(mac.finalize())
}
/// The label *and* the digest are authenticated, so a name ciphertext
/// cannot be moved onto another row to learn which plaintext belongs to it.
public static func encrypt(folderKey: Data, name: String, label: Label) throws -> (
digest: Data, ciphertext: Data
) {
let nameDigest = digest(folderKey: folderKey, name: name)
let sealed = try AES.GCM.seal(
normalize(name), using: SymmetricKey(data: folderKey),
authenticating: label.bytes + nameDigest)
guard let combined = sealed.combined else {
throw EngineError.malformed("seal produced no output")
}
return (nameDigest, Data([version]) + combined)
}
public static func decrypt(folderKey: Data, blob: Data, digest: Data, label: Label) throws
-> String
{
guard blob.count > 13 else { throw EngineError.malformed("name too short") }
let bytes = [UInt8](blob)
guard bytes[0] == version else { throw EngineError.unsupportedVersion(bytes[0]) }
let plaintext = try AES.GCM.open(
try AES.GCM.SealedBox(combined: Data(bytes[1...])),
using: SymmetricKey(data: folderKey),
authenticating: label.bytes + digest)
guard let name = String(data: plaintext, encoding: .utf8) else {
throw EngineError.malformed("name is not valid UTF-8")
}
return name
}
}
@@ -0,0 +1,121 @@
import CryptoKit
import Foundation
/// Recovery codes.
public enum Recovery {
static let alphabet = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
public static let entropyBytes = 16
static let checksumBits = 12
static let codeLength = 28
static func checksum(_ entropy: Data) -> UInt16 {
let digest = Array(SHA256.hash(data: entropy))
// Top 12 bits.
return (UInt16(digest[0]) << 4) | (UInt16(digest[1]) >> 4)
}
/// Fold the ambiguous glyphs and drop formatting, so a code read off paper
/// as "O" for zero or "l" for one still decodes.
public static func normalize(_ text: String) -> String {
String(
text.uppercased().compactMap { character -> Character? in
switch character {
case "O": return "0"
case "I", "L": return "1"
case "0"..."9", "A"..."Z": return character
default: return nil
}
})
}
public static func generate() -> (entropy: Data, code: String) {
let entropy = Bytes.random(entropyBytes)
return (entropy, try! encode(entropy))
}
public static func encode(_ entropy: Data) throws -> String {
guard entropy.count == entropyBytes else {
throw EngineError.malformed("entropy must be \(entropyBytes) bytes")
}
// 140 bits does not fit a fixed-width integer, so the value is carried
// as a little-endian digit array and shifted by hand.
var digits = [UInt8](repeating: 0, count: codeLength)
// Right-aligned in 18 bytes: 128 bits of entropy shifted up by the 12
// checksum bits is 140, which needs the extra two bytes of headroom.
var accumulator = [UInt8](repeating: 0, count: 2) + [UInt8](entropy)
// Append the 12-bit checksum: shift the 128 bits left by 12 and or it
// in.
let check = checksum(entropy)
shiftLeft(&accumulator, bits: checksumBits)
accumulator[accumulator.count - 1] |= UInt8(check & 0xFF)
accumulator[accumulator.count - 2] |= UInt8((check >> 8) & 0x0F)
for index in stride(from: codeLength - 1, through: 0, by: -1) {
digits[index] = accumulator[accumulator.count - 1] & 31
shiftRight(&accumulator, bits: 5)
}
let raw = String(digits.map { alphabet[Int($0)] })
return stride(from: 0, to: raw.count, by: 4).map {
let start = raw.index(raw.startIndex, offsetBy: $0)
let end = raw.index(start, offsetBy: min(4, raw.count - $0))
return String(raw[start..<end])
}.joined(separator: "-")
}
public static func decode(_ text: String) throws -> Data {
let normalized = normalize(text)
guard normalized.count == codeLength else {
throw EngineError.malformed(
"recovery code must be \(codeLength) characters, got \(normalized.count)")
}
var accumulator = [UInt8](repeating: 0, count: entropyBytes + 2)
for character in normalized {
guard let index = alphabet.firstIndex(of: character) else {
throw EngineError.malformed("invalid character '\(character)' in recovery code")
}
shiftLeft(&accumulator, bits: 5)
accumulator[accumulator.count - 1] |= UInt8(index)
}
let provided =
(UInt16(accumulator[accumulator.count - 2] & 0x0F) << 8)
| UInt16(accumulator[accumulator.count - 1])
shiftRight(&accumulator, bits: checksumBits)
let entropy = Data(accumulator.suffix(entropyBytes))
guard checksum(entropy) == provided else {
throw EngineError.malformed("recovery code checksum does not match, check for a typo")
}
return entropy
}
/// The string fed to the KDF.
public static func kdfSecret(_ entropy: Data) -> String {
entropy.map { String(format: "%02x", $0) }.joined()
}
// MARK: big-endian bit shifts over a byte array
private static func shiftLeft(_ bytes: inout [UInt8], bits: Int) {
for _ in 0..<bits {
var carry: UInt8 = 0
for index in stride(from: bytes.count - 1, through: 0, by: -1) {
let next = bytes[index] >> 7
bytes[index] = (bytes[index] << 1) | carry
carry = next
}
}
}
private static func shiftRight(_ bytes: inout [UInt8], bits: Int) {
for _ in 0..<bits {
var carry: UInt8 = 0
for index in 0..<bytes.count {
let next = bytes[index] & 1
bytes[index] = (bytes[index] >> 1) | (carry << 7)
carry = next
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import CommonCrypto
import CryptoKit
import Foundation
/// Share links that still respect the lattice.
public enum Shares {
static let info = "enclave/share/v1"
static let infoPassword = "enclave/share/v2"
public static let passwordIterations = 250_000
public struct Sealed: Sendable {
public let wrappedContentKey: Data
public let wrappedName: Data
/// Public.
public let passwordSalt: Data?
public let passwordIterations: Int?
}
public static func createSecret() -> Data { Bytes.random(32) }
static func passwordKey(_ password: String, salt: Data, iterations: Int) throws -> Data {
let passwordBytes = Array(password.utf8)
var derived = [UInt8](repeating: 0, count: 32)
let status = salt.withUnsafeBytes { saltBuffer -> Int32 in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes.map { Int8(bitPattern: $0) }, passwordBytes.count,
saltBuffer.bindMemory(to: UInt8.self).baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(iterations), &derived, derived.count
)
}
guard status == kCCSuccess else { throw EngineError.malformed("PBKDF2 failed") }
return Data(derived)
}
/// Two factors always; three when a password is set.
static func shareKey(labelKey: Data, secret: Data, passwordKey: Data?) -> Data {
let material = passwordKey.map { labelKey + secret + $0 } ?? (labelKey + secret)
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: material),
salt: Data(),
info: Data((passwordKey == nil ? info : infoPassword).utf8),
outputByteCount: 32
)
return key.withUnsafeBytes { Data($0) }
}
/// Both the key and the name carry the label as authenticated data, exactly
/// as everything else does, so a share cannot be replayed against a row the
/// server has since relabelled.
public static func seal(
labelKey: Data, label: Label, secret: Data,
contentKey: Data, name: String, password: String? = nil
) throws -> Sealed {
let salt = password == nil ? nil : Bytes.random(16)
let pwKey = try password.map {
try passwordKey($0, salt: salt!, iterations: passwordIterations)
}
let key = shareKey(labelKey: labelKey, secret: secret, passwordKey: pwKey)
let aad = label.bytes
return Sealed(
wrappedContentKey: try Wrap.withKey(key, key: contentKey, aad: aad),
wrappedName: try Wrap.withKey(key, key: Data(name.utf8), aad: aad),
passwordSalt: salt,
passwordIterations: password == nil ? nil : passwordIterations
)
}
public static func open(
labelKey: Data, label: Label, secret: Data,
wrappedContentKey: Data, wrappedName: Data,
password: String? = nil, passwordSalt: Data? = nil,
passwordIterations iterations: Int = passwordIterations
) throws -> (contentKey: Data, name: String) {
if passwordSalt != nil && password == nil {
throw EngineError.malformed("This link needs the password it was sent with.")
}
let pwKey = try passwordSalt.map {
try passwordKey(password!, salt: $0, iterations: iterations)
}
let key = shareKey(labelKey: labelKey, secret: secret, passwordKey: pwKey)
let aad = label.bytes
let contentKey = try Wrap.openWithKey(key, blob: wrappedContentKey, aad: aad)
let nameBytes = try Wrap.openWithKey(key, blob: wrappedName, aad: aad)
guard let name = String(data: nameBytes, encoding: .utf8) else {
throw EngineError.malformed("share name is not valid UTF-8")
}
return (contentKey, name)
}
/// The fragment form.
public static func encodeSecret(_ secret: Data) -> String {
secret.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
public static func decodeSecret(_ text: String) throws -> Data {
var padded =
text
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
padded += String(repeating: "=", count: (4 - padded.count % 4) % 4)
guard let data = Data(base64Encoded: padded) else {
throw EngineError.malformed("share secret is not valid base64url")
}
return data
}
}
@@ -0,0 +1,129 @@
import CryptoKit
import Foundation
/// Building, signing and encoding a transfer.
public enum SolanaTransaction {
/// The System Program's address is thirty-two zero bytes, which base58
/// prints as thirty-two `1`s.
public static let systemProgramID = Data(repeating: 0, count: 32)
/// `SystemInstruction::Transfer`, as a little-endian `u32`.
static let transferDiscriminator: UInt32 = 2
public enum Failure: LocalizedError, Equatable {
case badAddress(String)
case badBlockhash
case amountNotPositive
case sendingToYourself
public var errorDescription: String? {
switch self {
case .badAddress:
return "That isn't a valid address. Check it against where you "
+ "copied it from, an address with a typo in it is very "
+ "often still a valid address, and there is no undo."
case .badBlockhash:
return "The node gave an answer this app couldn't use. Try again."
case .amountNotPositive:
return "Enter an amount greater than zero."
case .sendingToYourself:
return "That's this wallet's own address."
}
}
}
// MARK: - Addresses
/// A 32-byte public key from a base58 address.
public static func publicKey(fromAddress address: String) throws -> Data {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard let raw = Base58.decode(trimmed), raw.count == 32 else {
throw Failure.badAddress(trimmed)
}
return raw
}
// MARK: - Compact-u16
/// Solana's length prefix: seven bits a byte, high bit meaning "more".
static func compactU16(_ value: Int) -> Data {
precondition(value >= 0 && value <= 0xffff, "compact-u16 out of range")
var remaining = value
var out = Data()
repeat {
var byte = UInt8(remaining & 0x7f)
remaining >>= 7
if remaining != 0 { byte |= 0x80 }
out.append(byte)
} while remaining != 0
return out
}
static func compactArray(_ items: [Data]) -> Data {
var out = compactU16(items.count)
for item in items { out.append(item) }
return out
}
// MARK: - The message
/// The bytes a signature covers.
public static func transferMessage(
from sender: Data,
to recipient: Data,
lamports: UInt64,
recentBlockhash: Data
) throws -> Data {
guard sender.count == 32, recipient.count == 32 else {
throw Failure.badAddress("")
}
guard recentBlockhash.count == 32 else { throw Failure.badBlockhash }
guard lamports > 0 else { throw Failure.amountNotPositive }
guard sender != recipient else { throw Failure.sendingToYourself }
var out = Data()
// Header: one signature required, no read-only signers, and the System
// Program is the single read-only unsigned account.
out.append(contentsOf: [1, 0, 1])
out.append(compactArray([sender, recipient, systemProgramID]))
out.append(recentBlockhash)
// One instruction: the program at account index 2, touching accounts 0
// and 1, carrying the transfer payload.
let payload = transferData(lamports: lamports)
var instruction = Data()
instruction.append(2) // program id index
instruction.append(compactU16(2)) // two account indices
instruction.append(contentsOf: [0, 1]) // from, to
instruction.append(compactU16(payload.count))
instruction.append(payload)
out.append(compactU16(1)) // one instruction
out.append(instruction)
return out
}
/// The instruction payload: a 4-byte discriminant then 8 bytes of amount,
/// both little-endian.
static func transferData(lamports: UInt64) -> Data {
var out = Data()
withUnsafeBytes(of: transferDiscriminator.littleEndian) { out.append(contentsOf: $0) }
withUnsafeBytes(of: lamports.littleEndian) { out.append(contentsOf: $0) }
return out
}
// MARK: - Signing
/// A signed transaction, ready to be base64'd and posted.
public static func signed(
message: Data,
key: Curve25519.Signing.PrivateKey
) throws -> Data {
let signature = try key.signature(for: message)
var out = compactU16(1)
out.append(signature)
out.append(message)
return out
}
}
@@ -0,0 +1,80 @@
import CryptoKit
import Foundation
/// A wallet seed, and the two independent ways back to it.
public enum Wallet {
/// AAD for the wrapped record, exactly as an identity record uses its
/// context.
static let aad = Data(KDF.Context.walletSeed.utf8)
/// A new wallet: the phrase to write down, and the record to store. 256
/// bits by default, 24 words.
public static func create(
walletKEK: Data, words: [String], entropyBytes: Int = 32
) throws -> (phrase: String, record: Data) {
var entropy = Data(count: entropyBytes)
let status = entropy.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, entropyBytes, $0.baseAddress!)
}
guard status == errSecSuccess else {
throw EngineError.malformed("the system would not provide random bytes")
}
return (
try BIP39.phrase(from: entropy, words: words),
try wrap(entropy: entropy, walletKEK: walletKEK)
)
}
/// Wrap the ENTROPY, not the seed.
public static func wrap(entropy: Data, walletKEK: Data) throws -> Data {
try Wrap.withKey(walletKEK, key: entropy, aad: aad)
}
public static func unwrap(record: Data, walletKEK: Data) throws -> Data {
try Wrap.openWithKey(walletKEK, blob: record, aad: aad)
}
/// The phrase, from the stored record, for showing it again later.
public static func phrase(record: Data, walletKEK: Data, words: [String]) throws -> String {
try BIP39.phrase(from: try unwrap(record: record, walletKEK: walletKEK), words: words)
}
/// Adopt a phrase that came from somewhere else.
public static func `import`(
phrase: String, walletKEK: Data, words: [String]
) throws -> Data {
try wrap(entropy: try BIP39.entropy(from: phrase, words: words), walletKEK: walletKEK)
}
/// The key a wallet record is wrapped under, from the Files identity key.
public static func kek(identity: Curve25519.KeyAgreement.PrivateKey) -> Data {
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: identity.rawRepresentation),
salt: Data(),
info: Data(KDF.Context.walletKEK.utf8),
outputByteCount: 32
)
return key.withUnsafeBytes { Data($0) }
}
/// Move a record from one identity key to another.
public static func rewrap(
record: Data,
from old: Curve25519.KeyAgreement.PrivateKey,
to new: Curve25519.KeyAgreement.PrivateKey
) throws -> Data {
try wrap(
entropy: try unwrap(record: record, walletKEK: kek(identity: old)),
walletKEK: kek(identity: new))
}
/// The 64-byte seed everything derives from.
public static func seed(
record: Data, walletKEK: Data, words: [String], passphrase: String = ""
) throws -> Data {
try BIP39.seed(
from: try phrase(record: record, walletKEK: walletKEK, words: words),
passphrase: passphrase)
}
}
@@ -0,0 +1,114 @@
import CryptoKit
import Foundation
/// Key wrapping.
public enum Wrap {
static let asymmetricVersion: UInt8 = 1
static let symmetricVersion: UInt8 = 17
static let info = Data("enclave/wrap/v1".utf8)
private static func sharedKey(
privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey,
ephemeralPublic: Data, recipientPublic: Data
) throws -> SymmetricKey {
let shared = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
// Both public keys in the salt bind the derived key to this exact pair,
// so a wrap cannot be replayed at a different recipient.
return shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: ephemeralPublic + recipientPublic,
sharedInfo: info,
outputByteCount: 32
)
}
/// version(1) || ephemeral public key(32) || nonce(12) || ciphertext ||
/// tag.
public static func toPublicKey(
_ recipient: Curve25519.KeyAgreement.PublicKey, key: Data, aad: Data
) throws -> Data {
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let ephemeralPublic = ephemeral.publicKey.rawRepresentation
let recipientPublic = recipient.rawRepresentation
let kek = try sharedKey(
privateKey: ephemeral, publicKey: recipient,
ephemeralPublic: ephemeralPublic, recipientPublic: recipientPublic)
let sealed = try AES.GCM.seal(key, using: kek, authenticating: aad)
guard let combined = sealed.combined else {
throw EngineError.malformed("seal produced no output")
}
return Data([asymmetricVersion]) + ephemeralPublic + combined
}
public static func fromPrivateKey(
_ privateKey: Curve25519.KeyAgreement.PrivateKey, blob: Data, aad: Data
) throws -> Data {
guard blob.count > 45 else { throw EngineError.malformed("wrap too short") }
let bytes = [UInt8](blob)
guard bytes[0] == asymmetricVersion else { throw EngineError.unsupportedVersion(bytes[0]) }
let ephemeralPublic = Data(bytes[1..<33])
let ephemeral = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: ephemeralPublic)
let kek = try sharedKey(
privateKey: privateKey, publicKey: ephemeral,
ephemeralPublic: ephemeralPublic,
recipientPublic: privateKey.publicKey.rawRepresentation)
return try AES.GCM.open(
try AES.GCM.SealedBox(combined: Data(bytes[33...])),
using: kek, authenticating: aad)
}
/// version(17) || nonce(12) || ciphertext || tag.
public static func withKey(_ kek: Data, key: Data, aad: Data) throws -> Data {
let sealed = try AES.GCM.seal(key, using: SymmetricKey(data: kek), authenticating: aad)
guard let combined = sealed.combined else {
throw EngineError.malformed("seal produced no output")
}
return Data([symmetricVersion]) + combined
}
public static func openWithKey(_ kek: Data, blob: Data, aad: Data) throws -> Data {
guard blob.count > 13 else { throw EngineError.malformed("wrap too short") }
let bytes = [UInt8](blob)
guard bytes[0] == symmetricVersion else { throw EngineError.unsupportedVersion(bytes[0]) }
return try AES.GCM.open(
try AES.GCM.SealedBox(combined: Data(bytes[1...])),
using: SymmetricKey(data: kek), authenticating: aad)
}
}
/// The key hierarchy.
public enum Keys {
public static func grantLabelKey(
_ labelKey: Data, label: Label,
to recipient: Curve25519.KeyAgreement.PublicKey
) throws -> Data {
try Wrap.toPublicKey(recipient, key: labelKey, aad: label.bytes)
}
public static func acceptLabelKey(
_ blob: Data, label: Label,
privateKey: Curve25519.KeyAgreement.PrivateKey
) throws -> Data {
try Wrap.fromPrivateKey(privateKey, blob: blob, aad: label.bytes)
}
public static func wrapNodeKey(_ nodeKey: Data, labelKey: Data, label: Label) throws -> Data {
try Wrap.withKey(labelKey, key: nodeKey, aad: label.bytes)
}
public static func unwrapNodeKey(_ blob: Data, labelKey: Data, label: Label) throws -> Data {
try Wrap.openWithKey(labelKey, blob: blob, aad: label.bytes)
}
public static func wrapContentKey(_ contentKey: Data, nodeKey: Data, label: Label) throws
-> Data
{
try Wrap.withKey(nodeKey, key: contentKey, aad: label.bytes)
}
public static func unwrapContentKey(_ blob: Data, nodeKey: Data, label: Label) throws -> Data {
try Wrap.openWithKey(nodeKey, blob: blob, aad: label.bytes)
}
}
@@ -0,0 +1,52 @@
import FilesEngine
import XCTest
@testable import FilesClient
/// The two halves of the model, and which one is enforced.
///
/// The mirror of `LabelTest` on the Kotlin side. Both engines must answer the
/// same way: a rule enforced on one client and not the other is worse than the
/// gap, because a file that saves on one phone and is refused on the other
/// reads as a bug and gets worked around.
final class ClearanceTests: XCTestCase {
/// No read up. Nothing above the clearance, and nothing outside its areas.
func testReadingStopsAtTheClearance() {
let cleared = Clearance(maximum: Label(classification: 5), minimum: Label(classification: 0))
XCTAssertTrue(cleared.mayRead(Label(classification: 5)))
XCTAssertTrue(cleared.mayRead(Label(classification: 1)))
XCTAssertFalse(cleared.mayRead(Label(classification: 8)))
}
/// Writing uses the same range check as reading, so the second rule of the
/// model is NOT enforced. Pinned, so the day it changes this fails and the
/// Kotlin test next to it has to change in the same commit.
func testWritingUsesTheSameCheckAsReading() {
let cleared = Clearance(maximum: Label(classification: 5), minimum: Label(classification: 0))
XCTAssertTrue(cleared.mayWrite(Label(classification: 5)))
XCTAssertTrue(
cleared.mayWrite(Label(classification: 1)),
"no-write-down is now enforced: see ios#65, and change the Kotlin "
+ "LabelTest and this test together")
XCTAssertFalse(cleared.mayWrite(Label(classification: 8)))
}
/// A floor bounds writing as well as reading.
func testAFloorBoundsBothDirections() {
let cleared = Clearance(maximum: Label(classification: 5), minimum: Label(classification: 2))
XCTAssertTrue(cleared.mayWrite(Label(classification: 3)))
XCTAssertFalse(cleared.mayWrite(Label(classification: 1)))
XCTAssertFalse(cleared.mayRead(Label(classification: 1)))
}
/// Areas, not only levels: a clearance without the area does not reach it.
func testAreasAreCheckedTooBothWays() {
let cleared = Clearance(
maximum: Label(classification: 5, compartments: [1]),
minimum: Label(classification: 0))
XCTAssertTrue(cleared.mayRead(Label(classification: 3, compartments: [1])))
XCTAssertFalse(cleared.mayRead(Label(classification: 3, compartments: [2])))
XCTAssertFalse(cleared.mayWrite(Label(classification: 3, compartments: [2])))
}
}
@@ -0,0 +1,594 @@
import CryptoKit
import FilesEngine
import XCTest
@testable import FilesClient
final class LocalVaultTests: XCTestCase {
private var root: URL!
private let identity = Identity.generate()
override func setUpWithError() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("vault-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: root)
}
private func open(_ key: Curve25519.KeyAgreement.PrivateKey? = nil) async throws -> LocalVault {
let vault = LocalVault(root: root, identity: key ?? identity)
try await vault.load()
return vault
}
private let confidentialLegal = Label(classification: 2, compartments: [0])
func testAFileGoesInAndComesBackOut() async throws {
let vault = try await open()
let bytes = Data("the quick brown fox".utf8)
let node = try await vault.add(bytes, named: "notes.txt", label: confidentialLegal)
let rows = await vault.list(nil)
XCTAssertEqual(rows.count, 1)
XCTAssertEqual(rows.first?.name, "notes.txt")
XCTAssertEqual(rows.first?.label, confidentialLegal)
XCTAssertEqual(rows.first?.byteCount, bytes.count)
let read = try await vault.contents(of: node)
XCTAssertEqual(read, bytes)
}
/// The point of local-first: it is still there with no network and no
/// process that saw it created.
func testTheVaultSurvivesBeingClosedAndReopened() async throws {
let first = try await open()
let node = try await first.add(
Data("keep me".utf8), named: "keep.txt",
label: confidentialLegal)
let second = try await open()
let reopened = await second.count
let firstName = await second.list(nil).first?.name
XCTAssertEqual(reopened, 1)
XCTAssertEqual(firstName, "keep.txt")
let readBack = try await second.contents(of: node)
XCTAssertEqual(readBack, Data("keep me".utf8))
}
/// Nothing readable is written to disk.
func testNothingOnDiskIsReadable() async throws {
let vault = try await open()
let node = try await vault.add(
Data("the salary figures".utf8),
named: "payroll.csv", label: confidentialLegal)
let index = try String(
decoding: Data(contentsOf: root.appendingPathComponent("index.json")),
as: UTF8.self)
XCTAssertFalse(index.contains("payroll"), "the file name is in the clear on disk")
let blob = try Data(
contentsOf: root.appendingPathComponent("blobs")
.appendingPathComponent(node.id.uuidString))
XCTAssertFalse(
String(decoding: blob, as: UTF8.self).contains("salary"),
"the contents are in the clear on disk")
// But the LABEL is, deliberately, it has to be readable to show a
// classification on a row you cannot open.
XCTAssertTrue(index.contains("\"classification\":2"))
}
/// The property the whole lattice rests on: a different identity derives
/// different label keys, so another account's vault does not open.
func testAnotherIdentityCannotReadIt() async throws {
let mine = try await open()
let node = try await mine.add(
Data("mine".utf8), named: "secret.txt",
label: confidentialLegal)
let theirs = try await open(Identity.generate())
let theirName = await theirs.list(nil).first?.name
XCTAssertNil(theirName, "another identity read the name")
await XCTAssertThrowsErrorAsync { try await theirs.contents(of: node) }
}
/// A row filed under a label this device has no key for is LISTED, with its
/// classification, and refuses to open.
func testAnUnreadableRowIsStillListedWithItsClassification() async throws {
let mine = try await open()
_ = try await mine.add(Data("x".utf8), named: "theirs.txt", label: confidentialLegal)
let theirs = try await open(Identity.generate())
let rows = await theirs.list(nil)
XCTAssertEqual(rows.count, 1)
XCTAssertNil(rows[0].name)
XCTAssertEqual(rows[0].label, confidentialLegal)
}
/// A node key is wrapped to its own label, never the folder's, so a
/// Restricted file inside a Confidential folder does not open to somebody
/// holding only the folder's key.
func testAHigherFileInsideALowerFolderKeepsItsOwnKey() async throws {
let vault = try await open()
let folder = try await vault.createFolder(
"Cases", label: confidentialLegal,
parentID: nil)
let restricted = Label(classification: 3, compartments: [0])
let file = try await vault.add(
Data("sealed".utf8), named: "sealed.pdf",
label: restricted, parentID: folder.id)
XCTAssertEqual(file.classification, 3)
let folderKey = await vault.labelKey(confidentialLegal)
XCTAssertThrowsError(
try Keys.unwrapNodeKey(file.wrappedNodeKey, labelKey: folderKey, label: restricted),
"the folder's key opened a file filed above it")
}
/// Compartments separate, not just levels.
func testTwoAreasAtTheSameLevelDoNotShareAKey() async throws {
let vault = try await open()
let legal = await vault.labelKey(Label(classification: 2, compartments: [0]))
let finance = await vault.labelKey(Label(classification: 2, compartments: [1]))
XCTAssertNotEqual(legal, finance)
}
func testAFolderWithSomethingInItWillNotBeDeleted() async throws {
let vault = try await open()
let folder = try await vault.createFolder("Cases", label: confidentialLegal)
_ = try await vault.add(
Data("x".utf8), named: "a.txt",
label: confidentialLegal, parentID: folder.id)
await XCTAssertThrowsErrorAsync { try await vault.remove(folder.id) }
let afterRefusal = await vault.count
XCTAssertEqual(afterRefusal, 2)
}
func testDeletingAFileTakesItsBytesWithIt() async throws {
let vault = try await open()
let node = try await vault.add(Data("bye".utf8), named: "bye.txt", label: confidentialLegal)
let blob = root.appendingPathComponent("blobs").appendingPathComponent(node.id.uuidString)
XCTAssertTrue(FileManager.default.fileExists(atPath: blob.path))
try await vault.remove(node.id)
let afterDelete = await vault.count
XCTAssertEqual(afterDelete, 0)
XCTAssertFalse(
FileManager.default.fileExists(atPath: blob.path),
"the bytes outlived the row that referenced them")
}
// MARK: - References
private func writeLoose(_ text: String, named name: String) throws -> URL {
let url = root.appendingPathComponent("outside-\(name)")
try Data(text.utf8).write(to: url)
return url
}
func testAReferencedFileReadsThroughToTheOriginal() async throws {
let vault = try await open()
let original = try writeLoose("on the phone already", named: "photo.txt")
let node = try await vault.reference(
original, named: "photo.txt",
label: confidentialLegal)
XCTAssertEqual(node.storage, .reference)
let read = try await vault.contents(of: node)
XCTAssertEqual(read, Data("on the phone already".utf8))
let rows = await vault.list(nil)
XCTAssertEqual(rows.first?.name, "photo.txt")
XCTAssertEqual(rows.first?.storage, .reference)
XCTAssertFalse(rows.first?.isMissing ?? true)
}
/// The honest cost of referencing, pinned as a test so nobody claims
/// otherwise: nothing was copied and nothing was encrypted, so the bytes
/// are still readable on disk to anyone who has the file.
func testAReferencedFileIsNotEncryptedByUs() async throws {
let vault = try await open()
let original = try writeLoose("the salary figures", named: "payroll.csv")
_ = try await vault.reference(original, named: "payroll.csv", label: confidentialLegal)
let onDisk = try String(decoding: Data(contentsOf: original), as: UTF8.self)
XCTAssertEqual(
onDisk, "the salary figures",
"referencing must leave the original exactly as it was")
// No blob was written for it.
let blobs = try FileManager.default.contentsOfDirectory(
atPath: root.appendingPathComponent("blobs").path)
XCTAssertTrue(blobs.isEmpty, "referencing wrote a copy")
}
/// Where a referenced file DOES become ciphertext: at send time.
func testSendingAReferencedFileSealsItUnderItsLabel() async throws {
let vault = try await open()
let original = try writeLoose("the salary figures", named: "payroll.csv")
let node = try await vault.reference(
original, named: "payroll.csv",
label: confidentialLegal)
let sealed = try await vault.sealedForSending(node)
XCTAssertFalse(
String(decoding: sealed.blob, as: UTF8.self).contains("salary"),
"the courier would have carried plaintext")
// And it opens again only with that key AND that label.
let back = try Content.decrypt(
sealed.blob, contentKey: sealed.contentKey,
label: confidentialLegal)
XCTAssertEqual(back, Data("the salary figures".utf8))
XCTAssertThrowsError(
try Content.decrypt(
sealed.blob, contentKey: sealed.contentKey,
label: Label(classification: 3, compartments: [0])),
"a relabelled blob opened anyway, the label is not bound as AAD")
}
func testAVaultedFileAlsoSealsForSending() async throws {
let vault = try await open()
let node = try await vault.add(
Data("copied in".utf8), named: "c.txt",
label: confidentialLegal)
let sealed = try await vault.sealedForSending(node)
let back = try Content.decrypt(
sealed.blob, contentKey: sealed.contentKey,
label: confidentialLegal)
XCTAssertEqual(back, Data("copied in".utf8))
}
/// The failure mode referencing brings with it.
func testAReferenceWhoseFileMovedReportsItselfMissing() async throws {
let vault = try await open()
let original = try writeLoose("here for now", named: "temp.txt")
let node = try await vault.reference(
original, named: "temp.txt",
label: confidentialLegal)
try FileManager.default.removeItem(at: original)
let rows = await vault.list(nil)
XCTAssertEqual(rows.count, 1, "a broken reference must still be listed")
XCTAssertTrue(rows.first?.isMissing ?? false)
await XCTAssertThrowsErrorAsync { try await vault.contents(of: node) }
}
/// Deleting a referenced row must NEVER delete the person's own file.
func testDeletingAReferenceLeavesTheOriginalAlone() async throws {
let vault = try await open()
let original = try writeLoose("still mine", named: "keep.txt")
let node = try await vault.reference(
original, named: "keep.txt",
label: confidentialLegal)
try await vault.remove(node.id)
XCTAssertTrue(
FileManager.default.fileExists(atPath: original.path),
"deleting the row deleted the person's own file")
let left = await vault.count
XCTAssertEqual(left, 0)
}
func testBulkDeletingReferencesLeavesTheOriginalsAlone() async throws {
let vault = try await open()
let one = try writeLoose("a", named: "a.txt")
let two = try writeLoose("b", named: "b.txt")
let first = try await vault.reference(one, named: "a.txt", label: confidentialLegal)
let second = try await vault.reference(two, named: "b.txt", label: confidentialLegal)
_ = try await vault.remove(ids: [first.id, second.id])
XCTAssertTrue(FileManager.default.fileExists(atPath: one.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: two.path))
}
/// An index written before referencing existed must still decode, and must
/// decode as vaulted rather than as a reference with no bookmark.
func testAnOlderIndexDecodesAsVaulted() async throws {
let vault = try await open()
_ = try await vault.add(Data("old".utf8), named: "old.txt", label: confidentialLegal)
// Strip the field the way an older writer would have left it.
let url = root.appendingPathComponent("index.json")
var text = try String(decoding: Data(contentsOf: url), as: UTF8.self)
text = text.replacingOccurrences(of: "\"storage\":\"vault\",", with: "")
try Data(text.utf8).write(to: url)
let reopened = try await open()
let rows = await reopened.list(nil)
XCTAssertEqual(rows.first?.storage, .vault)
XCTAssertEqual(rows.first?.name, "old.txt")
}
// MARK: - Promoting a reference
func testEncryptingAReferenceMakesItAVaultedCopy() async throws {
let vault = try await open()
let original = try writeLoose("the salary figures", named: "payroll.csv")
let node = try await vault.reference(
original, named: "payroll.csv",
label: confidentialLegal)
let origin = try await vault.encrypt(node.id)
XCTAssertEqual(origin?.lastPathComponent, original.lastPathComponent)
let rows = await vault.list(nil)
XCTAssertEqual(rows.first?.storage, .vault)
XCTAssertEqual(rows.first?.name, "payroll.csv")
// It reads back through the vault, and the blob on disk is ciphertext.
guard let promoted = await vault.node(node.id) else { return XCTFail("row vanished") }
let read = try await vault.contents(of: promoted)
XCTAssertEqual(read, Data("the salary figures".utf8))
let blob = try Data(
contentsOf: root.appendingPathComponent("blobs")
.appendingPathComponent(node.id.uuidString))
XCTAssertFalse(String(decoding: blob, as: UTF8.self).contains("salary"))
}
/// The bookmark must go, or the "encrypted" copy is not the only way in.
func testAPromotedRowStopsReadingThroughToThePlaintext() async throws {
let vault = try await open()
let original = try writeLoose("secret", named: "s.txt")
let node = try await vault.reference(original, named: "s.txt", label: confidentialLegal)
_ = try await vault.encrypt(node.id)
guard let promoted = await vault.node(node.id) else { return XCTFail("row vanished") }
XCTAssertNil(promoted.bookmark, "a vaulted row kept a path to the plaintext")
// Deleting the original now changes nothing, the vault holds it.
try FileManager.default.removeItem(at: original)
let read = try await vault.contents(of: promoted)
XCTAssertEqual(read, Data("secret".utf8))
}
/// Promoting does not delete the person's file.
func testEncryptingLeavesTheOriginalWhereItIs() async throws {
let vault = try await open()
let original = try writeLoose("mine", named: "m.txt")
let node = try await vault.reference(original, named: "m.txt", label: confidentialLegal)
_ = try await vault.encrypt(node.id)
XCTAssertTrue(FileManager.default.fileExists(atPath: original.path))
}
func testEncryptingSomethingAlreadyVaultedDoesNothing() async throws {
let vault = try await open()
let node = try await vault.add(
Data("already".utf8), named: "a.txt",
label: confidentialLegal)
let origin = try await vault.encrypt(node.id)
XCTAssertNil(origin)
let read = try await vault.contents(of: node)
XCTAssertEqual(read, Data("already".utf8))
}
func testEncryptingAReferenceWhoseFileIsGoneSaysSo() async throws {
let vault = try await open()
let original = try writeLoose("here", named: "h.txt")
let node = try await vault.reference(original, named: "h.txt", label: confidentialLegal)
try FileManager.default.removeItem(at: original)
await XCTAssertThrowsErrorAsync { try await vault.encrypt(node.id) }
}
// MARK: - Bulk delete
func testBulkDeleteRemovesEverythingSelected() async throws {
let vault = try await open()
let a = try await vault.add(Data("a".utf8), named: "a.txt", label: confidentialLegal)
let b = try await vault.add(Data("b".utf8), named: "b.txt", label: confidentialLegal)
let c = try await vault.add(Data("c".utf8), named: "c.txt", label: confidentialLegal)
let result = try await vault.remove(ids: [a.id, b.id])
XCTAssertEqual(result.removed.count, 2)
XCTAssertTrue(result.isCompletelyDone)
let left = await vault.list(nil)
XCTAssertEqual(left.map(\.id), [c.id])
}
/// Selecting a folder AND its contents works, the folder goes last.
func testAFolderGoesWhenItsContentsAreSelectedToo() async throws {
let vault = try await open()
let folder = try await vault.createFolder("Cases", label: confidentialLegal)
let inside = try await vault.add(
Data("x".utf8), named: "x.txt",
label: confidentialLegal, parentID: folder.id)
let deeper = try await vault.createFolder(
"2026", label: confidentialLegal,
parentID: folder.id)
let result = try await vault.remove(ids: [folder.id, inside.id, deeper.id])
XCTAssertEqual(result.removed.count, 3)
XCTAssertTrue(result.isCompletelyDone)
let remaining = await vault.count
XCTAssertEqual(remaining, 0)
}
/// A folder whose contents were NOT selected is kept, and SAID.
func testAFolderWithUnselectedContentsIsKeptAndReported() async throws {
let vault = try await open()
let folder = try await vault.createFolder("Cases", label: confidentialLegal)
let keep = try await vault.add(
Data("keep".utf8), named: "keep.txt",
label: confidentialLegal, parentID: folder.id)
let result = try await vault.remove(ids: [folder.id])
XCTAssertTrue(result.removed.isEmpty)
XCTAssertEqual(result.keptNonEmpty, [folder.id])
XCTAssertFalse(result.isCompletelyDone)
let stillThere = await vault.count
XCTAssertEqual(stillThere, 2)
let readBack = try await vault.contents(of: keep)
XCTAssertEqual(readBack, Data("keep".utf8))
}
func testBulkDeleteTakesTheBytesWithIt() async throws {
let vault = try await open()
let a = try await vault.add(Data("a".utf8), named: "a.txt", label: confidentialLegal)
let b = try await vault.add(Data("b".utf8), named: "b.txt", label: confidentialLegal)
let blobs = root.appendingPathComponent("blobs")
_ = try await vault.remove(ids: [a.id, b.id])
for id in [a.id, b.id] {
XCTAssertFalse(
FileManager.default.fileExists(
atPath: blobs.appendingPathComponent(id.uuidString).path),
"bytes outlived the row")
}
}
func testDescendantsFindsTheWholeSubtree() async throws {
let vault = try await open()
let top = try await vault.createFolder("Top", label: confidentialLegal)
let mid = try await vault.createFolder("Mid", label: confidentialLegal, parentID: top.id)
let leaf = try await vault.add(
Data("l".utf8), named: "l.txt",
label: confidentialLegal, parentID: mid.id)
_ = try await vault.add(Data("o".utf8), named: "outside.txt", label: confidentialLegal)
let found = Set(await vault.descendants(of: top.id))
XCTAssertEqual(found, Set([mid.id, leaf.id]))
}
/// An id that is not there is skipped, not an error: two screens racing on
/// the same selection should not turn into a failure dialog.
func testDeletingSomethingAlreadyGoneIsNotAnError() async throws {
let vault = try await open()
let a = try await vault.add(Data("a".utf8), named: "a.txt", label: confidentialLegal)
_ = try await vault.remove(ids: [a.id])
let again = try await vault.remove(ids: [a.id, UUID()])
XCTAssertTrue(again.removed.isEmpty)
XCTAssertTrue(again.isCompletelyDone)
}
func testTheIndexSurvivesABulkDeleteAndReopens() async throws {
let first = try await open()
let a = try await first.add(Data("a".utf8), named: "a.txt", label: confidentialLegal)
let b = try await first.add(Data("b".utf8), named: "b.txt", label: confidentialLegal)
_ = try await first.remove(ids: [a.id])
let second = try await open()
let names = await second.list(nil).map(\.name)
XCTAssertEqual(names, ["b.txt"])
let readBack = try await second.contents(of: b)
XCTAssertEqual(readBack, Data("b".utf8))
}
func testFoldersSortFirstAndUnreadableRowsSortLast() async throws {
let vault = try await open()
_ = try await vault.add(Data("a".utf8), named: "zebra.txt", label: confidentialLegal)
_ = try await vault.add(Data("b".utf8), named: "apple.txt", label: confidentialLegal)
_ = try await vault.createFolder("Misc", label: confidentialLegal)
let rows = await vault.list(nil)
XCTAssertEqual(rows.map(\.name), ["Misc", "apple.txt", "zebra.txt"])
}
// MARK: - Walking the tree (ios#66) and the send picker (ios#68)
/// A cycle cannot be built through this API, and the index is still a file
/// on disk that something else could write.
func testACycleInTheIndexReturnsEachNodeOnce() async throws {
let vault = try await open()
let a = try await vault.createFolder("A", label: confidentialLegal)
let b = try await vault.createFolder("B", label: confidentialLegal, parentID: a.id)
// The cycle written straight into the index, as a corrupt file would.
let url = root.appendingPathComponent("index.json")
var json = try XCTUnwrap(
JSONSerialization.jsonObject(
with: try Data(contentsOf: url)) as? [String: Any])
var nodes = try XCTUnwrap(json["nodes"] as? [[String: Any]])
for i in nodes.indices where (nodes[i]["id"] as? String) == a.id.uuidString {
nodes[i]["parentID"] = b.id.uuidString
}
json["nodes"] = nodes
try JSONSerialization.data(withJSONObject: json).write(to: url)
let reopened = try await open()
// Not merely "it terminated": each node exactly once.
let fromA = await reopened.descendants(of: a.id)
let fromB = await reopened.descendants(of: b.id)
XCTAssertEqual(fromA, [b.id])
XCTAssertEqual(fromB, [a.id])
}
func testDescendantsWalksTheWholeSubtree() async throws {
let vault = try await open()
let folder = try await vault.createFolder("Reports", label: confidentialLegal)
let inner = try await vault.createFolder(
"2026", label: confidentialLegal,
parentID: folder.id)
let file = try await vault.add(
Data("x".utf8), named: "q3.txt",
label: confidentialLegal, parentID: inner.id)
_ = try await vault.add(Data("y".utf8), named: "other.txt", label: confidentialLegal)
let found = Set(await vault.descendants(of: folder.id))
XCTAssertEqual(found, Set([inner.id, file.id]))
}
/// The send picker listed the ROOT only, so a document filed into a folder
/// could not be sent from a conversation at all, absent from the list, with
/// nothing saying why.
func testEverythingSendableIsOfferedHoweverDeepItIsFiled() async throws {
let vault = try await open()
let folder = try await vault.createFolder("Reports", label: confidentialLegal)
let inner = try await vault.createFolder(
"2026", label: confidentialLegal,
parentID: folder.id)
_ = try await vault.add(Data("a".utf8), named: "top.txt", label: confidentialLegal)
_ = try await vault.add(
Data("b".utf8), named: "buried.txt",
label: confidentialLegal, parentID: inner.id)
let offered = await vault.allFiles().compactMap(\.name)
XCTAssertEqual(offered, ["buried.txt", "top.txt"])
}
/// Folders are places, not files.
func testFoldersAreNotOffered() async throws {
let vault = try await open()
_ = try await vault.createFolder("Reports", label: confidentialLegal)
let offered = await vault.allFiles()
XCTAssertTrue(offered.isEmpty)
}
/// The one place this class hides a row it cannot read: a picker exists
/// only for choosing something to send, and a row that can never be chosen
/// is furniture.
func testRowsThatCouldNeverBeSentAreLeftOutOfThePicker() async throws {
let vault = try await open()
_ = try await vault.add(
Data("mine".utf8), named: "readable.txt",
label: confidentialLegal)
let stranger = LocalVault(root: root, identity: Identity.generate())
try await stranger.load()
let offered = await stranger.allFiles()
XCTAssertTrue(
offered.isEmpty,
"a row this device cannot decrypt was offered as sendable")
}
}
/// XCTest has no async throwing assertion of its own.
func XCTAssertThrowsErrorAsync<T>(
_ message: String = "expected an error",
file: StaticString = #filePath, line: UInt = #line,
_ expression: () async throws -> T
) async {
do {
_ = try await expression()
XCTFail(message, file: file, line: line)
} catch {}
}
@@ -0,0 +1,103 @@
import XCTest
@testable import FilesEngine
/// The official BIP-39 vectors, against the vendored list.
final class BIP39Tests: XCTestCase {
private func words() throws -> [String] { try BIP39.englishWordlist() }
private func hex(_ s: String) -> Data {
let c = Array(s)
var out = Data()
for i in stride(from: 0, to: c.count, by: 2) {
out.append(UInt8(String(c[i...i + 1]), radix: 16)!)
}
return out
}
private func toHex(_ d: Data) -> String {
d.map { String(format: "%02x", $0) }.joined()
}
func testTheBundledWordlistMatchesItsDigest() throws {
let list = try words()
XCTAssertEqual(list.count, 2048)
XCTAssertEqual(list.first, "abandon")
XCTAssertEqual(list.last, "zoo")
XCTAssertNoThrow(try BIP39.verify(list))
}
func testEntropyProducesThePublishedPhrase() throws {
let list = try words()
XCTAssertEqual(
try BIP39.phrase(from: hex("00000000000000000000000000000000"), words: list),
"abandon abandon abandon abandon abandon abandon abandon abandon "
+ "abandon abandon abandon about")
XCTAssertEqual(
try BIP39.phrase(from: hex("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f"), words: list),
"legal winner thank year wave sausage worth useful legal winner "
+ "thank yellow")
XCTAssertEqual(
try BIP39.phrase(from: hex("ffffffffffffffffffffffffffffffff"), words: list),
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong")
}
/// The assertion that settles interoperability.
func testTheSeedMatchesThePublishedVector() throws {
let phrase =
"abandon abandon abandon abandon abandon abandon abandon "
+ "abandon abandon abandon abandon about"
XCTAssertEqual(
toHex(try BIP39.seed(from: phrase, passphrase: "TREZOR")),
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553"
+ "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04")
}
func testPhrasesDecodeBackToTheirEntropy() throws {
let list = try words()
for size in [16, 20, 24, 28, 32] {
var entropy = Data(count: size)
entropy.withUnsafeMutableBytes {
_ = SecRandomCopyBytes(kSecRandomDefault, size, $0.baseAddress!)
}
let phrase = try BIP39.phrase(from: entropy, words: list)
XCTAssertEqual(try BIP39.entropy(from: phrase, words: list), entropy, "\(size) bytes")
}
}
func testAMistypedWordFailsTheChecksum() throws {
let list = try words()
// "about" swapped for another real word: right length, all real words.
let phrase =
"abandon abandon abandon abandon abandon abandon abandon "
+ "abandon abandon abandon abandon abandon"
XCTAssertThrowsError(try BIP39.entropy(from: phrase, words: list)) {
XCTAssertEqual($0 as? BIP39.Failure, .checksumFailed)
}
}
func testAWordNotOnTheListIsNamed() throws {
let list = try words()
let phrase =
"abandon abandon abandon abandon abandon abandon abandon "
+ "abandon abandon abandon abandon notaword"
XCTAssertThrowsError(try BIP39.entropy(from: phrase, words: list)) {
XCTAssertEqual($0 as? BIP39.Failure, .unknownWord("notaword"))
}
}
func testAWordlistThatDoesNotMatchIsRefused() {
let fake = (0..<2048).map { "w\($0)" }
XCTAssertThrowsError(try BIP39.verify(fake))
}
/// The standard's sharp edge, pinned so nobody "fixes" it: a different
/// BIP-39 passphrase is a different valid wallet, silently.
func testABIP39PassphraseChangesTheWallet() throws {
let phrase =
"abandon abandon abandon abandon abandon abandon abandon "
+ "abandon abandon abandon abandon about"
XCTAssertNotEqual(
try BIP39.seed(from: phrase),
try BIP39.seed(from: phrase, passphrase: "second factor"))
}
}
@@ -0,0 +1,96 @@
import XCTest
@testable import FilesEngine
final class Base58Tests: XCTestCase {
private func hex(_ s: String) -> Data {
let c = Array(s)
var out = Data()
for i in stride(from: 0, to: c.count, by: 2) {
out.append(UInt8(String(c[i...i + 1]), radix: 16)!)
}
return out
}
/// The vectors everyone uses, from the Bitcoin reference tests.
func testKnownValuesEncode() {
let cases: [(String, String)] = [
("", ""),
("61", "2g"),
("626262", "a3gV"),
("636363", "aPEr"),
("73696d706c792061206c6f6e6720737472696e67", "2cFupjhnEsSn59qHXstmK2ffpLv2"),
(
"00eb15231dfceb60925886b67d065299925915aeb172c06647",
"1NS17iag9jJgTHD1VXjvLCEnZuQ3rJDE9L"
),
("516b6fcd0f", "ABnLTmg"),
("bf4f89001e670274dd", "3SEo3LWLoPntC"),
("572e4794", "3EFU7m"),
("ecac89cad93923c02321", "EJDM8drfXA6uyA"),
("10c8511e", "Rt5zm"),
]
for (input, expected) in cases {
XCTAssertEqual(Base58.encode(hex(input)), expected, "encoding \(input)")
XCTAssertEqual(Base58.decode(expected), hex(input), "decoding \(expected)")
}
}
/// The rule that goes wrong: one '1' per leading zero BYTE, and it must
/// survive the round trip.
func testLeadingZeroBytesBecomeLeadingOnes() {
XCTAssertEqual(Base58.encode(Data([0x00])), "1")
XCTAssertEqual(Base58.encode(Data([0x00, 0x00])), "11")
XCTAssertEqual(Base58.encode(Data([0x00, 0x00, 0x00])), "111")
XCTAssertEqual(Base58.encode(Data([0x00, 0x01])), "12")
for count in 0...5 {
let data = Data(repeating: 0, count: count) + Data([0x2a])
XCTAssertEqual(
Base58.decode(Base58.encode(data)), data,
"\(count) leading zeros did not survive")
}
}
/// Two keys differing only by a leading zero must not collide.
func testAKeyWithALeadingZeroIsDistinct() {
let withZero = Data([0x00]) + Data(repeating: 0xab, count: 31)
let without = Data(repeating: 0xab, count: 31)
XCTAssertNotEqual(Base58.encode(withZero), Base58.encode(without))
}
func testAnAddressRoundTripsForEveryLength() {
for size in [1, 20, 31, 32, 33, 64] {
var data = Data(count: size)
data.withUnsafeMutableBytes {
_ = SecRandomCopyBytes(kSecRandomDefault, size, $0.baseAddress!)
}
XCTAssertEqual(Base58.decode(Base58.encode(data)), data, "\(size) bytes")
}
}
/// The characters left out on purpose.
func testTheAmbiguousCharactersAreNotInTheAlphabet() {
for character in "0OIl" {
XCTAssertFalse(
Base58.alphabet.contains(character),
"\(character) is in the alphabet")
XCTAssertNil(
Base58.decode("abc\(character)def"),
"\(character) decoded instead of failing")
}
}
func testAnAddressIsTheUsualLength() throws {
// A 32-byte ed25519 public key base58-encodes to 43 or 44 characters,
// which is what an address from any other wallet looks like.
let key = try HDKey.master(
seed: Data(repeating: 3, count: 64),
path: "m/44H/501H/0H/0H/0H"
).publicKey()
let address = Base58.encode(key)
XCTAssertTrue((43...44).contains(address.count), "got \(address.count): \(address)")
XCTAssertEqual(Base58.decode(address), key)
}
}
@@ -0,0 +1,138 @@
import XCTest
@testable import FilesEngine
/// Reading a chain, driven against canned node answers.
final class ChainRPCTests: XCTestCase {
private func rpc(_ body: String, status: Int = 200) -> ChainRPC {
ChainRPC(endpoint: URL(string: "https://node.example")!) { request in
let response = HTTPURLResponse(
url: request.url!, statusCode: status,
httpVersion: nil, headerFields: nil)!
return (Data(body.utf8), response)
}
}
func testABalanceIsRead() async throws {
let value = try await rpc(#"{"jsonrpc":"2.0","result":{"value":1234567890},"id":1}"#)
.balance(of: "abc")
XCTAssertEqual(value, 1_234_567_890)
}
func testAZeroBalanceIsABalanceAndNotAnError() async throws {
let value = try await rpc(#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
.balance(of: "abc")
XCTAssertEqual(value, 0)
}
/// A JSON-RPC error arrives with HTTP 200.
func testANodeErrorIsNotReadAsSuccess() async {
do {
_ = try await rpc(
#"{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: WrongSize"},"id":1}"#
)
.balance(of: "nonsense")
XCTFail("a node error was treated as a balance")
} catch let failure as ChainRPC.Failure {
// The node's own words reach the person.
XCTAssertEqual(failure, .node("Invalid param: WrongSize"))
} catch {
XCTFail("wrong error: \(error)")
}
}
func testAnHTTPFailureIsReported() async {
do {
_ = try await rpc("gateway down", status: 502).balance(of: "abc")
XCTFail("a 502 was treated as an answer")
} catch let failure as ChainRPC.Failure {
XCTAssertEqual(failure, .http(502))
} catch { XCTFail("wrong error: \(error)") }
}
func testGarbageIsRefusedRatherThanGuessedAt() async {
do {
_ = try await rpc("<html>not json</html>").balance(of: "abc")
XCTFail("garbage parsed as a balance")
} catch let failure as ChainRPC.Failure {
XCTAssertEqual(failure, .malformed("not an object"))
} catch { XCTFail("wrong error: \(error)") }
}
// MARK: - History
func testHistoryIsReadNewestFirst() async throws {
let body = """
{"jsonrpc":"2.0","result":[
{"signature":"sigA","slot":300,"blockTime":1750000000,"err":null},
{"signature":"sigB","slot":200,"blockTime":1740000000,"err":null}
],"id":1}
"""
let rows = try await rpc(body).history(for: "abc")
XCTAssertEqual(rows.map(\.signature), ["sigA", "sigB"])
XCTAssertEqual(rows[0].slot, 300)
XCTAssertEqual(rows[0].blockTime?.timeIntervalSince1970, 1_750_000_000)
}
/// A FAILED transaction still appears in an address's history.
func testAFailedTransactionIsMarkedFailed() async throws {
let body = """
{"jsonrpc":"2.0","result":[
{"signature":"bad","slot":1,"blockTime":1750000000,"err":{"InstructionError":[0,{"Custom":1}]}},
{"signature":"good","slot":2,"blockTime":1750000001,"err":null}
],"id":1}
"""
let rows = try await rpc(body).history(for: "abc")
XCTAssertTrue(rows[0].failed, "a failed transaction was shown as successful")
XCTAssertFalse(rows[1].failed)
}
/// A very new transaction has no time yet.
func testATransactionWithNoTimeYetHasNoDate() async throws {
let body =
#"{"jsonrpc":"2.0","result":[{"signature":"new","slot":9,"blockTime":null,"err":null}],"id":1}"#
let rows = try await rpc(body).history(for: "abc")
XCTAssertNil(rows[0].blockTime)
}
func testAnEmptyHistoryIsEmptyAndNotAnError() async throws {
let rows = try await rpc(#"{"jsonrpc":"2.0","result":[],"id":1}"#).history(for: "abc")
XCTAssertTrue(rows.isEmpty)
}
func testARowWithNoSignatureIsSkippedRatherThanCrashing() async throws {
let body = """
{"jsonrpc":"2.0","result":[{"slot":1},{"signature":"ok","slot":2,"err":null}],"id":1}
"""
let rows = try await rpc(body).history(for: "abc")
XCTAssertEqual(rows.map(\.signature), ["ok"])
}
/// The request is shaped the way a node expects.
func testTheRequestIsWellFormedJSONRPC() async throws {
actor Captured {
var body: Data?
func set(_ d: Data?) { body = d }
}
let captured = Captured()
let client = ChainRPC(endpoint: URL(string: "https://node.example")!) { request in
await captured.set(request.httpBody)
let response = HTTPURLResponse(
url: request.url!, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (Data(#"{"result":{"value":1}}"#.utf8), response)
}
_ = try await client.balance(of: "someaddress")
// Hoisted: XCTUnwrap takes an autoclosure, which is nonisolated, so an
// actor read cannot happen inside it.
let sent = await captured.body
let body = try XCTUnwrap(sent)
let json = try XCTUnwrap(
try JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(json["jsonrpc"] as? String, "2.0")
XCTAssertEqual(json["method"] as? String, "getBalance")
XCTAssertEqual((json["params"] as? [Any])?.first as? String, "someaddress")
}
}
@@ -0,0 +1,136 @@
import Foundation
import XCTest
@testable import FilesEngine
/// Submitting a transaction, and finding out what became of it.
final class ChainSendTests: XCTestCase {
private func node(
_ answer: @escaping @Sendable (String) -> String,
status: Int = 200
) -> ChainRPC {
ChainRPC(endpoint: URL(string: "https://node.example")!) { request in
let sent = request.httpBody.flatMap {
try? JSONSerialization.jsonObject(with: $0) as? [String: Any]
}
let method = (sent?["method"] as? String) ?? ""
let response = HTTPURLResponse(
url: URL(string: "https://node.example")!,
statusCode: status,
httpVersion: nil, headerFields: nil)!
return (Data(answer(method).utf8), response)
}
}
private let hash32 = String(repeating: "1", count: 32) // 32 zero bytes
// MARK: - Blockhash
func testABlockhashIsThirtyTwoBytesAndCarriesItsExpiry() async throws {
let rpc = node { _ in
#"{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"\#(String(repeating: "1", count: 32))","lastValidBlockHeight":250}}}"#
}
let hash = try await rpc.latestBlockhash()
XCTAssertEqual(hash.value.count, 32)
XCTAssertEqual(hash.lastValidBlockHeight, 250)
}
/// A blockhash of the wrong length would be signed over and rejected after
/// the fact.
func testAMalformedBlockhashIsRefused() async {
let rpc = node { _ in #"{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"abc"}}}"# }
do {
_ = try await rpc.latestBlockhash()
XCTFail("a short blockhash was accepted")
} catch {}
}
// MARK: - Sending
func testASendReturnsItsSignature() async throws {
let rpc = node { _ in #"{"jsonrpc":"2.0","id":1,"result":"5j7sSignature"}"# }
let signature = try await rpc.send(transaction: Data([1, 2, 3]))
XCTAssertEqual(signature, "5j7sSignature")
}
/// A JSON-RPC refusal arrives with HTTP 200, and the node's own words are
/// the only explanation there will be.
func testARefusalCarriesTheNodesOwnWords() async {
let rpc = node { _ in
#"{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"Transaction simulation failed: Insufficient funds"}}"#
}
do {
_ = try await rpc.send(transaction: Data([1]))
XCTFail("a refusal was read as a success")
} catch let failure as ChainRPC.Failure {
XCTAssertEqual(failure, .node("Transaction simulation failed: Insufficient funds"))
} catch { XCTFail("wrong error: \(error)") }
}
// MARK: - Status
/// Nil is "the network has not heard of it", which within the blockhash's
/// lifetime means *not yet*, never a licence to send again.
func testAnUnknownSignatureIsNotAFailure() async throws {
let rpc = node { _ in #"{"jsonrpc":"2.0","id":1,"result":{"value":[null]}}"# }
let status = try await rpc.status(of: "sig")
XCTAssertNil(status)
}
func testAConfirmedTransactionReportsItsLevel() async throws {
let rpc = node { _ in
#"{"jsonrpc":"2.0","id":1,"result":{"value":[{"err":null,"confirmations":12,"confirmationStatus":"confirmed"}]}}"#
}
let status = try await rpc.status(of: "sig")
XCTAssertEqual(status?.failed, false)
XCTAssertEqual(status?.confirmations, 12)
XCTAssertEqual(status?.level, "confirmed")
}
/// On the chain and failed is not the same as sent.
func testAFailedTransactionIsNotASuccess() async throws {
let rpc = node { _ in
#"{"jsonrpc":"2.0","id":1,"result":{"value":[{"err":{"InstructionError":[0,"Custom"]},"confirmations":null,"confirmationStatus":"finalized"}]}}"#
}
let status = try await rpc.status(of: "sig")
XCTAssertEqual(status?.failed, true, "a failed transaction read as confirmed")
XCTAssertEqual(status?.isFinal, true)
}
/// Null confirmations means rooted, finalised, not unknown.
func testNullConfirmationsMeansFinalised() async throws {
let rpc = node { _ in
#"{"jsonrpc":"2.0","id":1,"result":{"value":[{"err":null,"confirmations":null,"confirmationStatus":"finalized"}]}}"#
}
let status = try await rpc.status(of: "sig")
XCTAssertEqual(status?.failed, false)
XCTAssertEqual(status?.isFinal, true)
}
// MARK: - The request itself
/// The node's default encoding is base58.
func testASendDeclaresBase64AndKeepsPreflight() async throws {
final class Box: @unchecked Sendable { var body: [String: Any]? }
let box = Box()
let rpc = ChainRPC(endpoint: URL(string: "https://node.example")!) { request in
box.body = request.httpBody.flatMap {
try? JSONSerialization.jsonObject(with: $0) as? [String: Any]
}
let response = HTTPURLResponse(
url: URL(string: "https://node.example")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(#"{"jsonrpc":"2.0","id":1,"result":"sig"}"#.utf8), response)
}
_ = try await rpc.send(transaction: Data([9, 9, 9]))
let params = box.body?["params"] as? [Any]
XCTAssertEqual(params?.first as? String, Data([9, 9, 9]).base64EncodedString())
let options = params?.last as? [String: Any]
XCTAssertEqual(options?["encoding"] as? String, "base64")
// Preflight simulates against current state and refuses the obvious
// failures before a fee is taken.
XCTAssertEqual(options?["skipPreflight"] as? Bool, false)
}
}
@@ -0,0 +1,153 @@
import XCTest
@testable import FilesEngine
/// SLIP-0010 ed25519, against the published vectors.
final class HDKeyTests: XCTestCase {
private struct Vectors: Decodable {
struct Step: Decodable {
let path: String
let chainCode: String
let privateKey: String
let publicKey: String
}
struct Case: Decodable {
let seed: String
let steps: [Step]
}
let vectors: [Case]
}
private var loaded: Vectors!
override func setUpWithError() throws {
let url = try XCTUnwrap(
Bundle.module.url(
forResource: "vectors/slip10-ed25519",
withExtension: "json"))
loaded = try JSONDecoder().decode(Vectors.self, from: try Data(contentsOf: url))
}
private func hex(_ s: String) -> Data {
let c = Array(s)
var out = Data()
for i in stride(from: 0, to: c.count, by: 2) {
out.append(UInt8(String(c[i...i + 1]), radix: 16)!)
}
return out
}
private func toHex(_ d: Data) -> String { d.map { String(format: "%02x", $0) }.joined() }
func testEveryPublishedVectorStepMatches() throws {
for (n, testCase) in loaded.vectors.enumerated() {
let seed = hex(testCase.seed)
for step in testCase.steps {
let key = try HDKey.master(seed: seed, path: step.path)
XCTAssertEqual(
toHex(key.privateKey), step.privateKey,
"vector \(n + 1) \(step.path): private key")
XCTAssertEqual(
toHex(key.chainCode), step.chainCode,
"vector \(n + 1) \(step.path): chain code")
XCTAssertEqual(
toHex(try key.slip10PublicKey()), step.publicKey,
"vector \(n + 1) \(step.path): public key")
}
}
}
/// `'` and `H` are both written in the wild for hardened.
func testApostropheAndHMeanTheSameThing() throws {
let seed = hex(loaded.vectors[0].seed)
XCTAssertEqual(
try HDKey.master(seed: seed, path: "m/0H/1H"),
try HDKey.master(seed: seed, path: "m/0'/1'"))
}
/// An unhardened index is REFUSED, not silently hardened.
func testAnUnhardenedPathIsRefused() throws {
let seed = hex(loaded.vectors[0].seed)
XCTAssertThrowsError(try HDKey.master(seed: seed, path: "m/44/501H/0H"))
XCTAssertThrowsError(try HDKey.master(seed: seed, path: "m/0"))
}
func testAnUnparseablePathIsRefused() throws {
let seed = hex(loaded.vectors[0].seed)
XCTAssertThrowsError(try HDKey.master(seed: seed, path: "m/notanumberH"))
XCTAssertThrowsError(try HDKey.master(seed: seed, path: "m/99999999999H"))
}
func testDerivationIsDeterministic() throws {
let seed = hex(loaded.vectors[0].seed)
XCTAssertEqual(
try HDKey.master(seed: seed, path: "m/44H/501H/0H/0H/7H"),
try HDKey.master(seed: seed, path: "m/44H/501H/0H/0H/7H"))
}
/// A different seed must move every key in the tree.
func testADifferentSeedGivesADifferentTree() throws {
let a = try HDKey.master(seed: hex(loaded.vectors[0].seed), path: "m/44H/501H/0H/0H/0H")
let b = try HDKey.master(seed: hex(loaded.vectors[1].seed), path: "m/44H/501H/0H/0H/0H")
XCTAssertNotEqual(a.privateKey, b.privateKey)
XCTAssertNotEqual(a.chainCode, b.chainCode)
}
func testASeedOfTheWrongSizeIsRefused() {
XCTAssertThrowsError(try HDKey.master(seed: Data(repeating: 0, count: 8)))
XCTAssertThrowsError(try HDKey.master(seed: Data(repeating: 0, count: 65)))
XCTAssertNoThrow(try HDKey.master(seed: Data(repeating: 0, count: 64)))
}
// MARK: - Accounts
/// The privacy property: every receive gets its own key.
func testEachReceiveKeyIsNew() throws {
var account = HDAccount(seed: Data(repeating: 7, count: 64), coinType: 501)
var seen = Set<Data>()
for expected in UInt32(0)..<UInt32(8) {
let (index, key) = try account.next()
XCTAssertEqual(index, expected)
XCTAssertTrue(
seen.insert(key.privateKey).inserted,
"receive key \(index) repeated an earlier one")
}
XCTAssertEqual(account.nextIndex, 8)
}
/// And they are recoverable: the phrase restores every one of them, so
/// handing out a fresh address costs no new backup.
func testAReceiveKeyIsRederivableFromTheSeedAlone() throws {
var account = HDAccount(seed: Data(repeating: 7, count: 64), coinType: 501)
_ = try account.next()
_ = try account.next()
let (index, key) = try account.next()
let fresh = HDAccount(seed: Data(repeating: 7, count: 64), coinType: 501)
XCTAssertEqual(try fresh.key(index: index).privateKey, key.privateKey)
}
func testThePathIsTheOrdinaryBIP44Shape() {
let account = HDAccount(seed: Data(repeating: 7, count: 64), coinType: 501, account: 2)
XCTAssertEqual(account.path(index: 5), "m/44H/501H/2H/0H/5H")
}
/// Nothing identifying is an input.
func testTwoAccountsAtTheSamePathDifferOnlyBySeed() throws {
let one = HDAccount(seed: Data(repeating: 1, count: 64), coinType: 501)
let two = HDAccount(seed: Data(repeating: 2, count: 64), coinType: 501)
XCTAssertNotEqual(try one.key(index: 0).privateKey, try two.key(index: 0).privateKey)
let same = HDAccount(seed: Data(repeating: 1, count: 64), coinType: 501)
XCTAssertEqual(try one.key(index: 0).privateKey, try same.key(index: 0).privateKey)
}
func testDifferentCoinsAndAccountsDoNotCollide() throws {
let seed = Data(repeating: 9, count: 64)
let solana = HDAccount(seed: seed, coinType: 501)
let other = HDAccount(seed: seed, coinType: 397)
let second = HDAccount(seed: seed, coinType: 501, account: 1)
XCTAssertNotEqual(try solana.key(index: 0).privateKey, try other.key(index: 0).privateKey)
XCTAssertNotEqual(try solana.key(index: 0).privateKey, try second.key(index: 0).privateKey)
}
}
@@ -0,0 +1,298 @@
import CryptoKit
import Foundation
import XCTest
@testable import FilesEngine
/// Interop against the published reference implementation.
final class InteropTests: XCTestCase {
private struct Vectors: Decodable {
let version: Int
let cases: Cases
struct Cases: Decodable {
let labelBytes: [LabelCase]
let kdf: KDFCase
let hierarchy: Hierarchy
let nameDigest: [DigestCase]
let recovery: Recovery
}
struct LabelCase: Decodable {
let classification: Int16
let compartments: [Int]
let expected: String
}
struct KDFCase: Decodable {
let passphrase: String
let salt: String
let iterations: Int
let authSecret: String
}
struct DigestCase: Decodable {
let folderKey: String
let name: String
let expected: String
}
struct Recovery: Decodable {
let code: String
let entropy: String
}
struct Hierarchy: Decodable {
let label: LabelSpec
let identityPublicKey: String
let identityRecord: Identity.Record
let labelKey: String
let nodeKey: String
let contentKey: String
let labelKeyGrant: String
let wrappedNodeKey: String
let wrappedContentKey: String
let nameDigest: String
let nameCiphertext: String
let expectedName: String
let content: String
let expectedContent: String
let contentChunkSize: Int
struct LabelSpec: Decodable {
let classification: Int16
let compartments: [Int]
}
}
}
private var vectors: Vectors!
override func setUpWithError() throws {
let url = try XCTUnwrap(Bundle.module.url(forResource: "vectors/v1", withExtension: "json"))
vectors = try JSONDecoder().decode(Vectors.self, from: try Data(contentsOf: url))
}
private func b64(_ s: String) throws -> Data { try XCTUnwrap(Data(base64Encoded: s)) }
private var hierarchyLabel: Label {
Label(
classification: vectors.cases.hierarchy.label.classification,
compartments: Set(vectors.cases.hierarchy.label.compartments))
}
// MARK: §1
func testLabelEncodingMatchesTheReference() throws {
for entry in vectors.cases.labelBytes {
let label = Label(
classification: entry.classification, compartments: Set(entry.compartments))
XCTAssertEqual(
label.bytes, try b64(entry.expected),
"classification \(entry.classification) compartments \(entry.compartments)")
}
}
// MARK: §2
func testAuthSecretDerivationMatches() throws {
let c = vectors.cases.kdf
let derived = try KDF.authSecret(
passphrase: c.passphrase, salt: try b64(c.salt),
parameters: .init(id: 1, iterations: c.iterations)
)
XCTAssertEqual(derived, try b64(c.authSecret))
}
// MARK: §6
func testNameDigestsMatch() throws {
for entry in vectors.cases.nameDigest {
XCTAssertEqual(
Names.digest(folderKey: try b64(entry.folderKey), name: entry.name),
try b64(entry.expected), entry.name)
}
}
// MARK: §3-§7, the whole chain
func testUnlocksAnIdentityWrittenByTheBrowser() throws {
let h = vectors.cases.hierarchy
let identity = try Identity.unlock(
h.identityRecord, passphrase: vectors.cases.kdf.passphrase)
// The public half must match what the browser published for it.
XCTAssertEqual(identity.publicKey.rawRepresentation, try b64(h.identityPublicKey))
}
func testAcceptsALabelKeyGrantFromTheBrowser() throws {
let h = vectors.cases.hierarchy
let identity = try Identity.unlock(
h.identityRecord, passphrase: vectors.cases.kdf.passphrase)
let labelKey = try Keys.acceptLabelKey(
try b64(h.labelKeyGrant), label: hierarchyLabel,
privateKey: identity)
XCTAssertEqual(labelKey, try b64(h.labelKey))
}
func testUnwrapsTheKeyHierarchy() throws {
let h = vectors.cases.hierarchy
let nodeKey = try Keys.unwrapNodeKey(
try b64(h.wrappedNodeKey),
labelKey: try b64(h.labelKey), label: hierarchyLabel)
XCTAssertEqual(nodeKey, try b64(h.nodeKey))
let contentKey = try Keys.unwrapContentKey(
try b64(h.wrappedContentKey),
nodeKey: nodeKey, label: hierarchyLabel)
XCTAssertEqual(contentKey, try b64(h.contentKey))
}
func testDecryptsAFilenameFromTheBrowser() throws {
let h = vectors.cases.hierarchy
let name = try Names.decrypt(
folderKey: try b64(h.nodeKey), blob: try b64(h.nameCiphertext),
digest: try b64(h.nameDigest), label: hierarchyLabel)
XCTAssertEqual(name, h.expectedName)
}
func testDecryptsContentFromTheBrowser() throws {
let h = vectors.cases.hierarchy
let plaintext = try Content.decrypt(
try b64(h.content), contentKey: try b64(h.contentKey),
label: hierarchyLabel)
XCTAssertEqual(plaintext, try b64(h.expectedContent))
}
/// The label is authenticated everywhere, so a relabelled row must fail.
func testContentDoesNotOpenUnderADifferentLabel() throws {
let h = vectors.cases.hierarchy
let wrong = Label(classification: hierarchyLabel.classification, compartments: [])
XCTAssertThrowsError(
try Content.decrypt(
try b64(h.content),
contentKey: try b64(h.contentKey), label: wrong))
}
}
/// Recovery codes and identity protection.
final class RecoveryTests: XCTestCase {
private struct RecoveryVectors: Decodable {
struct Case: Decodable {
let entropy: String
let code: String
let kdfSecret: String
}
let recovery: [Case]
let recoveryWithQ: Case
}
private var vectors: RecoveryVectors!
override func setUpWithError() throws {
let url = try XCTUnwrap(Bundle.module.url(forResource: "vectors/v1", withExtension: "json"))
vectors = try JSONDecoder().decode(RecoveryVectors.self, from: try Data(contentsOf: url))
}
/// The one that matters: codes written by the browser must decode here, and
/// this engine must produce the identical string from the same entropy.
func testAgreesWithTheBrowser() throws {
for expected in vectors.recovery + [vectors.recoveryWithQ] {
let entropy = try XCTUnwrap(Data(base64Encoded: expected.entropy))
XCTAssertEqual(try Recovery.encode(entropy), expected.code)
XCTAssertEqual(try Recovery.decode(expected.code), entropy)
XCTAssertEqual(Recovery.kdfSecret(entropy), expected.kdfSecret)
}
}
/// A code containing Q, decoded from paper: lower-cased, spaces instead of
/// dashes.
func testTheQCaseSurvivesTranscription() throws {
let expected = vectors.recoveryWithQ
XCTAssertTrue(expected.code.contains("Q"), "vector should contain a Q")
let mangled = expected.code.lowercased().replacingOccurrences(of: "-", with: " ")
XCTAssertEqual(
try Recovery.decode(mangled),
try XCTUnwrap(Data(base64Encoded: expected.entropy)))
}
func testCodeRoundTrips() throws {
for _ in 0..<200 {
let (entropy, code) = Recovery.generate()
XCTAssertEqual(code.replacingOccurrences(of: "-", with: "").count, 28)
XCTAssertEqual(try Recovery.decode(code), entropy)
}
}
/// The failure that shipped once: `Q` is a valid Crockford character, and
/// folding it to `0` corrupted roughly a third of all codes.
func testQIsNotFolded() throws {
XCTAssertEqual(Recovery.normalize("Q"), "Q")
XCTAssertEqual(Recovery.normalize("O"), "0")
XCTAssertEqual(Recovery.normalize("I"), "1")
XCTAssertEqual(Recovery.normalize("L"), "1")
// A code containing Q must still decode after a round trip through
// normalisation, which is what the fold used to break.
var found = false
for _ in 0..<500 where !found {
let (entropy, code) = Recovery.generate()
if code.contains("Q") {
found = true
XCTAssertEqual(try Recovery.decode(code.lowercased()), entropy)
}
}
XCTAssertTrue(found, "expected at least one code containing Q in 500 draws")
}
func testFormattingDoesNotChangeTheKey() throws {
let (entropy, code) = Recovery.generate()
let mangled = code.lowercased().replacingOccurrences(of: "-", with: " ")
XCTAssertEqual(try Recovery.decode(mangled), entropy)
}
func testChecksumCatchesATypo() throws {
let (_, code) = Recovery.generate()
var characters = Array(Recovery.normalize(code))
// Change one character to a different valid one.
characters[3] = characters[3] == "7" ? "8" : "7"
XCTAssertThrowsError(try Recovery.decode(String(characters)))
}
func testIdentityRecordRoundTrips() throws {
// A low iteration count: this asserts the format, not the work factor,
// and 650,000 rounds per case would make the suite unbearable.
let fast = KDF.Parameters(id: 1, iterations: 1_000)
let key = Identity.generate()
let record = try Identity.protect(key, passphrase: "correct horse", parameters: fast)
let opened = try Identity.unlock(record, passphrase: "correct horse")
XCTAssertEqual(opened.rawRepresentation, key.rawRepresentation)
}
func testTheWrongPassphraseDoesNotOpenIt() throws {
let fast = KDF.Parameters(id: 1, iterations: 1_000)
let record = try Identity.protect(Identity.generate(), passphrase: "one", parameters: fast)
XCTAssertThrowsError(try Identity.unlock(record, passphrase: "two"))
}
/// The passphrase and the recovery code wrap the same key independently, so
/// changing one must not disturb the other.
func testRecoveryOpensTheSameKey() throws {
let fast = KDF.Parameters(id: 1, iterations: 1_000)
let key = Identity.generate()
let (entropy, _) = Recovery.generate()
let byRecovery = try Identity.protectWithRecovery(key, entropy: entropy, parameters: fast)
let opened = try Identity.unlockWithRecovery(byRecovery, entropy: entropy)
XCTAssertEqual(opened.rawRepresentation, key.rawRepresentation)
}
/// Different contexts, so a record made for one secret cannot be opened as
/// the other's even when the derived material would otherwise line up.
func testRecoveryRecordIsNotAPassphraseRecord() throws {
let fast = KDF.Parameters(id: 1, iterations: 1_000)
let key = Identity.generate()
let (entropy, _) = Recovery.generate()
let byRecovery = try Identity.protectWithRecovery(key, entropy: entropy, parameters: fast)
XCTAssertEqual(byRecovery.context, KDF.Context.filesRecovery)
// Same secret material, wrong context: must not open.
XCTAssertThrowsError(
try Identity.unlock(
Identity.Record(
v: 1, kdf: byRecovery.kdf, iterations: byRecovery.iterations,
context: KDF.Context.filesIdentity,
salt: byRecovery.salt, nonce: byRecovery.nonce,
privateKey: byRecovery.privateKey),
passphrase: Recovery.kdfSecret(entropy)))
}
}
@@ -0,0 +1,232 @@
import CryptoKit
import XCTest
@testable import FilesEngine
/// The bytes a signature covers.
final class SolanaTransactionTests: XCTestCase {
// MARK: - Compact-u16
/// Published boundary values.
func testCompactU16MatchesTheDocumentedEncoding() {
XCTAssertEqual(Array(SolanaTransaction.compactU16(0)), [0x00])
XCTAssertEqual(Array(SolanaTransaction.compactU16(1)), [0x01])
XCTAssertEqual(Array(SolanaTransaction.compactU16(127)), [0x7f])
XCTAssertEqual(Array(SolanaTransaction.compactU16(128)), [0x80, 0x01])
XCTAssertEqual(Array(SolanaTransaction.compactU16(255)), [0xff, 0x01])
XCTAssertEqual(Array(SolanaTransaction.compactU16(256)), [0x80, 0x02])
XCTAssertEqual(Array(SolanaTransaction.compactU16(32767)), [0xff, 0xff, 0x01])
}
// MARK: - The transfer payload
/// Four bytes of discriminant, then eight of amount, both little-endian.
func testTheTransferPayloadIsTwelveBytes() {
let data = SolanaTransaction.transferData(lamports: 1)
XCTAssertEqual(data.count, 12)
XCTAssertEqual(Array(data.prefix(4)), [2, 0, 0, 0], "wrong discriminant")
XCTAssertEqual(Array(data.suffix(8)), [1, 0, 0, 0, 0, 0, 0, 0])
}
func testTheAmountIsLittleEndianAndExact() {
// A round number in decimal is not a round number in bytes.
let data = SolanaTransaction.transferData(lamports: 1_000_000_000)
XCTAssertEqual(Array(data.suffix(8)), [0x00, 0xca, 0x9a, 0x3b, 0, 0, 0, 0])
}
func testTheLargestAmountDoesNotOverflow() {
let data = SolanaTransaction.transferData(lamports: .max)
XCTAssertEqual(Array(data.suffix(8)), Array(repeating: 0xff, count: 8))
}
// MARK: - Addresses
/// The System Program is thirty-two zero bytes, which base58 prints as
/// thirty-two `1`s.
func testTheSystemProgramIsThirtyTwoZeroBytes() {
XCTAssertEqual(SolanaTransaction.systemProgramID.count, 32)
XCTAssertTrue(SolanaTransaction.systemProgramID.allSatisfy { $0 == 0 })
XCTAssertEqual(
Base58.encode(SolanaTransaction.systemProgramID),
String(repeating: "1", count: 32))
}
func testAnAddressRoundTrips() throws {
let key = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation
let address = Base58.encode(key)
XCTAssertEqual(try SolanaTransaction.publicKey(fromAddress: address), key)
}
func testSurroundingWhitespaceIsForgiven() throws {
let key = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation
let address = " " + Base58.encode(key) + "\n"
XCTAssertEqual(try SolanaTransaction.publicKey(fromAddress: address), key)
}
func testAnAddressOfTheWrongLengthIsRefused() {
XCTAssertThrowsError(try SolanaTransaction.publicKey(fromAddress: "abc"))
}
/// `0`, `O`, `I` and `l` are outside the alphabet precisely so a hand-
/// copied address cannot become a different valid one.
func testACharacterOutsideTheAlphabetIsRefused() {
let key = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation
let broken = Base58.encode(key).dropLast() + "0"
XCTAssertThrowsError(try SolanaTransaction.publicKey(fromAddress: String(broken)))
}
// MARK: - Refusals
func testZeroIsNotAnAmount() {
XCTAssertThrowsError(
try SolanaTransaction.transferMessage(
from: Data(repeating: 1, count: 32), to: Data(repeating: 2, count: 32),
lamports: 0, recentBlockhash: Data(repeating: 3, count: 32)))
}
func testSendingToYourselfIsRefused() {
let me = Data(repeating: 1, count: 32)
XCTAssertThrowsError(
try SolanaTransaction.transferMessage(
from: me, to: me, lamports: 1, recentBlockhash: Data(repeating: 3, count: 32)))
}
func testAShortBlockhashIsRefused() {
XCTAssertThrowsError(
try SolanaTransaction.transferMessage(
from: Data(repeating: 1, count: 32), to: Data(repeating: 2, count: 32),
lamports: 1, recentBlockhash: Data(repeating: 3, count: 31)))
}
// MARK: - Reading the message back
/// An independent walk of the documented layout.
private struct Decoded {
var requiredSignatures: UInt8
var readonlySigned: UInt8
var readonlyUnsigned: UInt8
var accounts: [Data]
var blockhash: Data
var programIndex: UInt8
var accountIndices: [UInt8]
var data: Data
}
private func decode(_ message: Data) throws -> Decoded {
var i = message.startIndex
func byte() -> UInt8 {
defer { i += 1 }
return message[i]
}
func take(_ n: Int) -> Data {
defer { i += n }
return message[i..<(i + n)]
}
func length() -> Int {
var value = 0
var shift = 0
while true {
let b = byte()
value |= Int(b & 0x7f) << shift
if b & 0x80 == 0 { return value }
shift += 7
}
}
let required = byte()
let roSigned = byte()
let roUnsigned = byte()
let accountCount = length()
var accounts: [Data] = []
for _ in 0..<accountCount { accounts.append(Data(take(32))) }
let blockhash = Data(take(32))
let instructionCount = length()
XCTAssertEqual(instructionCount, 1, "a transfer is one instruction")
let program = byte()
let indexCount = length()
var indices: [UInt8] = []
for _ in 0..<indexCount { indices.append(byte()) }
let dataLength = length()
let data = Data(take(dataLength))
XCTAssertEqual(i, message.endIndex, "trailing bytes after the instruction")
return Decoded(
requiredSignatures: required, readonlySigned: roSigned,
readonlyUnsigned: roUnsigned, accounts: accounts,
blockhash: blockhash, programIndex: program,
accountIndices: indices, data: data)
}
/// The header counts account categories BY POSITION.
func testAMessageDecodesToExactlyWhatWasAskedFor() throws {
let from = Data(repeating: 0xa1, count: 32)
let to = Data(repeating: 0xb2, count: 32)
let blockhash = Data(repeating: 0xc3, count: 32)
let message = try SolanaTransaction.transferMessage(
from: from, to: to, lamports: 1_500_000_000, recentBlockhash: blockhash)
let read = try decode(message)
XCTAssertEqual(read.requiredSignatures, 1)
XCTAssertEqual(read.readonlySigned, 0)
XCTAssertEqual(read.readonlyUnsigned, 1, "the program must be read-only")
XCTAssertEqual(
read.accounts, [from, to, SolanaTransaction.systemProgramID],
"account order is what the header describes")
XCTAssertEqual(read.blockhash, blockhash)
XCTAssertEqual(read.programIndex, 2, "the program is the third account")
XCTAssertEqual(read.accountIndices, [0, 1], "from, then to")
XCTAssertEqual(read.data, SolanaTransaction.transferData(lamports: 1_500_000_000))
}
/// The amount the bytes carry is the amount that was asked for.
func testTheAmountInTheBytesIsTheAmountRequested() throws {
for lamports: UInt64 in [1, 999, 1_000_000_000, 123_456_789_012] {
let message = try SolanaTransaction.transferMessage(
from: Data(repeating: 1, count: 32), to: Data(repeating: 2, count: 32),
lamports: lamports, recentBlockhash: Data(repeating: 3, count: 32))
let read = try decode(message)
let carried = read.data.suffix(8).reversed().reduce(UInt64(0)) {
($0 << 8) | UInt64($1)
}
XCTAssertEqual(carried, lamports)
}
}
// MARK: - Signing
/// The signature must cover the message bytes exactly.
func testTheSignatureCoversTheMessageAndVerifies() throws {
let key = Curve25519.Signing.PrivateKey()
let from = key.publicKey.rawRepresentation
let message = try SolanaTransaction.transferMessage(
from: from, to: Data(repeating: 2, count: 32),
lamports: 42, recentBlockhash: Data(repeating: 3, count: 32))
let transaction = try SolanaTransaction.signed(message: message, key: key)
XCTAssertEqual(transaction[transaction.startIndex], 1, "one signature")
let signature = transaction[(transaction.startIndex + 1)..<(transaction.startIndex + 65)]
let carried = transaction[(transaction.startIndex + 65)...]
XCTAssertEqual(Data(carried), message, "the signed message was altered")
XCTAssertTrue(key.publicKey.isValidSignature(Data(signature), for: message))
}
/// And it must NOT verify against a different message, the check above
/// would pass on a signature scheme that ignored its input.
func testTheSignatureDoesNotVerifyAgainstAnotherMessage() throws {
let key = Curve25519.Signing.PrivateKey()
let from = key.publicKey.rawRepresentation
let one = try SolanaTransaction.transferMessage(
from: from, to: Data(repeating: 2, count: 32),
lamports: 42, recentBlockhash: Data(repeating: 3, count: 32))
let two = try SolanaTransaction.transferMessage(
from: from, to: Data(repeating: 2, count: 32),
lamports: 43, recentBlockhash: Data(repeating: 3, count: 32))
let transaction = try SolanaTransaction.signed(message: one, key: key)
let signature = transaction[(transaction.startIndex + 1)..<(transaction.startIndex + 65)]
XCTAssertFalse(
key.publicKey.isValidSignature(Data(signature), for: two),
"a signature verified against an amount it did not cover")
}
}
@@ -0,0 +1,80 @@
import XCTest
@testable import FilesEngine
final class WalletTests: XCTestCase {
private let kek = Data(repeating: 0x2b, count: 32)
private func words() throws -> [String] { try BIP39.englishWordlist() }
private func hex(_ s: String) -> Data {
let c = Array(s)
var out = Data()
for i in stride(from: 0, to: c.count, by: 2) {
out.append(UInt8(String(c[i...i + 1]), radix: 16)!)
}
return out
}
/// A record wrapped by the JavaScript, opened here.
func testAWalletRecordFromTheBrowserOpensHere() throws {
let record = hex(
"110bf45333f9d869b6cedd3167dbab1a9eaf77383dc50c8fb9ad18ce58c24a3afdf14accd0da11185c9ef20f33"
)
XCTAssertEqual(
try Wallet.unwrap(record: record, walletKEK: kek),
hex("0c1e24e5917779d297e14d45f14e1a1a"))
XCTAssertEqual(
try Wallet.phrase(record: record, walletKEK: kek, words: try words()),
"army van defense carry jealous true garbage claim echo media make crunch")
}
func testANewWalletsPhraseAndRecordDescribeTheSameSeed() throws {
let list = try words()
let made = try Wallet.create(walletKEK: kek, words: list)
XCTAssertEqual(made.phrase.split(separator: " ").count, 24)
XCTAssertEqual(
try Wallet.phrase(record: made.record, walletKEK: kek, words: list), made.phrase)
XCTAssertEqual(
try Wallet.seed(record: made.record, walletKEK: kek, words: list),
try BIP39.seed(from: made.phrase))
}
func testTwelveWordsAreAvailableForAnyoneWhoWantsThem() throws {
let made = try Wallet.create(walletKEK: kek, words: try words(), entropyBytes: 16)
XCTAssertEqual(made.phrase.split(separator: " ").count, 12)
}
/// A phrase from any other wallet restores the same addresses here.
func testAnImportedPhraseComesBackUnchanged() throws {
let list = try words()
let phrase = "army van defense carry jealous true garbage claim echo media make crunch"
let record = try Wallet.import(phrase: phrase, walletKEK: kek, words: list)
XCTAssertEqual(try Wallet.phrase(record: record, walletKEK: kek, words: list), phrase)
}
func testAPhraseThatFailsItsChecksumIsRefusedBeforeItIsStored() throws {
let list = try words()
XCTAssertThrowsError(
try Wallet.import(
phrase: "army van defense carry jealous true garbage claim echo media make abandon",
walletKEK: kek, words: list)
) { XCTAssertEqual($0 as? BIP39.Failure, .checksumFailed) }
}
/// The wrap is authenticated, so a different account cannot open it and the
/// record cannot be moved between accounts by editing a column.
func testAnotherAccountsKeyDoesNotOpenTheRecord() throws {
let made = try Wallet.create(walletKEK: kek, words: try words())
XCTAssertThrowsError(
try Wallet.unwrap(record: made.record, walletKEK: Data(repeating: 0x2c, count: 32)))
}
/// The context string is part of the stored bytes.
func testTheRecordIsBoundToTheWalletContext() throws {
XCTAssertEqual(String(decoding: Wallet.aad, as: UTF8.self), "flamenet/wallet/seed/v1")
let made = try Wallet.create(walletKEK: kek, words: try words())
XCTAssertThrowsError(
try Wrap.openWithKey(
kek, blob: made.record, aad: Data("flamenet/files/identity/v1".utf8)))
}
}
@@ -0,0 +1,28 @@
{
"_source": "https://github.com/satoshilabs/slips/blob/master/slip-0010.md",
"_note": "The published SLIP-0010 ed25519 vectors, verbatim. Public keys carry the standard's 0x00 prefix.",
"vectors": [
{
"seed": "000102030405060708090a0b0c0d0e0f",
"steps": [
{ "path": "m", "chainCode": "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb", "privateKey": "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7", "publicKey": "00a4b2856bfec510abab89753fac1ac0e1112364e7d250545963f135f2a33188ed" },
{ "path": "m/0H", "chainCode": "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69", "privateKey": "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", "publicKey": "008c8a13df77a28f3445213a0f432fde644acaa215fc72dcdf300d5efaa85d350c" },
{ "path": "m/0H/1H", "chainCode": "a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14", "privateKey": "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2", "publicKey": "001932a5270f335bed617d5b935c80aedb1a35bd9fc1e31acafd5372c30f5c1187" },
{ "path": "m/0H/1H/2H", "chainCode": "2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c", "privateKey": "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9", "publicKey": "00ae98736566d30ed0e9d2f4486a64bc95740d89c7db33f52121f8ea8f76ff0fc1" },
{ "path": "m/0H/1H/2H/2H", "chainCode": "8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc", "privateKey": "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662", "publicKey": "008abae2d66361c879b900d204ad2cc4984fa2aa344dd7ddc46007329ac76c429c" },
{ "path": "m/0H/1H/2H/2H/1000000000H", "chainCode": "68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230", "privateKey": "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", "publicKey": "003c24da049451555d51a7014a37337aa4e12d41e485abccfa46b47dfb2af54b7a" }
]
},
{
"seed": "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"steps": [
{ "path": "m", "chainCode": "ef70a74db9c3a5af931b5fe73ed8e1a53464133654fd55e7a66f8570b8e33c3b", "privateKey": "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012", "publicKey": "008fe9693f8fa62a4305a140b9764c5ee01e455963744fe18204b4fb948249308a" },
{ "path": "m/0H", "chainCode": "0b78a3226f915c082bf118f83618a618ab6dec793752624cbeb622acb562862d", "privateKey": "1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635", "publicKey": "0086fab68dcb57aa196c77c5f264f215a112c22a912c10d123b0d03c3c28ef1037" },
{ "path": "m/0H/2147483647H", "chainCode": "138f0b2551bcafeca6ff2aa88ba8ed0ed8de070841f0c4ef0165df8181eaad7f", "privateKey": "ea4f5bfe8694d8bb74b7b59404632fd5968b774ed545e810de9c32a4fb4192f4", "publicKey": "005ba3b9ac6e90e83effcd25ac4e58a1365a9e35a3d3ae5eb07b9e4d90bcf7506d" },
{ "path": "m/0H/2147483647H/1H", "chainCode": "73bd9fff1cfbde33a1b846c27085f711c0fe2d66fd32e139d3ebc28e5a4a6b90", "privateKey": "3757c7577170179c7868353ada796c839135b3d30554bbb74a4b1e4a5a58505c", "publicKey": "002e66aa57069c86cc18249aecf5cb5a9cebbfd6fadeab056254763874a9352b45" },
{ "path": "m/0H/2147483647H/1H/2147483646H", "chainCode": "0902fe8a29f9140480a00ef244bd183e8a13288e4412d8389d140aac1794825a", "privateKey": "5837736c89570de861ebc173b1086da4f505d4adb387c6a1b1342d5e4ac9ec72", "publicKey": "00e33c0f7d81d843c572275f287498e8d408654fdf0d1e065b84e2e6f157aab09b" },
{ "path": "m/0H/2147483647H/1H/2147483646H/2H", "chainCode": "5d70af781f3a37b829f0d060924d5e960bdc02e85423494afc0b1a41bbe196d4", "privateKey": "551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d", "publicKey": "0047150c75db263559a70d5778bf36abbab30fb061ad69f69ece61a72b0cfa4fc0" }
]
}
]
}
@@ -0,0 +1,136 @@
{
"version": 1,
"note": "Decrypt these. See docs/SPEC.md.",
"cases": {
"labelBytes": [
{
"classification": 3,
"compartments": [
0
],
"expected": "AAMAAAAAAAAAAQ=="
},
{
"classification": 1,
"compartments": [],
"expected": "AAEAAAAAAAAAAA=="
},
{
"classification": 0,
"compartments": [
63
],
"expected": "AACAAAAAAAAAAA=="
},
{
"classification": 2,
"compartments": [
0,
1,
63
],
"expected": "AAKAAAAAAAAAAw=="
}
],
"kdf": {
"passphrase": "correct horse battery staple",
"salt": "BwcHBwcHBwcHBwcHBwcHBw==",
"iterations": 1000,
"contexts": {
"filesIdentity": "flamenet/files/identity/v1",
"filesAuth": "flamenet/files/auth/v1",
"messengerIdentity": "flamenet/messenger/identity/v1"
},
"authSecret": "67XBl7YK3eWLxlwCsQfvGZKiokbcY8mEqCw1E8QDxaE="
},
"hierarchy": {
"label": {
"classification": 3,
"compartments": [
0,
1
]
},
"identityPublicKey": "rx6FBwu0ekejPG/KuVDGUjDGgpk1KeuneLAjxSQ4Ul0=",
"identityRecord": {
"v": 1,
"kdf": 1,
"iterations": 1000,
"context": "flamenet/files/identity/v1",
"salt": "3nW9N41kTjy+XV4MTrZpBw==",
"nonce": "/RfcIYNdqzoJjFJ0",
"privateKey": "Cq0gk03SqDC0QrYMdhyz/KwdwRxAaG95Cow0sE2+nilEjAnbgeDZPDSkKoZKSVla5Jv2Tf0AsDwYmGS01EWVGQ=="
},
"labelKey": "mdghL5vAIw5DeSsY9/BwRARhADaDBQnCxZDqeRHxl20=",
"nodeKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"contentKey": "WP2IXXEHgqN9B4LosOtkeQgbfNgaqmLPSm9t02XQkc0=",
"labelKeyGrant": "Acacc9qfWCLxyXcc6rRTEyUDJxwdc0wxqy4UW8NSHwlhv/rvokK6kYMRfcTyUfXoE8H68Sk6nvIuC3sBAYMIcGkX/El9tUms+c90ffr+EbRZqyR16dREwtPowST+",
"wrappedNodeKey": "EUITJmLQVJgoL8z+lcfoKXIxHsGDWGVXDsxqdQnoPghuMvbD3eyhCCVT3lkDOQJ2sOKrfm8aAgEpzUSGMw==",
"wrappedContentKey": "EUQ1b+IbuxUblsm1gKGp1GWk8Tw3IsNNCGvTVwfQBA8OHLWmiHgpDburxl3GweXMzo9rFbhPazZkYKBsmw==",
"nameDigest": "zEu1hnRTyD+91Vke88XDSSVa/jfVERPIbMLB2kul4hY=",
"nameCiphertext": "AZlKWc9giJcswo4Fva4Ez7BhdljC5MfIsUpeSYz4LxwlAl9bobFz6Te/jCQ=",
"expectedName": "Q3 Forecast.txt",
"content": "RU5DTAEAAAAQfPLZFLMzaHtthhTgPXIbF6Q5iDW4RM+x97t9Ggd4lEJ3V1xMmyKvg8ISLjEUixPNrNGpzhx58+qfkj0pXqqzZijC",
"expectedContent": "cmV2ZW51ZTogNDEKaGVhZGNvdW50OiAxMgo=",
"contentChunkSize": 16
},
"nameDigest": [
{
"folderKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"name": "Q3 Forecast.txt",
"expected": "zEu1hnRTyD+91Vke88XDSSVa/jfVERPIbMLB2kul4hY="
},
{
"folderKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"name": "café",
"expected": "yiuuJ6pdJeJX7gQ3YJFGWbtu10Qz4VvUwE2cdWhMLB4="
},
{
"folderKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"name": "Payroll",
"expected": "NN1zYjaALbTzp1MAf8dUNbfd/DVQVQ8jN3cN0DiEZt8="
}
],
"recovery": {
"code": "BKKR-SJXB-E8M8-1A6B-CN47-WNM7-6RQN",
"entropy": "XOeMy6tyKICoy2VIflaHNg=="
}
},
"recovery": [
{
"entropy": "AEeO1RxjqvE4f8YNVJviKQ==",
"code": "013R-XN8W-CENF-2E3Z-RR6N-96Z2-57K9",
"kdfSecret": "00478ed51c63aaf1387fc60d549be229"
},
{
"entropy": "JWyz+kGIzxZdpOsyecAHTg==",
"code": "4NPB-7YJ1-H37H-CQD4-XCS7-KG07-9RBA",
"kdfSecret": "256cb3fa4188cf165da4eb3279c0074e"
},
{
"entropy": "SpHYH2at9DuCyRBXnuUscw==",
"code": "9A8X-G7V6-NQT3-Q0P9-21BS-XS9C-EE7C",
"kdfSecret": "4a91d81f66adf43b82c910579ee52c73"
},
{
"entropy": "b7b9RIvSGWCn7jV8wwpRmA==",
"code": "DYVF-TH4B-T8CP-19ZE-6NYC-62JH-K3JD",
"kdfSecret": "6fb6fd448bd21960a7ee357cc30a5198"
},
{
"entropy": "lNsiabD3PoXME1qh6C92vQ==",
"code": "JKDJ-4TDG-YWZ8-BK0K-BAGY-GBVP-QN2Y",
"kdfSecret": "94db2269b0f73e85cc135aa1e82f76bd"
},
{
"entropy": "uQBHjtUcY6rxOH/GDVSb4g==",
"code": "Q404-F3PN-3HHT-NW9R-FZ30-TN4V-W87R",
"kdfSecret": "b900478ed51c63aaf1387fc60d549be2"
}
],
"recoveryWithQ": {
"entropy": "AB06V3SRrsvoBSI/XHmWsw==",
"code": "00EK-MNVM-J6QC-QT05-48ZN-RYCP-PDSA",
"kdfSecret": "001d3a577491aecbe805223f5c7996b3"
}
}
+22
View File
@@ -0,0 +1,22 @@
// swift-tools-version:6.0
import PackageDescription
/// Rules for the games played inside chats: chess, checkers, reversi and tic-tac-toe.
///
/// Both apps rebuild every game from its move log, so this package and the Android `:games`
/// module have to agree move for move. `games-v1.json` holds the vectors both suites run.
let package = Package(
name: "FlamenetGames",
platforms: [.iOS("26.0"), .macOS(.v13)],
products: [
.library(name: "FlamenetGames", targets: ["FlamenetGames"])
],
targets: [
.target(name: "FlamenetGames"),
.testTarget(
name: "FlamenetGamesTests",
dependencies: ["FlamenetGames"],
resources: [.copy("Resources/games-v1.json")]
),
]
)
@@ -0,0 +1,267 @@
/// An English draughts position on squares 1-32 in standard PDN numbering.
///
/// Keeps the plies since the last capture or man move and the positions in that stretch, for the
/// 80-ply draw and threefold repetition.
public struct CheckersPosition: Hashable, Sendable {
/// Index 0 is square 1. First's (dark) men are 1 and kings 2; second's are -1 and -2.
let squares: [Int8]
public let sideToMove: Side
let quietPlies: Int
/// The positions since the last capture or man move, oldest first, ending with this one.
let history: [Key]
struct Key: Hashable, Sendable {
let squares: [Int8]
let side: Side
}
struct Move: Hashable, Sendable {
/// The starting square, then every square the piece lands on (indexes, not numbers).
let path: [Int]
let captures: Bool
}
init(squares: [Int8], side: Side, quietPlies: Int, earlier: [Key]) {
self.squares = squares
self.sideToMove = side
self.quietPlies = quietPlies
self.history = earlier + [Key(squares: squares, side: side)]
}
static let start = CheckersPosition(
setup: "B:W21,22,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,11,12")!
var repetitions: Int {
let current = history[history.count - 1]
return history.reduce(0) { $0 + ($1 == current ? 1 : 0) }
}
/// Every move the rules allow, without the automatic draws: all capturing sequences when any
/// capture exists (not only the longest), otherwise all steps.
func movesByTheRules() -> [Move] {
let us: Int8 = sideToMove == .first ? 1 : -1
var board = squares
var path: [Int] = []
var jumps: [Move] = []
for from in 0..<32 where squares[from] != 0 && (squares[from] > 0) == (us > 0) {
path = [from]
CheckersRules.collectJumps(&board, from, squares[from], &path, &jumps)
}
if !jumps.isEmpty { return jumps }
var steps: [Move] = []
for from in 0..<32 where squares[from] != 0 && (squares[from] > 0) == (us > 0) {
for heading in CheckersRules.headings(for: squares[from]) {
let to = CheckersRules.neighbour[from][heading]
if to >= 0 && squares[to] == 0 {
steps.append(Move(path: [from, to], captures: false))
}
}
}
return steps
}
}
extension CheckersPosition: GameRules {
var outcome: Outcome {
if movesByTheRules().isEmpty { return .win(sideToMove.opponent, .noMoves) }
if quietPlies >= 80 { return .draw(.fortyMoves) }
if repetitions >= 3 { return .draw(.repetition) }
return .ongoing
}
func legalMoveList() -> [Move] {
quietPlies >= 80 || repetitions >= 3 ? [] : movesByTheRules()
}
func notation(_ move: Move) -> String {
move.path.map { String($0 + 1) }.joined(separator: move.captures ? "x" : "-")
}
func applying(_ move: Move) -> CheckersPosition {
var board = squares
let piece = board[move.path[0]]
board[move.path[0]] = 0
if move.captures {
for (from, to) in zip(move.path, move.path.dropFirst()) {
let heading = (0..<4).first { CheckersRules.landing[from][$0] == to }!
board[CheckersRules.neighbour[from][heading]] = 0
}
}
let end = move.path[move.path.count - 1]
board[end] = CheckersRules.crowns(piece, at: end) ? piece * 2 : piece
let reset = move.captures || abs(piece) == 1
return CheckersPosition(
squares: board, side: sideToMove.opponent, quietPlies: reset ? 0 : quietPlies + 1,
earlier: reset ? [] : history)
}
/// `11-15` for a step, `15x24` or `15x24x31` for captures, squares 1-32 without leading zeros.
static func isWellFormed(_ text: String) -> Bool {
let step = text.contains("-")
guard step != text.contains("x") else { return false }
let parts = text.split(separator: step ? "-" : "x", omittingEmptySubsequences: false)
guard step ? parts.count == 2 : parts.count >= 2 else { return false }
return parts.allSatisfy { CheckersRules.number($0) != nil }
}
var board: Board {
var cells: [Piece?] = []
var names: [String?] = []
for row in 0..<8 {
for column in 0..<8 {
// The first player sees the standard diagram turned round, own men at the bottom.
guard let index = CheckersRules.index(row: 7 - row, column: 7 - column) else {
cells.append(nil)
names.append(nil)
continue
}
let piece = squares[index]
cells.append(
piece == 0
? nil
: Piece(
side: piece > 0 ? .first : .second, kind: abs(piece) == 2 ? .king : .man
)
)
names.append(String(index + 1))
}
}
return Board(rows: 8, columns: 8, cells: cells, squares: names)
}
/// PDN FEN with the second (W) list first, as in `B:W21,...,32:B1,...,12`.
var setup: String {
func list(_ sign: Int8) -> String {
(0..<32).compactMap { index -> String? in
let piece = squares[index]
guard piece != 0, (piece > 0) == (sign > 0) else { return nil }
return (abs(piece) == 2 ? "K" : "") + String(index + 1)
}.joined(separator: ",")
}
return "\(sideToMove == .first ? "B" : "W"):W\(list(-1)):B\(list(1))"
}
/// Reads PDN FEN: the side to move (B first, W second), then both piece lists in either
/// order, K marking a king. A man on the row where it would have been crowned is refused.
init?(setup: String) {
let fields = setup.split(separator: ":", omittingEmptySubsequences: false)
guard fields.count == 3 else { return nil }
let side: Side
switch fields[0] {
case "B": side = .first
case "W": side = .second
default: return nil
}
var board = [Int8](repeating: 0, count: 32)
var listed: Set<Character> = []
for field in fields[1...] {
guard let colour = field.first, colour == "B" || colour == "W",
listed.insert(colour).inserted
else { return nil }
let sign: Int8 = colour == "B" ? 1 : -1
let items = field.dropFirst()
if items.isEmpty { continue }
for item in items.split(separator: ",", omittingEmptySubsequences: false) {
let crowned = item.first == "K"
guard let number = CheckersRules.number(crowned ? item.dropFirst() : item),
board[number - 1] == 0
else { return nil }
let piece = crowned ? sign * 2 : sign
guard !CheckersRules.crowns(piece, at: number - 1) else { return nil }
board[number - 1] = piece
}
}
self.init(squares: board, side: side, quietPlies: 0, earlier: [])
}
}
enum CheckersRules {
/// Rows as in the standard diagram: row 0 holds squares 1-4, row 7 holds 29-32.
static func row(_ index: Int) -> Int { index / 4 }
static func column(_ index: Int) -> Int {
row(index) % 2 == 0 ? 2 * (index % 4) + 1 : 2 * (index % 4)
}
static func index(row: Int, column: Int) -> Int? {
guard row >= 0 && row < 8 && column >= 0 && column < 8 && (row + column) % 2 == 1 else {
return nil
}
return row * 4 + column / 2
}
/// (row, column) steps. Headings 0 and 1 lead toward row 7, where first's men are crowned.
static let steps = [(1, -1), (1, 1), (-1, -1), (-1, 1)]
/// The square one step away in each heading, or -1 off the board.
static let neighbour: [[Int]] = (0..<32).map { index in
steps.map { step in
CheckersRules.index(row: row(index) + step.0, column: column(index) + step.1) ?? -1
}
}
/// Where a jump in each heading lands, or -1 off the board.
static let landing: [[Int]] = (0..<32).map { index in
(0..<4).map { heading in
let next = neighbour[index][heading]
return next < 0 ? -1 : neighbour[next][heading]
}
}
static func headings(for piece: Int8) -> [Int] {
switch piece {
case 1: return [0, 1]
case -1: return [2, 3]
default: return [0, 1, 2, 3]
}
}
/// Whether a man that ends on `index` is crowned there.
static func crowns(_ piece: Int8, at index: Int) -> Bool {
(piece == 1 && row(index) == 7) || (piece == -1 && row(index) == 0)
}
/// A square number 1-32 written without leading zeros.
static func number(_ text: Substring) -> Int? {
let bytes = Array(text.utf8)
guard (1...2).contains(bytes.count), bytes[0] != UInt8(ascii: "0"),
bytes.allSatisfy({ (UInt8(ascii: "0")...UInt8(ascii: "9")).contains($0) }),
let value = Int(text), (1...32).contains(value)
else { return nil }
return value
}
/// Adds every complete capturing sequence for the piece on `at`. A sequence goes on while
/// the piece can jump again, and stops where a man is crowned. Captured pieces come off as
/// they are jumped; with one-square kings that is the same as lifting them at the end.
static func collectJumps(
_ board: inout [Int8], _ at: Int, _ piece: Int8, _ path: inout [Int],
_ found: inout [CheckersPosition.Move]
) {
var jumped = false
for heading in headings(for: piece) {
let over = neighbour[at][heading]
let to = landing[at][heading]
guard to >= 0, board[to] == 0, board[over] != 0, (board[over] > 0) != (piece > 0)
else { continue }
jumped = true
let taken = board[over]
board[over] = 0
board[at] = 0
board[to] = piece
path.append(to)
if crowns(piece, at: to) {
found.append(CheckersPosition.Move(path: path, captures: true))
} else {
collectJumps(&board, to, piece, &path, &found)
}
path.removeLast()
board[to] = 0
board[at] = piece
board[over] = taken
}
if !jumped && path.count > 1 {
found.append(CheckersPosition.Move(path: path, captures: true))
}
}
}
@@ -0,0 +1,595 @@
/// A chess position.
///
/// Besides the placement it keeps what the rules need: castling rights, the en passant square
/// (only while an en passant capture is legal, which is also what repetition compares), the
/// clocks, and the positions since the last capture or pawn move, for threefold repetition.
public struct ChessPosition: Hashable, Sendable {
/// Square 0 is a1, 7 is h1, 63 is h8. First's (white) pieces are positive, second's negative.
let squares: [Int8]
public let sideToMove: Side
let castling: UInt8
/// -1 when no en passant capture is legal.
let enPassant: Int
let halfmoveClock: Int
let fullmoveNumber: Int
/// The positions since the last capture or pawn move, oldest first, ending with this one.
let history: [Key]
/// What makes two positions the same for repetition.
struct Key: Hashable, Sendable {
let squares: [Int8]
let side: Side
let castling: UInt8
let enPassant: Int
}
struct Move: Hashable, Sendable {
let from: Int
let to: Int
/// 0, or the code of the piece a pawn becomes.
let promotion: Int8
}
init(
squares: [Int8], side: Side, castling: UInt8, enPassant: Int, clock: Int, fullmove: Int,
earlier: [Key]
) {
self.squares = squares
self.sideToMove = side
self.castling = castling
self.enPassant = enPassant
self.halfmoveClock = clock
self.fullmoveNumber = fullmove
self.history =
earlier + [Key(squares: squares, side: side, castling: castling, enPassant: enPassant)]
}
static let start = ChessPosition(
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")!
/// Whether the side to move is in check.
public var inCheck: Bool {
ChessRules.isAttacked(squares, kingSquare(sign), by: -sign)
}
var sign: Int8 { sideToMove == .first ? 1 : -1 }
func kingSquare(_ sign: Int8) -> Int {
// Every position has exactly one king a side: setup refuses anything else.
squares.firstIndex(of: sign * ChessRules.king)!
}
var repetitions: Int {
let current = history[history.count - 1]
return history.reduce(0) { $0 + ($1 == current ? 1 : 0) }
}
/// K v K, K+B v K, K+N v K, and K+B v K+B with both bishops on one colour. Nothing else.
var insufficientMaterial: Bool {
var others: [(piece: Int8, square: Int)] = []
for (square, piece) in squares.enumerated()
where piece != 0 && abs(piece) != ChessRules.king {
others.append((piece, square))
if others.count > 2 { return false }
}
switch others.count {
case 0:
return true
case 1:
let kind = abs(others[0].piece)
return kind == ChessRules.bishop || kind == ChessRules.knight
default:
let (a, b) = (others[0], others[1])
return abs(a.piece) == ChessRules.bishop && abs(b.piece) == ChessRules.bishop
&& (a.piece > 0) != (b.piece > 0)
&& ChessRules.colour(a.square) == ChessRules.colour(b.square)
}
}
var drawnAutomatically: Bool {
insufficientMaterial || halfmoveClock >= 100 || repetitions >= 3
}
/// Every move the rules of movement allow, without the automatic draws.
func movesByTheRules() -> [Move] {
let us = sign
let king = kingSquare(us)
var board = squares
var legal: [Move] = []
for move in pseudoLegalMoves() {
let moving = board[move.from]
let taken = board[move.to]
board[move.to] = move.promotion != 0 ? us * move.promotion : moving
board[move.from] = 0
var victim = -1
if abs(moving) == ChessRules.pawn && move.to == enPassant && taken == 0
&& move.to % 8 != move.from % 8
{
victim = move.to - 8 * Int(us)
board[victim] = 0
}
let guarded = abs(moving) == ChessRules.king ? move.to : king
if !ChessRules.isAttacked(board, guarded, by: -us) {
legal.append(move)
}
board[move.from] = moving
board[move.to] = taken
if victim >= 0 { board[victim] = -us * ChessRules.pawn }
}
return legal
}
/// Moves by the pieces' rules of movement, before checking the mover's king is safe.
private func pseudoLegalMoves() -> [Move] {
let us = sign
var moves: [Move] = []
moves.reserveCapacity(64)
for from in 0..<64 {
let piece = squares[from]
guard piece != 0, (piece > 0) == (us > 0) else { continue }
switch abs(piece) {
case ChessRules.pawn: addPawnMoves(&moves, from)
case ChessRules.knight: addSteps(&moves, from, ChessRules.knightSteps)
case ChessRules.bishop: addSlides(&moves, from, ChessRules.diagonal)
case ChessRules.rook: addSlides(&moves, from, ChessRules.straight)
case ChessRules.queen:
addSlides(&moves, from, ChessRules.straight)
addSlides(&moves, from, ChessRules.diagonal)
default:
addSteps(&moves, from, ChessRules.kingSteps)
addCastling(&moves, from)
}
}
return moves
}
private func addPawnMoves(_ moves: inout [Move], _ from: Int) {
let us = sign
let file = from % 8
let rank = from / 8
// A pawn never stands on its last rank, so the rank ahead is always on the board.
let ahead = rank + Int(us)
let promotes = ahead == (us > 0 ? 7 : 0)
func add(_ to: Int) {
if promotes {
for piece in ChessRules.promotions {
moves.append(Move(from: from, to: to, promotion: piece))
}
} else {
moves.append(Move(from: from, to: to, promotion: 0))
}
}
let one = ahead * 8 + file
if squares[one] == 0 {
add(one)
let two = one + 8 * Int(us)
if rank == (us > 0 ? 1 : 6) && squares[two] == 0 {
moves.append(Move(from: from, to: two, promotion: 0))
}
}
for step in [-1, 1] {
let target = file + step
guard target >= 0 && target < 8 else { continue }
let to = ahead * 8 + target
let there = squares[to]
if there != 0 && (there > 0) != (us > 0) {
add(to)
} else if there == 0 && to == enPassant {
moves.append(Move(from: from, to: to, promotion: 0))
}
}
}
private func addSteps(_ moves: inout [Move], _ from: Int, _ steps: [(Int, Int)]) {
let file = from % 8
let rank = from / 8
for (df, dr) in steps {
let f = file + df
let r = rank + dr
guard f >= 0 && f < 8 && r >= 0 && r < 8 else { continue }
let there = squares[r * 8 + f]
if there == 0 || (there > 0) != (sign > 0) {
moves.append(Move(from: from, to: r * 8 + f, promotion: 0))
}
}
}
private func addSlides(_ moves: inout [Move], _ from: Int, _ directions: [(Int, Int)]) {
let file = from % 8
let rank = from / 8
for (df, dr) in directions {
var f = file + df
var r = rank + dr
while f >= 0 && f < 8 && r >= 0 && r < 8 {
let there = squares[r * 8 + f]
if there == 0 || (there > 0) != (sign > 0) {
moves.append(Move(from: from, to: r * 8 + f, promotion: 0))
}
if there != 0 { break }
f += df
r += dr
}
}
}
private func addCastling(_ moves: inout [Move], _ home: Int) {
let us = sign
guard home == (us > 0 ? 4 : 60) else { return }
let kingside = us > 0 ? ChessRules.whiteKingside : ChessRules.blackKingside
let queenside = us > 0 ? ChessRules.whiteQueenside : ChessRules.blackQueenside
let rook = us * ChessRules.rook
func attacked(_ square: Int) -> Bool {
ChessRules.isAttacked(squares, square, by: -us)
}
// Not out of, through or into check. On the queen's side b1 (b8) only has to be empty.
if castling & kingside != 0 && squares[home + 1] == 0 && squares[home + 2] == 0
&& squares[home + 3] == rook && !attacked(home) && !attacked(home + 1)
&& !attacked(home + 2)
{
moves.append(Move(from: home, to: home + 2, promotion: 0))
}
if castling & queenside != 0 && squares[home - 1] == 0 && squares[home - 2] == 0
&& squares[home - 3] == 0 && squares[home - 4] == rook && !attacked(home)
&& !attacked(home - 1) && !attacked(home - 2)
{
moves.append(Move(from: home, to: home - 2, promotion: 0))
}
}
}
extension ChessPosition: GameRules {
var outcome: Outcome {
if movesByTheRules().isEmpty {
return inCheck ? .win(sideToMove.opponent, .checkmate) : .draw(.stalemate)
}
if insufficientMaterial { return .draw(.insufficientMaterial) }
if halfmoveClock >= 100 { return .draw(.fiftyMoves) }
if repetitions >= 3 { return .draw(.repetition) }
return .ongoing
}
func legalMoveList() -> [Move] {
drawnAutomatically ? [] : movesByTheRules()
}
func notation(_ move: Move) -> String {
var text = ChessRules.name(move.from) + ChessRules.name(move.to)
if move.promotion != 0 {
text.append(ChessRules.letters[Int(move.promotion) - 1])
}
return text
}
func applying(_ move: Move) -> ChessPosition {
let us = sign
var board = squares
let moving = board[move.from]
let kind = abs(moving)
var captured = board[move.to] != 0
board[move.to] = move.promotion != 0 ? us * move.promotion : moving
board[move.from] = 0
if kind == ChessRules.pawn && move.to == enPassant && !captured
&& move.to % 8 != move.from % 8
{
board[move.to - 8 * Int(us)] = 0
captured = true
}
if kind == ChessRules.king && abs(move.to - move.from) == 2 {
// Castling: the rook lands on the square the king crossed.
let (rookFrom, rookTo) =
move.to > move.from
? (move.from + 3, move.from + 1) : (move.from - 4, move.from - 1)
board[rookTo] = board[rookFrom]
board[rookFrom] = 0
}
var rights = castling
if kind == ChessRules.king {
rights &=
us > 0
? ~(ChessRules.whiteKingside | ChessRules.whiteQueenside)
: ~(ChessRules.blackKingside | ChessRules.blackQueenside)
}
rights &= ~ChessRules.rightTiedTo(move.from) & ~ChessRules.rightTiedTo(move.to)
var target = -1
if kind == ChessRules.pawn && abs(move.to - move.from) == 16 {
let skipped = (move.from + move.to) / 2
if ChessRules.enPassantIsLegal(board, target: skipped, by: -us) {
target = skipped
}
}
let reset = captured || kind == ChessRules.pawn
return ChessPosition(
squares: board, side: sideToMove.opponent, castling: rights, enPassant: target,
clock: reset ? 0 : halfmoveClock + 1,
fullmove: fullmoveNumber + (sideToMove == .second ? 1 : 0),
earlier: reset ? [] : history)
}
static func isWellFormed(_ text: String) -> Bool {
let bytes = Array(text.utf8)
guard bytes.count == 4 || bytes.count == 5 else { return false }
let files = UInt8(ascii: "a")...UInt8(ascii: "h")
let ranks = UInt8(ascii: "1")...UInt8(ascii: "8")
guard files.contains(bytes[0]), ranks.contains(bytes[1]), files.contains(bytes[2]),
ranks.contains(bytes[3])
else { return false }
return bytes.count == 4 || "qrbn".utf8.contains(bytes[4])
}
var board: Board {
var cells: [Piece?] = []
var names: [String?] = []
for row in 0..<8 {
for column in 0..<8 {
let square = (7 - row) * 8 + column
let piece = squares[square]
cells.append(
piece == 0
? nil
: Piece(
side: piece > 0 ? .first : .second,
kind: ChessRules.kinds[Int(abs(piece)) - 1]))
names.append(ChessRules.name(square))
}
}
return Board(rows: 8, columns: 8, cells: cells, squares: names)
}
/// FEN, with the en passant square shown only while the capture is legal.
var setup: String {
var text = ""
for rank in stride(from: 7, through: 0, by: -1) {
var empty = 0
for file in 0..<8 {
let piece = squares[rank * 8 + file]
if piece == 0 {
empty += 1
continue
}
if empty > 0 { text += String(empty) }
empty = 0
let letter = ChessRules.letters[Int(abs(piece)) - 1]
text.append(piece > 0 ? Character(letter.uppercased()) : letter)
}
if empty > 0 { text += String(empty) }
if rank > 0 { text += "/" }
}
var rights = ""
if castling & ChessRules.whiteKingside != 0 { rights += "K" }
if castling & ChessRules.whiteQueenside != 0 { rights += "Q" }
if castling & ChessRules.blackKingside != 0 { rights += "k" }
if castling & ChessRules.blackQueenside != 0 { rights += "q" }
let side = sideToMove == .first ? "w" : "b"
let target = enPassant >= 0 ? ChessRules.name(enPassant) : "-"
return
"\(text) \(side) \(rights.isEmpty ? "-" : rights) \(target) \(halfmoveClock) \(fullmoveNumber)"
}
/// Reads FEN (four or six fields). Refuses positions the rules cannot reach: a side without
/// exactly one king, a pawn on the first or last rank, the side not to move in check, or an
/// en passant square with no pawn that just made the double step.
init?(setup: String) {
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
guard fields.count == 6 || fields.count == 4 else { return nil }
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
guard rows.count == 8 else { return nil }
var board = [Int8](repeating: 0, count: 64)
for (index, row) in rows.enumerated() {
let rank = 7 - index
var file = 0
for character in row {
if let gap = character.wholeNumberValue, character.isASCII, (1...8).contains(gap) {
file += gap
} else {
guard file < 8, let code = ChessRules.code(character) else { return nil }
board[rank * 8 + file] = code
file += 1
}
guard file <= 8 else { return nil }
}
guard file == 8 else { return nil }
}
let side: Side
switch fields[1] {
case "w": side = .first
case "b": side = .second
default: return nil
}
var rights: UInt8 = 0
if fields[2] != "-" {
guard !fields[2].isEmpty else { return nil }
for character in fields[2] {
guard let bit = ChessRules.rightBits[character], rights & bit == 0 else {
return nil
}
rights |= bit
}
}
// A right without its king and rook at home could never be used; drop it.
let rook = ChessRules.rook
let king = ChessRules.king
if board[4] != king || board[7] != rook { rights &= ~ChessRules.whiteKingside }
if board[4] != king || board[0] != rook { rights &= ~ChessRules.whiteQueenside }
if board[60] != -king || board[63] != -rook { rights &= ~ChessRules.blackKingside }
if board[60] != -king || board[56] != -rook { rights &= ~ChessRules.blackQueenside }
guard board.filter({ $0 == king }).count == 1, board.filter({ $0 == -king }).count == 1
else { return nil }
for file in 0..<8
where abs(board[file]) == ChessRules.pawn || abs(board[56 + file]) == ChessRules.pawn {
return nil
}
let us: Int8 = side == .first ? 1 : -1
guard let theirKing = board.firstIndex(of: -us * king),
!ChessRules.isAttacked(board, theirKing, by: us)
else { return nil }
var target = -1
if fields[3] != "-" {
guard let square = ChessRules.square(named: fields[3]),
square / 8 == (us > 0 ? 5 : 2)
else { return nil }
// The pawn that stepped two squares stands in front of the target, its start is empty.
let pushed = square - 8 * Int(us)
let origin = square + 8 * Int(us)
guard board[pushed] == -us * ChessRules.pawn, board[square] == 0, board[origin] == 0
else { return nil }
if ChessRules.enPassantIsLegal(board, target: square, by: us) {
target = square
}
}
var clock = 0
var fullmove = 1
if fields.count == 6 {
guard let c = Int(fields[4]), c >= 0, let f = Int(fields[5]), f >= 1 else {
return nil
}
clock = c
fullmove = f
}
self.init(
squares: board, side: side, castling: rights, enPassant: target, clock: clock,
fullmove: fullmove, earlier: [])
}
}
enum ChessRules {
static let pawn: Int8 = 1
static let knight: Int8 = 2
static let bishop: Int8 = 3
static let rook: Int8 = 4
static let queen: Int8 = 5
static let king: Int8 = 6
static let whiteKingside: UInt8 = 1
static let whiteQueenside: UInt8 = 2
static let blackKingside: UInt8 = 4
static let blackQueenside: UInt8 = 8
static let rightBits: [Character: UInt8] = [
"K": whiteKingside, "Q": whiteQueenside, "k": blackKingside, "q": blackQueenside,
]
/// Indexed by piece code minus one.
static let letters: [Character] = ["p", "n", "b", "r", "q", "k"]
static let kinds: [PieceKind] = [.pawn, .knight, .bishop, .rook, .queen, .king]
/// The order promotions are listed in.
static let promotions: [Int8] = [queen, rook, bishop, knight]
/// (file, rank) steps.
static let knightSteps = [
(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2),
]
static let kingSteps = [(0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1)]
static let straight = [(0, 1), (0, -1), (1, 0), (-1, 0)]
static let diagonal = [(1, 1), (-1, 1), (1, -1), (-1, -1)]
static func name(_ square: Int) -> String {
let file = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(square % 8)))
let rank = Character(UnicodeScalar(UInt8(ascii: "1") + UInt8(square / 8)))
return String([file, rank])
}
static func square(named text: Substring) -> Int? {
let bytes = Array(text.utf8)
guard bytes.count == 2,
(UInt8(ascii: "a")...UInt8(ascii: "h")).contains(bytes[0]),
(UInt8(ascii: "1")...UInt8(ascii: "8")).contains(bytes[1])
else { return nil }
return Int(bytes[1] - UInt8(ascii: "1")) * 8 + Int(bytes[0] - UInt8(ascii: "a"))
}
static func code(_ character: Character) -> Int8? {
guard character.isASCII,
let index = letters.firstIndex(of: Character(character.lowercased()))
else { return nil }
let code = Int8(index + 1)
return character.isUppercase ? code : -code
}
/// 0 for a dark square, 1 for a light one.
static func colour(_ square: Int) -> Int {
(square % 8 + square / 8) % 2
}
/// The castling right lost when a piece leaves or is taken on this corner.
static func rightTiedTo(_ square: Int) -> UInt8 {
switch square {
case 0: return whiteQueenside
case 7: return whiteKingside
case 56: return blackQueenside
case 63: return blackKingside
default: return 0
}
}
/// Whether a piece of the side with `sign` (1 white, -1 black) attacks `square`.
static func isAttacked(_ board: [Int8], _ square: Int, by sign: Int8) -> Bool {
let file = square % 8
let rank = square / 8
// A pawn attacks the rank ahead of it, so a white attacker stands one rank lower.
let pawnRank = rank - Int(sign)
if pawnRank >= 0 && pawnRank < 8 {
for step in [-1, 1] {
let f = file + step
if f >= 0 && f < 8 && board[pawnRank * 8 + f] == sign * pawn { return true }
}
}
if reaches(board, file, rank, knightSteps, sign * knight)
|| reaches(board, file, rank, kingSteps, sign * king)
{
return true
}
return slides(board, file, rank, straight, sign * rook, sign * queen)
|| slides(board, file, rank, diagonal, sign * bishop, sign * queen)
}
private static func reaches(
_ board: [Int8], _ file: Int, _ rank: Int, _ steps: [(Int, Int)], _ piece: Int8
) -> Bool {
for (df, dr) in steps {
let f = file + df
let r = rank + dr
if f >= 0 && f < 8 && r >= 0 && r < 8 && board[r * 8 + f] == piece { return true }
}
return false
}
/// Whether the first piece met along any direction is `slider` or `queen`.
private static func slides(
_ board: [Int8], _ file: Int, _ rank: Int, _ directions: [(Int, Int)], _ slider: Int8,
_ queen: Int8
) -> Bool {
for (df, dr) in directions {
var f = file + df
var r = rank + dr
while f >= 0 && f < 8 && r >= 0 && r < 8 {
let piece = board[r * 8 + f]
if piece != 0 {
if piece == slider || piece == queen { return true }
break
}
f += df
r += dr
}
}
return false
}
/// Whether the side with `sign` can legally capture en passant onto `target`.
static func enPassantIsLegal(_ board: [Int8], target: Int, by sign: Int8) -> Bool {
let victim = target - 8 * Int(sign)
guard let king = board.firstIndex(of: sign * self.king) else { return false }
for step in [-1, 1] {
let file = victim % 8 + step
guard file >= 0 && file < 8 else { continue }
let from = victim - victim % 8 + file
guard board[from] == sign * pawn else { continue }
var after = board
after[target] = sign * pawn
after[from] = 0
after[victim] = 0
if !isAttacked(after, king, by: -sign) { return true }
}
return false
}
}
@@ -0,0 +1,284 @@
/// The games played inside chats, by their wire names (DESIGN.md section 2).
public enum GameType: String, CaseIterable, Hashable, Sendable {
case chess
case checkers
case reversi
case tictactoe
/// The longest log accepted for this game. A longer one is refused before any of it is played.
public var maxPlies: Int {
switch self {
case .chess: return 600
case .checkers: return 400
case .reversi: return 130
case .tictactoe: return 9
}
}
/// The side that played entry `index` (zero-based) of a log played from the start.
///
/// Every entry, a reversi `pass` included, is one ply by the side to move, so the sides
/// alternate and entry 0 is always `first`'s. A receiver checks each new entry in a peer's log
/// against the peer's side with this.
public func side(ofPly index: Int) -> Side {
index % 2 == 0 ? .first : .second
}
}
/// Who plays. `first` is chess white, checkers dark, reversi black and tic-tac-toe X.
public enum Side: String, CaseIterable, Hashable, Sendable {
case first
case second
public var opponent: Side { self == .first ? .second : .first }
}
/// Why a game was won. The raw values are the names used in `games-v1.json`.
public enum WinReason: String, Hashable, Sendable {
case checkmate
/// Checkers: the side to move has no legal move, which includes having no pieces left.
case noMoves = "no-moves"
/// Reversi: neither side can move and the winner has more discs.
case moreDiscs = "more-discs"
case threeInARow = "three-in-a-row"
}
/// Why a game was drawn. The raw values are the names used in `games-v1.json`.
public enum DrawReason: String, Hashable, Sendable {
case stalemate
case insufficientMaterial = "insufficient-material"
/// Chess: 100 plies without a capture or a pawn move.
case fiftyMoves = "fifty-moves"
/// Checkers: 80 plies without a capture or a man move.
case fortyMoves = "forty-moves"
/// The same position for the third time.
case repetition
/// Reversi: neither side can move and the discs are level.
case equalDiscs = "equal-discs"
case fullBoard = "full-board"
}
/// How a position stands. Resigning and agreed draws travel on the wire; only the natural ends
/// that both sides compute from the log are here.
public enum Outcome: Hashable, Sendable {
case ongoing
case win(Side, WinReason)
case draw(DrawReason)
public var isOver: Bool { self != .ongoing }
}
/// Why a move was refused.
public enum MoveError: Error, Hashable, Sendable {
/// Not written in the game's notation, for example `E2E4` or `11 - 15`.
case malformed
/// Written correctly but not legal here. Every move is illegal once the game is over.
case illegal
}
/// Why a log could not be replayed.
public enum ReplayError: Error, Hashable, Sendable {
/// The log is longer than `GameType.maxPlies` allows.
case tooLong(plies: Int, max: Int)
/// The entry at this zero-based index of the log was refused.
case atPly(Int, MoveError)
}
/// What a piece is. Checkers uses `man` and `king`, reversi `disc`, tic-tac-toe `mark`.
public enum PieceKind: String, Hashable, Sendable {
case pawn
case knight
case bishop
case rook
case queen
case king
case man
case disc
case mark
}
/// One piece on the board.
public struct Piece: Hashable, Sendable {
public let side: Side
public let kind: PieceKind
public init(side: Side, kind: PieceKind) {
self.side = side
self.kind = kind
}
}
/// A board for drawing. Row 0 is the top row as the first player sees the board: chess has rank
/// 8 at the top, checkers has squares 29-32 there (dark's men start at the bottom), reversi has
/// rank 1 there, tic-tac-toe has cells 1-3. Turn it round to show the second player's view.
public struct Board: Hashable, Sendable {
public let rows: Int
public let columns: Int
/// Row-major from the top-left.
public let cells: [Piece?]
/// The wire name of each cell (`e4`, `15`, `d3`, `5`), nil where nothing can stand.
public let squares: [String?]
public func piece(row: Int, column: Int) -> Piece? {
cells[row * columns + column]
}
public func square(row: Int, column: Int) -> String? {
squares[row * columns + column]
}
}
/// A position in one of the four games. Values are immutable; applying a move makes a new one.
public enum GamePosition: Hashable, Sendable {
case chess(ChessPosition)
case checkers(CheckersPosition)
case reversi(ReversiPosition)
case tictactoe(TicTacToePosition)
public var type: GameType {
switch self {
case .chess: return .chess
case .checkers: return .checkers
case .reversi: return .reversi
case .tictactoe: return .tictactoe
}
}
public var sideToMove: Side { rules.sideToMove }
public var board: Board { rules.board }
/// This position in its game's setup notation; see `Games.position(_:setup:)`.
public var setup: String { rules.setup }
var rules: any GameRules {
switch self {
case .chess(let p): return p
case .checkers(let p): return p
case .reversi(let p): return p
case .tictactoe(let p): return p
}
}
}
/// The rules engine for the chat games.
///
/// Moves are strings in the wire notation of DESIGN.md section 2. `apply` accepts exactly the
/// strings `legalMoves` returns, so a log that one app wrote replays identically on the other.
public enum Games {
public static func start(_ type: GameType) -> GamePosition {
switch type {
case .chess: return .chess(.start)
case .checkers: return .checkers(.start)
case .reversi: return .reversi(.start)
case .tictactoe: return .tictactoe(.start)
}
}
/// A position from a setup string, for tests and tools; the wire never carries one.
///
/// Chess takes FEN. Checkers takes PDN FEN such as `B:W21,22:BK1,9` (B is the first, dark
/// side; K marks a king). Reversi takes eight rows of `x` (first), `o` and `-` from rank 1
/// to rank 8 split by `/`, a space and the side to move, `x` or `o`; tic-tac-toe the same
/// with three rows of three. Returns nil for anything malformed or impossible.
public static func position(_ type: GameType, setup: String) -> GamePosition? {
switch type {
case .chess: return ChessPosition(setup: setup).map { .chess($0) }
case .checkers: return CheckersPosition(setup: setup).map { .checkers($0) }
case .reversi: return ReversiPosition(setup: setup).map { .reversi($0) }
case .tictactoe: return TicTacToePosition(setup: setup).map { .tictactoe($0) }
}
}
/// Every legal move, empty once the game is over.
public static func legalMoves(_ position: GamePosition) -> [String] {
position.rules.legalNotations()
}
public static func apply(_ position: GamePosition, _ move: String) throws(MoveError)
-> GamePosition
{
switch position {
case .chess(let p): return .chess(try p.applying(notation: move))
case .checkers(let p): return .checkers(try p.applying(notation: move))
case .reversi(let p): return .reversi(try p.applying(notation: move))
case .tictactoe(let p): return .tictactoe(try p.applying(notation: move))
}
}
public static func outcome(_ position: GamePosition) -> Outcome {
position.rules.outcome
}
/// The position after a whole log played from the start.
public static func replay(_ type: GameType, _ log: [String]) throws(ReplayError)
-> GamePosition
{
guard log.count <= type.maxPlies else {
throw .tooLong(plies: log.count, max: type.maxPlies)
}
return try replay(from: start(type), log)
}
/// The position after `moves` played from `position`. An error names the index in `moves`.
public static func replay(from position: GamePosition, _ moves: [String]) throws(ReplayError)
-> GamePosition
{
var current = position
for (index, move) in moves.enumerated() {
do {
current = try apply(current, move)
} catch {
throw .atPly(index, error)
}
}
return current
}
/// The number of move sequences of length `depth`, for checking move generation.
public static func perft(_ position: GamePosition, depth: Int) -> Int {
position.rules.perft(depth)
}
}
/// What each game's position type provides to `Games`.
protocol GameRules: Hashable, Sendable {
associatedtype Move
var sideToMove: Side { get }
var board: Board { get }
var setup: String { get }
var outcome: Outcome { get }
/// The legal moves, empty once the game is over.
func legalMoveList() -> [Move]
func notation(_ move: Move) -> String
func applying(_ move: Move) -> Self
/// Whether `text` is written in the game's notation, legal or not.
static func isWellFormed(_ text: String) -> Bool
}
extension GameRules {
func legalNotations() -> [String] {
legalMoveList().map(notation)
}
func applying(notation text: String) throws(MoveError) -> Self {
guard Self.isWellFormed(text) else { throw .malformed }
for move in legalMoveList() where notation(move) == text {
return applying(move)
}
throw .illegal
}
func perft(_ depth: Int) -> Int {
if depth <= 0 { return 1 }
let moves = legalMoveList()
if depth == 1 { return moves.count }
var total = 0
for move in moves {
total += applying(move).perft(depth - 1)
}
return total
}
}
@@ -0,0 +1,141 @@
/// A reversi position. Files a-h run left to right and ranks 1-8 top to bottom as the first
/// player draws the board.
public struct ReversiPosition: Hashable, Sendable {
/// Index is row * 8 + column: row 0 is rank 1, column 0 is file a. First's (black) discs
/// are 1, second's (white) -1.
let squares: [Int8]
public let sideToMove: Side
/// A square index, or `pass`.
typealias Move = Int
static let pass: Move = -1
static let start = ReversiPosition(
setup: "--------/--------/--------/---ox---/---xo---/--------/--------/-------- x")!
var sign: Int8 { sideToMove == .first ? 1 : -1 }
}
extension ReversiPosition: GameRules {
var outcome: Outcome {
if !legalMoveList().isEmpty { return .ongoing }
let first = squares.filter { $0 == 1 }.count
let second = squares.filter { $0 == -1 }.count
if first == second { return .draw(.equalDiscs) }
return .win(first > second ? .first : .second, .moreDiscs)
}
/// Placements, or `pass` only when there are none and the opponent has one. Empty when
/// neither side can place, which ends the game.
func legalMoveList() -> [Move] {
let mine = ReversiRules.placements(squares, sign)
if !mine.isEmpty { return mine }
return ReversiRules.placements(squares, -sign).isEmpty ? [] : [Self.pass]
}
func notation(_ move: Move) -> String {
move == Self.pass ? "pass" : ReversiRules.name(move)
}
func applying(_ move: Move) -> ReversiPosition {
guard move != Self.pass else {
return ReversiPosition(squares: squares, sideToMove: sideToMove.opponent)
}
var board = squares
board[move] = sign
for flipped in ReversiRules.flips(squares, move, sign) {
board[flipped] = sign
}
return ReversiPosition(squares: board, sideToMove: sideToMove.opponent)
}
static func isWellFormed(_ text: String) -> Bool {
text == "pass" || ReversiRules.square(named: text) != nil
}
var board: Board {
Board(
rows: 8, columns: 8,
cells: squares.map {
$0 == 0 ? nil : Piece(side: $0 > 0 ? .first : .second, kind: .disc)
},
squares: (0..<64).map { ReversiRules.name($0) })
}
var setup: String {
let rows = (0..<8).map { row in
String(squares[row * 8..<row * 8 + 8].map { $0 == 1 ? "x" : $0 == -1 ? "o" : "-" })
}
return rows.joined(separator: "/") + (sideToMove == .first ? " x" : " o")
}
/// Reads eight rows of `x`, `o` and `-` from rank 1 to rank 8 split by `/`, a space, and
/// the side to move.
init?(setup: String) {
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
guard fields.count == 2 else { return nil }
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
guard rows.count == 8, rows.allSatisfy({ $0.count == 8 }) else { return nil }
var board: [Int8] = []
for character in rows.joined() {
switch character {
case "x": board.append(1)
case "o": board.append(-1)
case "-": board.append(0)
default: return nil
}
}
switch fields[1] {
case "x": self.init(squares: board, sideToMove: .first)
case "o": self.init(squares: board, sideToMove: .second)
default: return nil
}
}
}
enum ReversiRules {
/// (row, column) steps.
static let steps = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
static func name(_ index: Int) -> String {
let file = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(index % 8)))
return String(file) + String(index / 8 + 1)
}
static func square(named text: String) -> Int? {
let bytes = Array(text.utf8)
guard bytes.count == 2,
(UInt8(ascii: "a")...UInt8(ascii: "h")).contains(bytes[0]),
(UInt8(ascii: "1")...UInt8(ascii: "8")).contains(bytes[1])
else { return nil }
return Int(bytes[1] - UInt8(ascii: "1")) * 8 + Int(bytes[0] - UInt8(ascii: "a"))
}
/// The discs a placement on `index` by the side with `sign` would turn over.
static func flips(_ board: [Int8], _ index: Int, _ sign: Int8) -> [Int] {
guard board[index] == 0 else { return [] }
var turned: [Int] = []
for (dr, dc) in steps {
var row = index / 8 + dr
var column = index % 8 + dc
var line: [Int] = []
while row >= 0 && row < 8 && column >= 0 && column < 8
&& board[row * 8 + column] == -sign
{
line.append(row * 8 + column)
row += dr
column += dc
}
if !line.isEmpty && row >= 0 && row < 8 && column >= 0 && column < 8
&& board[row * 8 + column] == sign
{
turned += line
}
}
return turned
}
static func placements(_ board: [Int8], _ sign: Int8) -> [Int] {
(0..<64).filter { !flips(board, $0, sign).isEmpty }
}
}
@@ -0,0 +1,97 @@
/// A tic-tac-toe position. Cells are named 1-9 row by row from the top-left; X moves first.
public struct TicTacToePosition: Hashable, Sendable {
/// Cell 0 is `1`. X (first) is 1, O (second) is -1.
let cells: [Int8]
public let sideToMove: Side
/// A cell index.
typealias Move = Int
static let start = TicTacToePosition(cells: [Int8](repeating: 0, count: 9), sideToMove: .first)
static let lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6],
]
var winner: Side? {
for line in Self.lines where cells[line[0]] != 0 {
if cells[line[0]] == cells[line[1]] && cells[line[1]] == cells[line[2]] {
return cells[line[0]] > 0 ? .first : .second
}
}
return nil
}
}
extension TicTacToePosition: GameRules {
var outcome: Outcome {
if let winner { return .win(winner, .threeInARow) }
return cells.contains(0) ? .ongoing : .draw(.fullBoard)
}
func legalMoveList() -> [Move] {
winner != nil ? [] : (0..<9).filter { cells[$0] == 0 }
}
func notation(_ move: Move) -> String {
String(move + 1)
}
func applying(_ move: Move) -> TicTacToePosition {
var next = cells
next[move] = sideToMove == .first ? 1 : -1
return TicTacToePosition(cells: next, sideToMove: sideToMove.opponent)
}
static func isWellFormed(_ text: String) -> Bool {
text.utf8.count == 1 && (UInt8(ascii: "1")...UInt8(ascii: "9")).contains(text.utf8.first!)
}
var board: Board {
Board(
rows: 3, columns: 3,
cells: cells.map {
$0 == 0 ? nil : Piece(side: $0 > 0 ? .first : .second, kind: .mark)
},
squares: (1...9).map { String($0) })
}
var setup: String {
let rows = (0..<3).map { row in
String(cells[row * 3..<row * 3 + 3].map { $0 == 1 ? "x" : $0 == -1 ? "o" : "-" })
}
return rows.joined(separator: "/") + (sideToMove == .first ? " x" : " o")
}
/// Reads three rows of `x`, `o` and `-` split by `/`, a space, and the side to move, which
/// must be the one the counts say; a board where both sides have a line is refused.
init?(setup: String) {
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
guard fields.count == 2 else { return nil }
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
guard rows.count == 3, rows.allSatisfy({ $0.count == 3 }) else { return nil }
var cells: [Int8] = []
for character in rows.joined() {
switch character {
case "x": cells.append(1)
case "o": cells.append(-1)
case "-": cells.append(0)
default: return nil
}
}
let xs = cells.filter { $0 == 1 }.count
let os = cells.filter { $0 == -1 }.count
let side: Side
switch (fields[1], xs - os) {
case ("x", 0): side = .first
case ("o", 1): side = .second
default: return nil
}
let lines = Self.lines.filter { line in
cells[line[0]] != 0 && cells[line[0]] == cells[line[1]]
&& cells[line[1]] == cells[line[2]]
}
guard Set(lines.map { cells[$0[0]] }).count < 2 else { return nil }
self.init(cells: cells, sideToMove: side)
}
}
@@ -0,0 +1,114 @@
import FlamenetGames
import XCTest
/// The parts of the API the vectors do not reach: names, caps, board orientation, setup refusals.
final class GamesTests: XCTestCase {
func testWireNames() {
XCTAssertEqual(
GameType.allCases.map(\.rawValue), ["chess", "checkers", "reversi", "tictactoe"])
XCTAssertEqual(Side.allCases.map(\.rawValue), ["first", "second"])
XCTAssertEqual(GameType.allCases.map(\.maxPlies), [600, 400, 130, 9])
}
func testPliesAlternateFromFirst() {
for type in GameType.allCases {
XCTAssertEqual(
(0..<4).map { type.side(ofPly: $0) }, [.first, .second, .first, .second])
}
}
func testTheCapIsCheckedBeforeAnyMove() {
XCTAssertThrowsError(try Games.replay(.tictactoe, Array(repeating: "x", count: 10))) {
XCTAssertEqual($0 as? ReplayError, .tooLong(plies: 10, max: 9))
}
}
func testApplyReportsWhichKindOfRefusal() throws {
let start = Games.start(.chess)
XCTAssertThrowsError(try Games.apply(start, "e2e5")) {
XCTAssertEqual($0 as? MoveError, .illegal)
}
XCTAssertThrowsError(try Games.apply(start, "E2E4")) {
XCTAssertEqual($0 as? MoveError, .malformed)
}
let moved = try Games.apply(start, "e2e4")
XCTAssertEqual(start, Games.start(.chess), "applying a move left the old position alone")
XCTAssertEqual(moved, try Games.replay(.chess, ["e2e4"]))
XCTAssertEqual(moved.sideToMove, .second)
}
func testChessBoardHasRankEightOnTop() throws {
let board = Games.start(.chess).board
XCTAssertEqual(board.rows, 8)
XCTAssertEqual(board.square(row: 0, column: 0), "a8")
XCTAssertEqual(board.piece(row: 0, column: 0), Piece(side: .second, kind: .rook))
XCTAssertEqual(board.square(row: 7, column: 4), "e1")
XCTAssertEqual(board.piece(row: 7, column: 4), Piece(side: .first, kind: .king))
XCTAssertEqual(board.cells.compactMap { $0 }.count, 32)
}
func testChessReportsCheck() throws {
let mated = try Games.replay(.chess, ["f2f3", "e7e5", "g2g4", "d8h4"])
guard case .chess(let position) = mated else { return XCTFail("not chess") }
XCTAssertTrue(position.inCheck)
}
func testCheckersBoardHasTheFirstPlayersMenAtTheBottom() {
let board = Games.start(.checkers).board
XCTAssertEqual(
(0..<8).map { board.square(row: 7, column: $0) },
["4", nil, "3", nil, "2", nil, "1", nil])
XCTAssertEqual(
(0..<8).map { board.square(row: 0, column: $0) },
[nil, "32", nil, "31", nil, "30", nil, "29"])
XCTAssertEqual(board.piece(row: 7, column: 0), Piece(side: .first, kind: .man))
XCTAssertEqual(board.piece(row: 0, column: 1), Piece(side: .second, kind: .man))
XCTAssertNil(board.piece(row: 4, column: 3))
}
func testReversiBoardHasRankOneOnTop() {
let board = Games.start(.reversi).board
XCTAssertEqual(board.square(row: 0, column: 0), "a1")
XCTAssertEqual(board.square(row: 3, column: 3), "d4")
XCTAssertEqual(board.piece(row: 3, column: 3), Piece(side: .second, kind: .disc))
XCTAssertEqual(board.piece(row: 3, column: 4), Piece(side: .first, kind: .disc))
XCTAssertEqual(board.piece(row: 4, column: 3), Piece(side: .first, kind: .disc))
XCTAssertEqual(board.piece(row: 4, column: 4), Piece(side: .second, kind: .disc))
}
func testTicTacToeBoardIsNumberedRowByRow() throws {
let board = try Games.replay(.tictactoe, ["5", "1"]).board
XCTAssertEqual(board.squares, ["1", "2", "3", "4", "5", "6", "7", "8", "9"])
XCTAssertEqual(board.piece(row: 1, column: 1), Piece(side: .first, kind: .mark))
XCTAssertEqual(board.piece(row: 0, column: 0), Piece(side: .second, kind: .mark))
}
func testImpossibleSetupsAreRefused() {
let refused: [(GameType, String)] = [
(.chess, "4k3/8/8/8/8/8/8/4K2K w - - 0 1"), // two white kings
(.chess, "4k2P/8/8/8/8/8/8/4K3 w - - 0 1"), // a pawn on the last rank
(.chess, "4k3/8/8/8/8/8/8/4K2R w - - 0 1 extra"),
(.chess, "4k3/4R3/8/8/8/8/8/4K3 w - - 0 1"), // black in check with white to move
(.chess, "4k3/8/8/8/8/8/8/4K3 w - e6 0 1"), // en passant with no pawn that stepped
(.chess, "4k3/8/8/8/8/8/8/4K3 w KK - 0 1"),
(.checkers, "B:W21:B29"), // a dark man where it would have been crowned
(.checkers, "B:W21,21:B1"),
(.checkers, "B:W33:B1"),
(.checkers, "B:W21:W1"),
(.reversi, "--------/--------/--------/---ox---/---xo---/--------/--------/------- x"),
(.reversi, "--------/--------/--------/---ox---/---xo---/--------/--------/-------- b"),
(.tictactoe, "x--/---/--- x"), // X has moved, so O is to move
(.tictactoe, "xxx/ooo/--- x"),
]
for (type, setup) in refused {
XCTAssertNil(Games.position(type, setup: setup), setup)
}
}
func testSetupReadsBackTheStart() {
for type in GameType.allCases {
let start = Games.start(type)
XCTAssertEqual(Games.position(type, setup: start.setup), start, type.rawValue)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import FlamenetGames
import Foundation
import XCTest
/// Runs `games-v1.json`. The Android `:games` module runs the same file, byte for byte, so the
/// two engines agree on every vector or one of the suites is red.
final class VectorTests: XCTestCase {
struct Vectors: Decodable {
let v: Int
let perft: [Perft]
let cases: [Case]
}
struct Perft: Decodable {
let name: String
let t: String
let setup: String?
let counts: [Int]
let deep: [[Int]]?
}
struct Case: Decodable {
let name: String
let t: String
let setup: String?
let log: [String]
let expect: Expect
}
struct Expect: Decodable {
let error: String?
let ply: Int?
let outcome: String?
let toMove: String?
let legal: [String]?
let includes: [String]?
let excludes: [String]?
let setup: String?
let trace: [Int]?
}
func loadVectors() throws -> Vectors {
let url = try XCTUnwrap(Bundle.module.url(forResource: "games-v1", withExtension: "json"))
return try JSONDecoder().decode(Vectors.self, from: Data(contentsOf: url))
}
func testPerftMatchesTheLiterature() throws {
for entry in try loadVectors().perft {
let position = try startingPosition(entry.t, entry.setup)
for (index, expected) in entry.counts.enumerated() {
assertPerft(entry.name, position, depth: index + 1, expected)
}
}
}
/// The deeper published counts take seconds to minutes, so they run only on request.
func testDeepPerft() throws {
guard ProcessInfo.processInfo.environment["FLAMENET_GAMES_DEEP"] == "1" else {
throw XCTSkip("set FLAMENET_GAMES_DEEP=1 to run the deep perft counts")
}
for entry in try loadVectors().perft {
let position = try startingPosition(entry.t, entry.setup)
for pair in entry.deep ?? [] {
assertPerft(entry.name, position, depth: pair[0], pair[1])
}
}
}
func testEveryCase() throws {
let cases = try loadVectors().cases
for vector in cases {
check(vector)
}
print("games-v1.json: \(cases.count) cases checked")
}
/// Guards against a vectors file that lost entries and still parses.
func testTheVectorsCoverEveryGame() throws {
let vectors = try loadVectors()
XCTAssertEqual(vectors.v, 1)
XCTAssertGreaterThanOrEqual(vectors.cases.count, 40)
for type in GameType.allCases {
XCTAssertGreaterThanOrEqual(
vectors.cases.filter { $0.t == type.rawValue }.count, 10, type.rawValue)
XCTAssertTrue(vectors.perft.contains { $0.t == type.rawValue }, type.rawValue)
}
}
private func assertPerft(_ name: String, _ position: GamePosition, depth: Int, _ expected: Int)
{
let clock = ContinuousClock()
var count = 0
let elapsed = clock.measure { count = Games.perft(position, depth: depth) }
print("perft \(name) depth \(depth): \(count) in \(elapsed)")
XCTAssertEqual(count, expected, "perft \(name) depth \(depth)")
}
private func startingPosition(_ name: String, _ setup: String?) throws -> GamePosition {
let type = try XCTUnwrap(GameType(rawValue: name), "no game called \(name)")
guard let setup else { return Games.start(type) }
return try XCTUnwrap(Games.position(type, setup: setup), "setup refused: \(setup)")
}
private func check(_ vector: Case) {
let name = vector.name
guard let start = try? startingPosition(vector.t, vector.setup),
let type = GameType(rawValue: vector.t)
else { return XCTFail("\(name): cannot start") }
let expect = vector.expect
let result: Result<GamePosition, ReplayError>
do throws(ReplayError) {
result = .success(
vector.setup == nil
? try Games.replay(type, vector.log) : try Games.replay(from: start, vector.log)
)
} catch {
result = .failure(error)
}
switch result {
case .failure(let error):
XCTAssertEqual(describe(error), expectedError(expect), name)
case .success(let position):
XCTAssertNil(expect.error, "\(name): played through, expected \(expectedError(expect))")
if let outcome = expect.outcome {
XCTAssertEqual(describe(Games.outcome(position)), outcome, name)
}
if let toMove = expect.toMove {
XCTAssertEqual(position.sideToMove.rawValue, toMove, name)
}
let legal = Games.legalMoves(position)
if let exact = expect.legal {
XCTAssertEqual(Set(legal), Set(exact), name)
XCTAssertEqual(legal.count, exact.count, "\(name): duplicate moves")
}
for move in expect.includes ?? [] {
XCTAssertTrue(legal.contains(move), "\(name): \(move) should be legal")
}
for move in expect.excludes ?? [] {
XCTAssertFalse(legal.contains(move), "\(name): \(move) should not be legal")
}
if let setup = expect.setup {
XCTAssertEqual(position.setup, setup, name)
}
}
walk(vector, from: start)
}
/// Plays the log one move at a time and checks what must hold at every step: the game is over
/// exactly when there are no legal moves, `apply` accepts exactly the listed moves, the side
/// to move is the one `side(ofPly:)` names, the setup string reads back unchanged, and the
/// number of legal moves matches `trace` when the vector has one.
private func walk(_ vector: Case, from start: GamePosition) {
let name = vector.name
let trace = vector.expect.trace
if let trace {
XCTAssertEqual(trace.count, vector.log.count + 1, "\(name): trace length")
}
var position = start
for (index, move) in vector.log.enumerated() {
let legal = Games.legalMoves(position)
if let trace, index < trace.count {
XCTAssertEqual(
legal.count, trace[index], "\(name): legal moves before ply \(index)")
}
XCTAssertEqual(
Games.outcome(position).isOver, legal.isEmpty, "\(name): before ply \(index)")
if vector.setup == nil {
XCTAssertEqual(position.sideToMove, position.type.side(ofPly: index), name)
}
XCTAssertEqual(
Games.position(position.type, setup: position.setup)?.setup, position.setup,
"\(name): setup round trip before ply \(index)")
do {
position = try Games.apply(position, move)
XCTAssertTrue(legal.contains(move), "\(name): applied \(move), not listed")
} catch {
XCTAssertFalse(legal.contains(move), "\(name): refused \(move), but listed")
return
}
}
XCTAssertEqual(
Games.outcome(position).isOver, Games.legalMoves(position).isEmpty, "\(name): at end")
if let trace, let last = trace.last {
XCTAssertEqual(
Games.legalMoves(position).count, last, "\(name): legal moves at the end")
}
}
private func describe(_ outcome: Outcome) -> String {
switch outcome {
case .ongoing: return "ongoing"
case .win(let side, let reason): return "win \(side.rawValue) \(reason.rawValue)"
case .draw(let reason): return "draw \(reason.rawValue)"
}
}
private func describe(_ error: ReplayError) -> String {
switch error {
case .tooLong: return "too-long"
case .atPly(let ply, .malformed): return "malformed at \(ply)"
case .atPly(let ply, .illegal): return "illegal at \(ply)"
}
}
private func expectedError(_ expect: Expect) -> String {
guard let error = expect.error else { return "no error" }
return error == "too-long" ? error : "\(error) at \(expect.ply ?? -1)"
}
}
+1
Submodule Packages/Obfs4 added at c53e3776ea
+19
View File
@@ -0,0 +1,19 @@
// swift-tools-version:5.9
import PackageDescription
/// Google's WebRTC binary, pinned by us rather than by a tag we do not control.
let package = Package(
name: "WebRTCBinary",
platforms: [.iOS(.v14)],
products: [
.library(name: "WebRTC", targets: ["WebRTC"])
],
targets: [
.binaryTarget(
name: "WebRTC",
url:
"https://github.com/stasel/WebRTC/releases/download/151.0.1/WebRTC-M151.xcframework.zip",
checksum: "6f3f5693383ce65763190c46ca9f2c4325c34b83681acb9db30f01488e15f1e0"
)
]
)
+86 -43
View File
@@ -4,35 +4,50 @@ An end-to-end encrypted messenger: the apps, the server they talk to, and a
specification complete enough that someone else could build a compatible
client from it.
The site is at <https://flamenetmessenger.com>, which is served by the relay in
this family and explains what the apps do, what the server keeps, and where the
limits are.
This repository holds all of it, and is the source of truth for every part. A
protocol and the thing that speaks it drifting apart in separate repositories is
how "compatible" quietly stops being true — so the interop harness here compiles
how "compatible" quietly stops being true, so the interop harness here compiles
the iOS engine and runs it against the JavaScript one on every change, which only
works while they sit in the same checkout.
```
engine/ the JavaScript engine, the specification, and the published vectors
server/ the Swift relay — protocol, mobile routes, and this project's website
Sources/ the iOS app (SwiftUI), including its own Swift engine
android/ the Kotlin engine. No app yet; see android/README.md
docs/ historical design notes
Sources/ the app (SwiftUI), including its Swift E2E engine
Tests/ unit tests, run on any Mac, no device
UITests/ the driven-simulator suites
Resources/ assets, entitlements, the shared sticker manifest
docs/ design notes, the roadmap, the padding design
```
Two of those are also published on their own, for people who want one piece
rather than the whole project:
The rest of the product lives in its own repositories, each with its own
pipeline:
| Repo | Mirrors | For |
|---|---|---|
| [flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e) | `engine/` | The protocol, as a package |
| [flamenet-relay](https://gitlab.com/paulhitt/flamenet-relay) | `server/` | Running your own relay |
| Repo | What it is |
|---|---|
| [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 are **extracts, not forks**. Changes are made here and mirrored out; a
patch sent to one of them has to land here or it will be overwritten. They have
already drifted once, badly, which is why it is written down.
those three lived here as subtrees, and the public
repos were rsync extracts of them, refreshed by hand. The extracts drifted for
days at a time. They are standalone now, split out with their history, work on
each product happens in its own repository, and a patch to a subtree here has
nowhere to go because the subtrees are gone.
**What still spans repos**, and how it is wired rather than wished for:
- `flamenet-e2e`'s interop driver compiles this app's `Sources/E2E/*.swift`,
it finds this repo as a sibling checkout, or via `FNMSG_APP_DIR`.
- The Android engine fetches the published vectors from `flamenet-e2e` at HEAD
in CI rather than carrying a copy that would drift.
- The relay's conformance job clones `flamenet-e2e` for the §8 harness.
## The three engines
The protocol is implemented three times, and that is the point rather than an
The protocol is implemented three times rather than an
accident. Two implementations that agree may agree because one was written
while reading the other; a third, written against the specification and the
published vectors, is what finds the places where the spec says less than the
@@ -42,36 +57,55 @@ They are checked against each other rather than trusted:
| Engine | Where | State |
|---|---|---|
| JavaScript | `engine/src/` | Complete — the reference implementation |
| Swift | `Sources/E2E/` | Complete, and compiled directly into the interop harness rather than reimplemented for it |
| Kotlin | `android/engine/` | Safety numbers and key transparency only — see `android/README.md` |
| JavaScript | `flamenet-e2e/src/`, 8 files | Complete, the reference implementation, and the one the published vectors are generated from |
| Swift | `Sources/E2E/`, 20 files | Complete, and compiled directly into the interop harness rather than reimplemented for it |
| Kotlin | `flamenet-messenger-android/engine/`, 18 files | Complete: X3DH, the ratchet, sealed sender, the key-transparency log, safety numbers |
## Running the tests
Each repository tests itself; there is no longer a command that runs all of
them, because they are no longer one checkout.
```bash
cd server && swift build # the relay binary the suite drives
cd ../engine && npm test # 478 assertions across five suites
cd ../android && ./gradlew :engine:test
# this repo: the app's unit suite, the merge gate
xcodegen generate && xcodebuild -project FlamenetMessenger.xcodeproj \
-scheme FlamenetMessenger -sdk iphonesimulator \
-destination "platform=iOS Simulator,name=iPhone 17" \
test -only-testing:FlamenetMessengerTests
```
`npm test` runs conformance, ratchet, client, interop and relay. The interop
suite compiles the iOS app's own `Sources/E2E/*.swift` and runs it against the
JavaScript engine, so it needs a Mac; the relay suite drives the compiled Swift
binary over real HTTP, so `swift build` has to have run first — a stale binary
in `server/.build/` will happily pass tests for code you just changed.
The cross-repository suites live in `flamenet-e2e` and need it checked out as a
sibling of this one (or `FNMSG_APP_DIR` pointing here):
```bash
cd ../flamenet-e2e
node test/conformance.mjs # published vectors, both backends
node test/interop.mjs # compiles THIS app's Sources/E2E and runs it against the JS engine, needs a Mac
node test/relay.mjs # drives a compiled relay over real HTTP; needs FN_RELAY_DIR
```
`relay.mjs` drives a **binary**, so `swift build` in the relay checkout has
to have run first, a stale binary passes tests for code you just changed.
UI tests (`UITests/`) are not in any gate: they need a local relay on port
18810 and, for the conversation suites, two booted simulators. Without the
relay they **skip rather than fail**, so read the executed count and not the
tick.
## Running the relay
The server is its own repository now:
```bash
cd server
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
```
Open <http://localhost:8080>. The relay serves this project's website itself,
so a working install explains what it is rather than answering with a 404.
For production, see `server/DEPLOY.md`.
For production, see that repository's `docs/HOSTING.md`.
## Building the iOS app
@@ -80,24 +114,33 @@ xcodegen generate
open FlamenetMessenger.xcodeproj
```
Requires Xcode and [XcodeGen](https://github.com/yonsm/XcodeGen)
(`brew install xcodegen`). The app is pinned to `relay.flamenet.io`; the
sign-in screen accepts another address for a relay you run yourself.
Requires Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen)
(`brew install xcodegen`).
The app has never been released. `SCOPING-v1.md` tracks what stands between it
and a first TestFlight build.
`FlamenetMessenger.xcodeproj/project.pbxproj` is **generated and gitignored**.
Declare sources in `project.yml` and regenerate; never hand-edit it.
## What is published, and what is not
The app is pinned to `relay.flamenet.io`; the sign-in screen accepts another
address for a relay you run yourself.
`engine/` and `server/` are mirrored to a public repository under the MIT
licence. The iOS app is not: the mirror is built from an allowlist rather than
an ignore list, so a new directory here is private by default instead of
published by accident. The mirror job is manual — a push to a public repository
is the one step in this pipeline that cannot be undone.
The app has not been released. `SCOPING-v1.md` tracks what stands between it and
a first TestFlight build.
## What is published
All four repositories are public under the MIT licence, this one included.
That was not always true: the app was private while the engine and server were
mirrored out of it by an allowlist, and those mirrors were the
only public copies. They are standalone repositories now, so what you are
reading is the source rather than an extract of it.
Being public is a constraint on what may be committed: **no keystores, no
passphrases, no secrets, ever**, including in test fixtures and in the history.
## Where to read next
- The protocol: `engine/docs/SPEC.md`, also served at `/spec`
- What an auditor would need: `engine/docs/AUDIT-SCOPE.md`, served at `/audit`
- The protocol: `flamenet-e2e/docs/SPEC.md`, also served at `/spec`
- What an auditor would need: `flamenet-e2e/docs/AUDIT-SCOPE.md`, served at `/audit`
- What the relay stores: served at `/metadata`
- What is not done: served at `/roadmap`
- How to contribute: [CONTRIBUTING.md](CONTRIBUTING.md) · security: [SECURITY.md](SECURITY.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 391 KiB

@@ -0,0 +1,8 @@
{
"images": [
{ "filename": "logo@1x.png", "idiom": "universal", "scale": "1x" },
{ "filename": "logo@2x.png", "idiom": "universal", "scale": "2x" },
{ "filename": "logo@3x.png", "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

@@ -0,0 +1,23 @@
{
"images": [
{
"filename": "mark@1x.png",
"idiom": "universal",
"scale": "1x"
},
{
"filename": "mark@2x.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "mark@3x.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+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>
+40
View File
@@ -18,12 +18,43 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<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>
<key>CFBundleURLName</key>
<string>net.flamenet.messenger.hello</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flamenetmessenger</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>cydia</string>
<string>sileo</string>
<string>zbra</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Flamenet uses the camera to scan a contact's address code. Nothing is recorded and no image leaves your device.</string>
<key>NSFaceIDUsageDescription</key>
<string>Flamenet can require Face ID before opening the app, so somebody holding your unlocked phone cannot read your conversations.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Flamenet uses the microphone for voice notes and voice calls, which are end-to-end encrypted before they leave your device.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>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.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -32,6 +63,8 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>voip</string>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict/>
@@ -39,5 +72,12 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

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