Skip to content

Commit

Permalink
Bugfixes for integration (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed Oct 20, 2023
1 parent 22462b1 commit a247b7e
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 21 deletions.
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

0 comments on commit a247b7e

Please sign in to comment.