Skip to content

Commit 306db6c

Browse files
committed
node: report the verdict of the key that signed the token, not the last key tried
With two trusted control-plane keys, the node tried each in turn and returned the last error. A token that verified under the first key and was denied by policy then came back as "biscuit: invalid signature" from the second key, so the caller never saw the denial. Which error won depended on key order, which made the policy e2e Negative and Attenuation paths flaky. Only a signature mismatch now moves on to the next key; once a key verifies the token, its verdict is the answer. A trusted key of the wrong size is skipped with a warning instead of ending the search.
1 parent 231145b commit 306db6c

2 files changed

Lines changed: 96 additions & 15 deletions

File tree

‎internal/node/middleware.go‎

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package node
1616

1717
import (
1818
"crypto/ed25519"
19+
"errors"
1920
"fmt"
2021
"runtime/debug"
2122
"sync/atomic"
@@ -223,28 +224,30 @@ func (n *AgentMeshNode) verifyBiscuitTokenWithRules(biscuitBytes []byte, reqCtx
223224
keys := n.trustedKeys
224225
n.keysMu.RUnlock()
225226

226-
var authorized bool
227-
var taskRules []*api.TaskAuthorizationRule
227+
// Only a signature mismatch moves on to the next trusted key. Once a key
228+
// verifies the token, that key's verdict is the answer: carrying on would
229+
// replace a policy denial with the next key's "invalid signature".
228230
var lastErr error
229231
for _, pubKey := range keys {
232+
if len(pubKey.Key) != ed25519.PublicKeySize {
233+
logger.Warnf("[Auth] Skipping trusted key of invalid size %d", len(pubKey.Key))
234+
continue
235+
}
230236
logger.Infof("[Auth] Trying key: %x", pubKey.Key)
231-
if rules, err := n.authorizeWithRules(biscuitBytes, reqCtx, pubKey.Key); err == nil {
232-
authorized = true
233-
taskRules = rules
234-
break
235-
} else {
236-
lastErr = err
237+
rules, err := n.authorizeWithRules(biscuitBytes, reqCtx, pubKey.Key)
238+
if err == nil {
239+
return rules, nil
237240
}
238-
}
239-
240-
if !authorized {
241-
if lastErr != nil {
242-
return nil, lastErr
241+
if !errors.Is(err, biscuit.ErrInvalidSignature) {
242+
return nil, err
243243
}
244-
return nil, fmt.Errorf("authorization failed")
244+
lastErr = err
245245
}
246246

247-
return taskRules, nil
247+
if lastErr != nil {
248+
return nil, lastErr
249+
}
250+
return nil, fmt.Errorf("authorization failed")
248251
}
249252

250253
func (n *AgentMeshNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519.PublicKey) error {

‎internal/node/middleware_test.go‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
cryptorand "crypto/rand"
2222
"encoding/base64"
2323
"encoding/json"
24+
"errors"
2425
"fmt"
2526

2627
"io"
@@ -253,6 +254,83 @@ func TestAuthorizeRejectsExpiredBiscuit(t *testing.T) {
253254
}
254255
}
255256

257+
// TestVerifyBiscuitTokenReportsVerdictOfSigningKey reproduces the flaky
258+
// policy e2e: with two trusted control-plane keys, a token whose signature
259+
// verifies under one key and is denied by policy was reported as
260+
// "invalid signature" when the other key happened to be tried last, hiding
261+
// the denial from the caller.
262+
func TestVerifyBiscuitTokenReportsVerdictOfSigningKey(t *testing.T) {
263+
store, err := NewStore(t.TempDir())
264+
if err != nil {
265+
t.Fatal(err)
266+
}
267+
t.Cleanup(func() { _ = store.Close() })
268+
269+
signingPub, signingPriv, err := ed25519.GenerateKey(nil)
270+
if err != nil {
271+
t.Fatal(err)
272+
}
273+
otherPub, _, err := ed25519.GenerateKey(nil)
274+
if err != nil {
275+
t.Fatal(err)
276+
}
277+
278+
caller := peer.ID("caller-peer")
279+
builder := biscuit.NewBuilder(signingPriv)
280+
_ = builder.AddAuthorityFact(api.MarkerFact(api.FactTargetUnrestricted))
281+
_ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
282+
Name: "node",
283+
IDs: []biscuit.Term{biscuit.String(caller.String())},
284+
}})
285+
_ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
286+
Name: api.FactClientPeerID,
287+
IDs: []biscuit.Term{biscuit.String(caller.String())},
288+
}})
289+
_ = builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
290+
Name: api.FactGrantedServiceExact,
291+
IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String("calculator")},
292+
}})
293+
b, err := builder.Build()
294+
if err != nil {
295+
t.Fatal(err)
296+
}
297+
tokenBytes, err := b.Serialize()
298+
if err != nil {
299+
t.Fatal(err)
300+
}
301+
302+
// The token grants mcp://calculator only, so this request is denied by
303+
// policy after its signature verified under signingPub.
304+
req := RequestContext{
305+
PeerID: caller,
306+
Protocol: "/mesh/mcp/1.0.0",
307+
Target: "mcp://unauthorized-service",
308+
}
309+
310+
for _, tt := range []struct {
311+
name string
312+
keys []TrustedKey
313+
}{
314+
{"signing key first", []TrustedKey{{Key: signingPub}, {Key: otherPub}}},
315+
{"signing key last", []TrustedKey{{Key: otherPub}, {Key: signingPub}}},
316+
} {
317+
t.Run(tt.name, func(t *testing.T) {
318+
node := &AgentMeshNode{
319+
Store: store,
320+
trustedKeys: tt.keys,
321+
BiscuitTimeout: 500 * time.Millisecond,
322+
}
323+
err := node.VerifyBiscuitToken(tokenBytes, req)
324+
if err == nil {
325+
t.Fatal("VerifyBiscuitToken allowed a target the token does not grant")
326+
}
327+
if errors.Is(err, biscuit.ErrInvalidSignature) {
328+
t.Fatalf("VerifyBiscuitToken = %v, want the policy denial from the key that signed the token", err)
329+
}
330+
})
331+
}
332+
}
333+
256334
// TestAuthorizeRejectsAppendedPeerBinding covers the dataplane half of the
257335
// binding bypass. Appending a block needs no root key, so a peer holding any
258336
// valid token could append node(<its own peer id>) and connect under its own

0 commit comments

Comments
 (0)