Skip to content

Commit

Permalink
feat: SD-JWT Holder/Verifier - Process Disclosures
Browse files Browse the repository at this point in the history
Added utility function to decode disclosures (used by both holder and verifier)
Created disclose claims function for holder that re-creates SD-JWT with disclosures for selected claims only.

Created get verified payload function for verifier (removes _sd and _sd_alg from claims and inserts into payload disclosed claims).

Closes hyperledger-archives#3459

Signed-off-by: Sandra Vrtikapa <[email protected]>
  • Loading branch information
sandrask committed Jan 5, 2023
1 parent ace435d commit e60c388
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 45 deletions.
79 changes: 73 additions & 6 deletions pkg/doc/sdjwt/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package common
import (
"crypto"
"encoding/base64"
"encoding/json"
"fmt"
"strings"

Expand All @@ -18,7 +19,17 @@ import (
)

// DisclosureSeparator is disclosure separator.
const DisclosureSeparator = "~"
const (
DisclosureSeparator = "~"

SDAlgorithmKey = "_sd_alg"
SDKey = "_sd"

disclosureParts = 3
saltIndex = 0
nameIndex = 1
valueIndex = 2
)

// Payload represents SD-JWT payload.
type Payload struct {
Expand All @@ -40,6 +51,62 @@ type SDJWT struct {
Disclosures []string
}

// DisclosureClaim defines claim.
type DisclosureClaim struct {
Disclosure string
Salt string
Name string
Value interface{}
}

// GetDisclosureClaims de-codes disclosures.
func GetDisclosureClaims(disclosures []string) ([]*DisclosureClaim, error) {
var claims []*DisclosureClaim

for _, disclosure := range disclosures {
claim, err := getDisclosureClaim(disclosure)
if err != nil {
return nil, err
}

claims = append(claims, claim)
}

return claims, nil
}

func getDisclosureClaim(disclosure string) (*DisclosureClaim, error) {
decoded, err := base64.RawURLEncoding.DecodeString(disclosure)
if err != nil {
return nil, fmt.Errorf("failed to decode disclosure: %w", err)
}

var disclosureArr []interface{}

err = json.Unmarshal(decoded, &disclosureArr)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal disclosure array: %w", err)
}

if len(disclosureArr) != disclosureParts {
return nil, fmt.Errorf("disclosure array size[%d] must be %d", len(disclosureArr), disclosureParts)
}

salt, ok := disclosureArr[saltIndex].(string)
if !ok {
return nil, fmt.Errorf("disclosure salt type[%T] must be string", disclosureArr[saltIndex])
}

name, ok := disclosureArr[nameIndex].(string)
if !ok {
return nil, fmt.Errorf("disclosure name type[%T] must be string", disclosureArr[nameIndex])
}

claim := &DisclosureClaim{Disclosure: disclosure, Salt: salt, Name: name, Value: disclosureArr[valueIndex]}

return claim, nil
}

// ParseSDJWT parses SD-JWT serialized token into SDJWT parts.
func ParseSDJWT(sdJWTSerialized string) *SDJWT {
parts := strings.Split(sdJWTSerialized, DisclosureSeparator)
Expand Down Expand Up @@ -120,28 +187,28 @@ func getCryptoHash(sdAlg string) (crypto.Hash, error) {
case crypto.SHA256.String():
cryptoHash = crypto.SHA256
default:
err = fmt.Errorf("_sd_alg '%s 'not supported", sdAlg)
err = fmt.Errorf("%s '%s 'not supported", SDAlgorithmKey, sdAlg)
}

return cryptoHash, err
}

func getSDAlg(claims map[string]interface{}) (string, error) {
obj, ok := claims["_sd_alg"]
obj, ok := claims[SDAlgorithmKey]
if !ok {
return "", fmt.Errorf("_sd_alg must be present in SD-JWT")
return "", fmt.Errorf("%s must be present in SD-JWT", SDAlgorithmKey)
}

str, ok := obj.(string)
if !ok {
return "", fmt.Errorf("_sd_alg must be a string")
return "", fmt.Errorf("%s must be a string", SDAlgorithmKey)
}

return str, nil
}

func getDisclosureDigests(claims map[string]interface{}) (map[string]bool, error) {
disclosuresObj, ok := claims["_sd"]
disclosuresObj, ok := claims[SDKey]
if !ok {
return nil, nil
}
Expand Down
73 changes: 65 additions & 8 deletions pkg/doc/sdjwt/common/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -84,8 +87,8 @@ func TestVerifyDisclosuresInSDJWT(t *testing.T) {

t.Run("success - selective disclosures nil", func(t *testing.T) {
payload := make(map[string]interface{})
payload["_sd_alg"] = testAlg
payload["_sd"] = nil
payload[SDAlgorithmKey] = testAlg
payload[SDKey] = nil

signedJWT, err := afjwt.NewSigned(payload, nil, signer)
r.NoError(err)
Expand Down Expand Up @@ -132,7 +135,7 @@ func TestVerifyDisclosuresInSDJWT(t *testing.T) {

err = VerifyDisclosuresInSDJWT(nil, signedJWT)
r.Error(err)
r.Contains(err.Error(), "_sd_alg must be present in SD-JWT")
r.Contains(err.Error(), "_sd_alg must be present in SD-JWT", SDAlgorithmKey)
})

t.Run("error - invalid algorithm", func(t *testing.T) {
Expand All @@ -151,7 +154,7 @@ func TestVerifyDisclosuresInSDJWT(t *testing.T) {

t.Run("error - algorithm is not a string", func(t *testing.T) {
payload := make(map[string]interface{})
payload["_sd_alg"] = 18
payload[SDAlgorithmKey] = 18

signedJWT, err := afjwt.NewSigned(payload, nil, signer)
r.NoError(err)
Expand All @@ -163,8 +166,8 @@ func TestVerifyDisclosuresInSDJWT(t *testing.T) {

t.Run("error - selective disclosures must be an array", func(t *testing.T) {
payload := make(map[string]interface{})
payload["_sd_alg"] = testAlg
payload["_sd"] = "test"
payload[SDAlgorithmKey] = testAlg
payload[SDKey] = "test"

signedJWT, err := afjwt.NewSigned(payload, nil, signer)
r.NoError(err)
Expand All @@ -176,8 +179,8 @@ func TestVerifyDisclosuresInSDJWT(t *testing.T) {

t.Run("error - selective disclosures must be a string", func(t *testing.T) {
payload := make(map[string]interface{})
payload["_sd_alg"] = testAlg
payload["_sd"] = []int{123}
payload[SDAlgorithmKey] = testAlg
payload[SDKey] = []int{123}

signedJWT, err := afjwt.NewSigned(payload, nil, signer)
r.NoError(err)
Expand All @@ -188,6 +191,60 @@ func TestVerifyDisclosuresInSDJWT(t *testing.T) {
})
}

func TestGetDisclosureClaims(t *testing.T) {
r := require.New(t)

t.Run("success", func(t *testing.T) {
sdJWT := ParseSDJWT(sdJWT)
require.Equal(t, 1, len(sdJWT.Disclosures))

disclosureClaims, err := GetDisclosureClaims(sdJWT.Disclosures)
r.NoError(err)
r.Len(disclosureClaims, 1)

r.Equal("given_name", disclosureClaims[0].Name)
r.Equal("John", disclosureClaims[0].Value)
})

t.Run("error - invalid disclosure format (not encoded)", func(t *testing.T) {
sdJWT := ParseSDJWT("jws~xyz")
require.Equal(t, 1, len(sdJWT.Disclosures))

disclosureClaims, err := GetDisclosureClaims(sdJWT.Disclosures)
r.Error(err)
r.Nil(disclosureClaims)
r.Contains(err.Error(), "failed to unmarshal disclosure array")
})

t.Run("error - invalid disclosure array (not three parts)", func(t *testing.T) {
disclosureArr := []interface{}{"name", "value"}
disclosureJSON, err := json.Marshal(disclosureArr)
require.NoError(t, err)

sdJWT := ParseSDJWT(fmt.Sprintf("jws~%s", base64.RawURLEncoding.EncodeToString(disclosureJSON)))
require.Equal(t, 1, len(sdJWT.Disclosures))

disclosureClaims, err := GetDisclosureClaims(sdJWT.Disclosures)
r.Error(err)
r.Nil(disclosureClaims)
r.Contains(err.Error(), "disclosure array size[2] must be 3")
})

t.Run("error - invalid disclosure array (name is not a string)", func(t *testing.T) {
disclosureArr := []interface{}{"salt", 123, "value"}
disclosureJSON, err := json.Marshal(disclosureArr)
require.NoError(t, err)

sdJWT := ParseSDJWT(fmt.Sprintf("jws~%s", base64.RawURLEncoding.EncodeToString(disclosureJSON)))
require.Equal(t, 1, len(sdJWT.Disclosures))

disclosureClaims, err := GetDisclosureClaims(sdJWT.Disclosures)
r.Error(err)
r.Nil(disclosureClaims)
r.Contains(err.Error(), "disclosure name type[float64] must be string")
})
}

type NoopSignatureVerifier struct {
}

Expand Down
48 changes: 46 additions & 2 deletions pkg/doc/sdjwt/holder/holder.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ SPDX-License-Identifier: Apache-2.0
package holder

import (
"fmt"

"github.com/hyperledger/aries-framework-go/pkg/doc/jose"
afgjwt "github.com/hyperledger/aries-framework-go/pkg/doc/jwt"
"github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/common"
)

const notFound = -1

// jwtParseOpts holds options for the SD-JWT parsing.
type parseOpts struct {
detachedPayload []byte
Expand All @@ -37,13 +41,14 @@ func WithSignatureVerifier(signatureVerifier jose.SignatureVerifier) ParseOpt {

// Parse parses input JWT in serialized form into JSON Web Token.
func Parse(sdJWTSerialized string, opts ...ParseOpt) (*common.SDJWT, error) {
pOpts := &parseOpts{}
pOpts := &parseOpts{
sigVerifier: &NoopSignatureVerifier{},
}

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

// TODO: Holder is not required to check issuer signature so we should probably have default no-op verifier
var jwtOpts []afgjwt.ParseOpt
jwtOpts = append(jwtOpts,
afgjwt.WithSignatureVerifier(pOpts.sigVerifier),
Expand All @@ -64,6 +69,45 @@ func Parse(sdJWTSerialized string, opts ...ParseOpt) (*common.SDJWT, error) {
return sdJWT, nil
}

// DiscloseClaims discloses selected claims with specified claim names.
func DiscloseClaims(sdJWTSerialized string, claimNames []string) (string, error) {
sdJWT := common.ParseSDJWT(sdJWTSerialized)

if len(sdJWT.Disclosures) == 0 {
return "", fmt.Errorf("no disclosures found in SD-JWT")
}

disclosures, err := common.GetDisclosureClaims(sdJWT.Disclosures)
if err != nil {
return "", err
}

var selectedDisclosures []string

for _, claimName := range claimNames {
if index := getDisclosureByClaimName(claimName, disclosures); index != notFound {
selectedDisclosures = append(selectedDisclosures, sdJWT.Disclosures[index])
}
}

combinedFormatForPresentation := sdJWT.JWTSerialized
for _, disclosure := range selectedDisclosures {
combinedFormatForPresentation += common.DisclosureSeparator + disclosure
}

return combinedFormatForPresentation, nil
}

func getDisclosureByClaimName(name string, disclosures []*common.DisclosureClaim) int {
for index, disclosure := range disclosures {
if disclosure.Name == name {
return index
}
}

return notFound
}

// NoopSignatureVerifier is no-op signature verifier (signature will not get checked).
type NoopSignatureVerifier struct {
}
Expand Down
46 changes: 46 additions & 0 deletions pkg/doc/sdjwt/holder/holder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/common"
"strings"
"testing"

Expand Down Expand Up @@ -50,6 +51,13 @@ func TestParse(t *testing.T) {
require.Equal(t, 1, len(sdJWT.Disclosures))
})

t.Run("success - default is no signature verifier", func(t *testing.T) {
sdJWT, err := Parse(sdJWTSerialized)
r.NoError(err)
require.NotNil(t, sdJWT)
require.Equal(t, 1, len(sdJWT.Disclosures))
})

t.Run("success - spec SD-JWT", func(t *testing.T) {
sdJWT, err := Parse(specSDJWT, WithSignatureVerifier(&NoopSignatureVerifier{}))
r.NoError(err)
Expand Down Expand Up @@ -90,6 +98,44 @@ func TestParse(t *testing.T) {
})
}

func TestDiscloseClaims(t *testing.T) {
r := require.New(t)

_, privKey, e := ed25519.GenerateKey(rand.Reader)
r.NoError(e)

signer := afjwt.NewEd25519Signer(privKey)
claims := map[string]interface{}{"given_name": "Albert"}

token, e := issuer.New(testIssuer, claims, nil, signer)
r.NoError(e)
sdJWTSerialized, e := token.Serialize(false)
r.NoError(e)

t.Run("success", func(t *testing.T) {
sdJWTDisclosed, err := DiscloseClaims(sdJWTSerialized, []string{"given_name"})
r.NoError(err)
require.NotNil(t, sdJWTDisclosed)
require.Equal(t, sdJWTSerialized, sdJWTDisclosed)
})

t.Run("error - no disclosure(s)", func(t *testing.T) {
sdJWT := common.ParseSDJWT(sdJWTSerialized)

sdJWTDisclosed, err := DiscloseClaims(sdJWT.JWTSerialized, []string{"given_name"})
r.Error(err)
r.Empty(sdJWTDisclosed)
r.Contains(err.Error(), "no disclosures found in SD-JWT")
})

t.Run("error - invalid disclosure", func(t *testing.T) {
sdJWTDisclosed, err := DiscloseClaims(fmt.Sprintf("%s~%s", sdJWTSerialized, "abc"), []string{"given_name"})
r.Error(err)
r.Empty(sdJWTDisclosed)
r.Contains(err.Error(), "failed to unmarshal disclosure array")
})
}

func TestWithJWTDetachedPayload(t *testing.T) {
detachedPayloadOpt := WithJWTDetachedPayload([]byte("payload"))
require.NotNil(t, detachedPayloadOpt)
Expand Down
Loading

0 comments on commit e60c388

Please sign in to comment.