diff --git a/did/ion/did.go b/did/ion/did.go index 653a8ddd..740404d3 100644 --- a/did/ion/did.go +++ b/did/ion/did.go @@ -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,11 @@ 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 { + return strings.Count(maybeLongFormDID, ":") == 3 +} + // 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 +107,154 @@ 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 []Patch) (*did.Document, error) { + 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 { + switch patch.GetAction() { + case AddServices: + addServicePatch := patch.(AddServicesAction) + doc.Services = append(doc.Services, addServicePatch.Services...) + case RemoveServices: + removeServicePatch := patch.(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 AddPublicKeys: + addKeyPatch := patch.(AddPublicKeysAction) + gotDoc, err := addPublicKeysPatch(doc, addKeyPatch) + if err != nil { + return nil, err + } + doc = *gotDoc + case RemovePublicKeys: + removeKeyPatch := patch.(RemovePublicKeysAction) + gotDoc, err := removePublicKeysPatch(doc, removeKeyPatch) + if err != nil { + return nil, err + } + doc = *gotDoc + case Replace: + replacePatch := patch.(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 +} + +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 { + continue + } + doc.VerificationMethod = append(doc.VerificationMethod[:i], doc.VerificationMethod[i+1:]...) + 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, a := range doc.Authentication { + if a == id { + doc.Authentication = append(doc.Authentication[:j], doc.Authentication[j+1:]...) + } + } + for j, am := range doc.AssertionMethod { + if am == id { + doc.AssertionMethod = append(doc.AssertionMethod[:j], doc.AssertionMethod[j+1:]...) + } + } + for j, ka := range doc.KeyAgreement { + if ka == id { + doc.KeyAgreement = append(doc.KeyAgreement[:j], doc.KeyAgreement[j+1:]...) + } + } + for j, ci := range doc.CapabilityInvocation { + if ci == id { + doc.CapabilityInvocation = append(doc.CapabilityInvocation[:j], doc.CapabilityInvocation[j+1:]...) + } + } + for j, cd := range doc.CapabilityDelegation { + if cd == id { + doc.CapabilityDelegation = append(doc.CapabilityDelegation[:j], doc.CapabilityDelegation[j+1:]...) + } + } + } + if !removed { + return nil, fmt.Errorf("could not find key with id %s", id) + } + } + return &doc, nil +} diff --git a/did/ion/did_test.go b/did/ion/did_test.go index ea4674ef..e56f7dd2 100644 --- a/did/ion/did_test.go +++ b/did/ion/did_test.go @@ -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,160 @@ 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", "", []Patch{AddPublicKeysAction{}}) + assert.Empty(tt, doc) + assert.Error(tt, err) + assert.ErrorContains(tt, err, "unknown patch type") + }) + + t.Run("No patches", func(tt *testing.T) { + doc, err := PatchesToDIDDocument("did:ion:test", "", []Patch{}) + assert.Empty(tt, doc) + assert.Error(tt, err) + assert.ErrorContains(tt, err, "no patches to apply") + }) + + t.Run("Single patch - add keys", func(tt *testing.T) { + doc, err := PatchesToDIDDocument("did:ion:test", "", []Patch{ + AddPublicKeysAction{ + Action: AddPublicKeys, + 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{ + Action: AddPublicKeys, + PublicKeys: []PublicKey{{ + ID: "did:ion:test#key1", + Purposes: []PublicKeyPurpose{Authentication, AssertionMethod}, + }}, + } + removeKeys := RemovePublicKeysAction{ + Action: RemovePublicKeys, + IDs: []string{"did:ion:test#key2"}, + } + doc, err := PatchesToDIDDocument("did:ion:test", "", []Patch{addKeys, removeKeys}) + assert.Empty(tt, doc) + assert.Error(tt, err) + assert.ErrorContains(tt, err, "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{ + Action: AddPublicKeys, + PublicKeys: []PublicKey{ + { + ID: "did:ion:test#key1", + Purposes: []PublicKeyPurpose{Authentication, AssertionMethod}, + }, + { + ID: "did:ion:test#key2", + Purposes: []PublicKeyPurpose{Authentication, AssertionMethod}, + }, + }, + } + removeKeys := RemovePublicKeysAction{ + Action: RemovePublicKeys, + IDs: []string{"did:ion:test#key2"}, + } + doc, err := PatchesToDIDDocument("did:ion:test", "", []Patch{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{ + Action: AddServices, + 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{ + Action: RemoveServices, + IDs: []string{"did:ion:test#service2"}, + } + doc, err := PatchesToDIDDocument("did:ion:test", "", []Patch{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{ + Action: Replace, + 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", "", []Patch{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) + }) +} diff --git a/did/ion/enum.go b/did/ion/enum.go index 1e61565b..9d88eba0 100644 --- a/did/ion/enum.go +++ b/did/ion/enum.go @@ -33,6 +33,10 @@ const ( Recover OperationType = "recover" ) +type Patch interface { + GetAction() PatchAction +} + type PatchAction string const ( @@ -42,3 +46,7 @@ const ( AddServices PatchAction = "add-services" RemoveServices PatchAction = "remove-services" ) + +func (p PatchAction) String() string { + return string(p) +} diff --git a/did/ion/model.go b/did/ion/model.go index 9e664fb9..fd30c79c 100644 --- a/did/ion/model.go +++ b/did/ion/model.go @@ -1,27 +1,25 @@ package ion import ( + "fmt" + "github.com/TBD54566975/ssi-sdk/crypto/jwx" + "github.com/TBD54566975/ssi-sdk/did" + "github.com/goccy/go-json" + "github.com/pkg/errors" ) // 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 +31,12 @@ 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"` +} + +func (a AddServicesAction) GetAction() PatchAction { + return a.Action } // RemoveServicesAction https://identity.foundation/sidetree/spec/#remove-services @@ -43,24 +45,40 @@ type RemoveServicesAction struct { IDs []string `json:"ids,omitempty"` } +func (a RemoveServicesAction) GetAction() PatchAction { + return a.Action +} + // AddPublicKeysAction https://identity.foundation/sidetree/spec/#add-public-keys type AddPublicKeysAction struct { Action PatchAction `json:"action,omitempty"` PublicKeys []PublicKey `json:"publicKeys,omitempty"` } +func (a AddPublicKeysAction) GetAction() PatchAction { + return a.Action +} + // RemovePublicKeysAction https://identity.foundation/sidetree/spec/#add-public-keys type RemovePublicKeysAction struct { Action PatchAction `json:"action,omitempty"` IDs []string `json:"ids,omitempty"` } +func (a RemovePublicKeysAction) GetAction() PatchAction { + return a.Action +} + // ReplaceAction https://identity.foundation/sidetree/spec/#replace type ReplaceAction struct { Action PatchAction `json:"action,omitempty"` Document Document `json:"document,omitempty"` } +func (a ReplaceAction) GetAction() PatchAction { + return a.Action +} + // request models type AnchorOperation interface { @@ -101,18 +119,85 @@ type UpdateSignedDataObject struct { } type Delta struct { - Patches []any `json:"patches,omitempty"` //revive:disable-line - UpdateCommitment string `json:"updateCommitment,omitempty"` + Patches []Patch `json:"patches,omitempty"` //revive:disable-line + UpdateCommitment string `json:"updateCommitment,omitempty"` +} + +func (d *Delta) UnmarshalJSON(data []byte) error { + var deltaMap map[string]any + if err := json.Unmarshal(data, &deltaMap); err != nil { + return errors.Wrap(err, "unmarshalling patch to generic map") + } + updateCommitment, ok := deltaMap["updateCommitment"].(string) + if !ok { + return fmt.Errorf("no updateCommitment found in delta") + } + d.UpdateCommitment = updateCommitment + allPatches, ok := deltaMap["patches"].([]any) + if !ok { + return fmt.Errorf("no patches found in delta") + } + var patches []Patch + for _, patch := range allPatches { + currPatch, ok := patch.(map[string]any) + if !ok { + return fmt.Errorf("patch is not a map") + } + action, ok := currPatch["action"] + if !ok { + return fmt.Errorf("patch has no action") + } + currPatchBytes, err := json.Marshal(currPatch) + if err != nil { + return errors.Wrap(err, "marshalling patch") + } + switch action { + case Replace.String(): + var ra ReplaceAction + if err := json.Unmarshal(currPatchBytes, &ra); err != nil { + return errors.Wrap(err, "unmarshalling replace action") + } + patches = append(patches, ra) + case AddPublicKeys.String(): + var apa AddPublicKeysAction + if err := json.Unmarshal(currPatchBytes, &apa); err != nil { + return errors.Wrap(err, "unmarshalling add public keys action") + } + patches = append(patches, apa) + case RemovePublicKeys.String(): + var rpa RemovePublicKeysAction + if err := json.Unmarshal(currPatchBytes, &rpa); err != nil { + return errors.Wrap(err, "unmarshalling remove public keys action") + } + patches = append(patches, rpa) + case AddServices.String(): + var asa AddServicesAction + if err := json.Unmarshal(currPatchBytes, &asa); err != nil { + return errors.Wrap(err, "unmarshalling add services action") + } + patches = append(patches, asa) + case RemoveServices.String(): + var rsa RemoveServicesAction + if err := json.Unmarshal(currPatchBytes, &rsa); err != nil { + return errors.Wrap(err, "unmarshalling remove services action") + } + patches = append(patches, rsa) + default: + return fmt.Errorf("unknown patch action: %s", action) + } + } + d.Patches = patches + return nil } func NewDelta(updateCommitment string) Delta { return Delta{ - Patches: make([]any, 0), + Patches: make([]Patch, 0), UpdateCommitment: updateCommitment, } } -func (d *Delta) GetPatches() []any { +func (d *Delta) GetPatches() []Patch { return d.Patches } diff --git a/did/ion/operations.go b/did/ion/operations.go index fcaa64f1..17812039 100644 --- a/did/ion/operations.go +++ b/did/ion/operations.go @@ -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 diff --git a/did/ion/operations_test.go b/did/ion/operations_test.go index 0859f3ad..91b2da8d 100644 --- a/did/ion/operations_test.go +++ b/did/ion/operations_test.go @@ -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,8 +220,8 @@ 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", @@ -195,11 +229,11 @@ func TestRequests(t *testing.T) { }, }) 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) @@ -207,8 +241,8 @@ func TestRequests(t *testing.T) { }) 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", @@ -216,18 +250,18 @@ func TestRequests(t *testing.T) { }, }) 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) @@ -235,20 +269,20 @@ func TestRequests(t *testing.T) { // 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", @@ -256,10 +290,10 @@ func TestRequests(t *testing.T) { }, }) 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) }) } diff --git a/did/ion/request.go b/did/ion/request.go index 0e8f5a51..70f5044f 100644 --- a/did/ion/request.go +++ b/did/ion/request.go @@ -2,6 +2,7 @@ package ion import ( "github.com/TBD54566975/ssi-sdk/crypto/jwx" + "github.com/TBD54566975/ssi-sdk/did" "github.com/pkg/errors" ) @@ -13,7 +14,7 @@ const ( // NewCreateRequest creates a new create request https://identity.foundation/sidetree/spec/#create func NewCreateRequest(recoveryKey, updateKey jwx.PublicKeyJWK, document Document) (*CreateRequest, error) { // prepare delta - replaceActionPatch := ReplaceAction{ + replacePatch := ReplaceAction{ Action: Replace, Document: document, } @@ -22,7 +23,7 @@ func NewCreateRequest(recoveryKey, updateKey jwx.PublicKeyJWK, document Document return nil, err } delta := NewDelta(updateCommitment) - delta.AddReplaceAction(replaceActionPatch) + delta.AddReplaceAction(replacePatch) // prepare suffix data deltaCanonical, err := CanonicalizeAny(delta) @@ -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) diff --git a/did/ion/request_test.go b/did/ion/request_test.go index a4d904c5..08a10928 100644 --- a/did/ion/request_test.go +++ b/did/ion/request_test.go @@ -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) diff --git a/did/ion/resolver.go b/did/ion/resolver.go new file mode 100644 index 00000000..d4ff7e7f --- /dev/null +++ b/did/ion/resolver.go @@ -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} +} diff --git a/did/jwk/resolver.go b/did/jwk/resolver.go index 98a34874..9d4c6801 100644 --- a/did/jwk/resolver.go +++ b/did/jwk/resolver.go @@ -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 { diff --git a/did/key/resolver.go b/did/key/resolver.go index faa67b27..b1712509 100644 --- a/did/key/resolver.go +++ b/did/key/resolver.go @@ -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 { diff --git a/did/peer/peer.go b/did/peer/peer.go index 2c1a1e91..54e010a8 100644 --- a/did/peer/peer.go +++ b/did/peer/peer.go @@ -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) } diff --git a/did/peer/peer0.go b/did/peer/peer0.go index 90b9d216..0961d462 100644 --- a/did/peer/peer0.go +++ b/did/peer/peer0.go @@ -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 } diff --git a/did/peer/peer2.go b/did/peer/peer2.go index ebd1600f..2d081b81 100644 --- a/did/peer/peer2.go +++ b/did/peer/peer2.go @@ -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 diff --git a/did/peer/resolver.go b/did/peer/resolver.go index 804b9e43..68bcab2e 100644 --- a/did/peer/resolver.go +++ b/did/peer/resolver.go @@ -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) } diff --git a/did/pkh/resolver.go b/did/pkh/resolver.go index 27862177..8513588e 100644 --- a/did/pkh/resolver.go +++ b/did/pkh/resolver.go @@ -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 { diff --git a/did/resolution/model.go b/did/resolution/model.go index b32da025..6e0112b8 100644 --- a/did/resolution/model.go +++ b/did/resolution/model.go @@ -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 } diff --git a/did/resolution/resolver.go b/did/resolution/resolver.go index 0971ed36..d1999ab5 100644 --- a/did/resolution/resolver.go +++ b/did/resolution/resolver.go @@ -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 // 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! diff --git a/did/web/resolver.go b/did/web/resolver.go index 9e37475a..ee2e2bfc 100644 --- a/did/web/resolver.go +++ b/did/web/resolver.go @@ -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 } diff --git a/go.work.sum b/go.work.sum index 04ef240f..1cc4b388 100644 --- a/go.work.sum +++ b/go.work.sum @@ -3,7 +3,6 @@ github.com/PaesslerAG/gval v1.1.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= github.com/TBD54566975/ssi-sdk v0.0.3-alpha/go.mod h1:x7Ixj1VyoUbJ5d36xqgh+fJ1Ym+lT0KYzgI9qclPpIc= -github.com/TBD54566975/ssi-sdk v0.0.4-alpha/go.mod h1:O4iANflxGCX0NbjHOhthq0X0il2ZYNMYlUnjEa0rsC0= github.com/VictoriaMetrics/fastcache v1.5.7/go.mod h1:ptDBkNMQI4RtmVo8VS/XwRY6RoTu1dAWCbrk+6WsEM8= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= @@ -100,15 +99,17 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=