slither 0.3.0

Encrypted peer-to-peer UDP transport: reliable messages, streams and datagrams, authenticated by raw public keys - no certificates, no TLS. WireGuard-shaped handshake, QUIC-shaped frames.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
[package]
name = "slither"
version = "0.3.0"
edition = "2024"
# Tracks the hiss MSRV (edition 2024, MSRV 1.96) — same policy: a recent
# stable, floored at `stable - 3`, bumped in lockstep with the `msrv` CI job.
# Verified against hiss 0.4.1's own Cargo.toml.
rust-version = "1.96"
authors = ["Nicolas Di Prima <nicolas@primetype.co.uk>"]
license = "MIT OR Apache-2.0"
description = "Encrypted peer-to-peer UDP transport: reliable messages, streams and datagrams, authenticated by raw public keys - no certificates, no TLS. WireGuard-shaped handshake, QUIC-shaped frames."
readme = "README.md"
repository = "https://github.com/primetype/slither"
homepage = "https://primetype.co.uk/slither/"
documentation = "https://docs.rs/slither"
keywords = ["noise", "udp", "wireguard", "quic", "datagram"]
categories = ["cryptography", "network-programming"]
# Repo infrastructure ships nothing to a consumer; SPEC.md ships on purpose —
# it is the ratified protocol the rustdoc points at.
exclude = [
    "/.github", "/CLAUDE.md", "/TODO.md", "/deny.toml", "/.gitignore", "/.serena", "/.claude",
    # The wire-v2 design record: 80 rulings, their reviews, and the frozen
    # intermediate drafts. Provenance a maintainer needs and a consumer does
    # not — SPEC.md is what ships.
    "/.spec-v2-clean-slate", "/.spec-v2-pipeline", "/.slices",
    "/PLAN.md", "/STORIES.md",
    # The browser demo (ruling 277): its own unpublished crate plus a static
    # page, depending on slither by path. It is not a workspace member and
    # must never be a `.crate` payload — a path dependency would not resolve
    # for a consumer, and the vendored WASI shim is not slither's to ship.
    "/demo",
]

# A library only. Two sans-io cores (`core::Endpoint`, `core::Connection` —
# pure state machines that never read a clock) plus one `!Send` tokio actor
# shell behind the `Wire` trait. The packet layer holds the packtool wire
# headers, the mac1 DoS gate, the IK handshake driving (fresh-ephemeral
# retransmit) and the datagram session (replay window, WireGuard timers,
# roaming, ratchet rekey); the frame layer rides inside the sealed plaintext
# with streams, messages, datagrams, flow control and RFC 9002 recovery.
# An independent crate: every dependency resolves from crates.io, and nothing
# `bubble-*` appears here.
[lib]
name = "slither"
path = "src/lib.rs"

# docs.rs builds with every feature so the `testutil` module (the `FlakyWire`
# in-memory network and the counting identity, behind `test-util`) is
# documented — it is a consumer-facing surface, not test-only plumbing.
[package.metadata.docs.rs]
all-features = true

[features]
# Nothing is on by default: the base crate is the protocol, and every
# integration surface below is opt-in.
default = []

# Exposes the `testutil` module (the `Network` / `FlakyWire` in-memory
# datagram fabric, the seeded `FlakyPolicy` and the counting identity) in
# non-test builds, so a downstream crate can drive slither over the same
# kernel-free network SPEC.md §16.10 requires. Off by default; slither's own
# tests pull the module in implicitly via `cfg(test)`. Dependency-free —
# `FlakyWire` needs only `tokio/time` and `tokio/sync`, both hard deps.
test-util = []

# `futures_sink::Sink` / `futures_core::Stream` adapters over a stream and
# over the datagram path (SPEC.md §3.2 of PLAN.md). Slice 8.
sink = ["dep:futures-core", "dep:futures-sink"]

# `tokio_util::codec::Framed` support, i.e. an `Encoder`/`Decoder` driven
# over a slither stream. Implies `sink`: the codec surface is expressed in
# terms of it, so enabling `codec` alone must not be a compile error.
codec = ["sink", "dep:tokio-util"]

