Files
Johnathan Corgan af2f8e7970 Run the rekey state machine on next's XX handshakes
The merge brought in the rekey state machine with master's link and
session profiles, which are built for the IK and XK handshakes. Next
runs Noise XX on both layers, so this replaces those profiles and
adapts both shells to them.

In fips-core, the link and session profiles are XX profiles. A link
rekey's final message is read in the core, which creates the completion
the machine takes from the inbound leg. The profiles are tested byte for
byte against the handshakes next sends today, so the wire format does
not change: the negotiation payload rides the same messages as before,
and a session rekey's ack and msg3 still carry none. The unused helper
that derived a pinned key from an x-only key is removed.

The session shell runs on the XX session profile. The temporary XX
profile uses the same rules as master's session profile: the initiator
probes before it commits, and the responder cuts over at msg3 and sends
a prompt. The spin bit stays removed.

On the link layer, next's existing inbound legs become the machine's
leg table. Each leg owns its index lease and releases it on every exit,
and a declared rekey is handed from the leg to the peer's link machine.
A link rekey now uses the delayed initiator cutover the machine
implements, instead of cutting over on the next tick, and next's msg3
resend loop is gone with the old rekey code. A second dial of a peer is
resolved by address order. node.rekey.enabled is removed, with a warning
when an old config still sets it. An inbound handshake refused at the peer
cap is counted as max_peers rather than bad_state.

Master's tests that check IK-only behaviour are deleted; the rest are
ported to XX and the delayed cutover, including the tests that keep
frames on a previous link session out of the new session's MMP. The
ignored-test inventory, the changelog, and the design and reference
docs describe next's tree.
2026-10-20 16:26:26 +00:00

313 lines
16 KiB
Markdown

