Repository: hyperledger/aries-framework-go (go)

Issue:
Implement SD-JWT Issuer (Flat)
Implement SD-JWT Issuer (flat option, no optional features such as holder binding, decoy digests etc.)

Pull request description:
feat: Add SD-JWT Issuer (Flat)
Add SD-JWT Issuer (flat, no optional features).

Closes #3451

Signed-off-by: Sandra Vrtikapa <sandra.vrtikapa@securekey.com>




Patch:
diff --git a/pkg/doc/jwt/jwt.go b/pkg/doc/jwt/jwt.go
index e6abf0be..5145781b 100644
--- a/pkg/doc/jwt/jwt.go
+++ b/pkg/doc/jwt/jwt.go
@@ -172,7 +172,7 @@ func mapJWSToJWT(jws *jose.JSONWebSignature) (*JSONWebToken, error) {
 		return nil, fmt.Errorf("check JWT headers: %w", err)
 	}
 
-	claims, err := toMap(jws.Payload)
+	claims, err := PayloadToMap(jws.Payload)
 	if err != nil {
 		return nil, fmt.Errorf("read JWT claims from JWS payload: %w", err)
 	}
@@ -195,7 +195,7 @@ func NewUnsecured(claims interface{}, headers jose.Headers) (*JSONWebToken, erro
 }
 
 func newSigned(claims interface{}, headers jose.Headers, signer jose.Signer) (*JSONWebToken, error) {
-	payloadMap, err := toMap(claims)
+	payloadMap, err := PayloadToMap(claims)
 	if err != nil {
 		return nil, fmt.Errorf("unmarshallable claims: %w", err)
 	}
@@ -268,7 +268,8 @@ func checkHeaders(headers map[string]interface{}) error {
 	return nil
 }
 
-func toMap(i interface{}) (map[string]interface{}, error) {
+// PayloadToMap transforms interface to map.
+func PayloadToMap(i interface{}) (map[string]interface{}, error) {
 	if reflect.ValueOf(i).Kind() == reflect.Map {
 		return i.(map[string]interface{}), nil
 	}
diff --git a/pkg/doc/sdjwt/common/common.go b/pkg/doc/sdjwt/common/common.go
new file mode 100644
index 00000000..b4551ddb
--- /dev/null
+++ b/pkg/doc/sdjwt/common/common.go
@@ -0,0 +1,70 @@
+/*
+Copyright SecureKey Technologies Inc. All Rights Reserved.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package common
+
+import (
+	"crypto"
+	"encoding/base64"
+	"fmt"
+	"strings"
+
+	"github.com/go-jose/go-jose/v3/jwt"
+)
+
+// DisclosureSeparator is disclosure separator.
+const DisclosureSeparator = "~"
+
+// Payload represents SD-JWT payload.
+type Payload struct {
+	Issuer  string `json:"iss,omitempty"`
+	Subject string `json:"sub,omitempty"`
+	ID      string `json:"jti,omitempty"`
+
+	Expiry    *jwt.NumericDate `json:"exp,omitempty"`
+	NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
+	IssuedAt  *jwt.NumericDate `json:"iat,omitempty"`
+
+	SD    []string `json:"_sd,omitempty"`
+	SDAlg string   `json:"_sd_alg,omitempty"`
+}
+
+// SDJWT holds SD-JWT info.
+type SDJWT struct {
+	JWTSerialized string
+	Disclosures   []string
+}
+
+// ParseSDJWT parses SD-JWT serialized token into SDJWT parts.
+func ParseSDJWT(sdJWTSerialized string) *SDJWT {
+	parts := strings.Split(sdJWTSerialized, DisclosureSeparator)
+
+	var disclosures []string
+	if len(parts) > 1 {
+		disclosures = parts[1:]
+	}
+
+	jwtSerialized := parts[0]
+
+	return &SDJWT{JWTSerialized: jwtSerialized, Disclosures: disclosures}
+}
+
+// GetHash calculates hash of data using hash function identified by hash.
+func GetHash(hash crypto.Hash, value string) (string, error) {
+	if !hash.Available() {
+		return "", fmt.Errorf("hash function not available for: %d", hash)
+	}
+
+	h := hash.New()
+
+	if _, hashErr := h.Write([]byte(value)); hashErr != nil {
+		return "", hashErr
+	}
+
+	result := h.Sum(nil)
+
+	return base64.RawURLEncoding.EncodeToString(result), nil
+}
diff --git a/pkg/doc/sdjwt/issuer/issuer.go b/pkg/doc/sdjwt/issuer/issuer.go
new file mode 100644
index 00000000..5ba29486
--- /dev/null
+++ b/pkg/doc/sdjwt/issuer/issuer.go
@@ -0,0 +1,245 @@
+/*
+Copyright SecureKey Technologies Inc. All Rights Reserved.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package issuer
+
+import (
+	"crypto"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/go-jose/go-jose/v3/jwt"
+
+	"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 (
+	defaultHash     = crypto.SHA256
+	defaultSaltSize = 28
+
+	year = 365 * 24 * 60 * time.Minute
+)
+
+// Claims defines JSON Web Token Claims (https://tools.ietf.org/html/rfc7519#section-4)
+type Claims jwt.Claims
+
+// newOpts holds options for creating new SD-JWT.
+type newOpts struct {
+	Subject string
+	ID      string
+
+	Expiry    *jwt.NumericDate
+	NotBefore *jwt.NumericDate
+	IssuedAt  *jwt.NumericDate
+
+	HashAlg crypto.Hash
+
+	jsonMarshal func(v interface{}) ([]byte, error)
+	getSalt     func() (string, error)
+}
+
+// NewOpt is the SD-JWT New option.
+type NewOpt func(opts *newOpts)
+
+// WithJSONMarshaller is option is for marshalling disclosure.
+func WithJSONMarshaller(jsonMarshal func(v interface{}) ([]byte, error)) NewOpt {
+	return func(opts *newOpts) {
+		opts.jsonMarshal = jsonMarshal
+	}
+}
+
+// WithSaltFnc is option is for marshalling disclosure.
+func WithSaltFnc(fnc func() (string, error)) NewOpt {
+	return func(opts *newOpts) {
+		opts.getSalt = fnc
+	}
+}
+
+// WithIssuedAt is an option for SD-JWT payload.
+func WithIssuedAt(issuedAt *jwt.NumericDate) NewOpt {
+	return func(opts *newOpts) {
+		opts.IssuedAt = issuedAt
+	}
+}
+
+// WithExpiry is an option for SD-JWT payload.
+func WithExpiry(expiry *jwt.NumericDate) NewOpt {
+	return func(opts *newOpts) {
+		opts.Expiry = expiry
+	}
+}
+
+// WithNotBefore is an option for SD-JWT payload.
+func WithNotBefore(notBefore *jwt.NumericDate) NewOpt {
+	return func(opts *newOpts) {
+		opts.NotBefore = notBefore
+	}
+}
+
+// WithSubject is an option for SD-JWT payload.
+func WithSubject(subject string) NewOpt {
+	return func(opts *newOpts) {
+		opts.Subject = subject
+	}
+}
+
+// WithID is an option for SD-JWT payload.
+func WithID(id string) NewOpt {
+	return func(opts *newOpts) {
+		opts.ID = id
+	}
+}
+
+// WithHashAlgorithm is an option for hashing disclosures.
+func WithHashAlgorithm(alg crypto.Hash) NewOpt {
+	return func(opts *newOpts) {
+		opts.HashAlg = alg
+	}
+}
+
+// New creates new signed Selective Disclosure JWT based on input claims.
+func New(issuer string, claims interface{}, headers jose.Headers,
+	signer jose.Signer, opts ...NewOpt) (*SelectiveDisclosureJWT, error) {
+	now := time.Now()
+
+	nOpts := &newOpts{
+		jsonMarshal: json.Marshal,
+		getSalt:     generateSalt,
+		HashAlg:     defaultHash,
+
+		// TODO: Discuss with Troy about defaults
+		IssuedAt:  jwt.NewNumericDate(now),
+		NotBefore: jwt.NewNumericDate(now),
+		Expiry:    jwt.NewNumericDate(now.Add(year)),
+	}
+
+	for _, opt := range opts {
+		opt(nOpts)
+	}
+
+	claimsMap, err := afgjwt.PayloadToMap(claims)
+	if err != nil {
+		return nil, err
+	}
+
+	disclosures, err := createDisclosures(claimsMap, nOpts)
+	if err != nil {
+		return nil, err
+	}
+
+	var hashedDisclosures []string
+
+	for _, disclosure := range disclosures {
+		hashedDisclosure, inErr := common.GetHash(nOpts.HashAlg, disclosure)
+		if inErr != nil {
+			return nil, fmt.Errorf("hash disclosure: %w", inErr)
+		}
+
+		hashedDisclosures = append(hashedDisclosures, hashedDisclosure)
+	}
+
+	payload := &common.Payload{
+		Issuer:    issuer,
+		ID:        nOpts.ID,
+		Subject:   nOpts.Subject,
+		IssuedAt:  nOpts.IssuedAt,
+		Expiry:    nOpts.Expiry,
+		NotBefore: nOpts.NotBefore,
+		SD:        hashedDisclosures,
+		SDAlg:     strings.ToLower(nOpts.HashAlg.String()),
+	}
+
+	signedJWT, err := afgjwt.NewSigned(payload, headers, signer)
+	if err != nil {
+		return nil, err
+	}
+
+	return &SelectiveDisclosureJWT{Disclosures: disclosures, SignedJWT: signedJWT}, nil
+}
+
+// SelectiveDisclosureJWT defines Selective Disclosure JSON Web Token (https://tools.ietf.org/html/rfc7519)
+type SelectiveDisclosureJWT struct {
+	SignedJWT   *afgjwt.JSONWebToken
+	Disclosures []string
+}
+
+// DecodeClaims fills input c with claims of a token.
+func (j *SelectiveDisclosureJWT) DecodeClaims(c interface{}) error {
+	return j.SignedJWT.DecodeClaims(c)
+}
+
+// LookupStringHeader makes look up of particular header with string value.
+func (j *SelectiveDisclosureJWT) LookupStringHeader(name string) string {
+	return j.SignedJWT.LookupStringHeader(name)
+}
+
+// Serialize makes (compact) serialization of token.
+func (j *SelectiveDisclosureJWT) Serialize(detached bool) (string, error) {
+	if j.SignedJWT == nil {
+		return "", errors.New("JWS serialization is supported only")
+	}
+
+	signedJWT, err := j.SignedJWT.Serialize(detached)
+	if err != nil {
+		return "", err
+	}
+
+	combinedFormatForPresentation := signedJWT
+	for _, disclosure := range j.Disclosures {
+		combinedFormatForPresentation += common.DisclosureSeparator + disclosure
+	}
+
+	return combinedFormatForPresentation, nil
+}
+
+func createDisclosures(claims map[string]interface{}, opts *newOpts) ([]string, error) {
+	var disclosures []string
+
+	for key, value := range claims {
+		disclosure, err := createDisclosure(key, value, opts)
+		if err != nil {
+			return nil, fmt.Errorf("create disclosure: %w", err)
+		}
+
+		disclosures = append(disclosures, disclosure)
+	}
+
+	return disclosures, nil
+}
+
+func createDisclosure(key string, value interface{}, opts *newOpts) (string, error) {
+	salt, err := opts.getSalt()
+	if err != nil {
+		return "", fmt.Errorf("generate salt: %w", err)
+	}
+
+	disclosure := []interface{}{salt, key, value}
+
+	disclosureBytes, err := opts.jsonMarshal(disclosure)
+	if err != nil {
+		return "", fmt.Errorf("marshal disclosure: %w", err)
+	}
+
+	return base64.RawURLEncoding.EncodeToString(disclosureBytes), nil
+}
+
+func generateSalt() (string, error) {
+	salt := make([]byte, defaultSaltSize)
+
+	_, err := rand.Read(salt)
+	if err != nil {
+		return "", err
+	}
+
+	return string(salt), nil
+}


Test patch:
diff --git a/pkg/doc/jwt/jwt_test.go b/pkg/doc/jwt/jwt_test.go
index 35b8e808..8fcb0ff8 100644
--- a/pkg/doc/jwt/jwt_test.go
+++ b/pkg/doc/jwt/jwt_test.go
@@ -421,37 +421,37 @@ func Test_toMap(t *testing.T) {
 	r := require.New(t)
 
 	// pass map
-	resultMap, err := toMap(inputMap)
+	resultMap, err := PayloadToMap(inputMap)
 	r.NoError(err)
 	r.Equal(inputMap, resultMap)
 
 	// pass []byte
 	inputMapBytes, err := json.Marshal(inputMap)
 	r.NoError(err)
-	resultMap, err = toMap(inputMapBytes)
+	resultMap, err = PayloadToMap(inputMapBytes)
 	r.NoError(err)
 	r.Equal(inputMap, resultMap)
 
 	// pass string
 	inputMapStr := string(inputMapBytes)
-	resultMap, err = toMap(inputMapStr)
+	resultMap, err = PayloadToMap(inputMapStr)
 	r.NoError(err)
 	r.Equal(inputMap, resultMap)
 
 	// pass struct
 	s := testToMapStruct{TestField: "b"}
-	resultMap, err = toMap(s)
+	resultMap, err = PayloadToMap(s)
 	r.NoError(err)
 	r.Equal(inputMap, resultMap)
 
 	// pass invalid []byte
-	resultMap, err = toMap([]byte("not JSON"))
+	resultMap, err = PayloadToMap([]byte("not JSON"))
 	r.Error(err)
 	r.Contains(err.Error(), "convert to map")
 	r.Nil(resultMap)
 
 	// pass invalid structure
-	resultMap, err = toMap(make(chan int))
+	resultMap, err = PayloadToMap(make(chan int))
 	r.Error(err)
 	r.Contains(err.Error(), "convert to bytes")
 	r.Nil(resultMap)
diff --git a/pkg/doc/sdjwt/common/common_test.go b/pkg/doc/sdjwt/common/common_test.go
new file mode 100644
index 00000000..cb634a04
--- /dev/null
+++ b/pkg/doc/sdjwt/common/common_test.go
@@ -0,0 +1,41 @@
+/*
+Copyright SecureKey Technologies Inc. All Rights Reserved.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package common
+
+import (
+	"crypto"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+const defaultHash = crypto.SHA256
+
+func TestGetHash(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		digest, err := GetHash(defaultHash, "WyI2cU1RdlJMNWhhaiIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0")
+		require.NoError(t, err)
+		require.Equal(t, "uutlBuYeMDyjLLTpf6Jxi7yNkEF35jdyWMn9U7b_RYY", digest)
+	})
+
+	t.Run("error - hash not available", func(t *testing.T) {
+		digest, err := GetHash(0, "test")
+		require.Error(t, err)
+		require.Empty(t, digest)
+		require.Contains(t, err.Error(), "hash function not available for: 0")
+	})
+}
+
+func TestParseSDJWT(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		sdJWT := ParseSDJWT(sdJWT)
+		require.Equal(t, 1, len(sdJWT.Disclosures))
+	})
+}
+
+// nolint: lll
+const sdJWT = `eyJhbGciOiJFZERTQSJ9.eyJfc2QiOlsicXF2Y3FuY3pBTWdZeDdFeWtJNnd3dHNweXZ5dks3OTBnZTdNQmJRLU51cyJdLCJfc2RfYWxnIjoic2hhLTI1NiIsImV4cCI6MTcwMzAyMzg1NSwiaWF0IjoxNjcxNDg3ODU1LCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tL2lzc3VlciIsIm5iZiI6MTY3MTQ4Nzg1NX0.vscuzfwcHGi04pWtJCadc4iDELug6NH6YK-qxhY1qacsciIHuoLELAfon1tGamHtuu8TSs6OjtLk3lHE16jqAQ~WyIzanFjYjY3ejl3a3MwOHp3aUs3RXlRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd`
diff --git a/pkg/doc/sdjwt/issuer/issuer_test.go b/pkg/doc/sdjwt/issuer/issuer_test.go
new file mode 100644
index 00000000..b033133a
--- /dev/null
+++ b/pkg/doc/sdjwt/issuer/issuer_test.go
@@ -0,0 +1,501 @@
+/*
+Copyright SecureKey Technologies Inc. All Rights Reserved.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package issuer
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/rsa"
+	"errors"
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/go-jose/go-jose/v3/json"
+	"github.com/go-jose/go-jose/v3/jwt"
+	"github.com/stretchr/testify/require"
+
+	"github.com/hyperledger/aries-framework-go/pkg/doc/jose"
+	afjwt "github.com/hyperledger/aries-framework-go/pkg/doc/jwt"
+	"github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/common"
+)
+
+const (
+	issuer                 = "https://example.com/issuer"
+	expectedHashWithSpaces = "qqvcqnczAMgYx7EykI6wwtspyvyvK790ge7MBbQ-Nus"
+	sampleSalt             = "3jqcb67z9wks08zwiK7EyQ"
+)
+
+func TestNew(t *testing.T) {
+	claims := createClaims()
+
+	t.Run("Create JWS signed by EdDSA", func(t *testing.T) {
+		r := require.New(t)
+
+		pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
+		r.NoError(err)
+
+		token, err := New(issuer, claims, nil, afjwt.NewEd25519Signer(privKey),
+			WithJSONMarshaller(jsonMarshalWithSpace),
+			WithSaltFnc(func() (string, error) {
+				return sampleSalt, nil
+			}))
+		r.NoError(err)
+		sdJWTSerialized, err := token.Serialize(false)
+		require.NoError(t, err)
+
+		sdJWT := common.ParseSDJWT(sdJWTSerialized)
+		require.Equal(t, 1, len(sdJWT.Disclosures))
+
+		var parsedClaims map[string]interface{}
+		err = verifyEd25519ViaGoJose(sdJWT.JWTSerialized, pubKey, &parsedClaims)
+		r.NoError(err)
+
+		parsedClaimsBytes, err := json.Marshal(parsedClaims)
+		require.NoError(t, err)
+
+		prettyJSON, err := prettyPrint(parsedClaimsBytes)
+		require.NoError(t, err)
+
+		fmt.Println(prettyJSON)
+
+		require.True(t, existsInDisclosures(parsedClaims, expectedHashWithSpaces))
+
+		err = verifyEd25519(sdJWT.JWTSerialized, pubKey)
+		r.NoError(err)
+	})
+
+	t.Run("Create JWS signed by RS256", func(t *testing.T) {
+		r := require.New(t)
+
+		privKey, err := rsa.GenerateKey(rand.Reader, 2048)
+		r.NoError(err)
+
+		pubKey := &privKey.PublicKey
+
+		token, err := New(issuer, claims, nil, afjwt.NewRS256Signer(privKey, nil),
+			WithJSONMarshaller(jsonMarshalWithSpace),
+			WithSaltFnc(func() (string, error) {
+				return sampleSalt, nil
+			}))
+		r.NoError(err)
+		sdJWTSerialized, err := token.Serialize(false)
+		require.NoError(t, err)
+
+		sdJWT := common.ParseSDJWT(sdJWTSerialized)
+		require.Equal(t, 1, len(sdJWT.Disclosures))
+
+		var parsedClaims map[string]interface{}
+		err = verifyRS256ViaGoJose(sdJWT.JWTSerialized, pubKey, &parsedClaims)
+		r.NoError(err)
+
+		parsedClaimsBytes, err := json.Marshal(parsedClaims)
+		require.NoError(t, err)
+
+		prettyJSON, err := prettyPrint(parsedClaimsBytes)
+		require.NoError(t, err)
+
+		fmt.Println(prettyJSON)
+
+		expectedHashWithSpaces := expectedHashWithSpaces
+		require.True(t, existsInDisclosures(parsedClaims, expectedHashWithSpaces))
+
+		err = verifyRS256(sdJWT.JWTSerialized, pubKey)
+		r.NoError(err)
+	})
+
+	t.Run("Create Complex Claims JWS signed by EdDSA", func(t *testing.T) {
+		r := require.New(t)
+
+		pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
+		r.NoError(err)
+
+		complexClaims := createComplexClaims()
+
+		issued := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
+		expiry := time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)
+		notBefore := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+		var newOpts []NewOpt
+
+		newOpts = append(newOpts,
+			WithIssuedAt(jwt.NewNumericDate(issued)),
+			WithExpiry(jwt.NewNumericDate(expiry)),
+			WithNotBefore(jwt.NewNumericDate(notBefore)),
+			WithID("id"),
+			WithSubject("subject"),
+			// TODO: Audience ???
+			WithSaltFnc(generateSalt),
+			WithJSONMarshaller(json.Marshal),
+			WithHashAlgorithm(crypto.SHA256),
+		)
+
+		token, err := New(issuer, complexClaims, nil, afjwt.NewEd25519Signer(privKey), newOpts...)
+		r.NoError(err)
+		sdJWTSerialized, err := token.Serialize(false)
+		require.NoError(t, err)
+
+		sdJWT := common.ParseSDJWT(sdJWTSerialized)
+		require.Equal(t, 7, len(sdJWT.Disclosures))
+
+		var parsedClaims map[string]interface{}
+		err = verifyEd25519ViaGoJose(sdJWT.JWTSerialized, pubKey, &parsedClaims)
+		r.NoError(err)
+
+		parsedClaimsBytes, err := json.Marshal(parsedClaims)
+		require.NoError(t, err)
+
+		prettyJSON, err := prettyPrint(parsedClaimsBytes)
+		require.NoError(t, err)
+
+		fmt.Println(prettyJSON)
+
+		err = verifyEd25519(sdJWT.JWTSerialized, pubKey)
+		r.NoError(err)
+	})
+
+	t.Run("error - wrong hash function", func(t *testing.T) {
+		r := require.New(t)
+
+		privKey, err := rsa.GenerateKey(rand.Reader, 2048)
+		r.NoError(err)
+		token, err := New(issuer, claims, nil, afjwt.NewRS256Signer(privKey, nil),
+			WithJSONMarshaller(jsonMarshalWithSpace),
+			WithSaltFnc(func() (string, error) {
+				return sampleSalt, nil
+			}),
+			WithHashAlgorithm(0))
+		r.Error(err)
+		r.Nil(token)
+		r.Contains(err.Error(), "hash disclosure: hash function not available for: 0")
+	})
+
+	t.Run("error - get salt error", func(t *testing.T) {
+		r := require.New(t)
+
+		privKey, err := rsa.GenerateKey(rand.Reader, 2048)
+		r.NoError(err)
+		token, err := New(issuer, claims, nil, afjwt.NewRS256Signer(privKey, nil),
+			WithJSONMarshaller(jsonMarshalWithSpace),
+			WithSaltFnc(func() (string, error) {
+				return "", fmt.Errorf("salt error")
+			}))
+		r.Error(err)
+		r.Nil(token)
+		r.Contains(err.Error(), "create disclosure: generate salt: salt error")
+	})
+
+	t.Run("error - marshal error", func(t *testing.T) {
+		r := require.New(t)
+
+		privKey, err := rsa.GenerateKey(rand.Reader, 2048)
+		r.NoError(err)
+		token, err := New(issuer, claims, nil, afjwt.NewRS256Signer(privKey, nil),
+			WithJSONMarshaller(func(v interface{}) ([]byte, error) {
+				return nil, fmt.Errorf("marshal error")
+			}))
+		r.Error(err)
+		r.Nil(token)
+		r.Contains(err.Error(), "create disclosure: marshal disclosure: marshal error")
+	})
+}
+
+func TestJSONWebToken_DecodeClaims(t *testing.T) {
+	token, err := getValidJSONWebToken(
+		WithJSONMarshaller(jsonMarshalWithSpace),
+		WithSaltFnc(func() (string, error) {
+			return sampleSalt, nil
+		}))
+	require.NoError(t, err)
+
+	var tokensMap map[string]interface{}
+
+	err = token.DecodeClaims(&tokensMap)
+	require.NoError(t, err)
+
+	expectedHashWithSpaces := expectedHashWithSpaces
+	require.True(t, existsInDisclosures(tokensMap, expectedHashWithSpaces))
+
+	var claims Claims
+
+	err = token.DecodeClaims(&claims)
+	require.NoError(t, err)
+	require.Equal(t, claims.Issuer, issuer)
+
+	token, err = getJSONWebTokenWithInvalidPayload()
+	require.NoError(t, err)
+
+	err = token.DecodeClaims(&claims)
+	require.Error(t, err)
+}
+
+func TestJSONWebToken_LookupStringHeader(t *testing.T) {
+	token, err := getValidJSONWebToken()
+	require.NoError(t, err)
+
+	require.Equal(t, "JWT", token.LookupStringHeader("typ"))
+
+	require.Empty(t, token.LookupStringHeader("undef"))
+
+	token.SignedJWT.Headers["not_str"] = 55
+	require.Empty(t, token.LookupStringHeader("not_str"))
+}
+
+func TestJSONWebToken_Serialize(t *testing.T) {
+	token, err := getValidJSONWebToken()
+	require.NoError(t, err)
+
+	tokenSerialized, err := token.Serialize(false)
+	require.NoError(t, err)
+	require.NotEmpty(t, tokenSerialized)
+
+	// cannot serialize without signature
+	token.SignedJWT = nil
+	tokenSerialized, err = token.Serialize(false)
+	require.Error(t, err)
+	require.EqualError(t, err, "JWS serialization is supported only")
+	require.Empty(t, tokenSerialized)
+}
+
+func TestJSONWebToken_hashDisclosure(t *testing.T) {
+	t.Run("success - data from spec", func(t *testing.T) {
+		dh, err := common.GetHash(defaultHash, "WyI2cU1RdlJMNWhhaiIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0")
+		require.NoError(t, err)
+		require.Equal(t, "uutlBuYeMDyjLLTpf6Jxi7yNkEF35jdyWMn9U7b_RYY", dh)
+	})
+}
+
+func TestJSONWebToken_createDisclosure(t *testing.T) {
+	t.Run("success - given name", func(t *testing.T) {
+		nOpts := getOpts(
+			WithJSONMarshaller(jsonMarshalWithSpace),
+			WithSaltFnc(func() (string, error) {
+				return sampleSalt, nil
+			}))
+
+		// Disclosure data from spec: ["3jqcb67z9wks08zwiK7EyQ", "given_name", "John"]
+		expectedDisclosureWithSpaces := "WyIzanFjYjY3ejl3a3MwOHp3aUs3RXlRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd"
+		expectedHashWithSpaces := expectedHashWithSpaces
+
+		disclosure, err := createDisclosure("given_name", "John", nOpts)
+		require.NoError(t, err)
+		require.Equal(t, expectedDisclosureWithSpaces, disclosure)
+
+		dh, err := common.GetHash(defaultHash, disclosure)
+		require.NoError(t, err)
+		require.Equal(t, expectedHashWithSpaces, dh)
+	})
+
+	t.Run("success - family name", func(t *testing.T) {
+		// Disclosure data from spec: ["_26bc4LT-ac6q2KI6cBW5es", "family_name", "Möbius"]
+
+		expectedDisclosureWithoutSpaces := "WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsImZhbWlseV9uYW1lIiwiTcO2Yml1cyJd"
+		expectedDisclosureWithSpaces := "WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0"
+
+		nOpts := getOpts(
+			WithSaltFnc(func() (string, error) {
+				return "_26bc4LT-ac6q2KI6cBW5es", nil
+			}))
+
+		disclosure, err := createDisclosure("family_name", "Möbius", nOpts)
+		require.NoError(t, err)
+		require.Equal(t, expectedDisclosureWithoutSpaces, disclosure)
+
+		nOpts = getOpts(
+			WithJSONMarshaller(jsonMarshalWithSpace),
+			WithSaltFnc(func() (string, error) {
+				return "_26bc4LT-ac6q2KI6cBW5es", nil
+			}))
+
+		disclosure, err = createDisclosure("family_name", "Möbius", nOpts)
+		require.NoError(t, err)
+		require.Equal(t, expectedDisclosureWithSpaces, disclosure)
+	})
+}
+
+func getOpts(opts ...NewOpt) *newOpts {
+	nOpts := &newOpts{
+		jsonMarshal: json.Marshal,
+		getSalt:     generateSalt,
+		HashAlg:     defaultHash,
+	}
+
+	for _, opt := range opts {
+		opt(nOpts)
+	}
+
+	return nOpts
+}
+
+func getValidJSONWebToken(opts ...NewOpt) (*SelectiveDisclosureJWT, error) {
+	headers := map[string]interface{}{"typ": "JWT", "alg": "EdDSA"}
+	claims := map[string]interface{}{"given_name": "John"}
+
+	_, privKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+
+	signer := afjwt.NewEd25519Signer(privKey)
+
+	return New(issuer, claims, headers, signer, opts...)
+}
+
+func getJSONWebTokenWithInvalidPayload() (*SelectiveDisclosureJWT, error) {
+	token, err := getValidJSONWebToken()
+	if err != nil {
+		return nil, err
+	}
+
+	// hack the token
+	token.SignedJWT.Payload = getUnmarshallableMap()
+
+	return token, nil
+}
+
+func verifyEd25519ViaGoJose(jws string, pubKey ed25519.PublicKey, claims interface{}) error {
+	jwtToken, err := jwt.ParseSigned(jws)
+	if err != nil {
+		return fmt.Errorf("parse VC from signed JWS: %w", err)
+	}
+
+	if err = jwtToken.Claims(pubKey, claims); err != nil {
+		return fmt.Errorf("verify JWT signature: %w", err)
+	}
+
+	return nil
+}
+
+func verifyRS256ViaGoJose(jws string, pubKey *rsa.PublicKey, claims interface{}) error {
+	jwtToken, err := jwt.ParseSigned(jws)
+	if err != nil {
+		return fmt.Errorf("parse VC from signed JWS: %w", err)
+	}
+
+	if err = jwtToken.Claims(pubKey, claims); err != nil {
+		return fmt.Errorf("verify JWT signature: %w", err)
+	}
+
+	return nil
+}
+
+func getUnmarshallableMap() map[string]interface{} {
+	return map[string]interface{}{"error": map[chan int]interface{}{make(chan int): 6}}
+}
+
+func createClaims() map[string]interface{} {
+	claims := map[string]interface{}{
+		"given_name": "John",
+	}
+
+	return claims
+}
+
+func createComplexClaims() map[string]interface{} {
+	claims := map[string]interface{}{
+		"sub":          "john_doe_42",
+		"given_name":   "John",
+		"family_name":  "Doe",
+		"email":        "johndoe@example.com",
+		"phone_number": "+1-202-555-0101",
+		"birthdate":    "1940-01-01",
+		"address": map[string]interface{}{
+			"street_address": "123 Main St",
+			"locality":       "Anytown",
+			"region":         "Anystate",
+			"country":        "US",
+		},
+	}
+
+	return claims
+}
+
+func verifyEd25519(jws string, pubKey ed25519.PublicKey) error {
+	v, err := afjwt.NewEd25519Verifier(pubKey)
+	if err != nil {
+		return err
+	}
+
+	sVerifier := jose.NewCompositeAlgSigVerifier(jose.AlgSignatureVerifier{
+		Alg:      "EdDSA",
+		Verifier: v,
+	})
+
+	token, err := afjwt.Parse(jws, afjwt.WithSignatureVerifier(sVerifier))
+	if err != nil {
+		return err
+	}
+
+	if token == nil {
+		return errors.New("nil token")
+	}
+
+	return nil
+}
+
+func verifyRS256(jws string, pubKey *rsa.PublicKey) error {
+	v := afjwt.NewRS256Verifier(pubKey)
+
+	sVerifier := jose.NewCompositeAlgSigVerifier(jose.AlgSignatureVerifier{
+		Alg:      "RS256",
+		Verifier: v,
+	})
+
+	token, err := afjwt.Parse(jws, afjwt.WithSignatureVerifier(sVerifier))
+	if err != nil {
+		return err
+	}
+
+	if token == nil {
+		return errors.New("nil token")
+	}
+
+	return nil
+}
+
+func existsInDisclosures(claims map[string]interface{}, val string) bool {
+	disclosuresObj, ok := claims["_sd"]
+	if !ok {
+		return false
+	}
+
+	disclosures, ok := disclosuresObj.([]interface{})
+	if !ok {
+		return false
+	}
+
+	for _, d := range disclosures {
+		if d.(string) == val {
+			return true
+		}
+	}
+
+	return false
+}
+
+func jsonMarshalWithSpace(v interface{}) ([]byte, error) {
+	vBytes, err := json.Marshal(v)
+	if err != nil {
+		return nil, err
+	}
+
+	return []byte(strings.ReplaceAll(string(vBytes), ",", ", ")), nil
+}
+
+func prettyPrint(msg []byte) (string, error) {
+	var prettyJSON bytes.Buffer
+
+	err := json.Indent(&prettyJSON, msg, "", "\t")
+	if err != nil {
+		return "", err
+	}
+
+	return prettyJSON.String(), nil
+}