Skip to content

Commit

Permalink
check nonce, split code to reduce func LoC
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed Nov 27, 2023
1 parent 0958244 commit 8e0cbd5
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 132 deletions.
164 changes: 118 additions & 46 deletions auth/api/iam/s2s_vptoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ package iam

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
Expand Down Expand Up @@ -66,7 +65,7 @@ func (s Wrapper) handleS2SAccessTokenRequest(issuer did.DID, params map[string]s
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "assertion parameter is invalid: invalid query escaping",
Description: "assertion parameter is invalid",
InternalError: err,
}
}
Expand All @@ -83,62 +82,33 @@ func (s Wrapper) handleS2SAccessTokenRequest(issuer did.DID, params map[string]s
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation_submission parameter is invalid: invalid query escaping",
Description: "presentation_submission parameter is invalid",
InternalError: err,
}
}
submission, err := pe.ParsePresentationSubmission([]byte(submissionDecoded))
if err = json.Unmarshal([]byte(submissionDecoded), &submission); err != nil {
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation_submission parameter is invalid: invalid JSON",
InternalError: err,
Code: oauth.InvalidRequest,
Description: fmt.Sprintf("invalid presentation submission: %s", err.Error()),
}
}

for _, presentation := range pexEnvelope.Presentations {
// Presenter should be credential holder
err = credential.VerifyPresenterIsCredentialSubject(presentation)
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
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 err := validatePresentationValidity(presentation); err != nil {
return nil, err
}
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),
}
if err := validatePresentationSigner(presentation); err != nil {
return nil, err
}
}

// Validate the presentation submission:
// 1. Resolve presentation definition for the requested scope
// 2. Check submission against presentation and definition
definition := s.auth.PresentationDefinitions().ByScope(scope)
if definition == nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidScope,
Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", scope),
}
var definition *PresentationDefinition
if definition, err = s.validatePresentationSubmission(scope, submission, pexEnvelope); err != nil {
return nil, err
}

_, err = submission.Validate(*pexEnvelope, *definition)
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation submission does not conform to Presentation Definition",
InternalError: err,
for _, presentation := range pexEnvelope.Presentations {
if err := s.validatePresentationNonce(presentation); err != nil {
return nil, err
}
}

Expand All @@ -148,7 +118,7 @@ func (s Wrapper) handleS2SAccessTokenRequest(issuer did.DID, params map[string]s
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "verifiable presentation is invalid",
Description: "presentation(s) or contained credential(s) are invalid",
InternalError: err,
}
}
Expand Down Expand Up @@ -228,6 +198,108 @@ func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presenta
}, nil
}

func (s Wrapper) validatePresentationSubmission(scope string, submission *pe.PresentationSubmission, pexEnvelope *pe.Envelope) (*PresentationDefinition, error) {
// Validate the presentation submission:
// 1. Resolve presentation definition for the requested scope
// 2. Check submission against presentation and definition
definition := s.auth.PresentationDefinitions().ByScope(scope)
if definition == nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidScope,
Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", scope),
}
}

_, err := submission.Validate(*pexEnvelope, *definition)
if err != nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation submission does not conform to Presentation Definition",
InternalError: err,
}
}
return definition, err
}

func validatePresentationValidity(presentation vc.VerifiablePresentation) error {
// Presentation should not be valid for too long
created := credential.PresentationIssuanceDate(presentation)
expires := credential.PresentationExpirationDate(presentation)
if created == nil || expires == nil {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation is missing creation or expiration date",
}
}
if expires.Sub(*created) > maxPresentationValidity {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: fmt.Sprintf("presentation is valid for too long (max %s)", maxPresentationValidity),
}
}
return nil
}

// validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented.
func validatePresentationSigner(presentation vc.VerifiablePresentation) error {
ok, err := credential.PresenterIsCredentialSubject(presentation)
if err != nil {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: err.Error(),
}
}
if !ok {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation signer is not credential subject",
}
}
return nil
}

