Skip to content

Commit 13eec8e

Browse files
authored
Merge pull request #622 from huang195/fix/tlsbridge-leaf-cache-wallclock
Fix: Gate TLS-bridge leaf cache on wall-clock validity (survive suspend)
2 parents 160f917 + 17fc060 commit 13eec8e

3 files changed

Lines changed: 89 additions & 5 deletions

File tree

authbridge/authlib/tlsbridge/decision.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ func (s *SkipSet) Add(host string) {
114114
delete(s.m, oldestK)
115115
}
116116
}
117-
s.m[host] = now.Add(s.ttl)
117+
// .Round(0) strips the monotonic reading so the expiry is a pure wall-clock
118+
// time. Contains compares it against time.Now() via the wall clock, so an
119+
// entry expires after skipTTL of real time even across a suspend (where the
120+
// monotonic clock freezes and would otherwise keep the host skipped longer).
121+
s.m[host] = now.Add(s.ttl).Round(0)
118122
}
119123

120124
func (s *SkipSet) Contains(host string) bool {

authbridge/authlib/tlsbridge/minter.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ type cacheEntry struct {
3838
expires time.Time
3939
}
4040

41+
// renewBefore is the gap between the cache deadline (now+ttl, when Get
42+
// re-mints) and the leaf's NotAfter (now+ttl+renewBefore). It gives a
43+
// connection that grabbed the leaf just before re-mint ample remaining
44+
// validity, and a window for Get's NotAfter backstop to act.
45+
const renewBefore = time.Hour
46+
4147
func NewMinter(src CASource, o MinterOpts) *Minter {
4248
if o.CacheMax <= 0 {
4349
o.CacheMax = 1024
@@ -71,7 +77,15 @@ func (m *Minter) GetCertificateForHost(host string) (*tls.Certificate, error) {
7177
defer m.mu.Unlock()
7278
if el, ok := m.items[host]; ok {
7379
e := el.Value.(*cacheEntry)
74-
if time.Now().Before(e.expires) {
80+
// Gate freshness on WALL-clock deadlines, not the process monotonic
81+
// clock. Across a host suspend / VM pause the monotonic clock freezes
82+
// while wall time — and the leaf's x509 validity — keeps advancing, so
83+
// a monotonic deadline would keep serving a leaf the client already
84+
// rejects as expired. e.expires is monotonic-stripped (.Round(0) below);
85+
// the Leaf.NotAfter check is the backstop tied to the cert's real
86+
// validity, the only value the client actually verifies.
87+
now := time.Now()
88+
if now.Before(e.expires) && e.cert.Leaf != nil && now.Before(e.cert.Leaf.NotAfter) {
7589
m.ll.MoveToFront(el)
7690
return e.cert, nil
7791
}
@@ -82,7 +96,10 @@ func (m *Minter) GetCertificateForHost(host string) (*tls.Certificate, error) {
8296
if err != nil {
8397
return nil, err
8498
}
85-
el := m.ll.PushFront(&cacheEntry{host: host, cert: cert, expires: time.Now().Add(m.ttl)})
99+
// .Round(0) strips the monotonic reading so the deadline is a pure wall-clock
100+
// time; comparisons against time.Now() then fall back to the wall clock and
101+
// survive suspend (see the cache-hit gate above).
102+
el := m.ll.PushFront(&cacheEntry{host: host, cert: cert, expires: time.Now().Add(m.ttl).Round(0)})
86103
m.items[host] = el
87104
for m.ll.Len() > m.max {
88105
back := m.ll.Back()
@@ -102,8 +119,9 @@ func (m *Minter) mint(host string) (*tls.Certificate, error) {
102119
SerialNumber: serial,
103120
Subject: pkix.Name{CommonName: host},
104121
NotBefore: time.Now().Add(-time.Minute),
105-
// Leaf validity must exceed the cache TTL so a cached leaf never serves past expiry.
106-
NotAfter: time.Now().Add(m.ttl + time.Hour),
122+
// Leaf validity outlasts the cache deadline (now+ttl) by renewBefore so
123+
// a cached leaf is always re-minted before it can serve past expiry.
124+
NotAfter: time.Now().Add(m.ttl + renewBefore),
107125
KeyUsage: x509.KeyUsageDigitalSignature,
108126
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
109127
}
@@ -116,8 +134,15 @@ func (m *Minter) mint(host string) (*tls.Certificate, error) {
116134
if err != nil {
117135
return nil, fmt.Errorf("tlsbridge: mint leaf for %s: %w", host, err)
118136
}
137+
leaf, err := x509.ParseCertificate(der)
138+
if err != nil {
139+
return nil, fmt.Errorf("tlsbridge: parse minted leaf for %s: %w", host, err)
140+
}
119141
return &tls.Certificate{
120142
Certificate: [][]byte{der, caCert.Raw},
121143
PrivateKey: m.leafKey,
144+
// Populate Leaf so Get can gate on the cert's real wall-clock NotAfter
145+
// (and the TLS stack avoids re-parsing on each handshake).
146+
Leaf: leaf,
122147
}, nil
123148
}

authbridge/authlib/tlsbridge/minter_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,58 @@ func TestMinter_LRUEvictsOldest(t *testing.T) {
123123
t.Errorf("expected most-recent host \"c\" to still be cached")
124124
}
125125
}
126+
127+
// TestMinter_ReMintsWallClockExpiredLeaf exercises the NotAfter backstop: if a
128+
// cached leaf is ever wall-clock-expired while the cache deadline still reads
129+
// fresh, Get must gate on the leaf's real NotAfter and re-mint rather than
130+
// serve a cert the client rejects. (The primary suspend fix — the wall-clock
131+
// cache deadline — is guarded by TestMinter_CacheDeadlineIsWallClock.)
132+
func TestMinter_ReMintsWallClockExpiredLeaf(t *testing.T) {
133+
m, _ := newTestMinter(t) // LeafTTL=time.Hour, so the cache deadline stays "fresh"
134+
c1, err := m.GetCertificateForHost("h.example.com")
135+
if err != nil {
136+
t.Fatalf("first mint: %v", err)
137+
}
138+
// mint() must populate Leaf so the cache can gate on real validity.
139+
m.mu.Lock()
140+
e := m.items["h.example.com"].Value.(*cacheEntry)
141+
if e.cert.Leaf == nil {
142+
m.mu.Unlock()
143+
t.Fatal("minted cert has no Leaf populated")
144+
}
145+
// Simulate the leaf having aged past its NotAfter while the monotonic cache
146+
// deadline did not advance (host was suspended).
147+
e.cert.Leaf.NotAfter = time.Now().Add(-time.Minute)
148+
m.mu.Unlock()
149+
150+
c2, err := m.GetCertificateForHost("h.example.com")
151+
if err != nil {
152+
t.Fatalf("second mint: %v", err)
153+
}
154+
if &c1.Certificate[0][0] == &c2.Certificate[0][0] {
155+
t.Fatal("served a wall-clock-expired cached leaf; expected a re-mint")
156+
}
157+
if c2.Leaf == nil || !time.Now().Before(c2.Leaf.NotAfter) {
158+
t.Fatal("re-minted leaf is not valid")
159+
}
160+
}
161+
162+
// TestMinter_CacheDeadlineIsWallClock guards the actual suspend fix: the cache
163+
// freshness deadline must carry NO monotonic clock reading, so the freshness
164+
// check falls back to the wall clock and expires correctly across a host
165+
// suspend (where the monotonic clock freezes but wall time advances). A
166+
// monotonic-carrying deadline is exactly what made the cache keep serving a
167+
// wall-clock-expired leaf. time.Time's == compares the monotonic reading too,
168+
// so a deadline that still carried one would not equal its .Round(0) form.
169+
func TestMinter_CacheDeadlineIsWallClock(t *testing.T) {
170+
m, _ := newTestMinter(t)
171+
if _, err := m.GetCertificateForHost("h.example.com"); err != nil {
172+
t.Fatalf("mint: %v", err)
173+
}
174+
m.mu.Lock()
175+
exp := m.items["h.example.com"].Value.(*cacheEntry).expires
176+
m.mu.Unlock()
177+
if exp != exp.Round(0) {
178+
t.Errorf("cache deadline carries a monotonic clock reading; must be wall-clock (.Round(0)) to survive suspend")
179+
}
180+
}

0 commit comments

Comments
 (0)