diff --git a/vc/json.go b/vc/json.go index d0fda74..7323064 100644 --- a/vc/json.go +++ b/vc/json.go @@ -8,6 +8,7 @@ const ( contextKey = "@context" typeKey = "type" credentialSubjectKey = "credentialSubject" + credentialStatusKey = "credentialStatus" proofKey = "proof" verifiableCredentialKey = "verifiableCredential" ) diff --git a/vc/vc.go b/vc/vc.go index 0580753..05a81ab 100644 --- a/vc/vc.go +++ b/vc/vc.go @@ -1,6 +1,7 @@ package vc import ( + "bytes" "context" "encoding/json" "errors" @@ -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 } @@ -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. @@ -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. @@ -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)) } } @@ -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) diff --git a/vc/vc_test.go b/vc/vc_test.go index bf0f91c..629e2b6 100644 --- a/vc/vc_test.go +++ b/vc/vc_test.go @@ -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()) @@ -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. @@ -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