Together · text-message transport · technical page · July 12, 2026 · for people who build and break software

Every messaging operator is untrusted.

Text-message transport carries the sharing rail's envelopes (spec here) through SMS/iMessage/RCS as base64url frames, sealed e2e before Messages sees them. 25 tests green (test_pna_text_courier.py) at its July 12, 2026 build, the full pre-existing Personal Neutral Archive (PNA) suite still green beneath it, nothing else touched — current whole-suite counts live on the build log. Existing code and specification filenames retain the earlier courier name until the software is migrated. The plain-words version is for everyone else.

The verdict everything follows from

SHARING-SPEC-v0.1's I7 already says a transport-sealed envelope must refuse an untrusted transport. The new decision for text-message transport is who counts as untrusted. The rule is uniform:

ChannelWhat the operator seesTrust
SMS / MMSCarrier holds full plaintext, routine legal disclosure, SS7-era transportuntrusted
iMessageE2E in transit; iCloud/device backups have historically left Apple holding readable copies depending on a setting we cannot see from the envelope's sideuntrusted
RCSCross-platform E2E arriving unevenly; carrier still routesuntrusted

There is no per-channel matrix here, because a matrix rots the moment a platform changes its backup design. Every messaging channel gets the same verdict, and the code forces it: configure_text_courier() hardcodes trusted: false in Together/courier.yaml, and _require_text_courier() re-asserts the refusal even if that file is hand-edited to say otherwise. Consequence: every envelope sent by text-message transport must already carry sealing: "e2e". Nothing less is ever handed to Messages.

What the module does, and where it stops

pna_text_courier.py imports pna_share.py wholesale and reuses its reducer, its compose functions, its HMAC verification, and, critically, its entire receive-side gate stack (pairing, authenticity, authority, idempotency, hostile-input bounds) unmodified. This module adds exactly three things shared-folder transport doesn't need:

It does not talk to Messages, iMessage, or any carrier API. The bridge is a human: compose → frame → copy into Messages → send, and the reverse on receipt. That is a design decision (spec §7 — "the person is the last hop"). Any later automation of that hop will be said out loud here.

The wire frame

KSP1 <n>/<total> <env8> <payload> # KSP1 protocol+version marker — a bump changes the token; old tokens keep their meaning # n/total chunk index — base64url(json(envelope)), chunked at ~1200 chars/line # env8 first 8 chars of the env-id — reassembly key, human-visible correlation only # payload base64url chunk (GSM-7-safe alphabet)

A typical PROPOSE is one iMessage message or ~1 KB of chunked SMS. frame_for_text() refuses at the first line of code if env["sealing"] != "e2e" — there is no path from a transport-sealed envelope into a text frame.

>>> frame_for_text(env) TextCourierError: refusing to frame a non-e2e envelope for text-message transport: every messaging operator is untrusted uniformly (spec §2) — seal with `age` first, or use shared-folder transport for a `transport`-rung message.

Reassembly under a hostile channel

ReassemblyBuffer keys chunks by index. Arrival order is ignored, so loss, duplication, and reordering all resolve correctly without special-casing:

This extends pna_share's own hostile-input discipline (I10) one layer earlier, to the channel that hands bytes to the parser in the first place. None of that handling is duplicated here.

Pairing over text, with nothing secret on the wire

The card exchanged by text (KSPCARD {...}) carries only a contact id and the existing age_recipient public key — the same X25519 recipient string pna_share's optional e2e sealing already uses. Public keys are public; the carrier may read the card freely.

carmen: KSPCARD {"age_recipient":"age1carmen...","contact_id":"carmen"} marta: KSPCARD {"age_recipient":"age1marta...","contact_id":"marta"} # both phones independently compute: >>> compute_fingerprint("age1carmen...", "age1marta...") ["cedar", "kestrel", "opal", "tide", "walnut", "birch"] # order-independent — sorted before hashing

The two people speak the six words to each other — by voice call, video, or in person. complete_text_pairing() takes an explicit spoken_fingerprint_matched boolean and raises if it isn't True. No roster card is saved in that case. There is no code path to a paired contact that skips the human attestation.

