Files
OpenFlux/PROTOCOL_NEGOTIATION.md
f8f34767a5 One protocol for every client: Session with classic, codec and context fallback (#126)
* utils: status lines reach the app log; count secrets as Kotlin does

Infof now also goes to the log sink, so the Android log screen and the
iOS ring buffer show the status lines the CLI prints. Throttled rate-limits
warnings that would repeat on every packet. SecretChars counts a secret in
UTF-16 units: the core counted bytes, accepting a 10-letter Cyrillic secret
that Desktop and Android reject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* transport: one KDF context rule, and fall back when the peer's differs

KDFContexts replaces the copies of the context rule that had drifted apart
(CLI, Android bridge, Desktop, iOS). A classic cupsonline client given the
rooms as --url now derives the placeholder like the exit that created them;
it used the room list, and no packet ever decrypted.

The context is a public salt, so a peer may try several: alternates are
what other or older builds derive. A packet that fails under the current
keys is tried under them (derived lazily); the exit answers under the
context the client used, a client that hears nothing cycles through them.
A mismatch that dropped everything in silence now heals itself, and a key
that really differs is reported in the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* transport: classic codec that accepts both framings and falls back

batched against legacy dropped every packet in silence. CodecTransport
decodes both (they differ in the first byte), sends what the peer sends
(batched once it has seen a batch frame, including the iOS fork's empty
probe), and the client tries the other framing while the peer is silent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* session: classic compatibility on the same carrier, diagnostics

The classic and Session layerings differ in the first byte of a carrier
frame, so frameDemux splits one carrier between a Session and a classic
pipeline sharing its keys. SetClassic turns it on: a single-carrier client
falls back to classic while the exit does not answer the handshake and
switches once it does; a classic-configured exit also serves classic
clients while no Session client is active. Strict Sessions log classic
frames with the fix instead of dropping them silently.

Logs: carrier start failures, takeover by another client, mode switches,
and a handshake diagnosis (nothing arrived / key differs / exit is classic).
The carrier is stopped once instead of twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* manager: match the peer's carriers when names differ

The apps name carriers after their type (mailru, mailru-2) whatever the
exit's .conf or panel called them, so a cookie offer or AuthRequired for an
unknown name went nowhere. Cookie messages carry the document URL now, and
the name is matched by it, then by type when this side has one carrier of
it; what cannot be matched is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cli: classic setups with a key run as a Session with classic fallback

--transport=X with a key now builds a Session: a client speaks classic
until the exit answers the handshake, an exit also serves classic clients.
A single-carrier --transports/.conf client falls back the same way; only
--negotiate is strict. Without a key the classic path stays, with the
adaptive codec. The context comes from transport.KDFContexts, alternates
included, and a cupsonline exit accepts its room list as one.

transportFactory takes the role: a Session client's cupsonline carrier was
built as an exit and, with no or dead rooms, created rooms of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mobile: same Session, context and codec fallback as the CLI

Classic profiles with a key build a Session with classic fallback, as the
CLI does, so a classic profile works against every node; classic direct
works too. Single-carrier Session profiles fall back to classic; the node
wizard's check stays strict. The context rule is transport.KDFContexts
(the link's context first, the derived one as an alternate).
SetInitialCookies applies cookies got before the start to Yandex carriers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* share: read links the way every client does; --parse-link

Decode accepts what copying and other encoders do to a link: whitespace and
line breaks, base64 padding, the standard alphabet, and says what is wrong
otherwise. Secrets are counted in characters as Kotlin counts them.
--parse-link prints the core's reading of a link as JSON, for apps and
debugging; --share also prints the bare link on its own line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* boards: stop sending client pings; docs: reconnect on dead sockets

boards sent engine.io pings from the client, which an EIO=4 server treats
as an invalid heartbeat and closes the socket: the board dropped every 20
seconds. The server pings and we answer, as before.

yandex and mailru left a socket whose keepalive failed open, so a reader
on a half-open connection could wait forever; they close it now, and bound
each write. Drops and failures to open the document are logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* transport: tests for mixed builds (codecs, contexts, classic vs Session)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ios: the iOS C library on package mobile (mobile/ios)

The iOS app carried its own copy of the core (the saharev1/OpenFlux fork):
no Session, its own link importer, its own control channel, so it reached
no node the wizard or Desktop set up. The root export_ios*.go here were an
even older copy of the same.

mobile/ios is the app's C API (every call the app makes, same names and
signatures) implemented on package mobile, the Android library: an app
that links this core as a submodule and builds it with build_ios.sh gets
the Session with classic fallback, the one context rule with its
alternates, the codec fallback and the core's link reading. New calls:
OpenFluxShareDecode returns a Session profile ready for
OpenFluxStartSession / OpenFluxStartSessionPacketTunnel; OpenFluxMode,
OpenFluxActiveTransport, OpenFluxSetCodec, OpenFluxSetDebugLevel and the
captcha calls the app had no equivalent for.

The Network Extension runs the carriers on a phone profile
(mobile.SetLowMemory, Volga's SlimVolgaConfig) and keeps the fork's local
DNS-over-TLS, ICMP refusal for UDP and GeoSite split tunneling.

PROTOCOL_NEGOTIATION.md gave the Session's layering in the classic order
(AES inside the batch frame); the Session has always encrypted the batch
frame. Fixed, with the classic layering, codec and context fallback and
the context rule documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* session: say how to fix a classic peer at a Session-only exit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* crypto: search the context per pipeline; a gone Session client yields in 15s

A client's Session hellos and its classic fallback shared one context
search, so while a classic exit ignored the hellos the search moved the
classic pipeline off the context that exit uses, and the first classic
packets went out under the wrong keys. Keys are still derived once per
carrier (keyStore); each pipeline searches on its own (keyRing), and a
client's pipelines pass on what they learn.

An exit configured classic yielded to a classic client only linkTimeout
(30 s) after its Session client was last heard; a live one is heard every
keepaliveInterval, so it yields after 15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build_ios.sh: work from any directory

The output directory was made and listed relative to the caller's
directory while the library was written under the checkout, so running
core/build_ios.sh from an app that links the core as a submodule failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: p1neappleXpress <a@a.a>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:38:10 +03:00

11 KiB

Authenticated negotiation v1

Not a new cipher suite or a production security certification: an authenticated session (the Session) on top of the existing encrypted packet stack. Both peers need the same encryption secret and context (see "Encryption context"). A Session runs whenever a peer is configured with a key: --negotiate, --transports, .conf transports, the apps' Session profiles, and also classic setups (--transport=X with a key, the apps' classic profiles), which keep classic compatibility (see "Classic compatibility").

Layering on a carrier

There are two layerings, and implementations must match the one they speak byte for byte:

Session  IPv4 -> envelope -> batch-v2 frame of envelopes -> AES-GCM record -> carrier
classic  IPv4 -> AES-GCM record -> codec frame (batch-v2 or legacy) -> carrier

In the Session the batch frame (zstd when it helps) is encrypted as a whole, so every carrier frame starts with the record header OFX (0x4F 0x46 0x58), version 1 and the direction byte. In the classic layering each IPv4 packet is encrypted on its own and the records are framed by the codec, so a carrier frame starts with 0x02 (batch-v2) or 0x00 / 0x1F (legacy per-packet, raw / LZ4). Receive reverses the order. Because the first byte differs, one carrier can carry both, and a receiver can tell which layering its peer runs.

(An earlier version of this document gave the Session's order as envelope -> AES-GCM -> batch-v2, which is the classic order; the code has always encrypted the batch frame. A Session built from that description does not interoperate.)

Envelope

The envelope is inside AEAD, including its type, role, identities, capabilities and sequence. Integers are unsigned big-endian; unknown versions/types/reserved bits fail closed.

Bytes Meaning
0..3 OFN followed by version byte 1
4 1 = hello, 2 = IPv4 data, 3 = control
5 sender role: 0 client, 1 exit; peer must have opposite role
6..37 sender's random 256-bit process-session challenge
38..69 recipient's challenge; zero only for initial hello
70..77 (hello) capabilities uint32, max IPv4 packet uint16, ready byte (0/1), reserved zero byte
70..77 (data) sequence uint64, starting at 1
70..77 (control) subtype byte, flags byte (zero), payload length uint16, reserved zero
78.. (data) one complete IPv4 packet (possibly an IP fragment)
78.. (control) payload (JSON for the subtypes below)

Hello length is exactly 78 bytes. Capability bits: 0 IPv4, 1 TCP, 2 UDP, 4 ICMP errors. Bit 3 belonged to the retired wire-v3 prototype and is rejected. IPv4+TCP are mandatory. Allowed packet limits: 1280..65000, leaving space for this envelope and existing AES overhead within a 65535-byte batch record.

State and acceptance

Each instance generates a cryptographically random challenge. An authenticated hello without an echo can elicit a response but does not establish a session. Readiness requires a valid peer hello echoing the current local challenge. The effective policy is the capability intersection and smaller packet limit. The established peer cannot change its policy through later hellos. Retries with an unconfirmed ready bit receive a fresh confirmation, including after the other side has completed Start. The client's handshake deadline is 20 seconds (a client with classic compatibility starts sending classic after 3 and keeps offering the handshake); the exit never initiates and waits for a client indefinitely.

A hello from a different sender while established usually means the peer restarted, but may be old traffic replayed from a carrier (anyone with access to a document sees the ciphertext). The established session is left untouched: an initial hello from the new sender is answered with a challenge minted for it alone (one candidate at a time, at most one new candidate per second, valid for 20 seconds). Only a hello echoing that fresh challenge, which replayed traffic cannot contain, replaces the peer. The replacement runs under the fresh challenge with a new sequence and replay window, so the old session's data no longer matches. The exit therefore serves one active client at a time; two clients sharing a secret take the session from each other.

A client whose peer answers keepalives (below) and has been silent on every carrier for the link timeout drops the session and handshakes again under a new challenge, as a restarted process would: a restarted exit knows nothing of the old session and never speaks first.

Data must name both current challenges, use a negotiated protocol, fit the effective packet limit, and pass the 4096-entry sequence replay window. Duplicates, zero sequence numbers and packets older than the window are discarded; bounded reordering is accepted. There is no delivery acknowledgment or retransmission. This avoids adding a reliability layer underneath UDP/QUIC.

Carriers

Every configured carrier is started; one whose Start fails (e.g. a captcha during authorization) is retried with exponential backoff (1s to 30s) and joins when it comes up. Each carrier is started once, through its batching and encryption wrappers.

A carrier being attached to its document says nothing about the peer's side of it, so each side records when the peer was last heard on each carrier (any authenticated envelope). Carriers quiet for 10 seconds get a LinkPing, answered with a LinkPong on the carrier it arrived on. Once the peer has answered a ping, a carrier is live only if the peer was heard on it within 30 seconds; a peer that never answers predates keepalive, and liveness stays the carrier's own connection state. Data and control use the highest-priority live carrier; flows are hashed across carriers only when they share that priority.

Control messages

Subtype Direction Payload
0x01 CookiesRequest client -> exit {"transport"}; the exit answers with its jar
0x02 CookiesResponse exit -> client {"transport","jar"}
0x03 CookiesOffer both {"transport","jar"}, applied and persisted by the receiver
0x04 AuthRequired exit -> client {"transport","url","reason"} (smartcaptcha or login)
0x10..0x13 client <-> exit transport start, stop, status, list
0x20 LinkPing, 0x21 LinkPong both none

An empty transport (older peers) means the highest-priority transport that carries cookies. Cookie messages and AuthRequired may carry "doc", the document URL of that transport on the sender's side: the two sides do not always name carriers alike, so a receiver that has no carrier of that name matches by doc, then by the type the name starts with when it has one carrier of that type. Unknown subtypes are passed to the application and otherwise ignored.

Checks on the exit

When a transport on the exit hits SmartCaptcha or a login wall, the exit sends AuthRequired (at most every 20 seconds per transport) over any live carrier, typically a direct one while the document carrier is the one stuck. The check has to be passed from the exit's address. The client relays it to the app over IPC as a CookiesRequest with remote: true and proxy: a loopback HTTP proxy (CONNECT and plain requests) whose connections leave through the tunnel and the exit. The app points its browser at that proxy, passes the check and answers with a CookiesOffer carrying remote: true, which the client forwards to the exit as CookiesOffer for that transport.

The proxy runs its own small TCP stack on the tunnel. The exit answers only one client address, so this stack shares it and uses local TCP ports 12000..12999, below gVisor's ephemeral range and common OS ones; replies to those ports go to it, everything else to the regular client path. The proxy listens on loopback without authentication while it runs, like the SOCKS5 inbound.

Classic compatibility

A peer configured classic with a key runs a Session with the classic layering next to it on its one carrier:

  • A client falls back: it offers the handshake and, until the exit answers (it waits 3 seconds at start), sends IPv4 in the classic layering, so an exit that predates Session or runs without it still works. It keeps offering the handshake (every 2 seconds after the first 20, every 10 once the exit answered classic) and switches to the Session when answered. A single-carrier Session client (a Session profile or --transports with one carrier) falls back the same way; --negotiate does not.
  • An exit configured classic serves classic clients as well as Session ones, as long as no Session client was heard within the link timeout: classic frames that arrive while a Session client is active are dropped (a replayed capture cannot take the replies away from it).
  • An exit configured as a Session (--negotiate, --transports, .conf transports, the node wizard, the apps' Session exits) serves Session clients only; classic frames are dropped and logged with the fix.

Codec (classic layering)

Both framings are decoded whichever the peer uses. A peer sends batch-v2 once it has received a batch frame (an empty one, 02 00, is a capability probe), legacy while it has received only legacy frames, and its preferred framing (--codec) before it has heard anything. The side that speaks first (the client) switches to the other framing after 2 seconds without an answer, then every second, so a peer that decodes one framing only is still reached. A lone 0x00 is a carrier keepalive (Volga), not a frame.

Encryption context

The scrypt salt is SHA-256("OpenFlux encrypted transport v1\0" + context). Every peer picks the context with the same rule (transport.KDFContexts):

  1. an explicit context (--session-context, the context an openflux:// link carries);
  2. --url, unless it is a cupsonline room list;
  3. the URL of the highest-priority carrier that names the channel (not cupsonline: its exit creates the room list at start; not direct: host:port differs between the sides; not oneme);
  4. http://#.

Builds have derived it differently (the classic cupsonline client used the room list; panels and an older fork used the transport name), so each peer also knows the alternates other builds derive for its setup. A record that does not open under the current keys is tried under them; the exit answers under the context the client used, and a client that hears nothing moves to the next candidate after 4 seconds, then every 2. The context is a public salt: accepting several weakens nothing, each one still requires the secret.

Limits and compatibility

Shared-secret holders are trusted peers. The existing static key derivation is unchanged: no forward secrecy, automatic key rotation or protection after secret compromise is claimed. AEAD authenticates packets; capability assertions still describe configured software functionality, not a live Internet reachability test.

The classic layering has no challenge binding, sequence window or capability negotiation, only AES-GCM with a bounded nonce replay cache. A client that falls back to it can be kept there by whoever drops the exit's handshake answers; --negotiate rules that out on both sides.

ICMP-error support refers to errors returned from a raw exit to the client; it does not promise bidirectional arbitrary ICMP, IPv6, echo or redirects. There is no active path-MTU probing. The separate raw-exit ICMP/NAT implementation reports kernel route MTU errors and restores Internet ICMP quotes for live flows.