Skip to content

Commit

Permalink
Add UnmarshalCredentialStatus (#96)
Browse files Browse the repository at this point in the history
* add UnmarshalCredentialStatus

* changed vc.credentialStatus to []any
  • Loading branch information
gerardsn authored Dec 1, 2023
1 parent 3293353 commit fe64a8c
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 13 deletions.
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

0 comments on commit fe64a8c

Please sign in to comment.