Files
tsunagi/docs/protocol.md
T

179 lines
7.1 KiB
Markdown
Raw Normal View History

# Protocol
Module boundaries are in [architecture.md](architecture.md); the threat model
is in [threat-model.md](threat-model.md).
## Versioning
Two versions exist and are independent:
- **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a
different network space for the same name and secret.
- **Control protocol**, ALPN `tsunagi/ctrl/1`, `PROTOCOL_VERSION = 1`.
Upgrading the crate or bumping the control protocol must never change an
existing `NetworkId`.
## Canonical encoding
Everything that is hashed or MAC'd uses length-prefixed fields, written here as
`LP(x) = u32_be(len(x)) || x`. String concatenation is never used, so no two
different field splits can produce the same bytes.
## Network space identity
```text
salt = SHA-256( LP("tsunagi-network-id-v1") || LP(name_utf8) )
prk = HKDF-SHA256-Extract(salt, ikm = secret_bytes)
info(label) = LP("tsunagi-network-id-v1") || LP(label)
network_id = HKDF-Expand(prk, info("network-id"), 32)
discovery_key = HKDF-Expand(prk, info("discovery-key"), 32)
auth_key = HKDF-Expand(prk, info("handshake-auth"), 32)
```
HKDF's `info` parameter is what separates the three outputs (RFC 5869 §3.2).
The labels and this encoding are frozen.
**Name rules.** 1..=64 bytes of UTF-8, no control characters, no leading or
trailing ASCII whitespace — such a name is *rejected*, not trimmed. The name is
used verbatim: no case folding, no Unicode normalisation. `Home` and `home` are
different network spaces.
**Secret rules.** Used verbatim: never trimmed, case-folded, normalised or
truncated. At least 16 bytes; `NetworkSecret::generate()` produces 32 random
bytes. The canonical text form is `tsn1` followed by lowercase unpadded base32.
**Key separation.** `network_id` is public. `discovery_key` is what a discovery
backend is told; it is secret-derived but is **not** a credential — learning it
does not help pass the handshake, and it must never be used as a password or
bearer token. `auth_key` never leaves the process.
## No competing genesis
A network's description is `NetworkDescriptor { scheme, name, network_id }`. It
contains no creator identity, no creation time and no owner signature, so two
agents started independently with the same parameters produce byte-identical
descriptors. No minimum-hash rule, no vote, no leader. The secret is not part
of it.
A network's name does not change inside an existing space. A different name or
secret is a different space.
## Framing
One QUIC bidirectional stream per session. A frame is `u32_be(len) || payload`.
The announced length is checked against `Limits::max_frame_len` (64 KiB by
default) **before** any buffer of that size is allocated. Payloads are
[postcard], a compact deterministic serde format — not a general RPC framework.
No encryption is layered on top of iroh: QUIC/TLS already provides
confidentiality, integrity and endpoint authentication.
[postcard]: https://docs.rs/postcard
## Handshake
A successful iroh connection proves only *which endpoint* is on the other side,
because the endpoint id is the public key in the TLS certificate. Anyone can
dial us. Membership of a specific network is proved separately.
iroh exposes the TLS exporter (RFC 5705) through
`Connection::export_keying_material`. That yields the same secret bytes on both
ends of *this* connection, which is what stops a proof being replayed
elsewhere. **It proves nothing about the shared secret on its own**, because
both ends of any connection can compute it. The membership proof is the HMAC
keyed by `auth_key`; the exporter output is just one of its inputs.
```text
cb = TLS-Exporter(label = "tsunagi/handshake/v1", context = network_id, 32)
transcript(role) = LP("tsunagi-handshake-v1")
|| LP(role) // "initiator-proof" | "responder-proof"
|| LP(u16_be(protocol_version))
|| LP(network_id) // 32 bytes
|| LP(initiator_endpoint_id) // 32 bytes
|| LP(responder_endpoint_id) // 32 bytes
|| LP(cb) // 32 bytes
|| LP(nonce_initiator) // 16 bytes
|| LP(nonce_responder) // 16 bytes
proof(role) = HMAC-SHA256(auth_key, transcript(role))
```
What each input buys:
| input | property |
|---|---|
| `auth_key` | membership in this network space |
| `cb` | binding to this connection; a captured proof is useless on another |
| `network_id` | binding to this network space |
| both endpoint ids | binding to these two identities |
| distinct `role` labels | no reflection: a proof cannot be bounced back |
| both nonces | freshness contributed by each side |
Message order:
```text
initiator -> responder : Hello { version, network_id, nonce_i }
initiator <- responder : HelloAck { version, nonce_r }
initiator -> responder : AuthProof { proof(initiator) }
initiator <- responder : AuthProof { proof(responder) } // only after the first verified
```
The responder emits nothing derived from `auth_key` until the initiator's proof
verifies, so a caller who does not know the secret learns nothing. Comparison
is constant time. Endpoint ids always come from the TLS certificate, never from
a peer's claim. Until both steps complete, no regular control message is
accepted in either direction. The whole exchange is bounded by
`Limits::handshake_timeout`.
A `Hello` naming a network this agent does not have active is rejected with
"unknown network". Because the claim is unverified at that point, the rejection
event does not report a network id.
## Control messages
After authentication, every frame is an `Envelope { network_id, message }` and
the `network_id` is re-checked against the session's network on every message.
A mismatch ends that session and is counted as a protocol violation; it does
not affect other networks.
| message | meaning |
|---|---|
| `Announce { hostname, capabilities }` | this agent's hostname and IP-plugin capabilities |
| `Ping { seq, payload }` | small request used to verify the exchange |
| `Pong { seq, payload }` | the echoed reply |
| `Bye { reason }` | graceful goodbye; not a revocation of anything |
`PluginCapability { protocol, version, enabled, data }` is opaque to the core:
`data` is bounded and handed to the matching plugin unparsed. Nothing in it is
ever treated as a shell command, filesystem path or OS setting.
Every decoded message is validated against `Limits` before it reaches anything
else. A rejected message never stops a network or the agent.
## Limits
Defaults from `Limits`, all configurable:
| limit | default |
|---|---|
| frame payload | 64 KiB |
| hostname | 255 bytes |
| capabilities per announcement | 16 |
| capability payload | 4 KiB |
| echo payload | 4 KiB |
| reason string | 256 bytes |
| handshake timeout | 10 s |
| dial timeout | 10 s |
| write timeout | 30 s |
| concurrent dials per network | 8 |
| sessions per network | 64 |
| inbound handshakes in flight | 32 |
| outbound queue per session | 64 |
Liveness of an established session is delegated to QUIC: iroh configures
keep-alives and an idle timeout, so a dead peer surfaces as a read error rather
than needing a heartbeat in this protocol.