How two Aposemati devices authenticate each other, and the demonstrated attack that forced the final design. The MITM finding here is the single most valuable result of the 1 build and generalises well beyond this project.

The model as shipped

  1. Both sides generate a self-signed identity in memory at launch. Never written to disk, never in the keychain, never persisted.
  2. QUIC mutual TLS handshake. There is no CA and no pinning — the verify block accepts any well-formed, in-lifetime chain, which is correct under this model.
  3. Commit-reveal nonce exchange: each side commits to a nonce before seeing the peer’s, then reveals.
  4. Each side independently derives six digits from both certificate fingerprints and both nonces (HKDF).
  5. The user compares two screens. On match, each side accepts; photos only flow after.

For Agents

Nothing secret ever crosses the wire, so there is no offline verifier and the PAKE requirement dissolves. The security property is entirely in the commitment ordering, not in secrecy.

The attack that forced the commit-reveal

Short-authentication-string comparison alone is NOT MITM-resistant

A reviewer ran the attack, rather than reasoning about it.

The SAS was a pure function of the two certificate fingerprints, and both certificates are generated freely at launch. Nothing committed either side to a value before it had seen the peer’s — so a 20-bit comparison collapses into a ~10-bit birthday search.

Measured: 200 identities in 0.0836 s (0.42 ms per certificate, full P-256 keygen). Collision after 1019 certificate pairs. Then a complete machine-in-the-middle over loopback where camera and host both displayed 528724, the attacker saw 528724/528724, and a relayed asset frame — a real photo — arrived at the host. Total elapsed: 0.77 seconds.

Grinding only the random serial costs one SHA-256 per candidate, so even a targeted 2^20 preimage against one known peer is seconds.

The controller’s own recorded error: advising that SAS comparison removed the offline attack “because nothing secret crosses the wire”. True but insufficient — the attacker is not recovering a secret, it is choosing two certificates whose SAS values collide.

Why commit-reveal fixes it, and the verification

Nonces are public, so the fix preserves the “nothing secret on the wire” constraint. Because each side commits before it sees the peer’s contribution, the attacker can no longer choose both inputs.

A re-reviewer independently re-ran the MITM rather than trusting the shipped test: ground a colliding pair in 2549 identities (<1 s), confirmed the legacy nonce-free rule still gives one matching code on both legs (014927), relayed a real asset frame end to end — and then confirmed that under the shipped rule the camera shows 883294 and the host shows 271004. The attacker’s two legs each match their own victim, so no number exists that both users see. 25 further relays, 0 agreements.

Also verified: the responder genuinely aborts on a bad reveal (pairingCommitmentBroken, no transport surfaced); role is decided locally so a peer cannot choose its counterpart’s role; no nonce-free derivation path exists in production.

2026-08-19 — the “cheating initiator dictates the six digits” exploit is REFUTED

The commitment binds correctly; the grind never touched the shipped responder

The whole-branch review took the claim seriously enough to reproduce it honestly and it did not survive. Grinding runs at ~90k tries/s, so the reported 77,883 tries in 0.82 s was offline computation, not handshakes. Pushing the ground-out nonce through the shipped listener yields pairingCommitmentBroken and no transport admitted.

The step a real peer cannot perform is altering its nonce after committing. And a cheating initiator produces no second screen at all — so nothing is compared and nothing is defeated. Certificate grinding buys nothing either: the same two certificates produce a fresh code every handshake.

But the guard that refutes it has ZERO test coverage

pairingCommitmentBroken appears in exactly two places: its declaration and its throw site. No test exercises it. Mutation confirms the hole: deleting the commit-reveal verification guard at NetworkTransport.swift:450 SURVIVES (kill-proven), as does making the responder reveal its nonce before reading the commitment (:445).

The existing MITM test does not cover it. Its attacker is an honest relay that never cheats on the reveal — so it exercises nonce mixing, not the commitment. The one mechanism this project’s own documents call load-bearing is the one the suite cannot defend. AuditGapTests.swift (parked in docs/superpowers/2026-08-19-review-artifacts/) closes this.

Online regrinding: Low

~297 attempts/s against a live responder, with no rate limit — but the attacker cannot steer the code. Each round is a uniform 1-in-10⁶ draw producing a fresh code in front of a human who has to confirm it. Worth a cheap per-source cap as defence-in-depth; not a prerequisite.

Why the identity is in-memory

Decision (user, 2026-08-18)

TLS identity is in-memory, per session. Generated at launch, held in memory, never written to disk or the keychain. Accepted cost: re-pair whenever either app restarts.

The motivating context: earlier the same day an unscoped agent created keychain state and triggered unexplained password prompts. Nothing persists, so there is nothing to clean up and no repeat.

Verified end to end: grep for SecItem|SecIdentity|SecKeychain|SecCertificate across Sources/ returns nothing; after ~5000 identity generations and a dozen handshakes every file under ~/Library/Keychains/ was byte- and mtime-identical to a pre-run snapshot. No keychain entitlement in the signed bundle.

Proof of possession holds: SecIdentityCreate refuses a copied certificate with a foreign key, and mutual auth fails closed (an anonymous client is dropped with POSIX 53).

How the pairing model moved twice

Both moves came from measurement contradicting the plan, which is the process working rather than failing.

