Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion did/plc/plc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions did/plc/plc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 24 additions & 6 deletions validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
155 changes: 155 additions & 0 deletions validator/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package validator_test

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"testing"
"time"
Expand Down Expand Up @@ -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 {
Expand Down
Loading