Skip to content

Commit bc3bb62

Browse files
Fix status list indexed validation (#185)
Signed-off-by: Alexander Shenshin <alexander.shenshin@dsr-corporation.com> Co-authored-by: abhay-dev2901 <abhay-dev2901@users.noreply.github.com>
1 parent 35ad2fc commit bc3bb62

7 files changed

Lines changed: 293 additions & 43 deletions

File tree

heka-identity-service/src/common/entities/credential-status-list.entity.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ interface CredentialStatusListProps {
1818
owner: User
1919
}
2020

21-
export const defaultCredentialStatusListSize = 100
21+
// W3C Bitstring Status List v1.0 sets a 16KB = 131,072-bit minimum for herd privacy (§2.2, §3.3)
22+
export const defaultCredentialStatusListSize = 131072
23+
24+
// Upper bound on a caller-supplied size: 2^23 bits = 1MB buffer
25+
// Without it an API request can allocate an arbitrarily large bitstring
26+
export const maxCredentialStatusListSize = 8388608
2227

2328
@Entity()
2429
export class CredentialStatusList extends Identified {

heka-identity-service/src/openid4vc/issuance-sessions/__tests__/issuance-session.service.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc'
22
import { createMock } from '@golevelup/ts-vitest'
3-
import { UnprocessableEntityException } from '@nestjs/common'
3+
import { BadRequestException, UnprocessableEntityException } from '@nestjs/common'
44
import { ConfigType } from '@nestjs/config'
55

66
import { TenantAgent } from 'common/agent'
@@ -347,7 +347,16 @@ describe('OpenId4VcIssuanceSessionService', () => {
347347
expect.objectContaining({ issuerId: 'issuer-1' }),
348348
)
349349
expect(result.credentialOffer).toBe('openid-credential-offer://jwt')
350-
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-1', [6])
350+
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-1', [5])
351+
// The index reserved in the status list must be the one the issued credential carries,
352+
// otherwise the credential points at a bit that is never set on revocation.
353+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).toHaveBeenCalledWith(
354+
expect.objectContaining({
355+
issuanceMetadata: expect.objectContaining({
356+
credentials: [expect.objectContaining({ credentialStatus: expect.objectContaining({ index: 5 }) })],
357+
}),
358+
}),
359+
)
351360
})
352361

