Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
abc87df
add initial status list support for sd-jwts
w-ensink Jun 8, 2026
2a8a8e0
improve architecture for storage a bit
w-ensink Jun 9, 2026
182151c
reduce comment bloat
w-ensink Jun 9, 2026
bc6bcf8
reduce comment bloat
w-ensink Jun 9, 2026
7c897c0
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jun 19, 2026
3c53dd3
fix lint
w-ensink Jun 19, 2026
2dc368c
Merge remote-tracking branch 'origin/master' into sd-jwt-status-lists
w-ensink Jun 19, 2026
ecbb6cd
improve status list implementation and tests
w-ensink Jun 19, 2026
f610d6e
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jun 19, 2026
a5ca145
Merge remote-tracking branch 'origin/master' into sd-jwt-status-lists
w-ensink Jul 9, 2026
e96be97
improve automatic status list refreshes
w-ensink Jul 9, 2026
9e7c0fd
add status list agent and e2e integration tests
w-ensink Jul 9, 2026
7eb9a7a
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 9, 2026
f2331c3
update changelog
w-ensink Jul 9, 2026
c22a332
Merge branch 'sd-jwt-status-lists' of https://github.com/privacybydes…
w-ensink Jul 9, 2026
645f0bb
small improvements
w-ensink Jul 9, 2026
feecec7
some improvements and comment fixes
w-ensink Jul 9, 2026
6024c66
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 10, 2026
aeacf98
add sessions with credentials with status lists to storage regression…
w-ensink Jul 10, 2026
b69a98a
sharpen comments
w-ensink Jul 10, 2026
59f02e7
small changes and fixes
w-ensink Jul 10, 2026
2309389
fixes
w-ensink Jul 10, 2026
f947734
small cleanups
w-ensink Jul 13, 2026
5f9bbbd
make status refresh part of credential service
w-ensink Jul 13, 2026
4ce989d
fix test
w-ensink Jul 13, 2026
9f19a88
update some comments
w-ensink Jul 13, 2026
75262b1
fix comments
w-ensink Jul 13, 2026
4b1a218
fix one more comment
w-ensink Jul 13, 2026
4f2b92b
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 14, 2026
a59ba34
reduce implicit code
w-ensink Jul 14, 2026
32a3f4f
sanitize logging
w-ensink Jul 14, 2026
8b94dc4
only do single status list lookup for credential batch
w-ensink Jul 17, 2026
1749304
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 17, 2026
c5c593e
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 20, 2026
641f1e8
simplify status lists implementation
w-ensink Jul 21, 2026
9e1cd6b
create revocation service
w-ensink Jul 21, 2026
dd87654
format
w-ensink Jul 21, 2026
42281d2
docs: drop stale RequireStatusListIssuerMatch flag from changelog
dobby-coder[bot] Jul 21, 2026
0afaf71
docs: drop stale IssuerURL mention from ListInstancesWithStatusReference
dobby-coder[bot] Jul 21, 2026
f8be261
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 21, 2026
b11a51e
apply some feedback
w-ensink Jul 23, 2026
1119065
move doc comment
w-ensink Jul 23, 2026
acd414a
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 24, 2026
697b180
Merge branch 'sd-jwt-status-lists' of https://github.com/privacybydes…
w-ensink Jul 24, 2026
44977e7
use cached status in disclosure flow
w-ensink Jul 24, 2026
10791cc
treat suspended as revoked for now
w-ensink Jul 24, 2026
81ab568
treat any value not valid or unknown as revoked
w-ensink Jul 24, 2026
93ab688
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 24, 2026
cc8aefb
fix linter
w-ensink Jul 24, 2026
ca3133f
Merge branch 'master' into sd-jwt-status-lists
w-ensink Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
111 changes: 83 additions & 28 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"context"
"encoding/json"
"fmt"
"path/filepath"
Expand All @@ -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"
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -166,13 +190,15 @@ func New(
Clock: eudi_jwt.NewSystemClock(),
JwtVerifier: sdjwtvc.NewJwxJwtVerifier(),
VerifyVerifiableCredentialTypeInRequestorInfo: false,
StatusChecker: statusChecker,
}

// Initiate the OpenID4VCI client
openid4vciClient, err := openid4vci.NewClient(
common.HTTPClient,
eudiConf,
sdjwtvc.NewHolderVerificationProcessor(sdJwtVcVerificationContextOpenID4VCI),
credentialService,
services.NewHolderBindingKeyService(eudiConf.Storage.Db()),
)

Expand All @@ -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,
Expand All @@ -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 {
Comment thread
w-ensink marked this conversation as resolved.
return client.revocationService.RefreshStatuses(ctx)
}

type SessionRequestData struct {
irma.Qr
Protocol clientmodels.Protocol `json:"protocol,omitempty"`
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
}
7 changes: 2 additions & 5 deletions client/schemaless.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading