# Connection lifecycle: handshake, sessions, rekey
This document describes how a `simple_comms` connection gets secured and how
traffic flows on it. For the exact bytes on the wire, see
[`PROTOCOL.md`](./PROTOCOL.md).
## Why `Noise_NK`
`simple_comms` secures every connection with the [Noise Protocol
Framework](https://noiseprotocol.org/), specifically the `NK` handshake
pattern, via the [`snow`](https://docs.rs/snow) crate:
```
Noise_NK(rs):
<- s
...
-> e, es
<- e, ee
```
- **`N`** -- the *initiator* has no static key of its own. It doesn't need
one: `simple_comms` doesn't require client identity/authentication at the
transport layer (an application built on top of it is free to add its own,
e.g. inside the first `Data` message on a session).
- **`K`** -- the *responder*'s static key is already **k**nown to the
initiator, out-of-band, before the connection starts (config, DNS, a
pinned constant, ...). This is what authenticates the responder -- an
initiator that doesn't already have the right public key can't complete a
handshake with an impostor.
This gives every connection forward secrecy (each handshake generates fresh
ephemeral keys) and responder authentication, in a single round trip, using
a well-specified and independently-audited protocol rather than a
hand-rolled key exchange.
The concrete parameter string used is `Noise_NK_25519_AESGCM_SHA256`:
Curve25519 for the DH, AES-256-GCM for the AEAD, SHA-256 for the hash.
## `Hello` / `HelloAck`: securing the connection
Exactly one Noise handshake happens per physical connection (not per
logical session -- see below). It maps directly onto the two Noise `NK`
messages:
```
initiator responder
| |
| Hello (ConnectionParams::ENCRYPTED |
| unset -- wrapped in the fallback key |
| instead, see PROTOCOL.md; declares |
| the connection's ConnectionParams |
| baseline in the header's `reserved` |
| byte, see below) |
| -- Noise message 1: -> e, es -------->|
| |
| HelloAck (same fallback wrapping) |
|<-- Noise message 2: <- e, ee ----------|
| |
| [both sides: into_transport_mode()] |
| |
| connection is now secured |
```
Note that "`ConnectionParams::ENCRYPTED` unset" doesn't mean these two
messages go out as raw plaintext: `ProtocolMessage::to_bytes` still wraps
every message without `ConnectionParams::ENCRYPTED` in a fallback,
single-message AES-GCM key (see
[`PROTOCOL.md`](./PROTOCOL.md#the-encryption_key-field-recordmeta-overlay-or-a-fallback-key))
so nothing ever leaves the wire fully readable -- it's just that `Hello`
can't use the *connection's* Noise cipher, since it's the very message that
establishes it.
`Hello`'s own `flags` byte therefore always stays `ConnectionParams::NONE`
-- it can't simultaneously mean "how this message itself is wrapped" and
"the baseline this connection should use going forward" without a chicken-
and-egg problem. Instead, the initiator packs the intended baseline (see
[**Connection params, `INSECURE`, and `SIDEGRADE`**](#connection-params-insecure-and-sidegrade)
below) into the header's free `reserved` byte, purely as data for the
responder to read -- not as an instruction for encoding `Hello` itself.
Implementation: [`handshake::perform_handshake_initiator`](../src/protocol/handshake.rs)
/ [`handshake::perform_handshake_responder`](../src/protocol/handshake.rs).
Both sides end up with a `snow::TransportState` -- a single object that
handles encryption in both directions, tracking its own send/receive nonce
counters internally, so there's no nonce material to transmit on the wire
at all (contrast this with the discarded earlier design, where a
hand-rolled `nonce_salt` was meant to be derived via HKDF and mixed into an
explicit per-record nonce; using `snow`'s transport mode makes that
unnecessary).
Both sides also derive a **connection id** (`conn_id`) as the first 16 bytes
of the handshake transcript hash (`get_handshake_hash()`). Since both sides
compute it from the same transcript, it's identical on both ends with no
extra exchange, and serves as the default
[`RecordMeta::session_id`](./PROTOCOL.md#the-encryption_key-field-recordmeta-overlay-or-a-fallback-key)
for connections that don't use logical session multiplexing.
The result of a successful handshake is a
[`ConnectionCtx`](../src/protocol/message.rs) `{ noise, conn_id, next_seq,
params, insecure }`. `noise`/`conn_id`/`next_seq` are what every subsequent
`to_bytes`/`from_bytes` call needs when `ConnectionParams::ENCRYPTED` is
set; `params`/`insecure` are covered next.
### Setting up a responder identity
A responder needs a static keypair before it can accept connections:
```rust
let identity = NoiseIdentity::generate()?;
// Distribute identity.public_key() to initiators out-of-band --
// e.g. bake it into client config, publish it via DNS, etc.
```
Persist `identity.private_key()` across restarts (via
`NoiseIdentity::from_keypair`) rather than regenerating it each time --
every initiator that already has the old public key pinned won't be able to
complete a handshake against a new one.
## Connection params, `INSECURE`, and `SIDEGRADE`
Every connection is established with a **baseline** [`ConnectionParams`](../src/protocol/flags.rs)
value -- the initiator declares it on `Hello` (in the `reserved` byte, as
described above); the responder adopts it as-is. Both sides end up with
identical `ConnectionCtx::params`, with no separate exchange needed.
```rust
let ctx = establish_connection_initiator(
&mut stream,
&remote_static_pubkey,
ConnectionParams::ENCRYPTED | ConnectionParams::INSECURE,
).await?;
```
`ConnectionParams::INSECURE` is one of the bits that can be part of that
baseline: it declares whether this connection permits its params to be
renegotiated mid-connection via a `SIDEGRADE` response. `ConnectionCtx::insecure`
caches `params.contains(INSECURE)` for cheap access. This is a
connection-wide property -- both peers read the *same* declaration off
`Hello`, so `client_ctx.insecure` and `server_ctx.insecure` always agree.
### `SIDEGRADE`: renegotiating params mid-connection
A `SIDEGRADE` response (`status = ProtocolStatus::SIDEGRADE`, requested
params packed into the `reserved` byte -- see
[`send_receive::send_sidegrade`](../src/network/send_receive.rs)) asks the
sender of a message to resend it with different `ConnectionParams`. Two
paths trigger it:
**Transparent** -- [`receive_message`](../src/network/send_receive.rs)
compares an incoming message's params against `conn.params` (the
connection's established baseline). If they don't match:
- The mismatch is always logged, regardless of what happens next.
- If `conn.insecure` is `true`: `receive_message` automatically replies
with `SIDEGRADE` requesting the baseline, reads one retry, and returns
it -- transparent to the caller, who just sees the successfully
negotiated message. The sender's own [`send_message`](../src/network/send_receive.rs)
call (still waiting for a response to its original send) receives the
`SIDEGRADE` status and -- since it independently reads the *same*
`conn.insecure` off its own `ConnectionCtx` -- automatically resends with
the requested params. No consumer code is needed to wire this up; it
works as long as both sides pass a `conn` with `insecure` declared.
- If `conn.insecure` is `false`: no forced renegotiation. The message is
delivered as received.
**Manual** -- [`receive_message_with_required_params`](../src/network/send_receive.rs)
lets a caller explicitly demand different params for one exchange,
*regardless* of `conn.insecure` or the current baseline (an explicit ask
always attempts the negotiation). This is the "connection started
minimal/insecure, now upgrade for sensitive work" case:
```rust
// Established with just ConnectionParams::INSECURE; now require ENCRYPTED
// for this one exchange.
let msg = receive_message_with_required_params::<_, MyPayload>(
&mut stream,
true, // auto_reply
proto,
Some(&mut conn),
ConnectionParams::ENCRYPTED | ConnectionParams::INSECURE,
).await?;
```
Note this still relies on the *sender's* `send_message` call reading
`conn.insecure == true` to actually retry -- a connection whose `Hello`
never declared `INSECURE` can't be upgraded this way, by design (the
initiator's original declaration is what authorizes renegotiation at all).
On a successful negotiation (either path), `conn.params` is updated to the
newly-agreed value, so later calls on the same connection default to it.
## `Open` / `OpenAck`: logical, multiplexed sessions
A single Noise-secured physical connection can carry **multiple
independent logical sessions**, the same way SSH multiplexes channels over
one encrypted transport, or HTTP/2 multiplexes streams over one TLS
connection. There is only ever one `Noise_NK` handshake (and one
`TransportState`) per physical connection -- `Open`/`OpenAck` doesn't
negotiate new key material, it just tags subsequent `Data` messages so they
can be routed to the right logical handler.
```
initiator responder
| |
| Open { service, id, port } (enc.) |
|--------------------------------------->|
| | assigns a session_id
| OpenAck { port } (enc., tagged) | for this {service, id}
|<---------------------------------------|
| |
| Data (enc., tagged with session_id) |
|<--------------------------------------->|
```
- **`Open`** carries a [`SessionRequest`](../src/protocol/message.rs)
`{ service, id, port }` -- application-defined fields naming what's being
requested.
- **`OpenAck`** carries a [`SessionAck`](../src/protocol/message.rs)
`{ port }` confirming it, and is tagged (via
`ProtocolMessage::with_logical_session`) with the `session_id` the
responder assigned for this session.
- Subsequent `Data` messages for that logical session are tagged the same
way, so a reader can dispatch them by `header.meta().session_id`.
**Not yet implemented:** the actual session table (mapping `service`/`id` ->
assigned `session_id`/`port`, and dispatching incoming `Data` by that tag)
is application-level bookkeeping, not something the library does for you --
`send_receive.rs` and `message.rs` provide the pieces (`Open`/`OpenAck`
message types, `with_logical_session`, `RecordMeta.session_id`) but not a
connection-manager abstraction on top of them.
## `Data`
Ordinary application traffic. Typically sent with `ConnectionParams::ENCRYPTED`,
optionally combined with `COMPRESSED`/`ENCODED`/`SIGNATURE` (see
`PROTOCOL.md` for the transform order). Tag with
`ProtocolMessage::with_logical_session(session_id)` when it belongs to a
specific `Open`/`OpenAck`-negotiated session; omit it to default to the
connection id (fine for connections that don't multiplex).
## `Rekey`: rotating keys mid-connection
Either side can proactively rotate the transport key without tearing down
the connection, by re-running the `Hello`/`HelloAck` handshake in place:
```
initiator responder
| |
| Rekey (enc., using the OLD cipher) |
|--------------------------------------->| decrypts with old ConnectionCtx,
| | sees MsgType::Rekey
| Hello (fresh ephemeral, fallback- |
| wrapped like any other Hello) |
|--------------------------------------->|
| HelloAck (same) |
|<---------------------------------------|
| |
| [both sides swap in a fresh |
| ConnectionCtx / TransportState] |
```
Implementation: [`handshake::rekey_initiator`](../src/protocol/handshake.rs)
/ [`handshake::rekey_responder`](../src/protocol/handshake.rs). There's no
separate rekey sub-protocol beyond the `Rekey` signal frame -- redoing the
Noise handshake is cheap, so that's reused wholesale rather than inventing
a bespoke key-rotation scheme. A responder's receive loop is expected to
check for an incoming `MsgType::Rekey` and dispatch to `rekey_responder`
after decrypting the signal frame with the *old* context.
`simple_comms` doesn't currently decide *when* to rekey (message count,
elapsed time, ...) -- that policy is left to the caller.
## `Close` / `Error`
`Close` signals a graceful teardown; `Error` carries a `ProtocolStatus`
value describing what went wrong rather than a normal payload. Neither has
handshake-specific logic beyond normal message framing.
## `ConnectionDriver`: full-duplex sessions
Everything above -- `send_message`/`receive_message` in `send_receive.rs`
-- is strict half-duplex RPC: one send, blocked on exactly one reply, over
a single `&mut STREAM`. That's enough for simple request/response use, but
it can't express either peer pushing a `Data` message at any time
independent of a reply, and there's no way for one part of an application
to keep sending while another keeps receiving on the same connection.
[`network::driver::ConnectionDriver`](../src/network/driver.rs) is the
full-duplex counterpart: `ConnectionDriver::spawn(stream, ctx, role,
proto, config)` splits `stream` into independent read/write halves and
spawns one background task that owns them (plus `ctx`) for the
connection's lifetime, `select!`ing across an inbound read, an outbound
queue, a rekey control channel, and a heartbeat timer. Because that one
task is the *only* thing that ever touches `ctx` or the stream halves, no
lock is needed around them -- every operation is naturally serialized, so
a send can't race a rekey and no signal can be missed. It returns a
`ConnectionHandle<APP>` with `send`/`recv`/`rekey`/`shutdown`; `recv`
yields a `DriverMessage<APP>` for each dispatched `Data`/`Open`/`OpenAck`
message (`Heartbeat`/`Rekey`/`Close`/`Hello`/`HelloAck` are handled
internally and never surfaced to the caller).
This also closes out two things that used to be listed here as
unfinished:
- **Heartbeats** ([`heartbeat.rs`](../src/protocol/heartbeat.rs)) are now
wired in: the driver sends `MsgType::Heartbeat` on `config.heartbeat_interval`,
marks receipt of the peer's, and tears the connection down once
`Heartbeat::state` reports `Timeout`.
- **`Rekey` under concurrent traffic**: `rekey_initiator`/`rekey_responder`
originally assumed nothing else was reading/writing the connection
concurrently. The driver's exclusive single-task ownership makes both a
self-initiated (`ConnectionHandle::rekey`, initiator side only -- see
`ConnectionRole`) and peer-initiated (an incoming `MsgType::Rekey` frame,
responder side) rekey safe: it's processed inline, to completion, before
the loop's next iteration, so nothing else can be mid-flight against the
old cipher when the new one is swapped in.
## What's explicitly out of scope today
- **0-RTT session resumption** (e.g. via a Noise PSK pattern like
`NKpsk0`). A `ConnectionParams::RESUMED` bit briefly existed as an
unimplemented placeholder for this and was removed rather than kept
around unused; a fresh `Hello`/`HelloAck` handshake is always required
today. Re-add whatever's actually needed if/when this gets built.
- **`ProtocolStatus::READY`** ("peer finished handshake") is defined
(relocated here from `ConnectionParams`, where it was never checked) but
still not read or set by anything.
- **Session-table bookkeeping** for `Open`/`OpenAck` (see above) is left to
the application, including on the driver's `recv()` path -- it dispatches
`Open`/`OpenAck` messages to the caller (optionally taggable/routable by
`RecordMeta::session_id`) but doesn't maintain a session table itself.