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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions verifier/credential_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions verifier/credential_status_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions verifier/credential_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading