Skip to content

feat(operator): HMAC-signed queue message verification (forgery defense) - #137

Merged
todie merged 1 commit into
mainfrom
feat/queue-hmac-verify
Aug 16, 2026
Merged

feat(operator): HMAC-signed queue message verification (forgery defense)#137
todie merged 1 commit into
mainfrom
feat/queue-hmac-verify

Conversation

@todie

@todie todie commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Operator ruling 2026-08-16 (ecosystem program): implement HMAC-signed queue bodies as the forgery defense on the session-event queue, regardless of whether per-queue CF token scoping proves real.

What

  • src/core/hmac-envelope.ts — v1 envelope {v, alg, sig, body}, HMAC-SHA256 over the exact raw body string, constant-time verification. signBody/makeEnvelope are the receiver-side reference implementation.
  • Poller integration: with LINEARCTL_QUEUE_HMAC_KEY set, unsigned/invalid messages are rejected+acked with a reason-only log line; unset = one startup warning + legacy processing (backward compatible until the receiver half deploys).
  • Chart: optional env.queueHmacKey secretKeyRef (linearctl-cf-queue/hmac-key), template-guarded.

Verification

  • bun run typecheck clean; bun test 487 pass / 0 fail (new hmac-envelope suite: round-trip, wrong-key, tamper, legacy-body reject, reason hygiene, short-sig safety).
  • helm lint clean.

Out of scope

Receiver-side signing (unsigned/gg services/linear-webhook) — separate repo/lane; this consumer side is inert until the key is configured.

todie added a commit that referenced this pull request Aug 16, 2026
… rotation, fail-closed startup

Review findings applied (8-angle fan-out on PR #137):

- Envelope v1 now MAC-covers audience (queue name) + timestamp:
  sig = HMAC(key, "v1\n" + audience + "\n" + ts + "\n" + body). Domain
  separation kills cross-queue replay; a 10-min freshness window bounds
  same-queue replay. Both halves ship together, so the format change is
  free now and breaking later.
- Fixed-set failure reasons — attacker-controlled field values are never
  interpolated into logs (128KB forged `v` field cannot flood stderr).
- Sig format check (64 hex chars) BEFORE hashing: O(1) reject for
  malformed sigs, no HMAC pass over max-size forged bodies, and
  trailing-garbage sigs (silent Buffer.from hex truncation) now fail
  instead of verifying; dead "sig not hex" catch removed.
- Key resolved ONCE at startup (injectable via OperatorOptions.hmac, same
  pattern as queueEnv) — posture cannot flip mid-run; SET-but-empty key
  refuses startup (present-but-empty ESO failure, estate secret rule);
  unsigned posture logged AT STARTUP (idle queue no longer hides it);
  /readyz reports hmac: off|warn|enforce.
- LINEARCTL_QUEUE_HMAC_MODE=warn migration mode: processes non-verifying
  messages with an UNVERIFIED log line — closes the rollout-window
  data-loss trap (enforce + unsigned receiver = reject+ack = destroyed
  events). LINEARCTL_QUEUE_HMAC_KEY_PREV accepted during key rotation.
- Control socket hardened to 0600 (dir 0700) — the /delegate path's trust
  boundary is filesystem perms; umask alone doesn't guarantee it.
- Chart: queueHmacKeyPrev + queueHmacMode env; values.yaml documents the
  4-step rollout order + ESO sequencing. Runbook: step-3 scoping caveat
  reconciled, step-4 env + envelope format, 6 new troubleshooting rows.

Replay WITHIN the freshness window by a queue-read-capable principal
remains possible and is documented as the v1 scope limit.
CF API tokens for Queues may scope account-wide (per-queue resource
scoping unverified — runbook step 3 carries the caveat), so a producer
token could forge AgentSessionEvents into linear-agent-events. Defense:
the webhook receiver signs every body at enqueue; the operator verifies
before processing, so queue-level write access alone cannot reach the
event loop.

- src/core/hmac-envelope.ts: v1 envelope {v, alg, ts, sig, body}; sig =
  HMAC-SHA256(key, "v1\n"+audience+"\n"+ts+"\n"+body). Audience (queue
  name) binding kills cross-queue replay; the MAC-covered timestamp
  bounds same-queue replay to a 10-min window. Fixed-set failure reasons
  (no attacker-controlled interpolation), 64-hex sig format check before
  any hashing, constant-time digest compare. Replay WITHIN the window by
  a queue-read-capable principal remains possible — documented v1 limit.
- Poller: key/prev-key/mode resolved ONCE at startup (injectable via
  OperatorOptions.hmac); SET-but-empty key refuses startup; posture
  logged at startup and surfaced in /readyz (hmac: off|warn|enforce);
  "warn" migration mode processes non-verifying messages with an
  UNVERIFIED log line (closes the rollout-window reject+ack data-loss
  trap); LINEARCTL_QUEUE_HMAC_KEY_PREV accepted during rotation.
- Control socket hardened to 0600 (dir 0700) — the /delegate trust
  boundary is filesystem perms, not umask luck.
- Chart: queueHmacKey/queueHmacKeyPrev/queueHmacMode env (guarded);
  values.yaml documents the 4-step rollout order + rotation. Runbook:
  step-3 scoping caveat, step-4 env + envelope format, troubleshooting
  rows for every rejection reason.

Rebuilt as one commit on post-#136 main (supersedes 85867b2+a4959db;
review findings from the 8-angle pass are incorporated).
@todie
todie force-pushed the feat/queue-hmac-verify branch from a4959db to 2ac8e33 Compare August 16, 2026 11:59
@todie
todie merged commit dcf24b9 into main Aug 16, 2026
4 of 6 checks passed
@todie
todie deleted the feat/queue-hmac-verify branch August 16, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant