Skip to content

Commit ef993e1

Browse files
alanshawclaude
andauthored
feat: add did:plc parse method (#38)
Adds a parse method for `did:plc` DIDs. > The DID itself is derived from the hash of the first operation in the log, called the "genesis" operation. The signed operation is encoded in DAG-CBOR; the bytes are hashed with SHA-256; the hash bytes are base32-encoded (not hex encoded) as a string; and that string is truncated to 24 chars to yield the "identifier" segment of the DID. > > In pseudo-code: `did:plc:${base32Encode(sha256(createOp)).slice(0,24)}` https://github.com/did-method-plc/did-method-plc/blob/main/website/spec/v0.1/did-plc.md --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 79141c5 commit ef993e1

4 files changed

Lines changed: 253 additions & 7 deletions

File tree

did/plc/plc.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,32 @@ import (
1616

1717
const Method = "plc"
1818

19+
// IdentifierLength is the length in characters of the method-specific
20+
// identifier of a did:plc DID.
21+
const IdentifierLength = 24
22+
1923
var base32LowerNoPad = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
2024

25+
// Parse parses a did:plc DID string, verifying the method is "plc" and the
26+
// identifier is 24 characters of base32 (lowercase, no padding).
27+
func Parse(str string) (did.DID, error) {
28+
d, err := did.Parse(str)
29+
if err != nil {
30+
return did.Undef, err
31+
}
32+
if err := did.ValidateMethod(d, Method); err != nil {
33+
return did.Undef, err
34+
}
35+
id := d.Identifier()
36+
if len(id) != IdentifierLength {
37+
return did.Undef, fmt.Errorf("identifier must be %d characters, got %d", IdentifierLength, len(id))
38+
}
39+
if _, err := base32LowerNoPad.DecodeString(id); err != nil {
40+
return did.Undef, fmt.Errorf("identifier is not base32 (lowercase, no padding): %w", err)
41+
}
42+
return d, nil
43+
}
44+
2145
func New(signer Signer, options ...OperationOption) (did.DID, *SignedOperation, error) {
2246
op, err := NewOperation(nil, options...)
2347
if err != nil {
@@ -35,7 +59,7 @@ func New(signer Signer, options ...OperationOption) (did.DID, *SignedOperation,
3559

3660
digest := sha256.Sum256(signedOpBytes.Bytes())
3761
str := base32LowerNoPad.EncodeToString(digest[:])
38-
return did.New(Method, str[:24]), signedOp, nil
62+
return did.New(Method, str[:IdentifierLength]), signedOp, nil
3963
}
4064

4165
// SignOperation signs a PLC operation with the given signer and returns a

did/plc/plc_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,55 @@ func TestNew(t *testing.T) {
6060
})
6161
}
6262

63+
func TestParse(t *testing.T) {
64+
t.Run("accepts a DID produced by New", func(t *testing.T) {
65+
d, _, err := plc.New(testutil.RandomMultikeySigner(t), plc.WithRotationKeys(testutil.RandomDID(t)))
66+
require.NoError(t, err)
67+
68+
parsed, err := plc.Parse(d.String())
69+
require.NoError(t, err)
70+
require.Equal(t, d, parsed)
71+
})
72+
73+
t.Run("accepts a well-known did:plc", func(t *testing.T) {
74+
d, err := plc.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
75+
require.NoError(t, err)
76+
require.Equal(t, plc.Method, d.Method())
77+
})
78+
79+
t.Run("rejects a missing did prefix", func(t *testing.T) {
80+
_, err := plc.Parse("plc:ewvi7nxzyoun6zhxrhs64oiz")
81+
require.Error(t, err)
82+
})
83+
84+
t.Run("rejects a non-plc method", func(t *testing.T) {
85+
_, err := plc.Parse("did:web:example.com")
86+
var umErr did.UnsupportedMethodError
87+
require.ErrorAs(t, err, &umErr)
88+
})
89+
90+
t.Run("rejects an identifier that is too short", func(t *testing.T) {
91+
_, err := plc.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oi")
92+
require.ErrorContains(t, err, "24 characters")
93+
})
94+
95+
t.Run("rejects an identifier that is too long", func(t *testing.T) {
96+
_, err := plc.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oizz")
97+
require.ErrorContains(t, err, "24 characters")
98+
})
99+
100+
t.Run("rejects characters outside the base32 lower alphabet", func(t *testing.T) {
101+
for _, id := range []string{
102+
"EWVI7NXZYOUN6ZHXRHS64OIZ", // uppercase
103+
"ewvi7nxzyoun6zhxrhs64oi0", // digit outside 2-7
104+
"ewvi7nxzyoun6zhxrhs64oi=", // padding
105+
} {
106+
_, err := plc.Parse("did:plc:" + id)
107+
require.ErrorContains(t, err, "not base32", "identifier %q", id)
108+
}
109+
})
110+
}
111+
63112
func TestSignOperation(t *testing.T) {
64113
t.Run("signs the CBOR-encoded operation and copies all fields", func(t *testing.T) {
65114
signer := testutil.RandomMultikeySigner(t)

validator/validator.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,31 @@ func verifyTokenSignature(ctx context.Context, tok ucan.Token, cfg validationCon
121121
return fmt.Errorf("unsupported token type: %T", tok)
122122
}
123123

124+
// The capability relationships are optional (DID core §5.3): a document
125+
// that expresses one restricts verification to the methods it lists, but
126+
// a document that expresses nothing authorizes all of its verification
127+
// methods — e.g. did:plc documents carry only verificationMethod.
128+
// (Post-parse, an explicitly empty relationship is indistinguishable
129+
// from an absent one and gets the same fallback.) A relationship that
130+
// lists entries which fail to resolve to verification methods is NOT
131+
// silent: it restricts to those (unresolvable) methods and must not
132+
// widen to the full verificationMethod set.
133+
var vms []did.VerificationMethod
134+
if verRel == nil || verRel.IsZero() {
135+
if doc.VerificationMethods != nil {
136+
for _, vm := range *doc.VerificationMethods {
137+
vms = append(vms, vm)
138+
}
139+
}
140+
} else {
141+
vms = verRel.All()
142+
}
143+
124144
// Try each verification method, collecting rejection reasons for the error.
125145
validationTime := time.Unix(int64(cfg.validationTime), 0)
126146
var rejections []verrs.VMRejection
127147

128-
for _, vm := range verRel.All() {
148+
for _, vm := range vms {
129149
if vm.ExpiredAt(validationTime) {
130150
rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "expired"})
131151
continue
@@ -136,12 +156,10 @@ func verifyTokenSignature(ctx context.Context, tok ucan.Token, cfg validationCon
136156
}
137157
f, ok := cfg.verifierFactories[vm.Type]
138158
if !ok {
139-
err = fmt.Errorf("%w for VM type %q", ErrNoVerifierFactory, vm.Type)
140-
}
141-
var v ucan.Verifier
142-
if err == nil {
143-
v, err = f(ctx, vm.Material)
159+
rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "unsupported verification method type"})
160+
continue
144161
}
162+
v, err := f(ctx, vm.Material)
145163
if errors.Is(err, ErrNoVerifierFactory) {
146164
rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "unsupported verification method type"})
147165
continue

validator/validator_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package validator_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
7+
"fmt"
68
"os"
79
"testing"
810
"time"
@@ -570,6 +572,159 @@ func TestValidate(t *testing.T) {
570572
})
571573
}
572574

575+
// TestRelationshipFallback covers the optional capability relationships (DID
576+
// core §5.3): a document that expresses no capabilityInvocation /
577+
// capabilityDelegation authorizes all of its verification methods, while a
578+
// document that expresses one restricts verification to the methods listed.
579+
func TestRelationshipFallback(t *testing.T) {
580+
crankWidget := testutil.Must(command.Parse("/widget/crank"))(t)
581+
582+
// plcShapedResolver serves a document parsed from JSON that carries only
583+
// verificationMethod — the shape did:plc directories emit.
584+
plcShapedResolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) {
585+
docJSON := fmt.Sprintf(`{
586+
"id": %q,
587+
"verificationMethod": [{
588+
"id": "%s#key",
589+
"type": %q,
590+
"controller": %q,
591+
"publicKeyMultibase": %q
592+
}]
593+
}`, d, d, did.MultikeyVerificationMethodType, d, d.Identifier())
594+
var doc did.Document
595+
if err := json.Unmarshal([]byte(docJSON), &doc); err != nil {
596+
return did.Document{}, err
597+
}
598+
return doc, nil
599+
})
600+
601+
t.Run("invocation verifies against a document without relationships", func(t *testing.T) {
602+
subject := testutil.RandomIssuer(t)
603+
604+
inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{})
605+
require.NoError(t, err)
606+
607+
err = validator.ValidateInvocation(t.Context(), inv,
608+
validator.WithDIDResolver(plcShapedResolver))
609+
require.NoError(t, err)
610+
})
611+
612+
t.Run("delegation in chain verifies against a document without relationships", func(t *testing.T) {
613+
subject := testutil.RandomIssuer(t)
614+
bob := testutil.RandomIssuer(t)
615+
616+
del, err := delegation.Delegate(subject, bob.DID(), subject.DID(), crankWidget)
617+
require.NoError(t, err)
618+
inv, err := invocation.Invoke(bob, subject.DID(), crankWidget, datamodel.Map{},
619+
invocation.WithProofs(del.Link()))
620+
require.NoError(t, err)
621+
622+
err = validator.ValidateInvocation(t.Context(), inv,
623+
validator.WithDIDResolver(plcShapedResolver),
624+
validator.WithProofResolver(validator.ProofsFromContainer(
625+
container.New(container.WithDelegations(del)))),
626+
)
627+
require.NoError(t, err)
628+
})
629+
630+
t.Run("populated relationship still restricts to its listed methods", func(t *testing.T) {
631+
subject := testutil.RandomIssuer(t)
632+
other := testutil.RandomIssuer(t) // a key the subject does NOT sign with
633+
634+
resolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) {
635+
doc := did.NewDocument(d)
636+
signerVM := did.VerificationMethod{
637+
ID: doc.Fragment("signer"),
638+
Controller: d,
639+
Type: did.MultikeyVerificationMethodType,
640+
Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()},
641+
}
642+
otherVM := did.VerificationMethod{
643+
ID: doc.Fragment("other"),
644+
Controller: d,
645+
Type: did.MultikeyVerificationMethodType,
646+
Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: other.DID().Identifier()},
647+
}
648+
if err := doc.VerificationMethods.Add(signerVM); err != nil {
649+
return did.Document{}, err
650+
}
651+
// Only the non-signing key is authorized for capability invocation,
652+
// so verification must NOT fall back to the signer's method.
653+
if err := doc.CapabilityInvocation.Add(otherVM); err != nil {
654+
return did.Document{}, err
655+
}
656+
return doc, nil
657+
})
658+
659+
inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{})
660+
require.NoError(t, err)
661+
662+
err = validator.ValidateInvocation(t.Context(), inv,
663+
validator.WithDIDResolver(resolver))
664+
require.Error(t, err)
665+
})
666+
667+
t.Run("relationship referencing a missing method does not fall back", func(t *testing.T) {
668+
subject := testutil.RandomIssuer(t)
669+
670+
// The document lists the signer under verificationMethod, but
671+
// capabilityInvocation references an ID with no matching method. The
672+
// relationship is present (not silent), so verification must restrict
673+
// to it — not widen to all of verificationMethod.
674+
resolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) {
675+
docJSON := fmt.Sprintf(`{
676+
"id": %q,
677+
"verificationMethod": [{
678+
"id": "%s#key",
679+
"type": %q,
680+
"controller": %q,
681+
"publicKeyMultibase": %q
682+
}],
683+
"capabilityInvocation": ["%s#missing"]
684+
}`, d, d, did.MultikeyVerificationMethodType, d, d.Identifier(), d)
685+
var doc did.Document
686+
if err := json.Unmarshal([]byte(docJSON), &doc); err != nil {
687+
return did.Document{}, err
688+
}
689+
return doc, nil
690+
})
691+
692+
inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{})
693+
require.NoError(t, err)
694+
695+
err = validator.ValidateInvocation(t.Context(), inv,
696+
validator.WithDIDResolver(resolver))
697+
require.Error(t, err)
698+
})
699+
700+
t.Run("nil relationships fall back without panicking", func(t *testing.T) {
701+
subject := testutil.RandomIssuer(t)
702+
703+
// A document built without NewDocument: relationship fields are nil.
704+
resolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) {
705+
vms := did.VerificationMethods{}
706+
doc := did.Document{ID: d, VerificationMethods: &vms}
707+
vm := did.VerificationMethod{
708+
ID: doc.Fragment("key"),
709+
Controller: d,
710+
Type: did.MultikeyVerificationMethodType,
711+
Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()},
712+
}
713+
if err := vms.Add(vm); err != nil {
714+
return did.Document{}, err
715+
}
716+
return doc, nil
717+
})
718+
719+
inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{})
720+
require.NoError(t, err)
721+
722+
err = validator.ValidateInvocation(t.Context(), inv,
723+
validator.WithDIDResolver(resolver))
724+
require.NoError(t, err)
725+
})
726+
}
727+
573728
// expiredKeyResolver returns a DID resolver that serves a document for the
574729
// issuer's DID with its Multikey VM marked expired or revoked as specified.
575730
func expiredKeyResolver(t *testing.T, issuer ucan.Issuer, expires, revoked *did.DateTimeStamp) did.Resolver {

0 commit comments

Comments
 (0)