From 0fb7aec794822e87df77ebf650fee22bbb7c6676 Mon Sep 17 00:00:00 2001 From: sanderPostma Date: Mon, 27 Jan 2025 10:25:18 +0100 Subject: [PATCH] chore: Added test coverage to statusListCredentialToDetails and fixed expiresAt handling --- .../__tests__/statuslist.test.ts | 92 ++++++++++++++++++- packages/vc-status-list/src/functions.ts | 36 ++++++-- .../src/impl/OAuthStatusList.ts | 38 +++++--- .../vc-status-list/src/impl/encoding/cbor.ts | 42 +++++---- .../vc-status-list/src/impl/encoding/jwt.ts | 2 + packages/vc-status-list/src/types/index.ts | 3 +- 6 files changed, 176 insertions(+), 37 deletions(-) diff --git a/packages/vc-status-list-tests/__tests__/statuslist.test.ts b/packages/vc-status-list-tests/__tests__/statuslist.test.ts index 5ac0192e..0ac707d4 100644 --- a/packages/vc-status-list-tests/__tests__/statuslist.test.ts +++ b/packages/vc-status-list-tests/__tests__/statuslist.test.ts @@ -13,6 +13,7 @@ import { createNewStatusList, Status2021, statusList2021ToVerifiableCredential, + statusListCredentialToDetails, StatusOAuth, updateStatusIndexFromStatusListCredential, updateStatusListIndexFromEncodedList, @@ -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) @@ -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')) + }) + }) }) diff --git a/packages/vc-status-list/src/functions.ts b/packages/vc-status-list/src/functions.ts index e0833bbe..97b4c422 100644 --- a/packages/vc-status-list/src/functions.ts +++ b/packages/vc-status-list/src/functions.ts @@ -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' @@ -171,12 +179,28 @@ export async function statusListCredentialToDetails(args: { driverType?: StatusListDriverType }): Promise { 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({ diff --git a/packages/vc-status-list/src/impl/OAuthStatusList.ts b/packages/vc-status-list/src/impl/OAuthStatusList.ts index 3b7d11eb..66615aec 100644 --- a/packages/vc-status-list/src/impl/OAuthStatusList.ts +++ b/packages/vc-status-list/src/impl/OAuthStatusList.ts @@ -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, @@ -55,7 +55,7 @@ export class OAuthStatusListImplementation implements IStatusList { } async updateStatusListIndex(args: UpdateStatusListIndexArgs, context: IRequiredContext): Promise { - const { statusListCredential, value, keyRef } = args + const { statusListCredential, value, expiresAt, keyRef } = args if (typeof statusListCredential !== 'string') { return Promise.reject('statusListCredential in neither JWT nor CWT') } @@ -76,6 +76,7 @@ export class OAuthStatusListImplementation implements IStatusList { statusList, issuer, id, + expiresAt, keyRef, ) @@ -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, } @@ -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, @@ -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 }), @@ -168,14 +179,15 @@ export class OAuthStatusListImplementation implements IStatusList { statusList: StatusList, issuerString: string, id: string, + expiresAt?: Date, keyRef?: string, ): Promise { 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`) diff --git a/packages/vc-status-list/src/impl/encoding/cbor.ts b/packages/vc-status-list/src/impl/encoding/cbor.ts index 4883bf1a..33358ed4 100644 --- a/packages/vc-status-list/src/impl/encoding/cbor.ts +++ b/packages/vc-status-list/src/impl/encoding/cbor.ts @@ -24,6 +24,7 @@ export const createSignedCbor = async ( statusList: StatusList, issuerString: string, id: string, + expiresAt?: Date, keyRef?: string, ): Promise => { const identifier = await resolveIdentifier(context, issuerString, keyRef) @@ -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({ @@ -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> = [ + 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 { @@ -81,8 +80,8 @@ function buildClaimsMap( id: string, issuerString: string, statusListMap: com.sphereon.cbor.CborMap>, + 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]> = [ [new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.SUBJECT)), new cbor.CborString(id)], // "sub" @@ -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" ]) } @@ -113,10 +112,21 @@ function buildClaimsMap( return claimsMap } -const getCborValueFromMap = (map: Map, com.sphereon.cbor.CborItem>, key: number): T | never => { +const getCborValueFromMap = (map: Map, com.sphereon.cbor.CborItem>, key: number): T => { + const value = getCborOptionalValueFromMap(map, key) + if (value === undefined) { + throw new Error(`Required claim ${key} not found`) + } + return value +} + +const getCborOptionalValueFromMap = ( + map: Map, com.sphereon.cbor.CborItem>, + 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 } @@ -154,8 +164,8 @@ export const decodeStatusListCWT = (cwt: string): DecodedStatusListPayload => { issuer: getCborValueFromMap(claimsMap, CWT_CLAIMS.ISSUER), id: getCborValueFromMap(claimsMap, CWT_CLAIMS.SUBJECT), statusList, - exp: getCborValueFromMap(claimsMap, CWT_CLAIMS.EXPIRATION), - ttl: getCborValueFromMap(claimsMap, CWT_CLAIMS.TIME_TO_LIVE), iat: Number(getCborValueFromMap(claimsMap, CWT_CLAIMS.ISSUED_AT)), + exp: getCborOptionalValueFromMap(claimsMap, CWT_CLAIMS.EXPIRATION), + ttl: getCborOptionalValueFromMap(claimsMap, CWT_CLAIMS.TIME_TO_LIVE), } } diff --git a/packages/vc-status-list/src/impl/encoding/jwt.ts b/packages/vc-status-list/src/impl/encoding/jwt.ts index 27c2aeb6..bf28e516 100644 --- a/packages/vc-status-list/src/impl/encoding/jwt.ts +++ b/packages/vc-status-list/src/impl/encoding/jwt.ts @@ -14,6 +14,7 @@ export const createSignedJwt = async ( statusList: StatusList, issuerString: string, id: string, + expiresAt?: Date, keyRef?: string, ): Promise => { const identifier = await resolveIdentifier(context, issuerString, keyRef) @@ -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 = { diff --git a/packages/vc-status-list/src/types/index.ts b/packages/vc-status-list/src/types/index.ts index 71e8e95f..e90690d1 100644 --- a/packages/vc-status-list/src/types/index.ts +++ b/packages/vc-status-list/src/types/index.ts @@ -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 {