From 2662bddc02abaf12b1314fed35f81d031859c6f0 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Mon, 13 Jul 2026 13:28:29 +0100 Subject: [PATCH 1/2] fix: fallback to verificationMethods if capabilityInvocation or capabilityDelegation silent --- validator/validator.go | 18 +++++- validator/validator_test.go | 122 ++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/validator/validator.go b/validator/validator.go index 7d68827..11d94bf 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -121,11 +121,27 @@ func verifyTokenSignature(ctx context.Context, tok ucan.Token, cfg validationCon return fmt.Errorf("unsupported token type: %T", tok) } + // The capability relationships are optional (DID core §5.3): a document + // that expresses one restricts verification to the methods it lists, but + // a document that expresses nothing authorizes all of its verification + // methods — e.g. did:plc documents carry only verificationMethod. + // (Post-parse, an explicitly empty relationship is indistinguishable + // from an absent one and gets the same fallback.) + var vms []did.VerificationMethod + if verRel != nil { + vms = verRel.All() + } + if len(vms) == 0 && doc.VerificationMethods != nil { + for _, vm := range *doc.VerificationMethods { + vms = append(vms, vm) + } + } + // Try each verification method, collecting rejection reasons for the error. validationTime := time.Unix(int64(cfg.validationTime), 0) var rejections []verrs.VMRejection - for _, vm := range verRel.All() { + for _, vm := range vms { if vm.ExpiredAt(validationTime) { rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "expired"}) continue diff --git a/validator/validator_test.go b/validator/validator_test.go index a8bc417..1bdd2df 100644 --- a/validator/validator_test.go +++ b/validator/validator_test.go @@ -2,7 +2,9 @@ package validator_test import ( "context" + "encoding/json" "errors" + "fmt" "os" "testing" "time" @@ -570,6 +572,126 @@ func TestValidate(t *testing.T) { }) } +// TestRelationshipFallback covers the optional capability relationships (DID +// core §5.3): a document that expresses no capabilityInvocation / +// capabilityDelegation authorizes all of its verification methods, while a +// document that expresses one restricts verification to the methods listed. +func TestRelationshipFallback(t *testing.T) { + crankWidget := testutil.Must(command.Parse("/widget/crank"))(t) + + // plcShapedResolver serves a document parsed from JSON that carries only + // verificationMethod — the shape did:plc directories emit. + plcShapedResolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) { + docJSON := fmt.Sprintf(`{ + "id": %q, + "verificationMethod": [{ + "id": "%s#key", + "type": %q, + "controller": %q, + "publicKeyMultibase": %q + }] + }`, d, d, did.MultikeyVerificationMethodType, d, d.Identifier()) + var doc did.Document + if err := json.Unmarshal([]byte(docJSON), &doc); err != nil { + return did.Document{}, err + } + return doc, nil + }) + + t.Run("invocation verifies against a document without relationships", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(plcShapedResolver)) + require.NoError(t, err) + }) + + t.Run("delegation in chain verifies against a document without relationships", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) + + del, err := delegation.Delegate(subject, bob.DID(), subject.DID(), crankWidget) + require.NoError(t, err) + inv, err := invocation.Invoke(bob, subject.DID(), crankWidget, datamodel.Map{}, + invocation.WithProofs(del.Link())) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(plcShapedResolver), + validator.WithProofResolver(validator.ProofsFromContainer( + container.New(container.WithDelegations(del)))), + ) + require.NoError(t, err) + }) + + t.Run("populated relationship still restricts to its listed methods", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + other := testutil.RandomIssuer(t) // a key the subject does NOT sign with + + resolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) { + doc := did.NewDocument(d) + signerVM := did.VerificationMethod{ + ID: doc.Fragment("signer"), + Controller: d, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()}, + } + otherVM := did.VerificationMethod{ + ID: doc.Fragment("other"), + Controller: d, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: other.DID().Identifier()}, + } + if err := doc.VerificationMethods.Add(signerVM); err != nil { + return did.Document{}, err + } + // Only the non-signing key is authorized for capability invocation, + // so verification must NOT fall back to the signer's method. + if err := doc.CapabilityInvocation.Add(otherVM); err != nil { + return did.Document{}, err + } + return doc, nil + }) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(resolver)) + require.Error(t, err) + }) + + t.Run("nil relationships fall back without panicking", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + + // A document built without NewDocument: relationship fields are nil. + resolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) { + vms := did.VerificationMethods{} + doc := did.Document{ID: d, VerificationMethods: &vms} + vm := did.VerificationMethod{ + ID: doc.Fragment("key"), + Controller: d, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()}, + } + if err := vms.Add(vm); err != nil { + return did.Document{}, err + } + return doc, nil + }) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(resolver)) + require.NoError(t, err) + }) +} + // expiredKeyResolver returns a DID resolver that serves a document for the // issuer's DID with its Multikey VM marked expired or revoked as specified. func expiredKeyResolver(t *testing.T, issuer ucan.Issuer, expires, revoked *did.DateTimeStamp) did.Resolver { From 21ff4577115e16b0aa80c474236aace9a42e9c7a Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Mon, 13 Jul 2026 14:27:07 +0100 Subject: [PATCH 2/2] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fall back to verificationMethod only when the relationship is absent or empty, not when its entries fail to resolve — a populated relationship referencing missing methods must not widen authorization. - Stop reusing err across verification-method loop iterations, which caused valid methods to be skipped after one unsupported VM type. - Add test covering a relationship that references a missing method. Co-Authored-By: Claude Fable 5 --- validator/validator.go | 26 ++++++++++++++------------ validator/validator_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/validator/validator.go b/validator/validator.go index 11d94bf..2c10a4d 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -126,15 +126,19 @@ func verifyTokenSignature(ctx context.Context, tok ucan.Token, cfg validationCon // a document that expresses nothing authorizes all of its verification // methods — e.g. did:plc documents carry only verificationMethod. // (Post-parse, an explicitly empty relationship is indistinguishable - // from an absent one and gets the same fallback.) + // from an absent one and gets the same fallback.) A relationship that + // lists entries which fail to resolve to verification methods is NOT + // silent: it restricts to those (unresolvable) methods and must not + // widen to the full verificationMethod set. var vms []did.VerificationMethod - if verRel != nil { - vms = verRel.All() - } - if len(vms) == 0 && doc.VerificationMethods != nil { - for _, vm := range *doc.VerificationMethods { - vms = append(vms, vm) + if verRel == nil || verRel.IsZero() { + if doc.VerificationMethods != nil { + for _, vm := range *doc.VerificationMethods { + vms = append(vms, vm) + } } + } else { + vms = verRel.All() } // Try each verification method, collecting rejection reasons for the error. @@ -152,12 +156,10 @@ func verifyTokenSignature(ctx context.Context, tok ucan.Token, cfg validationCon } f, ok := cfg.verifierFactories[vm.Type] if !ok { - err = fmt.Errorf("%w for VM type %q", ErrNoVerifierFactory, vm.Type) - } - var v ucan.Verifier - if err == nil { - v, err = f(ctx, vm.Material) + rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "unsupported verification method type"}) + continue } + v, err := f(ctx, vm.Material) if errors.Is(err, ErrNoVerifierFactory) { rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "unsupported verification method type"}) continue diff --git a/validator/validator_test.go b/validator/validator_test.go index 1bdd2df..d0deb83 100644 --- a/validator/validator_test.go +++ b/validator/validator_test.go @@ -664,6 +664,39 @@ func TestRelationshipFallback(t *testing.T) { require.Error(t, err) }) + t.Run("relationship referencing a missing method does not fall back", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + + // The document lists the signer under verificationMethod, but + // capabilityInvocation references an ID with no matching method. The + // relationship is present (not silent), so verification must restrict + // to it — not widen to all of verificationMethod. + resolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) { + docJSON := fmt.Sprintf(`{ + "id": %q, + "verificationMethod": [{ + "id": "%s#key", + "type": %q, + "controller": %q, + "publicKeyMultibase": %q + }], + "capabilityInvocation": ["%s#missing"] + }`, d, d, did.MultikeyVerificationMethodType, d, d.Identifier(), d) + var doc did.Document + if err := json.Unmarshal([]byte(docJSON), &doc); err != nil { + return did.Document{}, err + } + return doc, nil + }) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(resolver)) + require.Error(t, err) + }) + t.Run("nil relationships fall back without panicking", func(t *testing.T) { subject := testutil.RandomIssuer(t)