diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..5bd3fd7 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,87 @@ +

DEMO

+ +This is the Credo demo. Walk through the Credo flow yourself together with agents Alice and Faber. + +Alice, a former student of Faber College, connects with the College, is issued a credential about her degree and then is asked by the College for a proof. + +## Features + +- ✅ Creating a connection +- ✅ Offering a credential +- ✅ Requesting a proof +- ✅ Sending basic messages + +## Getting Started + +### Platform Specific Setup + +In order to run the Credo demo, you need to make sure you have Node.JS and PNPM installed. See the [Credo Prerequisites](https://credo.js.org/guides/getting-started/prerequisites) for more information. + +### Run the demo + +These are the steps for running the Credo demo: + +Clone the Credo git repository: + +```sh +git clone https://github.com/openwallet-foundation/credo-ts.git +``` + +Open two different terminals next to each other and in both, go to the demo folder: + +```sh +cd credo-ts/demo +``` + +Install the project in one of the terminals: + +```sh +pnpm install +``` + +In the left terminal run Alice: + +```sh +pnpm alice +``` + +In the right terminal run Faber: + +```sh +pnpm faber +``` + +### Usage + +To set up a connection: + +- Select 'receive connection invitation' in Alice and 'create connection invitation' in Faber +- Faber will print a invitation link which you then copy and paste to Alice +- You have now set up a connection! + +To offer a credential: + +- Select 'offer credential' in Faber +- Faber will start with registering a schema and the credential definition accordingly +- You have now send a credential offer to Alice! +- Go to Alice to accept the incoming credential offer by selecting 'yes'. + +To request a proof: + +- Select 'request proof' in Faber +- Faber will create a new proof attribute and will then send a proof request to Alice! +- Go to Alice to accept the incoming proof request + +To send a basic message: + +- Select 'send message' in either one of the Agents +- Type your message and press enter +- Message sent! + +Exit: + +- Select 'exit' to shutdown the agent. + +Restart: + +- Select 'restart', to shutdown the current agent and start a new one diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..e9a36cd --- /dev/null +++ b/demo/package.json @@ -0,0 +1,36 @@ +{ + "name": "credo-demo", + "version": "1.0.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/openwallet-foundation/credo-ts", + "directory": "demo/" + }, + "license": "Apache-2.0", + "scripts": { + "holder": "ts-node src/HolderInquirer.ts", + "issuer": "ts-node src/IssuerInquirer.ts", + "index": "ts-node src/Index.ts" + }, + "dependencies": { + "@hyperledger/indy-vdr-nodejs": "^0.2.2", + "@hyperledger/anoncreds-nodejs": "^0.2.2", + "@openwallet-foundation/askar-nodejs": "^0.3.1", + "inquirer": "^8.2.5" + }, + "devDependencies": { + "@credo-ts/anoncreds": "workspace:*", + "@credo-ts/askar": "workspace:*", + "@credo-ts/core": "workspace:*", + "@credo-ts/didcomm": "workspace:*", + "@credo-ts/indy-vdr": "workspace:*", + "@credo-ts/cheqd": "workspace:*", + "@credo-ts/node": "workspace:*", + "@types/figlet": "^1.5.4", + "@types/inquirer": "^8.2.6", + "clear": "^0.1.0", + "figlet": "^1.5.2", + "ts-node": "^10.9.2" + } +} diff --git a/demo/src/BaseAgent.ts b/demo/src/BaseAgent.ts new file mode 100644 index 0000000..297b77b --- /dev/null +++ b/demo/src/BaseAgent.ts @@ -0,0 +1,151 @@ +import type { InitConfig } from '@credo-ts/core' +import type { DidCommModuleConfigOptions } from '@credo-ts/didcomm' +import type { IndyVdrPoolConfig } from '@credo-ts/indy-vdr' + +import { + AnonCredsCredentialFormatService, + AnonCredsModule, + AnonCredsProofFormatService, + LegacyIndyCredentialFormatService, + LegacyIndyProofFormatService, + V1CredentialProtocol, + V1ProofProtocol, +} from '@credo-ts/anoncreds' +import { AskarModule } from '@credo-ts/askar' +import { + CheqdAnonCredsRegistry, + CheqdDidRegistrar, + CheqdDidResolver, + CheqdModule, + CheqdModuleConfig, +} from '@credo-ts/cheqd' +import { DidsModule, Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { + ConnectionsModule, + V2ProofProtocol, + V2CredentialProtocol, + ProofsModule, + AutoAcceptProof, + AutoAcceptCredential, + CredentialsModule, + HttpOutboundTransport, + getDefaultDidcommModules, +} from '@credo-ts/didcomm' +import { IndyVdrIndyDidResolver, IndyVdrAnonCredsRegistry, IndyVdrModule } from '@credo-ts/indy-vdr' +import { agentDependencies, HttpInboundTransport } from '@credo-ts/node' +import { anoncreds } from '@hyperledger/anoncreds-nodejs' +import { indyVdr } from '@hyperledger/indy-vdr-nodejs' +import { askar } from '@openwallet-foundation/askar-nodejs' + +import { greenText } from './OutputClass' + +const bcovrin = `{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node1","blskey":"4N8aUNHSgjQVgkpm8nhNEfDf6txHznoYREg9kirmJrkivgL4oSEimFF6nsQ6M41QvhM2Z33nves5vfSn9n1UwNFJBYtWVnHYMATn76vLuL3zU88KyeAYcHfsih3He6UHcXDxcaecHVz6jhCYz1P2UZn2bDVruL5wXpehgBfBaLKm3Ba","blskey_pop":"RahHYiCvoNCtPTrVtP7nMC5eTYrsUA8WjXbdhNc8debh1agE9bGiJxWBXYNFbnJXoXhWFMvyqhqhRoq737YQemH5ik9oL7R4NTTCz2LEZhkgLJzB3QRQqJyBNyv7acbdHrAT8nQ9UkLbaVL9NBpnWXBTw4LEMePaSHEw66RzPNdAX1","client_ip":"10.133.133.1","client_port":9702,"node_ip":"10.133.133.1","node_port":9701,"services":["VALIDATOR"]},"dest":"Gw6pDLhcBcoQesN72qfotTgFa7cbuqZpkX3Xo6pLhPhv"},"metadata":{"from":"Th7MpTaRZVRYnPiabds81Y"},"type":"0"},"txnMetadata":{"seqNo":1,"txnId":"fea82e10e894419fe2bea7d96296a6d46f50f93f9eeda954ec461b2ed2950b62"},"ver":"1"} +{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node2","blskey":"37rAPpXVoxzKhz7d9gkUe52XuXryuLXoM6P6LbWDB7LSbG62Lsb33sfG7zqS8TK1MXwuCHj1FKNzVpsnafmqLG1vXN88rt38mNFs9TENzm4QHdBzsvCuoBnPH7rpYYDo9DZNJePaDvRvqJKByCabubJz3XXKbEeshzpz4Ma5QYpJqjk","blskey_pop":"Qr658mWZ2YC8JXGXwMDQTzuZCWF7NK9EwxphGmcBvCh6ybUuLxbG65nsX4JvD4SPNtkJ2w9ug1yLTj6fgmuDg41TgECXjLCij3RMsV8CwewBVgVN67wsA45DFWvqvLtu4rjNnE9JbdFTc1Z4WCPA3Xan44K1HoHAq9EVeaRYs8zoF5","client_ip":"10.133.133.2","client_port":9704,"node_ip":"10.133.133.2","node_port":9703,"services":["VALIDATOR"]},"dest":"8ECVSk179mjsjKRLWiQtssMLgp6EPhWXtaYyStWPSGAb"},"metadata":{"from":"EbP4aYNeTHL6q385GuVpRV"},"type":"0"},"txnMetadata":{"seqNo":2,"txnId":"1ac8aece2a18ced660fef8694b61aac3af08ba875ce3026a160acbc3a3af35fc"},"ver":"1"} +{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node3","blskey":"3WFpdbg7C5cnLYZwFZevJqhubkFALBfCBBok15GdrKMUhUjGsk3jV6QKj6MZgEubF7oqCafxNdkm7eswgA4sdKTRc82tLGzZBd6vNqU8dupzup6uYUf32KTHTPQbuUM8Yk4QFXjEf2Usu2TJcNkdgpyeUSX42u5LqdDDpNSWUK5deC5","blskey_pop":"QwDeb2CkNSx6r8QC8vGQK3GRv7Yndn84TGNijX8YXHPiagXajyfTjoR87rXUu4G4QLk2cF8NNyqWiYMus1623dELWwx57rLCFqGh7N4ZRbGDRP4fnVcaKg1BcUxQ866Ven4gw8y4N56S5HzxXNBZtLYmhGHvDtk6PFkFwCvxYrNYjh","client_ip":"10.133.133.3","client_port":9706,"node_ip":"10.133.133.3","node_port":9705,"services":["VALIDATOR"]},"dest":"DKVxG2fXXTU8yT5N7hGEbXB3dfdAnYv1JczDUHpmDxya"},"metadata":{"from":"4cU41vWW82ArfxJxHkzXPG"},"type":"0"},"txnMetadata":{"seqNo":3,"txnId":"7e9f355dffa78ed24668f0e0e369fd8c224076571c51e2ea8be5f26479edebe4"},"ver":"1"} +{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node4","blskey":"2zN3bHM1m4rLz54MJHYSwvqzPchYp8jkHswveCLAEJVcX6Mm1wHQD1SkPYMzUDTZvWvhuE6VNAkK3KxVeEmsanSmvjVkReDeBEMxeDaayjcZjFGPydyey1qxBHmTvAnBKoPydvuTAqx5f7YNNRAdeLmUi99gERUU7TD8KfAa6MpQ9bw","blskey_pop":"RPLagxaR5xdimFzwmzYnz4ZhWtYQEj8iR5ZU53T2gitPCyCHQneUn2Huc4oeLd2B2HzkGnjAff4hWTJT6C7qHYB1Mv2wU5iHHGFWkhnTX9WsEAbunJCV2qcaXScKj4tTfvdDKfLiVuU2av6hbsMztirRze7LvYBkRHV3tGwyCptsrP","client_ip":"10.133.133.4","client_port":9708,"node_ip":"10.133.133.4","node_port":9707,"services":["VALIDATOR"]},"dest":"4PS3EDQ3dW1tci1Bp6543CfuuebjFrg36kLAUcskGfaA"},"metadata":{"from":"TWwCRQRZ2ZHMJFn9TzLp7W"},"type":"0"},"txnMetadata":{"seqNo":4,"txnId":"aa5e817d7cc626170eca175822029339a444eb0ee8f0bd20d3b0b76e566fb008"},"ver":"1"}` + +export const indyNetworkConfig = { + genesisTransactions: bcovrin, + indyNamespace: 'bcovrin:test', + isProduction: false, + connectOnStartup: true, +} satisfies IndyVdrPoolConfig + +type DemoAgent = Agent> + +export class BaseAgent { + public port: number + public name: string + public config: InitConfig + public agent: DemoAgent + + public constructor({ port, name }: { port: number; name: string }) { + this.name = name + this.port = port + + const config = { + label: name, + walletConfig: { + id: name, + key: name, + }, + //logger: new ConsoleLogger(LogLevel.debug), + } satisfies InitConfig + + this.config = config + + this.agent = new Agent({ + config, + dependencies: agentDependencies, + modules: getAskarAnonCredsIndyModules({ endpoints: [`http://localhost:${this.port}`] }), + }) + this.agent.modules.didcomm.registerInboundTransport(new HttpInboundTransport({ port })) + this.agent.modules.didcomm.registerOutboundTransport(new HttpOutboundTransport()) + } + + public async initializeAgent() { + await this.agent.initialize() + + console.log(greenText(`\nAgent ${this.name} created!\n`)) + } +} + +function getAskarAnonCredsIndyModules(didcommConfig: DidCommModuleConfigOptions) { + const legacyIndyCredentialFormatService = new LegacyIndyCredentialFormatService() + const legacyIndyProofFormatService = new LegacyIndyProofFormatService() + + return { + ...getDefaultDidcommModules(didcommConfig), + connections: new ConnectionsModule({ + autoAcceptConnections: true, + }), + credentials: new CredentialsModule({ + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, + credentialProtocols: [ + new V1CredentialProtocol({ + indyCredentialFormat: legacyIndyCredentialFormatService, + }), + new V2CredentialProtocol({ + credentialFormats: [legacyIndyCredentialFormatService, new AnonCredsCredentialFormatService()], + }), + ], + }), + proofs: new ProofsModule({ + autoAcceptProofs: AutoAcceptProof.ContentApproved, + proofProtocols: [ + new V1ProofProtocol({ + indyProofFormat: legacyIndyProofFormatService, + }), + new V2ProofProtocol({ + proofFormats: [legacyIndyProofFormatService, new AnonCredsProofFormatService()], + }), + ], + }), + anoncreds: new AnonCredsModule({ + registries: [new IndyVdrAnonCredsRegistry(), new CheqdAnonCredsRegistry()], + anoncreds, + }), + indyVdr: new IndyVdrModule({ + indyVdr, + networks: [indyNetworkConfig], + }), + cheqd: new CheqdModule( + new CheqdModuleConfig({ + networks: [ + { + network: 'testnet', + cosmosPayerSeed: + 'robust across amount corn curve panther opera wish toe ring bleak empower wreck party abstract glad average muffin picnic jar squeeze annual long aunt', + }, + ], + }) + ), + dids: new DidsModule({ + resolvers: [new IndyVdrIndyDidResolver(), new CheqdDidResolver()], + registrars: [new CheqdDidRegistrar()], + }), + askar: new AskarModule({ + askar, + }), + } as const +} diff --git a/demo/src/BaseInquirer.ts b/demo/src/BaseInquirer.ts new file mode 100644 index 0000000..358d72b --- /dev/null +++ b/demo/src/BaseInquirer.ts @@ -0,0 +1,55 @@ +import { prompt } from 'inquirer' + +import { Title } from './OutputClass' + +export enum ConfirmOptions { + Yes = 'yes', + No = 'no', +} + +export class BaseInquirer { + public optionsInquirer: { type: string; prefix: string; name: string; message: string; choices: string[] } + public inputInquirer: { type: string; prefix: string; name: string; message: string; choices: string[] } + + public constructor() { + this.optionsInquirer = { + type: 'list', + prefix: '', + name: 'options', + message: '', + choices: [], + } + + this.inputInquirer = { + type: 'input', + prefix: '', + name: 'input', + message: '', + choices: [], + } + } + + public inquireOptions(promptOptions: string[]) { + this.optionsInquirer.message = Title.OptionsTitle + this.optionsInquirer.choices = promptOptions + return this.optionsInquirer + } + + public inquireInput(title: string) { + this.inputInquirer.message = title + return this.inputInquirer + } + + public inquireConfirmation(title: string) { + this.optionsInquirer.message = title + this.optionsInquirer.choices = [ConfirmOptions.Yes, ConfirmOptions.No] + return this.optionsInquirer + } + + public async inquireMessage() { + this.inputInquirer.message = Title.MessageTitle + const message = await prompt([this.inputInquirer]) + + return message.input[0] === 'q' ? null : message.input + } +} diff --git a/demo/src/Holder.ts b/demo/src/Holder.ts new file mode 100644 index 0000000..a84d7a1 --- /dev/null +++ b/demo/src/Holder.ts @@ -0,0 +1,83 @@ +import type { ConnectionRecord, CredentialExchangeRecord, ProofExchangeRecord } from '@credo-ts/didcomm' + +import { BaseAgent } from './BaseAgent' +import { greenText, Output, redText } from './OutputClass' + +export class Holder extends BaseAgent { + public connected: boolean + public connectionRecordFaberId?: string + + public constructor(port: number, name: string) { + super({ port, name }) + this.connected = false + } + + public static async build(): Promise { + const holder = new Holder(9004, 'holder') + await holder.initializeAgent() + return holder + } + + private async getConnectionRecord() { + if (!this.connectionRecordFaberId) { + throw Error(redText(Output.MissingConnectionRecord)) + } + return await this.agent.modules.connections.getById(this.connectionRecordFaberId) + } + + private async receiveConnectionRequest(invitationUrl: string) { + const { connectionRecord } = await this.agent.modules.oob.receiveInvitationFromUrl(invitationUrl) + if (!connectionRecord) { + throw new Error(redText(Output.NoConnectionRecordFromOutOfBand)) + } + return connectionRecord + } + + private async waitForConnection(connectionRecord: ConnectionRecord) { + connectionRecord = await this.agent.modules.connections.returnWhenIsConnected(connectionRecord.id) + this.connected = true + console.log("[HOLDER:]" + greenText(Output.ConnectionEstablished)) + return connectionRecord.id + } + + public async acceptConnection(invitation_url: string) { + const connectionRecord = await this.receiveConnectionRequest(invitation_url) + this.connectionRecordFaberId = await this.waitForConnection(connectionRecord) + } + + public async acceptCredentialOffer(credentialRecord: CredentialExchangeRecord) { + await this.agent.modules.credentials.acceptOffer({ + credentialRecordId: credentialRecord.id, + }) + + let [ credentials ] = await this.agent.modules.credentials.getAll(); + console.log(credentials); + } + + public async acceptProofRequest(proofRecord: ProofExchangeRecord) { + const requestedCredentials = await this.agent.modules.proofs.selectCredentialsForRequest({ + proofRecordId: proofRecord.id, + }) + + await this.agent.modules.proofs.acceptRequest({ + proofRecordId: proofRecord.id, + proofFormats: requestedCredentials.proofFormats, + }) + console.log(greenText('\nProof request accepted!\n')) + } + + public async sendMessage(message: string) { + const connectionRecord = await this.getConnectionRecord() + await this.agent.modules.basicMessages.sendMessage(connectionRecord.id, message) + } + + public async exit() { + console.log(Output.Exit) + await this.agent.shutdown() + process.exit(0) + } + + public async restart() { + await this.agent.shutdown() + } +} diff --git a/demo/src/HolderInquirer.ts b/demo/src/HolderInquirer.ts new file mode 100644 index 0000000..47add61 --- /dev/null +++ b/demo/src/HolderInquirer.ts @@ -0,0 +1,128 @@ +import type { CredentialExchangeRecord, ProofExchangeRecord } from '@credo-ts/didcomm' + +import { clear } from 'console' +import { textSync } from 'figlet' +import { prompt } from 'inquirer' + +import { Holder } from './Holder' +import { BaseInquirer, ConfirmOptions } from './BaseInquirer' +import { Listener } from './Listener' +import { Title } from './OutputClass' + +export const runHolder = async () => { + clear() + console.log(textSync('Holder', { horizontalLayout: 'full' })) + const holder = await HolderInquirer.build() + await holder.processAnswer() +} + +enum PromptOptions { + ReceiveConnectionUrl = 'Receive connection invitation', + SendMessage = 'Send message', + Exit = 'Exit', + Restart = 'Restart', +} + +export class HolderInquirer extends BaseInquirer { + public holder: Holder + public promptOptionsString: string[] + public listener: Listener + + public constructor(holder: Holder) { + super() + this.holder = holder + this.listener = new Listener() + this.promptOptionsString = Object.values(PromptOptions) + this.listener.messageListener(this.holder.agent, this.holder.name) + } + + public static async build(): Promise { + const holder = await Holder.build() + return new HolderInquirer(holder) + } + + private async getPromptChoice() { + if (this.holder.connectionRecordFaberId) return prompt([this.inquireOptions(this.promptOptionsString)]) + + const reducedOption = [PromptOptions.ReceiveConnectionUrl, PromptOptions.Exit, PromptOptions.Restart] + return prompt([this.inquireOptions(reducedOption)]) + } + + public async processAnswer() { + const choice = await this.getPromptChoice() + if (this.listener.on) return + + switch (choice.options) { + case PromptOptions.ReceiveConnectionUrl: + await this.connection() + break + case PromptOptions.SendMessage: + await this.message() + break + case PromptOptions.Exit: + await this.exit() + break + case PromptOptions.Restart: + await this.restart() + return + } + await this.processAnswer() + } + + public async acceptCredentialOffer(credentialRecord: CredentialExchangeRecord) { + const confirm = await prompt([this.inquireConfirmation(Title.CredentialOfferTitle)]) + if (confirm.options === ConfirmOptions.No) { + await this.holder.agent.modules.credentials.declineOffer(credentialRecord.id) + } else if (confirm.options === ConfirmOptions.Yes) { + await this.holder.acceptCredentialOffer(credentialRecord) + } + } + + public async acceptProofRequest(proofRecord: ProofExchangeRecord) { + const confirm = await prompt([this.inquireConfirmation(Title.ProofRequestTitle)]) + if (confirm.options === ConfirmOptions.No) { + await this.holder.agent.modules.proofs.declineRequest({ proofRecordId: proofRecord.id }) + } else if (confirm.options === ConfirmOptions.Yes) { + await this.holder.acceptProofRequest(proofRecord) + } + } + + public async connection() { + const title = Title.InvitationTitle + const getUrl = await prompt([this.inquireInput(title)]) + await this.holder.acceptConnection(getUrl.input) + if (!this.holder.connected) return + + this.listener.credentialOfferListener(this.holder, this) + this.listener.proofRequestListener(this.holder, this) + } + + public async message() { + const message = await this.inquireMessage() + if (!message) return + + await this.holder.sendMessage(message) + } + + public async exit() { + const confirm = await prompt([this.inquireConfirmation(Title.ConfirmTitle)]) + if (confirm.options === ConfirmOptions.No) { + return + } else if (confirm.options === ConfirmOptions.Yes) { + await this.holder.exit() + } + } + + public async restart() { + const confirm = await prompt([this.inquireConfirmation(Title.ConfirmTitle)]) + if (confirm.options === ConfirmOptions.No) { + await this.processAnswer() + return + } else if (confirm.options === ConfirmOptions.Yes) { + await this.holder.restart() + await runHolder() + } + } +} + +void runHolder() diff --git a/demo/src/Index.ts b/demo/src/Index.ts new file mode 100644 index 0000000..079a7b0 --- /dev/null +++ b/demo/src/Index.ts @@ -0,0 +1,76 @@ +import { Issuer, RegistryOptions } from './Issuer' +import { Holder } from './Holder' +import { Listener } from './Listener' +import { CredentialExchangeRecord } from '@credo-ts/didcomm' + +function delay(ms: number) { + return new Promise( resolve => setTimeout(resolve, ms) ); +} + +export const runIndex = async () => { + const index = await IndexThing.build() + index.process() +} + +export class IndexThing { + public issuer: Issuer; + public holder: Holder; + public listener: Listener; + + public constructor(issuer: Issuer, holder: Holder) + { + this.issuer = issuer; + this.holder = holder; + this.listener = new Listener() + } + + public static async build(): Promise + { + const issuer = await Issuer.build() + const holder = await Holder.build() + return new IndexThing(issuer, holder) + } + + + public async process() + { + let url = await this.issuer.createConnectionInvite() + console.log(url); + + [await this.holderConnection(url), await this.issuer.waitForConnection()]; + //this.issuer.waitForConnection() + //this.holderConnection(url) + + await this.issuer.importDid(RegistryOptions.indy) + await this.credential() + + } + + public async issuerWaitConnection() + { + await this.issuer.waitForConnection() + } + + public async credential() + { + await this.issuer.importDid(RegistryOptions.indy) + await this.issuer.issueCredential() + } + + public async acceptCredentialOffer(credentialRecord: CredentialExchangeRecord) + { + await delay(10000); + await this.holder.acceptCredentialOffer(credentialRecord) + } + + public async holderConnection(url: string) + { + await delay(10000); + await this.holder.acceptConnection(url); + if (!this.holder.connected) return + + this.listener.credentialOfferListener(this.holder, this) + } +} + +runIndex() \ No newline at end of file diff --git a/demo/src/Issuer.ts b/demo/src/Issuer.ts new file mode 100644 index 0000000..a63f7ad --- /dev/null +++ b/demo/src/Issuer.ts @@ -0,0 +1,297 @@ +import type { RegisterCredentialDefinitionReturnStateFinished } from '@credo-ts/anoncreds' +import type { ConnectionRecord, ConnectionStateChangedEvent } from '@credo-ts/didcomm' +import type { IndyVdrRegisterSchemaOptions, IndyVdrRegisterCredentialDefinitionOptions } from '@credo-ts/indy-vdr' +import type BottomBar from 'inquirer/lib/ui/bottom-bar' + +import { KeyType, TypedArrayEncoder, utils } from '@credo-ts/core' +import { ConnectionEventTypes } from '@credo-ts/didcomm' +import { ui } from 'inquirer' + +import { BaseAgent, indyNetworkConfig } from './BaseAgent' +import { Color, Output, greenText, purpleText, redText } from './OutputClass' + +export enum RegistryOptions { + indy = 'did:indy', + cheqd = 'did:cheqd', +} + +export class Issuer extends BaseAgent { + public outOfBandId?: string + public credentialDefinition?: RegisterCredentialDefinitionReturnStateFinished + public anonCredsIssuerId?: string + public ui: BottomBar + + public constructor(port: number, name: string) { + super({ port, name }) + this.ui = new ui.BottomBar() + } + + public static async build(): Promise { + const issuer = new Issuer(9005, 'issuer') + await issuer.initializeAgent() + return issuer + } + + public async importDid(registry: string) { + // NOTE: we assume the did is already registered on the ledger, we just store the private key in the wallet + // and store the existing did in the wallet + // indy did is based on private key (seed) + const unqualifiedIndyDid = '2jEvRuKmfBJTRa7QowDpNN' + const cheqdDid = 'did:cheqd:testnet:d37eba59-513d-42d3-8f9f-d1df0548b675' + const indyDid = `did:indy:${indyNetworkConfig.indyNamespace}:${unqualifiedIndyDid}` + + const did = registry === RegistryOptions.indy ? indyDid : cheqdDid + await this.agent.dids.import({ + did, + overwrite: true, + privateKeys: [ + { + keyType: KeyType.Ed25519, + privateKey: TypedArrayEncoder.fromString('afjdemoverysercure00000000000000'), + }, + ], + }) + this.anonCredsIssuerId = did + } + + private async getConnectionRecord() { + if (!this.outOfBandId) { + throw Error(redText(Output.MissingConnectionRecord)) + } + + const [connection] = await this.agent.modules.connections.findAllByOutOfBandId(this.outOfBandId) + + if (!connection) { + throw Error(redText(Output.MissingConnectionRecord)) + } + + return connection + } + + private async printConnectionInvite() { + const outOfBand = await this.agent.modules.oob.createInvitation() + this.outOfBandId = outOfBand.id + + console.log( + Output.ConnectionLink, + outOfBand.outOfBandInvitation.toUrl({ domain: `http://localhost:${this.port}` }), + '\n' + ) + } + + public async createConnectionInvite() { + const outOfBand = await this.agent.modules.oob.createInvitation() + this.outOfBandId = outOfBand.id + + return outOfBand.outOfBandInvitation.toUrl({ domain: `http://localhost:${this.port}` }) + } + + public async waitForConnection() { + if (!this.outOfBandId) { + throw new Error(redText(Output.MissingConnectionRecord)) + } + + console.log('Waiting for Alice to finish connection...') + + const getConnectionRecord = (outOfBandId: string) => + new Promise((resolve, reject) => { + // Timeout of 20 seconds + const timeoutId = setTimeout(() => reject(new Error(redText(Output.MissingConnectionRecord))), 20000) + console.log("son ofa bitch") + + // Start listener + this.agent.events.on(ConnectionEventTypes.ConnectionStateChanged, (e) => { + if (e.payload.connectionRecord.outOfBandId !== outOfBandId) return + + clearTimeout(timeoutId) + resolve(e.payload.connectionRecord) + }) + + // Also retrieve the connection record by invitation if the event has already fired + void this.agent.modules.connections.findAllByOutOfBandId(outOfBandId).then(([connectionRecord]) => { + if (connectionRecord) { + clearTimeout(timeoutId) + resolve(connectionRecord) + } + }) + }) + + const connectionRecord = await getConnectionRecord(this.outOfBandId) + + try { + await this.agent.modules.connections.returnWhenIsConnected(connectionRecord.id) + } catch (e) { + console.log(redText(`\nTimeout of 20 seconds reached.. Returning to home screen.\n`)) + return + } + console.log("deez") + console.log(greenText(Output.ConnectionEstablished)) + } + + public async setupConnection() { + // await this.printConnectionInvite() + await this.waitForConnection() + } + + private printSchema(name: string, version: string, attributes: string[]) { + console.log(`\n\ndidcommThe credential definition will look like this:\n`) + console.log(purpleText(`Name: ${Color.Reset}${name}`)) + console.log(purpleText(`Version: ${Color.Reset}${version}`)) + console.log(purpleText(`Attributes: ${Color.Reset}${attributes[0]}, ${attributes[1]}, ${attributes[2]}\n`)) + } + + private async registerSchema() { + if (!this.anonCredsIssuerId) { + throw new Error(redText('Missing anoncreds issuerId')) + } + const schemaTemplate = { + name: 'Issuer College' + utils.uuid(), + version: '1.0.0', + attrNames: ['name', 'degree', 'date'], + issuerId: this.anonCredsIssuerId, + } + this.printSchema(schemaTemplate.name, schemaTemplate.version, schemaTemplate.attrNames) + this.ui.updateBottomBar(greenText('\nRegistering schema...\n', false)) + + const { schemaState } = await this.agent.modules.anoncreds.registerSchema({ + schema: schemaTemplate, + options: { + endorserMode: 'internal', + endorserDid: this.anonCredsIssuerId, + }, + }) + + if (schemaState.state !== 'finished') { + throw new Error( + `Error registering schema: ${schemaState.state === 'failed' ? schemaState.reason : 'Not Finished'}` + ) + } + this.ui.updateBottomBar('\nSchema registered!\n') + return schemaState + } + + private async registerCredentialDefinition(schemaId: string) { + if (!this.anonCredsIssuerId) { + throw new Error(redText('Missing anoncreds issuerId')) + } + + this.ui.updateBottomBar('\nRegistering credential definition...\n') + const { credentialDefinitionState } = + await this.agent.modules.anoncreds.registerCredentialDefinition({ + credentialDefinition: { + schemaId, + issuerId: this.anonCredsIssuerId, + tag: 'latest', + }, + options: { + supportRevocation: false, + endorserMode: 'internal', + endorserDid: this.anonCredsIssuerId, + }, + }) + + if (credentialDefinitionState.state !== 'finished') { + throw new Error( + `Error registering credential definition: ${ + credentialDefinitionState.state === 'failed' ? credentialDefinitionState.reason : 'Not Finished' + }}` + ) + } + + this.credentialDefinition = credentialDefinitionState + this.ui.updateBottomBar('\nCredential definition registered!!\n') + return this.credentialDefinition + } + + public async issueCredential() { + const schema = await this.registerSchema() + const credentialDefinition = await this.registerCredentialDefinition(schema.schemaId) + const connectionRecord = await this.getConnectionRecord() + + this.ui.updateBottomBar('\nSending credential offer...\n') + + await this.agent.modules.credentials.offerCredential({ + connectionId: connectionRecord.id, + protocolVersion: 'v2', + credentialFormats: { + anoncreds: { + attributes: [ + { + name: 'name', + value: 'Alice Smith', + }, + { + name: 'degree', + value: 'Computer Science', + }, + { + name: 'date', + value: '01/01/2022', + }, + ], + credentialDefinitionId: credentialDefinition.credentialDefinitionId, + }, + }, + }) + this.ui.updateBottomBar( + `\nCredential offer sent!\n\nGo to the Alice agent to accept the credential offer\n\n${Color.Reset}` + ) + } + + private async printProofFlow(print: string) { + this.ui.updateBottomBar(print) + await new Promise((f) => setTimeout(f, 2000)) + } + + private async newProofAttribute() { + await this.printProofFlow(greenText(`Creating new proof attribute for 'name' ...\n`)) + const proofAttribute = { + name: { + name: 'name', + restrictions: [ + { + cred_def_id: this.credentialDefinition?.credentialDefinitionId, + }, + ], + }, + } + + return proofAttribute + } + + public async sendProofRequest() { + const connectionRecord = await this.getConnectionRecord() + const proofAttribute = await this.newProofAttribute() + await this.printProofFlow(greenText('\nRequesting proof...\n', false)) + + await this.agent.modules.proofs.requestProof({ + protocolVersion: 'v2', + connectionId: connectionRecord.id, + proofFormats: { + anoncreds: { + name: 'proof-request', + version: '1.0', + requested_attributes: proofAttribute, + }, + }, + }) + this.ui.updateBottomBar( + `\nProof request sent!\n\nGo to the Alice agent to accept the proof request\n\n${Color.Reset}` + ) + } + + public async sendMessage(message: string) { + const connectionRecord = await this.getConnectionRecord() + await this.agent.modules.basicMessages.sendMessage(connectionRecord.id, message) + } + + public async exit() { + console.log(Output.Exit) + await this.agent.shutdown() + process.exit(0) + } + + public async restart() { + await this.agent.shutdown() + } +} diff --git a/demo/src/IssuerInquirer.ts b/demo/src/IssuerInquirer.ts new file mode 100644 index 0000000..324cd9b --- /dev/null +++ b/demo/src/IssuerInquirer.ts @@ -0,0 +1,133 @@ +import { clear } from 'console' +import { textSync } from 'figlet' +import { prompt } from 'inquirer' + +import { BaseInquirer, ConfirmOptions } from './BaseInquirer' +import { Issuer, RegistryOptions } from './Issuer' +import { Listener } from './Listener' +import { Title } from './OutputClass' + +export const runIssuer = async () => { + clear() + console.log(textSync('Issuer', { horizontalLayout: 'full' })) + const issuer = await IssuerInquirer.build() + await issuer.processAnswer() +} + +enum PromptOptions { + CreateConnection = 'Create connection invitation', + OfferCredential = 'Offer credential', + RequestProof = 'Request proof', + SendMessage = 'Send message', + Exit = 'Exit', + Restart = 'Restart', +} + +export class IssuerInquirer extends BaseInquirer { + public issuer: Issuer + public promptOptionsString: string[] + public listener: Listener + + public constructor(issuer: Issuer) { + super() + this.issuer = issuer + this.listener = new Listener() + this.promptOptionsString = Object.values(PromptOptions) + this.listener.messageListener(this.issuer.agent, this.issuer.name) + } + + public static async build(): Promise { + const issuer = await Issuer.build() + return new IssuerInquirer(issuer) + } + + private async getPromptChoice() { + if (this.issuer.outOfBandId) return prompt([this.inquireOptions(this.promptOptionsString)]) + + const reducedOption = [PromptOptions.CreateConnection, PromptOptions.Exit, PromptOptions.Restart] + return prompt([this.inquireOptions(reducedOption)]) + } + + public async processAnswer() { + const choice = await this.getPromptChoice() + if (this.listener.on) return + + switch (choice.options) { + case PromptOptions.CreateConnection: + await this.connection() + break + case PromptOptions.OfferCredential: + await this.credential() + return + case PromptOptions.RequestProof: + await this.proof() + return + case PromptOptions.SendMessage: + await this.message() + break + case PromptOptions.Exit: + await this.exit() + break + case PromptOptions.Restart: + await this.restart() + return + } + await this.processAnswer() + } + + public async connection() { + await this.issuer.setupConnection() + } + + public async exitUseCase(title: string) { + const confirm = await prompt([this.inquireConfirmation(title)]) + if (confirm.options === ConfirmOptions.No) { + return false + } else if (confirm.options === ConfirmOptions.Yes) { + return true + } + } + + public async credential() { + const registry = await prompt([this.inquireOptions([RegistryOptions.indy, RegistryOptions.cheqd])]) + await this.issuer.importDid(registry.options) + await this.issuer.issueCredential() + const title = 'Is the credential offer accepted?' + await this.listener.newAcceptedPrompt(title, this) + } + + public async proof() { + await this.issuer.sendProofRequest() + const title = 'Is the proof request accepted?' + await this.listener.newAcceptedPrompt(title, this) + } + + public async message() { + const message = await this.inquireMessage() + if (!message) return + + await this.issuer.sendMessage(message) + } + + public async exit() { + const confirm = await prompt([this.inquireConfirmation(Title.ConfirmTitle)]) + if (confirm.options === ConfirmOptions.No) { + return + } else if (confirm.options === ConfirmOptions.Yes) { + await this.issuer.exit() + } + } + + public async restart() { + const confirm = await prompt([this.inquireConfirmation(Title.ConfirmTitle)]) + if (confirm.options === ConfirmOptions.No) { + await this.processAnswer() + return + } else if (confirm.options === ConfirmOptions.Yes) { + await this.issuer.restart() + await runIssuer() + } + } +} + +void runIssuer() diff --git a/demo/src/Listener.ts b/demo/src/Listener.ts new file mode 100644 index 0000000..a62a918 --- /dev/null +++ b/demo/src/Listener.ts @@ -0,0 +1,110 @@ +import type { Holder } from './Holder' +import type { HolderInquirer } from './HolderInquirer' +import type { Issuer } from './Issuer' +import type { IssuerInquirer } from './IssuerInquirer' +import type { Agent } from '@credo-ts/core' +import type { + BasicMessageStateChangedEvent, + CredentialExchangeRecord, + CredentialStateChangedEvent, + ProofExchangeRecord, + ProofStateChangedEvent, +} from '@credo-ts/didcomm' +import type BottomBar from 'inquirer/lib/ui/bottom-bar' + +import { + BasicMessageEventTypes, + BasicMessageRole, + CredentialEventTypes, + CredentialState, + ProofEventTypes, + ProofState, +} from '@credo-ts/didcomm' +import { ui } from 'inquirer' + +import { Color, purpleText } from './OutputClass' + +export class Listener { + public on: boolean + private ui: BottomBar + + public constructor() { + this.on = false + this.ui = new ui.BottomBar() + } + + private turnListenerOn() { + this.on = true + } + + private turnListenerOff() { + this.on = false + } + + private printCredentialAttributes(credentialRecord: CredentialExchangeRecord) { + if (credentialRecord.credentialAttributes) { + const attribute = credentialRecord.credentialAttributes + console.log('\n\nCredential preview:') + attribute.forEach((element) => { + console.log(purpleText(`${element.name} ${Color.Reset}${element.value}`)) + }) + } + } + + private async newCredentialPrompt(credentialRecord: CredentialExchangeRecord, aliceInquirer: HolderInquirer) { + this.printCredentialAttributes(credentialRecord) + this.turnListenerOn() + await aliceInquirer.acceptCredentialOffer(credentialRecord) + this.turnListenerOff() + await aliceInquirer.processAnswer() + } + + public credentialOfferListener(alice: Holder, aliceInquirer: any) { + alice.agent.events.on( + CredentialEventTypes.CredentialStateChanged, + async ({ payload }: CredentialStateChangedEvent) => { + if (payload.credentialRecord.state === CredentialState.OfferReceived) { + await this.newCredentialPrompt(payload.credentialRecord, aliceInquirer) + } + } + ) + } + + public messageListener(agent: Agent, name: string) { + agent.events.on(BasicMessageEventTypes.BasicMessageStateChanged, async (event: BasicMessageStateChangedEvent) => { + if (event.payload.basicMessageRecord.role === BasicMessageRole.Receiver) { + this.ui.updateBottomBar(purpleText(`\n${name} received a message: ${event.payload.message.content}\n`)) + } + }) + } + + private async newProofRequestPrompt(proofRecord: ProofExchangeRecord, aliceInquirer: HolderInquirer) { + this.turnListenerOn() + await aliceInquirer.acceptProofRequest(proofRecord) + this.turnListenerOff() + await aliceInquirer.processAnswer() + } + + public proofRequestListener(alice: Holder, aliceInquirer: HolderInquirer) { + alice.agent.events.on(ProofEventTypes.ProofStateChanged, async ({ payload }: ProofStateChangedEvent) => { + if (payload.proofRecord.state === ProofState.RequestReceived) { + await this.newProofRequestPrompt(payload.proofRecord, aliceInquirer) + } + }) + } + + public proofAcceptedListener(faber: Issuer, faberInquirer: IssuerInquirer) { + faber.agent.events.on(ProofEventTypes.ProofStateChanged, async ({ payload }: ProofStateChangedEvent) => { + if (payload.proofRecord.state === ProofState.Done) { + await faberInquirer.processAnswer() + } + }) + } + + public async newAcceptedPrompt(title: string, faberInquirer: IssuerInquirer) { + this.turnListenerOn() + await faberInquirer.exitUseCase(title) + this.turnListenerOff() + await faberInquirer.processAnswer() + } +} diff --git a/demo/src/OutputClass.ts b/demo/src/OutputClass.ts new file mode 100644 index 0000000..b9e69c7 --- /dev/null +++ b/demo/src/OutputClass.ts @@ -0,0 +1,40 @@ +export enum Color { + Green = `\x1b[32m`, + Red = `\x1b[31m`, + Purple = `\x1b[35m`, + Reset = `\x1b[0m`, +} + +export enum Output { + NoConnectionRecordFromOutOfBand = `\nNo connectionRecord has been created from invitation\n`, + ConnectionEstablished = `\nConnection established!`, + MissingConnectionRecord = `\nNo connectionRecord ID has been set yet\n`, + ConnectionLink = `\nRun 'Receive connection invitation' in Alice and paste this invitation link:\n\n`, + Exit = 'Shutting down agent...\nExiting...', +} + +export enum Title { + OptionsTitle = '\nOptions:', + InvitationTitle = '\n\nPaste the invitation url here:', + MessageTitle = '\n\nWrite your message here:\n(Press enter to send or press q to exit)\n', + ConfirmTitle = '\n\nAre you sure?', + CredentialOfferTitle = '\n\nCredential offer received, do you want to accept it?', + ProofRequestTitle = '\n\nProof request received, do you want to accept it?', +} + +export const greenText = (text: string, reset?: boolean) => { + if (reset) return Color.Green + text + Color.Reset + + return Color.Green + text +} + +export const purpleText = (text: string, reset?: boolean) => { + if (reset) return Color.Purple + text + Color.Reset + return Color.Purple + text +} + +export const redText = (text: string, reset?: boolean) => { + if (reset) return Color.Red + text + Color.Reset + + return Color.Red + text +}