From 5778522f76ad5b73d4686c1a8fa7d9fc354db03f Mon Sep 17 00:00:00 2001 From: Sandra Vrtikapa Date: Mon, 23 Jan 2023 17:56:07 -0500 Subject: [PATCH] chore: Support verification of 'vc' claims with holder binding (#3490) Support verification of 'vc' claims with holder binding Closes #3489 Signed-off-by: Sandra Vrtikapa Signed-off-by: Sandra Vrtikapa --- pkg/doc/sdjwt/common/common.go | 27 +++++- pkg/doc/sdjwt/common/common_test.go | 88 +++++++++++++++++- pkg/doc/sdjwt/integration_test.go | 134 ++++++++++++++++++++++++++++ pkg/doc/sdjwt/issuer/issuer.go | 12 ++- pkg/doc/sdjwt/issuer/issuer_test.go | 1 + pkg/doc/sdjwt/verifier/verifier.go | 18 +--- 6 files changed, 257 insertions(+), 23 deletions(-) diff --git a/pkg/doc/sdjwt/common/common.go b/pkg/doc/sdjwt/common/common.go index bb51a2f89..714d4c0e0 100644 --- a/pkg/doc/sdjwt/common/common.go +++ b/pkg/doc/sdjwt/common/common.go @@ -22,6 +22,7 @@ const ( SDAlgorithmKey = "_sd_alg" SDKey = "_sd" + CNFKey = "cnf" disclosureParts = 3 saltIndex = 0 @@ -272,7 +273,7 @@ func GetSDAlg(claims map[string]interface{}) (string, error) { obj, ok := claims[SDAlgorithmKey] if !ok { // if claims contain 'vc' claim it may be present in credential subject - obj, ok = getSDAlgFromCredentialSubject(claims) + obj, ok = GetKeyFromCredentialSubject(SDAlgorithmKey, claims) if !ok { return "", fmt.Errorf("%s must be present in SD-JWT", SDAlgorithmKey) } @@ -286,8 +287,8 @@ func GetSDAlg(claims map[string]interface{}) (string, error) { return alg, nil } -// getSDAlgFromCredentialSubject returns SD algorithm from VC credential subject. -func getSDAlgFromCredentialSubject(claims map[string]interface{}) (interface{}, bool) { +// GetKeyFromCredentialSubject returns key value from VC credential subject. +func GetKeyFromCredentialSubject(key string, claims map[string]interface{}) (interface{}, bool) { vcObj, ok := claims["vc"] if !ok { return nil, false @@ -308,7 +309,7 @@ func getSDAlgFromCredentialSubject(claims map[string]interface{}) (interface{}, return nil, false } - obj, ok := cs[SDAlgorithmKey] + obj, ok := cs[key] if !ok { return nil, false } @@ -316,6 +317,24 @@ func getSDAlgFromCredentialSubject(claims map[string]interface{}) (interface{}, return obj, true } +// GetCNF returns confirmation claim 'cnf'. +func GetCNF(claims map[string]interface{}) (map[string]interface{}, error) { + obj, ok := claims[CNFKey] + if !ok { + obj, ok = GetKeyFromCredentialSubject(CNFKey, claims) + if !ok { + return nil, fmt.Errorf("%s must be present in SD-JWT", CNFKey) + } + } + + cnf, ok := obj.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an object", CNFKey) + } + + return cnf, nil +} + // GetDisclosureDigests returns digests from claims map. func GetDisclosureDigests(claims map[string]interface{}) (map[string]bool, error) { disclosuresObj, ok := claims[SDKey] diff --git a/pkg/doc/sdjwt/common/common_test.go b/pkg/doc/sdjwt/common/common_test.go index 2166ea4b0..1f058e9f2 100644 --- a/pkg/doc/sdjwt/common/common_test.go +++ b/pkg/doc/sdjwt/common/common_test.go @@ -446,7 +446,7 @@ func TestGetDisclosedClaims(t *testing.T) { }) } -func TestGetSDAlgFromVC(t *testing.T) { +func TestGetSDAlg(t *testing.T) { r := require.New(t) t.Run("success", func(t *testing.T) { @@ -567,6 +567,55 @@ func TestGetSDAlgFromVC(t *testing.T) { }) } +func TestGetCNF(t *testing.T) { + r := require.New(t) + + t.Run("success - cnf is at the top level", func(t *testing.T) { + claims := make(map[string]interface{}) + claims["cnf"] = map[string]interface{}{ + "jwk": map[string]interface{}{ + "kty": "RSA", + "e": "AQAB", + "n": "pm4bOHBg-oYhAyPWzR56AWX3rUIXp11", + }, + } + + cnf, err := GetCNF(claims) + r.NoError(err) + r.NotEmpty(cnf["jwk"]) + }) + + t.Run("success - cnf is in VC credential subject", func(t *testing.T) { + var payload map[string]interface{} + + err := json.Unmarshal([]byte(vcSample), &payload) + require.NoError(t, err) + + cnf, err := GetCNF(payload) + r.NoError(err) + r.NotEmpty(cnf["jwk"]) + }) + + t.Run("error - cnf not found (empty claims)", func(t *testing.T) { + cnf, err := GetCNF(make(map[string]interface{})) + r.Error(err) + r.Empty(cnf) + + r.Contains(err.Error(), "cnf must be present in SD-JWT") + }) + + t.Run("error - cnf is not an object", func(t *testing.T) { + claims := make(map[string]interface{}) + claims["cnf"] = "abc" + + cnf, err := GetCNF(claims) + r.Error(err) + r.Empty(cnf) + + r.Contains(err.Error(), "cnf must be an object") + }) +} + type NoopSignatureVerifier struct { } @@ -595,3 +644,40 @@ type payload struct { const specExample2bJWT = `eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIjBsa1NjS2ppSk1IZWRKZnE3c0pCN0hRM3FvbUdmckVMYm81Z1podktSV28iLCAiMWgyOWdnUWkxeG91LV9OalZ5eW9DaEsyYXN3VXRvMlVqQ2ZGLTFMYWhBOCIsICIzQ29MVUxtRHh4VXdfTGR5WUVUanVkdVh1RXBHdUJ5NHJYSG90dUQ0MFg0IiwgIkFJRHlveHgxaXB5NDUtR0ZwS2d2Yy1ISWJjVnJsTGxyWUxYbXgzZXYyZTQiLCAiT2x0aGZSb0ZkUy1KNlM4Mk9XbHJPNHBXaG9lUk1ySF9LR1BfaDZFYXBZUSIsICJyNGRicEdlZWlhMDJTeUdMNWdCZEhZMXc4SWhqVDN4eDA1UnNmeXlIVWs0Il0sICJhZGRyZXNzIjogeyJfc2QiOiBbIjZPS053bkdHS1dYQ0k5dWlqTkFzdjY0dTIyZUxTNHJNZExObGcxZnFKcDQiLCAiSEVWTWdELU5LSzVOdlhQYkFSb3JWZE9ESVRta1V5dU1wQ3NfbTdIWG5ZYyIsICJVcTAyblY3M0swYmRSSzIzcnphYm1uRGE0TzhZTlFadnQ5eDhMeWtva19ZIiwgIm94RlJpbG5vMjZVWWU3a3FNTTRiZHE4SXZOTXRJaTZGOHB0dC11aVBMYk0iXX0sICJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9pc3N1ZXIiLCAiaWF0IjogMTUxNjIzOTAyMiwgImV4cCI6IDE1MTYyNDcwMjIsICJfc2RfYWxnIjogInNoYS0yNTYifQ.M45AUExpi9THOTVIHfBmb2GL0WXJf4TeWB5QPmsxdBkj9pUcLOPR8YVafLIt8m_imYHTBYYcAyf7qSnquxMxGQ` // nolint:lll const specExample2bDisclosures = `~WyJSdHczZUFFUE5wWjIwTkhZSzNNRWNnIiwgImZhbWlseV9uYW1lIiwgIlx1NWM3MVx1NzUzMCJd~WyJicjgxenVSc0NUcXJuWEp4MHVqMkRRIiwgImdpdmVuX25hbWUiLCAiXHU1OTJhXHU5MGNlIl0~WyI1Z2NXRmxWSEM1VVEwbktrallybDlnIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0~WyJJTms2bkx4WGFybDF4NmVabHdBOTV3IiwgImVtYWlsIiwgIlwidW51c3VhbCBlbWFpbCBhZGRyZXNzXCJAbmlob24uY29tIl0~WyJNOVY2N3V0UC1hTF9lR1B0UU5hM0RRIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ~WyJzNFhNSmxXQ2Eza3hDWk4wSVVrbnlBIiwgImNvdW50cnkiLCAiSlAiXQ~` // nolint:lll + +const vcSample = ` +{ + "iat": 1673987547, + "iss": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "jti": "http://example.edu/credentials/1872", + "nbf": 1673987547, + "sub": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1" + ], + "credentialSubject": { + "_sd": [ + "GJFkje8c1iayy1HQW__JEhuHTz8QGlkcMaxDTjT1wpQ", + "goPn0hokFnQBktqzXxgTK-4CCldmLjlRwUVCIltDyRg", + "FAiNODIxDMwGTljNYcVKkx7LBsr1pb-U6XuAfVFuOGY" + ], + "_sd_alg": "sha-256", + "cnf": { + "jwk": { + "crv": "Ed25519", + "kty": "OKP", + "x": "7jtkxxk0Pb3E0O6JXJiN8HyIp2DpCiqaHCWfMXl9ZFo" + } + }, + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21" + }, + "first_name": "First name", + "id": "http://example.edu/credentials/1872", + "info": "Info", + "issuanceDate": "2023-01-17T22:32:27.468109817+02:00", + "issuer": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "last_name": "Last name", + "type": "VerifiableCredential" + } +}` diff --git a/pkg/doc/sdjwt/integration_test.go b/pkg/doc/sdjwt/integration_test.go index a41b07212..82d8eb403 100644 --- a/pkg/doc/sdjwt/integration_test.go +++ b/pkg/doc/sdjwt/integration_test.go @@ -18,8 +18,10 @@ import ( "github.com/go-jose/go-jose/v3/jwt" "github.com/stretchr/testify/require" + afjose "github.com/hyperledger/aries-framework-go/pkg/doc/jose" "github.com/hyperledger/aries-framework-go/pkg/doc/jose/jwk/jwksupport" afjwt "github.com/hyperledger/aries-framework-go/pkg/doc/jwt" + "github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/common" "github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/holder" "github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/issuer" "github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/verifier" @@ -242,6 +244,116 @@ func TestSDJWTFlow(t *testing.T) { printObject(t, "Verified Claims", verifiedClaims) }) + + t.Run("success - create VC plus holder binding", func(t *testing.T) { + holderPublicKey, holderPrivateKey, err := ed25519.GenerateKey(rand.Reader) + r.NoError(err) + + holderPublicJWK, err := jwksupport.JWKFromKey(holderPublicKey) + require.NoError(t, err) + + credentialSubject := map[string]interface{}{ + "degree": map[string]interface{}{ + "degree": "MIT", + "type": "BachelorDegree", + }, + "name": "Jayden Doe", + "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1", + } + + // Note: if issuer can be empty; should I add it as an option then + // All reference apps have it as part of call + token, err := issuer.New("", credentialSubject, nil, &unsecuredJWTSigner{}, + issuer.WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"), + issuer.WithExpiry(nil), + issuer.WithNotBefore(nil), + issuer.WithIssuedAt(nil), + issuer.WithHolderPublicKey(holderPublicJWK)) + r.NoError(err) + + credSubjectCFI, err := token.Serialize(false) + r.NoError(err) + + cfi := common.ParseCombinedFormatForIssuance(credSubjectCFI) + + var selectiveCredentialSubject map[string]interface{} + err = token.DecodeClaims(&selectiveCredentialSubject) + r.NoError(err) + + printObject(t, "Selective Credential Subject", selectiveCredentialSubject) + + // create VC - we will use template here + var vc map[string]interface{} + err = json.Unmarshal([]byte(sampleVC), &vc) + r.NoError(err) + + // update VC with 'selective' credential subject + vc["vc"].(map[string]interface{})["credentialSubject"] = selectiveCredentialSubject + + // sign VC with 'selective' credential subject + signedJWT, err := afjwt.NewSigned(vc, nil, signer) + r.NoError(err) + + sdJWT := &issuer.SelectiveDisclosureJWT{Disclosures: cfi.Disclosures, SignedJWT: signedJWT} + + // create combined format for issuance for VC + vcCombinedFormatForIssuance, err := sdJWT.Serialize(false) + r.NoError(err) + + fmt.Println(fmt.Sprintf("issuer SD-JWT: %s", vcCombinedFormatForIssuance)) + + // Holder will parse combined format for issuance and hold on to that + // combined format for issuance and the claims that can be selected. + claims, err := holder.Parse(vcCombinedFormatForIssuance, holder.WithSignatureVerifier(signatureVerifier)) + r.NoError(err) + + printObject(t, "Holder Claims", claims) + + r.Equal(3, len(claims)) + + const testAudience = "https://test.com/verifier" + const testNonce = "nonce" + + holderSigner := afjwt.NewEd25519Signer(holderPrivateKey) + + selectedDisclosures := getDisclosuresFromClaimNames([]string{"given_name", "email", "street_address"}, claims) + + // Holder will disclose only sub-set of claims to verifier. + combinedFormatForPresentation, err := holder.CreatePresentation(vcCombinedFormatForIssuance, selectedDisclosures, + holder.WithHolderBinding(&holder.BindingInfo{ + Payload: holder.BindingPayload{ + Nonce: testNonce, + Audience: testAudience, + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + Signer: holderSigner, + })) + r.NoError(err) + + fmt.Println(fmt.Sprintf("holder SD-JWT: %s", combinedFormatForPresentation)) + + // Verifier will validate combined format for presentation and create verified claims. + // In this case it will be VC since VC was passed in. + verifiedClaims, err := verifier.Parse(combinedFormatForPresentation, + verifier.WithSignatureVerifier(signatureVerifier)) + r.NoError(err) + + printObject(t, "Verified Claims", verifiedClaims) + + r.Equal(len(vc), len(verifiedClaims)) + }) +} + +type unsecuredJWTSigner struct{} + +func (s unsecuredJWTSigner) Sign(_ []byte) ([]byte, error) { + return []byte(""), nil +} + +func (s unsecuredJWTSigner) Headers() afjose.Headers { + return map[string]interface{}{ + afjose.HeaderAlgorithm: afjwt.AlgorithmNone, + } } func createComplexClaims() map[string]interface{} { @@ -308,3 +420,25 @@ func prettyPrint(msg []byte) (string, error) { return prettyJSON.String(), nil } + +const sampleVC = ` +{ + "iat": 1673987547, + "iss": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "jti": "http://example.edu/credentials/1872", + "nbf": 1673987547, + "sub": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1" + ], + "credentialSubject": {}, + "first_name": "First name", + "id": "http://example.edu/credentials/1872", + "info": "Info", + "issuanceDate": "2023-01-17T22:32:27.468109817+02:00", + "issuer": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "last_name": "Last name", + "type": "VerifiableCredential" + } +}` diff --git a/pkg/doc/sdjwt/issuer/issuer.go b/pkg/doc/sdjwt/issuer/issuer.go index 71d956516..a541ecd79 100644 --- a/pkg/doc/sdjwt/issuer/issuer.go +++ b/pkg/doc/sdjwt/issuer/issuer.go @@ -44,6 +44,7 @@ type Claims jwt.Claims // newOpts holds options for creating new SD-JWT. type newOpts struct { Subject string + JTI string ID string Expiry *jwt.NumericDate @@ -106,6 +107,13 @@ func WithSubject(subject string) NewOpt { } } +// WithJTI is an option for SD-JWT payload. +func WithJTI(jti string) NewOpt { + return func(opts *newOpts) { + opts.JTI = jti + } +} + // WithID is an option for SD-JWT payload. func WithID(id string) NewOpt { return func(opts *newOpts) { @@ -194,6 +202,7 @@ func createPayload(issuer string, nOpts *newOpts) *payload { payload := &payload{ Issuer: issuer, + JTI: nOpts.JTI, ID: nOpts.ID, Subject: nOpts.Subject, IssuedAt: nOpts.IssuedAt, @@ -357,7 +366,8 @@ func generateSalt() (string, error) { type payload struct { Issuer string `json:"iss,omitempty"` Subject string `json:"sub,omitempty"` - ID string `json:"jti,omitempty"` + ID string `json:"id,omitempty"` + JTI string `json:"jti,omitempty"` Expiry *jwt.NumericDate `json:"exp,omitempty"` NotBefore *jwt.NumericDate `json:"nbf,omitempty"` diff --git a/pkg/doc/sdjwt/issuer/issuer_test.go b/pkg/doc/sdjwt/issuer/issuer_test.go index 0a9fb00c8..417d6f514 100644 --- a/pkg/doc/sdjwt/issuer/issuer_test.go +++ b/pkg/doc/sdjwt/issuer/issuer_test.go @@ -154,6 +154,7 @@ func TestNew(t *testing.T) { WithIssuedAt(jwt.NewNumericDate(issued)), WithExpiry(jwt.NewNumericDate(expiry)), WithNotBefore(jwt.NewNumericDate(notBefore)), + WithJTI("jti"), WithID("id"), WithSubject("subject"), WithSaltFnc(generateSalt), diff --git a/pkg/doc/sdjwt/verifier/verifier.go b/pkg/doc/sdjwt/verifier/verifier.go index f9286e75f..e7a186abe 100644 --- a/pkg/doc/sdjwt/verifier/verifier.go +++ b/pkg/doc/sdjwt/verifier/verifier.go @@ -19,8 +19,6 @@ import ( "github.com/hyperledger/aries-framework-go/pkg/doc/signature/verifier" ) -const confirmationClaim = "cnf" - // jwtParseOpts holds options for the SD-JWT parsing. type parseOpts struct { detachedPayload []byte @@ -218,7 +216,7 @@ func verifyHolderJWT(holderJWT *afgjwt.JSONWebToken, pOpts *parseOpts) error { } func getSignatureVerifier(claims map[string]interface{}) (jose.SignatureVerifier, error) { - cnf, err := getCNF(claims) + cnf, err := common.GetCNF(claims) if err != nil { return nil, err } @@ -231,20 +229,6 @@ func getSignatureVerifier(claims map[string]interface{}) (jose.SignatureVerifier return signatureVerifier, nil } -func getCNF(claims map[string]interface{}) (map[string]interface{}, error) { - obj, ok := claims[confirmationClaim] - if !ok { - return nil, fmt.Errorf("%s must be present in SD-JWT", confirmationClaim) - } - - cnf, ok := obj.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("%s must be an object", confirmationClaim) - } - - return cnf, nil -} - // getSignatureVerifierFromCNF will evolve over time as we support more cnf modes and algorithms. func getSignatureVerifierFromCNF(cnf map[string]interface{}) (jose.SignatureVerifier, error) { jwkObj, ok := cnf["jwk"]