Skip to content
This repository was archived by the owner on Dec 12, 2024. It is now read-only.

Support ION DID Reconstruction & Long Form DID resolution #389

Merged
merged 11 commits into from
May 24, 2023
Merged
Changes from 6 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
236 changes: 236 additions & 0 deletions did/ion/did.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package ion

import (
"fmt"
"strings"

"github.com/TBD54566975/ssi-sdk/crypto/jwx"
"github.com/TBD54566975/ssi-sdk/cryptosuite"
"github.com/goccy/go-json"

"github.com/TBD54566975/ssi-sdk/did"
@@ -17,6 +19,21 @@ type InitialState struct {
Delta Delta `json:"delta,omitempty"`
}

func (is InitialState) ToDIDStrings() (shortFormDID string, longFormDID string, err error) {
shortFormDID, err = CreateShortFormDID(is.SuffixData)
if err != nil {
return shortFormDID, longFormDID, err
}
initialStateBytesCanonical, err := CanonicalizeAny(is)
if err != nil {
err = errors.Wrap(err, "canonicalizing long form DID suffix data")
return shortFormDID, longFormDID, err
}
encoded := Encode(initialStateBytesCanonical)
longFormDID = strings.Join([]string{shortFormDID, encoded}, ":")
return shortFormDID, longFormDID, nil
}

// CreateLongFormDID generates a long form DID URI representation from a document, recovery, and update keys,
// intended to be the initial state of a DID Document. The method follows the guidelines in the spec:
// https://identity.foundation/sidetree/spec/#long-form-did-uris
@@ -41,6 +58,12 @@ func CreateLongFormDID(recoveryKey, updateKey jwx.PublicKeyJWK, document Documen
return strings.Join([]string{shortFormDID, encoded}, ":"), nil
}

// IsLongFormDID checks if a string is a long form DID URI
func IsLongFormDID(maybeLongFormDID string) bool {
split := strings.Split(maybeLongFormDID, ":")
return len(split) == 4
}

// DecodeLongFormDID decodes a long form DID into a short form DID and
// its create operation suffix data
func DecodeLongFormDID(longFormDID string) (string, *InitialState, error) {
@@ -85,3 +108,216 @@ func LongToShortFormDID(longFormDID string) (string, error) {
}
return shortFormDID, nil
}

// PatchesToDIDDocument applies a list of sidetree state patches in order resulting in a DID Document.
func PatchesToDIDDocument(shortFormDID, longFormDID string, patches []any) (*did.Document, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strongly discourage the use of any unless there is a strong reason to do so. Can you clarify why it's preferable to have patches be []any?

An alternative is to define an interface that all patches implement.

type Patch interface{
  isPatch()
}

func (a AddServicesAction) isPatch() { }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will switch to interface

if len(patches) == 0 {
return nil, errors.New("no patches to apply")
}
if shortFormDID == "" {
return nil, errors.New("short form DID is required")
}
doc := did.Document{
Context: []string{"https://www.w3.org/ns/did/v1"},
ID: shortFormDID,
AlsoKnownAs: longFormDID,
}
for _, patch := range patches {
knownPatch, err := tryCastPatch(patch)
if err != nil {
return nil, err
}
switch knownPatch.(type) {
case AddServicesAction:
addServicePatch := knownPatch.(AddServicesAction)
doc.Services = append(doc.Services, addServicePatch.Services...)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do this, then you don't need to re-cast within each case. You can use typedPatch

Suggested change
switch knownPatch.(type) {
case AddServicesAction:
addServicePatch := knownPatch.(AddServicesAction)
doc.Services = append(doc.Services, addServicePatch.Services...)
switch typedPatch := knownPatch.(type) {
case AddServicesAction:
doc.Services = append(doc.Services, typedPatch.Services...)

case RemoveServicesAction:
removeServicePatch := knownPatch.(RemoveServicesAction)
for _, id := range removeServicePatch.IDs {
for i, service := range doc.Services {
if service.ID == id {
doc.Services = append(doc.Services[:i], doc.Services[i+1:]...)
}
}
}
case AddPublicKeysAction:
addKeyPatch := knownPatch.(AddPublicKeysAction)
gotDoc, err := addPublicKeysPatch(doc, addKeyPatch)
if err != nil {
return nil, err
}
doc = *gotDoc
case RemovePublicKeysAction:
removeKeyPatch := knownPatch.(RemovePublicKeysAction)
gotDoc, err := removePublicKeysPatch(doc, removeKeyPatch)
if err != nil {
return nil, err
}
doc = *gotDoc
case ReplaceAction:
replacePatch := knownPatch.(ReplaceAction)
gotDoc, err := replaceActionPatch(doc, replacePatch)
if err != nil {
return nil, err
}
doc = *gotDoc
default:
return nil, fmt.Errorf("unknown patch type: %T", patch)
}
}
return &doc, nil
}

// tryCastPatch attempts to cast a patch to a known patch type
func tryCastPatch(patch any) (any, error) {
switch patch.(type) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above.

Mixing map[string]any and typed structs leads to too much freedom and code that is harder to maintain. Can this API be structured so we can have devs avoid shooting themselves in the foot?

case map[string]any:
patchMap := patch.(map[string]any)
patchBytes, err := json.Marshal(patch)
if err != nil {
return nil, errors.Wrap(err, "marshalling patch")
}
switch patchMap["action"] {
case Replace.String():
var ra ReplaceAction
if err = json.Unmarshal(patchBytes, &ra); err != nil {
return nil, errors.Wrap(err, "unmarshalling replace action")
}
return ra, nil
case AddPublicKeys.String():
var apa AddPublicKeysAction
if err = json.Unmarshal(patchBytes, &apa); err != nil {
return nil, errors.Wrap(err, "unmarshalling add public keys action")
}
return apa, nil
case RemovePublicKeys.String():
var rpa RemovePublicKeysAction
if err = json.Unmarshal(patchBytes, &rpa); err != nil {
return nil, errors.Wrap(err, "unmarshalling remove public keys action")
}
return rpa, nil
case AddServices.String():
var asa AddServicesAction
if err = json.Unmarshal(patchBytes, &asa); err != nil {
return nil, errors.Wrap(err, "unmarshalling add services action")
}
return asa, nil
case RemoveServices.String():
var rsa RemoveServicesAction
if err = json.Unmarshal(patchBytes, &rsa); err != nil {
return nil, errors.Wrap(err, "unmarshalling remove services action")
}
return rsa, nil
default:
return nil, fmt.Errorf("unknown patch action: %s", patchMap["action"])
}
case AddServicesAction:
return patch.(AddServicesAction), nil
case RemoveServicesAction:
return patch.(RemoveServicesAction), nil
case AddPublicKeysAction:
return patch.(AddPublicKeysAction), nil
case RemovePublicKeysAction:
return patch.(RemovePublicKeysAction), nil
case ReplaceAction:
return patch.(ReplaceAction), nil
default:
return nil, fmt.Errorf("unknown patch type: %T", patch)
}
}

func replaceActionPatch(doc did.Document, patch ReplaceAction) (*did.Document, error) {
// first zero out all public keys and services
doc.VerificationMethod = nil
doc.Authentication = nil
doc.AssertionMethod = nil
doc.KeyAgreement = nil
doc.CapabilityInvocation = nil
doc.CapabilityDelegation = nil
doc.Services = nil

// now add back what the patch includes
gotDoc, err := addPublicKeysPatch(doc, AddPublicKeysAction{PublicKeys: patch.Document.PublicKeys})
if err != nil {
return nil, err
}
doc = *gotDoc
for _, service := range patch.Document.Services {
doc.Services = append(doc.Services, service)
}
return &doc, nil
}

func addPublicKeysPatch(doc did.Document, patch AddPublicKeysAction) (*did.Document, error) {
for _, key := range patch.PublicKeys {
currKey := key
doc.VerificationMethod = append(doc.VerificationMethod, did.VerificationMethod{
ID: currKey.ID,
Type: cryptosuite.LDKeyType(currKey.Type),
Controller: doc.ID,
PublicKeyJWK: &currKey.PublicKeyJWK,
})
for _, purpose := range currKey.Purposes {
switch purpose {
case Authentication:
doc.Authentication = append(doc.Authentication, currKey.ID)
case AssertionMethod:
doc.AssertionMethod = append(doc.AssertionMethod, currKey.ID)
case KeyAgreement:
doc.KeyAgreement = append(doc.KeyAgreement, currKey.ID)
case CapabilityInvocation:
doc.CapabilityInvocation = append(doc.CapabilityInvocation, currKey.ID)
case CapabilityDelegation:
doc.CapabilityDelegation = append(doc.CapabilityDelegation, currKey.ID)
default:
return nil, fmt.Errorf("unknown key purpose: %s:%s", currKey.ID, purpose)
}
}
}
return &doc, nil
}

func removePublicKeysPatch(doc did.Document, patch RemovePublicKeysAction) (*did.Document, error) {
for _, id := range patch.IDs {
removed := false
for i, key := range doc.VerificationMethod {
if key.ID == id {
doc.VerificationMethod = append(doc.VerificationMethod[:i], doc.VerificationMethod[i+1:]...)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of copying around within this function. Is this OK?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a way to remove an element in an array should be fine

removed = true

// TODO(gabe): in the future handle the case where the value is not a simple ID
// remove from all other key lists
for j, auth := range doc.Authentication {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to un-nest this for loop and the following ones?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

if auth == id {
doc.Authentication = append(doc.Authentication[:j], doc.Authentication[j+1:]...)
}
}
for j, auth := range doc.AssertionMethod {
if auth == id {
doc.AssertionMethod = append(doc.AssertionMethod[:j], doc.AssertionMethod[j+1:]...)
}
}
for j, auth := range doc.Authentication {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be KeyAgreement?

if auth == id {
doc.KeyAgreement = append(doc.KeyAgreement[:j], doc.KeyAgreement[j+1:]...)
}
}
for j, auth := range doc.Authentication {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CapabilityInvocation?

if auth == id {
doc.CapabilityInvocation = append(doc.CapabilityInvocation[:j], doc.CapabilityInvocation[j+1:]...)
}
}
for j, auth := range doc.Authentication {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CapabilityDelegation?

if auth == id {
doc.CapabilityDelegation = append(doc.CapabilityDelegation[:j], doc.CapabilityDelegation[j+1:]...)
}
}
break
}
}
if !removed {
return nil, fmt.Errorf("could not find key with id %s", id)
}
}
return &doc, nil
}
164 changes: 160 additions & 4 deletions did/ion/did_test.go
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import (
"testing"

"github.com/TBD54566975/ssi-sdk/crypto/jwx"
"github.com/TBD54566975/ssi-sdk/did"
"github.com/stretchr/testify/assert"
)

@@ -18,14 +19,14 @@ func TestCreateLongFormDID(t *testing.T) {
var publicKey PublicKey
retrieveTestVectorAs(t, "publickeymodel1.json", &publicKey)

var service Service
var service did.Service
retrieveTestVectorAs(t, "service1.json", &service)

document := Document{
PublicKeys: []PublicKey{
publicKey,
},
Services: []Service{
Services: []did.Service{
service,
},
}
@@ -45,6 +46,13 @@ func TestCreateLongFormDID(t *testing.T) {

assert.Equal(t, expectedDID, ourDID)
assert.Equal(t, expectedIS, ourInitialState)

shortFormDID, longFormDID, err := ourInitialState.ToDIDStrings()
assert.NoError(t, err)
assert.NotEmpty(t, longFormDID)
assert.NotEmpty(t, shortFormDID)
assert.Equal(t, expectedLongFormDID, longFormDID)
assert.Equal(t, "did:ion:EiDyOQbbZAa3aiRzeCkV7LOx3SERjjH93EXoIM3UoN4oWg", shortFormDID)
}

func TestCreateShortFormDID(t *testing.T) {
@@ -70,14 +78,14 @@ func TestGetShortFormDIDFromLongFormDID(t *testing.T) {
var publicKey PublicKey
retrieveTestVectorAs(t, "publickeymodel1.json", &publicKey)

var service Service
var service did.Service
retrieveTestVectorAs(t, "service1.json", &service)

document := Document{
PublicKeys: []PublicKey{
publicKey,
},
Services: []Service{
Services: []did.Service{
service,
},
}
@@ -92,3 +100,151 @@ func TestGetShortFormDIDFromLongFormDID(t *testing.T) {

assert.Equal(t, shortFormDID, "did:ion:EiDyOQbbZAa3aiRzeCkV7LOx3SERjjH93EXoIM3UoN4oWg")
}

func TestPatchesToDIDDocument(t *testing.T) {
t.Run("Bad patch", func(tt *testing.T) {
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{struct{ Bad string }{Bad: "bad"}})
assert.Empty(tt, doc)
assert.Error(tt, err)
assert.Contains(tt, err.Error(), "unknown patch type")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and elsewhere

Suggested change
assert.Contains(tt, err.Error(), "unknown patch type")
assert.ErrorContains(tt, err, "unknown patch type")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

})

t.Run("No patches", func(tt *testing.T) {
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{})
assert.Empty(tt, doc)
assert.Error(tt, err)
assert.Contains(tt, err.Error(), "no patches to apply")
})

t.Run("Single patch - add keys", func(tt *testing.T) {
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{AddPublicKeysAction{
PublicKeys: []PublicKey{{
ID: "did:ion:test#key1",
Purposes: []PublicKeyPurpose{Authentication, AssertionMethod},
}},
}})
assert.NoError(tt, err)
assert.NotEmpty(tt, doc)
assert.Len(tt, doc.VerificationMethod, 1)
assert.Len(tt, doc.Authentication, 1)
assert.Equal(tt, "did:ion:test#key1", doc.Authentication[0])
assert.Len(tt, doc.AssertionMethod, 1)
assert.Equal(tt, "did:ion:test#key1", doc.AssertionMethod[0])

assert.Empty(tt, doc.KeyAgreement)
assert.Empty(tt, doc.CapabilityDelegation)
assert.Empty(tt, doc.CapabilityInvocation)
})

t.Run("Add and remove keys patches - invalid remove", func(tt *testing.T) {
addKeys := AddPublicKeysAction{
PublicKeys: []PublicKey{{
ID: "did:ion:test#key1",
Purposes: []PublicKeyPurpose{Authentication, AssertionMethod},
}},
}
removeKeys := RemovePublicKeysAction{
IDs: []string{"did:ion:test#key2"},
}
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{addKeys, removeKeys})
assert.Empty(tt, doc)
assert.Error(tt, err)
assert.Contains(tt, err.Error(), "could not find key with id did:ion:test#key2")
})

t.Run("Add and remove keys patches - valid remove", func(tt *testing.T) {
addKeys := AddPublicKeysAction{
PublicKeys: []PublicKey{
{
ID: "did:ion:test#key1",
Purposes: []PublicKeyPurpose{Authentication, AssertionMethod},
},
{
ID: "did:ion:test#key2",
Purposes: []PublicKeyPurpose{Authentication, AssertionMethod},
},
},
}
removeKeys := RemovePublicKeysAction{
IDs: []string{"did:ion:test#key2"},
}
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{addKeys, removeKeys})
assert.NoError(tt, err)
assert.NotEmpty(tt, doc)
assert.Len(tt, doc.VerificationMethod, 1)
assert.Len(tt, doc.Authentication, 1)
assert.Equal(tt, "did:ion:test#key1", doc.Authentication[0])
assert.Len(tt, doc.AssertionMethod, 1)
assert.Equal(tt, "did:ion:test#key1", doc.AssertionMethod[0])

assert.Empty(tt, doc.KeyAgreement)
assert.Empty(tt, doc.CapabilityDelegation)
assert.Empty(tt, doc.CapabilityInvocation)
})

t.Run("Add and remove services", func(tt *testing.T) {
addServices := AddServicesAction{
Services: []did.Service{
{
ID: "did:ion:test#service1",
Type: "test",
ServiceEndpoint: "https://example.com",
},
{
ID: "did:ion:test#service2",
Type: "test",
ServiceEndpoint: "https://example.com",
},
},
}
removeServices := RemoveServicesAction{
IDs: []string{"did:ion:test#service2"},
}
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{addServices, removeServices})
assert.NoError(tt, err)
assert.NotEmpty(tt, doc)
assert.Empty(tt, doc.VerificationMethod)
assert.Empty(tt, doc.Authentication)
assert.Empty(tt, doc.AssertionMethod)
assert.Empty(tt, doc.KeyAgreement)
assert.Empty(tt, doc.CapabilityDelegation)
assert.Empty(tt, doc.CapabilityInvocation)
assert.Len(tt, doc.Services, 1)
assert.Equal(tt, "did:ion:test#service1", doc.Services[0].ID)
})

t.Run("Replace patch", func(tt *testing.T) {
replaceAction := ReplaceAction{
Document: Document{
PublicKeys: []PublicKey{
{
ID: "did:ion:test#key1",
Purposes: []PublicKeyPurpose{Authentication, AssertionMethod},
},
},
Services: []did.Service{
{
ID: "did:ion:test#service1",
Type: "test",
ServiceEndpoint: "https://example.com",
},
},
},
}
doc, err := PatchesToDIDDocument("did:ion:test", "", []any{replaceAction})
assert.NoError(tt, err)
assert.NotEmpty(tt, doc)
assert.Len(tt, doc.VerificationMethod, 1)
assert.Len(tt, doc.Authentication, 1)
assert.Equal(tt, "did:ion:test#key1", doc.Authentication[0])
assert.Len(tt, doc.AssertionMethod, 1)
assert.Equal(tt, "did:ion:test#key1", doc.AssertionMethod[0])

assert.Empty(tt, doc.KeyAgreement)
assert.Empty(tt, doc.CapabilityDelegation)
assert.Empty(tt, doc.CapabilityInvocation)

assert.Len(tt, doc.Services, 1)
assert.Equal(tt, "did:ion:test#service1", doc.Services[0].ID)
})
}
4 changes: 4 additions & 0 deletions did/ion/enum.go
Original file line number Diff line number Diff line change
@@ -42,3 +42,7 @@ const (
AddServices PatchAction = "add-services"
RemoveServices PatchAction = "remove-services"
)

func (p PatchAction) String() string {
return string(p)
}
16 changes: 5 additions & 11 deletions did/ion/model.go
Original file line number Diff line number Diff line change
@@ -2,26 +2,20 @@ package ion

import (
"github.com/TBD54566975/ssi-sdk/crypto/jwx"
"github.com/TBD54566975/ssi-sdk/did"
)

// object models

type Document struct {
PublicKeys []PublicKey `json:"publicKeys,omitempty"`
Services []Service `json:"services,omitempty"`
PublicKeys []PublicKey `json:"publicKeys,omitempty"`
Services []did.Service `json:"services,omitempty"`
}

func (d Document) IsEmpty() bool {
return len(d.PublicKeys) == 0 && len(d.Services) == 0
}

// Service declaration in a DID Document
type Service struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
ServiceEndpoint any `json:"serviceEndpoint,omitempty"`
}

