From 4653ce981c4011b6c913e6160789af4fcbb19d72 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Fri, 11 Oct 2024 12:02:37 +0100 Subject: [PATCH] feat: Implement contact exchange library with OOB invitation processing and wallet management --- package-lock.json | 41 ++++------ packages/contact-exchange/.eslintrc.json | 25 ++++++ packages/contact-exchange/README.md | 52 ++++++++++++ packages/contact-exchange/jest.config.ts | 14 ++++ packages/contact-exchange/package.json | 21 +++++ packages/contact-exchange/project.json | 30 +++++++ packages/contact-exchange/src/index.ts | 1 + .../src/lib/contact-exchange.spec.ts | 7 ++ .../src/lib/contact-exchange.ts | 3 + .../src/services/DIDCommOOBInvitation.ts | 32 ++++++++ .../src/services/HandleOOBInvitation.ts | 55 +++++++++++++ .../src/services/OOBParser.ts | 43 ++++++++++ .../src/services/ProcessOOBInvitation.ts | 81 ++++++++++++++++++ .../contact-exchange/src/services/Wallet.ts | 71 ++++++++++++++++ .../src/tests/HandleOOBInvitation.test.ts | 34 ++++++++ .../src/tests/OOBParser.test.ts | 68 +++++++++++++++ .../src/tests/OOBTestFixtures.ts | 82 +++++++++++++++++++ .../src/tests/ProcessOOBInvitation.test.ts | 22 +++++ .../contact-exchange/src/tests/Wallet.test.ts | 38 +++++++++ packages/contact-exchange/tsconfig.json | 22 +++++ packages/contact-exchange/tsconfig.lib.json | 10 +++ packages/contact-exchange/tsconfig.spec.json | 14 ++++ 22 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 packages/contact-exchange/.eslintrc.json create mode 100644 packages/contact-exchange/README.md create mode 100644 packages/contact-exchange/jest.config.ts create mode 100644 packages/contact-exchange/package.json create mode 100644 packages/contact-exchange/project.json create mode 100644 packages/contact-exchange/src/index.ts create mode 100644 packages/contact-exchange/src/lib/contact-exchange.spec.ts create mode 100644 packages/contact-exchange/src/lib/contact-exchange.ts create mode 100644 packages/contact-exchange/src/services/DIDCommOOBInvitation.ts create mode 100644 packages/contact-exchange/src/services/HandleOOBInvitation.ts create mode 100644 packages/contact-exchange/src/services/OOBParser.ts create mode 100644 packages/contact-exchange/src/services/ProcessOOBInvitation.ts create mode 100644 packages/contact-exchange/src/services/Wallet.ts create mode 100644 packages/contact-exchange/src/tests/HandleOOBInvitation.test.ts create mode 100644 packages/contact-exchange/src/tests/OOBParser.test.ts create mode 100644 packages/contact-exchange/src/tests/OOBTestFixtures.ts create mode 100644 packages/contact-exchange/src/tests/ProcessOOBInvitation.test.ts create mode 100644 packages/contact-exchange/src/tests/Wallet.test.ts create mode 100644 packages/contact-exchange/tsconfig.json create mode 100644 packages/contact-exchange/tsconfig.lib.json create mode 100644 packages/contact-exchange/tsconfig.spec.json diff --git a/package-lock.json b/package-lock.json index 13fa7c6..fff866a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2034,6 +2034,10 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@datev/contact-exchange": { + "resolved": "packages/contact-exchange", + "link": true + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2518,7 +2522,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" @@ -2605,7 +2608,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -2745,7 +2747,6 @@ "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -3221,14 +3222,12 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" @@ -3238,7 +3237,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" @@ -3295,7 +3293,6 @@ "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -3336,7 +3333,6 @@ "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -3346,7 +3342,6 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -3930,7 +3925,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4490,7 +4484,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4527,7 +4520,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, "funding": [ { "type": "github", @@ -4593,7 +4585,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4606,7 +4597,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6092,7 +6082,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -6106,7 +6095,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7216,7 +7204,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -7285,7 +7272,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -7301,7 +7287,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -7631,7 +7616,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, "license": "MIT" }, "node_modules/merge2": { @@ -8124,7 +8108,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9123,7 +9106,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -9590,7 +9572,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -10117,6 +10098,20 @@ "node": ">=14.17" } }, + "packages/contact-exchange": { + "name": "@datev/contact-exchange", + "version": "0.0.1", + "dependencies": { + "jest-worker": "^29.7.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/jest": "^29.5.13", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "^5.0.0" + } + }, "packages/contact-service": { "name": "@adorsys-gis/contact-service", "version": "1.0.0", diff --git a/packages/contact-exchange/.eslintrc.json b/packages/contact-exchange/.eslintrc.json new file mode 100644 index 0000000..adbe7ae --- /dev/null +++ b/packages/contact-exchange/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/packages/contact-exchange/README.md b/packages/contact-exchange/README.md new file mode 100644 index 0000000..ed38a57 --- /dev/null +++ b/packages/contact-exchange/README.md @@ -0,0 +1,52 @@ +# Contact Exchange Library + +This library was generated with [Nx](https://nx.dev). + +# Building the Library + +- Before builld, please move to the root directory of the library folder, that is **contact-exchange** and installl dependencies with : + +```bash +npm i +`` + +## Building + +Run `npm run build ` to build the library. + +## Running unit tests + +Run `nx run test ` to execute the unit tests via [Jest](https://jestjs.io). + +## Basic overview of the library + +``` + + +---------------+ + | Wallet | + +---------------+ + | + | DIDComm Messaging + | + v + +---------------+ + | Contact Storage| + +---------------+ + | + | (Accessible by all identities) + | + v + +---------------+ + | Identities | + +---------------+ + | + | DIDComm Messaging + | + v + +---------------+ + | Wallet | + +---------------+ + +``` + +``` diff --git a/packages/contact-exchange/jest.config.ts b/packages/contact-exchange/jest.config.ts new file mode 100644 index 0000000..50bb193 --- /dev/null +++ b/packages/contact-exchange/jest.config.ts @@ -0,0 +1,14 @@ +// jest.config.js +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + testMatch: ['**/tests/**/*.test.ts', '**/src/**/*.test.ts'], +}; diff --git a/packages/contact-exchange/package.json b/packages/contact-exchange/package.json new file mode 100644 index 0000000..10cde2b --- /dev/null +++ b/packages/contact-exchange/package.json @@ -0,0 +1,21 @@ +{ + "name": "@datev/contact-exchange", + "version": "0.0.1", + "dependencies": { + "jest-worker": "^29.7.0", + "tslib": "^2.3.0" + }, + "type": "commonjs", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "devDependencies": { + "@types/jest": "^29.5.13", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "^5.0.0" + }, + "scripts": { + "build": "tsc", + "test": "jest" + } +} diff --git a/packages/contact-exchange/project.json b/packages/contact-exchange/project.json new file mode 100644 index 0000000..9f9ca5a --- /dev/null +++ b/packages/contact-exchange/project.json @@ -0,0 +1,30 @@ +{ + "name": "contact-exchange", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/contact-exchange/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/contact-exchange", + "main": "libs/contact-exchange/src/index.ts", + "tsConfig": "libs/contact-exchange/tsconfig.lib.json", + "assets": ["libs/contact-exchange/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/contact-exchange/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/packages/contact-exchange/src/index.ts b/packages/contact-exchange/src/index.ts new file mode 100644 index 0000000..46e53e5 --- /dev/null +++ b/packages/contact-exchange/src/index.ts @@ -0,0 +1 @@ +export * from './lib/contact-exchange'; diff --git a/packages/contact-exchange/src/lib/contact-exchange.spec.ts b/packages/contact-exchange/src/lib/contact-exchange.spec.ts new file mode 100644 index 0000000..107b1a2 --- /dev/null +++ b/packages/contact-exchange/src/lib/contact-exchange.spec.ts @@ -0,0 +1,7 @@ +import { contactExchange } from './contact-exchange'; + +describe('contactExchange', () => { + it('should work', () => { + expect(contactExchange()).toEqual('contact-exchange'); + }); +}); diff --git a/packages/contact-exchange/src/lib/contact-exchange.ts b/packages/contact-exchange/src/lib/contact-exchange.ts new file mode 100644 index 0000000..dc5443e --- /dev/null +++ b/packages/contact-exchange/src/lib/contact-exchange.ts @@ -0,0 +1,3 @@ +export function contactExchange(): string { + return 'contact-exchange'; +} diff --git a/packages/contact-exchange/src/services/DIDCommOOBInvitation.ts b/packages/contact-exchange/src/services/DIDCommOOBInvitation.ts new file mode 100644 index 0000000..6ae3194 --- /dev/null +++ b/packages/contact-exchange/src/services/DIDCommOOBInvitation.ts @@ -0,0 +1,32 @@ +// Types for DIDComm and Out of Band (OOB) Invitations +interface DIDCommMessage { + id: string; + type: string; + from: string; + to: string[]; + created_time: string; + body?: unknown; + attachments?: unknown[]; +} + +interface OutOfBandInvitation { + '@id': string; + '@type': string; + '@cid': string; + label?: string; + goal?: string; + goal_code?: string; + services: Array; + attachments?: Array; +} + +interface OutOfBandService { + id: string; + type: string; + serviceEndpoint: string; + recipientKeys: string[]; + routingKeys?: string[]; + accept?: string[]; +} + +export { DIDCommMessage, OutOfBandInvitation, OutOfBandService }; diff --git a/packages/contact-exchange/src/services/HandleOOBInvitation.ts b/packages/contact-exchange/src/services/HandleOOBInvitation.ts new file mode 100644 index 0000000..46dece4 --- /dev/null +++ b/packages/contact-exchange/src/services/HandleOOBInvitation.ts @@ -0,0 +1,55 @@ +import { Wallet } from './Wallet'; +import { OutOfBandInvitation, OutOfBandService } from './DIDCommOOBInvitation'; + +export function handleOOBInvitation( + wallet: Wallet, + invitation: OutOfBandInvitation | string, + identity: string, +): void { + try { + let parsedInvitation: OutOfBandInvitation | null = null; + + if (typeof invitation === 'string' && invitation.startsWith('{')) { + try { + parsedInvitation = JSON.parse(invitation); + } catch (error) { + console.error('Error parsing invitation:', error); + return; + } + } else if (typeof invitation === 'object') { + parsedInvitation = invitation; + } else { + console.error('Invalid invitation type:', typeof invitation); + return; + } + + // Validate the parsed invitation + if (!parsedInvitation?.services || parsedInvitation.services.length === 0) { + console.error('Invalid OOB invitation: no services provided.'); + return; + } + + // Handle contact addition based on the parsed services + parsedInvitation.services.forEach((service: string | OutOfBandService) => { + if ( + typeof service === 'object' && + service.recipientKeys && + service.recipientKeys.length > 0 + ) { + const contact = { + did: service.recipientKeys[0], // Use the first recipient key + label: parsedInvitation.label || 'Unknown', // Use the label from the invitation or default to 'Unknown' + serviceEndpoint: service.serviceEndpoint || '', // Service endpoint + }; + + // Add the contact to the wallet + wallet.addContact(contact, identity); + console.log(`Contact added for identity ${identity}`); + } else { + console.error('No recipient keys provided in the service.'); + } + }); + } catch (error) { + console.error('Error handling OOB invitation:', error); + } +} diff --git a/packages/contact-exchange/src/services/OOBParser.ts b/packages/contact-exchange/src/services/OOBParser.ts new file mode 100644 index 0000000..4669d67 --- /dev/null +++ b/packages/contact-exchange/src/services/OOBParser.ts @@ -0,0 +1,43 @@ +import { OutOfBandInvitation } from './DIDCommOOBInvitation'; + +export interface ParsedOOB { + invitation: OutOfBandInvitation; + encodedPart: string; +} + +export function parseOOBInvitation(url: string): ParsedOOB | null { + const urlPattern = /^(https:\/\/mediator\.rootsid\.cloud\?_oob=)(.*)$/; + const match = url.match(urlPattern); + if (!match) { + return null; + } + + const encodedPart = match[2]; + try { + const decodedData = atob(encodedPart); // Decoding base64 + const invitation: OutOfBandInvitation = JSON.parse(decodedData); + + // Validate the invitation object + if (!invitation['@id'] || !invitation['@type']) { + throw new Error('Invalid invitation object'); + } + + // Map @cid to @id if present + if (invitation['@cid'] && !invitation['@id']) { + invitation['@id'] = invitation['@cid']; + } + + return { invitation, encodedPart }; + } catch (error) { + if ( + error instanceof DOMException && + error.name === 'InvalidCharacterError' + ) { + throw new Error('Invalid base64 encoding'); + } else if (error instanceof SyntaxError) { + throw new Error('Invalid JSON'); + } else { + throw error; + } + } +} diff --git a/packages/contact-exchange/src/services/ProcessOOBInvitation.ts b/packages/contact-exchange/src/services/ProcessOOBInvitation.ts new file mode 100644 index 0000000..225bdf8 --- /dev/null +++ b/packages/contact-exchange/src/services/ProcessOOBInvitation.ts @@ -0,0 +1,81 @@ +import { + OutOfBandInvitation, + DIDCommMessage, + OutOfBandService, +} from './DIDCommOOBInvitation'; +import { parseOOBInvitation } from './OOBParser'; + +/** + * Process an out-of-band invitation and return a DIDComm message. + * + * @param invitation - The out-of-band invitation to process, either as a string (URL) or an object. + * @returns A DIDComm message, or null if the invitation is invalid. + */ +export function processOOBInvitation( + invitation: OutOfBandInvitation | string, +): DIDCommMessage | null { + try { + let parsedInvitation: OutOfBandInvitation | null = null; + + // Check if the invitation is a URL + if (typeof invitation === 'string') { + const parsed = parseOOBInvitation(invitation); + if (parsed) { + parsedInvitation = parsed.invitation; + } else { + throw new Error('Failed to parse OOB invitation from URL.'); + } + // Add the encoded part to the parsed invitation + ( + parsedInvitation as OutOfBandInvitation & { encodedPart: string } + ).encodedPart = parsed.encodedPart; + } else { + parsedInvitation = invitation; + } + + // Extract the necessary fields from the parsed invitation + const { + '@id': id, + '@type': type, + services, + label, + goal, + } = parsedInvitation; + + // Validate that services are provided in the OOB invitation + if (!services || services.length === 0) { + throw new Error('No service provided in the OOB invitation.'); + } + + // Determine if the first service is a string or an object + const service = + typeof services[0] === 'string' ? { id: services[0] } : services[0]; + + const outOfBandService = service as OutOfBandService; + const { serviceEndpoint, recipientKeys, routingKeys } = outOfBandService; + + // Create a basic DIDComm Message structure + const didCommMessage: DIDCommMessage = { + type: type, + from: recipientKeys[0], + body: { + goal: goal || undefined, + label: label || undefined, + recipientKeys, + routingKeys, + serviceEndpoint, + }, + to: [], + created_time: new Date().toISOString(), + id: id, + }; + + return didCommMessage; + } catch (error) { + console.error( + 'Error processing OOB Invitation:', + error instanceof Error ? error.message : error, + ); + return null; + } +} diff --git a/packages/contact-exchange/src/services/Wallet.ts b/packages/contact-exchange/src/services/Wallet.ts new file mode 100644 index 0000000..03df44c --- /dev/null +++ b/packages/contact-exchange/src/services/Wallet.ts @@ -0,0 +1,71 @@ +export interface Contact { + did: string; + label?: string; + serviceEndpoint?: string; +} + +export class Wallet { + private identities: Map; + + constructor() { + this.identities = new Map(); + } + + addContact(contact: Contact, identity: string): void { + if (!this.isValidContact(contact)) { + throw new Error(`Invalid contact: ${contact}`); + } + const didWithoutFragment = contact.did.split('#')[0]; + if (this.identities.has(identity)) { + const existingContacts = this.identities.get(identity); + if ( + existingContacts && + existingContacts.some((c) => c.did.split('#')[0] === didWithoutFragment) + ) { + console.log( + `Contact already exists in wallet for identity ${identity}`, + ); + return; + } + } + if (!this.identities.has(identity)) { + this.identities.set(identity, []); + } + this.identities.get(identity)?.push(contact); + } + + private isValidContact(contact: Contact): boolean { + if (!contact.did || !contact.serviceEndpoint) { + return false; + } + if (contact.label && contact.label.length > 50) { + return false; + } + return ( + contact.did.startsWith('did:') && + contact.serviceEndpoint.startsWith('http') + ); + } + + getContacts(identity: string): Contact[] { + return [...(this.identities.get(identity) || [])]; + } + + getAllContacts(): Contact[] { + const allContacts: Contact[] = []; + for (const contacts of Array.from(this.identities.values())) { + allContacts.push(...contacts); + } + return allContacts; + } + + removeContact(contact: Contact, identity: string): void { + if (this.identities.has(identity)) { + const existingContacts = this.identities.get(identity); + if (existingContacts && existingContacts.includes(contact)) { + const updatedContacts = existingContacts.filter((c) => c !== contact); + this.identities.set(identity, updatedContacts); + } + } + } +} diff --git a/packages/contact-exchange/src/tests/HandleOOBInvitation.test.ts b/packages/contact-exchange/src/tests/HandleOOBInvitation.test.ts new file mode 100644 index 0000000..df02188 --- /dev/null +++ b/packages/contact-exchange/src/tests/HandleOOBInvitation.test.ts @@ -0,0 +1,34 @@ +// handleOOBInvitation.test.ts +import { handleOOBInvitation } from '../services/HandleOOBInvitation'; +import { Wallet } from '../services/Wallet'; +import { + validOutOfBandInvitation, + invalidEncodedUrl, + invalidOutOfBandInvitation, +} from './OOBTestFixtures'; + +describe('handleOOBInvitation', () => { + let wallet: Wallet; + + beforeEach(() => { + wallet = new Wallet(); + }); + + it('should add a valid contact to the wallet from a JSON invitation', () => { + handleOOBInvitation(wallet, validOutOfBandInvitation, 'wallet-1'); + const contacts = wallet.getContacts('wallet-1'); + + expect(contacts).toHaveLength(1); + expect(contacts[0].did).toBe('did:example:123456789abcdefghi#key-1'); + }); + + it('should not add a contact if the OOB invitation URL is invalid', () => { + handleOOBInvitation(wallet, invalidEncodedUrl, 'wallet-1'); + expect(wallet.getContacts('wallet-1')).toHaveLength(0); + }); + + it('should handle invitations with missing recipient keys gracefully', () => { + handleOOBInvitation(wallet, invalidOutOfBandInvitation, 'wallet-1'); + expect(wallet.getContacts('wallet-1')).toHaveLength(0); + }); +}); diff --git a/packages/contact-exchange/src/tests/OOBParser.test.ts b/packages/contact-exchange/src/tests/OOBParser.test.ts new file mode 100644 index 0000000..63fa646 --- /dev/null +++ b/packages/contact-exchange/src/tests/OOBParser.test.ts @@ -0,0 +1,68 @@ +import { parseOOBInvitation } from '../services/OOBParser'; +import { handleOOBInvitation } from '../services/HandleOOBInvitation'; +import { Wallet } from '../services/Wallet'; +import { + validEncodedUrl, + invalidEncodedUrl, + validContact, +} from './OOBTestFixtures'; + +// Update the validEncodedUrl in OOBTestFixtures to match the new URL format +// For example: +// export const validEncodedUrl = 'https://mediator.rootsid.cloud?_oob=eyJ0eXBlIjoiaHR0cHM6Ly9kaWRjb21tLm9yZy9vdXQtb2YtYmFuZC8yLjAvaW52aXRhdGlvbiIsImlkIjoiMDczODZhZTUtMjYzYi00ZjgwLWE2M2ItZmI5OTE1ODIzM2IyIiwiZnJvbSI6ImRpZDpwZWVyOjIuRXo2TFNtczU1NVloRnRobjFXVjhjaURCcFptODZoSzl0cDgzV29qSlVteFBHazFoWi5WejZNa21kQmpNeUI0VFM1VWJiUXc1NHN6bTh5dk1NZjFmdEdWMnNRVllBeGFlV2hFLlNleUpwWkNJNkltNWxkeTFwWkNJc0luUWlPaUprYlNJc0luTWlPaUpvZEhSd2N6b3ZMMjFsWkdsaGRHOXlMbkp2YjNSemFXUXVZMnh2ZFdRaUxDSmhJanBiSW1ScFpHTnZiVzB2ZGpJaVhYMCIsImJvZHkiOnsiZ29hbF9jb2RlIjoicmVxdWVzdC1tZWRpYXRlIiwiZ29hbCI6IlJlcXVlc3RNZWRpYXRlIiwibGFiZWwiOiJNZWRpYXRvciIsImFjY2VwdCI6WyJkaWRjb21tL3YyIl19fQ'; + +describe('OOBParser', () => { + it('should parse a valid OOB invitation URL and return the invitation', () => { + const result = parseOOBInvitation(validEncodedUrl); + console.log('Result:', result); + expect(result).not.toBeNull(); + expect(result?.invitation['@id']).toBe('invitation-id'); + }); + + it('should return null for an invalid URL', () => { + const result = parseOOBInvitation(invalidEncodedUrl); + expect(result).toBeNull(); + }); + + it('should return null for a URL that is not a base64 encoded URL', () => { + const result = parseOOBInvitation('https://example.com'); + expect(result).toBeNull(); + }); + + it('should add a valid contact from a base64 encoded URL invitation', () => { + const parsedInvitation = parseOOBInvitation(validEncodedUrl); + if (!parsedInvitation) { + throw new Error('Failed to parse OOB invitation'); + } + + const wallet = new Wallet(); + handleOOBInvitation(wallet, parsedInvitation.invitation, 'wallet-1'); + const contacts = wallet.getContacts('wallet-1'); + + expect(contacts).toHaveLength(1); + expect(contacts[0].did).toBe('did:example:123456789abcdefghi#key-1'); + }); + + it('should not add a contact if it is already in the wallet', () => { + const wallet = new Wallet(); + const contact = validContact; + wallet.addContact(contact, 'wallet-1'); + + const parsedInvitation = parseOOBInvitation(validEncodedUrl); + if (parsedInvitation) { + handleOOBInvitation(wallet, parsedInvitation.invitation, 'wallet-1'); + } + const contacts = wallet.getContacts('wallet-1'); + + expect(contacts).toHaveLength(1); + expect(contacts[0].did).toBe(contact.did); + }); + + it('should not add a contact for an invalid OOB invitation URL', () => { + const wallet = new Wallet(); + handleOOBInvitation(wallet, invalidEncodedUrl, 'wallet-1'); + const contacts = wallet.getContacts('wallet-1'); + + expect(contacts).toHaveLength(0); + }); +}); diff --git a/packages/contact-exchange/src/tests/OOBTestFixtures.ts b/packages/contact-exchange/src/tests/OOBTestFixtures.ts new file mode 100644 index 0000000..102c028 --- /dev/null +++ b/packages/contact-exchange/src/tests/OOBTestFixtures.ts @@ -0,0 +1,82 @@ +// OOBTestFixtures.ts +import { OutOfBandInvitation } from '../services/DIDCommOOBInvitation'; +import { Contact } from '../services/Wallet'; + +/** + * A valid OutOfBandInvitation fixture. + */ +export const validOutOfBandInvitation: OutOfBandInvitation = { + '@id': 'invitation-id', + '@type': 'https://didcomm.org/out-of-band/2.0/invitation', + services: [ + { + id: 'did:example:123456789abcdefghi', + type: 'did-communication', + serviceEndpoint: 'http://example.com/endpoint', + recipientKeys: ['did:example:123456789abcdefghi#key-1'], + }, + ], + '@cid': '', +}; + +/** + * An invalid OutOfBandInvitation fixture, with no recipient keys. + */ +export const invalidOutOfBandInvitation: OutOfBandInvitation = { + '@id': 'invitation-id', + '@type': 'https://didcomm.org/out-of-band/2.0/invitation', + services: [ + { + id: 'did:example:123456789abcdefghi', + type: 'did-communication', + serviceEndpoint: 'http://example.com/endpoint', + recipientKeys: [], + }, + ], + '@cid': '', +}; + +// Base64 Encoded URL fixtures +import { Buffer } from 'buffer'; + +const validOutOfBandInvitationJson = JSON.stringify(validOutOfBandInvitation); +const encodedUrl = Buffer.from(validOutOfBandInvitationJson).toString('base64'); + +/** + * A valid encoded URL fixture. + */ +export const validEncodedUrl = `https://mediator.rootsid.cloud?_oob=${encodedUrl}`; + +/** + * An invalid encoded URL fixture. + */ +export const invalidEncodedUrl = 'invalid-url'; + +// Contact fixtures + +/** + * A valid Contact fixture. + */ +export const validContact: Contact = { + did: 'did:example:123456789abcdefghi', + label: 'Alice', + serviceEndpoint: 'http://example.com/endpoint', +}; + +/** + * A second valid Contact fixture. + */ +export const secondValidContact: Contact = { + did: 'did:example:987654321abcdefghi', + label: 'Bob', + serviceEndpoint: 'http://example.com/endpoint', +}; + +/** + * An invalid Contact fixture, with empty values. + */ +export const invalidContact: Contact = { + did: ' invalid-did', + label: ' label-too-long'.repeat(100), + serviceEndpoint: 'not-a-url', +}; diff --git a/packages/contact-exchange/src/tests/ProcessOOBInvitation.test.ts b/packages/contact-exchange/src/tests/ProcessOOBInvitation.test.ts new file mode 100644 index 0000000..f7a65f0 --- /dev/null +++ b/packages/contact-exchange/src/tests/ProcessOOBInvitation.test.ts @@ -0,0 +1,22 @@ +// processOOBInvitation.test.ts +import { processOOBInvitation } from '../services/ProcessOOBInvitation'; +import { validEncodedUrl, validOutOfBandInvitation } from './OOBTestFixtures'; + +describe('processOOBInvitation', () => { + it('should return a valid DIDCommMessage from a valid OOB invitation', () => { + const didCommMessage = processOOBInvitation(validOutOfBandInvitation); + + expect(didCommMessage).not.toBeNull(); + expect(didCommMessage?.type).toBe( + 'https://didcomm.org/out-of-band/2.0/invitation', + ); + expect(didCommMessage?.from).toBe('did:example:123456789abcdefghi#key-1'); + }); + + it('should return a valid DIDComm message from a valid OOB invitation URL', () => { + const result = processOOBInvitation(validEncodedUrl); + expect(result).not.toBeNull(); + expect(result?.type).toBe('https://didcomm.org/out-of-band/2.0/invitation'); + expect(result?.from).toBe('did:example:123456789abcdefghi#key-1'); + }); +}); diff --git a/packages/contact-exchange/src/tests/Wallet.test.ts b/packages/contact-exchange/src/tests/Wallet.test.ts new file mode 100644 index 0000000..8628fa5 --- /dev/null +++ b/packages/contact-exchange/src/tests/Wallet.test.ts @@ -0,0 +1,38 @@ +// Wallet.test.ts +import { Wallet } from '../services/Wallet'; +import { + validContact, + secondValidContact, + invalidContact, +} from './OOBTestFixtures'; + +describe('Wallet', () => { + let wallet: Wallet; + + beforeEach(() => { + wallet = new Wallet(); + }); + + it('should add and retrieve valid contacts for a specific identity', () => { + wallet.addContact(validContact, 'wallet-1'); + + const retrievedContacts = wallet.getContacts('wallet-1'); + expect(retrievedContacts[0].did).toBe(validContact.did); + expect(retrievedContacts[0].label).toBe(validContact.label); + }); + + it('should handle adding an invalid contact', () => { + expect(() => wallet.addContact(invalidContact, 'wallet-1')).toThrowError( + 'Invalid contact', + ); + expect(wallet.getContacts('wallet-1')).toEqual([]); + }); + + it('should return all contacts across identities', () => { + wallet.addContact(validContact, 'wallet-1'); + wallet.addContact(secondValidContact, 'wallet-2'); + + const allContacts = wallet.getAllContacts(); + expect(allContacts).toEqual([validContact, secondValidContact]); + }); +}); diff --git a/packages/contact-exchange/tsconfig.json b/packages/contact-exchange/tsconfig.json new file mode 100644 index 0000000..34ea11c --- /dev/null +++ b/packages/contact-exchange/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "types": ["jest", "node"], + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/contact-exchange/tsconfig.lib.json b/packages/contact-exchange/tsconfig.lib.json new file mode 100644 index 0000000..fcfe91f --- /dev/null +++ b/packages/contact-exchange/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "jest.setup.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/contact-exchange/tsconfig.spec.json b/packages/contact-exchange/tsconfig.spec.json new file mode 100644 index 0000000..9b2a121 --- /dev/null +++ b/packages/contact-exchange/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}