Add support for DID services and did:plc

This commit is contained in:
Michael Muré
2025-07-21 10:12:05 +02:00
parent 7b88f5587b
commit de98cf811b
17 changed files with 819 additions and 14 deletions

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
}

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

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

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