type PublicKey struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
@@ -33,8 +27,8 @@ type PublicKey struct {

// AddServicesAction https://identity.foundation/sidetree/spec/#add-services
type AddServicesAction struct {
Action PatchAction `json:"action,omitempty"`
Services []Service `json:"services,omitempty"`
Action PatchAction `json:"action,omitempty"`
Services []did.Service `json:"services,omitempty"`
}

// RemoveServicesAction https://identity.foundation/sidetree/spec/#remove-services
105 changes: 2 additions & 103 deletions did/ion/operations.go
Original file line number Diff line number Diff line change
@@ -34,24 +34,15 @@
package ion

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"strings"

"github.com/goccy/go-json"
"github.com/google/uuid"
"github.com/pkg/errors"

"github.com/TBD54566975/ssi-sdk/crypto"
"github.com/TBD54566975/ssi-sdk/crypto/jwx"
"github.com/TBD54566975/ssi-sdk/did"
"github.com/TBD54566975/ssi-sdk/did/resolution"
"github.com/TBD54566975/ssi-sdk/util"
"github.com/google/uuid"
"github.com/pkg/errors"
)

type (
@@ -85,98 +76,6 @@ func (ION) Method() did.Method {
return did.IONMethod
}

type Resolver struct {
client *http.Client
baseURL url.URL
}

// NewIONResolver creates a new resolution for the ION DID method with a common base URL
// The base URL is the URL of the ION node, for example: https://ion.tbd.network
// The resolution will append the DID to the base URL to resolve the DID such as
//
// https://ion.tbd.network/identifiers/did:ion:1234
//
// and similarly for submitting anchor operations to the ION node...
//
// https://ion.tbd.network/operations
func NewIONResolver(client *http.Client, baseURL string) (*Resolver, error) {
if client == nil {
return nil, errors.New("client cannot be nil")
}
parsedURL, err := url.ParseRequestURI(baseURL)
if err != nil {
return nil, errors.Wrap(err, "invalid resolution URL")
}
if parsedURL.Scheme != "https" {
return nil, errors.New("invalid resolution URL scheme; must use https")
}
return &Resolver{
client: client,
baseURL: *parsedURL,
}, nil
}

// Resolve resolves a did:ion DID by appending the DID to the base URL with the identifiers path and making a GET request
func (i Resolver) Resolve(ctx context.Context, id string, _ resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
if i.baseURL.String() == "" {
return nil, errors.New("resolution URL cannot be empty")
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.Join([]string{i.baseURL.String(), "identifiers", id}, "/"), nil)
if err != nil {
return nil, errors.Wrap(err, "creating request")
}
resp, err := i.client.Do(req)
if err != nil {
return nil, errors.Wrapf(err, "resolving, with URL: %s", i.baseURL.String())
}

defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrapf(err, "resolving, with response %+v", resp)
}
if !is2xxStatusCode(resp.StatusCode) {
return nil, fmt.Errorf("could not resolve DID: %q", string(body))
}
resolutionResult, err := resolution.ParseDIDResolution(body)
if err != nil {
return nil, errors.Wrapf(err, "resolving did:ion DID<%s>", id)
}
return resolutionResult, nil
}

