From d89238187dc9350060058184553f9439fe135c29 Mon Sep 17 00:00:00 2001 From: Gerard Snaauw <33763579+gerardsn@users.noreply.github.com> Date: Thu, 14 Dec 2023 13:46:27 +0100 Subject: [PATCH 1/6] split signature verification from verifier (#2683) * split signature verification from verifier * pr feedback --- jsonld/test.go | 2 +- vcr/holder/wallet.go | 2 +- vcr/holder/wallet_test.go | 6 +- vcr/signature/proof/jsonld.go | 3 + vcr/signature/proof/proof.go | 3 +- vcr/store.go | 2 +- vcr/verifier/interface.go | 8 +- vcr/verifier/mock.go | 24 +- vcr/verifier/signature_verifier.go | 128 +++++++++++ vcr/verifier/signature_verifier_test.go | 235 ++++++++++++++++++++ vcr/verifier/verifier.go | 215 +++--------------- vcr/verifier/verifier_test.go | 278 +++--------------------- 12 files changed, 452 insertions(+), 454 deletions(-) create mode 100644 vcr/verifier/signature_verifier.go create mode 100644 vcr/verifier/signature_verifier_test.go diff --git a/jsonld/test.go b/jsonld/test.go index 996d40e95b..a4215eba14 100644 --- a/jsonld/test.go +++ b/jsonld/test.go @@ -150,7 +150,7 @@ func (t testContextManager) Configure(config Config) error { } // NewTestJSONLDManager creates a new test context manager which contains extra test contexts -func NewTestJSONLDManager(t *testing.T) JSONLD { +func NewTestJSONLDManager(t testing.TB) JSONLD { t.Helper() contextConfig := DefaultContextConfig() diff --git a/vcr/holder/wallet.go b/vcr/holder/wallet.go index d464077e1e..e383235c5d 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -89,7 +89,7 @@ func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.Verifiab if validateVC { for _, cred := range credentials { - err := h.verifier.Validate(cred, &options.ProofOptions.Created) + err := h.verifier.VerifySignature(cred, &options.ProofOptions.Created) if err != nil { return nil, core.InvalidInputError("invalid credential (id=%s): %w", cred.ID, err) } diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index 10c0521370..0401ec82b9 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -224,7 +224,7 @@ func TestWallet_BuildPresentation(t *testing.T) { keyResolver := resolver.NewMockKeyResolver(ctrl) mockVerifier := verifier.NewMockVerifier(ctrl) - mockVerifier.EXPECT().Validate(testCredential, &created) + mockVerifier.EXPECT().VerifySignature(testCredential, &created) keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) @@ -240,7 +240,7 @@ func TestWallet_BuildPresentation(t *testing.T) { keyResolver := resolver.NewMockKeyResolver(ctrl) mockVerifier := verifier.NewMockVerifier(ctrl) - mockVerifier.EXPECT().Validate(testCredential, &created).Return(errors.New("failed")) + mockVerifier.EXPECT().VerifySignature(testCredential, &created).Return(errors.New("failed")) keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) @@ -256,7 +256,7 @@ func TestWallet_BuildPresentation(t *testing.T) { keyResolver := resolver.NewMockKeyResolver(ctrl) mockVerifier := verifier.NewMockVerifier(ctrl) - mockVerifier.EXPECT().Validate(gomock.Any(), gomock.Any()) + mockVerifier.EXPECT().VerifySignature(gomock.Any(), gomock.Any()) keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) diff --git a/vcr/signature/proof/jsonld.go b/vcr/signature/proof/jsonld.go index ff5b71fd2b..f4914b9c00 100644 --- a/vcr/signature/proof/jsonld.go +++ b/vcr/signature/proof/jsonld.go @@ -80,6 +80,9 @@ func (o ProofOptions) ValidAt(at time.Time, maxSkew time.Duration) bool { return true } +var _ Proof = (*LDProof)(nil) +var _ ProofVerifier = (*LDProof)(nil) + // LDProof contains the fields of the Proof data model: https://w3c-ccg.github.io/data-integrity-spec/#proofs type LDProof struct { ProofOptions diff --git a/vcr/signature/proof/proof.go b/vcr/signature/proof/proof.go index 164b3d8a02..199b4b842a 100644 --- a/vcr/signature/proof/proof.go +++ b/vcr/signature/proof/proof.go @@ -19,6 +19,7 @@ package proof import ( + "context" "crypto" "encoding/json" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" @@ -70,7 +71,7 @@ func (d SignedDocument) UnmarshalProofValue(target interface{}) error { // Proof is the interface that defines a set of methods which a proof should implement. type Proof interface { // Sign defines the basic signing operation on the proof. - Sign(document Document, suite signature.Suite, key nutsCrypto.Key) (interface{}, error) + Sign(ctx context.Context, document Document, suite signature.Suite, key nutsCrypto.Key) (interface{}, error) } // ProofVerifier defines the generic verifier interface diff --git a/vcr/store.go b/vcr/store.go index e74c467711..2c1a12e8ea 100644 --- a/vcr/store.go +++ b/vcr/store.go @@ -57,7 +57,7 @@ func (c *vcr) StoreCredential(credential vc.VerifiableCredential, validAt *time. } // verify first - if err := c.verifier.Validate(credential, validAt); err != nil { + if err := c.verifier.VerifySignature(credential, validAt); err != nil { return err } diff --git a/vcr/verifier/interface.go b/vcr/verifier/interface.go index 793d92acf1..a1d9d40aaf 100644 --- a/vcr/verifier/interface.go +++ b/vcr/verifier/interface.go @@ -32,12 +32,12 @@ import ( // Verifier defines the interface for verifying verifiable credentials. type Verifier interface { // Verify checks credential on full correctness. It checks: - // validity of the signature + // validity of the signature (optional) // if it has been revoked - // if the issuer is registered as trusted + // if the issuer is registered as trusted (optional) Verify(credential vc.VerifiableCredential, allowUntrusted bool, checkSignature bool, validAt *time.Time) error - // Validate checks the verifiable credential technical correctness - Validate(credentialToVerify vc.VerifiableCredential, at *time.Time) error + // VerifySignature checks that the signature on the verifiable credential is correct and valid at the given time and nothing else + VerifySignature(credentialToVerify vc.VerifiableCredential, at *time.Time) error // IsRevoked checks if the credential is revoked IsRevoked(credentialID ssi.URI) (bool, error) // GetRevocation returns the first revocation by credential ID diff --git a/vcr/verifier/mock.go b/vcr/verifier/mock.go index 709e9acd4f..a0557a852d 100644 --- a/vcr/verifier/mock.go +++ b/vcr/verifier/mock.go @@ -86,32 +86,32 @@ func (mr *MockVerifierMockRecorder) RegisterRevocation(revocation any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterRevocation", reflect.TypeOf((*MockVerifier)(nil).RegisterRevocation), revocation) } -// Validate mocks base method. -func (m *MockVerifier) Validate(credentialToVerify vc.VerifiableCredential, at *time.Time) error { +// Verify mocks base method. +func (m *MockVerifier) Verify(credential vc.VerifiableCredential, allowUntrusted, checkSignature bool, validAt *time.Time) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Validate", credentialToVerify, at) + ret := m.ctrl.Call(m, "Verify", credential, allowUntrusted, checkSignature, validAt) ret0, _ := ret[0].(error) return ret0 } -// Validate indicates an expected call of Validate. -func (mr *MockVerifierMockRecorder) Validate(credentialToVerify, at any) *gomock.Call { +// Verify indicates an expected call of Verify. +func (mr *MockVerifierMockRecorder) Verify(credential, allowUntrusted, checkSignature, validAt any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockVerifier)(nil).Validate), credentialToVerify, at) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockVerifier)(nil).Verify), credential, allowUntrusted, checkSignature, validAt) } -// Verify mocks base method. -func (m *MockVerifier) Verify(credential vc.VerifiableCredential, allowUntrusted, checkSignature bool, validAt *time.Time) error { +// VerifySignature mocks base method. +func (m *MockVerifier) VerifySignature(credentialToVerify vc.VerifiableCredential, at *time.Time) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Verify", credential, allowUntrusted, checkSignature, validAt) + ret := m.ctrl.Call(m, "VerifySignature", credentialToVerify, at) ret0, _ := ret[0].(error) return ret0 } -// Verify indicates an expected call of Verify. -func (mr *MockVerifierMockRecorder) Verify(credential, allowUntrusted, checkSignature, validAt any) *gomock.Call { +// VerifySignature indicates an expected call of VerifySignature. +func (mr *MockVerifierMockRecorder) VerifySignature(credentialToVerify, at any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockVerifier)(nil).Verify), credential, allowUntrusted, checkSignature, validAt) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifySignature", reflect.TypeOf((*MockVerifier)(nil).VerifySignature), credentialToVerify, at) } // VerifyVP mocks base method. diff --git a/vcr/verifier/signature_verifier.go b/vcr/verifier/signature_verifier.go new file mode 100644 index 0000000000..434fa1bf34 --- /dev/null +++ b/vcr/verifier/signature_verifier.go @@ -0,0 +1,128 @@ +package verifier + +import ( + crypt "crypto" + "errors" + "fmt" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/types" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/issuer" + "github.com/nuts-foundation/nuts-node/vcr/signature" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vdr/resolver" +) + +type signatureVerifier struct { + keyResolver resolver.KeyResolver + jsonldManager jsonld.JSONLD +} + +// VerifySignature checks if the signature on a VP is valid at a given time +func (sv *signatureVerifier) VerifySignature(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error { + switch credentialToVerify.Format() { + case issuer.JSONLDCredentialFormat: + return sv.jsonldProof(credentialToVerify, credentialToVerify.Issuer.String(), validateAt) + case issuer.JWTCredentialFormat: + return sv.jwtSignature(credentialToVerify.Raw(), credentialToVerify.Issuer.String(), validateAt) + default: + return errors.New("unsupported credential proof format") + } +} + +// VerifyVPSignature checks if the signature on a VP is valid at a given time +func (sv *signatureVerifier) VerifyVPSignature(presentation vc.VerifiablePresentation, validateAt *time.Time) error { + signerDID, err := credential.PresentationSigner(presentation) + if err != nil { + return toVerificationError(err) + } + + switch presentation.Format() { + case issuer.JSONLDPresentationFormat: + return sv.jsonldProof(presentation, signerDID.String(), validateAt) + case issuer.JWTPresentationFormat: + return sv.jwtSignature(presentation.Raw(), signerDID.String(), validateAt) + default: + return errors.New("unsupported presentation proof format") + } +} + +// jsonldProof implements the Proof Verification Algorithm: https://w3c-ccg.github.io/data-integrity-spec/#proof-verification-algorithm +func (sv *signatureVerifier) jsonldProof(documentToVerify any, issuer string, at *time.Time) error { + signedDocument, err := proof.NewSignedDocument(documentToVerify) + if err != nil { + return newVerificationError("invalid LD-JSON document: %w", err) + } + + ldProof := proof.LDProof{} + if err = signedDocument.UnmarshalProofValue(&ldProof); err != nil { + return newVerificationError("unsupported proof type: %w", err) + } + + // for a VP this will not fail + verificationMethod := ldProof.VerificationMethod.String() + verificationMethodIssuer := strings.Split(verificationMethod, "#")[0] + if verificationMethodIssuer == "" || verificationMethodIssuer != issuer { + return errVerificationMethodNotOfIssuer + } + + // verify signing time + validAt := time.Now() + if at != nil { + validAt = *at + } + if !ldProof.ValidAt(validAt, maxSkew) { + return toVerificationError(types.ErrPresentationNotValidAtTime) + } + + // find key + signingKey, err := sv.keyResolver.ResolveKeyByID(ldProof.VerificationMethod.String(), at, resolver.NutsSigningKeyType) + if err != nil { + return fmt.Errorf("unable to resolve valid signing key: %w", err) + } + + // verify signature + err = ldProof.Verify(signedDocument.DocumentWithoutProof(), signature.JSONWebSignature2020{ContextLoader: sv.jsonldManager.DocumentLoader()}, signingKey) + if err != nil { + return newVerificationError("invalid signature: %w", err) + } + return nil +} + +func (sv *signatureVerifier) jwtSignature(jwtDocumentToVerify string, issuer string, at *time.Time) error { + var keyID string + _, err := crypto.ParseJWT(jwtDocumentToVerify, func(kid string) (crypt.PublicKey, error) { + keyID = kid + return sv.resolveSigningKey(kid, issuer, at) + }, jwt.WithClock(jwt.ClockFunc(func() time.Time { + if at == nil { + return time.Now() + } + return *at + }))) + if err != nil { + return fmt.Errorf("unable to validate JWT signature: %w", err) + } + if keyID != "" && strings.Split(keyID, "#")[0] != issuer { + return errVerificationMethodNotOfIssuer + } + return nil +} + +func (sv *signatureVerifier) resolveSigningKey(kid string, issuer string, at *time.Time) (crypt.PublicKey, error) { + // Compatibility: VC data model v1 puts key discovery out of scope and does not require the `kid` header. + // When `kid` isn't present use the JWT issuer as `kid`, then it is at least compatible with DID methods that contain a single verification method (did:jwk). + if kid == "" { + kid = issuer + } + if strings.HasPrefix(kid, "did:jwk:") && !strings.Contains(kid, "#") { + kid += "#0" + } + return sv.keyResolver.ResolveKeyByID(kid, at, resolver.NutsSigningKeyType) +} diff --git a/vcr/verifier/signature_verifier_test.go b/vcr/verifier/signature_verifier_test.go new file mode 100644 index 0000000000..6169923315 --- /dev/null +++ b/vcr/verifier/signature_verifier_test.go @@ -0,0 +1,235 @@ +package verifier + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "os" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/audit" + nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/storage/spi" + "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vdr/didjwk" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestSignatureVerifier_VerifySignature(t *testing.T) { + const testKID = "did:nuts:CuE3qeFGGLhEAS3gKzhMCeqd1dGa9at5JCbmCfyMU2Ey#sNGDQ3NlOe6Icv0E7_ufviOLG6Y25bSEyS5EbXBgp8Y" + + // load pub key + pke := spi.PublicKeyEntry{} + pkeJSON, _ := os.ReadFile("../test/public.json") + json.Unmarshal(pkeJSON, &pke) + var pk = new(ecdsa.PublicKey) + pke.JWK().Raw(pk) + + now := time.Now() + timeFunc = func() time.Time { + return now + } + defer func() { + timeFunc = time.Now + }() + + t.Run("JSON-LD", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + mockKeyResolver.EXPECT().ResolveKeyByID(testKID, gomock.Any(), resolver.NutsSigningKeyType).Return(pk, nil) + + err := sv.VerifySignature(testCredential(t), nil) + + assert.NoError(t, err) + }) + t.Run("JWT", func(t *testing.T) { + // Create did:jwk for issuer, and sign credential + keyStore := nutsCrypto.NewMemoryCryptoInstance() + key, err := keyStore.New(audit.TestContext(), func(key crypto.PublicKey) (string, error) { + keyAsJWK, _ := jwk.FromRaw(key) + keyJSON, _ := json.Marshal(keyAsJWK) + return "did:jwk:" + base64.RawStdEncoding.EncodeToString(keyJSON) + "#0", nil + }) + require.NoError(t, err) + + template := testCredential(t) + template.Issuer = did.MustParseDIDURL(key.KID()).DID.URI() + + cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + return keyStore.SignJWT(ctx, claims, headers, key) + }) + require.NoError(t, err) + + t.Run("with kid header", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + mockKeyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(key.Public(), nil) + err = sv.VerifySignature(*cred, nil) + + assert.NoError(t, err) + }) + t.Run("kid header does not match credential issuer", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + + cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + return keyStore.SignJWT(ctx, claims, headers, key) + }) + require.NoError(t, err) + cred.Issuer = ssi.MustParseURI("did:example:test") + + mockKeyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(key.Public(), nil) + err = sv.VerifySignature(*cred, nil) + + assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) + }) + t.Run("signature invalid", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + realKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + mockKeyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(realKey.Public(), nil) + + err = sv.VerifySignature(*cred, nil) + + assert.EqualError(t, err, "unable to validate JWT signature: could not verify message using any of the signatures or keys") + }) + t.Run("expired token", func(t *testing.T) { + // Credential taken from Sphereon Wallet, expires on Tue Oct 03 2023 + const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.wdhtLXE4jU1C-3YBBpP9-qE-yh1xOZ6lBLJ-0e5_Sa7fnrUHcAaU1n3kN2CeCyTVjtm1Uy3Tl6RzUOM6MjP3vQ` + cred, _ := vc.ParseVerifiableCredential(credentialJSON) + + sv := signatureVerifier{ + keyResolver: resolver.DIDKeyResolver{ + Resolver: didjwk.NewResolver(), + }, + } + err := sv.VerifySignature(*cred, nil) + + assert.EqualError(t, err, "unable to validate JWT signature: \"exp\" not satisfied") + }) + t.Run("without kid header, derived from issuer", func(t *testing.T) { + // Credential taken from Sphereon Wallet + const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.wdhtLXE4jU1C-3YBBpP9-qE-yh1xOZ6lBLJ-0e5_Sa7fnrUHcAaU1n3kN2CeCyTVjtm1Uy3Tl6RzUOM6MjP3vQ` + cred, _ := vc.ParseVerifiableCredential(credentialJSON) + + sv := signatureVerifier{ + keyResolver: resolver.DIDKeyResolver{ + Resolver: didjwk.NewResolver(), + }, + } + validAt := time.Date(2023, 9, 30, 0, 0, 0, 0, time.UTC) + err := sv.VerifySignature(*cred, &validAt) + + assert.NoError(t, err) + }) + }) + + t.Run("error - invalid vm", func(t *testing.T) { + sv, _ := signatureVerifierTestSetup(t) + + vc2 := testCredential(t) + pr := make([]vc.JSONWebSignature2020Proof, 0) + _ = vc2.UnmarshalProofValue(&pr) + u := ssi.MustParseURI(vc2.Issuer.String() + "2") + pr[0].VerificationMethod = u + vc2.Proof = []interface{}{pr[0]} + + err := sv.VerifySignature(vc2, nil) + + assert.Error(t, err) + assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) + }) + + t.Run("error - wrong hashed payload", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + vc2 := testCredential(t) + issuanceDate := time.Now() + vc2.IssuanceDate = &issuanceDate + + mockKeyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) + + err := sv.VerifySignature(vc2, nil) + + assert.ErrorContains(t, err, "failed to verify signature") + }) + + t.Run("error - wrong hashed proof", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + vc2 := testCredential(t) + pr := make([]vc.JSONWebSignature2020Proof, 0) + vc2.UnmarshalProofValue(&pr) + pr[0].Created = time.Now() + vc2.Proof = []interface{}{pr[0]} + + mockKeyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) + + err := sv.VerifySignature(vc2, nil) + + assert.ErrorContains(t, err, "failed to verify signature") + }) + + t.Run("error - no proof", func(t *testing.T) { + sv, _ := signatureVerifierTestSetup(t) + vc2 := testCredential(t) + vc2.Proof = []interface{}{} + + err := sv.VerifySignature(vc2, nil) + + assert.EqualError(t, err, "verification error: unsupported proof type: json: cannot unmarshal array into Go value of type proof.LDProof") + }) + + t.Run("error - wrong jws in proof", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + mockKeyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) + vc2 := testCredential(t) + pr := make([]vc.JSONWebSignature2020Proof, 0) + vc2.UnmarshalProofValue(&pr) + pr[0].Jws = "" + vc2.Proof = []interface{}{pr[0]} + + err := sv.VerifySignature(vc2, nil) + + assert.ErrorContains(t, err, "invalid 'jws' value in proof") + }) + + t.Run("error - wrong base64 encoding in jws", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + mockKeyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) + vc2 := testCredential(t) + pr := make([]vc.JSONWebSignature2020Proof, 0) + vc2.UnmarshalProofValue(&pr) + pr[0].Jws = "abac..ab//" + vc2.Proof = []interface{}{pr[0]} + + err := sv.VerifySignature(vc2, nil) + + assert.ErrorContains(t, err, "illegal base64 data") + }) + + t.Run("error - resolving key", func(t *testing.T) { + sv, mockKeyResolver := signatureVerifierTestSetup(t) + mockKeyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(nil, errors.New("b00m!")) + + err := sv.VerifySignature(testCredential(t), nil) + + assert.Error(t, err) + }) +} + +func signatureVerifierTestSetup(t testing.TB) (signatureVerifier, *resolver.MockKeyResolver) { + ctrl := gomock.NewController(t) + keyResolver := resolver.NewMockKeyResolver(ctrl) + return signatureVerifier{ + keyResolver: keyResolver, + jsonldManager: jsonld.NewTestJSONLDManager(t), + }, keyResolver +} diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 31df90d8be..418d3d9c1d 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -19,14 +19,9 @@ package verifier import ( - crypt "crypto" "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/nuts-foundation/nuts-node/crypto" - "github.com/nuts-foundation/nuts-node/vcr/issuer" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "strings" "time" @@ -39,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vcr/trust" "github.com/nuts-foundation/nuts-node/vcr/types" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) var timeFunc = time.Now @@ -58,6 +54,7 @@ type verifier struct { jsonldManager jsonld.JSONLD store Store trustConfig *trust.Config + signatureVerifier } // VerificationError is used to describe a VC/VP verification failure. @@ -86,86 +83,10 @@ func (e VerificationError) Error() string { // NewVerifier creates a new instance of the verifier. It needs a key resolver for validating signatures. func NewVerifier(store Store, didResolver resolver.DIDResolver, keyResolver resolver.KeyResolver, jsonldManager jsonld.JSONLD, trustConfig *trust.Config) Verifier { - return &verifier{store: store, didResolver: didResolver, keyResolver: keyResolver, jsonldManager: jsonldManager, trustConfig: trustConfig} -} - -// Validate implements the Proof Verification Algorithm: https://w3c-ccg.github.io/data-integrity-spec/#proof-verification-algorithm -func (v *verifier) Validate(credentialToVerify vc.VerifiableCredential, at *time.Time) error { - err := v.validateType(credentialToVerify) - if err != nil { - return err - } - - switch credentialToVerify.Format() { - case issuer.JSONLDCredentialFormat: - return v.validateJSONLDCredential(credentialToVerify, at) - case issuer.JWTCredentialFormat: - return v.validateJWTCredential(credentialToVerify, at) - default: - return errors.New("unsupported credential proof format") - } -} - -func (v *verifier) validateJSONLDCredential(credentialToVerify vc.VerifiableCredential, at *time.Time) error { - signedDocument, err := proof.NewSignedDocument(credentialToVerify) - if err != nil { - return fmt.Errorf("unable to build signed document from verifiable credential: %w", err) - } - - ldProof := proof.LDProof{} - if err := signedDocument.UnmarshalProofValue(&ldProof); err != nil { - return fmt.Errorf("unable to extract ldproof from signed document: %w", err) - } - - verificationMethod := ldProof.VerificationMethod.String() - verificationMethodIssuer := strings.Split(verificationMethod, "#")[0] - if verificationMethodIssuer == "" || verificationMethodIssuer != credentialToVerify.Issuer.String() { - return errVerificationMethodNotOfIssuer - } - - // find key - pk, err := v.keyResolver.ResolveKeyByID(ldProof.VerificationMethod.String(), at, resolver.NutsSigningKeyType) - if err != nil { - if at == nil { - return fmt.Errorf("unable to resolve signing key: %w", err) - } - return fmt.Errorf("unable to resolve valid signing key at given time: %w", err) - } - - // Try first with the correct LDProof implementation - return ldProof.Verify(signedDocument.DocumentWithoutProof(), signature.JSONWebSignature2020{ContextLoader: v.jsonldManager.DocumentLoader()}, pk) -} - -func (v *verifier) validateJWTCredential(credential vc.VerifiableCredential, at *time.Time) error { - var keyID string - _, err := crypto.ParseJWT(credential.Raw(), func(kid string) (crypt.PublicKey, error) { - keyID = kid - return v.resolveSigningKey(kid, credential.Issuer.String(), at) - }, jwt.WithClock(jwt.ClockFunc(func() time.Time { - if at == nil { - return time.Now() - } - return *at - }))) - if err != nil { - return fmt.Errorf("unable to validate JWT credential: %w", err) - } - if keyID != "" && strings.Split(keyID, "#")[0] != credential.Issuer.String() { - return errVerificationMethodNotOfIssuer - } - return nil -} - -func (v *verifier) resolveSigningKey(kid string, issuer string, at *time.Time) (crypt.PublicKey, error) { - // Compatibility: VC data model v1 puts key discovery out of scope and does not require the `kid` header. - // When `kid` isn't present use the JWT issuer as `kid`, then it is at least compatible with DID methods that contain a single verification method (did:jwk). - if kid == "" { - kid = issuer - } - if strings.HasPrefix(kid, "did:jwk:") && !strings.Contains(kid, "#") { - kid += "#0" - } - return v.keyResolver.ResolveKeyByID(kid, at, resolver.NutsSigningKeyType) + return &verifier{store: store, didResolver: didResolver, keyResolver: keyResolver, jsonldManager: jsonldManager, trustConfig: trustConfig, signatureVerifier: signatureVerifier{ + keyResolver: keyResolver, + jsonldManager: jsonldManager, + }} } // Verify implements the verify interface. @@ -176,14 +97,23 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus if err := validator.Validate(credentialToVerify); err != nil { return err } + // We only accept VCs with at most 2 types: "VerifiableCredential" and a specific type + // The Validate above already checks "VerifiableCredential" is one of them + // This is a custom requirement + if len(credentialToVerify.Type) > 2 { + return errors.New("verifiable credential must list at most 2 types") + } // Check revocation status - revoked, err := v.IsRevoked(*credentialToVerify.ID) - if err != nil { - return err - } - if revoked { - return types.ErrRevoked + if credentialToVerify.ID != nil { + revoked, err := v.IsRevoked(*credentialToVerify.ID) + if err != nil { + return err + } + if revoked { + return types.ErrRevoked + } + } // Check trust status @@ -199,7 +129,8 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } } - // Check issuance/expiration time + // Check issuance/expiration time of the credential + // if the signing key is valid at the given time is checked during signature verification validAtNotNil := time.Now() if validAt != nil { validAtNotNil = *validAt @@ -211,12 +142,11 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus // Check signature if checkSignature { issuerDID, _ := did.ParseDID(credentialToVerify.Issuer.String()) - _, _, err = v.didResolver.Resolve(*issuerDID, &resolver.ResolveMetadata{ResolveTime: validAt, AllowDeactivated: false}) + _, _, err := v.didResolver.Resolve(*issuerDID, &resolver.ResolveMetadata{ResolveTime: validAt, AllowDeactivated: false}) if err != nil { return fmt.Errorf("could not validate issuer: %w", err) } - - return v.Validate(credentialToVerify, validAt) + return v.VerifySignature(credentialToVerify, validAt) } return nil @@ -297,22 +227,22 @@ func (v verifier) VerifyVP(vp vc.VerifiablePresentation, verifyVCs bool, allowUn // doVerifyVP delegates VC verification to the supplied Verifier, to aid unit testing. func (v verifier) doVerifyVP(vcVerifier Verifier, presentation vc.VerifiablePresentation, verifyVCs bool, allowUntrustedVCs bool, validAt *time.Time) ([]vc.VerifiableCredential, error) { - var err error - switch presentation.Format() { - case issuer.JSONLDPresentationFormat: - err = v.validateJSONLDPresentation(presentation, validAt) - case issuer.JWTPresentationFormat: - err = v.validateJWTPresentation(presentation, validAt) - default: - err = errors.New("unsupported presentation proof format") + // custom requirement: credentials may only be presented by subject + if subjectDID, err := credential.PresenterIsCredentialSubject(presentation); err != nil { + return nil, newVerificationError("presenter is credential subject: %w", err) + } else if subjectDID == nil { + return nil, newVerificationError("credential(s) must be presented by subject") } + + // check signature + err := v.signatureVerifier.VerifyVPSignature(presentation, validAt) if err != nil { return nil, err } if verifyVCs { for _, current := range presentation.VerifiableCredential { - err := vcVerifier.Verify(current, allowUntrustedVCs, true, validAt) + err = vcVerifier.Verify(current, allowUntrustedVCs, true, validAt) if err != nil { return nil, newVerificationError("invalid VC (id=%s): %w", current.ID, err) } @@ -321,80 +251,3 @@ func (v verifier) doVerifyVP(vcVerifier Verifier, presentation vc.VerifiablePres return presentation.VerifiableCredential, nil } - -func (v *verifier) validateJSONLDPresentation(presentation vc.VerifiablePresentation, validAt *time.Time) error { - // Multiple proofs might be supported in the future, when there's an actual use case. - if len(presentation.Proof) != 1 { - return newVerificationError("exactly 1 proof is expected") - } - // Make sure the proofs are LD-proofs - ldProof, err := credential.ParseLDProof(presentation) - if err != nil { - return newVerificationError("unsupported proof type: %w", err) - } - - // Validate signing time - at := timeFunc() - if validAt != nil { - at = *validAt - } - if !ldProof.ValidAt(at, maxSkew) { - return toVerificationError(types.ErrPresentationNotValidAtTime) - } - - // Validate signature - signingKey, err := v.keyResolver.ResolveKeyByID(ldProof.VerificationMethod.String(), validAt, resolver.NutsSigningKeyType) - if err != nil { - return fmt.Errorf("unable to resolve valid signing key: %w", err) - } - signedDocument, err := proof.NewSignedDocument(presentation) - if err != nil { - return newVerificationError("invalid LD-JSON document: %w", err) - } - err = ldProof.Verify(signedDocument.DocumentWithoutProof(), signature.JSONWebSignature2020{ContextLoader: v.jsonldManager.DocumentLoader()}, signingKey) - if err != nil { - return newVerificationError("invalid signature: %w", err) - } - return nil -} - -func (v *verifier) validateJWTPresentation(presentation vc.VerifiablePresentation, at *time.Time) error { - var keyID string - if len(presentation.VerifiableCredential) != 1 { - return errors.New("exactly 1 credential in JWT VP is expected") - } - subjectDID, err := presentation.VerifiableCredential[0].SubjectDID() - if err != nil { - return err - } - _, err = crypto.ParseJWT(presentation.Raw(), func(kid string) (crypt.PublicKey, error) { - keyID = kid - return v.resolveSigningKey(kid, subjectDID.String(), at) - }, jwt.WithClock(jwt.ClockFunc(func() time.Time { - if at == nil { - return time.Now() - } - return *at - })), jwt.WithAcceptableSkew(maxSkew)) - if err != nil { - return fmt.Errorf("unable to validate JWT credential: %w", err) - } - if keyID != "" && strings.Split(keyID, "#")[0] != subjectDID.String() { - return errVerificationMethodNotOfIssuer - } - return nil -} - -func (v *verifier) validateType(credential vc.VerifiableCredential) error { - // VCs must contain 2 types: "VerifiableCredential" and specific type - if len(credential.Type) > 2 { - return errors.New("verifiable credential must list at most 2 types") - } - // "VerifiableCredential" should be one of the types - for _, curr := range credential.Type { - if curr == vc.VerifiableCredentialTypeV1URI() { - return nil - } - } - return fmt.Errorf("verifiable credential does not list '%s' as type", vc.VerifiableCredentialTypeV1URI()) -} diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 2b18e30c04..2c376c2043 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -19,18 +19,10 @@ package verifier import ( - "context" crypt "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "encoding/base64" "encoding/json" "errors" "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/crypto/storage/spi" - "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/require" "os" @@ -58,234 +50,6 @@ func testCredential(t *testing.T) vc.VerifiableCredential { return subject } -func Test_verifier_Validate(t *testing.T) { - const testKID = "did:nuts:CuE3qeFGGLhEAS3gKzhMCeqd1dGa9at5JCbmCfyMU2Ey#sNGDQ3NlOe6Icv0E7_ufviOLG6Y25bSEyS5EbXBgp8Y" - - // load pub key - pke := spi.PublicKeyEntry{} - pkeJSON, _ := os.ReadFile("../test/public.json") - json.Unmarshal(pkeJSON, &pke) - var pk = new(ecdsa.PublicKey) - pke.JWK().Raw(pk) - - now := time.Now() - timeFunc = func() time.Time { - return now - } - defer func() { - timeFunc = time.Now - }() - - t.Run("JSON-LD", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, gomock.Any(), resolver.NutsSigningKeyType).Return(pk, nil) - - err := instance.Validate(testCredential(t), nil) - - assert.NoError(t, err) - }) - t.Run("JWT", func(t *testing.T) { - // Create did:jwk for issuer, and sign credential - keyStore := crypto.NewMemoryCryptoInstance() - key, err := keyStore.New(audit.TestContext(), func(key crypt.PublicKey) (string, error) { - keyAsJWK, _ := jwk.FromRaw(key) - keyJSON, _ := json.Marshal(keyAsJWK) - return "did:jwk:" + base64.RawStdEncoding.EncodeToString(keyJSON) + "#0", nil - }) - require.NoError(t, err) - - template := testCredential(t) - template.Issuer = did.MustParseDIDURL(key.KID()).DID.URI() - - cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { - return keyStore.SignJWT(ctx, claims, headers, key) - }) - require.NoError(t, err) - - t.Run("with kid header", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(key.Public(), nil) - err = instance.Validate(*cred, nil) - - assert.NoError(t, err) - }) - t.Run("kid header does not match credential issuer", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { - return keyStore.SignJWT(ctx, claims, headers, key) - }) - require.NoError(t, err) - cred.Issuer = ssi.MustParseURI("did:example:test") - - ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(key.Public(), nil) - err = instance.Validate(*cred, nil) - - assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) - }) - t.Run("signature invalid", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - realKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - - ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(realKey.Public(), nil) - err = instance.Validate(*cred, nil) - - assert.EqualError(t, err, "unable to validate JWT credential: could not verify message using any of the signatures or keys") - }) - t.Run("expired token", func(t *testing.T) { - // Credential taken from Sphereon Wallet, expires on Tue Oct 03 2023 - const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.wdhtLXE4jU1C-3YBBpP9-qE-yh1xOZ6lBLJ-0e5_Sa7fnrUHcAaU1n3kN2CeCyTVjtm1Uy3Tl6RzUOM6MjP3vQ` - cred, _ := vc.ParseVerifiableCredential(credentialJSON) - - keyResolver := resolver.DIDKeyResolver{ - Resolver: didjwk.NewResolver(), - } - err := (&verifier{keyResolver: keyResolver}).Validate(*cred, nil) - - assert.EqualError(t, err, "unable to validate JWT credential: \"exp\" not satisfied") - }) - t.Run("without kid header, derived from issuer", func(t *testing.T) { - // Credential taken from Sphereon Wallet - const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.wdhtLXE4jU1C-3YBBpP9-qE-yh1xOZ6lBLJ-0e5_Sa7fnrUHcAaU1n3kN2CeCyTVjtm1Uy3Tl6RzUOM6MjP3vQ` - cred, _ := vc.ParseVerifiableCredential(credentialJSON) - - keyResolver := resolver.DIDKeyResolver{ - Resolver: didjwk.NewResolver(), - } - validAt := time.Date(2023, 9, 30, 0, 0, 0, 0, time.UTC) - err := (&verifier{keyResolver: keyResolver}).Validate(*cred, &validAt) - - assert.NoError(t, err) - }) - }) - - t.Run("type", func(t *testing.T) { - t.Run("incorrect number of types", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - err := instance.Validate(vc.VerifiableCredential{Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("a"), ssi.MustParseURI("b")}}, nil) - - assert.EqualError(t, err, "verifiable credential must list at most 2 types") - }) - t.Run("does not contain v1 context", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - err := instance.Validate(vc.VerifiableCredential{Type: []ssi.URI{ssi.MustParseURI("foo"), ssi.MustParseURI("bar")}}, nil) - - assert.EqualError(t, err, "verifiable credential does not list 'VerifiableCredential' as type") - }) - }) - - t.Run("error - invalid vm", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - vc2 := testCredential(t) - pr := make([]vc.JSONWebSignature2020Proof, 0) - _ = vc2.UnmarshalProofValue(&pr) - u := ssi.MustParseURI(vc2.Issuer.String() + "2") - pr[0].VerificationMethod = u - vc2.Proof = []interface{}{pr[0]} - - err := instance.Validate(vc2, nil) - - assert.Error(t, err) - assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) - }) - - t.Run("error - wrong hashed payload", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - vc2 := testCredential(t) - issuanceDate := time.Now() - vc2.IssuanceDate = &issuanceDate - - ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) - - err := instance.Validate(vc2, nil) - - assert.ErrorContains(t, err, "failed to verify signature") - }) - - t.Run("error - wrong hashed proof", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - vc2 := testCredential(t) - pr := make([]vc.JSONWebSignature2020Proof, 0) - vc2.UnmarshalProofValue(&pr) - pr[0].Created = time.Now() - vc2.Proof = []interface{}{pr[0]} - - ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) - - err := instance.Validate(vc2, nil) - - assert.ErrorContains(t, err, "failed to verify signature") - }) - - t.Run("error - no proof", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - vc2 := testCredential(t) - vc2.Proof = []interface{}{} - - err := instance.Validate(vc2, nil) - - assert.ErrorContains(t, err, "unable to extract ldproof from signed document: json: cannot unmarshal array into Go value of type proof.LDProof") - }) - - t.Run("error - wrong jws in proof", func(t *testing.T) { - ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) - instance := ctx.verifier - vc2 := testCredential(t) - pr := make([]vc.JSONWebSignature2020Proof, 0) - vc2.UnmarshalProofValue(&pr) - pr[0].Jws = "" - vc2.Proof = []interface{}{pr[0]} - - err := instance.Validate(vc2, nil) - - assert.ErrorContains(t, err, "invalid 'jws' value in proof") - }) - - t.Run("error - wrong base64 encoding in jws", func(t *testing.T) { - ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(pk, nil) - instance := ctx.verifier - vc2 := testCredential(t) - pr := make([]vc.JSONWebSignature2020Proof, 0) - vc2.UnmarshalProofValue(&pr) - pr[0].Jws = "abac..ab//" - vc2.Proof = []interface{}{pr[0]} - - err := instance.Validate(vc2, nil) - - assert.ErrorContains(t, err, "illegal base64 data") - }) - - t.Run("error - resolving key", func(t *testing.T) { - ctx := newMockContext(t) - instance := ctx.verifier - - ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, nil, resolver.NutsSigningKeyType).Return(nil, errors.New("b00m!")) - - err := instance.Validate(testCredential(t), nil) - - assert.Error(t, err) - }) - -} - func TestVerifier_Verify(t *testing.T) { const testKID = "did:nuts:CuE3qeFGGLhEAS3gKzhMCeqd1dGa9at5JCbmCfyMU2Ey#sNGDQ3NlOe6Icv0E7_ufviOLG6Y25bSEyS5EbXBgp8Y" @@ -320,9 +84,9 @@ func TestVerifier_Verify(t *testing.T) { ctx.keyResolver.EXPECT().ResolveKeyByID(testKID, gomock.Any(), resolver.NutsSigningKeyType).Return(nil, errors.New("not found")) at := time.Now() - err := instance.Validate(subject, &at) + err := instance.VerifySignature(subject, &at) - assert.EqualError(t, err, "unable to resolve valid signing key at given time: not found") + assert.EqualError(t, err, "unable to resolve valid signing key: not found") }) // Verify calls other verifiers / validators. @@ -340,7 +104,7 @@ func TestVerifier_Verify(t *testing.T) { validationErr := ctx.verifier.Verify(vc, true, true, nil) - assert.EqualError(t, validationErr, "unable to resolve signing key: key not found in DID document") + assert.EqualError(t, validationErr, "unable to resolve valid signing key: key not found in DID document") }) t.Run("fails when controller or issuer is deactivated", func(t *testing.T) { @@ -434,6 +198,17 @@ func TestVerifier_Verify(t *testing.T) { }) }) + + t.Run("incorrect number of types", func(t *testing.T) { + ctx := newMockContext(t) + instance := ctx.verifier + testCred := testCredential(t) + testCred.Type = []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("a"), ssi.MustParseURI("b")} + + err := instance.Verify(testCred, true, false, nil) + + assert.EqualError(t, err, "verifiable credential must list at most 2 types") + }) } func Test_verifier_CheckAndStoreRevocation(t *testing.T) { @@ -555,32 +330,30 @@ func TestVerifier_VerifyVP(t *testing.T) { validAt := time.Date(2023, 10, 21, 12, 0, 0, 0, time.UTC) vcs, err := ctx.verifier.VerifyVP(*presentation, false, false, &validAt) - assert.EqualError(t, err, "unable to validate JWT credential: \"exp\" not satisfied") + assert.EqualError(t, err, "unable to validate JWT signature: \"exp\" not satisfied") assert.Empty(t, vcs) }) + t.Run("VP signer != VC credentialSubject.id", func(t *testing.T) { // This VP was produced by a Sphereon Wallet, using did:key. The signer of the VP is a did:key, // but the holder of the contained credential is a did:jwt. So the presenter is not the holder. Weird? const rawVP = `eyJraWQiOiJkaWQ6a2V5Ono2TWtzRXl4NmQ1cEIxZWtvYVZtYUdzaWJiY1lIRTlWeHg3VjEzUFNxUHd4WVJ6TCN6Nk1rc0V5eDZkNXBCMWVrb2FWbWFHc2liYmNZSEU5Vnh4N1YxM1BTcVB3eFlSekwiLCJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJ2cCI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vaWRlbnRpdHkuZm91bmRhdGlvbi9wcmVzZW50YXRpb24tZXhjaGFuZ2Uvc3VibWlzc2lvbi92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVQcmVzZW50YXRpb24iLCJQcmVzZW50YXRpb25TdWJtaXNzaW9uIl0sInZlcmlmaWFibGVDcmVkZW50aWFsIjpbImV5SmhiR2NpT2lKRlV6STFOaUlzSW5SNWNDSTZJa3BYVkNKOS5leUpsZUhBaU9qRTJPVFl6TURFM01EZ3NJblpqSWpwN0lrQmpiMjUwWlhoMElqcGJJbWgwZEhCek9pOHZkM2QzTG5jekxtOXlaeTh5TURFNEwyTnlaV1JsYm5ScFlXeHpMM1l4SWwwc0luUjVjR1VpT2xzaVZtVnlhV1pwWVdKc1pVTnlaV1JsYm5ScFlXd2lMQ0pIZFdWemRFTnlaV1JsYm5ScFlXd2lYU3dpWTNKbFpHVnVkR2xoYkZOMVltcGxZM1FpT25zaVptbHljM1JPWVcxbElqb2lTR1ZzYkc4aUxDSnNZWE4wVG1GdFpTSTZJbE53YUdWeVpXOXVJaXdpWlcxaGFXd2lPaUp6Y0dobGNtVnZia0JsZUdGdGNHeGxMbU52YlNJc0luUjVjR1VpT2lKVGNHaGxjbVZ2YmlCSGRXVnpkQ0lzSW1sa0lqb2laR2xrT21wM2F6cGxlVXBvWWtkamFVOXBTa1pWZWtreFRtdHphVXhEU2pGak1sVnBUMmxLZW1GWFkybE1RMHB5WkVocmFVOXBTa1pSZVVselNXMU9lV1JwU1RaSmJrNXNXVE5CZVU1VVduSk5VMGx6U1c1bmFVOXBTbXBOVm1SWlkzcGtXRTB5TVRWak1sWldXbXMxUTJOWVRqUmFSa0pZVVd0c1NHRkZkR3RPUmxJMlRVVjRVMHhWV25GUFJWcE9WMWRGZDBscGQybGxVMGsyU1d4a2RHRXdUbGxrVkVZelpWaHdZVm93WkU5T01WWTBWRzFHZDJOSVJuVlVNVVpvVkRKMFdFMXJUbTVVTVU1MVZESTVOVlJWYkZWa1YwMXBabEVpZlgwc0lrQmpiMjUwWlhoMElqcGJJbWgwZEhCek9pOHZkM2QzTG5jekxtOXlaeTh5TURFNEwyTnlaV1JsYm5ScFlXeHpMM1l4SWwwc0luUjVjR1VpT2xzaVZtVnlhV1pwWVdKc1pVTnlaV1JsYm5ScFlXd2lMQ0pIZFdWemRFTnlaV1JsYm5ScFlXd2lYU3dpWlhod2FYSmhkR2x2YmtSaGRHVWlPaUl5TURJekxURXdMVEF6VkRBeU9qVTFPakE0TGpFek0xb2lMQ0pqY21Wa1pXNTBhV0ZzVTNWaWFtVmpkQ0k2ZXlKbWFYSnpkRTVoYldVaU9pSklaV3hzYnlJc0lteGhjM1JPWVcxbElqb2lVM0JvWlhKbGIyNGlMQ0psYldGcGJDSTZJbk53YUdWeVpXOXVRR1Y0WVcxd2JHVXVZMjl0SWl3aWRIbHdaU0k2SWxOd2FHVnlaVzl1SUVkMVpYTjBJaXdpYVdRaU9pSmthV1E2YW5kck9tVjVTbWhpUjJOcFQybEtSbFY2U1RGT2EzTnBURU5LTVdNeVZXbFBhVXA2WVZkamFVeERTbkprU0d0cFQybEtSbEY1U1hOSmJVNTVaR2xKTmtsdVRteFpNMEY1VGxSYWNrMVRTWE5KYm1kcFQybEthazFXWkZsamVtUllUVEl4TldNeVZsWmFhelZEWTFoT05GcEdRbGhSYTJ4SVlVVjBhMDVHVWpaTlJYaFRURlZhY1U5RldrNVhWMFYzU1dsM2FXVlRTVFpKYkdSMFlUQk9XV1JVUmpObFdIQmhXakJrVDA0eFZqUlViVVozWTBoR2RWUXhSbWhVTW5SWVRXdE9ibFF4VG5WVU1qazFWRlZzVldSWFRXbG1VU0o5TENKcGMzTjFaWElpT2lKa2FXUTZhbmRyT21WNVNtaGlSMk5wVDJsS1JsVjZTVEZPYVVselNXNVdlbHBUU1RaSmJrNXdXbmxKYzBsdGREQmxVMGsyU1d0V1JFbHBkMmxaTTBveVNXcHZhVlZETUhsT1ZGbHBURU5LTkVscWIybFdSV041VTBSS05FMXRVbGhYUlRSNlpGVk9lRmR1UW5oU2FrWTFZekJHVVZWV1drVlRhMVpQV0RCbmRGRXdNVEJaYldSeFdXa3hUMXA1U1hOSmJtdHBUMmxKTlZSVWFFOWxSMUYzVlVVMGVVMXJNRFZpUmtKRlpVZFNkMUpJUW5aV1JYZzJUVlJXTTFwdWJHRlRiazB5VjIxb1RGTldWa3ROZWswMFNXNHdJaXdpYVhOemRXRnVZMlZFWVhSbElqb2lNakF5TXkwd09TMHlPVlF4TWpvek1Ub3dPQzR4TXpOYUlpd2ljM1ZpSWpvaVpHbGtPbXAzYXpwbGVVcG9Za2RqYVU5cFNrWlZla2t4VG10emFVeERTakZqTWxWcFQybEtlbUZYWTJsTVEwcHlaRWhyYVU5cFNrWlJlVWx6U1cxT2VXUnBTVFpKYms1c1dUTkJlVTVVV25KTlUwbHpTVzVuYVU5cFNtcE5WbVJaWTNwa1dFMHlNVFZqTWxaV1dtczFRMk5ZVGpSYVJrSllVV3RzU0dGRmRHdE9SbEkyVFVWNFUweFZXbkZQUlZwT1YxZEZkMGxwZDJsbFUwazJTV3hrZEdFd1RsbGtWRVl6WlZod1lWb3daRTlPTVZZMFZHMUdkMk5JUm5WVU1VWm9WREowV0UxclRtNVVNVTUxVkRJNU5WUlZiRlZrVjAxcFpsRWlMQ0p1WW1ZaU9qRTJPVFU1T1RBMk5qZ3NJbWx6Y3lJNkltUnBaRHBxZDJzNlpYbEthR0pIWTJsUGFVcEdWWHBKTVU1cFNYTkpibFo2V2xOSk5rbHVUbkJhZVVselNXMTBNR1ZUU1RaSmExWkVTV2wzYVZrelNqSkphbTlwVlVNd2VVNVVXV2xNUTBvMFNXcHZhVlpGWTNsVFJFbzBUVzFTV0ZkRk5IcGtWVTU0VjI1Q2VGSnFSalZqTUVaUlZWWmFSVk5yVms5WU1HZDBVVEF4TUZsdFpIRlphVEZQV25sSmMwbHVhMmxQYVVrMVZGUm9UMlZIVVhkVlJUUjVUV3N3TldKR1FrVmxSMUozVWtoQ2RsWkZlRFpOVkZZeldtNXNZVk51VFRKWGJXaE1VMVpXUzAxNlRUUkpiakFpZlEud2RodExYRTRqVTFDLTNZQkJwUDktcUUteWgxeE9aNmxCTEotMGU1X1NhN2ZuclVIY0FhVTFuM2tOMkNlQ3lUVmp0bTFVeTNUbDZSelVPTTZNalAzdlEiXX0sInByZXNlbnRhdGlvbl9zdWJtaXNzaW9uIjp7ImlkIjoidG9DdGp5Y0V3QlZCWVBsbktBQTZGIiwiZGVmaW5pdGlvbl9pZCI6InNwaGVyZW9uIiwiZGVzY3JpcHRvcl9tYXAiOlt7ImlkIjoiNGNlN2FmZjEtMDIzNC00ZjM1LTlkMjEtMjUxNjY4YTYwOTUwIiwiZm9ybWF0Ijoiand0X3ZjIiwicGF0aCI6IiQudmVyaWZpYWJsZUNyZWRlbnRpYWxbMF0ifV19LCJuYmYiOjE2OTU5OTU2MzYsImlzcyI6ImRpZDprZXk6ejZNa3NFeXg2ZDVwQjFla29hVm1hR3NpYmJjWUhFOVZ4eDdWMTNQU3FQd3hZUnpMIn0.w3guHX-pmxJGGn5dGSSIKSba9xywnOutDk-l3tc_bpgHEOSbcR1mmmCqX5sSlZM_G0hgAbgpIv_YYI5iQNIfCw` const keyID = "did:key:z6MksEyx6d5pB1ekoaVmaGsibbcYHE9Vxx7V13PSqPwxYRzL#z6MksEyx6d5pB1ekoaVmaGsibbcYHE9Vxx7V13PSqPwxYRzL" keyAsJWK, err := jwk.ParseKey([]byte(`{ - "kty": "OKP", - "crv": "Ed25519", - "x": "vgLDESnU0TIlW-PmajyrvSlk9VysAsRkSYiEPBELj-U" - }`)) + "kty": "OKP", + "crv": "Ed25519", + "x": "vgLDESnU0TIlW-PmajyrvSlk9VysAsRkSYiEPBELj-U" + }`)) require.NoError(t, err) require.NoError(t, keyAsJWK.Set("kid", keyID)) - publicKey, err := keyAsJWK.PublicKey() - require.NoError(t, err) presentation, err := vc.ParseVerifiablePresentation(rawVP) require.NoError(t, err) ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(keyID, gomock.Any(), resolver.NutsSigningKeyType).Return(publicKey, nil) vcs, err := ctx.verifier.VerifyVP(*presentation, false, false, nil) - assert.EqualError(t, err, "verification method is not of issuer") + assert.EqualError(t, err, "verification error: credential(s) must be presented by subject") assert.Empty(t, vcs) }) }) @@ -744,7 +517,7 @@ func TestVerifier_VerifyVP(t *testing.T) { vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - assert.EqualError(t, err, "verification error: unsupported proof type: invalid LD-proof for presentation: json: cannot unmarshal string into Go value of type proof.LDProof") + assert.EqualError(t, err, "verification error: presenter is credential subject: invalid LD-proof for presentation: json: cannot unmarshal string into Go value of type proof.LDProof") assert.Empty(t, vcs) }) t.Run("error - no proof", func(t *testing.T) { @@ -758,7 +531,7 @@ func TestVerifier_VerifyVP(t *testing.T) { vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - assert.EqualError(t, err, "verification error: exactly 1 proof is expected") + assert.EqualError(t, err, "verification error: presenter is credential subject: presentation should have exactly 1 proof, got 0") assert.Empty(t, vcs) }) }) @@ -843,6 +616,11 @@ func newMockContext(t *testing.T) mockContext { verifierStore := NewMockStore(ctrl) trustConfig := trust.NewConfig(path.Join(io.TestDirectory(t), "trust.yaml")) verifier := NewVerifier(verifierStore, didResolver, keyResolver, jsonldManager, trustConfig).(*verifier) + sv := signatureVerifier{ + keyResolver: keyResolver, + jsonldManager: jsonldManager, + } + verifier.signatureVerifier = sv return mockContext{ ctrl: ctrl, verifier: verifier, From ff7bcd7adae216096752976351d70e28489d591a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Dec 2023 13:51:27 +0100 Subject: [PATCH 2/6] Bump github/codeql-action from 2 to 3 (#2687) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 488fa8fdb5..93f1d10d97 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -72,4 +72,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 From 3aeef7004e15e38b9d46228edba4969311554024 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Fri, 15 Dec 2023 12:42:50 +0100 Subject: [PATCH 3/6] VCR: Use JWT/JSON-LD constants from go-did (#2691) --- vcr/issuer/interface.go | 7 ------- vcr/issuer/issuer.go | 6 +++--- vcr/issuer/issuer_test.go | 12 ++++++------ vcr/verifier/signature_verifier.go | 9 ++++----- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/vcr/issuer/interface.go b/vcr/issuer/interface.go index f8c3a54324..e760cec393 100644 --- a/vcr/issuer/interface.go +++ b/vcr/issuer/interface.go @@ -85,13 +85,6 @@ type CredentialSearcher interface { SearchCredential(credentialType ssi.URI, issuer did.DID, subject *ssi.URI) ([]vc.VerifiableCredential, error) } -const ( - JSONLDCredentialFormat = vc.JSONLDCredentialProofFormat - JWTCredentialFormat = vc.JWTCredentialProofFormat - JSONLDPresentationFormat = vc.JSONLDPresentationProofFormat - JWTPresentationFormat = vc.JWTPresentationProofFormat -) - // CredentialOptions specifies options for issuing a credential. type CredentialOptions struct { // Format specifies the proof format for the issued credential. If not set, it defaults to JSON-LD. diff --git a/vcr/issuer/issuer.go b/vcr/issuer/issuer.go index ed148e2f40..e4a4693128 100644 --- a/vcr/issuer/issuer.go +++ b/vcr/issuer/issuer.go @@ -92,7 +92,7 @@ type issuer struct { // Use the public flag to pass the visibility settings to the Publisher. func (i issuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { // Until further notice we don't support publishing JWT VCs, since they're not officially supported by Nuts yet. - if options.Publish && options.Format == JWTCredentialFormat { + if options.Publish && options.Format == vc.JWTCredentialProofFormat { return nil, errors.New("publishing VC JWTs is not supported") } @@ -229,13 +229,13 @@ func (i issuer) buildVC(ctx context.Context, template vc.VerifiableCredential, o } switch options.Format { - case JWTCredentialFormat: + case vc.JWTCredentialProofFormat: return vc.CreateJWTVerifiableCredential(ctx, unsignedCredential, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { return i.keyStore.SignJWT(ctx, claims, headers, key) }) case "": fallthrough - case JSONLDCredentialFormat: + case vc.JSONLDCredentialProofFormat: return i.buildJSONLDCredential(ctx, unsignedCredential, key) default: return nil, errors.New("unsupported credential proof format") diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index e70d89c998..25f6ee4854 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -86,11 +86,11 @@ func Test_issuer_buildVC(t *testing.T) { jsonldManager := jsonld.NewTestJSONLDManager(t) sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} - result, err := sut.buildVC(ctx, template, CredentialOptions{Format: JSONLDCredentialFormat}) + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: vc.JSONLDCredentialProofFormat}) require.NoError(t, err) require.NotNil(t, result) assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") - assert.Equal(t, JSONLDCredentialFormat, result.Format()) + assert.Equal(t, vc.JSONLDCredentialProofFormat, result.Format()) assert.Equal(t, issuerID.String(), result.Issuer.String(), "expected correct issuer") assert.Contains(t, result.Context, schemaOrgContext) assert.Contains(t, result.Context, vc.VCContextV1URI()) @@ -110,7 +110,7 @@ func Test_issuer_buildVC(t *testing.T) { result, err := sut.buildVC(ctx, template, CredentialOptions{}) require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, JSONLDCredentialFormat, result.Format()) + assert.Equal(t, vc.JSONLDCredentialProofFormat, result.Format()) }) }) t.Run("JWT", func(t *testing.T) { @@ -121,11 +121,11 @@ func Test_issuer_buildVC(t *testing.T) { jsonldManager := jsonld.NewTestJSONLDManager(t) sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} - result, err := sut.buildVC(ctx, template, CredentialOptions{Format: JWTCredentialFormat}) + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: vc.JWTCredentialProofFormat}) require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, JWTCredentialFormat, result.Format()) + assert.Equal(t, vc.JWTCredentialProofFormat, result.Format()) assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") assert.Contains(t, result.Context, schemaOrgContext) assert.Contains(t, result.Context, vc.VCContextV1URI()) @@ -291,7 +291,7 @@ func Test_issuer_Issue(t *testing.T) { result, err := sut.Issue(ctx, template, CredentialOptions{ Publish: true, Public: true, - Format: JWTCredentialFormat, + Format: vc.JWTCredentialProofFormat, }) require.EqualError(t, err, "publishing VC JWTs is not supported") assert.Nil(t, result) diff --git a/vcr/verifier/signature_verifier.go b/vcr/verifier/signature_verifier.go index 434fa1bf34..3ca21a8a2a 100644 --- a/vcr/verifier/signature_verifier.go +++ b/vcr/verifier/signature_verifier.go @@ -13,7 +13,6 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" - "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -27,9 +26,9 @@ type signatureVerifier struct { // VerifySignature checks if the signature on a VP is valid at a given time func (sv *signatureVerifier) VerifySignature(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error { switch credentialToVerify.Format() { - case issuer.JSONLDCredentialFormat: + case vc.JSONLDCredentialProofFormat: return sv.jsonldProof(credentialToVerify, credentialToVerify.Issuer.String(), validateAt) - case issuer.JWTCredentialFormat: + case vc.JWTCredentialProofFormat: return sv.jwtSignature(credentialToVerify.Raw(), credentialToVerify.Issuer.String(), validateAt) default: return errors.New("unsupported credential proof format") @@ -44,9 +43,9 @@ func (sv *signatureVerifier) VerifyVPSignature(presentation vc.VerifiablePresent } switch presentation.Format() { - case issuer.JSONLDPresentationFormat: + case vc.JSONLDPresentationProofFormat: return sv.jsonldProof(presentation, signerDID.String(), validateAt) - case issuer.JWTPresentationFormat: + case vc.JWTPresentationProofFormat: return sv.jwtSignature(presentation.Raw(), signerDID.String(), validateAt) default: return errors.New("unsupported presentation proof format") From 27f2bb6ac3de5c7c1da1e163d80c33c1c3fde275 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Fri, 15 Dec 2023 15:08:34 +0100 Subject: [PATCH 4/6] Discovery: implement server API (#2659) --- cmd/root.go | 2 + codegen/configs/discovery_v1.yaml | 10 + discovery/api/v1/generated.go | 772 ++++++++++++++++++++++++++++++ discovery/api/v1/types.go | 24 + discovery/api/v1/wrapper.go | 84 ++++ discovery/api/v1/wrapper_test.go | 136 ++++++ discovery/interface.go | 2 +- discovery/module.go | 55 ++- discovery/module_test.go | 34 +- docs/_static/discovery/v1.yaml | 63 --- docs/pages/integrating/api.rst | 2 + makefile | 1 + 12 files changed, 1090 insertions(+), 95 deletions(-) create mode 100644 codegen/configs/discovery_v1.yaml create mode 100644 discovery/api/v1/generated.go create mode 100644 discovery/api/v1/types.go create mode 100644 discovery/api/v1/wrapper.go create mode 100644 discovery/api/v1/wrapper_test.go diff --git a/cmd/root.go b/cmd/root.go index c9d1d9eda2..e33ce818fc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,6 +44,7 @@ import ( didmanAPI "github.com/nuts-foundation/nuts-node/didman/api/v1" didmanCmd "github.com/nuts-foundation/nuts-node/didman/cmd" "github.com/nuts-foundation/nuts-node/discovery" + discoveryAPI "github.com/nuts-foundation/nuts-node/discovery/api/v1" discoveryCmd "github.com/nuts-foundation/nuts-node/discovery/cmd" "github.com/nuts-foundation/nuts-node/events" eventsCmd "github.com/nuts-foundation/nuts-node/events/cmd" @@ -221,6 +222,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) + system.RegisterRoutes(&discoveryAPI.Wrapper{Server: discoveryInstance}) // Register engines // without dependencies diff --git a/codegen/configs/discovery_v1.yaml b/codegen/configs/discovery_v1.yaml new file mode 100644 index 0000000000..ffa5d4adbb --- /dev/null +++ b/codegen/configs/discovery_v1.yaml @@ -0,0 +1,10 @@ +package: v1 +generate: + echo-server: true + client: true + models: true + strict-server: true +output-options: + skip-prune: true + exclude-schemas: + - VerifiablePresentation diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go new file mode 100644 index 0000000000..aac9117245 --- /dev/null +++ b/discovery/api/v1/generated.go @@ -0,0 +1,772 @@ +// Package v1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT. +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" + strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" +) + +const ( + JwtBearerAuthScopes = "jwtBearerAuth.Scopes" +) + +// GetPresentationsParams defines parameters for GetPresentations. +type GetPresentationsParams struct { + Tag *string `form:"tag,omitempty" json:"tag,omitempty"` +} + +// RegisterPresentationJSONRequestBody defines body for RegisterPresentation for application/json ContentType. +type RegisterPresentationJSONRequestBody = VerifiablePresentation + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // GetPresentations request + GetPresentations(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterPresentationWithBody request with any body + RegisterPresentationWithBody(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RegisterPresentation(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetPresentations(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPresentationsRequest(c.Server, serviceID, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterPresentationWithBody(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterPresentationRequestWithBody(c.Server, serviceID, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterPresentation(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterPresentationRequest(c.Server, serviceID, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetPresentationsRequest generates requests for GetPresentations +func NewGetPresentationsRequest(server string, serviceID string, params *GetPresentationsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, serviceID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/discovery/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Tag != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "tag", runtime.ParamLocationQuery, *params.Tag); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRegisterPresentationRequest calls the generic RegisterPresentation builder with application/json body +func NewRegisterPresentationRequest(server string, serviceID string, body RegisterPresentationJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRegisterPresentationRequestWithBody(server, serviceID, "application/json", bodyReader) +} + +// NewRegisterPresentationRequestWithBody generates requests for RegisterPresentation with any type of body +func NewRegisterPresentationRequestWithBody(server string, serviceID string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, serviceID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/discovery/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // GetPresentationsWithResponse request + GetPresentationsWithResponse(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*GetPresentationsResponse, error) + + // RegisterPresentationWithBodyWithResponse request with any body + RegisterPresentationWithBodyWithResponse(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) + + RegisterPresentationWithResponse(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) +} + +type GetPresentationsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Entries []VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` + } + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetPresentationsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPresentationsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type RegisterPresentationResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSON400 *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r RegisterPresentationResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RegisterPresentationResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// GetPresentationsWithResponse request returning *GetPresentationsResponse +func (c *ClientWithResponses) GetPresentationsWithResponse(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*GetPresentationsResponse, error) { + rsp, err := c.GetPresentations(ctx, serviceID, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPresentationsResponse(rsp) +} + +// RegisterPresentationWithBodyWithResponse request with arbitrary body returning *RegisterPresentationResponse +func (c *ClientWithResponses) RegisterPresentationWithBodyWithResponse(ctx context.Context, serviceID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) { + rsp, err := c.RegisterPresentationWithBody(ctx, serviceID, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterPresentationResponse(rsp) +} + +func (c *ClientWithResponses) RegisterPresentationWithResponse(ctx context.Context, serviceID string, body RegisterPresentationJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterPresentationResponse, error) { + rsp, err := c.RegisterPresentation(ctx, serviceID, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterPresentationResponse(rsp) +} + +// ParseGetPresentationsResponse parses an HTTP response from a GetPresentationsWithResponse call +func ParseGetPresentationsResponse(rsp *http.Response) (*GetPresentationsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPresentationsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Entries []VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRegisterPresentationResponse parses an HTTP response from a RegisterPresentationWithResponse call +func ParseRegisterPresentationResponse(rsp *http.Response) (*RegisterPresentationResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RegisterPresentationResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Retrieves the presentations of a discovery service. + // (GET /discovery/{serviceID}) + GetPresentations(ctx echo.Context, serviceID string, params GetPresentationsParams) error + // Register a presentation on the discovery service. + // (POST /discovery/{serviceID}) + RegisterPresentation(ctx echo.Context, serviceID string) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GetPresentations converts echo context to params. +func (w *ServerInterfaceWrapper) GetPresentations(ctx echo.Context) error { + var err error + // ------------- Path parameter "serviceID" ------------- + var serviceID string + + err = runtime.BindStyledParameterWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, ctx.Param("serviceID"), &serviceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter serviceID: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetPresentationsParams + // ------------- Optional query parameter "tag" ------------- + + err = runtime.BindQueryParameter("form", true, false, "tag", ctx.QueryParams(), ¶ms.Tag) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tag: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetPresentations(ctx, serviceID, params) + return err +} + +// RegisterPresentation converts echo context to params. +func (w *ServerInterfaceWrapper) RegisterPresentation(ctx echo.Context) error { + var err error + // ------------- Path parameter "serviceID" ------------- + var serviceID string + + err = runtime.BindStyledParameterWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, ctx.Param("serviceID"), &serviceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter serviceID: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.RegisterPresentation(ctx, serviceID) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/discovery/:serviceID", wrapper.GetPresentations) + router.POST(baseURL+"/discovery/:serviceID", wrapper.RegisterPresentation) + +} + +type GetPresentationsRequestObject struct { + ServiceID string `json:"serviceID"` + Params GetPresentationsParams +} + +type GetPresentationsResponseObject interface { + VisitGetPresentationsResponse(w http.ResponseWriter) error +} + +type GetPresentations200JSONResponse struct { + Entries []VerifiablePresentation `json:"entries"` + Tag string `json:"tag"` +} + +func (response GetPresentations200JSONResponse) VisitGetPresentationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetPresentationsdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response GetPresentationsdefaultApplicationProblemPlusJSONResponse) VisitGetPresentationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RegisterPresentationRequestObject struct { + ServiceID string `json:"serviceID"` + Body *RegisterPresentationJSONRequestBody +} + +type RegisterPresentationResponseObject interface { + VisitRegisterPresentationResponse(w http.ResponseWriter) error +} + +type RegisterPresentation201Response struct { +} + +func (response RegisterPresentation201Response) VisitRegisterPresentationResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type RegisterPresentation400ApplicationProblemPlusJSONResponse struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` +} + +func (response RegisterPresentation400ApplicationProblemPlusJSONResponse) VisitRegisterPresentationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type RegisterPresentationdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response RegisterPresentationdefaultApplicationProblemPlusJSONResponse) VisitRegisterPresentationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Retrieves the presentations of a discovery service. + // (GET /discovery/{serviceID}) + GetPresentations(ctx context.Context, request GetPresentationsRequestObject) (GetPresentationsResponseObject, error) + // Register a presentation on the discovery service. + // (POST /discovery/{serviceID}) + RegisterPresentation(ctx context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) +} + +type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc +type StrictMiddlewareFunc = strictecho.StrictEchoMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetPresentations operation middleware +func (sh *strictHandler) GetPresentations(ctx echo.Context, serviceID string, params GetPresentationsParams) error { + var request GetPresentationsRequestObject + + request.ServiceID = serviceID + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetPresentations(ctx.Request().Context(), request.(GetPresentationsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetPresentations") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetPresentationsResponseObject); ok { + return validResponse.VisitGetPresentationsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// RegisterPresentation operation middleware +func (sh *strictHandler) RegisterPresentation(ctx echo.Context, serviceID string) error { + var request RegisterPresentationRequestObject + + request.ServiceID = serviceID + + var body RegisterPresentationJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RegisterPresentation(ctx.Request().Context(), request.(RegisterPresentationRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RegisterPresentation") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RegisterPresentationResponseObject); ok { + return validResponse.VisitRegisterPresentationResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/discovery/api/v1/types.go b/discovery/api/v1/types.go new file mode 100644 index 0000000000..7a9ff02004 --- /dev/null +++ b/discovery/api/v1/types.go @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package v1 + +import "github.com/nuts-foundation/go-did/vc" + +// VerifiablePresentation is a type alias for the VerifiablePresentation from the go-did library. +type VerifiablePresentation = vc.VerifiablePresentation diff --git a/discovery/api/v1/wrapper.go b/discovery/api/v1/wrapper.go new file mode 100644 index 0000000000..375a1939e3 --- /dev/null +++ b/discovery/api/v1/wrapper.go @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package v1 + +import ( + "context" + "errors" + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/discovery" + "net/http" +) + +var _ StrictServerInterface = (*Wrapper)(nil) +var _ core.ErrorStatusCodeResolver = (*Wrapper)(nil) + +type Wrapper struct { + Server discovery.Server + Client discovery.Client +} + +func (w *Wrapper) ResolveStatusCode(err error) int { + switch { + case errors.Is(err, discovery.ErrServerModeDisabled): + return http.StatusBadRequest + case errors.Is(err, discovery.ErrInvalidPresentation): + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + +func (w *Wrapper) Routes(router core.EchoRouter) { + RegisterHandlers(router, NewStrictHandler(w, []StrictMiddlewareFunc{ + func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { + return func(ctx echo.Context, request interface{}) (response interface{}, err error) { + ctx.Set(core.OperationIDContextKey, operationID) + ctx.Set(core.ModuleNameContextKey, discovery.ModuleName) + ctx.Set(core.StatusCodeResolverContextKey, w) + return f(ctx, request) + } + }, + })) +} + +func (w *Wrapper) GetPresentations(_ context.Context, request GetPresentationsRequestObject) (GetPresentationsResponseObject, error) { + var tag *discovery.Tag + if request.Params.Tag != nil { + tag = new(discovery.Tag) + *tag = discovery.Tag(*request.Params.Tag) + } + presentations, newTag, err := w.Server.Get(request.ServiceID, tag) + if err != nil { + return nil, err + } + return GetPresentations200JSONResponse{ + Entries: presentations, + Tag: string(*newTag), + }, nil +} + +func (w *Wrapper) RegisterPresentation(_ context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) { + err := w.Server.Add(request.ServiceID, *request.Body) + if err != nil { + return nil, err + } + return RegisterPresentation201Response{}, nil +} diff --git a/discovery/api/v1/wrapper_test.go b/discovery/api/v1/wrapper_test.go new file mode 100644 index 0000000000..df838920f0 --- /dev/null +++ b/discovery/api/v1/wrapper_test.go @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package v1 + +import ( + "errors" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/discovery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "net/http" + "testing" +) + +const serviceID = "wonderland" + +func TestWrapper_GetPresentations(t *testing.T) { + t.Run("no tag", func(t *testing.T) { + latestTag := discovery.Tag("latest") + test := newMockContext(t) + presentations := []vc.VerifiablePresentation{} + test.server.EXPECT().Get(serviceID, nil).Return(presentations, &latestTag, nil) + + response, err := test.wrapper.GetPresentations(nil, GetPresentationsRequestObject{ServiceID: serviceID}) + + require.NoError(t, err) + require.IsType(t, GetPresentations200JSONResponse{}, response) + assert.Equal(t, latestTag, discovery.Tag(response.(GetPresentations200JSONResponse).Tag)) + assert.Equal(t, presentations, response.(GetPresentations200JSONResponse).Entries) + }) + t.Run("with tag", func(t *testing.T) { + givenTag := discovery.Tag("given") + latestTag := discovery.Tag("latest") + test := newMockContext(t) + presentations := []vc.VerifiablePresentation{} + test.server.EXPECT().Get(serviceID, &givenTag).Return(presentations, &latestTag, nil) + + response, err := test.wrapper.GetPresentations(nil, GetPresentationsRequestObject{ + ServiceID: serviceID, + Params: GetPresentationsParams{ + Tag: (*string)(&givenTag), + }, + }) + + require.NoError(t, err) + require.IsType(t, GetPresentations200JSONResponse{}, response) + assert.Equal(t, latestTag, discovery.Tag(response.(GetPresentations200JSONResponse).Tag)) + assert.Equal(t, presentations, response.(GetPresentations200JSONResponse).Entries) + }) + t.Run("error", func(t *testing.T) { + test := newMockContext(t) + test.server.EXPECT().Get(serviceID, nil).Return(nil, nil, errors.New("foo")) + + _, err := test.wrapper.GetPresentations(nil, GetPresentationsRequestObject{ServiceID: serviceID}) + + assert.Error(t, err) + }) +} + +func TestWrapper_RegisterPresentation(t *testing.T) { + t.Run("ok", func(t *testing.T) { + test := newMockContext(t) + presentation := vc.VerifiablePresentation{} + test.server.EXPECT().Add(serviceID, presentation).Return(nil) + + response, err := test.wrapper.RegisterPresentation(nil, RegisterPresentationRequestObject{ + ServiceID: serviceID, + Body: &presentation, + }) + + assert.NoError(t, err) + assert.IsType(t, RegisterPresentation201Response{}, response) + }) + t.Run("error", func(t *testing.T) { + test := newMockContext(t) + presentation := vc.VerifiablePresentation{} + test.server.EXPECT().Add(serviceID, presentation).Return(discovery.ErrInvalidPresentation) + + _, err := test.wrapper.RegisterPresentation(nil, RegisterPresentationRequestObject{ + ServiceID: serviceID, + Body: &presentation, + }) + + assert.ErrorIs(t, err, discovery.ErrInvalidPresentation) + }) +} + +func TestWrapper_ResolveStatusCode(t *testing.T) { + expected := map[error]int{ + discovery.ErrServerModeDisabled: http.StatusBadRequest, + discovery.ErrInvalidPresentation: http.StatusBadRequest, + errors.New("foo"): http.StatusInternalServerError, + } + wrapper := Wrapper{} + for err, expectedCode := range expected { + t.Run(err.Error(), func(t *testing.T) { + assert.Equal(t, expectedCode, wrapper.ResolveStatusCode(err)) + }) + } +} + +type mockContext struct { + ctrl *gomock.Controller + server *discovery.MockServer + client *discovery.MockClient + wrapper Wrapper +} + +func newMockContext(t *testing.T) mockContext { + ctrl := gomock.NewController(t) + server := discovery.NewMockServer(ctrl) + client := discovery.NewMockClient(ctrl) + return mockContext{ + ctrl: ctrl, + server: server, + client: client, + wrapper: Wrapper{Server: server, Client: client}, + } +} diff --git a/discovery/interface.go b/discovery/interface.go index d308eb91a3..0e78892306 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -86,7 +86,7 @@ var ErrPresentationAlreadyExists = errors.New("presentation already exists") // Server defines the API for Discovery Servers. type Server interface { // Add registers a presentation on the given Discovery Service. - // If the presentation is not valid or it does not conform to the Service ServiceDefinition, it returns an error. + // If the presentation is not valid, or it does not conform to the Service ServiceDefinition, it returns an error. Add(serviceID string, presentation vc.VerifiablePresentation) error // Get retrieves the presentations for the given service, starting at the given timestamp. Get(serviceID string, startAt *Tag) ([]vc.VerifiablePresentation, *Tag, error) diff --git a/discovery/module.go b/discovery/module.go index 3685e7025c..87fb5ed53c 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -35,8 +35,24 @@ import ( const ModuleName = "Discovery" +// ErrServerModeDisabled is returned when a client invokes a Discovery Server (Add or Get) operation on the node, +// for a Discovery Service which it doesn't serve. var ErrServerModeDisabled = errors.New("node is not a discovery server for this service") +// ErrInvalidPresentation is returned when a client tries to register a Verifiable Presentation that is invalid. +var ErrInvalidPresentation = errors.New("presentation is invalid for registration") + +var ( + errUnsupportedPresentationFormat = errors.New("only JWT presentations are supported") + errPresentationWithoutID = errors.New("presentation does not have an ID") + errPresentationWithoutExpiration = errors.New("presentation does not have an expiration") + errPresentationValidityExceedsCredentials = errors.New("presentation is valid longer than the credential(s) it contains") + errPresentationDoesNotFulfillDefinition = errors.New("presentation does not fulfill Presentation ServiceDefinition") + errRetractionReferencesUnknownPresentation = errors.New("retraction presentation refers to a non-existing presentation") + errRetractionContainsCredentials = errors.New("retraction presentation must not contain credentials") + errInvalidRetractionJTIClaim = errors.New("invalid/missing 'retract_jti' claim for retraction presentation") +) + var _ core.Injectable = &Module{} var _ core.Runnable = &Module{} var _ core.Configurable = &Module{} @@ -107,6 +123,8 @@ func (m *Module) Config() interface{} { return &m.config } +// Add registers a presentation on the given Discovery Service. +// See interface.go for more information. func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) error { // First, simple sanity checks definition, isServer := m.serverDefinitions[serviceID] @@ -114,10 +132,10 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e return ErrServerModeDisabled } if presentation.Format() != vc.JWTPresentationProofFormat { - return errors.New("only JWT presentations are supported") + return errors.Join(ErrInvalidPresentation, errUnsupportedPresentationFormat) } if presentation.ID == nil { - return errors.New("presentation does not have an ID") + return errors.Join(ErrInvalidPresentation, errPresentationWithoutID) } // Make sure the presentation is intended for this service if err := validateAudience(definition, presentation.JWT().Audience()); err != nil { @@ -125,11 +143,11 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e } expiration := presentation.JWT().Expiration() if expiration.IsZero() { - return errors.New("presentation does not have an expiration") + return errors.Join(ErrInvalidPresentation, errPresentationWithoutExpiration) } // VPs should not be valid for too long, as that would prevent the server from pruning them. if int(expiration.Sub(time.Now()).Seconds()) > definition.PresentationMaxValidity { - return fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second) + return errors.Join(ErrInvalidPresentation, fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second)) } // Check if the presentation already exists credentialSubjectID, err := credential.PresentationSigner(presentation) @@ -141,7 +159,7 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e return err } if exists { - return ErrPresentationAlreadyExists + return errors.Join(ErrInvalidPresentation, ErrPresentationAlreadyExists) } // Depending on the presentation type, we need to validate different properties before storing it. if presentation.IsType(retractionPresentationType) { @@ -150,12 +168,12 @@ func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) e err = m.validateRegistration(definition, presentation) } if err != nil { - return err + return errors.Join(ErrInvalidPresentation, err) } // Check signature of presentation and contained credential(s) _, err = m.vcrInstance.Verifier().VerifyVP(presentation, true, true, nil) if err != nil { - return fmt.Errorf("presentation verification failed: %w", err) + return errors.Join(ErrInvalidPresentation, fmt.Errorf("presentation verification failed: %w", err)) } return m.store.add(definition.ID, presentation, nil) } @@ -165,7 +183,7 @@ func (m *Module) validateRegistration(definition ServiceDefinition, presentation expiration := presentation.JWT().Expiration() for _, cred := range presentation.VerifiableCredential { if cred.ExpirationDate != nil && expiration.After(*cred.ExpirationDate) { - return fmt.Errorf("presentation is valid longer than the credential(s) it contains") + return errPresentationValidityExceedsCredentials } } // VP must fulfill the PEX Presentation ServiceDefinition @@ -175,7 +193,7 @@ func (m *Module) validateRegistration(definition ServiceDefinition, presentation return err } if len(creds) != len(presentation.VerifiableCredential) { - return errors.New("presentation does not fulfill Presentation ServiceDefinition") + return errPresentationDoesNotFulfillDefinition } return nil } @@ -184,29 +202,28 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable // Presentation might be a retraction (deletion of an earlier credentialRecord) must contain no credentials, and refer to the VP being retracted by ID. // If those conditions aren't met, we don't need to register the retraction. if len(presentation.VerifiableCredential) > 0 { - return errors.New("retraction presentation must not contain credentials") + return errRetractionContainsCredentials } // Check that the retraction refers to an existing presentation. // If not, it might've already been removed due to expiry or superseded by a newer presentation. - var retractJTIString string - if retractJTIRaw, ok := presentation.JWT().Get("retract_jti"); !ok { - return errors.New("retraction presentation does not contain 'retract_jti' claim") - } else { - if retractJTIString, ok = retractJTIRaw.(string); !ok { - return errors.New("retraction presentation 'retract_jti' claim is not a string") - } + retractJTIRaw, _ := presentation.JWT().Get("retract_jti") + retractJTI, ok := retractJTIRaw.(string) + if !ok || retractJTI == "" { + return errInvalidRetractionJTIClaim } signerDID, _ := credential.PresentationSigner(presentation) // checked before - exists, err := m.store.exists(serviceID, signerDID.String(), retractJTIString) + exists, err := m.store.exists(serviceID, signerDID.String(), retractJTI) if err != nil { return err } if !exists { - return errors.New("retraction presentation refers to a non-existing presentation") + return errRetractionReferencesUnknownPresentation } return nil } +// Get retrieves the presentations for the given service, starting at the given tag. +// See interface.go for more information. func (m *Module) Get(serviceID string, tag *Tag) ([]vc.VerifiablePresentation, *Tag, error) { if _, exists := m.serverDefinitions[serviceID]; !exists { return nil, nil, ErrServerModeDisabled diff --git a/discovery/module_test.go b/discovery/module_test.go index 6c32c28e6b..3b54d56700 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -56,7 +56,7 @@ func Test_Module_Add(t *testing.T) { presentationVerifier.EXPECT().VerifyVP(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("failed")) err := m.Add(testServiceID, vpAlice) - require.EqualError(t, err, "presentation verification failed: failed") + require.EqualError(t, err, "presentation is invalid for registration\npresentation verification failed: failed") _, tag, err := m.Get(testServiceID, nil) require.NoError(t, err) @@ -70,7 +70,7 @@ func Test_Module_Add(t *testing.T) { err := m.Add(testServiceID, vpAlice) assert.NoError(t, err) err = m.Add(testServiceID, vpAlice) - assert.EqualError(t, err, "presentation already exists") + assert.ErrorIs(t, err, ErrPresentationAlreadyExists) }) t.Run("valid for too long", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -80,7 +80,7 @@ func Test_Module_Add(t *testing.T) { m.serverDefinitions[testServiceID] = def err := m.Add(testServiceID, vpAlice) - assert.EqualError(t, err, "presentation is valid for too long (max 1s)") + assert.EqualError(t, err, "presentation is invalid for registration\npresentation is valid for too long (max 1s)") }) t.Run("no expiration", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -88,7 +88,7 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} delete(claims, "exp") })) - assert.EqualError(t, err, "presentation does not have an expiration") + assert.ErrorIs(t, err, errPresentationWithoutExpiration) }) t.Run("presentation does not contain an ID", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -98,12 +98,12 @@ func Test_Module_Add(t *testing.T) { delete(claims, "jti") }, vcAlice) err := m.Add(testServiceID, vpWithoutID) - assert.EqualError(t, err, "presentation does not have an ID") + assert.ErrorIs(t, err, errPresentationWithoutID) }) t.Run("not a JWT", func(t *testing.T) { m, _ := setupModule(t, storageEngine) err := m.Add(testServiceID, vc.VerifiablePresentation{}) - assert.EqualError(t, err, "only JWT presentations are supported") + assert.ErrorIs(t, err, errUnsupportedPresentationFormat) }) t.Run("registration", func(t *testing.T) { @@ -129,7 +129,7 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }, vcAlice) err := m.Add(testServiceID, vpAlice) - assert.EqualError(t, err, "presentation is valid longer than the credential(s) it contains") + assert.ErrorIs(t, err, errPresentationValidityExceedsCredentials) }) t.Run("not conform to Presentation Definition", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -163,7 +163,7 @@ func Test_Module_Add(t *testing.T) { t.Run("non-existent presentation", func(t *testing.T) { m, _ := setupModule(t, storageEngine) err := m.Add(testServiceID, vpAliceRetract) - assert.EqualError(t, err, "retraction presentation refers to a non-existing presentation") + assert.ErrorIs(t, err, errRetractionReferencesUnknownPresentation) }) t.Run("must not contain credentials", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -172,7 +172,7 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }, vcAlice) err := m.Add(testServiceID, vp) - assert.EqualError(t, err, "retraction presentation must not contain credentials") + assert.ErrorIs(t, err, errRetractionContainsCredentials) }) t.Run("missing 'retract_jti' claim", func(t *testing.T) { m, _ := setupModule(t, storageEngine) @@ -181,9 +181,9 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }) err := m.Add(testServiceID, vp) - assert.EqualError(t, err, "retraction presentation does not contain 'retract_jti' claim") + assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) - t.Run("'retract_jti' claim in not a string", func(t *testing.T) { + t.Run("'retract_jti' claim is not a string", func(t *testing.T) { m, _ := setupModule(t, storageEngine) vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { vp.Type = append(vp.Type, retractionPresentationType) @@ -191,7 +191,17 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }) err := m.Add(testServiceID, vp) - assert.EqualError(t, err, "retraction presentation 'retract_jti' claim is not a string") + assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) + }) + t.Run("'retract_jti' claim is an empty string", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + claims["retract_jti"] = "" + claims[jwt.AudienceKey] = []string{testServiceID} + }) + err := m.Add(testServiceID, vp) + assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) }) } diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index 84be706c9e..062502ef56 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -81,69 +81,6 @@ paths: $ref: "../common/error_response.yaml" default: $ref: "../common/error_response.yaml" - /discovery/{serviceID}/search: - parameters: - - name: serviceID - in: path - required: true - schema: - type: string - # Way to specify dynamic query parameters - # See https://stackoverflow.com/questions/49582559/how-to-document-dynamic-query-parameter-names-in-openapi-swagger - - in: query - name: query - schema: - type: object - additionalProperties: - type: string - style: form - explode: true - get: - summary: Searches for presentations registered on the discovery service. - description: | - An API of the discovery client that searches for presentations on the discovery service, - whose credentials match the given query parameter. - The query parameters are interpreted as JSON path expressions, evaluated on the verifiable credentials. - The following features and limitations apply: - - only simple child-selectors are supported (so no arrays selectors, script expressions etc). - - only JSON string values can be matched, no numbers, booleans, etc. - - wildcard (*) are supported at the start and end of the value - - a single wildcard (*) means: match any (non-nil) value - - matching is case-insensitive - - expressions must not include the '$.' prefix, which is added by the API. - - all expressions must match a single credential, for the credential to be included in the result. - - if there are multiple credentials in the presentation, the presentation is included in the result if any of the credentials match. - - Valid examples: - - `credentialSubject.givenName=John` - - `credentialSubject.organization.city=Arnhem` - - `credentialSubject.organization.name=Hospital*` - - `credentialSubject.organization.name=*clinic` - - `issuer=did:web:example.com` - operationId: searchPresentations - tags: - - discovery - responses: - "200": - description: Search results are returned, if any. - content: - application/json: - schema: - type: array - items: - type: object - required: - - id - - credential - properties: - id: - type: string - description: The ID of the Verifiable Presentation. - credential: - type: object - description: The Verifiable Credential that matched the query. - default: - $ref: "../common/error_response.yaml" components: schemas: VerifiablePresentation: diff --git a/docs/pages/integrating/api.rst b/docs/pages/integrating/api.rst index c59379ba43..a8d7f84d18 100644 --- a/docs/pages/integrating/api.rst +++ b/docs/pages/integrating/api.rst @@ -7,6 +7,7 @@ Below you can discover the Nuts Node APIs and download their OpenAPI specificati - Common: `SSI types <../../_static/common/ssi_types.yaml>`_, `Default Error <../../_static/common/error_response.yaml>`_ - `DID Manager <../../_static/didman/v1.yaml>`_ +- `Discovery Service <../../_static/discovery/v1.yaml>`_ - `Crypto <../../_static/crypto/v1.yaml>`_ - `Verifiable Credential Registry (v2) <../../_static/vcr/vcr_v2.yaml>`_ - `Verifiable Data Registry <../../_static/vdr/v1.yaml>`_ @@ -30,6 +31,7 @@ Below you can discover the Nuts Node APIs and download their OpenAPI specificati "dom_id": "#swagger-ui", urls: [ {url: "../../_static/didman/v1.yaml", name: "DID Manager"}, + {url: "../../_static/discovery/v1.yaml", name: "Discovery Service"}, {url: "../../_static/crypto/v1.yaml", name: "Crypto"}, {url: "../../_static/vcr/vcr_v2.yaml", name: "Verifiable Credential Registry (v2)"}, {url: "../../_static/vdr/v1.yaml", name: "Verifiable Data Registry"}, diff --git a/makefile b/makefile index 4932e9302a..1365b36384 100644 --- a/makefile +++ b/makefile @@ -70,6 +70,7 @@ gen-api: oapi-codegen --config codegen/configs/auth_employeeid.yaml auth/services/selfsigned/web/spec.yaml | gofmt > auth/services/selfsigned/web/generated.go oapi-codegen --config codegen/configs/auth_iam.yaml docs/_static/auth/iam.yaml | gofmt > auth/api/iam/generated.go oapi-codegen --config codegen/configs/didman_v1.yaml docs/_static/didman/v1.yaml | gofmt > didman/api/v1/generated.go + oapi-codegen --config codegen/configs/discovery_v1.yaml docs/_static/discovery/v1.yaml | gofmt > discovery/api/v1/generated.go oapi-codegen --config codegen/configs/crypto_store_client.yaml https://raw.githubusercontent.com/nuts-foundation/secret-store-api/main/nuts-storage-api-v1.yaml | gofmt > crypto/storage/external/generated.go oapi-codegen --config codegen/configs/policy_client_v1.yaml docs/_static/policy/v1.yaml | gofmt > policy/api/v1/client/generated.go From 6ee1f521d43b1bec28e755ee877b7c338d4a3f92 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Sat, 16 Dec 2023 06:29:47 +0100 Subject: [PATCH 5/6] Cleanup unused file (#2692) --- auth/api/iam/interface.go | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 auth/api/iam/interface.go diff --git a/auth/api/iam/interface.go b/auth/api/iam/interface.go deleted file mode 100644 index 70e1c793b4..0000000000 --- a/auth/api/iam/interface.go +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2023 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package iam - -// authzResponse is the response to an Authorization Code flow request. -type authzResponse struct { - // html is the HTML page to be rendered to the user. - html []byte -} From 788f923c3f458b80c2b2720323d767cd5dc637e2 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Mon, 18 Dec 2023 09:23:47 +0100 Subject: [PATCH 6/6] complete s2s e2e test (#2677) --- auth/api/iam/api.go | 5 ++++ .../oauth-flow/rfc021/docker-compose.yml | 1 + e2e-tests/oauth-flow/rfc021/node-A/nginx.conf | 23 +++++++++++++++---- e2e-tests/oauth-flow/rfc021/node-A/oauth2.js | 23 +++++++++++++++++++ e2e-tests/oauth-flow/rfc021/run-test.sh | 18 +++++++-------- 5 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 e2e-tests/oauth-flow/rfc021/node-A/oauth2.js diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 9cfe7455c0..707b7c579c 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -170,18 +170,21 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce // Validate token if request.Body.Token == "" { // Return 200 + 'Active = false' when token is invalid or malformed + log.Logger().Debug("IntrospectAccessToken: missing token") return IntrospectAccessToken200JSONResponse{}, nil } token := AccessToken{} if err := r.accessTokenStore().Get(request.Body.Token, &token); err != nil { // Return 200 + 'Active = false' when token is invalid or malformed + log.Logger().Debug("IntrospectAccessToken: failed to get token from store") return IntrospectAccessToken200JSONResponse{}, err } if token.Expiration.Before(time.Now()) { // Return 200 + 'Active = false' when token is invalid or malformed // can happen between token expiration and pruning of database + log.Logger().Debug("IntrospectAccessToken: token is expired") return IntrospectAccessToken200JSONResponse{}, nil } @@ -215,12 +218,14 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce var err error response.PresentationDefinition, err = toAnyMap(token.PresentationDefinition) if err != nil { + log.Logger().WithError(err).Error("IntrospectAccessToken: failed to marshal presentation definition") return IntrospectAccessToken200JSONResponse{}, err } // set presentation submission if in token response.PresentationSubmission, err = toAnyMap(token.PresentationSubmission) if err != nil { + log.Logger().WithError(err).Error("IntrospectAccessToken: failed to marshal presentation submission") return IntrospectAccessToken200JSONResponse{}, err } return response, nil diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml index eea54d2446..df15b5c394 100644 --- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml @@ -28,6 +28,7 @@ services: - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro" - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" - "./node-A/html:/etc/nginx/html:ro" + - "./node-A/oauth2.js:/etc/nginx/oauth2.js:ro" nodeB-backend: image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}" ports: diff --git a/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf b/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf index cf2113e8fa..8cd8cdb0e4 100644 --- a/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf +++ b/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf @@ -1,18 +1,19 @@ +load_module /usr/lib/nginx/modules/ngx_http_js_module.so; + user nginx; worker_processes 1; error_log /var/log/nginx/error.log debug; pid /var/run/nginx.pid; - events { worker_connections 1024; } - http { - include /etc/nginx/mime.types; - default_type application/octet-stream; + js_import oauth2.js; + include /etc/nginx/mime.types; + default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' @@ -43,5 +44,19 @@ http { proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert; proxy_pass http://nodeA-backend; } + + # check access via token introspection as described by https://www.nginx.com/blog/validating-oauth-2-0-access-tokens-nginx/ + location /resource { + js_content oauth2.introspectAccessToken; + } + + # Location in javascript subrequest. + # this is needed to set headers and method + location /_oauth2_send_request { + internal; + proxy_method POST; + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_pass http://nodeA-backend/internal/auth/v2/accesstoken/introspect; + } } } diff --git a/e2e-tests/oauth-flow/rfc021/node-A/oauth2.js b/e2e-tests/oauth-flow/rfc021/node-A/oauth2.js new file mode 100644 index 0000000000..c963d03fdf --- /dev/null +++ b/e2e-tests/oauth-flow/rfc021/node-A/oauth2.js @@ -0,0 +1,23 @@ +// # check access via token introspection as described by https://www.nginx.com/blog/validating-oauth-2-0-access-tokens-nginx/ +function introspectAccessToken(r) { + // strip the first 8 chars + var token = "token=" + r.headersIn['Authorization'].substring(7); + // make a subrequest to the introspection endpoint + r.subrequest("/_oauth2_send_request", + { method: "POST", body: token }, + function(reply) { + if (reply.status == 200) { + var introspection = JSON.parse(reply.responseBody); + if (introspection.active) { + r.return(200, "OK"); + } else { + r.return(403, "Unauthorized"); + } + } else { + r.return(500, "Internal Server Error"); + } + } + ); +} + +export default { introspectAccessToken }; \ No newline at end of file diff --git a/e2e-tests/oauth-flow/rfc021/run-test.sh b/e2e-tests/oauth-flow/rfc021/run-test.sh index 7fafab3399..e85d8b1874 100755 --- a/e2e-tests/oauth-flow/rfc021/run-test.sh +++ b/e2e-tests/oauth-flow/rfc021/run-test.sh @@ -13,7 +13,7 @@ echo "------------------------------------" echo "Starting Docker containers..." echo "------------------------------------" docker compose up -d -docker compose up --wait nodeA-backend nodeB +docker compose up --wait nodeA nodeA-backend nodeB nodeB-backend echo "------------------------------------" echo "Registering vendors..." @@ -66,14 +66,14 @@ fi echo "------------------------------------" echo "Retrieving data..." echo "------------------------------------" -#RESPONSE=$(docker compose exec nodeB curl --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/ping -H "Authorization: bearer $(cat ./node-B/data/accesstoken.txt)" -v) -#if echo $RESPONSE | grep -q "pong"; then -# echo "success!" -#else -# echo "FAILED: Could not ping node-A" 1>&2 -# echo $RESPONSE -# exitWithDockerLogs 1 -#fi +RESPONSE=$(docker compose exec nodeB curl --http1.1 --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/resource -H "Authorization: bearer $(cat ./node-B/data/accesstoken.txt)" -v) +if echo $RESPONSE | grep -q "OK"; then + echo "success!" +else + echo "FAILED: Could not get resource from node-A" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi echo "------------------------------------" echo "Stopping Docker containers..."