ModelWhy it died
TLS-PSK from a typed 6-digit codeNetwork.framework does not support PSK over QUIC-9858 in every variant. See aposemati-network-framework-gotchas.
Typed code → HKDF → pinning secretHKDF is a fast KDF over 20 bits. Measured: enumerating the whole 10^6 space and matching a target key took 4.48 s single-threaded in release Swift, dominated by String(format:) and Data allocation — a native loop is sub-second and parallelises trivially. Online rate-limiting is necessary but not sufficient: any offline-verifiable function of the key on the wire (a MAC over a fingerprint, an encrypted confirmation, a commitment) lets a passive eavesdropper recover the code in seconds.
Displayed SAS from two fingerprintsDefeated by the 0.77 s MITM above.
Displayed SAS from two fingerprints + two committed noncesShipped.

Known and accepted weaknesses

The refusal blacklist is per-process-lifetime

Because identities are minted per launch, refusing a peer records a fingerprint that the peer will not have next time it launches. A refused peer that relaunches is offered again.

The reviewer’s judgement, accepted: it is a speed bump, and the right one — the real defence is that a new identity produces a new six-digit code the user must compare again. The UI says so honestly. Documented rather than fixed.

CLOSED in e1cd333 — refusing used to start an endless loop

Fixing “refusal must remember what it refused” created a new defect. The refused-peer screen’s only button called searchAgain(), which cleared the refusal — and the phone was still advertising on the desk. The cycle was scan → handshake → fingerprint hit → close → .searching → sleep 2 s → repeat, forever at ~2.1 s per iteration. The phone displayed a new six-digit code every 2 seconds indefinitely, and quitting was the only exit.

Fixed in e1cd333. Keep it as a worked example: a security fix that makes a refusal stick must also define what recovery looks like, or the recovery path becomes the bug.

UI is part of the security boundary

The last mile is where a correct protocol gets undone by its UI

Both apps shipped a Critical finding of exactly this shape.

On the phone: the model set phase = .connected and immediately ran the session, answering every captureStill that arrived. Nothing waited for a human. The presentation was fine — 56 pt monospaced, tracking(10), centred — but the mechanism motivated by a demonstrated 0.77 s attack was a suggestion. No mismatch affordance (recovery was force-quit), no acknowledgement gate, and nothing identified the peer, because the host never sent a hello — the copy said “your Mac” but the screen could not know which Mac.

On the Mac: the read barrier moved but the receive barrier did not. transport.begin() ran inside connect(), before the code was displayed, into an unbounded AsyncStream nobody drained. Measured: 13 MB RSS after handshake, 118 MB after 8×8 MB, 252 MB after 24×8 MB — all before anyone compared anything, then 24 frames replayed the instant the session started. A peer can pre-stage photos that land at t+0 after acceptance.

The pre-confirmation memory hole is CLOSED — aef872b

The 4 MB preConfirmationByteBudget was not a bound. It was a one-second sampler on an undrained, unthrottled AsyncStream, so it turned unbounded-forever exposure into ~1 second of line rate — measured at 10.25 MiB at ~96 Mbps and 37.25 MiB at ~400 Mbps (AWDL-realistic). On the phone side the same shape took a listening iPhone from 18 MB to 196 MB RSS against one unconfirmed stranger, and 321 MB against four. On a phone that is jetsam.

aef872b makes the transport read nothing from a peer until the application asks for its frames.

Verified in a separate victim process, with a control

34.7 MB peak against four flooding strangers, versus 486 MB with the fix reverted. The reverted control is what makes the number evidence rather than an assertion.

The fix then had to be corrected once more in 86fb3f1, because deferring reading had quietly become dropping — see The four fixes that closed the review loop.

Further UI rules that came out of it:

  • A green “direct” badge reads as a trust signal. It turns green about two seconds in — exactly while the user is comparing digits. “Direct” is a routing fact, not a trust fact; do not render it before acceptance.
  • Never render peer-chosen text in the security dialog. An attacker advertising as your iPhone (code 481920) puts misleading text in the one dialog whose job is to be trustworthy, and a long name reflows the layout around the digits.
  • Group the digits. 483 920 is measurably less error-prone to compare than 483920 — the cheapest possible improvement to the odds of the whole scheme.
  • A failed handshake is what a hostile peer looks like; do not write it to a field nobody reads.
  • The pre-confirmation byte budget (4 MB) cannot trip a legitimate 5–6 MB photo: the camera session is not constructed until the phone’s own user confirms, and the host sends no captureStill before acceptance. Measured baseline traffic pre-acceptance is 0 bytes.

What the authenticated session does — and does not — buy downstream

Added 2026-08-25, from Phase 2

Because preview datagrams ride inside this mutual-TLS session with peer_authentication_required, and there is no other path into the reassembler’s ingest(), an off-path attacker cannot inject a preview fragment at all. Every “attacker” in the preview findings means the authenticated paired peer, compromised or buggy — defence in depth, not a network attack.

What it does not buy: the receiver still cannot tell an honest sender restart from a spoofed one, because at admission the two are byte-identical. Four separate inference mechanisms were built and each was exploitable; the answer was to remove peer-driven state rewind entirely and let the session layer say when a stream is new. Full worked account: aposemati-peer-restart-inference-boundary.

The preview path also inherits this note’s pre-confirmation discipline: no pixels leave the phone before the user confirms, and the gate is mutation-checked (deleting peerIsConfirmed && fails three tests, including a 9,331-state exhaustive walk).