diff --git a/apps/agent/src/app/app.module.ts b/apps/agent/src/app/app.module.ts index 56ad943038ed0d1c0032ffd431966a2d391714b5..77019aa51f57d8c742b9ad2278cee3a98e28ec3f 100644 --- a/apps/agent/src/app/app.module.ts +++ b/apps/agent/src/app/app.module.ts @@ -11,6 +11,8 @@ import { ledgersSchema, ipfsConfig, ipfsSchema, + catalogConfig, + catalogSchema, } from "@ocm-engine/config"; import Joi from "joi"; @@ -19,6 +21,7 @@ const validationSchema = Joi.object({ auth: authSchema, ledgers: ledgersSchema, ipfs: ipfsSchema, + catalog: catalogSchema, }); @Module({ @@ -26,7 +29,7 @@ const validationSchema = Joi.object({ AskarDynamicModule.forRootAsync(), ConfigModule.forRoot({ isGlobal: true, - load: [agentConfig, authConfig, ledgersConfig, ipfsConfig], + load: [agentConfig, authConfig, ledgersConfig, ipfsConfig, catalogConfig], validationSchema, }), ], diff --git a/libs/askar/src/agent.utils.ts b/libs/askar/src/agent.utils.ts index 1895f51abc8a77e00f97d707330a80f62b198585..1f49aa05c58b46e25d79e703d50f9c67c776104f 100644 --- a/libs/askar/src/agent.utils.ts +++ b/libs/askar/src/agent.utils.ts @@ -48,6 +48,7 @@ import { anoncreds } from "@hyperledger/anoncreds-nodejs"; import { AskarModule } from "@credo-ts/askar"; import { ariesAskar } from "@hyperledger/aries-askar-nodejs"; import { Key as AskarKey, KeyAlgs } from "@hyperledger/aries-askar-shared"; +import crypto from "crypto"; import { catchError, filter, @@ -203,6 +204,11 @@ export const generateDidWeb = async ({ console.log(JSON.stringify(didDocumentInstance.toJSON(), null, 2)); }; +export const generateRandomDidSvdx = async (agent: Agent) => { + const seed = crypto.randomBytes(46).toString("hex"); + return generateDidSvdx({ agent, seed }); +}; + export const generateDidSvdx = async ({ seed, agent, @@ -235,6 +241,8 @@ export const generateDidSvdx = async ({ if (!didResult.didState.didDocument) { throw new Error("Could not create did svdx"); } + + return didResult; }; export const generateDidFromKey = (key: Key): string => { diff --git a/libs/askar/src/askar-rest/rest.controller.ts b/libs/askar/src/askar-rest/rest.controller.ts index c2d8a349c05b68c332b62fe7af8cbe629fe795eb..8ebc3eff30912744c0360b21bc697ad7a67183a2 100644 --- a/libs/askar/src/askar-rest/rest.controller.ts +++ b/libs/askar/src/askar-rest/rest.controller.ts @@ -17,6 +17,7 @@ import { AgentDidsService } from "../askar/services/agent.dids.service"; import { AgentJsonldService } from "../askar/services/agent.jsonld.service"; import { AgentOobService } from "../askar/services/agent.oob.service"; import { AgentProofsService } from "../askar/services/agent.proofs.service"; +import { AgentOcmService } from "../askar/services/agent.ocm.service"; import { CreateCredentialDefinitionRequestDto, OfferCredentialRequestDto, @@ -35,6 +36,7 @@ import { OfferJsonCredentialRequests, SignJsonCredentialRequests, DidRecordDto, + RequestSenderEmailVcDto, } from "@ocm-engine/dtos"; import { AllExceptionsHandler } from "./exception.handler"; import { DidResolutionResult } from "@credo-ts/core"; @@ -53,6 +55,7 @@ export class RestController { private readonly agentJsonldService: AgentJsonldService, private readonly agentOobService: AgentOobService, private readonly agentProofsService: AgentProofsService, + private readonly agentOcmService: AgentOcmService, ) {} @Get("/invitations") @@ -272,4 +275,9 @@ export class RestController { async resolveDid(@Body() dto: IdReqDto): Promise<DidResolutionResult> { return this.agentDidsService.resolve(dto.id); } + + @Post("/ocm/request-sender-email-vc") + async requestSenderEmailVC(@Body() dto: RequestSenderEmailVcDto) { + return this.agentOcmService.requestSenderEmailVC(dto); + } } diff --git a/libs/askar/src/askar/askar.module.ts b/libs/askar/src/askar/askar.module.ts index 9631ba3b94d40a55606202fc8e933ce338ce6a3f..dba3185cf9737b77e68d6b689362e07d3e4021c2 100644 --- a/libs/askar/src/askar/askar.module.ts +++ b/libs/askar/src/askar/askar.module.ts @@ -8,6 +8,8 @@ import { AgentDidsService } from "./services/agent.dids.service"; import { AgentJsonldService } from "./services/agent.jsonld.service"; import { AgentOobService } from "./services/agent.oob.service"; import { AgentProofsService } from "./services/agent.proofs.service"; +import { AgentOcmService } from "./services/agent.ocm.service"; +import { CatalogClient } from "./clients/catalog.client"; import { ConfigModule } from "@nestjs/config"; import { LedgersModule } from "@ocm-engine/ledgers"; @@ -23,6 +25,8 @@ import { LedgersModule } from "@ocm-engine/ledgers"; AgentJsonldService, AgentOobService, AgentProofsService, + AgentOcmService, + CatalogClient, AskarService, ], exports: [ @@ -34,6 +38,8 @@ import { LedgersModule } from "@ocm-engine/ledgers"; AgentJsonldService, AgentOobService, AgentProofsService, + AgentOcmService, + CatalogClient, AskarService, ], }) diff --git a/libs/askar/src/askar/clients/catalog.client.ts b/libs/askar/src/askar/clients/catalog.client.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e2971ea859a4fa90353d9a69a34165ae86579a3 --- /dev/null +++ b/libs/askar/src/askar/clients/catalog.client.ts @@ -0,0 +1,47 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { IConfCatalog } from "@ocm-engine/config"; +import { ConfigService } from "@nestjs/config"; +import axios, { AxiosError } from "axios"; + +@Injectable() +export class CatalogClient { + private readonly logger = new Logger(CatalogClient.name); + private catalogConfig: IConfCatalog; + constructor(private readonly configService: ConfigService) { + this.catalogConfig = this.configService.get<IConfCatalog>("catalog")!; + } + + searchAccount = async (email: string): Promise<string | null> => { + this.logger.debug(`searchAccount`, email); + try { + const response = await axios.post( + `${this.catalogConfig.catalogUrl}/v1/accounts/search`, + { + email: email, + }, + ); + return response.data.did; + } catch (e) { + this.logger.debug("searchAccount failed", e); + + if (e instanceof AxiosError && e.status === 404) { + return null; + } else { + throw e; + } + } + }; + + createAccount = async (email: string, didSvdx: string): Promise<void> => { + this.logger.debug(`createAccount`, email); + try { + await axios.post(`${this.catalogConfig.catalogUrl}/v1/accounts`, { + email: email, + did: didSvdx, + }); + } catch (e) { + this.logger.debug("createAccount failed", e); + throw e; + } + }; +} diff --git a/libs/askar/src/askar/services/agent.ocm.service.ts b/libs/askar/src/askar/services/agent.ocm.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ef2a31efd2bb629ebae4bfa3313ddd21542db87 --- /dev/null +++ b/libs/askar/src/askar/services/agent.ocm.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { EntityNotFoundError, RequestSenderEmailVcDto } from "@ocm-engine/dtos"; +import { CatalogClient } from "../clients/catalog.client"; +import { generateRandomDidSvdx } from "../../agent.utils"; +import { AskarService } from "./askar.service"; +import { IConfCatalog } from "@ocm-engine/config"; +import { ConfigService } from "@nestjs/config"; +import { + ClaimFormat, + JsonTransformer, + W3cCredential, + W3cCredentialService, +} from "@credo-ts/core"; +import { uuid } from "@credo-ts/core/build/utils/uuid"; + +@Injectable() +export class AgentOcmService { + private readonly logger = new Logger(AgentOcmService.name); + private activated = false; + constructor( + private readonly askar: AskarService, + private readonly catalogClient: CatalogClient, + private readonly configService: ConfigService, + ) { + const catalogConfig = this.configService.get<IConfCatalog>("catalog")!; + this.activated = !!catalogConfig.catalogUrl; + } + + requestSenderEmailVC = async ( + dto: RequestSenderEmailVcDto, + ): Promise<{ + email: string; + did: string; + vc: object; + }> => { + this.ensureActivated(); + + this.logger.debug(`requestSenderEmailVC`, dto.email); + const didSvdx = await this.catalogClient.searchAccount(dto.email); + if (didSvdx) { + const record = await this.askar.agent.genericRecords.findById( + "svdx_vc_" + dto.email, + ); + if (!record) { + // todo regenerate in that case? + throw new Error( + `VC not found in storage, even though the catalog has some data about it. did: ${didSvdx}, email: ${dto.email}`, + ); + } + const vc = record.content; + return { + email: dto.email, + did: didSvdx, + vc, + }; + } + + const newDidSvdx = await generateRandomDidSvdx(this.askar.agent); + + if (!newDidSvdx.didState.did) { + throw new Error("Svdx did generation failed"); + } + + const didRecords = await this.askar.agent.dids.getCreatedDids({ + method: "svdx", + did: newDidSvdx.didState.did, + }); + if (!didRecords.length) { + throw new EntityNotFoundError("Agent does not have did:svdx"); + } + const svdxDid = didRecords[0]; + const verificationMethodList = + svdxDid.didDocument?.verificationMethod || []; + if (!verificationMethodList.length) { + throw new EntityNotFoundError( + "DidDocument does not exists or contains no verification methods", + ); + } + + const verificationMethod = verificationMethodList[0]; + + const credentialRecordId = uuid(); + const credential = JsonTransformer.fromJSON( + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/jws-2020/v1", + "https://www.vereign.com/.well-known/svdx-account-schema", + ], + type: ["VerifiableCredential"], + id: svdxDid.did + "?uuid=" + credentialRecordId, + issuer: svdxDid.did, + issuanceDate: new Date().toISOString(), + credentialSubject: { + type: "svdx:SvdxAccount", + "svdx:email": dto.email, + "svdx:did": newDidSvdx.didState.did, + }, + }, + W3cCredential, + ); + + const w3cServ = + this.askar.agent.context.dependencyManager.resolve(W3cCredentialService); + const vc = await w3cServ.signCredential(this.askar.agent.context, { + format: ClaimFormat.LdpVc, + credential, + proofType: "Ed25519Signature2018", + verificationMethod: verificationMethod.id, + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const jsonVC = vc.toJson(); + + // Save credential + await this.askar.agent.genericRecords.save({ + id: "svdx_vc_" + dto.email, + content: jsonVC, + }); + + await this.catalogClient.createAccount(dto.email, svdxDid.did); + + return { + email: dto.email, + did: svdxDid.did, + vc: jsonVC, + }; + }; + + private ensureActivated() { + if (!this.activated) { + throw new Error( + "Please provide the catalog URL on startup to activate this endpoint.", + ); + } + } +} diff --git a/libs/config/src/config/catalog.config.ts b/libs/config/src/config/catalog.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc0fd33e2916eb1082ca7cf42c5cddd3c8886db5 --- /dev/null +++ b/libs/config/src/config/catalog.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; +import * as process from "process"; +import { IConfCatalog } from "../interfaces/catalog.config.interface"; + +export const catalogConfig = registerAs( + "catalog", + (): IConfCatalog => ({ + catalogUrl: process.env["CATALOG_URL"]!, + }), +); diff --git a/libs/config/src/index.ts b/libs/config/src/index.ts index 0f7e567de1a8f28255f94c49e9221eda4d73e457..8e2c21aea60f2ef58ad43f718f396809dedb4863 100644 --- a/libs/config/src/index.ts +++ b/libs/config/src/index.ts @@ -2,13 +2,16 @@ export * from "./config/agent.config"; export * from "./config/auth.config"; export * from "./config/ledgers.config"; export * from "./config/ipfs.config"; +export * from "./config/catalog.config"; export * from "./interfaces/agent.config.interface"; export * from "./interfaces/auth.config.interface"; export * from "./interfaces/ledgers.config.interface"; export * from "./interfaces/ipfs.config.interface"; +export * from "./interfaces/catalog.config.interface"; export * from "./schemas/agent.schema"; export * from "./schemas/auth.schema"; export * from "./schemas/ledgers.schema"; export * from "./schemas/ipfs.schema"; +export * from "./schemas/catalog.schema"; diff --git a/libs/config/src/interfaces/catalog.config.interface.ts b/libs/config/src/interfaces/catalog.config.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e70785ca6f208a343974453a87e78cf70f009aa --- /dev/null +++ b/libs/config/src/interfaces/catalog.config.interface.ts @@ -0,0 +1,3 @@ +export interface IConfCatalog { + catalogUrl: string; +} diff --git a/libs/config/src/schemas/catalog.schema.ts b/libs/config/src/schemas/catalog.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..e634ec71ccf59b63a85fddd13e297d1cbe12017d --- /dev/null +++ b/libs/config/src/schemas/catalog.schema.ts @@ -0,0 +1,5 @@ +import Joi from "joi"; + +export const catalogSchema = Joi.object({ + CATALOG_URL: Joi.string(), +}); diff --git a/libs/dtos/src/dtos/requests/request.sender.email.vc.dto.ts b/libs/dtos/src/dtos/requests/request.sender.email.vc.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..f75b9b63390dc5030920a7333b1cf8caf8dd36c6 --- /dev/null +++ b/libs/dtos/src/dtos/requests/request.sender.email.vc.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsEmail } from "class-validator"; + +export class RequestSenderEmailVcDto { + @IsEmail() + @IsNotEmpty() + email: string; +} diff --git a/libs/dtos/src/index.ts b/libs/dtos/src/index.ts index d57afd18a2a2a3e109ba3b2943cb94ef9846a1e7..cedb6f7f6eb8a7f42635c3e456dde263f3ae1804 100644 --- a/libs/dtos/src/index.ts +++ b/libs/dtos/src/index.ts @@ -26,6 +26,7 @@ export * from "./dtos/requests/offer.credential.request.dto"; export * from "./dtos/requests/request.proof.dto"; export * from "./dtos/requests/make.basic.message.request.dto"; export * from "./dtos/requests/create.invitation.request.dto"; +export * from "./dtos/requests/request.sender.email.vc.dto"; export * from "./dtos/credo/w3c/credential/w3c.credential.dto"; export * from "./dtos/credo/w3c/credential/w3c.credential-schema.dto";