Fix missing expiration checks - #316
Conversation
SamNode.Authorize built its authorizer without injecting the time(now) fact or the ControlPlaneStaticTimeCheck, unlike identity.VerifyBiscuit. An expired biscuit that still verified cryptographically was accepted on the node dataplane (tool-invocation path) while being correctly rejected by the generic verifier. Fixes google#296.
…IDC token's expiry Every mint site hardcoded the biscuit expiration() fact to the fixed api.BiscuitTokenTTL (24h), with no way for an operator to shorten or lengthen it, and no relationship to the OIDC token that authorized the enrollment. A biscuit could therefore outlive the session that vouched for it. Add Options.BiscuitTTL (defaulting to api.BiscuitTokenTTL) wired to a new --biscuit-ttl flag on sam-control-plane, and use it at every mint site instead of the hardcoded constant. In the interactive OIDC enrollment path, cap the minted expiry to min(BiscuitTTL, token.Expiry) and report that same value back in EnrollResponse.Expiration (it previously reported the OIDC token's own expiry, which the node uses to schedule its proactive refresh and did not match the biscuit's actual expiration() fact).
…admission The google#296 fix inlined the time fact and the expiration check into SamNode.Authorize, which closed the tool-invocation path but left the divergence that caused the bug: each authorizer site decides for itself whether expiry is checked, and expiry is a Datalog check over a fact, so forgetting the fact silently accepts expired tokens. Add identity.EnforceExpiration as the single place that pairs the time(now) fact with ControlPlaneStaticTimeCheck, and route VerifyBiscuitAndGetKey and SamNode.Authorize through it. Use it to close a second instance of the same bug: SamNode.verifyBiscuit, behind the /sam/auth/1.0.0 handshake, admitted peers into authPeers with no expiry check at all. nodeRelayACL grants relay reserve and connect rights off that set, so an expired biscuit bought relay access. Also correct the ControlPlaneStaticTimeCheck doc comment: it verifies the biscuit's own expiration() fact, not an OIDC token.
A biscuit must not outlive whatever vouched for it. Interactive enrollment already caps the minted expiry at the OIDC token's, but /refresh presents no live token at all: the node authenticates with a challenge signature and the control plane replays the claims recorded at enrollment. What vouches for it there is the session record, and HandleRefresh gated on it without bounding the biscuit by it. A node refreshing in the final minutes of its 90-day session therefore walked away with a full --biscuit-ttl token, valid a further 24h by default past the session it was supposed to end with. Cap biscuitExpiry at nodeRecord.ExpiresAt, guarding the zero value so bootstrap records, which deliberately carry no session deadline, keep the configured TTL. Cover the four combinations (OIDC token first, TTL first, session first, no session) by reading the minted expiration() fact back out and checking the wire fields that drive the node's refresh scheduling agree with it. Also gofmt config.go, missed when BiscuitTTL was added.
Reproduce the reported flow against real components rather than hand-built tokens: a real control plane mints a real biscuit over /register with a short --biscuit-ttl, and the same token is then put to both verification paths the issue found disagreeing, before and after its expiry. Before, both accept. After, both must reject. Reverting the fix fails the test on the dataplane assertion alone, which is the asymmetry reported. Also pins EnrollResponse.Expiration to the biscuit's real expiration, since the node schedules its proactive refresh from that field.
…banned peers from the relay ACL Follow-up audit of google#296 found two more paths where a lapsed token still buys access, both reached through VerifyAndExtractPeerID, which skipped time checks unconditionally. That exemption exists for /refresh, where it is correct: a node refreshes because its token lapsed, so it has nothing unexpired to present, and the handler bounds the request with the session record and a signed challenge. But GET /policies used the same helper to authenticate a node, so an expired-but-not-banned node kept reading the mesh policy. Make expiry the default and the exemption explicit and named: VerifyAndExtractPeerID now enforces it, VerifyExpiredAndExtractPeerID does not, and only the refresh handler calls the latter. Neither adds a policy, so both report ErrNoMatchingPolicy on success; the test pins that a failed check still outranks it, which is what makes this work. Separately, revocation did not reach the peer-admission handshake at all. HandleAuthHandshake never consulted revokedPeers, and handleBannedEvent closed the connection but left the peer in authPeers, which nodeRelayACL reads. A banned peer therefore kept relay reserve and connect rights until the process restarted, and could re-handshake to get them back. Check the cache before admitting, and evict on ban. Also document that VerifyBiscuitRole does not enforce expiry, and why its callers are fine with that: they hold a freshly minted token, or they are deciding whether to boot with an on-disk identity, where a lapsed token should trigger a refresh rather than refuse to start.
There was a problem hiding this comment.
Code Review
This pull request introduces a configurable Biscuit TTL (--biscuit-ttl) and ensures that a Biscuit's expiration is capped by the OIDC token's expiry or the OIDC session's end. It fixes a security issue where expired tokens were accepted on the node dataplane by implementing and enforcing expiration checks across all verification paths. Additionally, it drops prior admissions when a peer is banned to prevent revoked peers from retaining relay rights. Feedback on the changes highlights a security concern where expired peers might retain relay rights indefinitely in authPeers due to a lack of cleanup mechanisms, and suggests storing expiration times in authPeers to evict expired entries. It also recommends handling errors from AddFact and AddCheck within EnforceExpiration to prevent potential silent security bypasses.
| continue | ||
| } | ||
|
|
||
| identity.EnforceExpiration(authorizer) |
There was a problem hiding this comment.
Security Issue: Expired peers retain relay rights indefinitely in authPeers
Currently, when a peer successfully authenticates via HandleAuthHandshake, it is stored in n.authPeers with a value of true (on line 1431, which is outside this diff). However, there is no mechanism to clean up or re-verify these entries when the peer's Biscuit token expires. As a result, once a peer has authenticated once, it retains relay rights indefinitely in nodeRelayACL (which only checks authPeers), completely bypassing the short-lived token expiration security model.
Suggested Fix:
- Modify
verifyBiscuitto extract and return the Biscuit's expiration time. - Store the expiration time in
n.authPeersinstead oftrue. - Update
nodeRelayACL(AllowReserveandAllowConnect) to check and delete expired entries.
Here is how you can implement this:
In verifyBiscuit (this function):
func (n *SamNode) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Biscuit, time.Time, error) {
// ... (unmarshal and loop)
identity.EnforceExpiration(authorizer)
authorizer.AddPolicy(api.AllowIfTruePolicy)
if err := authorizer.Authorize(); err == nil {
var exp time.Time
rule := 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 facts, err := authorizer.Query(rule); err == nil && len(facts) > 0 && len(facts[0].IDs) > 0 {
if date, ok := facts[0].IDs[0].(biscuit.Date); ok {
exp = time.Time(date)
}
}
return b, exp, nil
}
// ...
}In HandleAuthHandshake:
b, exp, err := n.verifyBiscuit(exchange.Biscuit, remotePeer)
// ...
n.authPeers.Store(remotePeer, exp)In nodeRelayACL (around line 118):
func (a *nodeRelayACL) AllowReserve(p peer.ID, addr multiaddr.Multiaddr) bool {
val, ok := a.node.authPeers.Load(p)
if !ok {
return false
}
if exp, ok := val.(time.Time); ok && !exp.IsZero() && time.Now().After(exp) {
a.node.authPeers.Delete(p)
return false
}
return true
}There was a problem hiding this comment.
Good catch, and this is the same bug one layer up: enforcing expiry at admission is worth little if the admission itself never expires. Fixed in 61d2fc7.
Took the shape you suggested, with two changes:
verifyBiscuitreturns an error rather than a zero time when it cannot extract an expiration.EnforceExpirationalready requires anexpiration()fact for the token to authorize at all, so a token that authorizes but carries no such fact is a contradiction, not a peer to admit with an open-ended entry.- The
AllowReservesketch falls open on a type mismatch:if exp, ok := val.(time.Time); ok && ...returnstruewhen the assertion fails. That is not hypothetical here, since the old code storedtrueand the tests storedstruct{}{}. The version I landed treats any non-time.Timevalue as a bug and denies, with a regression test for it.
Extraction lives in identity.ExpirationOf, next to EnforceExpiration, so expiry logic stays in one place, and it takes the earliest expiration() fact in case an attenuated token narrows it.
| 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) | ||
| } |
There was a problem hiding this comment.
Defensive Programming: Unhandled errors from AddFact and AddCheck
The AddFact and AddCheck methods return an error. While they are highly unlikely to fail with programmatic inputs, ignoring these errors could lead to silent security bypasses if the authorizer is in an invalid state or if internal limits are exceeded.
Consider handling these errors or panicking to fail-closed:
func EnforceExpiration(authorizer biscuit.Authorizer) {
if err := authorizer.AddFact(biscuit.Fact{
Predicate: biscuit.Predicate{
Name: api.FactTime,
IDs: []biscuit.Term{biscuit.Date(time.Now())},
},
}); err != nil {
panic(fmt.Sprintf("failed to add time fact: %v", err))
}
if err := authorizer.AddCheck(api.ControlPlaneStaticTimeCheck); err != nil {
panic(fmt.Sprintf("failed to add static time check: %v", err))
}
}There was a problem hiding this comment.
This one is a false positive: Authorizer.AddFact and AddCheck return nothing, so there is no error to handle.
From biscuit-go/v2@v2.2.0/authorizer.go:
AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)The suggestion looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we do check at every call site. errcheck is one of the active linters, so an unchecked error here would not have got past make lint either.
Leaving EnforceExpiration as is. The suggested panic would compile only after inventing an error that cannot occur.
The handshake proves a peer's biscuit was valid at that instant, then records the peer in authPeers. nodeRelayACL reads only that set, and nothing ever removed an entry, so a single handshake bought relay reserve and connect rights for the lifetime of the process. Enforcing expiry at admission, as the previous commit did, is worth little if the admission outlives the token it was based on. Store the token's expiration alongside the admission and re-check it in the ACL, evicting on the way out. verifyBiscuit returns that instant and fails if it cannot extract one: EnforceExpiration already requires an expiration() fact to authorize at all, so a token that authorizes but has no such fact is a contradiction, not a peer to admit with a zero time. A value of any other type is likewise treated as a bug and denied, rather than falling through to allow. Extraction lives in identity.ExpirationOf, next to EnforceExpiration, so expiry stays in one place. It takes the earliest expiration() fact, since an attenuated token may narrow it. Raised in review of google#316.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a configurable Biscuit TTL and resolves a security vulnerability (#296) by ensuring that Biscuit token expiration is strictly enforced on the node dataplane. It adds the EnforceExpiration helper to inject the current time and static time checks into the authorizer. The review feedback highlights critical compilation errors due to incorrect field access on biscuit.Fact in biscuit-go v2, recommends handling errors returned by EnforceExpiration and AddPolicy to prevent silent failures, and suggests adding a nil check for the token before accessing its expiry to avoid potential panics.
| for _, fact := range facts { | ||
| if len(fact.IDs) != 1 { | ||
| continue | ||
| } | ||
| date, ok := fact.IDs[0].(biscuit.Date) | ||
| if !ok { | ||
| continue | ||
| } |
There was a problem hiding this comment.
In biscuit-go v2, biscuit.Fact has a named Predicate field of type Predicate. It does not embed Predicate anonymously, so accessing fact.IDs directly will result in a compilation error. You must access IDs via fact.Predicate.IDs.
| for _, fact := range facts { | |
| if len(fact.IDs) != 1 { | |
| continue | |
| } | |
| date, ok := fact.IDs[0].(biscuit.Date) | |
| if !ok { | |
| continue | |
| } | |
| for _, fact := range facts { | |
| if len(fact.Predicate.IDs) != 1 { | |
| continue | |
| } | |
| date, ok := fact.Predicate.IDs[0].(biscuit.Date) | |
| if !ok { | |
| continue | |
| } |
There was a problem hiding this comment.
biscuit.Fact embeds Predicate anonymously, so fact.IDs is a promoted field selector and compiles fine.
biscuit-go/v2@v2.2.0/types.go:116:
type Fact struct {
Predicate
}Field promotion applies. go build ./... and go vet ./... are green on this branch.
Applying this suggestion would actually break the build here, because staticcheck is an active linter and QF1008 rejects the longer form. That rule is what made me write it this way in the first place:
internal/controlplane/biscuit_ttl_test.go:64:23: QF1008: could remove embedded field "Predicate" from selector (staticcheck)
QF1008 only fires on embedded fields, so the diagnostic is itself proof that the field is embedded. No change.
| 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) |
There was a problem hiding this comment.
Similar to the issue in internal/identity/biscuit.go, biscuit.Fact has a named Predicate field. Accessing facts[0].IDs directly will cause a compilation error. Please access it via facts[0].Predicate.IDs.
| 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 len(facts) != 1 || len(facts[0].Predicate.IDs) != 1 { | |
| t.Fatalf("expected exactly one expiration fact, got %v", facts) | |
| } | |
| date, ok := facts[0].Predicate.IDs[0].(biscuit.Date) |
There was a problem hiding this comment.
biscuit.Fact embeds Predicate anonymously, so fact.IDs is a promoted field selector and compiles fine.
biscuit-go/v2@v2.2.0/types.go:116:
type Fact struct {
Predicate
}Field promotion applies. go build ./... and go vet ./... are green on this branch.
Applying this suggestion would actually break the build here, because staticcheck is an active linter and QF1008 rejects the longer form. That rule is what made me write it this way in the first place:
internal/controlplane/biscuit_ttl_test.go:64:23: QF1008: could remove embedded field "Predicate" from selector (staticcheck)
QF1008 only fires on embedded fields, so the diagnostic is itself proof that the field is embedded. No change.
| 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) | ||
| } |
There was a problem hiding this comment.
In biscuit-go, AddFact and AddCheck return an error. Ignoring these errors can lead to silent failures where expiration checks are not properly enforced. Update EnforceExpiration to return an error so that callers can handle failures appropriately.
| 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) | |
| } | |
| func EnforceExpiration(authorizer biscuit.Authorizer) error { | |
| if err := authorizer.AddFact(biscuit.Fact{ | |
| Predicate: biscuit.Predicate{ | |
| Name: api.FactTime, | |
| IDs: []biscuit.Term{biscuit.Date(time.Now())}, | |
| }, | |
| }); err != nil { | |
| return err | |
| } | |
| return authorizer.AddCheck(api.ControlPlaneStaticTimeCheck) | |
| } |
There was a problem hiding this comment.
Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.
biscuit-go/v2@v2.2.0/authorizer.go:22:
AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)Authorize and Query are the two that return errors, and both are checked at every call site.
This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.
These suggestions do not compile. No change.
| EnforceExpiration(authorizer) | ||
| authorizer.AddPolicy(api.AllowIfTruePolicy) |
There was a problem hiding this comment.
Handle the errors returned by EnforceExpiration and AddPolicy to ensure that failures during policy and expiration enforcement are not silently ignored.
if err := EnforceExpiration(authorizer); err != nil {
lastErr = err
continue
}
if err := authorizer.AddPolicy(api.AllowIfTruePolicy); err != nil {
lastErr = err
continue
}There was a problem hiding this comment.
Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.
biscuit-go/v2@v2.2.0/authorizer.go:22:
AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)Authorize and Query are the two that return errors, and both are checked at every call site.
This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.
These suggestions do not compile. No change.
| if enforceExpiry { | ||
| EnforceExpiration(auth) | ||
| } |
There was a problem hiding this comment.
There was a problem hiding this comment.
Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.
biscuit-go/v2@v2.2.0/authorizer.go:22:
AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)Authorize and Query are the two that return errors, and both are checked at every call site.
This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.
These suggestions do not compile. No change.
| // Enforce client_peer_id matches connection_peer_id | ||
| authorizer.AddCheck(api.BaselineReplayCheck) | ||
|
|
||
| identity.EnforceExpiration(authorizer) |
There was a problem hiding this comment.
Handle the error returned by EnforceExpiration to ensure that any failure to inject the expiration check is caught and handled.
| identity.EnforceExpiration(authorizer) | |
| if err := identity.EnforceExpiration(authorizer); err != nil { | |
| return fmt.Errorf("failed to enforce expiration: %w", err) | |
| } |
There was a problem hiding this comment.
Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.
biscuit-go/v2@v2.2.0/authorizer.go:22:
AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)Authorize and Query are the two that return errors, and both are checked at every call site.
This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.
These suggestions do not compile. No change.
| identity.EnforceExpiration(authorizer) | ||
| authorizer.AddPolicy(api.AllowIfTruePolicy) |
There was a problem hiding this comment.
Handle the errors returned by EnforceExpiration and AddPolicy to ensure that failures during policy and expiration enforcement are not silently ignored.
if err := identity.EnforceExpiration(authorizer); err != nil {
lastErr = err
continue
}
if err := authorizer.AddPolicy(api.AllowIfTruePolicy); err != nil {
lastErr = err
continue
}There was a problem hiding this comment.
Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.
biscuit-go/v2@v2.2.0/authorizer.go:22:
AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)Authorize and Query are the two that return errors, and both are checked at every call site.
This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.
These suggestions do not compile. No change.
| if token.Expiry.Before(biscuitExpiry) { | ||
| biscuitExpiry = token.Expiry | ||
| } |
There was a problem hiding this comment.
There was a problem hiding this comment.
token cannot be nil at this point.
identity.VerifyJWT returns nil, nil, err on every failure path, and on success returns the non-nil *oidc.IDToken that verifier.Verify produced (internal/identity/oidc.go:94-99). HandleRegister returns immediately when it errors (server.go:440-444), so reaching this line means verification succeeded and token is non-nil.
The guard would be unreachable. Repo guidance is to validate at real boundaries rather than defend against states that cannot occur, and an unreachable nil check here would also suggest to a reader that a nil token is expected, which would be misleading. If VerifyJWT ever grew a claims, nil, nil path, the fix would belong there, where the invariant lives.
No change.
Thanks also to @kaisoz for digging deeper, there were several paths were the biscuit expiration time was not checked , hence allowing attacks based on that.
Fixes #296