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 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 6743b9b4..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,7 +46,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 -} + "fileServerToken": "" +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index 533cd9e4..3ea425a1 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' @@ -151,7 +154,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') @@ -161,7 +166,7 @@ async function parseArguments(): Promise { }) .option('updateJwtSecret', { boolean: true, - default: false, + default: process.env.UPDATE_JWT_SECRET === 'true', }) .config() .env('AFJ_REST') diff --git a/src/cliAgent.ts b/src/cliAgent.ts index 18a8192d..422d76ed 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -31,6 +31,7 @@ import { X509Module, JwkDidRegistrar, JwkDidResolver, + SdJwtVcModule, } from '@credo-ts/core' import { DidCommHttpOutboundTransport, @@ -252,6 +253,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: { 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..4272a560 100644 --- a/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts +++ b/src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts @@ -4,9 +4,12 @@ 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' +import { STATUS_LISTS_PATH } from '../../../utils/constant' + class IssuanceSessionsService { public async createCredentialOffer(options: OpenId4VcIssuanceSessionsCreateOffer, agentReq: Req) { const { credentials, publicIssuerId } = options @@ -15,7 +18,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,19 +51,69 @@ 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 (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, + effectiveIssuerDid, + effectiveStatusList.listSize, + ) + const listUri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${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 + options.issuanceMetadata.isRevocable = options.isRevocable + + if (offerStatusInfo.length > 0) { + options.issuanceMetadata.StatusListInfo = offerStatusInfo + } const issuerModule = agentReq.agent.modules.openid4vc.issuer @@ -144,6 +200,30 @@ 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}`) + } + + if (!process.env.STATUS_LIST_SERVER_URL) { + throw new BadRequestError('Cannot execute revocation: STATUS_LIST_SERVER_URL is not configured') + } + + 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..7da2c857 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,12 @@ export interface OpenId4VcIssuanceSessionsCreateOffer { authorizationServerUrl: string } issuanceMetadata?: Record + statusListDetails?: { + listId: string + index: number + listSize?: number + } + isRevocable?: boolean } 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/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/routes/routes.ts b/src/routes/routes.ts index f29748a3..7c1d7e96 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,8 @@ 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}}}, + "isRevocable": {"dataType":"boolean"}, }, "additionalProperties": false, }, @@ -3244,6 +3249,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..92368c70 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,29 @@ }, "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" + }, + "isRevocable": { + "type": "boolean" } }, "required": [ @@ -6625,6 +6708,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/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/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 new file mode 100644 index 00000000..c7bb6e3d --- /dev/null +++ b/src/utils/statusListService.ts @@ -0,0 +1,180 @@ +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>(); + + +export function getServerUrl() { + 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() { + const key = process.env.STATUS_LIST_API_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: Agent, 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: Agent, verificationMethod: VerificationMethod, statusList: StatusList, listId: string, issuerDid: string): Promise { + const payload = new JwtPayload({ + iss: issuerDid, + sub: `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`, + iat: Math.floor(Date.now() / 1000), + additionalClaims: { + status_list: { + bits: statusList.getBitsPerStatus(), + lst: statusList.compressStatusList(), + } + } + }); + + const alg = getAlgFromVerificationMethod(verificationMethod); + const jwsService = agent.dependencyManager.resolve(JwsService); + const kmsKeyId = await getKmsKeyIdForDid(agent, issuerDid, verificationMethod.id); + + const header: JwsProtectedHeaderOptions = { + alg: alg as any, + typ: 'statuslist+jwt', + kid: verificationMethod.id, + }; + + return jwsService.createJwsCompact(agent.context, { + keyId: kmsKeyId, + payload, + protectedHeaderOptions: header, + }); +} + +export async function checkAndCreateStatusList(agent: Agent, listId: string, issuerDid: string, listSize?: number) { + const previousLock = statusListLocks.get(listId) || Promise.resolve(); + + let releaseLock: () => void; + const currentLock = new Promise((resolve) => { + releaseLock = resolve; + }); + + statusListLocks.set(listId, currentLock); + + try { + await previousLock; + + const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; + + const res = await fetchWithTimeout(uri, { + headers: getApiKeyHeaders() + }); + + 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); + const statusList = new StatusList(new Array(size).fill(0), 1); + + const didDocument = await agent.dids.resolve(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 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 && postRes.status !== 409) { + 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) { + 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); + throw error; + } finally { + releaseLock!(); + if (statusListLocks.get(listId) === currentLock) { + statusListLocks.delete(listId); + } + } +} + +export async function revokeCredentialInStatusList(agent: Agent, listId: string, index: number, issuerDid: string) { + const previousLock = statusListLocks.get(listId) || Promise.resolve(); + + let releaseLock: () => void; + const currentLock = new Promise((resolve) => { + releaseLock = resolve; + }); + + statusListLocks.set(listId, currentLock); + + try { + await previousLock; + + const uri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${listId}`; + + const res = await fetchWithTimeout(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); + + statusList.setStatus(index, 1); + + const didDocument = await agent.dids.resolve(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, verificationMethod, statusList, listId, issuerDid); + + const patchRes = await fetchWithTimeout(`${getServerUrl()}/${STATUS_LISTS_PATH}/${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); + } + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 9543ae73..d8e50b2d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,10 +13,17 @@ "emitDecoratorMetadata": true, "skipLibCheck": true, "outDir": "./build", - "types": ["node"], - "lib": ["ES2021.Promise"] + "types": [ + "node" + ], + "lib": [ + "ESNext" + ] }, - "include": ["src/**/*", "src/routes"], + "include": [ + "src/**/*", + "src/routes" + ], "exclude": [ "node_modules", "build",