@sphereon/ssi-sdk.vc-status-list
Version:
Sphereon SSI-SDK plugin for Status List management, like StatusList2021.
302 lines (267 loc) • 12.2 kB
text/typescript
import type { IAgentContext, IKeyManager } from '@veramo/core'
import { type CompactJWT, type CredentialProofFormat, type CWT, StatusListType } from '@sphereon/ssi-types'
import type {
CheckStatusIndexArgs,
CreateStatusListArgs,
IMergeDetailsWithEntityArgs,
IToDetailsFromCredentialArgs,
SignedStatusListData,
StatusListOAuthEntryCredentialStatus,
StatusListResult,
StatusOAuth,
UpdateStatusListFromEncodedListArgs,
UpdateStatusListIndexArgs,
} from '../types'
import { determineProofFormat, ensureDate, getAssertedValue, getAssertedValues } from '../utils'
import type { IExtractedCredentialDetails, IOAuthStatusListImplementationResult, IStatusList } from './IStatusList'
import { StatusList } from '@sd-jwt/jwt-status-list'
import type { IJwtService } from '@sphereon/ssi-sdk-ext.jwt-service'
import type { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution'
import { createSignedJwt, decodeStatusListJWT } from './encoding/jwt'
import { createSignedCbor, decodeStatusListCWT } from './encoding/cbor'
import { IBitstringStatusListEntryEntity, IStatusListEntryEntity, OAuthStatusListEntity, StatusListEntity } from '@sphereon/ssi-sdk.data-store'
import { IVcdmCredentialPlugin } from '@sphereon/ssi-sdk.credential-vcdm'
type IRequiredContext = IAgentContext<IVcdmCredentialPlugin & IJwtService & IIdentifierResolution & IKeyManager>
export const DEFAULT_BITS_PER_STATUS = 1 // 1 bit is sufficient for 0x00 - "VALID" 0x01 - "INVALID" saving space in the process
export const DEFAULT_LIST_LENGTH = 250000
export const DEFAULT_PROOF_FORMAT = 'jwt' as CredentialProofFormat
export class OAuthStatusListImplementation implements IStatusList {
async createNewStatusList(args: CreateStatusListArgs, context: IRequiredContext): Promise<StatusListResult> {
if (!args.oauthStatusList) {
throw new Error('OAuthStatusList options are required for type OAuthStatusList')
}
const proofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT
const { issuer, id, oauthStatusList, keyRef } = args
const { bitsPerStatus } = oauthStatusList
const expiresAt = ensureDate(oauthStatusList.expiresAt)
const length = args.length ?? DEFAULT_LIST_LENGTH
const issuerString = typeof issuer === 'string' ? issuer : issuer.id
const correlationId = getAssertedValue('correlationId', args.correlationId)
const statusList = new StatusList(new Array(length).fill(0), bitsPerStatus ?? DEFAULT_BITS_PER_STATUS)
const encodedList = statusList.compressStatusList()
const { statusListCredential } = await this.createSignedStatusList(proofFormat, context, statusList, issuerString, id, expiresAt, keyRef)
return {
encodedList,
statusListCredential,
oauthStatusList: { bitsPerStatus },
length,
type: StatusListType.OAuthStatusList,
proofFormat,
id,
correlationId,
issuer,
statuslistContentType: this.buildContentType(proofFormat),
}
}
async updateStatusListIndex(args: UpdateStatusListIndexArgs, context: IRequiredContext): Promise<StatusListResult> {
const { statusListCredential, value, keyRef } = args
const expiresAt = ensureDate(args.expiresAt)
if (typeof statusListCredential !== 'string') {
return Promise.reject('statusListCredential in neither JWT nor CWT')
}
const proofFormat = determineProofFormat(statusListCredential)
const decoded = proofFormat === 'jwt' ? decodeStatusListJWT(statusListCredential) : decodeStatusListCWT(statusListCredential)
const { statusList, issuer, id } = decoded
const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex)
if (index < 0 || index >= statusList.statusList.length) {
throw new Error('Status list index out of bounds')
}
if (typeof value !== 'number') {
throw new Error('Status list values should be of type number')
}
statusList.setStatus(index, value)
const { statusListCredential: signedCredential, encodedList } = await this.createSignedStatusList(
proofFormat,
context,
statusList,
issuer,
id,
expiresAt,
keyRef,
)
return {
statusListCredential: signedCredential,
encodedList,
oauthStatusList: {
bitsPerStatus: statusList.getBitsPerStatus(),
},
length: statusList.statusList.length,
type: StatusListType.OAuthStatusList,
proofFormat,
id,
issuer,
statuslistContentType: this.buildContentType(proofFormat),
}
}
// FIXME: This still assumes only two values (boolean), whilst this list supports 8 bits max
async updateStatusListFromEncodedList(args: UpdateStatusListFromEncodedListArgs, context: IRequiredContext): Promise<StatusListResult> {
if (!args.oauthStatusList) {
throw new Error('OAuthStatusList options are required for type OAuthStatusList')
}
const { proofFormat, oauthStatusList, keyRef } = args
const { bitsPerStatus } = oauthStatusList
const expiresAt = ensureDate(oauthStatusList.expiresAt)
const { issuer, id } = getAssertedValues(args)
const issuerString = typeof issuer === 'string' ? issuer : issuer.id
const listToUpdate = StatusList.decompressStatusList(args.encodedList, bitsPerStatus ?? DEFAULT_BITS_PER_STATUS)
const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex)
listToUpdate.setStatus(index, args.value)
const { statusListCredential, encodedList } = await this.createSignedStatusList(
proofFormat ?? DEFAULT_PROOF_FORMAT,
context,
listToUpdate,
issuerString,
id,
expiresAt,
keyRef,
)
return {
encodedList,
statusListCredential,
oauthStatusList: {
bitsPerStatus,
expiresAt,
},
length: listToUpdate.statusList.length,
type: StatusListType.OAuthStatusList,
proofFormat: proofFormat ?? DEFAULT_PROOF_FORMAT,
id,
issuer,
statuslistContentType: this.buildContentType(proofFormat),
}
}
async checkStatusIndex(args: CheckStatusIndexArgs): Promise<number | StatusOAuth> {
const { statusListCredential, statusListIndex } = args
if (typeof statusListCredential !== 'string') {
return Promise.reject('statusListCredential in neither JWT nor CWT')
}
const proofFormat = determineProofFormat(statusListCredential)
const { statusList } = proofFormat === 'jwt' ? decodeStatusListJWT(statusListCredential) : decodeStatusListCWT(statusListCredential)
const index = typeof statusListIndex === 'number' ? statusListIndex : parseInt(statusListIndex)
if (index < 0 || index >= statusList.statusList.length) {
throw new Error(`Status list index out of bounds, has ${statusList.statusList.length} items, requested ${index}`)
}
return statusList.getStatus(index)
}
/**
* 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: CompactJWT | CWT): Promise<IExtractedCredentialDetails> {
if (typeof credential !== 'string') {
return Promise.reject('statusListCredential must be a JWT or CWT string')
}
const proofFormat = determineProofFormat(credential)
const decoded = proofFormat === 'jwt' ? decodeStatusListJWT(credential) : decodeStatusListCWT(credential)
return {
id: decoded.id,
issuer: decoded.issuer,
encodedList: decoded.statusList.compressStatusList(),
decodedPayload: decoded,
}
}
// For CREATE and READ contexts
async toStatusListDetails(args: IToDetailsFromCredentialArgs): Promise<StatusListResult & IOAuthStatusListImplementationResult>
// For UPDATE contexts
async toStatusListDetails(args: IMergeDetailsWithEntityArgs): Promise<StatusListResult & IOAuthStatusListImplementationResult>
async toStatusListDetails(
args: IToDetailsFromCredentialArgs | IMergeDetailsWithEntityArgs,
): Promise<StatusListResult & IOAuthStatusListImplementationResult> {
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 OAuth status lists and must be 1 or higher'))
}
const proofFormat = determineProofFormat(statusListCredential as string)
const decoded =
proofFormat === 'jwt' ? decodeStatusListJWT(statusListCredential as string) : decodeStatusListCWT(statusListCredential as string)
const { statusList, issuer, id, exp } = decoded
const expiresAt = exp ? new Date(exp * 1000) : undefined
return {
id,
encodedList: statusList.compressStatusList(),
issuer,
type: StatusListType.OAuthStatusList,
proofFormat,
length: statusList.statusList.length,
statusListCredential: statusListCredential as CompactJWT | CWT,
statuslistContentType: this.buildContentType(proofFormat),
correlationId,
driverType,
bitsPerStatus,
...(expiresAt && { expiresAt }),
oauthStatusList: {
bitsPerStatus,
...(expiresAt && { expiresAt }),
},
}
} else {
// UPDATE context
const { extractedDetails, statusListEntity } = args
const oauthEntity = statusListEntity as OAuthStatusListEntity
const decoded = extractedDetails.decodedPayload as { statusList: StatusList; exp?: number }
const proofFormat = determineProofFormat(statusListEntity.statusListCredential as string)
const expiresAt = decoded.exp ? new Date(decoded.exp * 1000) : undefined
return {
id: extractedDetails.id,
encodedList: extractedDetails.encodedList,
issuer: extractedDetails.issuer,
type: StatusListType.OAuthStatusList,
proofFormat,
length: decoded.statusList.statusList.length,
statusListCredential: statusListEntity.statusListCredential!,
statuslistContentType: this.buildContentType(proofFormat),
correlationId: statusListEntity.correlationId,
driverType: statusListEntity.driverType,
bitsPerStatus: oauthEntity.bitsPerStatus,
...(expiresAt && { expiresAt }),
oauthStatusList: {
bitsPerStatus: oauthEntity.bitsPerStatus,
...(expiresAt && { expiresAt }),
},
}
}
}
async createCredentialStatus(args: {
statusList: StatusListEntity
statusListEntry: IStatusListEntryEntity | IBitstringStatusListEntryEntity
statusListIndex: number
}): Promise<StatusListOAuthEntryCredentialStatus> {
const { statusList, statusListIndex } = args
// Cast to OAuthStatusListEntity to access specific properties
const oauthStatusList = statusList as OAuthStatusListEntity
return {
id: `${statusList.id}#${statusListIndex}`,
type: 'OAuthStatusListEntry',
bitsPerStatus: oauthStatusList.bitsPerStatus,
statusListIndex: '' + statusListIndex,
statusListCredential: statusList.id,
expiresAt: oauthStatusList.expiresAt,
}
}
private buildContentType(proofFormat: CredentialProofFormat | undefined) {
return `application/statuslist+${proofFormat === 'cbor' ? 'cwt' : 'jwt'}`
}
private async createSignedStatusList(
proofFormat: CredentialProofFormat,
context: IAgentContext<IVcdmCredentialPlugin & IJwtService & IIdentifierResolution & IKeyManager>,
statusList: StatusList,
issuerString: string,
id: string,
expiresAt?: Date,
keyRef?: string,
): Promise<SignedStatusListData> {
switch (proofFormat) {
case 'jwt': {
return await createSignedJwt(context, statusList, issuerString, id, expiresAt, keyRef)
}
case 'cbor': {
return await createSignedCbor(context, statusList, issuerString, id, expiresAt, keyRef)
}
default:
throw new Error(`Invalid proof format '${proofFormat}' for OAuthStatusList`)
}
}
}