From 8a89c5309a22a0259b8de492bdae4ee6e93ea559 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Fri, 26 Jun 2026 09:46:24 +0200 Subject: [PATCH 1/6] add IETF Status List support --- README.md | 69 ++++++---- common/credential_status.go | 177 ++++++++++++++++++++++++- common/credential_status_test.go | 189 +++++++++++++++++++++++++++ config/configClient.go | 66 ++++++++-- config/configClient_test.go | 15 ++- verifier/credential_status.go | 82 +++++++++--- verifier/credential_status_client.go | 174 +++++++++++++++++++++++- verifier/credential_status_test.go | 156 ++++++++++++++++++++-- verifier/credentialsConfig.go | 2 +- verifier/credentialsConfig_test.go | 14 +- verifier/verifier.go | 9 +- verifier/verifier_test.go | 10 +- 12 files changed, 874 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 30be9cc0..7923b88f 100644 --- a/README.md +++ b/README.md @@ -469,21 +469,44 @@ configRepo: ### Credential revocation list -The verifier can check incoming credentials against a +The verifier can check incoming credentials against two revocation-list formats: + +**W3C Bitstring Status List / StatusList2021** — when a credential carries a +top-level `credentialStatus` entry of type `BitstringStatusListEntry` or +`StatusList2021Entry`, the verifier fetches the referenced status-list +credential, decodes the gzip-compressed, base64-encoded bitstring (MSB-first +bit ordering), and rejects the credential when the bit at its +`statusListIndex` is set. See the [W3C Bitstring Status List](https://www.w3.org/TR/vc-bitstring-status-list/) -or the legacy +and [StatusList2021](https://www.w3.org/community/reports/credentials/CG-FINAL-vc-status-list-2021-20230102/) -revocation list. When a credential carries a `credentialStatus` entry of type -`BitstringStatusListEntry` or `StatusList2021Entry`, the verifier fetches the -referenced status-list credential, decodes the bitstring, and rejects the -credential when the bit at its `statusListIndex` is set. +specifications. + +**IETF OAuth 2.0 Token Status List** — when a credential has no W3C +`credentialStatus` but its `credentialSubject` contains a +`status.status_list` object with `idx` (index) and `uri` (status-list +endpoint) fields, the verifier fetches the referenced status-list JWT +(Content-Type `application/statuslist+jwt`), decodes the zlib-compressed, +base64url-encoded bitstring from the JWT payload's `status_list.lst` field +(LSB-first bit ordering), and rejects the credential when the status value at +the given index is non-zero. The `status_list.bits` field in the JWT payload +controls how many bits represent each entry (1, 2, 4, or 8). See the +[IETF Token Status List](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/) +specification. This format is produced by implementations such as the +[token-status-link](https://github.com/adorsys/token-status-link) Keycloak +plugin paired with a status-list server. + +Both formats are detected automatically — no configuration is needed to +choose between them. When a credential carries W3C `credentialStatus` +entries, those are checked. When no W3C entries are present, the verifier +falls back to the IETF format. If neither format is found and `requireStatus` +is `false` (the default), the credential passes validation. The check is configured **per credential type** — it is not a global switch — -and is **off by default**. Credentials that do not opt in are validated -exactly as before and no status-list requests are issued. The per-credential -block sits next to `trustedParticipantsLists`, `trustedIssuersLists`, -`holderVerification`, `requireCompliance`, and `jwtInclusion` inside each -`credentials:` entry of a scope: +and is **enabled by default**. The per-credential block sits next to +`trustedParticipantsLists`, `trustedIssuersLists`, `holderVerification`, +`requireCompliance`, and `jwtInclusion` inside each `credentials:` entry of a +scope: ```yaml configRepo: @@ -495,30 +518,30 @@ configRepo: credentials: - type: CustomerCredential # ... trustedParticipantsLists / trustedIssuersLists ... - # Enable the revocation-list check for this credential type. + # Revocation-list check for this credential type. credentialStatus: - # When false (default) no status-list lookup is performed. + # Toggle the status-list check. Default: true. enabled: true - # Status purposes this credential enforces. When empty, - # defaults to ["revocation"]. Valid values are - # "revocation" and "suspension". + # Status purposes this credential enforces (W3C format + # only). When empty, defaults to ["revocation"]. Valid + # values are "revocation" and "suspension". acceptedPurposes: - revocation - # Reject credentials of this type that do not carry a - # credentialStatus entry. Defaults to false. + # Reject credentials of this type that do not carry any + # status entry (W3C or IETF). Defaults to false. requireStatus: false ``` The shared HTTP client and in-memory cache used to fetch status-list -credentials are parametrised globally on `verifier:`. These knobs do **not** -enable the feature — they only tune the transport when at least one -credential opts in: +credentials (both W3C and IETF) are parametrised globally on `verifier:`. +These knobs do **not** toggle the feature — they only tune the transport when +at least one credential opts in: ```yaml verifier: - # TTL in seconds for cached status-list credentials. Default: 300. + # TTL in seconds for cached status-list credentials / JWTs. Default: 300. statusListCacheExpiry: 300 - # Timeout in seconds for HTTP requests fetching a status-list credential. + # Timeout in seconds for HTTP requests fetching a status-list resource. # Default: 10. statusListHttpTimeout: 10 ``` diff --git a/common/credential_status.go b/common/credential_status.go index 7a149d1c..ada12f06 100644 --- a/common/credential_status.go +++ b/common/credential_status.go @@ -1,16 +1,19 @@ // Package common contains shared types and helpers used across the VCVerifier // codebase. This file defines the data model and helpers for W3C Bitstring // Status List / StatusList2021 credentials referenced from a Verifiable -// Credential's `credentialStatus` field. +// Credential's `credentialStatus` field, as well as the IETF OAuth 2.0 +// Token Status List format (draft-ietf-oauth-status-list). // // References: // - https://www.w3.org/TR/vc-bitstring-status-list/ // - https://www.w3.org/TR/2023/WD-vc-status-list-20230427/ (StatusList2021) +// - https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-11.html package common import ( "bytes" "compress/gzip" + "compress/zlib" "encoding/base64" "errors" "fmt" @@ -60,6 +63,28 @@ const ( StatusListEntryKeyStatusSize = "statusSize" ) +// IETF Token Status List constants — keys and types used by the OAuth 2.0 +// Token Status List specification (draft-ietf-oauth-status-list). +const ( + // IETFStatusClaimKey is the top-level key inside credentialSubject that + // carries the IETF status reference. + IETFStatusClaimKey = "status" + // IETFStatusListKey is the nested key inside the status object. + IETFStatusListKey = "status_list" + // IETFStatusListIdx is the index key inside the status_list object. + IETFStatusListIdx = "idx" + // IETFStatusListURI is the URI key inside the status_list object. + IETFStatusListURI = "uri" + // IETFStatusListBits is the bits-per-status key in a fetched status list JWT payload. + IETFStatusListBits = "bits" + // IETFStatusListLst is the encoded-list key in a fetched status list JWT payload. + IETFStatusListLst = "lst" + + // ContentTypeStatusListJWT is the Accept / Content-Type header for + // IETF Token Status List JWT responses. + ContentTypeStatusListJWT = "application/statuslist+jwt" +) + // Numeric defaults for status-list encoding. const ( // DefaultStatusSizeBits is the number of bits per status when an entry @@ -345,3 +370,153 @@ func IsStatusSet(bitstring []byte, index uint64, statusSize int) (bool, error) { } return false, nil } + +// --------------------------------------------------------------------------- +// IETF Token Status List helpers +// --------------------------------------------------------------------------- + +// IETFStatusEntry represents a parsed status reference from the IETF +// OAuth 2.0 Token Status List format. The entry is extracted from +// `credentialSubject.status.status_list` with fields `idx` and `uri`. +type IETFStatusEntry struct { + // Idx is the zero-based index into the status list bitstring. + Idx uint64 + // URI is the URL of the status list resource to fetch. + URI string +} + +// IETFStatusList represents the decoded payload of a fetched IETF Token +// Status List JWT. The `lst` field is the base64url-encoded, +// zlib-compressed bitstring; `bits` declares the number of bits per +// status entry. +type IETFStatusList struct { + // Bits is the number of bits per status entry (e.g. 1, 2, 4, 8). + Bits int + // Lst is the base64url-encoded, zlib-compressed bitstring. + Lst string +} + +// ParseIETFStatusEntry extracts an IETFStatusEntry from the +// `credentialSubject` map of a Verifiable Credential. The expected +// structure is: `{ "status": { "status_list": { "idx": N, "uri": "..." } } }`. +// +// Returns (entry, true) on success, or (zero, false) when the required +// structure is absent or malformed. +func ParseIETFStatusEntry(credentialSubject map[string]interface{}) (IETFStatusEntry, bool) { + statusRaw, ok := credentialSubject[IETFStatusClaimKey] + if !ok || statusRaw == nil { + return IETFStatusEntry{}, false + } + statusMap, ok := statusRaw.(map[string]interface{}) + if !ok { + return IETFStatusEntry{}, false + } + listRaw, ok := statusMap[IETFStatusListKey] + if !ok || listRaw == nil { + return IETFStatusEntry{}, false + } + listMap, ok := listRaw.(map[string]interface{}) + if !ok { + return IETFStatusEntry{}, false + } + + uri, ok := listMap[IETFStatusListURI].(string) + if !ok || uri == "" { + return IETFStatusEntry{}, false + } + + idx, err := parseIETFIdx(listMap[IETFStatusListIdx]) + if err != nil { + return IETFStatusEntry{}, false + } + + return IETFStatusEntry{Idx: idx, URI: uri}, true +} + +// parseIETFIdx converts the `idx` value (which may be a float64 from +// JSON unmarshalling or an integer) to a uint64. +func parseIETFIdx(raw interface{}) (uint64, error) { + switch v := raw.(type) { + case float64: + if v < 0 { + return 0, fmt.Errorf("idx must be non-negative") + } + return uint64(v), nil + case int: + if v < 0 { + return 0, fmt.Errorf("idx must be non-negative") + } + return uint64(v), nil + case int64: + if v < 0 { + return 0, fmt.Errorf("idx must be non-negative") + } + return uint64(v), nil + case uint64: + return v, nil + case string: + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("idx is not a valid unsigned integer: %v", err) + } + return n, nil + default: + return 0, fmt.Errorf("idx has unexpected type %T", raw) + } +} + +// DecodeIETFBitstring decodes an IETF Token Status List bitstring. +// The input is base64url-encoded, zlib (DEFLATE) compressed — note that +// this differs from the W3C format which uses gzip compression. +func DecodeIETFBitstring(encoded string) ([]byte, error) { + compressed, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + padded, padErr := base64.URLEncoding.DecodeString(encoded) + if padErr != nil { + return nil, fmt.Errorf("%w: base64url decode failed: %v", ErrorStatusListBitstringDecode, err) + } + compressed = padded + } + + reader, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, fmt.Errorf("%w: zlib reader init failed: %v", ErrorStatusListBitstringDecode, err) + } + defer func() { _ = reader.Close() }() + + decoded, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("%w: zlib inflate failed: %v", ErrorStatusListBitstringDecode, err) + } + return decoded, nil +} + +// IsIETFStatusSet reports whether the status value at the given index is +// non-zero in the IETF Token Status List bitstring. The IETF format uses +// LSB-first bit ordering within each byte, unlike the W3C format which +// uses MSB-first. +func IsIETFStatusSet(bitstring []byte, index uint64, bitsPerStatus int) (bool, error) { + if bitsPerStatus <= 0 { + return false, fmt.Errorf("%w: got %d", ErrorStatusListInvalidStatusSize, bitsPerStatus) + } + + startBit := index * uint64(bitsPerStatus) + endBitExclusive := startBit + uint64(bitsPerStatus) + + totalBits := uint64(len(bitstring)) * uint64(BitsPerByte) + if endBitExclusive > totalBits { + return false, fmt.Errorf("%w: index %d with size %d exceeds bitstring of %d bits", + ErrorStatusListIndexOutOfRange, index, bitsPerStatus, totalBits) + } + + for bit := startBit; bit < endBitExclusive; bit++ { + byteIndex := bit / uint64(BitsPerByte) + bitInByte := bit % uint64(BitsPerByte) + // LSB-first ordering within each byte (IETF spec). + mask := byte(1) << bitInByte + if bitstring[byteIndex]&mask != 0 { + return true, nil + } + } + return false, nil +} diff --git a/common/credential_status_test.go b/common/credential_status_test.go index 7ac04b07..92ae6134 100644 --- a/common/credential_status_test.go +++ b/common/credential_status_test.go @@ -3,6 +3,7 @@ package common import ( "bytes" "compress/gzip" + "compress/zlib" "encoding/base64" "errors" "testing" @@ -318,3 +319,191 @@ func TestIsStatusSet_RoundTripWithDecodeBitstring(t *testing.T) { t.Fatalf("expected index %d to be clear after round-trip", revokedIndex+1) } } + +// --------------------------------------------------------------------------- +// IETF Token Status List tests +// --------------------------------------------------------------------------- + +// encodeIETFBitstring is a test helper that produces the base64url(zlib(raw)) +// encoding used by the IETF Token Status List format. +func encodeIETFBitstring(t *testing.T, raw []byte) string { + t.Helper() + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + if _, err := w.Write(raw); err != nil { + t.Fatalf("zlib write failed: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("zlib close failed: %v", err) + } + return base64.RawURLEncoding.EncodeToString(buf.Bytes()) +} + +func TestParseIETFStatusEntry(t *testing.T) { + type testCase struct { + name string + subject map[string]interface{} + wantOK bool + want IETFStatusEntry + } + + tests := []testCase{ + { + name: "valid IETF status entry", + subject: map[string]interface{}{ + "status": map[string]interface{}{ + "status_list": map[string]interface{}{ + "idx": float64(42), + "uri": "https://example.org/statuslists/abc", + }, + }, + }, + wantOK: true, + want: IETFStatusEntry{Idx: 42, URI: "https://example.org/statuslists/abc"}, + }, + { + name: "no status field", + subject: map[string]interface{}{"email": "test@example.org"}, + wantOK: false, + }, + { + name: "missing uri", + subject: map[string]interface{}{ + "status": map[string]interface{}{ + "status_list": map[string]interface{}{ + "idx": float64(0), + }, + }, + }, + wantOK: false, + }, + { + name: "missing status_list", + subject: map[string]interface{}{ + "status": map[string]interface{}{ + "other_key": "value", + }, + }, + wantOK: false, + }, + { + name: "idx as integer zero", + subject: map[string]interface{}{ + "status": map[string]interface{}{ + "status_list": map[string]interface{}{ + "idx": float64(0), + "uri": "https://example.org/statuslists/abc", + }, + }, + }, + wantOK: true, + want: IETFStatusEntry{Idx: 0, URI: "https://example.org/statuslists/abc"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := ParseIETFStatusEntry(tc.subject) + if ok != tc.wantOK { + t.Fatalf("expected ok=%v, got ok=%v", tc.wantOK, ok) + } + if ok && got != tc.want { + t.Errorf("mismatch:\n got: %#v\n want: %#v", got, tc.want) + } + }) + } +} + +func TestDecodeIETFBitstring(t *testing.T) { + payload := []byte{0x00, 0x01, 0x80, 0xFF} + encoded := encodeIETFBitstring(t, payload) + + decoded, err := DecodeIETFBitstring(encoded) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(decoded, payload) { + t.Fatalf("round-trip mismatch:\n got: %x\n want: %x", decoded, payload) + } +} + +func TestDecodeIETFBitstring_Errors(t *testing.T) { + _, err := DecodeIETFBitstring("not*valid*base64") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrorStatusListBitstringDecode) { + t.Fatalf("expected ErrorStatusListBitstringDecode, got %v", err) + } +} + +func TestIsIETFStatusSet(t *testing.T) { + // IETF uses LSB-first bit ordering. + // byte 0 = 0b00000010 -> bit 1 set (index 1 at 1 bit per status) + bitstring := []byte{0b00000010} + + tests := []struct { + name string + index uint64 + bitsPerStatus int + want bool + wantErr error + }{ + {name: "index 0 clear", index: 0, bitsPerStatus: 1, want: false}, + {name: "index 1 set", index: 1, bitsPerStatus: 1, want: true}, + {name: "index 2 clear", index: 2, bitsPerStatus: 1, want: false}, + {name: "out of range", index: 8, bitsPerStatus: 1, wantErr: ErrorStatusListIndexOutOfRange}, + {name: "invalid bitsPerStatus", index: 0, bitsPerStatus: 0, wantErr: ErrorStatusListInvalidStatusSize}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := IsIETFStatusSet(bitstring, tc.index, tc.bitsPerStatus) + if tc.wantErr != nil { + if err == nil { + t.Fatalf("expected error %v, got nil", tc.wantErr) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("expected error %v, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("IsIETFStatusSet(%d, %d) = %v, want %v", tc.index, tc.bitsPerStatus, got, tc.want) + } + }) + } +} + +func TestIsIETFStatusSet_MultiBit(t *testing.T) { + // 2 bits per status, LSB-first: + // byte 0 = 0b11100100 → statuses: idx0=00 (valid), idx1=01 (invalid), + // idx2=10 (suspended), idx3=11 (app-specific) + bitstring := []byte{0b11100100} + + tests := []struct { + name string + index uint64 + want bool + }{ + {name: "idx 0 valid (00)", index: 0, want: false}, + {name: "idx 1 invalid (01)", index: 1, want: true}, + {name: "idx 2 suspended (10)", index: 2, want: true}, + {name: "idx 3 app-specific (11)", index: 3, want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := IsIETFStatusSet(bitstring, tc.index, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("IsIETFStatusSet(%d, 2) = %v, want %v", tc.index, got, tc.want) + } + }) + } +} diff --git a/config/configClient.go b/config/configClient.go index 02164cea..9f0a0c36 100644 --- a/config/configClient.go +++ b/config/configClient.go @@ -120,21 +120,42 @@ type Credential struct { // Configuration for the credential its inclusion into the JWT. JwtInclusion JwtInclusion `json:"jwtInclusion" mapstructure:"jwtInclusion"` // Per-credential configuration for the W3C Bitstring Status List / - // StatusList2021 revocation-list check. When omitted or disabled no - // revocation check is performed for credentials of this type, preserving - // prior behaviour for configurations that do not opt in. + // StatusList2021 revocation-list check. Defaults to an enabled + // CredentialStatus when omitted so that the revocation check is active + // unless explicitly disabled. CredentialStatus CredentialStatus `json:"credentialStatus" mapstructure:"credentialStatus"` } +func (c *Credential) UnmarshalJSON(data []byte) error { + type Alias Credential + aux := &struct{ *Alias }{Alias: (*Alias)(c)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if c.CredentialStatus.Enabled == nil { + t := true + c.CredentialStatus.Enabled = &t + } + return nil +} + +func (c Credential) MarshalJSON() ([]byte, error) { + type Alias Credential + if c.CredentialStatus.Enabled == nil { + t := true + c.CredentialStatus.Enabled = &t + } + return json.Marshal((Alias)(c)) +} + // CredentialStatus holds the per-credential-type configuration for the -// status-list based revocation check. The zero-value disables the check, so -// credentials that omit the block behave exactly as they did before the -// feature was introduced. +// status-list based revocation check. Defaults to enabled so that the +// revocation check is active unless explicitly disabled. type CredentialStatus struct { // Enabled toggles the revocation-list check for this credential type. - // When false (the default), no status-list lookup is performed for - // credentials of this type. - Enabled bool `json:"enabled" mapstructure:"enabled"` + // Defaults to true when absent so that status-list lookups are performed + // unless explicitly disabled. + Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"` // AcceptedPurposes lists the status purposes this credential type enforces // (for example "revocation" or "suspension"). When empty callers should // fall back to DefaultAcceptedStatusPurposes(). The field is intentionally @@ -147,6 +168,33 @@ type CredentialStatus struct { RequireStatus bool `json:"requireStatus" mapstructure:"requireStatus"` } +// IsEnabled returns true when Enabled is nil (absent) or explicitly true. +func (cs *CredentialStatus) IsEnabled() bool { + return cs.Enabled == nil || *cs.Enabled +} + +func (cs *CredentialStatus) UnmarshalJSON(data []byte) error { + type Alias CredentialStatus + aux := &struct{ *Alias }{Alias: (*Alias)(cs)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if cs.Enabled == nil { + t := true + cs.Enabled = &t + } + return nil +} + +func (cs CredentialStatus) MarshalJSON() ([]byte, error) { + type Alias CredentialStatus + if cs.Enabled == nil { + t := true + cs.Enabled = &t + } + return json.Marshal((Alias)(cs)) +} + type JwtInclusion struct { // Should the given credential be included into the generated JWT; defaults to true when absent. Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"` diff --git a/config/configClient_test.go b/config/configClient_test.go index c01874eb..687213a0 100644 --- a/config/configClient_test.go +++ b/config/configClient_test.go @@ -17,6 +17,8 @@ import ( var TRUE_VALUE bool = true +func boolPtr(v bool) *bool { return &v } + type MockHttpClient struct { Answer string } @@ -87,9 +89,9 @@ func Test_CredentialStatusDeserialisation(t *testing.T) { tests := []test{ { - testName: "A credential without a credentialStatus block deserialises to a zero-value CredentialStatus with Enabled false.", + testName: "A credential without a credentialStatus block defaults to Enabled true.", rawJSON: `{"type":"VerifiableCredential"}`, - expectedEnabled: false, + expectedEnabled: true, expectedAcceptedPurposes: nil, expectedRequireStatus: false, }, @@ -115,7 +117,7 @@ func Test_CredentialStatusDeserialisation(t *testing.T) { if err := json.Unmarshal([]byte(tc.rawJSON), &credential); err != nil { t.Fatalf("%s - failed to unmarshal JSON: %v", tc.testName, err) } - assert.Equal(t, tc.expectedEnabled, credential.CredentialStatus.Enabled) + assert.Equal(t, tc.expectedEnabled, credential.CredentialStatus.IsEnabled()) assert.Equal(t, tc.expectedAcceptedPurposes, credential.CredentialStatus.AcceptedPurposes) assert.Equal(t, tc.expectedRequireStatus, credential.CredentialStatus.RequireStatus) }) @@ -135,9 +137,9 @@ func Test_CredentialStatusMapstructureDecoding(t *testing.T) { tests := []test{ { - testName: "Missing credentialStatus key leaves zero-value CredentialStatus on the Credential.", + testName: "Missing credentialStatus key defaults to Enabled true.", input: map[string]interface{}{"type": "VerifiableCredential"}, - expectedEnabled: false, + expectedEnabled: true, expectedAcceptedPurposes: nil, expectedRequireStatus: false, }, @@ -182,7 +184,7 @@ func Test_CredentialStatusMapstructureDecoding(t *testing.T) { if err := decoder.Decode(tc.input); err != nil { t.Fatalf("%s - failed to decode: %v", tc.testName, err) } - assert.Equal(t, tc.expectedEnabled, credential.CredentialStatus.Enabled) + assert.Equal(t, tc.expectedEnabled, credential.CredentialStatus.IsEnabled()) assert.Equal(t, tc.expectedAcceptedPurposes, credential.CredentialStatus.AcceptedPurposes) assert.Equal(t, tc.expectedRequireStatus, credential.CredentialStatus.RequireStatus) }) @@ -223,6 +225,7 @@ func Test_getServices(t *testing.T) { TrustedParticipantsLists: []TrustedParticipantsList{{Type: "ebsi", Url: "https://tir-pdc.ebsi.fiware.dev"}}, TrustedIssuersLists: TrustedIssuersLists{{Type: "ebsi", Url: "https://til-pdc.ebsi.fiware.dev"}}, HolderVerification: HolderVerification{Enabled: false, Claim: "subject"}, + CredentialStatus: CredentialStatus{Enabled: boolPtr(true)}, }, }, PresentationDefinition: &PresentationDefinition{ diff --git a/verifier/credential_status.go b/verifier/credential_status.go index 88a5b538..dd6325a5 100644 --- a/verifier/credential_status.go +++ b/verifier/credential_status.go @@ -52,28 +52,24 @@ type CredentialStatusValidationContext struct { // CredentialStatusValidationService is the ValidationService responsible for // enforcing credential revocation via W3C Bitstring Status List / -// StatusList2021 credentials. +// StatusList2021 credentials and IETF OAuth 2.0 Token Status Lists. // -// The service is safe for concurrent use as long as the configured client is. +// The service is safe for concurrent use as long as the configured clients are. type CredentialStatusValidationService struct { - client StatusListCredentialClient - clock common.Clock + client StatusListCredentialClient + ietfClient IETFStatusListClient + clock common.Clock } // NewCredentialStatusValidationService constructs a ready-to-use validation -// service backed by the supplied status-list credential client and clock. -// The clock is retained for future use (for example to enforce the -// `validFrom`/`validUntil` window on a fetched status-list credential) and -// defaults to common.RealClock when nil. -// -// The service is returned by value so callers can register it through the -// ValidationService interface in the same &value style used for every other -// service in verifier.InitVerifier. -func NewCredentialStatusValidationService(client StatusListCredentialClient, clock common.Clock) CredentialStatusValidationService { +// service backed by the supplied status-list credential client, IETF status +// list client, and clock. The clock is retained for future use and defaults +// to common.RealClock when nil. +func NewCredentialStatusValidationService(client StatusListCredentialClient, ietfClient IETFStatusListClient, clock common.Clock) CredentialStatusValidationService { if clock == nil { clock = common.RealClock{} } - return CredentialStatusValidationService{client: client, clock: clock} + return CredentialStatusValidationService{client: client, ietfClient: ietfClient, clock: clock} } // ValidateVC enforces the per-credential-type revocation-list check against @@ -83,7 +79,7 @@ func NewCredentialStatusValidationService(client StatusListCredentialClient, clo // - casts the validation context to CredentialStatusValidationContext; // returns ErrorCannotConverContext on any other type; // - is a no-op (returns true, nil) when none of the credential's declared -// types opts in via config.CredentialStatus.Enabled == true; +// types has config.CredentialStatus.IsEnabled() == true; // - extracts the `credentialStatus` field from the credential's raw JSON // and parses it with common.ParseStatusListEntries; // - returns ErrorStatusMissing when the credential must carry a status @@ -111,6 +107,7 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com matchingConfigs := collectMatchingStatusConfigs(verifiableCredential.Contents().Types, statusContext.PerType) if len(matchingConfigs) == 0 { + logging.Log().Debugf("No matching config for %s", verifiableCredential.Contents().Types) // Feature off for every declared type → nothing to check. return true, nil } @@ -126,7 +123,14 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } + // When no W3C credentialStatus entries are found, try the IETF Token + // Status List format which stores the reference in + // credentialSubject.status.status_list. if len(entries) == 0 { + ietfEntry := s.extractIETFStatusEntry(verifiableCredential) + if ietfEntry != nil { + return s.validateIETFStatus(verifiableCredential, ietfEntry) + } if requireStatus { logging.Log().Warnf("Credential %s has no credentialStatus but RequireStatus is true", verifiableCredential.Contents().ID) return false, ErrorStatusMissing @@ -173,7 +177,53 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com return false, ErrorCredentialRevoked } } + logging.Log().Info("Successfully checked status.") + + return true, nil +} + +// extractIETFStatusEntry looks for an IETF Token Status List reference +// inside the credential's subject(s). It returns the first matching +// entry or nil when no IETF status information is present. +func (s *CredentialStatusValidationService) extractIETFStatusEntry(cred *common.Credential) *common.IETFStatusEntry { + for _, subject := range cred.Contents().Subject { + if entry, ok := common.ParseIETFStatusEntry(subject.CustomFields); ok { + return &entry + } + } + return nil +} + +// validateIETFStatus fetches the IETF Token Status List JWT referenced by +// the entry and checks whether the bit at the entry's index is set. +func (s *CredentialStatusValidationService) validateIETFStatus(cred *common.Credential, entry *common.IETFStatusEntry) (bool, error) { + logging.Log().Debugf("Checking IETF Token Status List at %s, index %d", entry.URI, entry.Idx) + + if s.ietfClient == nil { + logging.Log().Warn("IETF status entry found but no IETF client configured") + return false, fmt.Errorf("%w: IETF status list client not configured", ErrorStatusListHttpFailure) + } + + statusList, err := s.ietfClient.FetchIETF(entry.URI) + if err != nil { + return false, err + } + + bitstring, err := common.DecodeIETFBitstring(statusList.Lst) + if err != nil { + return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) + } + + set, err := common.IsIETFStatusSet(bitstring, entry.Idx, statusList.Bits) + if err != nil { + return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) + } + if set { + logging.Log().Infof("Credential %s is revoked (IETF status list, index=%d)", cred.Contents().ID, entry.Idx) + return false, ErrorCredentialRevoked + } + logging.Log().Info("Successfully checked IETF status.") return true, nil } @@ -188,7 +238,7 @@ func collectMatchingStatusConfigs(credentialTypes []string, perType map[string]c if !found { continue } - if !cfg.Enabled { + if !cfg.IsEnabled() { continue } matching = append(matching, cfg) diff --git a/verifier/credential_status_client.go b/verifier/credential_status_client.go index 0edda2e3..43c3c9a0 100644 --- a/verifier/credential_status_client.go +++ b/verifier/credential_status_client.go @@ -1,15 +1,19 @@ package verifier // credential_status_client.go implements the cached HTTP client responsible -// for fetching W3C Bitstring Status List / StatusList2021 credentials -// referenced from a Verifiable Credential's `credentialStatus` entry. +// for fetching status-list resources referenced from a Verifiable +// Credential's status entry. It supports both: +// - W3C Bitstring Status List / StatusList2021 credentials +// - IETF OAuth 2.0 Token Status List JWTs (draft-ietf-oauth-status-list) // -// The client keeps parsed status-list credentials in an in-memory cache so -// the verifier does not re-fetch the same list on every presentation. TTL -// and HTTP timeout are parametrised through config.Verifier +// The client keeps parsed results in an in-memory cache so the verifier +// does not re-fetch the same list on every presentation. TTL and HTTP +// timeout are parametrised through config.Verifier // (StatusListCacheExpiry / StatusListHttpTimeout) — see config/config.go. import ( + "compress/gzip" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -183,3 +187,163 @@ func parseStatusListCredentialBody(body []byte) (*common.Credential, error) { // interface. This protects callers who type against StatusListCredentialClient // from accidental signature drift. var _ StatusListCredentialClient = (*CachingStatusListClient)(nil) + +// --------------------------------------------------------------------------- +// IETF Token Status List client +// --------------------------------------------------------------------------- + +// IETFStatusListClient fetches IETF OAuth 2.0 Token Status List JWTs from +// the URI declared in a credential's `status.status_list.uri` field. +// +// The response is a JWT with Content-Type `application/statuslist+jwt`, +// optionally gzip-compressed (Content-Encoding: gzip). The JWT payload +// contains `status_list.bits` and `status_list.lst` — the latter being a +// base64url-encoded, zlib-compressed bitstring. +type IETFStatusListClient interface { + // FetchIETF fetches and returns the parsed IETF status list from the + // given URI. Implementations may cache results internally. + FetchIETF(uri string) (*common.IETFStatusList, error) +} + +// CachingIETFStatusListClient is the default IETFStatusListClient +// implementation with in-memory caching. +type CachingIETFStatusListClient struct { + httpClient *http.Client + cache common.Cache + expiry time.Duration +} + +// NewCachingIETFStatusListClient constructs a CachingIETFStatusListClient. +func NewCachingIETFStatusListClient(timeout time.Duration, cacheExpiry time.Duration) *CachingIETFStatusListClient { + return &CachingIETFStatusListClient{ + httpClient: &http.Client{Timeout: timeout}, + cache: cache.New(cacheExpiry, StatusListCacheCleanupMultiplier*cacheExpiry), + expiry: cacheExpiry, + } +} + +// FetchIETF retrieves the IETF Token Status List JWT from the given URI. +// The JWT is decoded (signature verification is intentionally deferred) +// and the `status_list` payload is extracted and cached. +func (c *CachingIETFStatusListClient) FetchIETF(uri string) (*common.IETFStatusList, error) { + if cached, hit := c.cache.Get(uri); hit { + logging.Log().Debugf("IETF status-list cache hit for %s", uri) + return cached.(*common.IETFStatusList), nil + } + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) + } + req.Header.Set("Accept", common.ContentTypeStatusListJWT) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < statusListHTTPOKMin || resp.StatusCode >= statusListHTTPOKMaxExclusive { + return nil, fmt.Errorf("%w: unexpected status %d from %s", ErrorStatusListHttpFailure, resp.StatusCode, uri) + } + + var bodyReader io.Reader = resp.Body + if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { + gzReader, gzErr := gzip.NewReader(resp.Body) + if gzErr != nil { + return nil, fmt.Errorf("%w: gzip decode failed: %v", ErrorStatusListUnparseable, gzErr) + } + defer func() { _ = gzReader.Close() }() + bodyReader = gzReader + } + + body, err := io.ReadAll(bodyReader) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) + } + + statusList, err := parseIETFStatusListJWT(string(body)) + if err != nil { + return nil, err + } + + c.cache.Set(uri, statusList, c.expiry) + logging.Log().Debugf("Cached IETF status-list for %s", uri) + return statusList, nil +} + +// parseIETFStatusListJWT extracts the `status_list` payload from an IETF +// Token Status List JWT without verifying the signature (status lists are +// public resources). The JWT payload is the second dot-separated segment. +func parseIETFStatusListJWT(jwtString string) (*common.IETFStatusList, error) { + parts := strings.Split(strings.TrimSpace(jwtString), ".") + if len(parts) != 3 { + return nil, fmt.Errorf("%w: expected 3 JWT parts, got %d", ErrorStatusListUnparseable, len(parts)) + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + padded, padErr := base64.URLEncoding.DecodeString(parts[1]) + if padErr != nil { + return nil, fmt.Errorf("%w: JWT payload base64 decode failed: %v", ErrorStatusListUnparseable, err) + } + payload = padded + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("%w: JWT payload JSON unmarshal failed: %v", ErrorStatusListUnparseable, err) + } + + statusListRaw, ok := claims[common.IETFStatusListKey] + if !ok || statusListRaw == nil { + // Fall back to top-level keys (some implementations put bits/lst at the top level). + statusListRaw = claims + } + + statusListMap, ok := statusListRaw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%w: status_list is not a JSON object", ErrorStatusListUnparseable) + } + + lst, ok := statusListMap[common.IETFStatusListLst].(string) + if !ok || lst == "" { + return nil, fmt.Errorf("%w: status_list.lst is missing or empty", ErrorStatusListUnparseable) + } + + bits, err := parseIETFBits(statusListMap[common.IETFStatusListBits]) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) + } + + return &common.IETFStatusList{Bits: bits, Lst: lst}, nil +} + +// parseIETFBits converts the `bits` value from a status list JWT payload +// to an int. JSON numbers arrive as float64. +func parseIETFBits(raw interface{}) (int, error) { + switch v := raw.(type) { + case float64: + if v <= 0 { + return 0, fmt.Errorf("bits must be positive, got %v", v) + } + return int(v), nil + case int: + if v <= 0 { + return 0, fmt.Errorf("bits must be positive, got %d", v) + } + return v, nil + case int64: + if v <= 0 { + return 0, fmt.Errorf("bits must be positive, got %d", v) + } + return int(v), nil + case nil: + return common.DefaultStatusSizeBits, nil + default: + return 0, fmt.Errorf("bits has unexpected type %T", raw) + } +} + +// Compile-time assertion. +var _ IETFStatusListClient = (*CachingIETFStatusListClient)(nil) diff --git a/verifier/credential_status_test.go b/verifier/credential_status_test.go index bf8ae342..26cbc9dc 100644 --- a/verifier/credential_status_test.go +++ b/verifier/credential_status_test.go @@ -9,6 +9,7 @@ package verifier import ( "bytes" "compress/gzip" + "compress/zlib" "encoding/base64" "errors" "fmt" @@ -18,6 +19,8 @@ import ( configModel "github.com/fiware/VCVerifier/config" ) +func boolPtr(v bool) *bool { return &v } + // Named constants used throughout the test file. Pulling them out of the // table rows keeps the intent of each row obvious and removes magic values. // The "statusValidation" prefix avoids collisions with the already-declared @@ -181,7 +184,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Type not present in PerType is a no-op", credential: newCredentialWithStatus(t, statusValidationUnconfiguredType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, expectedResult: true, expectedError: nil, expectedNoFetch: true, @@ -189,7 +192,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Type present but Enabled=false is a no-op", credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: false}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(false)}}, expectedResult: true, expectedError: nil, expectedNoFetch: true, @@ -197,7 +200,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Enabled, no credentialStatus, RequireStatus=false -> valid", credential: newCredentialWithStatus(t, statusValidationTestType, nil), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, expectedResult: true, expectedError: nil, expectedNoFetch: true, @@ -205,7 +208,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Enabled, no credentialStatus, RequireStatus=true -> ErrorStatusMissing", credential: newCredentialWithStatus(t, statusValidationTestType, nil), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true, RequireStatus: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true), RequireStatus: true}}, expectedResult: false, expectedError: ErrorStatusMissing, expectedNoFetch: true, @@ -213,7 +216,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Revoked bit set -> ErrorCredentialRevoked", credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, fixtures: map[string]*common.Credential{statusValidationTestURL: revokedList}, expectedResult: false, expectedError: ErrorCredentialRevoked, @@ -221,7 +224,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Revoked bit clear -> valid", credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, fixtures: map[string]*common.Credential{statusValidationTestURL: clearList}, expectedResult: true, expectedError: nil, @@ -230,7 +233,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { testName: "Status purpose not in AcceptedPurposes is skipped", credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeSuspension, statusValidationTestIndex)), perType: map[string]configModel.CredentialStatus{ - statusValidationTestType: {Enabled: true, AcceptedPurposes: []string{configModel.StatusPurposeRevocation}}, + statusValidationTestType: {Enabled: boolPtr(true), AcceptedPurposes: []string{configModel.StatusPurposeRevocation}}, }, expectedResult: true, expectedError: nil, @@ -239,7 +242,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Fetch failure is propagated", credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, fetchErr: ErrorStatusListHttpFailure, expectedResult: false, expectedError: ErrorStatusListHttpFailure, @@ -247,7 +250,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { { testName: "Malformed bitstring -> ErrorStatusListUnparseable", credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), - perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: true}}, + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, fixtures: map[string]*common.Credential{statusValidationTestURL: malformedList}, expectedResult: false, expectedError: ErrorStatusListUnparseable, @@ -260,7 +263,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { }), perType: map[string]configModel.CredentialStatus{ statusValidationTestType: { - Enabled: true, + Enabled: boolPtr(true), AcceptedPurposes: []string{configModel.StatusPurposeRevocation, configModel.StatusPurposeSuspension}, }, }, @@ -276,7 +279,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { for _, tc := range tests { t.Run(tc.testName, func(t *testing.T) { mock := &mockStatusListClient{credentials: tc.fixtures, err: tc.fetchErr} - service := NewCredentialStatusValidationService(mock, nil) + service := NewCredentialStatusValidationService(mock, nil, nil) result, err := service.ValidateVC(tc.credential, CredentialStatusValidationContext{PerType: tc.perType}) @@ -304,7 +307,7 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { // ValidateVC(cred, ValidationContext) signature shared across services. func TestCredentialStatusValidationService_ContextMismatch(t *testing.T) { mock := &mockStatusListClient{} - service := NewCredentialStatusValidationService(mock, nil) + service := NewCredentialStatusValidationService(mock, nil, nil) cred := newCredentialWithStatus(t, statusValidationTestType, nil) @@ -321,3 +324,132 @@ func TestCredentialStatusValidationService_ContextMismatch(t *testing.T) { t.Errorf("expected no fetches on context mismatch, got %d", len(mock.calls)) } } + +// --------------------------------------------------------------------------- +// IETF Token Status List tests +// --------------------------------------------------------------------------- + +// mockIETFStatusListClient is a test double for IETFStatusListClient. +type mockIETFStatusListClient struct { + statusList *common.IETFStatusList + err error + calls []string +} + +func (m *mockIETFStatusListClient) FetchIETF(uri string) (*common.IETFStatusList, error) { + m.calls = append(m.calls, uri) + return m.statusList, m.err +} + +// encodeIETFTestBitstring returns base64url(zlib(bits)), the encoding the +// IETF Token Status List spec requires on the `lst` field. +func encodeIETFTestBitstring(t *testing.T, bits []byte) string { + t.Helper() + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + if _, err := w.Write(bits); err != nil { + t.Fatalf("zlib write failed: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("zlib close failed: %v", err) + } + return base64.RawURLEncoding.EncodeToString(buf.Bytes()) +} + +// newCredentialWithIETFStatus builds a credential whose credentialSubject +// contains the IETF-format status reference. +func newCredentialWithIETFStatus(t *testing.T, credentialType string, idx uint64, uri string) *common.Credential { + t.Helper() + subject := common.Subject{ + CustomFields: common.CustomFields{ + common.IETFStatusClaimKey: map[string]interface{}{ + common.IETFStatusListKey: map[string]interface{}{ + common.IETFStatusListIdx: float64(idx), + common.IETFStatusListURI: uri, + }, + }, + }, + } + cred, err := common.CreateCredential(common.CredentialContents{ + Types: []string{credentialType}, + Subject: []common.Subject{subject}, + }, common.CustomFields{}) + if err != nil { + t.Fatalf("CreateCredential failed: %v", err) + } + return cred +} + +func TestCredentialStatusValidationService_IETF(t *testing.T) { + const testURI = "https://example.org/statuslists/test-list" + + type test struct { + testName string + credential *common.Credential + perType map[string]configModel.CredentialStatus + ietfList *common.IETFStatusList + ietfErr error + expectedResult bool + expectedError error + } + + // A list where index 0 is clear (valid). + clearBits := encodeIETFTestBitstring(t, []byte{0b00000000}) + // A list where index 1 is set (invalid), with 1 bit per status, LSB-first. + revokedBits := encodeIETFTestBitstring(t, []byte{0b00000010}) + + tests := []test{ + { + testName: "IETF: clear bit -> valid", + credential: newCredentialWithIETFStatus(t, statusValidationTestType, 0, testURI), + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, + ietfList: &common.IETFStatusList{Bits: 1, Lst: clearBits}, + expectedResult: true, + expectedError: nil, + }, + { + testName: "IETF: set bit -> ErrorCredentialRevoked", + credential: newCredentialWithIETFStatus(t, statusValidationTestType, 1, testURI), + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, + ietfList: &common.IETFStatusList{Bits: 1, Lst: revokedBits}, + expectedResult: false, + expectedError: ErrorCredentialRevoked, + }, + { + testName: "IETF: fetch error is propagated", + credential: newCredentialWithIETFStatus(t, statusValidationTestType, 0, testURI), + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, + ietfErr: ErrorStatusListHttpFailure, + expectedResult: false, + expectedError: ErrorStatusListHttpFailure, + }, + { + testName: "IETF: disabled config is a no-op", + credential: newCredentialWithIETFStatus(t, statusValidationTestType, 0, testURI), + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(false)}}, + expectedResult: true, + expectedError: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + w3cMock := &mockStatusListClient{} + ietfMock := &mockIETFStatusListClient{statusList: tc.ietfList, err: tc.ietfErr} + service := NewCredentialStatusValidationService(w3cMock, ietfMock, nil) + + result, err := service.ValidateVC(tc.credential, CredentialStatusValidationContext{PerType: tc.perType}) + + if result != tc.expectedResult { + t.Errorf("expected result %v, got %v", tc.expectedResult, result) + } + if tc.expectedError == nil { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + } else if !errors.Is(err, tc.expectedError) { + t.Errorf("expected error %v, got %v", tc.expectedError, err) + } + }) + } +} diff --git a/verifier/credentialsConfig.go b/verifier/credentialsConfig.go index 6c9eda72..44d10f8a 100644 --- a/verifier/credentialsConfig.go +++ b/verifier/credentialsConfig.go @@ -359,7 +359,7 @@ func (cc cacheBasedCredentialsConfig) GetCredentialStatusConfig(serviceIdentifie if hit { credential, ok := cacheEntry.(config.ConfiguredService).GetCredential(scope, credentialType) if ok { - logging.Log().Debugf("Found credential status config for %s - %v", credentialType, credential.CredentialStatus.Enabled) + logging.Log().Debugf("Found credential status config for %s - %v", credentialType, credential.CredentialStatus.IsEnabled()) return credential.CredentialStatus, nil } } diff --git a/verifier/credentialsConfig_test.go b/verifier/credentialsConfig_test.go index 59124ff0..bd3d2c0d 100644 --- a/verifier/credentialsConfig_test.go +++ b/verifier/credentialsConfig_test.go @@ -50,7 +50,7 @@ func TestServiceBackedCredentialsConfig_GetCredentialStatusConfig(t *testing.T) logging.Configure(LOGGING_CONFIG) enabledStatus := config.CredentialStatus{ - Enabled: true, + Enabled: boolPtr(true), AcceptedPurposes: []string{config.StatusPurposeRevocation, config.StatusPurposeSuspension}, RequireStatus: true, } @@ -134,11 +134,11 @@ func TestServiceBackedCredentialsConfig_GetCredentialStatusConfig(t *testing.T) } } -// TestServiceBackedCredentialsConfig_GetCredentialStatusConfig_DefaultsAreOff -// pins the critical "feature is off unless explicitly enabled" contract: a -// credential with the default (zero) `CredentialStatus` must report Enabled == -// false so that the rest of the validation chain skips the revocation check. -func TestServiceBackedCredentialsConfig_GetCredentialStatusConfig_DefaultsAreOff(t *testing.T) { +// TestServiceBackedCredentialsConfig_GetCredentialStatusConfig_DefaultsAreOn +// pins the contract: a credential with the default (zero) `CredentialStatus` +// must report IsEnabled() == true so that the revocation-list check is active +// by default. +func TestServiceBackedCredentialsConfig_GetCredentialStatusConfig_DefaultsAreOn(t *testing.T) { logging.Configure(LOGGING_CONFIG) seedServiceCache(t, newServiceWithCredentials(config.Credential{Type: testStatusCredentialType})) @@ -147,7 +147,7 @@ func TestServiceBackedCredentialsConfig_GetCredentialStatusConfig_DefaultsAreOff got, err := cc.GetCredentialStatusConfig(testStatusServiceID, testStatusScope, testStatusCredentialType) assert.NoError(t, err) - assert.False(t, got.Enabled, "zero-value CredentialStatus must leave the revocation-list check disabled") + assert.True(t, got.IsEnabled(), "zero-value CredentialStatus must leave the revocation-list check enabled") assert.Empty(t, got.AcceptedPurposes, "zero-value CredentialStatus must have no accepted purposes") assert.False(t, got.RequireStatus, "zero-value CredentialStatus must not require a status entry") } diff --git a/verifier/verifier.go b/verifier/verifier.go index 734f49eb..a82ebdee 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -355,13 +355,14 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi // Construct the shared status-list credential client and the // CredentialStatusValidationService. The service is always appended to - // the validation chain. When no credential has - // CredentialStatus.Enabled == true, the service's ValidateVC is a no-op - // so there is no performance impact for deployments that do not opt in. + // the validation chain. When a credential has + // CredentialStatus.IsEnabled() == false, the service's ValidateVC is a + // no-op for that type. statusListHttpTimeout := time.Duration(verifierConfig.StatusListHttpTimeout) * time.Second statusListCacheExpiry := time.Duration(verifierConfig.StatusListCacheExpiry) * time.Second statusListClient := NewCachingStatusListClient(statusListHttpTimeout, statusListCacheExpiry) - credentialStatusVerificationService := NewCredentialStatusValidationService(statusListClient, clock) + ietfStatusListClient := NewCachingIETFStatusListClient(statusListHttpTimeout, statusListCacheExpiry) + credentialStatusVerificationService := NewCredentialStatusValidationService(statusListClient, ietfStatusListClient, clock) key, err := initPrivateKey(verifierConfig.KeyAlgorithm, verifierConfig.GenerateKey, verifierConfig.KeyPath) diff --git a/verifier/verifier_test.go b/verifier/verifier_test.go index b481f72b..bf080f9d 100644 --- a/verifier/verifier_test.go +++ b/verifier/verifier_test.go @@ -1656,7 +1656,7 @@ func TestInitVerifier_CredentialStatusWiring(t *testing.T) { tests := []test{ { - testName: "Case A: no credential opts in so the status service is appended and its PerType entry is disabled.", + testName: "Case A: credential without explicit CredentialStatus defaults to enabled.", services: []configModel.ConfiguredService{ { Id: testStatusWiringServiceID, @@ -1669,10 +1669,10 @@ func TestInitVerifier_CredentialStatusWiring(t *testing.T) { }, }, }, - expectedPerTypeEnabled: false, + expectedPerTypeEnabled: true, }, { - testName: "Case B: at least one credential has CredentialStatus.Enabled=true so PerType reflects the opt-in.", + testName: "Case B: credential with explicit CredentialStatus.Enabled=true.", services: []configModel.ConfiguredService{ { Id: testStatusWiringServiceID, @@ -1681,7 +1681,7 @@ func TestInitVerifier_CredentialStatusWiring(t *testing.T) { Credentials: []configModel.Credential{ { Type: testStatusWiringCredentialType, - CredentialStatus: configModel.CredentialStatus{Enabled: true}, + CredentialStatus: configModel.CredentialStatus{Enabled: boolPtr(true)}, }, }, }, @@ -1739,7 +1739,7 @@ func TestInitVerifier_CredentialStatusWiring(t *testing.T) { assert.NoError(t, err, "%s - getCredentialStatusValidationContext returned an unexpected error", tc.testName) perTypeEntry, present := ctx.PerType[testStatusWiringCredentialType] assert.True(t, present, "%s - PerType must contain the configured credential type", tc.testName) - assert.Equal(t, tc.expectedPerTypeEnabled, perTypeEntry.Enabled, "%s - PerType Enabled flag mismatch", tc.testName) + assert.Equal(t, tc.expectedPerTypeEnabled, perTypeEntry.IsEnabled(), "%s - PerType Enabled flag mismatch", tc.testName) // No matter whether any credential opted in, validating with an // empty PerType context must be a no-op — this covers Case A's From 0e1e81d8ec5eaef21c9c3bf9ed83c1000233d906 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Fri, 26 Jun 2026 09:46:24 +0200 Subject: [PATCH 2/6] add IETF Status List support From e210c1692767f6bd4a59145df66d4a5e6665c151 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Tue, 30 Jun 2026 11:50:15 +0200 Subject: [PATCH 3/6] review fixes --- common/credential_status.go | 4 +- common/credential_status_test.go | 9 + verifier/credential_status.go | 33 ++ verifier/credential_status_client.go | 466 +++++++++++++-- verifier/credential_status_client_test.go | 11 +- verifier/credential_status_test.go | 677 +++++++++++++++++++++- verifier/verifier.go | 5 +- 7 files changed, 1158 insertions(+), 47 deletions(-) diff --git a/common/credential_status.go b/common/credential_status.go index ada12f06..76485039 100644 --- a/common/credential_status.go +++ b/common/credential_status.go @@ -7,7 +7,7 @@ // References: // - https://www.w3.org/TR/vc-bitstring-status-list/ // - https://www.w3.org/TR/2023/WD-vc-status-list-20230427/ (StatusList2021) -// - https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-11.html +// - https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-21.html package common import ( @@ -229,6 +229,8 @@ func parseStatusListEntry(obj map[string]interface{}) (StatusListEntry, error) { return StatusListEntry{}, err } entry.StatusListIndex = parsed + } else { + return StatusListEntry{}, fmt.Errorf("%w: %q is required", ErrorStatusListEntryMalformed, StatusListEntryKeyStatusListIndex) } if sz, ok := obj[StatusListEntryKeyStatusSize]; ok && sz != nil { parsed, err := parseStatusSize(sz) diff --git a/common/credential_status_test.go b/common/credential_status_test.go index 92ae6134..c0745598 100644 --- a/common/credential_status_test.go +++ b/common/credential_status_test.go @@ -144,6 +144,15 @@ func TestParseStatusListEntries(t *testing.T) { }, wantErr: ErrorStatusListEntryMalformed, }, + { + name: "missing statusListIndex", + input: map[string]interface{}{ + StatusListEntryKeyType: TypeBitstringStatusListEntry, + StatusListEntryKeyStatusPurpose: "revocation", + StatusListEntryKeyStatusListCredential: "https://example.org/status/1", + }, + wantErr: ErrorStatusListEntryMalformed, + }, { name: "invalid statusSize", input: map[string]interface{}{ diff --git a/verifier/credential_status.go b/verifier/credential_status.go index dd6325a5..50d69b22 100644 --- a/verifier/credential_status.go +++ b/verifier/credential_status.go @@ -120,6 +120,7 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com entries, err := common.ParseStatusListEntries(rawStatus) if err != nil { + logging.Log().Debugf("Failed to parse credentialStatus entries: %v", err) return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } @@ -127,6 +128,7 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com // Status List format which stores the reference in // credentialSubject.status.status_list. if len(entries) == 0 { + logging.Log().Debugf("No W3C credentialStatus entries found, checking for IETF status entry") ietfEntry := s.extractIETFStatusEntry(verifiableCredential) if ietfEntry != nil { return s.validateIETFStatus(verifiableCredential, ietfEntry) @@ -135,6 +137,7 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com logging.Log().Warnf("Credential %s has no credentialStatus but RequireStatus is true", verifiableCredential.Contents().ID) return false, ErrorStatusMissing } + logging.Log().Debugf("No status entries found and RequireStatus is false, skipping status check") return true, nil } @@ -148,13 +151,16 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com continue } + logging.Log().Debugf("Fetching status-list credential from %s (purpose=%s, index=%d)", entry.StatusListCredential, entry.StatusPurpose, entry.StatusListIndex) statusCred, fetchErr := s.client.Fetch(entry.StatusListCredential) if fetchErr != nil { + logging.Log().Debugf("Failed to fetch status-list credential from %s: %v", entry.StatusListCredential, fetchErr) return false, fetchErr } encodedList, purpose, extractErr := extractStatusListFields(statusCred) if extractErr != nil { + logging.Log().Debugf("Failed to extract status-list fields from credential at %s: %v", entry.StatusListCredential, extractErr) return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, extractErr) } @@ -165,11 +171,13 @@ func (s *CredentialStatusValidationService) ValidateVC(verifiableCredential *com bitstring, decodeErr := common.DecodeBitstring(encodedList) if decodeErr != nil { + logging.Log().Debugf("Failed to decode bitstring from %s: %v", entry.StatusListCredential, decodeErr) return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, decodeErr) } set, idxErr := common.IsStatusSet(bitstring, entry.StatusListIndex, entry.StatusSize) if idxErr != nil { + logging.Log().Debugf("Failed to check status bit at index %d in %s: %v", entry.StatusListIndex, entry.StatusListCredential, idxErr) return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, idxErr) } if set { @@ -206,16 +214,19 @@ func (s *CredentialStatusValidationService) validateIETFStatus(cred *common.Cred statusList, err := s.ietfClient.FetchIETF(entry.URI) if err != nil { + logging.Log().Debugf("Failed to fetch IETF status list from %s: %v", entry.URI, err) return false, err } bitstring, err := common.DecodeIETFBitstring(statusList.Lst) if err != nil { + logging.Log().Debugf("Failed to decode IETF bitstring from %s: %v", entry.URI, err) return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } set, err := common.IsIETFStatusSet(bitstring, entry.Idx, statusList.Bits) if err != nil { + logging.Log().Debugf("Failed to check IETF status bit at index %d from %s: %v", entry.Idx, entry.URI, err) return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } if set { @@ -314,12 +325,23 @@ func isPurposeAccepted(purpose string, acceptedPurposes []string) bool { // credential subject can be either a single object or an array; both shapes // are supported here so issuers can follow either VC Data Model flavour. // +// The credential must declare BitstringStatusListCredential or +// StatusList2021Credential as one of its types; otherwise the function +// returns an error to prevent accepting arbitrary credentials that happen +// to contain an `encodedList` field. +// // A non-nil error is returned when the credential does not expose an // `encodedList` string on at least one subject. func extractStatusListFields(statusCred *common.Credential) (encodedList string, statusPurpose string, err error) { if statusCred == nil { return "", "", fmt.Errorf("status-list credential is nil") } + + if !isStatusListCredentialType(statusCred.Contents().Types) { + return "", "", fmt.Errorf("%w: credential types %v do not include a supported status list type", + ErrorStatusListUnparseable, statusCred.Contents().Types) + } + raw := statusCred.ToRawJSON() subjectRaw, ok := raw[common.VCKeyCredentialSubject] if !ok || subjectRaw == nil { @@ -346,6 +368,17 @@ func extractStatusListFields(statusCred *common.Credential) (encodedList string, } } +// isStatusListCredentialType returns true when the credential's type list +// includes BitstringStatusListCredential or StatusList2021Credential. +func isStatusListCredentialType(types []string) bool { + for _, t := range types { + if t == common.TypeBitstringStatusListCredential || t == common.TypeStatusList2021Credential { + return true + } + } + return false +} + // readEncodedListFromSubject extracts the encoded bitstring and optional // purpose from a single credentialSubject object. The `statusPurpose` is // returned as an empty string when absent so callers can treat that as diff --git a/verifier/credential_status_client.go b/verifier/credential_status_client.go index 43c3c9a0..d59e43e2 100644 --- a/verifier/credential_status_client.go +++ b/verifier/credential_status_client.go @@ -12,7 +12,9 @@ package verifier // (StatusListCacheExpiry / StatusListHttpTimeout) — see config/config.go. import ( + "bytes" "compress/gzip" + "crypto/x509" "encoding/base64" "encoding/json" "errors" @@ -23,7 +25,11 @@ import ( "time" "github.com/fiware/VCVerifier/common" + "github.com/fiware/VCVerifier/did" "github.com/fiware/VCVerifier/logging" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" "github.com/patrickmn/go-cache" ) @@ -31,11 +37,20 @@ import ( // one place so reviewers can audit the Accept header and the cache cleanup // cadence without hunting through the implementation. const ( - // ContentTypeCredentialJson is the default Accept header sent when - // fetching a status-list credential. It follows the W3C VC Data Model 2.0 - // recommendation for JSON-LD encoded Verifiable Credentials. + // ContentTypeCredentialJson is the MIME type for JSON-LD encoded + // Verifiable Credentials, per the W3C VC Data Model 2.0. ContentTypeCredentialJson = "application/vc+ld+json" + // ContentTypeCredentialJWT is the MIME type for JWT-encoded Verifiable + // Credentials, per the W3C VC Data Model 2.0. + ContentTypeCredentialJWT = "application/vc+jwt" + + // AcceptHeaderStatusListCredential is the Accept header value sent when + // fetching a W3C status-list credential. It advertises both JSON-LD and + // JWT formats so issuers with strict content negotiation can respond + // with either representation. + AcceptHeaderStatusListCredential = ContentTypeCredentialJson + ", " + ContentTypeCredentialJWT + // StatusListCacheCleanupMultiplier scales the configured cache expiry to // obtain the go-cache janitor cleanup interval. A value of 2 matches the // 2×expiry pattern used by the existing caches in common/cache.go and @@ -62,6 +77,34 @@ var ( // is not a recognisable Verifiable Credential (neither a JSON-LD object // nor a decodable JWT). ErrorStatusListUnparseable = errors.New("status_list_unparseable") + // ErrorStatusListSubjectMismatch is returned when the `sub` claim of a + // fetched IETF Token Status List JWT does not match the `uri` from the + // credential's status reference, per draft-ietf-oauth-status-list §8.3 + // step 4a. + ErrorStatusListSubjectMismatch = errors.New("status_list_subject_mismatch") + // ErrorStatusListExpired is returned when the `exp` claim of a fetched + // IETF Token Status List JWT is in the past, per + // draft-ietf-oauth-status-list §8.3 step 4c. + ErrorStatusListExpired = errors.New("status_list_expired") + // ErrorStatusListInvalidTyp is returned when the JWT `typ` header is + // not `statuslist+jwt`, per draft-ietf-oauth-status-list §5.1. + ErrorStatusListInvalidTyp = errors.New("status_list_invalid_typ") +) + +const ( + // jwtClaimSub is the standard JWT `sub` (subject) claim key. + jwtClaimSub = "sub" + // jwtClaimExp is the standard JWT `exp` (expiration time) claim key. + jwtClaimExp = "exp" + // jwtClaimTTL is the IETF Token Status List `ttl` claim key, specifying + // the maximum time in seconds the status list should be cached before + // re-fetching, per draft-ietf-oauth-status-list §5.1. + jwtClaimTTL = "ttl" + // jwtHeaderTyp is the JWT header key for the token type. + jwtHeaderTyp = "typ" + // statusListJWTTyp is the required value of the `typ` JWT header for + // IETF Token Status List JWTs, per draft-ietf-oauth-status-list §5.1. + statusListJWTTyp = "statuslist+jwt" ) // StatusListCredentialClient fetches and returns W3C Bitstring / StatusList2021 @@ -113,29 +156,36 @@ func (c *CachingStatusListClient) Fetch(url string) (*common.Credential, error) return cached.(*common.Credential), nil } + logging.Log().Debugf("Fetching W3C status-list credential from %s", url) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { + logging.Log().Debugf("Failed to create HTTP request for %s: %v", url, err) return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) } - req.Header.Set("Accept", ContentTypeCredentialJson) + req.Header.Set("Accept", AcceptHeaderStatusListCredential) resp, err := c.httpClient.Do(req) if err != nil { + logging.Log().Debugf("HTTP request to %s failed: %v", url, err) return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < statusListHTTPOKMin || resp.StatusCode >= statusListHTTPOKMaxExclusive { + logging.Log().Debugf("Unexpected HTTP status %d from %s", resp.StatusCode, url) return nil, fmt.Errorf("%w: unexpected status %d", ErrorStatusListHttpFailure, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { + logging.Log().Debugf("Failed to read response body from %s: %v", url, err) return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) } + logging.Log().Debugf("Received %d bytes from %s, parsing credential", len(body), url) cred, err := parseStatusListCredentialBody(body) if err != nil { + logging.Log().Debugf("Failed to parse status-list credential from %s: %v", url, err) return nil, err } @@ -161,23 +211,29 @@ func (c *CachingStatusListClient) Fetch(url string) (*common.Credential, error) func parseStatusListCredentialBody(body []byte) (*common.Credential, error) { trimmed := strings.TrimSpace(string(body)) if len(trimmed) == 0 { + logging.Log().Debug("Status-list credential response body is empty") return nil, fmt.Errorf("%w: empty response body", ErrorStatusListUnparseable) } if trimmed[0] == '{' { + logging.Log().Debug("Parsing status-list credential as JSON-LD") var vcMap map[string]interface{} if err := json.Unmarshal([]byte(trimmed), &vcMap); err != nil { + logging.Log().Debugf("JSON-LD unmarshal failed: %v", err) return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } cred, err := parseJSONLDCredential(vcMap) if err != nil { + logging.Log().Debugf("JSON-LD credential parse failed: %v", err) return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } return cred, nil } + logging.Log().Debug("Parsing status-list credential as JWT") cred, err := parseUnsignedJWTCredential(trimmed) if err != nil { + logging.Log().Debugf("JWT credential parse failed: %v", err) return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } return cred, nil @@ -205,52 +261,214 @@ type IETFStatusListClient interface { FetchIETF(uri string) (*common.IETFStatusList, error) } +// StatusListJWTVerifier verifies the signature of an IETF Token Status List +// JWT and returns the verified payload bytes. Implementations may extract +// the verification key from the JWT header (e.g. x5c certificate chain) or +// resolve it externally (e.g. via DID resolution). +type StatusListJWTVerifier interface { + // VerifyStatusListJWT verifies the JWT signature and returns the payload. + VerifyStatusListJWT(jwtBytes []byte) (payload []byte, err error) +} + +// StatusListJWTVerifierImpl verifies IETF Token Status List JWTs using two +// strategies, tried in order: +// +// 1. If the JWT payload contains an `iss` claim that is a DID, the public +// key is resolved from the DID document via the configured DID registry. +// 2. Otherwise, if the JWT header carries an `x5c` certificate chain, the +// public key is extracted from the leaf certificate. +// +// This two-step approach covers both spec-compliant issuers (iss-based) and +// legacy/transitional deployments that only embed an x5c header. +type StatusListJWTVerifierImpl struct { + registry *did.Registry +} + +// NewStatusListJWTVerifier constructs a StatusListJWTVerifierImpl backed by +// the given DID registry. The registry must support the DID methods used by +// status list issuers (typically did:web and did:key). +func NewStatusListJWTVerifier(registry *did.Registry) *StatusListJWTVerifierImpl { + return &StatusListJWTVerifierImpl{registry: registry} +} + +// VerifyStatusListJWT parses the JWS and verifies the signature. It first +// attempts iss-based DID key resolution; when no iss claim is present it +// falls back to x5c certificate chain verification. +func (v *StatusListJWTVerifierImpl) VerifyStatusListJWT(jwtBytes []byte) ([]byte, error) { + logging.Log().Debug("Verifying status list JWT signature") + msg, err := jws.Parse(jwtBytes) + if err != nil { + logging.Log().Debugf("JWS parse failed: %v", err) + return nil, fmt.Errorf("%w: JWS parse failed: %v", ErrorStatusListUnparseable, err) + } + + sigs := msg.Signatures() + if len(sigs) == 0 { + logging.Log().Debug("Status list JWT has no signatures") + return nil, fmt.Errorf("%w: no signatures in status list JWT", ErrorStatusListUnparseable) + } + + headers := sigs[0].ProtectedHeaders() + alg, _ := headers.Algorithm() + + issuerDID := extractIssFromPayload(msg.Payload()) + if issuerDID != "" { + logging.Log().Debugf("Status list JWT has iss claim %s, verifying via DID resolution", issuerDID) + return v.verifyWithISS(jwtBytes, msg, alg, issuerDID) + } + + logging.Log().Debug("Status list JWT has no iss claim, falling back to x5c verification") + return v.verifyWithX5C(jwtBytes, headers, alg) +} + +// verifyWithISS resolves the public key from the iss DID and verifies the +// JWT signature. +func (v *StatusListJWTVerifierImpl) verifyWithISS(jwtBytes []byte, msg *jws.Message, alg jwa.SignatureAlgorithm, issuerDID string) ([]byte, error) { + kid, _ := msg.Signatures()[0].ProtectedHeaders().KeyID() + + key, err := v.resolveKeyFromDID(issuerDID, kid) + if err != nil { + return nil, fmt.Errorf("%w: failed to resolve key for %s: %v", ErrorStatusListUnparseable, issuerDID, err) + } + + payload, err := jws.Verify(jwtBytes, jws.WithKey(alg, key)) + if err != nil { + return nil, fmt.Errorf("%w: status list JWT signature verification failed for %s: %v", ErrorStatusListUnparseable, issuerDID, err) + } + + logging.Log().Debugf("Status list JWT signature verified via iss DID %s", issuerDID) + return payload, nil +} + +// verifyWithX5C extracts the public key from the x5c certificate chain in +// the JWT header and verifies the signature. +func (v *StatusListJWTVerifierImpl) verifyWithX5C(jwtBytes []byte, headers jws.Headers, alg jwa.SignatureAlgorithm) ([]byte, error) { + chain, hasX5C := headers.X509CertChain() + if !hasX5C || chain == nil || chain.Len() == 0 { + return nil, fmt.Errorf("%w: status list JWT has neither iss claim nor x5c header", ErrorStatusListUnparseable) + } + + leafB64, ok := chain.Get(0) + if !ok { + return nil, fmt.Errorf("%w: x5c chain is empty", ErrorStatusListUnparseable) + } + + leafDER, err := base64.StdEncoding.DecodeString(string(leafB64)) + if err != nil { + return nil, fmt.Errorf("%w: x5c leaf base64 decode failed: %v", ErrorStatusListUnparseable, err) + } + + cert, err := x509.ParseCertificate(leafDER) + if err != nil { + return nil, fmt.Errorf("%w: x5c leaf certificate parse failed: %v", ErrorStatusListUnparseable, err) + } + + pubKey, err := jwk.Import(cert.PublicKey) + if err != nil { + return nil, fmt.Errorf("%w: x5c public key import failed: %v", ErrorStatusListUnparseable, err) + } + + payload, err := jws.Verify(jwtBytes, jws.WithKey(alg, pubKey)) + if err != nil { + return nil, fmt.Errorf("%w: status list JWT x5c signature verification failed: %v", ErrorStatusListUnparseable, err) + } + + logging.Log().Debug("Status list JWT signature verified via x5c certificate chain") + return payload, nil +} + +// resolveKeyFromDID resolves the DID document and finds the verification +// method matching the given kid. When kid is empty, the first verification +// method with a JWK key is returned. +func (v *StatusListJWTVerifierImpl) resolveKeyFromDID(issuerDID, kid string) (interface{}, error) { + docRes, err := v.registry.Resolve(issuerDID) + if err != nil { + logging.Log().Warnf("Failed to resolve DID %s for status list verification: %v", issuerDID, err) + return nil, err + } + + for _, vm := range docRes.DIDDocument.VerificationMethod { + if kid != "" && !compareVerificationMethod(kid, vm.ID) { + logging.Log().Debugf("Skipping verification method %s (does not match kid %s)", vm.ID, kid) + continue + } + key := vm.JSONWebKey() + if key != nil { + logging.Log().Debugf("Resolved verification key from DID %s (method=%s)", issuerDID, vm.ID) + return key, nil + } + } + + logging.Log().Warnf("No matching verification method for status list issuer %s (kid=%s)", issuerDID, kid) + return nil, ErrorNoVerificationKey +} + +// Compile-time assertion. +var _ StatusListJWTVerifier = (*StatusListJWTVerifierImpl)(nil) + // CachingIETFStatusListClient is the default IETFStatusListClient -// implementation with in-memory caching. +// implementation with in-memory caching and JWT signature verification. type CachingIETFStatusListClient struct { - httpClient *http.Client - cache common.Cache - expiry time.Duration + httpClient *http.Client + cache common.Cache + expiry time.Duration + jwtVerifier StatusListJWTVerifier + clock common.Clock } // NewCachingIETFStatusListClient constructs a CachingIETFStatusListClient. -func NewCachingIETFStatusListClient(timeout time.Duration, cacheExpiry time.Duration) *CachingIETFStatusListClient { +// The jwtVerifier is used to verify the signature of fetched status list +// JWTs. When nil, JWT signatures are not verified (not recommended for +// production). The clock is used to check the `exp` claim; pass nil to use +// the real system clock. +func NewCachingIETFStatusListClient(timeout time.Duration, cacheExpiry time.Duration, jwtVerifier StatusListJWTVerifier, clock common.Clock) *CachingIETFStatusListClient { + if clock == nil { + clock = common.RealClock{} + } return &CachingIETFStatusListClient{ - httpClient: &http.Client{Timeout: timeout}, - cache: cache.New(cacheExpiry, StatusListCacheCleanupMultiplier*cacheExpiry), - expiry: cacheExpiry, + httpClient: &http.Client{Timeout: timeout}, + cache: cache.New(cacheExpiry, StatusListCacheCleanupMultiplier*cacheExpiry), + expiry: cacheExpiry, + jwtVerifier: jwtVerifier, + clock: clock, } } // FetchIETF retrieves the IETF Token Status List JWT from the given URI. -// The JWT is decoded (signature verification is intentionally deferred) -// and the `status_list` payload is extracted and cached. +// The JWT signature is verified using the configured StatusListJWTVerifier, +// then the `status_list` payload is extracted and cached. func (c *CachingIETFStatusListClient) FetchIETF(uri string) (*common.IETFStatusList, error) { if cached, hit := c.cache.Get(uri); hit { logging.Log().Debugf("IETF status-list cache hit for %s", uri) return cached.(*common.IETFStatusList), nil } + logging.Log().Debugf("Fetching IETF status list JWT from %s", uri) req, err := http.NewRequest(http.MethodGet, uri, nil) if err != nil { + logging.Log().Debugf("Failed to create HTTP request for %s: %v", uri, err) return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) } req.Header.Set("Accept", common.ContentTypeStatusListJWT) resp, err := c.httpClient.Do(req) if err != nil { + logging.Log().Debugf("HTTP request to %s failed: %v", uri, err) return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < statusListHTTPOKMin || resp.StatusCode >= statusListHTTPOKMaxExclusive { + logging.Log().Debugf("Unexpected HTTP status %d from %s", resp.StatusCode, uri) return nil, fmt.Errorf("%w: unexpected status %d from %s", ErrorStatusListHttpFailure, resp.StatusCode, uri) } var bodyReader io.Reader = resp.Body if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { + logging.Log().Debugf("Response from %s is gzip-encoded, decompressing", uri) gzReader, gzErr := gzip.NewReader(resp.Body) if gzErr != nil { + logging.Log().Debugf("Gzip decode failed for %s: %v", uri, gzErr) return nil, fmt.Errorf("%w: gzip decode failed: %v", ErrorStatusListUnparseable, gzErr) } defer func() { _ = gzReader.Close() }() @@ -259,90 +477,258 @@ func (c *CachingIETFStatusListClient) FetchIETF(uri string) (*common.IETFStatusL body, err := io.ReadAll(bodyReader) if err != nil { + logging.Log().Debugf("Failed to read response body from %s: %v", uri, err) return nil, fmt.Errorf("%w: %v", ErrorStatusListHttpFailure, err) } - statusList, err := parseIETFStatusListJWT(string(body)) + logging.Log().Debugf("Received %d bytes from %s, verifying JWT", len(body), uri) + payload, err := c.verifyAndExtractPayload(body) if err != nil { + logging.Log().Debugf("JWT verification failed for %s: %v", uri, err) return nil, err } - c.cache.Set(uri, statusList, c.expiry) - logging.Log().Debugf("Cached IETF status-list for %s", uri) - return statusList, nil + result, err := parseIETFStatusListPayload(payload, uri, c.clock) + if err != nil { + logging.Log().Debugf("Failed to parse IETF status list payload from %s: %v", uri, err) + return nil, err + } + + cacheExpiry := c.expiry + if result.ttl != nil && *result.ttl < cacheExpiry { + cacheExpiry = *result.ttl + logging.Log().Debugf("Using issuer ttl %v (shorter than configured %v) for %s", cacheExpiry, c.expiry, uri) + } + + c.cache.Set(uri, result.statusList, cacheExpiry) + logging.Log().Debugf("Cached IETF status-list for %s (expiry=%v)", uri, cacheExpiry) + return result.statusList, nil } -// parseIETFStatusListJWT extracts the `status_list` payload from an IETF -// Token Status List JWT without verifying the signature (status lists are -// public resources). The JWT payload is the second dot-separated segment. -func parseIETFStatusListJWT(jwtString string) (*common.IETFStatusList, error) { - parts := strings.Split(strings.TrimSpace(jwtString), ".") +// verifyAndExtractPayload validates the JWT `typ` header, verifies the +// signature (when a verifier is configured), and returns the payload bytes. +// Falls back to unverified base64 decoding when no verifier is set. +func (c *CachingIETFStatusListClient) verifyAndExtractPayload(jwtBytes []byte) ([]byte, error) { + if err := validateStatusListJWTTyp(jwtBytes); err != nil { + logging.Log().Debugf("Status list JWT typ validation failed: %v", err) + return nil, err + } + logging.Log().Debug("Status list JWT typ header is valid") + + if c.jwtVerifier != nil { + return c.jwtVerifier.VerifyStatusListJWT(jwtBytes) + } + + logging.Log().Warn("No JWT verifier configured for IETF status list — skipping signature verification") + return decodeJWTPayloadUnverified(jwtBytes) +} + +// validateStatusListJWTTyp decodes the JWT header (first dot-separated +// segment) and rejects the token when the `typ` field is not +// `statuslist+jwt`, as required by draft-ietf-oauth-status-list §5.1. +func validateStatusListJWTTyp(jwtBytes []byte) error { + dot := bytes.IndexByte(jwtBytes, '.') + if dot < 0 { + logging.Log().Debug("JWT has no dot separator, cannot extract header") + return fmt.Errorf("%w: JWT has no header segment", ErrorStatusListUnparseable) + } + + headerJSON, err := base64.RawURLEncoding.DecodeString(string(jwtBytes[:dot])) + if err != nil { + logging.Log().Debugf("JWT header base64 decode failed: %v", err) + return fmt.Errorf("%w: JWT header base64 decode failed: %v", ErrorStatusListUnparseable, err) + } + + var header map[string]interface{} + if err := json.Unmarshal(headerJSON, &header); err != nil { + logging.Log().Debugf("JWT header JSON unmarshal failed: %v", err) + return fmt.Errorf("%w: JWT header JSON unmarshal failed: %v", ErrorStatusListUnparseable, err) + } + + typ, _ := header[jwtHeaderTyp].(string) + if !strings.EqualFold(typ, statusListJWTTyp) { + logging.Log().Debugf("Status list JWT typ header is %q, expected %q", typ, statusListJWTTyp) + return fmt.Errorf("%w: expected %q, got %q", ErrorStatusListInvalidTyp, statusListJWTTyp, typ) + } + + return nil +} + +// decodeJWTPayloadUnverified extracts the payload from a JWT without +// verifying the signature. Used only as a fallback when no verifier is +// configured. +func decodeJWTPayloadUnverified(jwtBytes []byte) ([]byte, error) { + parts := strings.Split(strings.TrimSpace(string(jwtBytes)), ".") if len(parts) != 3 { + logging.Log().Debugf("JWT has %d parts instead of 3, cannot decode payload", len(parts)) return nil, fmt.Errorf("%w: expected 3 JWT parts, got %d", ErrorStatusListUnparseable, len(parts)) } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { + logging.Log().Debugf("JWT payload raw base64url decode failed, trying padded: %v", err) padded, padErr := base64.URLEncoding.DecodeString(parts[1]) if padErr != nil { + logging.Log().Debugf("JWT payload padded base64url decode also failed: %v", padErr) return nil, fmt.Errorf("%w: JWT payload base64 decode failed: %v", ErrorStatusListUnparseable, err) } payload = padded } + logging.Log().Debug("Decoded JWT payload without signature verification") + return payload, nil +} + +// ietfStatusListResult bundles the parsed status list with optional +// caching metadata extracted from the JWT payload. +type ietfStatusListResult struct { + statusList *common.IETFStatusList + // ttl is the issuer-requested cache duration from the `ttl` JWT claim + // (draft-ietf-oauth-status-list §5.1 / §8.3 step 4d). Nil when the + // claim is absent. + ttl *time.Duration +} +// parseIETFStatusListPayload extracts the `status_list` fields from a +// verified JWT payload. It enforces draft-ietf-oauth-status-list §8.3: +// - step 4a: the JWT's `sub` claim must equal expectedURI +// - step 4c: if `exp` is present it must not be in the past +// - step 4d: if `ttl` is present it is returned for cache control +func parseIETFStatusListPayload(payload []byte, expectedURI string, clock common.Clock) (*ietfStatusListResult, error) { + logging.Log().Debugf("Parsing IETF status list payload for %s", expectedURI) var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { + logging.Log().Debugf("JWT payload JSON unmarshal failed: %v", err) return nil, fmt.Errorf("%w: JWT payload JSON unmarshal failed: %v", ErrorStatusListUnparseable, err) } + sub, _ := claims[jwtClaimSub].(string) + if sub != expectedURI { + logging.Log().Warnf("Status list JWT sub claim %q does not match expected URI %q", sub, expectedURI) + return nil, fmt.Errorf("%w: sub=%q, expected=%q", ErrorStatusListSubjectMismatch, sub, expectedURI) + } + logging.Log().Debugf("Status list JWT sub claim matches expected URI %s", expectedURI) + + if err := checkExpClaim(claims, clock); err != nil { + return nil, err + } + statusListRaw, ok := claims[common.IETFStatusListKey] if !ok || statusListRaw == nil { - // Fall back to top-level keys (some implementations put bits/lst at the top level). + logging.Log().Debug("No top-level status_list key, treating entire payload as status list") statusListRaw = claims } statusListMap, ok := statusListRaw.(map[string]interface{}) if !ok { + logging.Log().Debugf("status_list is %T, expected JSON object", statusListRaw) return nil, fmt.Errorf("%w: status_list is not a JSON object", ErrorStatusListUnparseable) } lst, ok := statusListMap[common.IETFStatusListLst].(string) if !ok || lst == "" { + logging.Log().Debug("status_list.lst is missing or empty") return nil, fmt.Errorf("%w: status_list.lst is missing or empty", ErrorStatusListUnparseable) } bits, err := parseIETFBits(statusListMap[common.IETFStatusListBits]) if err != nil { + logging.Log().Debugf("Invalid bits value: %v", err) return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } + logging.Log().Debugf("Parsed IETF status list: bits=%d, lst length=%d", bits, len(lst)) - return &common.IETFStatusList{Bits: bits, Lst: lst}, nil + result := &ietfStatusListResult{ + statusList: &common.IETFStatusList{Bits: bits, Lst: lst}, + } + + if ttlRaw, hasTTL := claims[jwtClaimTTL]; hasTTL { + ttlSeconds, ttlErr := parsePositiveSeconds(ttlRaw) + if ttlErr != nil { + logging.Log().Debugf("Invalid ttl value: %v", ttlErr) + return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, ttlErr) + } + result.ttl = &ttlSeconds + logging.Log().Debugf("Status list JWT declares ttl=%v", ttlSeconds) + } + + return result, nil } -// parseIETFBits converts the `bits` value from a status list JWT payload -// to an int. JSON numbers arrive as float64. +// parsePositiveSeconds converts a JSON numeric value to a positive +// time.Duration in seconds. +func parsePositiveSeconds(raw interface{}) (time.Duration, error) { + var secs int64 + switch v := raw.(type) { + case float64: + secs = int64(v) + case int: + secs = int64(v) + case int64: + secs = v + default: + return 0, fmt.Errorf("ttl has unexpected type %T", raw) + } + if secs <= 0 { + return 0, fmt.Errorf("ttl must be positive, got %d", secs) + } + return time.Duration(secs) * time.Second, nil +} + +// checkExpClaim verifies the `exp` claim if present. Per +// draft-ietf-oauth-status-list §8.3 step 4c the token is rejected when `exp` +// is defined and the current time is past it. When `exp` is absent the check +// is a no-op. +func checkExpClaim(claims map[string]interface{}, clock common.Clock) error { + expRaw, ok := claims[jwtClaimExp] + if !ok { + logging.Log().Debug("Status list JWT has no exp claim, skipping expiration check") + return nil + } + + expFloat, ok := expRaw.(float64) + if !ok { + logging.Log().Debugf("exp claim has unexpected type %T", expRaw) + return fmt.Errorf("%w: exp claim has unexpected type %T", ErrorStatusListUnparseable, expRaw) + } + + expTime := time.Unix(int64(expFloat), 0) + if clock.Now().After(expTime) { + logging.Log().Warnf("Status list JWT expired at %v", expTime) + return fmt.Errorf("%w: token expired at %v", ErrorStatusListExpired, expTime) + } + + logging.Log().Debugf("Status list JWT exp check passed (expires %v)", expTime) + return nil +} + +// validIETFBitsValues is the set of allowed values for the `bits` field in +// an IETF Token Status List, per draft-ietf-oauth-status-list §4.1. +var validIETFBitsValues = map[int]bool{1: true, 2: true, 4: true, 8: true} + +// parseIETFBits converts the `bits` value from a status list JWT payload to +// an int and validates it against the allowed set {1, 2, 4, 8} defined in +// draft-ietf-oauth-status-list §4.1. The `bits` claim is REQUIRED per §4.2; +// a nil value is rejected. JSON numbers arrive as float64. func parseIETFBits(raw interface{}) (int, error) { + var bits int switch v := raw.(type) { case float64: - if v <= 0 { - return 0, fmt.Errorf("bits must be positive, got %v", v) - } - return int(v), nil + bits = int(v) case int: - if v <= 0 { - return 0, fmt.Errorf("bits must be positive, got %d", v) - } - return v, nil + bits = v case int64: - if v <= 0 { - return 0, fmt.Errorf("bits must be positive, got %d", v) - } - return int(v), nil + bits = int(v) case nil: - return common.DefaultStatusSizeBits, nil + return 0, fmt.Errorf("bits claim is required but missing") default: return 0, fmt.Errorf("bits has unexpected type %T", raw) } + + if !validIETFBitsValues[bits] { + return 0, fmt.Errorf("bits must be one of {1, 2, 4, 8}, got %d", bits) + } + + return bits, nil } // Compile-time assertion. diff --git a/verifier/credential_status_client_test.go b/verifier/credential_status_client_test.go index 4b9d0e37..e436d74d 100644 --- a/verifier/credential_status_client_test.go +++ b/verifier/credential_status_client_test.go @@ -127,9 +127,10 @@ func TestCachingStatusListClientTransportError(t *testing.T) { } // TestCachingStatusListClientAcceptHeader confirms the client advertises the -// JSON-LD VC media type when fetching status-list credentials. This keeps -// the client compatible with origins that serve different representations -// based on content negotiation. +// TestCachingStatusListClientAcceptHeader verifies that the client sends both +// the JSON-LD and JWT VC media types when fetching status-list credentials. +// This keeps the client compatible with issuers that perform strict content +// negotiation and may serve either representation. func TestCachingStatusListClientAcceptHeader(t *testing.T) { var received string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -141,7 +142,9 @@ func TestCachingStatusListClientAcceptHeader(t *testing.T) { client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry) _, err := client.Fetch(srv.URL) require.NoError(t, err) - assert.Equal(t, ContentTypeCredentialJson, received) + assert.Equal(t, AcceptHeaderStatusListCredential, received) + assert.Contains(t, received, ContentTypeCredentialJson) + assert.Contains(t, received, ContentTypeCredentialJWT) } // ensureInterfaceSatisfied asserts at test compile time that the concrete diff --git a/verifier/credential_status_test.go b/verifier/credential_status_test.go index 26cbc9dc..b78ec4f1 100644 --- a/verifier/credential_status_test.go +++ b/verifier/credential_status_test.go @@ -10,13 +10,25 @@ import ( "bytes" "compress/gzip" "compress/zlib" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" "encoding/base64" + "encoding/json" "errors" "fmt" + "math/big" "testing" + "time" "github.com/fiware/VCVerifier/common" configModel "github.com/fiware/VCVerifier/config" + "github.com/fiware/VCVerifier/did" + "github.com/lestrrat-go/jwx/v3/cert" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" ) func boolPtr(v bool) *bool { return &v } @@ -115,7 +127,32 @@ func newStatusListCredential(t *testing.T, encodedList, purpose string) *common. common.StatusListKeyStatusPurpose: purpose, }, } - cred, err := common.CreateCredential(common.CredentialContents{}, common.CustomFields{}) + cred, err := common.CreateCredential(common.CredentialContents{ + Types: []string{common.TypeVerifiableCredential, common.TypeBitstringStatusListCredential}, + }, common.CustomFields{}) + if err != nil { + t.Fatalf("CreateCredential failed: %v", err) + } + cred.SetRawJSON(raw) + return cred +} + +// newStatusListCredentialWithTypes builds a status-list credential with +// custom types, allowing tests to verify type-checking logic by passing +// types that do not include a recognised status-list credential type. +func newStatusListCredentialWithTypes(t *testing.T, encodedList, purpose string, types []string) *common.Credential { + t.Helper() + raw := common.JSONObject{ + common.JSONLDKeyID: statusValidationTestURL, + common.JSONLDKeyType: types, + common.VCKeyCredentialSubject: common.JSONObject{ + common.StatusListKeyEncodedList: encodedList, + common.StatusListKeyStatusPurpose: purpose, + }, + } + cred, err := common.CreateCredential(common.CredentialContents{ + Types: types, + }, common.CustomFields{}) if err != nil { t.Fatalf("CreateCredential failed: %v", err) } @@ -175,6 +212,13 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { suspensionClearList := newStatusListCredential(t, encodeTestBitstring(t, statusValidationClearByte), configModel.StatusPurposeSuspension) malformedList := newStatusListCredential(t, "not*valid*base64!", configModel.StatusPurposeRevocation) + // A credential with an encodedList but no recognised status-list type. + wrongTypeList := newStatusListCredentialWithTypes(t, + encodeTestBitstring(t, statusValidationClearByte), + configModel.StatusPurposeRevocation, + []string{common.TypeVerifiableCredential}, + ) + // Two-entry fixture credentials for the suspension + revocation case. // The suspension list's URL differs from the revocation list's URL so // the mock client returns the correct fixture for each lookup. @@ -255,6 +299,14 @@ func TestCredentialStatusValidationService_ValidateVC(t *testing.T) { expectedResult: false, expectedError: ErrorStatusListUnparseable, }, + { + testName: "Fetched credential without status list type -> ErrorStatusListUnparseable", + credential: newCredentialWithStatus(t, statusValidationTestType, bitstringStatusEntry(statusValidationTestURL, configModel.StatusPurposeRevocation, statusValidationTestIndex)), + perType: map[string]configModel.CredentialStatus{statusValidationTestType: {Enabled: boolPtr(true)}}, + fixtures: map[string]*common.Credential{statusValidationTestURL: wrongTypeList}, + expectedResult: false, + expectedError: ErrorStatusListUnparseable, + }, { testName: "Two entries (suspension clear + revocation set) -> ErrorCredentialRevoked", credential: newCredentialWithStatus(t, statusValidationTestType, []interface{}{ @@ -453,3 +505,626 @@ func TestCredentialStatusValidationService_IETF(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// StatusListJWTVerifierImpl tests +// --------------------------------------------------------------------------- + +// generateTestKeyAndDID creates an ECDSA P-256 key pair and returns the +// private key along with its did:jwk DID string. The did:jwk DID is +// constructed by base64url-encoding the public JWK, making it trivially +// resolvable by did.NewJWKVDR(). +func generateTestKeyAndDID(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + pubJWK, err := jwk.Import(&privateKey.PublicKey) + if err != nil { + t.Fatalf("import public key to JWK: %v", err) + } + pubJSON, err := json.Marshal(pubJWK) + if err != nil { + t.Fatalf("marshal public JWK: %v", err) + } + didJWK := "did:jwk:" + base64.RawURLEncoding.EncodeToString(pubJSON) + return privateKey, didJWK +} + +// generateTestKey creates an ECDSA P-256 key pair without a DID. +func generateTestKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +// generateSelfSignedCert creates a self-signed X.509 certificate from the +// given key for x5c testing. +func generateSelfSignedCert(t *testing.T, key *ecdsa.PrivateKey) []byte { + t.Helper() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + return certDER +} + +// buildStatusListJWTWithISS creates a signed JWT with the `iss` claim set +// to the given DID so that the verifier resolves the key via DID resolution. +func buildStatusListJWTWithISS(t *testing.T, payload map[string]interface{}, privateKey *ecdsa.PrivateKey, issuerDID string) []byte { + t.Helper() + payload["iss"] = issuerDID + + payloadBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + key, err := jwk.Import(privateKey) + if err != nil { + t.Fatalf("import private key: %v", err) + } + + hdrs := jws.NewHeaders() + _ = hdrs.Set("typ", "statuslist+jwt") + + signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256(), key, jws.WithProtectedHeaders(hdrs))) + if err != nil { + t.Fatalf("sign JWT: %v", err) + } + return signed +} + +// buildStatusListJWTWithX5C creates a signed JWT with an x5c header (no iss +// claim) so that the verifier falls back to x5c certificate chain verification. +func buildStatusListJWTWithX5C(t *testing.T, payload map[string]interface{}, privateKey *ecdsa.PrivateKey, certDER []byte) []byte { + t.Helper() + + payloadBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + key, err := jwk.Import(privateKey) + if err != nil { + t.Fatalf("import private key: %v", err) + } + + var chain cert.Chain + certB64 := base64.StdEncoding.EncodeToString(certDER) + if err := chain.AddString(certB64); err != nil { + t.Fatalf("add cert to chain: %v", err) + } + + hdrs := jws.NewHeaders() + _ = hdrs.Set("typ", "statuslist+jwt") + _ = hdrs.Set("x5c", &chain) + + signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256(), key, jws.WithProtectedHeaders(hdrs))) + if err != nil { + t.Fatalf("sign JWT: %v", err) + } + return signed +} + +// newTestStatusListJWTVerifier creates a StatusListJWTVerifierImpl backed by +// a registry that supports did:jwk resolution. +func newTestStatusListJWTVerifier() *StatusListJWTVerifierImpl { + registry := did.NewRegistry(did.WithVDR(did.NewJWKVDR())) + return NewStatusListJWTVerifier(registry) +} + +// --- ISS-based (DID resolution) tests --- + +func TestStatusListJWTVerifier_ISS_ValidSignature(t *testing.T) { + privateKey, issuerDID := generateTestKeyAndDID(t) + payload := map[string]interface{}{ + "status_list": map[string]interface{}{ + "bits": 1, + "lst": "eNpjAAAAAQAB", + }, + "sub": "https://example.org/statuslists/1", + } + + jwtBytes := buildStatusListJWTWithISS(t, payload, privateKey, issuerDID) + + verifier := newTestStatusListJWTVerifier() + verified, err := verifier.VerifyStatusListJWT(jwtBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(verified, &claims); err != nil { + t.Fatalf("unmarshal verified payload: %v", err) + } + if claims["sub"] != "https://example.org/statuslists/1" { + t.Errorf("unexpected sub claim: %v", claims["sub"]) + } +} + +func TestStatusListJWTVerifier_ISS_TamperedPayload(t *testing.T) { + privateKey, issuerDID := generateTestKeyAndDID(t) + payload := map[string]interface{}{"sub": "original"} + + jwtBytes := buildStatusListJWTWithISS(t, payload, privateKey, issuerDID) + + parts := bytes.SplitN(jwtBytes, []byte("."), 3) + tamperedPayload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"tampered","iss":"` + issuerDID + `"}`)) + tampered := append(parts[0], '.') + tampered = append(tampered, []byte(tamperedPayload)...) + tampered = append(tampered, '.') + tampered = append(tampered, parts[2]...) + + verifier := newTestStatusListJWTVerifier() + _, err := verifier.VerifyStatusListJWT(tampered) + if err == nil { + t.Fatal("expected error for tampered payload, got nil") + } + if !errors.Is(err, ErrorStatusListUnparseable) { + t.Errorf("expected ErrorStatusListUnparseable, got %v", err) + } +} + +func TestStatusListJWTVerifier_ISS_WrongKey(t *testing.T) { + signingKey, _ := generateTestKeyAndDID(t) + _, wrongDID := generateTestKeyAndDID(t) + + payload := map[string]interface{}{"sub": "test"} + jwtBytes := buildStatusListJWTWithISS(t, payload, signingKey, wrongDID) + + verifier := newTestStatusListJWTVerifier() + _, err := verifier.VerifyStatusListJWT(jwtBytes) + if err == nil { + t.Fatal("expected error when iss DID does not match signing key, got nil") + } + if !errors.Is(err, ErrorStatusListUnparseable) { + t.Errorf("expected ErrorStatusListUnparseable, got %v", err) + } +} + +// --- X5C fallback tests (no iss claim) --- + +func TestStatusListJWTVerifier_X5C_ValidSignature(t *testing.T) { + privateKey := generateTestKey(t) + certDER := generateSelfSignedCert(t, privateKey) + payload := map[string]interface{}{ + "status_list": map[string]interface{}{ + "bits": 1, + "lst": "eNpjAAAAAQAB", + }, + "sub": "https://example.org/statuslists/1", + } + + jwtBytes := buildStatusListJWTWithX5C(t, payload, privateKey, certDER) + + verifier := newTestStatusListJWTVerifier() + verified, err := verifier.VerifyStatusListJWT(jwtBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(verified, &claims); err != nil { + t.Fatalf("unmarshal verified payload: %v", err) + } + if claims["sub"] != "https://example.org/statuslists/1" { + t.Errorf("unexpected sub claim: %v", claims["sub"]) + } +} + +func TestStatusListJWTVerifier_X5C_TamperedPayload(t *testing.T) { + privateKey := generateTestKey(t) + certDER := generateSelfSignedCert(t, privateKey) + payload := map[string]interface{}{"sub": "original"} + + jwtBytes := buildStatusListJWTWithX5C(t, payload, privateKey, certDER) + + parts := bytes.SplitN(jwtBytes, []byte("."), 3) + tamperedPayload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"tampered"}`)) + tampered := append(parts[0], '.') + tampered = append(tampered, []byte(tamperedPayload)...) + tampered = append(tampered, '.') + tampered = append(tampered, parts[2]...) + + verifier := newTestStatusListJWTVerifier() + _, err := verifier.VerifyStatusListJWT(tampered) + if err == nil { + t.Fatal("expected error for tampered payload, got nil") + } + if !errors.Is(err, ErrorStatusListUnparseable) { + t.Errorf("expected ErrorStatusListUnparseable, got %v", err) + } +} + +func TestStatusListJWTVerifier_X5C_WrongKey(t *testing.T) { + signingKey := generateTestKey(t) + wrongKey := generateTestKey(t) + wrongCertDER := generateSelfSignedCert(t, wrongKey) + + payload := map[string]interface{}{"sub": "test"} + jwtBytes := buildStatusListJWTWithX5C(t, payload, signingKey, wrongCertDER) + + verifier := newTestStatusListJWTVerifier() + _, err := verifier.VerifyStatusListJWT(jwtBytes) + if err == nil { + t.Fatal("expected error when x5c cert does not match signing key, got nil") + } + if !errors.Is(err, ErrorStatusListUnparseable) { + t.Errorf("expected ErrorStatusListUnparseable, got %v", err) + } +} + +func TestStatusListJWTVerifier_NoISSNoX5C(t *testing.T) { + privateKey := generateTestKey(t) + payloadBytes := []byte(`{"sub":"test"}`) + + key, err := jwk.Import(privateKey) + if err != nil { + t.Fatalf("import key: %v", err) + } + + hdrs := jws.NewHeaders() + _ = hdrs.Set("typ", "statuslist+jwt") + + signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256(), key, jws.WithProtectedHeaders(hdrs))) + if err != nil { + t.Fatalf("sign: %v", err) + } + + verifier := newTestStatusListJWTVerifier() + _, err = verifier.VerifyStatusListJWT(signed) + if err == nil { + t.Fatal("expected error when neither iss nor x5c is present, got nil") + } + if !errors.Is(err, ErrorStatusListUnparseable) { + t.Errorf("expected ErrorStatusListUnparseable, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// parseIETFStatusListPayload claim verification tests +// --------------------------------------------------------------------------- + +// fixedTimeClock is a Clock that always returns a fixed time, allowing +// tests to control the "now" value for exp-claim verification. +type fixedTimeClock struct { + now time.Time +} + +func (c fixedTimeClock) Now() time.Time { return c.now } + +func TestParseIETFStatusListPayload_ClaimVerification(t *testing.T) { + const expectedURI = "https://example.org/statuslists/1" + + // A reference "now" for exp tests. + referenceNow := time.Unix(1700000000, 0) + // exp in the future relative to referenceNow. + expFuture := float64(referenceNow.Unix() + 3600) + // exp in the past relative to referenceNow. + expPast := float64(referenceNow.Unix() - 3600) + + type test struct { + testName string + payload map[string]interface{} + expectedURI string + clock common.Clock + expectedError error + expectedTTL *time.Duration + } + + validStatusList := map[string]interface{}{ + "bits": 1, + "lst": "eNpjAAAAAQAB", + } + + ttl60s := 60 * time.Second + ttl300s := 300 * time.Second + + tests := []test{ + // --- sub claim --- + { + testName: "sub matches URI", + payload: map[string]interface{}{ + "sub": expectedURI, + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: nil, + }, + { + testName: "sub does not match URI", + payload: map[string]interface{}{ + "sub": "https://wrong.example.org/statuslists/99", + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: ErrorStatusListSubjectMismatch, + }, + { + testName: "sub claim missing", + payload: map[string]interface{}{ + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: ErrorStatusListSubjectMismatch, + }, + { + testName: "sub is empty string", + payload: map[string]interface{}{ + "sub": "", + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: ErrorStatusListSubjectMismatch, + }, + // --- exp claim --- + { + testName: "exp in the future is accepted", + payload: map[string]interface{}{ + "sub": expectedURI, + "exp": expFuture, + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: nil, + }, + { + testName: "exp in the past is rejected", + payload: map[string]interface{}{ + "sub": expectedURI, + "exp": expPast, + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: ErrorStatusListExpired, + }, + { + testName: "exp absent is accepted", + payload: map[string]interface{}{ + "sub": expectedURI, + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: nil, + }, + // --- ttl claim --- + { + testName: "ttl is extracted when present", + payload: map[string]interface{}{ + "sub": expectedURI, + "ttl": float64(60), + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedTTL: &ttl60s, + }, + { + testName: "ttl with larger value", + payload: map[string]interface{}{ + "sub": expectedURI, + "ttl": float64(300), + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedTTL: &ttl300s, + }, + { + testName: "ttl absent leaves result.ttl nil", + payload: map[string]interface{}{ + "sub": expectedURI, + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedTTL: nil, + }, + { + testName: "ttl zero is rejected", + payload: map[string]interface{}{ + "sub": expectedURI, + "ttl": float64(0), + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: ErrorStatusListUnparseable, + }, + { + testName: "ttl negative is rejected", + payload: map[string]interface{}{ + "sub": expectedURI, + "ttl": float64(-10), + "status_list": validStatusList, + }, + expectedURI: expectedURI, + clock: fixedTimeClock{now: referenceNow}, + expectedError: ErrorStatusListUnparseable, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + payloadBytes, err := json.Marshal(tc.payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + result, err := parseIETFStatusListPayload(payloadBytes, tc.expectedURI, tc.clock) + + if tc.expectedError == nil { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if tc.expectedTTL == nil { + if result.ttl != nil { + t.Errorf("expected nil ttl, got %v", *result.ttl) + } + } else { + if result.ttl == nil { + t.Fatalf("expected ttl=%v, got nil", *tc.expectedTTL) + } + if *result.ttl != *tc.expectedTTL { + t.Errorf("expected ttl=%v, got %v", *tc.expectedTTL, *result.ttl) + } + } + } else { + if err == nil { + t.Fatalf("expected error %v, got nil", tc.expectedError) + } + if !errors.Is(err, tc.expectedError) { + t.Errorf("expected error %v, got %v", tc.expectedError, err) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// validateStatusListJWTTyp tests +// --------------------------------------------------------------------------- + +// buildRawJWT assembles a JWT from a header map, payload map and a dummy +// signature. No cryptographic signing is performed — this is only for +// testing header validation. +func buildRawJWT(t *testing.T, header, payload map[string]interface{}) []byte { + t.Helper() + h, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + p, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return []byte( + base64.RawURLEncoding.EncodeToString(h) + "." + + base64.RawURLEncoding.EncodeToString(p) + "." + + base64.RawURLEncoding.EncodeToString([]byte("signature")), + ) +} + +func TestValidateStatusListJWTTyp(t *testing.T) { + type test struct { + testName string + jwt []byte + expectedError error + } + + tests := []test{ + { + testName: "correct typ header", + jwt: buildRawJWT(t, + map[string]interface{}{"alg": "ES256", "typ": "statuslist+jwt"}, + map[string]interface{}{"sub": "x"}, + ), + expectedError: nil, + }, + { + testName: "wrong typ header", + jwt: buildRawJWT(t, + map[string]interface{}{"alg": "ES256", "typ": "JWT"}, + map[string]interface{}{"sub": "x"}, + ), + expectedError: ErrorStatusListInvalidTyp, + }, + { + testName: "typ header missing", + jwt: buildRawJWT(t, + map[string]interface{}{"alg": "ES256"}, + map[string]interface{}{"sub": "x"}, + ), + expectedError: ErrorStatusListInvalidTyp, + }, + { + testName: "no dot separator", + jwt: []byte("nodots"), + expectedError: ErrorStatusListUnparseable, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + err := validateStatusListJWTTyp(tc.jwt) + + if tc.expectedError == nil { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + } else { + if err == nil { + t.Fatalf("expected error %v, got nil", tc.expectedError) + } + if !errors.Is(err, tc.expectedError) { + t.Errorf("expected error %v, got %v", tc.expectedError, err) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// parseIETFBits tests +// --------------------------------------------------------------------------- + +func TestParseIETFBits(t *testing.T) { + type test struct { + testName string + input interface{} + expectedBits int + expectedError bool + } + + tests := []test{ + {testName: "bits=1 (float64)", input: float64(1), expectedBits: 1}, + {testName: "bits=2 (float64)", input: float64(2), expectedBits: 2}, + {testName: "bits=4 (float64)", input: float64(4), expectedBits: 4}, + {testName: "bits=8 (float64)", input: float64(8), expectedBits: 8}, + {testName: "bits=1 (int)", input: 1, expectedBits: 1}, + {testName: "bits=8 (int64)", input: int64(8), expectedBits: 8}, + {testName: "bits=3 is invalid", input: float64(3), expectedError: true}, + {testName: "bits=16 is invalid", input: float64(16), expectedError: true}, + {testName: "bits=0 is invalid", input: float64(0), expectedError: true}, + {testName: "bits=-1 is invalid", input: float64(-1), expectedError: true}, + {testName: "bits=nil is required", input: nil, expectedError: true}, + {testName: "bits=string is invalid type", input: "1", expectedError: true}, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + bits, err := parseIETFBits(tc.input) + + if tc.expectedError { + if err == nil { + t.Fatalf("expected error, got bits=%d", bits) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if bits != tc.expectedBits { + t.Errorf("expected bits=%d, got %d", tc.expectedBits, bits) + } + } + }) + } +} diff --git a/verifier/verifier.go b/verifier/verifier.go index a82ebdee..c934c9ca 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -24,6 +24,7 @@ import ( configModel "github.com/fiware/VCVerifier/config" "github.com/fiware/VCVerifier/database" "github.com/fiware/VCVerifier/gaiax" + "github.com/fiware/VCVerifier/did" "github.com/fiware/VCVerifier/tir" "github.com/google/uuid" @@ -361,7 +362,9 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi statusListHttpTimeout := time.Duration(verifierConfig.StatusListHttpTimeout) * time.Second statusListCacheExpiry := time.Duration(verifierConfig.StatusListCacheExpiry) * time.Second statusListClient := NewCachingStatusListClient(statusListHttpTimeout, statusListCacheExpiry) - ietfStatusListClient := NewCachingIETFStatusListClient(statusListHttpTimeout, statusListCacheExpiry) + statusListDIDRegistry := did.NewRegistry(did.WithVDR(did.NewWebVDR()), did.WithVDR(did.NewKeyVDR()), did.WithVDR(did.NewJWKVDR())) + ietfJWTVerifier := NewStatusListJWTVerifier(statusListDIDRegistry) + ietfStatusListClient := NewCachingIETFStatusListClient(statusListHttpTimeout, statusListCacheExpiry, ietfJWTVerifier, clock) credentialStatusVerificationService := NewCredentialStatusValidationService(statusListClient, ietfStatusListClient, clock) key, err := initPrivateKey(verifierConfig.KeyAlgorithm, verifierConfig.GenerateKey, verifierConfig.KeyPath) From 2526a0c46eb0370bffaeeeda42aa90a5bcbb78e1 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Tue, 30 Jun 2026 11:56:31 +0200 Subject: [PATCH 4/6] fix linting --- verifier/credential_status_client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/verifier/credential_status_client.go b/verifier/credential_status_client.go index d59e43e2..2441f0be 100644 --- a/verifier/credential_status_client.go +++ b/verifier/credential_status_client.go @@ -642,13 +642,13 @@ func parseIETFStatusListPayload(payload []byte, expectedURI string, clock common } if ttlRaw, hasTTL := claims[jwtClaimTTL]; hasTTL { - ttlSeconds, ttlErr := parsePositiveSeconds(ttlRaw) + ttl, ttlErr := parsePositiveSeconds(ttlRaw) if ttlErr != nil { logging.Log().Debugf("Invalid ttl value: %v", ttlErr) return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, ttlErr) } - result.ttl = &ttlSeconds - logging.Log().Debugf("Status list JWT declares ttl=%v", ttlSeconds) + result.ttl = &ttl + logging.Log().Debugf("Status list JWT declares ttl=%v", ttl) } return result, nil From 46dc811ea95afd74702a40efb2b4780510e43277 Mon Sep 17 00:00:00 2001 From: Miguel Ortega Date: Tue, 30 Jun 2026 15:45:27 +0200 Subject: [PATCH 5/6] verify JWT signature on W3C status list credentials Wire StatusListJWTVerifier into CachingStatusListClient so JWT-encoded W3C Bitstring Status List credentials have their signature verified before being trusted, consistent with the existing IETF path. --- verifier/credential_status_client.go | 29 ++-- verifier/credential_status_client_test.go | 159 +++++++++++++++++++++- verifier/verifier.go | 6 +- 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/verifier/credential_status_client.go b/verifier/credential_status_client.go index 2441f0be..8009bed2 100644 --- a/verifier/credential_status_client.go +++ b/verifier/credential_status_client.go @@ -123,9 +123,10 @@ type StatusListCredentialClient interface { // for the same URL and a configurable http.Client timeout to protect the // verifier from slow status-list issuers. type CachingStatusListClient struct { - httpClient *http.Client - cache common.Cache - expiry time.Duration + httpClient *http.Client + cache common.Cache + expiry time.Duration + jwtVerifier StatusListJWTVerifier } // NewCachingStatusListClient constructs a CachingStatusListClient using the @@ -135,11 +136,12 @@ type CachingStatusListClient struct { // The cache janitor's cleanup interval is derived from cacheExpiry via // StatusListCacheCleanupMultiplier so evicted entries are reaped on a cadence // that matches the rest of the codebase. -func NewCachingStatusListClient(timeout time.Duration, cacheExpiry time.Duration) *CachingStatusListClient { +func NewCachingStatusListClient(timeout time.Duration, cacheExpiry time.Duration, jwtVerifier StatusListJWTVerifier) *CachingStatusListClient { return &CachingStatusListClient{ - httpClient: &http.Client{Timeout: timeout}, - cache: cache.New(cacheExpiry, StatusListCacheCleanupMultiplier*cacheExpiry), - expiry: cacheExpiry, + httpClient: &http.Client{Timeout: timeout}, + cache: cache.New(cacheExpiry, StatusListCacheCleanupMultiplier*cacheExpiry), + expiry: cacheExpiry, + jwtVerifier: jwtVerifier, } } @@ -183,7 +185,7 @@ func (c *CachingStatusListClient) Fetch(url string) (*common.Credential, error) } logging.Log().Debugf("Received %d bytes from %s, parsing credential", len(body), url) - cred, err := parseStatusListCredentialBody(body) + cred, err := parseStatusListCredentialBody(body, c.jwtVerifier) if err != nil { logging.Log().Debugf("Failed to parse status-list credential from %s: %v", url, err) return nil, err @@ -208,7 +210,7 @@ func (c *CachingStatusListClient) Fetch(url string) (*common.Credential, error) // A non-nil error is always wrapped with ErrorStatusListUnparseable so // callers can distinguish parse failures from transport failures with // errors.Is. -func parseStatusListCredentialBody(body []byte) (*common.Credential, error) { +func parseStatusListCredentialBody(body []byte, jwtVerifier StatusListJWTVerifier) (*common.Credential, error) { trimmed := strings.TrimSpace(string(body)) if len(trimmed) == 0 { logging.Log().Debug("Status-list credential response body is empty") @@ -231,6 +233,15 @@ func parseStatusListCredentialBody(body []byte) (*common.Credential, error) { } logging.Log().Debug("Parsing status-list credential as JWT") + if jwtVerifier != nil { + if _, err := jwtVerifier.VerifyStatusListJWT([]byte(trimmed)); err != nil { + logging.Log().Debugf("W3C status-list JWT signature verification failed: %v", err) + return nil, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) + } + logging.Log().Debug("W3C status-list JWT signature verified") + } else { + logging.Log().Warn("No JWT verifier configured for W3C status list — skipping signature verification") + } cred, err := parseUnsignedJWTCredential(trimmed) if err != nil { logging.Log().Debugf("JWT credential parse failed: %v", err) diff --git a/verifier/credential_status_client_test.go b/verifier/credential_status_client_test.go index e436d74d..751400f6 100644 --- a/verifier/credential_status_client_test.go +++ b/verifier/credential_status_client_test.go @@ -68,7 +68,7 @@ func TestCachingStatusListClientFetch(t *testing.T) { })) defer srv.Close() - client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry) + client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry, nil) cred, err := client.Fetch(srv.URL) if tc.wantErr != nil { @@ -94,7 +94,7 @@ func TestCachingStatusListClientCache(t *testing.T) { })) defer srv.Close() - client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry) + client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry, nil) first, err := client.Fetch(srv.URL) require.NoError(t, err) @@ -118,7 +118,7 @@ func TestCachingStatusListClientTransportError(t *testing.T) { url := srv.URL srv.Close() - client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry) + client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry, nil) cred, err := client.Fetch(url) require.Error(t, err) @@ -139,7 +139,7 @@ func TestCachingStatusListClientAcceptHeader(t *testing.T) { })) defer srv.Close() - client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry) + client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry, nil) _, err := client.Fetch(srv.URL) require.NoError(t, err) assert.Equal(t, AcceptHeaderStatusListCredential, received) @@ -153,3 +153,154 @@ func TestCachingStatusListClientAcceptHeader(t *testing.T) { // common.Credential here so static analysis doesn't drop the import. var _ StatusListCredentialClient = (*CachingStatusListClient)(nil) var _ = (*common.Credential)(nil) + +// --------------------------------------------------------------------------- +// JWT signature verification tests +// --------------------------------------------------------------------------- + +// mockJWTVerifier is a test double for StatusListJWTVerifier. It records +// whether it was called and returns the configured error. +type mockJWTVerifier struct { + called bool + err error +} + +func (m *mockJWTVerifier) VerifyStatusListJWT(_ []byte) ([]byte, error) { + m.called = true + return nil, m.err +} + +// testStatusListVCJWT is a minimal JWT-encoded BitstringStatusListCredential +// built with the shared buildFakeJWT helper (defined in presentation_parser_test.go). +// The signature segment is a static placeholder — format checks pass without a +// real key pair, which is sufficient for unit tests of the verifier wiring. +var testStatusListVCJWT = buildFakeJWT(map[string]interface{}{ + "iss": "did:example:issuer", + "jti": "https://example.com/status/1", + "vc": map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "BitstringStatusListCredential"}, + "credentialSubject": map[string]interface{}{ + "id": "https://example.com/status/1#list", + "type": "BitstringStatusList", + "statusPurpose": "revocation", + "encodedList": "H4sIAAAAAAAA_2NgAAMAAAAEAAEAAAAA", + }, + }, +}) + +// TestParseStatusListCredentialBodyJWTVerification confirms that +// parseStatusListCredentialBody calls the JWT verifier for non-JSON-LD +// responses and respects its outcome. +func TestParseStatusListCredentialBodyJWTVerification(t *testing.T) { + jwtBody := testStatusListVCJWT + + tests := []struct { + name string + body string + verifier *mockJWTVerifier + wantErr error + wantVerifierHit bool + }{ + { + name: "verifier_failure_rejects_jwt", + body: jwtBody, + verifier: &mockJWTVerifier{err: ErrorStatusListUnparseable}, + wantErr: ErrorStatusListUnparseable, + wantVerifierHit: true, + }, + { + name: "verifier_success_proceeds_to_parse", + body: jwtBody, + verifier: &mockJWTVerifier{err: nil}, + wantErr: nil, + wantVerifierHit: true, + }, + { + name: "nil_verifier_skips_verification", + body: jwtBody, + verifier: nil, + wantErr: nil, + wantVerifierHit: false, + }, + { + name: "jsonld_body_skips_verifier", + body: testStatusListCredentialJSONLD, + verifier: &mockJWTVerifier{err: ErrorStatusListUnparseable}, + wantErr: nil, + wantVerifierHit: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var verifier StatusListJWTVerifier + if tc.verifier != nil { + verifier = tc.verifier + } + + cred, err := parseStatusListCredentialBody([]byte(tc.body), verifier) + + if tc.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tc.wantErr) + assert.Nil(t, cred) + } else { + require.NoError(t, err) + assert.NotNil(t, cred) + } + + if tc.verifier != nil { + assert.Equal(t, tc.wantVerifierHit, tc.verifier.called, "verifier.called mismatch") + } + }) + } +} + +// TestCachingStatusListClientFetchJWTVerification exercises the Fetch path +// end-to-end: the httptest server returns a JWT body, and the configured +// verifier is consulted before the credential is accepted. +func TestCachingStatusListClientFetchJWTVerification(t *testing.T) { + jwtBody := testStatusListVCJWT + + tests := []struct { + name string + verifier *mockJWTVerifier + wantErr error + }{ + { + name: "verifier_rejects_jwt_body", + verifier: &mockJWTVerifier{err: ErrorStatusListUnparseable}, + wantErr: ErrorStatusListUnparseable, + }, + { + name: "verifier_accepts_jwt_body", + verifier: &mockJWTVerifier{err: nil}, + wantErr: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", ContentTypeCredentialJWT) + _, _ = w.Write([]byte(jwtBody)) + })) + defer srv.Close() + + client := NewCachingStatusListClient(testStatusListHTTPTimeout, testStatusListCacheExpiry, tc.verifier) + cred, err := client.Fetch(srv.URL) + + if tc.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tc.wantErr) + assert.Nil(t, cred) + } else { + require.NoError(t, err) + assert.NotNil(t, cred) + } + + assert.True(t, tc.verifier.called, "verifier should have been called for JWT body") + }) + } +} diff --git a/verifier/verifier.go b/verifier/verifier.go index c934c9ca..f9a5cc0c 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -361,10 +361,10 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi // no-op for that type. statusListHttpTimeout := time.Duration(verifierConfig.StatusListHttpTimeout) * time.Second statusListCacheExpiry := time.Duration(verifierConfig.StatusListCacheExpiry) * time.Second - statusListClient := NewCachingStatusListClient(statusListHttpTimeout, statusListCacheExpiry) statusListDIDRegistry := did.NewRegistry(did.WithVDR(did.NewWebVDR()), did.WithVDR(did.NewKeyVDR()), did.WithVDR(did.NewJWKVDR())) - ietfJWTVerifier := NewStatusListJWTVerifier(statusListDIDRegistry) - ietfStatusListClient := NewCachingIETFStatusListClient(statusListHttpTimeout, statusListCacheExpiry, ietfJWTVerifier, clock) + statusListJWTVerifier := NewStatusListJWTVerifier(statusListDIDRegistry) + statusListClient := NewCachingStatusListClient(statusListHttpTimeout, statusListCacheExpiry, statusListJWTVerifier) + ietfStatusListClient := NewCachingIETFStatusListClient(statusListHttpTimeout, statusListCacheExpiry, statusListJWTVerifier, clock) credentialStatusVerificationService := NewCredentialStatusValidationService(statusListClient, ietfStatusListClient, clock) key, err := initPrivateKey(verifierConfig.KeyAlgorithm, verifierConfig.GenerateKey, verifierConfig.KeyPath) From 0f5032355d5afe9a3ce46a3e873bd5753e6dc155 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Wed, 1 Jul 2026 07:18:07 +0200 Subject: [PATCH 6/6] add validation in parsing --- common/credential_status.go | 10 +++++++--- common/credential_status_test.go | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/common/credential_status.go b/common/credential_status.go index 76485039..13b2667f 100644 --- a/common/credential_status.go +++ b/common/credential_status.go @@ -94,6 +94,10 @@ const ( BitsPerByte = 8 ) +// validStatusSizeValues is the set of allowed values for `statusSize` in a +// W3C Bitstring Status List entry, per the VC Bitstring Status List v1.0 spec. +var validStatusSizeValues = map[int]bool{1: true, 2: true, 4: true, 8: true} + // Typed errors returned by the helpers in this file. They are exported so // callers can match against them using `errors.Is`. var ( @@ -296,8 +300,8 @@ func parseStatusSize(raw interface{}) (int, error) { default: return 0, fmt.Errorf("%w: %q must be an integer, got %T", ErrorStatusListEntryMalformed, StatusListEntryKeyStatusSize, raw) } - if size <= 0 { - return 0, fmt.Errorf("%w: %q must be positive, got %d", ErrorStatusListInvalidStatusSize, StatusListEntryKeyStatusSize, size) + if !validStatusSizeValues[size] { + return 0, fmt.Errorf("%w: %q must be one of {1, 2, 4, 8}, got %d", ErrorStatusListInvalidStatusSize, StatusListEntryKeyStatusSize, size) } return size, nil } @@ -349,7 +353,7 @@ func DecodeBitstring(encoded string) ([]byte, error) { // covered by `bitstring`, and ErrorStatusListInvalidStatusSize when // `statusSize` is not a positive integer. func IsStatusSet(bitstring []byte, index uint64, statusSize int) (bool, error) { - if statusSize <= 0 { + if !validStatusSizeValues[statusSize] { return false, fmt.Errorf("%w: got %d", ErrorStatusListInvalidStatusSize, statusSize) } diff --git a/common/credential_status_test.go b/common/credential_status_test.go index c0745598..53f55e41 100644 --- a/common/credential_status_test.go +++ b/common/credential_status_test.go @@ -154,13 +154,21 @@ func TestParseStatusListEntries(t *testing.T) { wantErr: ErrorStatusListEntryMalformed, }, { - name: "invalid statusSize", + name: "invalid statusSize zero", input: map[string]interface{}{ StatusListEntryKeyStatusListIndex: "0", StatusListEntryKeyStatusSize: float64(0), }, wantErr: ErrorStatusListInvalidStatusSize, }, + { + name: "invalid statusSize not power of two", + input: map[string]interface{}{ + StatusListEntryKeyStatusListIndex: "0", + StatusListEntryKeyStatusSize: float64(3), + }, + wantErr: ErrorStatusListInvalidStatusSize, + }, } for _, tc := range tests { @@ -269,6 +277,7 @@ func TestIsStatusSet(t *testing.T) { {name: "index out of range", index: 16, statusSize: 1, wantErr: ErrorStatusListIndexOutOfRange}, {name: "zero statusSize", index: 0, statusSize: 0, wantErr: ErrorStatusListInvalidStatusSize}, {name: "negative statusSize", index: 0, statusSize: -1, wantErr: ErrorStatusListInvalidStatusSize}, + {name: "invalid statusSize not in allowed set", index: 0, statusSize: 3, wantErr: ErrorStatusListInvalidStatusSize}, {name: "multi-bit group all clear", index: 1, statusSize: 2, want: false}, // bits 2,3 = 00 {name: "multi-bit group with set bit", index: 3, statusSize: 2, want: true}, // bits 6,7 = 01 {name: "multi-bit group exceeds bitstring", index: 8, statusSize: 2, wantErr: ErrorStatusListIndexOutOfRange},