diff --git a/did/plc/plc.go b/did/plc/plc.go index 662477a..e9b2685 100644 --- a/did/plc/plc.go +++ b/did/plc/plc.go @@ -16,8 +16,32 @@ import ( const Method = "plc" +// IdentifierLength is the length in characters of the method-specific +// identifier of a did:plc DID. +const IdentifierLength = 24 + var base32LowerNoPad = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) +// Parse parses a did:plc DID string, verifying the method is "plc" and the +// identifier is 24 characters of base32 (lowercase, no padding). +func Parse(str string) (did.DID, error) { + d, err := did.Parse(str) + if err != nil { + return did.Undef, err + } + if err := did.ValidateMethod(d, Method); err != nil { + return did.Undef, err + } + id := d.Identifier() + if len(id) != IdentifierLength { + return did.Undef, fmt.Errorf("identifier must be %d characters, got %d", IdentifierLength, len(id)) + } + if _, err := base32LowerNoPad.DecodeString(id); err != nil { + return did.Undef, fmt.Errorf("identifier is not base32 (lowercase, no padding): %w", err) + } + return d, nil +} + func New(signer Signer, options ...OperationOption) (did.DID, *SignedOperation, error) { op, err := NewOperation(nil, options...) if err != nil { @@ -35,7 +59,7 @@ func New(signer Signer, options ...OperationOption) (did.DID, *SignedOperation, digest := sha256.Sum256(signedOpBytes.Bytes()) str := base32LowerNoPad.EncodeToString(digest[:]) - return did.New(Method, str[:24]), signedOp, nil + return did.New(Method, str[:IdentifierLength]), signedOp, nil } // SignOperation signs a PLC operation with the given signer and returns a diff --git a/did/plc/plc_test.go b/did/plc/plc_test.go index 5c65fa4..58633cb 100644 --- a/did/plc/plc_test.go +++ b/did/plc/plc_test.go @@ -60,6 +60,55 @@ func TestNew(t *testing.T) { }) } +func TestParse(t *testing.T) { + t.Run("accepts a DID produced by New", func(t *testing.T) { + d, _, err := plc.New(testutil.RandomMultikeySigner(t), plc.WithRotationKeys(testutil.RandomDID(t))) + require.NoError(t, err) + + parsed, err := plc.Parse(d.String()) + require.NoError(t, err) + require.Equal(t, d, parsed) + }) + + t.Run("accepts a well-known did:plc", func(t *testing.T) { + d, err := plc.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz") + require.NoError(t, err) + require.Equal(t, plc.Method, d.Method()) + }) + + t.Run("rejects a missing did prefix", func(t *testing.T) { + _, err := plc.Parse("plc:ewvi7nxzyoun6zhxrhs64oiz") + require.Error(t, err) + }) + + t.Run("rejects a non-plc method", func(t *testing.T) { + _, err := plc.Parse("did:web:example.com") + var umErr did.UnsupportedMethodError + require.ErrorAs(t, err, &umErr) + }) + + t.Run("rejects an identifier that is too short", func(t *testing.T) { + _, err := plc.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oi") + require.ErrorContains(t, err, "24 characters") + }) + + t.Run("rejects an identifier that is too long", func(t *testing.T) { + _, err := plc.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oizz") + require.ErrorContains(t, err, "24 characters") + }) + + t.Run("rejects characters outside the base32 lower alphabet", func(t *testing.T) { + for _, id := range []string{ + "EWVI7NXZYOUN6ZHXRHS64OIZ", // uppercase + "ewvi7nxzyoun6zhxrhs64oi0", // digit outside 2-7 + "ewvi7nxzyoun6zhxrhs64oi=", // padding + } { + _, err := plc.Parse("did:plc:" + id) + require.ErrorContains(t, err, "not base32", "identifier %q", id) + } + }) +} + func TestSignOperation(t *testing.T) { t.Run("signs the CBOR-encoded operation and copies all fields", func(t *testing.T) { signer := testutil.RandomMultikeySigner(t) diff --git a/validator/validator.go b/validator/validator.go index 7d68827..2c10a4d 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -121,11 +121,31 @@ 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.) 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 || 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. 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 @@ -136,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 a8bc417..d0deb83 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,159 @@ 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("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) + + // 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 {