From 22462b16eb3a78f5038b79214a298e3308e89f14 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Tue, 10 Oct 2023 15:29:35 +0200 Subject: [PATCH] Support VCs and VPs in JWT format (#83) --- README.md | 35 +++++---- did/document.go | 60 ++++++++------- did/document_test.go | 3 +- vc/vc.go | 136 ++++++++++++++++++++++++++++++--- vc/vc_test.go | 72 ++++++++++++++++++ vc/vp.go | 147 ++++++++++++++++++++++++++++++++---- vc/vp_test.go | 175 +++++++++++++++++++++++++++++++------------ 7 files changed, 513 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 5573ad0..68e1a41 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,20 @@ A library to parse and generate W3C [DID Documents](https://www.w3.org/TR/did-core/) and W3C [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/). -## Example usage: +## Example usage +Note on parsing: in earlier versions, DID documents, credentials and presentations were parsed using `UnmarshalJSON`. +Now, `ParseDocument()`, `ParseVerifiableCredential()` and `ParseVerifiablePresentation()` should be used instead: they better support VCs and VPs in JWT format. + +### Parsing a DID document +```go +didDoc, err := did.ParseDocument(didDocJson) +if err != nil { + panic(err) +} +// do something with didDoc +```` + +### Creating a DID document Creation of a simple DID Document which is its own controller and contains an AssertionMethod. ```go didID, err := did.ParseDID("did:example:123") @@ -29,18 +42,8 @@ doc.AddAssertionMethod(verificationMethod) didJson, _ := json.MarshalIndent(doc, "", " ") fmt.Println(string(didJson)) - -// Unmarshalling of a json did document: -parsedDIDDoc := did.Document{} -err = json.Unmarshal(didJson, &parsedDIDDoc) - -// It can return the key in the convenient lestrrat-go/jwx JWK -parsedDIDDoc.AssertionMethod[0].JWK() - -// Or return a native crypto.PublicKey -parsedDIDDoc.AssertionMethod[0].PublicKey() - ``` + Outputs: ```json { @@ -64,14 +67,16 @@ Outputs: } ] } - ``` +### Parsing Verifiable Credentials and Verifiable Presentations +The library supports parsing of Verifiable Credentials and Verifiable Presentations in JSON-LD, and JWT proof format. +Use `ParseVerifiableCredential(raw string)` and `ParseVerifiablePresentation(raw string)`. + ## Installation ``` go get github.com/nuts-foundation/go-did ``` ## State of the library -Currently, the library is under development. The api can change without notice. -Checkout the issues and PRs to be informed about any development. \ No newline at end of file +We keep the API stable, breaking changes will only be introduced in new major versions. diff --git a/did/document.go b/did/document.go index a3f31dc..66fe15b 100644 --- a/did/document.go +++ b/did/document.go @@ -16,6 +16,39 @@ import ( "github.com/nuts-foundation/go-did/internal/marshal" ) +// ParseDocument parses a DID Document from a string. +func ParseDocument(raw string) (*Document, error) { + type Alias Document + normalizedDoc, err := marshal.NormalizeDocument([]byte(raw), pluralContext, marshal.Plural(controllerKey)) + if err != nil { + return nil, err + } + doc := Alias{} + err = json.Unmarshal(normalizedDoc, &doc) + if err != nil { + return nil, err + } + d := Document(doc) + + const errMsg = "unable to resolve all '%s' references: %w" + if err = resolveVerificationRelationships(d.Authentication, d.VerificationMethod); err != nil { + return nil, fmt.Errorf(errMsg, authenticationKey, err) + } + if err = resolveVerificationRelationships(d.AssertionMethod, d.VerificationMethod); err != nil { + return nil, fmt.Errorf(errMsg, assertionMethodKey, err) + } + if err = resolveVerificationRelationships(d.KeyAgreement, d.VerificationMethod); err != nil { + return nil, fmt.Errorf(errMsg, keyAgreementKey, err) + } + if err = resolveVerificationRelationships(d.CapabilityInvocation, d.VerificationMethod); err != nil { + return nil, fmt.Errorf(errMsg, capabilityInvocationKey, err) + } + if err = resolveVerificationRelationships(d.CapabilityDelegation, d.VerificationMethod); err != nil { + return nil, fmt.Errorf(errMsg, capabilityDelegationKey, err) + } + return &d, nil +} + // Document represents a DID Document as specified by the DID Core specification (https://www.w3.org/TR/did-core/). type Document struct { Context []ssi.URI `json:"@context"` @@ -186,34 +219,11 @@ func (d Document) MarshalJSON() ([]byte, error) { } func (d *Document) UnmarshalJSON(b []byte) error { - type Alias Document - normalizedDoc, err := marshal.NormalizeDocument(b, pluralContext, marshal.Plural(controllerKey)) - if err != nil { - return err - } - doc := Alias{} - err = json.Unmarshal(normalizedDoc, &doc) + document, err := ParseDocument(string(b)) if err != nil { return err } - *d = (Document)(doc) - - const errMsg = "unable to resolve all '%s' references: %w" - if err = resolveVerificationRelationships(d.Authentication, d.VerificationMethod); err != nil { - return fmt.Errorf(errMsg, authenticationKey, err) - } - if err = resolveVerificationRelationships(d.AssertionMethod, d.VerificationMethod); err != nil { - return fmt.Errorf(errMsg, assertionMethodKey, err) - } - if err = resolveVerificationRelationships(d.KeyAgreement, d.VerificationMethod); err != nil { - return fmt.Errorf(errMsg, keyAgreementKey, err) - } - if err = resolveVerificationRelationships(d.CapabilityInvocation, d.VerificationMethod); err != nil { - return fmt.Errorf(errMsg, capabilityInvocationKey, err) - } - if err = resolveVerificationRelationships(d.CapabilityDelegation, d.VerificationMethod); err != nil { - return fmt.Errorf(errMsg, capabilityDelegationKey, err) - } + *d = *document return nil } diff --git a/did/document_test.go b/did/document_test.go index 5cc3ceb..bff43ea 100644 --- a/did/document_test.go +++ b/did/document_test.go @@ -341,8 +341,7 @@ func TestRoundTripMarshalling(t *testing.T) { for _, testCase := range testCases { t.Run(testCase, func(t *testing.T) { - document := Document{} - err := json.Unmarshal(test.ReadTestFile("test/"+testCase+".json"), &document) + document, err := ParseDocument(string(test.ReadTestFile("test/" + testCase + ".json"))) if !assert.NoError(t, err) { return } diff --git a/vc/vc.go b/vc/vc.go index c63bc57..f1a8935 100644 --- a/vc/vc.go +++ b/vc/vc.go @@ -4,7 +4,9 @@ import ( "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/jwt" "github.com/nuts-foundation/go-did/did" + "strings" "time" ssi "github.com/nuts-foundation/go-did" @@ -32,6 +34,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. + // 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" +) + +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. +// JWTs are parsed according to https://www.w3.org/TR/2022/REC-vc-data-model-20220303/#jwt-decoding +// If the format is JWT, the parsed token can be retrieved using JWT(). +// Note that it does not do any signature checking. +func ParseVerifiableCredential(raw string) (*VerifiableCredential, error) { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "{") { + // Assume JSON-LD format + return parseJSONLDCredential(raw) + } else { + // Assume JWT format + return parseJWTCredential(raw) + } +} + +// parseJWTCredential parses a JWT credential according to https://www.w3.org/TR/2022/REC-vc-data-model-20220303/#jwt-decoding +func parseJWTCredential(raw string) (*VerifiableCredential, error) { + 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() + } + } + } + // 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 +} + +func parseJSONLDCredential(raw string) (*VerifiableCredential, error) { + 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 +} + // 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 @@ -52,6 +142,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. @@ -87,18 +200,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. @@ -147,7 +261,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 } diff --git a/vc/vc_test.go b/vc/vc_test.go index 650dd93..c1adf5d 100644 --- a/vc/vc_test.go +++ b/vc/vc_test.go @@ -4,11 +4,83 @@ 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 diff --git a/vc/vp.go b/vc/vp.go index 0dad8e1..54a25c9 100644 --- a/vc/vp.go +++ b/vc/vp.go @@ -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" @@ -15,6 +18,14 @@ 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. + // Note: various specs have not yet decided on the exact const (jwt_vp or jwt_vp_json, etc), so this is subject to change. + 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 @@ -29,6 +40,102 @@ 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(). +// Note that it does not do any signature checking, or check that the signer of the VP is the subject of the VCs. +func ParseVerifiablePresentation(raw string) (*VerifiablePresentation, error) { + if strings.HasPrefix(raw, "{") { + // Assume JSON-LD format + return parseJSONLDPresentation(raw) + } else { + // Assume JWT format + return parseJTWPresentation(raw) + } +} + +func parseJSONLDPresentation(raw string) (*VerifiablePresentation, error) { + type Alias VerifiablePresentation + normalizedVC, err := marshal.NormalizeDocument([]byte(raw), pluralContext, marshal.Plural(typeKey), marshal.Plural(verifiableCredentialKey), marshal.Plural(proofKey)) + if err != nil { + return nil, err + } + alias := Alias{} + err = json.Unmarshal(normalizedVC, &alias) + if err != nil { + return nil, err + } + alias.raw = raw + alias.format = JSONLDPresentationProofFormat + result := VerifiablePresentation(alias) + return &result, err +} + +func parseJTWPresentation(raw string) (*VerifiablePresentation, error) { + 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 { + if vp.token == nil { + return nil + } + token, _ := vp.token.Clone() + return token +} + +// Raw returns the source of the presentation as it was parsed. +func (vp VerifiablePresentation) Raw() string { + return vp.raw } // Proofs returns the basic proofs for this presentation. For specific proof contents, UnmarshalProofValue must be used. @@ -48,28 +155,36 @@ 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 { + 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)) + } } } func (vp *VerifiablePresentation) UnmarshalJSON(b []byte) error { - type Alias VerifiablePresentation - normalizedVC, err := marshal.NormalizeDocument(b, pluralContext, marshal.Plural(typeKey), marshal.Plural(verifiableCredentialKey), 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 + presentation, err := ParseVerifiablePresentation(str) + if err == nil { + *vp = *presentation } - *vp = (VerifiablePresentation)(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. diff --git a/vc/vp_test.go b/vc/vp_test.go index 5616b54..b449d34 100644 --- a/vc/vp_test.go +++ b/vc/vp_test.go @@ -2,65 +2,102 @@ package vc import ( "encoding/json" - "testing" - ssi "github.com/nuts-foundation/go-did" + "github.com/stretchr/testify/require" + "strings" + "testing" "github.com/stretchr/testify/assert" ) -func TestVerifiablePresentation_MarshalJSON(t *testing.T) { - t.Run("ok - single credential and proof", func(t *testing.T) { - input := VerifiablePresentation{ - VerifiableCredential: []VerifiableCredential{ - { - Type: []ssi.URI{VerifiableCredentialTypeV1URI()}, - }, - }, - Proof: []interface{}{ - JSONWebSignature2020Proof{ - Jws: "", - }, - }, - } - - bytes, err := json.Marshal(input) - - if !assert.NoError(t, err) { - return - } - assert.Contains(t, string(bytes), "\"proof\":{") - assert.Contains(t, string(bytes), "\"verifiableCredential\":{") - }) +// jwtPresentation is taken from https://www.w3.org/TR/vc-data-model/#example-verifiable-presentation-using-jwt-compact-serialization-non-normative +const jwtPresentation = `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDpleGFtcGxlOjB4YWJjI2tleTEifQ.e +yJpc3MiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJqdGkiOiJ1cm46d +XVpZDozOTc4MzQ0Zi04NTk2LTRjM2EtYTk3OC04ZmNhYmEzOTAzYzUiLCJhdWQiOiJkaWQ6ZXhhbXBsZ +To0YTU3NTQ2OTczNDM2ZjZmNmM0YTRhNTc1NzMiLCJuYmYiOjE1NDE0OTM3MjQsImlhdCI6MTU0MTQ5M +zcyNCwiZXhwIjoxNTczMDI5NzIzLCJub25jZSI6IjM0M3MkRlNGRGEtIiwidnAiOnsiQGNvbnRleHQiO +lsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL3d3dy53My5vc +mcvMjAxOC9jcmVkZW50aWFscy9leGFtcGxlcy92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVQcmVzZW50Y +XRpb24iLCJDcmVkZW50aWFsTWFuYWdlclByZXNlbnRhdGlvbiJdLCJ2ZXJpZmlhYmxlQ3JlZGVudGlhb +CI6WyJleUpoYkdjaU9pSlNVekkxTmlJc0luUjVjQ0k2SWtwWFZDSXNJbXRwWkNJNkltUnBaRHBsZUdGd +GNHeGxPbUZpWm1VeE0yWTNNVEl4TWpBME16RmpNamMyWlRFeVpXTmhZaU5yWlhsekxURWlmUS5leUp6Z +FdJaU9pSmthV1E2WlhoaGJYQnNaVHBsWW1abFlqRm1OekV5WldKak5tWXhZekkzTm1VeE1tVmpNakVpT +ENKcWRHa2lPaUpvZEhSd09pOHZaWGhoYlhCc1pTNWxaSFV2WTNKbFpHVnVkR2xoYkhNdk16Y3pNaUlzS +W1semN5STZJbWgwZEhCek9pOHZaWGhoYlhCc1pTNWpiMjB2YTJWNWN5OW1iMjh1YW5kcklpd2libUptS +WpveE5UUXhORGt6TnpJMExDSnBZWFFpT2pFMU5ERTBPVE0zTWpRc0ltVjRjQ0k2TVRVM016QXlPVGN5T +Xl3aWJtOXVZMlVpT2lJMk5qQWhOak0wTlVaVFpYSWlMQ0oyWXlJNmV5SkFZMjl1ZEdWNGRDSTZXeUpvZ +EhSd2N6b3ZMM2QzZHk1M015NXZjbWN2TWpBeE9DOWpjbVZrWlc1MGFXRnNjeTkyTVNJc0ltaDBkSEJ6T +2k4dmQzZDNMbmN6TG05eVp5OHlNREU0TDJOeVpXUmxiblJwWVd4ekwyVjRZVzF3YkdWekwzWXhJbDBzS +W5SNWNHVWlPbHNpVm1WeWFXWnBZV0pzWlVOeVpXUmxiblJwWVd3aUxDSlZibWwyWlhKemFYUjVSR1ZuY +21WbFEzSmxaR1Z1ZEdsaGJDSmRMQ0pqY21Wa1pXNTBhV0ZzVTNWaWFtVmpkQ0k2ZXlKa1pXZHlaV1VpT +25zaWRIbHdaU0k2SWtKaFkyaGxiRzl5UkdWbmNtVmxJaXdpYm1GdFpTSTZJanh6Y0dGdUlHeGhibWM5S +jJaeUxVTkJKejVDWVdOallXeGhkWExEcVdGMElHVnVJRzExYzJseGRXVnpJRzUxYmNPcGNtbHhkV1Z6U +EM5emNHRnVQaUo5ZlgxOS5LTEpvNUdBeUJORDNMRFRuOUg3RlFva0VzVUVpOGpLd1hoR3ZvTjNKdFJhN +TF4ck5EZ1hEYjBjcTFVVFlCLXJLNEZ0OVlWbVIxTklfWk9GOG9HY183d0FwOFBIYkYySGFXb2RRSW9PQ +nh4VC00V05xQXhmdDdFVDZsa0gtNFM2VXgzclNHQW1jek1vaEVFZjhlQ2VOLWpDOFdla2RQbDZ6S1pRa +jBZUEIxcng2WDAteGxGQnM3Y2w2V3Q4cmZCUF90WjlZZ1ZXclFtVVd5cFNpb2MwTVV5aXBobXlFYkxaY +WdUeVBsVXlmbEdsRWRxclpBdjZlU2U2UnR4Snk2TTEtbEQ3YTVIVHphbllUV0JQQVVIRFpHeUdLWGRKd +y1XX3gwSVdDaEJ6STh0M2twRzI1M2ZnNlYzdFBnSGVLWEU5NGZ6X1FwWWZnLS03a0xzeUJBZlFHYmciX +X19.ft_Eq4IniBrr7gtzRfrYj8Vy1aPXuFZU-6_ai0wvaKcsrzI4JkQEKTvbJwdvIeuGuTqy7ipO-EYi +7V4TvonPuTRdpB7ZHOlYlbZ4wA9WJ6mSVSqDACvYRiFvrOFmie8rgm6GacWatgO4m4NqiFKFko3r58Lu +eFfGw47NK9RcfOkVQeHCq4btaDqksDKeoTrNysF4YS89INa-prWomrLRAhnwLOo1Etp3E4ESAxg73CR2 +kA5AoMbf5KtFueWnMcSbQkMRdWcGC1VssC0tB0JffVjq7ZV6OTyV4kl1-UVgiPLXUTpupFfLRhf9QpqM +BjYgP62KvhIvW8BbkGUelYMetA` - t.Run("ok - multiple credential and proof", func(t *testing.T) { - input := VerifiablePresentation{ - VerifiableCredential: []VerifiableCredential{ - { - Type: []ssi.URI{VerifiableCredentialTypeV1URI()}, +func TestVerifiablePresentation_MarshalJSON(t *testing.T) { + t.Run("JSON-LD", func(t *testing.T) { + t.Run("ok - single credential and proof", func(t *testing.T) { + input := VerifiablePresentation{ + VerifiableCredential: []VerifiableCredential{ + { + Type: []ssi.URI{VerifiableCredentialTypeV1URI()}, + }, }, - { - Type: []ssi.URI{VerifiableCredentialTypeV1URI()}, + Proof: []interface{}{ + JSONWebSignature2020Proof{ + Jws: "", + }, }, - }, - Proof: []interface{}{ - JSONWebSignature2020Proof{ - Jws: "", + } + + bytes, err := json.Marshal(input) + + if !assert.NoError(t, err) { + return + } + assert.Contains(t, string(bytes), "\"proof\":{") + assert.Contains(t, string(bytes), "\"verifiableCredential\":{") + }) + t.Run("ok - multiple credential and proof", func(t *testing.T) { + input := VerifiablePresentation{ + VerifiableCredential: []VerifiableCredential{ + { + Type: []ssi.URI{VerifiableCredentialTypeV1URI()}, + }, + { + Type: []ssi.URI{VerifiableCredentialTypeV1URI()}, + }, }, - JSONWebSignature2020Proof{ - Jws: "", + Proof: []interface{}{ + JSONWebSignature2020Proof{ + Jws: "", + }, + JSONWebSignature2020Proof{ + Jws: "", + }, }, - }, - } + } - bytes, err := json.Marshal(input) + bytes, err := json.Marshal(input) - if !assert.NoError(t, err) { - return - } - assert.Contains(t, string(bytes), "\"proof\":[") - assert.Contains(t, string(bytes), "\"verifiableCredential\":[") + if !assert.NoError(t, err) { + return + } + assert.Contains(t, string(bytes), "\"proof\":[") + assert.Contains(t, string(bytes), "\"verifiableCredential\":[") + }) }) + } func TestVerifiablePresentation_UnmarshalProof(t *testing.T) { @@ -150,3 +187,49 @@ func TestVerifiablePresentation_ContainsContext(t *testing.T) { assert.False(t, input.ContainsContext(*u)) }) } + +func TestParseVerifiablePresentation(t *testing.T) { + t.Run("JSON-LD", func(t *testing.T) { + raw := `{ + "id":"did:example:123#vp-1", + "@context":["https://www.w3.org/2018/credentials/v1"] + }` + vp, err := ParseVerifiablePresentation(raw) + require.NoError(t, err) + require.NotNil(t, vp) + assert.Equal(t, JSONLDPresentationProofFormat, vp.Format()) + assert.Equal(t, "did:example:123#vp-1", vp.ID.String()) + assert.Equal(t, []ssi.URI{VCContextV1URI()}, vp.Context) + assert.Nil(t, vp.JWT()) + assert.Equal(t, raw, vp.Raw()) + }) + t.Run("JWT", func(t *testing.T) { + vp, err := ParseVerifiablePresentation(jwtPresentation) + require.NoError(t, err) + require.NotNil(t, vp) + assert.Equal(t, JWTPresentationProofFormat, vp.Format()) + assert.Equal(t, "did:example:ebfeb1f712ebc6f1c276e12ec21", vp.Holder.String()) + assert.Equal(t, "urn:uuid:3978344f-8596-4c3a-a978-8fcaba3903c5", vp.ID.String()) + assert.Equal(t, []string{"did:example:4a57546973436f6f6c4a4a57573"}, vp.JWT().Audience()) + assert.Len(t, vp.Type, 2) + assert.True(t, vp.IsType(ssi.MustParseURI("VerifiablePresentation"))) + assert.True(t, vp.IsType(ssi.MustParseURI("CredentialManagerPresentation"))) + assert.NotNil(t, vp.JWT()) + assert.Equal(t, jwtPresentation, vp.Raw()) + // 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, "http://example.edu/credentials/3732", vc.ID.String()) + }) + t.Run("json.UnmarshalJSON for JWT-VP wrapped inside other document", func(t *testing.T) { + type Wrapper struct { + VP VerifiablePresentation `json:"vp"` + } + input := `{"vp":"` + strings.ReplaceAll(jwtPresentation, "\n", "") + `"}` + var expected Wrapper + err := json.Unmarshal([]byte(input), &expected) + require.NoError(t, err) + assert.Equal(t, JWTPresentationProofFormat, expected.VP.Format()) + }) +}