// validatePresentationNonce checks if the nonce has been used before; 'jti' claim for JWTs or LDProof's 'nonce' for JSON-LD.
func (s Wrapper) validatePresentationNonce(presentation vc.VerifiablePresentation) error {
var nonce string
switch presentation.Format() {
case vc.JWTPresentationProofFormat:
nonce = presentation.JWT().JwtID()
if nonce == "" {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation is missing jti",
}
}
case vc.JSONLDPresentationProofFormat:
proof, err := credential.ParseLDProof(presentation)
if err != nil || proof.Nonce == nil {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
InternalError: err,
Description: "presentation has invalid proof or nonce",
}
}
nonce = *proof.Nonce
}

nonceStore := s.storageEngine.GetSessionDatabase().GetStore(maxPresentationValidity, "s2s", "nonce")
err := nonceStore.Get(nonce, new(bool))
if !errors.Is(err, storage.ErrNotFound) {
if err != nil {
// unable to check nonce
return err
}
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "presentation nonce has already been used",
}
}
if err := nonceStore.Put(nonce, true); err != nil {
return fmt.Errorf("unable to store nonce: %w", err)
}
return nil
}

func (r Wrapper) s2sAccessTokenStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "s2s", "accesstoken")
}
Expand Down
69 changes: 29 additions & 40 deletions auth/api/iam/s2s_vptoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import (
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/nuts-foundation/nuts-node/vcr/test"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -193,7 +192,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

_, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

require.EqualError(t, err, "invalid_request - assertion parameter is invalid: missing creation or expiration date")
require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date")
})
t.Run("missing presentation not before date", func(t *testing.T) {
ctx := newTestClient(t)
Expand All @@ -209,7 +208,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

_, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

require.EqualError(t, err, "invalid_request - assertion parameter is invalid: missing creation or expiration date")
require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date")
})
t.Run("missing presentation valid for too long", func(t *testing.T) {
ctx := newTestClient(t)
Expand All @@ -225,7 +224,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

_, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

require.EqualError(t, err, "invalid_request - assertion parameter is invalid: presentation is valid for too long (max 10s)")
require.EqualError(t, err, "invalid_request - presentation is valid for too long (max 10s)")
})
t.Run("JWT VP", func(t *testing.T) {
ctx := newTestClient(t)
Expand All @@ -248,6 +247,22 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
assert.NotEmpty(t, tokenResponse.AccessToken)
})
t.Run("replay attack (nonce is reused)", func(t *testing.T) {
ctx := newTestClient(t)
ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
params := map[string]string{
"assertion": url.QueryEscape(presentation.Raw()),
"presentation_submission": url.QueryEscape(string(submissionJSON)),
"scope": requestedScope,
}

_, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)
require.NoError(t, err)

resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)
assert.EqualError(t, err, "invalid_request - presentation nonce has already been used")
assert.Nil(t, resp)
})
t.Run("VP is not valid JSON", func(t *testing.T) {
ctx := newTestClient(t)
params := map[string]string{
Expand All @@ -273,37 +288,15 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, "invalid_request - invalid - verifiable presentation is invalid")
assert.EqualError(t, err, "invalid_request - invalid - presentation(s) or contained credential(s) are invalid")
assert.Nil(t, resp)
})
t.Run("proof of ownership", func(t *testing.T) {
t.Run("VP has no proof", func(t *testing.T) {
ctx := newTestClient(t)
verifiablePresentation := vc.VerifiablePresentation{
VerifiableCredential: []vc.VerifiableCredential{verifiableCredential},
}
verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON()
params := map[string]string{
"assertion": url.QueryEscape(string(verifiablePresentationJSON)),
"presentation_submission": url.QueryEscape(string(submissionJSON)),
"scope": requestedScope,
}

resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, `invalid_request - verifiable presentation is invalid: presentation should have exactly 1 proof, got 0`)
assert.Nil(t, resp)
})
t.Run("VC without credentialSubject.id", func(t *testing.T) {
ctx := newTestClient(t)
verifiablePresentation := vc.VerifiablePresentation{
VerifiableCredential: []vc.VerifiableCredential{
{
CredentialSubject: []interface{}{map[string]string{}},
},
},
Proof: []interface{}{proof.LDProof{Type: ssi.JsonWebSignature2020}},
}
verifiablePresentation := test.CreateJSONLDPresentation(t, *subjectDID, vc.VerifiableCredential{
CredentialSubject: []interface{}{map[string]string{}},
})
verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON()
params := map[string]string{
"assertion": url.QueryEscape(string(verifiablePresentationJSON)),
Expand All @@ -313,20 +306,16 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, `invalid_request - verifiable presentation is invalid: invalid verification method for JSON-LD presentation: %!w(<nil>)`)
assert.EqualError(t, err, `invalid_request - unable to get subject DID from VC: credential subjects have no ID`)
assert.Nil(t, resp)
})
t.Run("signing key is not owned by credentialSubject.id", func(t *testing.T) {
ctx := newTestClient(t)
otherKeyID := ssi.MustParseURI("did:example:other#1")
invalidProof := presentation.Proof[0].(map[string]interface{})
invalidProof["verificationMethod"] = "did:example:other#1"
verifiablePresentation := vc.VerifiablePresentation{
VerifiableCredential: []vc.VerifiableCredential{verifiableCredential},
Proof: []interface{}{
proof.LDProof{
Type: ssi.JsonWebSignature2020,
VerificationMethod: otherKeyID,
},
},
Proof: []interface{}{invalidProof},
}
verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON()
params := map[string]string{
Expand All @@ -337,7 +326,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, `invalid_request - verifiable presentation is invalid: not all VC credentialSubject.id match VP signer`)
assert.EqualError(t, err, `invalid_request - presentation signer is not credential subject`)
assert.Nil(t, resp)
})
})
Expand All @@ -351,7 +340,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {

resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, `invalid_request - invalid character 'o' in literal null (expecting 'u') - presentation_submission parameter is invalid: invalid JSON`)
assert.EqualError(t, err, `invalid_request - invalid presentation submission: invalid character 'o' in literal null (expecting 'u')`)
assert.Nil(t, resp)
})
t.Run("unsupported scope", func(t *testing.T) {
Expand Down
24 changes: 17 additions & 7 deletions vcr/credential/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,27 @@ func PresentationSigner(presentation vc.VerifiablePresentation) (*did.DID, error
case vc.JSONLDCredentialProofFormat:
fallthrough
default:
var proofs []proof.LDProof
if err := presentation.UnmarshalProofValue(&proofs); err != nil {
return nil, fmt.Errorf("invalid LD-proof for presentation: %w", err)
proof, err := ParseLDProof(presentation)
if err != nil {
return nil, err
}
if len(proofs) != 1 {
return nil, fmt.Errorf("presentation should have exactly 1 proof, got %d", len(proofs))
}
verificationMethod, err := did.ParseDIDURL(proofs[0].VerificationMethod.String())
verificationMethod, err := did.ParseDIDURL(proof.VerificationMethod.String())
if err != nil || verificationMethod.DID.Empty() {
return nil, fmt.Errorf("invalid verification method for JSON-LD presentation: %w", err)
}
return &verificationMethod.DID, nil
}
}

// ParseLDProof parses the LinkedData proof from the presentation.
// It returns an error if the presentation does not have exactly 1 proof.
func ParseLDProof(presentation vc.VerifiablePresentation) (*proof.LDProof, error) {
var proofs []proof.LDProof
if err := presentation.UnmarshalProofValue(&proofs); err != nil {
return nil, fmt.Errorf("invalid LD-proof for presentation: %w", err)
}
if len(proofs) != 1 {
return nil, fmt.Errorf("presentation should have exactly 1 proof, got %d", len(proofs))
}
return &proofs[0], nil
}
Loading

0 comments on commit 8e0cbd5

Please sign in to comment.