diff --git a/tests/integration/cli_diddoc_test.go b/tests/integration/cli_diddoc_test.go index bb4742678..a9ba51f24 100644 --- a/tests/integration/cli_diddoc_test.go +++ b/tests/integration/cli_diddoc_test.go @@ -4,7 +4,9 @@ package integration import ( "crypto/ed25519" + "encoding/json" "fmt" + "strconv" "github.com/cheqd/cheqd-node/tests/integration/cli" "github.com/cheqd/cheqd-node/tests/integration/helpers" @@ -408,4 +410,133 @@ var _ = Describe("cheqd cli - positive did", func() { // Check that the DID Doc is deactivated Expect(resp2.Value.Metadata.Deactivated).To(BeTrue()) }) + + It("can create diddoc with augmented assertionMethod, update it and query the result (Ed25519VerificationKey2020)", func() { + AddReportEntry("Integration", fmt.Sprintf("%sPositive: %s", cli.Green, "can create diddoc with augmented assertionMethod (Ed25519VerificationKey2020)")) + // Create a new DID Doc + did := "did:cheqd:" + network.DidNamespace + ":" + uuid.NewString() + keyID := did + "#key1" + + publicKey, privateKey, err := ed25519.GenerateKey(nil) + Expect(err).To(BeNil()) + + publicKeyMultibase := testsetup.GenerateEd25519VerificationKey2020VerificationMaterial(publicKey) + publicKeyBase58 := testsetup.GenerateEd25519VerificationKey2018VerificationMaterial(publicKey) + + assertionMethodJSONEscaped := func() string { + b, _ := json.Marshal(types.AssertionMethodJSONUnescaped{ + Id: keyID, + Type: "Ed25519VerificationKey2018", + Controller: did, + PublicKeyBase58: &publicKeyBase58, // arbitrarily chosen, loosely validated + }) + return strconv.Quote(string(b)) + }() + + payload := didcli.DIDDocument{ + ID: did, + VerificationMethod: []didcli.VerificationMethod{ + map[string]any{ + "id": keyID, + "type": "Ed25519VerificationKey2020", + "controller": did, + "publicKeyMultibase": publicKeyMultibase, + }, + }, + Authentication: []string{keyID}, + AssertionMethod: []string{keyID, assertionMethodJSONEscaped}, + } + + signInputs := []didcli.SignInput{ + { + VerificationMethodID: keyID, + PrivKey: privateKey, + }, + } + + versionID := uuid.NewString() + + res, err := cli.CreateDidDoc(tmpDir, payload, signInputs, versionID, testdata.BASE_ACCOUNT_1, helpers.GenerateFees(feeParams.CreateDid.String())) + Expect(err).To(BeNil()) + Expect(res.Code).To(BeEquivalentTo(0)) + + AddReportEntry("Integration", fmt.Sprintf("%sPositive: %s", cli.Green, "can update diddoc with augmented assertionMethod (Ed25519VerificationKey2020)")) + // Update the DID Doc + + assertionMethodJSONEscaped2 := func() string { + b, _ := json.Marshal(types.AssertionMethodJSONUnescaped{ + Id: keyID, + Type: "Ed25519VerificationKey2020", + Controller: did, + PublicKeyMultibase: &publicKeyMultibase, // arbitrarily chosen, loosely validated + }) + return strconv.Quote(string(b)) + }() + + payload2 := didcli.DIDDocument{ + ID: did, + VerificationMethod: []didcli.VerificationMethod{ + map[string]any{ + "id": keyID, + "type": "Ed25519VerificationKey2020", + "controller": did, + "publicKeyMultibase": publicKeyMultibase, + }, + }, + Authentication: []string{keyID}, + AssertionMethod: []string{keyID, assertionMethodJSONEscaped, assertionMethodJSONEscaped2}, + } + + versionID = uuid.NewString() + + res2, err := cli.UpdateDidDoc(tmpDir, payload2, signInputs, versionID, testdata.BASE_ACCOUNT_1, helpers.GenerateFees(feeParams.UpdateDid.String())) + Expect(err).To(BeNil()) + Expect(res2.Code).To(BeEquivalentTo(0)) + + AddReportEntry("Integration", fmt.Sprintf("%sPositive: %s", cli.Green, "can query diddoc with augmented assertionMethod (Ed25519VerificationKey2020)")) + // Query the DID Doc + resp, err := cli.QueryDidDoc(did) + Expect(err).To(BeNil()) + + didDoc := resp.Value.DidDoc + Expect(didDoc.Id).To(BeEquivalentTo(did)) + Expect(didDoc.Authentication).To(HaveLen(1)) + Expect(didDoc.Authentication[0]).To(BeEquivalentTo(keyID)) + Expect(didDoc.VerificationMethod).To(HaveLen(1)) + Expect(didDoc.VerificationMethod[0].Id).To(BeEquivalentTo(keyID)) + Expect(didDoc.VerificationMethod[0].VerificationMethodType).To(BeEquivalentTo("Ed25519VerificationKey2020")) + Expect(didDoc.VerificationMethod[0].Controller).To(BeEquivalentTo(did)) + Expect(didDoc.VerificationMethod[0].VerificationMaterial).To(BeEquivalentTo(publicKeyMultibase)) + Expect(didDoc.AssertionMethod).To(HaveLen(3)) + Expect(didDoc.AssertionMethod[0]).To(BeEquivalentTo(keyID)) + Expect(didDoc.AssertionMethod[1]).To(BeEquivalentTo(assertionMethodJSONEscaped)) + Expect(didDoc.AssertionMethod[2]).To(BeEquivalentTo(assertionMethodJSONEscaped2)) + + // Check that DIDDoc is not deactivated + Expect(resp.Value.Metadata.Deactivated).To(BeFalse()) + + AddReportEntry("Integration", fmt.Sprintf("%sPositive: %s", cli.Green, "can deactivate diddoc with augmented assertionMethod (Ed25519VerificationKey2020)")) + // Deactivate the DID Doc + payload3 := types.MsgDeactivateDidDocPayload{ + Id: did, + } + + versionID = uuid.NewString() + + res3, err := cli.DeactivateDidDoc(tmpDir, payload3, signInputs, versionID, testdata.BASE_ACCOUNT_1, helpers.GenerateFees(feeParams.DeactivateDid.String())) + Expect(err).To(BeNil()) + Expect(res3.Code).To(BeEquivalentTo(0)) + + AddReportEntry("Integration", fmt.Sprintf("%sPositive: %s", cli.Green, "can query deactivated diddoc with augmented assertionMethod (Ed25519VerificationKey2020)")) + // Query the DID Doc + + resp2, err := cli.QueryDidDoc(did) + Expect(err).To(BeNil()) + + didDoc2 := resp2.Value.DidDoc + Expect(didDoc2).To(BeEquivalentTo(didDoc)) + + // Check that the DID Doc is deactivated + Expect(resp2.Value.Metadata.Deactivated).To(BeTrue()) + }) }) diff --git a/x/did/types/diddoc_assertion_method.go b/x/did/types/diddoc_assertion_method.go new file mode 100644 index 000000000..9d579da72 --- /dev/null +++ b/x/did/types/diddoc_assertion_method.go @@ -0,0 +1,10 @@ +package types + +type AssertionMethodJSONUnescaped struct { + Id string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyBase58 *string `json:"publicKeyBase58,omitempty"` + PublicKeyMultibase *string `json:"publicKeyMultibase,omitempty"` + PublicKeyJwk *string `json:"publicKeyJwk,omitempty"` +} diff --git a/x/did/types/diddoc_diddoc.go b/x/did/types/diddoc_diddoc.go index 83df2fb88..ce1ff8202 100644 --- a/x/did/types/diddoc_diddoc.go +++ b/x/did/types/diddoc_diddoc.go @@ -96,8 +96,7 @@ func (didDoc DidDoc) Validate(allowedNamespaces []string) error { IsUniqueStrList(), validation.Each(IsDIDUrl(allowedNamespaces, Empty, Empty, Required), HasPrefix(didDoc.Id)), ), validation.Field(&didDoc.AssertionMethod, - IsUniqueStrList(), validation.Each(IsDIDUrl(allowedNamespaces, Empty, Empty, Required), HasPrefix(didDoc.Id)), - ), + IsUniqueStrList(), validation.Each(IsAssertionMethod(allowedNamespaces, didDoc))), validation.Field(&didDoc.CapabilityInvocation, IsUniqueStrList(), validation.Each(IsDIDUrl(allowedNamespaces, Empty, Empty, Required), HasPrefix(didDoc.Id)), ), diff --git a/x/did/types/diddoc_diddoc_test.go b/x/did/types/diddoc_diddoc_test.go index e8f9ac777..e33be57a8 100644 --- a/x/did/types/diddoc_diddoc_test.go +++ b/x/did/types/diddoc_diddoc_test.go @@ -1,7 +1,9 @@ package types_test import ( + "encoding/json" "fmt" + "strconv" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -229,4 +231,146 @@ var _ = DescribeTable("DIDDoc Validation tests", func(testCase DIDDocTestCase) { isValid: false, errorMsg: "verification_method: there are verification method duplicates.", }), + Entry( + "Assertion method is valid", + DIDDocTestCase{ + didDoc: &DidDoc{ + Id: ValidTestDID, + Controller: []string{ValidTestDID}, + VerificationMethod: []*VerificationMethod{ + { + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + VerificationMethodType: "Ed25519VerificationKey2020", + Controller: ValidTestDID, + VerificationMaterial: ValidEd25519VerificationKey2020VerificationMaterial, + }, + }, + AssertionMethod: []string{fmt.Sprintf("%s#fragment", ValidTestDID), func() string { + b, _ := json.Marshal(AssertionMethodJSONUnescaped{ + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + Type: "Ed25519VerificationKey2018", + Controller: ValidTestDID, + PublicKeyBase58: &ValidEd25519VerificationKey2018VerificationMaterial, // arbitrarily chosen, loosely validated + }) + return strconv.Quote(string(b)) + }()}, + }, + isValid: true, + errorMsg: "", + }), + Entry( + "Assertion method has wrong fragment", + DIDDocTestCase{ + didDoc: &DidDoc{ + Id: ValidTestDID, + Controller: []string{ValidTestDID}, + VerificationMethod: []*VerificationMethod{ + { + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + VerificationMethodType: "Ed25519VerificationKey2020", + Controller: ValidTestDID, + VerificationMaterial: ValidEd25519VerificationKey2020VerificationMaterial, + }, + }, + AssertionMethod: []string{fmt.Sprintf("%s#fragment", ValidTestDID), func() string { + b, _ := json.Marshal(AssertionMethodJSONUnescaped{ + Id: fmt.Sprintf("%s#fragment-1", ValidTestDID), + Type: "Ed25519VerificationKey2018", + Controller: ValidTestDID, + PublicKeyBase58: &ValidEd25519VerificationKey2018VerificationMaterial, // arbitrarily chosen, loosely validated + }) + return strconv.Quote(string(b)) + }()}, + }, + isValid: false, + errorMsg: "assertionMethod should be a valid key reference within the DID document's verification method", + }), + Entry( + "Assertion method has invalid protobuf value", + DIDDocTestCase{ + didDoc: &DidDoc{ + Id: ValidTestDID, + Controller: []string{ValidTestDID}, + VerificationMethod: []*VerificationMethod{ + { + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + VerificationMethodType: "Ed25519VerificationKey2020", + Controller: ValidTestDID, + VerificationMaterial: ValidEd25519VerificationKey2020VerificationMaterial, + }, + }, + AssertionMethod: []string{func() string { + b, _ := json.Marshal(struct { + Id string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + InvalidField map[string]interface{} `json:"invalidField"` + }{ + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + Type: "Ed25519VerificationKey2018", + Controller: ValidTestDID, + InvalidField: map[string]interface{}{"unsupported": []int{1, 2, 3}}, + }) + return strconv.Quote(string(b)) + }()}, + }, + isValid: false, + errorMsg: "field invalidField is not protobuf-supported", + }), + Entry( + "Assertion method is missing controller value in JSON", + DIDDocTestCase{ + didDoc: &DidDoc{ + Id: ValidTestDID, + Controller: []string{ValidTestDID}, + VerificationMethod: []*VerificationMethod{ + { + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + VerificationMethodType: "Ed25519VerificationKey2020", + Controller: ValidTestDID, + VerificationMaterial: ValidEd25519VerificationKey2020VerificationMaterial, + }, + }, + AssertionMethod: []string{func() string { + b, _ := json.Marshal(struct { + Id string `json:"id"` + Type string `json:"type"` + }{ + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + Type: "Ed25519VerificationKey2018", + }) + return strconv.Quote(string(b)) + }()}, + }, + isValid: false, + errorMsg: "assertion_method: (0: (controller: cannot be blank.).).", + }), + Entry( + "Assertion method contains unescaped JSON string", + DIDDocTestCase{ + didDoc: &DidDoc{ + Id: ValidTestDID, + Controller: []string{ValidTestDID}, + VerificationMethod: []*VerificationMethod{ + { + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + VerificationMethodType: "Ed25519VerificationKey2020", + Controller: ValidTestDID, + VerificationMaterial: ValidEd25519VerificationKey2020VerificationMaterial, + }, + }, + AssertionMethod: []string{func() string { + b, _ := json.Marshal(struct { + Id string `json:"id"` + Type string `json:"type"` // controller is intentionally missing, no additional fields are necessary as the focal point is the unescaped JSON string, i.e. deserialisation should fail first, before any other validation + }{ + Id: fmt.Sprintf("%s#fragment", ValidTestDID), + Type: "Ed25519VerificationKey2018", + }) + return string(b) + }()}, + }, + isValid: false, + errorMsg: "assertionMethod should be a DIDUrl or an Escaped JSON string", + }), ) diff --git a/x/did/types/tx_msg_create_diddoc_payload.go b/x/did/types/tx_msg_create_diddoc_payload.go index b2c096735..49b229ef9 100644 --- a/x/did/types/tx_msg_create_diddoc_payload.go +++ b/x/did/types/tx_msg_create_diddoc_payload.go @@ -68,11 +68,11 @@ func (msg *MsgCreateDidDocPayload) Normalize() { s.Id = utils.NormalizeDIDUrl(s.Id) } msg.Controller = utils.NormalizeDIDList(msg.Controller) - msg.Authentication = utils.NormalizeDIDUrlList(msg.Authentication) - msg.AssertionMethod = utils.NormalizeDIDUrlList(msg.AssertionMethod) - msg.CapabilityInvocation = utils.NormalizeDIDUrlList(msg.CapabilityInvocation) - msg.CapabilityDelegation = utils.NormalizeDIDUrlList(msg.CapabilityDelegation) - msg.KeyAgreement = utils.NormalizeDIDUrlList(msg.KeyAgreement) + msg.Authentication = utils.NormalizeDIDUrlList(msg.Authentication, false) + msg.AssertionMethod = utils.NormalizeDIDUrlList(msg.AssertionMethod, true) + msg.CapabilityInvocation = utils.NormalizeDIDUrlList(msg.CapabilityInvocation, false) + msg.CapabilityDelegation = utils.NormalizeDIDUrlList(msg.CapabilityDelegation, false) + msg.KeyAgreement = utils.NormalizeDIDUrlList(msg.KeyAgreement, false) msg.VersionId = utils.NormalizeUUID(msg.VersionId) } diff --git a/x/did/types/tx_msg_update_did_doc_payload.go b/x/did/types/tx_msg_update_did_doc_payload.go index 649bb2715..823f0b33c 100644 --- a/x/did/types/tx_msg_update_did_doc_payload.go +++ b/x/did/types/tx_msg_update_did_doc_payload.go @@ -68,11 +68,11 @@ func (msg *MsgUpdateDidDocPayload) Normalize() { s.Id = utils.NormalizeDIDUrl(s.Id) } msg.Controller = utils.NormalizeDIDList(msg.Controller) - msg.Authentication = utils.NormalizeDIDUrlList(msg.Authentication) - msg.AssertionMethod = utils.NormalizeDIDUrlList(msg.AssertionMethod) - msg.CapabilityInvocation = utils.NormalizeDIDUrlList(msg.CapabilityInvocation) - msg.CapabilityDelegation = utils.NormalizeDIDUrlList(msg.CapabilityDelegation) - msg.KeyAgreement = utils.NormalizeDIDUrlList(msg.KeyAgreement) + msg.Authentication = utils.NormalizeDIDUrlList(msg.Authentication, false) + msg.AssertionMethod = utils.NormalizeDIDUrlList(msg.AssertionMethod, true) + msg.CapabilityInvocation = utils.NormalizeDIDUrlList(msg.CapabilityInvocation, false) + msg.CapabilityDelegation = utils.NormalizeDIDUrlList(msg.CapabilityDelegation, false) + msg.KeyAgreement = utils.NormalizeDIDUrlList(msg.KeyAgreement, false) msg.VersionId = utils.NormalizeUUID(msg.VersionId) } diff --git a/x/did/types/validate.go b/x/did/types/validate.go index 75643885b..9ac3f72ae 100644 --- a/x/did/types/validate.go +++ b/x/did/types/validate.go @@ -1,8 +1,10 @@ package types import ( + "encoding/json" "errors" "fmt" + "strconv" "strings" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -105,6 +107,45 @@ func IsDIDUrl(allowedNamespaces []string, pathRule, queryRule, fragmentRule Vali }) } +func IsAssertionMethod(allowedNamespaces []string, didDoc DidDoc) *CustomErrorRule { + return NewCustomErrorRule(func(value interface{}) error { + err := validation.Validate(value, IsDIDUrl(allowedNamespaces, Empty, Empty, Required), HasPrefix(didDoc.Id)) + casted, ok := value.(string) + if !ok { + panic("IsAssertionMethod must be only applied on string properties") + } + + if err == nil { + for _, v := range didDoc.VerificationMethod { + if v.Id == casted { + return nil + } + } + + return errors.New("assertionMethod should be a valid key reference within the DID document's verification method") + } + + unescapedJSON, err := strconv.Unquote(casted) + if err != nil { + return errors.New("assertionMethod should be a DIDUrl or an Escaped JSON string") + } + + if err := utils.ValidateProtobufFields(unescapedJSON); err != nil { + return err + } + + var result AssertionMethodJSONUnescaped + if err = json.Unmarshal([]byte(unescapedJSON), &result); err != nil { + return errors.New("assertionMethod should be a DIDUrl or an Escaped JSON string with id, type and controller values") + } + + return validation.ValidateStruct(&result, + validation.Field(&result.Id, validation.Required, IsAssertionMethod(allowedNamespaces, didDoc)), + validation.Field(&result.Controller, validation.Required, IsDID(allowedNamespaces)), + ) + }) +} + func IsURI() *CustomErrorRule { return NewCustomErrorRule(func(value interface{}) error { casted, ok := value.(string) diff --git a/x/did/utils/did_url.go b/x/did/utils/did_url.go index 2f38cdcf9..c605677ea 100644 --- a/x/did/utils/did_url.go +++ b/x/did/utils/did_url.go @@ -126,12 +126,17 @@ func NormalizeDIDUrl(didURL string) string { return JoinDIDUrl(did, path, query, fragment) } -func NormalizeDIDUrlList(didURLs []string) []string { +func NormalizeDIDUrlList(didURLs []string, verifyJSONEscaped bool) []string { if didURLs == nil { return nil } newDIDURLs := []string{} for _, id := range didURLs { + if verifyJSONEscaped && IsJSONEscapedString(id) { + newDIDURLs = append(newDIDURLs, id) + continue + } + newDIDURLs = append(newDIDURLs, NormalizeDIDUrl(id)) } return newDIDURLs diff --git a/x/did/utils/encoding.go b/x/did/utils/encoding.go index b6212a5a9..d0527b95a 100644 --- a/x/did/utils/encoding.go +++ b/x/did/utils/encoding.go @@ -3,6 +3,7 @@ package utils import ( "encoding/json" "fmt" + "strconv" "github.com/mr-tron/base58" "github.com/multiformats/go-multibase" @@ -88,3 +89,20 @@ func ValidateBase58Ed25519VerificationKey2018(data string) error { } return ValidateEd25519PubKey(pubKey) } + +func IsJSONEscapedString(value interface{}) bool { + casted, ok := value.(string) + if !ok { + panic("value must be a string: got: " + fmt.Sprintf("%T", value)) + } + + unescaped, err := strconv.Unquote(casted) + if err != nil { + return false + } + + var unmarshaled interface{} + err = json.Unmarshal([]byte(unescaped), &unmarshaled) + + return err == nil +} diff --git a/x/did/utils/str.go b/x/did/utils/str.go index b0fc56ce2..9e951cf16 100644 --- a/x/did/utils/str.go +++ b/x/did/utils/str.go @@ -1,6 +1,13 @@ package utils -import "sort" +import ( + "encoding/json" + "errors" + "fmt" + "sort" + + "google.golang.org/protobuf/proto" +) func IndexOf(array []string, searchElement string, fromIndex int) int { for i, v := range array[fromIndex:] { @@ -100,3 +107,22 @@ func UniqueSorted(ls []string) []string { func StrBytes(p string) []byte { return []byte(p) } + +// Generic function to validate protobuf-supported fields in a JSON string +func ValidateProtobufFields(jsonString string) error { + var input map[string]interface{} + if err := json.Unmarshal([]byte(jsonString), &input); err != nil { + return errors.New("input should be a valid JSON string") + } + + for key, value := range input { + switch value.(type) { + case string, int, int32, int64, float32, float64, bool, proto.Message: + continue + default: + return fmt.Errorf("field %s is not protobuf-supported", key) + } + } + + return nil +}