diff --git a/.env b/.env index 30579c304..45396758c 100644 --- a/.env +++ b/.env @@ -29,3 +29,22 @@ SCHEMA_BASE_URL=http://credential-schema:3333 CREDENTIAL_SERVICE_BASE_URL=https://example.com/credentials JWKS_URI= ENABLE_AUTH=false + + +# Anchor to cord block chain +# Flag to enable/disable anchoring to Cord blockchain +ANCHOR_TO_CORD=true + +# Base URL for Issuer Agent +# This is the service responsible for issuing credentials +# Example: https:///api/v1 +ISSUER_AGENT_BASE_URL=https:///api/v1 + +# Base URL for Verification Middleware +# This service is responsible for verifying credentials +# Example: https:///api/v1/verify +VERIFICATION_MIDDLEWARE_BASE_URL=https:///api/v1/verify + +# Additional Resources: +# - For more details on Issuer Agent, visit: https://github.com/dhiway/issuer-agent +# - For more details on Verification Middleware, visit: https://github.com/dhiway/verification-middleware \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 90da2ec54..d199e9552 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -243,6 +243,8 @@ services: - JWKS_URI=${JWKS_URI} - ENABLE_AUTH=${ENABLE_AUTH} - WEB_DID_BASE_URL=${WEB_DID_BASE_URL} + - ANCHOR_TO_CORD=${ANCHOR_TO_CORD} + - ISSUER_AGENT_BASE_URL=${ISSUER_AGENT_BASE_URL} healthcheck: test: [ "CMD-SHELL", "curl -f http://localhost:3332/health || exit 1" ] @@ -263,6 +265,8 @@ services: - IDENTITY_BASE_URL=${IDENTITY_BASE_URL} - JWKS_URI=${JWKS_URI} - ENABLE_AUTH=${ENABLE_AUTH} + - ANCHOR_TO_CORD=${ANCHOR_TO_CORD} + - ISSUER_AGENT_BASE_URL=${ISSUER_AGENT_BASE_URL} healthcheck: test: [ "CMD-SHELL", "curl -f http://localhost:3333/health || exit 1" ] @@ -288,6 +292,9 @@ services: - JWKS_URI=${JWKS_URI} - ENABLE_AUTH=${ENABLE_AUTH} - QR_TYPE=${QR_TYPE} + - ANCHOR_TO_CORD=${ANCHOR_TO_CORD} + - ISSUER_AGENT_BASE_URL=${ISSUER_AGENT_BASE_URL} + - VERIFICATION_MIDDLEWARE_BASE_URL=${VERIFICATION_MIDDLEWARE_BASE_URL} healthcheck: test: [ "CMD-SHELL", "curl -f http://localhost:3000/health || exit 1" ] diff --git a/services/credential-schema/.env.sample b/services/credential-schema/.env.sample index 425eb0038..4eddbad4e 100644 --- a/services/credential-schema/.env.sample +++ b/services/credential-schema/.env.sample @@ -11,3 +11,15 @@ IDENTITY_BASE_URL= # URL of the identity service to facilitate DID creation # Service VARS PORT=3000 SCHEMA_BASE_URL= + + +# Flag to enable/disable anchoring to Cord blockchain +ANCHOR_TO_CORD=false + +# Base URL for Issuer Agent +# This is the service responsible for issuing credentials +# Example: https:///api/v1 +ISSUER_AGENT_BASE_URL=https:///api/v1 + +# Additional Resources: +# - For more details on Issuer Agent, visit: https://github.com/dhiway/issuer-agent \ No newline at end of file diff --git a/services/credential-schema/docker-compose-test.yml b/services/credential-schema/docker-compose-test.yml index 53aa4210b..3a3b73eb5 100644 --- a/services/credential-schema/docker-compose-test.yml +++ b/services/credential-schema/docker-compose-test.yml @@ -25,6 +25,8 @@ services: DATABASE_URL: postgres://postgres:postgres@db-test:5432/postgres IDENTITY_BASE_URL: "http://identity-service:3332" ENABLE_AUTH: "false" + ANCHOR_TO_CORD: "${ANCHOR_TO_CORD}" + ISSUER_AGENT_BASE_URL: "${ISSUER_AGENT_BASE_URL}" networks: test: rcw-test: diff --git a/services/credential-schema/docker-compose.yml b/services/credential-schema/docker-compose.yml index 53d491fb9..f731c1242 100644 --- a/services/credential-schema/docker-compose.yml +++ b/services/credential-schema/docker-compose.yml @@ -26,6 +26,8 @@ services: DATABASE_URL: postgres://postgres:postgres@db:5432/postgres IDENTITY_BASE_URL: "http://identity-service:3332" ENABLE_AUTH: "false" + ANCHOR_TO_CORD: "${ANCHOR_TO_CORD}" + ISSUER_AGENT_BASE_URL: "${ISSUER_AGENT_BASE_URL}" networks: rcw-test: default: diff --git a/services/credential-schema/prisma/migrations/20241009132114_add_blockchain_status_field/migration.sql b/services/credential-schema/prisma/migrations/20241009132114_add_blockchain_status_field/migration.sql new file mode 100644 index 000000000..f9038ad60 --- /dev/null +++ b/services/credential-schema/prisma/migrations/20241009132114_add_blockchain_status_field/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "BlockchainStatus" AS ENUM ('PENDING', 'ANCHORED', 'FAILED'); + +-- AlterTable +ALTER TABLE "VerifiableCredentialSchema" ADD COLUMN "blockchainStatus" "BlockchainStatus" NOT NULL DEFAULT 'PENDING'; diff --git a/services/credential-schema/prisma/schema.prisma b/services/credential-schema/prisma/schema.prisma index b038f3712..a4233b134 100644 --- a/services/credential-schema/prisma/schema.prisma +++ b/services/credential-schema/prisma/schema.prisma @@ -18,6 +18,12 @@ enum SchemaStatus { REVOKED } +enum BlockchainStatus { + PENDING + ANCHORED + FAILED +} + model VerifiableCredentialSchema { id String type String @@ -35,6 +41,7 @@ model VerifiableCredentialSchema { tags String[] status SchemaStatus @default(DRAFT) deprecatedId String? + blockchainStatus BlockchainStatus @default(PENDING) @@id([id, version]) @@index([type], type: Hash) diff --git a/services/credential-schema/src/app.module.ts b/services/credential-schema/src/app.module.ts index 1b496a625..f49ebb1fd 100644 --- a/services/credential-schema/src/app.module.ts +++ b/services/credential-schema/src/app.module.ts @@ -11,6 +11,8 @@ import { PrismaHealthIndicator } from './utils/prisma.health'; import { PrismaClient } from '@prisma/client'; import { APP_GUARD } from '@nestjs/core'; import { AuthGuard } from './auth/auth.guard'; +import { AnchorCordService } from './schema/implementations/anchor-cord.service'; +import { BlockchainAnchorFactory } from './schema/factories/blockchain-anchor.factory'; @Module({ imports: [ @@ -32,6 +34,8 @@ import { AuthGuard } from './auth/auth.guard'; UtilsService, PrismaHealthIndicator, PrismaClient, + AnchorCordService, + BlockchainAnchorFactory, { provide: APP_GUARD, useClass: AuthGuard, diff --git a/services/credential-schema/src/rendering-templates/rendering-templates.module.ts b/services/credential-schema/src/rendering-templates/rendering-templates.module.ts index ecc150b2d..aa4241e9c 100644 --- a/services/credential-schema/src/rendering-templates/rendering-templates.module.ts +++ b/services/credential-schema/src/rendering-templates/rendering-templates.module.ts @@ -6,6 +6,8 @@ import { SchemaService } from '../schema/schema.service'; import { HttpModule } from '@nestjs/axios'; import { UtilsService } from '../utils/utils.service'; import { PrismaClient } from '@prisma/client'; +import { BlockchainAnchorFactory } from 'src/schema/factories/blockchain-anchor.factory'; +import { AnchorCordService } from 'src/schema/implementations/anchor-cord.service'; @Module({ imports: [HttpModule], @@ -15,6 +17,8 @@ import { PrismaClient } from '@prisma/client'; ValidateTemplateService, SchemaService, UtilsService, + BlockchainAnchorFactory, + AnchorCordService, ], controllers: [RenderingTemplatesController], }) diff --git a/services/credential-schema/src/schema/anchor-cord-service.spec.ts b/services/credential-schema/src/schema/anchor-cord-service.spec.ts new file mode 100644 index 000000000..0626c72e5 --- /dev/null +++ b/services/credential-schema/src/schema/anchor-cord-service.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SchemaService } from './schema.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; +import { PrismaClient } from '@prisma/client'; +import { CreateCredentialDTO } from './dto/create-credentials.dto'; +import { UtilsService } from '../utils/utils.service'; +import { generateCredentialSchemaTestBody } from './schema.fixtures'; +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; + +describe('SchemaService - createCredentialSchema', () => { + let service: SchemaService; + let blockchainFactory: BlockchainAnchorFactory; + let utilsService: UtilsService; + + const mockBlockchainService = { + anchorSchema: jest.fn(), + }; + + const mockPrismaService = { + verifiableCredentialSchema: { + create: jest.fn(), + findMany: jest.fn(), + }, + }; + + const mockUtilsService = { + generateDID: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SchemaService, + { + provide: BlockchainAnchorFactory, + useValue: { + getAnchorService: jest.fn(() => mockBlockchainService), + }, + }, + { + provide: PrismaClient, + useValue: mockPrismaService, + }, + { + provide: UtilsService, + useValue: mockUtilsService, + }, + ], + }).compile(); + + service = module.get(SchemaService); + blockchainFactory = module.get(BlockchainAnchorFactory); + utilsService = module.get(UtilsService); + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear mocks after each test + }); + + afterAll(async () => { + await mockPrismaService.verifiableCredentialSchema.create.mockClear(); + }); + + it('should verify ANCHOR_TO_CORD is true', () => { + const anchorToCord = process.env.ANCHOR_TO_CORD; + const isTrue = anchorToCord?.toLowerCase().trim() === 'true'; + expect(isTrue).toBe(true); + }); + + it('should verify ISSUER_AGENT_BASE_URL is a valid URL', () => { + const isValidUrl = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + expect(isValidUrl(process.env.ISSUER_AGENT_BASE_URL)).toBe(true); + }); + + it('should successfully anchor a credential schema to the blockchain', async () => { + const mockRequestBody = generateCredentialSchemaTestBody(); + const mockResponse = { schemaId: 'schema-id-blockchain' }; + + mockBlockchainService.anchorSchema.mockResolvedValueOnce(mockResponse); + mockPrismaService.verifiableCredentialSchema.create.mockResolvedValueOnce({ + ...mockRequestBody.schema, + blockchainStatus: 'ANCHORED', + }); + + const result = await service.createCredentialSchema(mockRequestBody); + + expect(blockchainFactory.getAnchorService).toHaveBeenCalledWith('cord'); + expect(mockBlockchainService.anchorSchema).toHaveBeenCalledWith(mockRequestBody.schema); + expect(result).toBeDefined(); + expect(result.schema.id).toEqual(mockResponse.schemaId); + expect(result.blockchainStatus).toEqual('ANCHORED'); + }); + + it('should throw an error if blockchain anchoring fails', async () => { + const mockRequestBody = generateCredentialSchemaTestBody(); + + mockBlockchainService.anchorSchema.mockRejectedValueOnce( + new InternalServerErrorException('Blockchain anchoring failed') + ); + await expect(service.createCredentialSchema(mockRequestBody)).rejects.toThrow( + InternalServerErrorException + ); + }); + + + +}); diff --git a/services/credential-schema/src/schema/entities/VCItem.entity.ts b/services/credential-schema/src/schema/entities/VCItem.entity.ts index 1c7f7829d..f332b9303 100644 --- a/services/credential-schema/src/schema/entities/VCItem.entity.ts +++ b/services/credential-schema/src/schema/entities/VCItem.entity.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Prisma, VerifiableCredentialSchema } from '@prisma/client'; +import { Prisma, VerifiableCredentialSchema ,BlockchainStatus} from '@prisma/client'; // represents the schema stored in Prisma export class VCItem implements VerifiableCredentialSchema { @@ -34,4 +34,6 @@ export class VCItem implements VerifiableCredentialSchema { createdBy: string; updatedBy: string; deprecatedId: string; + @ApiProperty({ enum: BlockchainStatus, description: 'Blockchain status' }) + blockchainStatus: BlockchainStatus | null; } diff --git a/services/credential-schema/src/schema/factories/blockchain-anchor.factory.ts b/services/credential-schema/src/schema/factories/blockchain-anchor.factory.ts new file mode 100644 index 000000000..67d48cbff --- /dev/null +++ b/services/credential-schema/src/schema/factories/blockchain-anchor.factory.ts @@ -0,0 +1,38 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { AnchorCordService } from '../implementations/anchor-cord.service'; +import { BlockchainAnchor } from '../interfaces/blockchain_anchor.interface'; + +/** + * Factory class to dynamically resolve the appropriate BlockchainAnchor service. + * It uses the specified method to determine which implementation to return. + */ +@Injectable() +export class BlockchainAnchorFactory { + /** + * Constructor for the BlockchainAnchorFactory. + * @param cordService - An instance of AnchorCordService, which handles CORD-specific anchoring logic. + */ + constructor(private readonly cordService: AnchorCordService) {} + + /** + * Resolves the appropriate BlockchainAnchor service based on the provided method. + * @param method - The blockchain method (e.g., 'cord'). + * @returns The service instance corresponding to the specified method or null if no method is provided. + * @throws + */ + getAnchorService(method?: string): BlockchainAnchor | null { + // If no method is specified, return null to indicate no anchoring is required + if (!method) { + return null; + } + + // Determine the appropriate service implementation based on the method + switch (method) { + case 'cord': + // Return the CORD-specific implementation + return this.cordService; + default: + throw new BadRequestException(`Unsupported blockchain method: ${method}`); + } + } +} diff --git a/services/credential-schema/src/schema/implementations/anchor-cord.service.ts b/services/credential-schema/src/schema/implementations/anchor-cord.service.ts new file mode 100644 index 000000000..5d39b6846 --- /dev/null +++ b/services/credential-schema/src/schema/implementations/anchor-cord.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { BlockchainAnchor } from '../interfaces/blockchain_anchor.interface'; +import {BadRequestException} from '@nestjs/common'; +@Injectable() +export class AnchorCordService implements BlockchainAnchor { + private readonly logger = new Logger(AnchorCordService.name); + + constructor(private readonly httpService: HttpService) {} + + async anchorSchema(body: any): Promise { + try { + const response = await this.httpService.axiosRef.post( + `${process.env.ISSUER_AGENT_BASE_URL}/schema`, + body, + ); + return response.data.result; + } catch (err) { + const errorDetails = { + message: err.message, + status: err.response?.status, + statusText: err.response?.statusText, + data: err.response?.data, + headers: err.response?.headers, + request: err.config, + }; + + this.logger.error( + 'Error anchoring schema to Cord blockchain', + errorDetails, + ); + throw new InternalServerErrorException( + 'Failed to anchor schema to Cord blockchain', + ); + } + } +} diff --git a/services/credential-schema/src/schema/interfaces/blockchain_anchor.interface.ts b/services/credential-schema/src/schema/interfaces/blockchain_anchor.interface.ts new file mode 100644 index 000000000..f99d35a6a --- /dev/null +++ b/services/credential-schema/src/schema/interfaces/blockchain_anchor.interface.ts @@ -0,0 +1,9 @@ +export interface BlockchainAnchor { + /** + * Anchors a Scheam to the blockchain. + * @param body The request payload for anchoring. + * @returns The anchored Schema or related data. + */ + anchorSchema(body: any): Promise; + } + \ No newline at end of file diff --git a/services/credential-schema/src/schema/schema.fixtures.ts b/services/credential-schema/src/schema/schema.fixtures.ts index 59bb9ce9b..7a0d06acf 100644 --- a/services/credential-schema/src/schema/schema.fixtures.ts +++ b/services/credential-schema/src/schema/schema.fixtures.ts @@ -85,6 +85,7 @@ export const testSchemaRespose1: VerifiableCredentialSchema = { tags: ['degree', 'computer science', 'bachelor'], status: 'DRAFT', deprecatedId: null, + blockchainStatus: null }; export const testSchemaRespose2: VerifiableCredentialSchema = { @@ -104,4 +105,5 @@ export const testSchemaRespose2: VerifiableCredentialSchema = { tags: ['certification', 'blockchain', 'expert'], status: 'DRAFT', deprecatedId: null, + blockchainStatus: null, }; \ No newline at end of file diff --git a/services/credential-schema/src/schema/schema.module.ts b/services/credential-schema/src/schema/schema.module.ts index b041d26fb..a9cd34b8a 100644 --- a/services/credential-schema/src/schema/schema.module.ts +++ b/services/credential-schema/src/schema/schema.module.ts @@ -4,10 +4,12 @@ import { SchemaService } from './schema.service'; import { HttpModule } from '@nestjs/axios'; import { UtilsService } from '../utils/utils.service'; import { PrismaClient } from '@prisma/client'; +import { AnchorCordService } from './implementations/anchor-cord.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; @Module({ imports: [HttpModule], controllers: [SchemaController], - providers: [SchemaService, PrismaClient, UtilsService], + providers: [SchemaService, PrismaClient, UtilsService,AnchorCordService,BlockchainAnchorFactory], }) export class SchemaModule {} diff --git a/services/credential-schema/src/schema/schema.service.spec.ts b/services/credential-schema/src/schema/schema.service.spec.ts index 244e3efae..ff14d868f 100644 --- a/services/credential-schema/src/schema/schema.service.spec.ts +++ b/services/credential-schema/src/schema/schema.service.spec.ts @@ -320,6 +320,7 @@ describe('SchemaService', () => { createdBy: schema.createdBy, updatedBy: schema.updatedBy, deprecatedId: schema.deprecatedId, + blockchainStatus:schema.blockchainStatus, }))); }); diff --git a/services/credential-schema/src/schema/schema.service.ts b/services/credential-schema/src/schema/schema.service.ts index f9a2bbc3a..51dc9017f 100644 --- a/services/credential-schema/src/schema/schema.service.ts +++ b/services/credential-schema/src/schema/schema.service.ts @@ -16,13 +16,15 @@ import { DefinedError } from 'ajv'; import { CreateCredentialDTO } from './dto/create-credentials.dto'; import { UtilsService } from '../utils/utils.service'; import { GetCredentialSchemaDTO } from './dto/getCredentialSchema.dto'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; @Injectable() export class SchemaService { constructor( private readonly prisma: PrismaClient, private readonly utilService: UtilsService, - ) {} + private readonly blockchainFactory: BlockchainAnchorFactory + ) { } private logger = new Logger(SchemaService.name); private semanticVersionRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; @@ -58,6 +60,7 @@ export class SchemaService { createdBy: schema.createdBy, updatedBy: schema.updatedBy, deprecatedId: schema.deprecatedId, + blockchainStatus: schema.blockchainStatus, } as GetCredentialSchemaDTO; } else { this.logger.error('schema not found for userInput', userWhereUniqueInput); @@ -118,10 +121,39 @@ export class SchemaService { createdBy: schema.createdBy, updatedBy: schema.updatedBy, deprecatedId: schema.deprecatedId, + blockchainStatus: schema.blockchainStatus, }; }); } + + /** + * Determines if anchoring to a blockchain is enabled based on environment variables. + * Checks for specific blockchain configurations and returns the appropriate method. + * @returns The blockchain method (e.g., 'cord', 'solana') if anchoring is enabled; otherwise, null. + */ + private shouldAnchorToBlockchain(): string | null { + // Check if the environment variable ANCHOR_TO_CORD is set to 'true' for the CORD blockchain + if ( + process.env.ANCHOR_TO_CORD && + process.env.ANCHOR_TO_CORD.toLowerCase().trim() === 'true' + ) { + return 'cord'; + } + + // Add additional checks here for other blockchains, e.g.,Solana, Ethereum, Polkadot + /* + if ( + process.env.ANCHOR_TO_SOLANA && + process.env.ANCHOR_TO_SOLANA.toLowerCase().trim() === 'true' + ) { + return 'solana'; // Return 'solana' if solana anchoring is enabled + } + */ + + return null; // Return null if no blockchain anchoring is required + } + async createCredentialSchema( createCredentialDto: CreateCredentialDTO, generateDID = true, @@ -129,92 +161,112 @@ export class SchemaService { ) { const data = createCredentialDto.schema; const tags = createCredentialDto.tags; + let blockchainSchemaId: string | null = null; + let did: string | null = null; - // verify the Credential Schema - if (validate(data)) { - let did; - if (generateDID) { - const didBody = { - content: [ - { - alsoKnownAs: [data.author, data.schema.$id], - services: [ - { - id: 'CredentialSchemaService', - type: 'CredentialSchema', - }, - ], - method: 'schema', - }, - ], - }; - did = await this.utilService.generateDID(didBody); - this.logger.debug('DID received from identity service', did); - } - - const credSchema = { - schema: { - type: data.type, - id: did ? did.id : data.id, - version: data.version ? data.version : '0.0.0', - name: data.name, - author: data.author, - authored: data.authored, - schema: data.schema, - proof: data.proof, - }, - tags: tags, - status: createCredentialDto.status, - deprecatedId: createCredentialDto.deprecatedId, - }; - - // sign the credential schema (only the schema part of the credSchema object above since it is the actual schema) - // const proof = await this.utilService.sign( - // credSchema.schema.author, - // credSchema.schema, - // ); - // credSchema.schema.proof = proof; - - try { - const resp = await this.prisma.verifiableCredentialSchema.create({ - data: { - id: credSchema.schema.id, - type: credSchema.schema?.type as string, - version: credSchema.schema.version, - name: credSchema.schema.name as string, - author: credSchema.schema.author as string, - authored: credSchema.schema.authored, - schema: credSchema.schema.schema as Prisma.JsonValue, - status: credSchema.status as SchemaStatus, - proof: credSchema.schema.proof as Prisma.JsonValue || undefined, - tags: credSchema.tags as string[], - deprecatedId: deprecatedId, - }, - }); - - credSchema['createdAt'] = resp.createdAt; - credSchema['updatedAt'] = resp.updatedAt; - credSchema['deletedAt'] = resp.deletedAt; - credSchema['createdBy'] = resp.createdBy; - credSchema['updatedBy'] = resp.updatedBy; - } catch (err) { - this.logger.error('Error saving schema to db', err); - throw new InternalServerErrorException('Error saving schema to db'); - } - return credSchema; - } else { + // Validate the credential schema + if (!validate(data)) { this.logger.log('Schema validation failed', validate.errors.join('\n')); for (const err of validate.errors as DefinedError[]) { this.logger.error(err, err.message); } throw new BadRequestException( - `Schema validation failed with the following errors: ${validate.errors.join( - '\n', - )}`, + `Schema validation failed with the following errors: ${validate.errors.join('\n')}`, ); } + // Check if anchoring to blockchain is enabled and get the method + const method = this.shouldAnchorToBlockchain(); + + if (method) { + // Get the appropriate service from the factory + const anchorService = this.blockchainFactory.getAnchorService(method); + const response = await anchorService.anchorSchema(data); + + blockchainSchemaId = response.schemaId; + did = blockchainSchemaId; + } else if (generateDID) { + // Generate a DID if anchoring is not enabled and generateDID is true + const didBody = { + content: [ + { + alsoKnownAs: [data.author, data.schema.$id], + services: [ + { + id: 'CredentialSchemaService', + type: 'CredentialSchema', + }, + ], + method: 'schema', + }, + ], + }; + + // Generate DID + const generatedDidResponse = await this.utilService.generateDID(didBody); + this.logger.debug('DID received from identity service', generatedDidResponse); + did = generatedDidResponse?.id || null; + + } + + + const credSchema = { + schema: { + type: data.type, + id: did ? did : data.id, + version: data.version ? data.version : '0.0.0', + name: data.name, + author: data.author, + authored: data.authored, + schema: data.schema, + proof: data.proof, + }, + tags: tags, + status: createCredentialDto.status, + deprecatedId: createCredentialDto.deprecatedId, + blockchainStatus: blockchainSchemaId ? 'ANCHORED' : 'PENDING', + }; + + // // sign the credential schema (only the schema part of the credSchema object above since it is the actual schema) + // // const proof = await this.utilService.sign( + // // credSchema.schema.author, + // // credSchema.schema, + // // ); + // // credSchema.schema.proof = proof; + + // Save the credential schema to the database + try { + const resp = await this.prisma.verifiableCredentialSchema.create({ + data: { + id: credSchema.schema.id, + type: credSchema.schema.type as string, + version: credSchema.schema.version, + name: credSchema.schema.name as string, + author: credSchema.schema.author as string, + authored: credSchema.schema.authored, + schema: credSchema.schema.schema as Prisma.JsonValue, + status: credSchema.status as SchemaStatus, + proof: credSchema.schema.proof as Prisma.JsonValue || undefined, + tags: credSchema.tags as string[], + deprecatedId: deprecatedId, + blockchainStatus: blockchainSchemaId ? 'ANCHORED' : 'PENDING', + }, + }); + + + credSchema['createdAt'] = resp.createdAt; + credSchema['updatedAt'] = resp.updatedAt; + credSchema['deletedAt'] = resp.deletedAt; + credSchema['createdBy'] = resp.createdBy; + credSchema['updatedBy'] = resp.updatedBy; + + return credSchema; + } catch (err) { + this.logger.error('Error saving schema to db', err); + throw new InternalServerErrorException('Error saving schema to db'); + } } + private formatResponse(schema: VerifiableCredentialSchema) { return JSON.parse( JSON.stringify({ @@ -235,6 +287,7 @@ export class SchemaService { createdBy: schema?.createdBy, updatedBy: schema?.updatedBy, deprecatedId: schema?.deprecatedId, + blockchainStatus: schema?.blockchainStatus, }), ); } diff --git a/services/credential-schema/src/utils/utils.service.ts b/services/credential-schema/src/utils/utils.service.ts index c2adbd5c3..4ee065735 100644 --- a/services/credential-schema/src/utils/utils.service.ts +++ b/services/credential-schema/src/utils/utils.service.ts @@ -38,4 +38,5 @@ export class UtilsService { throw new InternalServerErrorException('Can not generate a new DID'); } } + } diff --git a/services/credentials-service/.env.sample b/services/credentials-service/.env.sample index de575c4d0..398c5e168 100644 --- a/services/credentials-service/.env.sample +++ b/services/credentials-service/.env.sample @@ -12,3 +12,21 @@ IDENTITY_BASE_URL= # URL of the identity service to facilitate DID creation SCHEMA_BASE_URL= CREDENTIAL_SERVICE_BASE_URL="" + + +# Flag to enable/disable anchoring to Cord blockchain +ANCHOR_TO_CORD=false + +# Base URL for Issuer Agent +# This is the service responsible for issuing credentials +# Example: https:///api/v1 +ISSUER_AGENT_BASE_URL=https:///api/v1 + +# Base URL for Verification Middleware +# This service is responsible for verifying credentials +# Example: https:///api/v1/verify +VERIFICATION_MIDDLEWARE_BASE_URL=https:///api/v1/verify + +# Additional Resources: +# - For more details on Issuer Agent, visit: https://github.com/dhiway/issuer-agent +# - For more details on Verification Middleware, visit: https://github.com/dhiway/verification-middleware diff --git a/services/credentials-service/docker-compose-test.yml b/services/credentials-service/docker-compose-test.yml index 272f1c64f..6cf743715 100644 --- a/services/credentials-service/docker-compose-test.yml +++ b/services/credentials-service/docker-compose-test.yml @@ -25,6 +25,9 @@ services: ENABLE_AUTH: "false" JWKS_URI: "" SIGNING_ALGORITHM: "Ed25519Signature2020" + ANCHOR_TO_CORD: "${ANCHOR_TO_CORD}" + ISSUER_AGENT_BASE_URL: "${ISSUER_AGENT_BASE_URL}" + VERIFICATION_MIDDLEWARE_BASE_URL: "${VERIFICATION_MIDDLEWARE_BASE_URL}" networks: rcw-test: default: diff --git a/services/credentials-service/docker-compose.yml b/services/credentials-service/docker-compose.yml index 06802a63a..c6f9a103f 100644 --- a/services/credentials-service/docker-compose.yml +++ b/services/credentials-service/docker-compose.yml @@ -27,6 +27,9 @@ services: SCHEMA_BASE_URL: ENABLE_AUTH: "false" JWKS_URI: "" + ANCHOR_TO_CORD: "${ANCHOR_TO_CORD}" + ISSUER_AGENT_BASE_URL: "${ISSUER_AGENT_BASE_URL}" + VERIFICATION_MIDDLEWARE_BASE_URL: "${VERIFICATION_MIDDLEWARE_BASE_URL}" healthcheck: test: [ "CMD-SHELL", "curl -f http://localhost:3000/health || exit 1" ] diff --git a/services/credentials-service/prisma/migrations/20241010105751_add_blockchain_status_field/migration.sql b/services/credentials-service/prisma/migrations/20241010105751_add_blockchain_status_field/migration.sql new file mode 100644 index 000000000..c8eb5a20a --- /dev/null +++ b/services/credentials-service/prisma/migrations/20241010105751_add_blockchain_status_field/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "BlockchainStatusEnum" AS ENUM ('PENDING', 'ANCHORED', 'FAILED'); + +-- AlterTable +ALTER TABLE "VerifiableCredentials" ADD COLUMN "blockchainStatus" "BlockchainStatusEnum" NOT NULL DEFAULT 'PENDING'; diff --git a/services/credentials-service/prisma/schema.prisma b/services/credentials-service/prisma/schema.prisma index 784778ab6..d594b4423 100644 --- a/services/credentials-service/prisma/schema.prisma +++ b/services/credentials-service/prisma/schema.prisma @@ -18,6 +18,12 @@ enum VCStatus { REVOKED } +enum BlockchainStatusEnum { + PENDING + ANCHORED + FAILED +} + model VerifiableCredentials { id String @id @default(uuid()) type String[] @@ -36,6 +42,7 @@ model VerifiableCredentials { createdBy String? updatedBy String? tags String[] + blockchainStatus BlockchainStatusEnum @default(PENDING) } model RevocationLists { diff --git a/services/credentials-service/src/app.module.ts b/services/credentials-service/src/app.module.ts index 13e59c4fa..89eef9b9c 100644 --- a/services/credentials-service/src/app.module.ts +++ b/services/credentials-service/src/app.module.ts @@ -21,6 +21,6 @@ import { RevocationListModule } from './revocation-list/revocation-list.module'; RevocationListModule, ], controllers: [AppController], - providers: [HttpService, HealthCheckService, AppService, ConfigService, PrismaClient, HealthCheckUtilsService, RevocationList, RevocationListImpl, RevocationListService], + providers: [ AppService, ConfigService, PrismaClient, HealthCheckUtilsService, RevocationList, RevocationListImpl, RevocationListService], }) export class AppModule {} diff --git a/services/credentials-service/src/credentials/anchor-cord-service.spec.ts b/services/credentials-service/src/credentials/anchor-cord-service.spec.ts new file mode 100644 index 000000000..58c9deb42 --- /dev/null +++ b/services/credentials-service/src/credentials/anchor-cord-service.spec.ts @@ -0,0 +1,156 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CredentialsService } from './credentials.service'; +import { SchemaUtilsSerivce } from './utils/schema.utils.service'; +import { IdentityUtilsService } from './utils/identity.utils.service'; +import { RenderingUtilsService } from './utils/rendering.utils.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; +import { AnchorCordService } from './implementations/anchor-cord.service'; +import { PrismaClient } from '@prisma/client'; +import { Logger } from '@nestjs/common'; +import { HttpModule, HttpService } from '@nestjs/axios'; + +import { + generateCredentialRequestPayload, + generateCredentialSchemaTestBody, +} from './credentials.fixtures'; + +describe('CredentialsService - Integration', () => { + const logger = new Logger('CredentialsServiceTest'); + + let service: CredentialsService; + let httpService: HttpService; + let identityUtilsService: IdentityUtilsService; + let prismaClient: PrismaClient; + + let subjectDID: string; + let credentialSchemaID: string; + let sampleCredReqPayload: any; + + + const isValidUrl = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [HttpModule], + providers: [ + CredentialsService, + SchemaUtilsSerivce, + IdentityUtilsService, + RenderingUtilsService, + BlockchainAnchorFactory, + PrismaClient, + AnchorCordService, + ], + }).compile(); + + service = module.get(CredentialsService); + httpService = module.get(HttpService); + identityUtilsService = module.get(IdentityUtilsService); + prismaClient = module.get(PrismaClient); + + }); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(async () => { + jest.clearAllMocks(); + jest.resetModules(); + + if (prismaClient) { + await prismaClient.$disconnect(); + } + + if (module) { + await module.close(); + } + }); + + describe('Environment Variable Checks', () => { + it('should verify ANCHOR_TO_CORD is true', () => { + const anchorToCord = process.env.ANCHOR_TO_CORD; + expect(anchorToCord?.toLowerCase().trim()).toBe('true'); + }); + + it('should verify ISSUER_AGENT_BASE_URL is a valid URL', () => { + expect(isValidUrl(process.env.ISSUER_AGENT_BASE_URL)).toBe(true); + }); + + it('should verify VERIFICATION_MIDDLEWARE_BASE_URL is a valid URL', () => { + expect(isValidUrl(process.env.VERIFICATION_MIDDLEWARE_BASE_URL)).toBe(true); + }); + }); + + describe('Generate DID, Create Schema, and Issue Credential', () => { + it('should generate DIDs for subject', async () => { + try { + const Did = await identityUtilsService.generateDID(['CORD TESTING'], 'cord'); + subjectDID = Did[0].uri; + } catch (error) { + throw error; + } + + }, 10000); + + it('should create a credential schema', async () => { + const schemaPayload = generateCredentialSchemaTestBody(); + + const response = await httpService.axiosRef.post( + `${process.env.SCHEMA_BASE_URL}/credential-schema`, + schemaPayload, + ); + + credentialSchemaID = response.data.schema.id; + expect(credentialSchemaID).toBeDefined(); + }, 10000); + + it('should issue a credential', async () => { + sampleCredReqPayload = generateCredentialRequestPayload( + subjectDID, + subjectDID, + credentialSchemaID, + '1.0.0', + ); + delete sampleCredReqPayload.credential.credentialSubject.type; + + const issuedCredential = await service.issueCredential(sampleCredReqPayload); + + expect(issuedCredential).toBeDefined(); + expect(issuedCredential.credential.id).toBeDefined(); + }, 10000); + + it('should verify the issued credential', async () => { + const issuedCredential = await service.issueCredential(sampleCredReqPayload); + + const verifyRes = await service.verifyCredentialById(issuedCredential.credential.id); + + expect(verifyRes).toBeDefined(); + }, 10000); + }); + + describe('Error Handling', () => { + it('should throw if blockchain anchoring fails', async () => { + jest + .spyOn(service, 'issueCredential') + .mockRejectedValueOnce(new Error('Blockchain anchoring failed')); + + await expect(service.issueCredential(sampleCredReqPayload)).rejects.toThrow( + 'Blockchain anchoring failed', + ); + }); + + it('should throw if credential verification fails', async () => { + await expect(service.verifyCredentialById('invalid-credential')).rejects.toThrow(); + }); + }); +}); diff --git a/services/credentials-service/src/credentials/credentials.module.ts b/services/credentials-service/src/credentials/credentials.module.ts index 9c34c0912..c83417126 100644 --- a/services/credentials-service/src/credentials/credentials.module.ts +++ b/services/credentials-service/src/credentials/credentials.module.ts @@ -5,12 +5,14 @@ import { HttpModule, HttpService } from '@nestjs/axios'; import { IdentityUtilsService } from './utils/identity.utils.service'; import { RenderingUtilsService } from './utils/rendering.utils.service'; import { SchemaUtilsSerivce } from './utils/schema.utils.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; +import { AnchorCordService } from './implementations/anchor-cord.service'; import { PrismaClient } from '@prisma/client'; @Module({ imports: [HttpModule], - providers: [HttpService, CredentialsService, PrismaClient, IdentityUtilsService, RenderingUtilsService, SchemaUtilsSerivce], + providers: [CredentialsService, PrismaClient, IdentityUtilsService, RenderingUtilsService, SchemaUtilsSerivce, BlockchainAnchorFactory, AnchorCordService], controllers: [CredentialsController], exports: [IdentityUtilsService] }) -export class CredentialsModule {} +export class CredentialsModule { } diff --git a/services/credentials-service/src/credentials/credentials.service.spec.ts b/services/credentials-service/src/credentials/credentials.service.spec.ts index ca94ff28e..61aeddabd 100644 --- a/services/credentials-service/src/credentials/credentials.service.spec.ts +++ b/services/credentials-service/src/credentials/credentials.service.spec.ts @@ -5,6 +5,7 @@ import { UnsignedVCValidator, VCValidator } from './types/validators'; import { SchemaUtilsSerivce } from './utils/schema.utils.service'; import { IdentityUtilsService } from './utils/identity.utils.service'; import { RenderingUtilsService } from './utils/rendering.utils.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; import { PrismaClient } from '@prisma/client'; import { generateCredentialRequestPayload, @@ -56,6 +57,7 @@ describe('CredentialsService', () => { RenderingUtilsService, SchemaUtilsSerivce, IdentityUtilsService, + BlockchainAnchorFactory, ], }).compile(); diff --git a/services/credentials-service/src/credentials/credentials.service.ts b/services/credentials-service/src/credentials/credentials.service.ts index 0a6d6365f..257323023 100644 --- a/services/credentials-service/src/credentials/credentials.service.ts +++ b/services/credentials-service/src/credentials/credentials.service.ts @@ -13,15 +13,17 @@ import { JwtCredentialSubject } from 'src/app.interface'; import { SchemaUtilsSerivce } from './utils/schema.utils.service'; import { IdentityUtilsService } from './utils/identity.utils.service'; import { RenderingUtilsService } from './utils/rendering.utils.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; + import * as jsigs from 'jsonld-signatures'; import * as jsonld from 'jsonld'; import { DOCUMENTS } from './documents'; import { RSAKeyPair } from 'crypto-ld'; const AssertionProofPurpose = jsigs.purposes.AssertionProofPurpose; -import { +import { W3CCredential, Verifiable, DIDDocument, CredentialPayload, IssuerType, Proof, VerificationMethod - } from 'vc.types'; +} from 'vc.types'; import { RevocationListDTO } from './dto/revocaiton-list.dto'; @Injectable() @@ -41,17 +43,18 @@ export class CredentialsService { private readonly prisma: PrismaClient, private readonly identityUtilsService: IdentityUtilsService, private readonly renderingUtilsService: RenderingUtilsService, - private readonly schemaUtilsService: SchemaUtilsSerivce + private readonly schemaUtilsService: SchemaUtilsSerivce, + private readonly blockchainFactory: BlockchainAnchorFactory ) { this.init(); } async init() { const vc = await import('@digitalbazaar/vc'); - const {Ed25519VerificationKey2020} = await import('@digitalbazaar/ed25519-verification-key-2020'); - const {Ed25519Signature2020} = await import('@digitalbazaar/ed25519-signature-2020'); - const {Ed25519VerificationKey2018} = await import('@digitalbazaar/ed25519-verification-key-2018'); - const {Ed25519Signature2018} = await import('@digitalbazaar/ed25519-signature-2018'); + const { Ed25519VerificationKey2020 } = await import('@digitalbazaar/ed25519-verification-key-2020'); + const { Ed25519Signature2020 } = await import('@digitalbazaar/ed25519-signature-2020'); + const { Ed25519VerificationKey2018 } = await import('@digitalbazaar/ed25519-verification-key-2018'); + const { Ed25519Signature2018 } = await import('@digitalbazaar/ed25519-signature-2018'); this.map.Ed25519VerificationKey2020 = Ed25519VerificationKey2020; this.map.JsonWebKey2020 = Ed25519VerificationKey2020; this.map.Ed25519Signature2020 = Ed25519Signature2020; @@ -148,6 +151,15 @@ export class CredentialsService { async verifyCredential(credToVerify: Verifiable, status?: VCStatus) { try { + // Check if anchoring to blockchain is enabled and get the method + const method = this.shouldAnchorToBlockchain(); + + if (method) { + // Get the appropriate service from the factory + const anchorService = this.blockchainFactory.getAnchorService(method); + // delegate verification to appropriate service + return await anchorService.verifyCredential(credToVerify); + } // calling identity service to verify the issuer DID const issuerId = (credToVerify.issuer?.id || credToVerify.issuer) as string; const did: DIDDocument = await this.identityUtilsService.resolveDID( @@ -160,7 +172,7 @@ export class CredentialsService { const vm = did.verificationMethod?.find(d => (d.id === credVerificationMethod || d.id === credVerificationMethod?.id)); const suite = await this.getSuite(vm, credToVerify?.proof?.type); let results; - if(credToVerify?.proof?.type === "RsaSignature2018") { + if (credToVerify?.proof?.type === "RsaSignature2018") { this.map.vc._checkCredential({ credential: credToVerify }) @@ -179,14 +191,14 @@ export class CredentialsService { documentLoader: this.getDocumentLoader(did) }); } - if(!results?.verified) { + if (!results?.verified) { this.logger.error('Error in verifying credentials: ', results); } return { status: status, checks: [ { - ...(status && {revoked: status === VCStatus.REVOKED ? 'NOK' : 'OK'}), // NOK represents revoked + ...(status && { revoked: status === VCStatus.REVOKED ? 'NOK' : 'OK' }), // NOK represents revoked expired: new Date(credToVerify.expirationDate).getTime() < Date.now() ? 'NOK' @@ -207,14 +219,14 @@ export class CredentialsService { // getting the credential from the db const stored = (await this.prisma.verifiableCredentials.findUnique({ - where: { - id: credId, - }, - select: { - signed: true, - status: true, - }, - })); + where: { + id: credId, + }, + select: { + signed: true, + status: true, + }, + })); const { signed: credToVerify, status } = (stored || {}) as { signed: Verifiable; status: VCStatus }; this.logger.debug('Fetched credntial from db to verify'); @@ -233,28 +245,28 @@ export class CredentialsService { "Ed25519Signature2018": ["Ed25519VerificationKey2018"], "RsaSignature2018": ["RsaVerificationKey2018"], }; - if(!(signatureType in supportedSignatures)) { + if (!(signatureType in supportedSignatures)) { throw new NotFoundException("Suite for signature type not found"); } - if(!supportedSignatures[signatureType].includes(verificationMethod?.type)) { + if (!supportedSignatures[signatureType].includes(verificationMethod?.type)) { throw new NotFoundException("Suite for verification type not found"); } - if(!this.map[verificationMethod?.type]) await this.init(); - if(!this.map[verificationMethod?.type]) throw new NotFoundException("Library not loaded"); + if (!this.map[verificationMethod?.type]) await this.init(); + if (!this.map[verificationMethod?.type]) throw new NotFoundException("Library not loaded"); let keyPair = await this.map[verificationMethod?.type].from(verificationMethod); - return new this.map[signatureType]({key: keyPair}); + return new this.map[signatureType]({ key: keyPair }); } getDocumentLoader(didDoc: DIDDocument) { return jsigs.extendContextLoader(async url => { - if(url === didDoc?.id) { + if (url === didDoc?.id) { return { contextUrl: null, documentUrl: url, document: didDoc }; } - if(DOCUMENTS[url]) { + if (DOCUMENTS[url]) { return { contextUrl: null, documentUrl: url, @@ -265,44 +277,99 @@ export class CredentialsService { }) } + async issueCredential(issueRequest: IssueCredentialDTO) { this.logger.debug(`Received issue credential request`); const credInReq = issueRequest.credential; - // check for issuance date - if (!credInReq.issuanceDate) - credInReq.issuanceDate = new Date(Date.now()).toISOString(); - // Verify the credential with the credential schema using ajv - // get the credential schema - const schema = await this.schemaUtilsService.getCredentialSchema( - issueRequest.credentialSchemaId, - issueRequest.credentialSchemaVersion - ); - this.logger.debug('fetched schema'); - const { valid, errors } = - await this.schemaUtilsService.verifyCredentialSubject( + + let response: any = null; + + // Check if anchoring to blockchain is enabled and get the method + const method = this.shouldAnchorToBlockchain(); + + // Check if ANCHOR_TO_CORD is true + if (method) { + // Get the appropriate service from the factory + const anchorService = this.blockchainFactory.getAnchorService(method); + const anchoredCredentialData = await anchorService.anchorCredential(issueRequest); + response = this.saveCredentialToDatabase(anchoredCredentialData) + } else { + // Check for issuance date + if (!credInReq.issuanceDate) { + credInReq.issuanceDate = new Date(Date.now()).toISOString(); + } + + // Get the credential schema ID + const schema = await this.schemaUtilsService.getCredentialSchema( + issueRequest.credentialSchemaId, + issueRequest.credentialSchemaVersion + ); + + this.logger.debug('fetched schema', schema); + + const { valid, errors } = await this.schemaUtilsService.verifyCredentialSubject( credInReq, schema.schema ); - if (!valid) { - this.logger.error('Invalid credential schema', errors); - throw new BadRequestException(errors); + + if (!valid) { + this.logger.error('Invalid credential schema', errors); + throw new BadRequestException(errors); + } + + // Generate the DID for the credential + const credDID: ReadonlyArray = await this.identityUtilsService.generateDID([], issueRequest.method); + + try { + credInReq.id = credDID[0].id; + } catch (err) { + this.logger.error('Invalid response from generate DID', err); + throw new InternalServerErrorException('Problem creating DID'); + } + + this.logger.debug('Generated DID and validated schema'); + response = await this.signAndStoreCredential(credInReq, issueRequest); } - this.logger.debug('validated schema'); - // generate the DID for credential - const credDID: ReadonlyArray = - await this.identityUtilsService.generateDID( - [], - issueRequest.method - ); - this.logger.debug('generated DID'); - try { - credInReq.id = credDID[0].id; - } catch (err) { - this.logger.error('Invalid response from generate DID', err); - throw new InternalServerErrorException('Problem creating DID'); + + return response; + } + + + + /** + * Determines if anchoring to a blockchain is enabled based on environment variables. + * Checks for specific blockchain configurations and returns the appropriate method. + * @returns The blockchain method (e.g., 'cord', 'solana') if anchoring is enabled; otherwise, null. + */ + private shouldAnchorToBlockchain(): string | null { + // Check if the environment variable ANCHOR_TO_CORD is set to 'true' for the CORD blockchain + if ( + process.env.ANCHOR_TO_CORD && + process.env.ANCHOR_TO_CORD.toLowerCase().trim() === 'true' + ) { + return 'cord'; // Return 'cord' as the service method if CORD anchoring is enabled + } + + // Add additional checks here for other blockchains, e.g.,Solana, Ethereum, Polkadot + /* + if ( + process.env.ANCHOR_TO_SOLANA && + process.env.ANCHOR_TO_SOLANA.toLowerCase().trim() === 'true' + ) { + return 'solana'; // Return 'solana' if solana anchoring is enabled } - // sign the credential + */ + + return null; // Return null if no blockchain anchoring is required + } + + + /** + * Signs the credential locally and saves it to the database + */ + private async signAndStoreCredential(credInReq: any, issueRequest: IssueCredentialDTO) { let signedCredential: W3CCredential = {}; + try { signedCredential = await this.identityUtilsService.signVC( credInReq as CredentialPayload, @@ -313,33 +380,44 @@ export class CredentialsService { throw new InternalServerErrorException('Problem signing the credential'); } - this.logger.debug('signed credential'); + this.logger.debug('Signed credential'); + + const newCredData = { + id: signedCredential.id, + type: signedCredential.type, + issuer: signedCredential.issuer as IssuerType as string, + issuanceDate: signedCredential.issuanceDate, + expirationDate: signedCredential.expirationDate, + subject: signedCredential.credentialSubject as JwtCredentialSubject, + subjectId: (signedCredential.credentialSubject as JwtCredentialSubject).id, + proof: signedCredential.proof as Proof, + credential_schema: issueRequest.credentialSchemaId, + signed: signedCredential as object, + tags: issueRequest.tags, + }; - // TODO: add created by and updated by + return this.saveCredentialToDatabase(newCredData); + } + + /** + * Saves the credential to the database and returns the response + */ + private async saveCredentialToDatabase(credentialData: any) { const newCred = await this.prisma.verifiableCredentials.create({ - data: { - id: signedCredential.id, - type: signedCredential.type, - issuer: signedCredential.issuer as IssuerType as string, - issuanceDate: signedCredential.issuanceDate, - expirationDate: signedCredential.expirationDate, - subject: signedCredential.credentialSubject as JwtCredentialSubject, - subjectId: (signedCredential.credentialSubject as JwtCredentialSubject).id, - proof: signedCredential.proof as Proof, - credential_schema: issueRequest.credentialSchemaId, //because they can't refer to the schema db from here through an ID - signed: signedCredential as object, - tags: issueRequest.tags, - }, + data: credentialData, }); if (!newCred) { this.logger.error('Problem saving credential to db'); throw new InternalServerErrorException('Problem saving credential to db'); } + this.logger.debug('saved credential to db'); const res = newCred.signed; + delete res['options']; + return { credential: res, credentialSchemaId: newCred.credential_schema, @@ -377,11 +455,11 @@ export class CredentialsService { issuer: getCreds.issuer?.id, AND: filteringSubject ? Object.keys(filteringSubject).map((key: string) => ({ - subject: { - path: [key.toString()], - equals: filteringSubject[key], - }, - })) + subject: { + path: [key.toString()], + equals: filteringSubject[key], + }, + })) : [], }, select: { @@ -412,14 +490,14 @@ export class CredentialsService { async getRevocationList( issuerId: string, page = 1, - limit= 1000, - ){ + limit = 1000, + ) { let revocationList: RevocationListDTO[] if (issuerId === "") { throw new InternalServerErrorException('Please provide a valid issuer ID'); } - + try { revocationList = await this.prisma.verifiableCredentials.findMany({ where: { @@ -428,20 +506,20 @@ export class CredentialsService { }, select: { id: true, - tags : true, - issuer : true, + tags: true, + issuer: true, issuanceDate: true }, - skip: (page -1) * limit, + skip: (page - 1) * limit, take: limit, orderBy: { issuanceDate: 'desc', }, - }); + }); } catch (error) { this.logger.error('Error fetching RevocationList'); throw new InternalServerErrorException('Error fetching revocationList'); } - return revocationList + return revocationList } } diff --git a/services/credentials-service/src/credentials/factories/blockchain-anchor.factory.ts b/services/credentials-service/src/credentials/factories/blockchain-anchor.factory.ts new file mode 100644 index 000000000..0a7bfd5c3 --- /dev/null +++ b/services/credentials-service/src/credentials/factories/blockchain-anchor.factory.ts @@ -0,0 +1,38 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { AnchorCordService } from '../implementations/anchor-cord.service'; +import { BlockchainAnchor } from '../interfaces/blockchain_anchor.interface'; + +/** + * Factory class to dynamically resolve the appropriate BlockchainAnchor service. + * It uses the specified method to determine which implementation to return. + */ +@Injectable() +export class BlockchainAnchorFactory { + /** + * Constructor for the BlockchainAnchorFactory. + * @param cordService - An instance of AnchorCordService, which handles CORD-specific anchoring logic. + */ + constructor(private readonly cordService: AnchorCordService) { } + + /** + * Resolves the appropriate BlockchainAnchor service based on the provided method. + * @param method - The blockchain method (e.g., 'cord'). + * @returns The service instance corresponding to the specified method or null if no method is provided. + * @throws + */ + getAnchorService(method?: string): BlockchainAnchor | null { + // If no method is specified, return null to indicate no anchoring is required + if (!method) { + return null; + } + + // Determine the appropriate service implementation based on the method + switch (method) { + case 'cord': + // Return the CORD-specific implementation + return this.cordService; + default: + throw new BadRequestException(`Unsupported blockchain method: ${method}`); + } + } +} diff --git a/services/credentials-service/src/credentials/implementations/anchor-cord.service.ts b/services/credentials-service/src/credentials/implementations/anchor-cord.service.ts new file mode 100644 index 000000000..665174eb8 --- /dev/null +++ b/services/credentials-service/src/credentials/implementations/anchor-cord.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger, InternalServerErrorException, BadRequestException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { BlockchainAnchor } from '../interfaces/blockchain_anchor.interface'; +import { AxiosResponse } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces'; +import { IssueCredentialDTO } from '../dto/issue-credential.dto'; +import { JwtCredentialSubject } from 'src/app.interface'; +import { W3CCredential, Verifiable } from 'vc.types'; + +@Injectable() +export class AnchorCordService implements BlockchainAnchor { + + private readonly logger = new Logger(AnchorCordService.name); + + + constructor(private readonly httpService: HttpService) { + + } + + async anchorCredential(issueRequest: IssueCredentialDTO): Promise { + try { + const credInReq = issueRequest.credential; + if (!issueRequest.credentialSchemaId) { + + this.logger.error('Credential SchemaId Schema ID is required for anchoring but is missing'); + throw new BadRequestException('Cord Schema ID is missing'); + } + + this.logger.debug('url', process.env.ISSUER_AGENT_BASE_URL); + this.logger.debug('Anchoring unsigned credential to Cord blockchain with schema ID:', issueRequest.credentialSchemaId); + const credentialPayload = { + ...credInReq, + schemaId: issueRequest.credentialSchemaId, + } + let anchorHttpResponse: AxiosResponse = + await this.httpService.axiosRef.post( + `${process.env.ISSUER_AGENT_BASE_URL}/cred`, + { + credential: credentialPayload, + } + ); + + this.logger.debug('Credential successfully anchored'); + let anchoredResult = anchorHttpResponse.data.result; + this.logger.debug('Credential successfully anchored to Cord:', anchoredResult); + const { + id, issuer, issuanceDate, validUntil: expirationDate, credentialSubject, proof, + } = anchoredResult.vc; + + const anchoredCredentialData = { + id, + type: issueRequest.credential.type, + issuer, + issuanceDate, + expirationDate, + subject: credentialSubject, + subjectId: (credentialSubject as JwtCredentialSubject).id, + proof, + credential_schema: issueRequest.credentialSchemaId, + signed: anchoredResult.vc as object, + tags: issueRequest.tags, + blockchainStatus: "ANCHORED", + + }; + return anchoredCredentialData; + } catch (err) { + this.logger.error('Error anchoring credential:', err); + + throw new InternalServerErrorException(`Error anchoring credential : ${err.response.data.details}`); + } + } + + + async verifyCredential( + credToVerify: Verifiable + ): Promise { + try { + this.logger.debug(`${process.env.VERIFICATION_MIDDLEWARE_BASE_URL}/credentials/verify}`) + const response = await this.httpService.axiosRef.post( + `${process.env.VERIFICATION_MIDDLEWARE_BASE_URL}/credentials/verify`, + credToVerify + ); + + if (response.status !== 200) { + this.logger.error('Cord verification failed:', response.data); + throw new InternalServerErrorException('Cord verification failed'); + } + + return response.data; + } catch (err) { + this.logger.error('Error calling Cord verification API:', err); + throw new InternalServerErrorException( + 'Error verifying credential on Cord' + ); + } + } + +} diff --git a/services/credentials-service/src/credentials/interfaces/blockchain_anchor.interface.ts b/services/credentials-service/src/credentials/interfaces/blockchain_anchor.interface.ts new file mode 100644 index 000000000..b70d8f24e --- /dev/null +++ b/services/credentials-service/src/credentials/interfaces/blockchain_anchor.interface.ts @@ -0,0 +1,9 @@ +export interface BlockchainAnchor { + /** + * Anchors a Scheam to the blockchain. + * @param body The request payload for anchoring. + * @returns The anchored Schema or related data. + */ + anchorCredential(body: any): Promise; + verifyCredential(body: any): Promise; +} diff --git a/services/identity-service/.env.sample b/services/identity-service/.env.sample index cfba06cec..ffdd8f8b5 100644 --- a/services/identity-service/.env.sample +++ b/services/identity-service/.env.sample @@ -18,4 +18,12 @@ VAULT_PROXY='' #this is supposed to be a boolean flag given as a string # Configs for the server PORT=3332 -ENABLE_AUTH=false \ No newline at end of file +ENABLE_AUTH=false + +# Flag to enable/disable anchoring to Cord blockchain +ANCHOR_TO_CORD=true + +# Base URL for Issuer Agent +# This is the service responsible for create did in cord block chain +# Example: https:///api/v1 +ISSUER_AGENT_BASE_URL=https:///api/v1 \ No newline at end of file diff --git a/services/identity-service/.gitignore b/services/identity-service/.gitignore index 53c37a166..f95f4062c 100644 --- a/services/identity-service/.gitignore +++ b/services/identity-service/.gitignore @@ -1 +1,4 @@ -dist \ No newline at end of file +dist + +#Environment files +.env \ No newline at end of file diff --git a/services/identity-service/docker-compose-test.yml b/services/identity-service/docker-compose-test.yml index 19c605ae7..e2b601d9f 100644 --- a/services/identity-service/docker-compose-test.yml +++ b/services/identity-service/docker-compose-test.yml @@ -58,6 +58,8 @@ services: JWKS_URI: "" ENABLE_AUTH: "false" WEB_DID_BASE_URL: "https://example.com/identity" + ANCHOR_TO_CORD: "false" + ISSUER_AGENT_BASE_URL : networks: test: healthcheck: diff --git a/services/identity-service/docker-compose.yml b/services/identity-service/docker-compose.yml index 32dd8dfd0..9592fc4ee 100644 --- a/services/identity-service/docker-compose.yml +++ b/services/identity-service/docker-compose.yml @@ -10,6 +10,8 @@ services: - VAULT_ADDR=http://0.0.0.0:8200 - VAULT_API_ADDR=http://0.0.0.0:8200 - VAULT_ADDRESS=http://0.0.0.0:8200 + - ANCHOR_TO_CORD=false + - ISSUER_AGENT_BASE_URL= cap_add: - IPC_LOCK command: vault server -config=/vault/config/vault.json @@ -59,6 +61,9 @@ services: JWKS_URI: ENABLE_AUTH: "false" WEB_DID_BASE_URL: + ANCHOR_TO_CORD: "false" + ISSUER_AGENT_BASE_URL : + healthcheck: test: [ "CMD-SHELL", "curl -f http://localhost:3332/health || exit 1" ] diff --git a/services/identity-service/src/app.module.ts b/services/identity-service/src/app.module.ts index 9a0bedaba..9b7ba38e8 100644 --- a/services/identity-service/src/app.module.ts +++ b/services/identity-service/src/app.module.ts @@ -13,7 +13,8 @@ import { AuthGuard } from './auth/auth.guard'; import { TerminusModule } from '@nestjs/terminus'; import { PrismaHealthIndicator } from './utils/prisma.health'; import { VaultHealthIndicator } from './utils/vault.health'; - +import { AnchorCordService } from './did/implementations/anchor-cord.service'; +import { BlockchainAnchorFactory } from './did/factories/blockchain-anchor.factory'; @Module({ imports: [ DidModule, @@ -26,7 +27,7 @@ import { VaultHealthIndicator } from './utils/vault.health'; ], controllers: [AppController, DidController], providers: [ - PrismaService, DidService, VaultService, + PrismaService, DidService, VaultService,BlockchainAnchorFactory,AnchorCordService, { provide: APP_GUARD, useClass: AuthGuard, diff --git a/services/identity-service/src/did/anchor-cord.service.spec.ts b/services/identity-service/src/did/anchor-cord.service.spec.ts new file mode 100644 index 000000000..817677da4 --- /dev/null +++ b/services/identity-service/src/did/anchor-cord.service.spec.ts @@ -0,0 +1,170 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DidService } from './did.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; +import { VaultService } from '../utils/vault.service'; +import { PrismaService } from '../utils/prisma.service'; +import { GenerateDidDTO } from './dtos/GenerateDidRequest.dto'; +import { InternalServerErrorException } from '@nestjs/common'; + + +describe('DidService - generateDID', () => { + let service: DidService; + let blockchainFactory: BlockchainAnchorFactory; + let vaultService: VaultService; + let prismaService: PrismaService; + + const mockBlockchainService = { + anchorDid: jest.fn(), + }; + + const mockVaultService = { + writePvtKey: jest.fn(), + }; + + const mockPrismaService = { + identity: { + create: jest.fn(), + }, + }; + + const mockGenerateDidDTO: GenerateDidDTO = { + "services": [ + { + "id": "IdentityHub", + "type": "IdentityHub", + "serviceEndpoint": { + "instance": [ + "https://cord.network.in" + ] + } + } + ] + , "method": "cord" + } + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DidService, + { + provide: BlockchainAnchorFactory, + useValue: { + getAnchorService: jest.fn(() => mockBlockchainService), + }, + }, + { + provide: VaultService, + useValue: mockVaultService, + }, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(DidService); + blockchainFactory = module.get(BlockchainAnchorFactory); + vaultService = module.get(VaultService); + prismaService = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await prismaService?.$disconnect?.(); + }); + + it('should verify ANCHOR_TO_CORD is true', () => { + const anchorToCord = process.env.ANCHOR_TO_CORD; + const isTrue = anchorToCord?.toLowerCase().trim() === 'true'; + expect(isTrue).toBe(true); + }); + + + it('should verify the environment variable is a valid URL', () => { + const ISSUER_AGENT_BASE_URL = process.env.ISSUER_AGENT_BASE_URL; + function isValidUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch (err) { + return false; + } + } + expect(isValidUrl(ISSUER_AGENT_BASE_URL)).toBe(true); + }); + + it('should anchor a DID to the blockchain and validate JSON keys', async () => { + const mockResponse = { + document: { + uri: 'did:cord:test123', + authentication: ['did:cord:test123#key-1'], + service: mockGenerateDidDTO.services, + keyAgreement: ['did:cord:test123#key-2'], + capabilityDelegation: ['did:cord:test123#key-3'], + assertionMethod: ['did:cord:test123#key-4'], + }, + mnemonic: 'mock-mnemonic', + delegateKeys: ['key1', 'key2'], + }; + + mockBlockchainService.anchorDid.mockResolvedValueOnce(mockResponse); + + const result = await service.generateDID(mockGenerateDidDTO); + console.log("result", result); + expect(blockchainFactory.getAnchorService).toHaveBeenCalledWith('cord'); + expect(mockBlockchainService.anchorDid).toHaveBeenCalledWith(mockGenerateDidDTO); + + + expect(result).toBeDefined(); + + + const expectedKeys = [ + 'uri', + 'authentication', + 'service', + 'keyAgreement', + 'capabilityDelegation', + 'assertionMethod', + ]; + + expectedKeys.forEach((key) => { + expect(result).toHaveProperty(key); + }); + }); + + + + + it('should throw an error if blockchain anchoring fails', async () => { + mockBlockchainService.anchorDid.mockRejectedValueOnce(new Error('Blockchain Error')); + + await expect(service.generateDID(mockGenerateDidDTO)).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw an error if writing to the vault fails', async () => { + const mockResponse = { + document: { uri: 'did:cord:test123' }, + mnemonic: 'mock-mnemonic', + delegateKeys: ['key1', 'key2'], + }; + mockBlockchainService.anchorDid.mockResolvedValueOnce(mockResponse); + mockVaultService.writePvtKey.mockRejectedValueOnce(new Error('Vault Error')); + + await expect(service.generateDID(mockGenerateDidDTO)).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw an error if writing to the database fails', async () => { + const mockResponse = { + document: { uri: 'did:cord:test123' }, + mnemonic: 'mock-mnemonic', + delegateKeys: ['key1', 'key2'], + }; + mockBlockchainService.anchorDid.mockResolvedValueOnce(mockResponse); + mockPrismaService.identity.create.mockRejectedValueOnce(new Error('Database Error')); + + await expect(service.generateDID(mockGenerateDidDTO)).rejects.toThrow(InternalServerErrorException); + }); +}); diff --git a/services/identity-service/src/did/did.controller.spec.ts b/services/identity-service/src/did/did.controller.spec.ts index b006b43ba..02fc32f60 100644 --- a/services/identity-service/src/did/did.controller.spec.ts +++ b/services/identity-service/src/did/did.controller.spec.ts @@ -38,6 +38,7 @@ describe('DidController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DidController], + // providers: [DidService, PrismaService, ConfigService, VaultService,AnchorCordService] providers: [DidService, PrismaService, ConfigService, VaultService] }).compile(); diff --git a/services/identity-service/src/did/did.controller.ts b/services/identity-service/src/did/did.controller.ts index 1a5bb8e20..550bd7e28 100644 --- a/services/identity-service/src/did/did.controller.ts +++ b/services/identity-service/src/did/did.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, InternalServerErrorException, + HttpException, Logger, Param, Post, @@ -44,6 +45,9 @@ export class DidController { try { return await Promise.all(promises); } catch (err) { + if (err instanceof HttpException) { + throw err; + } Logger.error(err); throw new InternalServerErrorException(err?.message); } diff --git a/services/identity-service/src/did/did.module.ts b/services/identity-service/src/did/did.module.ts index 1955642da..d0d705233 100644 --- a/services/identity-service/src/did/did.module.ts +++ b/services/identity-service/src/did/did.module.ts @@ -5,7 +5,8 @@ import { PrismaService } from 'src/utils/prisma.service'; import { DidController } from './did.controller'; import { DidService } from './did.service'; import { VaultService } from '../utils/vault.service'; - +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; +import { AnchorCordService } from './implementations/anchor-cord.service'; @Module({ imports: [HttpModule], controllers: [DidController], @@ -13,6 +14,8 @@ import { VaultService } from '../utils/vault.service'; DidService, PrismaService, VaultService, + AnchorCordService, + BlockchainAnchorFactory ], }) export class DidModule {} diff --git a/services/identity-service/src/did/did.service.spec.ts b/services/identity-service/src/did/did.service.spec.ts index dead5270e..ebec83cd2 100644 --- a/services/identity-service/src/did/did.service.spec.ts +++ b/services/identity-service/src/did/did.service.spec.ts @@ -2,6 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DidService } from './did.service'; import { PrismaService } from '../utils/prisma.service'; import { VaultService } from '../utils/vault.service'; +import { AnchorCordService } from './implementations/anchor-cord.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; import { GenerateDidDTO, VerificationKeyType } from './dtos/GenerateDidRequest.dto'; import { ConfigService } from '@nestjs/config'; @@ -32,7 +34,7 @@ describe('DidService', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [DidService, PrismaService, VaultService, ConfigService], + providers: [DidService, PrismaService, VaultService, ConfigService,BlockchainAnchorFactory,AnchorCordService], }).compile(); service = module.get(DidService); diff --git a/services/identity-service/src/did/did.service.ts b/services/identity-service/src/did/did.service.ts index 73db04f23..3e55d6e5d 100644 --- a/services/identity-service/src/did/did.service.ts +++ b/services/identity-service/src/did/did.service.ts @@ -1,10 +1,12 @@ -import { Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, InternalServerErrorException, Logger, NotFoundException ,BadRequestException,HttpException} from '@nestjs/common'; import { PrismaService } from '../utils/prisma.service'; import { v4 as uuid } from 'uuid'; import { VaultService } from '../utils/vault.service'; import { Identity } from '@prisma/client'; import { RSAKeyPair } from 'crypto-ld'; import { GenerateDidDTO } from './dtos/GenerateDidRequest.dto'; +// import { AnchorCordService } from 'src/utils/cord.service'; +import { BlockchainAnchorFactory } from './factories/blockchain-anchor.factory'; const { DIDDocument } = require('did-resolver'); type DIDDocument = typeof DIDDocument; @@ -21,7 +23,7 @@ export class DidService { webDidPrefix: string; signingAlgorithm: string; didResolver: any; - constructor(private prisma: PrismaService, private vault: VaultService) { + constructor(private prisma: PrismaService, private vault: VaultService , private blockchainFactory:BlockchainAnchorFactory) { let baseUrl: string = process.env.WEB_DID_BASE_URL; this.webDidPrefix = this.getDidPrefixForBaseUrl(baseUrl); this.signingAlgorithm = process.env.SIGNING_ALGORITHM; @@ -97,86 +99,121 @@ export class DidService { if(name && !verificationKey) throw new NotFoundException("Verification Key '" + name + "' not found"); return verificationKey; } - async generateDID(doc: GenerateDidDTO): Promise { - // Create a UUID for the DID using uuidv4 - const didUri: string = this.generateDidUri(doc?.method, doc?.id, doc?.webDidBaseUrl); - - // Create private/public key pair - let authnKeys; + let didUri: string; + let document: DIDDocument; let privateKeys: object; - let verificationKey = await this.getVerificationKeyByName(doc?.keyPairType); - if(!verificationKey) verificationKey = await this.getVerificationKey(this.signingAlgorithm); - try { - const keyPair = await (verificationKey as any)?.key.generate({ - id: `${didUri}#key-0`, - controller: didUri - }); - const exportedKey = await keyPair.export({ - publicKey: true, privateKey: true, includeContext: true - }); - let privateKey = {}; - if(verificationKey?.name === "Ed25519VerificationKey2020") { - const {privateKeyMultibase, ...rest } = exportedKey; - authnKeys = rest; - privateKey = {privateKeyMultibase}; - } else if(verificationKey?.name === "Ed25519VerificationKey2018") { - const {privateKeyBase58, ...rest } = exportedKey; - authnKeys = rest; - privateKey = {privateKeyBase58}; - } else if(verificationKey?.name === "RsaVerificationKey2018") { - const {privateKeyPem, ...rest } = exportedKey; - authnKeys = {...rest}; - privateKey = {privateKeyPem}; - } else { - throw new NotFoundException("VerificationKey type not found"); - } - privateKeys = { - [authnKeys.id]: privateKey - }; - } catch (err: any) { - Logger.error(`Error generating key pair: ${err}`); - throw new InternalServerErrorException('Error generating key pair: ' + err.message); - } + let blockchainStatus: boolean = false; - const keyId = authnKeys?.id; - - // Create a DID Document - const document: DIDDocument = { - '@context': [ - "https://www.w3.org/ns/did/v1" - ], - id: didUri, - alsoKnownAs: doc.alsoKnownAs, - service: doc.services, - verificationMethod: [ - authnKeys, - ], - authentication: [keyId], - assertionMethod: [keyId] - }; + // Check if anchoring to blockchain is enabled and get the method + const method = this.shouldAnchorToBlockchain(); + + if (method) { + try { + // Get the appropriate service from the factory + const anchorService = this.blockchainFactory.getAnchorService(method); + // Use the service to anchor the DID + const response = await anchorService.anchorDid(doc); + didUri = response.document.uri; + document = response.document; + + // store mnemonic and delegate keys in to vault + privateKeys = { + "mnemonic":response.mnemonic, + "delegateKeys":response.delegateKeys + }; + blockchainStatus = true; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } + Logger.error(`Error anchoring to Cord: ${err}`); + throw new InternalServerErrorException('Failed to anchor DID to Cord blockchain'); + } + } else { + + didUri = this.generateDidUri(doc?.method, doc?.id, doc?.webDidBaseUrl); + + let authnKeys; + let verificationKey = await this.getVerificationKeyByName(doc?.keyPairType); + if (!verificationKey) verificationKey = await this.getVerificationKey(this.signingAlgorithm); + + try { + const keyPair = await (verificationKey as any)?.key.generate({ + id: `${didUri}#key-0`, + controller: didUri + }); + const exportedKey = await keyPair.export({ + publicKey: true, privateKey: true, includeContext: true + }); + + let privateKey = {}; + if (verificationKey?.name === "Ed25519VerificationKey2020") { + const { privateKeyMultibase, ...rest } = exportedKey; + authnKeys = rest; + privateKey = { privateKeyMultibase }; + } else if (verificationKey?.name === "Ed25519VerificationKey2018") { + const { privateKeyBase58, ...rest } = exportedKey; + authnKeys = rest; + privateKey = { privateKeyBase58 }; + } else if (verificationKey?.name === "RsaVerificationKey2018") { + const { privateKeyPem, ...rest } = exportedKey; + authnKeys = { ...rest }; + privateKey = { privateKeyPem }; + } else { + throw new NotFoundException("VerificationKey type not found"); + } + + privateKeys = { + [authnKeys.id]: privateKey + }; + + const keyId = authnKeys?.id; + + document = { + '@context': [ + "https://www.w3.org/ns/did/v1" + ], + id: didUri, + alsoKnownAs: doc.alsoKnownAs, + service: doc.services, + verificationMethod: [ + authnKeys, + ], + authentication: [keyId], + assertionMethod: [keyId] + }; + + } catch (err: any) { + Logger.error(`Error generating key pair: ${err}`); + throw new InternalServerErrorException('Error generating key pair: ' + err.message); + } + } + try { await this.prisma.identity.create({ data: { id: didUri, didDoc: JSON.stringify(document), + blockchainStatus: blockchainStatus, }, }); } catch (err) { - Logger.error(`Error writing DID to database ${err}`); + Logger.error(`Error writing DID to database: ${err}`); throw new InternalServerErrorException('Error writing DID to database'); } - + try { await this.vault.writePvtKey(privateKeys, didUri); } catch (err) { - Logger.error(err); + Logger.error(`Error writing private key to vault: ${err}`); throw new InternalServerErrorException('Error writing private key to vault'); } - + return document; } + async resolveDID(id: string): Promise { let artifact: Identity; @@ -211,4 +248,35 @@ export class DidService { const webDidId = this.getWebDidIdForId(id); return this.resolveDID(webDidId); } + + +/** + * Determines if anchoring to a blockchain is enabled based on environment variables. + * Checks for specific blockchain configurations and returns the appropriate method. + * @returns The blockchain method (e.g., 'cord', 'solana') if anchoring is enabled; otherwise, null. + */ +private shouldAnchorToBlockchain(): string | null { + // Check if the environment variable ANCHOR_TO_CORD is set to 'true' for the CORD blockchain + if ( + process.env.ANCHOR_TO_CORD && + process.env.ANCHOR_TO_CORD.toLowerCase().trim() === 'true' + ) { + return 'cord'; // Return 'cord' as the service method if CORD anchoring is enabled + } + + // Add additional checks here for other blockchains, e.g.,Solana, Ethereum, Polkadot + /* + if ( + process.env.ANCHOR_TO_SOLANA && + process.env.ANCHOR_TO_SOLANA.toLowerCase().trim() === 'true' + ) { + return 'solana'; // Return 'solana' if solana anchoring is enabled + } + */ + + return null; // Return null if no blockchain anchoring is required } + + +} + diff --git a/services/identity-service/src/did/factories/blockchain-anchor.factory.ts b/services/identity-service/src/did/factories/blockchain-anchor.factory.ts new file mode 100644 index 000000000..67d48cbff --- /dev/null +++ b/services/identity-service/src/did/factories/blockchain-anchor.factory.ts @@ -0,0 +1,38 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { AnchorCordService } from '../implementations/anchor-cord.service'; +import { BlockchainAnchor } from '../interfaces/blockchain_anchor.interface'; + +/** + * Factory class to dynamically resolve the appropriate BlockchainAnchor service. + * It uses the specified method to determine which implementation to return. + */ +@Injectable() +export class BlockchainAnchorFactory { + /** + * Constructor for the BlockchainAnchorFactory. + * @param cordService - An instance of AnchorCordService, which handles CORD-specific anchoring logic. + */ + constructor(private readonly cordService: AnchorCordService) {} + + /** + * Resolves the appropriate BlockchainAnchor service based on the provided method. + * @param method - The blockchain method (e.g., 'cord'). + * @returns The service instance corresponding to the specified method or null if no method is provided. + * @throws + */ + getAnchorService(method?: string): BlockchainAnchor | null { + // If no method is specified, return null to indicate no anchoring is required + if (!method) { + return null; + } + + // Determine the appropriate service implementation based on the method + switch (method) { + case 'cord': + // Return the CORD-specific implementation + return this.cordService; + default: + throw new BadRequestException(`Unsupported blockchain method: ${method}`); + } + } +} diff --git a/services/identity-service/src/did/implementations/anchor-cord.service.ts b/services/identity-service/src/did/implementations/anchor-cord.service.ts new file mode 100644 index 000000000..e6c4d221e --- /dev/null +++ b/services/identity-service/src/did/implementations/anchor-cord.service.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { BlockchainAnchor } from '../interfaces/blockchain_anchor.interface'; +import {BadRequestException} from '@nestjs/common'; +@Injectable() +export class AnchorCordService implements BlockchainAnchor { + private readonly logger = new Logger(AnchorCordService.name); + + constructor(private readonly httpService: HttpService) {} + + async anchorDid(body: any): Promise<{ document: any; mnemonic: string; delegateKeys: object }> { + try { + if (body.method !== 'cord') { + throw new BadRequestException('Invalid method: only "cord" is allowed for anchoring to Cord.'); + } + const response = await this.httpService.axiosRef.post( + `${process.env.ISSUER_AGENT_BASE_URL}/did/create/`, + body, + ); + return response.data.result; + } catch (err) { + this.logger.error('Error anchoring DID to CORD blockchain', err); + throw new InternalServerErrorException('Failed to anchor DID to CORD blockchain'); + } + } +} diff --git a/services/identity-service/src/did/interfaces/blockchain_anchor.interface.ts b/services/identity-service/src/did/interfaces/blockchain_anchor.interface.ts new file mode 100644 index 000000000..6e4d3998b --- /dev/null +++ b/services/identity-service/src/did/interfaces/blockchain_anchor.interface.ts @@ -0,0 +1,9 @@ +export interface BlockchainAnchor { + /** + * Anchors a DID document to the blockchain. + * @param body The request payload for anchoring. + * @returns The anchored DID document or related data. + */ + anchorDid(body: any): Promise; + } + \ No newline at end of file diff --git a/services/identity-service/src/vc/vc.module.ts b/services/identity-service/src/vc/vc.module.ts index 4038eef50..9e71d5686 100644 --- a/services/identity-service/src/vc/vc.module.ts +++ b/services/identity-service/src/vc/vc.module.ts @@ -5,10 +5,14 @@ import { VaultService } from 'src/utils/vault.service'; import { PrismaService } from 'src/utils/prisma.service'; import { VcController } from './vc.controller'; import VcService from './vc.service'; +import { BlockchainAnchorFactory } from 'src/did/factories/blockchain-anchor.factory'; +import { AnchorCordService } from 'src/did/implementations/anchor-cord.service'; +// import { AnchorCordService } from 'src/utils/cord.service'; @Module({ imports: [HttpModule], controllers: [VcController], - providers: [VcService, PrismaService, DidService, VaultService], + providers: [VcService, PrismaService, DidService, VaultService,BlockchainAnchorFactory,AnchorCordService], + // providers: [VcService, PrismaService, DidService, VaultService,AnchorCordService], }) export class VcModule {} diff --git a/services/identity-service/src/vc/vc.service.spec.ts b/services/identity-service/src/vc/vc.service.spec.ts index 7c8c29d25..ce243c672 100644 --- a/services/identity-service/src/vc/vc.service.spec.ts +++ b/services/identity-service/src/vc/vc.service.spec.ts @@ -3,7 +3,8 @@ import VcService from './vc.service'; import { PrismaService } from '../utils/prisma.service'; import { DidService } from '../did/did.service'; import { VaultService } from '../utils/vault.service'; - +import { BlockchainAnchorFactory } from 'src/did/factories/blockchain-anchor.factory'; +import { AnchorCordService } from 'src/did/implementations/anchor-cord.service'; describe('DidService', () => { let service: VcService; let didService: DidService; @@ -17,7 +18,7 @@ describe('DidService', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [VcService, PrismaService, DidService, VaultService], + providers: [VcService, PrismaService, DidService, VaultService,BlockchainAnchorFactory,AnchorCordService], }).compile(); service = module.get(VcService);