Repository: hyperledger/aries-framework-go
Pull request #3498 (2023-01-25 21:07:52)

feat: SD-JWT: Add option for non-selectively disclosable claims (issuer)
From spec: The Issuer may also make certain claims or sub-claims non-selectively disclosable. 

Added option WithNonSelectivelyDisclosableClaims() for specifying non-selectively disclosable claims to issuer.

Closes: #3497

Signed-off-by: Sandra Vrtikapa <sandra.vrtikapa@securekey.com>



codecov[bot]: # [Codecov](https://codecov.io/gh/hyperledger/aries-framework-go/pull/3498?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) Report
> Merging [#3498](https://codecov.io/gh/hyperledger/aries-framework-go/pull/3498?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) (1421b66) into [main](https://codecov.io/gh/hyperledger/aries-framework-go/commit/d947180cc78ee61a5b234d482177e7047fadb16d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) (d947180) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head 1421b66 differs from pull request most recent head 3f0dcd6. Consider uploading reports for the commit 3f0dcd6 to get more accurate results

```diff
@@           Coverage Diff           @@
##             main    #3498   +/-   ##
=======================================
  Coverage   87.67%   87.67%           
=======================================
  Files         350      350           
  Lines       47924    47930    +6     
=======================================
+ Hits        42017    42023    +6     
  Misses       4379     4379           
  Partials     1528     1528           
```


| [Impacted Files](https://codecov.io/gh/hyperledger/aries-framework-go/pull/3498?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger) | Coverage Δ | |
|---|---|---|
| [pkg/doc/sdjwt/common/common.go](https://codecov.io/gh/hyperledger/aries-framework-go/pull/3498?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-cGtnL2RvYy9zZGp3dC9jb21tb24vY29tbW9uLmdv) | `92.30% <100.00%> (ø)` | |
| [pkg/doc/sdjwt/holder/holder.go](https://codecov.io/gh/hyperledger/aries-framework-go/pull/3498?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-cGtnL2RvYy9zZGp3dC9ob2xkZXIvaG9sZGVyLmdv) | `100.00% <100.00%> (ø)` | |
| [pkg/doc/sdjwt/issuer/issuer.go](https://codecov.io/gh/hyperledger/aries-framework-go/pull/3498?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger#diff-cGtnL2RvYy9zZGp3dC9pc3N1ZXIvaXNzdWVyLmdv) | `91.66% <100.00%> (+0.45%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperledger)


Patch:
diff --git a/pkg/doc/sdjwt/common/common.go b/pkg/doc/sdjwt/common/common.go
index 6622366c..394aa67d 100644
--- a/pkg/doc/sdjwt/common/common.go
+++ b/pkg/doc/sdjwt/common/common.go
@@ -337,7 +337,7 @@ func GetDisclosureDigests(claims map[string]interface{}) (map[string]bool, error
 		return nil, fmt.Errorf("get disclosure digests: %w", err)
 	}
 
-	return sliceToMap(disclosures), nil
+	return SliceToMap(disclosures), nil
 }
 
 // GetDisclosedClaims returns disclosed claims only.
@@ -414,7 +414,8 @@ func stringArray(entry interface{}) ([]string, error) {
 	return result, nil
 }
 
-func sliceToMap(ids []string) map[string]bool {
+// SliceToMap converts slice to map.
+func SliceToMap(ids []string) map[string]bool {
 	// convert slice to map
 	values := make(map[string]bool)
 	for _, id := range ids {
diff --git a/pkg/doc/sdjwt/holder/holder.go b/pkg/doc/sdjwt/holder/holder.go
index e64c816a..56587148 100644
--- a/pkg/doc/sdjwt/holder/holder.go
+++ b/pkg/doc/sdjwt/holder/holder.go
@@ -139,7 +139,7 @@ func CreatePresentation(combinedFormatForIssuance string, claimsToDisclose []str
 		return "", fmt.Errorf("no disclosures found in SD-JWT")
 	}
 
-	disclosuresMap := sliceToMap(cfi.Disclosures)
+	disclosuresMap := common.SliceToMap(cfi.Disclosures)
 
 	for _, ctd := range claimsToDisclose {
 		if _, ok := disclosuresMap[ctd]; !ok {
@@ -177,16 +177,6 @@ func CreateHolderBinding(info *BindingInfo) (string, error) {
 	return hbJWT.Serialize(false)
 }
 
-func sliceToMap(ids []string) map[string]bool {
-	// convert slice to map
-	values := make(map[string]bool)
-	for _, id := range ids {
-		values[id] = true
-	}
-
-	return values
-}
-
 // NoopSignatureVerifier is no-op signature verifier (signature will not get checked).
 type NoopSignatureVerifier struct {
 }
diff --git a/pkg/doc/sdjwt/issuer/issuer.go b/pkg/doc/sdjwt/issuer/issuer.go
index e3f9cd39..1c0146f5 100644
--- a/pkg/doc/sdjwt/issuer/issuer.go
+++ b/pkg/doc/sdjwt/issuer/issuer.go
@@ -61,6 +61,8 @@ type newOpts struct {
 
 	addDecoyDigests  bool
 	structuredClaims bool
+
+	nonSDClaimsMap map[string]bool
 }
 
 // NewOpt is the SD-JWT New option.
@@ -150,13 +152,34 @@ func WithStructuredClaims(flag bool) NewOpt {
 	}
 }
 
+// WithNonSelectivelyDisclosableClaims is an option for provide claim names that should be ignored when creating
+// selectively disclosable claims.
+// For example if you would like to not selectively disclose id and degree type from the following claims:
+// {
+//
+//	"degree": {
+//	   "degree": "MIT",
+//	   "type": "BachelorDegree",
+//	 },
+//	 "name": "Jayden Doe",
+//	 "id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
+//	}
+//
+// you should specify the following array: []string{"id", "degree.type"}.
+func WithNonSelectivelyDisclosableClaims(nonSDClaims []string) NewOpt {
+	return func(opts *newOpts) {
+		opts.nonSDClaimsMap = common.SliceToMap(nonSDClaims)
+	}
+}
+
 // New creates new signed Selective Disclosure JWT based on input claims.
 func New(issuer string, claims interface{}, headers jose.Headers,
 	signer jose.Signer, opts ...NewOpt) (*SelectiveDisclosureJWT, error) {
 	nOpts := &newOpts{
-		jsonMarshal: json.Marshal,
-		getSalt:     generateSalt,
-		HashAlg:     defaultHash,
+		jsonMarshal:    json.Marshal,
+		getSalt:        generateSalt,
+		HashAlg:        defaultHash,
+		nonSDClaimsMap: make(map[string]bool),
 	}
 
 	for _, opt := range opts {
@@ -168,7 +191,7 @@ func New(issuer string, claims interface{}, headers jose.Headers,
 		return nil, fmt.Errorf("convert payload to map: %w", err)
 	}
 
-	disclosures, digests, err := createDisclosuresAndDigests(claimsMap, nOpts)
+	disclosures, digests, err := createDisclosuresAndDigests("", claimsMap, nOpts)
 	if err != nil {
 		return nil, err
 	}
@@ -333,7 +356,7 @@ func (j *SelectiveDisclosureJWT) Serialize(detached bool) (string, error) {
 	return cf.Serialize(), nil
 }
 
-func createDisclosuresAndDigests(claims map[string]interface{}, opts *newOpts) ([]string, map[string]interface{}, error) { // nolint:lll
+func createDisclosuresAndDigests(path string, claims map[string]interface{}, opts *newOpts) ([]string, map[string]interface{}, error) { // nolint:lll
 	var disclosures []string
 
 	var levelDisclosures []string
@@ -346,8 +369,13 @@ func createDisclosuresAndDigests(claims map[string]interface{}, opts *newOpts) (
 	}
 
 	for key, value := range claims {
+		curPath := key
+		if path != "" {
+			curPath = path + "." + key
+		}
+
 		if obj, ok := value.(map[string]interface{}); ok && opts.structuredClaims {
-			nestedDisclosures, nestedDigestsMap, e := createDisclosuresAndDigests(obj, opts)
+			nestedDisclosures, nestedDigestsMap, e := createDisclosuresAndDigests(curPath, obj, opts)
 			if e != nil {
 				return nil, nil, e
 			}
@@ -356,6 +384,12 @@ func createDisclosuresAndDigests(claims map[string]interface{}, opts *newOpts) (
 
 			disclosures = append(disclosures, nestedDisclosures...)
 		} else {
+			if _, ok := opts.nonSDClaimsMap[curPath]; ok {
+				digestsMap[key] = value
+
+				continue
+			}
+
 			disclosure, e := createDisclosure(key, value, opts)
 			if e != nil {
 				return nil, nil, fmt.Errorf("create disclosure: %w", e)


Test patch:
diff --git a/pkg/doc/sdjwt/integration_test.go b/pkg/doc/sdjwt/integration_test.go
index ceda7a59..226940a1 100644
--- a/pkg/doc/sdjwt/integration_test.go
+++ b/pkg/doc/sdjwt/integration_test.go
@@ -381,9 +381,10 @@ func TestSDJWTFlow(t *testing.T) {
 		r.NoError(err)
 
 		token, err := issuer.NewFromVC(vc, nil, signer,
-			issuer.WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"),
 			issuer.WithHolderPublicKey(holderPublicJWK),
-			issuer.WithStructuredClaims(true))
+			issuer.WithStructuredClaims(true),
+			issuer.WithNonSelectivelyDisclosableClaims([]string{"id", "degree.type"}),
+		)
 		r.NoError(err)
 
 		var decoded map[string]interface{}
@@ -410,7 +411,7 @@ func TestSDJWTFlow(t *testing.T) {
 
 		holderSigner := afjwt.NewEd25519Signer(holderPrivateKey)
 
-		selectedDisclosures := getDisclosuresFromClaimNames([]string{"degree", "name", "spouse"}, claims)
+		selectedDisclosures := getDisclosuresFromClaimNames([]string{"degree", "id", "name"}, claims)
 
 		// Holder will disclose only sub-set of claims to verifier.
 		combinedFormatForPresentation, err := holder.CreatePresentation(vcCombinedFormatForIssuance, selectedDisclosures,
@@ -551,8 +552,10 @@ const sampleVCFull = `
 		"credentialSubject": {
 			"degree": {
 				"degree": "MIT",
-				"type": "BachelorDegree"
+				"type": "BachelorDegree",
+				"id": "some-id"
 			},
+			"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
 			"name": "Jayden Doe",
 			"spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1"
 		},
diff --git a/pkg/doc/sdjwt/issuer/issuer_test.go b/pkg/doc/sdjwt/issuer/issuer_test.go
index 543edd8b..638f506e 100644
--- a/pkg/doc/sdjwt/issuer/issuer_test.go
+++ b/pkg/doc/sdjwt/issuer/issuer_test.go
@@ -18,6 +18,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/PaesslerAG/jsonpath"
 	"github.com/go-jose/go-jose/v3"
 	"github.com/go-jose/go-jose/v3/json"
 	"github.com/go-jose/go-jose/v3/jwt"
@@ -229,6 +230,111 @@ func TestNew(t *testing.T) {
 		r.NoError(err)
 	})
 
+	t.Run("Create Mixed (SD + non-SD) JWS with structured claims flag", func(t *testing.T) {
+		r := require.New(t)
+
+		_, privKey, err := ed25519.GenerateKey(rand.Reader)
+		r.NoError(err)
+
+		complexClaims := map[string]interface{}{
+			"degree": map[string]interface{}{
+				"degree": "MIT",
+				"type":   "BachelorDegree",
+				"id":     "some-id",
+			},
+			"name":   "Jayden Doe",
+			"id":     "did:example:ebfeb1f712ebc6f1c276e12ec21",
+			"spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1",
+		}
+
+		var newOpts []NewOpt
+
+		newOpts = append(newOpts,
+			WithStructuredClaims(true),
+			WithNonSelectivelyDisclosableClaims([]string{"id", "degree.type"}),
+		)
+
+		token, err := New(issuer, complexClaims, nil, afjwt.NewEd25519Signer(privKey), newOpts...)
+		r.NoError(err)
+
+		var tokenClaims map[string]interface{}
+		err = token.DecodeClaims(&tokenClaims)
+		r.NoError(err)
+
+		printObject(t, "Token Claims", tokenClaims)
+
+		combinedFormatForIssuance, err := token.Serialize(false)
+		require.NoError(t, err)
+
+		fmt.Printf(combinedFormatForIssuance)
+
+		cfi := common.ParseCombinedFormatForIssuance(combinedFormatForIssuance)
+		require.Equal(t, 4, len(cfi.Disclosures))
+
+		id, err := jsonpath.Get("$.id", tokenClaims)
+		r.NoError(err)
+		r.Equal("did:example:ebfeb1f712ebc6f1c276e12ec21", id)
+
+		degreeType, err := jsonpath.Get("$.degree.type", tokenClaims)
+		r.NoError(err)
+		r.Equal("BachelorDegree", degreeType)
+
+		degreeID, err := jsonpath.Get("$.degree.id", tokenClaims)
+		r.Error(err)
+		r.Nil(degreeID)
+		r.Contains(err.Error(), "unknown key id")
+	})
+
+	t.Run("Create Mixed (SD + non-SD) JWS with flat claims flag", func(t *testing.T) {
+		r := require.New(t)
+
+		_, privKey, err := ed25519.GenerateKey(rand.Reader)
+		r.NoError(err)
+
+		complexClaims := map[string]interface{}{
+			"degree": map[string]interface{}{
+				"degree": "MIT",
+				"type":   "BachelorDegree",
+				"id":     "some-id",
+			},
+			"name":   "Jayden Doe",
+			"id":     "did:example:ebfeb1f712ebc6f1c276e12ec21",
+			"spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1",
+		}
+
+		var newOpts []NewOpt
+
+		newOpts = append(newOpts,
+			WithNonSelectivelyDisclosableClaims([]string{"id", "degree.type"}),
+		)
+
+		token, err := New(issuer, complexClaims, nil, afjwt.NewEd25519Signer(privKey), newOpts...)
+		r.NoError(err)
+
+		var tokenClaims map[string]interface{}
+		err = token.DecodeClaims(&tokenClaims)
+		r.NoError(err)
+
+		printObject(t, "Token Claims", tokenClaims)
+
+		combinedFormatForIssuance, err := token.Serialize(false)
+		require.NoError(t, err)
+
+		fmt.Printf(combinedFormatForIssuance)
+
+		cfi := common.ParseCombinedFormatForIssuance(combinedFormatForIssuance)
+		require.Equal(t, 3, len(cfi.Disclosures))
+
+		id, err := jsonpath.Get("$.id", tokenClaims)
+		r.NoError(err)
+		r.Equal("did:example:ebfeb1f712ebc6f1c276e12ec21", id)
+
+		degreeType, err := jsonpath.Get("$.degree.type", tokenClaims)
+		r.Error(err)
+		r.Nil(degreeType)
+		r.Contains(err.Error(), "unknown key degree")
+	})
+
 	t.Run("Create SD-JWS with decoy disclosures", func(t *testing.T) {
 		r := require.New(t)
 
@@ -395,9 +501,9 @@ func TestNewFromVC(t *testing.T) {
 		r.NoError(err)
 
 		token, err := NewFromVC(vc, nil, signer,
-			WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"),
 			WithHolderPublicKey(holderPublicJWK),
-			WithStructuredClaims(true))
+			WithStructuredClaims(true),
+			WithNonSelectivelyDisclosableClaims([]string{"id", "degree.type"}))
 		r.NoError(err)
 
 		vcCombinedFormatForIssuance, err := token.Serialize(false)
@@ -410,6 +516,19 @@ func TestNewFromVC(t *testing.T) {
 		r.NoError(err)
 
 		printObject(t, "VC with selected disclosures", vcWithSelectedDisclosures)
+
+		id, err := jsonpath.Get("$.vc.credentialSubject.id", vcWithSelectedDisclosures)
+		r.NoError(err)
+		r.Equal("did:example:ebfeb1f712ebc6f1c276e12ec21", id)
+
+		degreeType, err := jsonpath.Get("$.vc.credentialSubject.degree.type", vcWithSelectedDisclosures)
+		r.NoError(err)
+		r.Equal("BachelorDegree", degreeType)
+
+		degreeID, err := jsonpath.Get("$.vc.credentialSubject.degree.id", vcWithSelectedDisclosures)
+		r.Error(err)
+		r.Nil(degreeID)
+		r.Contains(err.Error(), "unknown key id")
 	})
 
 	t.Run("success - flat claims + holder binding", func(t *testing.T) {
@@ -425,8 +544,8 @@ func TestNewFromVC(t *testing.T) {
 		r.NoError(err)
 
 		token, err := NewFromVC(vc, nil, signer,
-			WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"),
-			WithHolderPublicKey(holderPublicJWK))
+			WithHolderPublicKey(holderPublicJWK),
+			WithNonSelectivelyDisclosableClaims([]string{"id"}))
 		r.NoError(err)
 
 		vcCombinedFormatForIssuance, err := token.Serialize(false)
@@ -439,6 +558,10 @@ func TestNewFromVC(t *testing.T) {
 		r.NoError(err)
 
 		printObject(t, "VC with selected disclosures", vcWithSelectedDisclosures)
+
+		id, err := jsonpath.Get("$.vc.credentialSubject.id", vcWithSelectedDisclosures)
+		r.NoError(err)
+		r.Equal("did:example:ebfeb1f712ebc6f1c276e12ec21", id)
 	})
 
 	t.Run("error - missing credential subject", func(t *testing.T) {
@@ -826,9 +949,11 @@ const sampleVCFull = `
 		"credentialSubject": {
 			"degree": {
 				"degree": "MIT",
-				"type": "BachelorDegree"
+				"type": "BachelorDegree",
+				"id": "some-id"
 			},
 			"name": "Jayden Doe",
+			"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
 			"spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1"
 		},
 		"first_name": "First name",