# fips-core
The protocol core of FIPS: the code that decides what a node does, kept
free of everything that ties it to an operating system or a runtime.
The goal is one implementation of the protocol that runs unchanged on a
Linux daemon, an Android app and a microcontroller, with each host
supplying its own I/O around it.
The crate is a workspace member next to the root `fips` package. Code
moves in from the root crate one module at a time. So far it holds:
- the identity types: `Identity`, which holds the node's keypair and
signs, `NodeAddr`, `Ipv6Address`, `PeerIdentity`, the authentication
challenge and response, the npub and nsec encodings and
`IdentityError`, re-exported by the root crate under
`fips::identity`;
- the coordinate cache, `CoordCache`, re-exported under `fips::cache`;
- `EntropySource`, the trait through which the host supplies random
bytes;
- `time::Clock` and `time::Instant`, the traits through which the host
supplies the time, with `Duration` from `core::time`;
- the transport value types: `TransportId`, `LinkId`, `LinkDirection`,
`TransportAddr` and `LinkStats`, re-exported under `fips::transport`.
The transports themselves stay in the root crate;
- `SessionIndex`, its random allocator `IndexAllocator` and
`IndexError`, re-exported under `fips::utils::index`, where
`IndexAllocator` is an alias over `OsEntropy`;
- the rate limiters (`node::rate_limit`) and the stats history
(`node::stats_history`), generic over `time::Clock` and re-exported
under the root crate's `node::rate_limit` and `node::stats_history`
with aliases over its `HostClock`;
- Noise: the XX handshake, the transport sessions, the replay
window, the cipher state and the AEAD under it, with the handshake
message sizes. All but the AEAD are re-exported under `fips::noise`;
the root crate meets `AeadKey` only as the type the sessions' key
hand-outs return, and in the compile-time tie that pins that type to
`ring`;
- the protocol itself, `proto`: the wire codecs and sans-IO state
machines of every layer (link, session, spanning tree, bloom filters,
routing, lookups, metrics, probes). The root crate reaches it as
`crate::proto` and re-exports its public types at its crate root.
`proto::rekey`, the key-rotation machine both layers share, and its
two production profiles, `proto::fmp::rekey` and `proto::fsp::rekey`
(both Noise XX on this line), are hidden from the docs. The root
crate's session table holds every session in a machine on the session
profile, from its first handshake on, and each active peer holds its
link's sessions in a machine on the link profile.
## What belongs here
Code whose behaviour is the same on every target:
- identity types and their encodings (node addresses, npub and nsec);
- wire formats: parsing and serializing frames and messages;
- protocol state machines: handshakes, sessions, the spanning tree,
bloom filters, routing decisions, lookups;
- policy and bookkeeping that is pure computation: rate limits, caches,
statistics;
- the traits that describe what the core needs from its host, such as a
transport to send on.
What does not belong here: sockets, TUN devices, files, configuration
parsing, logging, signal handling, threads, timers and anything that
waits. Those stay in the root `fips` crate, which drives the core.
## The rules
These are enforced by the compiler where it can, and by review where it
cannot.
**`no_std` with `alloc`.** The crate is `#![no_std]` and uses
`extern crate alloc`. Use `core::` and `alloc::` paths (`core::fmt`,
`core::net::Ipv6Addr`, `alloc::vec::Vec`, `alloc::string::String`).
`std::collections::HashMap` and `HashSet` are not available. For a map
whose keys a peer can choose and that is looked up on a hot path, use
`utils::hash::HashMap`, keyed from the host's entropy source; otherwise
use `alloc::collections::BTreeMap` and `BTreeSet`.
**Sans-IO.** The core never performs I/O. It takes inputs (bytes that
arrived, the current time, an event) and returns values that describe
what the caller must do: bytes to send, a timer to arm, a link to close,
an event to report. The caller owns the socket, the clock and the
scheduler, and carries out what the core returned. A typical shape is a
method that takes `&mut self` plus the input and returns an action enum
or a small list of actions.
**No async.** No `async fn`, no futures, no `.await`, and no dependency
on tokio or any other runtime. The core is synchronous, and every call
returns promptly. Waiting is expressed as an action ("call me back at
time T"), not as a suspended task.
**Time is an argument.** Never read a clock. Functions that need the
time take it as a parameter, and timeouts are deadlines the caller
schedules. A type that measures time takes the host's clock as a type
parameter bounded by `time::Clock` and takes its instants as arguments;
it never calls `Clock::now`, which is for the host. The host fixes the
parameter in a type alias, as it does for the entropy source below: the
root crate's is `HostClock`, its monotonic clock. This is what makes the
state machines testable with synthetic timestamps, and what lets an
embedded host supply its own timer.
**Randomness is an argument.** Never reach for a thread-local or OS
random source. Code that needs random bytes or a nonce takes them from
its caller, or takes the host's source as a type parameter bounded by
`entropy::EntropySource`, as `Identity<E>`, `AuthChallenge<E>`,
`CoordCache<E>`, `HandshakeState<E>` and `IndexAllocator<E>` do. The
host fixes the parameter once, in a type alias at the path its callers
already use:
the root crate's `fips::identity::Identity`, for example, is
`Identity<OsEntropy>`, and `OsEntropy` is `rand`'s thread-local
generator. The source must be cryptographically secure; keys derived
from predictable bytes are predictable. The shared secp256k1 context in
`identity` follows this too: the host blinds it by calling
`identity::init_secp(seed)` at start-up with 32 bytes from its own
random source, as the daemon does.
**No OS calls and no target `cfg`.** No `cfg(target_os = ...)`,
`cfg(unix)`, `cfg(windows)` or similar. If behaviour differs by
platform, the difference lives in the host crate and reaches the core
through a parameter or a trait.
**No logging.** No `tracing`, `log` or `println!`. Report what happened
in the return value (an event, an error, a counter in a stats struct)
and let the caller decide what to log. Decisions belong to the core;
observability and I/O belong to the host.
**Avoid global state.** `std::sync::LazyLock`, `Mutex` and
`thread_local!` do not exist here. Prefer passing state in. Where a
shared, lazily built value is unavoidable, `once_cell::race::OnceBox`
works without `std`.
**No 64-bit std atomics.** `AtomicU64` and `AtomicI64` from
`core::sync::atomic` do not exist on the 32-bit MIPS targets the
OpenWrt packages cover. Use `portable_atomic::AtomicU64`.
`testing/check-portable-atomics.py` checks this crate as well as the
root.
**Peer input is untrusted.** Bytes from the network never cause a
panic: no `unwrap`, indexing or arithmetic that a malformed or hostile
frame can make fail. Return an error. Bound every allocation by a limit
the core controls, not by a length field the peer chose.
## Dependencies
Add a dependency with `default-features = false`, then enable `alloc`
(or the crate's equivalent) and nothing else it does not need. A new
dependency must build without `std`.
`libm` is the one dependency left on its default features, and it is
not a precedent: its only default is `arch`, kept so the bloom
false-positive estimate uses the implementation the root crate used
before the move.
`ring` is optional, behind this crate's `ring` feature, and is the one
dependency that must never be enabled for a bare-metal build: it does
not build for those targets. The root crate enables the feature, so
hosted builds seal and open with `ring`'s assembly; a build of this
crate alone uses the RustCrypto AEAD instead. The bare-metal script
builds `-p fips-core` alone and never enables it.
The workspace build unifies features across members, so a feature the
root crate turns on for its own use also applies to the core in a
normal `cargo build`. That can hide a feature the core needs but never
asked for. Check what the core gets on its own:
```bash
cargo tree -p fips-core -e features
```
Never enable a dependency's `std` feature under `[dependencies]`: the
crate builds for targets that have no `std` (see Checks), and that
build fails on any dependency that links it. When a dependency
implements a trait only with `std`, as `secp256k1`, `bech32` and `hex`
do for the error trait, write the code so it does not need the impl;
`IdentityError`'s hand-written `From` impls are the example.
`[dev-dependencies]` may enable `std` features, since tests run on the
host only; the core's `secp256k1` dev-dependency does, for the
`sign_schnorr` the test fixtures call. A dev-dependency's features
also reach the library in any build that includes the crate's tests:
`cargo test`, and `cargo clippy` or `cargo check` with
`--all-targets`. That is why the Checks below lint the library on its
own with `--lib`.
## Tests
Tests run on the host and may use `std`: `lib.rs` declares
`#[cfg(test)] extern crate std;`. That does not bring `std`'s prelude
macros into scope, so a test module that uses `format!` or `vec!` needs
`use alloc::format;` or `use alloc::vec;`.
Unit tests go in their own file, never in an inline `mod tests { ... }`
block, and they are kept per directory: a directory's tests are in its
`tests.rs` or its `tests/` directory, never both, declared from
`mod.rs` with `#[cfg(test)] mod tests;`. A file module's tests are
`tests/<file>.rs` in the same directory, attached from the source file
with `#[cfg(test)] #[path = "tests/<file>.rs"] mod tests;`, so they stay
a child module of that file, see its private items and keep their test
names. A single file's tests never get a directory of their own. If
`tests/<file>.rs` is already taken by the directory's own test module,
use another name, such as `tests/<file>_private.rs`.
`testutil.rs` holds fixtures several test modules share, and is compiled
for tests only. Dev-dependencies in `core/Cargo.toml` are built only for
the tests, which run on the host, so they may use `std`.
Test the state machines the way the host drives them: build the state,
feed it inputs with synthetic timestamps and fixed random bytes, and
assert on the actions it returns. No sockets, no sleeps and no real
clock.
## Moving code in from the root crate
- Move whole files with `git mv` so history follows them. When only
part of a file belongs here, cut it in a commit of its own, as
`SessionIndex` and the Noise sizes were, and keep the file moves pure
renames.
- Leave a re-export at the old path in the root crate
(`pub use fips_core::...;`), so existing `crate::...` imports keep
resolving and the move changes no caller.
- Items the root crate still reaches become `pub`. A module whose whole
surface the root drives, such as `proto`, stays documented, and
trimming that surface is later work: hiding a module the root drives
end to end would hide most of the crate from its own docs. Mark
individual internals that are public only for the root
`#[doc(hidden)]`, as `identity::secp` and `identity::sha256` are.
- An item only the root crate's tests reach is gated
`#[cfg(any(test, feature = "test-hooks"))]`, because `cfg(test)` does
not cross crates. The root enables `test-hooks` from its
`[dev-dependencies]` only, so hooks reach only a build that compiles
tests, examples or benches; a plain `cargo build`, release or not,
leaves them out. Make the `cfg` change while the item is still in the
root, so the move stays a pure rename.
- An inherent `impl` must live in the type's crate. Methods the root
crate adds to a moved type move with it, or become a free function or
an extension trait.
- Check `super::` imports as well as `crate::` ones when working out
what a module depends on; both have to be satisfied inside the core.
- An intra-doc link to an item that stays in the root crate will not
resolve from here. Write the name as plain text.
- Split the tests the same way: tests that use only moved items move
with them, and the rest stay. Compare the test names before and after
so none is lost.
- A module whose tests are inline has them extracted to their own file,
as described under Tests, in a commit before the move.
- Every directory in this crate has a `README.md`: what the directory is
for, what each file or subdirectory holds, how it relates to the
levels above and below, and the rules or invariants someone changing
it must know. Write it for a human reader and a coding agent alike.
When code moves into a new directory, or a directory's contents
change, add or update its README in the same change.
- A move changes no behaviour. Code that needs changing to meet the
rules above (a clock read, a log line, a target `cfg`) is changed in a
separate commit first, while it is still in the root crate.
## Checks
Both local CI (`testing/ci-local.sh`) and GitHub CI build, lint and
test this crate along with the root, because the workspace lists it in
`default-members`. They also build it on its own, under its own feature
resolution:
```bash
cargo clippy -p fips-core --lib -- -D warnings
cargo clippy -p fips-core --all-targets -- -D warnings
cargo clippy -p fips-core --all-targets --features slow-tests -- -D warnings
cargo test -p fips-core --features slow-tests
```
The first lints the library with only the features its
`[dependencies]` ask for. The second also type-checks the tests, and
in doing so gives the library the dev-dependencies' features, so code
that needs `secp256k1`'s `rand` or `std` passes it; the first does
not. The third lints the slow test suite the `slow-tests` feature gates
(the guard-switch meta-test). Every platform's ordinary test run skips
it; GitHub CI's Linux job runs it on every push, and local CI with
`--slow-tests`. Run all four before sending a change here. The
repository guards in `testing/` (atomics, comment references, log
strings) scan `core/src/` as well as `src/`.
These checks run on the host, where `std` exists, so they cannot catch
a dependency that links it. A separate build of the library for two
targets with no `std` at all, `thumbv7em-none-eabihf` (Cortex-M4F) and
`riscv32imac-unknown-none-elf`, fails on any such dependency with
"can't find crate for `std`":
```bash
testing/build-core-baremetal.sh
```
GitHub CI runs it on every push. Local CI runs it only when asked for,
with `testing/ci-local.sh --baremetal` or `FIPS_CI_BAREMETAL=1` in the
environment, because of the prerequisites below; a landing or merge-up
on a trunk path runs it explicitly.
It needs the two targets (`rustup target add thumbv7em-none-eabihf
riscv32imac-unknown-none-elf`), `clang` and `llvm-ar`, which compile
and archive the C in `secp256k1-sys`. The targets are not in
`rust-toolchain.toml`, so not every rustup user fetches them; they
belong to one toolchain, so they have to be added again when
`rust-toolchain.toml` moves to a new release. The Nix dev shell
provides neither target. Run the script after any change to this crate
if you have them; without them, the `--lib` lint above still catches
code that needs a `std` feature, but not a dependency that links
`std`. It builds `-p fips-core` only: the root crate needs `std`, and
building it alongside would turn `std` back on for the shared
dependencies.
## Versioning
The crate's version is fixed at `0.0.0` and is never bumped, and
`publish = false`. It is versioned with the root `fips` package, whose
`Cargo.toml` is the only place a release changes the version.