diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index e8cdc87b5a..cab7c19853 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -41,6 +41,9 @@ import ( // TODO: Might want to make this configurable at some point const accessTokenValidity = 15 * time.Minute +// maxPresentationValidity defines the maximum validity of a presentation. +const maxPresentationValidity = 10 * time.Second + // handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges. // It performs cheap checks first (parameter presence and validity), then the more expensive ones (checking signatures and matching VCs to the presentation definition): // 1. Check presence and format of parameters @@ -94,6 +97,7 @@ func (s Wrapper) handleS2SAccessTokenRequest(issuer did.DID, params map[string]s } for _, presentation := range pexEnvelope.Presentations { + // Presenter should be credential holder err = credential.VerifyPresenterIsCredentialSubject(presentation) if err != nil { return nil, oauth.OAuth2Error{ @@ -101,6 +105,21 @@ func (s Wrapper) handleS2SAccessTokenRequest(issuer did.DID, params map[string]s Description: fmt.Sprintf("verifiable presentation is invalid: %s", err.Error()), } } + // Presentation should not be valid for too long + created := credential.PresentationIssuanceDate(presentation) + expires := credential.PresentationExpirationDate(presentation) + if created == nil || expires == nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "assertion parameter is invalid: missing creation or expiration date", + } + } + if expires.Sub(*created) > maxPresentationValidity { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: fmt.Sprintf("assertion parameter is invalid: presentation is valid for too long (max %s)", maxPresentationValidity), + } + } } // Validate the presentation submission: diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 0573fbabf0..6e6807527f 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -19,6 +19,7 @@ package iam import ( + "github.com/lestrrat-go/jwx/v2/jwt" "net/http" "testing" "time" @@ -178,9 +179,57 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { assert.EqualError(t, err, "invalid_request - missing required parameters") assert.Nil(t, resp) }) + t.Run("missing presentation expiry date", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Remove(jwt.ExpirationKey)) + }, verifiableCredential) + + params := map[string]string{ + "assertion": url.QueryEscape(presentation.Raw()), + "presentation_submission": url.QueryEscape(string(submissionJSON)), + "scope": requestedScope, + } + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params) + + require.EqualError(t, err, "invalid_request - assertion parameter is invalid: missing creation or expiration date") + }) + t.Run("missing presentation not before date", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Remove(jwt.NotBeforeKey)) + }, verifiableCredential) + + params := map[string]string{ + "assertion": url.QueryEscape(presentation.Raw()), + "presentation_submission": url.QueryEscape(string(submissionJSON)), + "scope": requestedScope, + } + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params) + + require.EqualError(t, err, "invalid_request - assertion parameter is invalid: missing creation or expiration date") + }) + t.Run("missing presentation valid for too long", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour))) + }, verifiableCredential) + + params := map[string]string{ + "assertion": url.QueryEscape(presentation.Raw()), + "presentation_submission": url.QueryEscape(string(submissionJSON)), + "scope": requestedScope, + } + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params) + + require.EqualError(t, err, "invalid_request - assertion parameter is invalid: presentation is valid for too long (max 10s)") + }) t.Run("JWT VP", func(t *testing.T) { ctx := newTestClient(t) - presentation := test.CreateJWTPresentation(t, *subjectDID, verifiableCredential) + presentation := test.CreateJWTPresentation(t, *subjectDID, nil, verifiableCredential) ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) params := map[string]string{ diff --git a/vcr/credential/util.go b/vcr/credential/util.go index ecf0885dbc..844a768102 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -22,6 +22,8 @@ import ( "errors" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "time" ) // ResolveSubjectDID resolves the subject DID from the given credentials. @@ -62,3 +64,58 @@ func VerifyPresenterIsCredentialSubject(vp vc.VerifiablePresentation) error { } return nil } + +// PresentationIssuanceDate returns the date at which the presentation was issued. +// For JSON-LD, it looks at the first LinkedData proof's 'created' property. +// For JWT, it looks at the 'iat' claim, or if that is not present, the 'nbf' claim. +// If it can't resolve the date, it returns nil. +func PresentationIssuanceDate(presentation vc.VerifiablePresentation) *time.Time { + var result time.Time + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + jwt := presentation.JWT() + if result = jwt.IssuedAt(); result.IsZero() { + result = jwt.NotBefore() + } + case vc.JSONLDPresentationProofFormat: + var proofs []proof.LDProof + if err := presentation.UnmarshalProofValue(&proofs); err != nil { + return nil + } + if len(proofs) == 0 { + return nil + } + result = proofs[0].Created + } + if result.IsZero() { + return nil + } + return &result +} + +// PresentationExpirationDate returns the date at which the presentation was issued. +// For JSON-LD, it looks at the first LinkedData proof's 'expires' property. +// For JWT, it looks at the 'exp' claim. +// If it can't resolve the date, it returns nil. +func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Time { + var result time.Time + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + result = presentation.JWT().Expiration() + case vc.JSONLDPresentationProofFormat: + var proofs []proof.LDProof + if err := presentation.UnmarshalProofValue(&proofs); err != nil { + return nil + } + if len(proofs) == 0 { + return nil + } + if proofs[0].Expires != nil { + result = *proofs[0].Expires + } + } + if result.IsZero() { + return nil + } + return &result +} diff --git a/vcr/credential/util_test.go b/vcr/credential/util_test.go index 55b83ff9b1..f2e6fe1e16 100644 --- a/vcr/credential/util_test.go +++ b/vcr/credential/util_test.go @@ -19,12 +19,16 @@ package credential import ( + "github.com/lestrrat-go/jwx/v2/jwt" 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/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" + "time" ) func TestResolveSubjectDID(t *testing.T) { @@ -166,3 +170,105 @@ func TestVerifyPresenterIsCredentialSubject(t *testing.T) { assert.EqualError(t, err, "presentation should have exactly 1 proof, got 2") }) } + +func TestPresentationIssuanceDate(t *testing.T) { + presenterDID := did.MustParseDID("did:test:123") + expected := time.Now().In(time.UTC).Truncate(time.Second) + t.Run("JWT iat", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.NotBeforeKey) + require.NoError(t, token.Set(jwt.IssuedAtKey, expected)) + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT nbf", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.IssuedAtKey) + require.NoError(t, token.Set(jwt.NotBeforeKey, expected)) + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT no iat or nbf", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.IssuedAtKey) + _ = token.Remove(jwt.NotBeforeKey) + }) + actual := PresentationIssuanceDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + ProofOptions: proof.ProofOptions{ + Created: expected, + }, + }, + }, + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JSON-LD no proof", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{}) + actual := PresentationIssuanceDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD no created", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{}, + }, + }) + actual := PresentationIssuanceDate(presentation) + assert.Nil(t, actual) + }) +} + +func TestPresentationExpirationDate(t *testing.T) { + presenterDID := did.MustParseDID("did:test:123") + expected := time.Now().In(time.UTC).Truncate(time.Second) + t.Run("JWT", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.ExpirationKey, expected)) + }) + actual := PresentationExpirationDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT no exp", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.ExpirationKey) + }) + actual := PresentationExpirationDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + ProofOptions: proof.ProofOptions{ + Expires: &expected, + }, + }, + }, + }) + actual := PresentationExpirationDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JSON-LD no proof", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{}) + actual := PresentationExpirationDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD no expires", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{}, + }, + }) + actual := PresentationExpirationDate(presentation) + assert.Nil(t, actual) + }) +} diff --git a/vcr/test/test.go b/vcr/test/test.go index 96d887579c..7a02a7dc5d 100644 --- a/vcr/test/test.go +++ b/vcr/test/test.go @@ -36,7 +36,7 @@ import ( ) // CreateJWTPresentation creates a JWT presentation with the given subject DID and credentials. -func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { +func CreateJWTPresentation(t *testing.T, subjectDID did.DID, tokenVisitor func(token jwt.Token), credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { headers := jws.NewHeaders() require.NoError(t, headers.Set(jws.TypeKey, "JWT")) claims := map[string]interface{}{ @@ -44,7 +44,7 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.V jwt.SubjectKey: subjectDID.String(), jwt.JwtIDKey: subjectDID.String() + "#" + uuid.NewString(), jwt.NotBeforeKey: time.Now().Unix(), - jwt.ExpirationKey: time.Now().Add(time.Hour).Unix(), + jwt.ExpirationKey: time.Now().Add(5 * time.Second).Unix(), "vp": vc.VerifiablePresentation{ Type: []ssi.URI{vc.VerifiablePresentationTypeV1URI()}, VerifiableCredential: credentials, @@ -54,6 +54,9 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.V for k, v := range claims { require.NoError(t, unsignedToken.Set(k, v)) } + if tokenVisitor != nil { + tokenVisitor(unsignedToken) + } privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) token, _ := jwt.Sign(unsignedToken, jwt.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers))) result, err := vc.ParseVerifiablePresentation(string(token)) @@ -65,16 +68,28 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.V // The presentation is not actually signed. func CreateJSONLDPresentation(t *testing.T, subjectDID did.DID, verifiableCredential ...vc.VerifiableCredential) vc.VerifiablePresentation { id := ssi.MustParseURI(subjectDID.String() + "#" + uuid.NewString()) - data, _ := vc.VerifiablePresentation{ + exp := time.Now().Add(5 * time.Second) + vp := vc.VerifiablePresentation{ ID: &id, VerifiableCredential: verifiableCredential, Proof: []interface{}{ proof.LDProof{ Type: ssi.JsonWebSignature2020, VerificationMethod: ssi.MustParseURI(subjectDID.String() + "#1"), + ProofOptions: proof.ProofOptions{ + Created: time.Now(), + Expires: &exp, + }, }, }, - }.MarshalJSON() + } + return ParsePresentation(t, vp) +} + +// ParsePresentation marshals the given presentation and parses it again, to make sure the format property is set correctly. +func ParsePresentation(t *testing.T, presentation vc.VerifiablePresentation) vc.VerifiablePresentation { + data, err := presentation.MarshalJSON() + require.NoError(t, err) result, err := vc.ParseVerifiablePresentation(string(data)) require.NoError(t, err) return *result