Skip to content

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-store whose peerings grant cross-domain scope refuses to start without --require-message-auth, unless --federation-observe-only declares 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-seconds seconds in the past plus a small future clock skew allowance. The default past window is 10.0 seconds and the default future skew is 1.0 second (approximately a -10 s / +1 s signed-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-capacity is 4096 entries. 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 MessageAuthKey as revoked; frames naming that key id fail with revoked_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.