@sphereon/ssi-sdk.vc-status-list
Version:
Sphereon SSI-SDK plugin for Status List management, like StatusList2021.
497 lines (450 loc) • 20.1 kB
text/typescript
/**
* Bitstring Status List Implementation
*
* This module implements the W3C Bitstring Status List specification for managing
* credential status information. It provides functionality to create, update, and
* check the status of verifiable credentials using compressed bitstring status lists.
*
* Key features:
* - Create new bitstring status lists with configurable purposes and bit sizes
* - Update individual credential status entries in existing lists
* - Check the status of specific credentials by index
* - Support for multiple proof formats (JWT, LDS, CBOR)
* - Integration with Veramo agent context for credential signing
*
* @author Sphereon International B.V.
* @since 2024
*/
import type { IAgentContext } from '@veramo/core'
import type { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution'
import {
CredentialMapper,
type CredentialProofFormat,
DocumentFormat,
type IIssuer,
type StatusListCredential,
StatusListType,
} from '@sphereon/ssi-types'
import type { IBitstringStatusListImplementationResult, IExtractedCredentialDetails, IStatusList } from './IStatusList'
import {
CheckStatusIndexArgs,
CreateStatusListArgs,
IMergeDetailsWithEntityArgs,
IToDetailsFromCredentialArgs,
StatusListResult,
UpdateStatusListFromEncodedListArgs,
UpdateStatusListIndexArgs,
} from '../types'
import { assertValidProofType, ensureDate, getAssertedProperty, getAssertedValue, getAssertedValues } from '../utils'
import { BitstringStatusListCredential } from '../types/BitstringStatusList'
import {
BitstreamStatusList,
BitstringStatusListCredentialUnsigned,
BitstringStatusPurpose,
createStatusListCredential,
} from '@4sure-tech/vc-bitstring-status-lists'
import {
BitstringStatusListEntity,
BitstringStatusListEntryCredentialStatus,
IBitstringStatusListEntryEntity,
IStatusListEntryEntity,
StatusListEntity,
} from '@sphereon/ssi-sdk.data-store'
import { IVcdmCredentialPlugin } from '@sphereon/ssi-sdk.credential-vcdm'
export const DEFAULT_LIST_LENGTH = 131072 // W3C spec minimum
export const DEFAULT_PROOF_FORMAT = 'vc+jwt' as CredentialProofFormat
export const DEFAULT_STATUS_PURPOSE: BitstringStatusPurpose = 'revocation'
/**
* Implementation of the IStatusList interface for W3C Bitstring Status Lists
*
* This class handles the creation, updating, and verification of bitstring status lists
* according to the W3C Bitstring Status List specification. It supports multiple
* status purposes (revocation, suspension, etc.) and various proof formats.
*/
export class BitstringStatusListImplementation implements IStatusList {
/**
* Creates a new bitstring status list with the specified configuration
*
* @param args - Configuration for the new status list including issuer, purpose, and size
* @param context - Veramo agent context for credential operations
* @returns Promise resolving to the created status list details
*/
async createNewStatusList(
args: CreateStatusListArgs,
context: IAgentContext<IVcdmCredentialPlugin & IIdentifierResolution>,
): Promise<StatusListResult> {
if (!args.bitstringStatusList) {
throw new Error('BitstringStatusList options are required for type BitstringStatusList')
}
const length = args?.length ?? DEFAULT_LIST_LENGTH
const proofFormat: CredentialProofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT
assertValidProofType(StatusListType.BitstringStatusList, proofFormat)
const { issuer, id } = args
const correlationId = getAssertedValue('correlationId', args.correlationId)
const { statusPurpose, bitsPerStatus, validFrom, validUntil, ttl } = args.bitstringStatusList
const unsignedCredential: BitstringStatusListCredentialUnsigned = await createStatusListCredential({
id,
issuer,
statusPurpose: statusPurpose ?? DEFAULT_STATUS_PURPOSE,
validFrom: ensureDate(validFrom),
validUntil: ensureDate(validUntil),
ttl,
})
const statusListCredential = await this.createVerifiableCredential(
{
unsignedCredential,
id,
issuer,
proofFormat,
keyRef: args.keyRef,
},
context,
)
return {
encodedList: unsignedCredential.credentialSubject.encodedList,
statusListCredential,
bitstringStatusList: {
statusPurpose: statusPurpose ?? DEFAULT_STATUS_PURPOSE,
...(unsignedCredential.validFrom && { validFrom: new Date(unsignedCredential.validFrom) }),
...(unsignedCredential.validUntil && { validUntil: new Date(unsignedCredential.validUntil) }),
ttl,
bitsPerStatus,
},
length,
type: StatusListType.BitstringStatusList,
proofFormat,
id,
correlationId,
issuer,
statuslistContentType: this.buildContentType(proofFormat),
}
}
/**
* Updates the status of a specific credential in an existing status list
*
* @param args - Update parameters including the status list credential, index, and new value
* @param context - Veramo agent context for credential operations
* @returns Promise resolving to the updated status list details
*/
async updateStatusListIndex(
args: UpdateStatusListIndexArgs,
context: IAgentContext<IVcdmCredentialPlugin & IIdentifierResolution>,
): Promise<StatusListResult> {
if (!args.bitsPerStatus || args.bitsPerStatus < 1) {
return Promise.reject(Error('bitsPerStatus must be set for bitstring status lists and must be 1 or higher. (updateStatusListIndex)'))
}
const credential = args.statusListCredential
const uniform = CredentialMapper.toUniformCredential(credential)
const { issuer, credentialSubject } = uniform
const id = getAssertedValue('id', uniform.id)
const origEncodedList = getAssertedProperty('encodedList', credentialSubject)
const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex)
const statusList: BitstreamStatusList = await BitstreamStatusList.decode({ encodedList: origEncodedList, statusSize: args.bitsPerStatus })
const bitstringStatusId = args.value as number
statusList.setStatus(index, bitstringStatusId)
const proofFormat = CredentialMapper.detectDocumentType(credential) === DocumentFormat.JWT ? 'vc+jwt' : 'lds'
const credSubject = Array.isArray(credentialSubject) ? credentialSubject[0] : credentialSubject
const statusPurpose = getAssertedProperty('statusPurpose', credSubject)
const validFrom = uniform.validFrom ? new Date(uniform.validFrom) : undefined
const validUntil = uniform.validUntil ? new Date(uniform.validUntil) : undefined
const ttl = credSubject.ttl
const unsignedCredential: BitstringStatusListCredentialUnsigned = await createStatusListCredential({
id,
issuer,
statusList,
statusPurpose: statusPurpose ?? DEFAULT_STATUS_PURPOSE,
validFrom: ensureDate(validFrom),
validUntil: ensureDate(validUntil),
ttl,
})
const updatedCredential = await this.createVerifiableCredential(
{
unsignedCredential,
id,
issuer,
proofFormat,
keyRef: args.keyRef,
},
context,
)
return {
statusListCredential: updatedCredential,
encodedList: unsignedCredential.credentialSubject.encodedList,
bitstringStatusList: {
statusPurpose,
...(unsignedCredential.validFrom && { validFrom: new Date(unsignedCredential.validFrom) }),
...(unsignedCredential.validUntil && { validUntil: new Date(unsignedCredential.validUntil) }),
bitsPerStatus: args.bitsPerStatus,
ttl,
},
length: statusList.getLength(),
type: StatusListType.BitstringStatusList,
proofFormat,
id,
issuer,
statuslistContentType: this.buildContentType(proofFormat),
}
}
/**
* Updates a status list by decoding an encoded list, modifying it, and re-encoding
*
* @param args - Update parameters including encoded list, index, and new value
* @param context - Veramo agent context for credential operations
* @returns Promise resolving to the updated status list details
*/
async updateStatusListFromEncodedList(
args: UpdateStatusListFromEncodedListArgs,
context: IAgentContext<IVcdmCredentialPlugin & IIdentifierResolution>,
): Promise<StatusListResult> {
if (!args.bitstringStatusList) {
throw new Error('bitstringStatusList options required for type BitstringStatusList')
}
if (args.bitstringStatusList.bitsPerStatus < 1) {
return Promise.reject(Error('bitsPerStatus must be set for bitstring status lists and must be 1 or higher. (updateStatusListFromEncodedList)'))
}
const { statusPurpose, bitsPerStatus, ttl, validFrom, validUntil } = args.bitstringStatusList
const proofFormat: CredentialProofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT
assertValidProofType(StatusListType.BitstringStatusList, proofFormat)
const { issuer, id } = getAssertedValues(args)
const statusList: BitstreamStatusList = await BitstreamStatusList.decode({ encodedList: args.encodedList, statusSize: bitsPerStatus })
const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex)
statusList.setStatus(index, args.value)
const unsignedCredential: BitstringStatusListCredentialUnsigned = await createStatusListCredential({
id,
issuer,
statusList,
statusPurpose: statusPurpose ?? DEFAULT_STATUS_PURPOSE,
validFrom: ensureDate(validFrom),
validUntil: ensureDate(validUntil),
ttl,
})
const credential = await this.createVerifiableCredential(
{
unsignedCredential,
id,
issuer,
proofFormat,
keyRef: args.keyRef,
},
context,
)
return {
type: StatusListType.BitstringStatusList,
statusListCredential: credential,
encodedList: unsignedCredential.credentialSubject.encodedList,
bitstringStatusList: {
statusPurpose,
bitsPerStatus,
...(unsignedCredential.validFrom && { validFrom: new Date(unsignedCredential.validFrom) }),
...(unsignedCredential.validUntil && { validUntil: new Date(unsignedCredential.validUntil) }),
ttl,
},
length: statusList.getLength(),
proofFormat: args.proofFormat ?? 'lds',
id,
issuer,
statuslistContentType: this.buildContentType(proofFormat),
}
}
/**
* Checks the status of a specific credential by its index in the status list
*
* @param args - Check parameters including the status list credential and index
* @returns Promise resolving to the status value at the specified index
*/
async checkStatusIndex(args: CheckStatusIndexArgs): Promise<number> {
if (!args.bitsPerStatus || args.bitsPerStatus < 1) {
return Promise.reject(Error('bitsPerStatus must be set for bitstring status lists and must be 1 or higher. (checkStatusIndex)'))
}
const uniform = CredentialMapper.toUniformCredential(args.statusListCredential)
const { credentialSubject } = uniform
const encodedList = getAssertedProperty('encodedList', credentialSubject)
const statusList = await BitstreamStatusList.decode({ encodedList, statusSize: args.bitsPerStatus })
const numIndex = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex)
if (statusList.getLength() <= numIndex) {
throw new Error(`Status list index out of bounds, has ${statusList.getLength()} entries, requested ${numIndex}`)
}
return statusList.getStatus(numIndex)
}
/**
* Performs the initial parsing of a StatusListCredential.
* This method handles expensive operations like JWT/CWT decoding once.
* It extracts all details available from the credential payload itself.
*/
async extractCredentialDetails(credential: StatusListCredential): Promise<IExtractedCredentialDetails> {
const uniform = CredentialMapper.toUniformCredential(credential)
const { issuer, credentialSubject } = uniform
const subject = Array.isArray(credentialSubject) ? credentialSubject[0] : credentialSubject
return {
id: getAssertedValue('id', uniform.id),
issuer,
encodedList: getAssertedProperty('encodedList', subject),
}
}
/**
* Converts a status list credential payload to detailed status list information
*
* @param args - Conversion parameters including the status list payload
* @returns Promise resolving to detailed status list information
*/
// For CREATE and READ contexts
async toStatusListDetails(args: IToDetailsFromCredentialArgs): Promise<StatusListResult & IBitstringStatusListImplementationResult>
// For UPDATE contexts
async toStatusListDetails(args: IMergeDetailsWithEntityArgs): Promise<StatusListResult & IBitstringStatusListImplementationResult>
async toStatusListDetails(
args: IToDetailsFromCredentialArgs | IMergeDetailsWithEntityArgs,
): Promise<StatusListResult & IBitstringStatusListImplementationResult> {
if ('statusListCredential' in args) {
// CREATE/READ context
const { statusListCredential, bitsPerStatus, correlationId, driverType } = args
if (!bitsPerStatus || bitsPerStatus < 1) {
return Promise.reject(Error('bitsPerStatus must be set for bitstring status lists and must be 1 or higher'))
}
const uniform = CredentialMapper.toUniformCredential(statusListCredential)
const { issuer, credentialSubject } = uniform
const subject = Array.isArray(credentialSubject) ? credentialSubject[0] : credentialSubject
const id = getAssertedValue('id', uniform.id)
const encodedList = getAssertedProperty('encodedList', subject)
const statusPurpose = getAssertedProperty('statusPurpose', subject)
const validFrom = uniform.validFrom ? new Date(uniform.validFrom) : undefined
const validUntil = uniform.validUntil ? new Date(uniform.validUntil) : undefined
const ttl = subject.ttl
const proofFormat: CredentialProofFormat = CredentialMapper.detectDocumentType(statusListCredential) === DocumentFormat.JWT ? 'vc+jwt' : 'lds'
const statuslistLength = BitstreamStatusList.getStatusListLength(encodedList, bitsPerStatus)
return {
id,
encodedList,
issuer,
type: StatusListType.BitstringStatusList,
proofFormat,
length: statuslistLength,
statusListCredential,
statuslistContentType: this.buildContentType(proofFormat),
correlationId,
driverType,
statusPurpose,
bitsPerStatus,
...(validFrom && { validFrom }),
...(validUntil && { validUntil }),
...(ttl && { ttl }),
bitstringStatusList: {
statusPurpose,
bitsPerStatus,
...(validFrom && { validFrom }),
...(validUntil && { validUntil }),
...(ttl && { ttl }),
},
}
} else {
// UPDATE context
const { extractedDetails, statusListEntity } = args
const bitstringEntity = statusListEntity as BitstringStatusListEntity
if (!bitstringEntity.bitsPerStatus) {
return Promise.reject(Error('bitsPerStatus must be present for a bitstring status list'))
}
const proofFormat: CredentialProofFormat =
CredentialMapper.detectDocumentType(statusListEntity.statusListCredential!) === DocumentFormat.JWT ? 'vc+jwt' : 'lds'
const statuslistLength = BitstreamStatusList.getStatusListLength(extractedDetails.encodedList, bitstringEntity.bitsPerStatus)
return {
id: extractedDetails.id,
encodedList: extractedDetails.encodedList,
issuer: extractedDetails.issuer,
type: StatusListType.BitstringStatusList,
proofFormat,
length: statuslistLength,
statusListCredential: statusListEntity.statusListCredential!,
statuslistContentType: this.buildContentType(proofFormat),
correlationId: statusListEntity.correlationId,
driverType: statusListEntity.driverType,
statusPurpose: bitstringEntity.statusPurpose,
bitsPerStatus: bitstringEntity.bitsPerStatus,
...(bitstringEntity.validFrom && { validFrom: bitstringEntity.validFrom }),
...(bitstringEntity.validUntil && { validUntil: bitstringEntity.validUntil }),
...(bitstringEntity.ttl && { ttl: bitstringEntity.ttl }),
bitstringStatusList: {
statusPurpose: bitstringEntity.statusPurpose,
bitsPerStatus: bitstringEntity.bitsPerStatus,
...(bitstringEntity.validFrom && { validFrom: bitstringEntity.validFrom }),
...(bitstringEntity.validUntil && { validUntil: bitstringEntity.validUntil }),
...(bitstringEntity.ttl && { ttl: bitstringEntity.ttl }),
},
}
}
}
/**
* Creates a credential status entry for a specific credential in a status list
*
* @param args - Parameters including the status list, entry details, and index
* @returns Promise resolving to the credential status entry
*/
async createCredentialStatus(args: {
statusList: StatusListEntity
statusListEntry: IStatusListEntryEntity | IBitstringStatusListEntryEntity
statusListIndex: number
}): Promise<BitstringStatusListEntryCredentialStatus> {
const { statusList, statusListEntry, statusListIndex } = args
const bitstringStatusList = statusList as BitstringStatusListEntity
const bitstringStatusListEntry = statusListEntry as IBitstringStatusListEntryEntity
return {
id: `${statusList.id}#${statusListIndex}`,
type: 'BitstringStatusListEntry',
statusPurpose: bitstringStatusListEntry.statusPurpose,
statusListIndex: '' + statusListIndex,
statusListCredential: statusList.id,
bitsPerStatus: bitstringStatusList.bitsPerStatus,
statusMessage: bitstringStatusListEntry.statusMessage,
statusReference: bitstringStatusListEntry.statusReference,
} satisfies BitstringStatusListEntryCredentialStatus
}
/**
* Creates a signed verifiable credential from an unsigned status list credential
*
* @param args - Parameters including the unsigned credential and signing details
* @param context - Veramo agent context for credential operations
* @returns Promise resolving to the signed credential
*/
private async createVerifiableCredential(
args: {
unsignedCredential: BitstringStatusListCredentialUnsigned
id: string
issuer: string | IIssuer
proofFormat: CredentialProofFormat
keyRef?: string
},
context: IAgentContext<IVcdmCredentialPlugin & IIdentifierResolution>,
): Promise<BitstringStatusListCredential> {
const { unsignedCredential, issuer, proofFormat, keyRef } = args
const identifier = await context.agent.identifierManagedGet({
identifier: typeof issuer === 'string' ? issuer : issuer.id,
vmRelationship: 'assertionMethod',
offlineWhenNoDIDRegistered: true,
})
const verifiableCredential = await context.agent.createVerifiableCredential({
credential: unsignedCredential,
keyRef: keyRef ?? identifier.kmsKeyRef,
proofFormat,
fetchRemoteContexts: true,
})
return CredentialMapper.toWrappedVerifiableCredential(verifiableCredential as StatusListCredential).original as BitstringStatusListCredential
}
/**
* Builds the appropriate content type string for a given proof format
*
* @param proofFormat - The proof format to build content type for
* @returns The corresponding content type string
*/
private buildContentType(proofFormat: CredentialProofFormat | undefined): string {
switch (proofFormat) {
case 'jwt':
return 'application/statuslist+jwt'
case 'cbor':
return 'application/statuslist+cwt'
case 'vc+jwt':
return 'application/statuslist+vc+jwt'
case 'lds':
return 'application/statuslist+ld+json'
default:
throw Error(`Unsupported content type '${proofFormat}' for status lists`)
}
}
}