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

Issue:
SD-JWT Holder: Add Holder Binding
The Holder MAY add an optional JWT to prove Holder Binding to the Verifier. 
Usually, a nonce and aud claim are included to show that the proof is intended for the Verifier and to prevent replay attacks.

Example Holder Binding JWT payload:
`{
  "nonce": "XZOUco1u_gEPknxS78sWWg",
  "aud": "https://example.com/verifier",
  "iat": 1670366430
}`


Pull request description:
feat: SD-JWT Holder: Add Holder Binding
The Holder MAY add an optional JWT to prove Holder Binding to the Verifier. Nonce and aud claims are included to show that the proof is intended for the Verifier.

Closes #3470

Signed-off-by: Sandra Vrtikapa <sandra.vrtikapa@securekey.com>




Patch:
diff --git a/pkg/doc/sdjwt/holder/holder.go b/pkg/doc/sdjwt/holder/holder.go
index 35a7af88..5281c9bc 100644
--- a/pkg/doc/sdjwt/holder/holder.go
+++ b/pkg/doc/sdjwt/holder/holder.go
@@ -9,6 +9,8 @@ package holder
 import (
 	"fmt"
 
+	"github.com/go-jose/go-jose/v3/jwt"
+
 	"github.com/hyperledger/aries-framework-go/pkg/doc/jose"
 	afgjwt "github.com/hyperledger/aries-framework-go/pkg/doc/jwt"
 	"github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/common"
@@ -93,8 +95,42 @@ func getClaims(disclosures []string) ([]*Claim, error) {
 	return claims, nil
 }
 
+// BindingPayload represents holder binding payload.
+type BindingPayload struct {
+	Nonce    string           `json:"nonce,omitempty"`
+	Audience string           `json:"aud,omitempty"`
+	IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
+}
+
+// BindingInfo defines holder binding payload and signer.
+type BindingInfo struct {
+	Payload BindingPayload
+	Signer  jose.Signer
+}
+
+// options holds options for holder.
+type options struct {
+	holderBindingInfo *BindingInfo
+}
+
+// Option is a holder option.
+type Option func(opts *options)
+
+// WithHolderBinding option to set optional holder binding.
+func WithHolderBinding(info *BindingInfo) Option {
+	return func(opts *options) {
+		opts.holderBindingInfo = info
+	}
+}
+
 // DiscloseClaims discloses claims with specified claim names.
-func DiscloseClaims(combinedFormatForIssuance string, claimNames []string) (string, error) {
+func DiscloseClaims(combinedFormatForIssuance string, claimNames []string, opts ...Option) (string, error) {
+	hOpts := &options{}
+
+	for _, opt := range opts {
+		opt(hOpts)
+	}
+
 	cfi := common.ParseCombinedFormatForIssuance(combinedFormatForIssuance)
 
 	if len(cfi.Disclosures) == 0 {
@@ -111,17 +147,37 @@ func DiscloseClaims(combinedFormatForIssuance string, claimNames []string) (stri
 	for _, claimName := range claimNames {
 		if index := getDisclosureByClaimName(claimName, disclosures); index != notFound {
 			selectedDisclosures = append(selectedDisclosures, cfi.Disclosures[index])
+		} else {
+			return "", fmt.Errorf("claim name '%s' not found", claimName)
+		}
+	}
+
+	var hbJWT string
+	if hOpts.holderBindingInfo != nil {
+		hbJWT, err = createHolderBinding(hOpts.holderBindingInfo)
+		if err != nil {
+			return "", fmt.Errorf("failed to create holder binding: %w", err)
 		}
 	}
 
 	cf := common.CombinedFormatForPresentation{
-		SDJWT:       cfi.SDJWT,
-		Disclosures: selectedDisclosures,
+		SDJWT:         cfi.SDJWT,
+		Disclosures:   selectedDisclosures,
+		HolderBinding: hbJWT,
 	}
 
 	return cf.Serialize(), nil
 }
 
+func createHolderBinding(info *BindingInfo) (string, error) {
+	hbJWT, err := afgjwt.NewSigned(info.Payload, nil, info.Signer)
+	if err != nil {
+		return "", err
+	}
+
+	return hbJWT.Serialize(false)
+}
+
 func getDisclosureByClaimName(name string, disclosures []*common.DisclosureClaim) int {
 	for index, disclosure := range disclosures {
 		if disclosure.Name == name {


Test patch:
diff --git a/pkg/doc/sdjwt/holder/holder_test.go b/pkg/doc/sdjwt/holder/holder_test.go
index a9de16bb..5eb0110a 100644
--- a/pkg/doc/sdjwt/holder/holder_test.go
+++ b/pkg/doc/sdjwt/holder/holder_test.go
@@ -14,7 +14,9 @@ import (
 	"fmt"
 	"strings"
 	"testing"
+	"time"
 
+	"github.com/go-jose/go-jose/v3/jwt"
 	"github.com/stretchr/testify/require"
 
 	"github.com/hyperledger/aries-framework-go/pkg/doc/jose"
@@ -123,6 +125,40 @@ func TestDiscloseClaims(t *testing.T) {
 		require.Equal(t, combinedFormatForIssuance+common.DisclosureSeparator, combinedFormatForPresentation)
 	})
 
+	t.Run("success - with holder binding", func(t *testing.T) {
+		_, holderPrivKey, e := ed25519.GenerateKey(rand.Reader)
+		r.NoError(e)
+
+		holderSigner := afjwt.NewEd25519Signer(holderPrivKey)
+
+		combinedFormatForPresentation, err := DiscloseClaims(combinedFormatForIssuance, []string{"given_name"},
+			WithHolderBinding(&BindingInfo{
+				Payload: BindingPayload{
+					Audience: "https://example.com/verifier",
+					Nonce:    "nonce",
+					IssuedAt: jwt.NewNumericDate(time.Now()),
+				},
+				Signer: holderSigner,
+			}))
+		r.NoError(err)
+		r.NotEmpty(combinedFormatForPresentation)
+		r.Contains(combinedFormatForPresentation, combinedFormatForIssuance+common.DisclosureSeparator)
+	})
+
+	t.Run("error - failed to create holder binding due to signing error", func(t *testing.T) {
+		combinedFormatForPresentation, err := DiscloseClaims(combinedFormatForIssuance, []string{"given_name"},
+			WithHolderBinding(&BindingInfo{
+				Payload: BindingPayload{},
+				Signer:  &mockSigner{Err: fmt.Errorf("signing error")},
+			}))
+
+		r.Error(err)
+		r.Empty(combinedFormatForPresentation)
+
+		r.Contains(err.Error(),
+			"failed to create holder binding: create JWS: sign JWS: sign JWS verification data: signing error")
+	})
+
 	t.Run("error - no disclosure(s)", func(t *testing.T) {
 		cfi := common.ParseCombinedFormatForIssuance(combinedFormatForIssuance)
 
@@ -140,6 +176,14 @@ func TestDiscloseClaims(t *testing.T) {
 		r.Empty(combinedFormatForPresentation)
 		r.Contains(err.Error(), "failed to unmarshal disclosure array")
 	})
+
+	t.Run("error - claim name not found", func(t *testing.T) {
+		combinedFormatForPresentation, err := DiscloseClaims(combinedFormatForIssuance,
+			[]string{"given_name", "non_existent"})
+		r.Error(err)
+		r.Empty(combinedFormatForPresentation)
+		r.Contains(err.Error(), "claim name 'non_existent' not found")
+	})
 }
 
 func TestGetClaims(t *testing.T) {
@@ -182,6 +226,28 @@ func buildJWS(signer jose.Signer, claims interface{}) (string, error) {
 	return jws.SerializeCompact(false)
 }
 
+// Signer defines JWS Signer interface. It makes signing of data and provides custom JWS headers relevant to the signer.
+type mockSigner struct {
+	Err error
+}
+
+// Sign signs.
+func (m *mockSigner) Sign(_ []byte) ([]byte, error) {
+	if m.Err != nil {
+		return nil, m.Err
+	}
+
+	return nil, nil
+}
+
+// Headers provides JWS headers.
+func (m *mockSigner) Headers() jose.Headers {
+	headers := make(jose.Headers)
+	headers["alg"] = "EdDSA"
+
+	return headers
+}
+
 // nolint: lll
 const additionalDisclosure = `WyIzanFjYjY3ejl3a3MwOHp3aUs3RXlRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd`