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

Commit

Permalink
feat: add SD JWT credential format support
Browse files Browse the repository at this point in the history
Signed-off-by: Mykhailo Sizov <[email protected]>
  • Loading branch information
mishasizov-SK committed Jul 26, 2023
1 parent b2fdf79 commit 46f891f
Show file tree
Hide file tree
Showing 11 changed files with 259 additions and 10 deletions.
4 changes: 4 additions & 0 deletions component/models/sdjwt/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ func GetSDAlg(claims map[string]interface{}) (string, error) {

// GetKeyFromVC returns key value from VC.
func GetKeyFromVC(key string, claims map[string]interface{}) (interface{}, bool) {
if obj, ok := claims[key]; ok {
return obj, true
}

Check warning on line 298 in component/models/sdjwt/common/common.go

View check run for this annotation

Codecov / codecov/patch

component/models/sdjwt/common/common.go#L297-L298

Added lines #L297 - L298 were not covered by tests

vcObj, ok := claims["vc"]
if !ok {
return nil, false
Expand Down
38 changes: 34 additions & 4 deletions component/models/sdjwt/issuer/issuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ type newOpts struct {
addDecoyDigests bool
structuredClaims bool

sdjwtCredentialFormat bool

nonSDClaimsMap map[string]bool
}

Expand Down Expand Up @@ -202,6 +204,17 @@ func WithStructuredClaims(flag bool) NewOpt {
}
}

// WithSDJWTCredentialFormat is an option that declares the SDJWT credential format.
// Key difference with default format is that underlying object does not contain custom "vc" root claim.
// Example:
//
// https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-example-4b-w3c-verifiable-c.
func WithSDJWTCredentialFormat(flag bool) NewOpt {
return func(opts *newOpts) {
opts.sdjwtCredentialFormat = flag
}
}

// WithNonSelectivelyDisclosableClaims is an option for provide claim names that should be ignored when creating
// selectively disclosable claims.
// For example if you would like to not selectively disclose id and degree type from the following claims:
Expand Down Expand Up @@ -289,6 +302,12 @@ Algorithm:
*/
func NewFromVC(vc map[string]interface{}, headers jose.Headers,
signer jose.Signer, opts ...NewOpt) (*SelectiveDisclosureJWT, error) {
nOpts := &newOpts{}

for _, opt := range opts {
opt(nOpts)
}

csObj, ok := common.GetKeyFromVC(credentialSubjectKey, vc)
if !ok {
return nil, fmt.Errorf("credential subject not found")
Expand All @@ -304,20 +323,29 @@ func NewFromVC(vc map[string]interface{}, headers jose.Headers,
return nil, err
}

var vcClaims = vc
if !nOpts.sdjwtCredentialFormat {
// Since it's not SD JWT credential format - need to consider "vc" underlying object
vcClaims, ok = vc[vcKey].(map[string]interface{})
if !ok {
return nil, errors.New("invalid vc claim")
}

Check warning on line 332 in component/models/sdjwt/issuer/issuer.go

View check run for this annotation

Codecov / codecov/patch

component/models/sdjwt/issuer/issuer.go#L331-L332

Added lines #L331 - L332 were not covered by tests
}

selectiveCredentialSubject := utils.CopyMap(token.SignedJWT.Payload)
// move _sd_alg key from credential subject to vc as per example 4 in spec
vc[vcKey].(map[string]interface{})[common.SDAlgorithmKey] = selectiveCredentialSubject[common.SDAlgorithmKey]
vcClaims[common.SDAlgorithmKey] = selectiveCredentialSubject[common.SDAlgorithmKey]
delete(selectiveCredentialSubject, common.SDAlgorithmKey)

// move cnf key from credential subject to vc as per example 4 in spec
cnfObj, ok := selectiveCredentialSubject[common.CNFKey]
if ok {
vc[vcKey].(map[string]interface{})[common.CNFKey] = cnfObj
vcClaims[common.CNFKey] = cnfObj
delete(selectiveCredentialSubject, common.CNFKey)
}

// update VC with 'selective' credential subject
vc[vcKey].(map[string]interface{})[credentialSubjectKey] = selectiveCredentialSubject
vcClaims[credentialSubjectKey] = selectiveCredentialSubject

// sign VC with 'selective' credential subject
signedJWT, err := afgjwt.NewSigned(vc, headers, signer)
Expand Down Expand Up @@ -478,7 +506,9 @@ func createDisclosuresAndDigests(path string, claims map[string]interface{}, opt
return nil, nil, err
}

digestsMap[common.SDKey] = digests
if len(digests) > 0 {
digestsMap[common.SDKey] = digests
}

return disclosures, digestsMap, nil
}
Expand Down
73 changes: 73 additions & 0 deletions component/models/sdjwt/issuer/issuer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,50 @@ func TestNewFromVC(t *testing.T) {
r.Contains(err.Error(), "unknown key id")
})

t.Run("success - structured claims + holder binding + SD JWT credential format", func(t *testing.T) {
holderPublicKey, _, err := ed25519.GenerateKey(rand.Reader)
r.NoError(err)

holderPublicJWK, err := jwksupport.JWKFromKey(holderPublicKey)
require.NoError(t, err)

// create VC - we will use template here
var vc map[string]interface{}
err = json.Unmarshal([]byte(sampleSDJWTVCFull), &vc)
r.NoError(err)

token, err := NewFromVC(vc, nil, signer,
WithHolderPublicKey(holderPublicJWK),
WithStructuredClaims(true),
WithNonSelectivelyDisclosableClaims([]string{"id", "degree.type"}),
WithSDJWTCredentialFormat(true))
r.NoError(err)

vcCombinedFormatForIssuance, err := token.Serialize(false)
r.NoError(err)

fmt.Println(fmt.Sprintf("issuer SD-JWT: %s", vcCombinedFormatForIssuance))

var vcWithSelectedDisclosures map[string]interface{}
err = token.DecodeClaims(&vcWithSelectedDisclosures)
r.NoError(err)

printObject(t, "VC with selected disclosures", vcWithSelectedDisclosures)

id, err := jsonpath.Get("$.credentialSubject.id", vcWithSelectedDisclosures)
r.NoError(err)
r.Equal("did:example:ebfeb1f712ebc6f1c276e12ec21", id)

degreeType, err := jsonpath.Get("$.credentialSubject.degree.type", vcWithSelectedDisclosures)
r.NoError(err)
r.Equal("BachelorDegree", degreeType)

degreeID, err := jsonpath.Get("$.credentialSubject.degree.id", vcWithSelectedDisclosures)
r.Error(err)
r.Nil(degreeID)
r.Contains(err.Error(), "unknown key id")
})

t.Run("success - flat claims + holder binding", func(t *testing.T) {
holderPublicKey, _, err := ed25519.GenerateKey(rand.Reader)
r.NoError(err)
Expand Down Expand Up @@ -1014,3 +1058,32 @@ const sampleVCFull = `
"type": "VerifiableCredential"
}
}`

const sampleSDJWTVCFull = `
{
"iat": 1673987547,
"iss": "did:example:76e12ec712ebc6f1c221ebfeb1f",
"jti": "http://example.edu/credentials/1872",
"nbf": 1673987547,
"sub": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"@context": [
"https://www.w3.org/2018/credentials/v1"
],
"credentialSubject": {
"degree": {
"degree": "MIT",
"type": "BachelorDegree",
"id": "some-id"
},
"name": "Jayden Doe",
"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1"
},
"first_name": "First name",
"id": "http://example.edu/credentials/1872",
"info": "Info",
"issuanceDate": "2023-01-17T22:32:27.468109817+02:00",
"issuer": "did:example:76e12ec712ebc6f1c221ebfeb1f",
"last_name": "Last name",
"type": "VerifiableCredential"
}`
43 changes: 43 additions & 0 deletions component/models/verifiable/credential_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,49 @@ type JWTCredClaims struct {
VC map[string]interface{} `json:"vc,omitempty"`
}

// ToSDJWTCredentialPayload defines custom marshalling of JWTCredClaims.
// Key difference with default marshaller is that returned object does not contain custom "vc" root claim.
// Example:
//
// https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-example-4b-w3c-verifiable-c.
func (jcc *JWTCredClaims) ToSDJWTCredentialPayload() ([]byte, error) {
type Alias JWTCredClaims

alias := Alias(*jcc)

vcMap := alias.VC

alias.VC = nil

data, err := jsonutil.MarshalWithCustomFields(alias, vcMap)
if err != nil {
return nil, fmt.Errorf("marshal JWTW3CCredClaims: %w", err)
}

Check warning on line 52 in component/models/verifiable/credential_jwt.go

View check run for this annotation

Codecov / codecov/patch

component/models/verifiable/credential_jwt.go#L51-L52

Added lines #L51 - L52 were not covered by tests

return data, nil
}

// UnmarshalJSON defines custom unmarshalling of JWTCredClaims from JSON.
// For SD-JWT case, it supports both v2 and v5 formats.
func (jcc *JWTCredClaims) UnmarshalJSON(data []byte) error {
type Alias JWTCredClaims

alias := (*Alias)(jcc)

customFields := make(CustomFields)

err := jsonutil.UnmarshalWithCustomFields(data, alias, customFields)
if err != nil {
return fmt.Errorf("unmarshal JWTCredClaims: %w", err)
}

if len(customFields) > 0 && len(alias.VC) == 0 {
alias.VC = customFields
}

return nil
}

// newJWTCredClaims creates JWT Claims of VC with an option to minimize certain fields of VC
// which is put into "vc" claim.
func newJWTCredClaims(vc *Credential, minimizeVC bool) (*JWTCredClaims, error) {
Expand Down
43 changes: 43 additions & 0 deletions component/models/verifiable/credential_jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0
package verifiable

import (
"encoding/json"
"errors"
"testing"
"time"
Expand Down Expand Up @@ -53,3 +54,45 @@ func TestRefineVcFromJwtClaims(t *testing.T) {
require.Equal(t, "2019-08-10T00:00:00Z", vcMap["issuanceDate"])
require.Equal(t, "2029-08-10T00:00:00Z", vcMap["expirationDate"])
}

func TestJWTCredClaims_ToSDJWTCredentialPayload(t *testing.T) {
jcc := &JWTCredClaims{
Claims: &jwt.Claims{
Issuer: "issuer",
Subject: "subject",
Audience: josejwt.Audience{"leela", "fry"},
NotBefore: josejwt.NewNumericDate(time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC)),
ID: "http://example.edu/credentials/3732",
},
VC: map[string]interface{}{
"@context": []interface{}{
"https://www.w3.org/2018/credentials/v1",
"https://trustbloc.github.io/context/vc/examples-v1.jsonld",
},
"id": "http://example.edu/credentials/1989",
"type": "VerifiableCredential",
"credentialSubject": map[string]interface{}{
"id": "did:example:iuajk1f712ebc6f1c276e12ec21",
},
"issuer": map[string]interface{}{
"id": "did:example:09s12ec712ebc6f1c671ebfeb1f",
"name": "Example University",
},
"issuanceDate": "2020-01-01T10:54:01Z",
"credentialStatus": map[string]interface{}{
"id": "https://example.gov/status/65",
"type": "CredentialStatusList2017",
},
},
}

got, err := jcc.ToSDJWTCredentialPayload()
require.NoError(t, err)
require.NotContains(t, string(got), `"vc"`)

var jccMapped *JWTCredClaims
err = json.Unmarshal(got, &jccMapped)
require.NoError(t, err)

require.Equal(t, jcc, jccMapped)
}
23 changes: 21 additions & 2 deletions component/models/verifiable/credential_sdjwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ func filterDisclosures(
}

type makeSDJWTOpts struct {
hashAlg crypto.Hash
hashAlg crypto.Hash
sdjwtCredentialPayloadFormat bool
}

// MakeSDJWTOption provides an option for creating an SD-JWT from a VC.
Expand All @@ -249,6 +250,17 @@ func MakeSDJWTWithHash(hash crypto.Hash) MakeSDJWTOption {
}
}

// WithSDJWTCredentialPayloadFormat sets the payload format in SD-JWT VC.
// Key difference with default format is that returned object does not contain custom "vc" root claim.
// Example:
//
// https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-example-4b-w3c-verifiable-c.
func WithSDJWTCredentialPayloadFormat() MakeSDJWTOption {
return func(opts *makeSDJWTOpts) {
opts.sdjwtCredentialPayloadFormat = true
}
}

// MakeSDJWT creates an SD-JWT in combined format for issuance, with all fields in credentialSubject converted
// recursively into selectively-disclosable SD-JWT claims.
func (vc *Credential) MakeSDJWT(signer jose.Signer, signingKeyID string, options ...MakeSDJWTOption) (string, error) {
Expand Down Expand Up @@ -278,7 +290,13 @@ func makeSDJWT(vc *Credential, signer jose.Signer, signingKeyID string, options
return nil, fmt.Errorf("constructing VC JWT claims: %w", err)
}

claimBytes, err := json.Marshal(claims)
var claimBytes []byte
if opts.sdjwtCredentialPayloadFormat {
claimBytes, err = claims.ToSDJWTCredentialPayload()
} else {
claimBytes, err = json.Marshal(claims)
}

if err != nil {
return nil, err
}
Expand All @@ -297,6 +315,7 @@ func makeSDJWT(vc *Credential, signer jose.Signer, signingKeyID string, options
issuerOptions := []issuer.NewOpt{
issuer.WithStructuredClaims(true),
issuer.WithNonSelectivelyDisclosableClaims([]string{"id"}),
issuer.WithSDJWTCredentialFormat(opts.sdjwtCredentialPayloadFormat),
}

if opts.hashAlg != 0 {
Expand Down
23 changes: 21 additions & 2 deletions component/models/verifiable/credential_sdjwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ func TestParseSDJWT(t *testing.T) {
require.NotNil(t, newVC)
})

t.Run("success with SD JWT credential payload format", func(t *testing.T) {
sdJWTCredFormatString, issuerCredFormatID := createTestSDJWTCred(t, privKey, WithSDJWTCredentialPayloadFormat())

newVC, e := ParseCredential([]byte(sdJWTCredFormatString),
WithPublicKeyFetcher(createDIDKeyFetcher(t, pubKey, issuerCredFormatID)))
require.NoError(t, e)
require.NotNil(t, newVC)
})

t.Run("success with sd alg in subject", func(t *testing.T) {
vc, e := ParseCredential([]byte(sdJWTString), WithDisabledProofCheck())
require.NoError(t, e)
Expand Down Expand Up @@ -306,6 +315,15 @@ func TestMakeSDJWT(t *testing.T) {
require.NoError(t, err)
})

t.Run("with SD JWT credential payload format", func(t *testing.T) {
sdjwt, err := vc.MakeSDJWT(
afgojwt.NewEd25519Signer(privKey), "did:example:abc123#key-1", WithSDJWTCredentialPayloadFormat())
require.NoError(t, err)

_, err = ParseCredential([]byte(sdjwt), WithPublicKeyFetcher(holderPublicKeyFetcher(pubKey)))
require.NoError(t, err)
})

t.Run("with hash option", func(t *testing.T) {
sdjwt, err := vc.MakeSDJWT(afgojwt.NewEd25519Signer(privKey), "did:example:abc123#key-1",
MakeSDJWTWithHash(crypto.SHA512))
Expand Down Expand Up @@ -510,15 +528,16 @@ func (m *mockSigner) Headers() jose.Headers {
return jose.Headers{"alg": "foo"}
}

func createTestSDJWTCred(t *testing.T, privKey ed25519.PrivateKey) (sdJWTCred string, issuerID string) {
func createTestSDJWTCred(
t *testing.T, privKey ed25519.PrivateKey, opts ...MakeSDJWTOption) (sdJWTCred string, issuerID string) {
t.Helper()

testCred := []byte(jwtTestCredential)

srcVC, err := parseTestCredential(t, testCred)
require.NoError(t, err)

sdjwt, err := srcVC.MakeSDJWT(afgojwt.NewEd25519Signer(privKey), srcVC.Issuer.ID+"#keys-1")
sdjwt, err := srcVC.MakeSDJWT(afgojwt.NewEd25519Signer(privKey), srcVC.Issuer.ID+"#keys-1", opts...)
require.NoError(t, err)

return sdjwt, srcVC.Issuer.ID
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,5 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)

replace github.com/hyperledger/aries-framework-go/component/models => ./component/models
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ github.com/hyperledger/aries-framework-go/component/kmscrypto v0.0.0-20230622082
github.com/hyperledger/aries-framework-go/component/kmscrypto v0.0.0-20230622082138-3ffab1691857/go.mod h1:xgNlHAVQjqwoknzHbXkeHkAJgUxRWKfHXPT3nhVhH3Q=
github.com/hyperledger/aries-framework-go/component/log v0.0.0-20230427134832-0c9969493bd3 h1:x5qFQraTX86z9GCwF28IxfnPm6QH5YgHaX+4x97Jwvw=
github.com/hyperledger/aries-framework-go/component/log v0.0.0-20230427134832-0c9969493bd3/go.mod h1:CvYs4l8X2NrrF93weLOu5RTOIJeVdoZITtjEflyuTyM=
github.com/hyperledger/aries-framework-go/component/models v0.0.0-20230622171716-43af8054a539 h1:3wjwGDB4/D2z4lZexGtD8tf13KRy/jiXqI9mtiEHmUo=
github.com/hyperledger/aries-framework-go/component/models v0.0.0-20230622171716-43af8054a539/go.mod h1:Qklxf9WG44vpLGF+Efs1aCWeHhsVOU0HFvEslf0RDrQ=
github.com/hyperledger/aries-framework-go/component/storage/edv v0.0.0-20221025204933-b807371b6f1e h1:/hrQfwJvHJrwV2FSmfnRp5L6yKY9DqDFqwYyb+oVuDU=
github.com/hyperledger/aries-framework-go/component/storage/edv v0.0.0-20221025204933-b807371b6f1e/go.mod h1:ACGP1L+WeecDtyA0Mi2E1kqtPLIGrCWPSJ43q2elwX8=
github.com/hyperledger/aries-framework-go/component/storageutil v0.0.0-20230427134832-0c9969493bd3 h1:JGYA9l5zTlvsvfnXT9hYPpCokAjmVKX0/r7njba7OX4=
Expand Down
Loading

0 comments on commit 46f891f

Please sign in to comment.