diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5b60b66..9dbbb22ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.0] - 2026-07-22 ### Added +- Support for the IETF OAuth Token Status List (draft-ietf-oauth-status-list-15) on SD-JWT VC credentials: the wallet fetches, verifies, and caches Status List Tokens (`application/statuslist+jwt`), checks credential status at issuance, at disclosure, and on a background sweep, and exposes `Client.RefreshStatuses(ctx)` for UI-initiated refreshes. - `eudi/holderkeys`: a CGO-free package providing the holder-key seam (`HolderSigner`, `SoftwareHolderSigner`, the KB-JWT `NewSignerKeyBinder` bridge) so a WSCA adapter or a server-side (Postgres) holder can implement external holder-key signing without pulling in a sqlcipher (cgo) dependency. - Pluggable holder-key binding seams for external secure devices (WSCA/HSM): `openid4vci.NewClient` takes a required `HolderKeyBinder` and `eudi_sdjwt_dcql.NewSdJwtVcDcqlHandler` a required `sdjwtvc.KeyBinder`, and `proofs.BuildWithES256Signer` signs the OpenID4VCI proof of possession via an external signer. Callers pass the software, storage-backed binder for the existing behaviour, or a WSCA/HSM-backed implementation. - `storage.NewStorageWithDialector(dialector, fs)`: open the EUDI holder database on any GORM dialector (e.g. `gorm.io/driver/postgres`) rather than only sqlcipher, for server-side / multi-tenant deployments. `NewStorage` is unchanged (it builds the sqlcipher dialector and delegates). The caller owns the at-rest encryption posture of a non-sqlcipher driver. diff --git a/client/client.go b/client/client.go index 02dea6d5c..a379e6981 100644 --- a/client/client.go +++ b/client/client.go @@ -1,6 +1,7 @@ package client import ( + "context" "encoding/json" "fmt" "path/filepath" @@ -16,6 +17,7 @@ import ( "github.com/privacybydesign/irmago/eudi" "github.com/privacybydesign/irmago/eudi/credentials/sdjwtvc" "github.com/privacybydesign/irmago/eudi/credentials/sdjwtvc/typemetadata" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" "github.com/privacybydesign/irmago/eudi/openid4vci" "github.com/privacybydesign/irmago/eudi/openid4vp" @@ -35,17 +37,19 @@ import ( ) type Client struct { - storage *clientstorage.Storage - eudiStorage storage.Storage - sdjwtvcStorage irmaclient.SdJwtVcStorage - openid4vpClient *openid4vp.Client - openid4vciClient *openid4vci.Client - irmaClient *irmaclient.IrmaClient - logsStorage irmaclient.LogsStorage - keyBinder sdjwtvc.KeyBinder - didValidator *openid4vp.DidVerifierValidator - scheduler gocron.Scheduler - sessionManager sessionManager + storage *clientstorage.Storage + eudiStorage storage.Storage + sdjwtvcStorage irmaclient.SdJwtVcStorage + openid4vpClient *openid4vp.Client + openid4vciClient *openid4vci.Client + irmaClient *irmaclient.IrmaClient + logsStorage irmaclient.LogsStorage + keyBinder sdjwtvc.KeyBinder + didValidator *openid4vp.DidVerifierValidator + scheduler gocron.Scheduler + sessionManager sessionManager + credentialService services.CredentialService + revocationService *services.RevocationService // TODO: move preferences from IrmaClient to here //Preferences clientsettings.Preferences } @@ -108,6 +112,23 @@ func New( keyBindingStorage := irmaclient.NewBboltKeyBindingStorage(s) irmaKeyBinder := sdjwtvc.NewDefaultKeyBinder(keyBindingStorage) + credStore := db.NewCredentialStore(eudiStorage.Db()) + hbkStore := db.NewHolderBindingKeyStore(eudiStorage.Db()) + + // Token Status List checker + the single revocation service built on it. + // The checker is also shared with the holder-side verifier + // (sdJwtVcVerificationContext below). The revocation service is the one home + // for revocation: the background sweep, the credential list's flags, and the + // OpenID4VP disclosure planner's cached Revoked flag all go through it. + statusListCache := db.NewStatusListCacheStore(eudiStorage.Db()) + statusChecker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: &eudiConf.Issuers, + Clock: eudi_jwt.NewSystemClock(), + }, statusListCache) + revocationService := services.NewRevocationService(statusChecker, credStore) + + credentialService := services.NewCredentialService(credStore, hbkStore, eudiStorage.FileSystem(), revocationService) + // Verifier verification checks if the verifier is trusted x509Validator := openid4vp.NewRequestorCertificateStoreVerifierValidator(&eudiConf.Verifiers, &openid4vp.DefaultQueryValidatorFactory{}) didValidator := openid4vp.NewDidVerifierValidator(false) @@ -120,9 +141,11 @@ func New( // blank permission prompt. eudiSdJwtDcqlHandler := eudi_sdjwt_dcql.NewSdJwtVcDcqlHandler( eudiStorage, + credStore, typemetadata.NewDefaultVctFetcher(nil), typemetadata.NewDefaultIssuerFetcher(nil), sdjwtvc.NewDefaultKeyBinder(services.NewHolderBindingKeyService(eudiStorage.Db())), + revocationService, ) irmaSdJwtDcqlHandler := irma_sdjwt_dcql.NewIrmaSdJwtVcDcqlHandler(sdjwtvcStorage, irmaConf, irmaKeyBinder) @@ -137,6 +160,7 @@ func New( Clock: eudi_jwt.NewSystemClock(), JwtVerifier: sdjwtvc.NewJwxJwtVerifier(), VerifyVerifiableCredentialTypeInRequestorInfo: true, + StatusChecker: statusChecker, } irmaClient, err := irmaclient.NewIrmaClient(irmaConf, handler, signer, irmaStorage, sdJwtVcVerificationContext, sdjwtvcStorage, irmaKeyBinder) @@ -166,6 +190,7 @@ func New( Clock: eudi_jwt.NewSystemClock(), JwtVerifier: sdjwtvc.NewJwxJwtVerifier(), VerifyVerifiableCredentialTypeInRequestorInfo: false, + StatusChecker: statusChecker, } // Initiate the OpenID4VCI client @@ -173,6 +198,7 @@ func New( common.HTTPClient, eudiConf, sdjwtvc.NewHolderVerificationProcessor(sdJwtVcVerificationContextOpenID4VCI), + credentialService, services.NewHolderBindingKeyService(eudiConf.Storage.Db()), ) @@ -186,16 +212,18 @@ func New( irmaClient.SetOnSessionDoneCallback(openid4vpClient.RefreshPendingPermissionRequest) client := &Client{ - storage: s, - sdjwtvcStorage: sdjwtvcStorage, - eudiStorage: eudiStorage, - openid4vpClient: openid4vpClient, - openid4vciClient: openid4vciClient, - irmaClient: irmaClient, - logsStorage: irmaStorage, - keyBinder: irmaKeyBinder, - didValidator: didValidator, - scheduler: scheduler, + storage: s, + sdjwtvcStorage: sdjwtvcStorage, + eudiStorage: eudiStorage, + openid4vpClient: openid4vpClient, + openid4vciClient: openid4vciClient, + irmaClient: irmaClient, + logsStorage: irmaStorage, + keyBinder: irmaKeyBinder, + didValidator: didValidator, + scheduler: scheduler, + credentialService: credentialService, + revocationService: revocationService, sessionManager: sessionManager{ Sessions: map[int]*session{}, SessionHandler: sessionHandler, @@ -213,6 +241,15 @@ func (client *Client) Close() error { return client.storage.Close() } +// RefreshStatuses re-fetches the Token Status List for every stored +// SD-JWT VC instance and updates its LastKnownStatus column. Use +// this on app resume or when the UI exposes an explicit refresh +// action. Errors during the sweep are logged; the previous +// LastKnownStatus persists for any URI that fails to refresh. +func (client *Client) RefreshStatuses(ctx context.Context) error { + return client.revocationService.RefreshStatuses(ctx) +} + type SessionRequestData struct { irma.Qr Protocol clientmodels.Protocol `json:"protocol,omitempty"` @@ -397,8 +434,7 @@ func (client *Client) RemoveCredentialsByHash(hashByFormat map[clientmodels.Cred // Delete EUDI credentials (read metadata first for the removal log). if len(eudiHashes) > 0 { - credentialService := services.NewCredentialService(client.eudiStorage) - allEudiCreds, err := credentialService.GetCredentialMetadataList() + allEudiCreds, err := client.credentialService.GetCredentialMetadataList() if err != nil { return fmt.Errorf("failed to read eudi credentials for removal log: %v", err) } @@ -424,9 +460,8 @@ func (client *Client) RemoveCredentialsByHash(hashByFormat map[clientmodels.Cred } } - credentialStore := db.NewCredentialStore(client.eudiStorage.Db()) for _, hash := range eudiHashes { - if err := credentialStore.DeleteBatchByHash(hash); err != nil { + if err := client.credentialService.DeleteByHash(hash); err != nil { return fmt.Errorf("error while deleting eudi credential: %v", err) } } @@ -588,15 +623,35 @@ func (client *Client) GetPreferences() clientsettings.Preferences { return client.irmaClient.Preferences } -func (client *Client) InitJobs(eudiRevocationListUpdateInterval time.Duration) { +func (client *Client) InitJobs(eudiCrlUpdateInterval, statusTokenListRefreshInterval time.Duration) { // Future TODO: add Context so we can check for cancellation of the job ? _, err := client.scheduler.NewJob( - gocron.DurationJob(eudiRevocationListUpdateInterval), + gocron.DurationJob(eudiCrlUpdateInterval), gocron.NewTask(client.openid4vpClient.Configuration.UpdateCertificateRevocationLists), gocron.WithStartAt(gocron.WithStartImmediately()), ) if err != nil { - common.Logger.Warnf("failed to create new cron job for updating CLRs: %v", err) + common.Logger.Warnf("failed to create new cron job for updating CRLs: %v", err) + } + + // Periodically re-fetch referenced Token Status Lists and update one + // representative instance's LastKnownStatus per credential batch (a batch is + // revoked all at once, so one entry stands in for the whole batch). Skipped + // when the interval is non-positive. The sweep is fail-soft: per-URI errors + // are logged inside RefreshStatuses and the previous status is kept. + if statusTokenListRefreshInterval > 0 { + _, err = client.scheduler.NewJob( + gocron.DurationJob(statusTokenListRefreshInterval), + gocron.NewTask(func() { + if err := client.RefreshStatuses(context.Background()); err != nil { + common.Logger.Warnf("scheduled status refresh failed: %v", err) + } + }), + gocron.WithStartAt(gocron.WithStartImmediately()), + ) + if err != nil { + common.Logger.Warnf("failed to create new cron job for refreshing credential statuses: %v", err) + } } } diff --git a/client/schemaless.go b/client/schemaless.go index ff1e97573..121d9094a 100644 --- a/client/schemaless.go +++ b/client/schemaless.go @@ -7,7 +7,6 @@ import ( "time" "github.com/privacybydesign/irmago/common/clientmodels" - "github.com/privacybydesign/irmago/eudi/services" "github.com/privacybydesign/irmago/irma" "github.com/privacybydesign/irmago/irma/irmaclient" ) @@ -338,8 +337,7 @@ func (client *Client) GetCredentials() ([]*clientmodels.Credential, error) { } // Get EUDI credentials and convert to the same format, then combine with IRMA credentials. - credentialService := services.NewCredentialService(client.eudiStorage) - oidCreds, err := credentialService.GetCredentialMetadataList() + oidCreds, err := client.credentialService.GetCredentialMetadataList() if err != nil { return nil, fmt.Errorf("failed to get OID4VCI credentials from storage: %v", err) } @@ -358,8 +356,7 @@ func (client *Client) getCredentialsIncludingKeyshare() ([]*clientmodels.Credent return nil, fmt.Errorf("failed to convert IRMA credentials to schemaless format: %v", err) } - credentialService := services.NewCredentialService(client.eudiStorage) - oidCreds, err := credentialService.GetCredentialMetadataList() + oidCreds, err := client.credentialService.GetCredentialMetadataList() if err != nil { return nil, fmt.Errorf("failed to get OID4VCI credentials from storage: %v", err) } diff --git a/docker-compose.yml b/docker-compose.yml index 2ed74d0f7..be598ecc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,6 +96,8 @@ services: condition: service_healthy tls_proxy: condition: service_started + statuslist_agent: + condition: service_started postgres: condition: service_started mysql: @@ -130,7 +132,11 @@ services: - irma-net # OpenID4VCI issuer for integration tests (eduwallet/veramo-agent) - veramo_openid4vci_postgres: + # Single Postgres server shared by every veramo service (OID4VCI issuer, + # OID4VP verifier) and the status-list agent. Each consumer is isolated in + # its own schema (created by the init below), so there are no table + # collisions in the one `postgres` database. + veramo_postgres: image: postgres:16-bookworm environment: POSTGRES_DB: postgres @@ -139,25 +145,27 @@ services: networks: irma-net: aliases: - - veramo-openid4vci-postgres.localhost + - veramo-postgres.localhost ports: - 5434:5432 - veramo_openid4vci_postgres_init: + veramo_postgres_init: image: postgres:16-bookworm environment: - PGHOST: veramo-openid4vci-postgres.localhost + PGHOST: veramo-postgres.localhost PGUSER: postgres PGPASSWORD: testpassword PGDATABASE: postgres networks: - irma-net depends_on: - - veramo_openid4vci_postgres + - veramo_postgres # Wait until the postgres server is actually accepting connections before # issuing the CREATE SCHEMA. A naive `sleep 3` is too short on cold CI - # runners and causes flaky init failures. - command: /bin/sh -c "until pg_isready -q; do sleep 1; done && psql -c 'CREATE SCHEMA IF NOT EXISTS agent'" + # runners and causes flaky init failures. One schema per consumer — + # `agent` (OID4VCI issuer), `verifier` (OID4VP verifier), `statuslist` + # (status-list agent) — which is what keeps them isolated in the shared DB. + command: /bin/sh -c "until pg_isready -q; do sleep 1; done && psql -c 'CREATE SCHEMA IF NOT EXISTS agent' -c 'CREATE SCHEMA IF NOT EXISTS verifier' -c 'CREATE SCHEMA IF NOT EXISTS statuslist'" veramo_openid4vci_mock_authorization_server: build: @@ -173,22 +181,29 @@ services: - 9090:9090 veramo_openid4vci: + # NOTE (status lists): bumped 1.4.1 -> 1.5.5 to get IETF Token Status + # List support (it attaches `status.status_list {idx,uri}` to issued + # dc+sd-jwt creds and proxies revoke to the statuslist_agent). build: - context: https://github.com/eduwallet/veramo-agent.git#v1.4.1 + context: https://github.com/eduwallet/veramo-agent.git#v1.5.5 command: ["yarn", "start"] extra_hosts: - "localhost:host-gateway" depends_on: - veramo_openid4vci_postgres_init: + veramo_postgres_init: condition: service_completed_successfully veramo_openid4vci_mock_authorization_server: condition: service_started volumes: - ./testdata/openid4vci-issuer/conf:/app/conf + # Trust the tls_proxy cert so veramo can reach the status list agent + # over https if it resolves its DID / uri there. + - ./testdata/configurations/certs/localhost.crt:/usr/local/share/ca-certificates/localhost.crt:ro environment: NODE_ENV: development + NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/localhost.crt DB_TYPE: postgres - DB_HOST: veramo-openid4vci-postgres.localhost + DB_HOST: veramo-postgres.localhost DB_PORT: 5432 DB_USER: postgres DB_PASSWORD: testpassword @@ -204,6 +219,67 @@ services: aliases: - veramo-openid4vci.localhost + # --------------------------------------------------------------------------- + # IETF Token Status List agent (eduwallet/statuslist-agent). + # Hosts the `application/statuslist+jwt` token and the set/revoke admin API + # that veramo_openid4vci calls at issuance/revocation. It shares the + # veramo_postgres server, isolated in its own `statuslist` schema + # (created by veramo_postgres_init). + # --------------------------------------------------------------------------- + statuslist_agent: + # The repo's docker/Dockerfile is a non-functional stub, so build inline + # (mirrors the veramo_openid4vp pattern). The signing key is generated at + # build time with the agent's own `key` script, then assembled into + # local.key with our fixed did:web name — this avoids guessing the + # @muisit/cryptokey hex format. + build: + context: https://github.com/privacybydesign/statuslist-agent.git#46d5c8cc3197cdf4a1e3299f5ac50b4b3f19eefa + dockerfile_inline: | + FROM node:20-bookworm + WORKDIR /app + COPY . . + RUN corepack enable && yarn install + # Generate a valid Ed25519 key with the agent's own tooling and wrap + # it into local.key with the did:web name matching the public origin. + # NOTE: $$ escapes compose interpolation so the literal $ reaches the + # Docker build — otherwise compose expands $KEYHEX / $(...) to empty and + # the agent gets a blank signing key (can't sign tokens -> 404 on fetch). + RUN KEYHEX="$$(yarn --silent key Ed25519 | tail -n1)" && \ + printf '{"name":"did:web:localhost%%3A8445","privateKeyHex":"%s","type":"Ed25519"}\n' "$$KEYHEX" > /app/local.key + CMD ["yarn", "start"] + extra_hosts: + - "localhost:host-gateway" + depends_on: + veramo_postgres_init: + condition: service_completed_successfully + volumes: + # Only the list definitions are mounted; local.key is built into the image. + - ./testdata/statuslist-agent/conf/lists:/app/conf/lists:ro + environment: + NODE_ENV: development + DB_HOST: veramo-postgres.localhost + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: testpassword + DB_NAME: postgres + DB_SCHEMA: statuslist + KEY_FILE: /app/local.key + PORT: 9156 + LISTEN_ADDRESS: 0.0.0.0 + # Public origin (via tls_proxy :8445) — determines the token `uri`/`sub` + # and the did:web the wallet resolves. Admin calls from veramo use the + # internal http alias instead (see veramo conf statusLists). + BASEURL: https://localhost:8445 + CONF_PATH: conf + # Empty BEARER_TOKEN => list configs are loaded from files (conf/lists). + BEARER_TOKEN: "" + networks: + irma-net: + aliases: + - statuslist-agent.localhost + ports: + - 9156:9156 + # EUDI Python PID issuer authorization server (companion to eudi_pid_issuer_py). # Issues pre-authorized codes via POST /preauth_generate (used internally by the # backend's /credentialOfferReq2 endpoint). @@ -279,6 +355,8 @@ services: # can start before the issuer is accepting requests on cold runners. eudi_pid_issuer_py: condition: service_healthy + statuslist_agent: + condition: service_started volumes: - ./testdata/configurations/certs/nginx-tls-proxy.conf:/etc/nginx/nginx.conf:ro - ./testdata/configurations/certs/localhost.crt:/etc/nginx/certs/localhost.crt:ro @@ -291,37 +369,10 @@ services: ports: - 8443:443 - 8444:444 + - 8445:445 - # OpenID4VP verifier for integration tests (eduwallet/veramo-verifier) - veramo_openid4vp_postgres: - image: postgres:16-bookworm - environment: - POSTGRES_DB: postgres - POSTGRES_USER: postgres - POSTGRES_PASSWORD: testpassword - networks: - irma-net: - aliases: - - veramo-openid4vp-postgres.localhost - ports: - - 5435:5432 - - veramo_openid4vp_postgres_init: - image: postgres:16-bookworm - environment: - PGHOST: veramo-openid4vp-postgres.localhost - PGUSER: postgres - PGPASSWORD: testpassword - PGDATABASE: postgres - networks: - - irma-net - depends_on: - - veramo_openid4vp_postgres - # Wait until the postgres server is actually accepting connections before - # issuing the CREATE SCHEMA. A naive `sleep 3` is too short on cold CI - # runners and causes flaky init failures. - command: /bin/sh -c "until pg_isready -q; do sleep 1; done && psql -c 'CREATE SCHEMA IF NOT EXISTS verifier'" - + # OpenID4VP verifier for integration tests (eduwallet/veramo-verifier). + # Uses the shared veramo_postgres server, isolated in its own `verifier` schema. veramo_openid4vp: build: context: https://github.com/eduwallet/veramo-verifier.git#v1.6.0 @@ -339,7 +390,7 @@ services: extra_hosts: - "localhost:host-gateway" depends_on: - veramo_openid4vp_postgres_init: + veramo_postgres_init: condition: service_completed_successfully veramo_openid4vci: condition: service_started @@ -350,11 +401,12 @@ services: NODE_ENV: development NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/localhost.crt DEBUG: "verifier:*" - DB_HOST: veramo-openid4vp-postgres.localhost + DB_HOST: veramo-postgres.localhost DB_PORT: 5432 DB_USER: postgres DB_PASSWORD: testpassword DB_NAME: postgres + DB_SCHEMA: verifier PORT: 5000 LISTEN_ADDRESS: 0.0.0.0 BASEURL: https://localhost:8444 diff --git a/eudi/credentials/sdjwtvc/sdjwtvc.go b/eudi/credentials/sdjwtvc/sdjwtvc.go index 6b550f739..c16323dbd 100644 --- a/eudi/credentials/sdjwtvc/sdjwtvc.go +++ b/eudi/credentials/sdjwtvc/sdjwtvc.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" iana "github.com/privacybydesign/irmago/internal/crypto/hashing" ) @@ -195,6 +196,10 @@ func IssuerSignedJwtPayload_ToJson(payload IssuerSignedJwtPayload) (string, erro jsonValues[Key_Confirmationkey] = payload.Confirm } + if payload.Status != nil && payload.Status.StatusList != nil { + jsonValues[Key_Status] = payload.Status + } + jsonValues[Key_VerifiableCredentialType] = payload.VerifiableCredentialType jsonValues[Key_ExpiryTime] = payload.Expiry jsonValues[Key_IssuedAt] = payload.IssuedAt @@ -292,7 +297,8 @@ type IssuerSignedJwtPayload struct { Confirm *CnfField // OPTIONAL: The information on how to read the status of the verifiable credential - Status *string + // (draft-ietf-oauth-status-list-15 §5.1). + Status *statuslist.StatusClaim // OPTIONAL: expiry time, must not be accepted after this moment Expiry *int64 diff --git a/eudi/credentials/sdjwtvc/test_utils.go b/eudi/credentials/sdjwtvc/test_utils.go index 41560a004..d5c9f70f6 100644 --- a/eudi/credentials/sdjwtvc/test_utils.go +++ b/eudi/credentials/sdjwtvc/test_utils.go @@ -12,6 +12,7 @@ import ( _ "embed" "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" "github.com/privacybydesign/irmago/eudi/utils" iana "github.com/privacybydesign/irmago/internal/crypto/hashing" @@ -394,6 +395,7 @@ type testSdJwtVcConfig struct { sdAlg *iana.HashingAlgorithm vct *string disclosures []DisclosureContent + status *statuslist.StatusClaim // stuff inside the issuer signed header typHeader *string @@ -405,6 +407,11 @@ type testSdJwtVcConfig struct { issuerPrivateKey *ecdsa.PrivateKey } +func (c *testSdJwtVcConfig) withStatusListReference(uri string, idx uint64) *testSdJwtVcConfig { + c.status = &statuslist.StatusClaim{StatusList: &statuslist.Reference{Index: idx, URI: uri}} + return c +} + type testSdJwtVcKbConfig struct { testSdJwtVcConfig @@ -488,6 +495,14 @@ func createTestIssuerSignedJwt(config testSdJwtVcConfig) (IssuerSignedJwt, error if config.sdAlg != nil { issuerPayload[Key_SdAlg] = *config.sdAlg } + if config.status != nil && config.status.StatusList != nil { + issuerPayload[Key_Status] = map[string]any{ + "status_list": map[string]any{ + "idx": config.status.StatusList.Index, + "uri": config.status.StatusList.URI, + }, + } + } issuerHeader := map[string]any{} diff --git a/eudi/credentials/sdjwtvc/verifier_helpers.go b/eudi/credentials/sdjwtvc/verifier_helpers.go index ab28c99c5..6e9434d0e 100644 --- a/eudi/credentials/sdjwtvc/verifier_helpers.go +++ b/eudi/credentials/sdjwtvc/verifier_helpers.go @@ -1,52 +1,10 @@ package sdjwtvc import ( - "context" "fmt" - "slices" "strings" - - "github.com/lestrrat-go/jwx/v3/jws" - eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" ) -type SdJwtKeyProvider struct { - innerKeyProvider jws.KeyProvider - allowInsecure bool -} - -// FetchKeys fetches the keys for verifying the SD-JWT VC issuer signed jwt, but not before validating the 'typ' header. -// This is the only way to validate the 'typ' header against multiple possible values. -func (p *SdJwtKeyProvider) FetchKeys(ctx context.Context, sink jws.KeySink, sig *jws.Signature, msg *jws.Message) error { - // Validate 'typ' header first - if typ, ok := sig.ProtectedHeaders().Type(); !ok || !slices.Contains([]string{SdJwtVcTyp, SdJwtVcTyp_Legacy}, typ) { - return fmt.Errorf("invalid 'typ' header: %v", typ) - } - - // Basic header validation passed, now select the key reference. x5c and kid are - // mutually exclusive: if both were accepted, a kid would overwrite an x5c here while - // the X.509 trust/CRL check downstream (gated on the *X509KeyProvider type) is silently - // skipped, letting a forged credential be verified against the kid-resolved key. - x5c, x5cPresent := sig.ProtectedHeaders().X509CertChain() - x5cPresent = x5cPresent && x5c != nil - - kid, kidPresent := sig.ProtectedHeaders().KeyID() - kidPresent = kidPresent && kid != "" - - switch { - case x5cPresent && kidPresent: - return fmt.Errorf("ambiguous key reference: both 'x5c' and 'kid' headers are present") - case x5cPresent: - p.innerKeyProvider = eudi_jwt.NewX509KeyProvider(x5c) - case kidPresent: - p.innerKeyProvider = eudi_jwt.NewKidKeyProvider(kid, p.allowInsecure) - default: - return fmt.Errorf("no supported key reference header (x5c or kid) present in the signature") - } - - return p.innerKeyProvider.FetchKeys(ctx, sink, sig, msg) -} - // splitSdJwtVc splits the sdjwt at the ~ characters and returns the individual components. // The IssuerSignedJwt is guaranteed to contain a value (if there's no error). // The EncodedDisclosure list could be empty if there are no disclosures. diff --git a/eudi/credentials/sdjwtvc/verify.go b/eudi/credentials/sdjwtvc/verify.go index 4f59e9912..fafdd1a6c 100644 --- a/eudi/credentials/sdjwtvc/verify.go +++ b/eudi/credentials/sdjwtvc/verify.go @@ -1,6 +1,7 @@ package sdjwtvc import ( + "context" "encoding/json" "errors" "fmt" @@ -14,6 +15,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jws" "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" "github.com/privacybydesign/irmago/eudi/scheme" "github.com/privacybydesign/irmago/eudi/utils" @@ -61,6 +63,14 @@ type SdJwtVcVerificationContext struct { // specific request. ExpectedNonce string + // StatusChecker, when set, runs an IETF OAuth Token Status List + // check after the SD-JWT VC verification succeeds: if the + // payload carries a `status.status_list` reference, the verifier + // fetches/verifies the referenced Status List Token and rejects + // the credential unless the indexed bit reads StatusValid. + // Nil disables the check. + StatusChecker *statuslist.Checker + // ExpectedAudience is the audience from the OpenID4VP authorization request that the KB-JWT aud // must match. ExpectedAudience string @@ -166,6 +176,40 @@ func NewSdJwtVcProcessor(verificationContext SdJwtVcVerificationContext) sdJwtVc } } +// runStatusListCheck consults the configured StatusChecker (if any) +// for the credential's status reference. Returns nil when no checker +// is configured or when the credential has no status_list reference; +// otherwise returns nil only when the indexed bit reads StatusValid. +// +// Status-fetch / verify / decode errors and any non-Valid status are +// returned to the caller, which will reject the credential. The +// behaviour is fail-closed. +func (v *sdJwtVcProcessor) runStatusListCheck(payload *IssuerSignedJwtPayload) error { + if v.verificationContext.StatusChecker == nil { + return nil + } + if payload.Status == nil || payload.Status.StatusList == nil { + return nil + } + // context.Background is deliberate: there is no session/request context to + // thread here — every caller up to irmaclient manufactures its own + // Background, and this runs post-grant while the holder waits on the result. + // Both network steps are bounded without a caller context: the status-list + // GET by the checker's FetchTimeout, and did:web signing-key resolution by + // the timeout-bounded HTTP client used for DID resolution (didweb.NewHTTPClient). + // Threading a cancellable context down ~60 ParseAndVerifySdJwtVc call sites + // would only buy cancel-on-dismiss. + ctx := context.Background() + status, err := v.verificationContext.StatusChecker.Check(ctx, *payload.Status.StatusList) + if err != nil { + return fmt.Errorf("status list check failed: %w", err) + } + if status != statuslist.StatusValid { + return fmt.Errorf("credential status is %s, not valid", status) + } + return nil +} + // ProcessAndVerifySdJwtVc implements chapter 7.1 of the SD-JWT VC specification. func (v *sdJwtVcProcessor) ProcessAndVerifySdJwtVc( sdjwtvc SdJwtVcKb, @@ -199,6 +243,13 @@ func (v *sdJwtVcProcessor) ProcessAndVerifySdJwtVc( // } // } + // Token Status List check (draft-ietf-oauth-status-list-15). Skip + // silently when no checker is configured or the credential carries + // no status_list reference. + if err := v.runStatusListCheck(issuerSignedJwtPayload); err != nil { + return nil, err + } + // Valid SD-JWT, optionally with valid key binding JWT, depending on the key binding processor used return &VerifiedSdJwtVc{ IssuerSignedJwtPayload: *issuerSignedJwtPayload, @@ -268,6 +319,15 @@ func (v *sdJwtVcProcessor) parseAndVerifyIssuerSignedJwt(signedJwt IssuerSignedJ } } + // Optional Token Status List reference (draft-ietf-oauth-status-list-15 §5.1). + var status *statuslist.StatusClaim + if token.Has(Key_Status) { + status, err = parseStatusClaim(token) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to parse status claim: %v", err) + } + } + // Verify and process disclosures // Get structured SD-JWT claims, which we can check for embedded disclosure digests issuerSignedJwtClaims, err := extractClaimsAndDisclosuresDigestsFromToken(token) @@ -283,6 +343,7 @@ func (v *sdJwtVcProcessor) parseAndVerifyIssuerSignedJwt(signedJwt IssuerSignedJ Sd: sd, SdAlg: iana.HashingAlgorithm(sdAlg), Confirm: cnf, + Status: status, } if expPresent { @@ -395,6 +456,63 @@ func parseConfirmField(value any) (*CnfField, error) { return nil, fmt.Errorf("failed to parse cnf field: unsupported confirmation method, expected jwk or did:jwk: %v", value) } +// parseStatusClaim reads the `status` claim from a verified jwt.Token +// into the structured form expected by the Token Status List +// pipeline. Only the `status_list` member is parsed in v1; other +// sibling members defined by future specs are silently ignored. +func parseStatusClaim(token jwt.Token) (*statuslist.StatusClaim, error) { + var raw map[string]any + if err := token.Get(Key_Status, &raw); err != nil { + return nil, fmt.Errorf("status claim is not an object: %v", err) + } + slRaw, ok := raw["status_list"] + if !ok { + // status object present without a status_list member is a + // well-formed extension point; treat as "no status list + // reference on this credential". + return &statuslist.StatusClaim{}, nil + } + slMap, ok := slRaw.(map[string]any) + if !ok { + return nil, fmt.Errorf("status_list is not an object: %T", slRaw) + } + idxRaw, ok := slMap["idx"] + if !ok { + return nil, fmt.Errorf("status_list.idx missing") + } + uriRaw, ok := slMap["uri"] + if !ok { + return nil, fmt.Errorf("status_list.uri missing") + } + uri, ok := uriRaw.(string) + if !ok { + return nil, fmt.Errorf("status_list.uri is not a string: %T", uriRaw) + } + var idx uint64 + switch n := idxRaw.(type) { + case float64: + if n < 0 { + return nil, fmt.Errorf("status_list.idx is negative: %v", n) + } + idx = uint64(n) + case int: + if n < 0 { + return nil, fmt.Errorf("status_list.idx is negative: %v", n) + } + idx = uint64(n) + case int64: + if n < 0 { + return nil, fmt.Errorf("status_list.idx is negative: %v", n) + } + idx = uint64(n) + case uint64: + idx = n + default: + return nil, fmt.Errorf("status_list.idx is not a number: %T", idxRaw) + } + return &statuslist.StatusClaim{StatusList: &statuslist.Reference{Index: idx, URI: uri}}, nil +} + func verifyAndProcessDisclosures(sdAlg iana.HashingAlgorithm, issuerSignedJwtClaims *map[string]any, disclosures []EncodedDisclosure, @@ -599,11 +717,11 @@ func extractClaimsAndDisclosuresDigestsFromToken(token jwt.Token) (map[string]an func (v *sdJwtVcProcessor) decodeJwtAndVerifyFromX5cHeader( signedJwt []byte, ) (jwt.Token, *scheme.AttestationProviderRequestor, error) { - keyProvider := SdJwtKeyProvider{allowInsecure: v.allowInsecureDidWeb} + keyProvider := eudi_jwt.NewJwtKeyProvider([]string{SdJwtVcTyp, SdJwtVcTyp_Legacy}, v.allowInsecureDidWeb) // Create a context for the verification where we can retrieve the requestor info back token, err := jwt.Parse(signedJwt, - jwt.WithKeyProvider(&keyProvider), + jwt.WithKeyProvider(keyProvider), jwt.WithClock(v.verificationContext.Clock), jwt.WithAcceptableSkew(ClockSkewInSeconds*time.Second), jwt.WithVerify(true), @@ -613,7 +731,7 @@ func (v *sdJwtVcProcessor) decodeJwtAndVerifyFromX5cHeader( } // If the key provider used was a X509KeyProvider, we can get the certificate and verify it against the trusted roots/intermediates and CRLs. - if x509KeyProvider, ok := keyProvider.innerKeyProvider.(*eudi_jwt.X509KeyProvider); ok { + if x509KeyProvider, ok := keyProvider.InnerKeyProvider.(*eudi_jwt.X509KeyProvider); ok { cert := x509KeyProvider.GetCert() err = eudi_jwt.VerifyCertificate(v.verificationContext.X509VerificationContext, cert, nil) if err != nil { diff --git a/eudi/credentials/sdjwtvc/verify_test.go b/eudi/credentials/sdjwtvc/verify_test.go index aa97774a2..1f9cdf873 100644 --- a/eudi/credentials/sdjwtvc/verify_test.go +++ b/eudi/credentials/sdjwtvc/verify_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" "github.com/privacybydesign/irmago/eudi/utils" iana "github.com/privacybydesign/irmago/internal/crypto/hashing" @@ -914,6 +915,131 @@ func Test_HolderVerificationProcessor_BaselineGeneratedSdJwtVc_Succeeds(t *testi noErrorTestCaseHolder(t, config, "default working test sdjwtvc creator is valid") } +func Test_HolderVerificationProcessor_StatusClaim_RoundtripsThroughPayload(t *testing.T) { + config := newWorkingSdJwtVcTestConfig(). + withStatusListReference("https://issuer.example/sl/1", 42) + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + + verified, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.NoError(t, err) + require.NotNil(t, verified.IssuerSignedJwtPayload.Status) + require.NotNil(t, verified.IssuerSignedJwtPayload.Status.StatusList) + require.Equal(t, "https://issuer.example/sl/1", verified.IssuerSignedJwtPayload.Status.StatusList.URI) + require.Equal(t, uint64(42), verified.IssuerSignedJwtPayload.Status.StatusList.Index) +} + +func Test_HolderVerificationProcessor_StatusClaim_AbsentLeavesPayloadStatusNil(t *testing.T) { + config := newWorkingSdJwtVcTestConfig() // no status reference + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + + verified, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.NoError(t, err) + require.Nil(t, verified.IssuerSignedJwtPayload.Status) +} + +func Test_HolderVerificationProcessor_StatusCheck_ValidList_Accepts(t *testing.T) { + signer := statuslist.NewTestStatusListSigner(t) + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://openid4vc.staging.yivi.app", + Bits: 1, + Statuses: map[uint64]uint8{7: 0}, // Valid at idx 7 + }) + + config := newWorkingSdJwtVcTestConfig().withStatusListReference(srv.URL(), 7) + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + context.StatusChecker = statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + _, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.NoError(t, err) +} + +func Test_HolderVerificationProcessor_StatusCheck_InvalidList_Rejects(t *testing.T) { + signer := statuslist.NewTestStatusListSigner(t) + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://openid4vc.staging.yivi.app", + Bits: 1, + Statuses: map[uint64]uint8{7: 1}, // Invalid at idx 7 + }) + + config := newWorkingSdJwtVcTestConfig().withStatusListReference(srv.URL(), 7) + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + context.StatusChecker = statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + _, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.ErrorContains(t, err, "credential status is invalid") +} + +// Holder verification is the exact code path OpenID4VCI issuance runs, so this +// asserts that a credential whose status is non-VALID at issuance time is +// rejected — not only the revoked (Invalid, 0x01) case above but also +// Suspended (0x02), which requires bits >= 2. The gate is fail-closed: only +// StatusValid is accepted, every other value aborts the issuance. +func Test_HolderVerificationProcessor_StatusCheck_SuspendedList_RejectsAtIssuance(t *testing.T) { + signer := statuslist.NewTestStatusListSigner(t) + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://openid4vc.staging.yivi.app", + Bits: 2, + Statuses: map[uint64]uint8{7: 2}, // Suspended at idx 7 + }) + + config := newWorkingSdJwtVcTestConfig().withStatusListReference(srv.URL(), 7) + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + context.StatusChecker = statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + _, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.ErrorContains(t, err, "credential status is suspended") +} + +func Test_HolderVerificationProcessor_StatusCheck_UnreachableURI_FailsClosed(t *testing.T) { + signer := statuslist.NewTestStatusListSigner(t) + config := newWorkingSdJwtVcTestConfig().withStatusListReference("http://127.0.0.1:0/nope", 0) + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + context.StatusChecker = statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + _, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.ErrorContains(t, err, "status list check failed") +} + +func Test_HolderVerificationProcessor_StatusCheck_NilCheckerLeavesClaimUnverified(t *testing.T) { + // Even with a status reference present, a nil StatusChecker + // must not reject the credential — this is the back-compat path + // for callers that haven't opted into status checks. + config := newWorkingSdJwtVcTestConfig().withStatusListReference("https://issuer.example/sl/1", 0) + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + // context.StatusChecker is nil. + + _, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.NoError(t, err) +} + +func Test_HolderVerificationProcessor_StatusCheck_NoStatusClaim_PassesWithCheckerConfigured(t *testing.T) { + signer := statuslist.NewTestStatusListSigner(t) + config := newWorkingSdJwtVcTestConfig() // no status reference + sdjwtvc := createTestSdJwtVc(t, config) + context := CreateDefaultVerificationContext(testdata.SdJwtVc_IssuerCert_openid4vc_staging_yivi_app_Bytes) + context.StatusChecker = statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + _, err := NewHolderVerificationProcessor(context).ParseAndVerifySdJwtVc(SdJwtVcKb(sdjwtvc)) + require.NoError(t, err) +} + func Test_HolderVerificationProcessor_FewerDisclosuresThanSdHashes_Succeeds(t *testing.T) { config := newWorkingSdJwtVcTestConfig() config.disclosures = []DisclosureContent{ diff --git a/eudi/credentials/statuslist/cache.go b/eudi/credentials/statuslist/cache.go new file mode 100644 index 000000000..96d3cc13e --- /dev/null +++ b/eudi/credentials/statuslist/cache.go @@ -0,0 +1,92 @@ +package statuslist + +import ( + "sync" + "time" +) + +// Cache stores fetched Status List Tokens by URI. The interface +// intentionally exchanges raw JWT bytes (not decoded bit arrays) so +// that re-verification on read happens against the current trust +// anchors instead of trusting a decoded payload from an earlier run. +type Cache interface { + // Get returns the cached raw JWT for uri together with its + // scheduled expiry. ok is false when the URI is not cached. + Get(uri string) (rawJwt []byte, expiresAt time.Time, ok bool) + + // Put stores the raw JWT under uri with the given expiry. + Put(uri string, rawJwt []byte, expiresAt time.Time) error + + // Delete removes any cached entry for uri. + Delete(uri string) error +} + +// TTL bounds applied to the lifetime signal (the token's own ttl/exp, or the +// HTTP max-age as fallback) to defend against pathological providers (ttl=1s +// would hammer us; ttl=10y would make revocation effectively impossible). +const ( + TTLMin = 60 * time.Second + TTLMax = 24 * time.Hour + TTLDefault = 1 * time.Hour + MaxBodyDefault = 5 * 1024 * 1024 + FetchTimeoutDefault = 10 * time.Second +) + +// ClampTTL applies the [TTLMin, TTLMax] bounds. A non-positive input +// (no signal from the provider) is treated as TTLDefault before +// clamping. +func ClampTTL(d time.Duration) time.Duration { + if d <= 0 { + d = TTLDefault + } + if d < TTLMin { + return TTLMin + } + if d > TTLMax { + return TTLMax + } + return d +} + +// inMemoryCache is the default in-process Cache used by relying-party +// verifiers (long-lived processes that don't need persistence). The +// wallet wires the DB-backed implementation from eudi/storage/db. +type inMemoryCache struct { + mu sync.RWMutex + entries map[string]inMemoryEntry +} + +type inMemoryEntry struct { + rawJwt []byte + expiresAt time.Time +} + +// NewInMemoryCache returns a Cache backed by an in-process map. Safe +// for concurrent use. +func NewInMemoryCache() Cache { + return &inMemoryCache{entries: map[string]inMemoryEntry{}} +} + +func (c *inMemoryCache) Get(uri string) ([]byte, time.Time, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.entries[uri] + if !ok { + return nil, time.Time{}, false + } + return e.rawJwt, e.expiresAt, true +} + +func (c *inMemoryCache) Put(uri string, rawJwt []byte, expiresAt time.Time) error { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[uri] = inMemoryEntry{rawJwt: rawJwt, expiresAt: expiresAt} + return nil +} + +func (c *inMemoryCache) Delete(uri string) error { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.entries, uri) + return nil +} diff --git a/eudi/credentials/statuslist/cache_test.go b/eudi/credentials/statuslist/cache_test.go new file mode 100644 index 000000000..3c955d9d1 --- /dev/null +++ b/eudi/credentials/statuslist/cache_test.go @@ -0,0 +1,76 @@ +package statuslist + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_InMemoryCache_GetMiss_ReturnsFalse(t *testing.T) { + c := NewInMemoryCache() + _, _, ok := c.Get("https://example.com/list") + require.False(t, ok) +} + +func Test_InMemoryCache_PutThenGet_RoundtripsValueAndExpiry(t *testing.T) { + c := NewInMemoryCache() + expires := time.Now().Add(time.Hour) + require.NoError(t, c.Put("uri", []byte("raw-jwt"), expires)) + + raw, gotExpires, ok := c.Get("uri") + require.True(t, ok) + require.Equal(t, []byte("raw-jwt"), raw) + require.WithinDuration(t, expires, gotExpires, time.Millisecond) +} + +func Test_InMemoryCache_Delete_RemovesEntry(t *testing.T) { + c := NewInMemoryCache() + require.NoError(t, c.Put("uri", []byte("v"), time.Now().Add(time.Hour))) + require.NoError(t, c.Delete("uri")) + _, _, ok := c.Get("uri") + require.False(t, ok) +} + +func Test_InMemoryCache_OverwritesExistingEntry(t *testing.T) { + c := NewInMemoryCache() + require.NoError(t, c.Put("uri", []byte("v1"), time.Now().Add(time.Hour))) + require.NoError(t, c.Put("uri", []byte("v2"), time.Now().Add(2*time.Hour))) + raw, _, ok := c.Get("uri") + require.True(t, ok) + require.Equal(t, []byte("v2"), raw) +} + +func Test_InMemoryCache_ConcurrentReadsAndWrites(t *testing.T) { + c := NewInMemoryCache() + const n = 200 + var wg sync.WaitGroup + wg.Add(2 * n) + for range n { + go func() { defer wg.Done(); _ = c.Put("uri", []byte("v"), time.Now().Add(time.Hour)) }() + go func() { defer wg.Done(); _, _, _ = c.Get("uri") }() + } + wg.Wait() +} + +func Test_ClampTTL_BelowFloor_ReturnsFloor(t *testing.T) { + require.Equal(t, TTLMin, ClampTTL(5*time.Second)) +} + +func Test_ClampTTL_AboveCeiling_ReturnsCeiling(t *testing.T) { + require.Equal(t, TTLMax, ClampTTL(7*24*time.Hour)) +} + +func Test_ClampTTL_Zero_ReturnsDefault(t *testing.T) { + require.Equal(t, TTLDefault, ClampTTL(0)) +} + +func Test_ClampTTL_Negative_ReturnsDefault(t *testing.T) { + require.Equal(t, TTLDefault, ClampTTL(-time.Hour)) +} + +func Test_ClampTTL_WithinBounds_PassesThrough(t *testing.T) { + d := 30 * time.Minute + require.Equal(t, d, ClampTTL(d)) +} diff --git a/eudi/credentials/statuslist/checker.go b/eudi/credentials/statuslist/checker.go new file mode 100644 index 000000000..87f4d0e9e --- /dev/null +++ b/eudi/credentials/statuslist/checker.go @@ -0,0 +1,169 @@ +// Package statuslist implements client-side support for the IETF OAuth +// Token Status List specification, draft-ietf-oauth-status-list-15 +// (https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/15/). +// +// The package fetches Status List Tokens advertised by SD-JWT VCs via the +// `status.status_list` claim, verifies their signatures (x5c or kid+did +// resolution), decodes the zlib-compressed bit array, and returns the +// status value at a given index. Callers use the single Checker.Check +// verb to obtain a typed Status value for a given Reference; partial +// fetch/decode/verify is intentionally not exposed. +// +// Status List Token verification reuses the same x5c-or-kid+DID +// dispatcher as SD-JWT VC verification via eudi_jwt.JwtKeyProvider, +// configured with the `statuslist+jwt` typ value mandated by the spec. +// +// v1 supports JWT Status List Tokens only (`application/statuslist+jwt`); +// CWT (`application/statuslist+cwt`) is intentionally out of scope. +package statuslist + +import ( + "context" + "fmt" + "time" + + "github.com/privacybydesign/irmago/internal/common" + "golang.org/x/sync/singleflight" +) + +// Checker is the only public verb of this package. It orchestrates +// cache lookup, HTTP fetch, JWT signature verification, and bit +// extraction for a single status reference, with singleflight dedup +// across concurrent callers requesting the same URI. +// +// A Checker is safe for concurrent use. +type Checker struct { + ctx VerificationContext + cache Cache + sf singleflight.Group + + // nowFn lets tests inject a deterministic clock without touching + // VerificationContext.Clock (which jwx consumes for skew checks). + nowFn func() time.Time +} + +// NewChecker returns a Checker bound to a verification context and +// a cache. If cache is nil an in-memory cache is used. +func NewChecker(ctx VerificationContext, cache Cache) *Checker { + if cache == nil { + cache = NewInMemoryCache() + } + return &Checker{ctx: ctx, cache: cache, nowFn: time.Now} +} + +// Check fetches/verifies the status list for ref (using the cache +// when fresh) and returns the status at ref.Index. +func (c *Checker) Check(ctx context.Context, ref Reference) (Status, error) { + return c.check(ctx, ref, false) +} + +// Refresh ignores any cached entry and re-fetches the list. Used by +// the background sweep to bring stored credential statuses up to +// date independent of the Check-side TTL. +func (c *Checker) Refresh(ctx context.Context, ref Reference) (Status, error) { + return c.check(ctx, ref, true) +} + +// CheckCached returns the status for ref using only the local cache; it never +// performs a network fetch. On a cache miss or expired entry it returns +// (StatusUnknown, nil). A cached entry that no longer verifies is dropped (by +// verifyAndDecode) and returned as (StatusUnknown, err) for logging. Callers +// that rely on the background refresh to keep the cache warm treat any +// non-definitive result as advisory (see services.RevocationService.IsRevoked). +func (c *Checker) CheckCached(ref Reference) (Status, error) { + if ref.URI == "" { + return StatusUnknown, fmt.Errorf("%w: empty URI", ErrUnauthorized) + } + now := c.nowFn() + raw, expires, ok := c.cache.Get(ref.URI) + if !ok || !now.Before(expires) { + return StatusUnknown, nil + } + return c.verifyAndDecode(raw, ref, now) +} + +func (c *Checker) check(ctx context.Context, ref Reference, bypassCache bool) (Status, error) { + if ref.URI == "" { + return StatusUnknown, fmt.Errorf("%w: empty URI", ErrUnauthorized) + } + + now := c.nowFn() + + // Cache and singleflight are keyed on ref.URI alone; the verified list + // content does not depend on the caller. + + // Cache fast-path. + if !bypassCache { + if raw, expires, ok := c.cache.Get(ref.URI); ok && now.Before(expires) { + return c.verifyAndDecode(raw, ref, now) + } + } + + // Singleflight: collapse concurrent fetches for the same URI. + resAny, err, _ := c.sf.Do(ref.URI, func() (any, error) { + return c.fetchVerifyStore(ctx, ref.URI, now) + }) + if err != nil { + return StatusUnknown, err + } + v := resAny.(*verifiedStatusList) + + return decodeStatusFromVerified(v, ref, c.ctx.MaxBodyBytes) +} + +// fetchVerifyStore runs one fetch+verify cycle and writes the raw +// JWT into the cache with the computed expiry. +func (c *Checker) fetchVerifyStore(ctx context.Context, uri string, now time.Time) (*verifiedStatusList, error) { + res, err := fetchStatusListToken(ctx, c.ctx, uri) + if err != nil { + return nil, err + } + + v, err := verifyStatusListToken(res.rawJwt, c.ctx, uri, now) + if err != nil { + return nil, err + } + + // Caching lifetime. draft-ietf-oauth-status-list-15 §8.2 requires + // the token's own ttl/exp claims to take priority over HTTP caching + // headers, so the HTTP max-age is only a fallback used when the + // token advertises no lifetime of its own. ClampTTL bounds the + // result and supplies the default when neither signal is present. + ttl, ok := v.payloadTTLSignal() + if !ok { + ttl = res.httpMaxAge + } + expires := now.Add(ClampTTL(ttl)) + + if err := c.cache.Put(uri, res.rawJwt, expires); err != nil { + // Cache failures aren't fatal — the token is already verified. + // Log and proceed rather than fail-closed on a transient cache + // error (e.g. a locked/full DB), which would otherwise reject an + // otherwise-valid credential at issuance and disclosure. + if common.Logger != nil { // nil when this package is used without irma (e.g. unit tests) + common.Logger.Warnf("statuslist: cache write for %q failed, proceeding: %v", common.SanitizeForLog(uri), err) + } + } + return v, nil +} + +// verifyAndDecode runs the verify+decode path against an already +// cached raw JWT. +func (c *Checker) verifyAndDecode(raw []byte, ref Reference, now time.Time) (Status, error) { + v, err := verifyStatusListToken(raw, c.ctx, ref.URI, now) + if err != nil { + // Cached value failed re-verification — drop it so the + // next call re-fetches. + _ = c.cache.Delete(ref.URI) + return StatusUnknown, err + } + return decodeStatusFromVerified(v, ref, c.ctx.MaxBodyBytes) +} + +func decodeStatusFromVerified(v *verifiedStatusList, ref Reference, maxBytes int64) (Status, error) { + bits, err := decodeBits(v.payload.StatusList.Lst, maxBytes) + if err != nil { + return StatusUnknown, err + } + return statusAtIndex(bits, v.payload.StatusList.Bits, ref.Index) +} diff --git a/eudi/credentials/statuslist/checker_test.go b/eudi/credentials/statuslist/checker_test.go new file mode 100644 index 000000000..7500baa1d --- /dev/null +++ b/eudi/credentials/statuslist/checker_test.go @@ -0,0 +1,387 @@ +package statuslist + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// makeSignerServerChecker builds a (signer, server, checker) triple +// where the server serves whatever the signer produces. +func makeSignerServerChecker(t *testing.T) (*TestStatusListSigner, *TestStatusListServer, *Checker) { + t.Helper() + signer := NewTestStatusListSigner(t) + srv := NewTestStatusListServer(t, nil) + checker := NewChecker(VerificationContext{X509Context: signer.X509VerificationContext()}, NewInMemoryCache()) + return signer, srv, checker +} + +func Test_Checker_Check_1Bit_AllValid(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0, 1: 0, 2: 0, 3: 0}, + }) + + s, err := checker.Check(context.Background(), Reference{Index: 2, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusValid, s) +} + +func Test_Checker_Check_1Bit_Invalid(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{5: 1}, + }) + + s, err := checker.Check(context.Background(), Reference{Index: 5, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusInvalid, s) +} + +// CheckCached serves from the local cache only and never fetches: a cold cache +// reads Unknown with no HTTP hit. +func Test_Checker_CheckCached_MissReturnsUnknownNoFetch(t *testing.T) { + _, srv, checker := makeSignerServerChecker(t) + s, err := checker.CheckCached(Reference{Index: 3, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusUnknown, s) + require.Zero(t, srv.Hits(), "CheckCached must not fetch") +} + +// Once warmed (as the background refresh would), CheckCached returns the +// per-index status without any further HTTP hit. +func Test_Checker_CheckCached_ServesFromWarmCache(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{3: 1, 4: 0}, + }) + _, err := checker.Refresh(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + hits := srv.Hits() + + invalid, err := checker.CheckCached(Reference{Index: 3, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusInvalid, invalid) + + valid, err := checker.CheckCached(Reference{Index: 4, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusValid, valid) + + require.Equal(t, hits, srv.Hits(), "CheckCached must not fetch after warm") +} + +// An expired cache entry is not served: CheckCached respects freshness and +// returns Unknown rather than a stale status. +func Test_Checker_CheckCached_ExpiredEntryReturnsUnknown(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 1}, + TTLSeconds: 1, // clamped to TTLMin (60s) + }) + _, err := checker.Refresh(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + + // Advance past the cached entry's expiry; the stale entry must not be served. + checker.nowFn = func() time.Time { return time.Now().Add(TTLMax + time.Hour) } + + s, err := checker.CheckCached(Reference{URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusUnknown, s) +} + +func Test_Checker_Check_2Bit_Suspended(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 2, + Statuses: map[uint64]uint8{3: 2}, + }) + + s, err := checker.Check(context.Background(), Reference{Index: 3, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusSuspended, s) +} + +func Test_Checker_Check_4Bit_ApplicationSpecific(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 4, + Statuses: map[uint64]uint8{0: 7}, + }) + + s, err := checker.Check(context.Background(), Reference{Index: 0, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusApplicationSpecific, s) +} + +// failingPutCache always errors on Put, simulating a transient cache-write +// failure (e.g. a locked/full DB). Get/Delete delegate to the wrapped cache. +type failingPutCache struct{ Cache } + +func (failingPutCache) Put(string, []byte, time.Time) error { + return fmt.Errorf("simulated cache write failure") +} + +// A cache-write failure must NOT fail-closed: the token is already verified, +// so Check must still return the decoded status rather than an error. +func Test_Checker_Check_CacheWriteFailure_NotFatal(t *testing.T) { + signer := NewTestStatusListSigner(t) + srv := NewTestStatusListServer(t, nil) + checker := NewChecker( + VerificationContext{X509Context: signer.X509VerificationContext()}, + failingPutCache{NewInMemoryCache()}, + ) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{2: 0}, + }) + + s, err := checker.Check(context.Background(), Reference{Index: 2, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusValid, s) +} + +func Test_Checker_Check_8Bit_FullRange(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 8, + Statuses: map[uint64]uint8{0: 0, 1: 1, 2: 2, 3: 200}, + }) + + for idx, want := range map[uint64]Status{0: StatusValid, 1: StatusInvalid, 2: StatusSuspended, 3: StatusApplicationSpecific} { + s, err := checker.Check(context.Background(), Reference{Index: idx, URI: srv.URL()}) + require.NoError(t, err) + require.Equalf(t, want, s, "idx %d", idx) + } +} + +func Test_Checker_Check_CachesAcrossCalls(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + TTLSeconds: 3600, + }) + + for range 5 { + _, err := checker.Check(context.Background(), Reference{Index: 0, URI: srv.URL()}) + require.NoError(t, err) + } + require.Equal(t, int64(1), srv.Hits(), "checker should hit backend once and cache subsequent reads") +} + +func Test_Checker_Refresh_BypassesCache(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + TTLSeconds: 3600, + }) + + _, err := checker.Check(context.Background(), Reference{Index: 0, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, int64(1), srv.Hits()) + + _, err = checker.Refresh(context.Background(), Reference{Index: 0, URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, int64(2), srv.Hits()) +} + +func Test_Checker_Check_Singleflight_CollapsesConcurrentFetches(t *testing.T) { + signer := NewTestStatusListSigner(t) + var hits int64 + release := make(chan struct{}) + // body is assigned after the server exists so its `sub` can be set + // to the server URL (the §5.1 sub == uri binding). The handler + // closure reads body only at request time, after assignment. + var body []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&hits, 1) + <-release // hold the request open while concurrent callers pile up + w.Header().Set("Content-Type", StatusListTokenContentType) + _, _ = w.Write(body) + })) + defer srv.Close() + + body = signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: srv.URL, + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + checker := NewChecker(VerificationContext{X509Context: signer.X509VerificationContext()}, NewInMemoryCache()) + + const n = 20 + var wg sync.WaitGroup + wg.Add(n) + for range n { + go func() { + defer wg.Done() + _, err := checker.Check(context.Background(), Reference{URI: srv.URL}) + require.NoError(t, err) + }() + } + + // Let the held request complete. + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + + require.Equal(t, int64(1), atomic.LoadInt64(&hits), "singleflight should fold concurrent callers into one fetch") +} + +func Test_Checker_Check_FetchFailure_FailsClosed(t *testing.T) { + signer := NewTestStatusListSigner(t) + checker := NewChecker(VerificationContext{X509Context: signer.X509VerificationContext()}, NewInMemoryCache()) + + _, err := checker.Check(context.Background(), Reference{URI: "http://127.0.0.1:0/nope"}) + require.ErrorIs(t, err, ErrFetch) +} + +func Test_Checker_Check_DelegatedIssuer_Accepted(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://delegated-status-issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + // sub matches the fetch URI and the signature is trusted; the iss + // differs from the credential issuer but is accepted because the spec + // binds the token via sub + signature (§11.3). + s, err := checker.Check(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + require.Equal(t, StatusValid, s) +} + +func Test_Checker_Check_SubMismatch_FailsClosed(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + // Sign with a sub that is NOT this server's URL. The token is + // otherwise valid (correct iss, signature), but the sub != uri + // binding must reject it (§5.1 / §8.3). + srv.SetBody(signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/some-other-list", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + })) + + _, err := checker.Check(context.Background(), Reference{URI: srv.URL()}) + require.ErrorIs(t, err, ErrUnauthorized) + require.Contains(t, err.Error(), "sub") +} + +func Test_Checker_Check_IndexOutOfBounds_FailsClosed(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0, 1: 0, 2: 0}, + }) + + // Status array has 3 entries (= 1 byte at 1 bit each = 8 entries + // total). idx 999 must be rejected. + _, err := checker.Check(context.Background(), Reference{Index: 999, URI: srv.URL()}) + require.ErrorIs(t, err, ErrIndexBounds) +} + +func Test_Checker_Check_TTLClampedToMinimum(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + TTLSeconds: 1, // below TTLMin (60s) + }) + + _, err := checker.Check(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + _, expires, ok := checker.cache.Get(srv.URL()) + require.True(t, ok) + now := checker.nowFn() + require.GreaterOrEqual(t, expires.Sub(now), TTLMin-time.Second) +} + +func Test_Checker_Check_TTLClampedToMaximum(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + TTLSeconds: 7 * 24 * 3600, // above TTLMax (24h) + }) + + _, err := checker.Check(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + _, expires, ok := checker.cache.Get(srv.URL()) + require.True(t, ok) + now := checker.nowFn() + require.LessOrEqual(t, expires.Sub(now), TTLMax+time.Second) +} + +func Test_Checker_Check_PrioritizesJwtTtlOverHttpMaxAge(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.SetMaxAge(120) + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + TTLSeconds: 3600, + }) + + before := checker.nowFn() + _, err := checker.Check(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + _, expires, ok := checker.cache.Get(srv.URL()) + require.True(t, ok) + // §8.2: the JWT ttl (3600 s) takes priority over the HTTP max-age + // (120 s), so the effective TTL is ~3600 s, not 120 s. + require.InDelta(t, 3600*time.Second, expires.Sub(before), float64(5*time.Second)) +} + +func Test_Checker_Check_FallsBackToHttpMaxAgeWhenNoJwtTtl(t *testing.T) { + signer, srv, checker := makeSignerServerChecker(t) + srv.SetMaxAge(300) + // No ttl and no exp on the token → the HTTP max-age is the only + // caching signal and is used as the fallback. + srv.Serve(t, signer, TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + before := checker.nowFn() + _, err := checker.Check(context.Background(), Reference{URI: srv.URL()}) + require.NoError(t, err) + _, expires, ok := checker.cache.Get(srv.URL()) + require.True(t, ok) + require.InDelta(t, 300*time.Second, expires.Sub(before), float64(5*time.Second)) +} + +func Test_Checker_Check_EmptyURI_FailsClosed(t *testing.T) { + signer := NewTestStatusListSigner(t) + checker := NewChecker(VerificationContext{X509Context: signer.X509VerificationContext()}, NewInMemoryCache()) + _, err := checker.Check(context.Background(), Reference{Index: 0}) + require.ErrorIs(t, err, ErrUnauthorized) +} diff --git a/eudi/credentials/statuslist/context.go b/eudi/credentials/statuslist/context.go new file mode 100644 index 000000000..52a0a6ca2 --- /dev/null +++ b/eudi/credentials/statuslist/context.go @@ -0,0 +1,48 @@ +package statuslist + +import ( + "net/http" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" +) + +// VerificationContext is the narrow configuration bag the Checker +// needs. It is intentionally a subset of sdjwtvc.SdJwtVcVerificationContext +// (no VCT-in-requestor-info policy, no expected-nonce, no +// JwtVerifier indirection) so callers don't accidentally couple to +// SD-JWT VC semantics. +// +// Callers building this from an SdJwtVcVerificationContext copy the +// X509Context, Clock, and allow-insecure flag verbatim. +type VerificationContext struct { + // X509Context is consulted when the Status List Token's JWS + // protected header carries x5c. Nil is rejected at verify time + // if x5c is the chosen path. + X509Context eudi_jwt.X509VerificationContext + + // Clock supplies the "now" for iat/exp/nbf checks. nil falls + // back to time.Now(). + Clock jwt.Clock + + // AllowInsecureDidWeb mirrors the SD-JWT VC verifier's flag for + // permitting did:web resolution over plain HTTP (dev/test only). + AllowInsecureDidWeb bool + + // HTTPClient is used by the fetcher for the status list URI GET. + // nil falls back to http.DefaultClient (bounded by the fetcher's own + // context.WithTimeout). It is NOT threaded into signature-verification + // key resolution; did:web lookups during verify use their own + // timeout-bounded client (didweb.NewHTTPClient) instead. + HTTPClient *http.Client + + // MaxBodyBytes caps the HTTP response body and the + // post-decompression bit-array size. <= 0 falls back to + // MaxBodyDefault (5 MB). + MaxBodyBytes int64 + + // FetchTimeout bounds each HTTP request. <= 0 falls back to + // FetchTimeoutDefault (10 s). + FetchTimeout time.Duration +} diff --git a/eudi/credentials/statuslist/decoder.go b/eudi/credentials/statuslist/decoder.go new file mode 100644 index 000000000..61ad24ab4 --- /dev/null +++ b/eudi/credentials/statuslist/decoder.go @@ -0,0 +1,63 @@ +package statuslist + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "fmt" + "io" +) + +// decodeBits decompresses the base64url + zlib encoded `lst` field and +// returns the raw bit-array bytes. maxBytes caps the decompressed size +// to defend against zip bombs; 0 means use MaxBodyDefault. +func decodeBits(lstB64 string, maxBytes int64) ([]byte, error) { + if maxBytes <= 0 { + maxBytes = MaxBodyDefault + } + compressed, err := base64.RawURLEncoding.DecodeString(lstB64) + if err != nil { + return nil, fmt.Errorf("%w: lst is not base64url: %v", ErrDecode, err) + } + zr, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, fmt.Errorf("%w: zlib reader: %v", ErrDecode, err) + } + defer zr.Close() + + // LimitReader+1 lets us detect overflow without buffering the + // whole would-be payload. + out, err := io.ReadAll(io.LimitReader(zr, maxBytes+1)) + if err != nil { + return nil, fmt.Errorf("%w: zlib read: %v", ErrDecode, err) + } + if int64(len(out)) > maxBytes { + return nil, fmt.Errorf("%w: decompressed lst exceeds cap (%d bytes)", ErrDecode, maxBytes) + } + return out, nil +} + +// statusAtIndex extracts the bits-wide status value at the given +// index from a decoded bit array, then maps the raw value to a Status. +// +// Bit packing per draft-ietf-oauth-status-list-15 §4: the array is +// little-endian within each byte, lowest-order bits first. With +// bits=2, byte 0 holds entries 0..3 in positions [0:2, 2:4, 4:6, 6:8]. +func statusAtIndex(bits []byte, bitSize int, idx uint64) (Status, error) { + if !validBitSize(bitSize) { + return StatusUnknown, fmt.Errorf("%w: invalid bit size: %d", ErrUnauthorized, bitSize) + } + + totalBits := uint64(len(bits)) * 8 + startBit := idx * uint64(bitSize) + if startBit+uint64(bitSize) > totalBits { + return StatusUnknown, fmt.Errorf("%w: idx %d at bit %d exceeds %d total bits", ErrIndexBounds, idx, startBit, totalBits) + } + + byteIdx := startBit / 8 + bitOffset := uint(startBit % 8) + mask := byte((1 << bitSize) - 1) + raw := (bits[byteIdx] >> bitOffset) & mask + + return statusFromRaw(raw), nil +} diff --git a/eudi/credentials/statuslist/decoder_test.go b/eudi/credentials/statuslist/decoder_test.go new file mode 100644 index 000000000..813b2d3fd --- /dev/null +++ b/eudi/credentials/statuslist/decoder_test.go @@ -0,0 +1,151 @@ +package statuslist + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// encodeLst compresses raw with zlib and base64url-encodes it, +// matching the on-wire shape the spec requires for `lst`. +func encodeLst(t *testing.T, raw []byte) string { + t.Helper() + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + _, err := w.Write(raw) + require.NoError(t, err) + require.NoError(t, w.Close()) + return base64.RawURLEncoding.EncodeToString(buf.Bytes()) +} + +func Test_DecodeBits_RoundTrip(t *testing.T) { + raw := []byte{0x55, 0xAA, 0xF0, 0x0F} + lst := encodeLst(t, raw) + out, err := decodeBits(lst, 0) + require.NoError(t, err) + require.Equal(t, raw, out) +} + +func Test_DecodeBits_InvalidBase64_ReturnsErrDecode(t *testing.T) { + _, err := decodeBits("not_base64!!!", 0) + require.ErrorIs(t, err, ErrDecode) +} + +func Test_DecodeBits_InvalidZlib_ReturnsErrDecode(t *testing.T) { + bogus := base64.RawURLEncoding.EncodeToString([]byte("not-zlib")) + _, err := decodeBits(bogus, 0) + require.ErrorIs(t, err, ErrDecode) +} + +func Test_DecodeBits_PostDecompressionCap_ReturnsErrDecode(t *testing.T) { + // Highly compressible payload that decompresses to ~10 KB, + // against a cap of 100 bytes. + raw := bytes.Repeat([]byte{0x00}, 10000) + lst := encodeLst(t, raw) + _, err := decodeBits(lst, 100) + require.ErrorIs(t, err, ErrDecode) + require.True(t, strings.Contains(err.Error(), "exceeds cap")) +} + +// --- statusAtIndex: bit packing ---------------------------------------------- + +func Test_StatusAtIndex_1Bit_Valid_Invalid(t *testing.T) { + // One byte holds 8 entries at 1 bit each. + // Byte 0b10100011 → entries (lsb first): 1,1,0,0,0,1,0,1. + bits := []byte{0b10100011} + want := []Status{ + StatusInvalid, StatusInvalid, StatusValid, StatusValid, + StatusValid, StatusInvalid, StatusValid, StatusInvalid, + } + for i, w := range want { + s, err := statusAtIndex(bits, 1, uint64(i)) + require.NoError(t, err) + require.Equalf(t, w, s, "idx %d", i) + } +} + +func Test_StatusAtIndex_2Bit_AllFourValues(t *testing.T) { + // One byte holds 4 entries at 2 bits each. + // Byte 0b11100100 → entries (lsb first): 0,1,2,3 → Valid, + // Invalid, Suspended, ApplicationSpecific. + bits := []byte{0b11100100} + want := []Status{StatusValid, StatusInvalid, StatusSuspended, StatusApplicationSpecific} + for i, w := range want { + s, err := statusAtIndex(bits, 2, uint64(i)) + require.NoError(t, err) + require.Equalf(t, w, s, "idx %d", i) + } +} + +func Test_StatusAtIndex_4Bit_Nibbles(t *testing.T) { + // One byte holds 2 entries at 4 bits each. + // Byte 0xA5 → entries (lsb first): 0x5 (5 → AppSpecific), 0xA (10 → AppSpecific). + bits := []byte{0xA5} + s0, err := statusAtIndex(bits, 4, 0) + require.NoError(t, err) + require.Equal(t, StatusApplicationSpecific, s0) + s1, err := statusAtIndex(bits, 4, 1) + require.NoError(t, err) + require.Equal(t, StatusApplicationSpecific, s1) +} + +func Test_StatusAtIndex_4Bit_ValidThenInvalid(t *testing.T) { + // 0x10 → entry 0 = 0 (Valid), entry 1 = 1 (Invalid). + bits := []byte{0x10} + s0, err := statusAtIndex(bits, 4, 0) + require.NoError(t, err) + require.Equal(t, StatusValid, s0) + s1, err := statusAtIndex(bits, 4, 1) + require.NoError(t, err) + require.Equal(t, StatusInvalid, s1) +} + +func Test_StatusAtIndex_8Bit_FullByte(t *testing.T) { + // One byte = one entry at 8 bits. + bits := []byte{0, 1, 2, 7, 255} + want := []Status{ + StatusValid, StatusInvalid, StatusSuspended, + StatusApplicationSpecific, StatusApplicationSpecific, + } + for i, w := range want { + s, err := statusAtIndex(bits, 8, uint64(i)) + require.NoError(t, err) + require.Equalf(t, w, s, "idx %d", i) + } +} + +func Test_StatusAtIndex_LastIndexExactlyAtEnd_OK(t *testing.T) { + bits := []byte{0xFF} // 8 entries at 1 bit each + s, err := statusAtIndex(bits, 1, 7) // index 7 → bit 7 + require.NoError(t, err) + require.Equal(t, StatusInvalid, s) +} + +func Test_StatusAtIndex_PastEnd_ReturnsErrIndexBounds(t *testing.T) { + bits := []byte{0xFF} // 8 entries at 1 bit each + _, err := statusAtIndex(bits, 1, 8) + require.ErrorIs(t, err, ErrIndexBounds) +} + +func Test_StatusAtIndex_InvalidBitSize_ReturnsErrUnauthorized(t *testing.T) { + _, err := statusAtIndex([]byte{0xFF}, 3, 0) + require.True(t, errors.Is(err, ErrUnauthorized)) +} + +func Test_StatusAtIndex_CrossesByteBoundary_2Bit(t *testing.T) { + // 2-bit packing never crosses byte boundaries (8 % 2 == 0). With + // bits=2, idx=4 lives at byte 1, position 0. Use two bytes to + // confirm we land on the second. + bits := []byte{0x00, 0b11100100} + want := []Status{StatusValid, StatusInvalid, StatusSuspended, StatusApplicationSpecific} + for i, w := range want { + s, err := statusAtIndex(bits, 2, uint64(4+i)) + require.NoError(t, err) + require.Equalf(t, w, s, "idx %d", 4+i) + } +} diff --git a/eudi/credentials/statuslist/fetcher.go b/eudi/credentials/statuslist/fetcher.go new file mode 100644 index 000000000..d1295fde7 --- /dev/null +++ b/eudi/credentials/statuslist/fetcher.go @@ -0,0 +1,105 @@ +package statuslist + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// fetchResult bundles the raw JWT bytes returned by the status +// provider with the HTTP-side TTL signal (Cache-Control: max-age). +type fetchResult struct { + rawJwt []byte + httpMaxAge time.Duration // 0 if response had no max-age directive +} + +// fetchStatusListToken performs an HTTP GET against uri, enforcing +// the spec's Accept/Content-Type contract and the configured body +// size cap. The returned bytes are the unparsed signed JWT. +// +// Callers are expected to wrap this in singleflight at the URI level +// to dedupe concurrent fetches; the Checker does so. +func fetchStatusListToken(ctx context.Context, vc VerificationContext, uri string) (*fetchResult, error) { + httpClient := vc.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + timeout := vc.FetchTimeout + if timeout <= 0 { + timeout = FetchTimeoutDefault + } + maxBody := vc.MaxBodyBytes + if maxBody <= 0 { + maxBody = MaxBodyDefault + } + + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: build request: %v", ErrFetch, err) + } + req.Header.Set("Accept", StatusListTokenContentType) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFetch, err) + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("%w: non-2xx response: %s", ErrFetch, resp.Status) + } + + ct := resp.Header.Get("Content-Type") + // Accept "application/statuslist+jwt" with or without parameters + // like "; charset=...". Reject anything else (RFC §8.2). Note the + // CWT encoding (application/statuslist+cwt) is intentionally not + // supported by v1 — a CWT-only status list is rejected here. + if !strings.HasPrefix(strings.ToLower(ct), StatusListTokenContentType) { + return nil, fmt.Errorf( + "%w: unexpected Content-Type %q: only %s is supported (CWT status lists are not implemented)", + ErrFetch, ct, StatusListTokenContentType, + ) + } + + limited := io.LimitReader(resp.Body, maxBody+1) + body, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("%w: read body: %v", ErrFetch, err) + } + if int64(len(body)) > maxBody { + return nil, fmt.Errorf("%w: response body exceeds cap (%d bytes)", ErrFetch, maxBody) + } + + return &fetchResult{ + rawJwt: body, + httpMaxAge: parseMaxAge(resp.Header.Get("Cache-Control")), + }, nil +} + +// parseMaxAge picks the max-age=N directive out of a Cache-Control +// header. Returns 0 if the directive is absent or unparseable. +func parseMaxAge(cc string) time.Duration { + if cc == "" { + return 0 + } + for part := range strings.SplitSeq(cc, ",") { + part = strings.TrimSpace(part) + if !strings.HasPrefix(strings.ToLower(part), "max-age=") { + continue + } + v := strings.TrimSpace(part[len("max-age="):]) + secs, err := strconv.ParseInt(v, 10, 64) + if err != nil || secs < 0 { + return 0 + } + return time.Duration(secs) * time.Second + } + return 0 +} diff --git a/eudi/credentials/statuslist/fetcher_test.go b/eudi/credentials/statuslist/fetcher_test.go new file mode 100644 index 000000000..2b8403cd2 --- /dev/null +++ b/eudi/credentials/statuslist/fetcher_test.go @@ -0,0 +1,140 @@ +package statuslist + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_FetchStatusListToken_SendsAcceptHeader(t *testing.T) { + var gotAccept string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAccept = r.Header.Get("Accept") + w.Header().Set("Content-Type", StatusListTokenContentType) + _, _ = w.Write([]byte("body")) + })) + defer srv.Close() + + _, err := fetchStatusListToken(context.Background(), VerificationContext{}, srv.URL) + require.NoError(t, err) + require.Equal(t, StatusListTokenContentType, gotAccept) +} + +func Test_FetchStatusListToken_ReadsBodyAndCacheControl(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", StatusListTokenContentType) + w.Header().Set("Cache-Control", "max-age=300, public") + _, _ = w.Write([]byte("body-bytes")) + })) + defer srv.Close() + + res, err := fetchStatusListToken(context.Background(), VerificationContext{}, srv.URL) + require.NoError(t, err) + require.Equal(t, []byte("body-bytes"), res.rawJwt) + require.Equal(t, 300*time.Second, res.httpMaxAge) +} + +func Test_FetchStatusListToken_RejectsWrongContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + _, err := fetchStatusListToken(context.Background(), VerificationContext{}, srv.URL) + require.ErrorIs(t, err, ErrFetch) + require.Contains(t, err.Error(), "Content-Type") +} + +func Test_FetchStatusListToken_AcceptsContentTypeWithParameters(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", StatusListTokenContentType+"; charset=utf-8") + _, _ = w.Write([]byte("body")) + })) + defer srv.Close() + + _, err := fetchStatusListToken(context.Background(), VerificationContext{}, srv.URL) + require.NoError(t, err) +} + +func Test_FetchStatusListToken_RejectsNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", StatusListTokenContentType) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := fetchStatusListToken(context.Background(), VerificationContext{}, srv.URL) + require.ErrorIs(t, err, ErrFetch) +} + +func Test_FetchStatusListToken_BodySizeCap_ReturnsErrFetch(t *testing.T) { + big := strings.Repeat("X", 10000) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", StatusListTokenContentType) + _, _ = w.Write([]byte(big)) + })) + defer srv.Close() + + _, err := fetchStatusListToken(context.Background(), VerificationContext{MaxBodyBytes: 100}, srv.URL) + require.ErrorIs(t, err, ErrFetch) + require.Contains(t, err.Error(), "exceeds cap") +} + +func Test_FetchStatusListToken_TimeoutHonoured(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + w.Header().Set("Content-Type", StatusListTokenContentType) + _, _ = w.Write([]byte("late")) + })) + defer srv.Close() + defer close(release) + + _, err := fetchStatusListToken(context.Background(), VerificationContext{FetchTimeout: 50 * time.Millisecond}, srv.URL) + require.ErrorIs(t, err, ErrFetch) +} + +func Test_FetchStatusListToken_ConcurrentFetches_AllSucceed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", StatusListTokenContentType) + _, _ = w.Write([]byte("body")) + })) + defer srv.Close() + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + for range n { + go func() { + defer wg.Done() + _, err := fetchStatusListToken(context.Background(), VerificationContext{}, srv.URL) + require.NoError(t, err) + }() + } + wg.Wait() +} + +func Test_ParseMaxAge_Variants(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"max-age=60", 60 * time.Second}, + {"public, max-age=120", 120 * time.Second}, + {" Max-Age=30 ", 30 * time.Second}, // case-insensitive + {"", 0}, + {"no-store", 0}, + {"max-age=-1", 0}, // negative is invalid → ignored + {"max-age=notanumber", 0}, + } + for _, c := range cases { + require.Equalf(t, c.want, parseMaxAge(c.in), "input %q", c.in) + } +} diff --git a/eudi/credentials/statuslist/reference.go b/eudi/credentials/statuslist/reference.go new file mode 100644 index 000000000..bd3c15d81 --- /dev/null +++ b/eudi/credentials/statuslist/reference.go @@ -0,0 +1,98 @@ +package statuslist + +import "errors" + +// StatusListTokenTyp is the JOSE 'typ' header value mandated by the spec +// for the JWT encoding (draft-ietf-oauth-status-list-15 §5.1; the media +// type itself is registered in §8.2). +const StatusListTokenTyp = "statuslist+jwt" + +// StatusListTokenContentType is the HTTP Content-Type value for the JWT +// encoding (draft-ietf-oauth-status-list-15 §8.2). +const StatusListTokenContentType = "application/statuslist+jwt" + +// StatusClaim is the JSON shape of the `status` claim on a referenced +// token (e.g. an SD-JWT VC). The spec allows multiple sibling status +// mechanisms under `status`; v1 only supports `status_list`. +type StatusClaim struct { + StatusList *Reference `json:"status_list,omitempty"` +} + +// Reference identifies a single entry in a Status List Token: +// `idx` selects the bit-position, `uri` locates the token. +type Reference struct { + Index uint64 `json:"idx"` + URI string `json:"uri"` +} + +// Status is the typed value returned by Checker.Check. +// +// Mapping from the raw bit-value n is: +// +// n == 0 -> StatusValid +// n == 1 -> StatusInvalid +// n == 2 -> StatusSuspended (requires bits >= 2) +// n >= 3 -> StatusApplicationSpecific +// +// StatusUnknown is the zero value; it represents "not yet checked" +// and is the default for newly persisted credential rows. +type Status uint8 + +const ( + StatusUnknown Status = 0 + StatusValid Status = 1 + StatusInvalid Status = 2 + StatusSuspended Status = 3 + StatusApplicationSpecific Status = 4 +) + +// String returns a stable lowercase identifier for the status, used for +// log/diagnostic output and for surface-level wire formats. +func (s Status) String() string { + switch s { + case StatusValid: + return "valid" + case StatusInvalid: + return "invalid" + case StatusSuspended: + return "suspended" + case StatusApplicationSpecific: + return "application_specific" + default: + return "unknown" + } +} + +// fromRaw converts a decoded bit-value into a Status. +func statusFromRaw(raw uint8) Status { + switch raw { + case 0: + return StatusValid + case 1: + return StatusInvalid + case 2: + return StatusSuspended + default: + return StatusApplicationSpecific + } +} + +// Sentinel errors. Callers should use errors.Is to discriminate. +var ( + // ErrFetch wraps HTTP transport errors, non-2xx responses, and + // content-type / body-size violations during the fetch step. + ErrFetch = errors.New("status list fetch failed") + + // ErrUnauthorized covers signature failures, wrong typ, iss + // mismatch, time-bound violations, and unsupported bit-sizes — + // any integrity error after a successful fetch. + ErrUnauthorized = errors.New("status list token signature/iss/typ invalid") + + // ErrDecode covers malformed zlib payloads and bit-array + // length violations during decoding. + ErrDecode = errors.New("status list lst could not be decoded") + + // ErrIndexBounds is returned when the reference idx falls + // outside the decoded bit array. + ErrIndexBounds = errors.New("status list idx out of bounds") +) diff --git a/eudi/credentials/statuslist/reference_test.go b/eudi/credentials/statuslist/reference_test.go new file mode 100644 index 000000000..f6c309a75 --- /dev/null +++ b/eudi/credentials/statuslist/reference_test.go @@ -0,0 +1,54 @@ +package statuslist + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_StatusClaim_JSON_Roundtrip(t *testing.T) { + in := StatusClaim{StatusList: &Reference{Index: 42, URI: "https://example.com/sl/1"}} + b, err := json.Marshal(in) + require.NoError(t, err) + require.JSONEq(t, `{"status_list":{"idx":42,"uri":"https://example.com/sl/1"}}`, string(b)) + + var out StatusClaim + require.NoError(t, json.Unmarshal(b, &out)) + require.Equal(t, in, out) +} + +func Test_StatusClaim_JSON_OmitsStatusListWhenNil(t *testing.T) { + b, err := json.Marshal(StatusClaim{}) + require.NoError(t, err) + require.JSONEq(t, `{}`, string(b)) +} + +func Test_Status_String(t *testing.T) { + cases := map[Status]string{ + StatusUnknown: "unknown", + StatusValid: "valid", + StatusInvalid: "invalid", + StatusSuspended: "suspended", + StatusApplicationSpecific: "application_specific", + } + for s, want := range cases { + require.Equal(t, want, s.String()) + } +} + +func Test_StatusFromRaw_Mapping(t *testing.T) { + require.Equal(t, StatusValid, statusFromRaw(0)) + require.Equal(t, StatusInvalid, statusFromRaw(1)) + require.Equal(t, StatusSuspended, statusFromRaw(2)) + require.Equal(t, StatusApplicationSpecific, statusFromRaw(3)) + require.Equal(t, StatusApplicationSpecific, statusFromRaw(255)) +} + +func Test_Errors_AreDistinctSentinels(t *testing.T) { + require.False(t, errors.Is(ErrFetch, ErrUnauthorized)) + require.False(t, errors.Is(ErrFetch, ErrDecode)) + require.False(t, errors.Is(ErrFetch, ErrIndexBounds)) + require.True(t, errors.Is(ErrFetch, ErrFetch)) +} diff --git a/eudi/credentials/statuslist/test_utils.go b/eudi/credentials/statuslist/test_utils.go new file mode 100644 index 000000000..8c0618a1a --- /dev/null +++ b/eudi/credentials/statuslist/test_utils.go @@ -0,0 +1,244 @@ +package statuslist + +import ( + "bytes" + "compress/zlib" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/cert" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" + eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" + "github.com/stretchr/testify/require" +) + +// TestStatusListSigner is a fixture for building signed Status List +// Tokens in tests. Each instance carries an ephemeral ECDSA key and +// a self-signed certificate; the public X509VerificationContext() +// trusts that certificate so the verifier can validate signatures. +type TestStatusListSigner struct { + PrivKey *ecdsa.PrivateKey + Cert *x509.Certificate + DERBytes []byte +} + +// NewTestStatusListSigner creates a signer backed by a self-signed +// ECDSA P-256 certificate. +func NewTestStatusListSigner(t *testing.T) *TestStatusListSigner { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "statuslist-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, priv.Public(), priv) + require.NoError(t, err) + parsed, err := x509.ParseCertificate(der) + require.NoError(t, err) + return &TestStatusListSigner{PrivKey: priv, Cert: parsed, DERBytes: der} +} + +// X509VerificationContext returns a VerifyCertificate-friendly trust +// store that trusts this signer's certificate as a root. +func (s *TestStatusListSigner) X509VerificationContext() eudi_jwt.X509VerificationContext { + pool := x509.NewCertPool() + pool.AddCert(s.Cert) + return &eudi_jwt.StaticVerificationContext{ + VerifyOpts: x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}}, + } +} + +// TestStatusListOpts shapes a Status List Token built by SignToken. +// Zero-value defaults: bits=1, lst=all-zero (everyone Valid) sized +// to fit the highest Statuses key, iat=now, no exp/ttl. +type TestStatusListOpts struct { + Issuer string + Subject string // typically the status list URI + IssuedAt time.Time + // OmitIssuedAt builds a token without an `iat` claim, for + // negative-path tests — `iat` is REQUIRED by §5.1. + OmitIssuedAt bool + Expiry time.Time + TTLSeconds int64 + Bits int + // Statuses maps idx → raw status value. Indices not listed + // default to 0 (Valid). The lst is sized to fit max(idx)+1 + // entries at the requested bits per entry. + Statuses map[uint64]uint8 +} + +// SignToken builds a JWT carrying the Status List Token claims and +// signs it with the signer's key, embedding the certificate in the +// x5c header. typ defaults to "statuslist+jwt". +func (s *TestStatusListSigner) SignToken(t *testing.T, opts TestStatusListOpts) []byte { + t.Helper() + return s.SignTokenWithTyp(t, opts, StatusListTokenTyp) +} + +// SignTokenWithTyp is like SignToken but lets the caller override +// the 'typ' header for negative-path tests. +func (s *TestStatusListSigner) SignTokenWithTyp(t *testing.T, opts TestStatusListOpts, typ string) []byte { + t.Helper() + bits := opts.Bits + if bits == 0 { + bits = 1 + } + + lstBytes := encodeStatusBits(t, opts.Statuses, bits) + + builder := jwt.NewBuilder(). + Issuer(opts.Issuer). + Subject(opts.Subject). + Claim("status_list", map[string]any{ + "bits": bits, + "lst": lstBytes, + }) + if !opts.OmitIssuedAt { + if opts.IssuedAt.IsZero() { + opts.IssuedAt = time.Now() + } + builder = builder.IssuedAt(opts.IssuedAt) + } + if !opts.Expiry.IsZero() { + builder = builder.Expiration(opts.Expiry) + } + if opts.TTLSeconds > 0 { + builder = builder.Claim("ttl", opts.TTLSeconds) + } + tok, err := builder.Build() + require.NoError(t, err) + + chain := &cert.Chain{} + require.NoError(t, chain.Add([]byte(base64.StdEncoding.EncodeToString(s.DERBytes)))) + + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, typ)) + require.NoError(t, headers.Set(jws.X509CertChainKey, chain)) + + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256(), s.PrivKey, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + return signed +} + +// encodeStatusBits packs the per-index status values into a byte +// array of bits-wide entries (little-endian within each byte, per +// spec §4), then zlib-compresses and base64url-encodes the result. +func encodeStatusBits(t *testing.T, statuses map[uint64]uint8, bits int) string { + t.Helper() + maxIdx := uint64(0) + for idx := range statuses { + if idx > maxIdx { + maxIdx = idx + } + } + totalEntries := maxIdx + 1 + // Round up so the byte count fits all entries. + totalBits := totalEntries * uint64(bits) + byteLen := (totalBits + 7) / 8 + if byteLen == 0 { + byteLen = 1 + } + raw := make([]byte, byteLen) + for idx, val := range statuses { + bitStart := idx * uint64(bits) + byteIdx := bitStart / 8 + bitOffset := uint(bitStart % 8) + mask := byte((1 << bits) - 1) + // Clear old bits, then set new value. + raw[byteIdx] &^= mask << bitOffset + raw[byteIdx] |= (val & mask) << bitOffset + } + + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + _, err := w.Write(raw) + require.NoError(t, err) + require.NoError(t, w.Close()) + return base64.RawURLEncoding.EncodeToString(buf.Bytes()) +} + +// TestStatusListServer is an httptest.Server that serves a single +// Status List Token at any URL with the spec-mandated Content-Type. +type TestStatusListServer struct { + server *httptest.Server + bodyBytes atomic.Pointer[[]byte] + maxAge atomic.Int64 + hits atomic.Int64 +} + +// NewTestStatusListServer starts a server that returns body on every +// GET with Content-Type: application/statuslist+jwt. The returned URL +// is the server's base URL; tests should use it directly as the +// status_list.uri. +func NewTestStatusListServer(t *testing.T, body []byte) *TestStatusListServer { + t.Helper() + s := &TestStatusListServer{} + s.bodyBytes.Store(&body) + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r + s.hits.Add(1) + w.Header().Set("Content-Type", StatusListTokenContentType) + if mx := s.maxAge.Load(); mx > 0 { + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", mx)) + } + bp := s.bodyBytes.Load() + if bp != nil { + _, _ = w.Write(*bp) + } + })) + t.Cleanup(s.server.Close) + return s +} + +// NewTestStatusListServerWithToken starts a server and serves a Status +// List Token signed by signer. opts.Subject defaults to the server's +// own URL when unset, so the §5.1 `sub` == `uri` binding holds for +// callers that reference the token by this server's URL. +func NewTestStatusListServerWithToken(t *testing.T, signer *TestStatusListSigner, opts TestStatusListOpts) *TestStatusListServer { + t.Helper() + srv := NewTestStatusListServer(t, nil) + srv.Serve(t, signer, opts) + return srv +} + +// URL returns the server's base URL — what callers use as the +// status_list.uri. +func (s *TestStatusListServer) URL() string { return s.server.URL } + +// Serve signs opts with signer and serves the result on subsequent +// requests. opts.Subject defaults to this server's URL when unset so +// the spec-required `sub` == `uri` binding holds. +func (s *TestStatusListServer) Serve(t *testing.T, signer *TestStatusListSigner, opts TestStatusListOpts) { + t.Helper() + if opts.Subject == "" { + opts.Subject = s.URL() + } + s.SetBody(signer.SignToken(t, opts)) +} + +// SetBody atomically replaces the body served on subsequent requests. +func (s *TestStatusListServer) SetBody(body []byte) { s.bodyBytes.Store(&body) } + +// SetMaxAge sets the value of the Cache-Control: max-age=N response +// header on subsequent requests. 0 omits the header. +func (s *TestStatusListServer) SetMaxAge(seconds int64) { s.maxAge.Store(seconds) } + +// Hits returns the number of requests served so far. +func (s *TestStatusListServer) Hits() int64 { return s.hits.Load() } diff --git a/eudi/credentials/statuslist/verifier.go b/eudi/credentials/statuslist/verifier.go new file mode 100644 index 000000000..1a30723fc --- /dev/null +++ b/eudi/credentials/statuslist/verifier.go @@ -0,0 +1,173 @@ +package statuslist + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" +) + +// ClockSkewSeconds matches sdjwtvc.ClockSkewInSeconds. Kept local to +// avoid an import cycle between statuslist and sdjwtvc. +const ClockSkewSeconds = 180 + +// statusListClaim mirrors the inner `status_list` object of a Status +// List Token payload (draft-ietf-oauth-status-list-15 §6). +type statusListClaim struct { + Bits int `json:"bits"` + Lst string `json:"lst"` + // AggregationURI is intentionally not parsed. Aggregation lets a *relying + // party* prefetch every status list an issuer publishes; a wallet only ever + // needs the lists its own credentials reference, so v1 ignores it (fetching + // aggregated lists would pull in lists we hold no credentials for). +} + +// statusListPayload mirrors the verified Status List Token payload +// (draft-ietf-oauth-status-list-15 §6). Only fields v1 acts on are +// captured; unknown fields are tolerated. +type statusListPayload struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + IssuedAt int64 `json:"iat"` + Expiry int64 `json:"exp,omitempty"` + TTLSeconds int64 `json:"ttl,omitempty"` + StatusList statusListClaim `json:"status_list"` +} + +// verifiedStatusList holds a Status List Token whose signature, typ, +// iss, and time bounds have been validated. The lst field is still +// base64url-encoded and zlib-compressed; the decoder consumes it. +type verifiedStatusList struct { + payload statusListPayload + rawJwt []byte // original signed JWT bytes — kept for caching +} + +// payloadTTLSignal reports the caching lifetime advertised by the +// Status List Token itself — the `ttl` claim if present, otherwise the +// remaining `exp - now` — together with whether the token advertised +// one at all. draft-ietf-oauth-status-list-15 §8.2 requires the `ttl` +// and `exp` claims to take priority over HTTP caching headers, so the +// caller must distinguish "token said nothing" (fall back to the HTTP +// header) from "token advertised a lifetime". +func (v *verifiedStatusList) payloadTTLSignal() (time.Duration, bool) { + if v.payload.TTLSeconds > 0 { + return time.Duration(v.payload.TTLSeconds) * time.Second, true + } + if v.payload.Expiry > 0 { + if remaining := time.Until(time.Unix(v.payload.Expiry, 0)); remaining > 0 { + return remaining, true + } + } + return 0, false +} + +// verifyStatusListToken parses, signature-verifies, and time-checks a +// Status List Token. +// +// expectedURI MUST equal the sub claim — the spec's anti-substitution +// binding (§5.1, validation step §8.3): the fetched token's subject +// must be the very URI the credential pointed at. The spec leaves +// issuer alignment to the trust model (§11.3), so a delegated Status +// Issuer signing with its own key is accepted as long as the signature +// is trusted and sub matches. +func verifyStatusListToken(rawJwt []byte, ctx VerificationContext, expectedURI string, now time.Time) (*verifiedStatusList, error) { + keyProvider := eudi_jwt.NewJwtKeyProvider([]string{StatusListTokenTyp}, ctx.AllowInsecureDidWeb) + + clock := ctx.Clock + if clock == nil { + clock = staticClock{t: now} + } + + token, err := jwt.Parse(rawJwt, + jwt.WithKeyProvider(keyProvider), + jwt.WithClock(clock), + jwt.WithAcceptableSkew(ClockSkewSeconds*time.Second), + jwt.WithVerify(true), + ) + if err != nil { + return nil, fmt.Errorf("%w: parse/verify: %v", ErrUnauthorized, err) + } + + // If the dispatcher chose x5c, validate the chain against the + // configured trust anchors. The kid path delegates to did:web/ + // did:jwk resolution, which carries its own trust assumptions + // (HTTPS for did:web, key-binding-by-construction for did:jwk). + if x509KeyProvider, ok := keyProvider.InnerKeyProvider.(*eudi_jwt.X509KeyProvider); ok { + cert := x509KeyProvider.GetCert() + if ctx.X509Context == nil { + return nil, fmt.Errorf("%w: x5c chain present but no X509VerificationContext configured", ErrUnauthorized) + } + if err := eudi_jwt.VerifyCertificate(ctx.X509Context, cert, nil); err != nil { + return nil, fmt.Errorf("%w: certificate validation: %v", ErrUnauthorized, err) + } + } + + payload, err := payloadFromToken(token) + if err != nil { + return nil, fmt.Errorf("%w: invalid payload: %v", ErrUnauthorized, err) + } + + // sub MUST equal the URI the token was fetched from (§5.1, + // validation step §8.3). This binds the fetched Status List Token + // to the reference carried in the credential — without it a valid + // token for a *different* list could be substituted. + if payload.Subject == "" { + return nil, fmt.Errorf("%w: missing sub claim", ErrUnauthorized) + } + if payload.Subject != expectedURI { + return nil, fmt.Errorf("%w: sub %q does not match status list uri %q", ErrUnauthorized, payload.Subject, expectedURI) + } + + // iat is REQUIRED (§5.1). jwx validates it when present but does + // not enforce presence, so reject a token that omits it. + if payload.IssuedAt == 0 { + return nil, fmt.Errorf("%w: missing iat claim", ErrUnauthorized) + } + + // Token has a status_list claim; bits must be set to one of the + // spec-defined values before the decoder is willing to consume it. + if !validBitSize(payload.StatusList.Bits) { + return nil, fmt.Errorf("%w: invalid status_list.bits: %d", ErrUnauthorized, payload.StatusList.Bits) + } + if payload.StatusList.Lst == "" { + return nil, fmt.Errorf("%w: empty status_list.lst", ErrUnauthorized) + } + + return &verifiedStatusList{payload: payload, rawJwt: rawJwt}, nil +} + +// validBitSize matches RFC §6.1 — `bits` must be 1, 2, 4, or 8. +func validBitSize(b int) bool { + return b == 1 || b == 2 || b == 4 || b == 8 +} + +// staticClock is the default clock used when VerificationContext.Clock +// is nil. It is **only** used inside verifyStatusListToken; the +// Checker calls verify with a concrete "now" so cache decisions are +// monotonic with verification. +type staticClock struct{ t time.Time } + +func (s staticClock) Now() time.Time { return s.t } + +// payloadFromToken extracts the Status List Token's claims from a +// signature-verified jwt.Token into our statusListPayload struct via a +// JSON round-trip — the struct's json tags mirror the spec's claim +// names (iat/exp marshal as Unix seconds, status_list as a nested +// object). Returns an error if the mandatory status_list claim is +// missing or a claim is shaped wrong for its struct field. +func payloadFromToken(token jwt.Token) (statusListPayload, error) { + var out statusListPayload + if !token.Has("status_list") { + return out, fmt.Errorf("missing status_list claim") + } + raw, err := json.Marshal(token) + if err != nil { + return out, fmt.Errorf("serialize token claims: %v", err) + } + if err := json.Unmarshal(raw, &out); err != nil { + return out, fmt.Errorf("decode token claims: %v", err) + } + return out, nil +} diff --git a/eudi/credentials/statuslist/verifier_test.go b/eudi/credentials/statuslist/verifier_test.go new file mode 100644 index 000000000..b61f585c8 --- /dev/null +++ b/eudi/credentials/statuslist/verifier_test.go @@ -0,0 +1,237 @@ +package statuslist + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_VerifyStatusListToken_ValidX5cSignature(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0, 1: 1}, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + v, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.NoError(t, err) + require.Equal(t, "https://issuer.example", v.payload.Issuer) + require.Equal(t, 1, v.payload.StatusList.Bits) + require.NotEmpty(t, v.payload.StatusList.Lst) +} + +func Test_VerifyStatusListToken_DelegatedIssuer_Accepted(t *testing.T) { + signer := NewTestStatusListSigner(t) + // Token signed by a delegated Status Issuer: trusted signature, sub + // matches the uri, but iss differs from the credential issuer. + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://delegated-status-issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + // Accepted: the spec binds the token via sub + a trusted signature and + // leaves issuer alignment to the trust model (§11.3). + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.NoError(t, err) +} + +func Test_VerifyStatusListToken_WrongTyp_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignTokenWithTyp(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }, "dc+sd-jwt") + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) +} + +func Test_VerifyStatusListToken_X5cWithoutTrustAnchor_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + // No X509Context configured — x5c path can't validate. + vc := VerificationContext{} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) +} + +func Test_VerifyStatusListToken_FutureIat_BeyondSkew_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + farFuture := time.Now().Add(2 * time.Hour) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: farFuture, + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) +} + +func Test_VerifyStatusListToken_ExpiredBeyondSkew_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + now := time.Now() + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: now.Add(-2 * time.Hour), + Expiry: now.Add(-time.Hour), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", now) + require.ErrorIs(t, err, ErrUnauthorized) +} + +func Test_VerifyStatusListToken_InvalidBitSize_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + // Build a token manually with bits=3 (invalid) — go through the + // builder by setting status_list directly. + bits3Token := buildTokenWithBits(t, signer, 3) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(bits3Token, vc, "https://issuer.example/sl/1", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) + require.Contains(t, err.Error(), "bits") +} + +func Test_VerifyStatusListToken_SubMismatch_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/DIFFERENT", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) + require.Contains(t, err.Error(), "sub") +} + +func Test_VerifyStatusListToken_MissingSub_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + // no Subject + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) + require.Contains(t, err.Error(), "sub") +} + +func Test_VerifyStatusListToken_MissingIat_Rejected(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + OmitIssuedAt: true, + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + _, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.ErrorIs(t, err, ErrUnauthorized) + require.Contains(t, err.Error(), "iat") +} + +func Test_VerifyStatusListToken_TTLClaim_ReadOnPayload(t *testing.T) { + signer := NewTestStatusListSigner(t) + body := signer.SignToken(t, TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + TTLSeconds: 600, + }) + + vc := VerificationContext{X509Context: signer.X509VerificationContext()} + v, err := verifyStatusListToken(body, vc, "https://issuer.example/sl/1", time.Now()) + require.NoError(t, err) + require.Equal(t, int64(600), v.payload.TTLSeconds) +} + +func Test_VerifyStatusListToken_TTLSignal_Precedence(t *testing.T) { + // Explicit TTL claim wins over remaining exp duration. + v := &verifiedStatusList{payload: statusListPayload{ + TTLSeconds: 60, + Expiry: time.Now().Add(2 * time.Hour).Unix(), + }} + d, ok := v.payloadTTLSignal() + require.True(t, ok) + require.Equal(t, time.Minute, d) +} + +func Test_VerifyStatusListToken_TTLSignal_FallsBackToExp(t *testing.T) { + exp := time.Now().Add(30 * time.Minute) + v := &verifiedStatusList{payload: statusListPayload{Expiry: exp.Unix()}} + d, ok := v.payloadTTLSignal() + require.True(t, ok) + // Allow a small window so the test isn't time-sensitive. + require.InDelta(t, 30*time.Minute, d, float64(2*time.Second)) +} + +func Test_VerifyStatusListToken_TTLSignal_AbsentWhenNoTTLorExp(t *testing.T) { + // No ttl and no exp: the token advertises no lifetime, so the caller + // falls back to the HTTP max-age (and ultimately ClampTTL's default). + v := &verifiedStatusList{} + _, ok := v.payloadTTLSignal() + require.False(t, ok) +} + +// buildTokenWithBits builds a token whose status_list.bits is set to +// an arbitrary integer for negative-path tests. +func buildTokenWithBits(t *testing.T, s *TestStatusListSigner, bits int) []byte { + t.Helper() + // Reuse SignToken's machinery by signing with bits=1, then we + // patch the JWT payload below — but that breaks the signature. + // Instead, build via a parallel path that calls into the same + // token builder with arbitrary bits. + opts := TestStatusListOpts{ + Issuer: "https://issuer.example", + Subject: "https://issuer.example/sl/1", + IssuedAt: time.Now(), + Bits: bits, + Statuses: map[uint64]uint8{0: 0}, + } + // encodeStatusBits guards against invalid bits indirectly; for + // bits=3 the bit-packing math still produces *a* byte sequence + // (mask becomes 0b00000111). That's exactly what we want for + // this test — a verifiable token with bits=3 on the wire so we + // confirm the verifier rejects it. + return s.SignToken(t, opts) +} diff --git a/eudi/didweb/document_resolver.go b/eudi/didweb/document_resolver.go index 6459a7ba6..d297b118b 100644 --- a/eudi/didweb/document_resolver.go +++ b/eudi/didweb/document_resolver.go @@ -7,22 +7,49 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/privacybydesign/irmago/eudi/did" ) const Prefix = "did:web:" +// defaultResolveTimeout bounds a did:web fetch. It is applied by NewHTTPClient +// (and therefore NewDocumentResolver): without it the client would be the +// timeout-less http.DefaultClient, so a slow/malicious DID host could hang key +// resolution (and thus credential verification) indefinitely. +const defaultResolveTimeout = 10 * time.Second + +// NewHTTPClient returns the HTTP client used for did:web resolution: a client +// bounded by defaultResolveTimeout so a slow/malicious DID host cannot hang +// resolution indefinitely. Exposed for callers (e.g. the kid key provider) that +// hold their own client; callers that need a resolver should use NewDocumentResolver. +func NewHTTPClient() *http.Client { + return &http.Client{Timeout: defaultResolveTimeout} +} + // DocumentResolver resolves did:web DIDs to DID Documents by fetching them over HTTPS. // See: https://w3c-ccg.github.io/did-method-web/ type DocumentResolver struct { - // HTTPClient is the HTTP client used to fetch DID documents. If nil, http.DefaultClient is used. + // HTTPClient is the HTTP client used to fetch DID documents. It must be + // non-nil; construct the resolver via NewDocumentResolver to get a client + // bounded by defaultResolveTimeout (never the timeout-less http.DefaultClient). HTTPClient *http.Client // AllowInsecure additionally allows resolving did:web DIDs over HTTP when // the HTTPS request fails. This should only be enabled in developer mode. AllowInsecure bool } +// NewDocumentResolver returns a resolver whose HTTPClient is bounded by +// defaultResolveTimeout. allowInsecure permits falling back to plain HTTP when +// the HTTPS fetch fails (developer mode only). +func NewDocumentResolver(allowInsecure bool) *DocumentResolver { + return &DocumentResolver{ + HTTPClient: NewHTTPClient(), + AllowInsecure: allowInsecure, + } +} + // Resolve fetches and parses the DID Document for the given did:web DID. func (r *DocumentResolver) Resolve(didWeb string) (*did.Document, error) { docURL, err := didWebToURL(didWeb) @@ -48,12 +75,7 @@ func (r *DocumentResolver) Resolve(didWeb string) (*did.Document, error) { } func (r *DocumentResolver) fetchDocument(docURL string) (*did.Document, error) { - client := r.HTTPClient - if client == nil { - client = http.DefaultClient - } - - resp, err := client.Get(docURL) + resp, err := r.HTTPClient.Get(docURL) if err != nil { return nil, fmt.Errorf("did:web: failed to fetch DID document from %s: %w", docURL, err) } diff --git a/eudi/jwt/key_providers.go b/eudi/jwt/key_providers.go index 0ccc457e3..8b91b221f 100644 --- a/eudi/jwt/key_providers.go +++ b/eudi/jwt/key_providers.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "net/http" + "slices" "strings" "github.com/lestrrat-go/jwx/v3/cert" @@ -17,6 +18,66 @@ import ( "github.com/privacybydesign/irmago/eudi/didweb" ) +// JwtKeyProvider validates the 'typ' header against a configured allow-list, +// then dispatches signature key resolution to either X509KeyProvider (when the +// JWS protected header carries x5c) or KidKeyProvider (when it carries kid). +// +// Callers that need post-fetch access to the resolved certificate (for chain +// validation against an X509VerificationContext) can type-assert +// InnerKeyProvider to *X509KeyProvider after FetchKeys returns. +type JwtKeyProvider struct { + allowedTyps []string + allowInsecure bool + + // InnerKeyProvider is populated by FetchKeys and exposes the concrete + // provider used for verification (currently *X509KeyProvider or + // *KidKeyProvider). + InnerKeyProvider jws.KeyProvider +} + +func NewJwtKeyProvider(allowedTyps []string, allowInsecure bool) *JwtKeyProvider { + return &JwtKeyProvider{ + allowedTyps: allowedTyps, + allowInsecure: allowInsecure, + } +} + +// FetchKeys validates the 'typ' header against allowedTyps and dispatches to +// X509KeyProvider or KidKeyProvider depending on which header is present. +// 'typ' MUST be present in the protected header and MUST be one of allowedTyps. +func (p *JwtKeyProvider) FetchKeys(ctx context.Context, sink jws.KeySink, sig *jws.Signature, msg *jws.Message) error { + if sig == nil { + return fmt.Errorf("missing JWS signature") + } + typ, ok := sig.ProtectedHeaders().Type() + if !ok || !slices.Contains(p.allowedTyps, typ) { + return fmt.Errorf("invalid 'typ' header: %v", typ) + } + + // Select the key reference. x5c and kid are mutually exclusive: if both were + // accepted, a kid would overwrite an x5c here while the X.509 trust/CRL check + // downstream (gated on the *X509KeyProvider type) is silently skipped, letting a + // forged credential be verified against the kid-resolved key. + x5c, x5cPresent := sig.ProtectedHeaders().X509CertChain() + x5cPresent = x5cPresent && x5c != nil + + kid, kidPresent := sig.ProtectedHeaders().KeyID() + kidPresent = kidPresent && kid != "" + + switch { + case x5cPresent && kidPresent: + return fmt.Errorf("ambiguous key reference: both 'x5c' and 'kid' headers are present") + case x5cPresent: + p.InnerKeyProvider = NewX509KeyProvider(x5c) + case kidPresent: + p.InnerKeyProvider = NewKidKeyProvider(kid, p.allowInsecure) + default: + return fmt.Errorf("no supported key reference header (x5c or kid) present in the signature") + } + + return p.InnerKeyProvider.FetchKeys(ctx, sink, sig, msg) +} + type X509KeyProvider struct { x5cHeader *cert.Chain @@ -69,7 +130,9 @@ func (p *X509KeyProvider) FetchKeys(ctx context.Context, sink jws.KeySink, sig * } type KidKeyProvider struct { - kidHeader string + kidHeader string + // httpClient resolves did:web DID documents. NewKidKeyProvider sets it to a + // timeout-bounded client (didweb.NewHTTPClient); tests inject their own. httpClient *http.Client allowInsecure bool } @@ -77,6 +140,7 @@ type KidKeyProvider struct { func NewKidKeyProvider(kidHeader string, allowInsecure bool) *KidKeyProvider { return &KidKeyProvider{ kidHeader: kidHeader, + httpClient: didweb.NewHTTPClient(), allowInsecure: allowInsecure, } } diff --git a/eudi/jwt/key_providers_test.go b/eudi/jwt/key_providers_test.go index 8d7195b01..8fffda9fc 100644 --- a/eudi/jwt/key_providers_test.go +++ b/eudi/jwt/key_providers_test.go @@ -636,3 +636,120 @@ func Test_KidKeyProvider_FetchKeys_UnsupportedDidMethod_ReturnsError(t *testing. err := p.FetchKeys(context.Background(), &testKeySink{}, msg.Signatures()[0], msg) require.ErrorContains(t, err, "unsupported DID method") } + +// ─── JwtKeyProvider ────────────────────────────────────────────────────────── + +// newSignedJWSMessageWithTyp signs a JWT with the given key and writes the +// requested 'typ' value into the JWS protected header. +func newSignedJWSMessageWithTyp(t *testing.T, issuer string, privKey any, alg jwa.SignatureAlgorithm, typ string, headerExtra map[string]any) *jws.Message { + t.Helper() + tok, err := jwt.NewBuilder().Issuer(issuer).Build() + require.NoError(t, err) + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, typ)) + for k, v := range headerExtra { + require.NoError(t, headers.Set(k, v)) + } + tokenBytes, err := jwt.Sign(tok, jwt.WithKey(alg, privKey, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + msg, err := jws.Parse(tokenBytes) + require.NoError(t, err) + return msg +} + +func Test_JwtKeyProvider_FetchKeys_NilSignature_ReturnsError(t *testing.T) { + p := NewJwtKeyProvider([]string{"any+jwt"}, false) + err := p.FetchKeys(context.Background(), &testKeySink{}, nil, nil) + require.ErrorContains(t, err, "missing JWS signature") +} + +func Test_JwtKeyProvider_FetchKeys_MissingTypHeader_ReturnsError(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + // Plain message — no typ header set. + msg := newTestJWSMessageSigned(t, "test", privKey, jwa.ES256()) + sig := msg.Signatures()[0] + + p := NewJwtKeyProvider([]string{"statuslist+jwt"}, false) + err = p.FetchKeys(context.Background(), &testKeySink{}, sig, msg) + require.ErrorContains(t, err, "invalid 'typ' header") +} + +func Test_JwtKeyProvider_FetchKeys_DisallowedTyp_ReturnsError(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + msg := newSignedJWSMessageWithTyp(t, "test", privKey, jwa.ES256(), "dc+sd-jwt", nil) + sig := msg.Signatures()[0] + + p := NewJwtKeyProvider([]string{"statuslist+jwt"}, false) + err = p.FetchKeys(context.Background(), &testKeySink{}, sig, msg) + require.ErrorContains(t, err, "invalid 'typ' header") +} + +func Test_JwtKeyProvider_FetchKeys_AllowedTyp_NoKeyReference_ReturnsError(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + msg := newSignedJWSMessageWithTyp(t, "test", privKey, jwa.ES256(), "statuslist+jwt", nil) + sig := msg.Signatures()[0] + + p := NewJwtKeyProvider([]string{"statuslist+jwt"}, false) + err = p.FetchKeys(context.Background(), &testKeySink{}, sig, msg) + require.ErrorContains(t, err, "no supported key reference header") +} + +func Test_JwtKeyProvider_FetchKeys_AllowedTyp_WithX5c_DispatchesToX509Provider(t *testing.T) { + derBytes, privKey, _ := newTestECDSACert(t) + chain := newTestCertChain(t, derBytes) + + msg := newSignedJWSMessageWithTyp(t, "test", privKey, jwa.ES256(), "statuslist+jwt", map[string]any{ + jws.X509CertChainKey: chain, + }) + sig := msg.Signatures()[0] + + p := NewJwtKeyProvider([]string{"statuslist+jwt"}, false) + sink := &testKeySink{} + err := p.FetchKeys(context.Background(), sink, sig, msg) + require.NoError(t, err) + require.Equal(t, jwa.ES256(), sink.alg) + require.NotNil(t, sink.key) + + _, isX509 := p.InnerKeyProvider.(*X509KeyProvider) + require.True(t, isX509, "InnerKeyProvider should be *X509KeyProvider after x5c dispatch") +} + +func Test_JwtKeyProvider_FetchKeys_AllowedTyp_WithKid_DispatchesToKidProvider(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + didJwk := newTestDidJwk(t, privKey) + + msg := newSignedJWSMessageWithTyp(t, didJwk, privKey, jwa.ES256(), "statuslist+jwt", map[string]any{ + jws.KeyIDKey: "#0", + }) + sig := msg.Signatures()[0] + + p := NewJwtKeyProvider([]string{"statuslist+jwt"}, false) + sink := &testKeySink{} + err = p.FetchKeys(context.Background(), sink, sig, msg) + require.NoError(t, err) + require.Equal(t, jwa.ES256(), sink.alg) + require.NotNil(t, sink.key) + + _, ok := p.InnerKeyProvider.(*KidKeyProvider) + require.True(t, ok, "InnerKeyProvider should be *KidKeyProvider after kid dispatch") +} + +func Test_JwtKeyProvider_FetchKeys_MultipleAllowedTyps_AcceptsAny(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + didJwk := newTestDidJwk(t, privKey) + + for _, typ := range []string{"dc+sd-jwt", "vc+sd-jwt"} { + msg := newSignedJWSMessageWithTyp(t, didJwk, privKey, jwa.ES256(), typ, map[string]any{ + jws.KeyIDKey: "#0", + }) + sig := msg.Signatures()[0] + p := NewJwtKeyProvider([]string{"dc+sd-jwt", "vc+sd-jwt"}, false) + err = p.FetchKeys(context.Background(), &testKeySink{}, sig, msg) + require.NoError(t, err, "typ %s should be accepted", typ) + } +} diff --git a/eudi/openid4vci/client.go b/eudi/openid4vci/client.go index 5b4506217..b4b0bbe5a 100644 --- a/eudi/openid4vci/client.go +++ b/eudi/openid4vci/client.go @@ -17,6 +17,7 @@ import ( "github.com/privacybydesign/irmago/eudi/credentials/sdjwtvc/typemetadata" "github.com/privacybydesign/irmago/eudi/internal/helpers" "github.com/privacybydesign/irmago/eudi/metadata" + "github.com/privacybydesign/irmago/eudi/services" ) // SdJwtVcStorageClient is the interface that the openid4vci client requires for @@ -31,6 +32,8 @@ type Client struct { currentSession *session holderVerifier *sdjwtvc.HolderVerificationProcessor + credentialService services.CredentialService + // holderKeyBinder creates the holder binding keys and OpenID4VCI proofs of // possession during issuance. It is a required dependency (software or // WSCA-backed); see NewClient. @@ -47,6 +50,7 @@ type Client struct { func NewClient(httpClient *http.Client, config *eudi.Configuration, holderVerifier *sdjwtvc.HolderVerificationProcessor, + credentialService services.CredentialService, holderKeyBinder HolderKeyBinder, ) (*Client, error) { if config == nil { @@ -57,10 +61,11 @@ func NewClient(httpClient *http.Client, } return &Client{ - httpClient: httpClient, - Configuration: config, - holderVerifier: holderVerifier, - holderKeyBinder: holderKeyBinder, + httpClient: httpClient, + Configuration: config, + holderVerifier: holderVerifier, + credentialService: credentialService, + holderKeyBinder: holderKeyBinder, }, nil } @@ -153,6 +158,7 @@ func (client *Client) handleCredentialOffer( holderVerifier: client.holderVerifier, holderKeyBinder: client.holderKeyBinder, storage: client.Configuration.Storage, + credentialService: client.credentialService, vctResolver: vctResolver, allowInsecureHttp: client.allowInsecureHttp, originalCredentialMetadata: originalCredentialMetadata, diff --git a/eudi/openid4vci/client_test.go b/eudi/openid4vci/client_test.go index e3cc9191b..4266fb79d 100644 --- a/eudi/openid4vci/client_test.go +++ b/eudi/openid4vci/client_test.go @@ -15,6 +15,7 @@ import ( eudi_jwt "github.com/privacybydesign/irmago/eudi/jwt" "github.com/privacybydesign/irmago/eudi/services" "github.com/privacybydesign/irmago/eudi/storage" + "github.com/privacybydesign/irmago/eudi/storage/db" "github.com/privacybydesign/irmago/eudi/storage/sqlcipherstorage" "github.com/privacybydesign/irmago/eudi/utils" "github.com/privacybydesign/irmago/internal/common" @@ -57,7 +58,14 @@ func createOpenID4VCiClientForTesting(t *testing.T) (storage.Storage, *Client) { holderVerifier := sdjwtvc.NewHolderVerificationProcessor(sdJwtVcVerificationContext) - client, err := NewClient(&http.Client{}, conf, holderVerifier, services.NewHolderBindingKeyService(conf.Storage.Db())) + credStore := db.NewCredentialStore(s.Db()) + credentialService := services.NewCredentialService( + credStore, + db.NewHolderBindingKeyStore(s.Db()), + s.FileSystem(), + services.NewRevocationService(nil, credStore), + ) + client, err := NewClient(&http.Client{}, conf, holderVerifier, credentialService, services.NewHolderBindingKeyService(conf.Storage.Db())) require.NoError(t, err) client.AllowInsecureHttpForTesting() diff --git a/eudi/openid4vci/session.go b/eudi/openid4vci/session.go index 64703a4a7..8c6dd1fc8 100644 --- a/eudi/openid4vci/session.go +++ b/eudi/openid4vci/session.go @@ -38,6 +38,7 @@ type session struct { httpClient *http.Client handler Handler storage storage.Storage + credentialService services.CredentialService holderVerifier *sdjwtvc.HolderVerificationProcessor holderKeyBinder HolderKeyBinder @@ -367,9 +368,8 @@ func (s *session) verifyVctIntegrity(fetched []*fetchedCredential) error { } func (s *session) storeCredentials(fetched []*fetchedCredential) error { - credentialService := services.NewCredentialService(s.storage) for _, fc := range fetched { - err := credentialService.VerifyAndStoreIssuedCredentials( + err := s.credentialService.VerifyAndStoreIssuedCredentials( fc.verifiedSdJwtVcs, fc.credentialConfigurationId, *s.credentialIssuerMetadata, diff --git a/eudi/openid4vp/did_verifier_validator.go b/eudi/openid4vp/did_verifier_validator.go index 35824d457..2cabac6d6 100644 --- a/eudi/openid4vp/did_verifier_validator.go +++ b/eudi/openid4vp/did_verifier_validator.go @@ -28,9 +28,7 @@ type DidVerifierValidator struct { // NewDidVerifierValidator creates a new DID-based verifier validator. func NewDidVerifierValidator(allowInsecureDidWeb bool) *DidVerifierValidator { return &DidVerifierValidator{ - didWebResolver: &didweb.DocumentResolver{ - AllowInsecure: allowInsecureDidWeb, - }, + didWebResolver: didweb.NewDocumentResolver(allowInsecureDidWeb), } } diff --git a/eudi/openid4vp/eudi_sdjwt_dcql/handler.go b/eudi/openid4vp/eudi_sdjwt_dcql/handler.go index bfe0866d7..f9c08afa9 100644 --- a/eudi/openid4vp/eudi_sdjwt_dcql/handler.go +++ b/eudi/openid4vp/eudi_sdjwt_dcql/handler.go @@ -46,6 +46,13 @@ func isHttpVct(vct string) bool { return strings.HasPrefix(vct, "https://") || strings.HasPrefix(vct, "http://") } +// RevocationChecker reports whether a stored credential instance is currently +// revoked. The disclosure planner depends only on this narrow verb, keeping the +// Token Status List mechanics out of this package (see services.RevocationService). +type RevocationChecker interface { + IsRevoked(instance *models.IssuedCredentialInstance) bool +} + // SdJwtVcDcqlHandler implements dcql.DcqlCredentialQueryHandler for SD-JWT-VC // credentials stored in the eudi storage (SQLite). type SdJwtVcDcqlHandler struct { @@ -54,6 +61,10 @@ type SdJwtVcDcqlHandler struct { keyBinder sdjwtvc.KeyBinder vctFetcher typemetadata.VctFetcher issuerFetcher typemetadata.IssuerFetcher + + // revocation determines a candidate's Revoked flag. Nil disables the check + // (candidates are then never flagged revoked). + revocation RevocationChecker } // NewSdJwtVcDcqlHandler creates a new handler. vctFetcher and issuerFetcher are @@ -67,16 +78,19 @@ type SdJwtVcDcqlHandler struct { // WSCA/HSM-backed implementation to keep the holder private key out of process. func NewSdJwtVcDcqlHandler( eudiStorage storage.Storage, + credentialStore db.CredentialStore, vctFetcher typemetadata.VctFetcher, issuerFetcher typemetadata.IssuerFetcher, keyBinder sdjwtvc.KeyBinder, + revocation RevocationChecker, ) *SdJwtVcDcqlHandler { return &SdJwtVcDcqlHandler{ storage: eudiStorage, - credentialStore: db.NewCredentialStore(eudiStorage.Db()), + credentialStore: credentialStore, keyBinder: keyBinder, vctFetcher: vctFetcher, issuerFetcher: issuerFetcher, + revocation: revocation, } } @@ -116,6 +130,7 @@ func (h *SdJwtVcDcqlHandler) FindCandidates(query dcql.CredentialQuery) (*dcql.C } now := time.Now() + hasExhaustedBatch := false for _, batch := range batches { if !isBatchValid(batch, now) { @@ -128,7 +143,11 @@ func (h *SdJwtVcDcqlHandler) FindCandidates(query dcql.CredentialQuery) (*dcql.C continue } - rawSdJwt, _ := loadRawSdJwt(batch, h.credentialStore) + instance, err := h.credentialStore.GetUnusedInstance(batch.ID) + if err != nil { + continue + } + rawSdJwt := sdjwtvc.SdJwtVc(instance.RawCredential) attributes, err := parseBatchAttributes(batch, query, rawSdJwt) if err != nil { continue @@ -149,6 +168,8 @@ func (h *SdJwtVcDcqlHandler) FindCandidates(query dcql.CredentialQuery) (*dcql.C Attributes: attributes, ExpiryDate: expiryUnix(batch), Image: image, + Revoked: h.revocation != nil && h.revocation.IsRevoked(instance), + RevocationSupported: instance.StatusListURI != nil, } if batch.IssuedAt.Valid { diff --git a/eudi/openid4vp/eudi_sdjwt_dcql/handler_test.go b/eudi/openid4vp/eudi_sdjwt_dcql/handler_test.go index 8e3e9d923..0ff495eca 100644 --- a/eudi/openid4vp/eudi_sdjwt_dcql/handler_test.go +++ b/eudi/openid4vp/eudi_sdjwt_dcql/handler_test.go @@ -155,6 +155,72 @@ func TestFindCandidates_ValidCredentialIncluded(t *testing.T) { require.Len(t, result.OwnedCandidates, 1, "valid credential should appear as candidate") } +// stubRevocation is an injectable RevocationChecker for handler tests: it lets +// them exercise the disclosure planner's use of the flag without any Token +// Status List machinery (that lives with services.RevocationService). +type stubRevocation struct{ revoked bool } + +func (s stubRevocation) IsRevoked(*models.IssuedCredentialInstance) bool { return s.revoked } + +// TestFindCandidates_RevokedSurfaced pins the IRMA-parity contract: a revoked +// SD-JWT VC is NOT dropped or refused during planning. It still appears as an +// owned candidate carrying Revoked=true (from the injected RevocationChecker), +// so the frontend can decide — the verifier's own status check is the backstop. +func TestFindCandidates_RevokedSurfaced(t *testing.T) { + h, store := newTestHandler(t) + h.revocation = stubRevocation{revoked: true} + + batch := newTestBatch("hash-revoked", "https://example.com/EmailCredential", map[string]any{ + "email": "test@example.com", + }) + uri := "https://issuer.example.com/statuslist" + idx := uint64(3) + batch.Instances[0].StatusListURI = &uri + batch.Instances[0].StatusListIdx = &idx + require.NoError(t, store.StoreBatch(batch)) + + query := parseDcqlQuery(t, `{ + "id": "q1", + "format": "dc+sd-jwt", + "meta": {"vct_values": ["https://example.com/EmailCredential"]}, + "claims": [{"path": ["email"]}] + }`) + + result, err := h.FindCandidates(query) + require.NoError(t, err) + require.Len(t, result.OwnedCandidates, 1, "revoked credential must still be offered, not dropped") + assert.True(t, result.OwnedCandidates[0].Revoked, "checker reports revoked -> Revoked") + assert.True(t, result.OwnedCandidates[0].RevocationSupported) +} + +// TestFindCandidates_NotRevoked: an instance the checker reports as not revoked +// is offered with Revoked=false but RevocationSupported=true (it carries a +// status_list reference). +func TestFindCandidates_NotRevoked(t *testing.T) { + h, store := newTestHandler(t) + h.revocation = stubRevocation{revoked: false} + + batch := newTestBatch("hash-valid-stored", "https://example.com/EmailCredential", map[string]any{ + "email": "test@example.com", + }) + uri := "https://issuer.example.com/statuslist" + idx := uint64(3) + batch.Instances[0].StatusListURI = &uri + batch.Instances[0].StatusListIdx = &idx + require.NoError(t, store.StoreBatch(batch)) + + result, err := h.FindCandidates(parseDcqlQuery(t, `{ + "id": "q1", + "format": "dc+sd-jwt", + "meta": {"vct_values": ["https://example.com/EmailCredential"]}, + "claims": [{"path": ["email"]}] + }`)) + require.NoError(t, err) + require.Len(t, result.OwnedCandidates, 1) + assert.False(t, result.OwnedCandidates[0].Revoked, "checker reports not revoked -> not Revoked") + assert.True(t, result.OwnedCandidates[0].RevocationSupported) +} + // TestFindCandidates_RegionalLocale_KeyedByBaseLanguage pins the contract // that issuer name, credential name, and claim display name on OpenID4VP // disclosure candidates are keyed by BCP 47 base language — the same diff --git a/eudi/services/credential_service.go b/eudi/services/credential_service.go index 5929f72a8..cf49d5930 100644 --- a/eudi/services/credential_service.go +++ b/eudi/services/credential_service.go @@ -13,8 +13,8 @@ import ( "github.com/privacybydesign/irmago/common/clientmodels" "github.com/privacybydesign/irmago/eudi" "github.com/privacybydesign/irmago/eudi/credentials/sdjwtvc" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" "github.com/privacybydesign/irmago/eudi/metadata" - "github.com/privacybydesign/irmago/eudi/storage" "github.com/privacybydesign/irmago/eudi/storage/db" "github.com/privacybydesign/irmago/eudi/storage/db/models" "github.com/privacybydesign/irmago/eudi/storage/filesystem" @@ -32,28 +32,52 @@ type CredentialService interface { requireCryptographicKeyBinding bool, publicKeyIdentifiers []models.PublicHolderBindingKey, ) error + + // DeleteByHash deletes a stored CredentialBatch by its deterministic hash. + // Returns ErrNotFound if no batch exists with that hash. + DeleteByHash(hash string) error } type credentialService struct { credentialStore db.CredentialStore holderBindingKeyStore db.HolderBindingKeyStore fileStorage filesystem.FileSystemStorage + // revocation supplies the per-batch revocation flags for the credential + // list view (see GetCredentialMetadataList). + revocation *RevocationService } -func NewCredentialService(s storage.Storage) CredentialService { +func NewCredentialService( + credentialStore db.CredentialStore, + holderBindingKeyStore db.HolderBindingKeyStore, + fileStorage filesystem.FileSystemStorage, + revocation *RevocationService, +) CredentialService { return &credentialService{ - credentialStore: db.NewCredentialStore(s.Db()), - holderBindingKeyStore: db.NewHolderBindingKeyStore(s.Db()), - fileStorage: s.FileSystem(), + credentialStore: credentialStore, + holderBindingKeyStore: holderBindingKeyStore, + fileStorage: fileStorage, + revocation: revocation, } } +func (s *credentialService) DeleteByHash(hash string) error { + return s.credentialStore.DeleteBatchByHash(hash) +} + func (s *credentialService) GetCredentialMetadataList() ([]*clientmodels.Credential, error) { m, err := s.credentialStore.GetCredentialBatchList() if err != nil { return nil, err } + // Per-credential revocation flags are derived from stored Token Status List + // statuses (maintained by RevocationService.RefreshStatuses). + revoked, revocable, err := s.revocation.BatchRevocation() + if err != nil { + return nil, err + } + // Convert storage models to client models clientModels := make([]*clientmodels.Credential, len(m)) for i, batch := range m { @@ -124,20 +148,39 @@ func (s *credentialService) GetCredentialMetadataList() ([]*clientmodels.Credent iat = &x } - // Try get the credential image from filesystem storage, if it exists. + // Resolve credential/issuer logos from the filesystem cache. Scan ALL + // display entries (not just Display[0]): multi-locale metadata may carry + // the logo on a later entry, and only one logo is cached. Break on the + // first entry that actually resolves, not the first non-empty URI, since a + // display's URI may not be the one that got cached. Mirrors the + // issuance/disclosure paths. + // TODO: pick the logo matching the client's language preference instead of + // the first that resolves. credentialLogoManager := s.fileStorage.Credentials().LogoManager() issuerLogoManager := s.fileStorage.Issuers().LogoManager() - var issuerImage *clientmodels.Image = nil - var credentialImage *clientmodels.Image = nil - - // TODO: since we don't know which display is actually used by the client, we are currently just trying to get the logos for the first display. We should implement a more robust solution for this in the future, potentially by storing a separate logo for each display/language in the filesystem and retrieving the correct one based on the client's language preferences. - if len(batch.IssuerDisplay) > 0 && batch.IssuerDisplay[0].LogoURI.Valid { - issuerImage = eudi.LoadLogoImage(issuerLogoManager, batch.IssuerDisplay[0].LogoURI.V) + var issuerImage *clientmodels.Image + for _, d := range batch.IssuerDisplay { + if !d.LogoURI.Valid || d.LogoURI.V == "" { + continue + } + if img := eudi.LoadLogoImage(issuerLogoManager, d.LogoURI.V); img != nil { + issuerImage = img + break + } } - if batch.CredentialMetadata != nil && len(batch.CredentialMetadata.Display) > 0 && batch.CredentialMetadata.Display[0].LogoURI != "" { - credentialImage = eudi.LoadLogoImage(credentialLogoManager, batch.CredentialMetadata.Display[0].LogoURI) + var credentialImage *clientmodels.Image + if batch.CredentialMetadata != nil { + for _, d := range batch.CredentialMetadata.Display { + if d.LogoURI == "" { + continue + } + if img := eudi.LoadLogoImage(credentialLogoManager, d.LogoURI); img != nil { + credentialImage = img + break + } + } } clientModels[i] = &clientmodels.Credential{ @@ -160,8 +203,8 @@ func (s *credentialService) GetCredentialMetadataList() ([]*clientmodels.Credent Attributes: attrs, ExpiryDate: exp, IssuanceDate: iat, - Revoked: false, // revocation is not yet implemented, so default to false for now - RevocationSupported: false, + Revoked: revoked[batch.Hash], + RevocationSupported: revocable[batch.Hash], IssueURL: nil, // TODO: add issue URL to storage model so this can be filled in here } } @@ -196,6 +239,19 @@ func (s *credentialService) VerifyAndStoreIssuedCredentials( return nil // nothing to store } + // A batch's instances are the same logical credential and are revoked + // together. Per draft-ietf-oauth-status-list §13.2 each one-time-use copy + // MUST carry its OWN dedicated, distinct status entry (for unlinkability): + // require all-or-none presence and reject duplicate references. Reject here, + // before any side effects, so a malformed issuance can't delete the user's + // existing credential (computeHashAndDeleteExisting below is destructive). + if err := validateStatusReferences(verifiedSdJwtVcs); err != nil { + if requireCryptographicKeyBinding { + s.deleteOrphanedKeys(publicKeyIdentifiers) + } + return err + } + if requireCryptographicKeyBinding && len(publicKeyIdentifiers) != len(verifiedSdJwtVcs) { return fmt.Errorf( "publicKeyIdentifiers length (%d) must equal verifiedSdJwtVcs length (%d) when cryptographic key binding is used", @@ -283,12 +339,74 @@ func (s *credentialService) computeHashAndDeleteExisting(vc *sdjwtvc.VerifiedSdJ return hash, processedPayload, nil } +// statusReferenceOf returns the credential's Token Status List reference, or +// the zero Reference when it carries none. The zero value (empty URI) is a +// safe "absent" sentinel because a real reference always has a non-empty URI. +func statusReferenceOf(v *sdjwtvc.VerifiedSdJwtVc) statuslist.Reference { + if v.IssuerSignedJwtPayload.Status == nil || v.IssuerSignedJwtPayload.Status.StatusList == nil { + return statuslist.Reference{} + } + return *v.IssuerSignedJwtPayload.Status.StatusList +} + +// validateStatusReferences enforces the batch's Token Status List invariants from +// draft-ietf-oauth-status-list §13.2/§13.3: +// - all-or-none: either every instance carries a status_list reference or none +// does (a partially-referenced batch would leave some instances +// status-checkable and others not); +// - uniqueness: each reference MUST be distinct across the batch. Every +// one-time-use copy needs its own dedicated (uri, idx) entry so presentations +// can't be correlated and to avoid double allocation (§13.3). Copies may +// differ by idx on one list or be spread across multiple Status List Tokens. +func validateStatusReferences(vcs []*sdjwtvc.VerifiedSdJwtVc) error { + firstHasRef := statusReferenceOf(vcs[0]) != (statuslist.Reference{}) + seen := make(map[statuslist.Reference]int, len(vcs)) + for i, v := range vcs { + ref := statusReferenceOf(v) + hasRef := ref != (statuslist.Reference{}) + if hasRef != firstHasRef { + return fmt.Errorf( + "partial status_list reference in batch: instance 0 hasRef=%t but instance %d hasRef=%t; either all instances carry a status_list reference or none do", + firstHasRef, i, hasRef, + ) + } + if !hasRef { + continue + } + if prev, dup := seen[ref]; dup { + return fmt.Errorf( + "duplicate status_list reference in batch: instances %d and %d both use %+v; each one-time-use copy MUST have a dedicated entry (draft-ietf-oauth-status-list §13.2)", + prev, i, ref, + ) + } + seen[ref] = i + } + return nil +} + func buildInstances(vcs []*sdjwtvc.VerifiedSdJwtVc) []models.IssuedCredentialInstance { instances := make([]models.IssuedCredentialInstance, len(vcs)) + now := time.Now() for i, v := range vcs { - instances[i] = models.IssuedCredentialInstance{ + inst := models.IssuedCredentialInstance{ RawCredential: []byte(v.GetRawSdJwtVc()), } + // Persist the status_list reference so the disclosure path and + // the refresh sweep can run without re-parsing the SD-JWT VC. + // At issuance time the holder verifier has just confirmed the + // bit reads StatusValid (or the credential has no status + // reference), so seed LastKnownStatus accordingly. + if v.IssuerSignedJwtPayload.Status != nil && v.IssuerSignedJwtPayload.Status.StatusList != nil { + ref := v.IssuerSignedJwtPayload.Status.StatusList + uri := ref.URI + idx := ref.Index + t := now + inst.StatusListURI = &uri + inst.StatusListIdx = &idx + inst.LastKnownStatus = uint8(statuslist.StatusValid) + inst.LastStatusCheckAt = &t + } + instances[i] = inst } return instances } diff --git a/eudi/services/credential_service_test.go b/eudi/services/credential_service_test.go index 35632c2ac..438f71fcb 100644 --- a/eudi/services/credential_service_test.go +++ b/eudi/services/credential_service_test.go @@ -16,6 +16,7 @@ import ( "github.com/privacybydesign/irmago/common/clientmodels" "github.com/privacybydesign/irmago/eudi" "github.com/privacybydesign/irmago/eudi/credentials/sdjwtvc" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" "github.com/privacybydesign/irmago/eudi/metadata" "github.com/privacybydesign/irmago/eudi/storage/db" "github.com/privacybydesign/irmago/eudi/storage/db/models" @@ -67,6 +68,39 @@ func TestGetCredentialMetadataList_ReturnsSingleCredential(t *testing.T) { require.Len(t, result, 1) } +func TestGetCredentialMetadataList_SurfacesRevocation(t *testing.T) { + tests := []struct { + name string + statusRefs []db.BatchInstanceStatus + wantRevoked bool + wantRevocable bool + }{ + {"no status reference", nil, false, false}, + {"valid status", []db.BatchInstanceStatus{{Hash: "testhash", LastKnownStatus: uint8(statuslist.StatusValid)}}, false, true}, + {"invalid status is revoked", []db.BatchInstanceStatus{{Hash: "testhash", LastKnownStatus: uint8(statuslist.StatusInvalid)}}, true, true}, + {"any invalid instance marks the batch revoked", []db.BatchInstanceStatus{ + {Hash: "testhash", LastKnownStatus: uint8(statuslist.StatusInvalid)}, + {Hash: "testhash", LastKnownStatus: uint8(statuslist.StatusValid)}, + }, true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockCredentialStore{ + batchListResult: []*models.CredentialBatch{newStorageBatch()}, + statusRefs: tt.statusRefs, + } + svc := newServiceWithMocks(mock, filesystem.NewFileSystemStorage([32]byte{}, t.TempDir())) + + result, err := svc.GetCredentialMetadataList() + + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, tt.wantRevoked, result[0].Revoked) + assert.Equal(t, tt.wantRevocable, result[0].RevocationSupported) + }) + } +} + func TestGetCredentialMetadataList_MapsCredentialId(t *testing.T) { batch := newStorageBatch() mock := &mockCredentialStore{batchListResult: []*models.CredentialBatch{batch}} @@ -330,6 +364,54 @@ func TestGetCredentialMetadataList_MultipleCredentials(t *testing.T) { assert.Len(t, result, 2) } +// TestGetCredentialMetadataList_CredentialLogoOnNonFirstDisplay pins the bug +// where the data tab only inspected Display[0] for a credential logo. When +// multi-locale metadata carries the logo on a later display entry (and only that +// logo is cached, keyed by its URL, as issuance caches it), the data tab must +// still surface it — matching the issuance/disclosure/activity paths which scan +// all displays. +func TestGetCredentialMetadataList_CredentialLogoOnNonFirstDisplay(t *testing.T) { + const logoURL = "https://logo.example/cred.png" + batch := newStorageBatch() + batch.CredentialMetadata.Display = []models.CredentialDisplay{ + {Name: "My Credential", Locale: datatypes.NullString{V: "en", Valid: true}}, + {Name: "Mijn Credential", Locale: datatypes.NullString{V: "nl", Valid: true}, LogoURI: logoURL}, + } + fileStorageMock := filesystem.NewFileSystemStorage([32]byte{}, t.TempDir()) + require.NoError(t, fileStorageMock.Credentials().LogoManager().Save(logoURL, []byte("PNGDATA"), "image/png")) + + mock := &mockCredentialStore{batchListResult: []*models.CredentialBatch{batch}} + svc := newServiceWithMocks(mock, fileStorageMock) + + result, err := svc.GetCredentialMetadataList() + + require.NoError(t, err) + require.Len(t, result, 1) + require.NotNil(t, result[0].Image, "credential logo must resolve even when it's not on the first display entry") + assert.NotEmpty(t, result[0].Image.Base64) +} + +// TestGetCredentialMetadataList_IssuerLogoOnNonFirstDisplay is the issuer-logo +// counterpart of the credential-logo bug above. +func TestGetCredentialMetadataList_IssuerLogoOnNonFirstDisplay(t *testing.T) { + const logoURL = "https://logo.example/issuer.png" + batch := newStorageBatch() + batch.IssuerDisplay = []models.IssuerMetadataDisplay{ + {Name: "Test Issuer", Locale: datatypes.NullString{V: "en", Valid: true}}, + {Name: "Test Issuer NL", Locale: datatypes.NullString{V: "nl", Valid: true}, LogoURI: datatypes.NullString{V: logoURL, Valid: true}}, + } + fileStorageMock := filesystem.NewFileSystemStorage([32]byte{}, t.TempDir()) + require.NoError(t, fileStorageMock.Issuers().LogoManager().Save(logoURL, []byte("ISSUERPNG"), "image/png")) + + mock := &mockCredentialStore{batchListResult: []*models.CredentialBatch{batch}} + svc := newServiceWithMocks(mock, fileStorageMock) + + result, err := svc.GetCredentialMetadataList() + + require.NoError(t, err) + require.NotNil(t, result[0].Issuer.Image, "issuer logo must resolve even when it's not on the first display entry") +} + // ========== VerifyAndStoreIssuedCredentials ========== func TestVerifyAndStoreIssuedCredentials_EmptySlice(t *testing.T) { @@ -467,6 +549,63 @@ func TestVerifyAndStoreIssuedCredentials_BatchSize(t *testing.T) { assert.Len(t, batch.Instances, 2) } +func withStatusRef(vc *sdjwtvc.VerifiedSdJwtVc, uri string, idx uint64) *sdjwtvc.VerifiedSdJwtVc { + vc.IssuerSignedJwtPayload.Status = &statuslist.StatusClaim{StatusList: &statuslist.Reference{URI: uri, Index: idx}} + return vc +} + +func newVc() *sdjwtvc.VerifiedSdJwtVc { + return newVerifiedVc("https://vct.example.com/Cred", "https://issuer.example.com", time.Now().Unix(), 0, 0) +} + +func storeBatch(t *testing.T, mock *mockCredentialStore, vcs ...*sdjwtvc.VerifiedSdJwtVc) error { + t.Helper() + svc := newServiceWithMocks(mock, filesystem.NewFileSystemStorage([32]byte{}, t.TempDir())) + return svc.VerifyAndStoreIssuedCredentials( + vcs, "config-id", + newMinimalIssuerMetadata("config-id", metadata.CredentialFormatIdentifier_SdJwtVc), + false, nil, + ) +} + +func TestVerifyAndStoreIssuedCredentials_DuplicateStatusReferences_Rejected(t *testing.T) { + // Two instances sharing the exact same (uri, idx) entry — double allocation, + // which draft-ietf-oauth-status-list §13.2/§13.3 forbids: each one-time-use + // copy MUST have its own dedicated entry. Must be rejected before storage. + mock := &mockCredentialStore{} + err := storeBatch(t, mock, + withStatusRef(newVc(), "https://sl.example/1", 5), + withStatusRef(newVc(), "https://sl.example/1", 5), + ) + require.Error(t, err) + assert.Empty(t, mock.storedBatches, "double-allocated batch must not be stored") +} + +func TestVerifyAndStoreIssuedCredentials_DistinctStatusReferences_Stored(t *testing.T) { + // Same list, different index per instance — this is the spec-compliant + // one-time-use batch shape (draft-ietf-oauth-status-list §13.2: each copy + // MUST have its own dedicated entry for unlinkability), so it must be stored. + mock := &mockCredentialStore{} + err := storeBatch(t, mock, + withStatusRef(newVc(), "https://sl.example/1", 1), + withStatusRef(newVc(), "https://sl.example/1", 2), + ) + require.NoError(t, err) + require.Len(t, mock.storedBatches, 1) + require.Len(t, mock.storedBatches[0].Instances, 2) +} + +func TestVerifyAndStoreIssuedCredentials_PartialStatusReference_Rejected(t *testing.T) { + // One instance has a reference, the other doesn't — malformed batch. + mock := &mockCredentialStore{} + err := storeBatch(t, mock, + withStatusRef(newVc(), "https://sl.example/1", 1), + newVc(), + ) + require.Error(t, err) + assert.Empty(t, mock.storedBatches, "malformed batch must not be stored") +} + func TestVerifyAndStoreIssuedCredentials_SetsIssuerMetadata(t *testing.T) { mock := &mockCredentialStore{} fileStorageMock := filesystem.NewFileSystemStorage([32]byte{}, t.TempDir()) @@ -688,6 +827,66 @@ func TestVerifyAndStoreIssuedCredentials_HashIsDeterministic(t *testing.T) { assert.Equal(t, mock.storedBatches[0].Hash, mock.storedBatches[1].Hash) } +// TestVerifyAndStore_SeedsStatusReference pins that issuance persists the +// credential's status_list reference onto every stored instance, so the +// disclosure path and the background refresh sweep can run without +// re-parsing the SD-JWT VC. Seeded LastKnownStatus is Valid because the +// holder verifier has just confirmed the bit reads Valid at issuance. +func TestVerifyAndStore_SeedsStatusReference(t *testing.T) { + mock := &mockCredentialStore{} + fileStorageMock := filesystem.NewFileSystemStorage([32]byte{}, t.TempDir()) + svc := newServiceWithMocks(mock, fileStorageMock) + + vc := newVerifiedVc("https://vct.example.com/Cred", "https://issuer.example.com", time.Now().Unix(), 0, 0) + vc.IssuerSignedJwtPayload.Status = &statuslist.StatusClaim{ + StatusList: &statuslist.Reference{URI: "https://issuer.example.com/sl/1", Index: 42}, + } + + err := svc.VerifyAndStoreIssuedCredentials( + []*sdjwtvc.VerifiedSdJwtVc{vc}, + "config-id", + newMinimalIssuerMetadata("config-id", metadata.CredentialFormatIdentifier_SdJwtVc), + false, + nil, + ) + + require.NoError(t, err) + require.Len(t, mock.storedBatches, 1) + require.Len(t, mock.storedBatches[0].Instances, 1) + inst := mock.storedBatches[0].Instances[0] + require.NotNil(t, inst.StatusListURI) + assert.Equal(t, "https://issuer.example.com/sl/1", *inst.StatusListURI) + require.NotNil(t, inst.StatusListIdx) + assert.Equal(t, uint64(42), *inst.StatusListIdx) + assert.Equal(t, uint8(statuslist.StatusValid), inst.LastKnownStatus) + require.NotNil(t, inst.LastStatusCheckAt) +} + +func TestVerifyAndStore_NoStatusReference_LeavesStatusFieldsNil(t *testing.T) { + mock := &mockCredentialStore{} + fileStorageMock := filesystem.NewFileSystemStorage([32]byte{}, t.TempDir()) + svc := newServiceWithMocks(mock, fileStorageMock) + + vc := newVerifiedVc("https://vct.example.com/Cred", "https://issuer.example.com", time.Now().Unix(), 0, 0) + + err := svc.VerifyAndStoreIssuedCredentials( + []*sdjwtvc.VerifiedSdJwtVc{vc}, + "config-id", + newMinimalIssuerMetadata("config-id", metadata.CredentialFormatIdentifier_SdJwtVc), + false, + nil, + ) + + require.NoError(t, err) + require.Len(t, mock.storedBatches, 1) + require.Len(t, mock.storedBatches[0].Instances, 1) + inst := mock.storedBatches[0].Instances[0] + assert.Nil(t, inst.StatusListURI) + assert.Nil(t, inst.StatusListIdx) + assert.Equal(t, uint8(statuslist.StatusUnknown), inst.LastKnownStatus) + assert.Nil(t, inst.LastStatusCheckAt) +} + // ========== hashForSdJwtVc ========== func TestHashForSdJwtVc_NonEmpty(t *testing.T) { @@ -1030,6 +1229,8 @@ type mockCredentialStore struct { batchListResult []*models.CredentialBatch storeBatchErr error batchListErr error + statusRefs []db.BatchInstanceStatus + statusRefsErr error } func (m *mockCredentialStore) StoreBatch(batch *models.CredentialBatch) error { @@ -1068,6 +1269,18 @@ func (m *mockCredentialStore) DeleteBatchByHash(hash string) error { return nil } +func (m *mockCredentialStore) ListInstancesWithStatusReference() ([]db.CredentialStatusInstance, error) { + return nil, nil +} + +func (m *mockCredentialStore) ListStatusReferencedInstanceStatuses() ([]db.BatchInstanceStatus, error) { + return m.statusRefs, m.statusRefsErr +} + +func (m *mockCredentialStore) UpdateInstanceStatus(instanceID datatypes.UUID, status uint8, checkedAt time.Time) error { + return nil +} + // --- mock HolderBindingKeyStore --- type mockHolderBindingKeyStore struct { @@ -1106,6 +1319,8 @@ func newServiceWithMocks(storeMock *mockCredentialStore, fileStorageMock filesys credentialStore: storeMock, holderBindingKeyStore: &mockHolderBindingKeyStore{}, fileStorage: fileStorageMock, + // BatchRevocation reads the same mock store; no live checker needed. + revocation: NewRevocationService(nil, storeMock), } } diff --git a/eudi/services/credential_status_refresh_test.go b/eudi/services/credential_status_refresh_test.go new file mode 100644 index 000000000..c05f67ec4 --- /dev/null +++ b/eudi/services/credential_status_refresh_test.go @@ -0,0 +1,381 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" + dbpkg "github.com/privacybydesign/irmago/eudi/storage/db" + "github.com/privacybydesign/irmago/eudi/storage/db/models" + "github.com/privacybydesign/irmago/eudi/storage/db/sqlcipher" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// newRefreshService builds a RevocationService for the status-refresh tests. +func newRefreshService(db *gorm.DB, checker *statuslist.Checker) *RevocationService { + return NewRevocationService(checker, dbpkg.NewCredentialStore(db)) +} + +func newTestRefreshDB(t *testing.T) *gorm.DB { + t.Helper() + d, err := gorm.Open(sqlcipher.Dialector{Connector: sqlcipher.NewConnector(":memory:", []byte("test-key-refresh"))}, &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, d.AutoMigrate( + &models.HolderBindingKey{}, + &models.ECDSAKeyMetadata{}, + &models.RSAKeyMetadata{}, + &models.IssuerMetadataDisplay{}, + &models.CredentialMetadata{}, + &models.CredentialDisplay{}, + &models.CredentialClaim{}, + &models.ClaimDisplay{}, + &models.CredentialBatch{}, + &models.IssuedCredentialInstance{}, + &models.StatusListCacheEntry{}, + )) + return d +} + +func seedBatch(t *testing.T, db *gorm.DB, hash, issuer string, instances []models.IssuedCredentialInstance) *models.CredentialBatch { + t.Helper() + batch := &models.CredentialBatch{ + IssuerURL: issuer, + VerifiableCredentialType: "https://vct.example/x", + Format: models.CredentialFormatSdJwtVc, + Hash: hash, + ProcessedSdJwtPayload: datatypes.JSON(`{"sub":"u"}`), + IssuedAt: datatypes.NullTime{V: time.Now().UTC().Truncate(time.Second), Valid: true}, + BatchSize: uint(len(instances)), + RemainingCount: uint(len(instances)), + CredentialIssuer: issuer, + Instances: instances, + } + require.NoError(t, db.Create(batch).Error) + return batch +} + +func instanceWithStatus(uri string, idx uint64) models.IssuedCredentialInstance { + u := uri + i := idx + return models.IssuedCredentialInstance{ + RawCredential: []byte("raw"), + StatusListURI: &u, + StatusListIdx: &i, + } +} + +func Test_RefreshStatuses_NilChecker_NoOp(t *testing.T) { + db := newTestRefreshDB(t) + svc := newRefreshService(db, nil) + require.NoError(t, svc.RefreshStatuses(context.Background())) +} + +func Test_RefreshStatuses_NoInstancesWithStatus_NoOp(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + // Seed a batch but no status references. + seedBatch(t, db, "h1", "https://issuer.example", []models.IssuedCredentialInstance{ + {RawCredential: []byte("raw")}, + }) + + svc := newRefreshService(db, checker) + require.NoError(t, svc.RefreshStatuses(context.Background())) +} + +func Test_RefreshStatuses_OneFetchPerSharedURI_OneRepresentativePerBatch(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + // One status list shared across batches, all copies Valid. + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0, 1: 0, 2: 0}, + }) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + // Two batches sharing one status list URI: batch A has two instances + // (idx 0,1), batch B one (idx 2). The sweep must fetch the shared URI once + // and refresh exactly one representative per batch. + batchA := seedBatch(t, db, "hA", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus(srv.URL(), 0), + instanceWithStatus(srv.URL(), 1), + }) + seedBatch(t, db, "hB", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus(srv.URL(), 2), + }) + + svc := newRefreshService(db, checker) + require.NoError(t, svc.RefreshStatuses(context.Background())) + + // Shared URI fetched exactly once (Refresh warms the cache; each + // representative's Check reads from it). + require.Equal(t, int64(1), srv.Hits()) + + // Exactly one instance per batch was refreshed (LastStatusCheckAt set), + // i.e. two across the two batches, each reading Valid. + var all []models.IssuedCredentialInstance + require.NoError(t, db.Find(&all).Error) + checked := 0 + for _, r := range all { + if r.LastStatusCheckAt != nil { + checked++ + require.Equal(t, uint8(statuslist.StatusValid), r.LastKnownStatus) + } + } + require.Equal(t, 2, checked, "one representative per batch (2 batches)") + + // The multi-instance batch A had only one of its two instances refreshed. + var batchARows []models.IssuedCredentialInstance + require.NoError(t, db.Where("credential_batch_id = ?", batchA.ID).Find(&batchARows).Error) + require.Len(t, batchARows, 2) + batchAChecked := 0 + for _, r := range batchARows { + if r.LastStatusCheckAt != nil { + batchAChecked++ + } + } + require.Equal(t, 1, batchAChecked, "only one representative refreshed in a multi-instance batch") +} + +// Test_RefreshStatuses_MultiInstanceBatch_OneRepresentativeDrivesRevocation +// verifies that for a batch with several instances the sweep refreshes only a +// single representative, yet the batch is still reported revoked once that +// representative's bit reads Invalid (the read path's "any invalid" rule). +func Test_RefreshStatuses_MultiInstanceBatch_OneRepresentativeDrivesRevocation(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + + // All three copies of the batch are revoked together by the issuer. + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 1, 1: 1, 2: 1}, + }) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + seedBatch(t, db, "h1", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus(srv.URL(), 0), + instanceWithStatus(srv.URL(), 1), + instanceWithStatus(srv.URL(), 2), + }) + + svc := newRefreshService(db, checker) + require.NoError(t, svc.RefreshStatuses(context.Background())) + + // Only one representative was checked and flipped to Invalid; the others + // keep their default status. + var rows []models.IssuedCredentialInstance + require.NoError(t, db.Find(&rows).Error) + checked, invalid := 0, 0 + for _, r := range rows { + if r.LastStatusCheckAt != nil { + checked++ + } + if statuslist.Status(r.LastKnownStatus) == statuslist.StatusInvalid { + invalid++ + } + } + require.Equal(t, 1, checked, "exactly one representative refreshed") + require.Equal(t, 1, invalid, "only the representative flipped to Invalid") + + // The batch's derived revocation ("any status-referenced instance Invalid") + // is still true from the single representative. + statuses, err := dbpkg.NewCredentialStore(db).ListStatusReferencedInstanceStatuses() + require.NoError(t, err) + revoked := false + for _, st := range statuses { + if st.Hash == "h1" && statuslist.Status(st.LastKnownStatus) == statuslist.StatusInvalid { + revoked = true + } + } + require.True(t, revoked, "batch revoked once its representative reads Invalid") +} + +// Test_RefreshStatuses_DetectsRevocationTransition is the end-to-end guarantee +// behind the background refresh: a credential seen as Valid on one sweep is +// reported as Invalid on the next once the issuer flips its status bit. It +// also pins the cache-bypass property — RefreshStatuses must re-fetch and not +// return the previously-cached "valid" value, otherwise a revocation would +// never be observed until the TTL happened to expire. +func Test_RefreshStatuses_DetectsRevocationTransition(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + + // Initially the credential at idx 4 is Valid. + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{4: 0}, // Valid + }) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + seedBatch(t, db, "h1", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus(srv.URL(), 4), + }) + svc := newRefreshService(db, checker) + + // First sweep: the wallet records the credential as Valid. + require.NoError(t, svc.RefreshStatuses(context.Background())) + var row models.IssuedCredentialInstance + require.NoError(t, db.First(&row, "status_list_uri = ?", srv.URL()).Error) + require.Equal(t, uint8(statuslist.StatusValid), row.LastKnownStatus) + + // The issuer revokes the credential by flipping the bit at idx 4. + srv.Serve(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{4: 1}, // Invalid (revoked) + }) + + // Next sweep must pick up the revocation despite the earlier cached + // Valid value — RefreshStatuses re-fetches by design. + require.NoError(t, svc.RefreshStatuses(context.Background())) + require.NoError(t, db.First(&row, "status_list_uri = ?", srv.URL()).Error) + require.Equal(t, uint8(statuslist.StatusInvalid), row.LastKnownStatus) +} + +func Test_RefreshStatuses_OneURIFailure_DoesNotAbortSweep(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + good := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{0: 0}, + }) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + // One representative per batch is checked, so put the failing and the good + // URI in SEPARATE batches to prove one batch's failure doesn't abort the + // sweep for the other. + seedBatch(t, db, "hbad", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus("http://127.0.0.1:0/nope", 0), // unreachable + }) + seedBatch(t, db, "hgood", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus(good.URL(), 0), // good + }) + + svc := newRefreshService(db, checker) + require.NoError(t, svc.RefreshStatuses(context.Background())) + + // The good one should be updated to Valid; the failing one + // should remain at default (Unknown == 0). + var rows []models.IssuedCredentialInstance + require.NoError(t, db.Find(&rows).Error) + statusesByURI := map[string]uint8{} + for _, r := range rows { + statusesByURI[*r.StatusListURI] = r.LastKnownStatus + } + require.Equal(t, uint8(statuslist.StatusValid), statusesByURI[good.URL()]) + require.Equal(t, uint8(0), statusesByURI["http://127.0.0.1:0/nope"]) +} + +func Test_RefreshStatuses_OnlyUpdatesOnSuccess(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + // Pre-seed an instance with LastKnownStatus = Suspended (set on + // the row directly). After a failing refresh, the value must + // remain Suspended. + uri := "http://127.0.0.1:0/nope" + idx := uint64(0) + checked := time.Now().UTC().Truncate(time.Second).Add(-time.Hour) + inst := models.IssuedCredentialInstance{ + RawCredential: []byte("raw"), + StatusListURI: &uri, + StatusListIdx: &idx, + LastKnownStatus: uint8(statuslist.StatusSuspended), + LastStatusCheckAt: &checked, + } + seedBatch(t, db, "h1", "https://issuer.example", []models.IssuedCredentialInstance{inst}) + + svc := newRefreshService(db, checker) + require.NoError(t, svc.RefreshStatuses(context.Background())) + + var row models.IssuedCredentialInstance + require.NoError(t, db.First(&row, "status_list_uri = ?", uri).Error) + require.Equal(t, uint8(statuslist.StatusSuspended), row.LastKnownStatus, "failed refresh must not overwrite previous status") + require.WithinDuration(t, checked, row.LastStatusCheckAt.UTC(), time.Second) +} + +// Test_ScheduledRefresh_PicksUpRevocation is the closest robust analogue of a +// full client-level test: it drives the sweep on a real gocron scheduler wired +// exactly as client.InitJobs does (a DurationJob that starts immediately and +// repeats), against a real DB, a real Checker and a real status server. It +// proves the *automatic*, timer-driven path detects a Valid -> revoked +// transition without any manual RefreshStatuses call. +// +// A literal through-client.New() test is not achievable today: no issuer-side +// code emits a status_list claim (so real issuance can't produce a +// status-bearing credential), and the Client exposes no seam to seed one into +// its eudi DB. This test therefore mirrors the client's wiring at the service +// layer instead. +func Test_ScheduledRefresh_PicksUpRevocation(t *testing.T) { + db := newTestRefreshDB(t) + signer := statuslist.NewTestStatusListSigner(t) + + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{9: 0}, // Valid + }) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + seedBatch(t, db, "h1", "https://issuer.example", []models.IssuedCredentialInstance{ + instanceWithStatus(srv.URL(), 9), + }) + svc := newRefreshService(db, checker) + + // Wire the sweep onto a gocron scheduler exactly as client.InitJobs does. + scheduler, err := gocron.NewScheduler() + require.NoError(t, err) + _, err = scheduler.NewJob( + gocron.DurationJob(30*time.Millisecond), + gocron.NewTask(func() { _ = svc.RefreshStatuses(context.Background()) }), + gocron.WithStartAt(gocron.WithStartImmediately()), + ) + require.NoError(t, err) + scheduler.Start() + t.Cleanup(func() { _ = scheduler.Shutdown() }) + + statusOf := func() uint8 { + var row models.IssuedCredentialInstance + require.NoError(t, db.First(&row, "status_list_uri = ?", srv.URL()).Error) + return row.LastKnownStatus + } + + // The scheduled sweep records the credential as Valid. + require.Eventually(t, func() bool { return statusOf() == uint8(statuslist.StatusValid) }, + 3*time.Second, 20*time.Millisecond, "scheduled refresh should record Valid") + + // Issuer revokes the credential by flipping the bit at idx 9. + srv.Serve(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example", + Bits: 1, + Statuses: map[uint64]uint8{9: 1}, // Invalid (revoked) + }) + + // A later scheduled sweep must pick up the revocation automatically — + // RefreshStatuses bypasses the cache, so the flip is observed on the next tick. + require.Eventually(t, func() bool { return statusOf() == uint8(statuslist.StatusInvalid) }, + 3*time.Second, 20*time.Millisecond, "scheduled refresh should pick up the revocation") +} diff --git a/eudi/services/revocation_service.go b/eudi/services/revocation_service.go new file mode 100644 index 000000000..8a59e3c90 --- /dev/null +++ b/eudi/services/revocation_service.go @@ -0,0 +1,158 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/privacybydesign/irmago/eudi" + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" + "github.com/privacybydesign/irmago/eudi/storage/db" + "github.com/privacybydesign/irmago/eudi/storage/db/models" + "github.com/privacybydesign/irmago/internal/common" + "gorm.io/datatypes" +) + +// RevocationService is the single home for Token Status List revocation. It +// owns the status-list Checker and the credential store, and exposes the three +// ways the wallet consults revocation: +// +// - IsRevoked: a cached (no-fetch) check for one instance, used by the +// OpenID4VP disclosure planner; +// - RefreshStatuses: the background sweep that re-fetches and writes back each +// stored instance's LastKnownStatus, and keeps the status-list cache warm; +// - BatchRevocation: per-batch flags derived from stored status, for the +// credential list view. +// +// All three share one revocation policy (see statusRevoked): anything other +// than VALID counts as revoked (INVALID, SUSPENDED, application-specific), +// except UNKNOWN (no status information yet), which stays advisory not-revoked. +type RevocationService struct { + checker *statuslist.Checker + store db.CredentialStore +} + +// NewRevocationService returns a service backed by the given Token Status List +// Checker and credential store. A nil checker disables the cached-read and +// refresh paths (IsRevoked returns false, RefreshStatuses is a no-op); the +// stored-status path (BatchRevocation) still works. +func NewRevocationService(checker *statuslist.Checker, store db.CredentialStore) *RevocationService { + return &RevocationService{checker: checker, store: store} +} + +// statusRevoked is the one revocation policy shared by every path: a credential +// is usable only when it reads VALID. INVALID, SUSPENDED, and any +// application-specific status all count as revoked (fail-closed on anything the +// issuer flags). UNKNOWN is the sole exception — it means "no status +// information" (cold cache / not yet checked), not a bad status, so it stays +// advisory not-revoked (see IsRevoked and the cold-cache behaviour). +// TODO: the client model has no separate suspended state, so suspension is +// surfaced as "revoked" for now — add a distinct suspended state (clientmodels +// + frontend) to show it as temporary rather than permanent. +func statusRevoked(s statuslist.Status) bool { + return s != statuslist.StatusValid && s != statuslist.StatusUnknown +} + +// IsRevoked reports whether the instance's credential reads INVALID according +// to the locally cached Token Status List — no network fetch. An instance +// without a status_list reference is never revoked. A missing or +// undeterminable cached status reads as NOT revoked: the flag is advisory, the +// cache is kept warm by RefreshStatuses, and the verifier's own status check is +// the backstop. +// +// The check never blocks disclosure — revocation is surfaced as a flag for the +// frontend, with the verifier as the backstop. +func (s *RevocationService) IsRevoked(instance *models.IssuedCredentialInstance) bool { + if s.checker == nil || instance.StatusListURI == nil || instance.StatusListIdx == nil { + return false + } + ref := statuslist.Reference{URI: *instance.StatusListURI, Index: *instance.StatusListIdx} + status, err := s.checker.CheckCached(ref) + if err != nil { + eudi.Logger.Warnf("revocation: cached status read for instance %s: %v", instance.ID, err) + return false // advisory: undeterminable status -> not flagged + } + return statusRevoked(status) +} + +// RefreshStatuses re-fetches Token Status Lists and updates stored statuses, +// checking one representative instance per batch rather than every copy. A +// batch's instances are the same logical credential and are revoked together +// (draft-ietf-oauth-status-list §13.2), so one entry's bit determines the whole +// batch's status; re-checking every copy would be redundant work. +// +// Representatives are grouped by status list URI so a list shared across many +// batches is fetched once. +// +// Fail-soft: per-URI and per-instance errors are logged and skipped, leaving +// the previous LastKnownStatus in place. A nil checker makes this a no-op. +func (s *RevocationService) RefreshStatuses(ctx context.Context) error { + if s.checker == nil { + return nil + } + instances, err := s.store.ListInstancesWithStatusReference() + if err != nil { + return fmt.Errorf("load instances: %w", err) + } + + // Keep one representative instance per batch. Any copy gives the same + // answer under the whole-batch-revoked assumption, so the first seen wins. + seenBatch := make(map[datatypes.UUID]struct{}, len(instances)) + representatives := make([]db.CredentialStatusInstance, 0, len(instances)) + for _, inst := range instances { + if _, ok := seenBatch[inst.BatchID]; ok { + continue + } + seenBatch[inst.BatchID] = struct{}{} + representatives = append(representatives, inst) + } + + groups := map[string][]db.CredentialStatusInstance{} + for _, inst := range representatives { + groups[inst.StatusListURI] = append(groups[inst.StatusListURI], inst) + } + + for uri, group := range groups { + // One Refresh per URI populates the cache; the per-idx Check calls + // below then read from the warm cache (no extra HTTP traffic). + if _, err := s.checker.Refresh(ctx, statuslist.Reference{URI: uri}); err != nil { + eudi.Logger.Warnf("status refresh: refresh %s failed: %v", common.SanitizeForLog(uri), err) + continue + } + now := time.Now() + for _, inst := range group { + st, err := s.checker.Check(ctx, statuslist.Reference{URI: uri, Index: inst.StatusListIdx}) + if err != nil { + eudi.Logger.Warnf("status refresh: check idx %d on %s failed: %v", inst.StatusListIdx, common.SanitizeForLog(uri), err) + continue + } + if err := s.store.UpdateInstanceStatus(inst.InstanceID, uint8(st), now); err != nil { + eudi.Logger.Warnf("status refresh: writeback failed for instance %s: %v", inst.InstanceID, err) + } + } + } + return nil +} + +// BatchRevocation returns, keyed by batch hash, which batches support revocation +// (carry any status reference) and which are currently revoked, derived from the +// stored LastKnownStatus that RefreshStatuses maintains. A batch's instances are +// the same credential and are revoked together, so a batch is revoked as soon as +// any status-referenced instance reads a non-VALID status (see statusRevoked), +// and supports revocation if it carries any status reference at all. A lifted +// suspension is reflected on the next RefreshStatuses sweep. +func (s *RevocationService) BatchRevocation() (revoked, revocable map[string]bool, err error) { + statuses, err := s.store.ListStatusReferencedInstanceStatuses() + if err != nil { + return nil, nil, err + } + revoked = map[string]bool{} + revocable = map[string]bool{} + for _, st := range statuses { + revocable[st.Hash] = true + if statusRevoked(statuslist.Status(st.LastKnownStatus)) { + revoked[st.Hash] = true + } + } + return revoked, revocable, nil +} diff --git a/eudi/services/revocation_service_test.go b/eudi/services/revocation_service_test.go new file mode 100644 index 000000000..8ad0173e7 --- /dev/null +++ b/eudi/services/revocation_service_test.go @@ -0,0 +1,97 @@ +package services + +import ( + "context" + "testing" + + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" + "github.com/privacybydesign/irmago/eudi/storage/db/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newIsRevokedFixture wires a RevocationService to a bits-wide status list +// serving value at index 3 via a freshly-signed test token, and returns an +// instance pointing at that index. The cache starts cold; call warmCache to +// populate it. IsRevoked reads the cache only (never the network), and does not +// touch the store, so it is left nil. +func newIsRevokedFixture(t *testing.T, bits int, value uint8) (*RevocationService, *statuslist.TestStatusListServer, *models.IssuedCredentialInstance) { + t.Helper() + signer := statuslist.NewTestStatusListSigner(t) + srv := statuslist.NewTestStatusListServerWithToken(t, signer, statuslist.TestStatusListOpts{ + Issuer: "https://issuer.example.com", + Bits: bits, + Statuses: map[uint64]uint8{3: value}, + }) + checker := statuslist.NewChecker(statuslist.VerificationContext{ + X509Context: signer.X509VerificationContext(), + }, statuslist.NewInMemoryCache()) + + uri := srv.URL() + idx := uint64(3) + inst := &models.IssuedCredentialInstance{StatusListURI: &uri, StatusListIdx: &idx} + return NewRevocationService(checker, nil), srv, inst +} + +// warmCache populates rc's status-list cache from the test server, the way the +// background RefreshStatuses sweep would. +func warmCache(t *testing.T, rc *RevocationService, inst *models.IssuedCredentialInstance) { + t.Helper() + _, err := rc.checker.Refresh(context.Background(), statuslist.Reference{URI: *inst.StatusListURI}) + require.NoError(t, err) +} + +func Test_RevocationService_IsRevoked_InvalidBit(t *testing.T) { + rc, _, inst := newIsRevokedFixture(t, 1, 1) // idx 3 -> invalid + warmCache(t, rc, inst) + assert.True(t, rc.IsRevoked(inst)) +} + +func Test_RevocationService_IsRevoked_ValidBit(t *testing.T) { + rc, _, inst := newIsRevokedFixture(t, 1, 0) // idx 3 -> valid + warmCache(t, rc, inst) + assert.False(t, rc.IsRevoked(inst)) +} + +func Test_RevocationService_IsRevoked_Suspended(t *testing.T) { + rc, _, inst := newIsRevokedFixture(t, 2, 2) // idx 3 -> suspended + warmCache(t, rc, inst) + assert.True(t, rc.IsRevoked(inst), "suspended is not a usable candidate") +} + +func Test_RevocationService_IsRevoked_ApplicationSpecific(t *testing.T) { + rc, _, inst := newIsRevokedFixture(t, 2, 3) // idx 3 -> application-specific + warmCache(t, rc, inst) + assert.True(t, rc.IsRevoked(inst), "anything other than valid is revoked") +} + +// IsRevoked reads the cache only: once warm, a broken/unreachable server does +// not change the answer and triggers no further fetch. +func Test_RevocationService_IsRevoked_NoLiveFetch(t *testing.T) { + rc, srv, inst := newIsRevokedFixture(t, 1, 1) // idx 3 -> invalid + warmCache(t, rc, inst) + hitsAfterWarm := srv.Hits() + srv.SetBody([]byte("not-a-status-list-jwt")) // any live fetch would now fail + assert.True(t, rc.IsRevoked(inst), "served from warm cache") + assert.Equal(t, hitsAfterWarm, srv.Hits(), "IsRevoked must not fetch") +} + +// Cold cache (never refreshed) -> advisory not-revoked, and no fetch attempted. +func Test_RevocationService_IsRevoked_ColdCache_NotRevoked(t *testing.T) { + rc, srv, inst := newIsRevokedFixture(t, 1, 1) // would read invalid if fetched + assert.False(t, rc.IsRevoked(inst), "cold cache -> advisory not revoked") + assert.Zero(t, srv.Hits(), "IsRevoked must not fetch") +} + +func Test_RevocationService_IsRevoked_NoStatusReference(t *testing.T) { + rc, _, _ := newIsRevokedFixture(t, 1, 1) + require.False(t, rc.IsRevoked(&models.IssuedCredentialInstance{}), "no status_list reference -> never revoked") +} + +func Test_RevocationService_IsRevoked_NilChecker(t *testing.T) { + uri := "https://issuer.example/sl" + idx := uint64(0) + rc := NewRevocationService(nil, nil) + require.False(t, rc.IsRevoked(&models.IssuedCredentialInstance{StatusListURI: &uri, StatusListIdx: &idx}), + "disabled (nil checker) -> not revoked") +} diff --git a/eudi/storage/db/credential_store.go b/eudi/storage/db/credential_store.go index 10f96ff65..4de1c7c43 100644 --- a/eudi/storage/db/credential_store.go +++ b/eudi/storage/db/credential_store.go @@ -3,12 +3,30 @@ package db import ( "errors" "fmt" + "time" "github.com/privacybydesign/irmago/eudi/storage/db/models" "gorm.io/datatypes" "gorm.io/gorm" ) +// CredentialStatusInstance is an instance's status_list reference. +// BatchID lets callers select a single representative instance per batch. +type CredentialStatusInstance struct { + InstanceID datatypes.UUID + BatchID datatypes.UUID + StatusListURI string + StatusListIdx uint64 +} + +// BatchInstanceStatus pairs a batch's deterministic hash with one of its +// instances' last-known Token Status List status. Only instances that carry +// a status_list reference are reported. +type BatchInstanceStatus struct { + Hash string + LastKnownStatus uint8 +} + // CredentialStore is the public interface for inserting and retrieving issued credentials. type CredentialStore interface { // StoreBatch inserts a CredentialBatch and all its IssuedCredentialInstances atomically. @@ -42,6 +60,20 @@ type CredentialStore interface { // DeleteBatchByHash looks up a CredentialBatch by its deterministic hash and deletes it // along with all its instances (via CASCADE). Returns ErrNotFound if no batch exists with that hash. DeleteBatchByHash(hash string) error + + // ListInstancesWithStatusReference returns every IssuedCredentialInstance + // with a (status_list.uri, status_list.idx) pair. + ListInstancesWithStatusReference() ([]CredentialStatusInstance, error) + + // ListStatusReferencedInstanceStatuses returns the (batch hash, + // last_known_status) pair for every instance carrying a Token Status List + // reference. Used to surface per-credential revocation in the credential + // list without loading full instances. + ListStatusReferencedInstanceStatuses() ([]BatchInstanceStatus, error) + + // UpdateInstanceStatus writes last_known_status and last_status_check_at + // on a single IssuedCredentialInstance. Returns ErrNotFound on no match. + UpdateInstanceStatus(instanceID datatypes.UUID, status uint8, checkedAt time.Time) error } type credentialStore struct { @@ -182,3 +214,47 @@ func (s *credentialStore) DeleteBatch(batchID datatypes.UUID) error { return nil } + +func (s *credentialStore) ListInstancesWithStatusReference() ([]CredentialStatusInstance, error) { + var out []CredentialStatusInstance + err := s.db. + Model(&models.IssuedCredentialInstance{}). + Select("id AS instance_id, " + + "credential_batch_id AS batch_id, " + + "status_list_uri AS status_list_uri, " + + "status_list_idx AS status_list_idx"). + Where("status_list_uri IS NOT NULL AND status_list_idx IS NOT NULL"). + Scan(&out).Error + return out, err +} + +func (s *credentialStore) ListStatusReferencedInstanceStatuses() ([]BatchInstanceStatus, error) { + var out []BatchInstanceStatus + err := s.db. + Model(&models.IssuedCredentialInstance{}). + Select("credential_batches.hash AS hash, " + + "issued_credential_instances.last_known_status AS last_known_status"). + Joins("JOIN credential_batches ON credential_batches.id = issued_credential_instances.credential_batch_id"). + Where("issued_credential_instances.status_list_uri IS NOT NULL"). + Scan(&out).Error + return out, err +} + +func (s *credentialStore) UpdateInstanceStatus(instanceID datatypes.UUID, status uint8, checkedAt time.Time) error { + if instanceID.IsNil() { + return fmt.Errorf("instanceID is required") + } + res := s.db.Model(&models.IssuedCredentialInstance{}). + Where("id = ?", instanceID). + Updates(map[string]any{ + "last_known_status": status, + "last_status_check_at": checkedAt, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} diff --git a/eudi/storage/db/credential_store_test.go b/eudi/storage/db/credential_store_test.go index b06f0ac05..3c1073619 100644 --- a/eudi/storage/db/credential_store_test.go +++ b/eudi/storage/db/credential_store_test.go @@ -209,6 +209,63 @@ func TestStoreBatch_UniqueHashConstraint(t *testing.T) { require.Error(t, err) } +func TestStoreBatch_PersistsStatusListColumns(t *testing.T) { + store := newTestCredentialStore(t) + + uri := "https://issuer.example/sl/1" + idx := uint64(42) + checked := time.Now().UTC().Truncate(time.Second) + batch := newBatch("hash-status-cols") + batch.Instances[0].StatusListURI = &uri + batch.Instances[0].StatusListIdx = &idx + batch.Instances[0].LastKnownStatus = 1 // StatusValid + batch.Instances[0].LastStatusCheckAt = &checked + + require.NoError(t, store.StoreBatch(batch)) + + got, err := store.GetUnusedInstance(batch.ID) + require.NoError(t, err) + require.NotNil(t, got.StatusListURI) + require.Equal(t, uri, *got.StatusListURI) + require.NotNil(t, got.StatusListIdx) + require.Equal(t, idx, *got.StatusListIdx) + require.Equal(t, uint8(1), got.LastKnownStatus) + require.NotNil(t, got.LastStatusCheckAt) + require.WithinDuration(t, checked, got.LastStatusCheckAt.UTC(), time.Second) +} + +func TestListStatusReferencedInstanceStatuses(t *testing.T) { + store := newTestCredentialStore(t) + + uri := "https://issuer.example/sl/1" + idx := uint64(7) + withStatus := newBatch("hash-with-status") + withStatus.Instances[0].StatusListURI = &uri + withStatus.Instances[0].StatusListIdx = &idx + withStatus.Instances[0].LastKnownStatus = 2 // StatusInvalid + require.NoError(t, store.StoreBatch(withStatus)) + + // Batch without a status reference must be excluded (status_list_uri IS NULL). + require.NoError(t, store.StoreBatch(newBatch("hash-no-status"))) + + got, err := store.ListStatusReferencedInstanceStatuses() + require.NoError(t, err) + require.Equal(t, []BatchInstanceStatus{{Hash: "hash-with-status", LastKnownStatus: 2}}, got) +} + +func TestStoreBatch_StatusListColumnsDefaultToNil(t *testing.T) { + store := newTestCredentialStore(t) + batch := newBatch("hash-no-status") + require.NoError(t, store.StoreBatch(batch)) + + got, err := store.GetUnusedInstance(batch.ID) + require.NoError(t, err) + require.Nil(t, got.StatusListURI) + require.Nil(t, got.StatusListIdx) + require.Equal(t, uint8(0), got.LastKnownStatus) + require.Nil(t, got.LastStatusCheckAt) +} + func TestStoreBatch_MultipleInstances(t *testing.T) { store := newTestCredentialStore(t) diff --git a/eudi/storage/db/models/credentials.go b/eudi/storage/db/models/credentials.go index e2eab6a06..636602274 100644 --- a/eudi/storage/db/models/credentials.go +++ b/eudi/storage/db/models/credentials.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "time" "gorm.io/datatypes" "gorm.io/gorm" @@ -135,6 +136,26 @@ type IssuedCredentialInstance struct { // Used marks this instance as consumed after it has been presented. // Single-use batch wallets must not reuse an instance once Used is true. Used bool `gorm:"default:false"` + + // StatusListURI is the canonical URI from the credential's + // `status.status_list.uri` claim, when present. Nil for credentials + // that don't carry a Token Status List reference. + StatusListURI *string + + // StatusListIdx is the bit-position into the referenced status + // list. Nil iff StatusListURI is nil. + StatusListIdx *uint64 + + // LastKnownStatus is the most recently observed + // statuslist.Status for this instance. 0 (StatusUnknown) is the + // default for credentials that have not yet been checked, and + // for credentials without a status_list reference. + LastKnownStatus uint8 `gorm:"default:0"` + + // LastStatusCheckAt records the wall-clock time of the most + // recent successful status check. Nil iff the instance has + // never been checked. + LastStatusCheckAt *time.Time } func (i *IssuedCredentialInstance) BeforeCreate(tx *gorm.DB) error { diff --git a/eudi/storage/db/models/status_list_cache.go b/eudi/storage/db/models/status_list_cache.go new file mode 100644 index 000000000..c8b4044f6 --- /dev/null +++ b/eudi/storage/db/models/status_list_cache.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// StatusListCacheEntry persists a fetched Status List Token JWT so +// the wallet can read credential status across process restarts and +// while offline (within the entry's TTL). +// +// We store the raw signed JWT (not the decoded bit array): re-verify +// happens against the current trust anchors on every cache hit, and +// the entry remains compact. Decompression is performed by the +// statuslist package in-process on each read. +type StatusListCacheEntry struct { + // URI is the canonical status_list URI from the credential's + // `status.status_list.uri` claim; the table key. + URI string `gorm:"primaryKey"` + + // RawJwt is the unmodified signed Status List Token (typ + // `statuslist+jwt`). The SQLCipher layer encrypts this at rest. + RawJwt []byte `gorm:"type:bytea;not null"` + + // ExpiresAt is the absolute time at which the cached value + // becomes stale and the entry must be re-fetched. Set from the + // token's own ttl/exp (or the HTTP max-age as fallback), clamped + // to [60s, 24h] (see statuslist.ClampTTL). + ExpiresAt time.Time `gorm:"not null;index"` + + // FetchedAt records when the entry was written. Useful for + // diagnostics and for refresh-pacing decisions. + FetchedAt time.Time `gorm:"not null"` +} diff --git a/eudi/storage/db/status_list_cache_store.go b/eudi/storage/db/status_list_cache_store.go new file mode 100644 index 000000000..8e2b7d845 --- /dev/null +++ b/eudi/storage/db/status_list_cache_store.go @@ -0,0 +1,65 @@ +package db + +import ( + "errors" + "fmt" + "time" + + "github.com/privacybydesign/irmago/eudi/credentials/statuslist" + "github.com/privacybydesign/irmago/eudi/storage/db/models" + "gorm.io/gorm" +) + +type statusListCacheStore struct { + db *gorm.DB +} + +// NewStatusListCacheStore returns a statuslist.Cache backed by the +// status_list_cache table. Returning the interface (not the concrete +// type) lets the wallet client treat the persistent store and the +// in-memory test cache interchangeably. +func NewStatusListCacheStore(db *gorm.DB) statuslist.Cache { + return &statusListCacheStore{db: db} +} + +func (s *statusListCacheStore) Get(uri string) ([]byte, time.Time, bool) { + if uri == "" { + return nil, time.Time{}, false + } + var row models.StatusListCacheEntry + err := s.db.First(&row, "uri = ?", uri).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, time.Time{}, false + } + // Anything else (locked DB, schema mismatch) — treat as a + // miss so the caller re-fetches. We can't surface an error + // through the interface contract here. + return nil, time.Time{}, false + } + return row.RawJwt, row.ExpiresAt, true +} + +func (s *statusListCacheStore) Put(uri string, rawJwt []byte, expiresAt time.Time) error { + if uri == "" { + return fmt.Errorf("status_list_cache: empty uri") + } + if len(rawJwt) == 0 { + return fmt.Errorf("status_list_cache: empty rawJwt") + } + row := models.StatusListCacheEntry{ + URI: uri, + RawJwt: rawJwt, + ExpiresAt: expiresAt, + FetchedAt: time.Now(), + } + // Upsert: if the URI exists, overwrite RawJwt/ExpiresAt/FetchedAt. + return s.db.Save(&row).Error +} + +func (s *statusListCacheStore) Delete(uri string) error { + if uri == "" { + return nil + } + return s.db.Delete(&models.StatusListCacheEntry{}, "uri = ?", uri).Error +} diff --git a/eudi/storage/db/status_list_cache_store_test.go b/eudi/storage/db/status_list_cache_store_test.go new file mode 100644 index 000000000..61bd9a246 --- /dev/null +++ b/eudi/storage/db/status_list_cache_store_test.go @@ -0,0 +1,76 @@ +package db + +import ( + "testing" + "time" + + "github.com/privacybydesign/irmago/eudi/storage/db/models" + "github.com/privacybydesign/irmago/eudi/storage/db/sqlcipher" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newTestStatusListCacheStore(t *testing.T) (*statusListCacheStore, *gorm.DB) { + t.Helper() + const passphrase = "super-secret-key-123" + db, err := gorm.Open(sqlcipher.Dialector{Connector: sqlcipher.NewConnector(":memory:", []byte(passphrase))}, &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.StatusListCacheEntry{})) + return &statusListCacheStore{db: db}, db +} + +func Test_StatusListCacheStore_PutThenGet_RoundtripsValue(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + expires := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + require.NoError(t, store.Put("https://issuer.example/sl/1", []byte("raw-jwt"), expires)) + + raw, gotExpires, ok := store.Get("https://issuer.example/sl/1") + require.True(t, ok) + require.Equal(t, []byte("raw-jwt"), raw) + require.WithinDuration(t, expires, gotExpires.UTC(), time.Second) +} + +func Test_StatusListCacheStore_Get_Miss_ReturnsFalse(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + _, _, ok := store.Get("https://issuer.example/sl/missing") + require.False(t, ok) +} + +func Test_StatusListCacheStore_Put_Upserts(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + require.NoError(t, store.Put("uri", []byte("v1"), time.Now().Add(time.Hour))) + require.NoError(t, store.Put("uri", []byte("v2"), time.Now().Add(2*time.Hour))) + + raw, _, ok := store.Get("uri") + require.True(t, ok) + require.Equal(t, []byte("v2"), raw) +} + +func Test_StatusListCacheStore_Delete_RemovesEntry(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + require.NoError(t, store.Put("uri", []byte("v"), time.Now().Add(time.Hour))) + require.NoError(t, store.Delete("uri")) + _, _, ok := store.Get("uri") + require.False(t, ok) +} + +func Test_StatusListCacheStore_Delete_NonexistentIsNoop(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + require.NoError(t, store.Delete("never-stored")) +} + +func Test_StatusListCacheStore_Put_EmptyURI_Errors(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + require.Error(t, store.Put("", []byte("v"), time.Now().Add(time.Hour))) +} + +func Test_StatusListCacheStore_Put_EmptyRawJwt_Errors(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + require.Error(t, store.Put("uri", nil, time.Now().Add(time.Hour))) +} + +func Test_StatusListCacheStore_Get_EmptyURI_ReturnsFalse(t *testing.T) { + store, _ := newTestStatusListCacheStore(t) + _, _, ok := store.Get("") + require.False(t, ok) +} diff --git a/eudi/storage/storage.go b/eudi/storage/storage.go index 9723b3200..a13b4ddba 100644 --- a/eudi/storage/storage.go +++ b/eudi/storage/storage.go @@ -81,6 +81,7 @@ func autoMigrateHolderModels(db *gorm.DB) error { &models.ClaimDisplay{}, &models.EudiLogEntry{}, &models.EudiLogCredential{}, + &models.StatusListCacheEntry{}, ); err != nil { return fmt.Errorf("auto-migrate database failed: %w", err) } diff --git a/go.mod b/go.mod index 79d9a773c..cecf678ae 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/x-cray/logrus-prefixed-formatter v0.5.2 go.etcd.io/bbolt v1.4.3 golang.org/x/crypto v0.53.0 + golang.org/x/sync v0.21.0 golang.org/x/term v0.44.0 golang.org/x/text v0.38.0 gorm.io/datatypes v1.2.7 @@ -105,7 +106,6 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/qr v0.2.0 // indirect diff --git a/internal/sessiontest/client_integration_test.go b/internal/sessiontest/client_integration_test.go index 6f30cc9d7..49994b89a 100644 --- a/internal/sessiontest/client_integration_test.go +++ b/internal/sessiontest/client_integration_test.go @@ -714,7 +714,7 @@ func testDoubleSdJwtIssuanceFailsAfterRevocationListUpdate(t *testing.T) { defer c.Close() revocationListUpdateInterval := 3 * time.Second - c.InitJobs(revocationListUpdateInterval) + c.InitJobs(revocationListUpdateInterval, 0) // status refresh disabled: not under test here // Give the client some time to init and download the current CRL time.Sleep(4 * time.Second) diff --git a/internal/sessiontest/openid4vci_statuslist_test.go b/internal/sessiontest/openid4vci_statuslist_test.go new file mode 100644 index 000000000..bdfbf766e --- /dev/null +++ b/internal/sessiontest/openid4vci_statuslist_test.go @@ -0,0 +1,224 @@ +package sessiontest + +// End-to-end Token Status List (draft-ietf-oauth-status-list-15) tests over +// OpenID4VCI. These require the full docker-compose stack including the +// statuslist_agent service and veramo bumped to v1.5.5. +// +// What they exercise: +// - issuance-time holder check: the wallet fetches + verifies the +// statuslist+jwt the agent serves (signed by its did:web, sub == uri) and +// accepts the credential because the freshly-allocated bit reads VALID; +// - RefreshStatuses: the background sweep re-fetches for the stored instance +// and writes back its LastKnownStatus; +// - disclosure-time revocation surfacing: after the issuer revokes the +// credential, a subsequent OpenID4VP disclosure still offers it in the +// plan, now carrying Revoked=true (IRMA parity — the frontend decides, the +// wallet does not fail closed). +// +// At disclosure the plan's Revoked flag comes from a live (cache-aware) Token +// Status List check on the instance, performed by services.RevocationService +// (kept out of the disclosure planner itself). The checker serves the cached +// token while it is within its own ttl and re-fetches once expired; if no +// verifiable status is available it fails safe to revoked. The wallet does not +// error the session — it surfaces Revoked for the frontend, with the verifier +// as backstop. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/privacybydesign/irmago/client" + "github.com/privacybydesign/irmago/common/clientmodels" + "github.com/stretchr/testify/require" +) + +// testSessionHandlerForOpenID4VCIStatusList groups the status-list e2e tests. +// Wire it into TestSessionHandler (session_handler_test.go) with: +// +// t.Run("openid4vci/sdjwtvc/status-list", testSessionHandlerForOpenID4VCIStatusList) +func testSessionHandlerForOpenID4VCIStatusList(t *testing.T) { + t.Run("issuance accepts a valid status and refresh runs", testOpenID4VCIStatusListIssuanceAcceptsValid) + t.Run("revoked credential is surfaced in the disclosure plan", testOpenID4VPStatusListRevokedSurfacedInPlan) +} + +const statusListCredentialEmail = "statuslist@example.com" + +// createStatusListPreAuthOffer creates a pre-authorized offer for the +// StatusListCredentialSdJwt — the only credential whose issuer config wires a +// `statusLists` entry, so issuance reserves an index on the statuslist_agent +// and embeds status.status_list{idx,uri}. +func createStatusListPreAuthOffer(t *testing.T) openid4vciOfferResponse { + t.Helper() + return postOffer(t, preAuthIssuerURL, preAuthAdminToken, fmt.Sprintf(`{ + "credentials": ["StatusListCredentialSdJwt"], + "grants": { + "urn:ietf:params:oauth:grant-type:pre-authorized_code": { + "pre-authorized_code": "generate" + } + }, + "credentialDataSupplierInput": { + "given_name": "Test", + "family_name": "StatusList", + "email": %q + } + }`, statusListCredentialEmail)) +} + +// issueStatusListCredential runs the full pre-auth issuance for the +// status-list credential and asserts it succeeds. Success here already proves +// the issuance-time holder status check passed (the wallet fetched + verified +// the agent's statuslist+jwt and read VALID at the allocated index). +func issueStatusListCredential(t *testing.T, c *client.Client, sessionHandler *MockSessionHandler, sessionId int) { + t.Helper() + offer := createStatusListPreAuthOffer(t) + + startOpenID4VCISession(t, c, sessionId, offer.URI) + + session := awaitSessionState(t, sessionHandler) + requireSessionState(t, session, sessionId, clientmodels.Type_Issuance, clientmodels.Status_RequestPreAuthorizedCode) + + userInteraction(t, c, clientmodels.SessionUserInteraction{ + SessionId: session.Id, + Type: clientmodels.UI_PreAuthorizedCode, + Payload: clientmodels.SessionPreAuthorizedCodeInteractionPayload{Proceed: true}, + }) + + session = awaitSessionState(t, sessionHandler) + if session.Status == clientmodels.Status_Error { + t.Fatalf("status-list issuance errored before permission (check `docker compose logs veramo_openid4vci statuslist_agent`): %+v", session.Error) + } + requireSessionState(t, session, sessionId, clientmodels.Type_Issuance, clientmodels.Status_RequestPermission) + + grantPermission(t, c, session.Id) + + session = awaitSessionState(t, sessionHandler) + if session.Status == clientmodels.Status_Error { + t.Fatalf("status-list issuance errored after permission grant (the holder-side status check fetches the statuslist+jwt from the agent — check `docker compose logs statuslist_agent`): %+v", session.Error) + } + requireSessionState(t, session, sessionId, clientmodels.Type_Issuance, clientmodels.Status_Success) +} + +func testOpenID4VCIStatusListIssuanceAcceptsValid(t *testing.T) { + c, sessionHandler := createClientWithoutKeyshareEnrollment(t, nil) + defer c.Close() + + issueStatusListCredential(t, c, sessionHandler, 1) + + // The credential is present in the wallet. + creds, err := c.GetCredentials() + require.NoError(t, err) + cred := findCredentialByName(t, creds, "en", "Status List Credential (SD-JWT)") + require.NotNil(t, cred, "issued status-list credential should appear in GetCredentials") + + // The background refresh sweep runs against the real agent for the stored + // instance and completes without error (a per-URI fetch failure would be + // logged-and-swallowed, but a transport/setup error surfaces here). + require.NoError(t, c.RefreshStatuses(context.Background())) +} + +func testOpenID4VPStatusListRevokedSurfacedInPlan(t *testing.T) { + // Requires an IETF-compliant status source. The upstream eduwallet + // statuslist-agent packs bits MSB-first (W3C @digitalcredentials/bitstring), + // which the IETF wallet (LSB-first, draft §4.1) reads at the wrong position, + // so a revoked credential reads as VALID. docker-compose therefore builds + // the status agent from privacybydesign/statuslist-agent, a fork that emits + // LSB-first bit arrays for application/statuslist+jwt. + c, sessionHandler := createClientWithoutKeyshareEnrollment(t, nil) + defer c.Close() + + // Issue, then revoke at the issuer (which proxies the revoke to the + // statuslist_agent, flipping the credential's bit). + issueStatusListCredential(t, c, sessionHandler, 1) + revokeStatusListCredentialViaVeramo(t, statusListCredentialEmail) + + // The wallet cached the (valid) status list token at issuance. RefreshStatuses + // bypasses that cache, re-fetches the now-revoked list, and updates the shared + // cache. The disclosure plan's live (cache-aware) status check then reads the + // refreshed (revoked) list; without this refresh it would hit the still-valid + // cached token. + require.NoError(t, c.RefreshStatuses(context.Background())) + + // Start an OpenID4VP disclosure that requests the status-list credential by + // its vct. + dcqlQuery := `{ + "dcql": { + "credentials": [ + { + "id": "statuslist-cred", + "format": "dc+sd-jwt", + "meta": { "vct_values": ["https://localhost:8443/vct/statuslist"] }, + "claims": [ { "path": ["email"] } ] + } + ] + } + }` + veramoSession := createVeramoVerifierDcqlSessionWithQuery(t, dcqlQuery) + startOpenID4VPDisclosureSession(t, c, 2, veramoSession.RequestUri) + + session := awaitSessionState(t, sessionHandler) + requireSessionState(t, session, 2, clientmodels.Type_Disclosure, clientmodels.Status_RequestPermission) + + // IRMA parity: the wallet does not refuse a revoked credential outright. It + // offers it in the plan with Revoked=true so the frontend can decide what to + // do; the verifier's own status check is the backstop. + require.NotEmpty(t, session.DisclosurePlan.DisclosureChoicesOverview, "expected a disclosure choice") + owned := session.DisclosurePlan.DisclosureChoicesOverview[0].OwnedOptions + require.NotEmpty(t, owned, "revoked instance still expected in the plan") + require.NotEmpty(t, owned[0].Credentials, "bundle must hold the credential instance") + require.True(t, owned[0].Credentials[0].Revoked, + "revoked instance must be surfaced to the frontend with Revoked=true") +} + +// revokeStatusListCredentialViaVeramo revokes every issued status-list +// credential bearing the given email, via the veramo issuer admin API. veramo +// proxies each revoke to the statuslist_agent, flipping that credential's bit. +// +// StatusListCredentialSdJwt is issued as a batch (multiple instances, each with +// its own status index), and the same email is reused across test runs, so we +// revoke *all* matching records — that guarantees whichever instance the wallet +// discloses has been revoked. +func revokeStatusListCredentialViaVeramo(t *testing.T, email string) { + t.Helper() + + // 1) List the issued status-list credentials (POST, admin-authenticated). + // The claims are returned as a JSON string, so we match the email there. + listBody := `{"credential":"StatusListCredentialSdJwt"}` + req, err := http.NewRequest(http.MethodPost, preAuthIssuerURL+"/api/list-credentials", strings.NewReader(listBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+preAuthAdminToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "listing veramo credentials should succeed") + + var records []struct { + UUID string `json:"uuid"` + Claims string `json:"claims"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&records)) + + // 2) Revoke every record whose claims contain our email. veramo proxies + // each revoke to the statuslist_agent, which sets the bit. + revoked := 0 + for _, rec := range records { + if rec.UUID == "" || !strings.Contains(rec.Claims, email) { + continue + } + body := fmt.Sprintf(`{"uuid": %q, "state": "revoke"}`, rec.UUID) + rreq, err := http.NewRequest(http.MethodPost, preAuthIssuerURL+"/api/revoke-credential", strings.NewReader(body)) + require.NoError(t, err) + rreq.Header.Set("Content-Type", "application/json") + rreq.Header.Set("Authorization", "Bearer "+preAuthAdminToken) + rresp, err := http.DefaultClient.Do(rreq) + require.NoError(t, err) + rresp.Body.Close() + require.Equal(t, http.StatusOK, rresp.StatusCode, "revoke-credential should succeed") + revoked++ + } + require.Positive(t, revoked, "expected to revoke at least one status-list credential for %s", email) +} diff --git a/internal/sessiontest/session_handler_test.go b/internal/sessiontest/session_handler_test.go index 45f5e1851..a320cb125 100644 --- a/internal/sessiontest/session_handler_test.go +++ b/internal/sessiontest/session_handler_test.go @@ -9,6 +9,7 @@ func TestSessionHandler(t *testing.T) { t.Run("openid4vp/irma-sdjwt", testSessionHandlerForOpenID4VPWithIrmaSdJwts) t.Run("openid4vp/sdjwtvc", testSessionHandlerForOpenID4VPWithSdJwtVcs) t.Run("openid4vci/sdjwtvc/pre-authorized", testSessionHandlerForOpenID4VCIPreAuth) + t.Run("openid4vci/sdjwtvc/status-list", testSessionHandlerForOpenID4VCIStatusList) t.Run("openid4vci/sdjwtvc/auth-code", testSessionHandlerForOpenID4VCIAuthCode) t.Run("openid4vci/sdjwtvc/eudi-pid-python", testSessionHandlerForEudiPidPythonIssuer) t.Run("irma/disclosure", testSessionHandlerForIrmaDisclosures) diff --git a/internal/sessiontest/storage_regression_test.go b/internal/sessiontest/storage_regression_test.go index 3f0b0fd89..f69055586 100644 --- a/internal/sessiontest/storage_regression_test.go +++ b/internal/sessiontest/storage_regression_test.go @@ -322,24 +322,36 @@ func setupStorageRegressionClient(t *testing.T, version string) (*client.Client, // is version-agnostic and shared by every per-version test. func assertLoadedClientUsable(t *testing.T, c *client.Client, sessionHandler *MockSessionHandler, irmaServer *IrmaServer) { t.Helper() + assertFreshIrmaSessionsWork(t, c, sessionHandler, irmaServer) + assertFreshOpenID4VCISessionsWork(t, c, sessionHandler) + assertStatusListSessionsWork(t, c, sessionHandler) +} + +// assertFreshIrmaSessionsWork runs a fresh IRMA issuance, non-keyshare and +// keyshare disclosures, and an OpenID4VP disclosure of an IRMA-issued SD-JWT +// (served from bbolt) into the reloaded client. +func assertFreshIrmaSessionsWork(t *testing.T, c *client.Client, sessionHandler *MockSessionHandler, irmaServer *IrmaServer) { + t.Helper() - // A fresh IRMA issuance into the loaded client. issue(t, irmaServer, c, sessionHandler, 1, createMijnOverheidIssuanceRequest()) issued := awaitSessionState(t, sessionHandler) require.Equal(t, clientmodels.Status_Success, issued.Status) - // IRMA disclosures (non-keyshare + keyshare) and an OpenID4VP disclosure of an - // IRMA-issued SD-JWT (served from bbolt). performDisclosureSessionForAttribute(t, c, 2, sessionHandler, irmaServer, "irma-demo.MijnOverheid.fullName.familyname") performKeyshareDisclosureSession(t, c, 3, sessionHandler, irmaServer, "test.test.email.email") discloseOverOpenID4VP(t, c, 4, sessionHandler, testdata.OpenID4VP_DirectPost_Host) +} + +// assertFreshOpenID4VCISessionsWork runs a fresh OpenID4VCI issuance (veramo +// issuer) and then an OpenID4VP disclosure of that EUDI credential (veramo +// verifier) into the reloaded client. +func assertFreshOpenID4VCISessionsWork(t *testing.T, c *client.Client, sessionHandler *MockSessionHandler) { + t.Helper() - // A fresh OpenID4VCI issuance into the loaded client (veramo issuer), then an - // OpenID4VP disclosure of that EUDI credential (veramo verifier). issueCredentialViaOpenID4VCI(t, c, 5, sessionHandler, "TestCredentialSdJwt", `{"given_name": "Reload", "family_name": "Check", "email": "reload@example.com"}`) - veramoSession := createVeramoVerifierDcqlSessionWithQuery(t, `{ + session := discloseViaVeramoOpenID4VP(t, c, 6, sessionHandler, `{ "dcql": { "credentials": [ { @@ -351,12 +363,76 @@ func assertLoadedClientUsable(t *testing.T, c *client.Client, sessionHandler *Mo ] } }`) - startOpenID4VPDisclosureSession(t, c, 6, veramoSession.RequestUri) + require.Equal(t, clientmodels.Status_Success, session.Status) +} + +// assertStatusListSessionsWork issues a status-list SD-JWT (the issuance-time +// holder status check passes on the freshly-allocated VALID bit) and then +// discloses it twice over OpenID4VP: once while VALID (succeeds), and once after +// revocation. The wallet does not fail closed at disclosure (matching IRMA); it +// surfaces the revocation on the plan, so the revoked run asserts the plan's +// Revoked flag rather than the session outcome. +func assertStatusListSessionsWork(t *testing.T, c *client.Client, sessionHandler *MockSessionHandler) { + t.Helper() + + issueStatusListCredential(t, c, sessionHandler, 7) + + statusListDcql := `{ + "dcql": { + "credentials": [ + { + "id": "statuslist-cred", + "format": "dc+sd-jwt", + "meta": { "vct_values": ["https://localhost:8443/vct/statuslist"] }, + "claims": [ { "path": ["email"] } ] + } + ] + } + }` + + // Not revoked: disclosure-time status check reads the cached VALID token and succeeds. + valid := discloseViaVeramoOpenID4VP(t, c, 8, sessionHandler, statusListDcql) + require.Equal(t, clientmodels.Status_Success, valid.Status) + + // Revoked: revoke at the issuer and refresh so the wallet observes it. The + // wallet does not fail closed at disclosure — it surfaces the revocation on + // the disclosure plan and leaves the decision to the frontend and the + // verifier. Assert the plan marks the instance Revoked (a deterministic, + // wallet-owned guarantee) rather than the session outcome (which depends on + // the external verifier's own status check). + revokeStatusListCredentialViaVeramo(t, statusListCredentialEmail) + require.NoError(t, c.RefreshStatuses(context.Background())) + + revoked := awaitDisclosurePermission(t, c, 9, sessionHandler, statusListDcql) + revokedCred := revoked.DisclosurePlan.DisclosureChoicesOverview[0].OwnedOptions[0].Credentials[0] + require.True(t, revokedCred.Revoked, + "a revoked status-list credential must be surfaced as Revoked on the disclosure plan") +} + +// discloseViaVeramoOpenID4VP runs a full OpenID4VP disclosure against the veramo +// verifier for the given DCQL query: it awaits the permission request, grants +// the first owned option, and returns the final session state for the caller to +// assert on. +func discloseViaVeramoOpenID4VP(t *testing.T, c *client.Client, sessionId int, sessionHandler *MockSessionHandler, dcql string) clientmodels.SessionState { + t.Helper() + + session := awaitDisclosurePermission(t, c, sessionId, sessionHandler, dcql) + grantPermission(t, c, session.Id, makeDisclosureChoice(session.DisclosurePlan.DisclosureChoicesOverview[0].OwnedOptions[0])) + return awaitSessionState(t, sessionHandler) +} + +// awaitDisclosurePermission starts an OpenID4VP disclosure against the veramo +// verifier for the given DCQL query and returns the session state once it +// reaches RequestPermission, without granting — so callers can inspect the +// disclosure plan (e.g. a candidate's Revoked flag) before deciding. +func awaitDisclosurePermission(t *testing.T, c *client.Client, sessionId int, sessionHandler *MockSessionHandler, dcql string) clientmodels.SessionState { + t.Helper() + + verifierSession := createVeramoVerifierDcqlSessionWithQuery(t, dcql) + startOpenID4VPDisclosureSession(t, c, sessionId, verifierSession.RequestUri) session := awaitSessionState(t, sessionHandler) require.Equal(t, clientmodels.Status_RequestPermission, session.Status) - grantPermission(t, c, session.Id, makeDisclosureChoice(session.DisclosurePlan.DisclosureChoicesOverview[0].OwnedOptions[0])) - session = awaitSessionState(t, sessionHandler) - require.Equal(t, clientmodels.Status_Success, session.Status) + return session } // assertLogsNewestFirst is a universal invariant across all fixtures: LoadNewestLogs diff --git a/testdata/configurations/certs/nginx-tls-proxy.conf b/testdata/configurations/certs/nginx-tls-proxy.conf index 0be2f6416..6d301e1d5 100644 --- a/testdata/configurations/certs/nginx-tls-proxy.conf +++ b/testdata/configurations/certs/nginx-tls-proxy.conf @@ -45,4 +45,21 @@ http { proxy_set_header X-Forwarded-Proto https; } } + + # TLS proxy for the IETF status list agent. Public origin + # https://localhost:8445 — used for the statuslist+jwt token `uri`/`sub` + # and the agent's did:web (did:web:localhost%3A8445, resolved at + # /.well-known/did.json). veramo's admin calls use the internal http + # alias instead, so they don't traverse this proxy. + server { + listen 445 ssl; + server_name localhost; + ssl_certificate /etc/nginx/certs/localhost.crt; + ssl_certificate_key /etc/nginx/certs/localhost.key; + location / { + proxy_pass http://statuslist-agent.localhost:9156; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } + } } diff --git a/testdata/openid4vci-issuer/conf/issuer/test-issuer.json b/testdata/openid4vci-issuer/conf/issuer/test-issuer.json index 60fe5ebd1..fb5e4c079 100644 --- a/testdata/openid4vci-issuer/conf/issuer/test-issuer.json +++ b/testdata/openid4vci-issuer/conf/issuer/test-issuer.json @@ -3,5 +3,12 @@ "baseUrl": "https://localhost:8443/test-issuer", "adminToken": "test-admin-token", "did": "test-issuer-did", - "usesNonces": true + "usesNonces": true, + "statusLists": { + "StatusListCredentialSdJwt": { + "url": "http://statuslist-agent.localhost:9156/statuslist/api/index", + "revoke": "http://statuslist-agent.localhost:9156/statuslist/api/revoke", + "token": "statuslist-admin-token" + } + } } diff --git a/testdata/openid4vci-issuer/conf/metadata/test-issuer.json b/testdata/openid4vci-issuer/conf/metadata/test-issuer.json index 6fc8a294f..0281784fc 100644 --- a/testdata/openid4vci-issuer/conf/metadata/test-issuer.json +++ b/testdata/openid4vci-issuer/conf/metadata/test-issuer.json @@ -95,6 +95,62 @@ } ] }, + "StatusListCredentialSdJwt": { + "format": "dc+sd-jwt", + "scope": "StatusListCredentialSdJwt", + "extends": "TestCredential", + "cryptographic_binding_methods_supported": [ + "did:jwk" + ], + "credential_signing_alg_values_supported": [ + "ES256" + ], + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "ES256" + ] + } + }, + "credential_metadata": { + "display": [ + { + "name": "Status List Credential (SD-JWT)", + "locale": "en" + } + ], + "claims": [ + { + "path": [ + "given_name" + ], + "display": [ + { + "name": "Given Name", + "locale": "en" + } + ] + }, + { + "path": [ + "email" + ], + "display": [ + { + "name": "Email", + "locale": "en" + } + ] + } + ] + }, + "display": [ + { + "name": "Status List Credential (SD-JWT)", + "locale": "en" + } + ] + }, "EmailCredentialSdJwt": { "format": "dc+sd-jwt", "scope": "EmailCredentialSdJwt", diff --git a/testdata/openid4vci-issuer/conf/vct/statuslist-vct.json b/testdata/openid4vci-issuer/conf/vct/statuslist-vct.json new file mode 100644 index 000000000..ea0436bbb --- /dev/null +++ b/testdata/openid4vci-issuer/conf/vct/statuslist-vct.json @@ -0,0 +1,44 @@ +{ + "path": "/vct/statuslist", + "credentials": [ + "StatusListCredentialSdJwt" + ], + "document": { + "name": "Status List Credential (SD-JWT)", + "description": "VCT metadata for the Token Status List integration test SD-JWT credential", + "language": "en", + "display": [ + { + "locale": "en", + "name": "Status List Credential (SD-JWT)" + } + ], + "claims": [ + { + "path": ["given_name"], + "sd": "allowed", + "display": [ + { "locale": "en", "label": "Given Name" }, + { "locale": "nl", "label": "Voornaam" } + ] + }, + { + "path": ["family_name"], + "sd": "allowed", + "display": [ + { "locale": "en", "label": "Family Name" }, + { "locale": "nl", "label": "Achternaam" } + ] + }, + { + "path": ["email"], + "sd": "allowed", + "display": [ + { "locale": "en", "label": "Email" }, + { "locale": "nl", "label": "E-mailadres" } + ] + } + ], + "issuer": "{{ here }}" + } +} diff --git a/testdata/statuslist-agent/conf/lists/statuslist.json b/testdata/statuslist-agent/conf/lists/statuslist.json new file mode 100644 index 000000000..f7d07b662 --- /dev/null +++ b/testdata/statuslist-agent/conf/lists/statuslist.json @@ -0,0 +1,8 @@ +{ + "name": "statuslist", + "tokens": ["statuslist-admin-token"], + "size": 131072, + "bitSize": 1, + "purpose": "revocation", + "type": "statuslist+jwt" +}