device: fix TOCTOU race during session state update (#77)

API introduced in a927a66e has two cases of state determination
happening out of critical section for the state value:

(1) expiredSession loads sessionExpiresNano, then releases all locks
and calls noteSessionState(Expired). So a concurrent refresh that
lands in that gap gets clobbered by a stale Expired -- and sticks
until the next re-key.

(2) Likewise in noteSessionHandshakeStopped, hasKeyMaterial check
happens out of the session state lock and races with ZeroAndFlushAll.

Both lead to a wrong state emitted via the device.sessionState.fn,
but are otherwise benign.

This moves the expiry timestamp under a lock to address the former,
and provides a noteSessionStateLocked helper for the latter.
Also changes API semantics to serialize events per-peer, to avoid
sharing a single lock for all timestamps.

Updates tailscale/corp#42874

Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
Change-Id: Iee2cdf135375519e58a8e84362349d966a6a6964
This commit is contained in:
Alex Valiushko 2026-06-24 14:09:25 -07:00 committed by 世界
parent 35a60acb84
commit 15b912c1c0
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
3 changed files with 61 additions and 47 deletions

View file

@ -18,16 +18,20 @@ import (
)
type Peer struct {
isRunning atomic.Bool
keypairs Keypairs
handshake Handshake
device *Device
stopping sync.WaitGroup // routines pending stop
txBytes atomic.Uint64 // bytes send to peer (endpoint)
rxBytes atomic.Uint64 // bytes received from peer
lastHandshakeNano atomic.Int64 // nano seconds since epoch
sessionExpiresNano atomic.Int64 // nano seconds since epoch
sessionState PeerSessionState // guarded by device.sessionState.Mutex
isRunning atomic.Bool
keypairs Keypairs
handshake Handshake
device *Device
stopping sync.WaitGroup // routines pending stop
txBytes atomic.Uint64 // bytes send to peer (endpoint)
rxBytes atomic.Uint64 // bytes received from peer
lastHandshakeNano atomic.Int64 // nano seconds since epoch
sessionState struct {
sync.Mutex
current PeerSessionState
sessionExpires time.Time
}
queuedOutboundPackets atomic.Int32 // packets in staged+outbound queues, for input backpressure
@ -266,7 +270,6 @@ func (peer *Peer) Start() {
func (peer *Peer) ZeroAndFlushAll() {
device := peer.device
peer.sessionExpiresNano.Store(0)
if peer.timers.sessionExpired != nil {
peer.timers.sessionExpired.Del()
}
@ -292,7 +295,11 @@ func (peer *Peer) ZeroAndFlushAll() {
handshake.mutex.Unlock()
peer.FlushStagedPackets()
peer.noteSessionState(PeerSessionNone)
peer.sessionState.Lock()
peer.sessionState.sessionExpires = time.Time{}
peer.noteSessionStateLocked(PeerSessionNone)
peer.sessionState.Unlock()
}
func (peer *Peer) ExpireCurrentKeypairs() {
@ -313,8 +320,10 @@ func (peer *Peer) ExpireCurrentKeypairs() {
}
keypairs.Unlock()
peer.sessionExpiresNano.Store(0)
peer.noteSessionState(PeerSessionExpired)
peer.sessionState.Lock()
peer.sessionState.sessionExpires = time.Time{}
peer.noteSessionStateLocked(PeerSessionExpired)
peer.sessionState.Unlock()
}
func (peer *Peer) Stop() {
@ -338,42 +347,41 @@ func (peer *Peer) Stop() {
}
func (peer *Peer) noteSessionState(state PeerSessionState) {
device := peer.device
device.sessionState.Lock()
defer device.sessionState.Unlock()
peer.sessionState.Lock()
defer peer.sessionState.Unlock()
peer.noteSessionStateLocked(state)
}
if peer.sessionState == state {
// noteSessionStateLocked records a session state transition and delivers the
// callback. The caller must hold peer.sessionState.Mutex during the
// state determination and transition.
func (peer *Peer) noteSessionStateLocked(state PeerSessionState) {
if peer.sessionState.current == state {
return
}
peer.sessionState = state
if f := device.sessionState.fn; f != nil {
f(peer.handshake.remoteStatic, state)
peer.sessionState.current = state
if f := peer.device.peerStateFn.Load(); f != nil {
(*f)(peer.handshake.remoteStatic, state)
}
}
func (peer *Peer) noteSessionHandshakeStarted() {
device := peer.device
device.sessionState.Lock()
defer device.sessionState.Unlock()
switch peer.sessionState {
case PeerSessionEstablished:
return
case PeerSessionHandshake:
peer.sessionState.Lock()
defer peer.sessionState.Unlock()
if peer.sessionState.current == PeerSessionEstablished {
return
}
peer.sessionState = PeerSessionHandshake
if f := device.sessionState.fn; f != nil {
f(peer.handshake.remoteStatic, PeerSessionHandshake)
}
peer.noteSessionStateLocked(PeerSessionHandshake)
}
func (peer *Peer) noteSessionHandshakeStopped() {
peer.sessionState.Lock()
defer peer.sessionState.Unlock()
state := PeerSessionNone
if peer.hasKeyMaterial() {
state = PeerSessionExpired
}
peer.noteSessionState(state)
peer.noteSessionStateLocked(state)
}
func (peer *Peer) hasKeyMaterial() bool {