@dcxp/root
Version:
DCX: Decentralized Credential Exchange. DWN protocol for verifiable credential exchange.
614 lines (528 loc) • 20.8 kB
text/typescript
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;
}
}
}