diff --git a/packages/agentvault/ca.go b/packages/agentvault/ca.go index c3abac4c..c75ac0c5 100644 --- a/packages/agentvault/ca.go +++ b/packages/agentvault/ca.go @@ -135,7 +135,9 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { return tls.Certificate{}, err } - notAfter := time.Now().Add(leafTTL) + // Truncate drops the monotonic reading, which pauses while the machine sleeps and kept serving leaves + // past their NotAfter, and matches the whole seconds NotAfter is encoded in. + notAfter := time.Now().Add(leafTTL).Truncate(time.Second) // A leaf must never outlive its issuer, or clients reject the chain as the root nears expiry. if notAfter.After(rootCert.NotAfter) { notAfter = rootCert.NotAfter diff --git a/packages/agentvault/cache.go b/packages/agentvault/cache.go index 947c6a0a..077ed46c 100644 --- a/packages/agentvault/cache.go +++ b/packages/agentvault/cache.go @@ -64,6 +64,9 @@ type resolvedService struct { substitutions []substitution } +// lastSeen and fetchedAt are wall-clock (Round(0)) so time asleep counts toward the grace and idle windows. +// Trade-off: a wall-clock step moves them too (forward re-resolves early, backward stretches grace while +// Infisical is unreachable), which is rarer and milder than sleep. type sessionEntry struct { sessionID string expiresAt *time.Time @@ -189,7 +192,7 @@ func (c *sessionCache) lookup(sessionToken string) ([]*resolvedService, *session delete(c.entries, key) delete(c.tokens, key) } else { - entry.lastSeen = time.Now() + entry.lastSeen = time.Now().Round(0) svcs, grant := entry.services, entry.sessionLog c.mu.Unlock() return svcs, grant, nil @@ -225,8 +228,8 @@ func (c *sessionCache) lookup(sessionToken string) ([]*resolvedService, *session expiresAt: result.ExpiresAt, services: result.Services, sessionLog: result.SessionLog, - lastSeen: time.Now(), - fetchedAt: time.Now(), + lastSeen: time.Now().Round(0), + fetchedAt: time.Now().Round(0), } c.tokens[key] = sessionToken return cacheLookup{services: result.Services, sessionLog: result.SessionLog}, nil @@ -331,7 +334,7 @@ func (c *sessionCache) refreshOne(key, token string) { entry.expiresAt = result.ExpiresAt entry.services = result.Services entry.sessionLog = result.SessionLog - entry.fetchedAt = time.Now() + entry.fetchedAt = time.Now().Round(0) } } diff --git a/packages/agentvault/cache_test.go b/packages/agentvault/cache_test.go index d15abb58..49bdb2be 100644 --- a/packages/agentvault/cache_test.go +++ b/packages/agentvault/cache_test.go @@ -373,3 +373,29 @@ func TestARejectedProxyTokenIsNotReResolvedEveryRequest(t *testing.T) { t.Fatalf("20 requests against a revoked proxy made %d resolves, want 1", got) } } + +// == rather than Equal: Equal ignores the monotonic reading, which pauses while the machine sleeps. +func TestCacheTimesIgnoreTheSleepingClock(t *testing.T) { + resolver := &stubResolver{result: &resolveResult{SessionID: "s1", Services: []*resolvedService{serviceWithSecret("v")}}} + cache := newTestCache(resolver) + key := sessionKey("agv_token") + + if _, err := cache.get("agv_token"); err != nil { + t.Fatalf("get: %v", err) + } + if e := cache.entries[key]; e.fetchedAt != e.fetchedAt.Round(0) || e.lastSeen != e.lastSeen.Round(0) { + t.Fatal("a fresh entry carries a monotonic reading, so time asleep would not count toward its grace or idle window") + } + + if _, err := cache.get("agv_token"); err != nil { + t.Fatalf("get: %v", err) + } + if e := cache.entries[key]; e.lastSeen != e.lastSeen.Round(0) { + t.Fatal("a cache hit stamps lastSeen with a monotonic reading") + } + + cache.refresh() + if e := cache.entries[key]; e.fetchedAt != e.fetchedAt.Round(0) { + t.Fatal("a refresh stamps fetchedAt with a monotonic reading") + } +} diff --git a/packages/agentvault/proxy_hostname_test.go b/packages/agentvault/proxy_hostname_test.go index 6c680189..d0beac23 100644 --- a/packages/agentvault/proxy_hostname_test.go +++ b/packages/agentvault/proxy_hostname_test.go @@ -2,6 +2,7 @@ package agentvault import ( "bytes" + "crypto/x509" "testing" ) @@ -61,3 +62,28 @@ func TestOneHostMintsOneLeafWhateverTheCase(t *testing.T) { } } } + +// == rather than Equal: Equal ignores the monotonic reading, which pauses while the machine sleeps. +func TestALeafExpiryIgnoresTheSleepingClock(t *testing.T) { + key, cert, err := generateRootCa() + if err != nil { + t.Fatalf("generateRootCa: %v", err) + } + ca := newCaManager(key, cert) + + leaf, err := ca.mintLeaf("api.example.com") + if err != nil { + t.Fatalf("mintLeaf: %v", err) + } + exp := ca.leafCache["api.example.com"].expiration + if exp != exp.Round(0) { + t.Fatal("the cached leaf expiry carries a monotonic reading, so time asleep would not count toward it") + } + parsed, err := x509.ParseCertificate(leaf.Certificate[0]) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + if !exp.Equal(parsed.NotAfter) { + t.Fatalf("the cache expires the leaf at %v but the certificate says %v", exp, parsed.NotAfter) + } +} diff --git a/packages/agentvault/session_log.go b/packages/agentvault/session_log.go index e2881b21..d5b45aa0 100644 --- a/packages/agentvault/session_log.go +++ b/packages/agentvault/session_log.go @@ -147,7 +147,9 @@ func (r *sessionLogRecorder) record(g *sessionLogGrant, rec sessionLogRecord) { // Without the monotonic reading, Before and After compare wall time, which is what Ts shows. rec.at = now.Round(0) rec.Ts = rec.at.UTC().Format(time.RFC3339Nano) - spool.lastRecordAt = now + // rec.at, not now: idle close must count time asleep, while lastFlushAt stays monotonic so a + // backward clock step cannot stall the flush interval. + spool.lastRecordAt = rec.at if !r.admitLocked(g) { // Switching off says so when it happens; the proxy-wide cap is only reported here. @@ -545,7 +547,8 @@ func (r *sessionLogRecorder) sendShipment(ctx context.Context, next shipment, pa } res.posted = true res.postedURL = created.UploadURL - res.postedExpires = r.now().Add(time.Duration(created.ExpiresInSeconds) * time.Second) + // Round(0): the link expires by the wall clock, which keeps running while the machine sleeps. + res.postedExpires = r.now().Add(time.Duration(created.ExpiresInSeconds) * time.Second).Round(0) uploadURL = created.UploadURL } diff --git a/packages/agentvault/session_log_ship_test.go b/packages/agentvault/session_log_ship_test.go index 06749405..944c5fd4 100644 --- a/packages/agentvault/session_log_ship_test.go +++ b/packages/agentvault/session_log_ship_test.go @@ -2,6 +2,7 @@ package agentvault import ( "context" + "errors" "net/http" "net/http/httptest" "strconv" @@ -242,3 +243,20 @@ func TestAChunkAlreadyStoredCountsAsUploaded(t *testing.T) { t.Fatalf("a chunk that is already stored was treated as a failed upload: %v", err) } } + +// The real clock, not newTestLog's: its fake times never carry a monotonic reading, so the check would always pass. +// == rather than Equal: Equal ignores the monotonic reading, which pauses while the machine sleeps. +func TestAnUploadLinkExpiryIgnoresTheSleepingClock(t *testing.T) { + // A failing upload keeps the chunk queued, so the link it was given can be read back. + log := newSessionLogRecorder("proxy-1", &fakeShipper{putDefault: errors.New("bucket down")}) + log.record(testGrant("s1"), aRecord("api.github.com")) + log.flush(context.Background(), flushFinal) + + pending := log.spools["s1"].pending + if len(pending) != 1 { + t.Fatalf("%d chunks queued, want the one whose upload failed", len(pending)) + } + if exp := pending[0].urlExpires; exp.IsZero() || exp != exp.Round(0) { + t.Fatal("the upload link expiry carries a monotonic reading, so time asleep would not count toward it") + } +} diff --git a/packages/agentvault/session_log_test.go b/packages/agentvault/session_log_test.go index 69e99904..be60a3cf 100644 --- a/packages/agentvault/session_log_test.go +++ b/packages/agentvault/session_log_test.go @@ -1616,3 +1616,14 @@ func TestOneStuckRequestAtShutdownDoesNotHoldBackTheOtherSessions(t *testing.T) }) } } + +// The real clock, not newTestLog's: its fake times never carry a monotonic reading, so the check would always pass. +// == rather than Equal: Equal ignores the monotonic reading, which pauses while the machine sleeps. +func TestIdleCloseIgnoresTheSleepingClock(t *testing.T) { + log := newSessionLogRecorder("proxy-1", &fakeShipper{}) + log.record(testGrant("s1"), aRecord("api.github.com")) + + if at := log.spools["s1"].lastRecordAt; at != at.Round(0) { + t.Fatal("lastRecordAt carries a monotonic reading, so time asleep would not count toward idle close") + } +}