Skip to content

Commit

Permalink
Support VCs and VPs in JWT format
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed Sep 29, 2023
1 parent c1495ef commit b457fb1
Show file tree
Hide file tree
Showing 4 changed files with 406 additions and 63 deletions.
136 changes: 125 additions & 11 deletions vc/vc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/lestrrat-go/jwx/jwt"
"github.com/nuts-foundation/go-did/did"
"net/url"
"strings"
"time"

ssi "github.com/nuts-foundation/go-did"
Expand Down Expand Up @@ -33,6 +35,94 @@ 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.
JWTCredentialsProofFormat = "jwt_vc"
)

var errCredentialSubjectWithoutID = errors.New("credential subjects have no ID")

// ParseVerifiableCredential parses a Verifiable Credential from a string, which can be either in JSON-LD or JWT format.
// If the format is JWT, the parsed token can be retrieved using JWT().
func ParseVerifiableCredential(raw string) (*VerifiableCredential, error) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "{") {
// Assume JSON-LD format
type Alias VerifiableCredential
normalizedVC, err := marshal.NormalizeDocument([]byte(raw), pluralContext, marshal.Plural(typeKey), marshal.Plural(credentialSubjectKey), marshal.Plural(proofKey))
if err != nil {
return nil, err
}
alias := Alias{}
err = json.Unmarshal(normalizedVC, &alias)
if err != nil {
return nil, err
}
alias.format = JSONLDCredentialProofFormat
alias.raw = raw
result := VerifiableCredential(alias)
return &result, err
} else {
// Assume JWT format
token, err := jwt.Parse([]byte(raw))
if err != nil {
return nil, err
}
var result VerifiableCredential
if innerVCInterf := token.PrivateClaims()["vc"]; innerVCInterf != nil {
innerVCJSON, _ := json.Marshal(innerVCInterf)
err = json.Unmarshal(innerVCJSON, &result)
if err != nil {
return nil, fmt.Errorf("invalid JWT 'vc' claim: %w", err)
}
}
// parse exp
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()
// parse sub
if token.Subject() != "" {
for _, credentialSubjectInterf := range result.CredentialSubject {
credentialSubject, isMap := credentialSubjectInterf.(map[string]interface{})
if isMap {
credentialSubject["id"] = token.Subject()
}
}
}
var subject string
if subjectDID, err := result.SubjectDID(); err != nil {
// credentialSubject.id is optional
if !errors.Is(err, errCredentialSubjectWithoutID) {
return nil, fmt.Errorf("invalid JWT 'sub' claim: %w", err)
}
} else if subjectDID != nil {
subject = subjectDID.String()
}
if token.Subject() != subject {
return nil, errors.New("invalid JWT 'sub' claim: must equal credentialSubject.id")
}
// parse jti
if jti, err := parseURIClaim(token, jwt.JwtIDKey); err != nil {
return nil, err
} else if jti != nil {
result.ID = jti
}
result.format = JWTCredentialsProofFormat
result.raw = raw
result.token = token
return &result, nil
}
}