# A `tower_service::Service` shape over §16.2's verbs. Slice 8.
#
# **[RATIFIED 2026/08/16 — ruling 225]** This comment read *"over the message
# verb"* and that shape cannot work: slither has **no request/response
# correlation on the wire**, so a `Service` over §11 messages would need a
# request id the transport does not carry — slither would have to invent
# application framing above its own frame layer to find it. The correlation
# slither already has is a **stream**: one bi stream per call, opened by
# `call()`, the request ended by `finish()` and the response by EOF.
# `PLAN.md` §3.4 is the reasoned statement and it wins; this line and
# `src/lib.rs`'s feature table were manifest comments carrying no argument.
#
# Deliberately still `tower-service` only — the cheap dependency. Ruling 230
# keeps `Rpc<C: Codec>` out of slice 8 rather than gating it on `codec`, so
# nothing here implies `tokio-util`.
tower = ["dep:tower-service"]

[dependencies]
# The Noise handshake and the sealed datagram transport. slither declares its IK
# handshake (P-256, msg1 carrying the 12-byte timestamp payload) via the `noise!`
# macro — hiss generates fixed-size sans-io state machines, so the messages frame
# straight into slither's packets — then rides the datagram mode
# (`Transport::into_datagram_with_epoch` → `DatagramSend`/`DatagramRecv`) for
# every Data packet: the send half owns the monotonic counter that slither puts
# in its packet header, the recv half is stateless so replay rejection is
# slither's duty. Also supplies `P256`, the P-256 key types, the
# `DhProvider<P256>` seam (so a hardware static could drive it — an iOS Secure
# Enclave key is the story that forbids `Send` on the actor path), the software
# `EphemeralOnly` provider, and `SessionId`. `default-features = false` keeps
# hiss to the P-256 surface slither names — the `x25519-cryptoxide` default backs
# a curve slither never touches.
hiss = { version = "0.4.1", default-features = false }

# Keyed BLAKE2b for mac1 ONLY (the day-one DoS gate: the raw primitive that keys a
# cheap tag off the recipient's public static so a garbage flood is dropped before
# any curve/DH work). Taken from cryptoxide DIRECTLY per the raw-primitive rule —
# NOT via `hiss::noise::*` — and pinned to EXACTLY the range hiss uses
# (`>=0.6.3, <0.7`, verified against hiss 0.4.1's Cargo.toml). `default-features =
# false, features = ["blake2"]` is the minimal surface: only the
# `Blake2b`/keyed-`Blake2b` primitive, none of cryptoxide's other ciphers or KDFs.
# No RustCrypto for any slither cryptography.
cryptoxide = { version = ">=0.6.3, <0.7", default-features = false, features = ["blake2"] }

# The fixed-size, const-`SIZE`-pinned wire headers (`InitHeader`/`RespHeader`/
# `DataHeader`). Each header is a `#[derive(Packed)]` struct whose multi-byte
# fields are plain `u32`/`u64`: SPEC.md §3.1 makes the header little-endian
# (ruling 64) and packtool packs raw integers little-endian, so the derive is
# the encoder and no per-field byte conversion appears anywhere. The frame
# layer is unaffected — §8.1's varints are RFC 9000's, big-endian, and hand-
# encoded in `varint.rs`. The maintainer's crate; pinned to 0.6.
packtool = "0.6"

# The async runtime pieces the shell actor needs: `rt` for
# `tokio::task::spawn_local`, `time` for the retransmit / keepalive / liveness
# / recovery timers, `sync` for the command channels and oneshots that connect
# the `!Send` actor to its handles, and `macros` for the `select!` the actor's
# event loop is built on. slither provides no runtime of its own; the actor
# runs on the consumer's current-thread runtime inside a `LocalSet`.
# `net` — for the `tokio::net::UdpSocket` behind the `Wire` trait — is
# target-gated below (ruling 277): tokio's `net` never compiles on wasm, and
# these five are exactly tokio's supported-on-wasm set minus `io-util`. Cargo
# unions base and target features, so native builds resolve identically.
tokio = { version = "1", features = ["rt", "time", "sync", "macros"] }

# Typed, structured errors for the public surface (SPEC.md §18.1's taxonomy).
# A library, so `thiserror`, not `anyhow`.
thiserror = "2"

# CSPRNG plumbing for the handshake ephemerals, the session index and the
# retransmit jitter: the software identity seeds a fresh `ChaCha20Rng` per
# handshake attempt (a FRESH ephemeral every retransmit is a WireGuard
# requirement) and draws the uniform jitter (≤ 333 ms) from the same stream.
# NOT a crypto crate in the Bubble sense — it carries no key material and
# performs no slither cryptography; every Noise/curve operation flows through
# `hiss`. Pinned to the `rand_core` 0.10 line hiss 0.4.1's public bounds name
# (`hiss::rand_core` re-exports it): an RNG handed to `EphemeralOnly::new` must
# implement the SAME rand_core's `CryptoRng`.
rand_chacha = "0.10"
rand_core = "0.10"

