diff --git a/api/datalog.go b/api/datalog.go index a105a954..d10134cb 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -253,7 +253,9 @@ var ( // TargetFactRules maps node and OIDC claims to target_fact datalog facts. TargetFactRules []biscuit.Rule - // ControlPlaneStaticTimeCheck is the standard check for verifying OIDC token expiration. + // ControlPlaneStaticTimeCheck is the standard check for verifying a Biscuit's + // own expiration() fact. Every path that admits a token must add it together + // with a FactTime fact; see identity.EnforceExpiration. ControlPlaneStaticTimeCheck biscuit.Check // AllowIfTruePolicy is the static policy "allow if true" used during token verification. diff --git a/cmd/sam-control-plane/main.go b/cmd/sam-control-plane/main.go index 8a9d077d..00aa67e2 100644 --- a/cmd/sam-control-plane/main.go +++ b/cmd/sam-control-plane/main.go @@ -41,6 +41,7 @@ var ( keyRotationInterval time.Duration keyGracePeriod time.Duration leaseDuration time.Duration + biscuitTTL time.Duration adminTokenPath string insecureSkipTLSVerify bool logLevel string @@ -120,6 +121,7 @@ func main() { KeyGracePeriod: keyGracePeriod, InsecureSkipTLSVerify: insecureSkipTLSVerify, BiscuitTimeout: 10 * time.Second, + BiscuitTTL: biscuitTTL, AdminToken: adminToken, AutoApproveEnrollment: autoApproveEnrollment, } @@ -153,6 +155,7 @@ func main() { rootCmd.Flags().DurationVar(&keyRotationInterval, "key-rotation-interval", 24*time.Hour, "Key rotation interval (e.g. 24h). 0 disables rotation.") rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.") rootCmd.Flags().DurationVar(&leaseDuration, "lease-duration", 15*time.Minute, "Router lease registration TTL.") + rootCmd.Flags().DurationVar(&biscuitTTL, "biscuit-ttl", api.BiscuitTokenTTL, "Lifespan minted into every issued Biscuit's expiration fact. Capped to the OIDC token's own expiry when shorter.") rootCmd.Flags().StringVar(&adminTokenPath, "admin-token-path", "", "Path to file containing the token for authenticating policy REST API requests (or env SAM_ADMIN_TOKEN)") rootCmd.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS verification for OIDC providers") rootCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)") diff --git a/internal/controlplane/biscuit_ttl_test.go b/internal/controlplane/biscuit_ttl_test.go new file mode 100644 index 00000000..66115dbf --- /dev/null +++ b/internal/controlplane/biscuit_ttl_test.go @@ -0,0 +1,254 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/biscuit-auth/biscuit-go/v2/parser" + "github.com/google/sam/api" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/proto" +) + +// biscuitExpiration reads the expiration() authority fact out of a minted token. +func biscuitExpiration(t *testing.T, tokenBytes []byte, cpPubKey ed25519.PublicKey) time.Time { + t.Helper() + + b, err := biscuit.Unmarshal(tokenBytes) + if err != nil { + t.Fatalf("malformed biscuit: %v", err) + } + authorizer, err := b.Authorizer(cpPubKey) + if err != nil { + t.Fatalf("authorizer: %v", err) + } + authorizer.AddPolicy(api.AllowIfTruePolicy) + if err := authorizer.Authorize(); err != nil { + t.Fatalf("authorize: %v", err) + } + + rule, err := parser.FromStringRule(fmt.Sprintf(`get_exp($e) <- %s($e)`, api.FactExpiration)) + if err != nil { + t.Fatal(err) + } + facts, err := authorizer.Query(rule) + if err != nil { + t.Fatalf("query: %v", err) + } + if len(facts) != 1 || len(facts[0].IDs) != 1 { + t.Fatalf("expected exactly one expiration fact, got %v", facts) + } + date, ok := facts[0].IDs[0].(biscuit.Date) + if !ok { + t.Fatalf("expiration term is %T, want biscuit.Date", facts[0].IDs[0]) + } + return time.Time(date) +} + +// registerNode enrolls a fresh peer over /register and returns its keys and the response. +func registerNode(t *testing.T, cpURL, jwtToken string) (crypto.PrivKey, peer.ID, *api.EnrollResponse) { + t.Helper() + + priv, pub, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + peerID, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + pubBytes, err := crypto.MarshalPublicKey(pub) + if err != nil { + t.Fatal(err) + } + + reqData, err := proto.Marshal(&api.EnrollRequest{ + Jwt: jwtToken, + PeerId: peerID.String(), + PublicKey: pubBytes, + RequestedRole: api.RoleNode, + }) + if err != nil { + t.Fatal(err) + } + + resp, err := (&http.Client{Timeout: 5 * time.Second}).Post(cpURL+"/register", "application/x-protobuf", bytes.NewReader(reqData)) + if err != nil { + t.Fatalf("/register failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("/register status %s: %s", resp.Status, string(body)) + } + + var enrollResp api.EnrollResponse + if err := proto.Unmarshal(body, &enrollResp); err != nil { + t.Fatalf("unmarshal EnrollResponse: %v", err) + } + return priv, peerID, &enrollResp +} + +// refreshNode drives /refresh with a signed challenge and returns the new token. +func refreshNode(t *testing.T, cpURL string, priv crypto.PrivKey, currentBiscuit []byte) *api.TokenRefreshResponse { + t.Helper() + + timestamp := time.Now().Unix() + sig, err := priv.Sign([]byte(fmt.Sprintf("%d", timestamp))) + if err != nil { + t.Fatal(err) + } + reqData, err := proto.Marshal(&api.TokenRefreshRequest{ + Timestamp: timestamp, + ChallengeSignature: sig, + }) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodPost, cpURL+"/refresh", bytes.NewReader(reqData)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-protobuf") + req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(currentBiscuit)) + + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + t.Fatalf("/refresh failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("/refresh status %s: %s", resp.Status, string(body)) + } + + var refreshResp api.TokenRefreshResponse + if err := proto.Unmarshal(body, &refreshResp); err != nil { + t.Fatalf("unmarshal TokenRefreshResponse: %v", err) + } + return &refreshResp +} + +// assertNear fails unless got is within a second of want, absorbing the +// whole-second resolution of Biscuit date terms and the minting round trip. +func assertNear(t *testing.T, what string, got, want time.Time) { + t.Helper() + if skew := got.Sub(want); skew < -2*time.Second || skew > 2*time.Second { + t.Errorf("%s = %v, want ~%v (skew %v)", what, got.UTC(), want.UTC(), skew) + } +} + +// TestBiscuitExpiryIsCappedByItsVoucher pins the rule that a biscuit never +// outlives whatever authorized it: the OIDC ID token on interactive enrollment, +// and the recorded OIDC session on refresh (where no live token is presented). +// The configured --biscuit-ttl is a ceiling, never a floor. +func TestBiscuitExpiryIsCappedByItsVoucher(t *testing.T) { + issuer, mintToken := startCustomMockOIDC(t) + srv, store, cpURL := setupTestServer(t, issuer) + defer func() { + _ = srv.Close() + _ = store.Close() + }() + + ctx := context.Background() + if err := store.SaveMeshPolicy(ctx, []*api.PolicyRole{}, []*api.PolicyBinding{ + {Role: api.RoleNode, Members: []string{"group:users"}}, + }); err != nil { + t.Fatal(err) + } + + newJWT := func(oidcTTL time.Duration) string { + return mintToken(map[string]interface{}{ + "sub": "ttl-test", + "groups": []string{"users"}, + "exp": time.Now().Add(oidcTTL).Unix(), + }) + } + + t.Run("register clamps to the OIDC token when it expires first", func(t *testing.T) { + srv.config.BiscuitTTL = 24 * time.Hour + oidcExpiry := time.Now().Add(10 * time.Minute) + + _, _, resp := registerNode(t, cpURL, newJWT(10*time.Minute)) + cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey) + + assertNear(t, "biscuit expiration()", biscuitExpiration(t, resp.BiscuitToken, cpPubKey), oidcExpiry) + assertNear(t, "EnrollResponse.Expiration", time.Unix(resp.Expiration, 0), oidcExpiry) + }) + + t.Run("register uses the configured TTL when it expires first", func(t *testing.T) { + srv.config.BiscuitTTL = 5 * time.Minute + want := time.Now().Add(5 * time.Minute) + + _, _, resp := registerNode(t, cpURL, newJWT(time.Hour)) + cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey) + + assertNear(t, "biscuit expiration()", biscuitExpiration(t, resp.BiscuitToken, cpPubKey), want) + assertNear(t, "EnrollResponse.Expiration", time.Unix(resp.Expiration, 0), want) + }) + + t.Run("refresh clamps to the end of the OIDC session", func(t *testing.T) { + srv.config.BiscuitTTL = 24 * time.Hour + priv, peerID, resp := registerNode(t, cpURL, newJWT(time.Hour)) + cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey) + + // Wind the 90-day session down to its last 10 minutes. + sessionEnd := time.Now().Add(10 * time.Minute) + record, err := store.GetNode(ctx, peerID.String()) + if err != nil { + t.Fatal(err) + } + record.ExpiresAt = sessionEnd + if err := store.EnrollNode(ctx, record); err != nil { + t.Fatal(err) + } + + refreshed := refreshNode(t, cpURL, priv, resp.BiscuitToken) + assertNear(t, "refreshed biscuit expiration()", biscuitExpiration(t, refreshed.BiscuitToken, cpPubKey), sessionEnd) + assertNear(t, "TokenRefreshResponse.ExpiresAt", time.Unix(refreshed.ExpiresAt, 0), sessionEnd) + }) + + t.Run("refresh uses the configured TTL when the session never expires", func(t *testing.T) { + srv.config.BiscuitTTL = 30 * time.Minute + priv, peerID, resp := registerNode(t, cpURL, newJWT(time.Hour)) + cpPubKey := ed25519.PublicKey(resp.ControlPlanePublicKey) + + // Bootstrap-style record: no session deadline at all. + record, err := store.GetNode(ctx, peerID.String()) + if err != nil { + t.Fatal(err) + } + record.ExpiresAt = time.Time{} + if err := store.EnrollNode(ctx, record); err != nil { + t.Fatal(err) + } + + want := time.Now().Add(30 * time.Minute) + refreshed := refreshNode(t, cpURL, priv, resp.BiscuitToken) + assertNear(t, "refreshed biscuit expiration()", biscuitExpiration(t, refreshed.BiscuitToken, cpPubKey), want) + }) +} diff --git a/internal/controlplane/config.go b/internal/controlplane/config.go index bc78fce4..fd7ef22b 100644 --- a/internal/controlplane/config.go +++ b/internal/controlplane/config.go @@ -17,6 +17,8 @@ package controlplane import ( "fmt" "time" + + "github.com/google/sam/api" ) // Options holds configuration for the control plane. @@ -32,8 +34,9 @@ type Options struct { KeyGracePeriod time.Duration InsecureSkipTLSVerify bool BiscuitTimeout time.Duration - AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs - AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate + BiscuitTTL time.Duration // Lifespan minted into every issued Biscuit's expiration() fact; defaults to api.BiscuitTokenTTL + AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs + AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate } // Default sets default values for control plane options. @@ -54,6 +57,9 @@ func (o *Options) Default() { if o.KeyGracePeriod <= 0 { o.KeyGracePeriod = 1 * time.Hour } + if o.BiscuitTTL <= 0 { + o.BiscuitTTL = api.BiscuitTokenTTL + } } // Validate ensures options are valid. diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index 4062d564..5b428233 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -506,8 +506,12 @@ func (s *Server) HandleRegister(w http.ResponseWriter, r *http.Request) { finalRoles := []string{req.RequestedRole} finalRoles = append(finalRoles, customAccessRoles...) - // Mint token - biscuitExpiry := time.Now().Add(api.BiscuitTokenTTL) + // Mint token. A biscuit must never outlive the OIDC token that vouched + // for it, so its expiration is capped at whichever comes first. + biscuitExpiry := time.Now().Add(s.config.BiscuitTTL) + if token.Expiry.Before(biscuitExpiry) { + biscuitExpiry = token.Expiry + } biscuitData, _, err := identity.MintBiscuitToken(privKey, claims, token, pID, biscuitExpiry, finalRoles, policyRoles, req.Labels) if err != nil { logger.Errorw("Biscuit minting failed", "peer_id", req.PeerId, "error", err) @@ -562,7 +566,7 @@ func (s *Server) HandleRegister(w http.ResponseWriter, r *http.Request) { BiscuitToken: biscuitData, ControlPlanePublicKey: pubKey, RouterAddresses: routerAddrs, // routers nodes multiaddresses - Expiration: token.Expiry.Unix(), + Expiration: biscuitExpiry.Unix(), } respData, err := proto.Marshal(resp) @@ -622,8 +626,10 @@ func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) { trustedKeys = append(trustedKeys, k.Public) } - // Verify current biscuit signature and extract peer ID - pID, err := identity.VerifyAndExtractPeerID(trustedKeys, currentBiscuitBytes, s.config.BiscuitTimeout) + // Verify current biscuit signature and extract peer ID. Expiry is not + // enforced here: a node refreshes because its token lapsed. The session + // record and the signed challenge below are what bound this request. + pID, err := identity.VerifyExpiredAndExtractPeerID(trustedKeys, currentBiscuitBytes, s.config.BiscuitTimeout) if err != nil { logger.Warnw("Invalid biscuit presented for refresh", "error", err) http.Error(w, "Invalid biscuit: "+err.Error(), http.StatusUnauthorized) @@ -687,7 +693,12 @@ func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) { } var biscuitBytes []byte - biscuitExpiry := time.Now().Add(api.BiscuitTokenTTL) + // No live OIDC token is presented on refresh, so the session record is what + // vouches for this node. The biscuit must not outlive it. + biscuitExpiry := time.Now().Add(s.config.BiscuitTTL) + if !nodeRecord.ExpiresAt.IsZero() && nodeRecord.ExpiresAt.Before(biscuitExpiry) { + biscuitExpiry = nodeRecord.ExpiresAt + } if nodeRecord.EnrollmentType == "OIDC" { var claims jwt.MapClaims @@ -1194,7 +1205,7 @@ func (s *Server) HandleEnroll(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(api.BiscuitTokenTTL), policyRoles, req.Labels) + biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(s.config.BiscuitTTL), policyRoles, req.Labels) if err != nil { logger.Errorf("Failed to mint bootstrap biscuit: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1547,7 +1558,7 @@ func (s *Server) HandleAdminEnrollmentAction(w http.ResponseWriter, r *http.Requ } // Admin approval is the attestation of the operator-declared labels // recorded on the pending request. - biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(api.BiscuitTokenTTL), policyRoles, enrollReq.Labels) + biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(s.config.BiscuitTTL), policyRoles, enrollReq.Labels) if err != nil { logger.Errorf("Failed to mint bootstrap biscuit: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1672,9 +1683,9 @@ func (s *Server) buildApprovedBootstrapEnrollResponse(ctx context.Context, biscu routerAddrs = append(routerAddrs, r.Addresses...) } - expiration := time.Now().Add(api.BiscuitTokenTTL).Unix() + expiration := time.Now().Add(s.config.BiscuitTTL).Unix() if resolvedAt != nil { - expiration = resolvedAt.Add(api.BiscuitTokenTTL).Unix() + expiration = resolvedAt.Add(s.config.BiscuitTTL).Unix() } return &api.BootstrapEnrollResponse{ diff --git a/internal/identity/biscuit.go b/internal/identity/biscuit.go index 2b2de055..71c851e2 100644 --- a/internal/identity/biscuit.go +++ b/internal/identity/biscuit.go @@ -47,6 +47,52 @@ func AuthorizerOptions(timeout time.Duration) []biscuit.AuthorizerOption { return []biscuit.AuthorizerOption{biscuit.WithWorldOptions(datalog.WithMaxDuration(timeout))} } +// EnforceExpiration injects the current time and the expiration check into an +// authorizer. Every path that admits a biscuit must call this: expiry is a +// Datalog check over a time fact, so an authorizer built without the fact +// silently accepts expired tokens (see #296). A token carrying no expiration() +// fact fails the check, so this is fail-closed. +func EnforceExpiration(authorizer biscuit.Authorizer) { + authorizer.AddFact(biscuit.Fact{ + Predicate: biscuit.Predicate{ + Name: api.FactTime, + IDs: []biscuit.Term{biscuit.Date(time.Now())}, + }, + }) + authorizer.AddCheck(api.ControlPlaneStaticTimeCheck) +} + +// ExpirationOf reports the expiration() fact of an already-authorized token, for +// callers that cache an admission decision and must later know when it lapses. +func ExpirationOf(authorizer biscuit.Authorizer) (time.Time, error) { + facts, err := authorizer.Query(biscuit.Rule{ + Head: biscuit.Predicate{Name: "get_exp", IDs: []biscuit.Term{biscuit.Variable("e")}}, + Body: []biscuit.Predicate{{Name: api.FactExpiration, IDs: []biscuit.Term{biscuit.Variable("e")}}}, + }) + if err != nil { + return time.Time{}, fmt.Errorf("expiration query failed: %w", err) + } + + // A token may carry several; the earliest is the one that binds. + var earliest time.Time + for _, fact := range facts { + if len(fact.IDs) != 1 { + continue + } + date, ok := fact.IDs[0].(biscuit.Date) + if !ok { + continue + } + if t := time.Time(date); earliest.IsZero() || t.Before(earliest) { + earliest = t + } + } + if earliest.IsZero() { + return time.Time{}, fmt.Errorf("no %s fact in token", api.FactExpiration) + } + return earliest, nil +} + // MintBiscuitToken generates a signed Biscuit token for a peer with policy rules based on JWT claims. // labels are control-plane-attested key=value claims (canonical, pre-validated); empty means no claims. func MintBiscuitToken(signingKey ed25519.PrivateKey, claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID, biscuitExpiry time.Time, roles []string, policyRoles []*api.PolicyRole, labels map[string]string) ([]byte, []string, error) { @@ -253,14 +299,7 @@ func VerifyBiscuitAndGetKey(biscuitData []byte, expectedPeer peer.ID, trustedPub continue } - authorizer.AddFact(biscuit.Fact{ - Predicate: biscuit.Predicate{ - Name: api.FactTime, - IDs: []biscuit.Term{biscuit.Date(time.Now())}, - }, - }) - - authorizer.AddCheck(api.ControlPlaneStaticTimeCheck) + EnforceExpiration(authorizer) authorizer.AddPolicy(api.AllowIfTruePolicy) if err := authorizer.Authorize(); err == nil { @@ -361,9 +400,23 @@ func MintBootstrapBiscuitToken(signingKey ed25519.PrivateKey, remotePeer peer.ID return mintBiscuit(signingKey, remotePeer, []string{role}, expiration, nil, policyRoles, labels) } -// VerifyAndExtractPeerID checks that the biscuit is signed by one of the trusted keys and returns the peer ID. -// This function does NOT perform time checks, making it suitable for token refresh flows. +// VerifyExpiredAndExtractPeerID checks that the biscuit is signed by one of the +// trusted keys and returns the peer ID, deliberately WITHOUT enforcing expiry. +// Only the refresh flow may use it: a node refreshes precisely because its token +// lapsed, so it has nothing unexpired to present. Callers must bound the request +// some other way (the refresh handler gates on the session record and a signed +// challenge). Everywhere else, use VerifyAndExtractPeerID. +func VerifyExpiredAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData []byte, timeout time.Duration) (peer.ID, error) { + return extractPeerID(trustedPublicKeys, biscuitData, timeout, false) +} + +// VerifyAndExtractPeerID checks that the biscuit is signed by one of the trusted +// keys and is unexpired, and returns the peer ID. func VerifyAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData []byte, timeout time.Duration) (peer.ID, error) { + return extractPeerID(trustedPublicKeys, biscuitData, timeout, true) +} + +func extractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData []byte, timeout time.Duration, enforceExpiry bool) (peer.ID, error) { b, err := biscuit.Unmarshal(biscuitData) if err != nil { return "", fmt.Errorf("malformed biscuit: %w", err) @@ -381,6 +434,12 @@ func VerifyAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData [ lastErr = err continue } + if enforceExpiry { + EnforceExpiration(auth) + } + // No policy is added, so a token that passes every check still reports + // ErrNoMatchingPolicy; that is success here. A failed check reports the + // check error instead, so expiry still rejects. if err := auth.Authorize(); err == nil || errors.Is(err, biscuit.ErrNoMatchingPolicy) { authorizer = auth verified = true @@ -429,7 +488,11 @@ func VerifyAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData [ } // VerifyBiscuitRole checks that the biscuit is signed by the control plane's public key -// and contains the specified role fact. +// and contains the specified role fact. It deliberately does NOT enforce expiry: its +// callers either hold a token the control plane minted moments ago, or are deciding +// whether an identity loaded from disk is worth starting with, where a lapsed token +// should trigger a refresh rather than refuse to boot. Do not use it to admit a token +// received from a peer. func VerifyBiscuitRole(biscuitData []byte, controlPlanePubKey ed25519.PublicKey, expectedRole string, timeout time.Duration) error { b, err := biscuit.Unmarshal(biscuitData) if err != nil { diff --git a/internal/identity/biscuit_test.go b/internal/identity/biscuit_test.go index fc98ea0d..cde10414 100644 --- a/internal/identity/biscuit_test.go +++ b/internal/identity/biscuit_test.go @@ -576,6 +576,48 @@ func TestVerifyAndExtractPeerID_MultipleTrustedKeys(t *testing.T) { } } +// TestExtractPeerIDExpiry pins that adding the expiration check still rejects an +// expired token even though neither variant adds a policy, i.e. that a failed +// check outranks ErrNoMatchingPolicy. +func TestExtractPeerIDExpiry(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(privNode) + if err != nil { + t.Fatal(err) + } + trustedKeys := []ed25519.PublicKey{pub} + + fresh, err := MintBootstrapBiscuitToken(priv, dummyPeer, api.RoleNode, time.Now().Add(time.Hour), nil, nil) + if err != nil { + t.Fatal(err) + } + expired, err := MintBootstrapBiscuitToken(priv, dummyPeer, api.RoleNode, time.Now().Add(-time.Hour), nil, nil) + if err != nil { + t.Fatal(err) + } + + if _, err := VerifyAndExtractPeerID(trustedKeys, fresh, 5*time.Second); err != nil { + t.Errorf("unexpired token rejected: %v", err) + } + if _, err := VerifyAndExtractPeerID(trustedKeys, expired, 5*time.Second); err == nil { + t.Error("expired token accepted by the expiry-enforcing variant") + } + + // The refresh flow depends on the exempt variant staying permissive. + if got, err := VerifyExpiredAndExtractPeerID(trustedKeys, expired, 5*time.Second); err != nil { + t.Errorf("expired token rejected by the refresh variant: %v", err) + } else if got != dummyPeer { + t.Errorf("got peer %s, want %s", got, dummyPeer) + } +} + func TestVerifyBiscuitRole_TimeoutIsHonored(t *testing.T) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index 9b345725..abfefef9 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -48,6 +48,12 @@ func buildAndSaveBiscuit(node *SamNode, rootPriv ed25519.PrivateKey) error { }}); err != nil { return err } + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}, + }}); err != nil { + return err + } bisc, err := builder.Build() if err != nil { return err @@ -454,6 +460,12 @@ func buildAndSaveCustomBiscuit(node *SamNode, rootPriv ed25519.PrivateKey, allow return err } } + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}, + }}); err != nil { + return err + } bisc, err := builder.Build() if err != nil { return err diff --git a/internal/node/middleware.go b/internal/node/middleware.go index b41ac712..538ea485 100644 --- a/internal/node/middleware.go +++ b/internal/node/middleware.go @@ -262,6 +262,8 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. // Enforce client_peer_id matches connection_peer_id authorizer.AddCheck(api.BaselineReplayCheck) + identity.EnforceExpiration(authorizer) + // Inject facts from our own identity token to support target matching if err := n.injectIdentityFacts(authorizer, pubKey); err != nil { return fmt.Errorf("failed to inject target facts: %w", err) diff --git a/internal/node/middleware_test.go b/internal/node/middleware_test.go index 7f53f6ef..7befbed1 100644 --- a/internal/node/middleware_test.go +++ b/internal/node/middleware_test.go @@ -140,6 +140,14 @@ func TestAuthorize(t *testing.T) { t.Fatal(err) } + err = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}, + }}) + if err != nil { + t.Fatal(err) + } + b, err := builder.Build() if err != nil { t.Fatal(err) @@ -166,6 +174,80 @@ func TestAuthorize(t *testing.T) { } } +// TestAuthorizeRejectsExpiredBiscuit reproduces #296: SamNode.Authorize did not +// inject the time(now) fact nor the ControlPlaneStaticTimeCheck, so an expired +// biscuit that would be rejected by identity.VerifyBiscuit was still accepted +// on the node dataplane. +func TestAuthorizeRejectsExpiredBiscuit(t *testing.T) { + dir, err := os.MkdirTemp("", "middleware-test") + if err != nil { + t.Fatal(err) + } + defer func() { + _ = os.RemoveAll(dir) + }() + + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = store.Close() + }() + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + + dummyPeer := peer.ID("dummy-peer") + + builder := biscuit.NewBuilder(priv) + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactTargetUnrestricted}}) + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: "node", + IDs: []biscuit.Term{biscuit.String(dummyPeer.String())}, + }}) + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactClientPeerID, + IDs: []biscuit.Term{biscuit.String(dummyPeer.String())}, + }}) + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactGrantedServiceExact, + IDs: []biscuit.Term{biscuit.String(api.SystemNamespace), biscuit.String("/test/proto")}, + }}) + // Expired an hour ago. + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(-time.Hour))}, + }}) + + b, err := builder.Build() + if err != nil { + t.Fatal(err) + } + + tokenBytes, err := b.Serialize() + if err != nil { + t.Fatal(err) + } + + node := &SamNode{ + Store: store, + trustedKeys: []TrustedKey{{Key: pub, ReceivedAt: time.Now()}}, + BiscuitTimeout: 500 * time.Millisecond, + } + + req := RequestContext{ + PeerID: dummyPeer, + Protocol: "/test/proto", + } + + if err := node.Authorize(tokenBytes, req, pub); err == nil { + t.Fatal("Authorize succeeded with an expired biscuit; expiration is not enforced on the node dataplane") + } +} + func TestBaselineRules(t *testing.T) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil { @@ -274,6 +356,10 @@ func TestBaselineRules(t *testing.T) { Name: "node", IDs: []biscuit.Term{biscuit.String(dummyPeer.String())}, }}) + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}, + }}) // For the happy paths, add the matching client_peer_id if tt.name != "Baseline Replay Check Rejection: mismatched peer ID" { @@ -417,6 +503,14 @@ attenuation: t.Fatal(err) } + err = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}, + }}) + if err != nil { + t.Fatal(err) + } + tt.mintToken(t, builder) b, err := builder.Build() @@ -572,6 +666,7 @@ func TestWithBiscuitAuth_MutualBiscuit(t *testing.T) { {Predicate: biscuit.Predicate{Name: "client_peer_id", IDs: []biscuit.Term{biscuit.String(clientPeer.String())}}}, {Predicate: biscuit.Predicate{Name: "granted_service_all_types"}}, {Predicate: biscuit.Predicate{Name: "target_unrestricted"}}, + {Predicate: biscuit.Predicate{Name: api.FactExpiration, IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}}}, } { if err := builder.AddAuthorityFact(f); err != nil { t.Fatal(err) @@ -793,6 +888,10 @@ func TestMiddlewareTargetChecks(t *testing.T) { Name: api.FactNode, IDs: []biscuit.Term{biscuit.String(dummyPeer.String())}, }}) + _ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}, + }}) // Allow exact service factStr := fmt.Sprintf(`%s("mcp", "test_tool")`, api.FactGrantedServiceExact) fact, _ := parser.FromStringFact(factStr) diff --git a/internal/node/node.go b/internal/node/node.go index c079da7a..f83fe45f 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -116,13 +116,27 @@ type nodeRelayACL struct { } func (a *nodeRelayACL) AllowReserve(p peer.ID, addr multiaddr.Multiaddr) bool { - _, ok := a.node.authPeers.Load(p) - return ok + return a.node.isAdmitted(p) } func (a *nodeRelayACL) AllowConnect(src peer.ID, srcAddr multiaddr.Multiaddr, dest peer.ID) bool { - _, ok := a.node.authPeers.Load(dest) - return ok + return a.node.isAdmitted(dest) +} + +// isAdmitted reports whether a peer completed the auth handshake and its token +// has not lapsed since. The handshake only proves the token was valid at that +// instant, so without this the relay ACL would honour an admission forever. +func (n *SamNode) isAdmitted(p peer.ID) bool { + v, ok := n.authPeers.Load(p) + if !ok { + return false + } + expiry, ok := v.(time.Time) + if !ok || !time.Now().Before(expiry) { + n.authPeers.Delete(p) + return false + } + return true } type SamNode struct { @@ -1197,7 +1211,9 @@ func (n *SamNode) handleBannedEvent(event *api.MeshEvent) { if n.revokedPeers != nil { n.revokedPeers.Add(event.PeerId, event.Timestamp) } + // Drop any prior admission, otherwise the relay ACL keeps honouring it. if p, err := peer.Decode(event.PeerId); err == nil { + n.authPeers.Delete(p) if n.Host != nil { _ = n.Host.Network().ClosePeer(p) } @@ -1389,6 +1405,13 @@ func (n *SamNode) HandleAuthHandshake(s network.Stream) { }() remotePeer := s.Conn().RemotePeer() + if n.revokedPeers != nil { + if _, revoked := n.revokedPeers.Get(remotePeer.String()); revoked { + logger.Warnf("[AuthN] Peer %s is revoked", remotePeer) + return + } + } + reader := msgio.NewVarintReaderSize(s, 1024*64) msg, err := reader.ReadMsg() if err != nil { @@ -1403,7 +1426,7 @@ func (n *SamNode) HandleAuthHandshake(s network.Stream) { return } - b, err := n.verifyBiscuit(exchange.Biscuit, remotePeer) + b, expiry, err := n.verifyBiscuit(exchange.Biscuit, remotePeer) if err != nil { logger.Warnf("[AuthN] Authorization failed for %s: %v", remotePeer, err) return @@ -1419,7 +1442,7 @@ func (n *SamNode) HandleAuthHandshake(s network.Stream) { return } - n.authPeers.Store(remotePeer, true) + n.authPeers.Store(remotePeer, expiry) logger.Infof("[AuthN] Successfully authenticated peer %s", remotePeer) // Mutual response with our identity, mirroring the router handler, so @@ -1431,10 +1454,10 @@ func (n *SamNode) HandleAuthHandshake(s network.Stream) { } } -func (n *SamNode) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Biscuit, error) { +func (n *SamNode) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Biscuit, time.Time, error) { b, err := biscuit.Unmarshal(biscuitData) if err != nil { - return nil, fmt.Errorf("malformed biscuit: %w", err) + return nil, time.Time{}, fmt.Errorf("malformed biscuit: %w", err) } n.keysMu.RLock() @@ -1452,19 +1475,24 @@ func (n *SamNode) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscui continue } + identity.EnforceExpiration(authorizer) authorizer.AddPolicy(api.AllowIfTruePolicy) if err := authorizer.Authorize(); err == nil { - return b, nil + expiry, err := identity.ExpirationOf(authorizer) + if err != nil { + return nil, time.Time{}, err + } + return b, expiry, nil } else { lastErr = fmt.Errorf("authorize error: %w", err) } } if lastErr != nil { - return nil, fmt.Errorf("no valid key found (last error: %v)", lastErr) + return nil, time.Time{}, fmt.Errorf("no valid key found (last error: %v)", lastErr) } - return nil, fmt.Errorf("no valid key found") + return nil, time.Time{}, fmt.Errorf("no valid key found") } func (n *SamNode) RegisterService(ctx context.Context, req *api.RegisterServiceRequest) error { diff --git a/internal/node/node_test.go b/internal/node/node_test.go index b3fa0123..e0a70af8 100644 --- a/internal/node/node_test.go +++ b/internal/node/node_test.go @@ -22,9 +22,11 @@ import ( "testing" "time" + "github.com/biscuit-auth/biscuit-go/v2" "github.com/google/sam/api" lru "github.com/hashicorp/golang-lru/v2" "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multiaddr" ) @@ -142,6 +144,57 @@ func TestHandleKeyRotationEvent(t *testing.T) { } } +// TestVerifyBiscuitRejectsExpiredToken covers the peer-admission half of #296: +// HandleAuthHandshake admits a peer into authPeers, which the relay ACL then +// trusts, so it must reject an expired token exactly like the dataplane does. +func TestVerifyBiscuitRejectsExpiredToken(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + remotePeer := peer.ID("dummy-peer") + + mint := func(expiration time.Time) []byte { + builder := biscuit.NewBuilder(priv) + for _, f := range []biscuit.Fact{ + {Predicate: biscuit.Predicate{Name: api.FactNode, IDs: []biscuit.Term{biscuit.String(remotePeer.String())}}}, + {Predicate: biscuit.Predicate{Name: api.FactExpiration, IDs: []biscuit.Term{biscuit.Date(expiration)}}}, + } { + if err := builder.AddAuthorityFact(f); err != nil { + t.Fatal(err) + } + } + b, err := builder.Build() + if err != nil { + t.Fatal(err) + } + data, err := b.Serialize() + if err != nil { + t.Fatal(err) + } + return data + } + + node := &SamNode{ + trustedKeys: []TrustedKey{{Key: pub, ReceivedAt: time.Now()}}, + BiscuitTimeout: 500 * time.Millisecond, + } + + want := time.Now().Add(time.Hour) + _, expiry, err := node.verifyBiscuit(mint(want), remotePeer) + if err != nil { + t.Fatalf("valid token rejected: %v", err) + } + // The admission is cached against this instant, so it has to be the token's. + if skew := expiry.Sub(want); skew < -time.Second || skew > time.Second { + t.Errorf("reported expiry %v, want ~%v", expiry, want) + } + + if _, _, err := node.verifyBiscuit(mint(time.Now().Add(-time.Hour)), remotePeer); err == nil { + t.Fatal("expired token admitted on the peer-authentication path") + } +} + func TestStartRenewalLoop_ExpiredAndFails(t *testing.T) { if os.Getenv("BE_CRASHER") == "1" { store, _ := NewStore(t.TempDir()) diff --git a/internal/node/relay_acl_test.go b/internal/node/relay_acl_test.go index c9c31107..c0829300 100644 --- a/internal/node/relay_acl_test.go +++ b/internal/node/relay_acl_test.go @@ -18,6 +18,8 @@ import ( "testing" "time" + "github.com/google/sam/api" + lru "github.com/hashicorp/golang-lru/v2" "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multiaddr" ) @@ -36,14 +38,14 @@ func TestNodeRelayACL_AllowConnect(t *testing.T) { } // Src is authenticated, dest is not -> should fail - node.authPeers.Store(srcPeer, struct{}{}) + node.authPeers.Store(srcPeer, time.Now().Add(time.Hour)) if acl.AllowConnect(srcPeer, srcAddr, destPeer) { t.Errorf("Expected AllowConnect to return false when dest is not authenticated, even if src is") } // Dest is authenticated, src is not -> should succeed node.authPeers.Delete(srcPeer) - node.authPeers.Store(destPeer, struct{}{}) + node.authPeers.Store(destPeer, time.Now().Add(time.Hour)) if !acl.AllowConnect(srcPeer, srcAddr, destPeer) { t.Errorf("Expected AllowConnect to return true when dest is authenticated") } @@ -60,8 +62,66 @@ func TestNodeRelayACL_AllowReserve(t *testing.T) { t.Errorf("Expected AllowReserve to return false when peer is not authenticated") } - node.authPeers.Store(peerID, struct{}{}) + node.authPeers.Store(peerID, time.Now().Add(time.Hour)) if !acl.AllowReserve(peerID, addr) { t.Errorf("Expected AllowReserve to return true when peer is authenticated") } } + +// TestExpiredAdmissionLosesRelayRights covers a token lapsing after the peer was +// admitted. The handshake only proves validity at that instant, so the ACL has +// to re-check, otherwise one handshake buys relay rights forever. +func TestExpiredAdmissionLosesRelayRights(t *testing.T) { + node := &SamNode{BiscuitTimeout: 500 * time.Millisecond} + acl := &nodeRelayACL{node: node} + + peerID := peer.ID("lapsed-peer") + addr, _ := multiaddr.NewMultiaddr("/ip4/127.0.0.1/tcp/1234") + + node.authPeers.Store(peerID, time.Now().Add(-time.Second)) + if acl.AllowReserve(peerID, addr) { + t.Error("expired admission still holds relay rights") + } + if _, still := node.authPeers.Load(peerID); still { + t.Error("expired admission was not evicted") + } + + // A value of any other type is a bug, not an admission. + node.authPeers.Store(peerID, true) + if acl.AllowReserve(peerID, addr) { + t.Error("malformed admission entry granted relay rights") + } +} + +// TestBannedPeerLosesRelayRights covers a ban arriving after the peer was +// already admitted: the relay ACL reads authPeers, so leaving a stale entry +// there keeps granting reservations to a revoked peer. +func TestBannedPeerLosesRelayRights(t *testing.T) { + revokedCache, err := lru.New[string, int64](10) + if err != nil { + t.Fatal(err) + } + node := &SamNode{revokedPeers: revokedCache, BiscuitTimeout: 500 * time.Millisecond} + acl := &nodeRelayACL{node: node} + + peerID, err := peer.Decode("12D3KooWAFv4iJst5G6MjwXhZ66K5zS1tP7A9vSg4vK8f1T7X8t9") + if err != nil { + t.Fatal(err) + } + addr, _ := multiaddr.NewMultiaddr("/ip4/127.0.0.1/tcp/1234") + + node.authPeers.Store(peerID, time.Now().Add(time.Hour)) + if !acl.AllowReserve(peerID, addr) { + t.Fatal("expected an admitted peer to hold relay rights") + } + + node.handleBannedEvent(&api.MeshEvent{ + Type: api.MeshEvent_BANNED, + PeerId: peerID.String(), + Timestamp: time.Now().UnixMilli(), + }) + + if acl.AllowReserve(peerID, addr) { + t.Error("banned peer still holds relay rights") + } +} diff --git a/tests/integration/biscuit_expiry_test.go b/tests/integration/biscuit_expiry_test.go new file mode 100644 index 00000000..bd9309ed --- /dev/null +++ b/tests/integration/biscuit_expiry_test.go @@ -0,0 +1,185 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/identity" + "github.com/google/sam/internal/node" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/proto" +) + +// testBiscuitTTL is short enough to expire inside the test's time budget while +// still leaving room for the enrollment round trip to be observed as valid. +const testBiscuitTTL = 2 * time.Second + +// Biscuit date terms carry whole seconds, so the injected time($now) fact only +// compares greater than expiration() a full second past it. +const expiryMargin = 2 * time.Second + +// TestBiscuitExpiryIsEnforcedOnEveryPath is the end-to-end reproduction of #296. +// +// A real control plane mints a real biscuit over the real /register flow, and +// the same token is then presented to both verification paths: the generic +// verifier (identity.VerifyBiscuit) and the node dataplane authorizer +// (SamNode.VerifyBiscuitToken, the tool-invocation path). Before the expiry +// both accept it; after the expiry both must reject it. The bug was that the +// dataplane kept accepting, because it never injected the time fact that the +// expiration check reads. +// +// It also pins the second half of the fix: the biscuit's lifetime is the +// admin-configured --biscuit-ttl, and EnrollResponse.Expiration (which drives +// the node's proactive refresh) reports that same instant rather than the OIDC +// token's own, much later, expiry. +func TestBiscuitExpiryIsEnforcedOnEveryPath(t *testing.T) { + cpBin := buildBinary(t, "./cmd/sam-control-plane") + tmpDir := t.TempDir() + + oidcURL, mintToken := startCustomMockOIDC(t) + cpPort := getFreePort(t) + + cpCmd := exec.Command(cpBin, + "--bind-address", fmt.Sprintf("127.0.0.1:%d", cpPort), + "--db-dsn", filepath.Join(tmpDir, "cp-keys.db"), + "--issuer", oidcURL, + "--insecure-skip-tls-verify", + "--biscuit-ttl", testBiscuitTTL.String(), + ) + cpCmd.Stdout = os.Stdout + cpCmd.Stderr = os.Stderr + if err := cpCmd.Start(); err != nil { + t.Fatalf("failed to start control plane: %v", err) + } + defer func() { _ = cpCmd.Process.Kill(); _ = cpCmd.Wait() }() + waitForControlPlane(t, cpPort) + + privKey, pubKey, err := crypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatal(err) + } + peerID, err := peer.IDFromPublicKey(pubKey) + if err != nil { + t.Fatal(err) + } + pubBytes, err := crypto.MarshalPublicKey(pubKey) + if err != nil { + t.Fatal(err) + } + + jwtToken := mintToken(map[string]interface{}{ + "sub": "expiry-user", + "roles": []string{api.RoleNode}, + }) + + mintedAt := time.Now() + enrollResp := registerOnControlPlane(t, cpPort, peerID, pubBytes, jwtToken) + biscuitToken := enrollResp.BiscuitToken + cpPubKey := ed25519.PublicKey(enrollResp.ControlPlanePublicKey) + + // The advertised expiration is the biscuit's, not the OIDC token's (1h). + reported := time.Unix(enrollResp.Expiration, 0) + if skew := reported.Sub(mintedAt.Add(testBiscuitTTL)); skew < -2*time.Second || skew > 2*time.Second { + t.Errorf("EnrollResponse.Expiration is %v, want ~%v (--biscuit-ttl %v after minting)", + reported, mintedAt.Add(testBiscuitTTL), testBiscuitTTL) + } + + store, err := node.NewStore(filepath.Join(tmpDir, "node-data")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + + samNode, err := node.NewSamNode(node.Options{ + PrivKey: privKey, + Store: store, + ControlPlanePubKey: cpPubKey, + }) + if err != nil { + t.Fatal(err) + } + samNode.BiscuitTimeout = 5 * time.Second + + reqCtx := node.RequestContext{PeerID: peerID, Protocol: string(api.MCPProtocolID)} + + // Fresh: both paths agree the token is good. + if _, err := identity.VerifyBiscuit(biscuitToken, peerID, []ed25519.PublicKey{cpPubKey}, 5*time.Second); err != nil { + t.Fatalf("generic verifier rejected a freshly minted biscuit: %v", err) + } + if err := samNode.VerifyBiscuitToken(biscuitToken, reqCtx); err != nil { + t.Fatalf("node dataplane rejected a freshly minted biscuit: %v", err) + } + + time.Sleep(time.Until(reported) + expiryMargin) + + // Expired: both paths must agree it is no longer good. + if _, err := identity.VerifyBiscuit(biscuitToken, peerID, []ed25519.PublicKey{cpPubKey}, 5*time.Second); err == nil { + t.Error("generic verifier accepted an expired biscuit") + } + if err := samNode.VerifyBiscuitToken(biscuitToken, reqCtx); err == nil { + t.Error("node dataplane accepted an expired biscuit (#296)") + } +} + +func registerOnControlPlane(t *testing.T, cpPort int, clientID peer.ID, pubBytes []byte, jwtToken string) *api.EnrollResponse { + t.Helper() + + reqBytes, err := proto.Marshal(&api.EnrollRequest{ + Jwt: jwtToken, + PeerId: clientID.String(), + PublicKey: pubBytes, + RequestedRole: api.RoleNode, + }) + if err != nil { + t.Fatal(err) + } + + resp, err := http.Post( + fmt.Sprintf("http://127.0.0.1:%d/register", cpPort), + "application/octet-stream", + bytes.NewReader(reqBytes), + ) + if err != nil { + t.Fatalf("failed to send enroll request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("enroll request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var enrollResp api.EnrollResponse + if err := proto.Unmarshal(body, &enrollResp); err != nil { + t.Fatalf("failed to decode enroll response: %v", err) + } + return &enrollResp +}