UNPKG

@dcxp/root

Version:

DCX: Decentralized Credential Exchange. DWN protocol for verifiable credential exchange.

614 lines (528 loc) 20.8 kB
import { CredentialManifest, DcxAgent, DcxDwnError, DcxIdentityVault, DcxIssuerError, DcxIssuerParams, DcxIssuerProcessRecordParams, DcxManager, DcxOptions, DcxProtocolHandlerError, DcxRecordsCreateResponse, DcxRecordsFilterResponse, DcxRecordsQueryResponse, DcxRecordsReadResponse, DwnError, DwnUtils, FileSystem, Handler, Issuer, Logger, ManifestParams, manifestSchema, Mnemonic, Objects, Provider, RecordsParams, responseSchema, ServerHandler, stringifier } from '@dcx-protocol/common'; import { DwnResponseStatus } from '@web5/agent'; import { ProtocolsConfigureResponse, ProtocolsQueryResponse, Record, RecordsCreateResponse, Web5, } from '@web5/api'; import { PresentationExchange, VerifiableCredential, VerifiablePresentation, } from '@web5/credentials'; import { DcxIssuerConfig, issuer, issuerConfig, issuerOptions } from './index.js'; export class DcxIssuer implements DcxManager { options : DcxOptions; config : DcxIssuerConfig; isSetup : boolean = false; isInitialized : boolean = false; public static web5 : Web5; public static agent : DcxAgent; public static agentVault : DcxIdentityVault = new DcxIdentityVault(); constructor(params: DcxIssuerParams = {}) { this.selectCredentials = this.findHandler('selectCredentials', this.selectCredentials); this.verifyCredentials = this.findHandler('verifyCredentials', this.verifyCredentials); this.requestCredential = this.findHandler('requestCredential', this.requestCredential); this.issueCredential = this.findHandler('issueCredential', this.issueCredential); this.config = { ...issuerConfig, ...params.config }; this.options = params.options ?? issuerOptions; } public findHandler(id: string, staticHandler: Handler): Handler { return this?.options?.handlers?.find((serverHandler: ServerHandler) => serverHandler.id === id)?.handler ?? staticHandler; } /** * * Verify the credentials in a Verifiable Presentation * @param vcs The selected credentials to verify * @param subjectDid The DID of the subject of the credentials * @returns An array of verified credentials */ public async verifyCredentials( vcJwts: string[], manifest: CredentialManifest, subjectDid: string, ): Promise<VerifiableCredential[]> { PresentationExchange.satisfiesPresentationDefinition({ vcJwts, presentationDefinition : manifest.presentation_definition, }); const verifiedCredentials: VerifiableCredential[] = []; for (const vcJwt of vcJwts) { Logger.debug('Parsing credential ...', vcJwt); const vc = VerifiableCredential.parseJwt({ vcJwt }); Logger.debug('Parsed credential', stringifier(vc)); if (vc.subject !== subjectDid) { Logger.debug(`Credential subject ${vc.subject} doesn't match subjectDid ${subjectDid}`); continue; } const issuers = [...this.options.issuers, ...issuerConfig.DCX_INPUT_ISSUERS].map((issuer: Issuer) => issuer.id); const issuerDidSet = new Set<string>(issuers); if (!issuerDidSet.has(vc.vcDataModel.issuer as string)) { continue; } const verified = await VerifiableCredential.verify({ vcJwt }); if (!verified || Objects.isEmpty(verified)) { Logger.debug('Credential verification failed'); continue; } verifiedCredentials.push(vc); } return verifiedCredentials; } /** * * Select credentials from a Verifiable Presentation * @param vp The verifiable presentation * @param manifest The credential manifest * @returns An array of selected credentials */ public selectCredentials( vp: VerifiablePresentation, manifest: CredentialManifest, ): string[] { Logger.debug('Using verifiable presentation for credential selection', stringifier(vp)); return PresentationExchange.selectCredentials({ vcJwts : vp.verifiableCredential, presentationDefinition : manifest.presentation_definition, }); } /** * * Issue a credential * @param data The data to include in the credential * @param subjectDid The DID of the subject of the credential * @param manifest The credential manifest * @returns The issued credential */ public async issueCredential( data: any, subjectDid: string, manifest: CredentialManifest, ): Promise<any> { const manifestOutputDescriptor = manifest.output_descriptors[0]; Logger.debug(`Issuing ${manifestOutputDescriptor.id} credential`); const vc = await VerifiableCredential.create({ data, subject : subjectDid, issuer : DcxIssuer.agent.agentDid.uri, type : manifestOutputDescriptor.name, }); Logger.debug(`Created ${manifestOutputDescriptor.id} credential`, stringifier(vc)); const signed = await vc.sign({ did: DcxIssuer.agent.agentDid }); Logger.debug(`Signed ${manifestOutputDescriptor.id} credential`, stringifier(signed)); return { fulfillment : { descriptor_map : [ { id : manifestOutputDescriptor.id, format : 'jwt_vc', path : '$.verifiableCredential[0]', }, ], }, verifiableCredential : [signed], }; } /** * * Request credential data from a VC data provider * @param body The body of the request * @param method The HTTP method to use * @param headers The headers to include in the request * @returns The response from the VC data provider */ public async requestCredential( params: { body : { vcs: VerifiableCredential[] | any }, id? : string }): Promise<any> { const provider = this.options.providers.find((provider: Provider) => provider.id === params?.id); if (!provider) { throw new DcxProtocolHandlerError('No VC data provider configured'); } Logger.debug(`Requesting VC data from ${provider.id} at ${provider.endpoint}`); const response = await fetch(provider.endpoint, { method : provider.method ?? 'POST', headers : provider.headers, body : stringifier(params.body), }); Logger.debug('VC request response', stringifier(response)); const data = await response.json(); Logger.debug('VC request data', stringifier(data)); return data; } /** * Query DWN for credential-issuer protocol * @returns Protocol[]; see {@link Protocol} */ public async queryProtocols(): Promise<ProtocolsQueryResponse> { // Query DWN for credential-issuer protocol const { status: query, protocols = [] } = await DcxIssuer.web5.dwn.protocols.query({ message : { filter : { protocol : issuer.protocol, }, }, }); if (DwnUtils.isFailure(query.code)) { const { code, detail } = query; Logger.error(`DWN protocols query failed`, query); throw new DwnError(code, detail); } Logger.debug(`DWN has ${protocols.length} protocols available`); Logger.debug('protocols', stringifier(protocols)); return { status: query, protocols }; } /** * Configure DWN for credential-issuer protocol * @returns DwnResponseStatus; see {@link DwnResponseStatus} */ public async configureProtocols(): Promise<ProtocolsConfigureResponse> { const { status: configure, protocol } = await DcxIssuer.web5.dwn.protocols.configure({ message : { definition: issuer }, }); if (DwnUtils.isFailure(configure.code) || !protocol) { const { code, detail } = configure; Logger.error('DWN protocol configure fail', configure, protocol); throw new DwnError(code, detail); } const { status: send } = await protocol.send(DcxIssuer.agent.agentDid.uri); if (DwnUtils.isFailure(send.code)) { const { code, detail } = send; Logger.error('DWN protocols send failed', send); throw new DwnError(code, detail); } Logger.debug('Sent protocol to remote DWN', send); return { status: send, protocol }; } /** * Query DWN for manifest records * * @returns Record[]; see {@link Record} */ public async queryRecords(): Promise<DcxRecordsQueryResponse> { const { status, records = [], cursor } = await DcxIssuer.web5.dwn.records.query({ message : { filter : { protocol : issuer.protocol, protocolPath : 'manifest', schema : manifestSchema.$id, dataFormat : 'application/json', }, }, }); if (DwnUtils.isFailure(status.code)) { const { code, detail } = status; Logger.error('DWN manifest records query failed', status); throw new DwnError(code, detail); } return { status, records, cursor }; } /** * Read records from DWN * * @param params.records list of Record objects to read; see {@link RecordsParams} * @returns a list of records that have been read into json; see {@link DcxRecordsReadResponse} */ public async readRecords({ records: manifestRecords }: RecordsParams): Promise<DcxRecordsReadResponse> { const records = await Promise.all( manifestRecords.map(async (manifestRecord: Record) => { const { record } = await DcxIssuer.web5.dwn.records.read({ message : { filter : { recordId : manifestRecord.id, }, }, }); return record.data.json(); }), ); return { records }; } /** * Filter manifests passed to to options against manifest record * reads in dwn to find missing manifests; See {@link CredentialManifest} * * @param params.records list of CredentialManifest objects; see {@link ManifestParams} * @returns list of CredentialManifest objects that need writing to remote DWN */ public async filterRecords({ records: manifestRecords }: ManifestParams): Promise<DcxRecordsFilterResponse> { const records = this.options.manifests.filter((manifest: CredentialManifest) => manifestRecords.find((manifestRecord: CredentialManifest) => manifest.id !== manifestRecord.id), ); return { records }; } /** * Create missing manifest record * @param unwrittenManifest CredentialManifest; see {@link CredentialManifest} * @returns Record | undefined; see {@link Record} */ public async createManifestRecord({ manifestRecord }: { manifestRecord: CredentialManifest }): Promise<RecordsCreateResponse> { manifestRecord.issuer.id = DcxIssuer.agent.agentDid.uri; const { record, status: create } = await DcxIssuer.web5.dwn.records.create({ store : true, data : manifestRecord, message : { schema : manifestSchema.$id, dataFormat : 'application/json', protocol : issuer.protocol, protocolPath : 'manifest', published : true, }, }); if (DwnUtils.isFailure(create.code)) { const { code, detail } = create; Logger.error('Failed to create missing manifest record', create); throw new DwnError(code, detail); } if (!record) { throw new DcxDwnError( `Failed to create missing dwn manifest record: ${manifestRecord.id}`, ); } const { status: send } = await record.send(); if (DwnUtils.isFailure(send.code)) { const { code, detail } = send; Logger.error('Failed to send dwn manifest record', send); throw new DwnError(code, detail); } Logger.debug(`Sent manifest record to remote dwn`, send); return { status: send, record }; } /** * Create missing manifests * @param missingManifests CredentialManifest[]; see {@link CredentialManifest} * @returns Record[]; see {@link Record} */ public async createRecords({ records: manifestRecords }: DcxRecordsCreateResponse): Promise<DcxRecordsCreateResponse> { const records = await Promise.all( manifestRecords.map( async (manifestRecord: CredentialManifest) => (await this.createManifestRecord({ manifestRecord }))?.record, ), ); return { records: records.filter((record?: Record) => record !== undefined) as Record[]}; } /** * * Process an application record * @param record The application record to process * @param manifest The credential manifest * @returns The status of the application record processing */ public async processRecord({ record, manifest, providerId }: DcxIssuerProcessRecordParams): Promise<DwnResponseStatus> { Logger.debug('Processing application record', stringifier(record)); // Parse the JSON VP from the application record; this will contain the credentials const vp: VerifiablePresentation = await record.data.json(); Logger.debug('Application record verifiable presentation', stringifier(vp)); // Select valid credentials against the manifest const vcJwts = this.selectCredentials(vp, manifest); Logger.debug(`Selected ${vcJwts.length} credentials`); const recordAuthor = record.author; const verified = await this.verifyCredentials(vcJwts, manifest, recordAuthor); Logger.debug(`Verified ${verified.length} credentials`); // request vc data const data = await this.requestCredential({ body: { vcs: verified }, id: providerId }); Logger.debug('VC data from provider', stringifier(data)); const vc = await this.issueCredential(data, recordAuthor, manifest); const { record: responseRecord, status: create } = await DcxIssuer.web5.dwn.records.create({ data : vc, store : true, message : { schema : responseSchema.$id, protocol : issuer.protocol, dataFormat : 'application/json', protocolPath : 'application/response', }, }); if (DwnUtils.isFailure(create.code)) { const { code, detail } = create; Logger.error(`DWN records create failed`, create); throw new DwnError(code, detail); } if (!responseRecord) { throw new DcxProtocolHandlerError('Failed to create application response record.'); } const { status: send } = await responseRecord?.send(recordAuthor); if (DwnUtils.isFailure(send.code)) { const { code, detail } = send; Logger.error(`DWN records send failed`, send); throw new DwnError(code, detail); } Logger.debug(`Sent application response to applicant DWN`, send, create); return { status: send }; } /** * * Checks the state of the password and recovery phrase * * @param firstLaunch A boolean indicating if this is the first launch of the agent * @returns { password: string, recoveryPhrase?: string } * @throws DcxIssuerError * */ public async checkWeb5Config( firstLaunch: boolean ): Promise<{ password: string; recoveryPhrase?: string }> { const web5Password = this.config.web5Password; const web5RecoveryPhrase = this.config.web5RecoveryPhrase; if (firstLaunch && !(web5Password && web5RecoveryPhrase)) { Logger.security( 'WEB5_PASSWORD and WEB5_RECOVERY_PHRASE not found on first launch! ' + 'New WEB5_PASSWORD saved to issuer.password.key file. ' + 'New WEB5_RECOVERY_PHRASE saved to issuer.recovery.key file.', ); const password = Mnemonic.createPassword(); await FileSystem.overwrite('issuer.password.key', password); const recoveryPhrase = Mnemonic.createRecoveryPhrase(); await FileSystem.overwrite('issuer.recovery.key', recoveryPhrase); this.config.web5Password = password; this.config.web5RecoveryPhrase = recoveryPhrase; return { password, recoveryPhrase }; } if (firstLaunch && !web5Password && web5RecoveryPhrase) { throw new DcxIssuerError( 'WEB5_RECOVERY_PHRASE found without WEB5_PASSWORD on first launch! ' + 'WEB5_PASSWORD is required to unlock the vault recovered by WEB5_RECOVERY_PHRASE. ' + 'Please set WEB5_PASSWORD and WEB5_RECOVERY_PHRASE in .env file.', ); } if (!firstLaunch && !(web5Password && web5RecoveryPhrase)) { throw new DcxIssuerError( 'WEB5_PASSWORD and WEB5_RECOVERY_PHRASE not found on non-first launch! ' + 'Either set both WEB5_PASSWORD and WEB5_RECOVERY_PHRASE in .env file or delete the local DATA folder ' + 'to create a new password and recovery phrase.', ); } if (!firstLaunch && !web5Password && web5RecoveryPhrase) { throw new DcxIssuerError( 'WEB5_RECOVERY_PHRASE found without WEB5_PASSWORD on non-first launch! ' + 'Either set both WEB5_PASSWORD and WEB5_RECOVERY_PHRASE in .env file or delete the local DATA folder ' + 'to create a new recovery phrase with the given password.', ); } if (!firstLaunch && web5Password && !web5RecoveryPhrase) { Logger.warn( 'WEB5_PASSWORD found without WEB5_RECOVERY_PHRASE on non-first launch! ' + 'Attempting to unlock the vault with WEB5_PASSWORD.', ); return { password: web5Password }; } return { password : web5Password, recoveryPhrase : web5RecoveryPhrase, }; } /** * * Configures the DCX server by creating a new password, initializing Web5, * connecting to the remote DWN and configuring the DWN with the DCX issuer protocol * */ public async initializeWeb5(): Promise<void> { Logger.log('Initializing Web5 for DcxIssuer ... '); // Create a new DcxIdentityVault instance const agentVault = new DcxIdentityVault(); const dataPath = this.config.agentDataPath; // Create a new DcxAgent instance const agent = await DcxAgent.create({ agentVault, dataPath }); // Check if this is the first launch of the agent const firstLaunch = await agent.firstLaunch(); // TODO: consider checking if vault is locked // const isLocked = agent.vault.isLocked(); // Check the state of the password and recovery phrase const { password, recoveryPhrase } = await this.checkWeb5Config(firstLaunch); // Toggle the initialization options based on the presence of a recovery phrase const dwnEndpoints = this.options.dwns!; const initializeParams = !recoveryPhrase ? { password, dwnEndpoints } : { password, dwnEndpoints, recoveryPhrase }; // Initialize the agent with the options // TODO: rethink how im doing this if (firstLaunch) { await agent.initialize(initializeParams); } // Start the agent and create a new Web5 instance await agent.start({ password }); // Initialize the Web5 instance const web5 = new Web5({ agent, connectedDid: agent.agentDid.uri }); // Set the DcxManager properties DcxIssuer.web5 = web5; DcxIssuer.agent = agent; DcxIssuer.agentVault = agentVault; // Set the server initialized flag this.isInitialized = true; } /** * Setup DWN with credential-issuer protocol and manifest records * @returns boolean indicating success or failure */ public async setupDwn(): Promise<void> { Logger.log('Setting up dcx issuer dwn ...'); try { // Query DWN for credential-issuer protocols const { protocols } = await this.queryProtocols(); Logger.log(`Found ${protocols.length} dcx issuer protocol in dwn`, protocols); // Configure DWN with credential-issuer protocol if not found if (!protocols.length) { Logger.log('Configuring dcx issuer protocol in dwn ...'); const { status, protocol } = await this.configureProtocols(); Logger.debug(`Dcx issuer protocol configured: ${status.code} - ${status.detail}`, protocol); } // Query DWN for manifest records const { records: query } = await this.queryRecords(); Logger.log(`Found ${query.length} manifest records in dcx issuer dwn`); // Read manifest records data const { records: manifests } = await this.readRecords({ records: query }); Logger.debug(`Read ${manifests.length} manifest records`, manifests); if (!manifests.length) { // Create missing manifest records const { records } = await this.createRecords({ records: this.options.manifests }); Logger.log(`Created ${records.length} manifest records in dcx issuer dwn`, records); } else { // Filter and create missing manifest records const { records } = await this.filterRecords({ records: manifests }); Logger.debug(`Found ${records.length} unwritten manifests`); const { records: create } = await this.createRecords({ records }); Logger.log(`Created ${create.length} records`, create); } Logger.log('Dcx Issuer DWN Setup Complete!'); this.isSetup = true; } catch (error: any) { Logger.error('DWN Setup Failed!', error); throw error; } } }