# The 32-byte OS-entropy seed for the endpoint's index/jitter `ChaCha20Rng`.
# rand_core 0.10 no longer ships `OsRng` (it moved to `rand` as `SysRng`);
# `getrandom::fill` is that same syscall surface without the rest of `rand`.
getrandom = "0.4"

# Structured diagnostics (SPEC.md §18.2's trace targets). §18.2 is a CLOSED
# list of exactly five — `slither::policy`, `::replay`, `::frames`, `::roam`,
# `::io` — and it is operator-visible contract: renaming, dropping or *adding*
# one is a protocol revision. They surface as `tracing` events rather than logs
# so a host can wire them to its own subscriber. Note what is deliberately NOT
# here: the pre-AEAD DoS gate (§3.1) emits nothing — it is silent in the trace
# as well as to the peer (ruling 67). An earlier version of this comment
# claimed §18.2 carried "the DoS-gate drop counters … and the handshake
# give-up"; §18.2 contains neither, and the claim was the error.
tracing = "0.1"

# ── Optional: the integration surfaces, all off by default ────────────────
# `Stream` / `Sink` for the stream and datagram adapters (feature `sink`).
futures-core = { version = "0.3", optional = true }
futures-sink = { version = "0.3", optional = true }

# `Framed` over a slither stream (feature `codec`). `default-features = false`
# deliberately: the default pulls `tokio/io-util` and more than `codec` needs.
tokio-util = { version = "0.7", default-features = false, features = ["codec"], optional = true }

# `tower_service::Service` over ONE BI STREAM PER CALL (feature `tower`).
# [corrected 2026/08/18 — ruling 264] This read "`Service` over the message
# verb", which is the shape ruling 225 *rejected*: slither carries no
# request/response correlation on the wire, so a message-verb `Service`
# would need a request id the transport does not have. `call()` opens a bi
# stream; `finish()` ends the request and EOF the response. No `Rpc`
# (ruling 230) — see `src/compat/tower.rs`'s module doc.
tower-service = { version = "0.3", optional = true }

# Ruling 277: the one target-conditional dependency. On wasm there is no UDP
# socket for the `Wire` impl to wrap and tokio's `net` feature is a hard
# compile error, so `net` applies everywhere EXCEPT wasm — which makes the
# lib (and `test-util`'s in-memory `FlakyWire`) compile for wasm32-wasip1.
# The CI `wasm` job in check.yml pins this from regressing.
[target.'cfg(not(target_family = "wasm"))'.dependencies]
tokio = { version = "1", features = ["net"] }

[dev-dependencies]
# NOT a new crate — `cryptoxide` is already a dependency above (mac1's keyed
# BLAKE2b). This dev-only entry makes `chacha`/`poly1305` an explicit
# requirement for `tests/spec_rekey.rs`'s `REKEY(0³²)` vector pin (ruling
# 251): today those features reach the build only by unification from hiss's
# own list, and a hiss feature change would otherwise break the pin with a
# "configured out" error that reads like a version problem.
cryptoxide = { version = ">=0.6.3, <0.7", default-features = false, features = ["chacha", "poly1305"] }

# NOT a new crate — `tokio` is already a dependency above. This dev-only entry adds
# `test-util` (the PAUSED CLOCK the timer tests drive: every timer in SPEC.md
# resolves in virtual time), plus `rt`/`macros` for `#[tokio::test]`.
tokio = { version = "1", features = ["test-util", "rt", "macros", "time", "net", "sync", "io-util"] }

# NOT a new crate either — `hiss` is already a dependency above, and this adds
# no crate to the graph: `x25519-cryptoxide` is a feature of `hiss` that turns
# on a feature of `cryptoxide`, which is itself already a dependency. Under the
# library's `default-features = false`, hiss offers slither exactly ONE curve, so
# SPEC.md §2.3's per-suite size derivation (`MSG1_LEN = PK + (PK + TAG) + …`)
# would be a compile-shape claim no test ever executes — on the one slice whose
# whole job is to freeze those sizes. With this, a second suite over X25519 can
# be declared in the test profile, whose `PK` differs from P-256's, and the four
# derivations checked against a second data point. No runtime dependency, no
# version change, no wire byte.
hiss = { version = "0.4.1", features = ["x25519-cryptoxide"] }

