sec: add jti and replay-protect subscribe tokens - #23
Merged
Conversation
5 tasks
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
force-pushed
the
sec/token-jti-replay-cache
branch
from
May 7, 2026 23:09
2ebb4d8 to
28a25e8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #7.
VerifyTokenpreviously checked only expiry + HMAC, so a subscribe token observed on the wire (a misconfigured upstream proxy log, aRefererleak, 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)
<expMs>:<b64mac><expMs>:<jti>:<b64mac>expMs|sock|chanexpMs|sock|chan|jtijtiis a fresh 16-byte hex string perSignTokencall. It's inside the HMAC payload, so an attacker can't swap a new jti onto a captured signature.ReplayCache
New
auth.ReplayCacheis ajti -> expirymap with three methods:CheckAndRecord(jti, exp)— returnstruefirst time,falsethereafter.Sweep()— removes entries past expiry.Len()— for tests / metrics.Serverconstructs one per process and startssweepReplayCacheon a 1-minute ticker for the lifetime ofctx. Memory is bounded by issuance rate × token lifetime (5 min default).Verify path
handleSubscribenow passesc.replayCacheand distinguishesAUTH_REPLAYEDfromAUTH_FAILEDso a client + its operator can tell "bad token" from "already used."What changed
internal/auth/token.go: jti in token format; newVerifyTokenAgainst; newReplayCache;ErrTokenReplayed.internal/auth/token_test.go: 4 new test cases.internal/conn/conn.go:Runtakes*auth.ReplayCache; field onConn.internal/conn/handler.go:handleSubscribecallsVerifyTokenAgainstand emitsAUTH_REPLAYEDonErrTokenReplayed.internal/conn/conn_test.go,internal/conn/handler_test.go: passnilcache.internal/server/upgrade.go:UpgradeHandler.replayCache;NewUpgradeHandlertakes it; threads it intoconn.Run.internal/server/server.go:Server.replayCache;Newconstructs it;sweepReplayCachegoroutine;ReplayCache()accessor.internal/server/upgrade_test.go,internal/server/leak_test.go,internal/server/shutdown_test.go: passnil.Test plan
go build ./...clean.go test ./internal/auth/... ./internal/conn/... ./internal/server/...passes (4 new cases plus existing)./v1/auth/signonce, subscribe twice with the sametok→ first succeeds, second errors withAUTH_REPLAYED.AUTH_FAILED(expired) before replay check fires.Conflict notes
internal/conn/handler.gooverlaps with PR sec: GC empty channels and rate-limit subscribe/unsubscribe #20 (channel GC + sub rate limit).internal/server/upgrade.gooverlaps with PR sec: parse X-Forwarded-For from trusted proxies via WIREFAN_TRUSTED_PROXIES #19 (--trust-proxy) and sec: sanitize ws-upgrade error logs and validate sqlite path #16 (sanitize-logs).internal/server/server.gooverlaps with PR sec: split admin listener and require explicit --allowed-origins #18 (listener split).internal/conn/conn.gooverlaps with PR sec: add per-conn publish rate limit alongside per-key bucket #21 (per-conn token bucket).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).