security: hot-reload JWT signing keys on SIGHUP (#9826)

* security: reload JWT signing keys on SIGHUP

Signing keys were read once in the server constructors and never
refreshed. After a key rotation (Secret update, divergent reads) the
in-memory key stayed stale and every request kept failing "wrong jwt"
until the affected process was restarted.

Add Guard.UpdateSigningKeys and call it from the master, volume and
filer reload paths and the s3 reload hook, next to the existing
whitelist refresh. Make the global chunk-read JWT cache reloadable via
an atomic swap, and register the master's Reload with grace.OnReload --
it was never wired, so the master ignored SIGHUP entirely.

Mirror the same refresh in the Rust volume server's SIGHUP handler.

* security: swap signing keys behind an atomic pointer

Addresses review feedback on the in-place key swap: SigningKey is a
[]byte, so reassigning the Guard fields while a request handler reads
them is a data race that can tear the multi-word slice header and read
out of bounds.

Hold the four signing-key fields in an immutable signingConfig snapshot
behind atomic.Pointer; UpdateSigningKeys swaps the whole pointer, so a
reader sees either the old keys or the new ones. Reads go through new
SigningKey/ExpiresAfterSec/ReadSigningKey/ReadExpiresAfterSec accessors.

The Rust guard is already safe: every read and the SIGHUP write go
through the shared RwLock<Guard>.

* security: fold whitelist + auth state into the atomic snapshot

Review follow-up. UpdateSigningKeys still wrote isWriteActive while the
request path read it (and the whitelist maps) unsynchronized, so a SIGHUP
under load could expose an inconsistent mix of activation bits and
whitelist contents.

Move all hot-reloadable Guard state -- keys, expirations, whitelist, and
the activation flags -- into a single immutable guardState swapped behind
one atomic.Pointer. The Update* methods take a small mutex to serialize
the read-modify-write; readers stay lock-free. The concurrency test now
also rotates the whitelist and probes IsWhiteListed under -race.

Also read each signing key once per branch in the volume/filer JWT auth
checks, so a reload landing mid-check can't take the allow-fast-path
after auth was enabled or verify against a different key than the branch
saw.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-06-04 22:26:08 -07:00
1 parent 0d72023fac
commit ab7be7867d
16 files changed
+285 -50

No files matched your search

+64 -22
View File
@@ -6,6 +6,8 @@ import (
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
@@ -39,31 +41,50 @@ Generating JWT:
Referenced:
https://github.com/pkieltyka/jwtauth/blob/master/jwtauth.go
*/
type Guard struct {
whiteListIp map[string]struct{}
whiteListCIDR map[string]*net.IPNet
SigningKey SigningKey
ExpiresAfterSec int
ReadSigningKey SigningKey
ReadExpiresAfterSec int
// guardState is the immutable snapshot of all hot-reloadable Guard state. The
// Update* methods build a new snapshot from the current one and swap it in
// atomically, so request-path readers (WhiteList, IsWhiteListed, the SigningKey
// accessors) always observe a consistent set of keys and whitelist — never a
// torn slice header or a mix of old and new state across a SIGHUP.
type guardState struct {
signingKey SigningKey
expiresAfterSec int
readSigningKey SigningKey
readExpiresAfterSec int
whiteListIp map[string]struct{}
whiteListCIDR map[string]*net.IPNet
isWriteActive bool
isEmptyWhiteList bool
}
type Guard struct {
// state is swapped atomically by the Update* methods. Read it via Load.
state atomic.Pointer[guardState]
// updateMu serializes the read-modify-write inside the Update* methods so
// concurrent reloads don't clobber each other; readers stay lock-free.
updateMu sync.Mutex
}
func NewGuard(whiteList []string, signingKey string, expiresAfterSec int, readSigningKey string, readExpiresAfterSec int) *Guard {
g := &Guard{
SigningKey: SigningKey(signingKey),
ExpiresAfterSec: expiresAfterSec,
ReadSigningKey: SigningKey(readSigningKey),
ReadExpiresAfterSec: readExpiresAfterSec,
}
g := &Guard{}
g.state.Store(&guardState{
signingKey: SigningKey(signingKey),
expiresAfterSec: expiresAfterSec,
readSigningKey: SigningKey(readSigningKey),
readExpiresAfterSec: readExpiresAfterSec,
})
g.UpdateWhiteList(whiteList)
return g
}
func (g *Guard) SigningKey() SigningKey { return g.state.Load().signingKey }
func (g *Guard) ExpiresAfterSec() int { return g.state.Load().expiresAfterSec }
func (g *Guard) ReadSigningKey() SigningKey { return g.state.Load().readSigningKey }
func (g *Guard) ReadExpiresAfterSec() int { return g.state.Load().readExpiresAfterSec }
func (g *Guard) WhiteList(f http.HandlerFunc) http.HandlerFunc {
if !g.isWriteActive {
if !g.state.Load().isWriteActive {
//if no security needed, just skip all checking
return f
}
@@ -109,18 +130,19 @@ func (g *Guard) checkWhiteList(w http.ResponseWriter, r *http.Request) error {
// IsWhiteListed returns true if the given host IP is allowed by the guard.
// When no whitelist is configured (security inactive), all hosts are allowed.
func (g *Guard) IsWhiteListed(host string) bool {
if !g.isWriteActive {
st := g.state.Load()
if !st.isWriteActive {
return true
}
if g.isEmptyWhiteList {
if st.isEmptyWhiteList {
return true
}
if _, ok := g.whiteListIp[host]; ok {
if _, ok := st.whiteListIp[host]; ok {
return true
}
remote := net.ParseIP(host)
if remote != nil {
for _, cidrnet := range g.whiteListCIDR {
for _, cidrnet := range st.whiteListCIDR {
if cidrnet.Contains(remote) {
return true
}
@@ -129,6 +151,22 @@ func (g *Guard) IsWhiteListed(host string) bool {
return false
}
// UpdateSigningKeys refreshes the JWT signing keys and their expirations so
// operators can rotate keys (e.g. via SIGHUP) without restarting the process.
// It swaps in a new snapshot carrying the existing whitelist, so a concurrent
// reader sees either the old keys or the new ones, never a torn slice header.
func (g *Guard) UpdateSigningKeys(signingKey string, expiresAfterSec int, readSigningKey string, readExpiresAfterSec int) {
g.updateMu.Lock()
defer g.updateMu.Unlock()
next := *g.state.Load()
next.signingKey = SigningKey(signingKey)
next.expiresAfterSec = expiresAfterSec
next.readSigningKey = SigningKey(readSigningKey)
next.readExpiresAfterSec = readExpiresAfterSec
next.isWriteActive = !next.isEmptyWhiteList || len(next.signingKey) != 0
g.state.Store(&next)
}
func (g *Guard) UpdateWhiteList(whiteList []string) {
whiteListIp := make(map[string]struct{})
whiteListCIDR := make(map[string]*net.IPNet)
@@ -144,8 +182,12 @@ func (g *Guard) UpdateWhiteList(whiteList []string) {
whiteListIp[ip] = struct{}{}
}
}
g.isEmptyWhiteList = len(whiteListIp) == 0 && len(whiteListCIDR) == 0
g.isWriteActive = !g.isEmptyWhiteList || len(g.SigningKey) != 0
g.whiteListIp = whiteListIp
g.whiteListCIDR = whiteListCIDR
g.updateMu.Lock()
defer g.updateMu.Unlock()
next := *g.state.Load()
next.isEmptyWhiteList = len(whiteListIp) == 0 && len(whiteListCIDR) == 0
next.isWriteActive = !next.isEmptyWhiteList || len(next.signingKey) != 0
next.whiteListIp = whiteListIp
next.whiteListCIDR = whiteListCIDR
g.state.Store(&next)
}
+81
View File
@@ -0,0 +1,81 @@
package security
import (
"sync"
"testing"
)
// TestUpdateSigningKeysRotates pins the hot-reload contract: after a key
// rotation the guard validates tokens minted with the new key and rejects the
// old one, so a SIGHUP recovers from a key mismatch without a process restart.
func TestUpdateSigningKeysRotates(t *testing.T) {
g := NewGuard(nil, "old-write", 10, "old-read", 60)
g.UpdateSigningKeys("new-write", 11, "new-read", 61)
if string(g.SigningKey()) != "new-write" || g.ExpiresAfterSec() != 11 {
t.Fatalf("write key not refreshed: key=%q exp=%d", g.SigningKey(), g.ExpiresAfterSec())
}
if string(g.ReadSigningKey()) != "new-read" || g.ReadExpiresAfterSec() != 61 {
t.Fatalf("read key not refreshed: key=%q exp=%d", g.ReadSigningKey(), g.ReadExpiresAfterSec())
}
tok := GenJwtForVolumeServer(g.SigningKey(), g.ExpiresAfterSec(), "3,01637037d6")
if _, err := DecodeJwt(g.SigningKey(), tok, &SeaweedFileIdClaims{}); err != nil {
t.Fatalf("token minted with rotated key should verify: %v", err)
}
if _, err := DecodeJwt(SigningKey("old-write"), tok, &SeaweedFileIdClaims{}); err == nil {
t.Fatalf("token minted with rotated key must not verify against the old key")
}
}
// TestUpdateSigningKeysConcurrent rotates keys and whitelist while readers mint
// and verify tokens and check whitelist membership, so `go test -race` would
// flag any torn read of the signing-key slice header or the whitelist maps.
// Each key rotation uses matching keys so every reader's token validates.
func TestUpdateSigningKeysConcurrent(t *testing.T) {
g := NewGuard([]string{"10.0.0.1"}, "k0", 60, "k0", 60)
var wg sync.WaitGroup
for w := 0; w < 8; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
key := g.SigningKey()
tok := GenJwtForVolumeServer(key, g.ExpiresAfterSec(), "3,01637037d6")
if _, err := DecodeJwt(key, tok, &SeaweedFileIdClaims{}); err != nil {
t.Errorf("token should verify against the snapshot it was minted from: %v", err)
return
}
g.IsWhiteListed("10.0.0.1")
}
}()
}
for i := 0; i < 1000; i++ {
g.UpdateSigningKeys("kw", 60, "kr", 60)
g.UpdateSigningKeys("kw2", 60, "kr2", 60)
g.UpdateWhiteList([]string{"10.0.0.1", "192.168.0.0/16"})
}
wg.Wait()
}
// TestUpdateSigningKeysTogglesWriteActive guards the isWriteActive recompute:
// adding a signing key to an otherwise-open guard must activate auth, and
// clearing it again must deactivate.
func TestUpdateSigningKeysTogglesWriteActive(t *testing.T) {
g := NewGuard(nil, "", 0, "", 0)
if g.state.Load().isWriteActive {
t.Fatalf("no whitelist and no key should be inactive")
}
g.UpdateSigningKeys("write", 10, "", 0)
if !g.state.Load().isWriteActive {
t.Fatalf("setting a signing key should activate auth")
}
g.UpdateSigningKeys("", 0, "", 0)
if g.state.Load().isWriteActive {
t.Fatalf("clearing the signing key should deactivate auth")
}
}