diff --git a/doc/swagger.yaml b/doc/swagger.yaml index 9debc12fa..0d8e6a02a 100644 --- a/doc/swagger.yaml +++ b/doc/swagger.yaml @@ -2893,11 +2893,23 @@ paths: delete: consumes: - application/json - description: |- - When this is called with the correct did method and id it will flip the softDelete flag to true for the db entry. - A user can still get the did if they know the DID ID, and the did keys will still exist, but this did will not show up in the ListDIDsByMethod call - This facilitates a clean SSI-Service Admin UI but not leave any hanging VCs with inaccessible hanging DIDs. - Soft deletes a DID by its method + description: "Soft deletes and deactivates (when applicable) a DID for which + SSI is the custodian. The DID must have\nbeen previously created by calling\tthe + \"Create DID Document\" endpoint. The effects of Deleting a DID depend on + it's DID Method.\n\nWhen this is called, it will flip the `softDelete` flag + to true for the db entry.\nA user can still get the did if they know the DID + ID, and the did keys will still exist, but this did will not show up in the + ListDIDsByMethod call\nThis facilitates a clean SSI-Service Admin UI but not + leave any hanging VCs with inaccessible hanging DIDs.\n\nFor a DID who's DID + Method is `ion`, deactivation is also performed. The effects of deactivating + a DID include:\n\n* The `didDocumentMetadata.deactivated` property will be + set to `true` after\ndoing DID resolution (e.g. by calling the `v1/dids/resolution/` + endpoint).\n* All the DID Document properties will be removed, except for + the `id` and `@context`. In practical terms, this\nmeans that no counterparty + will be able to obtain verification material from this DID.\n* All keys stored + by SSI service that are related to this DID (i.e. update, recovery, verification) + will be revoked.\n\nPlease note that deactivation of an `ion` DID is an irreversible + operation. For more details, refer to the sidetree spec at https://identity.foundation/sidetree/spec/#deactivate" parameters: - description: Method in: path @@ -2924,7 +2936,7 @@ paths: description: Internal server error schema: type: string - summary: Soft delete a DID + summary: Deletes a DID tags: - DecentralizedIdentifiers get: diff --git a/pkg/server/router/did.go b/pkg/server/router/did.go index 63ccd9d8d..8f125b39a 100644 --- a/pkg/server/router/did.go +++ b/pkg/server/router/did.go @@ -203,7 +203,6 @@ func (dr DIDRouter) UpdateDIDByMethod(c *gin.Context) { resp := CreateDIDByMethodResponse{DID: updateIONDIDResponse.DID} framework.Respond(c, resp, http.StatusOK) - } func toUpdateIONDIDRequest(id string, request UpdateDIDByMethodRequest) (*did.UpdateIONDIDRequest, error) { @@ -399,13 +398,25 @@ type ResolveDIDResponse struct { DIDDocumentMetadata *resolution.DocumentMetadata `json:"didDocumentMetadata,omitempty"` } -// SoftDeleteDIDByMethod godoc +// DeleteDIDByMethod godoc // -// @Description When this is called with the correct did method and id it will flip the softDelete flag to true for the db entry. +// @Summary Deletes a DID +// @Description Soft deletes and deactivates (when applicable) a DID for which SSI is the custodian. The DID must have +// @Description been previously created by calling the "Create DID Document" endpoint. The effects of Deleting a DID depend on it's DID Method. +// @Description +// @Description When this is called, it will flip the `softDelete` flag to true for the db entry. // @Description A user can still get the did if they know the DID ID, and the did keys will still exist, but this did will not show up in the ListDIDsByMethod call // @Description This facilitates a clean SSI-Service Admin UI but not leave any hanging VCs with inaccessible hanging DIDs. -// @Summary Soft delete a DID -// @Description Soft deletes a DID by its method +// @Description +// @Description For a DID who's DID Method is `ion`, deactivation is also performed. The effects of deactivating a DID include: +// @Description +// @Description * The `didDocumentMetadata.deactivated` property will be set to `true` after +// @Description doing DID resolution (e.g. by calling the `v1/dids/resolution/` endpoint). +// @Description * All the DID Document properties will be removed, except for the `id` and `@context`. In practical terms, this +// @Description means that no counterparty will be able to obtain verification material from this DID. +// @Description * All keys stored by SSI service that are related to this DID (i.e. update, recovery, verification) will be revoked. +// @Description +// @Description Please note that deactivation of an `ion` DID is an irreversible operation. For more details, refer to the sidetree spec at https://identity.foundation/sidetree/spec/#deactivate // @Tags DecentralizedIdentifiers // @Accept json // @Produce json @@ -415,7 +426,7 @@ type ResolveDIDResponse struct { // @Failure 400 {string} string "Bad request" // @Failure 500 {string} string "Internal server error" // @Router /v1/dids/{method}/{id} [delete] -func (dr DIDRouter) SoftDeleteDIDByMethod(c *gin.Context) { +func (dr DIDRouter) DeleteDIDByMethod(c *gin.Context) { method := framework.GetParam(c, MethodParam) if method == nil { errMsg := "soft delete DID by method request missing method parameter" @@ -430,7 +441,7 @@ func (dr DIDRouter) SoftDeleteDIDByMethod(c *gin.Context) { } deleteDIDRequest := did.DeleteDIDRequest{Method: didsdk.Method(*method), ID: *id} - if err := dr.service.SoftDeleteDIDByMethod(c, deleteDIDRequest); err != nil { + if err := dr.service.DeleteDIDByMethod(c, deleteDIDRequest); err != nil { errMsg := fmt.Sprintf("could not soft delete DID with id: %s", *id) framework.LoggingRespondErrWithMsg(c, err, errMsg, http.StatusInternalServerError) return diff --git a/pkg/server/router/did_test.go b/pkg/server/router/did_test.go index 41f5f18a6..f3edfbc71 100644 --- a/pkg/server/router/did_test.go +++ b/pkg/server/router/did_test.go @@ -145,10 +145,10 @@ func TestDIDRouter(t *testing.T) { assert.Len(tt, knownDIDs, 0) // delete dids - err = didService.SoftDeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.KeyMethod, ID: createDIDResponse.DID.ID}) + err = didService.DeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.KeyMethod, ID: createDIDResponse.DID.ID}) assert.NoError(tt, err) - err = didService.SoftDeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.KeyMethod, ID: createDIDResponse2.DID.ID}) + err = didService.DeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.KeyMethod, ID: createDIDResponse2.DID.ID}) assert.NoError(tt, err) // get all DIDs back @@ -248,10 +248,10 @@ func TestDIDRouter(t *testing.T) { assert.Len(tt, knownDIDs, 0) // delete dids - err = didService.SoftDeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.WebMethod, ID: createDIDResponse.DID.ID}) + err = didService.DeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.WebMethod, ID: createDIDResponse.DID.ID}) assert.NoError(tt, err) - err = didService.SoftDeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.WebMethod, ID: createDIDResponse2.DID.ID}) + err = didService.DeleteDIDByMethod(context.Background(), did.DeleteDIDRequest{Method: didsdk.WebMethod, ID: createDIDResponse2.DID.ID}) assert.NoError(tt, err) // get all DIDs back diff --git a/pkg/server/server.go b/pkg/server/server.go index b4d7dd9b0..f8d891d51 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -177,7 +177,7 @@ func DecentralizedIdentityAPI(rg *gin.RouterGroup, service *didsvc.Service, did didAPI.PUT("/:method/batch", middleware.Webhook(webhookService, webhook.DID, webhook.BatchCreate), batchDIDRouter.BatchCreateDIDs) didAPI.GET("/:method", didRouter.ListDIDsByMethod) didAPI.GET("/:method/:id", didRouter.GetDIDByMethod) - didAPI.DELETE("/:method/:id", didRouter.SoftDeleteDIDByMethod) + didAPI.DELETE("/:method/:id", didRouter.DeleteDIDByMethod) didAPI.GET(ResolverPrefix+"/:id", didRouter.ResolveDID) return } diff --git a/pkg/server/server_did_test.go b/pkg/server/server_did_test.go index 32c71c23e..8f9161699 100644 --- a/pkg/server/server_did_test.go +++ b/pkg/server/server_did_test.go @@ -374,7 +374,71 @@ func TestDIDAPI(t *testing.T) { assert.Len(tt, updateDIDResponse.DID.KeyAgreement, 1+len(createDIDResponse.DID.KeyAgreement)) assert.Len(tt, updateDIDResponse.DID.CapabilityInvocation, 0+len(createDIDResponse.DID.CapabilityInvocation)) assert.Len(tt, updateDIDResponse.DID.CapabilityInvocation, 0+len(createDIDResponse.DID.CapabilityInvocation)) + }) + + t.Run("Create, deactivate, and resolve", func(tt *testing.T) { + // setup + db := test.ServiceStorage(t) + require.NotEmpty(tt, db) + + _, keyStoreService, keyStoreServiceFactory := testKeyStore(tt, db) + didService, _ := testDIDRouter(tt, db, keyStoreService, []string{"ion"}, keyStoreServiceFactory) + + params := map[string]string{ + "method": "ion", + } + w := httptest.NewRecorder() + + gock.New(testIONResolverURL). + Post("/operations"). + Reply(200). + JSON(string(BasicDIDResolution)) + defer gock.Off() + + // create the did + createDIDRequest := router.CreateDIDByMethodRequest{KeyType: crypto.Ed25519} + requestReader := newRequestValue(tt, createDIDRequest) + req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/dids/ion", requestReader) + + c := newRequestContextWithParams(w, req, params) + didService.CreateDIDByMethod(c) + assert.True(tt, util.Is2xxResponse(w.Code)) + + var createDIDResponse router.CreateDIDByMethodResponse + err := json.NewDecoder(w.Body).Decode(&createDIDResponse) + assert.NoError(tt, err) + + // delete and deactivate it + w = httptest.NewRecorder() + params["id"] = createDIDResponse.DID.ID + requestReader = newRequestValue(tt, nil) + req = httptest.NewRequest(http.MethodDelete, "https://ssi-service.com/v1/dids/ion/"+createDIDResponse.DID.ID, requestReader) + + gock.New(testIONResolverURL). + Post("/operations"). + Reply(200). + JSON("{}") + defer gock.Off() + + c = newRequestContextWithParams(w, req, params) + didService.DeleteDIDByMethod(c) + assert.True(tt, util.Is2xxResponse(w.Code)) + + // And resolve it + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/dids/resolver/"+createDIDResponse.DID.ID, nil) + c = newRequestContextWithParams(w, req, params) + didService.ResolveDID(c) + assert.True(tt, util.Is2xxResponse(w.Code)) + + var resolveDIDResponse router.ResolveDIDResponse + err = json.NewDecoder(w.Body).Decode(&resolveDIDResponse) + assert.NoError(tt, err) + // verify that the resolution came through correctly + assert.True(tt, resolveDIDResponse.DIDDocumentMetadata.Deactivated) + assert.False(tt, resolveDIDResponse.DIDDocumentMetadata.Method.Published) + assert.Equal(tt, createDIDResponse.DID.ID, resolveDIDResponse.DIDDocumentMetadata.CanonicalID) }) t.Run("Test Create Duplicate DID:Webs", func(tt *testing.T) { @@ -525,7 +589,7 @@ func TestDIDAPI(t *testing.T) { } c := newRequestContextWithParams(w, req, badParams) - didService.SoftDeleteDIDByMethod(c) + didService.DeleteDIDByMethod(c) assert.Contains(tt, w.Body.String(), "could not soft delete DID") // good method, bad id @@ -535,7 +599,7 @@ func TestDIDAPI(t *testing.T) { } w = httptest.NewRecorder() c = newRequestContextWithParams(w, req, badParams1) - didService.SoftDeleteDIDByMethod(c) + didService.DeleteDIDByMethod(c) assert.Contains(tt, w.Body.String(), "could not soft delete DID with id: worse: error getting DID: worse") // store a DID @@ -591,7 +655,7 @@ func TestDIDAPI(t *testing.T) { w = httptest.NewRecorder() c = newRequestContextWithParams(w, req, goodParams) - didService.SoftDeleteDIDByMethod(c) + didService.DeleteDIDByMethod(c) assert.True(tt, util.Is2xxResponse(w.Code)) // get it back diff --git a/pkg/service/did/common.go b/pkg/service/did/common.go new file mode 100644 index 000000000..835cb880d --- /dev/null +++ b/pkg/service/did/common.go @@ -0,0 +1,41 @@ +package did + +import ( + "context" + "fmt" + "time" + + "github.com/TBD54566975/ssi-sdk/did/resolution" + "github.com/sirupsen/logrus" +) + +func resolve(ctx context.Context, id string, storage *Storage) (*resolution.Result, error) { + gotDID, err := storage.GetDIDDefault(ctx, id) + if err != nil { + return nil, fmt.Errorf("error getting DID: %s", id) + } + if gotDID == nil { + return nil, fmt.Errorf("did with id<%s> could not be found", id) + } + + createdAt, err := time.Parse(time.RFC3339, gotDID.CreatedAt) + if err != nil { + logrus.WithError(err).Errorf("parsing created at") + } + updatedAt, err := time.Parse(time.RFC3339, gotDID.UpdatedAt) + if err != nil { + logrus.WithError(err).Errorf("parsing created at") + } + + const XMLFormat = "2006-01-02T15:04:05Z" + + return &resolution.Result{ + Context: "https://w3id.org/did-resolution/v1", + Document: gotDID.DID, + DocumentMetadata: &resolution.DocumentMetadata{ + Created: createdAt.Format(XMLFormat), + Updated: updatedAt.Format(XMLFormat), + Deactivated: gotDID.SoftDeleted, + }, + }, nil +} diff --git a/pkg/service/did/handler.go b/pkg/service/did/handler.go index e37499a99..1aea8c709 100644 --- a/pkg/service/did/handler.go +++ b/pkg/service/did/handler.go @@ -30,8 +30,11 @@ type MethodHandler interface { // ListDeletedDIDs returns all soft-deleted DIDs. ListDeletedDIDs(ctx context.Context) (*ListDIDsResponse, error) - // SoftDeleteDID marks the given DID as deleted. It is not removed from storage. - SoftDeleteDID(ctx context.Context, request DeleteDIDRequest) error + // DeleteDID marks DIDs as deleted, and should do a reasonable effort to Delete. For instance, a DID ION would be deactivated. + DeleteDID(ctx context.Context, request DeleteDIDRequest) (*DeleteDIDResponse, error) + + // Resolve returns the resolution result of the given DID according to https://w3c-ccg.github.io/did-resolution/#did-resolution-result. + Resolve(ctx context.Context, did string) (*resolution.Result, error) } // NewHandlerResolver creates a new HandlerResolver from a map of MethodHandlers which are used to resolve DIDs @@ -62,23 +65,7 @@ type handlerResolver struct { } func (h handlerResolver) Resolve(ctx context.Context, did string, _ ...resolution.Option) (*resolution.Result, error) { - method, err := resolution.GetMethodForDID(did) - if err != nil { - return nil, errors.Wrap(err, "getting method from DID") - } - - if method != h.method { - return nil, errors.Errorf("invalid method %s for handler %s", method, h.method) - } - - gotDIDResponse, err := h.handler.GetDID(ctx, GetDIDRequest{ - Method: h.method, - ID: did, - }) - if err != nil { - return nil, errors.Wrap(err, "getting DID from handler") - } - return &resolution.Result{Document: gotDIDResponse.DID}, nil + return h.handler.Resolve(ctx, did) } func (h handlerResolver) Methods() []didsdk.Method { diff --git a/pkg/service/did/ion.go b/pkg/service/did/ion.go index ceb83d26a..2b48292ce 100644 --- a/pkg/service/did/ion.go +++ b/pkg/service/did/ion.go @@ -9,6 +9,7 @@ import ( "github.com/TBD54566975/ssi-sdk/crypto/jwx" "github.com/TBD54566975/ssi-sdk/did" "github.com/TBD54566975/ssi-sdk/did/ion" + "github.com/TBD54566975/ssi-sdk/did/resolution" "github.com/TBD54566975/ssi-sdk/util" "github.com/goccy/go-json" "github.com/google/uuid" @@ -60,6 +61,63 @@ type ionHandler struct { didStorageFactory StorageFactory } +func (h *ionHandler) Resolve(ctx context.Context, did string) (*resolution.Result, error) { + method, err := resolution.GetMethodForDID(did) + if err != nil { + return nil, errors.Wrap(err, "getting method from DID") + } + + if method != h.method { + return nil, errors.Errorf("invalid method %s for handler %s", method, h.method) + } + + gotDID := new(ionStoredDID) + if err := h.storage.GetDID(ctx, did, gotDID); err == nil { + return newResolutionResult(did, gotDID.DID, gotDID.Deactivated, gotDID.Published), nil + } + + result, err := h.resolver.Resolve(ctx, did) + if err != nil { + return nil, errors.Wrap(err, "resolving DID on network") + } + + return result, nil +} + +func newResolutionResult(did string, document did.Document, deactivated bool, published bool) *resolution.Result { + isLongForm := ion.IsLongFormDID(did) + documentMetadata := &resolution.DocumentMetadata{ + Deactivated: deactivated, + Method: resolution.Method{ + Published: published, + }, + } + if isLongForm { + if !published { + documentMetadata.CanonicalID = "" + + } else { + shortFormDID, _, _ := ion.DecodeLongFormDID(did) + documentMetadata.CanonicalID = shortFormDID + } + } else { + documentMetadata.CanonicalID = did + } + + if documentMetadata.CanonicalID != "" { + if isLongForm { + shortFormDID, _, _ := ion.DecodeLongFormDID(did) + documentMetadata.EquivalentID = []string{shortFormDID} + } + } + return &resolution.Result{ + Context: "https://w3id.org/did-resolution/v1", + Metadata: resolution.Metadata{}, + Document: document, + DocumentMetadata: documentMetadata, + } +} + // Verify interface compliance https://github.com/uber-go/guide/blob/master/style.md#verify-interface-compliance var _ MethodHandler = (*ionHandler)(nil) @@ -85,11 +143,14 @@ func (h *ionHandler) GetMethod() did.Method { } type ionStoredDID struct { - ID string `json:"id"` - DID did.Document `json:"did"` - SoftDeleted bool `json:"softDeleted"` - LongFormDID string `json:"longFormDID"` - Operations []any `json:"operations"` + ID string `json:"id"` + DID did.Document `json:"did"` + SoftDeleted bool `json:"softDeleted"` + LongFormDID string `json:"longFormDID"` + Operations []any `json:"operations"` + Deactivated bool `json:"deactivated"` + DeactivationResponse *resolution.Result `json:"deactivationResponse"` + Published bool `json:"published"` } func (i ionStoredDID) GetID() string { @@ -280,7 +341,7 @@ func (h *ionHandler) prepareUpdate(request UpdateIONDIDRequest) func(ctx context return nil, errors.Wrap(err, "getting ion did from storage") } - updatedLongForm, updatedDIDDoc, err := updateLongForm(request.DID.String(), storedDID.LongFormDID, updateOp) + updatedLongForm, updatedDIDDoc, err := updateLongForm(request.DID.String(), storedDID.LongFormDID, updateOp.Delta) if err != nil { return nil, err } @@ -309,15 +370,15 @@ func (h *ionHandler) prepareUpdate(request UpdateIONDIDRequest) func(ctx context } } -func updateLongForm(shortFormDID string, longFormDID string, updateOp *ion.UpdateRequest) (string, *did.Document, error) { +func updateLongForm(shortFormDID, longFormDID string, opDelta ion.Delta) (string, *did.Document, error) { _, initialState, err := ion.DecodeLongFormDID(longFormDID) if err != nil { return "", nil, errors.Wrap(err, "invalid long form DID") } delta := ion.Delta{ - Patches: append(initialState.Delta.Patches, updateOp.Delta.GetPatches()...), - UpdateCommitment: updateOp.Delta.UpdateCommitment, + Patches: append(initialState.Delta.Patches, opDelta.GetPatches()...), + UpdateCommitment: opDelta.UpdateCommitment, } suffixData := initialState.SuffixData createRequest := ion.CreateRequest{ @@ -570,37 +631,125 @@ func (h *ionHandler) ListDeletedDIDs(ctx context.Context) (*ListDIDsResponse, er } // SoftDeleteDID soft deletes a DID from storage but has no effect on the DID's state on the network -func (h *ionHandler) SoftDeleteDID(ctx context.Context, request DeleteDIDRequest) error { - logrus.Debugf("soft deleting DID: %+v", request) +func (h *ionHandler) DeleteDID(ctx context.Context, request DeleteDIDRequest) (*DeleteDIDResponse, error) { + ionDID := ion.ION(request.ID) + storedDID := new(ionStoredDID) + if err := h.storage.GetDID(ctx, ionDID.String(), storedDID); err != nil { + return nil, errors.Wrap(err, "getting ion did from storage") + } - id := request.ID - gotDID := new(ionStoredDID) - if err := h.storage.GetDID(ctx, id, gotDID); err != nil { - return fmt.Errorf("error getting DID: %s", id) + if storedDID.Deactivated { + return &DeleteDIDResponse{Result: storedDID.DeactivationResponse}, nil } - if gotDID.GetID() == "" { - return fmt.Errorf("did with id<%s> could not be found", id) + + privateUpdateJWK, err := h.readUpdatePrivateKey(ctx, ionDID.String()) + if err != nil { + return nil, err } + privateRecoveryJWK, err := h.readRecoveryPrivateKey(ctx, ionDID.String()) + if err != nil { + return nil, err + } + + signer, err := ion.NewBTCSignerVerifier(*privateUpdateJWK) + if err != nil { + return nil, errors.Wrap(err, "creating signer") + } + + suffix, err := ionDID.Suffix() + if err != nil { + return nil, errors.Wrap(err, "getting suffix") + } + deactivateRequest, err := ion.NewDeactivateRequest(suffix, privateRecoveryJWK.ToPublicKeyJWK(), *signer) + if err != nil { + return nil, errors.Wrap(err, "creating deactivate request") + } + + if _, err := h.resolver.Anchor(ctx, deactivateRequest); err != nil { + return nil, errors.Wrap(err, "anchoring deactivate operation") + } + + const deactivateRequestNamespace = "deactivate-request" + deactivateRequestKey := ionDID.String() + watchKeys := []storage.WatchKey{ + { + Namespace: deactivateRequestNamespace, + Key: deactivateRequestKey, + }, + } + deactivatedDID, err := h.storage.db.Execute(ctx, func(ctx context.Context, tx storage.Tx) (any, error) { + kidToDelete := did.FullyQualifiedVerificationMethodID(storedDID.ID, storedDID.DID.VerificationMethod[0].ID) + if storedDID.Deactivated { + return storedDID, nil + } + storedDID.Operations = append(storedDID.Operations, deactivateRequest) + storedDID.DID.Controller = "" + storedDID.DID.AlsoKnownAs = "" + storedDID.DID.VerificationMethod = nil + storedDID.DID.Authentication = nil + storedDID.DID.AssertionMethod = nil + storedDID.DID.KeyAgreement = nil + storedDID.DID.CapabilityInvocation = nil + storedDID.DID.CapabilityDelegation = nil + storedDID.DID.Services = nil + storedDID.Deactivated = true + storedDID.SoftDeleted = true + storedDID.DeactivationResponse = newResolutionResult(storedDID.DID.ID, storedDID.DID, storedDID.Deactivated, storedDID.Published) + + didStorage, err := h.didStorageFactory(tx) + if err != nil { + return nil, errors.Wrap(err, "creating did storage") + } + if err := didStorage.StoreDID(ctx, storedDID); err != nil { + return nil, errors.Wrap(err, "storing DID in storage") + } + + keyStore, err := h.keyStoreFactory(tx) + if err != nil { + return nil, errors.Wrap(err, "creating keystore service") + } + if err := keyStore.RevokeKey(ctx, keystore.RevokeKeyRequest{ID: updateKeyID(storedDID.ID)}); err != nil { + return nil, errors.Wrap(err, "revoking update key") + } + if err := keyStore.RevokeKey(ctx, keystore.RevokeKeyRequest{ID: recoveryKeyID(storedDID.ID)}); err != nil { + return nil, errors.Wrap(err, "revoking recovery key") + } + if err := keyStore.RevokeKey(ctx, keystore.RevokeKeyRequest{ID: kidToDelete}); err != nil { + return nil, errors.Wrap(err, "revoking recovery key") + } + return storedDID, nil + }, watchKeys) - gotDID.SoftDeleted = true + if err != nil { + return nil, errors.Wrap(err, "executing deactivation") + } - return h.storage.StoreDID(ctx, *gotDID) + return &DeleteDIDResponse{Result: deactivatedDID.(*ionStoredDID).DeactivationResponse}, nil } func (h *ionHandler) readUpdatePrivateKey(ctx context.Context, did string) (*jwx.PrivateKeyJWK, error) { keyID := updateKeyID(did) + return h.readPrivateKey(ctx, keyID) +} + +func (h *ionHandler) readPrivateKey(ctx context.Context, keyID string) (*jwx.PrivateKeyJWK, error) { getKeyRequest := keystore.GetKeyRequest{ID: keyID} key, err := h.keyStore.GetKey(ctx, getKeyRequest) if err != nil { - return nil, errors.Wrap(err, "fetching update private key") + return nil, errors.Wrap(err, "fetching private key") } _, privateJWK, err := jwx.PrivateKeyToPrivateKeyJWK(keyID, key.Key) if err != nil { - return nil, errors.Wrap(err, "getting update private key") + return nil, errors.Wrap(err, "getting private key") } return privateJWK, err } +func (h *ionHandler) readRecoveryPrivateKey(ctx context.Context, did string) (*jwx.PrivateKeyJWK, error) { + keyID := recoveryKeyID(did) + return h.readPrivateKey(ctx, keyID) +} + func updateKeyID(did string) string { return did + "#" + updateKeySuffix } diff --git a/pkg/service/did/ion_test.go b/pkg/service/did/ion_test.go index f9385748e..846aa11c6 100644 --- a/pkg/service/did/ion_test.go +++ b/pkg/service/did/ion_test.go @@ -13,6 +13,7 @@ import ( "github.com/goccy/go-json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tbd54566975/ssi-service/internal/encryption" "gopkg.in/h2non/gock.v1" "github.com/tbd54566975/ssi-service/config" @@ -34,7 +35,7 @@ func TestIONHandler(t *testing.T) { assert.Contains(tt, err.Error(), "baseURL cannot be empty") s := test.ServiceStorage(tt) - keystoreService := testKeyStoreService(tt, s) + keystoreService, _ := testKeyStoreService(tt, s) didStorage, err := NewDIDStorage(s) assert.NoError(tt, err) handler, err = NewIONHandler("bad", nil, keystoreService, nil, nil) @@ -62,7 +63,7 @@ func TestIONHandler(t *testing.T) { t.Run("Create DID", func(tt *testing.T) { // create a handler s := test.ServiceStorage(tt) - keystoreService := testKeyStoreService(tt, s) + keystoreService, _ := testKeyStoreService(tt, s) didStorage, err := NewDIDStorage(s) assert.NoError(tt, err) handler, err := NewIONHandler("https://test-ion-resolver.com", didStorage, keystoreService, nil, nil) @@ -134,7 +135,7 @@ func TestIONHandler(t *testing.T) { // create a handler s := test.ServiceStorage(tt) - keystoreService := testKeyStoreService(tt, s) + keystoreService, _ := testKeyStoreService(tt, s) didStorage, err := NewDIDStorage(s) assert.NoError(tt, err) handler, err := NewIONHandler("https://ion.tbddev.org", didStorage, keystoreService, nil, nil) @@ -161,7 +162,7 @@ func TestIONHandler(t *testing.T) { t.Run("Get DID from storage", func(tt *testing.T) { // create a handler s := test.ServiceStorage(tt) - keystoreService := testKeyStoreService(tt, s) + keystoreService, _ := testKeyStoreService(tt, s) didStorage, err := NewDIDStorage(s) assert.NoError(tt, err) handler, err := NewIONHandler("https://test-ion-resolver.com", didStorage, keystoreService, nil, nil) @@ -200,10 +201,11 @@ func TestIONHandler(t *testing.T) { t.Run("Get DIDs from storage", func(tt *testing.T) { // create a handler s := test.ServiceStorage(tt) - keystoreService := testKeyStoreService(tt, s) - didStorage, err := NewDIDStorage(s) + keystoreService, keystoreServiceFactory := testKeyStoreService(tt, s) + didStorageFactory := NewDIDStorageFactory(s) + didStorage, err := didStorageFactory(s) assert.NoError(tt, err) - handler, err := NewIONHandler("https://test-ion-resolver.com", didStorage, keystoreService, nil, nil) + handler, err := NewIONHandler("https://test-ion-resolver.com", didStorage, keystoreService, keystoreServiceFactory, didStorageFactory) assert.NoError(tt, err) assert.NotEmpty(tt, handler) @@ -236,7 +238,12 @@ func TestIONHandler(t *testing.T) { assert.Len(tt, gotDIDs.DIDs, 1) // delete a did - err = handler.SoftDeleteDID(context.Background(), DeleteDIDRequest{ + gock.New("https://test-ion-resolver.com"). + Post("/operations"). + Reply(200). + JSON(`{}`) + defer gock.Off() + _, err = handler.DeleteDID(context.Background(), DeleteDIDRequest{ Method: did.IONMethod, ID: created.DID.ID, }) @@ -258,7 +265,7 @@ func TestIONHandler(t *testing.T) { t.Run("Get DID from resolver", func(tt *testing.T) { // create a handler s := test.ServiceStorage(tt) - keystoreService := testKeyStoreService(tt, s) + keystoreService, _ := testKeyStoreService(tt, s) didStorage, err := NewDIDStorage(s) assert.NoError(tt, err) handler, err := NewIONHandler("https://test-ion-resolver.com", didStorage, keystoreService, nil, nil) @@ -280,16 +287,80 @@ func TestIONHandler(t *testing.T) { assert.NotEmpty(tt, gotDID) assert.Equal(tt, "did:ion:test", gotDID.DID.ID) }) + + t.Run("Deactivate DID", func(tt *testing.T) { + s := test.ServiceStorage(tt) + keystoreService, keystoreServiceFactory := testKeyStoreService(tt, s) + didStorageFactory := NewDIDStorageFactory(s) + didStorage, err := didStorageFactory(s) + assert.NoError(tt, err) + handler, err := NewIONHandler("https://test-ion-resolver.com", didStorage, keystoreService, keystoreServiceFactory, didStorageFactory) + assert.NoError(tt, err) + assert.NotEmpty(tt, handler) + + gock.New("https://test-ion-resolver.com"). + Post("/operations"). + Reply(200). + BodyString(string(BasicDIDResolution)) + defer gock.Off() + + createDIDRequest := CreateDIDRequest{ + Method: did.IONMethod, + KeyType: crypto.Ed25519, + } + created, err := handler.CreateDID(context.Background(), createDIDRequest) + assert.NoError(tt, err) + assert.NotEmpty(tt, created) + + gock.New("https://test-ion-resolver.com"). + Post("/operations"). + Reply(200). + JSON(`{}`) + defer gock.Off() + + request := DeleteDIDRequest{ + ID: created.DID.ID, + } + resp, err := handler.(*ionHandler).DeleteDID(context.Background(), request) + + assert.NoError(tt, err) + assert.NotEmpty(tt, resp) + assert.Equal(tt, created.DID.ID, resp.Result.Document.ID) + assert.Empty(tt, resp.Result.Document.VerificationMethod) + assert.Empty(tt, resp.Result.Document.Authentication) + assert.Empty(tt, resp.Result.Document.AssertionMethod) + assert.Empty(tt, resp.Result.Document.CapabilityInvocation) + assert.Empty(tt, resp.Result.Document.CapabilityDelegation) + assert.Empty(tt, resp.Result.Document.KeyAgreement) + + // And the some keys were revoked. + updateKey, err := keystoreService.GetKey(context.Background(), keystore.GetKeyRequest{ID: created.DID.ID + "#update"}) + assert.NoError(tt, err) + assert.True(tt, updateKey.Revoked) + recoveryKey, err := keystoreService.GetKey(context.Background(), keystore.GetKeyRequest{ID: created.DID.ID + "#recover"}) + assert.NoError(tt, err) + assert.True(tt, recoveryKey.Revoked) + signingKey, err := keystoreService.GetKey(context.Background(), keystore.GetKeyRequest{ID: created.DID.ID + "#recover"}) + assert.NoError(tt, err) + assert.True(tt, signingKey.Revoked) + + t.Run("is idempotent", func(t *testing.T) { + resp2, err := handler.(*ionHandler).DeleteDID(context.Background(), request) + assert.NoError(t, err) + assert.Equal(t, resp, resp2) + }) + }) }) } } -func testKeyStoreService(t *testing.T, db storage.ServiceStorage) *keystore.Service { +func testKeyStoreService(t *testing.T, db storage.ServiceStorage) (*keystore.Service, keystore.ServiceFactory) { serviceConfig := new(config.KeyStoreServiceConfig) + factory := keystore.NewKeyStoreServiceFactory(*serviceConfig, db, encryption.NoopEncrypter, encryption.NoopDecrypter) // create a keystore service - keystoreService, err := keystore.NewKeyStoreService(*serviceConfig, db) + keystoreService, err := factory(db) require.NoError(t, err) require.NotEmpty(t, keystoreService) - return keystoreService + return keystoreService, factory } diff --git a/pkg/service/did/key.go b/pkg/service/did/key.go index 421b4238a..2b6b9a27d 100644 --- a/pkg/service/did/key.go +++ b/pkg/service/did/key.go @@ -3,10 +3,12 @@ package did import ( "context" "fmt" + "time" "github.com/TBD54566975/ssi-sdk/crypto" "github.com/TBD54566975/ssi-sdk/did" "github.com/TBD54566975/ssi-sdk/did/key" + "github.com/TBD54566975/ssi-sdk/did/resolution" "github.com/mr-tron/base58" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -30,6 +32,10 @@ type keyHandler struct { keyStore *keystore.Service } +func (h *keyHandler) Resolve(ctx context.Context, id string) (*resolution.Result, error) { + return resolve(ctx, id, h.storage) +} + var _ MethodHandler = (*keyHandler)(nil) func (h *keyHandler) GetMethod() did.Method { @@ -53,7 +59,10 @@ func (h *keyHandler) CreateDID(ctx context.Context, request CreateDIDRequest) (* // store metadata in DID storage id := doc.String() + nowUTC := time.Now().UTC() storedDID := DefaultStoredDID{ + CreatedAt: nowUTC.Format(time.RFC3339), + UpdatedAt: nowUTC.Format(time.RFC3339), ID: id, DID: *expanded, SoftDeleted: false, @@ -131,19 +140,21 @@ func (h *keyHandler) ListDeletedDIDs(ctx context.Context) (*ListDIDsResponse, er return &ListDIDsResponse{DIDs: dids}, nil } -func (h *keyHandler) SoftDeleteDID(ctx context.Context, request DeleteDIDRequest) error { +func (h *keyHandler) DeleteDID(ctx context.Context, request DeleteDIDRequest) (*DeleteDIDResponse, error) { logrus.Debugf("soft deleting DID: %+v", request) id := request.ID gotStoredDID, err := h.storage.GetDIDDefault(ctx, id) if err != nil { - return fmt.Errorf("error getting DID: %s", id) + return nil, fmt.Errorf("error getting DID: %s", id) } if gotStoredDID == nil { - return fmt.Errorf("did with id<%s> could not be found", id) + return nil, fmt.Errorf("did with id<%s> could not be found", id) } + nowUTC := time.Now().UTC() gotStoredDID.SoftDeleted = true + gotStoredDID.UpdatedAt = nowUTC.Format(time.RFC3339) - return h.storage.StoreDID(ctx, *gotStoredDID) + return nil, h.storage.StoreDID(ctx, *gotStoredDID) } diff --git a/pkg/service/did/model.go b/pkg/service/did/model.go index d3c4766f6..425926d4f 100644 --- a/pkg/service/did/model.go +++ b/pkg/service/did/model.go @@ -86,6 +86,10 @@ type DeleteDIDRequest struct { ID string `json:"id" validate:"required"` } +type DeleteDIDResponse struct { + Result *resolution.Result `json:"resolutionResult"` +} + type UpdateIONDIDRequest struct { DID ion.ION `json:"did"` diff --git a/pkg/service/did/service.go b/pkg/service/did/service.go index d3608bcc7..d34ac3c23 100644 --- a/pkg/service/did/service.go +++ b/pkg/service/did/service.go @@ -221,12 +221,13 @@ func (s *Service) ListDIDsByMethod(ctx context.Context, request ListDIDsRequest) return handler.ListDIDs(ctx, request.PageRequest) } -func (s *Service) SoftDeleteDIDByMethod(ctx context.Context, request DeleteDIDRequest) error { +func (s *Service) DeleteDIDByMethod(ctx context.Context, request DeleteDIDRequest) error { handler, err := s.getHandler(request.Method) if err != nil { return sdkutil.LoggingErrorMsgf(err, "could not get handler for method<%s>", request.Method) } - return handler.SoftDeleteDID(ctx, request) + _, err = handler.DeleteDID(ctx, request) + return err } func (s *Service) getHandler(method didsdk.Method) (MethodHandler, error) { diff --git a/pkg/service/did/storage.go b/pkg/service/did/storage.go index a1fc0f943..a532e4fa7 100644 --- a/pkg/service/did/storage.go +++ b/pkg/service/did/storage.go @@ -41,6 +41,8 @@ type StoredDID interface { // DefaultStoredDID is the default implementation of StoredDID if no other implementation requirements are needed. type DefaultStoredDID struct { + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` ID string `json:"id"` DID did.Document `json:"did"` SoftDeleted bool `json:"softDeleted"` diff --git a/pkg/service/did/web.go b/pkg/service/did/web.go index 381f36bf7..1e22cb2ea 100644 --- a/pkg/service/did/web.go +++ b/pkg/service/did/web.go @@ -3,9 +3,11 @@ package did import ( "context" "fmt" + "time" "github.com/TBD54566975/ssi-sdk/crypto" "github.com/TBD54566975/ssi-sdk/did" + "github.com/TBD54566975/ssi-sdk/did/resolution" "github.com/TBD54566975/ssi-sdk/did/web" "github.com/TBD54566975/ssi-sdk/util" "github.com/mr-tron/base58" @@ -31,6 +33,10 @@ type webHandler struct { keyStore *keystore.Service } +func (h *webHandler) Resolve(ctx context.Context, id string) (*resolution.Result, error) { + return resolve(ctx, id, h.storage) +} + var _ MethodHandler = (*webHandler)(nil) type CreateWebDIDOptions struct { @@ -97,7 +103,10 @@ func (h *webHandler) CreateDID(ctx context.Context, request CreateDIDRequest) (* // store metadata in DID storage id := didWeb.String() + nowUTC := time.Now().UTC() storedDID := DefaultStoredDID{ + CreatedAt: nowUTC.Format(time.RFC3339), + UpdatedAt: nowUTC.Format(time.RFC3339), ID: id, DID: *doc, SoftDeleted: false, @@ -174,19 +183,21 @@ func (h *webHandler) ListDeletedDIDs(ctx context.Context) (*ListDIDsResponse, er return &ListDIDsResponse{DIDs: dids}, nil } -func (h *webHandler) SoftDeleteDID(ctx context.Context, request DeleteDIDRequest) error { +func (h *webHandler) DeleteDID(ctx context.Context, request DeleteDIDRequest) (*DeleteDIDResponse, error) { logrus.Debugf("soft deleting DID: %+v", request) id := request.ID gotStoredDID, err := h.storage.GetDIDDefault(ctx, id) if err != nil { - return errors.Wrapf(err, "getting DID: %s", id) + return nil, errors.Wrapf(err, "getting DID: %s", id) } if gotStoredDID == nil { - return fmt.Errorf("did with id<%s> could not be found", id) + return nil, fmt.Errorf("did with id<%s> could not be found", id) } + nowUTC := time.Now().UTC() gotStoredDID.SoftDeleted = true + gotStoredDID.UpdatedAt = nowUTC.Format(time.RFC3339) - return h.storage.StoreDID(ctx, *gotStoredDID) + return nil, h.storage.StoreDID(ctx, *gotStoredDID) }