# Slice 8, S33 only. NOT the library dependency — that is `tower-service`, the
# bare trait, and ruling 225 keeps it that way. S33's acceptance asserts that
# *"`UnsyncBoxService` composes"*, and `UnsyncBoxService` lives in `tower`'s
# `util` feature, not in `tower-service`. Dev-only, so no consumer inherits it.
# `util` is deliberately the *only* feature: `tower`'s defaults pull the
# buffered/spawn-ready layers, which are exactly what §16.11's `!Send`
# paragraph says does **not** compose with a `!Send` driver.
tower = { version = "0.5", default-features = false, features = ["util"] }

# Slice 8, the `Sink` half of the compat suites. **[ruling 235]** NOT a
# duplicate of the `futures-sink` library dependency: `SinkExt::send` lives in
# `futures-util`, and its `sink` module is **not in that crate's default
# features** — a plain `futures-util = "0.3"` yields `stream::Stream` but not
# `sink::Sink`, and rustc reports it as *"found an item that was configured
# out"*, which reads like a version problem and is not. Both blind test
# authors met this and neither could fix it: `Cargo.toml` is the integrator's
# by working rule 15.
futures-util = { version = "0.3", features = ["sink"] }
# ── Integration test targets ──────────────────────────────────────────────
# The three story/spec suites drive two endpoints over `slither::testutil`,
# which lives behind `test-util`. Declared here rather than as an inner
# `#![cfg(feature = "test-util")]` so the requirement is visible in the
# manifest and a feature-less `cargo test` skips the targets outright
# instead of compiling three files down to "running 0 tests".
#
# Both `cargo test` and `cargo test --all-features` are release gates, so
# these still run on every gated commit -- S1's acceptance test included.
[[test]]
name = "story_lifecycle"
required-features = ["test-util"]

[[test]]
name = "story_dial"
required-features = ["test-util"]

[[test]]
name = "spec_shell"
required-features = ["test-util"]

[[test]]
name = "story_streams"
required-features = ["test-util"]

[[test]]
name = "spec_streams"
required-features = ["test-util"]

[[test]]
name = "story_reliability"
required-features = ["test-util"]

[[test]]
name = "story_datagram"
required-features = ["test-util"]

[[test]]
name = "story_message"
required-features = ["test-util"]

# Slice 7. These are the integrator's to add (working rule 15): each names
# a file written by a blind agent that did not exist when the other agent's
# worktree was cut, and cargo *refuses to parse* a manifest naming a missing
# test file — so an implementer landing them early leaves a tree on which no
# gate can run at all.
#
# **[ruling 194]** They are also not cosmetic. There is no `autotests =
# false` here, so cargo auto-discovers both targets *without* their
# `required-features`, and the feature-less `cargo test` gate fails on
# `unresolved import slither::testutil` until these stanzas exist.
[[test]]
name = "story_keepalive"
required-features = ["test-util"]

[[test]]
name = "story_mobility"
required-features = ["test-util"]

# Slice 7b (ruling 208's path validation). Landed by the integrator, not by
# either blind agent: cargo does not warn about a `[[test]]` whose file is
# missing, it **refuses to parse the manifest**, so an implementer adding a
# stanza for its partner's not-yet-written file commits a tree on which no
# gate can run at all (working rule 15).
[[test]]
name = "story_path"
required-features = ["test-util"]

# Slice R40-C (ruling 251's rekey obligations). Integrator-owned (working
# rule 15), same reasoning as every stanza above.
[[test]]
name = "spec_rekey"
required-features = ["test-util"]

[[test]]
name = "story_rekey"
required-features = ["test-util"]

# Slice 8 (`compat/`). Integrator-owned (working rule 15): each names a file
# written by a blind agent that did not exist when the others' worktrees were
# cut, and cargo **refuses to parse** a manifest naming a missing test file.
#
# **[ruling 194]** Not cosmetic. There is no `autotests = false`, so cargo
# auto-discovers each of these *without* its `required-features`, and the
# feature-less `cargo test` gate fails outright -- it does not degrade --
# until every stanza lands. Both test authors flagged this independently.
[[test]]
name = "story_compat"
required-features = ["test-util", "sink"]

[[test]]
name = "story_codec"
required-features = ["test-util", "codec"]