// Anchor submits an anchor operation to the ION node by appending the operations path to the base URL
// and making a POST request
func (i Resolver) Anchor(ctx context.Context, op AnchorOperation) error {
if i.baseURL.String() == "" {
return errors.New("resolution URL cannot be empty")
}
jsonOpBytes, err := json.Marshal(op)
if err != nil {
return errors.Wrapf(err, "marshalling anchor operation %+v", op)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.Join([]string{i.baseURL.String(), "operations"}, "/"), bytes.NewReader(jsonOpBytes))
if err != nil {
return errors.Wrap(err, "creating request")
}
resp, err := i.client.Do(req)
if err != nil {
return errors.Wrapf(err, "posting anchor operation %+v", op)
}

defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrapf(err, "could not resolve with response %+v", resp)
}
if !is2xxStatusCode(resp.StatusCode) {
return fmt.Errorf("anchor operation failed: %s", string(body))
}
return nil
}

// DID is a representation of a did:ion DID and should be used to maintain the state of an ION
// DID Document. It contains the DID suffix, the long form DID, the operations of the DID, and both
// the update and recovery private keys. All receiver methods are side effect free, and return new
156 changes: 95 additions & 61 deletions did/ion/operations_test.go
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import (
"net/http"
"testing"

"github.com/TBD54566975/ssi-sdk/did"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
@@ -88,6 +89,47 @@ func TestResolver(t *testing.T) {
assert.Equal(tt, "did:ion:test", result.Document.ID)
})