If a carrier substitutes a public key toward one side, the two participants receive different six-word fingerprints because each fingerprint derives from the keys that side received. The spoken comparison detects that mismatch.

The pairwise HMAC secret pna_share needs for envelope authenticity is then derived (sha256("pair-secret-v1:" + sorted(key_a, key_b))) from the two public keys, computed identically on both sides. No elliptic-curve math is implemented here (stdlib has none); this is the v0.1 star-topology-appropriate authenticity root, arrived at over text with a spoken check standing in for meeting in person. It carries none of the Ed25519 non-repudiation the spec schedules for v0.2 introductions.

The spoken rung

A neighbor with no PNA still gets an invitation. It goes by ordinary text, with no envelope and no seal. record_spoken() writes Together/events/<evt>/spoken.yaml and nothing else. It never touches exchange/, and it never calls the reducer. So it can never produce an agreed state, and it never acquires CONFIRM/CANCEL authority. Recording a spoken note against a real sealed thread's event id changes nothing about that thread's actual, reducer-derived state, verified directly (test B6).

Enforced versus recorded

ClaimStandingWhere
Every text-transport config is trusted: false, even hand-editedENFORCED_require_text_courier — test B1
Framing refuses any envelope not sealed e2eENFORCEDframe_for_text — test B1
Reassembly survives loss/duplication/reordering/corruption/oversizeENFORCEDReassemblyBuffer — test B2 (9 cases)
Pairing refuses to save a card without explicit spoken-fingerprint attestationENFORCEDcomplete_text_pairing — test B3/B4
A substituted public key produces mismatched fingerprints on the two sidesENFORCEDtest B4 (deterministic hash property)
The postcard line has no code path into the envelope or derived stateENFORCEDseparate data path — test B5
spoken records never touch exchange/ or the reducerENFORCEDtest B6
The same envelope via two different transports is one admissionENFORCEDpna_share's I5, transport-agnostic — test B7
A live age round trip through this moduleRECORDEDno age binary in this build environment; the module enforces the sealing discipline; the sealing itself belongs to pna_share's existing (also untested-live) e2e path, which is the dependency
A real Messages/iMessage bridge (share-sheet automation, Shortcuts)FUTUREspec §7 names the human-hop design on purpose; OS integration is unbuilt
ES fingerprint wordlistFUTURErides the terms-pack seam — spec §12 sign-off #3

Run it yourself

# from KISOP-SYSTEM/pna/cli — stdlib only, Python 3.9+, no installs, no `age` required python3 test_pna_text_courier.py # 25 tests — B1..B7 by name python3 test_pna_share.py # 38 — unaffected; this module imports pna_share and leaves it alone python3 test_pna_share_adversarial.py # 88 — unaffected, same reason

Every archive in the test suite is a throwaway under tempfile; ~/PNA was never touched by this build.

Attack here

Good-faith security research is welcome through the main site's Write to us → security door — a safe harbor written into our Terms, §15.

Where this stands

Built and tested July 12, 2026, on synthetic data only. No real archive, no real phone number, no real Messages thread was touched. 25 new tests green; the full pre-existing 619-test PNA suite (pna/pna_mcp/pna_mcp_adversarial/pna_share/pna_share_adversarial/pna_copurchase/pna_copurchase_circles/pna_replenish) is unchanged and still green. This module only imports the code it builds on, and never edits it.

Not yet: a real age encrypt/decrypt round trip (no binary in this environment); the OS-level Messages bridge (share-sheet, Shortcuts automation); an ES fingerprint wordlist; independent adversarial review (the pattern this house uses for the gate and shared-folder transport — Opus builder + independent Sonnet adversary — has not yet run against this module).

Open design calls (Carmen's, recorded in COURIER-SMS-SPEC-v0.1.md §12; work continues while they wait): the uniform-distrust verdict itself · the postcard line's default-off behavior and its compose-screen wording · six words vs. four emoji for the fingerprint, EN/ES · the spoken rung's name · sequencing against v0.2 Ed25519 introductions.