Merge pull request #14 from INFURA/plc

Support for did:plc
This commit is contained in:
Michael Muré
2025-07-22 09:57:01 +02:00
committed by GitHub
17 changed files with 827 additions and 14 deletions

View File

@@ -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

15
controller/did-key/key.go Normal file
View File

@@ -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)
}

4
did.go
View File

@@ -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.

View File

@@ -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.
@@ -69,6 +71,7 @@ func fromAux(aux *aux) (*Document, error) {
res := Document{
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
}

View File

@@ -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{}

View File

@@ -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")
)

View File

@@ -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.

View File

@@ -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
}
}

155
service.go Normal file
View File

@@ -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)
}

107
service_test.go Normal file
View File

@@ -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))
})
}
}

View File

@@ -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"
)

View File

@@ -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:

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

223
verifiers/did-plc/plc.go Normal file
View File

@@ -0,0 +1,223 @@
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
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "go-did-it")
res, err := params.HttpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %w", did.ErrResolutionFailure, err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: HTTP %d", did.ErrResolutionFailure, res.StatusCode)
}
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 to avoid abuse
limiter := io.LimitReader(res.Body, 1<<20)
err = json.NewDecoder(limiter).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
}

View File

@@ -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)
})
}