Skip to content

Commit

Permalink
chore: Added test coverage to statusListCredentialToDetails and fixed…
Browse files Browse the repository at this point in the history
… expiresAt handling
  • Loading branch information
sanderPostma committed Jan 27, 2025
1 parent 6ac08a7 commit 0fb7aec
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 37 deletions.
92 changes: 91 additions & 1 deletion packages/vc-status-list-tests/__tests__/statuslist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createNewStatusList,
Status2021,
statusList2021ToVerifiableCredential,
statusListCredentialToDetails,
StatusOAuth,
updateStatusIndexFromStatusListCredential,
updateStatusListIndexFromEncodedList,
Expand All @@ -28,7 +29,7 @@ import {
} from '@sphereon/ssi-sdk.vc-handler-ld-local'
// @ts-ignore
import nock from 'nock'
import { StatusListType } from '@sphereon/ssi-types'
import { StatusListDriverType, StatusListType } from '@sphereon/ssi-types'
import { JwtService } from '@sphereon/ssi-sdk-ext.jwt-service'

jest.setTimeout(100000)
Expand Down Expand Up @@ -342,4 +343,93 @@ describe('Status list', () => {
).rejects.toThrow()
})
})

describe('statusListCredentialToDetails', () => {
it('should handle StatusList2021 JWT credential', async () => {
const initialList = await createNewStatusList(
{
type: StatusListType.StatusList2021,
proofFormat: 'jwt',
id: 'http://localhost:9543/details1',
issuer: didKeyIdentifier.did,
length: 1000,
correlationId: 'test-details-1',
statusList2021: {
indexingDirection: 'rightToLeft',
},
},
{ agent },
)

const details = await statusListCredentialToDetails({
statusListCredential: initialList.statusListCredential,
correlationId: 'test-details-1',
driverType: StatusListDriverType.AGENT_TYPEORM,
})

expect(details.type).toBe(StatusListType.StatusList2021)
expect(details.proofFormat).toBe('jwt')
expect(details.correlationId).toBe('test-details-1')
expect(details.driverType).toBe(StatusListDriverType.AGENT_TYPEORM)
expect(details.statusList2021?.indexingDirection).toBe('rightToLeft')
})

it('should handle OAuthStatusList credential', async () => {
const initialList = await createNewStatusList(
{
type: StatusListType.OAuthStatusList,
proofFormat: 'jwt',
id: 'http://localhost:9543/details2',
issuer: didKeyIdentifier.did,
length: 1000,
correlationId: 'test-details-2',
oauthStatusList: {
bitsPerStatus: 2,
expiresAt: new Date('2025-01-01'),
},
},
{ agent },
)

const details = await statusListCredentialToDetails({
statusListCredential: initialList.statusListCredential,
correlationId: 'test-details-2',
})

expect(details.type).toBe(StatusListType.OAuthStatusList)
expect(details.proofFormat).toBe('jwt')
expect(details.correlationId).toBe('test-details-2')
expect(details.oauthStatusList?.bitsPerStatus).toBe(2)
expect(details.oauthStatusList?.expiresAt).toEqual(new Date('2025-01-01'))
})

it('should handle OAuthStatusList with CBOR format', async () => {
const initialList = await createNewStatusList(
{
type: StatusListType.OAuthStatusList,
proofFormat: 'cbor',
id: 'http://localhost:9543/details3',
issuer: didKeyIdentifier.did,
length: 1000,
correlationId: 'test-details-3',
oauthStatusList: {
bitsPerStatus: 2,
expiresAt: new Date('2025-01-01'),
},
},
{ agent },
)

const details = await statusListCredentialToDetails({
statusListCredential: initialList.statusListCredential,
correlationId: 'test-details-3',
})

expect(details.type).toBe(StatusListType.OAuthStatusList)
expect(details.proofFormat).toBe('cbor')
expect(details.correlationId).toBe('test-details-3')
expect(details.oauthStatusList?.bitsPerStatus).toBe(2)
expect(details.oauthStatusList?.expiresAt).toEqual(new Date('2025-01-01'))
})
})
})
36 changes: 30 additions & 6 deletions packages/vc-status-list/src/functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution'
import { CredentialMapper, ProofFormat, StatusListCredential, StatusListDriverType, StatusListType, StatusPurpose2021 } from '@sphereon/ssi-types'
import {
CredentialMapper,
DocumentFormat,
ProofFormat,
StatusListCredential,
StatusListDriverType,
StatusListType,
StatusPurpose2021,
} from '@sphereon/ssi-types'
import { CredentialStatus, DIDDocument, IAgentContext, ICredentialPlugin, ProofFormat as VeramoProofFormat } from '@veramo/core'

