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

Add UnmarshalCredentialStatus #96

Merged
merged 2 commits into from
Dec 1, 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
1 change: 1 addition & 0 deletions vc/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (
contextKey = "@context"
typeKey = "type"
credentialSubjectKey = "credentialSubject"
credentialStatusKey = "credentialStatus"
proofKey = "proof"
verifiableCredentialKey = "verifiableCredential"
)
Expand Down
67 changes: 56 additions & 11 deletions vc/vc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package vc

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -113,7 +114,7 @@ func parseJWTCredential(raw string) (*VerifiableCredential, error) {

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))
normalizedVC, err := marshal.NormalizeDocument([]byte(raw), pluralContext, marshal.Plural(typeKey), marshal.Plural(credentialSubjectKey), marshal.Plural(credentialStatusKey), marshal.Plural(proofKey))
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -142,8 +143,8 @@ type VerifiableCredential struct {
IssuanceDate time.Time `json:"issuanceDate"`
// ExpirationDate is a rfc3339 formatted datetime. It is optional
ExpirationDate *time.Time `json:"expirationDate,omitempty"`
// CredentialStatus holds information on how the credential can be revoked. It is optional
CredentialStatus *CredentialStatus `json:"credentialStatus,omitempty"`
// CredentialStatus holds information on how the credential can be revoked. It must be extracted using the UnmarshalCredentialStatus method and a custom type.
CredentialStatus []any `json:"credentialStatus,omitempty"`
// CredentialSubject holds the actual data for the credential. It must be extracted using the UnmarshalCredentialSubject method and a custom type.
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.
Expand Down Expand Up @@ -173,10 +174,49 @@ func (vc VerifiableCredential) JWT() jwt.Token {
return token
}

// CredentialStatus defines the method on how to determine a credential is revoked.
// CredentialStatus contains the required fields ID and Type, and the raw data for unmarshalling into a custom type.
type CredentialStatus struct {
ID ssi.URI `json:"id"`
Type string `json:"type"`
raw []byte
}

func (cs *CredentialStatus) UnmarshalJSON(input []byte) error {
type alias *CredentialStatus
a := alias(cs)
err := json.Unmarshal(input, a)
if err != nil {
return err
}

// keep compacted copy of the input
buf := new(bytes.Buffer)
if err = json.Compact(buf, input); err != nil {
// should never happen, already parsed as valid json
return err
}
cs.raw = buf.Bytes()
return nil
}

// Raw returns a copy of the underlying credentialStatus data as set during UnmarshalJSON.
// This can be used to marshal the data into a custom status credential type.
func (cs *CredentialStatus) Raw() []byte {
if cs.raw == nil {
return nil
}
cp := make([]byte, len(cs.raw))
copy(cp, cs.raw)
return cp
}

// CredentialStatuses returns VerifiableCredential.CredentialStatus marshalled into a CredentialStatus slice.
func (vc VerifiableCredential) CredentialStatuses() ([]CredentialStatus, error) {
var statuses []CredentialStatus
if err := vc.UnmarshalCredentialStatus(&statuses); err != nil {
return nil, err
}
return statuses, nil
}

// Proofs returns the basic proofs for this credential. For specific proof contents, UnmarshalProofValue must be used.
Expand Down Expand Up @@ -206,7 +246,7 @@ func (vc VerifiableCredential) MarshalJSON() ([]byte, error) {
if data, err := json.Marshal(tmp); err != nil {
return nil, err
} else {
return marshal.NormalizeDocument(data, pluralContext, marshal.Unplural(typeKey), marshal.Unplural(credentialSubjectKey), marshal.Unplural(proofKey))
return marshal.NormalizeDocument(data, pluralContext, marshal.Unplural(typeKey), marshal.Unplural(credentialSubjectKey), marshal.Unplural(credentialStatusKey), marshal.Unplural(proofKey))
}
}

Expand All @@ -229,16 +269,21 @@ func (vc *VerifiableCredential) UnmarshalJSON(b []byte) error {
// UnmarshalProofValue unmarshalls the proof to the given proof type. Always pass a slice as target since there could be multiple proofs.
// Each proof will result in a value, where null values may exist when the proof doesn't have the json member.
func (vc VerifiableCredential) UnmarshalProofValue(target interface{}) error {
if asJSON, err := json.Marshal(vc.Proof); err != nil {
return err
} else {
return json.Unmarshal(asJSON, target)
}
return unmarshalAnySliceToTarget(vc.Proof, target)
}

// UnmarshalCredentialSubject unmarshalls the credentialSubject to the given credentialSubject type. Always pass a slice as target.
func (vc VerifiableCredential) UnmarshalCredentialSubject(target interface{}) error {
if asJSON, err := json.Marshal(vc.CredentialSubject); err != nil {
return unmarshalAnySliceToTarget(vc.CredentialSubject, target)
}

// UnmarshalCredentialStatus unmarshalls the credentialStatus field to the provided target. Always pass a slice as target.
func (vc VerifiableCredential) UnmarshalCredentialStatus(target any) error {
return unmarshalAnySliceToTarget(vc.CredentialStatus, target)
}

func unmarshalAnySliceToTarget(s []any, target any) error {
if asJSON, err := json.Marshal(s); err != nil {
return err
} else {
return json.Unmarshal(asJSON, target)
Expand Down
71 changes: 69 additions & 2 deletions vc/vc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ func TestVerifiableCredential_JSONMarshalling(t *testing.T) {
raw := `{
"id":"did:example:123#vc-1",
"type":["VerifiableCredential", "custom"],
"credentialSubject": {"name": "test"}
"credentialSubject": {"name": "test"},
"credentialStatus": {"id": "example.com", "type": "Custom"}
}`
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, []interface{}{map[string]interface{}{"id": "example.com", "type": "Custom"}}, input.CredentialStatus)
assert.Equal(t, JSONLDCredentialProofFormat, input.Format())
assert.Equal(t, raw, input.Raw())
assert.Nil(t, input.JWT())
Expand Down Expand Up @@ -138,7 +140,53 @@ func TestVerifiableCredential_UnmarshalCredentialSubject(t *testing.T) {
})
}

func TestCredentialStatus(t *testing.T) {
func TestVerifiableCredential_UnmarshalCredentialStatus(t *testing.T) {
type CustomCredentialStatus struct {
Id string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
CustomField string `json:"customField,omitempty"`
}
expectedJSON := `
{ "credentialStatus": {
"id": "not a uri but doesn't fail",
"type": "CustomType",
"customField": "not empty"
}
}`
// custom status that contains more fields than CredentialStatus
cred := VerifiableCredential{}
require.NoError(t, json.Unmarshal([]byte(expectedJSON), &cred))
var target []CustomCredentialStatus

err := cred.UnmarshalCredentialStatus(&target)

assert.NoError(t, err)
require.Len(t, target, 1)
assert.Equal(t, "CustomType", target[0].Type)
assert.Equal(t, "not empty", target[0].CustomField)
}

func TestVerifiableCredential_CredentialStatuses(t *testing.T) {
expectedJSON := `
{ "credentialStatus": {
"id": "valid.uri",
"type": "CustomType",
"customField": "not empty"
}
}`
cred := VerifiableCredential{}
require.NoError(t, json.Unmarshal([]byte(expectedJSON), &cred))

statuses, err := cred.CredentialStatuses()

assert.NoError(t, err)
require.Len(t, statuses, 1)
assert.Equal(t, ssi.MustParseURI("valid.uri"), statuses[0].ID)
assert.Equal(t, "CustomType", statuses[0].Type)
assert.NotEmpty(t, statuses[0].Raw())
}

func TestCredentialStatus_UnmarshalJSON(t *testing.T) {
t.Run("can unmarshal JWT VC Presentation Profile JWT-VC example", func(t *testing.T) {
// CredentialStatus example taken from https://identity.foundation/jwt-vc-presentation-profile/#vc-jwt
// Regression: earlier defined credentialStatus.id as url.URL, which breaks since it's specified as URI by the core specification.
Expand All @@ -154,9 +202,28 @@ func TestCredentialStatus(t *testing.T) {
require.NoError(t, err)

assert.Equal(t, "urn:uuid:7facf41c-1dc5-486b-87e6-587d015e76d7?bit-index=10", actual.ID.String())
assert.Greater(t, len(actual.raw), 1)
})
}

func TestCredentialStatus_Raw(t *testing.T) {
orig := CredentialStatus{
ID: ssi.MustParseURI("something"),
Type: "statusType",
}
bs, _ := json.Marshal(orig)

var remarshalled CredentialStatus
require.NoError(t, json.Unmarshal(bs, &remarshalled))

raw := remarshalled.Raw()
require.Greater(t, len(raw), 1) // make sure raw exists, and we do not end up creating a new slice

assert.Equal(t, raw, remarshalled.raw)
raw[0] = 'x' // was '{'
assert.NotEqual(t, raw, remarshalled.raw)
}

func TestVerifiableCredential_UnmarshalProof(t *testing.T) {
type jsonWebSignature struct {
Jws string
Expand Down