diff --git a/verifier/credential_status.go b/verifier/credential_status.go index 50d69b2..ae6009c 100644 --- a/verifier/credential_status.go +++ b/verifier/credential_status.go @@ -204,6 +204,10 @@ func (s *CredentialStatusValidationService) extractIETFStatusEntry(cred *common. // validateIETFStatus fetches the IETF Token Status List JWT referenced by // the entry and checks whether the bit at the entry's index is set. +// When the index falls outside the cached bitstring, the cache entry is +// evicted and a fresh copy is fetched once before returning an error. +// This handles the race where a credential is issued with a new index +// that extends the list beyond the previously cached size. 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) @@ -212,6 +216,18 @@ func (s *CredentialStatusValidationService) validateIETFStatus(cred *common.Cred return false, fmt.Errorf("%w: IETF status list client not configured", ErrorStatusListHttpFailure) } + valid, err := s.checkIETFStatusList(cred, entry) + if err != nil && errors.Is(err, common.ErrorStatusListIndexOutOfRange) { + logging.Log().Debugf("Index %d out of range for cached status list at %s, retrying with fresh fetch", entry.Idx, entry.URI) + s.ietfClient.InvalidateIETF(entry.URI) + return s.checkIETFStatusList(cred, entry) + } + return valid, err +} + +// checkIETFStatusList performs a single fetch-decode-check cycle for +// an IETF Token Status List entry. +func (s *CredentialStatusValidationService) checkIETFStatusList(cred *common.Credential, entry *common.IETFStatusEntry) (bool, error) { 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) @@ -227,6 +243,9 @@ func (s *CredentialStatusValidationService) validateIETFStatus(cred *common.Cred 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) + if errors.Is(err, common.ErrorStatusListIndexOutOfRange) { + return false, err + } return false, fmt.Errorf("%w: %v", ErrorStatusListUnparseable, err) } if set { diff --git a/verifier/credential_status_client.go b/verifier/credential_status_client.go index 8009bed..abfc25b 100644 --- a/verifier/credential_status_client.go +++ b/verifier/credential_status_client.go @@ -270,6 +270,10 @@ 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) + + // InvalidateIETF removes a cached status list entry so the next + // FetchIETF call retrieves a fresh copy from the origin. + InvalidateIETF(uri string) } // StatusListJWTVerifier verifies the signature of an IETF Token Status List @@ -516,6 +520,12 @@ func (c *CachingIETFStatusListClient) FetchIETF(uri string) (*common.IETFStatusL return result.statusList, nil } +// InvalidateIETF removes the cached status list for the given URI so the +// next FetchIETF call fetches a fresh copy from the origin server. +func (c *CachingIETFStatusListClient) InvalidateIETF(uri string) { + c.cache.Delete(uri) +} + // 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. diff --git a/verifier/credential_status_test.go b/verifier/credential_status_test.go index b78ec4f..c700404 100644 --- a/verifier/credential_status_test.go +++ b/verifier/credential_status_test.go @@ -386,13 +386,24 @@ type mockIETFStatusListClient struct { statusList *common.IETFStatusList err error calls []string + // refreshedStatusList is returned after InvalidateIETF is called, + // simulating a fresh fetch with an updated list. + refreshedStatusList *common.IETFStatusList + invalidated bool } func (m *mockIETFStatusListClient) FetchIETF(uri string) (*common.IETFStatusList, error) { m.calls = append(m.calls, uri) + if m.invalidated && m.refreshedStatusList != nil { + return m.refreshedStatusList, m.err + } return m.statusList, m.err } +func (m *mockIETFStatusListClient) InvalidateIETF(uri string) { + m.invalidated = true +} + // 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 { @@ -506,6 +517,83 @@ func TestCredentialStatusValidationService_IETF(t *testing.T) { } } +func TestCredentialStatusValidationService_IETF_RetryOnOutOfRange(t *testing.T) { + testURI := "https://example.org/statuslists/test-list" + + // The cached list has 1 byte (8 bits), so index 10 is out of range. + smallBits := encodeIETFTestBitstring(t, []byte{0b00000000}) + // After a fresh fetch the list has 2 bytes (16 bits), covering index 10. + grownBits := encodeIETFTestBitstring(t, []byte{0b00000000, 0b00000000}) + + tests := []struct { + testName string + index uint64 + initial *common.IETFStatusList + refreshed *common.IETFStatusList + expectedResult bool + expectedError error + expectedCalls int + }{ + { + testName: "retry succeeds after cache eviction", + index: 10, + initial: &common.IETFStatusList{Bits: 1, Lst: smallBits}, + refreshed: &common.IETFStatusList{Bits: 1, Lst: grownBits}, + expectedResult: true, + expectedError: nil, + expectedCalls: 2, + }, + { + testName: "retry still out of range after fresh fetch", + index: 20, + initial: &common.IETFStatusList{Bits: 1, Lst: smallBits}, + refreshed: &common.IETFStatusList{Bits: 1, Lst: grownBits}, + expectedResult: false, + expectedError: common.ErrorStatusListIndexOutOfRange, + expectedCalls: 2, + }, + { + testName: "no retry needed when index is in range", + index: 3, + initial: &common.IETFStatusList{Bits: 1, Lst: smallBits}, + refreshed: nil, + expectedResult: true, + expectedError: nil, + expectedCalls: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + ietfMock := &mockIETFStatusListClient{ + statusList: tc.initial, + refreshedStatusList: tc.refreshed, + } + service := NewCredentialStatusValidationService(nil, ietfMock, nil) + cred := newCredentialWithIETFStatus(t, statusValidationTestType, tc.index, testURI) + perType := map[string]configModel.CredentialStatus{ + statusValidationTestType: {Enabled: boolPtr(true)}, + } + + result, err := service.ValidateVC(cred, CredentialStatusValidationContext{PerType: 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) + } + if len(ietfMock.calls) != tc.expectedCalls { + t.Errorf("expected %d FetchIETF calls, got %d", tc.expectedCalls, len(ietfMock.calls)) + } + }) + } +} + // --------------------------------------------------------------------------- // StatusListJWTVerifierImpl tests // ---------------------------------------------------------------------------