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
69 changes: 46 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
```
Expand Down
189 changes: 185 additions & 4 deletions common/credential_status.go
Original file line number Diff line number Diff line change
@@ -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-21.html
package common

import (
"bytes"
"compress/gzip"
"compress/zlib"
"encoding/base64"
"errors"
"fmt"
Expand Down Expand Up @@ -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
Expand All @@ -69,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 (
Expand Down Expand Up @@ -204,6 +233,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)
Expand Down Expand Up @@ -269,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
}
Expand Down Expand Up @@ -322,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)
}

Expand All @@ -345,3 +376,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
}
Loading
Loading