Files
Johnathan Corgan 5119b0f5f3 Add a workspace with an empty no_std fips-core crate
Start the no_std core as a second workspace member. The crate is empty:
it holds the rule that code in it builds for any target with an
allocator (no std, no async runtime, no OS calls, no target cfg), and
the root crate does not depend on it yet, so nothing that builds or
ships changes. The root package stays first in Cargo.toml with its
version line untouched, since the packaging scripts read the release
version from it, and the core is fixed at 0.0.0 with publish = false so
releases keep a single bump site.

default-members names both crates, so the bare cargo clippy and test
commands in local CI and GitHub CI cover the core as well as the root
package.

Add a fence to both runners: clippy and the tests of the core on its
own, so a dependency feature that only the root crate enables cannot
hide one the core needs.

Pin the privileged interface-binding step to the fips package's lib
test binary. With two lib test executables, `tail -1` picked whichever
cargo listed last, and the core's binary passes the ethernet filter
having run nothing. The step now also runs under bash with pipefail and
fails unless at least one test passed.

Widen the comment-reference, portable-atomics and log-string guards to
core/src/, and add core/ to the stale-binary checks of the mdns,
native-api and deb-install harnesses, so code that later moves into the
core stays checked. Copy core/ into the two nostr relay example images,
whose build would otherwise fail to load the workspace manifest, and
give the standalone BLE spike under testing/ble an empty workspace table
of its own, so cargo keeps loading it as its own project rather than
refusing it as an unlisted member of the root workspace.


Add core/README.md for anyone reading or adding code under core/: what the crate is for, the rules its code follows, how to add a dependency without letting workspace feature unification hide a missing feature, how tests work under no_std, a checklist for moving a module in from the root crate, and the checks to run before a change.
2026-10-11 17:08:56 +00:00
..
2026-08-30 10:42:59 +00:00

FIPS over a Mixnet — Single-Container Demo (Nym)

An isolated environment demonstrating how FIPS peer traffic can travel through a mixnet — a network that hides traffic patterns by routing each packet through several relays with cover traffic and timing obfuscation. The mixnet here is Nym, but the FIPS side is transport-agnostic: it just sees a SOCKS5 proxy, so any mixnet exposing one would slot in the same way.

Everything runs in one Docker container: the FIPS daemon, the mixnet proxy (nym-socks5-client), a strfry Nostr relay behind nginx, and dnsmasq.

┌────────────────────────── one container ───────────────────────────┐
│                                                                    │
│  nginx :80 ──► strfry :7777          (Nostr relay, fips0-only)     │
│                                                                    │
│  fips daemon ── transports.nym ──► nym-socks5-client :1080         │
│      │                                   │                         │
│      ▼                                   ▼ Sphinx packets          │
│   fips0 (TUN, fd00::/8)           Nym gateway ► 3 mix hops ►       │
│                                   network requester ► peer (TCP)   │
│                                                                    │
│  iptables: direct route to the peer is DROPped — the FIPS link     │
│  can only exist through the mixnet.                                │
└────────────────────────────────────────────────────────────────────┘

How the pieces interlock:

  • The FIPS nym transport dials peers through a local SOCKS5 proxy; the proxy routes each TCP stream through the mixnet (gateway → 3 mix hops → network requester), which performs the final TCP connection to the peer. The peer address must therefore be a TCP endpoint — find public peers at https://join.fips.network/.
  • The nym-socks5-client is started by the entrypoint only when the generated FIPS config contains a transports.nym block (FIPS_PEER_TRANSPORT=nym), and always before the FIPS daemon, so the proxy is listening by the time FIPS dials.
  • In nym mode, iptables drops the direct route to the peer: if the peer handshake completes, the traffic provably went through the mixnet.

Quick start

# 1. Generate a node identity (any machine with fipsctl, or reuse one):
#    --stdout prints the nsec and npub instead of writing key files.
fipsctl keygen --stdout

# 2. Put the nsec into the environment:
export FIPS_NSEC=<your-nsec>

# 3. Build and run (native image; FIPS compiles for your host's arch):
docker compose up --build

Watch the logs: the entrypoint auto-discovers a Nym service provider, bootstraps the SOCKS5 client (Nym SOCKS5 proxy ready …), and only then starts FIPS. After the mixnet handshake completes (can take 30–120 s):

docker compose exec fips fipsctl show transports   # nym transport: up
docker compose exec fips fipsctl show peers        # test-us03: active

Switching transport: mixnet ↔ direct (TCP/UDP)

The single knob is FIPS_PEER_TRANSPORT in .env (or an inline override). It selects how FIPS reaches the peer and whether the mixnet proxy runs at all — the two are always in sync.

# Default — through the Nym mixnet (anonymized, ~1-2 s RTT):
FIPS_PEER_TRANSPORT=nym  docker compose up -d        # or just `docker compose up -d`

