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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What the resolutions keep, and why:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

DRAFT, needs native review.

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

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

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

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

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

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

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

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

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

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

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

AContactLinkOpensTheAppTests: 3/3.

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

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

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

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

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

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

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

AFirstMessageByLinkOpensItsConversationTests: 2 red, then 3/3 green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:09:19 -04:00