t.Run("resolve a long form DID", func(tt *testing.T) {
tt.Run("bad long form DID", func(ttt *testing.T) {
gock.New("https://test-ion-resolution.com").
Get("/did:ion:test").
Reply(200).
BodyString(`{"didDocument": {"id": "did:ion:test"}}`)
defer gock.Off()

resolver, err := NewIONResolver(http.DefaultClient, "https://test-ion-resolution.com")
assert.NoError(ttt, err)
assert.NotEmpty(ttt, resolver)

badLongFormDID := "did:ion:Eia3aiRzeCkV7LOx3SERjjH93EXoIM3UoDyOQbbZAN4oWg:eyJRpb24iOiJyZXBsYWNlIiwiZG9jdW1lbnQiOkZWx0YSI6eyJwYXRjaGVzIjpbeyJhY3nsicHVibGljS2V5cyI6W3siaWQiOiJwdWJsaWNLZXlNb2RlbDFJZCIsInB1YmxpY0tleUp3ayI6eyJjcnYiOiJzZWNwMjU2azEiLCJrdHkiOiJFQyIsIngiOiJ0WFNLQl9ydWJYUzdzQ2pYcXVwVkpFelRjVzNNc2ptRXZxMVlwWG45NlpnIiwieSI6ImRPaWNYcWJqRnhvR0otSzAtR0oxa0hZSnFpY19EX09NdVV3a1E3T2w2bmsifSwicHVycG9zZXMiOlsiYXV0aGVudGljYXRpb24iLCJrZXlBZ3JlZW1lbnQiXSwidHlwZSI6IkVjZHNhU2VjcDI1NmsxVmVyaWZpY2F0aW9uS2V5MjAxOSJ9XSwic2VydmljZXMiOlt7ImlkIjoic2VydmljZTFJZCIsInNlcnZpY2VFbmRwb2ludCI6Imh0dHA6Ly93d3cuc2VydmljZTEuY29tIiwidHlwZSI6InNlcnZpY2UxVHlwZSJ9XX19XSwidXBkYXRlQ29tbWl0bWVudCI6IkVpREtJa3dxTzY5SVBHM3BPbEhrZGI4Nm5ZdDBhTnhTSFp1MnItYmhFem5qZEEifSwic3VmZml4RGF0YSI6eyJkZWx0YUhhc2giOiJFaUNmRFdSbllsY0Q5RUdBM2RfNVoxQUh1LWlZcU1iSjluZmlxZHo1UzhWRGJnIiwicmVjb3ZlcnlDb21taXRtZW50IjoiRWlCZk9aZE10VTZPQnc4UGs4NzlRdFotMkotOUZiYmpTWnlvYUFfYnFENHpoQSJ9fQ"

result, err := resolver.Resolve(context.Background(), badLongFormDID, nil)
assert.Empty(ttt, result)
assert.Error(ttt, err)
assert.Contains(ttt, err.Error(), "invalid long form DID")
})

tt.Run("good long form DID", func(ttt *testing.T) {
gock.New("https://test-ion-resolution.com").
Get("/did:ion:test").
Reply(200).
BodyString(`{"didDocument": {"id": "did:ion:test"}}`)
defer gock.Off()

resolver, err := NewIONResolver(http.DefaultClient, "https://test-ion-resolution.com")
assert.NoError(ttt, err)
assert.NotEmpty(ttt, resolver)

longFormDID := "did:ion:EiDyOQbbZAa3aiRzeCkV7LOx3SERjjH93EXoIM3UoN4oWg:eyJkZWx0YSI6eyJwYXRjaGVzIjpbeyJhY3Rpb24iOiJyZXBsYWNlIiwiZG9jdW1lbnQiOnsicHVibGljS2V5cyI6W3siaWQiOiJwdWJsaWNLZXlNb2RlbDFJZCIsInB1YmxpY0tleUp3ayI6eyJjcnYiOiJzZWNwMjU2azEiLCJrdHkiOiJFQyIsIngiOiJ0WFNLQl9ydWJYUzdzQ2pYcXVwVkpFelRjVzNNc2ptRXZxMVlwWG45NlpnIiwieSI6ImRPaWNYcWJqRnhvR0otSzAtR0oxa0hZSnFpY19EX09NdVV3a1E3T2w2bmsifSwicHVycG9zZXMiOlsiYXV0aGVudGljYXRpb24iLCJrZXlBZ3JlZW1lbnQiXSwidHlwZSI6IkVjZHNhU2VjcDI1NmsxVmVyaWZpY2F0aW9uS2V5MjAxOSJ9XSwic2VydmljZXMiOlt7ImlkIjoic2VydmljZTFJZCIsInNlcnZpY2VFbmRwb2ludCI6Imh0dHA6Ly93d3cuc2VydmljZTEuY29tIiwidHlwZSI6InNlcnZpY2UxVHlwZSJ9XX19XSwidXBkYXRlQ29tbWl0bWVudCI6IkVpREtJa3dxTzY5SVBHM3BPbEhrZGI4Nm5ZdDBhTnhTSFp1MnItYmhFem5qZEEifSwic3VmZml4RGF0YSI6eyJkZWx0YUhhc2giOiJFaUNmRFdSbllsY0Q5RUdBM2RfNVoxQUh1LWlZcU1iSjluZmlxZHo1UzhWRGJnIiwicmVjb3ZlcnlDb21taXRtZW50IjoiRWlCZk9aZE10VTZPQnc4UGs4NzlRdFotMkotOUZiYmpTWnlvYUFfYnFENHpoQSJ9fQ"

result, err := resolver.Resolve(context.Background(), longFormDID, nil)
assert.NoError(ttt, err)
assert.NotEmpty(ttt, result)
assert.Equal(ttt, "did:ion:EiDyOQbbZAa3aiRzeCkV7LOx3SERjjH93EXoIM3UoN4oWg", result.Document.ID)
assert.Equal(ttt, longFormDID, result.Document.AlsoKnownAs)
})
})