// VerifiableCredential represents a credential as defined by the Verifiable Credentials Data Model 1.0 specification (https://www.w3.org/TR/vc-data-model/).
type VerifiableCredential struct {
// Context defines the json-ld context to dereference the URIs
Expand All @@ -53,6 +143,29 @@ type VerifiableCredential struct {
CredentialSubject []interface{} `json:"credentialSubject"`
// Proof contains the cryptographic proof(s). It must be extracted using the Proofs method or UnmarshalProofValue method for non-generic proof fields.
Proof []interface{} `json:"proof"`

format string
raw string
token jwt.Token
}

// Format returns the format of the credential (e.g. jwt_vc or ldp_vc).
func (vc VerifiableCredential) Format() string {
return vc.format
}

// Raw returns the source of the credential as it was parsed.
func (vc VerifiableCredential) Raw() string {
return vc.raw
}

// JWT returns the JWT token if the credential was parsed from a JWT.
func (vc VerifiableCredential) JWT() jwt.Token {
if vc.token == nil {
return nil
}
token, _ := vc.token.Clone()
return token
}

// CredentialStatus defines the method on how to determine a credential is revoked.
Expand Down Expand Up @@ -88,18 +201,19 @@ func (vc VerifiableCredential) MarshalJSON() ([]byte, error) {
}

func (vc *VerifiableCredential) UnmarshalJSON(b []byte) error {
type Alias VerifiableCredential
normalizedVC, err := marshal.NormalizeDocument(b, pluralContext, marshal.Plural(typeKey), marshal.Plural(credentialSubjectKey), marshal.Plural(proofKey))
if err != nil {
return err
var str string
if len(b) > 0 && b[0] == '"' {
if err := json.Unmarshal(b, &str); err != nil {
return err
}
} else {
str = string(b)
}
tmp := Alias{}
err = json.Unmarshal(normalizedVC, &tmp)
if err != nil {
return err
credential, err := ParseVerifiableCredential(str)
if err == nil {
*vc = *credential
}
*vc = (VerifiableCredential)(tmp)
return nil
return err
}

// UnmarshalProofValue unmarshalls the proof to the given proof type. Always pass a slice as target since there could be multiple proofs.
Expand Down Expand Up @@ -148,7 +262,7 @@ func (vc VerifiableCredential) SubjectDID() (*did.DID, error) {
}
}
if subjectID.Empty() {
return nil, errors.New("unable to get subject DID from VC: credential subjects have no ID")
return nil, fmt.Errorf("unable to get subject DID from VC: %w", errCredentialSubjectWithoutID)
}
return &subjectID, nil
}
Expand Down
73 changes: 73 additions & 0 deletions vc/vc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,84 @@ package vc
import (
"encoding/json"
ssi "github.com/nuts-foundation/go-did"
"github.com/stretchr/testify/require"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

// jwtCredential is taken from https://www.w3.org/TR/vc-data-model/#example-verifiable-credential-using-jwt-compact-serialization-non-normative
const jwtCredential = `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDpleGFtcGxlOmFiZmUxM2Y3MTIxMjA0
MzFjMjc2ZTEyZWNhYiNrZXlzLTEifQ.eyJzdWIiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxY
zI3NmUxMmVjMjEiLCJqdGkiOiJodHRwOi8vZXhhbXBsZS5lZHUvY3JlZGVudGlhbHMvMzczMiIsImlzc
yI6Imh0dHBzOi8vZXhhbXBsZS5jb20va2V5cy9mb28uandrIiwibmJmIjoxNTQxNDkzNzI0LCJpYXQiO
jE1NDE0OTM3MjQsImV4cCI6MTU3MzAyOTcyMywibm9uY2UiOiI2NjAhNjM0NUZTZXIiLCJ2YyI6eyJAY
29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vd
3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL2V4YW1wbGVzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZ
UNyZWRlbnRpYWwiLCJVbml2ZXJzaXR5RGVncmVlQ3JlZGVudGlhbCJdLCJjcmVkZW50aWFsU3ViamVjd
CI6eyJkZWdyZWUiOnsidHlwZSI6IkJhY2hlbG9yRGVncmVlIiwibmFtZSI6IjxzcGFuIGxhbmc9J2ZyL
UNBJz5CYWNjYWxhdXLDqWF0IGVuIG11c2lxdWVzIG51bcOpcmlxdWVzPC9zcGFuPiJ9fX19.KLJo5GAy
BND3LDTn9H7FQokEsUEi8jKwXhGvoN3JtRa51xrNDgXDb0cq1UTYB-rK4Ft9YVmR1NI_ZOF8oGc_7wAp
8PHbF2HaWodQIoOBxxT-4WNqAxft7ET6lkH-4S6Ux3rSGAmczMohEEf8eCeN-jC8WekdPl6zKZQj0YPB
1rx6X0-xlFBs7cl6Wt8rfBP_tZ9YgVWrQmUWypSioc0MUyiphmyEbLZagTyPlUyflGlEdqrZAv6eSe6R
txJy6M1-lD7a5HTzanYTWBPAUHDZGyGKXdJw-W_x0IWChBzI8t3kpG253fg6V3tPgHeKXE94fz_QpYfg
--7kLsyBAfQGbg`

func TestVerifiableCredential_UnmarshalJSON(t *testing.T) {
t.Run("JSON-LD", func(t *testing.T) {
input := VerifiableCredential{}
raw := `{
"id":"did:example:123#vc-1",
"type":["VerifiableCredential", "custom"],
"credentialSubject": {"name": "test"}
}`
err := json.Unmarshal([]byte(raw), &input)
require.NoError(t, err)
assert.Equal(t, "did:example:123#vc-1", input.ID.String())
assert.Equal(t, []ssi.URI{VerifiableCredentialTypeV1URI(), ssi.MustParseURI("custom")}, input.Type)
assert.Equal(t, []interface{}{map[string]interface{}{"name": "test"}}, input.CredentialSubject)
assert.Equal(t, JSONLDCredentialProofFormat, input.Format())
assert.Equal(t, raw, input.Raw())
assert.Nil(t, input.JWT())
})
t.Run("JWT", func(t *testing.T) {
input := VerifiableCredential{}
raw := strings.ReplaceAll(jwtCredential, "\n", "")
err := json.Unmarshal([]byte(`"`+raw+`"`), &input)
require.NoError(t, err)
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, raw, input.Raw())
assert.NotNil(t, input.JWT())
})
}

func TestParseVerifiableCredential(t *testing.T) {
t.Run("JSON-LD", func(t *testing.T) {
input := VerifiableCredential{}
err := json.Unmarshal([]byte(`{
"id":"did:example:123#vc-1",
"type":["VerifiableCredential", "custom"],
"credentialSubject": {"name": "test"}
}`), &input)
require.NoError(t, err)
assert.Equal(t, "did:example:123#vc-1", input.ID.String())
assert.Equal(t, []ssi.URI{VerifiableCredentialTypeV1URI(), ssi.MustParseURI("custom")}, input.Type)
assert.Equal(t, []interface{}{map[string]interface{}{"name": "test"}}, input.CredentialSubject)
})
t.Run("JWT", func(t *testing.T) {
input := VerifiableCredential{}
err := json.Unmarshal([]byte(`"`+strings.ReplaceAll(jwtCredential, "\n", "")+`"`), &input)
require.NoError(t, err)
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"])
})
}

func TestVerifiableCredential_UnmarshalCredentialSubject(t *testing.T) {
type exampleSubject struct {
Name string
Expand Down
101 changes: 95 additions & 6 deletions vc/vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package vc

import (
"encoding/json"
"fmt"
"github.com/lestrrat-go/jwx/jwt"
"strings"

ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/internal/marshal"
Expand All @@ -15,6 +18,13 @@ func VerifiablePresentationTypeV1URI() ssi.URI {
return ssi.MustParseURI(VerifiablePresentationType)
}

const (
// JSONLDPresentationProofFormat is the format for JSON-LD based presentations.
JSONLDPresentationProofFormat string = "ldp_vp"
// JWTPresentationProofFormat is the format for JWT based presentations.
JWTPresentationProofFormat = "jwt_vp"
)

// VerifiablePresentation represents a presentation as defined by the Verifiable Credentials Data Model 1.0 specification (https://www.w3.org/TR/vc-data-model/).
type VerifiablePresentation struct {
// Context defines the json-ld context to dereference the URIs
Expand All @@ -29,6 +39,77 @@ type VerifiablePresentation struct {
VerifiableCredential []VerifiableCredential `json:"verifiableCredential,omitempty"`
// Proof contains the cryptographic proof(s). It must be extracted using the Proofs method or UnmarshalProofValue method for non-generic proof fields.
Proof []interface{} `json:"proof,omitempty"`

format string
raw string
token jwt.Token
}

// ParseVerifiablePresentation parses a Verifiable Presentation from a string, which can be either in JSON-LD or JWT format.
// If the format is JWT, the parsed token can be retrieved using JWT().
func ParseVerifiablePresentation(raw string) (*VerifiablePresentation, error) {
if strings.HasPrefix(raw, "{") {
// Assume JSON-LD format
var result VerifiablePresentation
err := json.Unmarshal([]byte(raw), &result)
if err == nil {
result.format = JSONLDPresentationProofFormat
}
return &result, err
} else {
// Assume JWT format
token, err := jwt.Parse([]byte(raw))
if err != nil {
return nil, err
}
var result VerifiablePresentation
if innerVPInterf := token.PrivateClaims()["vp"]; innerVPInterf != nil {
innerVPJSON, _ := json.Marshal(innerVPInterf)
err = json.Unmarshal(innerVPJSON, &result)
if err != nil {
return nil, fmt.Errorf("invalid JWT 'vp' claim: %w", err)
}
}
// parse jti
if jti, err := parseURIClaim(token, jwt.JwtIDKey); err != nil {
return nil, err
} else if jti != nil {
result.ID = jti
}
// parse iss
if iss, err := parseURIClaim(token, jwt.IssuerKey); err != nil {
return nil, err
} else if iss != nil {
result.Holder = iss
}
// the other claims don't have a designated field in VerifiablePresentation and can be accessed through JWT()
result.format = JWTPresentationProofFormat
result.raw = raw
result.token = token
return &result, nil
}
}

func parseURIClaim(token jwt.Token, claim string) (*ssi.URI, error) {
if val, ok := token.Get(claim); ok {
if str, ok := val.(string); !ok {
return nil, fmt.Errorf("%s must be a string", claim)
} else {
return ssi.ParseURI(str)
}
}
return nil, nil
}

// Format returns the format of the presentation (e.g. jwt_vp or ldp_vp).
func (vp VerifiablePresentation) Format() string {
return vp.format
}

// JWT returns the JWT token if the presentation was parsed from a JWT.
func (vp VerifiablePresentation) JWT() jwt.Token {
token, _ := vp.token.Clone()
return token
}

// Proofs returns the basic proofs for this presentation. For specific proof contents, UnmarshalProofValue must be used.
Expand All @@ -48,12 +129,19 @@ func (vp VerifiablePresentation) Proofs() ([]Proof, error) {
}

func (vp VerifiablePresentation) MarshalJSON() ([]byte, error) {
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))
switch vp.format {
default:
fallthrough
case JSONLDPresentationProofFormat:
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))
}
case JWTPresentationProofFormat:
return json.Marshal(vp.raw)
}
}

Expand All @@ -69,6 +157,7 @@ func (vp *VerifiablePresentation) UnmarshalJSON(b []byte) error {
return err
}
*vp = (VerifiablePresentation)(tmp)
vp.format = JSONLDPresentationProofFormat
return nil
}

Expand Down
Loading

0 comments on commit b457fb1

Please sign in to comment.