From f9ef8cba9214551e3e26f1589e80ed369790f50f Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 16:20:16 +0530 Subject: [PATCH 01/16] feat: Add status list management and revocation for OpenID4VC issuance sessions and integrate SdJwtVcModule. Signed-off-by: Sagar Khole --- samples/cliConfig.json | 5 +- src/cli.ts | 9 ++ src/cliAgent.ts | 18 +++ .../issuance-sessions.Controller.ts | 15 ++ .../issuance-sessions.service.ts | 67 ++++++++- .../openid4vc/types/issuer.types.ts | 15 +- src/controllers/types.ts | 19 +++ src/controllers/x509/x509.types.ts | 23 ++- src/routes/routes.ts | 41 +++++ src/routes/swagger.json | 127 ++++++++++++++++ src/utils/statusListService.ts | 140 ++++++++++++++++++ tsconfig.build.json | 16 +- 12 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 src/utils/statusListService.ts diff --git a/samples/cliConfig.json b/samples/cliConfig.json index 105845d7..d2155a11 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -44,5 +44,8 @@ "fileServerUrl": "https://schema.credebl.id", "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk", "apiKey": "supersecret-that-too-16chars", - "updateJwtSecret": false + "updateJwtSecret": false, + "statusListServerUrl": "http://localhost:3000", + "statusListApiKey": "test_key", + "statusListDefaultSize": 131072 } diff --git a/src/cli.ts b/src/cli.ts index 533cd9e4..15ccf91e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,9 @@ interface Parsed { fileServerToken?: string apiKey?: string updateJwtSecret?: boolean + 'status-list-server-url'?: string + 'status-list-api-key'?: string + 'status-list-default-size'?: number } interface InboundTransport { @@ -163,6 +166,9 @@ async function parseArguments(): Promise { boolean: true, default: false, }) + .option('status-list-server-url', { string: true }) + .option('status-list-api-key', { string: true }) + .option('status-list-default-size', { number: true }) .config() .env('AFJ_REST') .parseAsync() as Promise @@ -211,5 +217,8 @@ export async function runCliServer() { fileServerToken: parsed.fileServerToken, apiKey: parsed['apiKey'], updateJwtSecret: parsed['updateJwtSecret'], + statusListServerUrl: parsed['status-list-server-url'], + statusListApiKey: parsed['status-list-api-key'], + statusListDefaultSize: parsed['status-list-default-size'], } as AriesRestConfig) } diff --git a/src/cliAgent.ts b/src/cliAgent.ts index 2b044cb7..1924c680 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -29,6 +29,7 @@ import { X509Module, JwkDidRegistrar, JwkDidResolver, + SdJwtVcModule, } from '@credo-ts/core' import { DidCommHttpOutboundTransport, @@ -113,6 +114,9 @@ export interface AriesRestConfig { schemaFileServerURL?: string apiKey: string updateJwtSecret?: boolean + statusListServerUrl?: string + statusListApiKey?: string + statusListDefaultSize?: number } export async function readRestConfig(path: string) { @@ -247,6 +251,7 @@ const getModules = ( rpcUrl: rpcUrl ? rpcUrl : (process.env.RPC_URL as string), serverUrl: fileServerUrl ? fileServerUrl : (process.env.SERVER_URL as string), }), + sdJwtVc: new SdJwtVcModule(), openid4vc: new OpenId4VcModule({ app: expressApp, issuer: { @@ -362,9 +367,22 @@ export async function runRestAgent(restConfig: AriesRestConfig) { walletScheme, apiKey, updateJwtSecret, + statusListServerUrl, + statusListApiKey, + statusListDefaultSize, ...afjConfig } = restConfig + if (statusListServerUrl) { + process.env.STATUS_LIST_SERVER_URL = statusListServerUrl + } + if (statusListApiKey) { + process.env.STATUS_LIST_API_KEY = statusListApiKey + } + if (statusListDefaultSize) { + process.env.STATUS_LIST_DEFAULT_SIZE = String(statusListDefaultSize) + } + const logger = new TsLogger(logLevel ?? LogLevel.error) const agentConfig: InitConfig = { diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts index fd8d0ea1..01b1df1d 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts @@ -104,4 +104,19 @@ export class IssuanceSessionsController extends Controller { throw ErrorHandlingService.handle(error) } } + + /** + * Revoke credentials in an issuance session by session ID + */ + @Post('{issuanceSessionId}/revoke') + public async revokeSessionById( + @Request() request: Req, + @Path('issuanceSessionId') issuanceSessionId: string, + ) { + try { + return await issuanceSessionService.revokeBySessionId(request, issuanceSessionId) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } } diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts index 1959379a..0282621c 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -7,6 +7,8 @@ import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc' import { SignerMethod } from '../../../enums/enum' import { BadRequestError, NotFoundError } from '../../../errors/errors' +import { checkAndCreateStatusList, getServerUrl } from '../../../utils/statusListService' + class IssuanceSessionsService { public async createCredentialOffer(options: OpenId4VcIssuanceSessionsCreateOffer, agentReq: Req) { const { credentials, publicIssuerId } = options @@ -15,7 +17,10 @@ class IssuanceSessionsService { if (!issuer) { throw new NotFoundError(`Issuer with id ${publicIssuerId} not found`) } - const mappedCredentials = credentials.map((cred) => { + + const offerStatusInfo: any[] = [] + + const mappedCredentials = await Promise.all(credentials.map(async (cred) => { const supported = issuer?.credentialConfigurationsSupported[cred.credentialSupportedId] if (!supported) { throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`) @@ -45,20 +50,52 @@ class IssuanceSessionsService { ) } - const currentVct = cred.payload && 'vct' in cred.payload ? cred.payload.vct : undefined + const effectiveIssuerDid = cred.signerOptions?.method === SignerMethod.Did ? cred.signerOptions.did : undefined + const effectiveStatusList = cred.statusListDetails || options.statusListDetails + + let statusBlock = undefined + if (effectiveIssuerDid && effectiveStatusList) { + await checkAndCreateStatusList( + agentReq.agent as any, + effectiveStatusList.listId, + effectiveIssuerDid, + effectiveStatusList.listSize, + ) + const listUri = `${getServerUrl()}/status-lists/${effectiveStatusList.listId}` + + statusBlock = { + status_list: { + uri: listUri, + idx: effectiveStatusList.index + } + } + + offerStatusInfo.push({ + credentialSupportedId: cred.credentialSupportedId, + listId: effectiveStatusList.listId, + index: effectiveStatusList.index, + issuerDid: effectiveIssuerDid + }) + } + + const currentVct = cred.payload && 'vct' in cred.payload ? (cred.payload as any).vct : undefined return { ...cred, payload: { ...cred.payload, vct: currentVct ?? (typeof supported.vct === 'string' ? supported.vct : undefined), + ...(statusBlock ? { status: statusBlock } : {}) }, } - }) + })) options.issuanceMetadata ||= {} - options.issuanceMetadata.credentials = mappedCredentials + if (offerStatusInfo.length > 0) { + options.issuanceMetadata.StatusListInfo = offerStatusInfo + } + const issuerModule = agentReq.agent.modules.openid4vc.issuer if (!issuerModule) { @@ -144,6 +181,28 @@ class IssuanceSessionsService { const issuanceSessionRepository = agentReq.agent.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository) await issuanceSessionRepository.deleteById(agentReq.agent.context, sessionId) } + + public async revokeBySessionId(agentReq: Req, sessionId: string) { + const issuanceSessionRepository = agentReq.agent.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository) + const record = await issuanceSessionRepository.findById(agentReq.agent.context, sessionId) + + if (!record) { + throw new NotFoundError(`Issuance session with id ${sessionId} not found`) + } + + const statusInfo = record.issuanceMetadata?.StatusListInfo as any[] + if (!statusInfo || statusInfo.length === 0) { + throw new Error(`No status list information found for session ${sessionId}`) + } + + const { revokeCredentialInStatusList } = await import('../../../utils/statusListService') + + for (const info of statusInfo) { + await revokeCredentialInStatusList(agentReq.agent as any, info.listId, info.index, info.issuerDid) + } + + return { message: 'Credentials in session revoked successfully' } + } } export const issuanceSessionService = new IssuanceSessionsService() diff --git a/src/controllers/openid4vc/types/issuer.types.ts b/src/controllers/openid4vc/types/issuer.types.ts index 897dfca6..10ff3ef8 100644 --- a/src/controllers/openid4vc/types/issuer.types.ts +++ b/src/controllers/openid4vc/types/issuer.types.ts @@ -4,10 +4,7 @@ import type { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc' import { Kms } from '@credo-ts/core' import { OpenId4VciCreateCredentialOfferOptions, OpenId4VciSignCredentials } from '@credo-ts/openid4vc' -export enum SignerMethod { - Did = 'did', - X5c = 'x5c', -} +import { SignerMethod } from '../../../enums/enum' export interface OpenId4VciOfferCredentials { credentialSupportedId: string @@ -18,6 +15,11 @@ export interface OpenId4VciOfferCredentials { x5c?: string[] keyId?: string } + statusListDetails?: { + listId: string + index: number + listSize?: number + } } export interface DisclosureFrameForOffer { @@ -72,6 +74,11 @@ export interface OpenId4VcIssuanceSessionsCreateOffer { authorizationServerUrl: string } issuanceMetadata?: Record + statusListDetails?: { + listId: string + index: number + listSize?: number + } } export interface X509GenericRecordContent { diff --git a/src/controllers/types.ts b/src/controllers/types.ts index 19e4c635..36908bb8 100644 --- a/src/controllers/types.ts +++ b/src/controllers/types.ts @@ -51,6 +51,25 @@ export interface AgentToken { token: string } +export enum CredentialState { + ProposalSent = 'proposal-sent', + ProposalReceived = 'proposal-received', + OfferSent = 'offer-sent', + OfferReceived = 'offer-received', + Declined = 'declined', + RequestSent = 'request-sent', + RequestReceived = 'request-received', + CredentialIssued = 'credential-issued', + CredentialReceived = 'credential-received', + Done = 'done', + Abandoned = 'abandoned', +} + +export enum CredentialRole { + Issuer = 'issuer', + Holder = 'holder', +} + export interface AgentMessageType { '@id': string '@type': string diff --git a/src/controllers/x509/x509.types.ts b/src/controllers/x509/x509.types.ts index a642a0ae..274db1c4 100644 --- a/src/controllers/x509/x509.types.ts +++ b/src/controllers/x509/x509.types.ts @@ -1,5 +1,26 @@ import type { Curve } from '../types' -import type { X509ExtendedKeyUsage, X509KeyUsage } from '@credo-ts/core' + +export enum X509KeyUsage { + DigitalSignature = 1, + NonRepudiation = 2, + KeyEncipherment = 4, + DataEncipherment = 8, + KeyAgreement = 16, + KeyCertSign = 32, + CrlSign = 64, + EncipherOnly = 128, + DecipherOnly = 256, +} + +export enum X509ExtendedKeyUsage { + ServerAuth = '1.3.6.1.5.5.7.3.1', + ClientAuth = '1.3.6.1.5.5.7.3.2', + CodeSigning = '1.3.6.1.5.5.7.3.3', + EmailProtection = '1.3.6.1.5.5.7.3.4', + TimeStamping = '1.3.6.1.5.5.7.3.8', + OcspSigning = '1.3.6.1.5.5.7.3.9', + MdlDs = '1.0.18013.5.1.2', +} // Enum remains the same export enum GeneralNameType { diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 55f296e3..58438768 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -812,6 +812,7 @@ const models: TsoaRoute.Models = { "credentialSupportedId": {"dataType":"string","required":true}, "format": {"ref":"OpenId4VciCredentialFormatProfile","required":true}, "signerOptions": {"dataType":"nestedObjectLiteral","nestedProperties":{"keyId":{"dataType":"string"},"x5c":{"dataType":"array","array":{"dataType":"string"}},"did":{"dataType":"string"},"method":{"ref":"SignerMethod","required":true}},"required":true}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, "payload": {"dataType":"nestedObjectLiteral","nestedProperties":{"vct":{"dataType":"string"}},"additionalProperties":{"dataType":"any"},"required":true}, "disclosureFrame": {"ref":"DisclosureFrameForOffer"}, }, @@ -839,6 +840,7 @@ const models: TsoaRoute.Models = { "credentialSupportedId": {"dataType":"string","required":true}, "format": {"ref":"OpenId4VciCredentialFormatProfile","required":true}, "signerOptions": {"dataType":"nestedObjectLiteral","nestedProperties":{"keyId":{"dataType":"string"},"x5c":{"dataType":"array","array":{"dataType":"string"}},"did":{"dataType":"string"},"method":{"ref":"SignerMethod","required":true}},"required":true}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, "payload": {"dataType":"nestedObjectLiteral","nestedProperties":{"namespaces":{"ref":"MdocNameSpaces","required":true},"validityInfo":{"ref":"Partial_ValidityInfo_"},"docType":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["org.iso.18013.5.1.mDL"]},{"dataType":"intersection","subSchemas":[{"dataType":"string"},{"dataType":"nestedObjectLiteral","nestedProperties":{}}]}],"required":true}},"required":true}, }, "additionalProperties": false, @@ -928,6 +930,7 @@ const models: TsoaRoute.Models = { "credentialSupportedId": {"dataType":"string","required":true}, "format": {"ref":"OpenId4VciCredentialFormatProfile","required":true}, "signerOptions": {"dataType":"nestedObjectLiteral","nestedProperties":{"keyId":{"dataType":"string"},"x5c":{"dataType":"array","array":{"dataType":"string"}},"did":{"dataType":"string"},"method":{"ref":"SignerMethod","required":true}},"required":true}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, "payload": {"dataType":"nestedObjectLiteral","nestedProperties":{"credential":{"ref":"W3cCredential","required":true},"verificationMethod":{"dataType":"string","required":true}},"required":true}, }, "additionalProperties": false, @@ -941,6 +944,7 @@ const models: TsoaRoute.Models = { "authorizationCodeFlowConfig": {"dataType":"nestedObjectLiteral","nestedProperties":{"issuerState":{"dataType":"string"},"requirePresentationDuringIssuance":{"dataType":"boolean"},"authorizationServerUrl":{"dataType":"string","required":true}}}, "preAuthorizedCodeFlowConfig": {"dataType":"nestedObjectLiteral","nestedProperties":{"authorizationServerUrl":{"dataType":"string","required":true},"txCode":{"dataType":"nestedObjectLiteral","nestedProperties":{"input_mode":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["numeric"]},{"dataType":"enum","enums":["text"]}]},"length":{"dataType":"double"},"description":{"dataType":"string"}}},"preAuthorizedCode":{"dataType":"string"}}}, "issuanceMetadata": {"ref":"Record_string.unknown_"}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, }, "additionalProperties": false, }, @@ -2523,6 +2527,43 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsIssuanceSessionsController_revokeSessionById: Record = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + issuanceSessionId: {"in":"path","name":"issuanceSessionId","required":true,"dataType":"string"}, + }; + app.post('/openid4vc/issuance-sessions/:issuanceSessionId/revoke', + authenticateMiddleware([{"jwt":["tenant","dedicated"]}]), + ...(fetchMiddlewares(IssuanceSessionsController)), + ...(fetchMiddlewares(IssuanceSessionsController.prototype.revokeSessionById)), + + async function IssuanceSessionsController_revokeSessionById(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsIssuanceSessionsController_revokeSessionById, request, response }); + + const container: IocContainer = typeof iocContainer === 'function' ? (iocContainer as IocContainerFactory)(request) : iocContainer; + + const controller: any = await container.get(IssuanceSessionsController); + if (typeof controller['setStatus'] === 'function') { + controller.setStatus(undefined); + } + + await templateService.apiHandler({ + methodName: 'revokeSessionById', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsHolderController_getSdJwtCredentials: Record = { request: {"in":"request","name":"request","required":true,"dataType":"object"}, }; diff --git a/src/routes/swagger.json b/src/routes/swagger.json index 55f04345..b5a9f710 100644 --- a/src/routes/swagger.json +++ b/src/routes/swagger.json @@ -1720,6 +1720,26 @@ ], "type": "object" }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" + }, "payload": { "properties": { "vct": { @@ -1806,6 +1826,26 @@ ], "type": "object" }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" + }, "payload": { "properties": { "namespaces": { @@ -2059,6 +2099,26 @@ ], "type": "object" }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" + }, "payload": { "properties": { "credential": { @@ -2157,6 +2217,26 @@ }, "issuanceMetadata": { "$ref": "#/components/schemas/Record_string.unknown_" + }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" } }, "required": [ @@ -4974,6 +5054,53 @@ ] } }, + "/openid4vc/issuance-sessions/{issuanceSessionId}/revoke": { + "post": { + "operationId": "RevokeSessionById", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + } + } + }, + "description": "Revoke credentials in an issuance session by session ID", + "tags": [ + "oid4vc issuance sessions" + ], + "security": [ + { + "jwt": [ + "tenant", + "dedicated" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "issuanceSessionId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/openid4vc/holder/sd-jwt-vcs": { "get": { "operationId": "GetSdJwtCredentials", diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts new file mode 100644 index 00000000..02e4c88a --- /dev/null +++ b/src/utils/statusListService.ts @@ -0,0 +1,140 @@ +import { JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; +import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; + +// We evaluate this at request-time instead of statically so cliAgent config is available +export function getServerUrl() { + return process.env.STATUS_LIST_SERVER_URL || 'http://localhost:3000'; +} + +function getApiKeyHeaders() { + const key = process.env.STATUS_LIST_API_KEY; + const headers: Record = { 'Content-Type': 'application/json' }; + if (key) { + headers['x-api-key'] = key; + } + return headers; +} + +async function getKmsKeyIdForDid(agent: any, did: string, verificationMethodId: string) { + const didRecords = await agent.dids.getCreatedDids({ did }); + const didRecord = didRecords[0]; + if (didRecord && didRecord.keys) { + const relativeId = verificationMethodId.includes('#') ? verificationMethodId.split('#')[1] : verificationMethodId; + const keyMap = didRecord.keys.find((k: any) => k.didDocumentRelativeKeyId === `#${relativeId}` || k.didDocumentRelativeKeyId === relativeId); + if (keyMap) { + return keyMap.kmsKeyId; + } + } + return verificationMethodId; +} + +async function signStatusList(agent: any, verificationMethodId: string, statusList: StatusList, listId: string, issuerDid: string): Promise { + const payload = new JwtPayload({ + iss: issuerDid, + sub: `${getServerUrl()}/status-lists/${listId}`, + iat: Math.floor(Date.now() / 1000), + additionalClaims: { + status_list: { + bits: statusList.getBitsPerStatus(), + lst: statusList.compressStatusList(), + } + } + }); + + const header: JwsProtectedHeaderOptions = { + alg: 'EdDSA', + typ: 'statuslist+jwt', + }; + + const jwsService = agent.dependencyManager.resolve(JwsService); + const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethodId); + + // In v0.6.x, createJwsCompact takes keyId instead of key + return jwsService.createJwsCompact(agent.context, { + keyId: kmsKeyId, + payload, + protectedHeaderOptions: header, + }); +} + +export async function checkAndCreateStatusList(agent: any, listId: string, issuerDid: string, listSize?: number) { + const uri = `${getServerUrl()}/status-lists/${listId}`; + + try { + const res = await fetch(uri); + + // If it does not exist (404), we need to create it + if (res.status === 404) { + console.log(`Status list ${listId} not found, creating a new one...`); + // Use provided listSize or fallback to env var (default to 131072) + const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE) || 131072; + const statusList = new StatusList(new Array(size).fill(0), 1); + + const didDocument = await agent.dids.resolve(issuerDid); + const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + + if (!verificationMethod) { + throw new Error(`Could not find verification method for DID ${issuerDid}`); + } + + // Hack to extract keyId + const keyId = verificationMethod.id; + + const jwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + + // Post the new status list back to the server + const postRes = await fetch(`${getServerUrl()}/status-lists`, { + method: 'POST', + headers: getApiKeyHeaders(), + body: JSON.stringify({ id: listId, jwt }), + }); + + if (!postRes.ok) { + const errBody = await postRes.text(); + throw new Error(`Failed to create list on server: ${postRes.status} ${errBody}`); + } + + console.log(`Successfully created and published new status list ${listId}`); + } else if (!res.ok) { + throw new Error(`Failed to check status list ${listId}: ${res.statusText}`); + } + } catch (error) { + console.error(`Error in checkAndCreateStatusList:`, error); + throw error; + } +} + +export async function revokeCredentialInStatusList(agent: any, listId: string, index: number, issuerDid: string) { + const uri = `${getServerUrl()}/status-lists/${listId}`; + + // 1. Fetch current + const res = await fetch(uri); + if (!res.ok) throw new Error(`Failed to fetch status list to revoke: ${res.statusText}`); + + const currentJwt = await res.text(); + const statusList = getListFromStatusListJWT(currentJwt); + + // 2. Flip the bit + statusList.setStatus(index, 1); + + // 3. Resolve keyId + const didDocument = await agent.dids.resolve(issuerDid); + const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); + const keyId = verificationMethod.id; + + // 4. Re-sign + const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + + // 5. Update the server + const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { + method: 'PATCH', + headers: getApiKeyHeaders(), + body: JSON.stringify({ jwt: newJwt }), + }); + + if (!patchRes.ok) { + const errBody = await patchRes.text(); + throw new Error(`Failed to update status list on server: ${patchRes.status} ${errBody}`); + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 9543ae73..13879532 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,10 +13,18 @@ "emitDecoratorMetadata": true, "skipLibCheck": true, "outDir": "./build", - "types": ["node"], - "lib": ["ES2021.Promise"] + "types": [ + "node" + ], + "lib": [ + "ESNext", + "DOM" + ] }, - "include": ["src/**/*", "src/routes"], + "include": [ + "src/**/*", + "src/routes" + ], "exclude": [ "node_modules", "build", @@ -28,4 +36,4 @@ "**/*.d.ts", "scripts" ] -} +} \ No newline at end of file From 3bb06454d650c88da950ef2513217d9a22ba097d Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 16:25:23 +0530 Subject: [PATCH 02/16] feat: Add status list management and revocation for OpenID4VC issuance sessions and integrate SdJwtVcModule. Signed-off-by: Sagar Khole --- src/utils/statusListService.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index 02e4c88a..e26b8cbe 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -63,10 +63,8 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue try { const res = await fetch(uri); - // If it does not exist (404), we need to create it if (res.status === 404) { console.log(`Status list ${listId} not found, creating a new one...`); - // Use provided listSize or fallback to env var (default to 131072) const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE) || 131072; const statusList = new StatusList(new Array(size).fill(0), 1); @@ -77,12 +75,9 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue throw new Error(`Could not find verification method for DID ${issuerDid}`); } - // Hack to extract keyId const keyId = verificationMethod.id; const jwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - - // Post the new status list back to the server const postRes = await fetch(`${getServerUrl()}/status-lists`, { method: 'POST', headers: getApiKeyHeaders(), From 7986f73e1990d5bfa84327ce296dd346bbd0ff24 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 18:15:42 +0530 Subject: [PATCH 03/16] feat: Add property to credential offer types and issuance logic Signed-off-by: Sagar Khole --- .../openid4vc/issuance-sessions/issuance-sessions.service.ts | 3 ++- src/controllers/openid4vc/types/issuer.types.ts | 1 + src/routes/routes.ts | 1 + src/routes/swagger.json | 3 +++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts index 0282621c..7a9e2cd9 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -54,7 +54,7 @@ class IssuanceSessionsService { const effectiveStatusList = cred.statusListDetails || options.statusListDetails let statusBlock = undefined - if (effectiveIssuerDid && effectiveStatusList) { + if (options.isRevocable && effectiveIssuerDid && effectiveStatusList) { await checkAndCreateStatusList( agentReq.agent as any, effectiveStatusList.listId, @@ -91,6 +91,7 @@ class IssuanceSessionsService { options.issuanceMetadata ||= {} options.issuanceMetadata.credentials = mappedCredentials + options.issuanceMetadata.isRevocable = options.isRevocable if (offerStatusInfo.length > 0) { options.issuanceMetadata.StatusListInfo = offerStatusInfo diff --git a/src/controllers/openid4vc/types/issuer.types.ts b/src/controllers/openid4vc/types/issuer.types.ts index 10ff3ef8..987a0543 100644 --- a/src/controllers/openid4vc/types/issuer.types.ts +++ b/src/controllers/openid4vc/types/issuer.types.ts @@ -79,6 +79,7 @@ export interface OpenId4VcIssuanceSessionsCreateOffer { index: number listSize?: number } + isRevocable?: boolean } export interface X509GenericRecordContent { diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 2fc737d2..d170b56b 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -949,6 +949,7 @@ const models: TsoaRoute.Models = { "preAuthorizedCodeFlowConfig": {"dataType":"nestedObjectLiteral","nestedProperties":{"authorizationServerUrl":{"dataType":"string","required":true},"txCode":{"dataType":"nestedObjectLiteral","nestedProperties":{"input_mode":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["numeric"]},{"dataType":"enum","enums":["text"]}]},"length":{"dataType":"double"},"description":{"dataType":"string"}}},"preAuthorizedCode":{"dataType":"string"}}}, "issuanceMetadata": {"ref":"Record_string.unknown_"}, "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, + "isRevocable": {"dataType":"boolean"}, }, "additionalProperties": false, }, diff --git a/src/routes/swagger.json b/src/routes/swagger.json index f9741416..1c5ed875 100644 --- a/src/routes/swagger.json +++ b/src/routes/swagger.json @@ -2237,6 +2237,9 @@ "listId" ], "type": "object" + }, + "isRevocable": { + "type": "boolean" } }, "required": [ From fb0729d40e141e67d423859787e64831d90dfc63 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 21:19:15 +0530 Subject: [PATCH 04/16] making status list server URL mandatory Signed-off-by: Sagar Khole --- src/cliAgent.ts | 2 ++ src/utils/statusListService.ts | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/cliAgent.ts b/src/cliAgent.ts index 2d191890..2f2738bc 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -380,6 +380,8 @@ export async function runRestAgent(restConfig: AriesRestConfig) { if (statusListServerUrl) { process.env.STATUS_LIST_SERVER_URL = statusListServerUrl + } else { + throw new Error('statusListServerUrl is required in the configuration') } if (statusListApiKey) { process.env.STATUS_LIST_API_KEY = statusListApiKey diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index e26b8cbe..7b528014 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -3,7 +3,11 @@ import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; // We evaluate this at request-time instead of statically so cliAgent config is available export function getServerUrl() { - return process.env.STATUS_LIST_SERVER_URL || 'http://localhost:3000'; + const url = process.env.STATUS_LIST_SERVER_URL; + if (!url) { + throw new Error('STATUS_LIST_SERVER_URL is not configured'); + } + return url.endsWith('/') ? url.slice(0, -1) : url; } function getApiKeyHeaders() { @@ -61,7 +65,9 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue const uri = `${getServerUrl()}/status-lists/${listId}`; try { - const res = await fetch(uri); + const res = await fetch(uri, { + headers: getApiKeyHeaders() + }); if (res.status === 404) { console.log(`Status list ${listId} not found, creating a new one...`); @@ -91,7 +97,8 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue console.log(`Successfully created and published new status list ${listId}`); } else if (!res.ok) { - throw new Error(`Failed to check status list ${listId}: ${res.statusText}`); + const errBody = await res.text().catch(() => ''); + throw new Error(`Failed to check status list ${listId} at ${uri}: ${res.status} ${res.statusText} ${errBody}`); } } catch (error) { console.error(`Error in checkAndCreateStatusList:`, error); @@ -103,8 +110,13 @@ export async function revokeCredentialInStatusList(agent: any, listId: string, i const uri = `${getServerUrl()}/status-lists/${listId}`; // 1. Fetch current - const res = await fetch(uri); - if (!res.ok) throw new Error(`Failed to fetch status list to revoke: ${res.statusText}`); + const res = await fetch(uri, { + headers: getApiKeyHeaders() + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Failed to fetch status list to revoke at ${uri}: ${res.status} ${res.statusText} ${errBody}`); + } const currentJwt = await res.text(); const statusList = getListFromStatusListJWT(currentJwt); From d3a02fd4361f7160308f81809536f9e505debff5 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Mon, 30 Mar 2026 12:07:16 +0530 Subject: [PATCH 05/16] feat: concurrency handled in the status list service. Signed-off-by: Sagar Khole --- samples/cliConfig.json | 8 ++-- src/utils/statusListService.ts | 72 +++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/samples/cliConfig.json b/samples/cliConfig.json index 092c3d79..17001d25 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -1,6 +1,6 @@ { - "label": "AFJ Rest Agent 1", - "walletId": "sample", + "label": "platform-admin-agent", + "walletId": "platform_admin_agent", "walletKey": "sample", "walletType": "postgres", "walletUrl": "localhost:5432", @@ -23,7 +23,7 @@ "indyNamespace": "bcovrin:testnet" } ], - "endpoint": ["http://localhost:4002"], + "endpoint": ["https://e70b-122-170-198-40.ngrok-free.app"], "autoAcceptConnections": true, "autoAcceptCredentials": "always", "autoAcceptProofs": "contentApproved", @@ -45,7 +45,7 @@ "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk", "apiKey": "supersecret-that-too-16chars", "updateJwtSecret": false, - "statusListServerUrl": "http://localhost:3000", + "statusListServerUrl": "https://dev-status-list.sovio.id/", "statusListApiKey": "test_key", "statusListDefaultSize": 131072 } diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index 7b528014..aca9ea8e 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -1,6 +1,9 @@ import { JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; +const statusListLocks = new Map>(); + + // We evaluate this at request-time instead of statically so cliAgent config is available export function getServerUrl() { const url = process.env.STATUS_LIST_SERVER_URL; @@ -107,41 +110,54 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue } export async function revokeCredentialInStatusList(agent: any, listId: string, index: number, issuerDid: string) { - const uri = `${getServerUrl()}/status-lists/${listId}`; + const previousLock = statusListLocks.get(listId) || Promise.resolve(); - // 1. Fetch current - const res = await fetch(uri, { - headers: getApiKeyHeaders() + let releaseLock: () => void; + const currentLock = new Promise((resolve) => { + releaseLock = resolve; }); - if (!res.ok) { - const errBody = await res.text().catch(() => ''); - throw new Error(`Failed to fetch status list to revoke at ${uri}: ${res.status} ${res.statusText} ${errBody}`); - } - const currentJwt = await res.text(); - const statusList = getListFromStatusListJWT(currentJwt); + statusListLocks.set(listId, currentLock); - // 2. Flip the bit - statusList.setStatus(index, 1); + try { + await previousLock; - // 3. Resolve keyId - const didDocument = await agent.dids.resolve(issuerDid); - const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; - if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); - const keyId = verificationMethod.id; + const uri = `${getServerUrl()}/status-lists/${listId}`; - // 4. Re-sign - const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + const res = await fetch(uri, { + headers: getApiKeyHeaders(), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Failed to fetch status list to revoke at ${uri}: ${res.status} ${res.statusText} ${errBody}`); + } - // 5. Update the server - const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { - method: 'PATCH', - headers: getApiKeyHeaders(), - body: JSON.stringify({ jwt: newJwt }), - }); + const currentJwt = await res.text(); + const statusList = getListFromStatusListJWT(currentJwt); + + statusList.setStatus(index, 1); + + const didDocument = await agent.dids.resolve(issuerDid); + const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); + const keyId = verificationMethod.id; + + const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - if (!patchRes.ok) { - const errBody = await patchRes.text(); - throw new Error(`Failed to update status list on server: ${patchRes.status} ${errBody}`); + const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { + method: 'PATCH', + headers: getApiKeyHeaders(), + body: JSON.stringify({ jwt: newJwt }), + }); + + if (!patchRes.ok) { + const errBody = await patchRes.text(); + throw new Error(`Failed to update status list on server: ${patchRes.status} ${errBody}`); + } + } finally { + releaseLock!(); + if (statusListLocks.get(listId) === currentLock) { + statusListLocks.delete(listId); + } } } From 5e7263b0648b6e483be36c65322bac79d4f01a96 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Mon, 30 Mar 2026 12:56:28 +0530 Subject: [PATCH 06/16] feat: update CLI configuration and refine status list service logic Signed-off-by: Sagar Khole --- samples/cliConfig.json | 6 +++--- src/utils/statusListService.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/samples/cliConfig.json b/samples/cliConfig.json index 17001d25..f4d2a2d1 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -1,6 +1,6 @@ { - "label": "platform-admin-agent", - "walletId": "platform_admin_agent", + "label": "AFJ Rest Agent 1", + "walletId": "sample", "walletKey": "sample", "walletType": "postgres", "walletUrl": "localhost:5432", @@ -23,7 +23,7 @@ "indyNamespace": "bcovrin:testnet" } ], - "endpoint": ["https://e70b-122-170-198-40.ngrok-free.app"], + "endpoint": ["http://localhost:4002"], "autoAcceptConnections": true, "autoAcceptCredentials": "always", "autoAcceptProofs": "contentApproved", diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index aca9ea8e..f57891a4 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -4,7 +4,6 @@ import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; const statusListLocks = new Map>(); -// We evaluate this at request-time instead of statically so cliAgent config is available export function getServerUrl() { const url = process.env.STATUS_LIST_SERVER_URL; if (!url) { @@ -56,7 +55,6 @@ async function signStatusList(agent: any, verificationMethodId: string, statusLi const jwsService = agent.dependencyManager.resolve(JwsService); const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethodId); - // In v0.6.x, createJwsCompact takes keyId instead of key return jwsService.createJwsCompact(agent.context, { keyId: kmsKeyId, payload, @@ -74,7 +72,7 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue if (res.status === 404) { console.log(`Status list ${listId} not found, creating a new one...`); - const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE) || 131072; + const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE); const statusList = new StatusList(new Array(size).fill(0), 1); const didDocument = await agent.dids.resolve(issuerDid); From b48e63ff52adb6903cb76d9144ce117dd3da8f85 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 16:20:16 +0530 Subject: [PATCH 07/16] feat: Add status list management and revocation for OpenID4VC issuance sessions and integrate SdJwtVcModule. Signed-off-by: Sagar Khole --- samples/cliConfig.json | 5 +- src/cli.ts | 9 ++ src/cliAgent.ts | 18 +++ .../issuance-sessions.Controller.ts | 15 ++ .../issuance-sessions.service.ts | 67 ++++++++- .../openid4vc/types/issuer.types.ts | 15 +- src/controllers/types.ts | 19 +++ src/controllers/x509/x509.types.ts | 23 ++- src/routes/routes.ts | 41 +++++ src/routes/swagger.json | 127 ++++++++++++++++ src/utils/statusListService.ts | 140 ++++++++++++++++++ tsconfig.build.json | 16 +- 12 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 src/utils/statusListService.ts diff --git a/samples/cliConfig.json b/samples/cliConfig.json index 6743b9b4..092c3d79 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -44,5 +44,8 @@ "fileServerUrl": "https://schema.credebl.id", "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk", "apiKey": "supersecret-that-too-16chars", - "updateJwtSecret": false + "updateJwtSecret": false, + "statusListServerUrl": "http://localhost:3000", + "statusListApiKey": "test_key", + "statusListDefaultSize": 131072 } diff --git a/src/cli.ts b/src/cli.ts index 533cd9e4..15ccf91e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,9 @@ interface Parsed { fileServerToken?: string apiKey?: string updateJwtSecret?: boolean + 'status-list-server-url'?: string + 'status-list-api-key'?: string + 'status-list-default-size'?: number } interface InboundTransport { @@ -163,6 +166,9 @@ async function parseArguments(): Promise { boolean: true, default: false, }) + .option('status-list-server-url', { string: true }) + .option('status-list-api-key', { string: true }) + .option('status-list-default-size', { number: true }) .config() .env('AFJ_REST') .parseAsync() as Promise @@ -211,5 +217,8 @@ export async function runCliServer() { fileServerToken: parsed.fileServerToken, apiKey: parsed['apiKey'], updateJwtSecret: parsed['updateJwtSecret'], + statusListServerUrl: parsed['status-list-server-url'], + statusListApiKey: parsed['status-list-api-key'], + statusListDefaultSize: parsed['status-list-default-size'], } as AriesRestConfig) } diff --git a/src/cliAgent.ts b/src/cliAgent.ts index 18a8192d..2342c5c8 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -31,6 +31,7 @@ import { X509Module, JwkDidRegistrar, JwkDidResolver, + SdJwtVcModule, } from '@credo-ts/core' import { DidCommHttpOutboundTransport, @@ -120,6 +121,9 @@ export interface AriesRestConfig { schemaFileServerURL?: string apiKey: string updateJwtSecret?: boolean + statusListServerUrl?: string + statusListApiKey?: string + statusListDefaultSize?: number } export async function readRestConfig(path: string) { @@ -252,6 +256,7 @@ const getModules = ( rpcUrl: rpcUrl ? rpcUrl : (process.env.RPC_URL as string), serverUrl: fileServerUrl ? fileServerUrl : (process.env.SERVER_URL as string), }), + sdJwtVc: new SdJwtVcModule(), openid4vc: new OpenId4VcModule({ app: expressApp, issuer: { @@ -377,9 +382,22 @@ export async function runRestAgent(restConfig: AriesRestConfig) { walletScheme, apiKey, updateJwtSecret, + statusListServerUrl, + statusListApiKey, + statusListDefaultSize, ...afjConfig } = restConfig + if (statusListServerUrl) { + process.env.STATUS_LIST_SERVER_URL = statusListServerUrl + } + if (statusListApiKey) { + process.env.STATUS_LIST_API_KEY = statusListApiKey + } + if (statusListDefaultSize) { + process.env.STATUS_LIST_DEFAULT_SIZE = String(statusListDefaultSize) + } + const logger = new TsLogger(logLevel ?? LogLevel.error) const agentConfig: InitConfig = { diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts index fd8d0ea1..01b1df1d 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts @@ -104,4 +104,19 @@ export class IssuanceSessionsController extends Controller { throw ErrorHandlingService.handle(error) } } + + /** + * Revoke credentials in an issuance session by session ID + */ + @Post('{issuanceSessionId}/revoke') + public async revokeSessionById( + @Request() request: Req, + @Path('issuanceSessionId') issuanceSessionId: string, + ) { + try { + return await issuanceSessionService.revokeBySessionId(request, issuanceSessionId) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } } diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts index 1959379a..0282621c 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -7,6 +7,8 @@ import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc' import { SignerMethod } from '../../../enums/enum' import { BadRequestError, NotFoundError } from '../../../errors/errors' +import { checkAndCreateStatusList, getServerUrl } from '../../../utils/statusListService' + class IssuanceSessionsService { public async createCredentialOffer(options: OpenId4VcIssuanceSessionsCreateOffer, agentReq: Req) { const { credentials, publicIssuerId } = options @@ -15,7 +17,10 @@ class IssuanceSessionsService { if (!issuer) { throw new NotFoundError(`Issuer with id ${publicIssuerId} not found`) } - const mappedCredentials = credentials.map((cred) => { + + const offerStatusInfo: any[] = [] + + const mappedCredentials = await Promise.all(credentials.map(async (cred) => { const supported = issuer?.credentialConfigurationsSupported[cred.credentialSupportedId] if (!supported) { throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`) @@ -45,20 +50,52 @@ class IssuanceSessionsService { ) } - const currentVct = cred.payload && 'vct' in cred.payload ? cred.payload.vct : undefined + const effectiveIssuerDid = cred.signerOptions?.method === SignerMethod.Did ? cred.signerOptions.did : undefined + const effectiveStatusList = cred.statusListDetails || options.statusListDetails + + let statusBlock = undefined + if (effectiveIssuerDid && effectiveStatusList) { + await checkAndCreateStatusList( + agentReq.agent as any, + effectiveStatusList.listId, + effectiveIssuerDid, + effectiveStatusList.listSize, + ) + const listUri = `${getServerUrl()}/status-lists/${effectiveStatusList.listId}` + + statusBlock = { + status_list: { + uri: listUri, + idx: effectiveStatusList.index + } + } + + offerStatusInfo.push({ + credentialSupportedId: cred.credentialSupportedId, + listId: effectiveStatusList.listId, + index: effectiveStatusList.index, + issuerDid: effectiveIssuerDid + }) + } + + const currentVct = cred.payload && 'vct' in cred.payload ? (cred.payload as any).vct : undefined return { ...cred, payload: { ...cred.payload, vct: currentVct ?? (typeof supported.vct === 'string' ? supported.vct : undefined), + ...(statusBlock ? { status: statusBlock } : {}) }, } - }) + })) options.issuanceMetadata ||= {} - options.issuanceMetadata.credentials = mappedCredentials + if (offerStatusInfo.length > 0) { + options.issuanceMetadata.StatusListInfo = offerStatusInfo + } + const issuerModule = agentReq.agent.modules.openid4vc.issuer if (!issuerModule) { @@ -144,6 +181,28 @@ class IssuanceSessionsService { const issuanceSessionRepository = agentReq.agent.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository) await issuanceSessionRepository.deleteById(agentReq.agent.context, sessionId) } + + public async revokeBySessionId(agentReq: Req, sessionId: string) { + const issuanceSessionRepository = agentReq.agent.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository) + const record = await issuanceSessionRepository.findById(agentReq.agent.context, sessionId) + + if (!record) { + throw new NotFoundError(`Issuance session with id ${sessionId} not found`) + } + + const statusInfo = record.issuanceMetadata?.StatusListInfo as any[] + if (!statusInfo || statusInfo.length === 0) { + throw new Error(`No status list information found for session ${sessionId}`) + } + + const { revokeCredentialInStatusList } = await import('../../../utils/statusListService') + + for (const info of statusInfo) { + await revokeCredentialInStatusList(agentReq.agent as any, info.listId, info.index, info.issuerDid) + } + + return { message: 'Credentials in session revoked successfully' } + } } export const issuanceSessionService = new IssuanceSessionsService() diff --git a/src/controllers/openid4vc/types/issuer.types.ts b/src/controllers/openid4vc/types/issuer.types.ts index 0e334f34..f80a215b 100644 --- a/src/controllers/openid4vc/types/issuer.types.ts +++ b/src/controllers/openid4vc/types/issuer.types.ts @@ -4,10 +4,7 @@ import type { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc' import { Kms } from '@credo-ts/core' import { OpenId4VciCreateCredentialOfferOptions, OpenId4VciSignCredentials } from '@credo-ts/openid4vc' -export enum SignerMethod { - Did = 'did', - X5c = 'x5c', -} +import { SignerMethod } from '../../../enums/enum' export interface OpenId4VciOfferCredentials { credentialSupportedId: string @@ -18,6 +15,11 @@ export interface OpenId4VciOfferCredentials { x5c?: string[] keyId?: string } + statusListDetails?: { + listId: string + index: number + listSize?: number + } } export interface DisclosureFrameForOffer { @@ -72,6 +74,11 @@ export interface OpenId4VcIssuanceSessionsCreateOffer { authorizationServerUrl: string } issuanceMetadata?: Record + statusListDetails?: { + listId: string + index: number + listSize?: number + } } export interface X509GenericRecordContent { diff --git a/src/controllers/types.ts b/src/controllers/types.ts index ce84d863..a267b0ec 100644 --- a/src/controllers/types.ts +++ b/src/controllers/types.ts @@ -67,6 +67,25 @@ export interface AgentToken { token: string } +export enum CredentialState { + ProposalSent = 'proposal-sent', + ProposalReceived = 'proposal-received', + OfferSent = 'offer-sent', + OfferReceived = 'offer-received', + Declined = 'declined', + RequestSent = 'request-sent', + RequestReceived = 'request-received', + CredentialIssued = 'credential-issued', + CredentialReceived = 'credential-received', + Done = 'done', + Abandoned = 'abandoned', +} + +export enum CredentialRole { + Issuer = 'issuer', + Holder = 'holder', +} + export interface AgentMessageType { '@id': string '@type': string diff --git a/src/controllers/x509/x509.types.ts b/src/controllers/x509/x509.types.ts index a642a0ae..274db1c4 100644 --- a/src/controllers/x509/x509.types.ts +++ b/src/controllers/x509/x509.types.ts @@ -1,5 +1,26 @@ import type { Curve } from '../types' -import type { X509ExtendedKeyUsage, X509KeyUsage } from '@credo-ts/core' + +export enum X509KeyUsage { + DigitalSignature = 1, + NonRepudiation = 2, + KeyEncipherment = 4, + DataEncipherment = 8, + KeyAgreement = 16, + KeyCertSign = 32, + CrlSign = 64, + EncipherOnly = 128, + DecipherOnly = 256, +} + +export enum X509ExtendedKeyUsage { + ServerAuth = '1.3.6.1.5.5.7.3.1', + ClientAuth = '1.3.6.1.5.5.7.3.2', + CodeSigning = '1.3.6.1.5.5.7.3.3', + EmailProtection = '1.3.6.1.5.5.7.3.4', + TimeStamping = '1.3.6.1.5.5.7.3.8', + OcspSigning = '1.3.6.1.5.5.7.3.9', + MdlDs = '1.0.18013.5.1.2', +} // Enum remains the same export enum GeneralNameType { diff --git a/src/routes/routes.ts b/src/routes/routes.ts index f29748a3..d85571a3 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -820,6 +820,7 @@ const models: TsoaRoute.Models = { "credentialSupportedId": {"dataType":"string","required":true}, "format": {"ref":"OpenId4VciCredentialFormatProfile","required":true}, "signerOptions": {"dataType":"nestedObjectLiteral","nestedProperties":{"keyId":{"dataType":"string"},"x5c":{"dataType":"array","array":{"dataType":"string"}},"did":{"dataType":"string"},"method":{"ref":"SignerMethod","required":true}},"required":true}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, "payload": {"dataType":"nestedObjectLiteral","nestedProperties":{"vct":{"dataType":"string"}},"additionalProperties":{"dataType":"any"},"required":true}, "disclosureFrame": {"ref":"DisclosureFrameForOffer"}, }, @@ -847,6 +848,7 @@ const models: TsoaRoute.Models = { "credentialSupportedId": {"dataType":"string","required":true}, "format": {"ref":"OpenId4VciCredentialFormatProfile","required":true}, "signerOptions": {"dataType":"nestedObjectLiteral","nestedProperties":{"keyId":{"dataType":"string"},"x5c":{"dataType":"array","array":{"dataType":"string"}},"did":{"dataType":"string"},"method":{"ref":"SignerMethod","required":true}},"required":true}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, "payload": {"dataType":"nestedObjectLiteral","nestedProperties":{"namespaces":{"ref":"MdocNameSpaces","required":true},"validityInfo":{"ref":"Partial_ValidityInfo_"},"docType":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["org.iso.18013.5.1.mDL"]},{"dataType":"intersection","subSchemas":[{"dataType":"string"},{"dataType":"nestedObjectLiteral","nestedProperties":{}}]}],"required":true}},"required":true}, }, "additionalProperties": false, @@ -936,6 +938,7 @@ const models: TsoaRoute.Models = { "credentialSupportedId": {"dataType":"string","required":true}, "format": {"ref":"OpenId4VciCredentialFormatProfile","required":true}, "signerOptions": {"dataType":"nestedObjectLiteral","nestedProperties":{"keyId":{"dataType":"string"},"x5c":{"dataType":"array","array":{"dataType":"string"}},"did":{"dataType":"string"},"method":{"ref":"SignerMethod","required":true}},"required":true}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, "payload": {"dataType":"nestedObjectLiteral","nestedProperties":{"credential":{"ref":"W3cCredential","required":true},"verificationMethod":{"dataType":"string","required":true}},"required":true}, }, "additionalProperties": false, @@ -949,6 +952,7 @@ const models: TsoaRoute.Models = { "authorizationCodeFlowConfig": {"dataType":"nestedObjectLiteral","nestedProperties":{"issuerState":{"dataType":"string"},"requirePresentationDuringIssuance":{"dataType":"boolean"},"authorizationServerUrl":{"dataType":"string","required":true}}}, "preAuthorizedCodeFlowConfig": {"dataType":"nestedObjectLiteral","nestedProperties":{"authorizationServerUrl":{"dataType":"string","required":true},"txCode":{"dataType":"nestedObjectLiteral","nestedProperties":{"input_mode":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["numeric"]},{"dataType":"enum","enums":["text"]}]},"length":{"dataType":"double"},"description":{"dataType":"string"}}},"preAuthorizedCode":{"dataType":"string"}}}, "issuanceMetadata": {"ref":"Record_string.unknown_"}, + "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, }, "additionalProperties": false, }, @@ -3244,6 +3248,43 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + const argsIssuanceSessionsController_revokeSessionById: Record = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + issuanceSessionId: {"in":"path","name":"issuanceSessionId","required":true,"dataType":"string"}, + }; + app.post('/openid4vc/issuance-sessions/:issuanceSessionId/revoke', + authenticateMiddleware([{"jwt":["tenant","dedicated"]}]), + ...(fetchMiddlewares(IssuanceSessionsController)), + ...(fetchMiddlewares(IssuanceSessionsController.prototype.revokeSessionById)), + + async function IssuanceSessionsController_revokeSessionById(request: ExRequest, response: ExResponse, next: any) { + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsIssuanceSessionsController_revokeSessionById, request, response }); + + const container: IocContainer = typeof iocContainer === 'function' ? (iocContainer as IocContainerFactory)(request) : iocContainer; + + const controller: any = await container.get(IssuanceSessionsController); + if (typeof controller['setStatus'] === 'function') { + controller.setStatus(undefined); + } + + await templateService.apiHandler({ + methodName: 'revokeSessionById', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsHolderController_getSdJwtCredentials: Record = { request: {"in":"request","name":"request","required":true,"dataType":"object"}, }; diff --git a/src/routes/swagger.json b/src/routes/swagger.json index 81edf22e..a560d8e3 100644 --- a/src/routes/swagger.json +++ b/src/routes/swagger.json @@ -1726,6 +1726,26 @@ ], "type": "object" }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" + }, "payload": { "properties": { "vct": { @@ -1812,6 +1832,26 @@ ], "type": "object" }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" + }, "payload": { "properties": { "namespaces": { @@ -2065,6 +2105,26 @@ ], "type": "object" }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" + }, "payload": { "properties": { "credential": { @@ -2163,6 +2223,26 @@ }, "issuanceMetadata": { "$ref": "#/components/schemas/Record_string.unknown_" + }, + "statusListDetails": { + "properties": { + "listSize": { + "type": "number", + "format": "double" + }, + "index": { + "type": "number", + "format": "double" + }, + "listId": { + "type": "string" + } + }, + "required": [ + "index", + "listId" + ], + "type": "object" } }, "required": [ @@ -6625,6 +6705,53 @@ ] } }, + "/openid4vc/issuance-sessions/{issuanceSessionId}/revoke": { + "post": { + "operationId": "RevokeSessionById", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + } + } + }, + "description": "Revoke credentials in an issuance session by session ID", + "tags": [ + "oid4vc issuance sessions" + ], + "security": [ + { + "jwt": [ + "tenant", + "dedicated" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "issuanceSessionId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/openid4vc/holder/sd-jwt-vcs": { "get": { "operationId": "GetSdJwtCredentials", diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts new file mode 100644 index 00000000..02e4c88a --- /dev/null +++ b/src/utils/statusListService.ts @@ -0,0 +1,140 @@ +import { JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; +import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; + +// We evaluate this at request-time instead of statically so cliAgent config is available +export function getServerUrl() { + return process.env.STATUS_LIST_SERVER_URL || 'http://localhost:3000'; +} + +function getApiKeyHeaders() { + const key = process.env.STATUS_LIST_API_KEY; + const headers: Record = { 'Content-Type': 'application/json' }; + if (key) { + headers['x-api-key'] = key; + } + return headers; +} + +async function getKmsKeyIdForDid(agent: any, did: string, verificationMethodId: string) { + const didRecords = await agent.dids.getCreatedDids({ did }); + const didRecord = didRecords[0]; + if (didRecord && didRecord.keys) { + const relativeId = verificationMethodId.includes('#') ? verificationMethodId.split('#')[1] : verificationMethodId; + const keyMap = didRecord.keys.find((k: any) => k.didDocumentRelativeKeyId === `#${relativeId}` || k.didDocumentRelativeKeyId === relativeId); + if (keyMap) { + return keyMap.kmsKeyId; + } + } + return verificationMethodId; +} + +async function signStatusList(agent: any, verificationMethodId: string, statusList: StatusList, listId: string, issuerDid: string): Promise { + const payload = new JwtPayload({ + iss: issuerDid, + sub: `${getServerUrl()}/status-lists/${listId}`, + iat: Math.floor(Date.now() / 1000), + additionalClaims: { + status_list: { + bits: statusList.getBitsPerStatus(), + lst: statusList.compressStatusList(), + } + } + }); + + const header: JwsProtectedHeaderOptions = { + alg: 'EdDSA', + typ: 'statuslist+jwt', + }; + + const jwsService = agent.dependencyManager.resolve(JwsService); + const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethodId); + + // In v0.6.x, createJwsCompact takes keyId instead of key + return jwsService.createJwsCompact(agent.context, { + keyId: kmsKeyId, + payload, + protectedHeaderOptions: header, + }); +} + +export async function checkAndCreateStatusList(agent: any, listId: string, issuerDid: string, listSize?: number) { + const uri = `${getServerUrl()}/status-lists/${listId}`; + + try { + const res = await fetch(uri); + + // If it does not exist (404), we need to create it + if (res.status === 404) { + console.log(`Status list ${listId} not found, creating a new one...`); + // Use provided listSize or fallback to env var (default to 131072) + const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE) || 131072; + const statusList = new StatusList(new Array(size).fill(0), 1); + + const didDocument = await agent.dids.resolve(issuerDid); + const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + + if (!verificationMethod) { + throw new Error(`Could not find verification method for DID ${issuerDid}`); + } + + // Hack to extract keyId + const keyId = verificationMethod.id; + + const jwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + + // Post the new status list back to the server + const postRes = await fetch(`${getServerUrl()}/status-lists`, { + method: 'POST', + headers: getApiKeyHeaders(), + body: JSON.stringify({ id: listId, jwt }), + }); + + if (!postRes.ok) { + const errBody = await postRes.text(); + throw new Error(`Failed to create list on server: ${postRes.status} ${errBody}`); + } + + console.log(`Successfully created and published new status list ${listId}`); + } else if (!res.ok) { + throw new Error(`Failed to check status list ${listId}: ${res.statusText}`); + } + } catch (error) { + console.error(`Error in checkAndCreateStatusList:`, error); + throw error; + } +} + +export async function revokeCredentialInStatusList(agent: any, listId: string, index: number, issuerDid: string) { + const uri = `${getServerUrl()}/status-lists/${listId}`; + + // 1. Fetch current + const res = await fetch(uri); + if (!res.ok) throw new Error(`Failed to fetch status list to revoke: ${res.statusText}`); + + const currentJwt = await res.text(); + const statusList = getListFromStatusListJWT(currentJwt); + + // 2. Flip the bit + statusList.setStatus(index, 1); + + // 3. Resolve keyId + const didDocument = await agent.dids.resolve(issuerDid); + const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); + const keyId = verificationMethod.id; + + // 4. Re-sign + const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + + // 5. Update the server + const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { + method: 'PATCH', + headers: getApiKeyHeaders(), + body: JSON.stringify({ jwt: newJwt }), + }); + + if (!patchRes.ok) { + const errBody = await patchRes.text(); + throw new Error(`Failed to update status list on server: ${patchRes.status} ${errBody}`); + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 9543ae73..13879532 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,10 +13,18 @@ "emitDecoratorMetadata": true, "skipLibCheck": true, "outDir": "./build", - "types": ["node"], - "lib": ["ES2021.Promise"] + "types": [ + "node" + ], + "lib": [ + "ESNext", + "DOM" + ] }, - "include": ["src/**/*", "src/routes"], + "include": [ + "src/**/*", + "src/routes" + ], "exclude": [ "node_modules", "build", @@ -28,4 +36,4 @@ "**/*.d.ts", "scripts" ] -} +} \ No newline at end of file From 2919807bdb34d2d646963a151e16b5e01815e312 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 16:25:23 +0530 Subject: [PATCH 08/16] feat: Add status list management and revocation for OpenID4VC issuance sessions and integrate SdJwtVcModule. Signed-off-by: Sagar Khole --- src/utils/statusListService.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index 02e4c88a..e26b8cbe 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -63,10 +63,8 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue try { const res = await fetch(uri); - // If it does not exist (404), we need to create it if (res.status === 404) { console.log(`Status list ${listId} not found, creating a new one...`); - // Use provided listSize or fallback to env var (default to 131072) const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE) || 131072; const statusList = new StatusList(new Array(size).fill(0), 1); @@ -77,12 +75,9 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue throw new Error(`Could not find verification method for DID ${issuerDid}`); } - // Hack to extract keyId const keyId = verificationMethod.id; const jwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - - // Post the new status list back to the server const postRes = await fetch(`${getServerUrl()}/status-lists`, { method: 'POST', headers: getApiKeyHeaders(), From 738a54065c6486b60ca9106a7c17b4229c98848a Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 18:15:42 +0530 Subject: [PATCH 09/16] feat: Add property to credential offer types and issuance logic Signed-off-by: Sagar Khole --- .../openid4vc/issuance-sessions/issuance-sessions.service.ts | 3 ++- src/controllers/openid4vc/types/issuer.types.ts | 1 + src/routes/routes.ts | 1 + src/routes/swagger.json | 3 +++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts index 0282621c..7a9e2cd9 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -54,7 +54,7 @@ class IssuanceSessionsService { const effectiveStatusList = cred.statusListDetails || options.statusListDetails let statusBlock = undefined - if (effectiveIssuerDid && effectiveStatusList) { + if (options.isRevocable && effectiveIssuerDid && effectiveStatusList) { await checkAndCreateStatusList( agentReq.agent as any, effectiveStatusList.listId, @@ -91,6 +91,7 @@ class IssuanceSessionsService { options.issuanceMetadata ||= {} options.issuanceMetadata.credentials = mappedCredentials + options.issuanceMetadata.isRevocable = options.isRevocable if (offerStatusInfo.length > 0) { options.issuanceMetadata.StatusListInfo = offerStatusInfo diff --git a/src/controllers/openid4vc/types/issuer.types.ts b/src/controllers/openid4vc/types/issuer.types.ts index f80a215b..7da2c857 100644 --- a/src/controllers/openid4vc/types/issuer.types.ts +++ b/src/controllers/openid4vc/types/issuer.types.ts @@ -79,6 +79,7 @@ export interface OpenId4VcIssuanceSessionsCreateOffer { index: number listSize?: number } + isRevocable?: boolean } export interface X509GenericRecordContent { diff --git a/src/routes/routes.ts b/src/routes/routes.ts index d85571a3..7c1d7e96 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -953,6 +953,7 @@ const models: TsoaRoute.Models = { "preAuthorizedCodeFlowConfig": {"dataType":"nestedObjectLiteral","nestedProperties":{"authorizationServerUrl":{"dataType":"string","required":true},"txCode":{"dataType":"nestedObjectLiteral","nestedProperties":{"input_mode":{"dataType":"union","subSchemas":[{"dataType":"enum","enums":["numeric"]},{"dataType":"enum","enums":["text"]}]},"length":{"dataType":"double"},"description":{"dataType":"string"}}},"preAuthorizedCode":{"dataType":"string"}}}, "issuanceMetadata": {"ref":"Record_string.unknown_"}, "statusListDetails": {"dataType":"nestedObjectLiteral","nestedProperties":{"listSize":{"dataType":"double"},"index":{"dataType":"double","required":true},"listId":{"dataType":"string","required":true}}}, + "isRevocable": {"dataType":"boolean"}, }, "additionalProperties": false, }, diff --git a/src/routes/swagger.json b/src/routes/swagger.json index a560d8e3..92368c70 100644 --- a/src/routes/swagger.json +++ b/src/routes/swagger.json @@ -2243,6 +2243,9 @@ "listId" ], "type": "object" + }, + "isRevocable": { + "type": "boolean" } }, "required": [ From d7c682235b01a2a31bf20e2f88dc85104093bc71 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 21:19:15 +0530 Subject: [PATCH 10/16] making status list server URL mandatory Signed-off-by: Sagar Khole --- src/cliAgent.ts | 2 ++ src/utils/statusListService.ts | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/cliAgent.ts b/src/cliAgent.ts index 2342c5c8..183cfea9 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -390,6 +390,8 @@ export async function runRestAgent(restConfig: AriesRestConfig) { if (statusListServerUrl) { process.env.STATUS_LIST_SERVER_URL = statusListServerUrl + } else { + throw new Error('statusListServerUrl is required in the configuration') } if (statusListApiKey) { process.env.STATUS_LIST_API_KEY = statusListApiKey diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index e26b8cbe..7b528014 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -3,7 +3,11 @@ import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; // We evaluate this at request-time instead of statically so cliAgent config is available export function getServerUrl() { - return process.env.STATUS_LIST_SERVER_URL || 'http://localhost:3000'; + const url = process.env.STATUS_LIST_SERVER_URL; + if (!url) { + throw new Error('STATUS_LIST_SERVER_URL is not configured'); + } + return url.endsWith('/') ? url.slice(0, -1) : url; } function getApiKeyHeaders() { @@ -61,7 +65,9 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue const uri = `${getServerUrl()}/status-lists/${listId}`; try { - const res = await fetch(uri); + const res = await fetch(uri, { + headers: getApiKeyHeaders() + }); if (res.status === 404) { console.log(`Status list ${listId} not found, creating a new one...`); @@ -91,7 +97,8 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue console.log(`Successfully created and published new status list ${listId}`); } else if (!res.ok) { - throw new Error(`Failed to check status list ${listId}: ${res.statusText}`); + const errBody = await res.text().catch(() => ''); + throw new Error(`Failed to check status list ${listId} at ${uri}: ${res.status} ${res.statusText} ${errBody}`); } } catch (error) { console.error(`Error in checkAndCreateStatusList:`, error); @@ -103,8 +110,13 @@ export async function revokeCredentialInStatusList(agent: any, listId: string, i const uri = `${getServerUrl()}/status-lists/${listId}`; // 1. Fetch current - const res = await fetch(uri); - if (!res.ok) throw new Error(`Failed to fetch status list to revoke: ${res.statusText}`); + const res = await fetch(uri, { + headers: getApiKeyHeaders() + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Failed to fetch status list to revoke at ${uri}: ${res.status} ${res.statusText} ${errBody}`); + } const currentJwt = await res.text(); const statusList = getListFromStatusListJWT(currentJwt); From 02e80bbb8ccc69112dcdc7ac939c612b9188f4c1 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Mon, 30 Mar 2026 12:07:16 +0530 Subject: [PATCH 11/16] feat: concurrency handled in the status list service. Signed-off-by: Sagar Khole --- samples/cliConfig.json | 8 ++-- src/utils/statusListService.ts | 72 +++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/samples/cliConfig.json b/samples/cliConfig.json index 092c3d79..17001d25 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -1,6 +1,6 @@ { - "label": "AFJ Rest Agent 1", - "walletId": "sample", + "label": "platform-admin-agent", + "walletId": "platform_admin_agent", "walletKey": "sample", "walletType": "postgres", "walletUrl": "localhost:5432", @@ -23,7 +23,7 @@ "indyNamespace": "bcovrin:testnet" } ], - "endpoint": ["http://localhost:4002"], + "endpoint": ["https://e70b-122-170-198-40.ngrok-free.app"], "autoAcceptConnections": true, "autoAcceptCredentials": "always", "autoAcceptProofs": "contentApproved", @@ -45,7 +45,7 @@ "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk", "apiKey": "supersecret-that-too-16chars", "updateJwtSecret": false, - "statusListServerUrl": "http://localhost:3000", + "statusListServerUrl": "https://dev-status-list.sovio.id/", "statusListApiKey": "test_key", "statusListDefaultSize": 131072 } diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index 7b528014..aca9ea8e 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -1,6 +1,9 @@ import { JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; +const statusListLocks = new Map>(); + + // We evaluate this at request-time instead of statically so cliAgent config is available export function getServerUrl() { const url = process.env.STATUS_LIST_SERVER_URL; @@ -107,41 +110,54 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue } export async function revokeCredentialInStatusList(agent: any, listId: string, index: number, issuerDid: string) { - const uri = `${getServerUrl()}/status-lists/${listId}`; + const previousLock = statusListLocks.get(listId) || Promise.resolve(); - // 1. Fetch current - const res = await fetch(uri, { - headers: getApiKeyHeaders() + let releaseLock: () => void; + const currentLock = new Promise((resolve) => { + releaseLock = resolve; }); - if (!res.ok) { - const errBody = await res.text().catch(() => ''); - throw new Error(`Failed to fetch status list to revoke at ${uri}: ${res.status} ${res.statusText} ${errBody}`); - } - const currentJwt = await res.text(); - const statusList = getListFromStatusListJWT(currentJwt); + statusListLocks.set(listId, currentLock); - // 2. Flip the bit - statusList.setStatus(index, 1); + try { + await previousLock; - // 3. Resolve keyId - const didDocument = await agent.dids.resolve(issuerDid); - const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; - if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); - const keyId = verificationMethod.id; + const uri = `${getServerUrl()}/status-lists/${listId}`; - // 4. Re-sign - const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + const res = await fetch(uri, { + headers: getApiKeyHeaders(), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Failed to fetch status list to revoke at ${uri}: ${res.status} ${res.statusText} ${errBody}`); + } - // 5. Update the server - const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { - method: 'PATCH', - headers: getApiKeyHeaders(), - body: JSON.stringify({ jwt: newJwt }), - }); + const currentJwt = await res.text(); + const statusList = getListFromStatusListJWT(currentJwt); + + statusList.setStatus(index, 1); + + const didDocument = await agent.dids.resolve(issuerDid); + const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); + const keyId = verificationMethod.id; + + const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - if (!patchRes.ok) { - const errBody = await patchRes.text(); - throw new Error(`Failed to update status list on server: ${patchRes.status} ${errBody}`); + const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { + method: 'PATCH', + headers: getApiKeyHeaders(), + body: JSON.stringify({ jwt: newJwt }), + }); + + if (!patchRes.ok) { + const errBody = await patchRes.text(); + throw new Error(`Failed to update status list on server: ${patchRes.status} ${errBody}`); + } + } finally { + releaseLock!(); + if (statusListLocks.get(listId) === currentLock) { + statusListLocks.delete(listId); + } } } From 12919847884302f16ac1766d4ba7788df3e10a6b Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Mon, 30 Mar 2026 12:56:28 +0530 Subject: [PATCH 12/16] feat: update CLI configuration and refine status list service logic Signed-off-by: Sagar Khole --- samples/cliConfig.json | 6 +++--- src/utils/statusListService.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/samples/cliConfig.json b/samples/cliConfig.json index 17001d25..f4d2a2d1 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -1,6 +1,6 @@ { - "label": "platform-admin-agent", - "walletId": "platform_admin_agent", + "label": "AFJ Rest Agent 1", + "walletId": "sample", "walletKey": "sample", "walletType": "postgres", "walletUrl": "localhost:5432", @@ -23,7 +23,7 @@ "indyNamespace": "bcovrin:testnet" } ], - "endpoint": ["https://e70b-122-170-198-40.ngrok-free.app"], + "endpoint": ["http://localhost:4002"], "autoAcceptConnections": true, "autoAcceptCredentials": "always", "autoAcceptProofs": "contentApproved", diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index aca9ea8e..f57891a4 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -4,7 +4,6 @@ import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; const statusListLocks = new Map>(); -// We evaluate this at request-time instead of statically so cliAgent config is available export function getServerUrl() { const url = process.env.STATUS_LIST_SERVER_URL; if (!url) { @@ -56,7 +55,6 @@ async function signStatusList(agent: any, verificationMethodId: string, statusLi const jwsService = agent.dependencyManager.resolve(JwsService); const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethodId); - // In v0.6.x, createJwsCompact takes keyId instead of key return jwsService.createJwsCompact(agent.context, { keyId: kmsKeyId, payload, @@ -74,7 +72,7 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue if (res.status === 404) { console.log(`Status list ${listId} not found, creating a new one...`); - const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE) || 131072; + const size = listSize || Number(process.env.STATUS_LIST_DEFAULT_SIZE); const statusList = new StatusList(new Array(size).fill(0), 1); const didDocument = await agent.dids.resolve(issuerDid); From 9cacb99038f535f5dad9e14c3601beac699bd066 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Tue, 7 Apr 2026 18:36:39 +0530 Subject: [PATCH 13/16] refactor:code review changes Signed-off-by: Sagar Khole --- .env.sample | 11 +++++++- samples/cliConfig.json | 7 +---- src/cli.ts | 15 ++++++---- .../issuance-sessions.service.ts | 7 ++--- src/utils/constant.ts | 1 + src/utils/statusListService.ts | 28 ++++++++++--------- tsconfig.build.json | 3 +- 7 files changed, 41 insertions(+), 31 deletions(-) diff --git a/.env.sample b/.env.sample index f5fb4426..9b8eaaaa 100644 --- a/.env.sample +++ b/.env.sample @@ -77,4 +77,13 @@ OID4VCI_CNONCE_EXPIRY=3600 OID4VP_AUTH_REQUEST_PROOF_REQUEST_EXPIRY=3600 APP_JSON_BODY_SIZE=5mb -APP_URL_ENCODED_BODY_SIZE=5mb \ No newline at end of file +APP_URL_ENCODED_BODY_SIZE=5mb + +# Security +API_KEY=supersecret-that-too-16chars +UPDATE_JWT_SECRET=false + +# Status List +STATUS_LIST_SERVER_URL=https://dev-status-list.sovio.id/ +STATUS_LIST_API_KEY=test_key +STATUS_LIST_DEFAULT_SIZE=131072 \ No newline at end of file diff --git a/samples/cliConfig.json b/samples/cliConfig.json index f4d2a2d1..b924aa21 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -42,10 +42,5 @@ "schemaManagerContractAddress": "0x4742d43C2dFCa5a1d4238240Afa8547Daf87Ee7a", "rpcUrl": "https://rpc-amoy.polygon.technology", "fileServerUrl": "https://schema.credebl.id", - "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk", - "apiKey": "supersecret-that-too-16chars", - "updateJwtSecret": false, - "statusListServerUrl": "https://dev-status-list.sovio.id/", - "statusListApiKey": "test_key", - "statusListDefaultSize": 131072 + "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk" } diff --git a/src/cli.ts b/src/cli.ts index 15ccf91e..4454cb4d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,9 @@ import type { AriesRestConfig } from './cliAgent.js' import yargs from 'yargs' import { hideBin } from 'yargs/helpers' +import dotenv from 'dotenv' + +dotenv.config() import { runRestAgent } from './cliAgent.js' @@ -154,7 +157,9 @@ async function parseArguments(): Promise { .option('wallet-idle-timeout', { number: true }) .option('apiKey', { string: true, - coerce: (input: string) => { + default: process.env.API_KEY, + coerce: (input: string | undefined) => { + if (!input) return input input = input.trim() if (input && input.length < 16) { throw new Error('API key must be at least 16 characters long') @@ -164,11 +169,11 @@ async function parseArguments(): Promise { }) .option('updateJwtSecret', { boolean: true, - default: false, + default: process.env.UPDATE_JWT_SECRET === 'true', }) - .option('status-list-server-url', { string: true }) - .option('status-list-api-key', { string: true }) - .option('status-list-default-size', { number: true }) + .option('status-list-server-url', { string: true, default: process.env.STATUS_LIST_SERVER_URL }) + .option('status-list-api-key', { string: true, default: process.env.STATUS_LIST_API_KEY }) + .option('status-list-default-size', { number: true, default: process.env.STATUS_LIST_DEFAULT_SIZE ? Number(process.env.STATUS_LIST_DEFAULT_SIZE) : undefined }) .config() .env('AFJ_REST') .parseAsync() as Promise diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts index 7a9e2cd9..278896ae 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -7,7 +7,8 @@ import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc' import { SignerMethod } from '../../../enums/enum' import { BadRequestError, NotFoundError } from '../../../errors/errors' -import { checkAndCreateStatusList, getServerUrl } from '../../../utils/statusListService' +import { checkAndCreateStatusList, getServerUrl, revokeCredentialInStatusList } from '../../../utils/statusListService' +import { STATUS_LISTS_PATH } from '../../../utils/constant' class IssuanceSessionsService { public async createCredentialOffer(options: OpenId4VcIssuanceSessionsCreateOffer, agentReq: Req) { @@ -61,7 +62,7 @@ class IssuanceSessionsService { effectiveIssuerDid, effectiveStatusList.listSize, ) - const listUri = `${getServerUrl()}/status-lists/${effectiveStatusList.listId}` + const listUri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${effectiveStatusList.listId}` statusBlock = { status_list: { @@ -196,8 +197,6 @@ class IssuanceSessionsService { throw new Error(`No status list information found for session ${sessionId}`) } - const { revokeCredentialInStatusList } = await import('../../../utils/statusListService') - for (const info of statusInfo) { await revokeCredentialInStatusList(agentReq.agent as any, info.listId, info.index, info.issuerDid) } diff --git a/src/utils/constant.ts b/src/utils/constant.ts index 737b1e54..179b75bf 100644 --- a/src/utils/constant.ts +++ b/src/utils/constant.ts @@ -28,3 +28,4 @@ export const curveToKty = { export const verkey = '#verkey' export const p521 = 'p521' +export const STATUS_LISTS_PATH = 'status-lists' diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index f57891a4..105a7d17 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -1,5 +1,6 @@ -import { JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; +import { Agent, JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; +import { STATUS_LISTS_PATH } from './constant'; const statusListLocks = new Map>(); @@ -14,14 +15,15 @@ export function getServerUrl() { function getApiKeyHeaders() { const key = process.env.STATUS_LIST_API_KEY; - const headers: Record = { 'Content-Type': 'application/json' }; - if (key) { - headers['x-api-key'] = key; + if (!key) { + throw new Error('STATUS_LIST_API_KEY is not configured'); } + const headers: Record = { 'Content-Type': 'application/json' }; + headers['x-api-key'] = key; return headers; } -async function getKmsKeyIdForDid(agent: any, did: string, verificationMethodId: string) { +async function getKmsKeyIdForDid(agent: Agent, did: string, verificationMethodId: string) { const didRecords = await agent.dids.getCreatedDids({ did }); const didRecord = didRecords[0]; if (didRecord && didRecord.keys) { @@ -34,10 +36,10 @@ async function getKmsKeyIdForDid(agent: any, did: string, verificationMethodId: return verificationMethodId; } -async function signStatusList(agent: any, verificationMethodId: string, statusList: StatusList, listId: string, issuerDid: string): Promise { +async function signStatusList(agent: Agent, verificationMethodId: string, statusList: StatusList, listId: string, issuerDid: string): Promise { const payload = new JwtPayload({ iss: issuerDid, - sub: `${getServerUrl()}/status-lists/${listId}`, + sub: `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`, iat: Math.floor(Date.now() / 1000), additionalClaims: { status_list: { @@ -62,8 +64,8 @@ async function signStatusList(agent: any, verificationMethodId: string, statusLi }); } -export async function checkAndCreateStatusList(agent: any, listId: string, issuerDid: string, listSize?: number) { - const uri = `${getServerUrl()}/status-lists/${listId}`; +export async function checkAndCreateStatusList(agent: Agent, listId: string, issuerDid: string, listSize?: number) { + const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; try { const res = await fetch(uri, { @@ -85,7 +87,7 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue const keyId = verificationMethod.id; const jwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - const postRes = await fetch(`${getServerUrl()}/status-lists`, { + const postRes = await fetch(`${getServerUrl()}/${STATUS_LISTS_PATH}`, { method: 'POST', headers: getApiKeyHeaders(), body: JSON.stringify({ id: listId, jwt }), @@ -107,7 +109,7 @@ export async function checkAndCreateStatusList(agent: any, listId: string, issue } } -export async function revokeCredentialInStatusList(agent: any, listId: string, index: number, issuerDid: string) { +export async function revokeCredentialInStatusList(agent: Agent, listId: string, index: number, issuerDid: string) { const previousLock = statusListLocks.get(listId) || Promise.resolve(); let releaseLock: () => void; @@ -120,7 +122,7 @@ export async function revokeCredentialInStatusList(agent: any, listId: string, i try { await previousLock; - const uri = `${getServerUrl()}/status-lists/${listId}`; + const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; const res = await fetch(uri, { headers: getApiKeyHeaders(), @@ -142,7 +144,7 @@ export async function revokeCredentialInStatusList(agent: any, listId: string, i const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - const patchRes = await fetch(`${getServerUrl()}/status-lists/${listId}`, { + const patchRes = await fetch(`${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`, { method: 'PATCH', headers: getApiKeyHeaders(), body: JSON.stringify({ jwt: newJwt }), diff --git a/tsconfig.build.json b/tsconfig.build.json index 13879532..f64e3ff5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,8 +17,7 @@ "node" ], "lib": [ - "ESNext", - "DOM" + "ESNext" ] }, "include": [ From 53c330d9649bb0e79c0f45dfd19ae9396d852d75 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Tue, 7 Apr 2026 19:09:05 +0530 Subject: [PATCH 14/16] refactor:code review changes Signed-off-by: Sagar Khole --- .env.demo | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.env.demo b/.env.demo index 5ce6f718..c35c0e5a 100644 --- a/.env.demo +++ b/.env.demo @@ -80,4 +80,13 @@ OID4VCI_CNONCE_EXPIRY=3600 OID4VP_AUTH_REQUEST_PROOF_REQUEST_EXPIRY=3600 APP_JSON_BODY_SIZE=5mb -APP_URL_ENCODED_BODY_SIZE=5mb \ No newline at end of file +APP_URL_ENCODED_BODY_SIZE=5mb + + +API_KEY=supersecret-that-too-16chars +UPDATE_JWT_SECRET=false + + +STATUS_LIST_SERVER_URL=https://dev-status-list.sovio.id/ +STATUS_LIST_API_KEY=test_key +STATUS_LIST_DEFAULT_SIZE=131072 \ No newline at end of file From ccd4574fc0453c00fd9c57c823f39c06f4655339 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Mon, 13 Apr 2026 18:55:14 +0530 Subject: [PATCH 15/16] feat: implement per-list concurrency locking and dynamic algorithm selection for status list operations Signed-off-by: Sagar Khole --- .../issuance-sessions.service.ts | 18 +++++- src/enums/enum.ts | 8 +++ src/utils/helpers.ts | 56 +++++++++++++++++- src/utils/statusListService.ts | 57 ++++++++++++------- tsconfig.build.json | 2 +- 5 files changed, 117 insertions(+), 24 deletions(-) diff --git a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts index e4dbc1bd..4272a560 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -4,7 +4,7 @@ import type { Request as Req } from 'express' import { type OpenId4VcIssuanceSessionState } from '@credo-ts/openid4vc' import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc' -import { SignerMethod } from '../../../enums/enum' +import { CredentialFormat, SignerMethod } from '../../../enums/enum' import { BadRequestError, NotFoundError } from '../../../errors/errors' import { checkAndCreateStatusList, getServerUrl, revokeCredentialInStatusList } from '../../../utils/statusListService' @@ -55,10 +55,24 @@ class IssuanceSessionsService { const effectiveStatusList = cred.statusListDetails || options.statusListDetails let statusBlock = undefined - if (options.isRevocable && effectiveIssuerDid && effectiveStatusList) { + + if (options.isRevocable) { + if (![CredentialFormat.VcSdJwt, CredentialFormat.DcSdJwt].includes(cred.format as unknown as CredentialFormat)) { + throw new BadRequestError(`Revocation is only supported for SD-JWT formats (vc+sd-jwt, dc+sd-jwt), got '${cred.format}'`) + } + if (!process.env.STATUS_LIST_SERVER_URL) { throw new BadRequestError('Cannot create revocable credentials: STATUS_LIST_SERVER_URL is not configured') } + + if (cred.signerOptions.method !== SignerMethod.Did || !effectiveIssuerDid) { + throw new BadRequestError(`Revocation is not supported without a DID signer (found ${cred.signerOptions.method})`) + } + + if (!effectiveStatusList) { + throw new BadRequestError('Status list details must be provided for revocable credentials') + } + await checkAndCreateStatusList( agentReq.agent as any, effectiveStatusList.listId, diff --git a/src/enums/enum.ts b/src/enums/enum.ts index ed45ac7b..8eb51544 100644 --- a/src/enums/enum.ts +++ b/src/enums/enum.ts @@ -111,3 +111,11 @@ export enum KeyAlgorithmCurve { secp256k1 = 'secp256k1', Bls12381G2 = 'bls12381g2', } + +export enum CredentialFormat { + VcSdJwt = 'vc+sd-jwt', + DcSdJwt = 'dc+sd-jwt', + JwtVcJson = 'jwt_vc_json', + JwtVcJsonLd = 'jwt_vc_json-ld', + LdpVc = 'ldp_vc', +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index fc2eff3f..d05fdf1b 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,7 +1,7 @@ import type { Curve, EcCurve, EcType, OkpCurve, OkpType } from '../controllers/types' import type { KeyAlgorithm } from '@openwallet-foundation/askar-nodejs' -import { JsonEncoder, JsonTransformer } from '@credo-ts/core' +import { JsonEncoder, JsonTransformer, DidDocument, VerificationMethod } from '@credo-ts/core' import axios from 'axios' import { randomBytes } from 'crypto' @@ -116,6 +116,60 @@ export function getTypeFromCurve(key: Curve | KeyAlgorithm): OkpType | EcType { return keyTypeInfo } +export function getVerificationMethod(didDocument: DidDocument): VerificationMethod | undefined { + // Try assertionMethod first + const assertionMethod = didDocument.assertionMethod?.[0] + if (assertionMethod) { + if (typeof assertionMethod === 'string') { + return didDocument.dereferenceVerificationMethod(assertionMethod) + } + return assertionMethod as VerificationMethod + } + // Fallback to verificationMethod[0] + return didDocument.verificationMethod?.[0] +} + +export function getAlgFromVerificationMethod(vm: VerificationMethod): string { + if (vm.type === 'Ed25519VerificationKey2018' || vm.type === 'Ed25519VerificationKey2020') { + return 'EdDSA' + } + + if (vm.type === 'JsonWebKey2020' || vm.type === 'JsonWebKey2024') { + const jwk = vm.publicKeyJwk as any + if (jwk?.crv === 'P-256') return 'ES256' + if (jwk?.crv === 'P-384') return 'ES384' + if (jwk?.crv === 'P-521') return 'ES512' + if (jwk?.crv === 'secp256k1') return 'ES256K' + } + + if (vm.type === 'EcdsaSecp256k1VerificationKey2019') { + return 'ES256K' + } + + return 'EdDSA' +} + +export async function fetchWithTimeout(resource: string, options: any = {}) { + const { timeout = 10000, ...fetchOptions } = options + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(resource, { + ...fetchOptions, + signal: controller.signal, + }) + return response + } catch (error: any) { + if (error.name === 'AbortError') { + throw new Error(`Request timed out after ${timeout}ms`) + } + throw error + } finally { + clearTimeout(id) + } +} + type Namespaces = Record export function processIsoImages(namespaces: Namespaces): Namespaces { diff --git a/src/utils/statusListService.ts b/src/utils/statusListService.ts index 105a7d17..c7bb6e3d 100644 --- a/src/utils/statusListService.ts +++ b/src/utils/statusListService.ts @@ -1,6 +1,7 @@ -import { Agent, JwsProtectedHeaderOptions, JwsService, JwtPayload } from '@credo-ts/core'; +import { Agent, JwsProtectedHeaderOptions, JwsService, JwtPayload, VerificationMethod } from '@credo-ts/core'; import { StatusList, getListFromStatusListJWT } from '@sd-jwt/jwt-status-list'; import { STATUS_LISTS_PATH } from './constant'; +import { getAlgFromVerificationMethod, getVerificationMethod, fetchWithTimeout } from './helpers'; const statusListLocks = new Map>(); @@ -36,7 +37,7 @@ async function getKmsKeyIdForDid(agent: Agent, did: string, verificationMethodId return verificationMethodId; } -async function signStatusList(agent: Agent, verificationMethodId: string, statusList: StatusList, listId: string, issuerDid: string): Promise { +async function signStatusList(agent: Agent, verificationMethod: VerificationMethod, statusList: StatusList, listId: string, issuerDid: string): Promise { const payload = new JwtPayload({ iss: issuerDid, sub: `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`, @@ -49,14 +50,16 @@ async function signStatusList(agent: Agent, verificationMethodId: string, status } }); + const alg = getAlgFromVerificationMethod(verificationMethod); + const jwsService = agent.dependencyManager.resolve(JwsService); + const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethod.id); + const header: JwsProtectedHeaderOptions = { - alg: 'EdDSA', + alg: alg as any, typ: 'statuslist+jwt', + kid: verificationMethod.id, }; - const jwsService = agent.dependencyManager.resolve(JwsService); - const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethodId); - return jwsService.createJwsCompact(agent.context, { keyId: kmsKeyId, payload, @@ -65,10 +68,21 @@ async function signStatusList(agent: Agent, verificationMethodId: string, status } export async function checkAndCreateStatusList(agent: Agent, listId: string, issuerDid: string, listSize?: number) { - const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; + const previousLock = statusListLocks.get(listId) || Promise.resolve(); + + let releaseLock: () => void; + const currentLock = new Promise((resolve) => { + releaseLock = resolve; + }); + + statusListLocks.set(listId, currentLock); try { - const res = await fetch(uri, { + await previousLock; + + const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; + + const res = await fetchWithTimeout(uri, { headers: getApiKeyHeaders() }); @@ -78,22 +92,20 @@ export async function checkAndCreateStatusList(agent: Agent, listId: string, iss const statusList = new StatusList(new Array(size).fill(0), 1); const didDocument = await agent.dids.resolve(issuerDid); - const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; + const verificationMethod = didDocument.didDocument ? getVerificationMethod(didDocument.didDocument) : undefined; if (!verificationMethod) { - throw new Error(`Could not find verification method for DID ${issuerDid}`); + throw new Error(`Could not find suitable verification method (assertionMethod) for DID ${issuerDid}`); } - const keyId = verificationMethod.id; - - const jwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); - const postRes = await fetch(`${getServerUrl()}/${STATUS_LISTS_PATH}`, { + const jwt = await signStatusList(agent, verificationMethod, statusList, listId, issuerDid); + const postRes = await fetchWithTimeout(`${getServerUrl()}/${STATUS_LISTS_PATH}`, { method: 'POST', headers: getApiKeyHeaders(), body: JSON.stringify({ id: listId, jwt }), }); - if (!postRes.ok) { + if (!postRes.ok && postRes.status !== 409) { const errBody = await postRes.text(); throw new Error(`Failed to create list on server: ${postRes.status} ${errBody}`); } @@ -106,6 +118,11 @@ export async function checkAndCreateStatusList(agent: Agent, listId: string, iss } catch (error) { console.error(`Error in checkAndCreateStatusList:`, error); throw error; + } finally { + releaseLock!(); + if (statusListLocks.get(listId) === currentLock) { + statusListLocks.delete(listId); + } } } @@ -124,7 +141,7 @@ export async function revokeCredentialInStatusList(agent: Agent, listId: string, const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; - const res = await fetch(uri, { + const res = await fetchWithTimeout(uri, { headers: getApiKeyHeaders(), }); if (!res.ok) { @@ -138,13 +155,13 @@ export async function revokeCredentialInStatusList(agent: Agent, listId: string, statusList.setStatus(index, 1); const didDocument = await agent.dids.resolve(issuerDid); - const verificationMethod = didDocument.didDocument?.verificationMethod?.[0]; - if (!verificationMethod) throw new Error(`Could not find verification method for DID ${issuerDid}`); + const verificationMethod = didDocument.didDocument ? getVerificationMethod(didDocument.didDocument) : undefined; + if (!verificationMethod) throw new Error(`Could not find suitable verification method (assertionMethod) for DID ${issuerDid}`); const keyId = verificationMethod.id; - const newJwt = await signStatusList(agent, keyId, statusList, listId, issuerDid); + const newJwt = await signStatusList(agent, verificationMethod, statusList, listId, issuerDid); - const patchRes = await fetch(`${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`, { + const patchRes = await fetchWithTimeout(`${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`, { method: 'PATCH', headers: getApiKeyHeaders(), body: JSON.stringify({ jwt: newJwt }), diff --git a/tsconfig.build.json b/tsconfig.build.json index f64e3ff5..d8e50b2d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -35,4 +35,4 @@ "**/*.d.ts", "scripts" ] -} \ No newline at end of file +} From 9fcf4c09143dc744646428b9fef97275f3462941 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Tue, 14 Apr 2026 11:43:35 +0530 Subject: [PATCH 16/16] update:Removed file server token from cli config for security Signed-off-by: Sagar Khole --- samples/cliConfig.json | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/samples/cliConfig.json b/samples/cliConfig.json index b924aa21..578eda93 100644 --- a/samples/cliConfig.json +++ b/samples/cliConfig.json @@ -1,7 +1,7 @@ { "label": "AFJ Rest Agent 1", - "walletId": "sample", - "walletKey": "sample", + "walletId": "sample10", + "walletKey": "sample10", "walletType": "postgres", "walletUrl": "localhost:5432", "walletAccount": "postgres", @@ -23,7 +23,9 @@ "indyNamespace": "bcovrin:testnet" } ], - "endpoint": ["http://localhost:4002"], + "endpoint": [ + "http://localhost:4002" + ], "autoAcceptConnections": true, "autoAcceptCredentials": "always", "autoAcceptProofs": "contentApproved", @@ -34,7 +36,9 @@ "port": 4002 } ], - "outboundTransport": ["http"], + "outboundTransport": [ + "http" + ], "adminPort": 4001, "tenancy": true, "schemaFileServerURL": "https://schema.credebl.id/schemas/", @@ -42,5 +46,5 @@ "schemaManagerContractAddress": "0x4742d43C2dFCa5a1d4238240Afa8547Daf87Ee7a", "rpcUrl": "https://rpc-amoy.polygon.technology", "fileServerUrl": "https://schema.credebl.id", - "fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk" -} + "fileServerToken": "" +} \ No newline at end of file