Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions eudi/credentials/sdjwtvc/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"github.com/privacybydesign/irmago/eudi/utils"
)

const ClockSkewInSeconds = 180

// VerificationContext contains some options and configuration for verifying SD-JWT VCs.
type VerificationContext struct {
// Used to fetch the issuer metadata found at the `iss` field.
Expand Down Expand Up @@ -268,26 +270,29 @@ func (f *HttpIssuerMetadataFetcher) FetchIssuerMetadata(url string) (IssuerMetad

func verifyTime(context VerificationContext, issuerSignedJwtPayload IssuerSignedJwtPayload, kbjwtPayload *KeyBindingJwtPayload) error {
now := context.Clock.Now()
minSkewNow := now - ClockSkewInSeconds
maxSkewNow := now + ClockSkewInSeconds

iat := issuerSignedJwtPayload.IssuedAt
exp := issuerSignedJwtPayload.Expiry
nbf := issuerSignedJwtPayload.NotBefore

if nbf != 0 && now < nbf {
return fmt.Errorf("verification before nbf: now: %v < nbf: %v", now, nbf)
if nbf != 0 && maxSkewNow < nbf {
Comment thread
kamphuisem marked this conversation as resolved.
return fmt.Errorf("verification before nbf: now: %v + skew: %v < nbf: %v", now, ClockSkewInSeconds, nbf)
}

if now < iat {
return fmt.Errorf("verification before issued at: %v < %v", now, iat)
if maxSkewNow < iat {
return fmt.Errorf("verification before issued at: %v + skew: %v < %v", now, ClockSkewInSeconds, iat)
}

if exp != 0 && now > exp {
return fmt.Errorf("verification after expiry of issuer signed jwt: %v > %v", now, exp)
if exp != 0 && minSkewNow > exp {
return fmt.Errorf("verification after expiry of issuer signed jwt: %v - skew: %v > %v", now, ClockSkewInSeconds, exp)
}

if kbjwtPayload != nil {
kbiat := kbjwtPayload.IssuedAt
if now < kbiat {
return fmt.Errorf("verification before issued at of kbjwt: %v < %v", now, iat)
if maxSkewNow < kbiat {
return fmt.Errorf("verification before issued at of kbjwt: %v + skew %v < %v", now, ClockSkewInSeconds, kbiat)
}
}

Expand Down Expand Up @@ -341,9 +346,10 @@ func parseAndVerifyKeyBindingJwt(
}

now := context.Clock.Now()
maxSkewNow := now + ClockSkewInSeconds

if payload.IssuedAt >= now {
return KeyBindingJwtPayload{}, fmt.Errorf("iat value (%v) was after current time (%v)", payload.IssuedAt, now)
if payload.IssuedAt >= maxSkewNow {
return KeyBindingJwtPayload{}, fmt.Errorf("kbjwt iat value (%v) was after current time (%v)", payload.IssuedAt, now)
}

return KeyBindingJwtPayload{}, nil
Expand Down
70 changes: 62 additions & 8 deletions eudi/credentials/sdjwtvc/verifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sdjwtvc

import (
"testing"
"time"

"github.com/privacybydesign/irmago/eudi"
"github.com/privacybydesign/irmago/testdata"
Expand All @@ -19,8 +20,9 @@ import (
// - [x] iss link with wrong key in metadata
// - [x] iss link with wrong issuer url in metadata
// - [x] iss link is non-https, when it should be
// - [x] clock.now is before nbf
// - [x] clock.now is after exp
// - [x] clock.now + skew is before iat
// - [x] clock.now + skew is before nbf
// - [x] clock.now - skew is after exp
// - [x] cnf missing while there is a kbjwt
// - [x] cnf contains wrong key to verify kbjwt
// - [x] mismatch for sd_hash field in kbjwt
Expand All @@ -44,6 +46,9 @@ import (
// - [x] iss link is non-https, but is accepted (for testing purposes)
// - [x] valid self-signed x509 certificate with DNS/URI value that matches `iss` value
// - [x] valid x509 certificate chain with DNS/URI value that matches `iss` value
// - [x] clock.now - 1 minute is before iat (valid because of skew)
// - [x] clock.now - 1 minute is before nbf (valid because of skew)
// - [x] clock.now + 1 minute is after exp (valid because of skew)

// =======================================================================

Expand Down Expand Up @@ -436,10 +441,13 @@ func Test_WrongKeyInIssuerMetadata_Fails(t *testing.T) {
}

func Test_IatIsAfterVerification_Fails(t *testing.T) {
config := newWorkingSdJwtTestConfig().withIssuedAt(100).withKbIssuedAt(101)
now := time.Now().Unix()
iat := now + ClockSkewInSeconds + 100
kbIat := now
config := newWorkingSdJwtTestConfig().withIssuedAt(iat).withKbIssuedAt(kbIat)
context := VerificationContext{
IssuerMetadataFetcher: NewHttpIssuerMetadataFetcher(),
Clock: &testClock{time: 90},
Clock: &testClock{time: now},
JwtVerifier: NewJwxJwtVerifier(),
}

Expand All @@ -449,10 +457,12 @@ func Test_IatIsAfterVerification_Fails(t *testing.T) {
}

func Test_VerificationIsAfterExp_Fails(t *testing.T) {
config := newWorkingSdJwtTestConfig().withIssuedAt(50).withKbIssuedAt(70).withExpiryTime(100)
now := time.Now().Unix()
exp := now - ClockSkewInSeconds - 100
config := newWorkingSdJwtTestConfig().withIssuedAt(now).withKbIssuedAt(now).withExpiryTime(exp)
context := VerificationContext{
IssuerMetadataFetcher: NewHttpIssuerMetadataFetcher(),
Clock: &testClock{time: 200},
Clock: &testClock{time: now},
JwtVerifier: NewJwxJwtVerifier(),
}

Expand All @@ -462,10 +472,12 @@ func Test_VerificationIsAfterExp_Fails(t *testing.T) {
}

func Test_VerificationIsBeforeNotBefore_Fails(t *testing.T) {
config := newWorkingSdJwtTestConfig().withIssuedAt(40).withKbIssuedAt(40).withExpiryTime(100).withNotBefore(50)
now := time.Now().Unix()
nbf := now + ClockSkewInSeconds + 50
config := newWorkingSdJwtTestConfig().withIssuedAt(now).withKbIssuedAt(now).withExpiryTime(100).withNotBefore(nbf)
context := VerificationContext{
IssuerMetadataFetcher: NewHttpIssuerMetadataFetcher(),
Clock: &testClock{time: 45},
Clock: &testClock{time: now},
JwtVerifier: NewJwxJwtVerifier(),
}

Expand All @@ -474,6 +486,48 @@ func Test_VerificationIsBeforeNotBefore_Fails(t *testing.T) {
requireErr(t, err)
}

func Test_VerificationMinusOneMinuteIsBeforeIat_GivenClockSkew_Success(t *testing.T) {
now := time.Now().Unix()
config := newWorkingSdJwtTestConfig().withIssuedAt(now).withKbIssuedAt(now)
context := VerificationContext{
IssuerMetadataFetcher: NewHttpIssuerMetadataFetcher(),
Clock: &testClock{time: now - 60},
JwtVerifier: NewJwxJwtVerifier(),
}

sdjwtvc := createTestSdJwtVc(t, config)
_, err := ParseAndVerifySdJwtVc(context, sdjwtvc)
require.NoError(t, err)
}

func Test_VerificationPlusOneMinuteIsAfterExp_GivenClockSkew_Success(t *testing.T) {
now := time.Now().Unix()
config := newWorkingSdJwtTestConfig().withIssuedAt(now).withKbIssuedAt(now).withExpiryTime(now)
context := VerificationContext{
IssuerMetadataFetcher: NewHttpIssuerMetadataFetcher(),
Clock: &testClock{time: now + 60},
JwtVerifier: NewJwxJwtVerifier(),
}

sdjwtvc := createTestSdJwtVc(t, config)
_, err := ParseAndVerifySdJwtVc(context, sdjwtvc)
require.NoError(t, err)
}

func Test_VerificationMinusOneMinuteIsBeforeNotBefore_GivenClockSkew_Success(t *testing.T) {
now := time.Now().Unix()
config := newWorkingSdJwtTestConfig().withIssuedAt(now).withKbIssuedAt(now).withNotBefore(now)
context := VerificationContext{
IssuerMetadataFetcher: NewHttpIssuerMetadataFetcher(),
Clock: &testClock{time: now - 60},
JwtVerifier: NewJwxJwtVerifier(),
}

sdjwtvc := createTestSdJwtVc(t, config)
_, err := ParseAndVerifySdJwtVc(context, sdjwtvc)
require.NoError(t, err)
}

// ==============================================================================

func errorTestCase(t *testing.T, config testSdJwtVcConfig, message string) {
Expand Down
71 changes: 71 additions & 0 deletions eudi/descriptions.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
package eudi

import (
"fmt"
"slices"

"github.com/go-errors/errors"
"github.com/privacybydesign/irmago/eudi/openid4vp/dcql"
)

type RelyingPartyRequestor struct {
Requestor
RelyingParty RelyingParty `json:"rp"`
Expand Down Expand Up @@ -30,3 +38,66 @@ type QueryableAttributeSet struct {
Credential string `json:"credential"`
Attributes []string `json:"attributes"`
}

// SchemeQueryValidator validates queries against the relying party's authorized attribute sets.
// SchemeQueryValidator implements eudi/openid4vp/dcql/QueryValidator interface.
type SchemeQueryValidator struct {
RelyingParty *RelyingParty
}

func (v *SchemeQueryValidator) ValidateQuery(query *dcql.DcqlQuery) error {
if v.RelyingParty == nil {
return fmt.Errorf("relying party is not set")
}

// Validate the query against the relying party's authorized attribute sets
for _, query := range query.Credentials {
// TODO: validate `id` is correctly formatted and is present once in the query
// TODO: validate `format` is present and valid
// TODO: validate `meta` is present and valid

if len(query.Meta.VctValues) == 0 {
return errors.New("credential query is missing vct_values")
}

if err := isQueryAuthorized(query, v.RelyingParty.AuthorizedQueryableAttributeSets); err != nil {
return err
}
}

return nil
}

func isQueryAuthorized(query dcql.CredentialQuery, authorizedAttributeSets []QueryableAttributeSet) error {
for _, vctValue := range query.Meta.VctValues {
authorizedCredential := false
for _, authorizedSet := range authorizedAttributeSets {
if authorizedSet.Credential == vctValue {
authorizedCredential = true

// Credential is authorized, validate the query claims against the attributes
for _, claim := range query.Claims {
if err := isSubset(vctValue, []string(claim.Path), authorizedSet.Attributes); err != nil {
return fmt.Errorf("credential query %v is not authorized: %v", query, err)
}
}
break
}
}

if !authorizedCredential {
return fmt.Errorf("credential query is not authorized: credential %s is not in the authorized set", vctValue)
}
}

return nil
}

func isSubset(vctValue string, subset []string, superset []string) error {
for _, s := range subset {
if !slices.Contains(superset, s) {
return fmt.Errorf("requested attribute %s.%v is not in the authorized set", vctValue, s)
}
}
return nil
}
Loading