Skip to content
This repository has been archived by the owner on Mar 27, 2024. It is now read-only.

Commit

Permalink
feat: wallet.Issue supports jwtvc, verifiable.Credential can hold a j…
Browse files Browse the repository at this point in the history
…wtvc (#3345)

Signed-off-by: Filip Burlacu <[email protected]>

Signed-off-by: Filip Burlacu <[email protected]>
  • Loading branch information
Moopli authored Aug 25, 2022
1 parent 07695f2 commit 6c0c224
Show file tree
Hide file tree
Showing 11 changed files with 571 additions and 20 deletions.
63 changes: 52 additions & 11 deletions pkg/doc/verifiable/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ type Credential struct {
Evidence Evidence
TermsOfUse []TypedID
RefreshService []TypedID
JWT string

CustomFields CustomFields
}
Expand All @@ -528,6 +529,7 @@ type rawCredential struct {
Evidence Evidence `json:"evidence,omitempty"`
TermsOfUse json.RawMessage `json:"termsOfUse,omitempty"`
RefreshService json.RawMessage `json:"refreshService,omitempty"`
JWT string `json:"jwt,omitempty"`

// All unmapped fields are put here.
CustomFields `json:"-"`
Expand Down Expand Up @@ -794,7 +796,7 @@ func ParseCredential(vcData []byte, opts ...CredentialOpt) (*Credential, error)
vcOpts := getCredentialOpts(opts)

// Decode credential (e.g. from JWT).
vcDataDecoded, err := decodeRaw(vcData, vcOpts)
vcDataDecoded, externalJWT, err := decodeRaw(vcData, vcOpts)
if err != nil {
return nil, fmt.Errorf("decode new credential: %w", err)
}
Expand All @@ -817,6 +819,8 @@ func ParseCredential(vcData []byte, opts ...CredentialOpt) (*Credential, error)
return nil, err
}

vc.JWT = externalJWT

return vc, nil
}

Expand Down Expand Up @@ -987,6 +991,7 @@ func newCredential(raw *rawCredential) (*Credential, error) {
Evidence: raw.Evidence,
TermsOfUse: termsOfUse,
RefreshService: refreshService,
JWT: raw.JWT,
CustomFields: raw.CustomFields,
}, nil
}
Expand All @@ -1013,33 +1018,62 @@ func parseTypedID(bytes json.RawMessage) ([]TypedID, error) {
return nil, err
}

func decodeRaw(vcData []byte, vcOpts *credentialOpts) ([]byte, error) {
vcStr := string(vcData)
type externalJWTVC struct {
JWT string `json:"jwt,omitempty"`
}

func unQuote(s []byte) []byte {
if len(s) <= 1 {
return s
}

if s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}

return s
}

