diff --git a/Readme.md b/Readme.md index 147a6d6..04ed8a3 100644 --- a/Readme.md +++ b/Readme.md @@ -121,9 +121,10 @@ func main() { ### Supported DID Methods -| Method | Status | Description | -|-----------|--------|------------------------------------------| -| `did:key` | ✅ | Self-contained DIDs based on public keys | +| Method | Controller | Verifier | Description | +|-----------|------------|----------|----------------------------------------------------| +| `did:key` | ✅ | ✅ | Self-contained DIDs based on public keys | +| `did:plc` | ❌ | ✅ | Bluesky's DID with rotation and a public directory | ### Supported Verification Method Types diff --git a/controller/did-key/key.go b/controller/did-key/key.go new file mode 100644 index 0000000..d76faf3 --- /dev/null +++ b/controller/did-key/key.go @@ -0,0 +1,15 @@ +package didkeyctl + +import ( + "github.com/ucan-wg/go-did-it" + "github.com/ucan-wg/go-did-it/crypto" + didkey "github.com/ucan-wg/go-did-it/verifiers/did-key" +) + +func FromPublicKey(pub crypto.PublicKey) did.DID { + return didkey.FromPublicKey(pub) +} + +func FromPrivateKey(priv crypto.PrivateKey) did.DID { + return didkey.FromPrivateKey(priv) +} diff --git a/did.go b/did.go index 5f8e314..4a5ad06 100644 --- a/did.go +++ b/did.go @@ -7,6 +7,10 @@ import ( "sync" ) +// Specifications: +// - https://www.w3.org/TR/did-1.0/ +// - https://www.w3.org/TR/did-1.1/ + const JsonLdContext = "https://www.w3.org/ns/did/v1" // Decoder is a function decoding a DID string representation ("did:example:foo") into a DID. diff --git a/document/document.go b/document/document.go index cbf5754..6e8f401 100644 --- a/document/document.go +++ b/document/document.go @@ -26,6 +26,7 @@ type Document struct { keyAgreement []did.VerificationMethodKeyAgreement capabilityInvocation []did.VerificationMethodSignature capabilityDelegation []did.VerificationMethodSignature + services did.Services } type aux struct { @@ -39,6 +40,7 @@ type aux struct { KeyAgreement []json.RawMessage `json:"keyAgreement,omitempty"` CapabilityInvocation []json.RawMessage `json:"capabilityInvocation,omitempty"` CapabilityDelegation []json.RawMessage `json:"capabilityDelegation,omitempty"` + Services did.Services `json:"service,omitempty"` } // FromJsonReader decodes an arbitrary Json DID Document into a usable did.Document. @@ -67,8 +69,9 @@ func FromJsonBytes(data []byte) (*Document, error) { func fromAux(aux *aux) (*Document, error) { var err error res := Document{ - context: aux.Context, - id: aux.Id, + context: aux.Context, + id: aux.Id, + services: aux.Services, } // id @@ -191,7 +194,7 @@ func resolveVerificationMethods[T did.VerificationMethod](doc *Document, msgs [] func (d Document) MarshalJSON() ([]byte, error) { var err error - data := aux{Context: d.context, Id: d.id} + data := aux{Context: d.context, Id: d.id, Services: d.services} // alsoKnownAs data.AlsoKnownAs = make([]string, len(d.alsoKnownAs)) @@ -309,3 +312,7 @@ func (d Document) CapabilityInvocation() []did.VerificationMethodSignature { func (d Document) CapabilityDelegation() []did.VerificationMethodSignature { return d.capabilityDelegation } + +func (d Document) Services() did.Services { + return d.services +} diff --git a/document/document_test.go b/document/document_test.go index 9f752af..8f8a40a 100644 --- a/document/document_test.go +++ b/document/document_test.go @@ -44,6 +44,15 @@ func TestRoundTrip(t *testing.T) { require.Equal(t, jsonwebkey.Type, doc.verificationMethods["did:example:123#NjQ6Y_ZMj6IUK_XkgCDwtKHlNTUTVjEYOWZtxhp1n-E"].Type()) }, }, + { + name: "plc", + strDoc: plcDoc, + assertion: func(t *testing.T, doc *Document) { + require.Equal(t, "did:plc:ewvi7nxzyoun6zhxrhs64oiz", doc.ID()) + require.Len(t, doc.VerificationMethods(), 1) + require.Len(t, doc.Services(), 1) + }, + }, } { t.Run(tc.name, func(t *testing.T) { doc, err := FromJsonBytes([]byte(tc.strDoc)) @@ -198,6 +207,32 @@ const jsonWebKeyDoc = ` } ` +const plcDoc = `{ + "@context":[ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], + "id":"did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "alsoKnownAs":[ + "at://atproto.com" + ], + "verificationMethod":[ + { + "id":"did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto", + "type":"Multikey", + "controller":"did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "publicKeyMultibase":"zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w" + } + ], + "service":[ + { + "id":"#atproto_pds", + "type":"AtprotoPersonalDataServer", + "serviceEndpoint":"https://enoki.us-east.host.bsky.network" + } + ] +}` + // requireDocEqual compare two DID JSON document but ignore the ordering inside arrays of VerificationMethods func requireDocEqual(t *testing.T, expected, actual string) { propsExpected := map[string]json.RawMessage{} diff --git a/errors.go b/errors.go index eadac61..9491bc4 100644 --- a/errors.go +++ b/errors.go @@ -16,4 +16,7 @@ var ( var ( // ErrNotFound indicates that the DID resolver was unable to find the DID document for the given DID. ErrNotFound = fmt.Errorf("did not found") + + // ErrResolutionFailure indicates that the DID resolver failed to resolve the DID, in a way that is not ErrNotFound + ErrResolutionFailure = fmt.Errorf("resolution failure") ) diff --git a/interfaces.go b/interfaces.go index a4ec466..e04f955 100644 --- a/interfaces.go +++ b/interfaces.go @@ -42,7 +42,8 @@ type Document interface { // Controllers is the set of DID that is authorized to make changes to the Document. It's often the same as ID. Controllers() []string - // AlsoKnownAs returns an optional set of URL describing ???TODO + // AlsoKnownAs returns an optional set of URL describing different identifier for the DID subject, + // for different purpose or different time. AlsoKnownAs() []*url.URL // VerificationMethods returns all the VerificationMethod known in the document. @@ -70,8 +71,10 @@ type Document interface { // capability to another party, such as delegating the authority to access a specific HTTP API to a subordinate. CapabilityDelegation() []VerificationMethodSignature - // TODO: Service - // https://www.w3.org/TR/did-extensions-properties/#service-types + // Services are means of communicating or interacting with the DID subject or associated entities + // via one or more endpoints. Examples include discovery services, agent services, social networking + // services, file storage services, and verifiable credential repository services. + Services() Services } // VerificationMethod is a common interface for a cryptographic signature verification method. diff --git a/options.go b/options.go index cd7c018..ab91650 100644 --- a/options.go +++ b/options.go @@ -1,7 +1,21 @@ package did +import ( + "context" + "net/http" +) + type ResolutionOpts struct { + ctx context.Context hintVerificationMethod []string + client HttpClient +} + +func (opts *ResolutionOpts) Context() context.Context { + if opts.ctx != nil { + return opts.ctx + } + return context.Background() } func (opts *ResolutionOpts) HasVerificationMethodHint(hint string) bool { @@ -13,6 +27,13 @@ func (opts *ResolutionOpts) HasVerificationMethodHint(hint string) bool { return false } +func (opts *ResolutionOpts) HttpClient() HttpClient { + if opts.client != nil { + return opts.client + } + return http.DefaultClient +} + func CollectResolutionOpts(opts []ResolutionOption) ResolutionOpts { res := ResolutionOpts{} for _, opt := range opts { @@ -23,6 +44,14 @@ func CollectResolutionOpts(opts []ResolutionOption) ResolutionOpts { type ResolutionOption func(opts *ResolutionOpts) +// WithResolutionContext provides a go context to use for the resolution. +// This context can be used for deadline or cancellation. +func WithResolutionContext(ctx context.Context) ResolutionOption { + return func(opts *ResolutionOpts) { + opts.ctx = ctx + } +} + // WithResolutionHintVerificationMethod adds a hint for the type of verification method to be used // when resolving and constructing the DID Document, if possible. // Hints are expected to be VerificationMethod string types, like ed25519vm.Type. @@ -39,3 +68,14 @@ func WithResolutionHintVerificationMethod(hint string) ResolutionOption { opts.hintVerificationMethod = append(opts.hintVerificationMethod, hint) } } + +type HttpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// WithHttpClient provides an HttpClient to be used during resolution. +func WithHttpClient(client HttpClient) ResolutionOption { + return func(opts *ResolutionOpts) { + opts.client = client + } +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..ee4a277 --- /dev/null +++ b/service.go @@ -0,0 +1,155 @@ +package did + +import ( + "encoding/json" + "fmt" +) + +// Specification: https://www.w3.org/TR/cid-1.0/#services +// List of service types and their fields: https://www.w3.org/TR/did-extensions-properties/#service-types + +// Services is a collection of Service. +type Services []Service + +// ServiceById retrieves a Service from the Services slice by its id. +// Returns the Service and true if found, otherwise returns an empty Service and false. +func (ss Services) ServiceById(id string) (Service, bool) { + for _, s := range ss { + if s.Id == id { + return s, true + } + } + return Service{}, false +} + +// ServiceByType returns zero or one Service matching the given type. +// If there is more than one service for that type, the first match is returned. +func (ss Services) ServiceByType(_type string) (Service, bool) { + for _, s := range ss { + if s.HasType(_type) { + return s, true + } + } + return Service{}, false +} + +// Service is a means of communicating or interacting with the DID subject or associated entities +// via one or more service endpoints. +// It can have one or more types. +type Service struct { + Id string + Types []string + Endpoints []any // either strEndpoint or mapEndpoint +} + +func (s Service) HasType(_type string) bool { + for _, t := range s.Types { + if t == _type { + return true + } + } + return false +} + +func (s Service) MarshalJSON() ([]byte, error) { + var aux struct { + Id string `json:"id"` + Type any `json:"type"` + Endpoint any `json:"serviceEndpoint"` + } + + aux.Id = s.Id + + switch len(s.Types) { + case 0: + return nil, fmt.Errorf("service type is required") + case 1: + aux.Type = s.Types[0] + default: + aux.Type = s.Types + } + + switch len(s.Endpoints) { + case 0: + return nil, fmt.Errorf("service endpoint is required") + case 1: + aux.Endpoint = s.Endpoints[0] + default: + aux.Endpoint = s.Endpoints + } + + return json.Marshal(aux) +} + +func (s *Service) UnmarshalJSON(bytes []byte) error { + var aux struct { + Id string `json:"id"` + Type json.RawMessage `json:"type"` + Endpoint json.RawMessage `json:"serviceEndpoint"` + } + + err := json.Unmarshal(bytes, &aux) + if err != nil { + return err + } + + if len(aux.Id) == 0 { + return fmt.Errorf("service id is required") + } + s.Id = aux.Id + + s.Types, err = unmarshalSingleOrArray[string](aux.Type) + if err != nil { + return err + } + if len(s.Types) == 0 { + return fmt.Errorf("service type is required") + } + for _, _type := range s.Types { + if len(_type) == 0 { + return fmt.Errorf("invalid service type: must not be empty string") + } + } + + s.Endpoints, err = unmarshalSingleOrArray[any](aux.Endpoint) + if err != nil { + return err + } + if len(s.Endpoints) == 0 { + return fmt.Errorf("service endpoint is required") + } + for i, endpoint := range s.Endpoints { + switch endpoint := endpoint.(type) { + case string: + s.Endpoints[i] = StrEndpoint(endpoint) + case map[string]any: + s.Endpoints[i] = MapEndpoint(endpoint) + default: + return fmt.Errorf("endpoint must be %T or %T", StrEndpoint(""), MapEndpoint{}) + } + } + + return nil +} + +type StrEndpoint string + +type MapEndpoint map[string]any + +func unmarshalSingleOrArray[T any](data json.RawMessage) ([]T, error) { + if data == nil { + return nil, nil + } + + var single T + if err := json.Unmarshal(data, &single); err == nil { + return []T{single}, nil + } + + var array []T + if err := json.Unmarshal(data, &array); err == nil { + return array, nil + } + + return nil, fmt.Errorf("must be %T or array of %T", single, single) +} diff --git a/service_test.go b/service_test.go new file mode 100644 index 0000000..026a914 --- /dev/null +++ b/service_test.go @@ -0,0 +1,107 @@ +package did + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestServicesJsonRountrip(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "LinkedDomains", + input: `[ + { + "id":"did:example:123#foo", + "type": "LinkedDomains", + "serviceEndpoint": { + "origins": ["https://foo.example.com", "https://identity.foundation"] + } + }, + { + "id":"did:example:123#bar", + "type": "LinkedDomains", + "serviceEndpoint": "https://bar.example.com" + } + ]`, + }, + { + name: "LinkedVerifiablePresentation", + input: `[ + { + "id": "did:example:123#foo", + "type": "LinkedVerifiablePresentation", + "serviceEndpoint": "https://bar.example.com/verifiable-presentation.jsonld" + }, + { + "id": "did:example:123#baz", + "type": "LinkedVerifiablePresentation", + "serviceEndpoint": "ipfs://bafybeihkoviema7g3gxyt6la7vd5ho32ictqbilu3wnlo3rs7ewhnp7lly/verifiable-presentation.jwt" + } + ]`, + }, + { + name: "WotThing", + input: `[{ + "id": "did:example:wotdiscoveryexample#td", + "type": "WotThing", + "serviceEndpoint": + "https://wot.example.com/.well-known/wot" + }]`, + }, + { + name: "multi types", + input: `[ + { + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#node", + "type": [ + "DIDCommMessaging", + "CredentialRepositoryService", + "RevocationList2020Status", + "TrustRegistryService" + ], + "serviceEndpoint": "https://node.blockchain-network.com/api/v1" + } + ]`, + }, + { + name: "multi types, map values", + input: `[ + { + "id": "did:web:wallet.example.com#wallet-service", + "type": [ + "VerifiableCredentialService", + "OpenIdConnectVersion1.0Service", + "DIDCommMessaging", + "CredentialRepositoryService" + ], + "serviceEndpoint": { + "credentialIssue": "https://wallet.example.com/credentials/issue", + "credentialVerify": "https://wallet.example.com/credentials/verify", + "credentialStore": "https://wallet.example.com/vault", + "oidcAuth": "https://wallet.example.com/auth", + "oidcToken": "https://wallet.example.com/token", + "didcommInbox": "https://wallet.example.com/didcomm/inbox", + "didcommOutbox": "https://wallet.example.com/didcomm/outbox" + } + } + ]`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var services []Service + err := json.Unmarshal([]byte(tc.input), &services) + require.NoError(t, err) + + rt, err := json.Marshal(services) + require.NoError(t, err) + require.JSONEq(t, tc.input, string(rt)) + }) + } +} diff --git a/verifiers/_methods/multikey/multikey.go b/verifiers/_methods/multikey/multikey.go index bb5b1ec..62e584b 100644 --- a/verifiers/_methods/multikey/multikey.go +++ b/verifiers/_methods/multikey/multikey.go @@ -13,7 +13,8 @@ import ( // Specification: https://www.w3.org/TR/cid-1.0/#Multikey const ( - JsonLdContext = "https://www.w3.org/ns/cid/v1" + // This is apparently the right context despite the spec above saying otherwise. + JsonLdContext = "https://w3id.org/security/multikey/v1" Type = "Multikey" ) diff --git a/verifiers/did-key/document.go b/verifiers/did-key/document.go index 78703dc..635411d 100644 --- a/verifiers/did-key/document.go +++ b/verifiers/did-key/document.go @@ -10,7 +10,7 @@ import ( var _ did.Document = &document{} type document struct { - id did.DID + id string signature did.VerificationMethodSignature keyAgreement did.VerificationMethodKeyAgreement } @@ -38,7 +38,7 @@ func (d document) MarshalJSON() ([]byte, error) { CapabilityDelegation []string `json:"capabilityDelegation,omitempty"` }{ Context: d.Context(), - ID: d.id.String(), + ID: d.id, AlsoKnownAs: nil, VerificationMethod: vms, Authentication: []string{d.signature.ID()}, @@ -58,7 +58,7 @@ func (d document) Context() []string { } func (d document) ID() string { - return d.id.String() + return d.id } func (d document) Controllers() []string { @@ -102,6 +102,10 @@ func (d document) CapabilityDelegation() []did.VerificationMethodSignature { return []did.VerificationMethodSignature{d.signature} } +func (d document) Services() did.Services { + return nil +} + func stringSet(values ...string) []string { res := make([]string, 0, len(values)) loop: diff --git a/verifiers/did-key/key.go b/verifiers/did-key/key.go index c21df4e..bd18673 100644 --- a/verifiers/did-key/key.go +++ b/verifiers/did-key/key.go @@ -66,7 +66,7 @@ func (d DidKey) Method() string { func (d DidKey) Document(opts ...did.ResolutionOption) (did.Document, error) { params := did.CollectResolutionOpts(opts) - doc := document{id: d} + doc := document{id: d.String()} mainVmId := fmt.Sprintf("did:key:%s#%s", d.msi, d.msi) switch pub := d.pubkey.(type) { diff --git a/verifiers/did-plc/document.go b/verifiers/did-plc/document.go new file mode 100644 index 0000000..917eaea --- /dev/null +++ b/verifiers/did-plc/document.go @@ -0,0 +1,98 @@ +package didplc + +import ( + "encoding/json" + "net/url" + + "github.com/ucan-wg/go-did-it" +) + +var _ did.Document = &document{} + +type document struct { + id string + alsoKnownAs []*url.URL + signatures []did.VerificationMethodSignature + services did.Services +} + +func (d document) MarshalJSON() ([]byte, error) { + akas := make([]string, len(d.alsoKnownAs)) + for i, aka := range d.alsoKnownAs { + akas[i] = aka.String() + } + + return json.Marshal(struct { + Context []string `json:"@context"` + ID string `json:"id"` + AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` + Controller string `json:"controller,omitempty"` + VerificationMethod []did.VerificationMethodSignature `json:"verificationMethod,omitempty"` + Services did.Services `json:"service,omitempty"` + }{ + Context: d.Context(), + ID: d.id, + AlsoKnownAs: akas, + VerificationMethod: d.signatures, + Services: d.services, + }) +} + +func (d document) Context() []string { + res := make([]string, 0, 1+len(d.signatures)) + res = append(res, did.JsonLdContext) +loop: + for _, method := range d.signatures { + for _, item := range res { + if method.JsonLdContext() == item { + continue loop + } + } + res = append(res, method.JsonLdContext()) + } + return res +} + +func (d document) ID() string { + return d.id +} + +func (d document) Controllers() []string { + return nil +} + +func (d document) AlsoKnownAs() []*url.URL { + return d.alsoKnownAs +} + +func (d document) VerificationMethods() map[string]did.VerificationMethod { + res := make(map[string]did.VerificationMethod) + for _, signature := range d.signatures { + res[signature.ID()] = signature + } + return res +} + +func (d document) Authentication() []did.VerificationMethodSignature { + return d.signatures +} + +func (d document) Assertion() []did.VerificationMethodSignature { + return d.signatures +} + +func (d document) KeyAgreement() []did.VerificationMethodKeyAgreement { + return nil +} + +func (d document) CapabilityInvocation() []did.VerificationMethodSignature { + return d.signatures +} + +func (d document) CapabilityDelegation() []did.VerificationMethodSignature { + return d.signatures +} + +func (d document) Services() did.Services { + return d.services +} diff --git a/verifiers/did-plc/document_test.go b/verifiers/did-plc/document_test.go new file mode 100644 index 0000000..575f196 --- /dev/null +++ b/verifiers/did-plc/document_test.go @@ -0,0 +1,74 @@ +package didplc + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ucan-wg/go-did-it" +) + +func TestDocument(t *testing.T) { + // current resolved /data for did:plc:ewvi7nxzyoun6zhxrhs64oiz + resolvedData := `{"did":"did:plc:ewvi7nxzyoun6zhxrhs64oiz","verificationMethods":{"atproto":"did:key:zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w"},"rotationKeys":["did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg","did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK"],"alsoKnownAs":["at://atproto.com"],"services":{"atproto_pds":{"type":"AtprotoPersonalDataServer","endpoint":"https://enoki.us-east.host.bsky.network"}}}` + + // as resolved by https://plc.directory/did:plc:ewvi7nxzyoun6zhxrhs64oiz + // the original json had an additional + // "https://w3id.org/security/suites/secp256k1-2019/v1" context that + // I removed as it's just wrong + expectedJson := ` +{ + "@context":[ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], + "id":"did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "alsoKnownAs":[ + "at://atproto.com" + ], + "verificationMethod":[ + { + "id":"did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto", + "type":"Multikey", + "controller":"did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "publicKeyMultibase":"zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w" + } + ], + "service":[ + { + "id":"#atproto_pds", + "type":"AtprotoPersonalDataServer", + "serviceEndpoint":"https://enoki.us-east.host.bsky.network" + } + ] +} +` + + mockClient := &MockHTTPClient{resp: resolvedData} + + d, err := did.Parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz") + require.NoError(t, err) + + doc, err := d.Document(did.WithHttpClient(mockClient)) + require.NoError(t, err) + + docBytes, err := json.Marshal(doc) + require.NoError(t, err) + + require.JSONEq(t, expectedJson, string(docBytes)) +} + +type MockHTTPClient struct { + resp string +} + +func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(m.resp)), + }, nil +} diff --git a/verifiers/did-plc/plc.go b/verifiers/did-plc/plc.go new file mode 100644 index 0000000..d8c6d31 --- /dev/null +++ b/verifiers/did-plc/plc.go @@ -0,0 +1,215 @@ +package didplc + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/ucan-wg/go-did-it" + allkeys "github.com/ucan-wg/go-did-it/crypto/_allkeys" + "github.com/ucan-wg/go-did-it/crypto/ed25519" + "github.com/ucan-wg/go-did-it/crypto/p256" + "github.com/ucan-wg/go-did-it/crypto/p384" + "github.com/ucan-wg/go-did-it/crypto/p521" + "github.com/ucan-wg/go-did-it/crypto/rsa" + "github.com/ucan-wg/go-did-it/crypto/secp256k1" + ed25519vm "github.com/ucan-wg/go-did-it/verifiers/_methods/ed25519" + "github.com/ucan-wg/go-did-it/verifiers/_methods/jsonwebkey" + "github.com/ucan-wg/go-did-it/verifiers/_methods/multikey" + p256vm "github.com/ucan-wg/go-did-it/verifiers/_methods/p256" + secp256k1vm "github.com/ucan-wg/go-did-it/verifiers/_methods/secp256k1" +) + +// Specification: https://web.plc.directory/spec/v0.1/did-plc + +const DefaultRegistry = "https://plc.directory" + +func init() { + did.RegisterMethod("plc", Decode) +} + +var _ did.DID = DidPlc{} + +type DidPlc struct { + msi string // method-specific identifier, i.e. "12345" in "did:plc:12345" +} + +func Decode(identifier string) (did.DID, error) { + const plcPrefix = "did:plc:" + + if !strings.HasPrefix(identifier, plcPrefix) { + return nil, fmt.Errorf("%w: must start with 'did:plc'", did.ErrInvalidDid) + } + + msi := identifier[len(plcPrefix):] + + if len(msi) != 24 { + return nil, fmt.Errorf("%w: incorrect did:plc identifier length", did.ErrInvalidDid) + } + + for _, char := range msi { + switch { + case char >= 'a' && char <= 'z': + case char >= '2' && char <= '7': + default: + return nil, fmt.Errorf("%w: did:plc identifier contains invalid character", did.ErrInvalidDid) + } + } + + return DidPlc{msi: msi}, nil +} + +func (d DidPlc) Method() string { + return "plc" +} + +func (d DidPlc) Document(opts ...did.ResolutionOption) (did.Document, error) { + params := did.CollectResolutionOpts(opts) + identifier := d.String() + + u, err := url.JoinPath(DefaultRegistry, identifier, "data") + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(params.Context(), "GET", u, nil) + if err != nil { + return nil, err + } + + res, err := params.HttpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + + var aux struct { + Did string `json:"did"` + VerificationMethods map[string]string `json:"verificationMethods"` + // RotationKeys []string `json:"rotationKeys"` + AlsoKnownAs []string `json:"alsoKnownAs"` + Services map[string]struct { + Type string `json:"type"` + Endpoint string `json:"endpoint"` + } `json:"services"` + } + + // limit at 1MB + err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&aux) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + + if aux.Did != identifier { + return nil, fmt.Errorf("%w: did:plc identifier mismatch", did.ErrResolutionFailure) + } + + doc := &document{ + id: aux.Did, + alsoKnownAs: make([]*url.URL, len(aux.AlsoKnownAs)), + signatures: make([]did.VerificationMethodSignature, 0, len(aux.VerificationMethods)), + services: make(did.Services, 0, len(aux.Services)), + } + + for i, aka := range aux.AlsoKnownAs { + doc.alsoKnownAs[i], err = url.Parse(aka) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err) + } + } + + for vmName, data := range aux.VerificationMethods { + // decode the did:key. It's a similar handling as in the did:key implementation, but: + // - the VM identifier is different + // - did:plc doesn't seem to care about key agreement VM + const keyPrefix = "did:key:" + + if !strings.HasPrefix(data, keyPrefix) { + return nil, fmt.Errorf("%w: must start with 'did:key'", did.ErrInvalidDid) + } + msi := data[len(keyPrefix):] + + pub, err := allkeys.PublicKeyFromPublicKeyMultibase(msi) + if err != nil { + return nil, fmt.Errorf("%w: %w", did.ErrInvalidDid, err) + } + + vmId := fmt.Sprintf("%s#%s", doc.id, vmName) + + switch pub := pub.(type) { + case ed25519.PublicKey: + switch { + case params.HasVerificationMethodHint(jsonwebkey.Type): + doc.signatures = append(doc.signatures, jsonwebkey.NewJsonWebKey2020(vmId, pub, d)) + case params.HasVerificationMethodHint(multikey.Type): + doc.signatures = append(doc.signatures, multikey.NewMultiKey(vmId, pub, d)) + default: + if params.HasVerificationMethodHint(ed25519vm.Type2018) { + doc.signatures = append(doc.signatures, ed25519vm.NewVerificationKey2018(vmId, pub, d)) + } else { + doc.signatures = append(doc.signatures, ed25519vm.NewVerificationKey2020(vmId, pub, d)) + } + } + case *p256.PublicKey: + switch { + case params.HasVerificationMethodHint(jsonwebkey.Type): + doc.signatures = append(doc.signatures, jsonwebkey.NewJsonWebKey2020(vmId, pub, d)) + case params.HasVerificationMethodHint(p256vm.Type2021): + doc.signatures = append(doc.signatures, p256vm.NewKey2021(vmId, pub, d)) + default: + doc.signatures = append(doc.signatures, multikey.NewMultiKey(vmId, pub, d)) + } + + case *secp256k1.PublicKey: + switch { + case params.HasVerificationMethodHint(jsonwebkey.Type): + doc.signatures = append(doc.signatures, jsonwebkey.NewJsonWebKey2020(vmId, pub, d)) + case params.HasVerificationMethodHint(secp256k1vm.Type2019): + doc.signatures = append(doc.signatures, secp256k1vm.NewVerificationKey2019(vmId, pub, d)) + default: + doc.signatures = append(doc.signatures, multikey.NewMultiKey(vmId, pub, d)) + } + + case *p384.PublicKey, *p521.PublicKey, *rsa.PublicKey: + switch { + case params.HasVerificationMethodHint(jsonwebkey.Type): + doc.signatures = append(doc.signatures, jsonwebkey.NewJsonWebKey2020(vmId, pub, d)) + default: + doc.signatures = append(doc.signatures, multikey.NewMultiKey(vmId, pub, d)) + } + + default: + return nil, fmt.Errorf("unsupported public key: %T", pub) + } + } + + for id, service := range aux.Services { + doc.services = append(doc.services, did.Service{ + Id: "#" + id, + Types: []string{service.Type}, + Endpoints: []any{did.StrEndpoint(service.Endpoint)}, + }) + } + + return doc, nil +} + +func (d DidPlc) String() string { + return fmt.Sprintf("did:plc:%s", d.msi) +} + +func (d DidPlc) ResolutionIsExpensive() bool { + // requires an external HTTP request + return true +} + +func (d DidPlc) Equal(d2 did.DID) bool { + if d2, ok := d2.(DidPlc); ok { + return d.msi == d2.msi + } + if d2, ok := d2.(*DidPlc); ok { + return d.msi == d2.msi + } + return false +} diff --git a/verifiers/did-plc/plc_test.go b/verifiers/did-plc/plc_test.go new file mode 100644 index 0000000..449178c --- /dev/null +++ b/verifiers/did-plc/plc_test.go @@ -0,0 +1,43 @@ +package didplc_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ucan-wg/go-did-it" +) + +func TestParseDIDPlc(t *testing.T) { + str := "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + d, err := did.Parse(str) + require.NoError(t, err) + require.Equal(t, str, d.String()) +} + +func TestIncorrectDIDPlc(t *testing.T) { + tests := []string{ + "did:plc:ewvi7nxzyoun6zhxrhs64oi", // too short + "did:plc:ewvi7nxzyoun6zhxrhs64oizz", // too long + "did:plc:ewvi7nxzyoun6zhxrhs64oi0", // wrong char + "did:plc:ewvi7nxzyoun6zhxrhs64oiz:", // extra : + } + for _, tt := range tests { + t.Run(tt, func(t *testing.T) { + _, err := did.Parse(tt) + require.Error(t, err) + }) + } +} + +func TestMustParseDIDPlc(t *testing.T) { + str := "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + require.NotPanics(t, func() { + d := did.MustParse(str) + require.Equal(t, str, d.String()) + }) + str = "did:plc:ewvi7nxzyoun6zhxrhs6" // too short + require.Panics(t, func() { + did.MustParse(str) + }) +}