t.Run("bad anchor", func(tt *testing.T) {
gock.New("https://test-ion-resolution.com").
Post("/operations").
@@ -114,70 +156,62 @@ func TestResolver(t *testing.T) {
assert.NotEmpty(tt, resolver)

// generate a good create op
did, createOp, err := NewIONDID(Document{
Services: []Service{
ionDID, createOp, err := NewIONDID(Document{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
ID: "tbd-website",
Type: "TBD",
ServiceEndpoint: "https://tbd.website",
},
},
})

assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

err = resolver.Anchor(context.Background(), CreateRequest{
Type: Create,
SuffixData: SuffixData{
DeltaHash: "deltaHash",
RecoveryCommitment: "recoveryCommitment",
},
Delta: Delta{
Patches: nil,
UpdateCommitment: "",
},
})
err = resolver.Anchor(context.Background(), createOp)
assert.NoError(tt, err)
})
}

func TestRequests(t *testing.T) {
t.Run("bad create request", func(tt *testing.T) {
did, createOp, err := NewIONDID(Document{})
ionDID, createOp, err := NewIONDID(Document{})
assert.Error(tt, err)
assert.Empty(tt, did)
assert.Empty(tt, ionDID)
assert.Empty(tt, createOp)
assert.Contains(tt, err.Error(), "document cannot be empty")
})

t.Run("good create request", func(tt *testing.T) {
did, createOp, err := NewIONDID(Document{
Services: []Service{
ionDID, createOp, err := NewIONDID(Document{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
ID: "tbd-service-endpoint",
Type: "TBDServiceEndpoint",
ServiceEndpoint: "https://tbd.website",
},
},
})
assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

// check DID object
assert.NotEmpty(tt, did.ID())
assert.Contains(tt, did.ID(), "did:ion:")
assert.Len(tt, did.Operations(), 1)
assert.NotEmpty(tt, did.updatePrivateKey)
assert.NotEmpty(tt, did.recoveryPrivateKey)
assert.NotEqual(tt, did.updatePrivateKey, did.recoveryPrivateKey)
assert.NotEmpty(tt, ionDID.ID())
assert.Contains(tt, ionDID.ID(), "did:ion:")
assert.Len(tt, ionDID.Operations(), 1)
assert.NotEmpty(tt, ionDID.updatePrivateKey)
assert.NotEmpty(tt, ionDID.recoveryPrivateKey)
assert.NotEqual(tt, ionDID.updatePrivateKey, ionDID.recoveryPrivateKey)

// try to decode long form DID
decoded, initialState, err := DecodeLongFormDID(did.LongForm())
decoded, initialState, err := DecodeLongFormDID(ionDID.LongForm())
assert.NoError(tt, err)
assert.NotEmpty(tt, decoded)
assert.NotEmpty(tt, initialState)
assert.Equal(tt, did.ID(), decoded)
assert.Equal(tt, ionDID.ID(), decoded)

// check create op
assert.Equal(tt, Create, createOp.Type)
@@ -186,80 +220,80 @@ func TestRequests(t *testing.T) {
})

t.Run("bad update request", func(tt *testing.T) {
did, createOp, err := NewIONDID(Document{
Services: []Service{
ionDID, createOp, err := NewIONDID(Document{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
},
},
})
assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

badStateChange := StateChange{}
updatedDID, updateOp, err := did.Update(badStateChange)
updatedDID, updateOp, err := ionDID.Update(badStateChange)
assert.Error(tt, err)
assert.Empty(tt, updatedDID)
assert.Empty(tt, updateOp)
assert.Contains(tt, err.Error(), "state change cannot be empty")
})

t.Run("good update request", func(tt *testing.T) {
did, createOp, err := NewIONDID(Document{
Services: []Service{
ionDID, createOp, err := NewIONDID(Document{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
},
},
})
assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

stateChange := StateChange{
ServicesToAdd: []Service{
ServicesToAdd: []did.Service{
{
ID: "serviceID2",
Type: "serviceType2",
},
},
}
updatedDID, updateOp, err := did.Update(stateChange)
updatedDID, updateOp, err := ionDID.Update(stateChange)
assert.NoError(tt, err)
assert.NotEmpty(tt, updatedDID)
assert.NotEmpty(tt, updateOp)

// check update op
assert.Equal(tt, Update, updateOp.Type)
assert.NotEmpty(tt, updateOp.DIDSuffix)
assert.Contains(tt, did.ID(), updateOp.DIDSuffix)
assert.Contains(tt, ionDID.ID(), updateOp.DIDSuffix)
assert.NotEmpty(tt, updateOp.RevealValue)
assert.NotEmpty(tt, updateOp.Delta)
assert.NotEmpty(tt, updateOp.SignedData)

// make sure keys are different and op is added
assert.NotEqual(tt, did.updatePrivateKey, updatedDID.updatePrivateKey)
assert.Len(tt, did.Operations(), 1)
assert.NotEqual(tt, ionDID.updatePrivateKey, updatedDID.updatePrivateKey)
assert.Len(tt, ionDID.Operations(), 1)
assert.Len(tt, updatedDID.Operations(), 2)
})

t.Run("bad recover request", func(tt *testing.T) {
did, createOp, err := NewIONDID(Document{
Services: []Service{
ionDID, createOp, err := NewIONDID(Document{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
},
},
})
assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

recoveredDID, recoverOp, err := did.Recover(Document{})
recoveredDID, recoverOp, err := ionDID.Recover(Document{})
assert.Error(tt, err)
assert.Empty(tt, recoveredDID)
assert.Empty(tt, recoverOp)
@@ -268,34 +302,34 @@ func TestRequests(t *testing.T) {

t.Run("good recover request", func(tt *testing.T) {
document := Document{
Services: []Service{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
},
},
}
did, createOp, err := NewIONDID(document)
ionDID, createOp, err := NewIONDID(document)
assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

recoveredDID, recoverOp, err := did.Recover(document)
recoveredDID, recoverOp, err := ionDID.Recover(document)
assert.NoError(tt, err)
assert.NotEmpty(tt, recoveredDID)
assert.NotEmpty(tt, recoverOp)

assert.Equal(tt, Recover, recoverOp.Type)
assert.NotEmpty(tt, recoverOp.DIDSuffix)
assert.Contains(tt, did.ID(), recoverOp.DIDSuffix)
assert.Contains(tt, ionDID.ID(), recoverOp.DIDSuffix)
assert.NotEmpty(tt, recoverOp.RevealValue)
assert.NotEmpty(tt, recoverOp.Delta)
assert.NotEmpty(tt, recoverOp.SignedData)

// make sure keys are different and op is added
assert.NotEqual(tt, did.updatePrivateKey, recoveredDID.updatePrivateKey)
assert.NotEqual(tt, did.recoveryPrivateKey, recoveredDID.recoveryPrivateKey)
assert.Len(tt, did.Operations(), 1)
assert.NotEqual(tt, ionDID.updatePrivateKey, recoveredDID.updatePrivateKey)
assert.NotEqual(tt, ionDID.recoveryPrivateKey, recoveredDID.recoveryPrivateKey)
assert.Len(tt, ionDID.Operations(), 1)
assert.Len(tt, recoveredDID.Operations(), 2)
})

@@ -310,30 +344,30 @@ func TestRequests(t *testing.T) {

t.Run("good deactivate request", func(tt *testing.T) {
document := Document{
Services: []Service{
Services: []did.Service{
{
ID: "serviceID",
Type: "serviceType",
},
},
}
did, createOp, err := NewIONDID(document)
ionDID, createOp, err := NewIONDID(document)
assert.NoError(tt, err)
assert.NotEmpty(tt, did)
assert.NotEmpty(tt, ionDID)
assert.NotEmpty(tt, createOp)

deactivatedDID, deactivateOp, err := did.Deactivate()
deactivatedDID, deactivateOp, err := ionDID.Deactivate()
assert.NoError(tt, err)
assert.NotEmpty(tt, deactivatedDID)
assert.NotEmpty(tt, deactivateOp)

assert.Equal(tt, Deactivate, deactivateOp.Type)
assert.NotEmpty(tt, deactivateOp.DIDSuffix)
assert.Contains(tt, did.ID(), deactivateOp.DIDSuffix)
assert.Contains(tt, ionDID.ID(), deactivateOp.DIDSuffix)
assert.NotEmpty(tt, deactivateOp.RevealValue)
assert.NotEmpty(tt, deactivateOp.SignedData)

assert.Len(tt, did.Operations(), 1)
assert.Len(tt, ionDID.Operations(), 1)
assert.Len(tt, deactivatedDID.Operations(), 2)
})
}
5 changes: 3 additions & 2 deletions did/ion/request.go
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package ion

import (
"github.com/TBD54566975/ssi-sdk/crypto/jwx"
"github.com/TBD54566975/ssi-sdk/did"
"github.com/pkg/errors"
)

@@ -212,7 +213,7 @@ func NewDeactivateRequest(didSuffix string, recoveryKey jwx.PublicKeyJWK, signer
}

type StateChange struct {
ServicesToAdd []Service
ServicesToAdd []did.Service
ServiceIDsToRemove []string
PublicKeysToAdd []PublicKey
PublicKeyIDsToRemove []string
@@ -232,7 +233,7 @@ func (s StateChange) IsValid() error {

// check if services are valid
// build index of services to make sure IDs are unique
services := make(map[string]Service, len(s.ServicesToAdd))
services := make(map[string]did.Service, len(s.ServicesToAdd))
for _, service := range s.ServicesToAdd {
if _, ok := services[service.ID]; ok {
return errors.Errorf("service %s duplicated", service.ID)
13 changes: 7 additions & 6 deletions did/ion/request_test.go
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import (
"testing"

"github.com/TBD54566975/ssi-sdk/crypto/jwx"
"github.com/TBD54566975/ssi-sdk/did"
"github.com/stretchr/testify/assert"
)

@@ -18,12 +19,12 @@ func TestCreateRequest(t *testing.T) {
var publicKey PublicKey
retrieveTestVectorAs(t, "publickeymodel1.json", &publicKey)

var service Service
var service did.Service
retrieveTestVectorAs(t, "service1.json", &service)

document := Document{
PublicKeys: []PublicKey{publicKey},
Services: []Service{service},
Services: []did.Service{service},
}

createRequest, err := NewCreateRequest(recoveryKey, updateKey, document)
@@ -51,7 +52,7 @@ func TestUpdateRequest(t *testing.T) {
var publicKey PublicKey
retrieveTestVectorAs(t, "publickeymodel1.json", &publicKey)

var service Service
var service did.Service
retrieveTestVectorAs(t, "service1.json", &service)

signer, err := NewBTCSignerVerifier(updatePrivateKey)
@@ -60,7 +61,7 @@ func TestUpdateRequest(t *testing.T) {

didSuffix := "EiDyOQbbZAa3aiRzeCkV7LOx3SERjjH93EXoIM3UoN4oWg"
stateChange := StateChange{
ServicesToAdd: []Service{service},
ServicesToAdd: []did.Service{service},
ServiceIDsToRemove: []string{"someId1"},
PublicKeysToAdd: []PublicKey{publicKey},
PublicKeyIDsToRemove: []string{"someId2"},
@@ -82,10 +83,10 @@ func TestRecoverRequest(t *testing.T) {
var publicKey PublicKey
retrieveTestVectorAs(t, "publickeymodel1.json", &publicKey)

var service Service
var service did.Service
retrieveTestVectorAs(t, "service1.json", &service)

document := Document{PublicKeys: []PublicKey{publicKey}, Services: []Service{service}}
document := Document{PublicKeys: []PublicKey{publicKey}, Services: []did.Service{service}}

var recoveryKey jwx.PublicKeyJWK
retrieveTestVectorAs(t, "jwkes256k1public.json", &recoveryKey)
131 changes: 131 additions & 0 deletions did/ion/resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package ion

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/TBD54566975/ssi-sdk/did"
"github.com/TBD54566975/ssi-sdk/did/resolution"
"github.com/goccy/go-json"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)

type Resolver struct {
client *http.Client
baseURL url.URL
}

var _ resolution.Resolver = (*Resolver)(nil)

// NewIONResolver creates a new resolution for the ION DID method with a common base URL
// The base URL is the URL of the ION node, for example: https://ion.tbd.network
// The resolution will append the DID to the base URL to resolve the DID such as
//
// https://ion.tbd.network/identifiers/did:ion:1234
//
// and similarly for submitting anchor operations to the ION node...
//
// https://ion.tbd.network/operations
func NewIONResolver(client *http.Client, baseURL string) (*Resolver, error) {
if client == nil {
return nil, errors.New("client cannot be nil")
}
parsedURL, err := url.ParseRequestURI(baseURL)
if err != nil {
return nil, errors.Wrap(err, "invalid resolution URL")
}
if parsedURL.Scheme != "https" {
return nil, errors.New("invalid resolution URL scheme; must use https")
}
return &Resolver{
client: client,
baseURL: *parsedURL,
}, nil
}

// Resolve resolves a did:ion DID by appending the DID to the base URL with the identifiers path and making a GET request
func (i Resolver) Resolve(ctx context.Context, id string, _ ...resolution.Option) (*resolution.Result, error) {
// first attempt to decode as a long form DID, if we get an error, continue
if id == "" {
return nil, errors.New("id cannot be empty")
}
if IsLongFormDID(id) {
shortFormDID, initialState, err := DecodeLongFormDID(id)
if err != nil {
return nil, errors.Wrap(err, "invalid long form DID")
}
didDoc, err := PatchesToDIDDocument(shortFormDID, id, initialState.Delta.Patches)
if err != nil {
return nil, errors.Wrap(err, "reconstructing document from long form DID")
}
return &resolution.Result{Document: *didDoc}, nil
}

if i.baseURL.String() == "" {
return nil, errors.New("resolution URL cannot be empty")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.Join([]string{i.baseURL.String(), "identifiers", id}, "/"), nil)
if err != nil {
return nil, errors.Wrap(err, "creating request")
}
resp, err := i.client.Do(req)
if err != nil {
return nil, errors.Wrapf(err, "resolving, with URL: %s", i.baseURL.String())
}

defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrapf(err, "resolving, with response %+v", resp)
}
if !is2xxStatusCode(resp.StatusCode) {
return nil, fmt.Errorf("could not resolve DID: %q", string(body))
}
resolutionResult, err := resolution.ParseDIDResolution(body)
if err != nil {
return nil, errors.Wrapf(err, "resolving did:ion DID<%s>", id)
}
return resolutionResult, nil
}

// Anchor submits an anchor operation to the ION node by appending the operations path to the base URL
// and making a POST request
func (i Resolver) Anchor(ctx context.Context, op AnchorOperation) error {
if i.baseURL.String() == "" {
return errors.New("resolution URL cannot be empty")
}
jsonOpBytes, err := json.Marshal(op)
if err != nil {
return errors.Wrapf(err, "marshalling anchor operation %+v", op)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.Join([]string{i.baseURL.String(), "operations"}, "/"), bytes.NewReader(jsonOpBytes))
if err != nil {
return errors.Wrap(err, "creating request")
}
resp, err := i.client.Do(req)
if err != nil {
return errors.Wrapf(err, "posting anchor operation %+v", op)
}

defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrapf(err, "could not resolve with response %+v", resp)
}
if !is2xxStatusCode(resp.StatusCode) {
return fmt.Errorf("anchor operation failed: %s", string(body))
}
logrus.Infof("successfully anchored operation: %s", string(body))
return nil
}

func (Resolver) Methods() []did.Method {
return []did.Method{did.IONMethod}
}
4 changes: 2 additions & 2 deletions did/jwk/resolver.go
Original file line number Diff line number Diff line change
@@ -13,13 +13,13 @@ type Resolver struct{}

var _ resolution.Resolver = (*Resolver)(nil)

func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Option) (*resolution.Result, error) {
didJWK := JWK(id)
doc, err := didJWK.Expand()
if err != nil {
return nil, errors.Wrap(err, "expanding did:jwk")
}
return &resolution.ResolutionResult{Document: *doc}, nil
return &resolution.Result{Document: *doc}, nil
}

func (Resolver) Methods() []did.Method {
4 changes: 2 additions & 2 deletions did/key/resolver.go
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ type Resolver struct{}

var _ resolution.Resolver = (*Resolver)(nil)

func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Option) (*resolution.Result, error) {
if !strings.HasPrefix(id, Prefix) {
return nil, fmt.Errorf("not a id:key DID: %s", id)
}
@@ -24,7 +24,7 @@ func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Resolution
if err != nil {
return nil, errors.Wrapf(err, "could not expand did:key DID: %s", id)
}
return &resolution.ResolutionResult{Document: *doc}, nil
return &resolution.Result{Document: *doc}, nil
}

func (Resolver) Methods() []did.Method {
2 changes: 1 addition & 1 deletion did/peer/peer.go
Original file line number Diff line number Diff line change
@@ -152,7 +152,7 @@ func (DIDPeer) IsValidPurpose(p PurposeType) bool {
return false
}

func (Method1) resolve(d did.DID, _ resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Method1) resolve(d did.DID, _ resolution.Option) (*resolution.Result, error) {
if _, ok := d.(DIDPeer); !ok {
return nil, errors.Wrap(util.CastingError, DIDPeerPrefix)
}
4 changes: 2 additions & 2 deletions did/peer/peer0.go
Original file line number Diff line number Diff line change
@@ -37,7 +37,7 @@ func (Method0) Generate(kt crypto.KeyType, publicKey gocrypto.PublicKey) (*DIDPe
// Resolve resolves a did:peer into a DID Document
// To do so, it decodes the key, constructs a verification method, and returns a DID Document .This allows Method0
// to implement the DID Resolver interface and be used to expand the did into the DID Document.
func (Method0) resolve(didDoc did.DID, _ resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Method0) resolve(didDoc did.DID, _ resolution.Option) (*resolution.Result, error) {
d, ok := didDoc.(DIDPeer)
if !ok {
return nil, errors.Wrap(util.CastingError, "did:peer")
@@ -71,5 +71,5 @@ func (Method0) resolve(didDoc did.DID, _ resolution.ResolutionOption) (*resoluti
KeyAgreement: verificationMethodSet,
CapabilityDelegation: verificationMethodSet,
}
return &resolution.ResolutionResult{Document: document}, nil
return &resolution.Result{Document: document}, nil
}
4 changes: 2 additions & 2 deletions did/peer/peer2.go
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@ func (Method2) Method() did.Method {
// Resolve Splits the DID string into element.
// Extract element purpose and decode each key or service.
// Insert each key or service into the document according to the designated pu
func (Method2) resolve(didDoc did.DID, _ resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Method2) resolve(didDoc did.DID, _ resolution.Option) (*resolution.Result, error) {
d, ok := didDoc.(DIDPeer)
if !ok {
return nil, errors.Wrap(util.CastingError, "did:peer")
@@ -93,7 +93,7 @@ func (Method2) resolve(didDoc did.DID, _ resolution.ResolutionOption) (*resoluti
return nil, errors.Wrap(util.UnsupportedError, string(entry[0]))
}
}
return &resolution.ResolutionResult{Document: doc}, nil
return &resolution.Result{Document: doc}, nil
}

// Generate If numalgo == 2, the generation mode is similar to Method 0 (and therefore also did:key) with the ability
2 changes: 1 addition & 1 deletion did/peer/resolver.go
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ type Resolver struct{}

var _ resolution.Resolver = (*Resolver)(nil)

func (Resolver) Resolve(_ context.Context, id string, opts ...resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Resolver) Resolve(_ context.Context, id string, opts ...resolution.Option) (*resolution.Result, error) {
if !strings.HasPrefix(id, DIDPeerPrefix) {
return nil, fmt.Errorf("not a did:peer DID: %s", id)
}
4 changes: 2 additions & 2 deletions did/pkh/resolver.go
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ type Resolver struct{}

var _ resolution.Resolver = (*Resolver)(nil)

func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Option) (*resolution.Result, error) {
if !strings.HasPrefix(id, DIDPKHPrefix) {
return nil, fmt.Errorf("not a did:pkh DID: %s", id)
}
@@ -24,7 +24,7 @@ func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Resolution
if err != nil {
return nil, errors.Wrapf(err, "could not expand did:pkh DID: %s", id)
}
return &resolution.ResolutionResult{Document: *doc}, nil
return &resolution.Result{Document: *doc}, nil
}

func (Resolver) Methods() []did.Method {
26 changes: 13 additions & 13 deletions did/resolution/model.go
Original file line number Diff line number Diff line change
@@ -7,19 +7,19 @@ import (
"github.com/TBD54566975/ssi-sdk/util"
)

// ResolutionResult encapsulates the tuple of a DID resolution https://www.w3.org/TR/did-core/#did-resolution
type ResolutionResult struct {
Context string `json:"@context,omitempty"`
ResolutionMetadata `json:"didResolutionMetadata,omitempty"`
did.Document `json:"didDocument,omitempty"`
DocumentMetadata `json:"didDocumentMetadata,omitempty"`
// Result encapsulates the tuple of a DID resolution https://www.w3.org/TR/did-core/#did-resolution
type Result struct {
Context string `json:"@context,omitempty"`
Metadata `json:"didResolutionMetadata,omitempty"`
did.Document `json:"didDocument,omitempty"`
DocumentMetadata `json:"didDocumentMetadata,omitempty"`
}

func (r *ResolutionResult) IsEmpty() bool {
func (r *Result) IsEmpty() bool {
if r == nil {
return true
}
return reflect.DeepEqual(r, ResolutionResult{})
return reflect.DeepEqual(r, Result{})
}

// DocumentMetadata https://www.w3.org/TR/did-core/#did-document-metadata
@@ -38,16 +38,16 @@ func (s *DocumentMetadata) IsValid() bool {
return util.NewValidator().Struct(s) == nil
}

// ResolutionError https://www.w3.org/TR/did-core/#did-resolution-metadata
type ResolutionError struct {
// Error https://www.w3.org/TR/did-core/#did-resolution-metadata
type Error struct {
Code string `json:"code"`
InvalidDID bool `json:"invalidDid"`
NotFound bool `json:"notFound"`
RepresentationNotSupported bool `json:"representationNotSupported"`
}

// ResolutionMetadata https://www.w3.org/TR/did-core/#did-resolution-metadata
type ResolutionMetadata struct {
// Metadata https://www.w3.org/TR/did-core/#did-resolution-metadata
type Metadata struct {
ContentType string
Error *ResolutionError
Error *Error
}
14 changes: 7 additions & 7 deletions did/resolution/resolver.go
Original file line number Diff line number Diff line change
@@ -12,13 +12,13 @@ import (
"github.com/TBD54566975/ssi-sdk/did"
)

// ResolutionOption https://www.w3.org/TR/did-spec-registries/#did-resolution-options
type ResolutionOption any
// Option https://www.w3.org/TR/did-spec-registries/#did-resolution-options
type Option any
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having any makes me sad :(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can fix this when we implement any options
#331


// Resolver provides an interface for resolving DIDs as per the spec https://www.w3.org/TR/did-core/#did-resolution
type Resolver interface {
// Resolve Attempts to resolve a DID for a given method
Resolve(ctx context.Context, id string, opts ...ResolutionOption) (*ResolutionResult, error)
Resolve(ctx context.Context, id string, opts ...Option) (*Result, error)
// Methods returns all methods that can be resolved by this resolution.
Methods() []did.Method
}
@@ -50,7 +50,7 @@ func NewResolver(resolvers ...Resolver) (*MultiMethodResolver, error) {
}

// Resolve attempts to resolve a DID for a given method
func (dr MultiMethodResolver) Resolve(ctx context.Context, id string, opts ...ResolutionOption) (*ResolutionResult, error) {
func (dr MultiMethodResolver) Resolve(ctx context.Context, id string, opts ...Option) (*Result, error) {
method, err := GetMethodForDID(id)
if err != nil {
return nil, errors.Wrap(err, "failed to get method for DID before resolving")
@@ -75,13 +75,13 @@ func GetMethodForDID(id string) (did.Method, error) {
}

// ParseDIDResolution attempts to parse a DID Resolution Result or a DID Document
func ParseDIDResolution(resolvedDID []byte) (*ResolutionResult, error) {
func ParseDIDResolution(resolvedDID []byte) (*Result, error) {
if len(resolvedDID) == 0 {
return nil, errors.New("cannot parse empty resolved DID")
}

// first try to parse as a DID Resolver Result
var result ResolutionResult
var result Result
if err := json.Unmarshal(resolvedDID, &result); err == nil {
if result.IsEmpty() {
return nil, errors.New("empty DID Resolution Result")
@@ -95,7 +95,7 @@ func ParseDIDResolution(resolvedDID []byte) (*ResolutionResult, error) {
if didDoc.IsEmpty() {
return nil, errors.New("empty DID Document")
}
return &ResolutionResult{Document: didDoc}, nil
return &Result{Document: didDoc}, nil
}

// if that fails we don't know what it is!
4 changes: 2 additions & 2 deletions did/web/resolver.go
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ func (Resolver) Methods() []did.Method {

// Resolve fetches and returns the Document from the expected URL
// specification: https://w3c-ccg.github.io/did-method-web/#read-resolve
func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.ResolutionOption) (*resolution.ResolutionResult, error) {
func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Option) (*resolution.Result, error) {
if !strings.HasPrefix(id, WebPrefix) {
return nil, fmt.Errorf("not a did:web DID: %s", id)
}
@@ -30,5 +30,5 @@ func (Resolver) Resolve(_ context.Context, id string, _ ...resolution.Resolution
if err != nil {
return nil, errors.Wrapf(err, "cresolving did:web DID: %s", id)
}
return &resolution.ResolutionResult{Document: *doc}, nil
return &resolution.Result{Document: *doc}, nil
}