Repository: hyperledger/aries-framework-go (go)

Issue:
SD-JWT: Add Decoy Disclosures
From: https://drafts.oauth.net/oauth-selective-disclosure-jwt/draft-ietf-oauth-selective-disclosure-jwt.html#name-decoy-digests

An Issuer MAY add additional digests to the SD-JWT that are not associated with any claim. The purpose of such "decoy" digests is to make it more difficult for an attacker to see the original number of claims contained in the SD-JWT. It is RECOMMENDED to create the decoy digests by hashing over a cryptographically secure random number. The bytes of the digest MUST then be base64url-encoded as above. The same digest function as for the Disclosures MUST be used.

For decoy digests, no Disclosure is sent to the Holder, i.e., the Holder will see digests that do not correspond to any Disclosure. See [Section 9.2](https://drafts.oauth.net/oauth-selective-disclosure-jwt/draft-ietf-oauth-selective-disclosure-jwt.html#decoy_digests_privacy) for additional privacy considerations.

Pull request:
feat: Add decoy digests to SD-JWT
Add decoy digests to SD-JWT (if enabled)

Closes #3463

Signed-off-by: Sandra Vrtikapa <sandra.vrtikapa@securekey.com>



Patch:
diff --git a/pkg/doc/sdjwt/common/common.go b/pkg/doc/sdjwt/common/common.go
index 0fd2f2e1..8a5f81fa 100644
--- a/pkg/doc/sdjwt/common/common.go
+++ b/pkg/doc/sdjwt/common/common.go
@@ -159,7 +159,7 @@ func VerifyDisclosuresInSDJWT(disclosures []string, signedJWT *afgjwt.JSONWebTok
 		return err
 	}
 
-	claimsDisclosureDigests, err := getDisclosureDigests(claims)
+	claimsDisclosureDigests, err := GetDisclosureDigests(claims)
 	if err != nil {
 		return err
 	}
@@ -207,7 +207,8 @@ func getSDAlg(claims map[string]interface{}) (string, error) {
 	return str, nil
 }
 
-func getDisclosureDigests(claims map[string]interface{}) (map[string]bool, error) {
+// GetDisclosureDigests returns digests from from claims map.
+func GetDisclosureDigests(claims map[string]interface{}) (map[string]bool, error) {
 	disclosuresObj, ok := claims[SDKey]
 	if !ok {
 		return nil, nil
diff --git a/pkg/doc/sdjwt/issuer/issuer.go b/pkg/doc/sdjwt/issuer/issuer.go
index fcc8fd82..973e94c4 100644
--- a/pkg/doc/sdjwt/issuer/issuer.go
+++ b/pkg/doc/sdjwt/issuer/issuer.go
@@ -13,6 +13,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	mathrand "math/rand"
 	"strings"
 	"time"
 
@@ -27,9 +28,14 @@ const (
 	defaultHash     = crypto.SHA256
 	defaultSaltSize = 128 / 8
 
+	decoyMinElements = 1
+	decoyMaxElements = 4
+
 	year = 365 * 24 * 60 * time.Minute
 )
 
+var mr = mathrand.New(mathrand.NewSource(time.Now().Unix())) // nolint:gochecknoglobals
+
 // Claims defines JSON Web Token Claims (https://tools.ietf.org/html/rfc7519#section-4)
 type Claims jwt.Claims
 
@@ -46,6 +52,8 @@ type newOpts struct {
 
 	jsonMarshal func(v interface{}) ([]byte, error)
 	getSalt     func() (string, error)
+
+	addDecoyDigests bool
 }
 
 // NewOpt is the SD-JWT New option.
@@ -107,6 +115,13 @@ func WithHashAlgorithm(alg crypto.Hash) NewOpt {
 	}
 }
 
+// WithDecoyDigests is an option for adding decoy digests(default is false).
+func WithDecoyDigests(flag bool) NewOpt {
+	return func(opts *newOpts) {
+		opts.addDecoyDigests = flag
+	}
+}
+
 // 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) {
@@ -127,6 +142,11 @@ func New(issuer string, claims interface{}, headers jose.Headers,
 		opt(nOpts)
 	}
 
+	decoyDisclosures, err := createDecoyDisclosures(nOpts)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create decoy disclosures: %w", err)
+	}
+
 	claimsMap, err := afgjwt.PayloadToMap(claims)
 	if err != nil {
 		return nil, err
@@ -137,15 +157,9 @@ func New(issuer string, claims interface{}, headers jose.Headers,
 		return nil, err
 	}
 
-	var hashedDisclosures []string
-
-	for _, disclosure := range disclosures {
-		hashedDisclosure, inErr := common.GetHash(nOpts.HashAlg, disclosure)
-		if inErr != nil {
-			return nil, fmt.Errorf("hash disclosure: %w", inErr)
-		}
-
-		hashedDisclosures = append(hashedDisclosures, hashedDisclosure)
+	digests, err := createDigests(append(disclosures, decoyDisclosures...), nOpts)
+	if err != nil {
+		return nil, err
 	}
 
 	payload := &common.Payload{
@@ -155,7 +169,7 @@ func New(issuer string, claims interface{}, headers jose.Headers,
 		IssuedAt:  nOpts.IssuedAt,
 		Expiry:    nOpts.Expiry,
 		NotBefore: nOpts.NotBefore,
-		SD:        hashedDisclosures,
+		SD:        digests,
 		SDAlg:     strings.ToLower(nOpts.HashAlg.String()),
 	}
 
@@ -167,6 +181,46 @@ func New(issuer string, claims interface{}, headers jose.Headers,
 	return &SelectiveDisclosureJWT{Disclosures: disclosures, SignedJWT: signedJWT}, nil
 }
 
+func createDigests(disclosures []string, nOpts *newOpts) ([]string, error) {
+	var digests []string
+
+	for _, disclosure := range disclosures {
+		digest, inErr := common.GetHash(nOpts.HashAlg, disclosure)
+		if inErr != nil {
+			return nil, fmt.Errorf("hash disclosure: %w", inErr)
+		}
+
+		digests = append(digests, digest)
+	}
+
+	mr.Shuffle(len(digests), func(i, j int) {
+		digests[i], digests[j] = digests[j], digests[i]
+	})
+
+	return digests, nil
+}
+
+func createDecoyDisclosures(opts *newOpts) ([]string, error) {
+	if !opts.addDecoyDigests {
+		return nil, nil
+	}
+
+	n := mr.Intn(decoyMaxElements-decoyMinElements+1) + decoyMinElements
+
+	var decoyDisclosures []string
+
+	for i := 0; i < n; i++ {
+		salt, err := opts.getSalt()
+		if err != nil {
+			return nil, err
+		}
+
+		decoyDisclosures = append(decoyDisclosures, salt)
+	}
+
+	return decoyDisclosures, nil
+}
+
 // SelectiveDisclosureJWT defines Selective Disclosure JSON Web Token (https://tools.ietf.org/html/rfc7519)
 type SelectiveDisclosureJWT struct {
 	SignedJWT   *afgjwt.JSONWebToken


Test patch:
diff --git a/pkg/doc/sdjwt/issuer/issuer_test.go b/pkg/doc/sdjwt/issuer/issuer_test.go
index 770436f9..772ff5a8 100644
--- a/pkg/doc/sdjwt/issuer/issuer_test.go
+++ b/pkg/doc/sdjwt/issuer/issuer_test.go
@@ -165,6 +165,55 @@ func TestNew(t *testing.T) {
 		r.NoError(err)
 	})
 
+	t.Run("Create SD-JWS with decoy disclosures", func(t *testing.T) {
+		r := require.New(t)
+
+		pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
+		r.NoError(err)
+
+		verifier, e := afjwt.NewEd25519Verifier(pubKey)
+		r.NoError(e)
+
+		token, err := New(issuer, claims, nil, afjwt.NewEd25519Signer(privKey),
+			WithDecoyDigests(true))
+		r.NoError(err)
+		sdJWTSerialized, err := token.Serialize(false)
+		r.NoError(err)
+
+		sdJWT := common.ParseSDJWT(sdJWTSerialized)
+		r.Equal(1, len(sdJWT.Disclosures))
+
+		afjwtToken, err := afjwt.Parse(sdJWT.JWTSerialized, afjwt.WithSignatureVerifier(verifier))
+		r.NoError(err)
+
+		var parsedClaims map[string]interface{}
+		err = afjwtToken.DecodeClaims(&parsedClaims)
+		r.NoError(err)
+
+		digests, err := common.GetDisclosureDigests(parsedClaims)
+		require.NoError(t, err)
+
+		if len(digests) < 1+decoyMinElements || len(digests) > 1+decoyMaxElements {
+			r.Fail(fmt.Sprintf("invalid number of digests: %d", len(digests)))
+		}
+	})
+
+	t.Run("error - create decoy disclosures failed", func(t *testing.T) {
+		r := require.New(t)
+
+		_, privKey, err := ed25519.GenerateKey(rand.Reader)
+		r.NoError(err)
+
+		token, err := New(issuer, claims, nil, afjwt.NewEd25519Signer(privKey),
+			WithDecoyDigests(true),
+			WithSaltFnc(func() (string, error) {
+				return "", fmt.Errorf("salt error")
+			}))
+		r.Error(err)
+		r.Nil(token)
+		r.Contains(err.Error(), "failed to create decoy disclosures: salt error")
+	})
+
 	t.Run("error - wrong hash function", func(t *testing.T) {
 		r := require.New(t)