SignedPulse lets a server learn and verify the real UDP source IP of a client that lives behind NAT or on a dynamic IP, then run a configured hook command with that IP as an argument. The exchange is authenticated with Ed25519 signatures over a non-replayable challenge/response protocol, framed in a compact binary format and (by default) fully encrypted so a network sniffer learns nothing — not even the protocol or the client id.
A typical use: a roaming machine periodically "pulses" your server; the server verifies the pulse really came from that machine and updates a firewall allow-list, a DNS record, or a port-knock rule with the freshly observed IP.
- The client is behind NAT / has a dynamic IP and cannot reliably know its own public address.
- The server can see the public source IP of any UDP datagram it receives.
- SignedPulse proves the datagram genuinely came from an authorized client (not a spoofer, not a replay) before trusting that observed IP and acting on it.
SignedPulse is a strong, modern port knock. A classic port knock is a fixed, sniffable, replayable sequence; SignedPulse instead requires an Ed25519 signature over a single-use server nonce, optionally inside a fully encrypted datagram — so the knock cannot be observed, replayed, or forged, and the server learns the client's real source IP from packet metadata. Point the hook at your firewall to open a pinhole for that IP:
# server.toml
[command]
argv = ["/usr/local/sbin/signedpulse-hook", "grant", "{ip}", "{client_id}", "{new}"]# signedpulse-hook (nftables): $1 is the action (grant|revoke), $2 the verified IP
case "$1" in
grant) nft add element inet filter signedpulse_allow "{ $2 }" ;;
revoke) nft delete element inet filter signedpulse_allow "{ $2 }" ;;
esac(See examples/signedpulse-hook.sh for the full grant/revoke hook, and
"Access while pulsing" below for the matching revoke_argv config.) The client
can even choose which port to open by emitting it from
param_command and passing {param} to the hook (validate it against an
allow-list — see examples/signedpulse-hook.sh).
Rather than leaving the pinhole open forever, let SignedPulse close it for you.
Set a revoke hook and the server tracks a lease per client: each
verified pulse runs the grant hook (command.argv) and renews that client's
lease; when a client stops pulsing, the server runs command.revoke_argv to
close it.
# server.toml
[command]
argv = ["/usr/local/sbin/signedpulse-hook", "grant", "{ip}", "{client_id}", "{new}"]
revoke_argv = ["/usr/local/sbin/signedpulse-hook", "revoke", "{ip}", "{client_id}", "{reason}", "{ip_clients}"]The lease TTL is derived from the client's own pulse interval, which it
advertises in each RESPONSE: TTL = interval × lease_grace_multiplier
(default 3 → revoke after ~3 missed pulses), capped by lease_max_seconds. So a
client that keeps pulsing stays allowed; one that goes away is revoked
automatically — no separate timer or reconciler needed.
The grant hook also gets a {new} flag: 1 on a new or reactivated session
(first pulse, or the first after the lease expired) and 0 on a keep-alive
renewal — so you can log/notify only when access is freshly granted.
The revoke hook gets a {reason} placeholder so it can tell why it ran:
expired (the lease timed out — the client went silent) or bye (the client
released it explicitly on shutdown — see below). The grant hook receives
reason=grant. Use it to branch, e.g. revoke … "{reason}" then in the hook
case "$reason" in bye) … ;; expired) … ;; esac.
Shared NAT. Everything keys on the authenticated client_id — leases,
cooldown, {new}, per-client status, and the hooks all fire per client,
independently. The revoke hook always runs when a client's lease ends; the
server hands it {ip_clients} — the number of other clients still holding a
live lease on the same source IP. The hook decides what that means: for a
firewall that filters by IP, close the pinhole only when {ip_clients} is 0
(you were the last client behind that NAT) — a still-pulsing sibling keeps it
open. The firewall is just one consumer; another hook might log, page, or update
a per-client record regardless of the count.
revoke_argv = ["/usr/local/sbin/signedpulse-hook", "revoke", "{ip}", "{client_id}", "{reason}", "{ip_clients}"]Make both hooks idempotent: the grant runs on every pulse (and, behind a
shared NAT, once per client → repeated nft add of the same IP), and because
leases live in memory a server restart forgets them (a grant may recur and a
revoke may be skipped until the client pulses again). See
examples/signedpulse-hook.sh for a complete grant/revoke nftables hook.
When the client daemon stops gracefully (SIGTERM / Ctrl-C), it sends a signed
BYE to each server, which releases the lease immediately — the revoke
hook runs right away instead of after the grace period. A BYE is a full
HELLO → CHALLENGE → BYE exchange (same single-use nonce and anti-replay as a
pulse) signed over a distinct payload, so a captured RESPONSE can never be
re-framed into a release. It is on by default; set bye_on_shutdown = false in
[client] to disable. A BYE can also carry an optional param (sealed/signed
like a pulse's) passed to the revoke hook. Old servers that don't understand
BYE simply drop it and the lease times out as before.
You can also release on demand without stopping the daemon:
signedpulse-client bye # release the lease on every server
signedpulse-client bye --param drain # … and pass {param} to the revoke hookBy default a server restart just forgets its in-memory leases — it does not
run the revoke hook, so any pinhole stays open until the hook's own backstop
timeout (or the client BYEs / stops pulsing). Set revoke_on_shutdown = true in
[command] to instead run revoke_argv for every active lease with
{reason} = "shutdown" on a graceful stop (SIGTERM/SIGINT), closing pinholes
immediately. It is off by default on purpose: SIGTERM is indistinguishable
from a systemctl restart, so with it on, every restart tears down all access
until each client's next pulse (up to interval_seconds later). And a crash or
SIGKILL never runs it — so the hook must still carry its own timeout
backstop regardless. Enable it for hosts where fail-closed teardown matters more
than seamless restarts; leave it off to keep access sticky across a quick bounce.
Besides the long-running daemon (no subcommand), the client has one-shot commands that run a single handshake against every configured server and exit with a non-zero code if any fails (handy for cron/scripts):
| Command | Retries? | Effect |
|---|---|---|
signedpulse-client pulse |
no (one attempt) | renew the lease once |
signedpulse-client ping |
yes (SIP backoff) | renew the lease, retrying |
signedpulse-client bye |
no | release the lease (signed BYE) |
pulse and ping send the configured param_command output as usual, or you
can override it for a single run with --param <value> (sealed and signed like
the configured param, subject to the same param_max_len):
signedpulse-client pulse --param deploy-v2What SignedPulse defends against
- Spoofed / unauthorized clients — both the HELLO and the RESPONSE are Ed25519-signed, so only a holder of a configured client private key can elicit a CHALLENGE or produce a valid RESPONSE.
- Replay — a captured RESPONSE cannot be reused: each response is tied to a single-use, short-lived nonce bound to the requesting client_id and exact UDP source IP/port, with the signature covering that nonce plus protocol metadata. A captured HELLO cannot be reused either: it is bound to a timestamp (checked against a skew window) and a fresh nonce kept in a short-lived replay cache. (See "Replay protection" below.)
- Command injection via client-controlled data — the hook is run from an
argvarray with safe placeholder substitution; no shell is involved unless you explicitly opt in. - Eavesdropping (optional, on by default) — with
wire_encryption, every datagram is an opaque sealed blob (anonymous X25519 + XChaCha20-Poly1305 to the server's key); the client id, nonces, packet type and the optional parameter are all hidden. The optional parameter is always encrypted even withwire_encryptionoff. - Server impersonation (encrypted mode) — the CHALLENGE is sealed with the server's static X25519 key, so a client only accepts a CHALLENGE that an on-path attacker cannot forge without the server's secret. (In cleartext mode the CHALLENGE is unauthenticated; the client still cannot be made to reveal its parameter, which is sealed to the server's key regardless.)
- Unauthenticated floods / CPU exhaustion — HELLO packets are rate-limited
per source IP, packet size is bounded, concurrent executions are capped, and a
source IP that sends more than
max_faulty_packetsmalformed/undecryptable packets is blacklisted (dropped before any decryption work).
What it does not do
- With
wire_encryption = off, packets are compact binary cleartext (still signed); an observer can then see the client id and nonces (but not the parameter, which stays sealed). The defaultrequiredmode hides everything. - It does not defend against an attacker who has stolen a client's private key.
- UDP source IPs can be spoofed on networks without ingress filtering, but a blind spoofer cannot complete the handshake: the CHALLENGE is sent to the claimed source address, so the attacker would also need to receive it.
Client Server
| |
| HELLO {client_id, ts, hello_nonce, |
| signature} |
|---------------------------------------->| record source IP/port (from socket)
| | (decrypt sealed datagram if enabled)
| | rate-limit per source IP
| | verify HELLO signature (authorize)
| | check ts within skew window
| | reject if (client_id, hello_nonce)
| | already seen (replay)
| | mint 256-bit single-use nonce,
| | bind to (client_id, ip, port, ttl)
| CHALLENGE {client_id, nonce, expires} |
|<----------------------------------------|
| |
build canonical payload, sign with Ed25519 |
(optionally generate + seal a parameter) |
| RESPONSE {client_id, nonce, [param], |
| signature} |
|---------------------------------------->| verify: client known? nonce valid,
| | unexpired, unused, same client,
| | same source IP/port? signature ok?
| | -> consume nonce (single-use)
| | -> decrypt param, run argv with
| | source IP from packet metadata
Both the HELLO and the RESPONSE are signed. Each uses a canonical signing
payload built by one shared function used by both peers, so the bytes that get
signed can never drift — the signature is computed over this text, never over
the wire bytes. client_id is hex; nonce/param are base64. server_id is
not transmitted; both sides use their configured value, so a client aimed at
the wrong server simply fails verification.
RESPONSE payload (param is the base64 ciphertext, or empty — encrypt-then-sign):
signedpulse:v2:response
server_id=<server_id>
client_id=<client_id_hex>
nonce=<base64_nonce>
interval=<advertised_pulse_interval_seconds>
expires_at=<expires_at_unix>
param=<base64_ciphertext_or_empty>
The interval is the client's own pulse cadence, advertised so the server knows
when to expect the next pulse (it drives the access lease — see "Access while
pulsing"). It is signed, so an on-path attacker cannot alter it.
A BYE (clean-shutdown lease release — see "Access while pulsing") uses the same fields but a distinct header, so a captured RESPONSE signature can never be replayed as a release (and vice versa):
signedpulse:v2:bye
server_id=<server_id>
client_id=<client_id_hex>
nonce=<base64_nonce>
interval=<advertised_pulse_interval_seconds>
expires_at=<expires_at_unix>
param=<base64_ciphertext_or_empty>
HELLO payload (signed with a fresh per-HELLO nonce and a timestamp):
signedpulse:v2:hello
server_id=<server_id>
client_id=<client_id_hex>
timestamp=<client_timestamp_unix>
hello_nonce=<base64_nonce>
Packets use a compact hand-rolled binary framing. In cleartext form a packet
is header(2) || body, where the 16-bit header packs a magic byte, a 4-bit
version and a 4-bit packet type; client_id is a 256-bit (32-byte) value. A
HELLO is ~122 bytes on the wire.
Wire compatibility. The current protocol version is v2 (the RESPONSE carries the client's advertised
interval). It is not compatible with v1: a v1 packet is rejected (and silently dropped). Upgrade the server and all clients together.
With wire_encryption (default required), the datagram on the wire is instead
a bare opaque sealed blob — no header, no magic — so the protocol is
unfingerprintable and a sniffer sees only random-looking bytes. Encryption is an
anonymous X25519 sealed box (ephemeral X25519 + ECDH + HKDF-SHA256 →
XChaCha20-Poly1305) to the server's X25519 key, with the AEAD bound to a
per-use context string (wire/param/reply) so a ciphertext from one context
cannot be opened in another. The server seals the CHALLENGE back using its
static secret keyed to the client's transport-ephemeral key (a static-ephemeral
ECDH): since only the holder of the server's secret can produce a reply the
client accepts, this authenticates the server to the client — yet still needs
no per-client keys. The inner plaintext is the same signed binary packet, so
authentication/replay protection are unchanged.
The HELLO is the first packet, so the server has no prior nonce to bind it to.
Instead, the client signs the HELLO over its own timestamp and a fresh random
hello_nonce. On receipt the server:
- verifies the signature against the configured public key — so it only ever
replies to authorized, key-holding clients, not to anyone who merely
knows a (non-secret)
client_id; - rejects the HELLO if its timestamp is outside
hello_max_skew_seconds(default 30s) of the server clock; and - rejects it if
(client_id, hello_nonce)was already seen — blocking replays of a captured HELLO.
Because each HELLO (including legitimate retries) carries a new random nonce, a genuine retry is always distinct, while a third party replaying captured HELLO bytes collides in the replay cache and is dropped. This requires loose clock synchronization (NTP) between client and server. The replay cache is in-memory and bounded by the skew window; it is cleared on server restart (see Security notes).
A previously captured HELLO cannot be replayed because it is signed over a timestamp (rejected outside the skew window) and a fresh nonce that the server remembers for the duration of that window — a resent HELLO collides with the cached nonce and is dropped.
A previously captured RESPONSE cannot be replayed because the nonce it carries:
- is single-use — consumed atomically on first valid use, then rejected as a replay for the remainder of its lifetime;
- expires quickly (
nonce_ttl_seconds, default 30s); - is bound to the client_id that requested it;
- is bound to the UDP source IP/port that requested it; and
- is covered by the signature along with the protocol metadata, so it cannot be lifted into a different message.
The server is designed to give a scanner nothing. It emits exactly one kind
of outbound packet — a CHALLENGE — and only in response to a HELLO that has
passed every check: rate limit, known client_id, valid Ed25519 signature,
in-window timestamp, and not-already-seen nonce. There is precisely one send
call in the whole server, and it sits at the end of that pipeline.
Everything else is dropped with no reply at all:
- random/garbage bytes, or (in encrypted mode) anything that fails to decrypt;
- a malformed binary packet (bad magic/version/type/length);
- a HELLO for an unknown
client_id; - a HELLO with an invalid signature;
- a HELLO with a stale/out-of-window timestamp;
- a replayed HELLO (seen nonce);
- a stray CHALLENGE or RESPONSE sent to the server unprompted.
The RESPONSE handler is not even given the socket, so it is structurally
incapable of replying — it can only run the hook or drop. As a result the server
does not confirm its own existence, the protocol it speaks, or whether a given
client_id is configured. A port scan or a malformed-packet probe sees silence.
This is enforced by the server_is_silent_to_probes integration test, which
fires a battery of bad inputs and asserts zero datagrams come back.
Bounding decryption cost. Because each datagram in encrypted mode triggers a
decryption attempt, a flood of fake packets could otherwise burn CPU. A source
IP that sends more than max_faulty_packets (default 10) packets that fail to
decrypt or decode is blacklisted for blacklist_seconds, and blacklisted
IPs are dropped before any decryption — checked first in the receive path. Only
those cheap-to-detect failures count; rate-limited, post-decrypt, or
authentication failures do not feed the blacklist, so a chatty or
misconfigured (or shared-NAT) legitimate client cannot blacklist itself. The
blacklisting is logged once, then further packets from that IP are dropped
silently until the block expires.
Caveat: the blacklist is keyed on the UDP source address, which is spoofable on
networks without ingress filtering — an attacker who can forge a victim's source
IP could get that IP blocked. Pair it with network-level anti-spoofing where that
matters, or set max_faulty_packets = 0 to disable it.
Attack lockdown. If more than attack_blacklist_threshold distinct IPs are
blacklisted within attack_window_seconds (i.e. a broad flood is underway), the
server enters lockdown: it only processes datagrams from source IPs that
recently completed a handshake (an authenticated HELLO or verified pulse, kept
for active_ip_ttl_seconds), dropping all other sources before any decryption.
Already-active clients keep working; new/idle clients are turned away until the
flood subsides. Lockdown is self-clearing — it lifts automatically once the
blacklisting events age out of the window — and is logged once on entry. Set
attack_blacklist_threshold = 0 to disable it.
Independently, the server caps the number of datagrams processed concurrently
(max_inflight_packets, default 1024 — excess is dropped while at capacity) and
the number of distinct blacklist-tracked IPs (max_tracked_ips, default
100000). Together these bound CPU, task, and memory growth even under a
distributed flood that never trips any single IP's blacklist.
A note on timing. Internally, an unknown client_id is rejected a touch
earlier than a known client with a bad signature (the latter spends time
verifying). Because the server never replies, a remote attacker has no
round-trip to measure and therefore cannot observe that difference — there is no
timing oracle over the network. It would only be measurable by an attacker
already running code on the same host (CPU/cache side channels), which is a far
stronger threat model than the network scanner this design targets and is out of
scope.
signedpulse/
Cargo.toml # workspace
examples/server.toml # example server config
examples/client.toml # example client config
crates/
signedpulse-common/ # protocol, crypto, config (no async/net)
src/protocol.rs # packets + canonical signing payloads
src/crypto.rs # Ed25519, nonce generation
src/config.rs # TOML config structs
src/service.rs # systemd/launchd unit rendering + install
src/status.rs # on-demand status snapshot + PID/signal helpers
signedpulse-server/
src/server.rs # UDP loop, decryption, verification pipeline
src/nonce_store.rs # in-memory, single-use nonce store
src/seen_cache.rs # HELLO replay cache
src/command_runner.rs # CommandExecutor trait + process runner
src/rate_limit.rs # HELLO rate limit, cooldown, IP blacklist
signedpulse-client/
src/client.rs # handshake + periodic run loop
xtask/ # `cargo deploy` (build + install)
The signedpulse crate ships both binaries (signedpulse-server and
signedpulse-client), so a single command installs them to ~/.cargo/bin:
cargo install signedpulse # from crates.io (once published)
cargo install --git https://github.com/aallamaa/signedpulse signedpulse # from Git
cargo install --path crates/signedpulse # from a local checkoutFor a system-wide install (default prefix /usr/local), use cargo deploy,
which builds --release and installs both binaries:
sudo cargo deploy # installs to /usr/local/bin
cargo deploy --prefix ~/.local # user install, no sudo
cargo deploy --uninstall # remove them(cargo deploy is a workspace alias for the bundled xtask.)
The whole setup is three commands once the binaries are in place.
# Write a starter config (defaults: bind 0.0.0.0:7370, server_id signedpulse-main,
# hook /usr/local/sbin/signedpulse-hook). Generates the server X25519 encryption
# key and prints its public half for clients.
sudo signedpulse-server init
# Install + start as a systemd service (auto-runs daemon-reload + enable --now).
sudo signedpulse-server install-serviceinit prints the exact line to run on each client, including the
--server-key <PUBLIC> value. It also fills in --server <HOST> for you: if you
bound to a concrete address it uses that, otherwise it auto-detects this host's
IP from the default route (the source address the kernel would use for outbound
traffic). On a NAT'd box that is the private IP, so replace it with the
public/reachable address if clients reach you differently. Edit
/etc/signedpulse/server.toml to point command.argv at your hook program.
Point the client at the server and give it the server's encryption public key (from step 1). The client generates its own 256-bit id + signing keypair and prints exactly what to run on the server:
# --server takes "HOST" (port defaults to 7370) or "HOST:PORT".
sudo signedpulse-client init --server 203.0.113.10 \
--server-key "ZCWT…=" --label laptop-1This writes /etc/signedpulse/client.toml (mode 0600, since it holds the
private key) and prints something like:
=== Do this on the SERVER to authorize this client ===
Run:
signedpulse-server add-client --client-id "9819…f8" --public-key "p3y91Ee…=" --label "laptop-1"
(Pass --no-encryption to both inits for a cleartext-binary setup that needs
no encryption key.)
Copy the printed command and run it on the server:
sudo signedpulse-server add-client --client-id "9819…f8" --public-key "p3y91Ee…=" --label laptop-1
sudo systemctl restart signedpulse-server # pick up the new clientadd-client validates the id (64 hex) and public key, refuses duplicates, and
appends a [[clients]] block to the server config.
# Linux (system service):
sudo signedpulse-client install-service
# Linux (per-user service):
signedpulse-client install-service --user
# macOS (installs a launchd LaunchAgent in ~/Library/LaunchAgents):
signedpulse-client install-serviceinstall-service auto-detects the platform (systemd on Linux, launchd on
macOS), writes the unit/plist referencing the running binary and --config
path, and tries to activate it. If it cannot (e.g. not run with privileges), it
prints the exact commands to run. Use --print to preview the unit without
writing anything.
Both binaries have a status subcommand that shows what a running daemon is
doing:
signedpulse-server status
signedpulse-client statusExample output (the terminal view is colorized — green for healthy, red for
errors, yellow for warnings; color is auto-disabled when piped or when
NO_COLOR is set):
$ signedpulse-server status
SignedPulse server
service active (running) [systemctl]
config /etc/signedpulse/server.toml
bind 0.0.0.0:7370 · server_id signedpulse-main · clients 3
pid 4821 · uptime 2h13m
Activity
last pulse 203.0.113.7:51456 · 2m ago
last hook grant "laptop-1" → 203.0.113.7 · exit 0 · 2m ago
last revoke bye "phone-2" → 198.51.100.9 · exit 0 · 40s ago
Counters
hello 144 · verified 140 · rejected 4 · replays 0 · leases 3
Clients (3)
laptop-1
pulse 203.0.113.7:51456 · 2m ago
hook grant · exit 0 · 2m ago
phone-2
pulse 198.51.100.9:33012 · 5m ago
revoke bye · exit 0 · 40s ago
Leases (revoked when the countdown elapses with no new pulse)
203.0.113.7 laptop-1 revoke in 12m43s
$ signedpulse-client status
SignedPulse client
service active (running) [systemctl]
config /etc/signedpulse/client.toml
client_id 9819…f8 · server 203.0.113.10:7370
pid 5120 · uptime 1h02m
Servers (1)
main (203.0.113.10:7370)
last pulse OK · 43s ago
next pulse in ~4m17s
last result ok
For scripting, status --json prints the raw live snapshot instead of the
human view (or null when the daemon isn't running):
$ signedpulse-server status --json | jq .verified
140
How it works (and why it's safe). status is local-only — it never
sends anything over the network, so it does not weaken the "invisible to probes"
property. The daemon keeps its live counters in memory and writes them to a
state file only on demand: status reads the daemon's PID file, sends it
SIGUSR1, the daemon writes a fresh snapshot, and status reads it back. There
are no periodic disk writes and no admin port.
The snapshot and PID files default to $XDG_RUNTIME_DIR/signedpulse/ (falling
back to /run/signedpulse/), are created mode 0600, and are cleared on reboot.
For a per-user systemd service or a macOS launchd agent — where /run is not
user-writable — set state_file in the config to a writable path; both the
daemon and status honour it. Service up/down comes best-effort from
systemctl/launchctl; if neither is available it shows unknown (the live
data still works as long as the daemon is running and can write its state file).
signedpulse-server --config /etc/signedpulse/server.toml
signedpulse-client --config /etc/signedpulse/client.tomlLogging verbosity is controlled with RUST_LOG, e.g. RUST_LOG=debug.
Instead of running the client as a service you can fire a single handshake and exit — handy for cron, scripts, or testing connectivity:
signedpulse-client pulse # one HELLO→CHALLENGE→RESPONSE per server, NO retry
signedpulse-client ping # same, but retries (SIP backoff) if no replyBoth run one cycle against every configured server (the [client] primary and
each [client.servers.*]), print a per-server ok / FAILED line, and exit
non-zero if any server did not respond — so they compose with shell && and
cron alerting. pulse makes exactly one attempt (waiting retry_initial_ms for
the reply); ping makes up to retries attempts with the configured backoff
(retry_initial_ms → ×2 each retry, capped at retry_max_ms). (A daemonized client keeps the access lease alive; a periodic pulse
from cron does too, as long as it runs more often than the lease TTL.)
signedpulse-client init generates keys for you. If you want a bare keypair
(e.g. to manage configs yourself):
signedpulse-client generate-keyThis prints a base64 private key (for the client config) and a base64 public key (for the server config). The private key never leaves the client.
[server]
bind = "0.0.0.0:7370"
server_id = "signedpulse-main"
nonce_ttl_seconds = 30
command_timeout_seconds = 10
client_cooldown_seconds = 60 # default 0 = disabled; 60 shown as an example
max_packet_size = 2048
hello_rate_max = 30 # HELLOs per source IP per window (0 disables)
hello_rate_window_seconds = 60
hello_max_skew_seconds = 30 # accepted clock skew for signed HELLO timestamps
max_faulty_packets = 10 # blacklist a source IP after this many bad packets
blacklist_seconds = 300
attack_blacklist_threshold = 10 # lockdown if > this many IPs blacklisted in the window
attack_window_seconds = 10
attack_rejection_threshold = 256 # ...or > this many rejected packets in the window
active_ip_ttl_seconds = 900 # how long a handshaked IP stays lockdown-allowlisted
max_inflight_packets = 1024 # concurrent datagrams processed (0 = unbounded)
max_tracked_ips = 100000 # cap on per-IP tracking maps (0 = unbounded)
wire_encryption = "required" # or "off" for cleartext binary
encryption_private_key = "BASE64_X25519_SECRET" # generated by `init`
max_param_len = 256
lease_grace_multiplier = 3 # lease TTL = client interval × this (revoke after ~3 misses)
lease_max_seconds = 86400 # cap on a derived lease TTL
[command]
# Placeholders (literal args, no shell): {ip} {client_id} {source_port} {param} {new} {reason} {ip_clients}
# {new} = "1" on a new/reactivated session, "0" on a keep-alive renewal
# {reason} = "grant" | "expired" (lease timed out) | "bye" (client released it)
# {ip_clients} = count of OTHER clients still on this source IP (0 = last one;
# on a revoke, only close an IP-based firewall rule when it's 0)
argv = ["/usr/local/sbin/signedpulse-hook", "grant", "{ip}", "{client_id}", "{new}"]
# Optional per-client revoke (runs on every lease end / BYE; omit to leave open):
revoke_argv = ["/usr/local/sbin/signedpulse-hook", "revoke", "{ip}", "{client_id}", "{reason}", "{ip_clients}"]
working_dir = "/"
max_concurrent = 4
allow_shell = false # DANGEROUS if true; keep false
[[clients]]
client_id = "9819…f8" # 64 hex chars (256-bit)
public_key = "BASE64_ED25519_PUBLIC_KEY"
label = "laptop-1" # optional, for logs/status[client]
client_id = "9819…f8" # 64 hex chars (256-bit)
server_addr = "203.0.113.10:7370"
server_id = "signedpulse-main"
interval_seconds = 300
private_key = "BASE64_ED25519_PRIVATE_KEY"
retry_initial_ms = 500 # SIP backoff: attempt k waits min(500·2^(k-1), retry_max_ms)
retry_max_ms = 4000 # backoff cap
retries = 3
wire_encryption = true # must match the server
server_encryption_key = "BASE64_X25519_SERVER_PUBLIC"
# Optional: stdout of this command is sealed + signed and passed to the hook as {param}
# param_command = ["/usr/local/bin/get-tag"]
# param_command_timeout_seconds = 5
# param_max_len = 256One client can pulse several servers at once. [client] is the primary server;
add a [client.servers.<name>] table for each additional one. The table key is a
local label (used for status and uniqueness), not the wire server_id.
Each server signs its payloads with a server_id that must match that remote
server's configured server_id — otherwise every HELLO is rejected as an
invalid signature. That server_id defaults to the label, so you only set it
explicitly when they differ (e.g. two servers that both kept the default
signedpulse-main). Every target reuses the shared identity (client_id /
private_key) but runs its own independent pulse loop, inherits any omitted field
from [client], and has its own X25519 key (server_encryption_key is never
inherited).
It is fine for two servers to share a server_id: in the default encrypted mode
each pulse is sealed to that server's own X25519 key, so a datagram for one
server cannot even be decrypted (let alone replayed) by another. If you run with
wire_encryption = "off", that per-server key separation is gone, so give each
server a distinct server_id to keep a cleartext HELLO from being replayable
across servers.
[client]
client_id = "9819…f8"
server_addr = "203.0.113.10:7370"
server_id = "signedpulse-main"
private_key = "BASE64_ED25519_PRIVATE_KEY"
server_encryption_key = "BASE64_X25519_MAIN_PUBLIC"
interval_seconds = 300
[client.servers.backup] # "backup" is just a local label
server_addr = "203.0.113.20:7370"
server_id = "signedpulse-main" # set this to the REMOTE server's server_id
server_encryption_key = "BASE64_X25519_BACKUP_PUBLIC"
interval_seconds = 600 # optional; else inherits [client]Rather than editing the file by hand, add a server (same address flags as init).
--server-id is the remote's server_id (omit if it equals --name); --interval
is optional, and --no-encryption switches that server to cleartext:
sudo signedpulse-client add-server --name backup --server 203.0.113.20 \
--server-key "BASE64_X25519_BACKUP_PUBLIC" \
--server-id signedpulse-main \
--interval 600It validates the inputs, rejects a duplicate or primary-colliding label, appends
the [client.servers.<name>] block, rewrites the config atomically at 0600, and
prints the exact signedpulse-server add-client … line (with this client's public
key) to run on the new server.
Authorize the same client public key on each server (or use separate client
identities/configs if you prefer per-server key isolation), then restart the
client. signedpulse-client status reports each server separately by label.
If param_command is set, the client runs it each pulse, takes its stdout
(trimmed, length-capped by param_max_len, control-chars rejected), encrypts
it to the server's X25519 key, and includes it in the RESPONSE. The signature
covers the ciphertext (encrypt-then-sign), so it is confidential on the wire
and tamper-proof. The server decrypts it and passes the plaintext to the hook
wherever you placed {param} in command.argv — as a single literal argument
(no shell). It always fits one UDP packet; a too-long value is rejected.
- The source IP is taken from the UDP packet metadata, never from the packet body. A client could put any IP in a packet field; that value is attacker controlled and meaningless. The whole point of the system is to learn the observed source address — the address the kernel reports for the datagram — which is exactly what the hook receives. Trusting a body field would let a client (or a spoofer) make the server act on an arbitrary IP.
- No shell by default. The hook runs via
tokio::process::Commandwith an argv array, so a hostile value is passed as one literal argument and is never parsed as shell syntax. The hook also runs with a scrubbed environment (env_clear()plus a fixed minimalPATH), soLD_PRELOAD/IFS/BASH_ENVand the like cannot influence it. The hook receives the canonical 64-hexclient_id(the verified identity, not the operator label). The client-supplied{param}is attacker-influenced text: it is rejected if it contains control characters or begins with-, but still place it so it cannot be read as an option by your hook (preferKEY={param}/ a trailing positional after--, and validate against an allow-list) — seeexamples/signedpulse-hook.sh. allow_shellmeans remote code execution — keep itfalse. Settingallow_shell = truere-enablessh -cand joins the substituted argv into one shell string. The client-supplied{param}then becomes shell code, so any authorized (or compromised) client can run arbitrary commands as the daemon's user. The leading--guard does not help here. Only enable it in fully trusted, controlled setups, and never with{param}/{ip}in the argv.- The hook runs as the daemon's user. The verified-pulse hook is a child of
the server process and inherits its uid. Binding the default port (7370 > 1024)
does not require root, so prefer running the server under a dedicated
unprivileged account and granting only what the hook needs (e.g.
CAP_NET_ADMINfor a firewall hook) rather than running everything as root. The generated systemd unit setsNoNewPrivileges=true; you can tighten it further with directives such asUser=,ProtectSystem=strict, andPrivateTmp=true— but a firewall/port-knock hook typically needsAF_NETLINKand write access, so add sandboxing deliberately and test the hook still works. - Silent to probes. The server emits exactly one kind of reply — a
CHALLENGE — and only in response to a fully validated, non-replayable HELLO.
Every other input (malformed bytes, wrong protocol/version, unknown client,
bad signature, stale or replayed HELLO, stray CHALLENGE/RESPONSE) is dropped
with no response at all, so the server does not reveal itself to scanners.
This is covered by the
server_is_silent_to_probesintegration test. - Secrets are never logged. Private keys, signatures, and full nonces are kept out of logs; failures log a short, specific reason instead.
- Encrypted by default.
wire_encryption = "required"makes every datagram an opaque sealed blob, so the client id, nonces, packet type and parameter are hidden and the protocol is unfingerprintable. The optional parameter is sealed to the server's X25519 key regardless of this setting (encrypt-then-sign). In this mode the CHALLENGE is server-authenticated (sealed with the server's static key to the client's transport ephemeral) and bound to the client's HELLO nonce via the AEAD additional-data, so a captured CHALLENGE cannot be replayed back into the client against a different HELLO. Inwire_encryption = "off"mode the CHALLENGE is cleartext and unauthenticated to the client — only useoffon a trusted network, or where the server's reply does not need to be trusted (the server-side guarantees still hold either way). - Bounded resources. Maximum packet size, HELLO rate limiting, command
timeout, max concurrent executions, and per-client/per-IP cooldown all guard
against abuse and runaway hooks. A source IP that sends more than
max_faulty_packetsmalformed/undecryptable packets is blacklisted (checked before any decryption), bounding CPU-flood attacks. The blacklist, per-IP HELLO rate limiter, and lockdown active-IP allow-list are all hard-capped atmax_tracked_ipsdistinct IPs; the nonce store and HELLO replay cache are bounded by their TTL and only grow via signature-authenticated HELLOs, so a pure source-spoofer cannot inflate them. - UDP source spoofing has inherent limits — know what each defense does. On a
network without ingress filtering an attacker can forge source IPs. Because the
per-IP HELLO rate limiter and the per-IP blacklist are keyed on the (spoofable)
source address, a flood that rotates source IPs evades both — those defenses
only constrain a non-spoofing client. The real backstops against a spoofed
flood are: (1) the CHALLENGE is only issued after Ed25519 verification, so a
blind spoofer never gets a reply and never mints nonce state; (2) the in-flight
semaphore (
max_inflight_packets) caps concurrent decryption work; and (3) the rejection-rate lockdown. Pair the daemon with network-level anti-spoofing (uRPF/BCP 38) where you can. - Lockdown is an availability trade-off. When more than
attack_blacklist_thresholddistinct IPs are blacklisted, or more thanattack_rejection_thresholdpackets are rejected, withinattack_window_seconds, the server enters lockdown and serves only source IPs that recently completed a handshake (the active allow-list). A sustained spoofed flood can therefore keep the server in lockdown and lock out idle or first-time legitimate clients (which must complete a HELLO to become active) until the flood subsides. This deliberately favors already-known-good sources under attack. Keepactive_ip_ttl_secondscomfortably above your clients'interval_secondsso an established client stays allow-listed across the attack. - Strict signature verification. Ed25519 verification uses
verify_strictto avoid malleability/torsion edge cases, and X25519 key agreement rejects non-contributory (low-order) shared secrets. - Key material is zeroized. Ed25519/X25519 secret types are built with the
zeroizefeature and decoded secrets are held in zeroizing storage, so keys are scrubbed from memory on drop. - In-memory state and restarts. The nonce store and the HELLO replay cache live in memory and are cleared on restart. A HELLO captured shortly before a restart could therefore be replayed within its skew window after the restart. This is a small, time-bounded window and matches the nonce store's design; a persistent store could close it but is not implemented.
- Clock synchronization. HELLO freshness relies on comparing the signed
timestamp to the server clock, so client and server should run NTP. Widen
hello_max_skew_secondsfor poorly-synced fleets (at the cost of a larger replay window before the timestamp check rejects a stale HELLO).
The two solve different problems and SignedPulse uses both:
- Signatures (always) provide authenticity and integrity — "this pulse really came from client X and was not altered" — and are the basis for replay protection. They are verifiable by the server using only the client's public key, so the server holds no client secret. This is the core guarantee and does not depend on encryption.
- Encryption (optional, default on) adds confidentiality so a sniffer cannot read the client id, nonces, or the parameter, and cannot even fingerprint the protocol. It uses anonymous sealed boxes to the server's X25519 key — still no per-client shared secrets. Encryption alone would not prevent replay or prove authenticity, which is why it complements, rather than replaces, the signatures.
cargo testUnit tests cover the binary codec (round-trip + bad magic/version/length),
canonical payload stability, Ed25519 sign/verify, X25519 seal/open (incl. the
reply round-trip and tamper/wrong-key failure), nonce length/expiry,
single-use/replay rejection, endpoint binding, argv {param} substitution, and
the IP blacklist. The integration test
(crates/signedpulse-server/tests/handshake.rs) drives a real UDP server with a
mock executor through a full handshake in both cleartext and encrypted modes,
asserts the decrypted parameter and source IP reach the hook, that a replayed
RESPONSE and an over-length parameter are rejected, and that the server stays
silent to probes.
This project is source-available under the PolyForm Noncommercial License 1.0.0.
You may use, copy, modify, and distribute this software for non-commercial purposes only.
Commercial use is not permitted without a separate commercial license. This includes, but is not limited to:
- SaaS or hosted-service offerings
- managed DevOps or security services
- cloud-provider offerings
- resale
- paid consulting or support bundles based on this software
- inclusion in a paid product or platform
For commercial licensing, contact the copyright holder.