diff --git a/pkg/doc/sdjwt/common/common.go b/pkg/doc/sdjwt/common/common.go index 714d4c0e0..b87b93e38 100644 --- a/pkg/doc/sdjwt/common/common.go +++ b/pkg/doc/sdjwt/common/common.go @@ -289,32 +289,42 @@ func GetSDAlg(claims map[string]interface{}) (string, error) { // GetKeyFromCredentialSubject returns key value from VC credential subject. func GetKeyFromCredentialSubject(key string, claims map[string]interface{}) (interface{}, bool) { - vcObj, ok := claims["vc"] + csObj, ok := GetCredentialSubject(claims) if !ok { return nil, false } - vc, ok := vcObj.(map[string]interface{}) + cs, ok := csObj.(map[string]interface{}) if !ok { return nil, false } - csObj, ok := vc["credentialSubject"] + obj, ok := cs[key] if !ok { return nil, false } - cs, ok := csObj.(map[string]interface{}) + return obj, true +} + +// GetCredentialSubject returns credential subject from vc. +func GetCredentialSubject(claims map[string]interface{}) (interface{}, bool) { + vcObj, ok := claims["vc"] if !ok { return nil, false } - obj, ok := cs[key] + vc, ok := vcObj.(map[string]interface{}) if !ok { return nil, false } - return obj, true + csObj, ok := vc["credentialSubject"] + if !ok { + return nil, false + } + + return csObj, true } // GetCNF returns confirmation claim 'cnf'. diff --git a/pkg/doc/sdjwt/integration_test.go b/pkg/doc/sdjwt/integration_test.go index 82d8eb403..2a79647d7 100644 --- a/pkg/doc/sdjwt/integration_test.go +++ b/pkg/doc/sdjwt/integration_test.go @@ -29,6 +29,8 @@ import ( const ( testIssuer = "https://example.com/issuer" + + year = 365 * 24 * 60 * time.Minute ) func TestSDJWTFlow(t *testing.T) { @@ -47,9 +49,17 @@ func TestSDJWTFlow(t *testing.T) { "last_name": "Smith", } + now := time.Now() + + var timeOpts []issuer.NewOpt + timeOpts = append(timeOpts, + issuer.WithNotBefore(jwt.NewNumericDate(now)), + issuer.WithIssuedAt(jwt.NewNumericDate(now)), + issuer.WithExpiry(jwt.NewNumericDate(now.Add(year)))) + t.Run("success - simple claims (flat option)", func(t *testing.T) { // Issuer will issue SD-JWT for specified claims. - token, err := issuer.New(testIssuer, claims, nil, signer) + token, err := issuer.New(testIssuer, claims, nil, signer, timeOpts...) r.NoError(err) var simpleClaimsFlatOption map[string]interface{} @@ -101,6 +111,9 @@ func TestSDJWTFlow(t *testing.T) { // Issuer will issue SD-JWT for specified claims and holder public key. token, err := issuer.New(testIssuer, claims, nil, signer, + issuer.WithNotBefore(jwt.NewNumericDate(now)), + issuer.WithIssuedAt(jwt.NewNumericDate(now)), + issuer.WithExpiry(jwt.NewNumericDate(now.Add(year))), issuer.WithHolderPublicKey(holderPublicJWK)) r.NoError(err) @@ -148,7 +161,7 @@ func TestSDJWTFlow(t *testing.T) { printObject(t, "Verified Claims", verifiedClaims) - // expected claims cnf, iss, exp, iat, nbf, given_name; last_name was not disclosed + // expected claims cnf, iss, given_name, iat, nbf, exp; last_name was not disclosed r.Equal(6, len(verifiedClaims)) }) @@ -193,8 +206,8 @@ func TestSDJWTFlow(t *testing.T) { verifier.WithSignatureVerifier(signatureVerifier)) r.NoError(err) - // expected claims iss, exp, iat, nbf, given_name, email, street_address - r.Equal(7, len(verifiedClaims)) + // expected claims iss, given_name, email, street_address; time options not provided + r.Equal(4, len(verifiedClaims)) printObject(t, "Verified Claims", verifiedClaims) }) @@ -239,8 +252,8 @@ func TestSDJWTFlow(t *testing.T) { verifier.WithSignatureVerifier(signatureVerifier)) r.NoError(err) - // expected claims iss, exp, iat, nbf, given_name, email, street_address - r.Equal(7, len(verifiedClaims)) + // expected claims iss, given_name, email, street_address; time options not provided + r.Equal(4, len(verifiedClaims)) printObject(t, "Verified Claims", verifiedClaims) }) @@ -265,9 +278,6 @@ func TestSDJWTFlow(t *testing.T) { // 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) @@ -342,6 +352,68 @@ func TestSDJWTFlow(t *testing.T) { r.Equal(len(vc), len(verifiedClaims)) }) + + t.Run("success - NewFromVC API", func(t *testing.T) { + holderPublicKey, holderPrivateKey, err := ed25519.GenerateKey(rand.Reader) + r.NoError(err) + + holderPublicJWK, err := jwksupport.JWKFromKey(holderPublicKey) + require.NoError(t, err) + + // create VC - we will use template here + var vc map[string]interface{} + err = json.Unmarshal([]byte(sampleVCFull), &vc) + r.NoError(err) + + token, err := issuer.NewFromVC(vc, nil, signer, + issuer.WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"), + issuer.WithHolderPublicKey(holderPublicJWK), + issuer.WithStructuredClaims(true)) + r.NoError(err) + + vcCombinedFormatForIssuance, err := token.Serialize(false) + r.NoError(err) + + fmt.Println(fmt.Sprintf("issuer SD-JWT: %s", vcCombinedFormatForIssuance)) + + claims, err := holder.Parse(vcCombinedFormatForIssuance, holder.WithSignatureVerifier(signatureVerifier)) + r.NoError(err) + + printObject(t, "Holder Claims", claims) + + r.Equal(4, len(claims)) + + const testAudience = "https://test.com/verifier" + const testNonce = "nonce" + + holderSigner := afjwt.NewEd25519Signer(holderPrivateKey) + + selectedDisclosures := getDisclosuresFromClaimNames([]string{"degree", "name", "spouse"}, 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{} @@ -442,3 +514,32 @@ const sampleVC = ` "type": "VerifiableCredential" } }` + +const sampleVCFull = ` +{ + "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": { + "degree": { + "degree": "MIT", + "type": "BachelorDegree" + }, + "name": "Jayden Doe", + "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1" + }, + "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 a541ecd79..4b357d9e3 100644 --- a/pkg/doc/sdjwt/issuer/issuer.go +++ b/pkg/doc/sdjwt/issuer/issuer.go @@ -32,8 +32,6 @@ const ( decoyMinElements = 1 decoyMaxElements = 4 - - year = 365 * 24 * 60 * time.Minute ) var mr = mathrand.New(mathrand.NewSource(time.Now().Unix())) // nolint:gochecknoglobals @@ -152,18 +150,10 @@ func WithStructuredClaims(flag bool) NewOpt { // 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) { - now := time.Now() - nOpts := &newOpts{ jsonMarshal: json.Marshal, getSalt: generateSalt, HashAlg: defaultHash, - - // TODO: Discuss with Troy about defaults - // TODO: We may not need defaulted values here at all - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now), - Expiry: jwt.NewNumericDate(now.Add(year)), } for _, opt := range opts { @@ -193,6 +183,45 @@ func New(issuer string, claims interface{}, headers jose.Headers, return &SelectiveDisclosureJWT{Disclosures: disclosures, SignedJWT: signedJWT}, nil } +// NewFromVC creates new signed Selective Disclosure JWT based on vc. +func NewFromVC(vc map[string]interface{}, headers jose.Headers, + signer jose.Signer, opts ...NewOpt) (*SelectiveDisclosureJWT, error) { + csObj, ok := common.GetCredentialSubject(vc) + if !ok { + return nil, fmt.Errorf("credential subject not found") + } + + cs, ok := csObj.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("credential subject must be an object") + } + + token, err := New("", cs, nil, &unsecuredJWTSigner{}, opts...) + if err != nil { + return nil, err + } + + var selectiveCredentialSubject map[string]interface{} + + err = token.DecodeClaims(&selectiveCredentialSubject) + if err != nil { + return nil, err + } + + // update VC with 'selective' credential subject + vc["vc"].(map[string]interface{})["credentialSubject"] = selectiveCredentialSubject + + // sign VC with 'selective' credential subject + signedJWT, err := afgjwt.NewSigned(vc, nil, signer) + if err != nil { + return nil, err + } + + sdJWT := &SelectiveDisclosureJWT{Disclosures: token.Disclosures, SignedJWT: signedJWT} + + return sdJWT, nil +} + func createPayload(issuer string, nOpts *newOpts) *payload { var cnf map[string]interface{} if nOpts.HolderPublicKey != nil { @@ -377,3 +406,15 @@ type payload struct { SDAlg string `json:"_sd_alg,omitempty"` } + +type unsecuredJWTSigner struct{} + +func (s unsecuredJWTSigner) Sign(_ []byte) ([]byte, error) { + return []byte(""), nil +} + +func (s unsecuredJWTSigner) Headers() jose.Headers { + return map[string]interface{}{ + jose.HeaderAlgorithm: afgjwt.AlgorithmNone, + } +} diff --git a/pkg/doc/sdjwt/issuer/issuer_test.go b/pkg/doc/sdjwt/issuer/issuer_test.go index 417d6f514..543edd8bc 100644 --- a/pkg/doc/sdjwt/issuer/issuer_test.go +++ b/pkg/doc/sdjwt/issuer/issuer_test.go @@ -374,6 +374,116 @@ func TestNew(t *testing.T) { }) } +func TestNewFromVC(t *testing.T) { + r := require.New(t) + + _, issuerPrivateKey, e := ed25519.GenerateKey(rand.Reader) + r.NoError(e) + + signer := afjwt.NewEd25519Signer(issuerPrivateKey) + + t.Run("success - structured claims + holder binding", func(t *testing.T) { + holderPublicKey, _, err := ed25519.GenerateKey(rand.Reader) + r.NoError(err) + + holderPublicJWK, err := jwksupport.JWKFromKey(holderPublicKey) + require.NoError(t, err) + + // create VC - we will use template here + var vc map[string]interface{} + err = json.Unmarshal([]byte(sampleVCFull), &vc) + r.NoError(err) + + token, err := NewFromVC(vc, nil, signer, + WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"), + WithHolderPublicKey(holderPublicJWK), + WithStructuredClaims(true)) + r.NoError(err) + + vcCombinedFormatForIssuance, err := token.Serialize(false) + r.NoError(err) + + fmt.Println(fmt.Sprintf("issuer SD-JWT: %s", vcCombinedFormatForIssuance)) + + var vcWithSelectedDisclosures map[string]interface{} + err = token.DecodeClaims(&vcWithSelectedDisclosures) + r.NoError(err) + + printObject(t, "VC with selected disclosures", vcWithSelectedDisclosures) + }) + + t.Run("success - flat claims + holder binding", func(t *testing.T) { + holderPublicKey, _, err := ed25519.GenerateKey(rand.Reader) + r.NoError(err) + + holderPublicJWK, err := jwksupport.JWKFromKey(holderPublicKey) + require.NoError(t, err) + + // create VC - we will use template here + var vc map[string]interface{} + err = json.Unmarshal([]byte(sampleVCFull), &vc) + r.NoError(err) + + token, err := NewFromVC(vc, nil, signer, + WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"), + WithHolderPublicKey(holderPublicJWK)) + r.NoError(err) + + vcCombinedFormatForIssuance, err := token.Serialize(false) + r.NoError(err) + + fmt.Println(fmt.Sprintf("issuer SD-JWT: %s", vcCombinedFormatForIssuance)) + + var vcWithSelectedDisclosures map[string]interface{} + err = token.DecodeClaims(&vcWithSelectedDisclosures) + r.NoError(err) + + printObject(t, "VC with selected disclosures", vcWithSelectedDisclosures) + }) + + t.Run("error - missing credential subject", func(t *testing.T) { + vc := make(map[string]interface{}) + + token, err := NewFromVC(vc, nil, signer, + WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"), + WithStructuredClaims(true)) + r.Error(err) + r.Nil(token) + + r.Contains(err.Error(), "credential subject not found") + }) + + t.Run("error - credential subject no an object", func(t *testing.T) { + vc := map[string]interface{}{ + "vc": map[string]interface{}{ + "credentialSubject": "invalid", + }, + } + + token, err := NewFromVC(vc, nil, signer, + WithID("did:example:ebfeb1f712ebc6f1c276e12ec21"), + WithStructuredClaims(true)) + r.Error(err) + r.Nil(token) + + r.Contains(err.Error(), "credential subject must be an object") + }) + + t.Run("error - signing error", func(t *testing.T) { + // create VC - we will use template here + var vc map[string]interface{} + err := json.Unmarshal([]byte(sampleVCFull), &vc) + r.NoError(err) + + token, err := NewFromVC(vc, nil, &mockSigner{Err: fmt.Errorf("signing error")}, + WithID("did:example:ebfeb1f712ebc6f1c276e12ec21")) + r.Error(err) + r.Nil(token) + + r.Contains(err.Error(), "create JWS: sign JWS: sign JWS verification data: signing error") + }) +} + func TestJSONWebToken_DecodeClaims(t *testing.T) { token, err := getValidJSONWebToken( WithJSONMarshaller(jsonMarshalWithSpace), @@ -667,14 +777,66 @@ func prettyPrint(msg []byte) (string, error) { return prettyJSON.String(), nil } -type unsecuredJWTSigner struct{} +func printObject(t *testing.T, name string, obj interface{}) { + t.Helper() + + objBytes, err := json.Marshal(obj) + require.NoError(t, err) + + prettyJSON, err := prettyPrint(objBytes) + require.NoError(t, err) + + fmt.Println(name + ":") + fmt.Println(prettyJSON) +} -func (s unsecuredJWTSigner) Sign(_ []byte) ([]byte, error) { - return []byte(""), nil +// Signer defines JWS Signer interface. It makes signing of data and provides custom JWS headers relevant to the signer. +type mockSigner struct { + Err error } -func (s unsecuredJWTSigner) Headers() afjose.Headers { - return map[string]interface{}{ - afjose.HeaderAlgorithm: afjwt.AlgorithmNone, +// 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() afjose.Headers { + headers := make(afjose.Headers) + headers["alg"] = "EdDSA" + + return headers } + +const sampleVCFull = ` +{ + "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": { + "degree": { + "degree": "MIT", + "type": "BachelorDegree" + }, + "name": "Jayden Doe", + "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1" + }, + "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/verifier/verifier_test.go b/pkg/doc/sdjwt/verifier/verifier_test.go index 3b945b7b6..de8781bbb 100644 --- a/pkg/doc/sdjwt/verifier/verifier_test.go +++ b/pkg/doc/sdjwt/verifier/verifier_test.go @@ -35,6 +35,8 @@ const ( testAudience = "https://test.com/verifier" testNonce = "nonce" testSDAlg = "sha-256" + + year = 365 * 24 * 60 * time.Minute ) func TestParse(t *testing.T) { @@ -46,7 +48,15 @@ func TestParse(t *testing.T) { signer := afjwt.NewEd25519Signer(privKey) selectiveClaims := map[string]interface{}{"given_name": "Albert"} - token, e := issuer.New(testIssuer, selectiveClaims, nil, signer) + now := time.Now() + + var timeOpts []issuer.NewOpt + timeOpts = append(timeOpts, + issuer.WithNotBefore(jwt.NewNumericDate(now)), + issuer.WithIssuedAt(jwt.NewNumericDate(now)), + issuer.WithExpiry(jwt.NewNumericDate(now.Add(year)))) + + token, e := issuer.New(testIssuer, selectiveClaims, nil, signer, timeOpts...) r.NoError(e) combinedFormatForIssuance, e := token.Serialize(false) r.NoError(e) @@ -62,7 +72,6 @@ func TestParse(t *testing.T) { require.NotNil(t, claims) // expected claims iss, exp, iat, nbf, given_name - // TODO: should we default exp, iat, nbf require.Equal(t, 5, len(claims)) }) @@ -103,7 +112,10 @@ func TestParse(t *testing.T) { claims, err := Parse(cfp, WithSignatureVerifier(v)) r.NoError(err) - require.Equal(t, 5, len(claims)) + + // expected claims iss, given_name + require.Equal(t, 2, len(claims)) + printObject(t, "claims", claims) }) t.Run("success - valid SD-JWT times", func(t *testing.T) { @@ -289,8 +301,8 @@ func TestHolderBinding(t *testing.T) { WithExpectedNonceForHolderBinding(testNonce)) r.NoError(err) - // expected claims cnf, iss, exp, iat, nbf, given_name; last_name was not disclosed - r.Equal(6, len(verifiedClaims)) + // expected claims cnf, iss, given_name; last_name was not disclosed + r.Equal(3, len(verifiedClaims)) }) t.Run("success - with holder binding; expected nonce and audience not specified", func(t *testing.T) { @@ -310,8 +322,8 @@ func TestHolderBinding(t *testing.T) { WithHolderBindingRequired(true)) r.NoError(err) - // expected claims cnf, iss, exp, iat, nbf, given_name; last_name was not disclosed - r.Equal(6, len(verifiedClaims)) + // expected claims cnf, iss, given_name; last_name was not disclosed + r.Equal(3, len(verifiedClaims)) }) t.Run("success - with holder binding (required)", func(t *testing.T) { @@ -334,8 +346,8 @@ func TestHolderBinding(t *testing.T) { WithExpectedNonceForHolderBinding(testNonce)) r.NoError(err) - // expected claims cnf, iss, exp, iat, nbf, given_name; last_name was not disclosed - r.Equal(6, len(verifiedClaims)) + // expected claims cnf, iss, given_name; last_name was not disclosed + r.Equal(3, len(verifiedClaims)) }) t.Run("error - holder binding required, however not provided by the holder", func(t *testing.T) { @@ -742,7 +754,15 @@ func TestGetVerifiedPayload(t *testing.T) { signer := afjwt.NewEd25519Signer(privKey) selectiveClaims := map[string]interface{}{"given_name": "Albert"} - token, e := issuer.New(testIssuer, selectiveClaims, nil, signer) + now := time.Now() + + var timeOpts []issuer.NewOpt + timeOpts = append(timeOpts, + issuer.WithNotBefore(jwt.NewNumericDate(now)), + issuer.WithIssuedAt(jwt.NewNumericDate(now)), + issuer.WithExpiry(jwt.NewNumericDate(now.Add(year)))) + + token, e := issuer.New(testIssuer, selectiveClaims, nil, signer, timeOpts...) r.NoError(e) t.Run("success", func(t *testing.T) {