Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfixes for integration #87

Merged
merged 5 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions vc/vc.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -74,16 +76,20 @@ 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
} else if iss != nil {
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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
102 changes: 100 additions & 2 deletions vc/vc_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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 := `{
Expand All @@ -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{}
Expand All @@ -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))
})
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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])
})
}
26 changes: 14 additions & 12 deletions vc/vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
2 changes: 1 addition & 1 deletion vc/vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down