Per-message authentication runtime¶
Per-message authentication is implemented for selected mutating hub frames as
an opt-in HMAC-SHA256 runtime. It runs after WebSocket connect authentication:
synapse hub --token still gates admission, and --require-message-auth then
requires signed frames for claims, releases, task updates, handoffs,
checkpoints, and resource offers.
The default remains off for compatibility and for loopback-only single-operator use. Operators opt in explicitly:
synapse hub \
--db ~/synapse/hub.db \
--token "$SYNAPSE_TOKEN" \
--message-auth-key main:"$SYNAPSE_MESSAGE_AUTH_SECRET":project/agent \
--require-message-auth
Clients can sign mutating frames by constructing SynapseAgent with
per_message_auth_key_id and per_message_auth_secret. Unsigned chat and
heartbeat frames remain ordinary envelopes; the first runtime tranche protects
state-changing coordination frames.
This runtime does not encrypt payloads, does not replace TLS, does not create per-agent identity, and does not enforce ACLs. The signed events and mTLS runtime adds an Ed25519 signed-event verification path for selected mutating frames, but the HMAC path described here remains the stable CLI-facing frame-authentication mode.
Trust model: connect-once versus per-frame¶
By default a hub authenticates the connection, not each frame. --token
gates admission at connect time; once a socket is admitted it is trusted for the
life of that connection, and every frame it sends afterwards is accepted on the
strength of that one check. On a loopback, single-operator hub this is
proportionate — the only party that can open a socket is the operator — and it is
why per-message authentication is off by default.
Enable --require-message-auth when that assumption no longer holds:
- More than one party can reach the hub — a shared workstation, a bound non-loopback host, or any deployment where distinct agents connect. Per-frame HMAC means a hijacked or reused socket cannot mint state-changing frames without the key.
- Authorship must be attributable — when a claim, release, or task update has to be bound to a key rather than merely to whoever holds the connection.
- You federate. Cross-domain frames require per-message authentication to
bind authority: a cross-domain frame on a hub without it is refused
signature_not_verified(see the federated trust model), so a federating hub always sets--require-message-auth. The hub enforces this at start-up:--federation-storewhose peerings grant cross-domain scope refuses to start without--require-message-auth, unless--federation-observe-onlydeclares the store is loaded for diagnostics and deny-closed refusal only.
The token still gates admission in every mode; per-message authentication adds a second, per-frame check on top of it for the mutating frames listed above. It is not a substitute for TLS on an untrusted network — see the boundaries below.
Frame authentication profile¶
An authenticated frame remains an ordinary protocol envelope with an auth
object:
{
"sender": "project/agent",
"target": "System",
"type": "claim",
"payload": "",
"timestamp": 1782648000.0,
"task_id": "TASK-1",
"auth": {
"alg": "hmac-sha256",
"kid": "main",
"nonce": "base64-url-nonce",
"sequence": 1,
"timestamp": 1782648000.5,
"value": "hex-hmac"
}
}
The authenticated frame bytes come from a canonical frame: JSON with
sorted keys and compact separators, with auth.value excluded and all other
auth fields included. The message authentication code binds the frame type,
sender, target, payload, key id, nonce, sequence, and authentication timestamp.
Each configured key id maps to one HMAC secret and at least one allowed
sender. The CLI requires
--message-auth-key KEY_ID:SECRET:SENDER[,SENDER...]; embedded callers must
configure MessageAuthKey(..., senders=...). Empty sender bindings fail
closed.
Replay controls¶
Per-message authentication includes replay protection:
- A nonce makes each authenticated frame unique for a key id and sender.
The replay identity is the triple
(key_id, sender, nonce). - The signed sequence metadata is authenticated so it cannot be rewritten in transit. By default it is not a monotonic replay floor: nonce uniqueness remains the identity. That avoids false replay failures after a client reconnect resets an in-memory sequence counter.
- A timestamp window rejects signed frames outside
--message-auth-window-secondsseconds in the past plus a small future clock skew allowance. The default past window is10.0seconds and the default future skew is1.0second (approximately a-10 s / +1 ssigned-frame budget relative to server time). Federation availability assumes clocks stay inside that budget. - A bounded replay cache records recent nonces. The default
--message-auth-replay-capacityis4096entries. After expired entries are evicted, a full live cache rejects new signed frames rather than evicting in-window nonces and reopening replay. - The existing idempotency key remains part of mutating retry semantics; it is not a replay-protection system by itself. The reusable signed client emits a fresh idempotency key on signed mutating frames that did not already provide one.
Capacity planning and principal fairness¶
The replay capacity is global to one hub, not partitioned by key or sender. This
keeps memory bounded and never discards a live nonce, but authenticated senders
share the same admission budget. Once the live window contains 4,096 accepted
nonces, every new authenticated mutation is refused until at least one entry
expires. The refusal deliberately uses the existing replayed result so a
caller cannot use error detail to distinguish a known nonce from capacity
pressure.
The secure flood limits bound how quickly that condition can occur; they do not
reserve replay capacity per principal. Under the default 10-second replay
window, one host can admit at most 100 + 500 × 10 = 5,100 frames from its
initial burst and sustained allowance. Four fully active principals remain just
below cache capacity (4 × (20 + 100 × 10) = 4,080), while five can reach the
host ceiling and fill the cache after an idealised (4,096 − 100) / 500 = 7.992
seconds. This is an admission envelope derived from configured token buckets,
not a throughput benchmark; protocol processing and scheduling can only delay
the point at which it is reached.
The alternatives have different replay guarantees:
| Policy | Memory bound | In-window replay resistance | Fairness consequence |
|---|---|---|---|
| Current global fail-closed cache | Fixed global cap | Preserved | One sender set can consume the shared budget. |
| Per-principal quotas alone | Quota × active principals | Preserved within each quota | Fair per principal, but total memory is not globally bounded. A second global admission rule is still required. |
| Bounded LRU eviction | Fixed global cap | Broken | Evicting a live nonce lets the evicted signed frame verify again; this policy is rejected. |
| Larger global cap | Higher fixed global cap | Preserved | Moves the saturation point linearly but does not isolate principals. |
For that reason the runtime retains the current fail-closed policy. Operators
who expect more than four principals to sustain authenticated mutations near
the secure ceilings should size --message-auth-replay-capacity from the
configured replay window and aggregate admitted rate, while keeping a finite
global bound. A future fairness policy must combine per-principal reservations
with a global cap and prove that it never evicts an in-window nonce.
Runtime default: durable with a journal, process-local without one¶
When --require-message-auth is paired with --db, the CLI automatically
opens a separate FULL-synchronous nonce ledger at <DB>.message-auth.db.
Accepted nonce history therefore survives a hub restart. --db-key-file
protects both the authoritative journal and this derived replay ledger through
SQLCipher; the CLI never silently creates a plaintext replay sidecar beside an
encrypted journal. Override the location with --message-auth-replay-db.
Without either --db or --message-auth-replay-db, the cache remains
in-memory only and the CLI prints a warning. A restart then clears accepted
nonce history, so a captured signed frame still inside the timestamp window can
verify again. The tighter default window and signed-client idempotency keys
bound that compatibility residual. Durable command idempotency is separate: a
journal-backed hub may replay an already-applied mutating response by
idempotency key without restoring per-message-auth nonce memory.
Optional durable nonces and sequence floors (REV-SEC-07)¶
The hub CLI attaches DurableMessageAuthReplayStore automatically when
--require-message-auth and --db are both present. Embedders may attach the
same store directly to MessageReplayCache. Sequence floors remain opt-in
through --message-auth-sequence-floor-mode:
| Mode | Behaviour |
|---|---|
off (default) |
Sequence is metadata only; durable store (if attached) still persists nonces. |
compat |
Floor advances on accept; a lower sequence with a new nonce is still admitted (reconnect-safe). Same nonce remains replayed. |
strict |
sequence <= floor for that (key_id, sender) is refused as sequence_mismatch. Clients must keep counters monotonic across restarts. |
Durable I/O faults fail closed (verification refuses the frame). Capacity-full
behaviour matches the in-memory cache. compat and strict are refused at
startup unless a durable replay path exists; an in-memory sequence floor would
misrepresent its restart guarantee. Design notes:
docs/internal/DESIGN_REV_SEC_07_sequence_floor_2026-07-20.md.
Verification produces stable verification result strings: ok, missing,
expired, unknown_key, revoked_key, bad_authentication,
sender_mismatch, sequence_mismatch, and replayed. Hub refusals return an
error frame with verification_result set to the refusal reason.
Key lifecycle¶
The runtime keeps keys local and explicit:
- Add one or more
--message-auth-key KEY_ID:SECRET:SENDER[,SENDER...]values to the hub. - Rotate by adding a new key id for new clients while keeping the older key id available until its replay and operational retry windows have passed.
- Embedded callers can mark a
MessageAuthKeyas revoked; frames naming that key id fail withrevoked_key. - Do not put secrets in shell history, service files, logs, receipts, or diagnostics. Prefer environment files or a local secret manager when running a long-lived service.
There is no managed key store, no key-file lifecycle command, and no automatic rotation workflow yet. Lost-key recovery is an operator action, not a hub action.
Relationship to signed events¶
Per-message authentication and signed events solve different problems:
- Per-message authentication rejects a bad incoming frame before it mutates hub state.
- Signed events make selected durable event-log records tamper-evident after admission, storage, replay, relay export, or postmortem reconstruction.
An exposed deployment may need both. The hub gate can now accept either a valid
HMAC auth envelope or a valid Ed25519 signature envelope when an embedded
runtime provides a signed-event trust bundle. CLI trust-bundle loading remains
future work, so command-line operators still use --message-auth-key for this
runtime.
Boundaries¶
The implemented runtime proves that a selected mutating frame was signed by a configured HMAC key holder inside the accepted timestamp window. It does not encrypt payloads, does not replace TLS, does not replace the signed-event trust bundle for multi-host tamper evidence, does not replace per-agent identity, does not replace ACL enforcement, does not sandbox connected agents, and does not make a shared-token hub safe on an untrusted network by itself.
The first runtime tranche gates only claims, releases, task updates, handoffs, checkpoints, and resource offers. It runs after WebSocket admission and hub sender resolution; it does not authenticate initial name binding, presence, heartbeat traffic, or takeover of a live identity.
The identity and ACL design remains the future layer that maps a key holder to an audit subject and decides whether the requested verb and target are allowed.
The local-first tradeoff is key-management complexity. Loopback-only single operator use can stay unsigned. Exposed deployments need explicit keys, sender binding, replay cache bounds, key rotation, revocation procedures, diagnostics, and operator review before per-message authentication is treated as one part of a broader hardening profile.