Skip to content

Commit 82939f4

Browse files
authored
fix(shared_poll): enforce previous HMAC key cutoff against server time (#1209)
hmac_previous_secret_key_valid_until was compared against the iat carried inside the track signature only. That value is chosen by whoever mints the signature, so anyone retaining the rotated-out key could keep producing accepted signatures indefinitely by backdating iat - the cutoff bounded nothing in the scenario it exists for. Check the cutoff against server time before consulting the previous verifier, the same way jwtverify does for the previous JWT HMAC key. The iat check is kept as an additional constraint inside the grace period.
1 parent 9deec95 commit 82939f4

2 files changed

Lines changed: 143 additions & 2 deletions

File tree

internal/client/handler.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,9 +1137,22 @@ func (h *Handler) OnTrack(c Client, e centrifuge.TrackEvent) (centrifuge.TrackRe
11371137
}
11381138
verified := verifier.verify(e.Channel, b.Signature, keys, c.UserID())
11391139
if !verified && prevVerifier != nil {
1140-
if sharedPollCfg.HMACPreviousSecretKeyValidUntil > 0 {
1140+
if validUntil := sharedPollCfg.HMACPreviousSecretKeyValidUntil; validUntil > 0 {
1141+
// The rotation cutoff must be enforced against server time. The iat
1142+
// inside a signature is chosen by whoever minted it, so checking iat
1143+
// alone would let anyone holding the rotated-out key keep minting
1144+
// accepted signatures indefinitely by backdating iat – exactly the
1145+
// scenario the cutoff exists to contain. Once the cutoff passed the
1146+
// previous key is not consulted at all, same as JWT verifier does.
1147+
if now > validUntil {
1148+
log.Info().Str("channel", e.Channel).Str("client", c.ID()).Str("user", c.UserID()).Msg("previous shared poll secret key expired")
1149+
return centrifuge.TrackReply{}, centrifuge.ErrorPermissionDenied
1150+
}
1151+
// Inside the grace period still require the signature to claim an iat
1152+
// at or before the cutoff, so an old-key backend which keeps issuing
1153+
// new signatures does not silently extend the rotation window.
11411154
iat, _ := parseSignatureTimestamps(b.Signature)
1142-
if iat > sharedPollCfg.HMACPreviousSecretKeyValidUntil {
1155+
if iat > validUntil {
11431156
log.Info().Str("channel", e.Channel).Str("client", c.ID()).Str("user", c.UserID()).Msg("track signature issued after previous secret key expiry")
11441157
return centrifuge.TrackReply{}, centrifuge.ErrorPermissionDenied
11451158
}

internal/client/handler_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,134 @@ func TestSharedPollRefreshProxyDispatch_FallbackToDefault(t *testing.T) {
15131513
require.Equal(t, 1, defaultCalls)
15141514
}
15151515

1516+
// TestOnTrackPreviousSecretKeyRotation checks that shared_poll
1517+
// hmac_previous_secret_key_valid_until bounds the previous key usage in real
1518+
// server time. The iat inside a signature is chosen by whoever minted it, so a
1519+
// holder of the rotated-out key must not be able to keep minting accepted
1520+
// signatures after the cutoff by backdating iat.
1521+
func TestOnTrackPreviousSecretKeyRotation(t *testing.T) {
1522+
const (
1523+
currentKey = "current-secret-key"
1524+
prevKey = "previous-secret-key"
1525+
channel = "shared:poll"
1526+
userID = "42"
1527+
)
1528+
keys := []string{"key1", "key2"}
1529+
1530+
newHandlerWithClient := func(t *testing.T, validUntil int64) (*Handler, *centrifuge.Client, func()) {
1531+
t.Helper()
1532+
node := tools.NodeWithMemoryEngineNoHandlers()
1533+
1534+
cfg := config.DefaultConfig()
1535+
cfg.SharedPoll.HMACSecretKey = currentKey
1536+
cfg.SharedPoll.HMACPreviousSecretKey = prevKey
1537+
cfg.SharedPoll.HMACPreviousSecretKeyValidUntil = validUntil
1538+
cfg.Channel.Proxy.SharedPollRefresh.Endpoint = "http://localhost:9999"
1539+
cfg.Channel.Namespaces = []configtypes.ChannelNamespace{
1540+
{
1541+
Name: "shared",
1542+
ChannelOptions: configtypes.ChannelOptions{SubscriptionType: "shared_poll"},
1543+
},
1544+
}
1545+
cfgContainer, err := config.NewContainer(cfg)
1546+
require.NoError(t, err)
1547+
h := NewHandler(node, cfgContainer, hmacJWTVerifier(t, cfgContainer), nil, &ProxyMap{})
1548+
1549+
node.OnConnecting(func(ctx context.Context, event centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
1550+
return centrifuge.ConnectReply{Credentials: &centrifuge.Credentials{UserID: userID}}, nil
1551+
})
1552+
1553+
transport := tools.NewTestTransport()
1554+
client, closeFn, err := centrifuge.NewClient(context.Background(), node, transport)
1555+
require.NoError(t, err)
1556+
1557+
encoder := protocol.NewJSONCommandEncoder()
1558+
data, err := encoder.Encode(&protocol.Command{Id: 1, Connect: &protocol.ConnectRequest{}})
1559+
require.NoError(t, err)
1560+
require.True(t, centrifuge.HandleReadFrame(client, bytes.NewReader(data), 1<<20))
1561+
1562+
return h, client, func() {
1563+
_ = closeFn()
1564+
_ = node.Shutdown(context.Background())
1565+
}
1566+
}
1567+
1568+
track := func(h *Handler, client *centrifuge.Client, sig string) (centrifuge.TrackReply, error) {
1569+
return h.OnTrack(client, centrifuge.TrackEvent{
1570+
Channel: channel,
1571+
Batches: []centrifuge.TrackBatch{{
1572+
Items: []centrifuge.TrackItem{{Key: keys[0]}, {Key: keys[1]}},
1573+
Signature: sig,
1574+
}},
1575+
})
1576+
}
1577+
1578+
now := time.Now().Unix()
1579+
1580+
t.Run("previous key accepted inside grace period", func(t *testing.T) {
1581+
validUntil := now + 3600
1582+
h, client, cleanup := newHandlerWithClient(t, validUntil)
1583+
defer cleanup()
1584+
1585+
sig := makeTestSignature(prevKey, channel, keys, userID, now-60, now+300)
1586+
reply, err := track(h, client, sig)
1587+
require.NoError(t, err)
1588+
require.Len(t, reply.Batches, 1)
1589+
require.Equal(t, now+300, reply.Batches[0].ExpireAt)
1590+
})
1591+
1592+
t.Run("previous key rejected for iat after cutoff", func(t *testing.T) {
1593+
validUntil := now + 3600
1594+
h, client, cleanup := newHandlerWithClient(t, validUntil)
1595+
defer cleanup()
1596+
1597+
sig := makeTestSignature(prevKey, channel, keys, userID, validUntil+1, now+300)
1598+
_, err := track(h, client, sig)
1599+
require.Equal(t, centrifuge.ErrorPermissionDenied, err)
1600+
})
1601+
1602+
t.Run("previous key rejected after cutoff passed", func(t *testing.T) {
1603+
validUntil := now - 3600
1604+
h, client, cleanup := newHandlerWithClient(t, validUntil)
1605+
defer cleanup()
1606+
1607+
// Honest signature – iat tells the truth about when it was minted.
1608+
sig := makeTestSignature(prevKey, channel, keys, userID, now, now+300)
1609+
_, err := track(h, client, sig)
1610+
require.Equal(t, centrifuge.ErrorPermissionDenied, err)
1611+
1612+
// Backdated signature – minted now but claiming an iat before the cutoff.
1613+
// Must be rejected too, otherwise the cutoff protects nothing against
1614+
// someone who retained the rotated-out key.
1615+
sig = makeTestSignature(prevKey, channel, keys, userID, validUntil-10, now+300)
1616+
_, err = track(h, client, sig)
1617+
require.Equal(t, centrifuge.ErrorPermissionDenied, err)
1618+
})
1619+
1620+
t.Run("current key still accepted after cutoff passed", func(t *testing.T) {
1621+
validUntil := now - 3600
1622+
h, client, cleanup := newHandlerWithClient(t, validUntil)
1623+
defer cleanup()
1624+
1625+
sig := makeTestSignature(currentKey, channel, keys, userID, now, now+300)
1626+
reply, err := track(h, client, sig)
1627+
require.NoError(t, err)
1628+
require.Len(t, reply.Batches, 1)
1629+
require.Equal(t, now+300, reply.Batches[0].ExpireAt)
1630+
})
1631+
1632+
t.Run("previous key accepted when no cutoff configured", func(t *testing.T) {
1633+
h, client, cleanup := newHandlerWithClient(t, 0)
1634+
defer cleanup()
1635+
1636+
sig := makeTestSignature(prevKey, channel, keys, userID, now, now+300)
1637+
reply, err := track(h, client, sig)
1638+
require.NoError(t, err)
1639+
require.Len(t, reply.Batches, 1)
1640+
require.Equal(t, now+300, reply.Batches[0].ExpireAt)
1641+
})
1642+
}
1643+
15161644
func TestSingleConnection(t *testing.T) {
15171645
node := tools.NodeWithMemoryEngineNoHandlers()
15181646
defer func() { _ = node.Shutdown(context.Background()) }()

0 commit comments

Comments
 (0)