diff --git a/vc/vc.go b/vc/vc.go index f1a8935..0076e6b 100644 --- a/vc/vc.go +++ b/vc/vc.go @@ -1,9 +1,11 @@ package vc import ( + "context" "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/jws" "github.com/lestrrat-go/jwx/jwt" "github.com/nuts-foundation/go-did/did" "strings" @@ -37,9 +39,9 @@ func VCContextV1URI() ssi.URI { const ( // JSONLDCredentialProofFormat is the format for JSON-LD based credentials. JSONLDCredentialProofFormat string = "ldp_vc" - // JWTCredentialsProofFormat is the format for JWT based credentials. + // JWTCredentialProofFormat is the format for JWT based credentials. // Note: various specs have not yet decided on the exact const (jwt_vc or jwt_vc_json, etc), so this is subject to change. - JWTCredentialsProofFormat = "jwt_vc" + JWTCredentialProofFormat = "jwt_vc" ) var errCredentialSubjectWithoutID = errors.New("credential subjects have no ID") @@ -74,8 +76,10 @@ func parseJWTCredential(raw string) (*VerifiableCredential, error) { } } // parse exp - exp := token.Expiration() - result.ExpirationDate = &exp + if _, ok := token.Get(jwt.ExpirationKey); ok { + exp := token.Expiration() + result.ExpirationDate = &exp + } // parse iss if iss, err := parseURIClaim(token, jwt.IssuerKey); err != nil { return nil, err @@ -83,7 +87,9 @@ func parseJWTCredential(raw string) (*VerifiableCredential, error) { result.Issuer = *iss } // parse nbf - result.IssuanceDate = token.NotBefore() + if _, ok := token.Get(jwt.NotBeforeKey); ok { + result.IssuanceDate = token.NotBefore() + } // parse sub if token.Subject() != "" { for _, credentialSubjectInterf := range result.CredentialSubject { @@ -99,7 +105,7 @@ func parseJWTCredential(raw string) (*VerifiableCredential, error) { } else if jti != nil { result.ID = jti } - result.format = JWTCredentialsProofFormat + result.format = JWTCredentialProofFormat result.raw = raw result.token = token return &result, nil @@ -190,6 +196,11 @@ func (vc VerifiableCredential) Proofs() ([]Proof, error) { } func (vc VerifiableCredential) MarshalJSON() ([]byte, error) { + if vc.format == JWTCredentialProofFormat { + // Marshal as JSON string + return json.Marshal(vc.raw) // raw is only set by the parse function + } + // Must be a JSON-LD credential type alias VerifiableCredential tmp := alias(vc) if data, err := json.Marshal(tmp); err != nil { @@ -287,3 +298,39 @@ func (vc VerifiableCredential) ContainsContext(context ssi.URI) bool { return false } + +type JWTSigner func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) + +// CreateJWTVerifiableCredential creates a JWT Verifiable Credential from the given credential template. +// For signing the actual JWT it calls the given signer, which must return the created JWT in string format. +// Note: the signer is responsible for adding the right key claims (e.g. `kid`). +func CreateJWTVerifiableCredential(ctx context.Context, template VerifiableCredential, signer JWTSigner) (*VerifiableCredential, error) { + subjectDID, err := template.SubjectDID() + if err != nil { + return nil, err + } + headers := map[string]interface{}{ + jws.TypeKey: "JWT", + } + claims := map[string]interface{}{ + jwt.NotBeforeKey: template.IssuanceDate, + jwt.IssuerKey: template.Issuer.String(), + jwt.SubjectKey: subjectDID.String(), + "vc": map[string]interface{}{ + "@context": template.Context, + "type": template.Type, + "credentialSubject": template.CredentialSubject, + }, + } + if template.ID != nil { + claims[jwt.JwtIDKey] = template.ID.String() + } + if template.ExpirationDate != nil { + claims[jwt.ExpirationKey] = *template.ExpirationDate + } + token, err := signer(ctx, claims, headers) + if err != nil { + return nil, fmt.Errorf("unable to sign JWT credential: %w", err) + } + return parseJWTCredential(token) +} diff --git a/vc/vc_test.go b/vc/vc_test.go index c1adf5d..e55eaf9 100644 --- a/vc/vc_test.go +++ b/vc/vc_test.go @@ -1,11 +1,19 @@ package vc import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" + "github.com/lestrrat-go/jwx/jwa" + "github.com/lestrrat-go/jwx/jwt" ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" "github.com/stretchr/testify/require" "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -27,7 +35,9 @@ BND3LDTn9H7FQokEsUEi8jKwXhGvoN3JtRa51xrNDgXDb0cq1UTYB-rK4Ft9YVmR1NI_ZOF8oGc_7wAp txJy6M1-lD7a5HTzanYTWBPAUHDZGyGKXdJw-W_x0IWChBzI8t3kpG253fg6V3tPgHeKXE94fz_QpYfg --7kLsyBAfQGbg` -func TestVerifiableCredential_UnmarshalJSON(t *testing.T) { +// TestVerifiableCredential_JSONMarshalling tests JSON marshalling of VerifiableCredential. +// Credentials in JSON-LD format are marshalled JSON object, while JWT credentials are marshalled as JSON string. +func TestVerifiableCredential_JSONMarshalling(t *testing.T) { t.Run("JSON-LD", func(t *testing.T) { input := VerifiableCredential{} raw := `{ @@ -43,6 +53,17 @@ func TestVerifiableCredential_UnmarshalJSON(t *testing.T) { assert.Equal(t, JSONLDCredentialProofFormat, input.Format()) assert.Equal(t, raw, input.Raw()) assert.Nil(t, input.JWT()) + // Should marshal into JSON object + marshalled, err := json.Marshal(input) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(marshalled), "{")) + + t.Run("marshal empty VC", func(t *testing.T) { + input := VerifiableCredential{} + marshalled, err := json.Marshal(input) + require.NoError(t, err) + assert.Equal(t, "{\"@context\":null,\"credentialSubject\":null,\"issuanceDate\":\"0001-01-01T00:00:00Z\",\"issuer\":\"\",\"proof\":null,\"type\":null}", string(marshalled)) + }) }) t.Run("JWT", func(t *testing.T) { input := VerifiableCredential{} @@ -52,9 +73,13 @@ func TestVerifiableCredential_UnmarshalJSON(t *testing.T) { assert.Equal(t, []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("UniversityDegreeCredential")}, input.Type) assert.Len(t, input.CredentialSubject, 1) assert.NotNil(t, input.CredentialSubject[0].(map[string]interface{})["degree"]) - assert.Equal(t, JWTCredentialsProofFormat, input.Format()) + assert.Equal(t, JWTCredentialProofFormat, input.Format()) assert.Equal(t, raw, input.Raw()) assert.NotNil(t, input.JWT()) + // Should marshal into JSON string + marshalled, err := json.Marshal(input) + require.NoError(t, err) + assert.JSONEq(t, `"`+raw+`"`, string(marshalled)) }) } @@ -79,6 +104,18 @@ func TestParseVerifiableCredential(t *testing.T) { assert.Len(t, input.CredentialSubject, 1) assert.NotNil(t, input.CredentialSubject[0].(map[string]interface{})["degree"]) }) + t.Run("JWT without `exp` and `nbf` claim", func(t *testing.T) { + token := jwt.New() + require.NoError(t, token.Set("vc", map[string]interface{}{})) + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tokenBytes, err := jwt.Sign(token, jwa.ES256, keyPair) + require.NoError(t, err) + credential, err := ParseVerifiableCredential(string(tokenBytes)) + require.NoError(t, err) + assert.Equal(t, JWTCredentialProofFormat, credential.Format()) + assert.Nil(t, credential.ExpirationDate) + assert.Empty(t, credential.IssuanceDate) + }) } func TestVerifiableCredential_UnmarshalCredentialSubject(t *testing.T) { @@ -281,3 +318,64 @@ func TestVerifiableCredential_SubjectDID(t *testing.T) { assert.EqualError(t, err, "unable to get subject DID from VC: invalid DID") }) } + +func TestCreateJWTVerifiableCredential(t *testing.T) { + issuerDID := did.MustParseDID("did:example:issuer") + subjectDID := did.MustParseDID("did:example:subject") + credentialID := ssi.MustParseURI(issuerDID.String() + "#1") + issuanceDate := time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC) + expirationDate := issuanceDate.AddDate(0, 0, 10) + template := VerifiableCredential{ + ID: &credentialID, + Context: []ssi.URI{ + VCContextV1URI(), + }, + Type: []ssi.URI{ + VerifiableCredentialTypeV1URI(), + ssi.MustParseURI("https://example.com/custom"), + }, + IssuanceDate: issuanceDate, + ExpirationDate: &expirationDate, + CredentialSubject: []interface{}{ + map[string]interface{}{ + "id": subjectDID.String(), + }, + }, + Issuer: issuerDID.URI(), + } + ctx := context.Background() + t.Run("all properties", func(t *testing.T) { + var claims map[string]interface{} + var headers map[string]interface{} + _, err := CreateJWTVerifiableCredential(ctx, template, func(_ context.Context, c map[string]interface{}, h map[string]interface{}) (string, error) { + claims = c + headers = h + return jwtCredential, nil + }) + assert.NoError(t, err) + assert.Equal(t, issuerDID.String(), claims[jwt.IssuerKey]) + assert.Equal(t, subjectDID.String(), claims[jwt.SubjectKey]) + assert.Equal(t, template.ID.String(), claims[jwt.JwtIDKey]) + assert.Equal(t, issuanceDate, claims[jwt.NotBeforeKey]) + assert.Equal(t, expirationDate, claims[jwt.ExpirationKey]) + assert.Equal(t, map[string]interface{}{ + "credentialSubject": template.CredentialSubject, + "@context": template.Context, + "type": template.Type, + }, claims["vc"]) + assert.Equal(t, map[string]interface{}{"typ": "JWT"}, headers) + }) + t.Run("only mandatory properties", func(t *testing.T) { + minimumTemplate := template + minimumTemplate.ExpirationDate = nil + minimumTemplate.ID = nil + var claims map[string]interface{} + _, err := CreateJWTVerifiableCredential(ctx, minimumTemplate, func(_ context.Context, c map[string]interface{}, _ map[string]interface{}) (string, error) { + claims = c + return jwtCredential, nil + }) + assert.NoError(t, err) + assert.Nil(t, claims[jwt.ExpirationKey]) + assert.Nil(t, claims[jwt.JwtIDKey]) + }) +} diff --git a/vc/vp.go b/vc/vp.go index 54a25c9..2161e3a 100644 --- a/vc/vp.go +++ b/vc/vp.go @@ -155,19 +155,21 @@ func (vp VerifiablePresentation) Proofs() ([]Proof, error) { } func (vp VerifiablePresentation) MarshalJSON() ([]byte, error) { - switch vp.format { - case JWTPresentationProofFormat: - return json.Marshal(vp.raw) - case JSONLDPresentationProofFormat: - fallthrough - default: - type alias VerifiablePresentation - tmp := alias(vp) - if data, err := json.Marshal(tmp); err != nil { - return nil, err - } else { - return marshal.NormalizeDocument(data, pluralContext, marshal.Unplural(typeKey), marshal.Unplural(verifiableCredentialKey), marshal.Unplural(proofKey)) + if vp.raw != "" { + // Presentation instance created through ParseVerifiablePresentation() + if vp.format == JWTPresentationProofFormat { + // Marshal as JSON string + return json.Marshal(vp.raw) } + // JSON-LD, already in JSON format so return as-is + return []byte(vp.raw), nil + } + type alias VerifiablePresentation + tmp := alias(vp) + if data, err := json.Marshal(tmp); err != nil { + return nil, err + } else { + return marshal.NormalizeDocument(data, pluralContext, marshal.Unplural(typeKey), marshal.Unplural(verifiableCredentialKey), marshal.Unplural(proofKey)) } } diff --git a/vc/vp_test.go b/vc/vp_test.go index b449d34..fb708a5 100644 --- a/vc/vp_test.go +++ b/vc/vp_test.go @@ -219,7 +219,7 @@ func TestParseVerifiablePresentation(t *testing.T) { // Assert contained JWT VerifiableCredential was unmarshalled assert.Len(t, vp.VerifiableCredential, 1) vc := vp.VerifiableCredential[0] - assert.Equal(t, JWTCredentialsProofFormat, vc.Format()) + assert.Equal(t, JWTCredentialProofFormat, vc.Format()) assert.Equal(t, "http://example.edu/credentials/3732", vc.ID.String()) }) t.Run("json.UnmarshalJSON for JWT-VP wrapped inside other document", func(t *testing.T) {