import { checkStatus } from '@sphereon/vc-status-list'
Expand Down Expand Up @@ -171,12 +179,28 @@ export async function statusListCredentialToDetails(args: {
driverType?: StatusListDriverType
}): Promise<StatusListResult> {
const credential = getAssertedValue('statusListCredential', args.statusListCredential)
const uniform = CredentialMapper.toUniformCredential(credential)
const type = uniform.type.find((t) => t.includes('StatusList2021') || t.includes('OAuth2StatusList'))
if (!type) {
throw new Error('Invalid status list credential type')

let statusListType: StatusListType | undefined
const documentFormat = CredentialMapper.detectDocumentType(credential)
if (documentFormat === DocumentFormat.JWT) {
const [header] = credential.split('.')
const decodedHeader = JSON.parse(Buffer.from(header, 'base64').toString())

if (decodedHeader.typ === 'statuslist+jwt') {
statusListType = StatusListType.OAuthStatusList
}
} else if (documentFormat === DocumentFormat.MSO_MDOC) {
statusListType = StatusListType.OAuthStatusList
// TODO check CBOR content?
}
if (!statusListType) {
const uniform = CredentialMapper.toUniformCredential(credential)
const type = uniform.type.find((t) => t.includes('StatusList2021') || t.includes('OAuth2StatusList'))
if (!type) {
throw new Error('Invalid status list credential type')
}
statusListType = type.replace('Credential', '') as StatusListType
}
const statusListType = type.replace('Credential', '') as StatusListType // "StatusList2021Credential" is a VC schema type and does not map 1:1 to our internal StatusListType enum

const implementation = getStatusListImplementation(statusListType)
return await implementation.toStatusListDetails({
Expand Down
38 changes: 25 additions & 13 deletions packages/vc-status-list/src/impl/OAuthStatusList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ export class OAuthStatusListImplementation implements IStatusList {
}

const proofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT
const { issuer, id, keyRef } = args
const { issuer, id, oauthStatusList, keyRef } = args
const { bitsPerStatus, expiresAt } = oauthStatusList
const length = args.length ?? DEFAULT_LIST_LENGTH
const bitsPerStatus = args.oauthStatusList.bitsPerStatus ?? DEFAULT_BITS_PER_STATUS
const issuerString = typeof issuer === 'string' ? issuer : issuer.id
const correlationId = getAssertedValue('correlationId', args.correlationId)

const statusList = new StatusList(new Array(length).fill(0), bitsPerStatus)
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, keyRef)
const { statusListCredential } = await this.createSignedStatusList(proofFormat, context, statusList, issuerString, id, expiresAt, keyRef)

return {
encodedList,
Expand All @@ -55,7 +55,7 @@ export class OAuthStatusListImplementation implements IStatusList {
}

async updateStatusListIndex(args: UpdateStatusListIndexArgs, context: IRequiredContext): Promise<StatusListResult> {
const { statusListCredential, value, keyRef } = args
const { statusListCredential, value, expiresAt, keyRef } = args
if (typeof statusListCredential !== 'string') {
return Promise.reject('statusListCredential in neither JWT nor CWT')
}
Expand All @@ -76,6 +76,7 @@ export class OAuthStatusListImplementation implements IStatusList {
statusList,
issuer,
id,
expiresAt,
keyRef,
)

Expand All @@ -97,27 +98,36 @@ export class OAuthStatusListImplementation implements IStatusList {
if (!args.oauthStatusList) {
throw new Error('OAuthStatusList options are required for type OAuthStatusList')
}
const { proofFormat, oauthStatusList, keyRef } = args
const { bitsPerStatus, expiresAt } = oauthStatusList

const proofFormat = args.proofFormat ?? DEFAULT_PROOF_FORMAT
const { issuer, id } = getAssertedValues(args)
const bitsPerStatus = args.oauthStatusList.bitsPerStatus ?? DEFAULT_BITS_PER_STATUS
const issuerString = typeof issuer === 'string' ? issuer : issuer.id

const listToUpdate = StatusList.decompressStatusList(args.encodedList, bitsPerStatus)
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 ? 1 : 0)

const { statusListCredential, encodedList } = await this.createSignedStatusList(proofFormat, context, listToUpdate, issuerString, id, args.keyRef)
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: proofFormat ?? DEFAULT_PROOF_FORMAT,
id,
issuer,
}
Expand All @@ -144,7 +154,7 @@ export class OAuthStatusListImplementation implements IStatusList {
const { statusListPayload } = args as { statusListPayload: CompactJWT | CWT }
const proofFormat = determineProofFormat(statusListPayload)
const decoded = proofFormat === 'jwt' ? decodeStatusListJWT(statusListPayload) : decodeStatusListCWT(statusListPayload)
const { statusList, issuer, id } = decoded
const { statusList, issuer, id, exp } = decoded

return {
id,
Expand All @@ -156,6 +166,7 @@ export class OAuthStatusListImplementation implements IStatusList {
statusListCredential: statusListPayload,
oauthStatusList: {
bitsPerStatus: statusList.getBitsPerStatus(),
...(exp && { expiresAt: new Date(exp * 1000) }),
},
...(args.correlationId && { correlationId: args.correlationId }),
...(args.driverType && { driverType: args.driverType }),
Expand All @@ -168,14 +179,15 @@ export class OAuthStatusListImplementation implements IStatusList {
statusList: StatusList,
issuerString: string,
id: string,
expiresAt?: Date,
keyRef?: string,
): Promise<SignedStatusListData> {
switch (proofFormat) {
case 'jwt': {
return await createSignedJwt(context, statusList, issuerString, id, keyRef)
return await createSignedJwt(context, statusList, issuerString, id, expiresAt, keyRef)
}
case 'cbor': {
return await createSignedCbor(context, statusList, issuerString, id, keyRef)
return await createSignedCbor(context, statusList, issuerString, id, expiresAt, keyRef)
}
default:
throw new Error(`Invalid proof format '${proofFormat}' for OAuthStatusList`)
Expand Down
42 changes: 26 additions & 16 deletions packages/vc-status-list/src/impl/encoding/cbor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const createSignedCbor = async (
statusList: StatusList,
issuerString: string,
id: string,
expiresAt?: Date,
keyRef?: string,
): Promise<SignedStatusListData> => {
const identifier = await resolveIdentifier(context, issuerString, keyRef)
Expand All @@ -47,7 +48,7 @@ export const createSignedCbor = async (
),
)
const protectedHeaderEncoded = cbor.Cbor.encode(protectedHeader)
const claimsMap = buildClaimsMap(id, issuerString, statusListMap)
const claimsMap = buildClaimsMap(id, issuerString, statusListMap, expiresAt)
const claimsEncoded: Int8Array = cbor.Cbor.encode(claimsMap)

const signedCWT: string = await context.agent.keyManagerSign({
Expand All @@ -61,14 +62,12 @@ export const createSignedCbor = async (
const signatureBytes = base64url.decode(signedCWT)
const signatureInt8 = new Int8Array(Buffer.from(signatureBytes))

const cwtArray = new cbor.CborArray(
kotlin.collections.KtMutableList.fromJsArray([
new cbor.CborByteString(protectedHeaderEncodedInt8),
new cbor.CborByteString(claimsEncodedInt8),
new cbor.CborByteString(signatureInt8),
]),
)

const cwtArrayElements: Array<com.sphereon.cbor.CborItem<any>> = [
new cbor.CborByteString(protectedHeaderEncodedInt8),
new cbor.CborByteString(claimsEncodedInt8),
new cbor.CborByteString(signatureInt8),
]
const cwtArray = new cbor.CborArray(kotlin.collections.KtMutableList.fromJsArray(cwtArrayElements))
const cwtEncoded = cbor.Cbor.encode(cwtArray)
const cwtBuffer = Buffer.from(cwtEncoded)
return {
Expand All @@ -81,8 +80,8 @@ function buildClaimsMap(
id: string,
issuerString: string,
statusListMap: com.sphereon.cbor.CborMap<com.sphereon.cbor.CborString, com.sphereon.cbor.CborItem<any>>,
expiresAt?: Date,
) {
const exp = Math.floor(new Date().getTime() / 1000)
const ttl = 65535 // FIXME figure out what value should be / come from and what the difference is with exp
const claimsEntries: Array<[com.sphereon.cbor.CborUInt, com.sphereon.cbor.CborItem<any>]> = [
[new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.SUBJECT)), new cbor.CborString(id)], // "sub"
Expand All @@ -93,10 +92,10 @@ function buildClaimsMap(
],
]

if (exp) {
if (expiresAt) {
claimsEntries.push([
new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.EXPIRATION)),
new cbor.CborUInt(kmp.LongKMP.fromNumber(exp)), // "exp"
new cbor.CborUInt(kmp.LongKMP.fromNumber(Math.floor(expiresAt.getTime() / 1000))), // "exp"
])
}

Expand All @@ -113,10 +112,21 @@ function buildClaimsMap(
return claimsMap
}

const getCborValueFromMap = <T>(map: Map<com.sphereon.cbor.CborItem<any>, com.sphereon.cbor.CborItem<any>>, key: number): T | never => {
const getCborValueFromMap = <T>(map: Map<com.sphereon.cbor.CborItem<any>, com.sphereon.cbor.CborItem<any>>, key: number): T => {
const value = getCborOptionalValueFromMap<T>(map, key)
if (value === undefined) {
throw new Error(`Required claim ${key} not found`)
}
return value
}

const getCborOptionalValueFromMap = <T>(
map: Map<com.sphereon.cbor.CborItem<any>, com.sphereon.cbor.CborItem<any>>,
key: number,
): T | undefined | never => {
const value = map.get(new com.sphereon.cbor.CborUInt(kmp.LongKMP.fromNumber(key)))
if (!value) {
throw new Error(`Required claim ${key} not found`)
return undefined
}
return value.value as T
}
Expand Down Expand Up @@ -154,8 +164,8 @@ export const decodeStatusListCWT = (cwt: string): DecodedStatusListPayload => {
issuer: getCborValueFromMap<string>(claimsMap, CWT_CLAIMS.ISSUER),
id: getCborValueFromMap<string>(claimsMap, CWT_CLAIMS.SUBJECT),
statusList,
exp: getCborValueFromMap<number>(claimsMap, CWT_CLAIMS.EXPIRATION),
ttl: getCborValueFromMap<number>(claimsMap, CWT_CLAIMS.TIME_TO_LIVE),
iat: Number(getCborValueFromMap<number>(claimsMap, CWT_CLAIMS.ISSUED_AT)),
exp: getCborOptionalValueFromMap<number>(claimsMap, CWT_CLAIMS.EXPIRATION),
ttl: getCborOptionalValueFromMap<number>(claimsMap, CWT_CLAIMS.TIME_TO_LIVE),
}
}
2 changes: 2 additions & 0 deletions packages/vc-status-list/src/impl/encoding/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const createSignedJwt = async (
statusList: StatusList,
issuerString: string,
id: string,
expiresAt?: Date,
keyRef?: string,
): Promise<SignedStatusListData> => {
const identifier = await resolveIdentifier(context, issuerString, keyRef)
Expand All @@ -23,6 +24,7 @@ export const createSignedJwt = async (
iss: issuerString,
sub: id,
iat: Math.floor(Date.now() / 1000),
...(expiresAt && { exp: Math.floor(expiresAt.getTime() / 1000) }),
}

const header: StatusListJWTHeaderParameters = {
Expand Down
3 changes: 2 additions & 1 deletion packages/vc-status-list/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,10 @@ export interface CreateStatusListArgs {

export interface UpdateStatusListIndexArgs {
statusListCredential: StatusListCredential // | CompactJWT
keyRef?: string
statusListIndex: number | string
value: number | Status2021 | StatusOAuth
keyRef?: string
expiresAt?: Date
}

export interface CheckStatusIndexArgs {
Expand Down

0 comments on commit 0fb7aec

Please sign in to comment.