Skip to content

sec: add jti and replay-protect subscribe tokens - #23

Merged
EthanY33 merged 1 commit into
mainfrom
sec/token-jti-replay-cache
May 7, 2026
Merged

sec: add jti and replay-protect subscribe tokens#23
EthanY33 merged 1 commit into
mainfrom
sec/token-jti-replay-cache

Conversation

@EthanY33

@EthanY33 EthanY33 commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #7.

VerifyToken previously checked only expiry + HMAC, so a subscribe token observed on the wire (a misconfigured upstream proxy log, a Referer leak, etc.) was reusable for its full 5-minute window. Anyone with a valid socket connected to wirefan could replay it.

This PR adds a jti (token nonce) and an in-process replay cache.

Token format (internal — no external callers depend on this)

before after
layout <expMs>:<b64mac> <expMs>:<jti>:<b64mac>
HMAC over expMs|sock|chan expMs|sock|chan|jti

jti is a fresh 16-byte hex string per SignToken call. It's inside the HMAC payload, so an attacker can't swap a new jti onto a captured signature.

ReplayCache

New auth.ReplayCache is a jti -> expiry map with three methods:

  • CheckAndRecord(jti, exp) — returns true first time, false thereafter.
  • Sweep() — removes entries past expiry.
  • Len() — for tests / metrics.

Server constructs one per process and starts sweepReplayCache on a 1-minute ticker for the lifetime of ctx. Memory is bounded by issuance rate × token lifetime (5 min default).

Verify path

// New cache-aware path; production callers use this.
auth.VerifyTokenAgainst(secret, sock, chan, tok, cache)

// Backward-compatible no-replay-check shim; equivalent to cache=nil.
auth.VerifyToken(secret, sock, chan, tok)

handleSubscribe now passes c.replayCache and distinguishes AUTH_REPLAYED from AUTH_FAILED so a client + its operator can tell "bad token" from "already used."

What changed

  • internal/auth/token.go: jti in token format; new VerifyTokenAgainst; new ReplayCache; ErrTokenReplayed.
  • internal/auth/token_test.go: 4 new test cases.
  • internal/conn/conn.go: Run takes *auth.ReplayCache; field on Conn.
  • internal/conn/handler.go: handleSubscribe calls VerifyTokenAgainst and emits AUTH_REPLAYED on ErrTokenReplayed.
  • internal/conn/conn_test.go, internal/conn/handler_test.go: pass nil cache.
  • internal/server/upgrade.go: UpgradeHandler.replayCache; NewUpgradeHandler takes it; threads it into conn.Run.
  • internal/server/server.go: Server.replayCache; New constructs it; sweepReplayCache goroutine; ReplayCache() accessor.
  • internal/server/upgrade_test.go, internal/server/leak_test.go, internal/server/shutdown_test.go: pass nil.

Test plan

  • go build ./... clean.
  • go test ./internal/auth/... ./internal/conn/... ./internal/server/... passes (4 new cases plus existing).
  • Manual: /v1/auth/sign once, subscribe twice with the same tok → first succeeds, second errors with AUTH_REPLAYED.
  • Manual: leave the server running >5 min after sign, then attempt the same tok → AUTH_FAILED (expired) before replay check fires.

Conflict notes

All overlaps are additive (different fields / different lines). Whichever lands first; the rest need a small textual rebase. The token-format change is internal-only (no on-the-wire surprise for old clients of unrelated services).

Closes #7.

VerifyToken previously checked only expiry + HMAC, so a token observed
on the wire (proxy log, Referer leak) was reusable for the full 5-minute
window — the same token, replayed against any conn the attacker could
get a socket on, would let them subscribe to a private channel.

Token format change (internal — no external callers):
  before: "<expMs>:<base64mac>"  with HMAC over expMs|sock|chan
  after:  "<expMs>:<jti>:<base64mac>"  with HMAC over expMs|sock|chan|jti

jti is a random 16-byte hex string generated in SignToken; embedding it
inside the HMAC payload means an attacker can't swap a fresh jti onto a
captured signature.

ReplayCache (auth package, new) is a simple jti -> expiry map with
CheckAndRecord (returns false on second use of the same jti) and Sweep
(removes entries past expiry). Server constructs one per process and
runs sweepReplayCache as a goroutine on a 1-minute ticker for the
lifetime of ctx.

VerifyTokenAgainst is the cache-aware verify path; VerifyToken keeps
its old signature as a thin shim with cache=nil for callers that don't
need replay protection (tests).

handleSubscribe now passes c.replayCache and surfaces ErrTokenReplayed
as a distinct AUTH_REPLAYED error so a client (and its operator) can
tell "wrong token" from "already used" — useful when debugging stale
state on a reconnect.

Tests:
- TestVerifyTokenAgainstCachePreventsReplay: same tok, two verifies
  -> second returns ErrTokenReplayed.
- TestVerifyTokenAgainstNilCache: nil cache disables replay check.
- TestReplayCacheSweepRemovesExpired: Sweep evicts past-expiry only.
- TestSignTokenJtiUnique: two SignToken calls with identical args
  produce different tokens.
@EthanY33
EthanY33 force-pushed the sec/token-jti-replay-cache branch from 2ebb4d8 to 28a25e8 Compare May 7, 2026 23:09
@EthanY33
EthanY33 merged commit 137cfdf into main May 7, 2026
2 checks passed
@EthanY33
EthanY33 deleted the sec/token-jti-replay-cache branch May 7, 2026 23:10
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.

[High] No replay protection on subscribe tokens; attacker-controllable socket_id

1 participant