Skip to content

Transport v3, phase 3a: the iPad's v3 client core in Swift - #250

Merged
whoisaldo merged 8 commits into
feat/transport-v3from
feat/transport-v3-ios-core
Nov 8, 2026
Merged

whoisaldo merged 8 commits into
feat/transport-v3from
feat/transport-v3-ios-core

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Phase 3a of transport v3: the iPad's side of the reconnect handshake and sealed records, as a Swift package on CryptoKit (ios/EternalTransportV3). There's no UI and no connection wiring. The app target doesn't link the package; only EternalMonitorTests does, for the Keychain round trip. v2 behaviour is unchanged.

What's in the package

  • Handshake. Noise_NNpsk0_25519_ChaChaPoly_SHA256, as proto/src/v3/handshake.rs runs it: the session prologue, K_dev as the PSK in position 0, CryptoKit's X25519, ChaCha20-Poly1305 and HMAC-SHA-256, and Noise's own HKDF. A message 2 that fails framing or authentication changes nothing. Once one authenticates, the attempt is over, even if its payload then fails to parse.
  • Records. The 20-byte header as AAD, the Noise nonce of the counter, separate keys per direction, and the 4,096-wide replay window, marked only after the AEAD succeeds. A record that would need counter 2^32 is never sealed or opened.
  • Reconnect state machine (Client), ported branch for branch from proto/src/v3/client.rs:
    • message 1 resent unchanged every 250 ms for 2 s;
    • confirms freshly sealed every 100 ms for 2 s;
    • an attempt whose confirms ran out is kept receive-only for 11 s (at most 6);
    • the intent rules, a one-time retry after Stale or TooNew, and PairAgain, which stops new attempts under that key without retiring it;
    • SessionBusy (0x53): it ends a confirming attempt and drops an abandoned one, and leaves a live session as it is;
    • the old session drains for up to 1 s;
    • with no authenticated record within the host's liveness timeout, the session closes and an attempt starts if the key is still wanted;
    • a reconnect before the age and counter limits.
  • What counts as live. Only sealed records count. Only inner types below 0x40 are application data, as in the reference client: an authenticated record with an unknown type of 0x40 or above is never delivered and never taken as proof of a commit, and on a live session it still counts for liveness.
  • Key rule (KeyRing). A key is deleted only on an authenticated tombstone, a refused pairing, or Forget. A tombstoned key is never used again, even if saving fails. Connect order is pending, then active, then unlisted, newest first. A key the host lists as pending gets an immediate reconnect, and after three silent cycles the client suggests pairing again. The ring holds at most 16 keys; a pairing past that gets .full, with nothing evicted, and leads to Forget.
  • Key store. KeychainKeyStore keeps one item per host_id: after first unlock, this device only, not synchronized. Each save is one atomic write, and a damaged item is reported, never overwritten. The in-memory store the tests use lives in the test target.
  • Key memory. Keys live in storage wiped with memset_s on release, and buffers that held secrets are wiped after use.

Interop with the Rust reference

  • proto/testdata/v3/client_golden.txt (new, v3::golden_client) holds the other message 2 answers, the largest Accept with records sealed under it, message 2s that authenticate but don't parse, name truncation, slot status encodings, 29 payloads every parser must reject, and a 3,000-counter replay script.
  • proto/testdata/v3/parser_differential.txt (new, v3::golden_differential): a seeded generator mutates valid encodings into 4,000 inputs for each of 8 parsers. The file pins the inputs' digest, every Rust accept/reject verdict and the accepted outputs' digest, and Swift reproduces all of them. The session parser's output also says whether a non-session type is application data, so the application-type rule is pinned too.
  • The Swift tests reproduce session_golden.txt, noise_vectors.txt (cacophony NNpsk0, RFC 8439, RFC 7748), both replay scripts and the two new files byte for byte. Each test checks that it read every entry of its file.
  • Live. proto/examples/v3_interop_host.rs runs the reference session table on a loopback UDP socket, and scripts/v3_swift_interop.sh runs the package tests against it, on macOS or (--simulator <udid>) on an iOS simulator. One run covers:
    • a connect with the slot status, then 50 sealed records each way;
    • a reconnect that supersedes the live session without a BYE, while the old one drains;
    • Stale and TooNew answers and their retries;
    • a refusal carrying the key's tombstone;
    • a sealed BYE;
    • the host's own counts, read back over a sealed record.

Checks

  • On the current feat/transport-v3 (with Transport v3, phase 2b-ii: QR pairing and the busy answer #243 and Transport v3 pairing: per-source limits only, and unknown inner types never prove a commit #252 merged in): cargo fmt --check, cargo clippy -p eternal-wire --all-targets -D warnings (with and without test-hooks), cargo test -p eternal-wire: all pass.
  • scripts/v3_swift_interop.sh on macOS (Xcode 16.4): 76 of 76 tests, live interop included.
  • scripts/v3_swift_interop.sh --simulator on iOS 18.6 (Xcode 16.4): 76 of 76.
  • scripts/test_ios.sh -only-testing:EternalMonitorTests on iOS 18.6: 397 of 397, including the three Keychain tests.
  • Three intent rules (promotion only for the current intent, PairAgain keeping abandoned attempts, the fallback to the live key only while a session is live) each catch their mutation.
  • CI runs the interop script in the macOS Rust job. The Keychain tests run with the iOS unit tests.

- client_golden.txt: the TooNew and PairAgain answers, the largest Accept
  with records under it, message 2s that authenticate but don't parse (the
  attempt is then over), name cuts at character boundaries, slot status
  encodings, payloads every parser rejects, and a long replay script.
- parser_differential.txt: a seeded generator mutates valid encodings into
  4,000 inputs per parser; the file pins the inputs' digest, every
  accept/reject verdict and the accepted outputs' digest, for another
  implementation to match.
- v3_interop_host example: the reference session table on a loopback UDP
  socket, with one key that commits and one that is always refused.
A Swift package, ios/EternalTransportV3, on CryptoKit. Nothing in the app
uses it yet; only the unit tests link it.

- Noise_NNpsk0_25519_ChaChaPoly_SHA256 as the Rust side runs it, with the
  session prologue and K_dev as the PSK. A failed message 2 read changes
  nothing; once one authenticates, the attempt is over.
- Sealed records with per-direction counters, and the 4,096-wide replay
  window, marked only after authentication.
- The reconnect state machine, ported branch for branch from the Rust
  reference client: retries, confirms, abandoned attempts kept
  receive-only, the drain, the liveness rule, age and counter limits.
  Only sealed v2 messages count as application records.
- The key rule with slot status, and a Keychain store by host_id (one
  item per host, after first unlock, this device only) with an in-memory
  store for tests. Keys live in memory that is wiped on release.
- Tests: every Rust golden transcript byte for byte, the parser
  differential against the Rust verdicts, the state machine row by row,
  fuzz-like mutation of every datagram in each client state, and a live
  run against the Rust host over loopback (scripts/v3_swift_interop.sh,
  on macOS or an iOS simulator, and in CI).
@whoisaldo whoisaldo added the 0.4 Part of the 0.4 release (transport v3) label Nov 8, 2026
…eference

Only inner types below 0x40 are application data; an unknown type at 0x40
or above never promotes an attempt and is never delivered, and on a live
session still counts for liveness. The test now covers confirming, live,
draining and abandoned attempts.
- The interop script builds the tests before starting the host, so a slow
  build can't outlast the host's idle limit (now 120 s).
- Tests for three intent rules: an abandoned attempt is promoted only for
  the key the app wants; PairAgain keeps abandoned attempts; a failed
  attempt falls back to the live key only while a session is live.
- A full key ring refuses a new key with `.full` instead of a bare false;
  nothing is evicted.
- The in-memory key store moves to the tests, and the stored-form codec is
  internal.
…base

- SessionMessage.busy (0x53, empty body) and ClientOutput.busy, as the
  reference client handles them: a busy answer ends a confirming attempt,
  and removes an abandoned one; on a live session it changes nothing.
- parser_differential.txt regenerated: the session parser now returns
  Busy for 0x53. client_golden.txt adds a sealed Busy record.
- KeyRing.add no longer discards its outcome; every caller checks it.
…ifferential

The session parser's canonical output now tells a non-session inner type
that is application data ([2], below 0x40) from a protocol type the client
drops ([3]), using wire::is_application. Swift matches every case.
@whoisaldo
whoisaldo marked this pull request as ready for review November 8, 2026 22:36
@whoisaldo
whoisaldo merged commit dd6c5ac into feat/transport-v3 Nov 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.4 Part of the 0.4 release (transport v3)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants