diff --git a/cmd/vc-rest/startcmd/start.go b/cmd/vc-rest/startcmd/start.go index 0672b2af3..cf932a288 100644 --- a/cmd/vc-rest/startcmd/start.go +++ b/cmd/vc-rest/startcmd/start.go @@ -657,6 +657,8 @@ func buildEchoHandler( PreAuthCodeTTL: conf.StartupParameters.transientDataParams.claimDataTTL, CredentialOfferReferenceStore: credentialOfferStore, DataProtector: claimsDataProtector, + KMSRegistry: kmsRegistry, + CryptoJWTSigner: vcCrypto, }) if err != nil { return nil, fmt.Errorf("failed to instantiate new oidc4ci service: %w", err) @@ -932,6 +934,10 @@ type credentialOfferReferenceStore interface { ctx context.Context, request *oidc4ci.CredentialOfferResponse, ) (string, error) + CreateJWT( + ctx context.Context, + credentialOfferJWT string, + ) (string, error) } func getOIDC4VPClaimsStore( diff --git a/component/wallet-cli/pkg/credentialoffer/credentialoffer.go b/component/wallet-cli/pkg/credentialoffer/credentialoffer.go index 087d82175..16106e011 100644 --- a/component/wallet-cli/pkg/credentialoffer/credentialoffer.go +++ b/component/wallet-cli/pkg/credentialoffer/credentialoffer.go @@ -3,23 +3,39 @@ package credentialoffer import ( "encoding/json" "fmt" + "io" "net/http" "net/url" + "github.com/hyperledger/aries-framework-go/component/models/jwt" + "github.com/hyperledger/aries-framework-go/component/models/verifiable" + vdrapi "github.com/hyperledger/aries-framework-go/component/vdr/api" + "github.com/valyala/fastjson" + "github.com/trustbloc/vcs/pkg/service/oidc4ci" ) -func ParseInitiateIssuanceUrl(rawURL string, client *http.Client) (*oidc4ci.CredentialOfferResponse, error) { +func ParseInitiateIssuanceUrl(rawURL string, client *http.Client, vdrRegistry vdrapi.Registry) (*oidc4ci.CredentialOfferResponse, error) { initiateIssuanceURLParsed, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("failed to parse url %w", err) } - credentialOfferURL := initiateIssuanceURLParsed.Query().Get("credential_offer") var offerResponse oidc4ci.CredentialOfferResponse + var credentialOfferPayload []byte + + if credentialOfferQueryParam := initiateIssuanceURLParsed.Query().Get("credential_offer"); len(credentialOfferQueryParam) > 0 { + credentialOfferPayload = []byte(credentialOfferQueryParam) + // Depends on Issuer configuration, credentialOfferURL might be either JWT signed CredentialOfferResponse, + // or encoded oidc4ci.CredentialOfferResponse itself. + if jwt.IsJWS(credentialOfferQueryParam) { + credentialOfferPayload, err = getCredentialOfferJWTPayload(credentialOfferQueryParam, vdrRegistry) + if err != nil { + return nil, err + } + } - if len(credentialOfferURL) > 0 { - if err = json.Unmarshal([]byte(credentialOfferURL), &offerResponse); err != nil { + if err = json.Unmarshal(credentialOfferPayload, &offerResponse); err != nil { return nil, fmt.Errorf("can not parse credential offer. %w", err) } @@ -37,9 +53,51 @@ func ParseInitiateIssuanceUrl(rawURL string, client *http.Client) (*oidc4ci.Cred } defer resp.Body.Close() - if err = json.NewDecoder(resp.Body).Decode(&offerResponse); err != nil { + + credentialOfferPayload, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read credential_offer_uriresponse body: %w", err) + } + + // Depends on Issuer configuration, rspBody might be either JWT signed CredentialOfferResponse, + // or encoded oidc4ci.CredentialOfferResponse itself. + if jwt.IsJWS(string(credentialOfferPayload)) { + credentialOfferPayload, err = getCredentialOfferJWTPayload(string(credentialOfferPayload), vdrRegistry) + if err != nil { + return nil, err + } + } + + if err = json.Unmarshal(credentialOfferPayload, &offerResponse); err != nil { return nil, err } return &offerResponse, nil } + +func getCredentialOfferJWTPayload(rawResponse string, vdrRegistry vdrapi.Registry) ([]byte, error) { + jwtVerifier := jwt.NewVerifier(jwt.KeyResolverFunc( + verifiable.NewVDRKeyResolver(vdrRegistry).PublicKeyFetcher())) + + _, credentialOfferPayload, err := jwt.Parse( + rawResponse, + jwt.WithSignatureVerifier(jwtVerifier), + jwt.WithIgnoreClaimsMapDecoding(true), + ) + if err != nil { + return nil, fmt.Errorf("parse credential offer JWT: %w", err) + } + + var fastParser fastjson.Parser + v, err := fastParser.ParseBytes(credentialOfferPayload) + if err != nil { + return nil, fmt.Errorf("decode claims: %w", err) + } + + sb, err := v.Get("credential_offer").Object() + if err != nil { + return nil, fmt.Errorf("fastjson.Parser Get credential_offer: %w", err) + } + + return sb.MarshalTo([]byte{}), nil +} diff --git a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci.go b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci.go index 04b2d3813..440bcf6ac 100644 --- a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci.go +++ b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci.go @@ -72,9 +72,16 @@ func (s *Service) RunOIDC4CI(config *OIDC4CIConfig, hooks *Hooks) error { log.Println("Starting OIDC4VCI authorized code flow") ctx := context.Background() log.Printf("Initiate issuance URL:\n\n\t%s\n\n", config.InitiateIssuanceURL) + + err := s.CreateWallet() + if err != nil { + return fmt.Errorf("create wallet: %w", err) + } + offerResponse, err := credentialoffer.ParseInitiateIssuanceUrl( config.InitiateIssuanceURL, s.httpClient, + s.ariesServices.vdrRegistry, ) if err != nil { return fmt.Errorf("parse initiate issuance url: %w", err) @@ -193,11 +200,6 @@ func (s *Service) RunOIDC4CI(config *OIDC4CIConfig, hooks *Hooks) error { s.token = token - err = s.CreateWallet() - if err != nil { - return fmt.Errorf("create wallet: %w", err) - } - s.print("Getting credential") vc, _, err := s.getCredential( oidcIssuerCredentialConfig.CredentialEndpoint, diff --git a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci_pre_auth.go b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci_pre_auth.go index f6e63c6eb..21f1a5697 100644 --- a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci_pre_auth.go +++ b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4ci_pre_auth.go @@ -30,14 +30,26 @@ func (s *Service) RunOIDC4CIPreAuth(config *OIDC4CIConfig) (*verifiable.Credenti log.Println("Starting OIDC4VCI pre-authorized code flow") ctx := context.Background() + + startTime := time.Now() + err := s.CreateWallet() + if err != nil { + return nil, fmt.Errorf("failed to create wallet: %w", err) + } + s.perfInfo.CreateWallet = time.Since(startTime) + log.Printf("Initiate issuance URL:\n\n\t%s\n\n", config.InitiateIssuanceURL) - offerResponse, err := credentialoffer.ParseInitiateIssuanceUrl(config.InitiateIssuanceURL, s.httpClient) + offerResponse, err := credentialoffer.ParseInitiateIssuanceUrl( + config.InitiateIssuanceURL, + s.httpClient, + s.ariesServices.vdrRegistry, + ) if err != nil { return nil, fmt.Errorf("parse initiate issuance url: %w", err) } s.print("Getting issuer OIDC config") - startTime := time.Now() + startTime = time.Now() oidcConfig, err := s.getIssuerOIDCConfig(ctx, offerResponse.CredentialIssuer) s.perfInfo.VcsCIFlowDuration += time.Since(startTime) // oidc config s.perfInfo.GetIssuerOIDCConfig = time.Since(startTime) @@ -106,13 +118,6 @@ func (s *Service) RunOIDC4CIPreAuth(config *OIDC4CIConfig) (*verifiable.Credenti "c_nonce": *token.CNonce, }) - startTime = time.Now() - err = s.CreateWallet() - if err != nil { - return nil, fmt.Errorf("failed to create wallet: %w", err) - } - s.perfInfo.CreateWallet = time.Since(startTime) - s.print("Getting credential") startTime = time.Now() vc, vcsDuration, err := s.getCredential(credentialsEndpoint, config.CredentialType, config.CredentialFormat, diff --git a/pkg/doc/vc/crypto/crypto.go b/pkg/doc/vc/crypto/crypto.go index 3c7e842a0..00dcc05a4 100644 --- a/pkg/doc/vc/crypto/crypto.go +++ b/pkg/doc/vc/crypto/crypto.go @@ -13,7 +13,9 @@ import ( "github.com/piprate/json-gold/ld" + "github.com/hyperledger/aries-framework-go/component/kmscrypto/doc/jose" "github.com/hyperledger/aries-framework-go/component/models/did" + "github.com/hyperledger/aries-framework-go/component/models/jwt" ldprocessor "github.com/hyperledger/aries-framework-go/component/models/ld/processor" ariessigner "github.com/hyperledger/aries-framework-go/component/models/signature/signer" "github.com/hyperledger/aries-framework-go/component/models/signature/suite" @@ -168,6 +170,35 @@ func (c *Crypto) SignCredential( } } +// NewJWTSigned returns JWT signed claims. +func (c *Crypto) NewJWTSigned(claims interface{}, signerData *vc.Signer) (string, error) { + jwsAlgo, err := verifiable.KeyTypeToJWSAlgo(signerData.KeyType) + if err != nil { + return "", fmt.Errorf("getting JWS algo based on signature type: %w", err) + } + + jwtAlgoStr, err := jwsAlgo.Name() + if err != nil { + return "", fmt.Errorf("get jwt algo name: %w", err) + } + + signer, _, err := c.getSigner(signerData.KMSKeyID, signerData.KMS, signerData.SignatureType) + if err != nil { + return "", err + } + + headers := map[string]interface{}{ + jose.HeaderKeyID: signerData.Creator, + } + + token, err := jwt.NewSigned(claims, headers, verifiable.GetJWTSigner(signer, jwtAlgoStr)) + if err != nil { + return "", fmt.Errorf("newSigned: %w", err) + } + + return token.Serialize(false) +} + // signCredentialLDP adds verifiable.LinkedDataProofContext to the VC. func (c *Crypto) signCredentialLDP( signerData *vc.Signer, vc *verifiable.Credential, opts ...SigningOpts) (*verifiable.Credential, error) { diff --git a/pkg/doc/vc/crypto/crypto_test.go b/pkg/doc/vc/crypto/crypto_test.go index 89cc9d811..468c97766 100644 --- a/pkg/doc/vc/crypto/crypto_test.go +++ b/pkg/doc/vc/crypto/crypto_test.go @@ -33,6 +33,7 @@ import ( vdrmock "github.com/hyperledger/aries-framework-go/component/vdr/mock" ariescrypto "github.com/hyperledger/aries-framework-go/spi/crypto" "github.com/hyperledger/aries-framework-go/spi/kms" + "github.com/piprate/json-gold/ld" "github.com/stretchr/testify/require" "github.com/trustbloc/vcs/pkg/doc/vc" @@ -819,6 +820,7 @@ func getTestLDPSigner() *vc.Signer { SignatureType: "Ed25519Signature2018", Creator: "did:trustbloc:abc#key1", KMSKeyID: "key1", + KeyType: kms.ED25519, KMS: &mockVCSKeyManager{ crypto: &cryptomock.Crypto{}, kms: &mockkms.KeyManager{}, @@ -881,12 +883,17 @@ func createKMS(t *testing.T) *localkms.LocalKMS { } type mockVCSKeyManager struct { + err error crypto ariescrypto.Crypto kms kms.KeyManager } func (m *mockVCSKeyManager) NewVCSigner(creator string, signatureType vcsverifiable.SignatureType) (vc.SignerAlgorithm, error) { + if m.err != nil { + return nil, m.err + } + return signer.NewKMSSigner(m.kms, m.crypto, creator, signatureType, nil) } @@ -938,3 +945,114 @@ func createDIDDoc(didID string, opts ...opt) *did.Doc { CapabilityDelegation: []did.Verification{{VerificationMethod: signingKey}}, } } + +func TestCrypto_NewJWTSigned(t *testing.T) { + testClaims := map[string]interface{}{ + "key": "value", + } + + type fields struct { + vdr vdrapi.Registry + documentLoader ld.DocumentLoader + } + type args struct { + getClaims func() interface{} + getSignerData func() *vc.Signer + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + { + name: "Success", + fields: fields{ + vdr: &vdrmock.VDRegistry{ResolveValue: createDIDDoc("did:trustbloc:abc")}, + documentLoader: testutil.DocumentLoader(t), + }, + args: args{ + getClaims: func() interface{} { + return testClaims + }, + getSignerData: getTestLDPSigner, + }, + want: "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDp0cnVzdGJsb2M6YWJjI2tleTEifQ.eyJrZXkiOiJ2YWx1ZSJ9.", + wantErr: false, + }, + { + name: "Error KeyTypeToJWSAlgo", + fields: fields{ + vdr: &vdrmock.VDRegistry{ResolveValue: createDIDDoc("did:trustbloc:abc")}, + documentLoader: testutil.DocumentLoader(t), + }, + args: args{ + getClaims: func() interface{} { + return testClaims + }, + getSignerData: func() *vc.Signer { + s := getTestLDPSigner() + s.KeyType = "" + + return s + }, + }, + want: "", + wantErr: true, + }, + { + name: "Error getSigner", + fields: fields{ + vdr: &vdrmock.VDRegistry{ResolveValue: createDIDDoc("did:trustbloc:abc")}, + documentLoader: testutil.DocumentLoader(t), + }, + args: args{ + getClaims: func() interface{} { + return testClaims + }, + getSignerData: func() *vc.Signer { + s := getTestLDPSigner() + s.KMS = &mockVCSKeyManager{ + err: errors.New("some error"), + } + + return s + }, + }, + want: "", + wantErr: true, + }, + { + name: "Error NewSigned", + fields: fields{ + vdr: &vdrmock.VDRegistry{ResolveValue: createDIDDoc("did:trustbloc:abc")}, + documentLoader: testutil.DocumentLoader(t), + }, + args: args{ + getClaims: func() interface{} { + return func() {} + }, + getSignerData: getTestLDPSigner, + }, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Crypto{ + vdr: tt.fields.vdr, + documentLoader: tt.fields.documentLoader, + } + got, err := c.NewJWTSigned(tt.args.getClaims(), tt.args.getSignerData()) + if (err != nil) != tt.wantErr { + t.Errorf("NewJWTSigned() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("NewJWTSigned() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/profile/api.go b/pkg/profile/api.go index 2941ab0b8..263f204e6 100644 --- a/pkg/profile/api.go +++ b/pkg/profile/api.go @@ -108,6 +108,7 @@ type OIDCConfig struct { InitialAccessTokenLifespan time.Duration `json:"initial_access_token_lifespan"` PreAuthorizedGrantAnonymousAccessSupported bool `json:"pre-authorized_grant_anonymous_access_supported"` WalletInitiatedAuthFlowSupported bool `json:"wallet_initiated_auth_flow_supported"` + SignedCredentialOfferSupported bool `json:"signed_credential_offer_supported"` ClaimsEndpoint string `json:"claims_endpoint"` } diff --git a/pkg/restapi/v1/issuer/controller.go b/pkg/restapi/v1/issuer/controller.go index dcfa6a153..ae080dcde 100644 --- a/pkg/restapi/v1/issuer/controller.go +++ b/pkg/restapi/v1/issuer/controller.go @@ -399,19 +399,19 @@ func (c *Controller) InitiateCredentialIssuance(e echo.Context, profileID, profi span.SetAttributes(attributeutil.JSON("initiate_issuance_request", body, attributeutil.WithRedacted("claim_data"))) - resp, err := c.initiateIssuance(ctx, &body, profile) + resp, ct, err := c.initiateIssuance(ctx, &body, profile) if err != nil { return err } - return util.WriteOutput(e)(resp, nil) + return util.WriteOutputWithContentType(e)(resp, ct, nil) } func (c *Controller) initiateIssuance( ctx context.Context, req *InitiateOIDC4CIRequest, profile *profileapi.Issuer, -) (*InitiateOIDC4CIResponse, error) { +) (*InitiateOIDC4CIResponse, string, error) { issuanceReq := &oidc4ci.InitiateIssuanceRequest{ CredentialTemplateID: lo.FromPtr(req.CredentialTemplateId), ClientInitiateIssuanceURL: lo.FromPtr(req.ClientInitiateIssuanceUrl), @@ -433,17 +433,17 @@ func (c *Controller) initiateIssuance( if err != nil { if errors.Is(err, oidc4ci.ErrCredentialTemplateNotFound) || errors.Is(err, oidc4ci.ErrCredentialTemplateIDRequired) { - return nil, resterr.NewValidationError(resterr.InvalidValue, "credential_template_id", err) + return nil, "", resterr.NewValidationError(resterr.InvalidValue, "credential_template_id", err) } - return nil, resterr.NewSystemError("OIDC4CIService", "InitiateIssuance", err) + return nil, "", resterr.NewSystemError("OIDC4CIService", "InitiateIssuance", err) } return &InitiateOIDC4CIResponse{ OfferCredentialUrl: resp.InitiateIssuanceURL, TxId: string(resp.TxID), UserPin: lo.ToPtr(resp.UserPin), - }, nil + }, resp.ContentType, nil } // PushAuthorizationDetails updates authorization details. diff --git a/pkg/restapi/v1/util/bind.go b/pkg/restapi/v1/util/bind.go index 59e85883c..ba19c3498 100644 --- a/pkg/restapi/v1/util/bind.go +++ b/pkg/restapi/v1/util/bind.go @@ -40,3 +40,18 @@ func WriteOutput(ctx echo.Context) func(output interface{}, err error) error { return ctx.JSONBlob(http.StatusOK, b) } } + +func WriteOutputWithContentType(ctx echo.Context) func(output interface{}, ct string, err error) error { + return func(output interface{}, ct string, err error) error { + if err != nil { + return err + } + + b, err := json.Marshal(output) + if err != nil { + return err + } + + return ctx.Blob(http.StatusOK, ct, b) + } +} diff --git a/pkg/service/oidc4ci/api.go b/pkg/service/oidc4ci/api.go index ccb13dc88..36ef8f4fc 100644 --- a/pkg/service/oidc4ci/api.go +++ b/pkg/service/oidc4ci/api.go @@ -12,6 +12,7 @@ import ( "time" "github.com/hyperledger/aries-framework-go/component/models/verifiable" + "github.com/labstack/echo/v4" "github.com/trustbloc/vcs/pkg/dataprotect" vcsverifiable "github.com/trustbloc/vcs/pkg/doc/verifiable" @@ -32,6 +33,8 @@ type Transaction struct { type TransactionState int16 +type InitiateIssuanceResponseContentType = string + const ( TransactionStateUnknown = TransactionState(0) TransactionStateIssuanceInitiated = TransactionState(1) @@ -41,6 +44,11 @@ const ( TransactionStateCredentialsIssued = TransactionState(5) ) +const ( + ContentTypeApplicationJSON InitiateIssuanceResponseContentType = echo.MIMEApplicationJSONCharsetUTF8 + ContentTypeApplicationJWT InitiateIssuanceResponseContentType = "application/jwt" +) + // ClaimData represents user claims in pre-auth code flow. type ClaimData struct { EncryptedData *dataprotect.EncryptedData `json:"encrypted_data"` @@ -127,7 +135,8 @@ type InitiateIssuanceResponse struct { InitiateIssuanceURL string TxID TxID UserPin string - Tx *Transaction `json:"-"` + Tx *Transaction `json:"-"` + ContentType InitiateIssuanceResponseContentType `json:"-"` } // PrepareClaimDataAuthorizationRequest is the request to prepare the claim data authorization request. diff --git a/pkg/service/oidc4ci/oidc4ci_service.go b/pkg/service/oidc4ci/oidc4ci_service.go index e26546e3a..79a69df98 100644 --- a/pkg/service/oidc4ci/oidc4ci_service.go +++ b/pkg/service/oidc4ci/oidc4ci_service.go @@ -4,7 +4,7 @@ Copyright SecureKey Technologies Inc. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ -//go:generate mockgen -destination oidc4ci_service_mocks_test.go -self_package mocks -package oidc4ci_test -source=oidc4ci_service.go -mock_names transactionStore=MockTransactionStore,wellKnownService=MockWellKnownService,eventService=MockEventService,pinGenerator=MockPinGenerator,credentialOfferReferenceStore=MockCredentialOfferReferenceStore,claimDataStore=MockClaimDataStore,profileService=MockProfileService,dataProtector=MockDataProtector +//go:generate mockgen -destination oidc4ci_service_mocks_test.go -self_package mocks -package oidc4ci_test -source=oidc4ci_service.go -mock_names transactionStore=MockTransactionStore,wellKnownService=MockWellKnownService,eventService=MockEventService,pinGenerator=MockPinGenerator,credentialOfferReferenceStore=MockCredentialOfferReferenceStore,claimDataStore=MockClaimDataStore,profileService=MockProfileService,dataProtector=MockDataProtector,kmsRegistry=MockKMSRegistry,cryptoJWTSigner=MockCryptoJWTSigner package oidc4ci @@ -19,13 +19,14 @@ import ( "time" "github.com/google/uuid" - "github.com/trustbloc/logutil-go/pkg/log" - util "github.com/hyperledger/aries-framework-go/component/models/util/time" "github.com/hyperledger/aries-framework-go/component/models/verifiable" + "github.com/trustbloc/logutil-go/pkg/log" "github.com/trustbloc/vcs/pkg/dataprotect" + "github.com/trustbloc/vcs/pkg/doc/vc" "github.com/trustbloc/vcs/pkg/event/spi" + vcskms "github.com/trustbloc/vcs/pkg/kms" profileapi "github.com/trustbloc/vcs/pkg/profile" "github.com/trustbloc/vcs/pkg/restapi/resterr" "github.com/trustbloc/vcs/pkg/restapi/v1/common" @@ -90,7 +91,11 @@ type eventService interface { type credentialOfferReferenceStore interface { Create( ctx context.Context, - request *CredentialOfferResponse, + credentialOffer *CredentialOfferResponse, + ) (string, error) + CreateJWT( + ctx context.Context, + credentialOfferJWT string, ) (string, error) } @@ -99,6 +104,14 @@ type dataProtector interface { Decrypt(ctx context.Context, encryptedData *dataprotect.EncryptedData) ([]byte, error) } +type kmsRegistry interface { + GetKeyManager(config *vcskms.Config) (vcskms.VCSKeyManager, error) +} + +type cryptoJWTSigner interface { + NewJWTSigned(claims interface{}, signerData *vc.Signer) (string, error) +} + // Config holds configuration options and dependencies for Service. type Config struct { TransactionStore transactionStore @@ -113,6 +126,8 @@ type Config struct { PreAuthCodeTTL int32 CredentialOfferReferenceStore credentialOfferReferenceStore // optional DataProtector dataProtector + KMSRegistry kmsRegistry + CryptoJWTSigner cryptoJWTSigner } // Service implements VCS credential interaction API for OIDC credential issuance. @@ -129,6 +144,8 @@ type Service struct { preAuthCodeTTL int32 credentialOfferReferenceStore credentialOfferReferenceStore // optional dataProtector dataProtector + kmsRegistry kmsRegistry + cryptoJWTSigner cryptoJWTSigner } // NewService returns a new Service instance. @@ -146,6 +163,8 @@ func NewService(config *Config) (*Service, error) { preAuthCodeTTL: config.PreAuthCodeTTL, credentialOfferReferenceStore: config.CredentialOfferReferenceStore, dataProtector: config.DataProtector, + kmsRegistry: config.KMSRegistry, + cryptoJWTSigner: config.CryptoJWTSigner, }, nil } diff --git a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go index 09ca788cb..1fbc61e20 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go +++ b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go @@ -14,11 +14,14 @@ import ( "net/url" "time" + josejwt "github.com/go-jose/go-jose/v3/jwt" "github.com/google/uuid" + "github.com/hyperledger/aries-framework-go/component/models/jwt" "github.com/samber/lo" "github.com/trustbloc/logutil-go/pkg/log" "github.com/trustbloc/vcs/internal/logfields" + "github.com/trustbloc/vcs/pkg/doc/vc" "github.com/trustbloc/vcs/pkg/doc/verifiable" "github.com/trustbloc/vcs/pkg/event/spi" profileapi "github.com/trustbloc/vcs/pkg/profile" @@ -132,7 +135,7 @@ func (s *Service) InitiateIssuance( // nolint:funlen,gocyclo,gocognit return nil, errSendEvent } - finalURL, err := s.buildInitiateIssuanceURL(ctx, req, template, tx) + finalURL, contentType, err := s.buildInitiateIssuanceURL(ctx, req, template, tx, profile) if err != nil { return nil, err } @@ -142,6 +145,7 @@ func (s *Service) InitiateIssuance( // nolint:funlen,gocyclo,gocognit TxID: tx.ID, UserPin: tx.UserPin, Tx: tx, + ContentType: contentType, }, nil } @@ -307,24 +311,150 @@ func (s *Service) prepareCredentialOffer( return resp } +// JWTCredentialOfferClaims is JWT Claims extension by CredentialOfferResponse (with custom "credential_offer" claim). +type JWTCredentialOfferClaims struct { + *jwt.Claims + + CredentialOffer *CredentialOfferResponse `json:"credential_offer,omitempty"` +} + +func (s *Service) getJWTCredentialOfferClaims( + profileSigningDID string, + credentialOffer *CredentialOfferResponse, +) *JWTCredentialOfferClaims { + return &JWTCredentialOfferClaims{ + Claims: &jwt.Claims{ + Issuer: profileSigningDID, + Subject: profileSigningDID, + IssuedAt: josejwt.NewNumericDate(time.Now()), + }, + CredentialOffer: credentialOffer, + } +} + +// storeCredentialOffer stores signedCredentialOfferJWT or CredentialOfferResponse object +// to underlying credentialOfferReferenceStore. +// +// Returns: +// +// remoteOfferURL +// error +// +// returned remoteOfferURL might be empty in case credentialOfferReferenceStore is not initialized. +func (s *Service) storeCredentialOffer( //nolint:nonamedreturns + ctx context.Context, + credentialOffer *CredentialOfferResponse, + signedCredentialOfferJWT string, +) (remoteOfferURL string, err error) { + if s.credentialOfferReferenceStore == nil { + return "", nil + } + + if signedCredentialOfferJWT != "" { + return s.credentialOfferReferenceStore.CreateJWT(ctx, signedCredentialOfferJWT) + } + + return s.credentialOfferReferenceStore.Create(ctx, credentialOffer) +} + +func (s *Service) getSignedCredentialOfferJWT( + profile *profileapi.Issuer, + credentialOffer *CredentialOfferResponse, +) (string, error) { + kms, err := s.kmsRegistry.GetKeyManager(profile.KMSConfig) + if err != nil { + return "", fmt.Errorf("get kms: %w", err) + } + + signerData := &vc.Signer{ + KeyType: profile.VCConfig.KeyType, + KMSKeyID: profile.SigningDID.KMSKeyID, + KMS: kms, + SignatureType: profile.VCConfig.SigningAlgorithm, + Creator: profile.SigningDID.Creator, + } + + credentialOfferClaims := s.getJWTCredentialOfferClaims(profile.SigningDID.DID, credentialOffer) + + signedCredentialOffer, err := s.cryptoJWTSigner.NewJWTSigned(credentialOfferClaims, signerData) + if err != nil { + return "", fmt.Errorf("sign credential offer: %w", err) + } + + return signedCredentialOffer, nil +} + func (s *Service) buildInitiateIssuanceURL( ctx context.Context, req *InitiateIssuanceRequest, template *profileapi.CredentialTemplate, tx *Transaction, -) (string, error) { + profile *profileapi.Issuer, +) (string, InitiateIssuanceResponseContentType, error) { credentialOffer := s.prepareCredentialOffer(ctx, req, template, tx) - var remoteOfferURL string - if s.credentialOfferReferenceStore != nil { - remoteURL, remoteErr := s.credentialOfferReferenceStore.Create(ctx, credentialOffer) - if remoteErr != nil { - return "", remoteErr + var ( + signedCredentialOfferJWT string + remoteOfferURL string + err error + ) + + if profile.OIDCConfig.SignedCredentialOfferSupported { + signedCredentialOfferJWT, err = s.getSignedCredentialOfferJWT(profile, credentialOffer) + if err != nil { + return "", "", err } + } - remoteOfferURL = remoteURL + remoteOfferURL, err = s.storeCredentialOffer(ctx, credentialOffer, signedCredentialOfferJWT) + if err != nil { + return "", "", err } + initiateIssuanceQueryParams, err := s.getInitiateIssuanceQueryParams( + remoteOfferURL, signedCredentialOfferJWT, credentialOffer) + if err != nil { + return "", "", err + } + + ct := ContentTypeApplicationJSON + if signedCredentialOfferJWT != "" { + ct = ContentTypeApplicationJWT + } + + initiateIssuanceURL := s.getInitiateIssuanceURL(ctx, req) + + return initiateIssuanceURL + "?" + initiateIssuanceQueryParams.Encode(), ct, nil +} + +func (s *Service) getInitiateIssuanceQueryParams( + remoteOfferURL, signedCredentialOfferJWT string, + credentialOffer *CredentialOfferResponse, +) (url.Values, error) { + q := url.Values{} + if remoteOfferURL != "" { + q.Set("credential_offer_uri", remoteOfferURL) + + return q, nil + } + + if signedCredentialOfferJWT != "" { + q.Set("credential_offer", signedCredentialOfferJWT) + + return q, nil + } + + b, err := json.Marshal(credentialOffer) + if err != nil { + return nil, err + } + + q.Set("credential_offer", string(b)) + + return q, nil +} + +func (s *Service) getInitiateIssuanceURL(ctx context.Context, req *InitiateIssuanceRequest) string { var initiateIssuanceURL string if req.ClientInitiateIssuanceURL != "" { @@ -343,16 +473,5 @@ func (s *Service) buildInitiateIssuanceURL( initiateIssuanceURL = "openid-credential-offer://" } - q := url.Values{} - if remoteOfferURL != "" { - q.Set("credential_offer_uri", remoteOfferURL) - } else { - b, err := json.Marshal(credentialOffer) - if err != nil { - return "", err - } - q.Set("credential_offer", string(b)) - } - - return initiateIssuanceURL + "?" + q.Encode(), nil + return initiateIssuanceURL } diff --git a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go index 47affa430..f568c648d 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go +++ b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go @@ -18,8 +18,10 @@ import ( "github.com/samber/lo" "github.com/trustbloc/vcs/pkg/dataprotect" + "github.com/trustbloc/vcs/pkg/doc/vc" "github.com/trustbloc/vcs/pkg/doc/verifiable" "github.com/trustbloc/vcs/pkg/event/spi" + vcskms "github.com/trustbloc/vcs/pkg/kms" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" @@ -113,6 +115,7 @@ func TestService_InitiateIssuance(t *testing.T) { require.NoError(t, err) assert.NotNil(t, resp.Tx) require.Contains(t, resp.InitiateIssuanceURL, "https://wallet.example.com/initiate_issuance") + require.Equal(t, oidc4ci.ContentTypeApplicationJSON, resp.ContentType) }, }, { @@ -952,6 +955,8 @@ func TestService_InitiateIssuanceWithRemoteStore(t *testing.T) { eventService = NewMockEventService(gomock.NewController(t)) pinGenerator = NewMockPinGenerator(gomock.NewController(t)) referenceStore = NewMockCredentialOfferReferenceStore(gomock.NewController(t)) + kmsRegistry = NewMockKMSRegistry(gomock.NewController(t)) + cryptoJWTSigner = NewMockCryptoJWTSigner(gomock.NewController(t)) issuanceReq *oidc4ci.InitiateIssuanceRequest profile *profileapi.Issuer ) @@ -965,7 +970,7 @@ func TestService_InitiateIssuanceWithRemoteStore(t *testing.T) { check func(t *testing.T, resp *oidc4ci.InitiateIssuanceResponse, err error) }{ { - name: "Success with reference store", + name: "JWT disabled - Success with reference store", setup: func() { mockTransactionStore.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func( @@ -1024,10 +1029,11 @@ func TestService_InitiateIssuanceWithRemoteStore(t *testing.T) { require.Contains(t, resp.InitiateIssuanceURL, "https://wallet.example.com/initiate_issuance?"+ "credential_offer_uri=https%3A%2F%2Fremote_url%2Ffile.jwt") + require.Equal(t, oidc4ci.ContentTypeApplicationJSON, resp.ContentType) }, }, { - name: "Fail uploading to remote", + name: "JWT disabled - Fail uploading to remote", setup: func() { mockTransactionStore.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func( @@ -1081,6 +1087,230 @@ func TestService_InitiateIssuanceWithRemoteStore(t *testing.T) { require.Nil(t, resp) }, }, + { + name: "JWT enabled - Success with reference store", + setup: func() { + const mockSignedCredentialOfferJWT = "aa.bb.cc" + + mockTransactionStore.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func( + ctx context.Context, + data *oidc4ci.TransactionData, + params ...func(insertOptions *oidc4ci.InsertOptions), + ) (*oidc4ci.Transaction, error) { + assert.Equal(t, oidc4ci.TransactionStateIssuanceInitiated, data.State) + + return &oidc4ci.Transaction{ + ID: "txID", + TransactionData: oidc4ci.TransactionData{ + CredentialFormat: verifiable.Jwt, + CredentialTemplate: &profileapi.CredentialTemplate{ + ID: "templateID", + }, + }, + }, nil + }) + referenceStore = NewMockCredentialOfferReferenceStore(gomock.NewController(t)) + referenceStore.EXPECT().CreateJWT(gomock.Any(), mockSignedCredentialOfferJWT). + DoAndReturn(func( + ctx context.Context, + signedCredentialOffer string, + ) (string, error) { + return "https://remote_url/file.jwt", nil + }) + + mockWellKnownService.EXPECT().GetOIDCConfiguration(gomock.Any(), walletWellKnownURL).Return( + &oidc4ci.OIDCConfiguration{ + InitiateIssuanceEndpoint: "https://wallet.example.com/initiate_issuance", + }, nil) + + eventService.EXPECT().Publish(gomock.Any(), spi.IssuerEventTopic, gomock.Any()). + DoAndReturn(func(ctx context.Context, topic string, messages ...*spi.Event) error { + assert.Len(t, messages, 1) + assert.Equal(t, messages[0].Type, spi.IssuerOIDCInteractionInitiated) + + return nil + }) + + kmsConfig := &vcskms.Config{ + KMSType: vcskms.AWS, + Endpoint: "example.com", + Region: "us-central-1", + AliasPrefix: "AliasPrefix", + SecretLockKeyPath: "SecretLockKeyPath", + DBType: "DBType", + DBURL: "DBURL", + DBPrefix: "DBPrefix", + } + + kmsRegistry.EXPECT().GetKeyManager(kmsConfig).Return(nil, nil) + + cryptoJWTSigner.EXPECT().NewJWTSigned(gomock.Any(), gomock.Any()). + DoAndReturn(func(claims interface{}, signerData *vc.Signer) (string, error) { + assert.Equal(t, &vc.Signer{ + KeyType: "ECDSASecp256k1DER", + KMSKeyID: "", + KMS: nil, + SignatureType: "JsonWebSignature2020", + Creator: "", + }, signerData) + + credentialOfferClaims, ok := claims.(*oidc4ci.JWTCredentialOfferClaims) + assert.True(t, ok) + + assert.Equal(t, "did:orb:anything", credentialOfferClaims.Issuer) + assert.Equal(t, "did:orb:anything", credentialOfferClaims.Subject) + assert.False(t, credentialOfferClaims.IssuedAt.Time().IsZero()) + + return mockSignedCredentialOfferJWT, nil + }) + + issuanceReq = &oidc4ci.InitiateIssuanceRequest{ + CredentialTemplateID: "templateID", + ClientWellKnownURL: walletWellKnownURL, + ClaimEndpoint: "https://vcs.pb.example.com/claim", + OpState: "eyJhbGciOiJSU0Et", + } + + profileSignedCredentialOfferSupported := testProfile + profileSignedCredentialOfferSupported.KMSConfig = kmsConfig + profileSignedCredentialOfferSupported.OIDCConfig = &profileapi.OIDCConfig{ + SignedCredentialOfferSupported: true, + } + + profile = &profileSignedCredentialOfferSupported + }, + check: func(t *testing.T, resp *oidc4ci.InitiateIssuanceResponse, err error) { + require.NoError(t, err) + require.Contains(t, resp.InitiateIssuanceURL, + "https://wallet.example.com/initiate_issuance?"+ + "credential_offer_uri=https%3A%2F%2Fremote_url%2Ffile.jwt") + require.Equal(t, oidc4ci.ContentTypeApplicationJWT, resp.ContentType) + }, + }, + { + name: "JWT enabled - KMS registry error", + setup: func() { + referenceStore = NewMockCredentialOfferReferenceStore(gomock.NewController(t)) + mockWellKnownService = NewMockWellKnownService(gomock.NewController(t)) + cryptoJWTSigner = NewMockCryptoJWTSigner(gomock.NewController(t)) + + mockTransactionStore.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func( + ctx context.Context, + data *oidc4ci.TransactionData, + params ...func(insertOptions *oidc4ci.InsertOptions), + ) (*oidc4ci.Transaction, error) { + assert.Equal(t, oidc4ci.TransactionStateIssuanceInitiated, data.State) + + return &oidc4ci.Transaction{ + ID: "txID", + TransactionData: oidc4ci.TransactionData{ + CredentialFormat: verifiable.Jwt, + CredentialTemplate: &profileapi.CredentialTemplate{ + ID: "templateID", + }, + }, + }, nil + }) + + eventService.EXPECT().Publish(gomock.Any(), spi.IssuerEventTopic, gomock.Any()). + DoAndReturn(func(ctx context.Context, topic string, messages ...*spi.Event) error { + assert.Len(t, messages, 1) + assert.Equal(t, messages[0].Type, spi.IssuerOIDCInteractionInitiated) + + return nil + }) + + kmsRegistry.EXPECT().GetKeyManager(gomock.Any()).Return(nil, errors.New("some error")) + + issuanceReq = &oidc4ci.InitiateIssuanceRequest{ + CredentialTemplateID: "templateID", + ClientWellKnownURL: walletWellKnownURL, + ClaimEndpoint: "https://vcs.pb.example.com/claim", + OpState: "eyJhbGciOiJSU0Et", + } + + profileSignedCredentialOfferSupported := testProfile + profileSignedCredentialOfferSupported.OIDCConfig = &profileapi.OIDCConfig{ + SignedCredentialOfferSupported: true, + } + + profile = &profileSignedCredentialOfferSupported + }, + check: func(t *testing.T, resp *oidc4ci.InitiateIssuanceResponse, err error) { + require.Nil(t, resp) + require.ErrorContains(t, err, "get kms:") + }, + }, + { + name: "JWT enabled - crypto signer error", + setup: func() { + referenceStore = NewMockCredentialOfferReferenceStore(gomock.NewController(t)) + mockWellKnownService = NewMockWellKnownService(gomock.NewController(t)) + + mockTransactionStore.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func( + ctx context.Context, + data *oidc4ci.TransactionData, + params ...func(insertOptions *oidc4ci.InsertOptions), + ) (*oidc4ci.Transaction, error) { + assert.Equal(t, oidc4ci.TransactionStateIssuanceInitiated, data.State) + + return &oidc4ci.Transaction{ + ID: "txID", + TransactionData: oidc4ci.TransactionData{ + CredentialFormat: verifiable.Jwt, + CredentialTemplate: &profileapi.CredentialTemplate{ + ID: "templateID", + }, + }, + }, nil + }) + + eventService.EXPECT().Publish(gomock.Any(), spi.IssuerEventTopic, gomock.Any()). + DoAndReturn(func(ctx context.Context, topic string, messages ...*spi.Event) error { + assert.Len(t, messages, 1) + assert.Equal(t, messages[0].Type, spi.IssuerOIDCInteractionInitiated) + + return nil + }) + + kmsConfig := &vcskms.Config{ + KMSType: vcskms.AWS, + Endpoint: "example.com", + Region: "us-central-1", + AliasPrefix: "AliasPrefix", + SecretLockKeyPath: "SecretLockKeyPath", + DBType: "DBType", + DBURL: "DBURL", + DBPrefix: "DBPrefix", + } + + kmsRegistry.EXPECT().GetKeyManager(kmsConfig).Return(nil, nil) + + cryptoJWTSigner.EXPECT().NewJWTSigned(gomock.Any(), gomock.Any()).Return("", errors.New("some error")) + + issuanceReq = &oidc4ci.InitiateIssuanceRequest{ + CredentialTemplateID: "templateID", + ClientWellKnownURL: walletWellKnownURL, + ClaimEndpoint: "https://vcs.pb.example.com/claim", + OpState: "eyJhbGciOiJSU0Et", + } + + profileSignedCredentialOfferSupported := testProfile + profileSignedCredentialOfferSupported.KMSConfig = kmsConfig + profileSignedCredentialOfferSupported.OIDCConfig = &profileapi.OIDCConfig{ + SignedCredentialOfferSupported: true, + } + + profile = &profileSignedCredentialOfferSupported + }, + check: func(t *testing.T, resp *oidc4ci.InitiateIssuanceResponse, err error) { + require.Nil(t, resp) + require.ErrorContains(t, err, "sign credential offer:") + }, + }, } for _, tt := range tests { @@ -1094,6 +1324,8 @@ func TestService_InitiateIssuanceWithRemoteStore(t *testing.T) { EventService: eventService, PinGenerator: pinGenerator, CredentialOfferReferenceStore: referenceStore, + KMSRegistry: kmsRegistry, + CryptoJWTSigner: cryptoJWTSigner, EventTopic: spi.IssuerEventTopic, }) require.NoError(t, err) diff --git a/pkg/storage/s3/credentialoffer/credential_offer_store.go b/pkg/storage/s3/credentialoffer/credential_offer_store.go index 9fe2778b0..2b63520f9 100644 --- a/pkg/storage/s3/credentialoffer/credential_offer_store.go +++ b/pkg/storage/s3/credentialoffer/credential_offer_store.go @@ -61,9 +61,23 @@ func (p *Store) Create( return "", err } + return p.put(ctx, data) +} + +func (p *Store) CreateJWT( + ctx context.Context, + credentialOfferJWT string, +) (string, error) { + return p.put(ctx, []byte(credentialOfferJWT)) +} + +func (p *Store) put( + ctx context.Context, + data []byte, +) (string, error) { key := fmt.Sprintf("%v.jwt", uuid.NewString()) - _, err = p.s3Client.PutObject(ctx, &s3.PutObjectInput{ + _, err := p.s3Client.PutObject(ctx, &s3.PutObjectInput{ Body: bytes.NewReader(data), Key: aws.String(key), Bucket: aws.String(p.bucket), diff --git a/pkg/storage/s3/credentialoffer/credential_offer_store_test.go b/pkg/storage/s3/credentialoffer/credential_offer_store_test.go index 4b138beeb..974ab67d4 100644 --- a/pkg/storage/s3/credentialoffer/credential_offer_store_test.go +++ b/pkg/storage/s3/credentialoffer/credential_offer_store_test.go @@ -23,7 +23,7 @@ import ( "github.com/trustbloc/vcs/pkg/storage/s3/credentialoffer" ) -func TestNewStore(t *testing.T) { +func TestCreate(t *testing.T) { req := &oidc4ci.CredentialOfferResponse{ CredentialIssuer: "https://localhost", } @@ -77,3 +77,48 @@ func TestNewStore(t *testing.T) { assert.Empty(t, finalURL) }) } + +func TestCreateJWT(t *testing.T) { + const mockSignedCredentialOfferJWT = "aa.bb.cc" + + t.Run("success", func(t *testing.T) { + up := NewMockS3Uploader(gomock.NewController(t)) + s := credentialoffer.NewStore(up, "a", "b", "") + + key := "" + up.EXPECT().PutObject(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func( + ctx context.Context, + input *s3.PutObjectInput, + opts ...func(*s3.Options), + ) (*s3.PutObjectOutput, error) { + key = *input.Key + b, err := io.ReadAll(input.Body) + assert.NoError(t, err) + + assert.Equal(t, mockSignedCredentialOfferJWT, string(b)) + + assert.Equal(t, "a", *input.Bucket) + assert.NotEmpty(t, *input.Key) + assert.Equal(t, "application/json", *input.ContentType) + + return &s3.PutObjectOutput{}, nil + }) + + finalURL, err := s.CreateJWT(context.TODO(), mockSignedCredentialOfferJWT) + assert.NoError(t, err) + assert.Contains(t, finalURL, fmt.Sprintf("https://a.s3.b.amazonaws.com/%v", key)) + assert.True(t, strings.HasSuffix(key, ".jwt")) + }) + + t.Run("err upload", func(t *testing.T) { + up := NewMockS3Uploader(gomock.NewController(t)) + s := credentialoffer.NewStore(up, "a", "b", "") + + up.EXPECT().PutObject(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("upload err")) + + finalURL, err := s.CreateJWT(context.TODO(), mockSignedCredentialOfferJWT) + assert.ErrorContains(t, err, "upload err") + assert.Empty(t, finalURL) + }) +} diff --git a/test/bdd/fixtures/profile/profiles.json b/test/bdd/fixtures/profile/profiles.json index 6e7933a0d..0f415ba35 100644 --- a/test/bdd/fixtures/profile/profiles.json +++ b/test/bdd/fixtures/profile/profiles.json @@ -660,6 +660,7 @@ "enable_dynamic_client_registration": true, "pre-authorized_grant_anonymous_access_supported": true, "wallet_initiated_auth_flow_supported": true, + "signed_credential_offer_supported": true, "claims_endpoint": "https://mock-login-consent.example.com:8099/claim-data?credentialType=CrudeProductCredential" }, "credentialTemplates": [