diff --git a/.env.demo b/.env.demo index 29ac22636..d34cbd651 100644 --- a/.env.demo +++ b/.env.demo @@ -340,6 +340,7 @@ CONSUMER_CONFIG_MAX_DELIVER=4 AGGREGATE_STREAM=aggregate DID_STREAM=did-notify PULL_CONSUMER=hub-pull-consumer +STATUS_LIST_HOST= NATS_CREDS_FILE= diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index 9024f06e8..5016a08ff 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -390,6 +390,11 @@ export class AgentServiceController { return this.agentServiceService.oidcDeleteCredentialOffer(payload.url, payload.orgId); } + @MessagePattern({ cmd: 'agent-service-oid4vc-revoke-credential' }) + async oidcRevokeCredential(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcRevokeCredential(payload.url, payload.orgId); + } + @MessagePattern({ cmd: 'agent-create-x509-certificate' }) async createX509Certificate(payload: { options: X509CreateCertificateOptions; diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index 19aec908b..0c09d315c 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1489,6 +1489,20 @@ export class AgentServiceService { } } + async oidcRevokeCredential(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(`${url}`, {}, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + const errorMsg = error.response ? JSON.stringify(error.response.data) : error.message; + this.logger.error(`Error in oidcRevokeCredential in agent service : ${errorMsg}`); + throw error; + } + } + async oidcIssuerTemplate(templatePayload, url: string, orgId: string): Promise { try { const getApiKey = await this.getOrgAgentApiKey(orgId); diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts index 6faa741c6..3f88f405f 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts @@ -19,7 +19,8 @@ import { ValidatorConstraintInterface, ValidationArguments, Validate, - IsDate + IsDate, + IsBoolean } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -196,6 +197,11 @@ export class CreateOidcCredentialOfferDto { noticeUrl?: string; issuerId?: string; + + @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) + @IsOptional() + @IsBoolean() + isRevocable?: boolean = false; } export class GetAllCredentialOfferDto { @@ -301,7 +307,7 @@ export class CredentialDto { }, iat: 1698151532, nbf: dateToSeconds(new Date()), - exp: dateToSeconds(new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000)) + exp: dateToSeconds(new Date(Date.now() + 157680000000)) } ] }) @@ -391,6 +397,11 @@ export class CreateCredentialOfferD2ADto { @IsOptional() issuerId?: string; + @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) + @IsOptional() + @IsBoolean() + isRevocable?: boolean = false; + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' }) diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 41ceec797..cf44a1899 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -672,4 +672,28 @@ export class Oid4vcIssuanceController { return res.status(HttpStatus.CREATED).json(finalResponse); } + + @Post('/orgs/:orgId/openid4vc/issuance-sessions/:issuanceSessionId/revoke') + @ApiOperation({ + summary: 'Revoke an OID4VC Credential', + description: 'Instantly revokes a credential issued during a specific OID4VC session.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential revoked successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async revokeCredential( + @Param('orgId') orgId: string, + @Param('issuanceSessionId') issuanceSessionId: string, + @Res() res: Response + ): Promise { + const revoked = await this.oid4vcIssuanceService.revokeCredential(issuanceSessionId, orgId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: 'Credential revoked successfully.', + data: revoked + }; + return res.status(HttpStatus.OK).json(finalResponse); + } } diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts index 8a90ec4d2..45a14ba83 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts @@ -131,6 +131,11 @@ export class Oid4vcIssuanceService extends BaseService { return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-delete', payload); } + async revokeCredential(issuanceSessionId: string, orgId: string): Promise { + const payload = { issuanceSessionId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-revoke-credential', payload); + } + oidcIssueCredentialWebhook( oidcIssueCredentialDto: OidcIssueCredentialDto, id: string diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index 1bc4785f9..df6d4c00c 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -48,6 +48,7 @@ export interface CreateOidcCredentialOffer { authenticationType: AuthenticationType; // only option selector credentials: CredentialRequest[]; // one or more credentials noticeUrl?: string; + isRevocable?: boolean; } export interface GetAllCredentialOffer { diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index a2b8b208d..757c629a8 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -33,7 +33,7 @@ export enum SignerMethodOption { export interface DisclosureFrame { _sd?: string[]; - [claim: string]: DisclosureFrame | string[] | undefined; + [claim: string]: DisclosureFrame | DisclosureFrame[] | string[] | undefined; } export interface validityInfo { @@ -58,6 +58,7 @@ export interface CreateOidcCredentialOfferDtoLike { authorizationServerUrl: string; }; publicIssuerId?: string; + isRevocable?: boolean; } export interface ResolvedSignerOption { @@ -84,12 +85,18 @@ export interface BuiltCredential { format: CredentialFormat; payload: Record; disclosureFrame?: DisclosureFrame; + statusListDetails?: { + listId: string; + index: number; + listSize?: number; + }; } export interface BuiltCredentialOfferBase { signerOption?: ResolvedSignerOption; credentials: BuiltCredential[]; publicIssuerId?: string; + isRevocable?: boolean; } export type CredentialOfferPayload = BuiltCredentialOfferBase & @@ -223,36 +230,46 @@ export function validatePayloadAgainstTemplate(template: any, payload: any): { v return { valid: 0 === errors.length, errors }; } -function buildDisclosureFrameFromTemplate(attributes: CredentialAttribute[]): DisclosureFrame { +function buildDisclosureFrameFromTemplate( + attributes: CredentialAttribute[], + payload?: Record +): DisclosureFrame { const frame: DisclosureFrame = {}; const sd: string[] = []; for (const attr of attributes) { - const childFrame = - attr.children && 0 < attr.children.length ? buildDisclosureFrameFromTemplate(attr.children) : undefined; - - const hasChildDisclosure = - childFrame && (childFrame._sd?.length || Object.keys(childFrame).some((k) => '_sd' !== k)); - - // Case 1: this attribute itself is disclosed - if (attr.disclose) { - // If it has children, children are handled separately - if (!attr.children || 0 === attr.children.length) { - sd.push(attr.key); - continue; + const hasChildren = attr.children && 0 < attr.children.length; + + if (hasChildren) { + const payloadValue = payload?.[attr.key]; + //todo: + //1) Need to handle the type validation here to ensure payloadValue is in expected format (object or array of objects) + //2) Need to add add validation based on template definition (e.g. if template defines an array, payload must be an array, etc.) + if (Array.isArray(payloadValue)) { + // Array of objects → [{ _sd: [...] }, ...] + const childFrame = buildDisclosureFrameFromTemplate(attr.children!); + frame[attr.key] = payloadValue.map(() => ({ ...childFrame })); + } else { + // Plain object → { _sd: [...] } + const childFrame = buildDisclosureFrameFromTemplate(attr.children!, payloadValue as Record); + const hasChildDisclosure = childFrame._sd?.length || Object.keys(childFrame).some((k) => '_sd' !== k); + if (hasChildDisclosure) { + frame[attr.key] = childFrame; + } } + continue; } - // Case 2: attribute has disclosed children - if (hasChildDisclosure) { - frame[attr.key] = childFrame!; + // Root attribute — add to _sd if disclosed + if (attr.disclose) { + sd.push(attr.key); } } if (0 < sd.length) { frame._sd = sd; } - + // console.log('Built disclosure frame:', JSON.stringify(frame, null, 2)); return frame; } @@ -366,8 +383,11 @@ function buildSdJwtCredential( const apiFormat = mapDbFormatToApiFormat(templateRecord.format); const idSuffix = formatSuffix(apiFormat); const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - const disclosureFrame = buildDisclosureFrameFromTemplate(sdJwtTemplate.attributes); + const disclosureFrame = buildDisclosureFrameFromTemplate( + sdJwtTemplate.attributes, + payloadCopy as Record + ); return { credentialSupportedId, signerOptions: templateSignerOption ? templateSignerOption : undefined, @@ -477,7 +497,8 @@ export function buildCredentialOfferPayload( const baseEnvelope: BuiltCredentialOfferBase = { credentials: builtCredentials, - ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) + ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}), + isRevocable: dto.isRevocable }; // Determine which authorization flow to return: diff --git a/apps/oid4vc-issuance/src/main.ts b/apps/oid4vc-issuance/src/main.ts index 9f968ed7d..68aa19bbf 100644 --- a/apps/oid4vc-issuance/src/main.ts +++ b/apps/oid4vc-issuance/src/main.ts @@ -10,6 +10,10 @@ import { Oid4vcIssuanceModule } from './oid4vc-issuance.module'; const logger = new Logger(); async function bootstrap(): Promise { + if (!process.env.STATUS_LIST_HOST) { + logger.warn('STATUS_LIST_HOST is not configured. Revocable SD-JWT flow will be disabled.'); + } + const app = await NestFactory.createMicroservice(Oid4vcIssuanceModule, { transport: Transport.NATS, options: getNatsOptions( diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts index b1e471fc3..0d31ae747 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -165,16 +165,17 @@ export class Oid4vcIssuanceController { } @MessagePattern({ cmd: 'oid4vc-credential-offer-delete' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async deleteCredentialOffers(payload: { - orgId: string; - credentialId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { + async deleteCredentialOffers(payload: { orgId: string; credentialId: string }): Promise { const { orgId, credentialId } = payload; return this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); } + @MessagePattern({ cmd: 'oid4vc-revoke-credential' }) + async revokeCredential(payload: { issuanceSessionId: string; orgId: string }): Promise { + const { issuanceSessionId, orgId } = payload; + return this.oid4vcIssuanceService.revokeCredential(issuanceSessionId, orgId); + } + @MessagePattern({ cmd: 'webhook-oid4vc-issue-credential' }) async oidcIssueCredentialWebhook(payload: Oid4vcCredentialOfferWebhookPayload): Promise { return this.oid4vcIssuanceService.storeOidcCredentialWebhook(payload); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts index f9e0fd194..61cb23a23 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts @@ -13,6 +13,7 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; import { NATSClient } from '@credebl/common/NATSClient'; import { PrismaService } from '@credebl/prisma-service'; +import { StatusListAllocatorService } from './status-list-allocator.service'; @Module({ imports: [ @@ -35,6 +36,13 @@ import { PrismaService } from '@credebl/prisma-service'; CacheModule.register() ], controllers: [Oid4vcIssuanceController], - providers: [Oid4vcIssuanceService, Oid4vcIssuanceRepository, PrismaService, Logger, NATSClient] + providers: [ + Oid4vcIssuanceService, + Oid4vcIssuanceRepository, + PrismaService, + Logger, + NATSClient, + StatusListAllocatorService + ] }) export class Oid4vcIssuanceModule {} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 2240a53a0..811da6aa1 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -10,6 +10,7 @@ import { BadRequestException, ConflictException, HttpException, + HttpStatus, Inject, Injectable, InternalServerErrorException, @@ -18,6 +19,8 @@ import { Scope } from '@nestjs/common'; import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; +import { StatusListAllocatorService } from './status-list-allocator.service'; +import { CredentialFormat } from '@credebl/enum/enum'; import { CommonConstants } from '@credebl/common/common.constant'; import { ResponseMessages } from '@credebl/common/response-messages'; import { ClientProxy, RpcException } from '@nestjs/microservices'; @@ -79,7 +82,8 @@ export class Oid4vcIssuanceService { private readonly logger = new Logger('IssueCredentialService'); constructor( @Inject('NATS_CLIENT') private readonly issuanceServiceProxy: ClientProxy, - private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository + private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository, + private readonly statusListAllocatorService: StatusListAllocatorService ) {} async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { @@ -528,6 +532,8 @@ export class Oid4vcIssuanceService { userDetails: user, issuerId: string ): Promise { + let buildOidcCredentialOffer: CredentialOfferPayload; + const newlyAllocatedIndices: { listId: string; index: number }[] = []; try { const filterTemplateIds = extractTemplateIds(createOidcCredentialOffer); if (!filterTemplateIds) { @@ -598,7 +604,7 @@ export class Oid4vcIssuanceService { //TODO: add logic to pass the issuer info const issuerDetailsFromDb = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const { publicIssuerId, authorizationServerUrl } = issuerDetailsFromDb || {}; - const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( + buildOidcCredentialOffer = buildCredentialOfferPayload( createOidcCredentialOffer, // getAllOfferTemplates, @@ -623,12 +629,37 @@ export class Oid4vcIssuanceService { CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER, issuerDetails.publicIssuerId ); - this.logger.debug(`Creating OIDC Credential Offer for :`, buildOidcCredentialOffer); + + if (createOidcCredentialOffer.isRevocable) { + const hasSdJwt = buildOidcCredentialOffer.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); + if (hasSdJwt) { + if (!process.env.STATUS_LIST_HOST) { + throw new BadRequestException('Revocable SD-JWT is not supported as STATUS_LIST_HOST is not configured.'); + } + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable SD-JWT credentials'); + } + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc && !cred.statusListDetails) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; + newlyAllocatedIndices.push({ listId: allocation.listId, index: allocation.index }); + } + } + } + } + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(buildOidcCredentialOffer, url, orgId); if (!createCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } + // Logic to add noticeUrl in response from agent if it is present in template or request payload const { response } = createCredentialOfferOnAgent; if (null !== response) { @@ -644,8 +675,46 @@ export class Oid4vcIssuanceService { } } - return response; + // Revocation logic to save the credential offer id and status list details in DB for later use during revocation + let responseToSave; + if ('string' === typeof createCredentialOfferOnAgent.response) { + responseToSave = JSON.parse(createCredentialOfferOnAgent.response); + } else { + responseToSave = createCredentialOfferOnAgent.response; + } + + const sessionId = + responseToSave.issuanceSessionId || + responseToSave.credentialOfferId || + responseToSave.id || + responseToSave.issuanceSession?.id; + + if (sessionId && createOidcCredentialOffer.isRevocable) { + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; + await this.statusListAllocatorService.saveCredentialAllocation( + orgId, + `${sessionId}-${cred.statusListDetails.index}`, + cred.statusListDetails.listId, + cred.statusListDetails.index, + sessionId, + statusListUri + ); + } + } + } + + return createCredentialOfferOnAgent.response; } catch (error) { + for (const alloc of newlyAllocatedIndices) { + try { + await this.statusListAllocatorService.release(alloc.listId, alloc.index); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); + } + } + const errorResponse = ErrorHandler.categorize(error, 'Failed to create credential offer'); this.logger.error( `[createOidcCredentialOffer] - ${errorResponse.statusCode}: ${errorResponse.message}`, @@ -656,6 +725,7 @@ export class Oid4vcIssuanceService { } async createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId: string): Promise { + const newlyAllocatedIndices: { listId: string; index: number }[] = []; try { for (const credential of oidcCredentialD2APayload.credentials) { const { signerOptions } = credential; @@ -694,14 +764,75 @@ export class Oid4vcIssuanceService { await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER ); + + if (oidcCredentialD2APayload.isRevocable) { + const hasSdJwt = oidcCredentialD2APayload.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); + if (hasSdJwt) { + if (!process.env.STATUS_LIST_HOST) { + throw new BadRequestException('Revocable SD-JWT is not supported as STATUS_LIST_HOST is not configured.'); + } + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of oidcCredentialD2APayload.credentials) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((cred as any).format === CredentialFormat.SdJwtVc && !cred.statusListDetails) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; + newlyAllocatedIndices.push({ listId: allocation.listId, index: allocation.index }); + } + } + } + } + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(oidcCredentialD2APayload, url, orgId); if (!createCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } + let parsedResponse; + if ('string' === typeof createCredentialOfferOnAgent.response) { + parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); + } else { + parsedResponse = createCredentialOfferOnAgent.response; + } + + const issuanceSessionId = + parsedResponse.issuanceSessionId || + parsedResponse.credentialOfferId || + parsedResponse.id || + parsedResponse.issuanceSession?.id; + if (issuanceSessionId && oidcCredentialD2APayload.isRevocable) { + for (const cred of oidcCredentialD2APayload.credentials) { + if (cred.statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; + await this.statusListAllocatorService.saveCredentialAllocation( + orgId, + `${issuanceSessionId}-${cred.statusListDetails.index}`, + cred.statusListDetails.listId, + cred.statusListDetails.index, + issuanceSessionId, + statusListUri + ); + } + } + } + return createCredentialOfferOnAgent.response; } catch (error) { - this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + for (const alloc of newlyAllocatedIndices) { + try { + await this.statusListAllocatorService.release(alloc.listId, alloc.index); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); + } + } + this.logger.error(`[createOidcCredentialOfferD2A] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } } @@ -782,7 +913,38 @@ export class Oid4vcIssuanceService { } return deletedCredentialOffer.response; } catch (error) { - this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + this.logger.error(`[deleteCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async revokeCredential(issuanceSessionId: string, orgId: string): Promise { + try { + if (!issuanceSessionId) { + throw new BadRequestException('Please provide a valid issuanceSessionId'); + } + + const allocations = await this.statusListAllocatorService.getCredentialAllocations(orgId, issuanceSessionId); + + const url = getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID, + `${issuanceSessionId}/revoke` + ); + + const pattern = { cmd: 'agent-service-oid4vc-revoke-credential' }; + + if (allocations && 0 < allocations.length) { + const payload: any = { + url, + orgId + }; + return this.natsCall(pattern, payload); + } else { + throw new BadRequestException('Credential is not revocable as no status list allocation was found.'); + } + } catch (error) { + this.logger.error(`[revokeCredential] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } } @@ -959,10 +1121,10 @@ export class Oid4vcIssuanceService { this.logger.error(`catch: ${JSON.stringify(error)}`); throw new HttpException( { - status: error.statusCode, + status: error.statusCode || HttpStatus.INTERNAL_SERVER_ERROR, error: error.message }, - error.error + error.statusCode || error.status || HttpStatus.INTERNAL_SERVER_ERROR ); }); } catch (error) { @@ -990,16 +1152,16 @@ export class Oid4vcIssuanceService { ): Promise { try { // pick fields - let organisationId: string; const { oidcIssueCredentialDto, id } = CredentialOfferWebhookPayload; + let organisationId = id; if ('default' !== oidcIssueCredentialDto?.contextCorrelationId) { const getOrganizationId = await this.oid4vcIssuanceRepository.getOrganizationByTenantId( oidcIssueCredentialDto?.contextCorrelationId ); - organisationId = getOrganizationId?.orgId; - } else { - organisationId = id; + if (getOrganizationId?.orgId) { + organisationId = getOrganizationId.orgId; + } } const { diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts new file mode 100644 index 000000000..028f9c38c --- /dev/null +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -0,0 +1,220 @@ +/* eslint-disable camelcase */ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@credebl/prisma-service'; +import { issued_oid4vc_credentials } from '@prisma/client'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { randomInt, randomUUID } from 'crypto'; + +export class RandomBitmapIndexAllocator { + private bitmap: Uint8Array; + private capacity: number; + private allocatedCount: number; + + constructor(capacity: number, existing?: Uint8Array) { + this.capacity = capacity; + this.bitmap = existing ? new Uint8Array(existing) : new Uint8Array(Math.ceil(capacity / 8)); + + this.allocatedCount = 0; + + for (const byte of this.bitmap) { + if (0 !== byte) { + this.allocatedCount += byte.toString(2).split('1').length - 1; + } + } + } + + private isSet(index: number): boolean { + const byteIndex = index >> 3; + const bit = index & 7; + return 0 !== (this.bitmap[byteIndex] & (1 << bit)); + } + + private set(index: number): void { + const byteIndex = index >> 3; + const bit = index & 7; + this.bitmap[byteIndex] |= 1 << bit; + } + + private clear(index: number): void { + const byteIndex = index >> 3; + const bit = index & 7; + this.bitmap[byteIndex] &= ~(1 << bit); + } + + public allocate(): number { + if (this.allocatedCount === this.capacity) { + throw new Error('No indexes left'); + } + + const maxRandomAttempts = 32; + for (let i = 0; i < maxRandomAttempts; i++) { + const idx = randomInt(this.capacity); + if (!this.isSet(idx)) { + this.set(idx); + this.allocatedCount++; + return idx; + } + } + + const startByte = randomInt(this.bitmap.length); + for (let i = 0; i < this.bitmap.length; i++) { + const byteIdx = (startByte + i) % this.bitmap.length; + if (255 !== this.bitmap[byteIdx]) { + for (let bit = 0; 8 > bit; bit++) { + const idx = (byteIdx << 3) + bit; + if (idx < this.capacity && !this.isSet(idx)) { + this.set(idx); + this.allocatedCount++; + return idx; + } + } + } + } + + throw new Error('No indexes left'); + } + + public isIndexAllocated(index: number): boolean { + if (0 > index || index >= this.capacity) { + throw new Error('Invalid index'); + } + return this.isSet(index); + } + + public release(index: number): void { + if (0 > index || index >= this.capacity) { + throw new Error('Invalid index'); + } + + if (this.isSet(index)) { + this.clear(index); + this.allocatedCount--; + } + } + + public export(): Uint8Array { + return new Uint8Array(this.bitmap); + } + + public getAllocatedCount(): number { + return this.allocatedCount; + } +} + +@Injectable() +export class StatusListAllocatorService { + private readonly logger = new Logger(StatusListAllocatorService.name); + + constructor(private readonly prisma: PrismaService) {} + + async allocate(orgId: string, issuerDid: string, listSize?: number): Promise<{ listId: string; index: number }> { + if (!orgId || !issuerDid) { + throw new Error('orgId and issuerDid are required for status list allocation'); + } + const defaultListSize = CommonConstants.DEFAULT_STATUS_LIST_SIZE; + return this.prisma.$transaction(async (tx) => { + // Find active list or create one + let activeList = await tx.status_list_allocation.findFirst({ + where: { orgId, issuerDid, isActive: true } + }); + + if (!activeList || activeList.allocatedCount >= activeList.listSize) { + // Mark inactive if full + if (activeList) { + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { isActive: false } + }); + } + + activeList = await tx.status_list_allocation.create({ + data: { + orgId, + issuerDid, + listId: randomUUID(), + listSize: listSize || defaultListSize, + allocatedCount: 0, + bitmap: Buffer.from(new Uint8Array(Math.ceil((listSize || defaultListSize) / 8))), + isActive: true + } + }); + } + + const allocator = new RandomBitmapIndexAllocator(activeList.listSize, new Uint8Array(activeList.bitmap)); + + try { + const index = allocator.allocate(); + + // Update database + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { + bitmap: Buffer.from(allocator.export()), + allocatedCount: allocator.getAllocatedCount() + } + }); + + return { listId: activeList.listId, index }; + } catch (error) { + if ('No indexes left' === error.message) { + // Retry rotation if we hit the race condition limit + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { isActive: false } + }); + throw new Error('Retry allocation, list full'); + } + throw error; + } + }); + } + + async saveCredentialAllocation( + orgId: string, + credentialId: string, + listId: string, + index: number, + issuanceSessionId: string, + statusListUri: string + ): Promise { + await this.prisma.issued_oid4vc_credentials.create({ + data: { + orgId, + credentialId, + listId, + index, + issuanceSessionId, + statusListUri + } + }); + } + + async getCredentialAllocations(orgId: string, issuanceSessionId: string): Promise { + return this.prisma.issued_oid4vc_credentials.findMany({ + where: { orgId, issuanceSessionId } + }); + } + + async release(listId: string, index: number): Promise { + await this.prisma.$transaction(async (tx) => { + const allocation = await tx.status_list_allocation.findUnique({ + where: { listId } + }); + + if (!allocation) { + return; + } + + const allocator = new RandomBitmapIndexAllocator(allocation.listSize, new Uint8Array(allocation.bitmap)); + allocator.release(index); + + await tx.status_list_allocation.update({ + where: { listId }, + data: { + bitmap: Buffer.from(allocator.export()), + allocatedCount: allocator.getAllocatedCount() + } + }); + }); + } +} diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index 0b2531284..99d23bb4d 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -229,6 +229,7 @@ export enum CommonConstants { // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values DEFAULT_CACHE_TTL = 60000, + DEFAULT_STATUS_LIST_SIZE = 131072, DEFAULT_FIELD_UPLOAD_SIZE = 10485760, // SUBSCRIPTION TYPES diff --git a/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql new file mode 100644 index 000000000..f57bb7c69 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "status_list_allocation" ( + "id" UUID NOT NULL, + "orgId" UUID NOT NULL, + "issuerDid" TEXT NOT NULL, + "listId" UUID NOT NULL, + "listSize" INTEGER NOT NULL DEFAULT 131072, + "allocatedCount" INTEGER NOT NULL DEFAULT 0, + "bitmap" BYTEA NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "status_list_allocation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "issued_oid4vc_credentials" ( + "id" UUID NOT NULL, + "credentialId" TEXT NOT NULL, + "listId" UUID NOT NULL, + "index" INTEGER NOT NULL, + "issuanceSessionId" VARCHAR(100) NOT NULL, + "orgId" UUID, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "statusListUri" TEXT NOT NULL, + + CONSTRAINT "issued_oid4vc_credentials_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "status_list_allocation_listId_key" ON "status_list_allocation"("listId"); + +-- CreateIndex +CREATE INDEX "status_list_allocation_orgId_isActive_idx" ON "status_list_allocation"("orgId", "isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "issued_oid4vc_credentials_credentialId_key" ON "issued_oid4vc_credentials"("credentialId"); +CREATE INDEX "issued_oid4vc_credentials_orgId_issuanceSessionId_idx" ON "issued_oid4vc_credentials"("orgId", "issuanceSessionId"); + +-- AddForeignKey +ALTER TABLE "status_list_allocation" ADD CONSTRAINT "status_list_allocation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "issued_oid4vc_credentials" ADD CONSTRAINT "issued_oid4vc_credentials_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Add webhookSecret safely +ALTER TABLE "org_agents" ADD COLUMN IF NOT EXISTS "webhookSecret" VARCHAR; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index 13421aa84..2ef2327c5 100755 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -160,6 +160,8 @@ model organisation { intent_notices intent_notices[] ecosystemOrgs ecosystem_orgs[] ecosystem_invitations ecosystem_invitations[] + status_list_allocations status_list_allocation[] + issued_oid4vc_credentials issued_oid4vc_credentials[] } model org_invitations { @@ -231,6 +233,7 @@ model org_agents { orgAgentTypeId String? @db.Uuid ledgerId String? @db.Uuid didDocument Json? + webhookSecret String? @db.VarChar agent_invitations agent_invitations[] agents agents? @relation(fields: [agentId], references: [id]) agents_type agents_type? @relation(fields: [agentsTypeId], references: [id]) @@ -238,7 +241,6 @@ model org_agents { org_agent_type org_agents_type? @relation(fields: [orgAgentTypeId], references: [id]) organisation organisation? @relation(fields: [orgId], references: [id]) webhookUrl String? @db.VarChar - webhookSecret String? @db.VarChar org_dids org_dids[] oidc_issuer oidc_issuer[] x509_certificates x509_certificates[] @@ -599,7 +601,7 @@ model oidc_issuer { model oid4vc_credentials { id String @id @default(uuid()) @db.Uuid - orgId String @db.Uuid + orgId String? @db.Uuid issuanceSessionId String @unique credentialOfferId String state String @@ -612,7 +614,7 @@ model oid4vc_credentials { issuedCredentials String[] publicIssuerId String - organisation organisation @relation(fields: [orgId], references: [id]) + organisation organisation? @relation(fields: [orgId], references: [id]) @@index([credentialConfigurationIds], type: Gin) } @@ -632,6 +634,38 @@ model oid4vp_presentations { organisation organisation @relation(fields: [orgId], references: [id]) } +model status_list_allocation { + id String @id @default(uuid()) @db.Uuid + orgId String @db.Uuid + issuerDid String + listId String @unique @db.Uuid + listSize Int @default(131072) + allocatedCount Int @default(0) + bitmap Bytes + isActive Boolean @default(true) + createDateTime DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt + organisation organisation @relation(fields: [orgId], references: [id]) + + @@index([orgId, isActive]) +} + +model issued_oid4vc_credentials { + id String @id @default(uuid()) @db.Uuid + credentialId String @unique + listId String @db.Uuid + index Int + issuanceSessionId String @db.VarChar(100) + orgId String? @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt + statusListUri String + + organisation organisation? @relation(fields: [orgId], references: [id]) + + @@index([orgId, issuanceSessionId]) +} + enum SignerOption { DID X509_P256