github.com/hyperledger/aries-framework-go@v0.3.2/pkg/doc/sdjwt/issuer/issuer.go (about)

     1  /*
     2  Copyright SecureKey Technologies Inc. All Rights Reserved.
     3  
     4  SPDX-License-Identifier: Apache-2.0
     5  */
     6  
     7  /*
     8  Package issuer enables the Issuer: An entity that creates SD-JWTs.
     9  
    10  An SD-JWT is a digitally signed document containing digests over the claims
    11  (per claim: a random salt, the claim name and the claim value).
    12  It MAY further contain clear-text claims that are always disclosed to the Verifier.
    13  It MUST be digitally signed using the Issuer's private key.
    14  
    15  	SD-JWT-DOC = (METADATA, SD-CLAIMS, NON-SD-CLAIMS)
    16  	SD-JWT = SD-JWT-DOC | SIG(SD-JWT-DOC, ISSUER-PRIV-KEY)
    17  
    18  SD-CLAIMS is an array of digest values that ensure the integrity of
    19  and map to the respective Disclosures.  Digest values are calculated
    20  over the Disclosures, each of which contains the claim name (CLAIM-NAME),
    21  the claim value (CLAIM-VALUE), and a random salt (SALT).
    22  Digests are calculated using a hash function:
    23  
    24  SD-CLAIMS = (
    25  HASH(SALT, CLAIM-NAME, CLAIM-VALUE)
    26  )*
    27  
    28  SD-CLAIMS can also be nested deeper to capture more complex objects.
    29  
    30  The Issuer further creates a set of Disclosures for all claims in the
    31  SD-JWT. The Disclosures are sent to the Holder together with the SD-JWT:
    32  
    33  DISCLOSURES = (
    34  (SALT, CLAIM-NAME, CLAIM-VALUE)
    35  )*
    36  
    37  The SD-JWT and the Disclosures are sent to the Holder by the Issuer:
    38  
    39  COMBINED-ISSUANCE = SD-JWT | DISCLOSURES
    40  */
    41  package issuer
    42  
    43  import (
    44  	"crypto"
    45  	"crypto/rand"
    46  	"encoding/base64"
    47  	"encoding/json"
    48  	"errors"
    49  	"fmt"
    50  	mathrand "math/rand"
    51  	"strings"
    52  	"time"
    53  
    54  	"github.com/go-jose/go-jose/v3/jwt"
    55  
    56  	"github.com/hyperledger/aries-framework-go/pkg/common/utils"
    57  	"github.com/hyperledger/aries-framework-go/pkg/doc/jose"
    58  	"github.com/hyperledger/aries-framework-go/pkg/doc/jose/jwk"
    59  	afgjwt "github.com/hyperledger/aries-framework-go/pkg/doc/jwt"
    60  	"github.com/hyperledger/aries-framework-go/pkg/doc/sdjwt/common"
    61  	jsonutil "github.com/hyperledger/aries-framework-go/pkg/doc/util/json"
    62  )
    63  
    64  const (
    65  	defaultHash     = crypto.SHA256
    66  	defaultSaltSize = 128 / 8
    67  
    68  	decoyMinElements = 1
    69  	decoyMaxElements = 4
    70  
    71  	credentialSubjectKey = "credentialSubject"
    72  	vcKey                = "vc"
    73  )
    74  
    75  var mr = mathrand.New(mathrand.NewSource(time.Now().Unix())) // nolint:gochecknoglobals
    76  
    77  // Claims defines JSON Web Token Claims (https://tools.ietf.org/html/rfc7519#section-4)
    78  type Claims jwt.Claims
    79  
    80  // newOpts holds options for creating new SD-JWT.
    81  type newOpts struct {
    82  	Subject  string
    83  	Audience string
    84  	JTI      string
    85  	ID       string
    86  
    87  	Expiry    *jwt.NumericDate
    88  	NotBefore *jwt.NumericDate
    89  	IssuedAt  *jwt.NumericDate
    90  
    91  	HolderPublicKey *jwk.JWK
    92  
    93  	HashAlg crypto.Hash
    94  
    95  	jsonMarshal func(v interface{}) ([]byte, error)
    96  	getSalt     func() (string, error)
    97  
    98  	addDecoyDigests  bool
    99  	structuredClaims bool
   100  
   101  	nonSDClaimsMap map[string]bool
   102  }
   103  
   104  // NewOpt is the SD-JWT New option.
   105  type NewOpt func(opts *newOpts)
   106  
   107  // WithJSONMarshaller is option is for marshalling disclosure.
   108  func WithJSONMarshaller(jsonMarshal func(v interface{}) ([]byte, error)) NewOpt {
   109  	return func(opts *newOpts) {
   110  		opts.jsonMarshal = jsonMarshal
   111  	}
   112  }
   113  
   114  // WithSaltFnc is an option for generating salt. Mostly used for testing.
   115  // A new salt MUST be chosen for each claim independently of other salts.
   116  // The RECOMMENDED minimum length of the randomly-generated portion of the salt is 128 bits.
   117  // It is RECOMMENDED to base64url-encode the salt value, producing a string.
   118  func WithSaltFnc(fnc func() (string, error)) NewOpt {
   119  	return func(opts *newOpts) {
   120  		opts.getSalt = fnc
   121  	}
   122  }
   123  
   124  // WithIssuedAt is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   125  func WithIssuedAt(issuedAt *jwt.NumericDate) NewOpt {
   126  	return func(opts *newOpts) {
   127  		opts.IssuedAt = issuedAt
   128  	}
   129  }
   130  
   131  // WithAudience is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   132  func WithAudience(audience string) NewOpt {
   133  	return func(opts *newOpts) {
   134  		opts.Audience = audience
   135  	}
   136  }
   137  
   138  // WithExpiry is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   139  func WithExpiry(expiry *jwt.NumericDate) NewOpt {
   140  	return func(opts *newOpts) {
   141  		opts.Expiry = expiry
   142  	}
   143  }
   144  
   145  // WithNotBefore is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   146  func WithNotBefore(notBefore *jwt.NumericDate) NewOpt {
   147  	return func(opts *newOpts) {
   148  		opts.NotBefore = notBefore
   149  	}
   150  }
   151  
   152  // WithSubject is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   153  func WithSubject(subject string) NewOpt {
   154  	return func(opts *newOpts) {
   155  		opts.Subject = subject
   156  	}
   157  }
   158  
   159  // WithJTI is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   160  func WithJTI(jti string) NewOpt {
   161  	return func(opts *newOpts) {
   162  		opts.JTI = jti
   163  	}
   164  }
   165  
   166  // WithID is an option for SD-JWT payload. This is a clear-text claim that is always disclosed.
   167  func WithID(id string) NewOpt {
   168  	return func(opts *newOpts) {
   169  		opts.ID = id
   170  	}
   171  }
   172  
   173  // WithHolderPublicKey is an option for SD-JWT payload.
   174  // The Holder can prove legitimate possession of an SD-JWT by proving control over the same private key during
   175  // the issuance and presentation. An SD-JWT with Holder Binding contains a public key or a reference to a public key
   176  // that matches to the private key controlled by the Holder.
   177  // The "cnf" claim value MUST represent only a single proof-of-possession key. This implementation is using CNF "jwk".
   178  func WithHolderPublicKey(jwk *jwk.JWK) NewOpt {
   179  	return func(opts *newOpts) {
   180  		opts.HolderPublicKey = jwk
   181  	}
   182  }
   183  
   184  // WithHashAlgorithm is an option for hashing disclosures.
   185  func WithHashAlgorithm(alg crypto.Hash) NewOpt {
   186  	return func(opts *newOpts) {
   187  		opts.HashAlg = alg
   188  	}
   189  }
   190  
   191  // WithDecoyDigests is an option for adding decoy digests(default is false).
   192  func WithDecoyDigests(flag bool) NewOpt {
   193  	return func(opts *newOpts) {
   194  		opts.addDecoyDigests = flag
   195  	}
   196  }
   197  
   198  // WithStructuredClaims is an option for handling structured claims(default is false).
   199  func WithStructuredClaims(flag bool) NewOpt {
   200  	return func(opts *newOpts) {
   201  		opts.structuredClaims = flag
   202  	}
   203  }
   204  
   205  // WithNonSelectivelyDisclosableClaims is an option for provide claim names that should be ignored when creating
   206  // selectively disclosable claims.
   207  // For example if you would like to not selectively disclose id and degree type from the following claims:
   208  // {
   209  //
   210  //	"degree": {
   211  //	   "degree": "MIT",
   212  //	   "type": "BachelorDegree",
   213  //	 },
   214  //	 "name": "Jayden Doe",
   215  //	 "id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
   216  //	}
   217  //
   218  // you should specify the following array: []string{"id", "degree.type"}.
   219  func WithNonSelectivelyDisclosableClaims(nonSDClaims []string) NewOpt {
   220  	return func(opts *newOpts) {
   221  		opts.nonSDClaimsMap = common.SliceToMap(nonSDClaims)
   222  	}
   223  }
   224  
   225  // New creates new signed Selective Disclosure JWT based on input claims.
   226  // The Issuer MUST create a Disclosure for each selectively disclosable claim as follows:
   227  // Create an array of three elements in this order:
   228  //
   229  //	A salt value. Generated by the system, the salt value MUST be unique for each claim that is to be selectively
   230  //	disclosed.
   231  //	The claim name, or key, as it would be used in a regular JWT body. This MUST be a string.
   232  //	The claim's value, as it would be used in a regular JWT body. The value MAY be of any type that is allowed in JSON,
   233  //	including numbers, strings, booleans, arrays, and objects.
   234  //
   235  // Then JSON-encode the array such that an UTF-8 string is produced.
   236  // Then base64url-encode the byte representation of the UTF-8 string to create the Disclosure.
   237  func New(issuer string, claims interface{}, headers jose.Headers,
   238  	signer jose.Signer, opts ...NewOpt) (*SelectiveDisclosureJWT, error) {
   239  	nOpts := &newOpts{
   240  		jsonMarshal:    json.Marshal,
   241  		getSalt:        generateSalt,
   242  		HashAlg:        defaultHash,
   243  		nonSDClaimsMap: make(map[string]bool),
   244  	}
   245  
   246  	for _, opt := range opts {
   247  		opt(nOpts)
   248  	}
   249  
   250  	claimsMap, err := afgjwt.PayloadToMap(claims)
   251  	if err != nil {
   252  		return nil, fmt.Errorf("convert payload to map: %w", err)
   253  	}
   254  
   255  	// check for the presence of the _sd claim in claims map
   256  	found := common.KeyExistsInMap(common.SDKey, claimsMap)
   257  	if found {
   258  		return nil, fmt.Errorf("key '%s' cannot be present in the claims", common.SDKey)
   259  	}
   260  
   261  	disclosures, digests, err := createDisclosuresAndDigests("", claimsMap, nOpts)
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  
   266  	payload, err := jsonutil.MergeCustomFields(createPayload(issuer, nOpts), digests)
   267  	if err != nil {
   268  		return nil, fmt.Errorf("failed to merge payload and digests: %w", err)
   269  	}
   270  
   271  	signedJWT, err := afgjwt.NewSigned(payload, headers, signer)
   272  	if err != nil {
   273  		return nil, fmt.Errorf("failed to create SD-JWT from payload[%+v]: %w", payload, err)
   274  	}
   275  
   276  	return &SelectiveDisclosureJWT{Disclosures: disclosures, SignedJWT: signedJWT}, nil
   277  }
   278  
   279  /*
   280  NewFromVC creates new signed Selective Disclosure JWT based on Verifiable Credential.
   281  
   282  Algorithm:
   283    - extract credential subject map from verifiable credential
   284    - create un-signed SD-JWT plus Disclosures with credential subject map
   285    - decode claims from SD-JWT to get credential subject map with selective disclosures
   286    - replace VC credential subject with newly created credential subject with selective disclosures
   287    - create signed SD-JWT based on VC
   288    - return signed SD-JWT plus Disclosures
   289  */
   290  func NewFromVC(vc map[string]interface{}, headers jose.Headers,
   291  	signer jose.Signer, opts ...NewOpt) (*SelectiveDisclosureJWT, error) {
   292  	csObj, ok := common.GetKeyFromVC(credentialSubjectKey, vc)
   293  	if !ok {
   294  		return nil, fmt.Errorf("credential subject not found")
   295  	}
   296  
   297  	cs, ok := csObj.(map[string]interface{})
   298  	if !ok {
   299  		return nil, fmt.Errorf("credential subject must be an object")
   300  	}
   301  
   302  	token, err := New("", cs, nil, &unsecuredJWTSigner{}, opts...)
   303  	if err != nil {
   304  		return nil, err
   305  	}
   306  
   307  	selectiveCredentialSubject := utils.CopyMap(token.SignedJWT.Payload)
   308  	// move _sd_alg key from credential subject to vc as per example 4 in spec
   309  	vc[vcKey].(map[string]interface{})[common.SDAlgorithmKey] = selectiveCredentialSubject[common.SDAlgorithmKey]
   310  	delete(selectiveCredentialSubject, common.SDAlgorithmKey)
   311  
   312  	// move cnf key from credential subject to vc as per example 4 in spec
   313  	cnfObj, ok := selectiveCredentialSubject[common.CNFKey]
   314  	if ok {
   315  		vc[vcKey].(map[string]interface{})[common.CNFKey] = cnfObj
   316  		delete(selectiveCredentialSubject, common.CNFKey)
   317  	}
   318  
   319  	// update VC with 'selective' credential subject
   320  	vc[vcKey].(map[string]interface{})[credentialSubjectKey] = selectiveCredentialSubject
   321  
   322  	// sign VC with 'selective' credential subject
   323  	signedJWT, err := afgjwt.NewSigned(vc, headers, signer)
   324  	if err != nil {
   325  		return nil, err
   326  	}
   327  
   328  	sdJWT := &SelectiveDisclosureJWT{Disclosures: token.Disclosures, SignedJWT: signedJWT}
   329  
   330  	return sdJWT, nil
   331  }
   332  
   333  func createPayload(issuer string, nOpts *newOpts) *payload {
   334  	var cnf map[string]interface{}
   335  	if nOpts.HolderPublicKey != nil {
   336  		cnf = make(map[string]interface{})
   337  		cnf["jwk"] = nOpts.HolderPublicKey
   338  	}
   339  
   340  	payload := &payload{
   341  		Issuer:    issuer,
   342  		JTI:       nOpts.JTI,
   343  		ID:        nOpts.ID,
   344  		Subject:   nOpts.Subject,
   345  		Audience:  nOpts.Audience,
   346  		IssuedAt:  nOpts.IssuedAt,
   347  		Expiry:    nOpts.Expiry,
   348  		NotBefore: nOpts.NotBefore,
   349  		CNF:       cnf,
   350  		SDAlg:     strings.ToLower(nOpts.HashAlg.String()),
   351  	}
   352  
   353  	return payload
   354  }
   355  
   356  func createDigests(disclosures []string, nOpts *newOpts) ([]string, error) {
   357  	var digests []string
   358  
   359  	for _, disclosure := range disclosures {
   360  		digest, inErr := common.GetHash(nOpts.HashAlg, disclosure)
   361  		if inErr != nil {
   362  			return nil, fmt.Errorf("hash disclosure: %w", inErr)
   363  		}
   364  
   365  		digests = append(digests, digest)
   366  	}
   367  
   368  	mr.Shuffle(len(digests), func(i, j int) {
   369  		digests[i], digests[j] = digests[j], digests[i]
   370  	})
   371  
   372  	return digests, nil
   373  }
   374  
   375  func createDecoyDisclosures(opts *newOpts) ([]string, error) {
   376  	if !opts.addDecoyDigests {
   377  		return nil, nil
   378  	}
   379  
   380  	n := mr.Intn(decoyMaxElements-decoyMinElements+1) + decoyMinElements
   381  
   382  	var decoyDisclosures []string
   383  
   384  	for i := 0; i < n; i++ {
   385  		salt, err := opts.getSalt()
   386  		if err != nil {
   387  			return nil, err
   388  		}
   389  
   390  		decoyDisclosures = append(decoyDisclosures, salt)
   391  	}
   392  
   393  	return decoyDisclosures, nil
   394  }
   395  
   396  // SelectiveDisclosureJWT defines Selective Disclosure JSON Web Token (https://tools.ietf.org/html/rfc7519)
   397  type SelectiveDisclosureJWT struct {
   398  	SignedJWT   *afgjwt.JSONWebToken
   399  	Disclosures []string
   400  }
   401  
   402  // DecodeClaims fills input c with claims of a token.
   403  func (j *SelectiveDisclosureJWT) DecodeClaims(c interface{}) error {
   404  	return j.SignedJWT.DecodeClaims(c)
   405  }
   406  
   407  // LookupStringHeader makes look up of particular header with string value.
   408  func (j *SelectiveDisclosureJWT) LookupStringHeader(name string) string {
   409  	return j.SignedJWT.LookupStringHeader(name)
   410  }
   411  
   412  // Serialize makes (compact) serialization of token.
   413  func (j *SelectiveDisclosureJWT) Serialize(detached bool) (string, error) {
   414  	if j.SignedJWT == nil {
   415  		return "", errors.New("JWS serialization is supported only")
   416  	}
   417  
   418  	signedJWT, err := j.SignedJWT.Serialize(detached)
   419  	if err != nil {
   420  		return "", err
   421  	}
   422  
   423  	cf := common.CombinedFormatForIssuance{
   424  		SDJWT:       signedJWT,
   425  		Disclosures: j.Disclosures,
   426  	}
   427  
   428  	return cf.Serialize(), nil
   429  }
   430  
   431  func createDisclosuresAndDigests(path string, claims map[string]interface{}, opts *newOpts) ([]string, map[string]interface{}, error) { // nolint:lll
   432  	var disclosures []string
   433  
   434  	var levelDisclosures []string
   435  
   436  	digestsMap := make(map[string]interface{})
   437  
   438  	decoyDisclosures, err := createDecoyDisclosures(opts)
   439  	if err != nil {
   440  		return nil, nil, fmt.Errorf("failed to create decoy disclosures: %w", err)
   441  	}
   442  
   443  	for key, value := range claims {
   444  		curPath := key
   445  		if path != "" {
   446  			curPath = path + "." + key
   447  		}
   448  
   449  		if obj, ok := value.(map[string]interface{}); ok && opts.structuredClaims {
   450  			nestedDisclosures, nestedDigestsMap, e := createDisclosuresAndDigests(curPath, obj, opts)
   451  			if e != nil {
   452  				return nil, nil, e
   453  			}
   454  
   455  			digestsMap[key] = nestedDigestsMap
   456  
   457  			disclosures = append(disclosures, nestedDisclosures...)
   458  		} else {
   459  			if _, ok := opts.nonSDClaimsMap[curPath]; ok {
   460  				digestsMap[key] = value
   461  
   462  				continue
   463  			}
   464  
   465  			disclosure, e := createDisclosure(key, value, opts)
   466  			if e != nil {
   467  				return nil, nil, fmt.Errorf("create disclosure: %w", e)
   468  			}
   469  
   470  			levelDisclosures = append(levelDisclosures, disclosure)
   471  		}
   472  	}
   473  
   474  	disclosures = append(disclosures, levelDisclosures...)
   475  
   476  	digests, err := createDigests(append(levelDisclosures, decoyDisclosures...), opts)
   477  	if err != nil {
   478  		return nil, nil, err
   479  	}
   480  
   481  	digestsMap[common.SDKey] = digests
   482  
   483  	return disclosures, digestsMap, nil
   484  }
   485  
   486  func createDisclosure(key string, value interface{}, opts *newOpts) (string, error) {
   487  	salt, err := opts.getSalt()
   488  	if err != nil {
   489  		return "", fmt.Errorf("generate salt: %w", err)
   490  	}
   491  
   492  	disclosure := []interface{}{salt, key, value}
   493  
   494  	disclosureBytes, err := opts.jsonMarshal(disclosure)
   495  	if err != nil {
   496  		return "", fmt.Errorf("marshal disclosure: %w", err)
   497  	}
   498  
   499  	return base64.RawURLEncoding.EncodeToString(disclosureBytes), nil
   500  }
   501  
   502  func generateSalt() (string, error) {
   503  	salt := make([]byte, defaultSaltSize)
   504  
   505  	_, err := rand.Read(salt)
   506  	if err != nil {
   507  		return "", err
   508  	}
   509  
   510  	// it is RECOMMENDED to base64url-encode the salt value, producing a string.
   511  	return base64.RawURLEncoding.EncodeToString(salt), nil
   512  }
   513  
   514  // payload represents SD-JWT payload.
   515  type payload struct {
   516  	// registered claim names
   517  	Issuer    string           `json:"iss,omitempty"`
   518  	Subject   string           `json:"sub,omitempty"`
   519  	Audience  string           `json:"aud,omitempty"`
   520  	JTI       string           `json:"jti,omitempty"`
   521  	Expiry    *jwt.NumericDate `json:"exp,omitempty"`
   522  	NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
   523  	IssuedAt  *jwt.NumericDate `json:"iat,omitempty"`
   524  
   525  	// non-registered name that can be used for claims based holder binding
   526  	ID string `json:"id,omitempty"`
   527  
   528  	// SD-JWT specific
   529  	CNF   map[string]interface{} `json:"cnf,omitempty"`
   530  	SDAlg string                 `json:"_sd_alg,omitempty"`
   531  }
   532  
   533  type unsecuredJWTSigner struct{}
   534  
   535  func (s unsecuredJWTSigner) Sign(_ []byte) ([]byte, error) {
   536  	return []byte(""), nil
   537  }
   538  
   539  func (s unsecuredJWTSigner) Headers() jose.Headers {
   540  	return map[string]interface{}{
   541  		jose.HeaderAlgorithm: afgjwt.AlgorithmNone,
   542  	}
   543  }