VLP Transports
Audience: Rust contributors. This page documents the Rust implementation of the VLP transport layer — trait shapes, feature-flag matrix, operational guidance. For the normative wire format of the base frame and the AEAD-wrapped secure frames, see the VLP specification and VLP secure-transport specification.
The Varta Lifeline Protocol (VLP) wire format is entirely transport-agnostic — a 32-byte,
8-byte-aligned #[repr(C)] frame. The transport layer is abstracted via traits that
allow swapping out the underlying socket type without modifying the protocol core.
Architecture
graph TD
VLP["<b>varta-vlp</b><br/>Frame (32 bytes) · Status · DecodeError<br/><i>Zero dependencies. Never changes.</i>"]
VLP --> CLIENT
VLP --> WATCH
subgraph CLIENT["varta-client"]
BT["BeatTransport"]
BT --> UdsT["UdsTransport"]
BT --> UdpT["UdpTransport<br/><i>(udp feat.)</i>"]
BT --> SecT["SecureUdpTransport<br/><i>(secure-udp feat.)</i>"]
end
subgraph WATCH["varta-watch"]
BL["BeatListener"]
BL --> UdsL["UdsListener"]
BL --> UdpL["UdpListener<br/><i>(udp feat.)</i>"]
BL --> SecL["SecureUdpListener<br/><i>(secure-udp feat.)</i>"]
end
Agent side (varta-client)
#![allow(unused)]
fn main() {
pub trait BeatTransport: Send + 'static {
fn send(&mut self, buf: &[u8; 32]) -> io::Result<usize>;
fn reconnect(&mut self) -> io::Result<()>;
}
}
Varta<T: BeatTransport> owns a transport and calls send(2) on every beat().
The default transport is UdsTransport (Unix Domain Socket). When the udp
feature is enabled, UdpTransport is available via Varta::connect_udp(addr).
When the secure-udp feature is enabled, SecureUdpTransport is available
via Varta::connect_secure_udp(addr, key) — every beat is encrypted with
ChaCha20-Poly1305 AEAD (RFC 8439).
Observer side (varta-watch)
#![allow(unused)]
fn main() {
pub trait BeatListener: Send + 'static {
fn recv(&mut self) -> RecvResult;
fn drain_decrypt_failures(&mut self) -> u64 { 0 } // default = 0
fn drain_truncated(&mut self) -> u64 { 0 } // default = 0
}
}
The Observer holds a Vec<Box<dyn BeatListener>> and polls all listeners
round-robin on each poll() call. When --udp-port is passed at the CLI,
a UdpListener is added alongside the UDS listener.
Transport comparison
| | UDS (default) | UDP (feature = “udp”) | Secure UDP (feature = “secure-udp”) |
|—|—|—|—|—|
| Addressing | Filesystem path | IP:PORT | IP:PORT |
| Encryption | None (kernel isolation) | None | ChaCha20-Poly1305 AEAD |
| Authentication | Kernel PID + UID via SO_PASSCRED / SCM_CREDS where available; socket permissions only on macOS pathname UDS | None | Poly1305 tag + PID in IV prefix (master-key mode) — wire-content only, not the sending process |
| Replay protection | None (local IPC) | None | Per-sender IV counter monotonicity |
| Trust model | Filesystem permissions + kernel credential attestation on supported kernels | Network segmentation | 256-bit pre-shared or per-agent derived key |
| Origin classification | KernelAttested on Linux / supported BSDs / illumos / Solaris; SocketModeOnly on macOS pathname UDS | NetworkUnverified | NetworkUnverified (cryptographic binding != kernel attestation) |
| Recovery-eligible by default? | Yes where kernel-attested; no on socket-mode-only targets | No (see [peer-authentication.md → Recovery eligibility]) | No (same gate; even master-key derivation cannot replace kernel attestation) |
| Frame size | 32 bytes | 32 bytes | 60 bytes (AEAD overhead) |
| Socket cleanup | UdsListener::drop unlinks socket | Kernel reclaims port | Kernel reclaims port |
| Use case | Local IPC, process monitoring | IoT/edge, microservices | Anything crossing untrusted networks |
Recovery-on-UDP is structurally rejected by default. Combining any recovery flag (
--recovery-exec/--recovery-exec-file) with--udp-portis a startup hard-error unless the operator passes the transport-qualified accept flag for that listener —--secure-udp-i-accept-recovery-on-unauthenticated-transportfor a secure-UDP listener, or--plaintext-udp-i-accept-recovery-on-unauthenticated-transportfor a plaintext one. That flag stamps the listener’s beatsOperatorAttestedTransport, which the runtime origin gate (Recovery::on_stall) accepts; without it, UDP beats stayNetworkUnverifiedand recovery is refused. It is the single switch — there is no separate runtime opt-in. Seebook/src/architecture/peer-authentication.mdfor the full threat model.
CLI additions
# Listen on UDS only (default)
varta-watch --socket /tmp/varta.sock --threshold-ms 500
# Listen on UDS + UDP (requires --features udp at build time)
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --udp-bind-addr 0.0.0.0
# UDP-only (no UDS)
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000
# UDP with ChaCha20-Poly1305 encryption
# Generate a 256-bit key (64 hex chars)
openssl rand -hex 32 > /tmp/varta.key
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --key-file /tmp/varta.key
# Rotation: accept old key while transitioning to new key
openssl rand -hex 32 > /tmp/varta-new.key
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --key-file /tmp/varta.key \
--accepted-key-file /tmp/varta-new.key
# Per-agent key derivation from master key
# The observer derives agent-specific keys from the PID embedded in
# each frame's iv_random prefix. Compromise of one agent's key does
# not reveal other agents' keys or the master key.
openssl rand -hex 32 > /tmp/varta-master.key
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --master-key-file /tmp/varta-master.key
The combined shared-key set from --key-file and --accepted-key-file
is capped at 8 keys. The observer trials every shared key on every secure-UDP
datagram to avoid leaking the active rotation slot through response timing, so
the cap is also a poll-loop work bound.
Feature flags
| Crate | Flag | Effect |
|---|---|---|
varta-vlp | crypto | Enables ChaCha20-Poly1305 AEAD (seal, open, Key). No_std-compatible — all four RustCrypto deps are default-features = false. |
varta-vlp | std | Opt-in std-dependent conveniences (Key::from_file, std::path::Path-typed helpers). Off by default so the crate is #![no_std] + alloc-free out of the box — ready for FreeRTOS/Zephyr targets. |
varta-client | udp | Enables UdpTransport, Varta::connect_udp(), install_panic_handler_udp() |
varta-client | secure-udp | Enables SecureUdpTransport, Varta::connect_secure_udp(); implies udp, varta-vlp/crypto, and varta-vlp/std (the secure_udp example calls Key::from_file). |
varta-watch | udp | Enables UdpListener, --udp-port / --udp-bind-addr CLI flags |
varta-watch | secure-udp | Enables SecureUdpListener, --key-file / --accepted-key-file / --master-key-file; implies udp-core |
varta-tests | udp | Enables UDP integration tests |
varta-bench | udp | Enables udp-latency benchmark subcommand |
Security
-
UDS: On Linux, the kernel attests the sender’s PID and UID via
SCM_CREDENTIALS. The observer rejects frames whereframe.pid != peer_pidorpeer_uid != observer_uid. Linux recovery eligibility also requires the observer to pin the sender’s/proc/<pid>/statstart-time generation before first contact can becomeKernelAttested; an unpinned first-contact beat is tracked asSocketModeOnly. On macOS pathname datagram sockets,LOCAL_PEERTOKENrequires a connected local socket and the observer falls back to--socket-mode 0600. On other platforms without per-datagram credentials, the only defence is--socket-mode. -
UDP (plaintext): No kernel credential mechanism exists.
peer_pidis always 0, which causes the observer to skip PID verification. Trust must be established at the network layer — firewall rules, VPC boundaries. -
UDP (secure): Every frame is encrypted with ChaCha20-Poly1305 (RFC 8439) using a 256-bit key. Primitives are provided by the
chacha20poly1305crate (RustCrypto, NCC Group audit 2020) — no hand-rolled crypto. Key derivation uses HKDF-SHA256 (RFC 5869) via thehkdf+sha2crates. Two key modes:- Shared key: A single pre-shared key for all agents (
--key-file). - Master key: Per-agent keys derived from the agent’s PID via HKDF-SHA256
(
--master-key-file). The PID is embedded in theiv_randomprefix so the observer can derive the correct agent key before decryption. Compromise of one agent’s key does not reveal other agents’ keys or the master key. Note: the HKDF-based KDF is incompatible with the ChaCha20-PRF KDF used in earlier releases — agents must re-key when upgrading from a pre-RustCrypto build if master-key mode was in use. - Replay attacks are blocked by enforcing monotonic IV counters per sender.
Key rotation is supported via
--accepted-key-file(no downtime required). - Panic-hook entropy:
install_panic_handler_secure_udpreads all IV material at install time and fails closed if the entropy chain (getrandom,getentropy,/dev/urandom) is unavailable. Forked-child panic prefixes are derived from that pre-read salt with HKDF, so the hook never calls the OS entropy chain. In chrooted environments without/dev, useinstall_panic_handler_secure_udp_accept_degraded_entropyto opt into a non-cryptographic fallback — seebook/src/architecture/peer-authentication.mdfor the full nonce-reuse risk analysis.
- Shared key: A single pre-shared key for all agents (
-
Recovery commands: Exec mode only (shell mode was permanently removed):
--recovery-exec: Command executed directly viaexecvp(2)with{pid}replaced in arguments; the pid is also appended as the final argument. No shell is involved.--recovery-exec-file: Read the program + args from a hardened file with mandatory ownership/permission checks (UID match, mode ≤ 0600).
Container / PID-namespace semantics
Frame.pid carries the agent’s PID in the agent’s PID namespace. The
observer’s kernel-attested peer PID (SO_PASSCRED / SCM_CREDS) is in the
observer’s namespace. When the two namespaces
differ:
- The pid in the frame cannot be used to identify a process the observer can
kill(2)orsystemctl restart— the same numeric PID refers to a different process in each namespace. - The existing
frame.pid == peer_pidcheck at observer ingress catches most cases (different namespaces usually produce different numeric pids), but same-pid collisions across containers (every container’s first process is PID 1) are invisible to that gate.
varta-watch therefore (Linux only):
- Reads
/proc/self/ns/pidonce at startup and caches the inode as the observer’s namespace identity. - For every kernel-attested beat (UDS), reads
/proc/<peer_pid>/ns/pidand compares the inode to the observer’s. Mismatch ⇒ drop the beat (varta_frame_namespace_mismatch_total++) and emitEvent::NamespaceConflict. - Per-pid tracker slots pin the namespace inode at first beat; a later beat
with a different
Some(_)inode is rejected asUpdate::NamespaceConflict(varta_tracker_namespace_conflict_total++). - Recovery commands refuse to spawn for cross-namespace stalls and log an
audit record with
reason=cross_namespace_agent(varta_recovery_refused_total{reason="cross_namespace_agent"}++).
Escape hatch — --allow-cross-namespace-agents
When agents are intentionally run with --pid=host (containers sharing the
host PID namespace), the observer’s namespace and the agents’ namespace agree
at the kernel level — the gate above is a no-op.
For deployments where the agent runs in a private namespace and the
operator has out-of-band PID translation (e.g. CNI metadata that lets a
recovery script translate container pids to host pids), pass
--allow-cross-namespace-agents. The audit log and metrics still fire, but
beats are admitted and recovery is permitted.
--strict-namespace-check
Treat namespace mismatch as a fatal startup error: on the first
Event::NamespaceConflict, the daemon logs a FATAL line and exits with a
non-zero status. Used in environments where the operator wants the daemon to
fail loudly rather than silently log audit refusals.
Non-Linux platforms
PID namespaces are a Linux kernel concept. On macOS and the BSDs,
observer_pid_namespace_inode() returns None and all comparisons
short-circuit to “match”. The CLI flags are accepted for portability but
have no runtime effect.
UDP transports
UDP listeners (plain or secure) have no kernel peer-cred mechanism.
peer_pid is 0; peer_pid_ns_inode is None. Recovery is already refused
for NetworkUnverified origins by the existing transport gate — namespace
mismatch adds nothing for UDP. See
peer-authentication.md for the full trust model.
Secure UDP — replay-state capacity boundary (H4)
SecureUdpListener keeps per-sender replay state in a bounded table
indexed by the authenticated VLP frame PID:
- Capacity:
MAX_SENDER_STATES = 1024simultaneously-tracked senders. - Known senders can advance their replay state even while the table is full.
- Unknown senders are accepted only if a stale-sender sweep frees a slot.
- If the table remains full, the authenticated frame is consumed and refused; no live sender’s replay state is evicted to admit it.
This is a fail-closed replay posture. A reachable network can still create an availability event by sending authenticated traffic for enough unique PIDs to fill the table, but it cannot make the listener forget an existing sender’s last counter and then accept a captured older ciphertext for that sender.
Why the table refuses instead of evicting
Any finite eviction shadow can be rotated out by enough authenticated chaff. The only replay-safe bounded behavior is to preserve live replay state and refuse new identities at capacity. This trades admission of new senders for nonce monotonicity of already-tracked senders, which matches the safety profile used elsewhere in Varta: capacity pressure is visible and alertable, but replay state is not sacrificed silently.
Mitigation
varta-watch defaults --udp-bind-addr to 127.0.0.1 when secure-UDP
keys are configured. Operators who genuinely need the listener to accept
non-loopback peers must pass --i-accept-secure-udp-non-loopback
explicitly — a CLI flag whose name signals the residual availability risk.
When the flag is set, a high-visibility startup warning is emitted to stderr
and the operator is expected to constrain network reach (firewall, private
VLAN, mTLS-fronted tunnel) so that no untrusted host can reach the bound port.
The recovery gate on NetworkUnverified origins (see
peer-authentication.md) remains independent
of this flag — opting in to non-loopback secure-UDP does NOT enable
recovery commands from UDP-origin beats. Those still require the
separate
--secure-udp-i-accept-recovery-on-unauthenticated-transport
acknowledgement.
Secure UDP — session-restart replay window (H5)
SecureUdpListener resets a sender’s per-PID replay high-water when a
recycled PID legitimately restarts its session. A process that dies and has
its PID reused by a fresh agent reconnects with a new session salt, so its
IV prefixes and VLP nonce sequence restart from the beginning; after
SESSION_RESTART_GAP (= --threshold-ms) of silence the listener treats the
first frame on an aged-out prefix as a session restart and resets the
per-sender max_regular_nonce so the new low-nonce session is admitted.
That reset opens a bounded replay window. An on-path attacker who captured a frame from the dead session — one whose nonce is higher than the recycled session’s reset baseline; no key is needed to resend a captured ciphertext — can replay it in the window after the reset. Because the captured nonce exceeds the reset baseline it is accepted, forging a single liveness beat attributed to the recycled PID (and advancing the high-water to the replayed value).
Bounded by. The attack requires all of: (a) a captured dead-session frame,
(b) the PID to be recycled, (c) SESSION_RESTART_GAP of silence, and (d) the
replay to land before the recycled agent’s own nonce climbs past the captured
value. The forged beat carries a NetworkUnverified origin (or, under the
explicit opt-in, OperatorAttestedTransport): recovery commands remain gated
behind --secure-udp-i-accept-recovery-on-unauthenticated-transport and are
not triggerable by a replayed beat alone. The residual impact is therefore
forged liveness (a recycled PID briefly appearing alive) plus a poisoned
nonce high-water — not arbitrary command execution.
Root cause. Nonce-based replay protection cannot distinguish a recycled agent’s fresh-but-low-nonce session from a replayed high-nonce frame of the dead session, because the recycled agent derives a new session salt (and thus new IV prefixes) that the observer cannot bind to: VLP v0.2 carries no wire-level session/epoch identifier. Closing the window fully requires a protocol change — a per-session epoch in the frame that the observer binds into replay state — so it is deferred to a future VLP version rather than patched in the listener. Any listener-only heuristic either re-opens the window or rejects a legitimately fast-beating recycled agent (its nonce can outpace any fixed post-reset jump bound), so no clean implementation-only fix exists.
Mitigation. The loopback-default binding (H4) and the recovery-origin gate
already constrain who can reach the port and what a replayed beat can do.
Operators who require strict cross-session replay rejection should keep
secure-UDP loopback-only or front it with an authenticated tunnel; a shorter
--threshold-ms narrows (but does not eliminate) the window.
Fork-safety on secure-UDP
After fork(2), a child process inherits its parent’s
SecureUdpTransport state — the 16-byte iv_session_salt, the
iv_prefix_index, and the iv_counter. Three nominally-independent
fields whose product defines the AEAD nonce. If the child ever calls
Varta::beat() without intervention, it derives the same 12-byte
ChaCha20-Poly1305 nonce its parent has already emitted under the same
key — a catastrophic confidentiality and integrity failure (Poly1305
key recovery, plaintext XOR leak).
How Varta enforces fork-safety structurally
Varta::connect snapshots both std::process::id() and a process-lineage
epoch maintained by a one-time pthread_atfork child callback. Every
Varta::beat reads the current PID and epoch and compares both — on either
mismatch, the wrapper invokes transport.reconnect() before building the
frame. The epoch changes on every fork and therefore remains distinct even
if a later descendant is assigned the original connect-time PID.
SecureUdpTransport::reconnect() re-reads OS entropy into a fresh
16-byte session salt, recomputes the IV prefix, and resets the prefix
index and counter to zero. The child’s first emitted frame therefore
uses an IV prefix derived from independent entropy — nonce collision
across the fork boundary is impossible.
Auto-recovery is silent: the caller observes BeatOutcome::Sent. The
event is observable via Varta::fork_recoveries() -> u64 (suggested
Prometheus name: varta_client_fork_recoveries_total). The local
session epoch resets too — nonce → 0, start → Instant::now(),
last_timestamp → 0, consecutive_dropped → 0 — so the child’s
wire stream looks like a fresh session to the observer.
Observer view
The observer’s per-sender state in SecureUdpListener is keyed by the
authenticated VLP frame PID, with a 1-deep IV-prefix history per sender
(see H4 replay-state capacity
above). When the forked child sends frames with a new IV prefix, the
observer transitions its current state into the prev_* slots and accepts
the new prefix as a fresh session — no replay error, no protocol-level
signal required. Fork-recovery is entirely transparent to the wire format.
Advanced callers
Callers using SecureUdpTransport directly receive the same protection.
The transport snapshots the process-lineage epoch at construction and checks
it before every seal, reconnecting before inherited AEAD state can be used in
a child.
Parent-pid stall window (transport-agnostic)
Auto-recovery handles the child. The parent does not get a free pass:
if the parent forks and then exit(0)s (the daemonise pattern), its PID
disappears from the kernel but the observer’s tracker slot for that PID
keeps aging. After --threshold-ms the slot stalls; if recovery is
configured for kernel-attested origins, the observer may fire a recovery
command for a PID that no longer exists. This applies to every transport
(UDS, plaintext UDP, secure UDP) — it is a property of the
silence-equals-stall contract, not of any particular wire format.
The fix is on the agent side. The recommended pattern is to emit a final
Status::Critical beat from the parent immediately before its terminal
exit() — the observer records the critical frame and treats subsequent
silence as expected closure rather than as a stall. See
crates/varta-client/README.md — Fork recovery & tracker semantics
for the operator-side patterns and the alternative --threshold-ms
widening approach.
Panic-hook parallel
install_panic_handler_secure_udp caches an 8-byte IV prefix plus a
16-byte fork salt at install time to avoid non-async-signal-safe entropy
reads inside the panic hook itself. The same fork hazard applies: a child
that panics would otherwise emit (cached_iv, iv_counter=0) — colliding
with the parent’s identical pair if the parent panicked too. The installer
snapshots install_pid; if the hook later sees a different PID, it derives
a child-specific IV prefix with HKDF-SHA256 over the pre-read fork salt,
the panic PID, the panic timestamp, and the AEAD counter. No getrandom,
getentropy, or /dev/urandom call happens from the hook body. The strict
variant fails closed at install time when no entropy source is reachable;
the accept-degraded-entropy variant falls back to fallback_iv_random()
and fallback_iv_session_salt() per the documented degraded-entropy policy.
Cross-references
- Observer liveness — the watcher’s own liveness story: in-process self-watchdog, systemd
sd_notify, hardware watchdog, and paired-observer pattern - Safety profiles — compile-time vs. runtime feature gating for production-safe builds
- Peer authentication — kernel-level PID attestation and transport trust classification
- Namespaces — dedicated reference for cross-namespace deployments
Future transports
Additional transports can be implemented by implementing BeatTransport (agent
side) and BeatListener (observer side) without touching the protocol core:
- Shared memory (
memfd,shm) — Wasm plugins writing directly to a shared ring buffer - Unix pipes (
pipe,fifo) — stdin/stdout health frames for supervised processes - WebSocket — for browser-based health dashboards