diff --git a/cmd/vc-rest/startcmd/start.go b/cmd/vc-rest/startcmd/start.go index 5ceec41f7..075e79416 100644 --- a/cmd/vc-rest/startcmd/start.go +++ b/cmd/vc-rest/startcmd/start.go @@ -618,6 +618,20 @@ func buildEchoHandler( issueCredentialSvc = issuecredentialtracing.Wrap(issueCredentialSvc, conf.Tracer) } + var verifyCredentialSvc verifycredential.ServiceInterface + + verifyCredentialSvc = verifycredential.New(&verifycredential.Config{ + HTTPClient: getHTTPClient(metricsProvider.ClientCredentialVerifier), + VCStatusProcessorGetter: statustype.GetVCStatusProcessor, + StatusListVCResolver: statusListVCSvc, + DocumentLoader: documentLoader, + VDR: conf.VDR, + }) + + if conf.IsTraceEnabled { + verifyCredentialSvc = verifycredentialtracing.Wrap(verifyCredentialSvc, conf.Tracer) + } + oidc4ciTransactionStore, err := getOIDC4CITransactionStore( conf.StartupParameters.transientDataParams.storeType, redisClient, @@ -668,9 +682,14 @@ func buildEchoHandler( jsonSchemaValidator := jsonschema.NewCachingValidator() + proofChecker := defaults.NewDefaultProofChecker(vermethod.NewVDRResolver(conf.VDR)) + attestationService := attestation.NewService( &attestation.Config{ - HTTPClient: getHTTPClient(metricsProvider.ClientAttestationService), + HTTPClient: getHTTPClient(metricsProvider.ClientAttestationService), + DocumentLoader: documentLoader, + ProofChecker: proofChecker, + VCStatusVerifier: verifyCredentialSvc, }, ) @@ -790,7 +809,7 @@ func buildEchoHandler( IssuerVCSPublicHost: conf.StartupParameters.apiGatewayURL, // use api gateway here, as this endpoint will be called by clients HTTPClient: getHTTPClient(metricsProvider.ClientOIDC4CIV1), ExternalHostURL: conf.StartupParameters.hostURLExternal, // use host external as this url will be called internally - JWTVerifier: defaults.NewDefaultProofChecker(vermethod.NewVDRResolver(conf.VDR)), + JWTVerifier: proofChecker, ClientManager: clientManagerService, ClientIDSchemeService: clientIDSchemeSvc, Tracer: conf.Tracer, @@ -829,20 +848,6 @@ func buildEchoHandler( return nil, err } - var verifyCredentialSvc verifycredential.ServiceInterface - - verifyCredentialSvc = verifycredential.New(&verifycredential.Config{ - HTTPClient: getHTTPClient(metricsProvider.ClientCredentialVerifier), - VCStatusProcessorGetter: statustype.GetVCStatusProcessor, - StatusListVCResolver: statusListVCSvc, - DocumentLoader: documentLoader, - VDR: conf.VDR, - }) - - if conf.IsTraceEnabled { - verifyCredentialSvc = verifycredentialtracing.Wrap(verifyCredentialSvc, conf.Tracer) - } - var verifyPresentationSvc verifypresentation.ServiceInterface verifyPresentationSvc = verifypresentation.New(&verifypresentation.Config{ diff --git a/pkg/service/attestation/api.go b/pkg/service/attestation/api.go index 7e65708e0..23bca25e7 100644 --- a/pkg/service/attestation/api.go +++ b/pkg/service/attestation/api.go @@ -13,4 +13,5 @@ import "context" type ServiceInterface interface { ValidateClientAttestationJWT(ctx context.Context, clientID, clientAttestationJWT string) error ValidateClientAttestationPoPJWT(ctx context.Context, clientID, clientAttestationPoPJWT string) error + ValidateClientAttestationVP(ctx context.Context, clientID, jwtVP string) error } diff --git a/pkg/service/attestation/attestation_service.go b/pkg/service/attestation/attestation_service.go index d20ca0419..e792095d4 100644 --- a/pkg/service/attestation/attestation_service.go +++ b/pkg/service/attestation/attestation_service.go @@ -4,43 +4,116 @@ Copyright Gen Digital Inc. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ -//go:generate mockgen -destination attestation_service_mocks_test.go -package attestation_test -source=attestation_service.go -mock_names httpClient=MockHTTPClient +//go:generate mockgen -destination attestation_service_mocks_test.go -package attestation_test -source=attestation_service.go -mock_names httpClient=MockHTTPClient,vcStatusVerifier=MockVCStatusVerifier + package attestation import ( "context" + "fmt" "net/http" + "time" + + "github.com/piprate/json-gold/ld" + "github.com/trustbloc/vc-go/verifiable" ) type httpClient interface { Do(req *http.Request) (*http.Response, error) } +type vcStatusVerifier interface { + ValidateVCStatus(ctx context.Context, vcStatus *verifiable.TypedID, issuer *verifiable.Issuer) error +} + // Config defines configuration for Service. type Config struct { - HTTPClient httpClient + HTTPClient httpClient + DocumentLoader ld.DocumentLoader + ProofChecker verifiable.CombinedProofChecker + VCStatusVerifier vcStatusVerifier } // Service implements attestation functionality for OAuth 2.0 Attestation-Based Client Authentication. type Service struct { - httpClient httpClient + httpClient httpClient + documentLoader ld.DocumentLoader + proofChecker verifiable.CombinedProofChecker + vcStatusVerifier vcStatusVerifier } // NewService returns a new Service instance. func NewService(config *Config) *Service { return &Service{ - httpClient: config.HTTPClient, + httpClient: config.HTTPClient, + documentLoader: config.DocumentLoader, + proofChecker: config.ProofChecker, + vcStatusVerifier: config.VCStatusVerifier, } } +// ValidateClientAttestationJWT validates Client Attestation JWT. +// //nolint:revive func (s *Service) ValidateClientAttestationJWT(ctx context.Context, clientID, clientAttestationJWT string) error { // TODO: Validate Client Attestation JWT and check the status of Attestation VC. + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-attestation-based-client-auth-01#section-4.1.1 return nil } +// ValidateClientAttestationPoPJWT validates Client Attestation Proof-of-Possession JWT. +// //nolint:revive func (s *Service) ValidateClientAttestationPoPJWT(ctx context.Context, clientID, clientAttestationPoPJWT string) error { // TODO: Validate Client Attestation Proof of Possession (PoP) JWT. + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-attestation-based-client-auth-01#section-4.1.2 + return nil +} + +// ValidateClientAttestationVP validates Client Attestation VP in jwt_vp format. +// +//nolint:revive +func (s *Service) ValidateClientAttestationVP(ctx context.Context, clientID, jwtVP string) error { + vp, err := verifiable.ParsePresentation( + []byte(jwtVP), + verifiable.WithPresProofChecker(s.proofChecker), + verifiable.WithPresJSONLDDocumentLoader(s.documentLoader), + ) + if err != nil { + return fmt.Errorf("parse attestation vp: %w", err) + } + + if len(vp.Credentials()) == 0 { + return fmt.Errorf("missing attestation vc") + } + + attestationVC := vp.Credentials()[0] + + // validate attestation vc + opts := []verifiable.CredentialOpt{ + verifiable.WithProofChecker(s.proofChecker), + verifiable.WithJSONLDDocumentLoader(s.documentLoader), + } + + if err = attestationVC.ValidateCredential(opts...); err != nil { + return fmt.Errorf("validate attestation vc: %w", err) + } + + if err = attestationVC.CheckProof(opts...); err != nil { + return fmt.Errorf("check attestation vc proof: %w", err) + } + + vcc := attestationVC.Contents() + if vcc.Expired != nil && time.Now().UTC().After(vcc.Expired.Time) { + return fmt.Errorf("attestation vc is expired") + } + + // check attestation vc status + if err = s.vcStatusVerifier.ValidateVCStatus(ctx, vcc.Status, vcc.Issuer); err != nil { + return fmt.Errorf("validate attestation vc status: %w", err) + } + + // TODO: validate attestation vc in trust registry + return nil } diff --git a/pkg/service/attestation/attestation_service_test.go b/pkg/service/attestation/attestation_service_test.go index 48ca9f1fc..afd0b0cc5 100644 --- a/pkg/service/attestation/attestation_service_test.go +++ b/pkg/service/attestation/attestation_service_test.go @@ -8,14 +8,33 @@ package attestation_test import ( "context" + "errors" "testing" + "time" "github.com/golang/mock/gomock" + "github.com/google/uuid" "github.com/stretchr/testify/require" + utiltime "github.com/trustbloc/did-go/doc/util/time" + "github.com/trustbloc/kms-go/doc/jose" + "github.com/trustbloc/kms-go/spi/kms" + "github.com/trustbloc/vc-go/jwt" + "github.com/trustbloc/vc-go/proof/checker" + "github.com/trustbloc/vc-go/proof/testsupport" + "github.com/trustbloc/vc-go/verifiable" + "github.com/trustbloc/vcs/pkg/internal/testutil" "github.com/trustbloc/vcs/pkg/service/attestation" ) +const ( + attestationDID = "did:example:attestation-service" + attestationKeyID = "did:example:attestation-service#attestation-key-id" + + walletDID = "did:example:wallet" + walletKeyID = "did:example:wallet#wallet-key-id" +) + func TestService_ValidateClientAttestationJWT(t *testing.T) { httpClient := NewMockHTTPClient(gomock.NewController(t)) @@ -79,3 +98,220 @@ func TestService_ValidateClientAttestationPoPJWT(t *testing.T) { }) } } + +func TestService_ValidateClientAttestationVP(t *testing.T) { + httpClient := NewMockHTTPClient(gomock.NewController(t)) + vcStatusVerifier := NewMockVCStatusVerifier(gomock.NewController(t)) + + var clientID, jwtVP string + + proofCreators, defaultProofChecker := testsupport.NewKMSSignersAndVerifier(t, + []testsupport.SigningKey{ + { + Type: kms.ECDSAP256TypeDER, + PublicKeyID: attestationKeyID, + }, + { + Type: kms.ECDSAP256TypeDER, + PublicKeyID: walletKeyID, + }, + }, + ) + + attestationProofCreator := proofCreators[0] + walletProofCreator := proofCreators[1] + + var proofChecker *checker.ProofChecker + + tests := []struct { + name string + setup func() + check func(t *testing.T, err error) + }{ + { + name: "success", + setup: func() { + // create wallet attestation VC with wallet DID as subject and attestation DID as issuer + attestationVC := createAttestationVC(t, attestationProofCreator, false) + + // prepare wallet attestation VP (in jwt_vp format) signed by wallet DID + jwtVP = createAttestationVP(t, attestationVC, walletProofCreator) + + proofChecker = defaultProofChecker + + vcStatusVerifier.EXPECT().ValidateVCStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + }, + check: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "fail to parse attestation vp", + setup: func() { + attestationVC := createAttestationVC(t, attestationProofCreator, false) + + jwtVP = createAttestationVP(t, attestationVC, + &mockProofCreator{ + SignJWTFunc: func(params jwt.SignParameters, data []byte) ([]byte, error) { + return []byte("invalid signature"), nil + }, + }, + ) + + proofChecker = defaultProofChecker + + vcStatusVerifier.EXPECT().ValidateVCStatus(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + }, + check: func(t *testing.T, err error) { + require.ErrorContains(t, err, "parse attestation vp") + }, + }, + { + name: "missing attestation vc", + setup: func() { + jwtVP = createAttestationVP(t, nil, walletProofCreator) + + proofChecker = defaultProofChecker + + vcStatusVerifier.EXPECT().ValidateVCStatus(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + }, + check: func(t *testing.T, err error) { + require.ErrorContains(t, err, "missing attestation vc") + }, + }, + { + name: "attestation vc is expired", + setup: func() { + attestationVC := createAttestationVC(t, attestationProofCreator, true) + + jwtVP = createAttestationVP(t, attestationVC, walletProofCreator) + + proofChecker = defaultProofChecker + + vcStatusVerifier.EXPECT().ValidateVCStatus(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + }, + check: func(t *testing.T, err error) { + require.ErrorContains(t, err, "attestation vc is expired") + }, + }, + { + name: "fail to check attestation vc status", + setup: func() { + attestationVC := createAttestationVC(t, attestationProofCreator, false) + + jwtVP = createAttestationVP(t, attestationVC, walletProofCreator) + + proofChecker = defaultProofChecker + + vcStatusVerifier.EXPECT().ValidateVCStatus(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New("validate status error")) + }, + check: func(t *testing.T, err error) { + require.ErrorContains(t, err, "validate attestation vc status") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + + tt.check(t, + attestation.NewService( + &attestation.Config{ + HTTPClient: httpClient, + DocumentLoader: testutil.DocumentLoader(t), + ProofChecker: proofChecker, + VCStatusVerifier: vcStatusVerifier, + }, + ).ValidateClientAttestationVP(context.Background(), clientID, jwtVP), + ) + }) + } +} + +func createAttestationVC(t *testing.T, proofCreator jwt.ProofCreator, isExpired bool) *verifiable.Credential { + t.Helper() + + vcc := verifiable.CredentialContents{ + Context: []string{ + verifiable.ContextURI, + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json", + }, + ID: uuid.New().String(), + Types: []string{ + verifiable.VCType, + }, + Subject: []verifiable.Subject{ + { + ID: walletDID, + }, + }, + Issuer: &verifiable.Issuer{ + ID: attestationDID, + }, + Issued: &utiltime.TimeWrapper{ + Time: time.Now(), + }, + } + + if isExpired { + vcc.Expired = &utiltime.TimeWrapper{ + Time: time.Now().Add(-1 * time.Hour), + } + } + + vc, err := verifiable.CreateCredential(vcc, nil) + require.NoError(t, err) + + jwtVC, err := vc.CreateSignedJWTVC( + false, + verifiable.ECDSASecp256r1, + proofCreator, + attestationKeyID, + ) + require.NoError(t, err) + + return jwtVC +} + +func createAttestationVP( + t *testing.T, + attestationVC *verifiable.Credential, + proofCreator jwt.ProofCreator, +) string { + t.Helper() + + vp, err := verifiable.NewPresentation() + require.NoError(t, err) + + if attestationVC != nil { + vp.AddCredentials(attestationVC) + } + + vp.ID = uuid.New().String() + + claims, err := vp.JWTClaims([]string{}, false) + require.NoError(t, err) + + jwsAlgo, err := verifiable.KeyTypeToJWSAlgo(kms.ECDSAP256TypeDER) + require.NoError(t, err) + + jws, err := claims.MarshalJWS(jwsAlgo, proofCreator, walletKeyID) + require.NoError(t, err) + + return jws +} + +type mockProofCreator struct { + SignJWTFunc func(params jwt.SignParameters, data []byte) ([]byte, error) +} + +func (m *mockProofCreator) SignJWT(params jwt.SignParameters, data []byte) ([]byte, error) { + return m.SignJWTFunc(params, data) +} + +func (m *mockProofCreator) CreateJWTHeaders(_ jwt.SignParameters) (jose.Headers, error) { + return map[string]interface{}{ + jose.HeaderAlgorithm: "ES256", + }, nil +} diff --git a/pkg/service/oidc4ci/oidc4ci_service.go b/pkg/service/oidc4ci/oidc4ci_service.go index b4c587527..6e05126be 100644 --- a/pkg/service/oidc4ci/oidc4ci_service.go +++ b/pkg/service/oidc4ci/oidc4ci_service.go @@ -121,6 +121,7 @@ type jsonSchemaValidator interface { type attestationService interface { ValidateClientAttestationJWT(ctx context.Context, clientID, clientAttestationJWT string) error ValidateClientAttestationPoPJWT(ctx context.Context, clientID, clientAttestationPoPJWT string) error + ValidateClientAttestationVP(ctx context.Context, clientID, jwtVP string) error } // Config holds configuration options and dependencies for Service. diff --git a/pkg/service/oidc4ci/oidc4ci_service_authenticate_client.go b/pkg/service/oidc4ci/oidc4ci_service_authenticate_client.go index 88518834a..b8b35b043 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_authenticate_client.go +++ b/pkg/service/oidc4ci/oidc4ci_service_authenticate_client.go @@ -17,10 +17,7 @@ import ( "github.com/trustbloc/vcs/pkg/restapi/resterr" ) -const ( - attestJWTClientAuthType = "attest_jwt_client_auth" - attestJWTClientAuthJWTCount = 2 -) +const attestJWTClientAuthType = "attest_jwt_client_auth" func (s *Service) AuthenticateClient( ctx context.Context, @@ -45,18 +42,23 @@ func (s *Service) AuthenticateClient( jwts := strings.Split(clientAssertion, "~") - if len(jwts) != attestJWTClientAuthJWTCount { + switch { + case len(jwts) == 1 && jwts[0] != "": + if err := s.attestationService.ValidateClientAttestationVP(ctx, clientID, jwts[0]); err != nil { + return resterr.NewCustomError(resterr.OIDCClientAuthenticationFailed, err) + } + case len(jwts) == 2 && jwts[0] != "" && jwts[1] != "": + if err := s.attestationService.ValidateClientAttestationJWT(ctx, clientID, jwts[0]); err != nil { + return resterr.NewCustomError(resterr.OIDCClientAuthenticationFailed, err) + } + + if err := s.attestationService.ValidateClientAttestationPoPJWT(ctx, clientID, jwts[1]); err != nil { + return resterr.NewCustomError(resterr.OIDCClientAuthenticationFailed, err) + } + default: return resterr.NewCustomError(resterr.OIDCClientAuthenticationFailed, errors.New("invalid client assertion format")) } - if err := s.attestationService.ValidateClientAttestationJWT(ctx, clientID, jwts[0]); err != nil { - return resterr.NewCustomError(resterr.OIDCClientAuthenticationFailed, err) - } - - if err := s.attestationService.ValidateClientAttestationPoPJWT(ctx, clientID, jwts[1]); err != nil { - return resterr.NewCustomError(resterr.OIDCClientAuthenticationFailed, err) - } - return nil } diff --git a/pkg/service/oidc4ci/oidc4ci_service_authenticate_client_test.go b/pkg/service/oidc4ci/oidc4ci_service_authenticate_client_test.go index a4bf9b557..4ae86e663 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_authenticate_client_test.go +++ b/pkg/service/oidc4ci/oidc4ci_service_authenticate_client_test.go @@ -33,7 +33,7 @@ func TestService_AuthenticateClient(t *testing.T) { check func(t *testing.T, err error) }{ { - name: "success", + name: "success with client attestation jwt and client attestation pop jwt", setup: func() { profile = &profileapi.Issuer{ OIDCConfig: &profileapi.OIDCConfig{ @@ -62,6 +62,31 @@ func TestService_AuthenticateClient(t *testing.T) { require.NoError(t, err) }, }, + { + name: "success with client attestation vp", + setup: func() { + profile = &profileapi.Issuer{ + OIDCConfig: &profileapi.OIDCConfig{ + TokenEndpointAuthMethodsSupported: []string{"attest_jwt_client_auth"}, + }, + } + + clientID = "client-id" + clientAssertionType = "attest_jwt_client_auth" + clientAssertion = "client-attestation-vp" + + attestationService = NewMockAttestationService(gomock.NewController(t)) + + attestationService.EXPECT().ValidateClientAttestationVP( + gomock.Any(), + clientID, + "client-attestation-vp", + ).Return(nil) + }, + check: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, { name: "attest_jwt_client_auth not supported by profile", setup: func() { @@ -163,7 +188,7 @@ func TestService_AuthenticateClient(t *testing.T) { clientID = "client-id" clientAssertionType = "attest_jwt_client_auth" - clientAssertion = "invalid_assertion_format" + clientAssertion = "invalid~assertion~format" attestationService = NewMockAttestationService(gomock.NewController(t))