[[test]]
name = "story_tower"
required-features = ["test-util", "tower"]

[[test]]
name = "spec_compat"
required-features = ["test-util", "sink"]

# Round 41 (ruling 260). Appendix B's O53a/O53b, re-scoped from
# pre-ratification gates to measured, pinned obligations: the ACK-loss-burst
# simulation and the no-stall throughput floor. Same reasoning as every
# stanza above — cargo auto-discovers the file *without* its
# `required-features` unless the stanza names them (ruling 194), and the
# feature-less `cargo test` gate then fails on `unresolved import
# slither::testutil` rather than skipping the target.
[[test]]
name = "spec_ack_burst"
required-features = ["test-util"]

# ruling 265
[[test]]
name = "story_park"
required-features = ["test-util"]

# ── Benchmarks ────────────────────────────────────────────────────────────
# Throughput measurement, not a gate. Three things about this stanza are
# load-bearing:
#
# `harness = false` — there is no `criterion` dependency, deliberately.
# `cargo deny check` is a release gate and `Cargo.lock` is not committed, so
# every run re-resolves from the index; criterion's ~40-crate dev tree is
# that much more surface for a semver-compatible break, and it must hold the
# 1.96 MSRV under `--all-targets` too. The benchmark reports min/median/max
# itself. Revisit if these ever become regression gates.
#
# `test = false` — a bench target's `test` flag defaults to **true**, and
# with `harness = false` that means `cargo test` would execute `main()` and
# spend minutes moving 24 MiB through two endpoints on every gate run. The
# compile is still checked, because `cargo build --all-features
# --all-targets` is itself a gate.
#
# `required-features` — it drives two endpoints over `testutil::Network`.
# The same auto-discovery trap ruling 194 recorded for `[[test]]` applies:
# without the stanza, cargo would discover the file *without* its features
# and the feature-less build would fail outright rather than skip it.
[[bench]]
name = "throughput"
harness = false
test = false
required-features = ["test-util"]

# Ruling 259(viii) — the flow-control `Config` knob, end to end: the two
# ratified windows as the observed defaults, the raise measured on both
# birth paths, and §9.8's message bound held still while they move.
# Drives two endpoints over `testutil`, so `test-util` like its neighbours.
[[test]]
name = "story_reassembly"
required-features = ["test-util"]

[[test]]
name = "story_flow"
required-features = ["test-util"]

# Gap slice C — G7's traced clauses (S25/ruling 49, S30/ruling 59). Drives
# two endpoints over `testutil` and installs `testutil::Capture`, so
# `test-util` like its neighbours.
#
# **[ruling 194]** Landed live rather than commented out, and this file is
# the one exception to that rule's shape: a commented-out stanza is the same
# thing as no stanza, and *no* stanza is exactly the failure ruling 194
# records — cargo auto-discovers `tests/story_traced.rs` **without** its
# `required-features` and the feature-less `cargo test` gate fails on
# `unresolved import slither::testutil` rather than skipping the target.
# Measured on this tree before the stanza was added, not assumed. The file
# it names is committed in the same commit, so the manifest never parses
# against a missing file.
[[test]]
name = "story_traced"
required-features = ["test-util"]

# Gap slice A (S8/S10/S24) and B (S22 clause 4) — both drive `testutil`
# endpoints. Stanzas added by the integrator: working rule 15 makes this
# manifest the integrator's file, and both authors reported the missing
# stanza as a blocker rather than racing each other into one block
# (ruling 194's auto-discovery trap, the slice-4b lesson).
[[test]]
name = "story_intro"
required-features = ["test-util"]

[[test]]
name = "story_wrong_static"
required-features = ["test-util"]

# Ruling 279's story coverage: the offered AES suite establishes and
# carries traffic, and the same-curve mismatch dies at the probe, where
# amended §2.2 says — not at mac1, which the old text claimed.
[[test]]
name = "story_suite"
required-features = ["test-util"]

# Ruling 280's pairing window: `channel_psk!`'s IKpsk1 pattern driven end to
# end on the paused clock — the ceremony establishes and carries traffic, a
# wrong key is refused at §6.1's ordinary 2 DH, and a stranger the window
# holds no key for is dropped at **one** `es`. Needs `test-util` for
# `Network`/`Tap`: the DH-cost assertions read `CountingIdentity`'s counter,
# and the wire-length assertions read the fabric's tap.
[[test]]
name = "story_pairing"
required-features = ["test-util"]