# Direct TCP (no mixnet, ~50-300 ms RTT). The nym client is NOT started:
FIPS_PEER_TRANSPORT=tcp  docker compose up -d

# Direct UDP — also point FIPS_PEER_ADDR at the peer's UDP endpoint:
FIPS_PEER_TRANSPORT=udp  FIPS_PEER_ADDR=54.183.70.180:2121  docker compose up -d

What changes under the hood for each value:

FIPS_PEER_TRANSPORT nym client FIPS config block peer endpoint used direct route to peer
nym (default) started, before FIPS transports.nym FIPS_PEER_ADDR (TCP) via SOCKS5 firewalled off
tcp not started transports.tcp FIPS_PEER_ADDR (TCP) direct allowed
udp not started transports.udp FIPS_PEER_ADDR (UDP :2121) direct allowed

To switch back to a direct link, set the value to tcp (no other change) or udp (and swap FIPS_PEER_ADDR to the :2121 endpoint), then re-run docker compose up -d. To return to the mixnet, set it back to nym. Persist your choice by editing .env instead of prefixing the command. The same node can be compared both ways — direct shows ~50-300 ms RTT, the mixnet ~1-2 s, which is the visible signature that traffic is routing through the Sphinx mix hops.

Verifying the traffic really crosses the mixnet

# The direct route to the peer is dropped — the only way packets reach
# the peer is via the nym-socks5-client:
docker compose exec fips iptables -L OUTPUT -v -n   # DROP rule for peer IP

# Mixnet activity (Sphinx packet flow) in the nym client output:
docker compose logs fips | grep -i nym

# End-to-end data plane across the mesh. FIPS addresses every node by its
# key as <npub>.fips (each npub maps into fd00::/8); short names like
# `test-us03` are only local aliases for the peer you configured. Pick a
# node you are NOT directly linked to — grab a current npub from
# https://join.fips.network/ — so the ICMPv6 echo routes over the mixnet
# to your peer and then hop-by-hop across the mesh to the target:
docker compose exec fips ping6 -c3 <peer-npub>.fips

# A reply while the direct route is DROPped proves the traffic crossed the
# mixnet; the seconds-range RTT is the Sphinx path's signature, and a few
# extra hundred ms over reaching your own peer is the added mesh hops (a
# direct, non-mixnet connection would be ~30 ms).

The Nostr relay answers only over the FIPS mesh (fd00::/8) and on the container's loopback — inbound eth0 traffic, including the host's port-80 mapping, is dropped by the isolation rules. Check it from inside:

docker compose exec fips curl -s -H "Accept: application/nostr+json" http://127.0.0.1/

Configuration (.env)

Variable Default Meaning
FIPS_NSEC (required) Node identity, fipsctl keygen --stdout
FIPS_PEER_NPUB test-us03's npub Peer to dial; empty = standalone
FIPS_PEER_ADDR 54.183.70.180:443 TCP endpoint in nym/tcp mode (use :2121 for udp)
FIPS_PEER_TRANSPORT nym nym | tcp | udp — see "Switching transport" above
NYM_SERVICE_PROVIDER (auto) Network requester; empty = pick the best-scored from harbourmaster
NYM_CLIENT_ID fips-nym-client Nym client identity (kept in the nym-data volume)

With FIPS_PEER_TRANSPORT=tcp or udp the nym client is not started at all and FIPS connects directly — useful as a baseline comparison.

Troubleshooting

  • could not auto-discover a Nym service provider — the harbourmaster API was unreachable or returned no providers; pick one manually from https://harbourmaster.nymtech.net/ and set NYM_SERVICE_PROVIDER.
  • Slow or failing mixnet bootstrap — service providers and gateways vary in quality. Delete the client state and retry with another provider: docker compose down -v && NYM_SERVICE_PROVIDER=<other> docker compose up. (The provider is baked into the client state at init; changing it requires wiping the nym-data volume.)
  • Peer never becomes active — confirm the peer's TCP endpoint is reachable from the open internet (the network requester dials it from the Nym exit side, not from your machine).
  • Never run this image under emulation — the image builds native for a reason: under Rosetta/qemu, the FIPS daemon's ChaCha20-Poly1305 assembly (ring/BoringSSL) silently fails AEAD on larger frames; bloom filter announces are dropped and multi-hop routing never converges, while small control traffic keeps working — a maddeningly subtle failure mode. Only the embedded amd64 nym-socks5-client (Nym ships no other arch) runs emulated on Apple Silicon, which it tolerates.

Notes

  • The container's lifecycle follows the FIPS daemon; strfry, nginx and the nym client run as background processes inside the same container and are restarted with it (restart: unless-stopped).
  • SSH (port 22, no auth) and tools like tcpdump, nak, iperf3 are inside the image for poking around — this is a demo image, do not expose it beyond your machine.