353362
test('should create issuance session for JwtVcJsonLd format WITH credentialStatus', async () => {
@@ -401,7 +410,14 @@ describe('OpenId4VcIssuanceSessionService', () => {
401410

402411
await service.offer(authInfo, tenantAgent, req)
403412

404-
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-2', [11])
413+
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-2', [10])
414+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).toHaveBeenCalledWith(
415+
expect.objectContaining({
416+
issuanceMetadata: expect.objectContaining({
417+
credentials: [expect.objectContaining({ credentialStatus: expect.objectContaining({ index: 10 }) })],
418+
}),
419+
}),
420+
)
405421
})
406422

407423
test('should create issuance session for LdpVc format WITH credentialStatus', async () => {
@@ -455,7 +471,62 @@ describe('OpenId4VcIssuanceSessionService', () => {
455471

456472
await service.offer(authInfo, tenantAgent, req)
457473

458-
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-3', [1])
474+
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-3', [0])
475+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).toHaveBeenCalledWith(
476+
expect.objectContaining({
477+
issuanceMetadata: expect.objectContaining({
478+
credentials: [expect.objectContaining({ credentialStatus: expect.objectContaining({ index: 0 }) })],
479+
}),
480+
}),
481+
)
482+
})
483+
484+
test('should reject an over-capacity batch before creating the credential offer', async () => {
485+
const mockIssuer = issuerRecordStub({
486+
issuerId: 'issuer-1',
487+
credentialConfigurationsSupported: {
488+
'cred-jwt-1': {
489+
format: 'jwt_vc_json',
490+
credential_definition: { type: ['VerifiableCredential'] },
491+
},
492+
},
493+
})
494+
495+
vi.mocked(tenantAgent.openid4vc.issuer.getIssuerByIssuerId).mockResolvedValue(mockIssuer)
496+
// One free slot (lastIndex 99 of size 100) but two revocable credentials requested
497+
vi.mocked(statusListService.getOrCreate).mockResolvedValue({ id: 'sl-4', lastIndex: 99, size: 100 } as any)
498+
vi.mocked(statusListService.location).mockReturnValue('https://example.com/status-lists/sl-4')
499+
vi.mocked(tenantAgent.dids.resolve).mockResolvedValue(
500+
didResolutionResultStub({
501+
didDocument: { verificationMethod: [{ id: 'did:key:z6MkJwt#key-1' }] },
502+
}),
503+
)
504+
vi.mocked(statusListService.assertHasFreeIndexes).mockImplementation(() => {
505+
throw new BadRequestException('Status list does not have enough free indexes')
506+
})
507+
508+
const req = {
509+
publicIssuerId: 'issuer-1',
510+
credentials: [
511+
{
512+
credentialSupportedId: 'cred-jwt-1',
513+
format: OpenId4VciCredentialFormatProfile.JwtVcJson,
514+
issuer: { did: 'did:key:z6MkJwt' },
515+
},
516+
{
517+
credentialSupportedId: 'cred-jwt-1',
518+
format: OpenId4VciCredentialFormatProfile.JwtVcJson,
519+
issuer: { did: 'did:key:z6MkJwt' },
520+
},
521+
],
522+
baseUri: 'https://example.com',
523+
} as any
524+
525+
await expect(service.offer(authInfo, tenantAgent, req)).rejects.toThrow(BadRequestException)
526+
527+
// The point of the preflight: nothing irreversible may happen once capacity is known to be short
528+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).not.toHaveBeenCalled()
529+
expect(statusListService.addItems).not.toHaveBeenCalled()
459530
})
460531

461532
test('should create issuance session for MsoMdoc format without DID resolution or credentialStatus', async () => {

heka-identity-service/src/openid4vc/issuance-sessions/issuance-session.service.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { SdJwtVcPayload } from '@credo-ts/core'
22
import {
33
OpenId4VciCredentialFormatProfile,
4-
OpenId4VcIssuerService,
54
OpenId4VcIssuanceSessionRepository,
5+
OpenId4VcIssuerService,
66
} from '@credo-ts/openid4vc'
77
import { Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'
88
import { ConfigType } from '@nestjs/config'
@@ -84,12 +84,14 @@ export class OpenId4VcIssuanceSessionService {
8484
credential.format === OpenId4VciCredentialFormatProfile.JwtVcJsonLd ||
8585
credential.format === OpenId4VciCredentialFormatProfile.LdpVc
8686
) {
87-
credentialIndex += 1
87+
// Assign the current free index (0-based), then advance
88+
// This keeps issued indexes within [0, size), consistent with capacity guard and per-index bound in StatusListService
8889
credentialIndexes.push(credentialIndex)
8990
credentialStatus = {
9091
location: this.statusListService.location(statusList.id),
9192
index: credentialIndex,
9293
}
94+
credentialIndex += 1
9395
}
9496

9597
let type: string | string[]
@@ -104,21 +106,19 @@ export class OpenId4VcIssuanceSessionService {
104106
let credentialIssuanceMeta: CredentialIssuanceMetadata
105107

106108
if (credential.format === OpenId4VciCredentialFormatProfile.MsoMdoc) {
107-
const mdocCredential = credential
108109
credentialIssuanceMeta = {
109110
format: credential.format,
110111
credentialSupportedId: credential.credentialSupportedId,
111112
type,
112113
issuer: {},
113-
namespaces: mdocCredential.namespaces,
114+
namespaces: credential.namespaces,
114115
}
115116
} else {
116-
const didCredential = credential as { issuer: { did: string; name?: string; image?: string; url?: string } }
117117
credentialIssuanceMeta = {
118118
...credential,
119119
type,
120120
issuer: {
121-
...didCredential.issuer,
121+
...credential.issuer,
122122
didUrl: issuerDidUrl,
123123
},
124124
credentialStatus,
@@ -129,6 +129,11 @@ export class OpenId4VcIssuanceSessionService {
129129
mappedCredentials.push(credentialIssuanceMeta)
130130
}
131131

132+
// Preflight status list capacity check before the offer is created
133+
if (credentialIndexes.length) {
134+
this.statusListService.assertHasFreeIndexes(statusList, credentialIndexes.length)
135+
}
136+
132137
const { credentialOffer, issuanceSession } = await tenantAgent.openid4vc.issuer.createCredentialOffer({
133138
baseUri: req.baseUri,
134139
credentialConfigurationIds: req.credentials.map((c) => c.credentialSupportedId),
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import 'reflect-metadata'
2+
3+
import { plainToInstance } from 'class-transformer'
4+
import { validate } from 'class-validator'
5+
6+
import {
7+
defaultCredentialStatusListSize,
8+
maxCredentialStatusListSize,
9+
} from '../../../common/entities/credential-status-list.entity'
10+
import { CreateStatusListRequest } from '../dto/create-status-list.dto'
11+
12+
const validateRequest = async (payload: Record<string, unknown>) => {
13+
const instance = plainToInstance(CreateStatusListRequest, payload)
14+
const errors = await validate(instance)
15+
return { instance, errors }
16+
}
17+
18+
const sizeConstraints = (errors: Awaited<ReturnType<typeof validateRequest>>['errors']) =>
19+
Object.keys(errors.find((error) => error.property === 'size')?.constraints ?? {})
20+
21+
describe('CreateStatusListRequest', () => {
22+
describe('size', () => {
23+
it('is optional - omitting it falls back to the default', async () => {
24+
const { errors } = await validateRequest({ issuer: 'did:example:issuer' })
25+
26+
expect(errors).toHaveLength(0)
27+
})
28+
29+
it.each([1, defaultCredentialStatusListSize, maxCredentialStatusListSize])('accepts %i', async (size) => {
30+
const { errors } = await validateRequest({ issuer: 'did:example:issuer', size })
31+
32+
expect(errors).toHaveLength(0)
33+
})
34+
35+
// `new Bitstring({ length })` throws on a non-positive or fractional length, which would surface
36+
// as a 500 rather than a 400 if the DTO let these through.
37+
it.each([0, -1, -131072])('rejects the non-positive size %i', async (size) => {
38+
const { errors } = await validateRequest({ issuer: 'did:example:issuer', size })
39+
40+
expect(sizeConstraints(errors)).toContain('min')
41+
})
42+
43+
it('rejects a fractional size', async () => {
44+
const { errors } = await validateRequest({ issuer: 'did:example:issuer', size: 2.5 })
45+
46+
expect(sizeConstraints(errors)).toContain('isInt')
47+
})
48+
49+
it('rejects a size above the maximum, so one request cannot allocate an oversized bitstring', async () => {
50+
const { errors } = await validateRequest({
51+
issuer: 'did:example:issuer',
52+
size: maxCredentialStatusListSize + 1,
53+
})
54+
55+
expect(sizeConstraints(errors)).toContain('max')
56+
})
57+
})
58+
})

0 commit comments

Comments
 (0)