func decodeRaw(vcData []byte, vcOpts *credentialOpts) ([]byte, string, error) {
vcStr := string(unQuote(vcData))
externalVCStr := vcStr

jwtHolder := &externalJWTVC{}
e := json.Unmarshal(vcData, jwtHolder)

hasJWT := e == nil && jwtHolder.JWT != ""
if hasJWT {
externalVCStr = jwtHolder.JWT
}

if jwt.IsJWS(vcStr) { // External proof, is checked by JWS.
if jwt.IsJWS(externalVCStr) { // External proof, is checked by JWS.
if vcOpts.publicKeyFetcher == nil && !vcOpts.disabledProofCheck {
return nil, errors.New("public key fetcher is not defined")
return nil, "", errors.New("public key fetcher is not defined")
}

vcDecodedBytes, err := decodeCredJWS(vcStr, !vcOpts.disabledProofCheck, vcOpts.publicKeyFetcher)
vcDecodedBytes, err := decodeCredJWS(externalVCStr, !vcOpts.disabledProofCheck, vcOpts.publicKeyFetcher)
if err != nil {
return nil, fmt.Errorf("JWS decoding: %w", err)
return nil, "", fmt.Errorf("JWS decoding: %w", err)
}

return vcDecodedBytes, nil
return vcDecodedBytes, externalVCStr, nil
}

if jwt.IsJWTUnsecured(vcStr) { // Embedded proof.
vcDecodedBytes, err := decodeCredJWTUnsecured(vcStr)
if err != nil {
return nil, fmt.Errorf("unsecured JWT decoding: %w", err)
return nil, "", fmt.Errorf("unsecured JWT decoding: %w", err)
}

return checkEmbeddedProof(vcDecodedBytes, getEmbeddedProofCheckOpts(vcOpts))
vc, err := checkEmbeddedProof(vcDecodedBytes, getEmbeddedProofCheckOpts(vcOpts))

return vc, "", err
}

// Embedded proof.
return checkEmbeddedProof(vcData, getEmbeddedProofCheckOpts(vcOpts))
vc, e := checkEmbeddedProof(vcData, getEmbeddedProofCheckOpts(vcOpts))

return vc, "", e
}

func getEmbeddedProofCheckOpts(vcOpts *credentialOpts) *embeddedProofCheckOpts {
Expand Down Expand Up @@ -1432,6 +1466,7 @@ func (vc *Credential) raw() (*rawCredential, error) {
TermsOfUse: rawTermsOfUse,
Issued: vc.Issued,
Expired: vc.Expired,
JWT: vc.JWT,
CustomFields: vc.CustomFields,
}

Expand Down Expand Up @@ -1476,6 +1511,12 @@ func typedIDsToRaw(typedIDs []TypedID) ([]byte, error) {

// MarshalJSON converts Verifiable Credential to JSON bytes.
func (vc *Credential) MarshalJSON() ([]byte, error) {
if vc.JWT != "" {
// If vc.JWT exists, marshal only the JWT, since all other values should be unchanged
// from when the JWT was parsed.
return []byte("\"" + vc.JWT + "\""), nil
}

raw, err := vc.raw()
if err != nil {
return nil, fmt.Errorf("JSON marshalling of verifiable credential: %w", err)
Expand Down
3 changes: 3 additions & 0 deletions pkg/doc/verifiable/credential_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func newJWTCredClaims(vc *Credential, minimizeVC bool) (*JWTCredClaims, error) {
return nil, err
}

// If a Credential was parsed from JWT, we don't want the original JWT included when marshaling back to JWT claims.
raw.JWT = ""

vcMap, err := jsonutil.MergeCustomFields(raw, raw.CustomFields)
if err != nil {
return nil, err
Expand Down
9 changes: 9 additions & 0 deletions pkg/doc/verifiable/credential_jwt_proof_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ func TestParseCredentialFromJWS(t *testing.T) {
vc, err := parseTestCredential(t, testCred)
require.NoError(t, err)

require.NotEqual(t, "", vcFromJWT.JWT)
vcFromJWT.JWT = ""

require.Equal(t, vc, vcFromJWT)
})

Expand All @@ -80,6 +83,9 @@ func TestParseCredentialFromJWS(t *testing.T) {
vc, err := parseTestCredential(t, testCred)
require.NoError(t, err)

require.NotEqual(t, "", vcFromJWT.JWT)
vcFromJWT.JWT = ""

require.Equal(t, vc, vcFromJWT)
})

Expand Down Expand Up @@ -140,6 +146,9 @@ func TestParseCredentialFromJWS_EdDSA(t *testing.T) {
WithPublicKeyFetcher(SingleKey(signer.PublicKeyBytes(), kms.ED25519)))
require.NoError(t, err)

require.NotEqual(t, "", vcFromJWS.JWT)
vcFromJWS.JWT = ""

// unmarshalled credential must be the same as original one
require.Equal(t, vc, vcFromJWS)
}
Expand Down
4 changes: 4 additions & 0 deletions pkg/doc/verifiable/credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,10 @@ func TestParseUnverifiedCredential(t *testing.T) {
WithDisabledProofCheck())
require.NoError(t, err)
require.NotNil(t, vcUnverified)

require.Equal(t, jws, vcUnverified.JWT)
vcUnverified.JWT = ""

require.Equal(t, vc, vcUnverified)
})

Expand Down
2 changes: 2 additions & 0 deletions pkg/doc/verifiable/embedded_proof.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ func checkEmbeddedProof(docBytes []byte, opts *embeddedProofCheckOpts) ([]byte,
return nil, fmt.Errorf("embedded proof is not JSON: %w", err)
}

delete(jsonldDoc, "jwt")

proofElement, ok := jsonldDoc["proof"]
if !ok || proofElement == nil {
// do not make a check if there is no proof defined as proof presence is not mandatory
Expand Down
36 changes: 35 additions & 1 deletion pkg/doc/verifiable/example_credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ func ExampleCredential_embedding() {
panic(fmt.Errorf("failed to encode VC from JWS: %w", err))
}

// When a Credential was parsed from JWS, it Marshals into a JSON string containing the original JWS.

vcBytesFromJWS, err := vcParsed.MarshalJSON()
if err != nil {
panic(fmt.Errorf("failed to marshal VC: %w", err))
Expand All @@ -144,9 +146,21 @@ func ExampleCredential_embedding() {
// todo missing referenceNumber here (https://github.com/hyperledger/aries-framework-go/issues/847)
fmt.Println(string(vcBytesFromJWS))

// To marshal the Credential into JSON-LD form, clear the JWT field.

vcParsed.JWT = ""

vcBytesFromJWS, err = vcParsed.MarshalJSON()
if err != nil {
panic(fmt.Errorf("failed to marshal VC: %w", err))
}

fmt.Println(string(vcBytesFromJWS))

// Output:
// {"@context":["https://www.w3.org/2018/credentials/v1","https://www.w3.org/2018/credentials/examples/v1"],"credentialSubject":{"degree":{"type":"BachelorDegree","university":"MIT"},"id":"did:example:ebfeb1f712ebc6f1c276e12ec21","name":"Jayden Doe","spouse":"did:example:c276e12ec21ebfeb1f712ebc6f1"},"expirationDate":"2020-01-01T19:23:24Z","id":"http://example.edu/credentials/1872","issuanceDate":"2010-01-01T19:23:24Z","issuer":{"id":"did:example:76e12ec712ebc6f1c221ebfeb1f","name":"Example University"},"referenceNumber":83294847,"type":["VerifiableCredential","UniversityDegreeCredential"]}
// eyJhbGciOiJFZERTQSIsImtpZCI6IiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1Nzc5MDY2MDQsImlhdCI6MTI2MjM3MzgwNCwiaXNzIjoiZGlkOmV4YW1wbGU6NzZlMTJlYzcxMmViYzZmMWMyMjFlYmZlYjFmIiwianRpIjoiaHR0cDovL2V4YW1wbGUuZWR1L2NyZWRlbnRpYWxzLzE4NzIiLCJuYmYiOjEyNjIzNzM4MDQsInN1YiI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjEiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZGVncmVlIjp7InR5cGUiOiJCYWNoZWxvckRlZ3JlZSIsInVuaXZlcnNpdHkiOiJNSVQifSwiaWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJuYW1lIjoiSmF5ZGVuIERvZSIsInNwb3VzZSI6ImRpZDpleGFtcGxlOmMyNzZlMTJlYzIxZWJmZWIxZjcxMmViYzZmMSJ9LCJpc3N1ZXIiOnsibmFtZSI6IkV4YW1wbGUgVW5pdmVyc2l0eSJ9LCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiVW5pdmVyc2l0eURlZ3JlZUNyZWRlbnRpYWwiXX19.7He-0-kAUCgjgMUSI-BmH-9MjI-ixuMV6NUnJCtfLpoOJIkdK0Tf1iU6SWGSURpv67Mi91H-pzQCmW6jzEUABQ
// "eyJhbGciOiJFZERTQSIsImtpZCI6IiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1Nzc5MDY2MDQsImlhdCI6MTI2MjM3MzgwNCwiaXNzIjoiZGlkOmV4YW1wbGU6NzZlMTJlYzcxMmViYzZmMWMyMjFlYmZlYjFmIiwianRpIjoiaHR0cDovL2V4YW1wbGUuZWR1L2NyZWRlbnRpYWxzLzE4NzIiLCJuYmYiOjEyNjIzNzM4MDQsInN1YiI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjEiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZGVncmVlIjp7InR5cGUiOiJCYWNoZWxvckRlZ3JlZSIsInVuaXZlcnNpdHkiOiJNSVQifSwiaWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJuYW1lIjoiSmF5ZGVuIERvZSIsInNwb3VzZSI6ImRpZDpleGFtcGxlOmMyNzZlMTJlYzIxZWJmZWIxZjcxMmViYzZmMSJ9LCJpc3N1ZXIiOnsibmFtZSI6IkV4YW1wbGUgVW5pdmVyc2l0eSJ9LCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiVW5pdmVyc2l0eURlZ3JlZUNyZWRlbnRpYWwiXX19.7He-0-kAUCgjgMUSI-BmH-9MjI-ixuMV6NUnJCtfLpoOJIkdK0Tf1iU6SWGSURpv67Mi91H-pzQCmW6jzEUABQ"
// {"@context":["https://www.w3.org/2018/credentials/v1","https://www.w3.org/2018/credentials/examples/v1"],"credentialSubject":{"degree":{"type":"BachelorDegree","university":"MIT"},"id":"did:example:ebfeb1f712ebc6f1c276e12ec21","name":"Jayden Doe","spouse":"did:example:c276e12ec21ebfeb1f712ebc6f1"},"expirationDate":"2020-01-01T19:23:24Z","id":"http://example.edu/credentials/1872","issuanceDate":"2010-01-01T19:23:24Z","issuer":{"id":"did:example:76e12ec712ebc6f1c221ebfeb1f","name":"Example University"},"type":["VerifiableCredential","UniversityDegreeCredential"]}
}

Expand Down Expand Up @@ -214,6 +228,8 @@ func ExampleCredential_extraFields() {
panic(fmt.Errorf("failed to encode VC from JWS: %w", err))
}

vcParsed.JWT = ""

vcBytesFromJWS, err := vcParsed.MarshalJSON()
if err != nil {
panic(fmt.Errorf("failed to marshal VC: %w", err))
Expand Down Expand Up @@ -282,6 +298,11 @@ func ExampleParseCredential() {
panic(fmt.Errorf("failed to decode VC JWS: %w", err))
}

// When parsing a verifiable.Credential from JWS, Credential.JWT is set to the raw JWS value.
// This allows the user to save the Credential and verify it later.

// When Credential.JWT is set, the Credential Marshals into a JSON string containing the original JWS.

vcDecodedBytes, err := vcParsed.MarshalJSON()
if err != nil {
panic(fmt.Errorf("failed to marshal VC: %w", err))
Expand All @@ -290,7 +311,20 @@ func ExampleParseCredential() {
// The Holder then e.g. can save the credential to her personal verifiable credential wallet.
fmt.Println(string(vcDecodedBytes))

// Output: {"@context":["https://www.w3.org/2018/credentials/v1","https://www.w3.org/2018/credentials/examples/v1"],"credentialSubject":{"degree":{"type":"BachelorDegree","university":"MIT"},"id":"did:example:ebfeb1f712ebc6f1c276e12ec21","name":"Jayden Doe","spouse":"did:example:c276e12ec21ebfeb1f712ebc6f1"},"expirationDate":"2020-01-01T19:23:24Z","id":"http://example.edu/credentials/1872","issuanceDate":"2010-01-01T19:23:24Z","issuer":{"id":"did:example:76e12ec712ebc6f1c221ebfeb1f","name":"Example University"},"referenceNumber":83294847,"type":["VerifiableCredential","UniversityDegreeCredential"]}
// To marshal the Credential into JSON-LD form, clear the JWT field.
vcParsed.JWT = ""

vcDecodedBytes, err = vcParsed.MarshalJSON()
if err != nil {
panic(fmt.Errorf("failed to marshal VC: %w", err))
}

// The Credential is now in JSON-LD form..
fmt.Println(string(vcDecodedBytes))

// Output:
// "eyJhbGciOiJFZERTQSIsImtpZCI6IiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1Nzc5MDY2MDQsImlhdCI6MTI2MjM3MzgwNCwiaXNzIjoiZGlkOmV4YW1wbGU6NzZlMTJlYzcxMmViYzZmMWMyMjFlYmZlYjFmIiwianRpIjoiaHR0cDovL2V4YW1wbGUuZWR1L2NyZWRlbnRpYWxzLzE4NzIiLCJuYmYiOjEyNjIzNzM4MDQsInN1YiI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjEiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZGVncmVlIjp7InR5cGUiOiJCYWNoZWxvckRlZ3JlZSIsInVuaXZlcnNpdHkiOiJNSVQifSwiaWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJuYW1lIjoiSmF5ZGVuIERvZSIsInNwb3VzZSI6ImRpZDpleGFtcGxlOmMyNzZlMTJlYzIxZWJmZWIxZjcxMmViYzZmMSJ9LCJpc3N1ZXIiOnsibmFtZSI6IkV4YW1wbGUgVW5pdmVyc2l0eSJ9LCJyZWZlcmVuY2VOdW1iZXIiOjguMzI5NDg0N2UrMDcsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJVbml2ZXJzaXR5RGVncmVlQ3JlZGVudGlhbCJdfX0.a5yKMPmDnEXvM-fG3BaOqfdkqdvU4s2rzeZuOzLmkTH1y9sJT-mgTe7map5E9x7abrNVpyYbaH7JaAb9Yhr1DQ"
// {"@context":["https://www.w3.org/2018/credentials/v1","https://www.w3.org/2018/credentials/examples/v1"],"credentialSubject":{"degree":{"type":"BachelorDegree","university":"MIT"},"id":"did:example:ebfeb1f712ebc6f1c276e12ec21","name":"Jayden Doe","spouse":"did:example:c276e12ec21ebfeb1f712ebc6f1"},"expirationDate":"2020-01-01T19:23:24Z","id":"http://example.edu/credentials/1872","issuanceDate":"2010-01-01T19:23:24Z","issuer":{"id":"did:example:76e12ec712ebc6f1c221ebfeb1f","name":"Example University"},"referenceNumber":83294847,"type":["VerifiableCredential","UniversityDegreeCredential"]}
}

func ExampleCredential_JWTClaims() {
Expand Down
10 changes: 6 additions & 4 deletions pkg/doc/verifiable/presentation.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,13 +476,15 @@ func decodeCredentials(rawCred interface{}, opts *presentationOpts) ([]interface
return nil, nil
}

marshalSingleCredFn := func(cred interface{}) (interface{}, error) {
unmarshalSingleCredFn := func(cred interface{}) (interface{}, error) {
// Check the case when VC is defined in string format (e.g. JWT).
// Decode credential and keep result of decoding.
if sCred, ok := cred.(string); ok {
bCred := []byte(sCred)

credDecoded, err := decodeRaw(bCred, mapOpts(opts))
// TODO: check if JWT VPs require the raw JWT for their JWT VCs
// if so, save the raw JWT strings returned from decodeRaw()
credDecoded, _, err := decodeRaw(bCred, mapOpts(opts))
if err != nil {
return nil, fmt.Errorf("decode credential of presentation: %w", err)
}
Expand All @@ -505,7 +507,7 @@ func decodeCredentials(rawCred interface{}, opts *presentationOpts) ([]interface
creds := make([]interface{}, len(cred))

for i := range cred {
c, err := marshalSingleCredFn(cred[i])
c, err := unmarshalSingleCredFn(cred[i])
if err != nil {
return nil, err
}
Expand All @@ -516,7 +518,7 @@ func decodeCredentials(rawCred interface{}, opts *presentationOpts) ([]interface
return creds, nil
default:
// single credential
c, err := marshalSingleCredFn(cred)
c, err := unmarshalSingleCredFn(cred)
if err != nil {
return nil, err
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/doc/verifiable/test-suite/verifiable_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ func decodeVCJWTToJSON(vcBytes []byte, publicKey *rsa.PublicKey) {
abort("failed to decode credential: %v", err)
}

credential.JWT = ""

jsonBytes, err := credential.MarshalJSON()
if err != nil {
abort("failed to marshall verifiable credential to JSON: %v", err)
Expand Down
18 changes: 17 additions & 1 deletion pkg/wallet/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,20 @@ type QueryParams struct {
Query []json.RawMessage `json:"credentialQuery"`
}

// ProofFormat determines whether a credential or presentation should be signed with an external JWT proof
// (wrapping the credential to form a JWT-VC) or with an embedded LD proof.
type ProofFormat string

const (
// ExternalJWTProofFormat indicates that a credential or presentation should be signed with an external JWT proof.
ExternalJWTProofFormat = "ExternalJWTProofFormat"
// EmbeddedLDProofFormat indicates that a credential or presentation should be signed with an embedded LD proof.
EmbeddedLDProofFormat = "EmbeddedLDProofFormat"
)

// ProofOptions model
//
// Options for adding linked data proofs to a verifiable credential or a verifiable presentation.
// Options for adding JWT or linked data proofs to a verifiable credential or a verifiable presentation.
// To be used as options for issue/prove wallet features.
//
type ProofOptions struct {
Expand All @@ -38,6 +49,11 @@ type ProofOptions struct {
// Created date of the proof.
// Optional, current system time will be used.
Created *time.Time `json:"created,omitempty"`
// ProofFormat determines whether a credential or presentation should be signed with an external JWT proof
// (wrapping the credential to form a JWT-VC) or with an embedded LD proof.
//
// Optional: If empty, defaults to EmbeddedLDProofFormat.
ProofFormat ProofFormat `json:"proofFormat,omitempty"`
// Domain is operational domain of a digital proof.
// Optional, by default domain will not be part of proof.
Domain string `json:"domain,omitempty"`
Expand Down
Loading

0 comments on commit 6c0c224

Please sign in to comment.