diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6f130baf5..9e37d4b43 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -156,7 +156,7 @@ jobs: run: | ssh -i ./ssh-key root@${{ vars.HOST_DOMAIN }} "\ ufw allow from 172.18.0.1/16 proto tcp to ${{ vars.HOST_IP}} port 80; - ufw allow from 172.18.0.1/16 proto tcp to ${{ secrets.IP_ADDRESS }} port 443; + ufw allow from 172.18.0.1/16 proto tcp to ${{ vars.HOST_IP }} port 443; ufw enable" - name: Start docker on server run: | diff --git a/packages/backend/README.md b/packages/backend/README.md index 0a20dc02c..e7e80aab7 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -8,6 +8,15 @@ cd ../../ && yarn build ``` +#### Contributing + +Whenever the prisma schema is updated, run the following command to generate the types: + +1. `prisma-create-migrations`: this will add a new migration to the migrations folder, which will be committed to the repository. Our server environments do not generate the migrations on the fly, so we need to commit the migration to the repository. This requires a running database, so make sure to have the database running before running this command. +2. `prisma-generate`: this will generate the types for the prisma schema (and the client). This step is executed automatically when running on the server. + +Fogetting step #1 will result in the server not being able to start, as the types will be missing. + ### Usage yarn @@ -15,9 +24,3 @@ yarn ``` yarn start ``` - -npm - -``` -npm start -``` diff --git a/packages/backend/manual_data_migration/insertWithinDocker.sh b/packages/backend/manual_data_migration/insertWithinDocker.sh new file mode 100755 index 000000000..c99c7cf57 --- /dev/null +++ b/packages/backend/manual_data_migration/insertWithinDocker.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Input file +input_file="dump.txt" + +DB_NAME="dm3" +DB_USER="prisma" + + +# Read the input file line by line +while IFS= read -r line +do + # Extract the ID (first part of the line) and timestamp (after "createdAt") + id=$(echo "$line" | cut -d ':' -f 2) + timestamp=$(echo "$line" | grep -oP '(?<="createdAt":)[0-9]+') + + # Convert the timestamp from milliseconds to seconds + timestamp_seconds=$(echo $timestamp | sed 's/...$//') + + # Insert the extracted values into the PostgreSQL table + psql -U $DB_USER -d $DB_NAME -c "INSERT INTO \"Account\" (id, \"createdAt\") \ + VALUES ('$id', to_timestamp($timestamp_seconds))\ + ON CONFLICT (id) \ + DO UPDATE SET \"createdAt\" = excluded.\"createdAt\";" + +done < "$input_file" + diff --git a/packages/backend/manual_data_migration/notes.md b/packages/backend/manual_data_migration/notes.md new file mode 100644 index 000000000..f7ab24c2c --- /dev/null +++ b/packages/backend/manual_data_migration/notes.md @@ -0,0 +1,37 @@ +Process: + +check data +`docker exec -it dm3-db-1 redis-cli --scan --pattern 'session*addr.dm3.eth'` + +`docker exec -it dm3-storage psql -U prisma -d dm3 -c 'SELECT * FROM "Account";'` + +go into the redis container +docker exec -it dm3-db-1 bash + +dump all relevant sessions +for key in `redis-cli --scan --pattern 'session*addr.dm3.eth'`; do echo $key: `redis-cli GET $key` >> dump.txt; echo $key; done + +copy the dump to the host +docker cp dm3-db-1:/data/dump.txt . + +copy the dump to the postgres container +docker cp dump.txt dm3-storage:/ + +paste script onto server +vi insertWithinDocker.sh +-> paste, close + +copy the script to the postgres container +docker cp insertWithinDocker.sh dm3-storage:/ + +go into the postgres container +docker exec -it dm3-storage bash + +make script executable +chmod a+x insertWithinDocker.sh + +run the script +./insertWithinDocker.sh + +check the data from outside the container +docker exec -it dm3-storage psql -U prisma -d dm3 -c 'SELECT \* FROM "Account";' diff --git a/packages/backend/migrations/20240719092732_add_account_created_at/migration.sql b/packages/backend/migrations/20240719092732_add_account_created_at/migration.sql new file mode 100644 index 000000000..6472c4b02 --- /dev/null +++ b/packages/backend/migrations/20240719092732_add_account_created_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/packages/backend/migrations/20240807075030_/migration.sql b/packages/backend/migrations/20240807075030_/migration.sql new file mode 100644 index 000000000..f884a4619 --- /dev/null +++ b/packages/backend/migrations/20240807075030_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Conversation" ADD COLUMN "encryptedProfileLocation" TEXT NOT NULL DEFAULT ''; diff --git a/packages/backend/package.json b/packages/backend/package.json index ed07599af..745b7c9af 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-backend", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { @@ -27,15 +27,16 @@ "yaml": "^2.1.3" }, "scripts": { - "docker:up": "docker-compose up -d", + "docker:up": "docker compose up -d", "prisma-init": "prisma generate && prisma migrate deploy ", + "prisma-create-migrations": "prisma generate && prisma migrate dev", "start": "yarn prisma-init && node ./dist/index.js", "start-inspect": "node --inspect=0.0.0.0:9229 ./dist/index.js", "test": "yarn run before:tests && DATABASE_URL='postgresql://prisma:prisma@localhost:5433/tests?schema=public' yarn jest --coverage --runInBand --transformIgnorePatterns 'node_modules/(?!(dm3-lib-\\w*)/)'", - "build": "yarn tsc && cp ./config.yml ./dist/config.yml | true", + "build": "yarn prisma generate && yarn tsc && cp ./config.yml ./dist/config.yml | true", "build:schema": "sh ./schemas.sh", "createDeliveryServiceProfile": "node --no-warnings ./cli.js", - "before:tests": "docker-compose -f docker-compose.test.yml up -d && DATABASE_URL='postgresql://prisma:prisma@localhost:5433/tests?schema=public' yarn prisma-init" + "before:tests": "docker compose -f docker-compose.test.yml up -d && DATABASE_URL='postgresql://prisma:prisma@localhost:5433/tests?schema=public' yarn prisma-init" }, "devDependencies": { "@babel/core": "^7.19.6", diff --git a/packages/backend/schema.prisma b/packages/backend/schema.prisma index 38bb48aaa..e5a0c379e 100644 --- a/packages/backend/schema.prisma +++ b/packages/backend/schema.prisma @@ -22,17 +22,19 @@ model EncryptedMessage { } model Conversation { - id String @id @default(uuid()) - updatedAt DateTime @default(now()) - encryptedContactName String - Message EncryptedMessage[] - Account Account @relation(fields: [accountId], references: [id]) - accountId String - isHidden Boolean @default(false) + id String @id @default(uuid()) + updatedAt DateTime @default(now()) + encryptedContactName String + encryptedProfileLocation String @default("") + Message EncryptedMessage[] + Account Account @relation(fields: [accountId], references: [id]) + accountId String + isHidden Boolean @default(false) } model Account { id String @id + createdAt DateTime @default(now()) conversations Conversation[] EncryptedMessage EncryptedMessage[] } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3fc9ef1bc..20fe2d05b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -14,8 +14,8 @@ import express from 'express'; import http from 'http'; import path from 'path'; import { getDatabase } from './persistence/getDatabase'; -import Profile from './profile'; import Storage from './storage'; +import Profile from './profile/profile'; const app = express(); app.use(express.json({ limit: '50mb' })); @@ -35,11 +35,11 @@ app.use(bodyParser.json()); app.use(logRequest); app.get('/hello', (req, res) => { - return res.send('Hello DM3'); + return res.status(200).send('Hello DM3'); }); app.use('/profile', Profile(db, web3Provider, serverSecret)); app.use('/storage', Storage(db, web3Provider, serverSecret)); - app.use('/auth', Auth(db.getAccount as any, serverSecret)); + app.use('/auth', Auth(db, serverSecret, web3Provider)); app.use(logError); app.use(errorHandler); })(); diff --git a/packages/backend/src/persistence/session/getAccount.ts b/packages/backend/src/persistence/account/getAccount.ts similarity index 90% rename from packages/backend/src/persistence/session/getAccount.ts rename to packages/backend/src/persistence/account/getAccount.ts index adc54bdef..d83694907 100644 --- a/packages/backend/src/persistence/session/getAccount.ts +++ b/packages/backend/src/persistence/account/getAccount.ts @@ -8,6 +8,8 @@ export function getAccount(redis: Redis) { RedisPrefix.Account + (await getIdEnsName(redis)(ensName)), ); + console.debug('get account ', ensName, session); + return session ? (JSON.parse(session) as Session & { spamFilterRules: spamFilter.SpamFilterRules; diff --git a/packages/backend/src/persistence/session/index.ts b/packages/backend/src/persistence/account/index.ts similarity index 100% rename from packages/backend/src/persistence/session/index.ts rename to packages/backend/src/persistence/account/index.ts diff --git a/packages/backend/src/persistence/account/setAccount.test.ts b/packages/backend/src/persistence/account/setAccount.test.ts new file mode 100644 index 000000000..f82ab6d57 --- /dev/null +++ b/packages/backend/src/persistence/account/setAccount.test.ts @@ -0,0 +1,33 @@ +import { UserProfile } from '@dm3-org/dm3-lib-profile'; +import { PrismaClient } from '@prisma/client'; +import { IBackendDatabase, getDatabase, getPrismaClient } from '../getDatabase'; + +const USER_NAME = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292.dm3.eth'; + +describe('Set Account', () => { + let prismaClient: PrismaClient; + let db: IBackendDatabase; + + beforeEach(async () => { + prismaClient = await getPrismaClient(); + db = await getDatabase(prismaClient); + }); + + it('Creates a new Account ', async () => { + const profile: UserProfile = { + publicEncryptionKey: '', + publicSigningKey: '', + deliveryServices: [], + }; + + const priorSetAccount = await db.getAccount(USER_NAME); + + //User has no account yet + expect(priorSetAccount).toBe(null); + await db.setAccount(USER_NAME); + + const afterSetAccount = await db.getAccount(USER_NAME); + //User has no account yet + expect(afterSetAccount?.id).toEqual(USER_NAME); + }); +}); diff --git a/packages/backend/src/persistence/session/setAccount.ts b/packages/backend/src/persistence/account/setAccount.ts similarity index 91% rename from packages/backend/src/persistence/session/setAccount.ts rename to packages/backend/src/persistence/account/setAccount.ts index 2aacd2657..2308bcdf9 100644 --- a/packages/backend/src/persistence/session/setAccount.ts +++ b/packages/backend/src/persistence/account/setAccount.ts @@ -10,6 +10,7 @@ export function setAccount(redis: Redis) { if (!isValid) { throw Error('Invalid session'); } + console.debug('set account ', ensName, session); await redis.set( RedisPrefix.Account + (await getIdEnsName(redis)(ensName)), stringify(session), diff --git a/packages/backend/src/persistence/getDatabase.ts b/packages/backend/src/persistence/getDatabase.ts index 6832e7671..6c16b1db8 100644 --- a/packages/backend/src/persistence/getDatabase.ts +++ b/packages/backend/src/persistence/getDatabase.ts @@ -1,11 +1,9 @@ -import { Session as DSSession, spamFilter } from '@dm3-org/dm3-lib-delivery'; -import { IAccountDatabase } from '@dm3-org/dm3-lib-server-side'; -import { PrismaClient } from '@prisma/client'; +import { Account, PrismaClient } from '@prisma/client'; import { createClient } from 'redis'; -import Session from './session'; import Storage from './storage'; import { ConversationRecord } from './storage/postgres/dto/ConversationRecord'; import { MessageRecord } from './storage/postgres/dto/MessageRecord'; +import { IAccountDatabase } from '@dm3-org/dm3-lib-server-side'; export enum RedisPrefix { Conversation = 'conversation:', @@ -13,7 +11,6 @@ export enum RedisPrefix { Sync = 'sync:', // Account used to be called Session. The prefix still resolves to "session:" for now. Account = 'session:', - Session = 'session:', NotificationChannel = 'notificationChannel:', GlobalNotification = 'globalNotification:', Otp = 'otp:', @@ -54,16 +51,15 @@ export async function getPrismaClient() { } export async function getDatabase( - _redis?: Redis, _prisma?: PrismaClient, -): Promise { - const redis = _redis ?? (await getRedisClient()); +): Promise { const prisma = _prisma ?? (await getPrismaClient()); return { //Session - setAccount: Session.setAccount(redis), - getAccount: Session.getAccount(redis), + setAccount: Storage.setAccount(prisma), + getAccount: Storage.getAccount(prisma), + hasAccount: Storage.hasAccount(prisma), //Storage AddConversation addConversation: Storage.addConversation(prisma), getConversationList: Storage.getConversationList(prisma), @@ -86,17 +82,15 @@ export async function getDatabase( }; } -export interface IDatabase extends IAccountDatabase { - setAccount: (ensName: string, session: DSSession) => Promise; - getAccount: (ensName: string) => Promise< - | (DSSession & { - spamFilterRules: spamFilter.SpamFilterRules; - }) - | null - >; +export interface IBackendDatabase extends IAccountDatabase { + setAccount: (ensName: string) => Promise; + getAccount: (ensName: string) => Promise; + hasAccount: (ensName: string) => Promise; + addConversation: ( ensName: string, encryptedContactName: string, + encryptedProfileLocation: string, ) => Promise; getConversationList: ( ensName: string, diff --git a/packages/backend/src/persistence/session/setAccount.test.ts b/packages/backend/src/persistence/session/setAccount.test.ts deleted file mode 100644 index 2e333d730..000000000 --- a/packages/backend/src/persistence/session/setAccount.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Redis, IDatabase, getRedisClient, getDatabase } from '../getDatabase'; -import { UserProfile } from '@dm3-org/dm3-lib-profile'; -import { Session } from '@dm3-org/dm3-lib-delivery'; - -const USER_ADDRESS = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292'; - -describe('Set Session', () => { - let redisClient: Redis; - let db: IDatabase; - - beforeEach(async () => { - redisClient = await getRedisClient(); - db = await getDatabase(redisClient); - await redisClient.flushDb(); - }); - - afterEach(async () => { - await redisClient.flushDb(); - await redisClient.disconnect(); - }); - - it('Creates a new Session ', async () => { - const profile: UserProfile = { - publicEncryptionKey: '', - publicSigningKey: '', - deliveryServices: [], - }; - const session: Session = { - account: USER_ADDRESS, - signedUserProfile: { profile, signature: 'foo' }, - token: '', - createdAt: 0, - profileExtension: { - notSupportedMessageTypes: [], - }, - }; - - const priorSetSession = await db.getAccount(USER_ADDRESS); - //User has no session yet - expect(priorSetSession).toBe(null); - await db.setAccount(USER_ADDRESS, session); - - const afterSetSession = await db.getAccount(USER_ADDRESS); - //User has no session yet - expect(afterSetSession?.signedUserProfile).toEqual({ - profile, - signature: 'foo', - }); - }); - - it('Rejcts session with an invalid schema', async () => { - const invalidSession = {} as Session; - try { - await db.setAccount('foo', invalidSession); - fail(); - } catch (e) { - expect(e).toStrictEqual(Error('Invalid session')); - } - }); -}); diff --git a/packages/backend/src/persistence/storage/index.ts b/packages/backend/src/persistence/storage/index.ts index ad50305cc..dd7fb55f3 100644 --- a/packages/backend/src/persistence/storage/index.ts +++ b/packages/backend/src/persistence/storage/index.ts @@ -10,6 +10,9 @@ import { MessageRecord } from './postgres/dto/MessageRecord'; import { getHaltedMessages } from './postgres/haltedMessage/getHaltedMessages'; import { clearHaltedMessage } from './postgres/haltedMessage/clearHaltedMessage'; +import { getAccount } from './postgres/getAccount'; +import { setAccount } from './postgres/setAccount'; +import { hasAccount } from './postgres/hasAccount'; export default { addConversation, @@ -22,6 +25,9 @@ export default { toggleHideConversation, getHaltedMessages, clearHaltedMessage, + getAccount, + setAccount, + hasAccount, }; export type { MessageRecord }; diff --git a/packages/backend/src/persistence/storage/postgres/addConversation.ts b/packages/backend/src/persistence/storage/postgres/addConversation.ts index b9fcf4612..0e65bd3d5 100644 --- a/packages/backend/src/persistence/storage/postgres/addConversation.ts +++ b/packages/backend/src/persistence/storage/postgres/addConversation.ts @@ -1,11 +1,21 @@ import { PrismaClient } from '@prisma/client'; +import { createOrUpdateConversation } from './utils/createOrUpdateConversation'; import { getOrCreateAccount } from './utils/getOrCreateAccount'; -import { getOrCreateConversation } from './utils/getOrCreateConversation'; export const addConversation = - (db: PrismaClient) => async (ensName: string, contactName: string) => { + (db: PrismaClient) => + async ( + ensName: string, + contactName: string, + encryptedProfileLocation: string, + ) => { try { const account = await getOrCreateAccount(db, ensName); - await getOrCreateConversation(db, account.id, contactName); + await createOrUpdateConversation( + db, + account.id, + contactName, + encryptedProfileLocation, + ); return true; } catch (e) { console.log('addConversation error ', e); diff --git a/packages/backend/src/persistence/storage/postgres/dto/ConversationRecord.ts b/packages/backend/src/persistence/storage/postgres/dto/ConversationRecord.ts index 4c358ad09..de60e2068 100644 --- a/packages/backend/src/persistence/storage/postgres/dto/ConversationRecord.ts +++ b/packages/backend/src/persistence/storage/postgres/dto/ConversationRecord.ts @@ -5,4 +5,6 @@ export type ConversationRecord = { previewMessage: string | null; //The time the conversation was last updated updatedAt: Date; + //This field can be used by the client to store information about the contacts TLD name + encryptedProfileLocation: string; }; diff --git a/packages/backend/src/persistence/storage/postgres/getAccount.ts b/packages/backend/src/persistence/storage/postgres/getAccount.ts new file mode 100644 index 000000000..d3499e82b --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/getAccount.ts @@ -0,0 +1,18 @@ +import { PrismaClient } from '@prisma/client'; + +export const getAccount = (db: PrismaClient) => async (ensName: string) => { + // Find the account + const account = await db.account.findFirst({ + where: { + id: ensName, + }, + }); + + // Return the account if it exists + if (account) { + return account; + } + + // If the account does not exist, return null + return null; +}; diff --git a/packages/backend/src/persistence/storage/postgres/getConversationList.ts b/packages/backend/src/persistence/storage/postgres/getConversationList.ts index ddb965f7c..d7054077f 100644 --- a/packages/backend/src/persistence/storage/postgres/getConversationList.ts +++ b/packages/backend/src/persistence/storage/postgres/getConversationList.ts @@ -53,5 +53,6 @@ export const getConversationList = previewMessage: previewMessages[idx]?.encryptedEnvelopContainer ?? null, updatedAt: c.updatedAt, + encryptedProfileLocation: c.encryptedProfileLocation, })); }; diff --git a/packages/backend/src/persistence/storage/postgres/hasAccount.ts b/packages/backend/src/persistence/storage/postgres/hasAccount.ts new file mode 100644 index 000000000..342c27694 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/hasAccount.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from '@prisma/client'; + +/// Check if an account exists for a given ENS name +/// @param db +/// @returns true if account exists, false otherwise +export const hasAccount = (db: PrismaClient) => async (ensName: string) => { + //Check if account exists + const account = await db.account.findFirst({ + where: { + id: ensName, + }, + }); + + return !!account; +}; diff --git a/packages/backend/src/persistence/storage/postgres/setAccount.ts b/packages/backend/src/persistence/storage/postgres/setAccount.ts new file mode 100644 index 000000000..3c4e7b659 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/setAccount.ts @@ -0,0 +1,6 @@ +import { PrismaClient } from '@prisma/client'; +import { getOrCreateAccount } from './utils/getOrCreateAccount'; + +export const setAccount = (db: PrismaClient) => async (ensName: string) => { + return getOrCreateAccount(db, ensName); +}; diff --git a/packages/backend/src/persistence/storage/postgres/utils/createOrUpdateConversation.ts b/packages/backend/src/persistence/storage/postgres/utils/createOrUpdateConversation.ts new file mode 100644 index 000000000..56aaaec3a --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/utils/createOrUpdateConversation.ts @@ -0,0 +1,42 @@ +import { PrismaClient } from '@prisma/client'; + +export const createOrUpdateConversation = async ( + db: PrismaClient, + accountId: string, + encryptedContactName: string, + encryptedProfileLocation: string, +) => { + //Check if conversation already exists + const conversation = await db.conversation.findFirst({ + where: { + accountId, + encryptedContactName, + }, + }); + if (conversation) { + //If a conversation already exist. Update the encryptedProfileLocation. + //At the moemnt this is the only updatable field + await db.conversation.update({ + where: { + id: conversation.id, + }, + data: { + encryptedProfileLocation, + }, + }); + + //If conversation exists, return it + return conversation; + } + //If conversation does not exist, create it + return await db.conversation.create({ + data: { + accountId, + encryptedProfileLocation, + encryptedContactName, + //Internal field to order conversations properly + //Will set whenever a conversation is created or a message is added + updatedAt: new Date(), + }, + }); +}; diff --git a/packages/backend/src/profile/getUserProfile.ts b/packages/backend/src/profile/getUserProfile.ts new file mode 100644 index 000000000..337a5a666 --- /dev/null +++ b/packages/backend/src/profile/getUserProfile.ts @@ -0,0 +1,11 @@ +import { Session } from '@dm3-org/dm3-lib-delivery'; +import { SignedUserProfile, normalizeEnsName } from '@dm3-org/dm3-lib-profile'; + +export async function getUserProfile( + getAccount: (accountAddress: string) => Promise, + ensName: string, +): Promise { + const account = normalizeEnsName(ensName); + const session = await getAccount(account); + return session?.signedUserProfile; +} diff --git a/packages/backend/src/profile.test.ts b/packages/backend/src/profile/profile.test.ts similarity index 74% rename from packages/backend/src/profile.test.ts rename to packages/backend/src/profile/profile.test.ts index 936dc696c..673869ee0 100644 --- a/packages/backend/src/profile.test.ts +++ b/packages/backend/src/profile/profile.test.ts @@ -1,22 +1,22 @@ -import { - Session, - generateAuthJWT, - spamFilter, -} from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { UserProfile, getProfileCreationMessage, } from '@dm3-org/dm3-lib-profile'; import { stringify } from '@dm3-org/dm3-lib-shared'; +import { Account } from '@prisma/client'; import bodyParser from 'body-parser'; import { ethers } from 'ethers'; import express from 'express'; import http from 'http'; import request from 'supertest'; -import { IDatabase } from './persistence/getDatabase'; +import { IBackendDatabase } from '../persistence/getDatabase'; import profile from './profile'; -import storage from './storage'; +import storage from '../storage'; +import { mockUserProfile } from '@dm3-org/dm3-lib-test-helper'; +// todo: create a web3 provider mock that returns a resolver and that thren returns a text when the respective functions +// are called const web3ProviderMock: ethers.providers.JsonRpcProvider = new ethers.providers.JsonRpcProvider(); @@ -26,7 +26,7 @@ let token = generateAuthJWT('alice.eth', serverSecret); const setUpApp = async ( app: express.Express, - db: IDatabase, + db: IBackendDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string = 'my-secret', ) => { @@ -36,20 +36,13 @@ const setUpApp = async ( }; const createDbMock = async () => { - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: {}, - } as Session & { spamFilterRules: spamFilter.SpamFilterRules }; + const accountMocked = { + id: 'alice.eth', + } as Account; const dbMock = { - getAccount: async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked), // returns some valid session - setAccount: async (_: string, __: Session) => {}, + getAccount: async (ensName: string) => Promise.resolve(accountMocked), + setAccount: async (id: string) => {}, getIdEnsName: async (ensName: string) => ensName, }; @@ -63,15 +56,33 @@ describe('Profile', () => { const db = await createDbMock(); + const user = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + const expectedUserProfile = user.signedUserProfile; + const userAddress = user.wallet.address; + + const mockGetEnsResolver = (_: string) => + Promise.resolve({ + getText: (_: string) => + Promise.resolve( + 'data:application/json,' + + stringify(expectedUserProfile), + ), + }); + const _web3Provider = { - resolveName: async () => - '0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5', - }; + getResolver: mockGetEnsResolver, + resolveName: async () => userAddress, + } as unknown as ethers.providers.StaticJsonRpcProvider; + // I don't know why this function is needed in this test. // Remove it after storage migration. db.getUserStorage = () => {}; - app.use(storage(db, _web3Provider as any, serverSecret)); - setUpApp(app, db, web3ProviderMock); + app.use(storage(db, _web3Provider, serverSecret)); + setUpApp(app, db, _web3Provider); const response = await request(app) .get('/alice.eth') diff --git a/packages/backend/src/profile/profile.ts b/packages/backend/src/profile/profile.ts new file mode 100644 index 000000000..6b64090c4 --- /dev/null +++ b/packages/backend/src/profile/profile.ts @@ -0,0 +1,114 @@ +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; +import { getUserProfile } from '@dm3-org/dm3-lib-profile'; + +import { + checkUserProfile, + normalizeEnsName, + schema, +} from '@dm3-org/dm3-lib-profile'; +import { validateSchema } from '@dm3-org/dm3-lib-shared'; +import { ethers } from 'ethers'; +import express from 'express'; +import { IBackendDatabase } from '../persistence/getDatabase'; + +export default ( + db: IBackendDatabase, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string, +) => { + const router = express.Router(); + + router.get('/:ensName', async (req: express.Request, res, next) => { + try { + const ensName = normalizeEnsName(req.params.ensName); + + const profile = await getUserProfile(web3Provider, ensName); + if (profile) { + res.json(profile); + } else { + res.sendStatus(404); + } + } catch (e) { + next(e); + } + }); + + /** + * Creates a new profile for the given ensName + * @param ensName ens id of the account + * @param signedUserProfile signed user profile + */ + router.post('/:ensName', async (req: express.Request, res, next) => { + try { + const schemaIsValid = validateSchema( + schema.SignedUserProfile, + req.body, + ); + + if (!schemaIsValid) { + console.error({ message: 'invalid schema' }); + return res.status(400).send({ error: 'invalid schema' }); + } + const ensName = normalizeEnsName(req.params.ensName); + console.debug({ + method: 'POST', + url: req.url, + ensName, + disableSessionCheck: + process.env.DISABLE_SESSION_CHECK === 'true', + }); + + // check if profile and signature are valid + if ( + !(await checkUserProfile( + web3Provider, + req.body, // as SignedUserProfile, + normalizeEnsName(ensName), + )) + ) { + console.debug( + 'Not creating account for ' + + ensName + + ' - Signature invalid', + ); + throw Error('Signature invalid.'); + } + + // check if an account for this name already exists + if (await db.getAccount(ensName)) { + console.debug( + 'Not creating account for ' + + ensName + + ' - account exists already', + ); + throw Error('Account exists already'); + } + + // create account + const account = await db.setAccount(ensName); + console.debug({ + message: 'POST profile', + ensName, + account, + }); + + // generate auth jwt + const token = generateAuthJWT(ensName, serverSecret); + + res.json(token); + } catch (e) { + console.warn({ + message: 'POST profile', + error: JSON.stringify(e), + }); + // eslint-disable-next-line no-console + console.log('POST PROFILE ERROR', e); + res.status(400).send({ + message: `Couldn't store profile`, + error: JSON.stringify(e), + }); + } + }); + + return router; +}; diff --git a/packages/backend/src/profile/submitUserProfile.ts b/packages/backend/src/profile/submitUserProfile.ts new file mode 100644 index 000000000..4c0b7831c --- /dev/null +++ b/packages/backend/src/profile/submitUserProfile.ts @@ -0,0 +1,36 @@ +import { + SignedUserProfile, + checkUserProfile, + normalizeEnsName, +} from '@dm3-org/dm3-lib-profile'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; +import { logDebug } from '@dm3-org/dm3-lib-shared'; +import { ethers } from 'ethers'; +import { IBackendDatabase } from '../persistence/getDatabase'; + +export async function submitUserProfile( + provider: ethers.providers.JsonRpcProvider, + db: IBackendDatabase, + ensName: string, + signedUserProfile: SignedUserProfile, + serverSecret: string, +): Promise { + const account = normalizeEnsName(ensName); + + if (!(await checkUserProfile(provider, signedUserProfile, account))) { + logDebug('submitUserProfile - Signature invalid'); + throw Error('Signature invalid.'); + } + if (await db.hasAccount(account)) { + logDebug('submitUserProfile - Profile exists already'); + throw Error('Profile exists already'); + } + logDebug({ text: 'submitUserProfile', account }); + + await db.setAccount(account.toLocaleLowerCase()); + + // create auth jwt + const token = generateAuthJWT(account, serverSecret); + + return token; +} diff --git a/packages/backend/src/storage.test.ts b/packages/backend/src/storage.test.ts index 3635eeba9..f8beaf672 100644 --- a/packages/backend/src/storage.test.ts +++ b/packages/backend/src/storage.test.ts @@ -1,9 +1,4 @@ -import { - Session, - generateAuthJWT, - spamFilter, -} from '@dm3-org/dm3-lib-delivery'; -import { SignedUserProfile, schema } from '@dm3-org/dm3-lib-profile'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { sha256 } from '@dm3-org/dm3-lib-shared'; import { MockDeliveryServiceProfile, @@ -17,17 +12,10 @@ import bodyParser from 'body-parser'; import { ethers } from 'ethers'; import express from 'express'; import request from 'supertest'; -import { - IDatabase, - Redis, - getDatabase, - getRedisClient, -} from './persistence/getDatabase'; +import { IBackendDatabase, getDatabase } from './persistence/getDatabase'; import { MessageRecord } from './persistence/storage/postgres/dto/MessageRecord'; import storage from './storage'; -import fs from 'fs'; - const keysA = { encryptionKeyPair: { publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', @@ -51,13 +39,10 @@ describe('Storage', () => { let sender: MockedUserProfile; let receiver: MockedUserProfile; let deliveryService: MockDeliveryServiceProfile; - let redisClient: Redis; beforeEach(async () => { prisma = new PrismaClient(); - redisClient = await getRedisClient(); - await redisClient.flushDb(); app = express(); app.use(bodyParser.json()); @@ -78,29 +63,12 @@ describe('Storage', () => { 'http://localhost:3000', ); - const db = await getDatabase(redisClient, prisma); - - const sessionMocked = { - challenge: '123', - token, - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - } as SignedUserProfile, - } as Session & { spamFilterRules: spamFilter.SpamFilterRules }; + const db = await getDatabase(prisma); const dbMocked = { - getAccount: async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked), - setAccount: async (_: string, __: Session) => {}, - getIdEnsName: async (ensName: string) => ensName, + hasAccount: (ensName: string) => true, }; - const dbFinal: IDatabase = { ...db, ...dbMocked }; + const dbFinal: IBackendDatabase = { ...db, ...dbMocked }; //const web3ProviderBase = getWeb3Provider(process.env); @@ -121,8 +89,6 @@ describe('Storage', () => { await prisma.encryptedMessage.deleteMany(); await prisma.conversation.deleteMany(); await prisma.account.deleteMany(); - await redisClient.flushDb(); - await redisClient.disconnect(); }); describe('addConversation', () => { @@ -150,6 +116,78 @@ describe('Storage', () => { expect(body[0].contact).toEqual(aliceId); expect(body.length).toBe(1); }); + it('can add conversation with encryptedProfileLocation', async () => { + const aliceId = 'alice.eth'; + + const { status } = await request(app) + .post(`/new/bob.eth/addConversation`) + .set({ + authorization: 'Bearer ' + token, + }) + .send({ + encryptedContactName: aliceId, + encryptedProfileLocation: '123', + }); + expect(status).toBe(200); + + const { body } = await request(app) + .get(`/new/bob.eth/getConversations`) + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + + expect(status).toBe(200); + expect(body[0].contact).toEqual(aliceId); + expect(body[0].encryptedProfileLocation).toEqual('123'); + expect(body.length).toBe(1); + }); + it('can update existing conversation with encryptedProfileLocation', async () => { + const aliceId = 'alice.eth'; + + const { status } = await request(app) + .post(`/new/bob.eth/addConversation`) + .set({ + authorization: 'Bearer ' + token, + }) + .send({ + encryptedContactName: aliceId, + encryptedProfileLocation: '123', + }); + expect(status).toBe(200); + + const { body } = await request(app) + .get(`/new/bob.eth/getConversations`) + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + + expect(status).toBe(200); + expect(body[0].contact).toEqual(aliceId); + expect(body[0].encryptedProfileLocation).toEqual('123'); + expect(body.length).toBe(1); + + const { status: updateStatus } = await request(app) + .post(`/new/bob.eth/addConversation`) + .set({ + authorization: 'Bearer ' + token, + }) + .send({ + encryptedContactName: aliceId, + encryptedProfileLocation: '123456', + }); + expect(updateStatus).toBe(200); + + const { body: updatedBody } = await request(app) + .get(`/new/bob.eth/getConversations`) + .set({ + authorization: 'Bearer ' + token, + }); + + expect(updatedBody[0].contact).toEqual(aliceId); + expect(updatedBody[0].encryptedProfileLocation).toEqual('123456'); + }); it('handle duplicates add conversation', async () => { const aliceId = 'alice.eth'; const ronId = 'ron.eth'; diff --git a/packages/backend/src/storage.ts b/packages/backend/src/storage.ts index 5f0f34839..736df4555 100644 --- a/packages/backend/src/storage.ts +++ b/packages/backend/src/storage.ts @@ -1,10 +1,10 @@ import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; -import { auth } from '@dm3-org/dm3-lib-server-side'; +import { authorize } from '@dm3-org/dm3-lib-server-side'; import { sha256, validateSchema } from '@dm3-org/dm3-lib-shared'; import cors from 'cors'; import { ethers } from 'ethers'; import express, { NextFunction, Request, Response } from 'express'; -import { IDatabase } from './persistence/getDatabase'; +import { IBackendDatabase } from './persistence/getDatabase'; import { MessageRecord } from './persistence/storage'; import { AddMessageBatchRequest } from './schema/storage/AddMessageBatchRequest'; import { AddMessageRequest } from './schema/storage/AddMesssageRequest'; @@ -15,7 +15,7 @@ const DEFAULT_CONVERSATION_PAGE_SIZE = 10; const DEFAULT_MESSAGE_PAGE_SIZE = 100; export default ( - db: IDatabase, + db: IBackendDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string, ) => { @@ -32,7 +32,15 @@ export default ( next: NextFunction, ensName: string, ) => { - auth(req, res, next, ensName, db, web3Provider, serverSecret); + authorize( + req, + res, + next, + ensName, + db.hasAccount, + web3Provider, + serverSecret, + ); }, ); @@ -60,7 +68,7 @@ export default ( message.encryptedEnvelopContainer, })), ); - return res.send(); + return res.sendStatus(200); } catch (e) { next(e); } @@ -100,7 +108,7 @@ export default ( ], ); if (success) { - return res.send(); + return res.sendStatus(200); } res.status(400).send('unable to add message'); } catch (e) { @@ -133,7 +141,7 @@ export default ( isHalted: message.isHalted, })), ); - return res.send(); + return res.sendStatus(200); } catch (e) { return res.status(400).send('unable to add message batch'); } @@ -196,19 +204,23 @@ export default ( ); router.post('/new/:ensName/addConversation', async (req, res, next) => { - const { encryptedContactName } = req.body; + const { encryptedContactName, encryptedProfileLocation } = req.body; if (!encryptedContactName) { res.status(400).send('invalid schema'); return; } + + //Param encryptedProfileLocation is optional, hence the default value is an empty string + const _encryptedProfileLocation = encryptedProfileLocation || ''; try { const ensName = normalizeEnsName(req.params.ensName); const success = await db.addConversation( ensName, encryptedContactName, + _encryptedProfileLocation, ); if (success) { - return res.send(); + return res.sendStatus(200); } res.status(400).send('unable to add conversation'); } catch (e) { @@ -286,7 +298,7 @@ export default ( ); if (success) { - return res.send(); + return res.sendStatus(200); } res.status(400).send('unable to clear halted message'); } catch (err) { @@ -312,7 +324,7 @@ export default ( encryptedContactName, hide, ); - return res.send(); + return res.sendStatus(200); } catch (e) { return res .status(400) diff --git a/packages/billboard-client/package.json b/packages/billboard-client/package.json index bc87f9936..f494cef00 100644 --- a/packages/billboard-client/package.json +++ b/packages/billboard-client/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-billboard-client", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "private": true, "main": "dist/index.js", "types": "dist/ined.d.ts", diff --git a/packages/billboard-widget/package.json b/packages/billboard-widget/package.json index 212a2490e..9a491d9ac 100644 --- a/packages/billboard-widget/package.json +++ b/packages/billboard-widget/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-billboard-widget", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "files": [ "dist" ], diff --git a/packages/delivery-service/package.json b/packages/delivery-service/package.json index b3d95fd3d..1d784e2d0 100644 --- a/packages/delivery-service/package.json +++ b/packages/delivery-service/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/delivery-service", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { @@ -24,14 +24,14 @@ "yaml": "^2.1.3" }, "scripts": { - "docker:up": "docker-compose up -d", + "docker:up": "docker compose up -d", "start": "node ./dist/index.js", "start-inspect": "node --inspect=0.0.0.0:9229 ./dist/index.js", - "test": "yarn run before:tests && jest --coverage --runInBand --transformIgnorePatterns 'node_modules/(?!(dm3-lib-\\w*)/)' && yarn run after:tests", + "test": "yarn run before:tests && jest --coverage --runInBand --transformIgnorePatterns 'node_modules/(?!(dm3-lib-\\w*)/)' ", "build": "yarn tsc ", "createDeliveryServiceProfile": "node --no-warnings ./cli.js", - "before:tests": "docker-compose -f docker-compose.test.yml up -d", - "after:tests": "docker-compose -f docker-compose.test.yml down" + "before:tests": "docker compose -f docker-compose.test.yml up -d", + "after:tests": "docker compose -f docker-compose.test.yml down" }, "devDependencies": { "@babel/core": "^7.19.6", diff --git a/packages/delivery-service/src/delivery.test.ts b/packages/delivery-service/src/delivery.test.ts index 0f7abff6d..0839b4d88 100644 --- a/packages/delivery-service/src/delivery.test.ts +++ b/packages/delivery-service/src/delivery.test.ts @@ -1,4 +1,4 @@ -import { generateAuthJWT } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import bodyParser from 'body-parser'; import express from 'express'; import request from 'supertest'; @@ -61,14 +61,13 @@ describe('Delivery', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getMessages: () => Promise.resolve([]), getIdEnsName: async (ensName: string) => ensName, }; const app = express(); app.use(bodyParser.json()); - app.use( - delivery(web3Provider as any, db as any, keysA, serverSecret), - ); + app.use(delivery(web3Provider as any, db as any, serverSecret)); const { status } = await request(app) .get('/messages/alice.eth/contact/bob.eth') @@ -102,24 +101,23 @@ describe('Delivery', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getIdEnsName: async (ensName: string) => ensName, }; const app = express(); app.use(bodyParser.json()); - app.use( - delivery(web3Provider as any, db as any, keysA, serverSecret), - ); + app.use(delivery(web3Provider as any, db as any, serverSecret)); const { status } = await request(app) .post( - '/messages/0x99C19AB10b9EC8aC6fcda9586E81f6B73a298870/syncAcknowledgments', + '/messages/0x99C19AB10b9EC8aC6fcda9586E81f6B73a298870/syncAcknowledgements', ) .set({ authorization: `Bearer ${token}`, }) .send({ - acknowledgments: [ + acknowledgements: [ { contactAddress: '0x99C19AB10b9EC8aC6fcda9586E81f6B73a298870', @@ -149,18 +147,17 @@ describe('Delivery', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getIdEnsName: async (ensName: string) => ensName, }; const app = express(); app.use(bodyParser.json()); - app.use( - delivery(web3Provider as any, db as any, keysA, serverSecret), - ); + app.use(delivery(web3Provider as any, db as any, serverSecret)); const { status } = await request(app) .post( - '/messages/0x99C19AB10b9EC8aC6fcda9586E81f6B73a298870/syncAcknowledgments', + '/messages/0x99C19AB10b9EC8aC6fcda9586E81f6B73a298870/syncAcknowledgements', ) .set({ authorization: `Bearer ${token}`, diff --git a/packages/delivery-service/src/delivery.ts b/packages/delivery-service/src/delivery.ts index 298442920..19b07799d 100644 --- a/packages/delivery-service/src/delivery.ts +++ b/packages/delivery-service/src/delivery.ts @@ -1,12 +1,15 @@ -import { Acknowledgment, getMessages, schema } from '@dm3-org/dm3-lib-delivery'; -import { auth } from '@dm3-org/dm3-lib-server-side'; +import { + Acknowledgement, + getConversationId, + schema, +} from '@dm3-org/dm3-lib-delivery'; +import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { authorize } from '@dm3-org/dm3-lib-server-side'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; -import { getConversationId } from '@dm3-org/dm3-lib-delivery'; import cors from 'cors'; import { ethers } from 'ethers'; import express from 'express'; import { IDatabase } from './persistence/getDatabase'; -import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; const syncAcknowledgementParamsSchema = { type: 'object', @@ -19,46 +22,72 @@ const syncAcknowledgementParamsSchema = { const syncAcknowledgementBodySchema = { type: 'object', properties: { - acknowledgments: { + acknowledgements: { type: 'array', - items: schema.Acknowledgment, + items: schema.Acknowledgement, }, }, - required: ['acknowledgments'], + required: ['acknowledgements'], additionalProperties: false, }; export default ( web3Provider: ethers.providers.JsonRpcProvider, db: IDatabase, - keys: DeliveryServiceProfileKeys, serverSecret: string, ) => { const router = express.Router(); //TODO remove router.use(cors()); router.param('ensName', async (req, res, next, ensName: string) => { - auth(req, res, next, ensName, db, web3Provider, serverSecret); + authorize( + req, + res, + next, + ensName, + db.hasAccount, + web3Provider, + serverSecret, + ); }); //Returns all incoming messages for a specific contact name + //TODO deprecated. remove in the future router.get( '/messages/:ensName/contact/:contactEnsName', async (req: express.Request, res, next) => { try { - const idEnsName = await db.getIdEnsName(req.params.ensName); + //retrieve the address for the contact name since it is used as a key in the db + const receiverAddress = await web3Provider.resolveName( + req.params.ensName, + ); + + //If the address is not found we return a 404. This should normally not happen since the receiver always is known to the delivery service + if (!receiverAddress) { + console.error( + 'receiver address not found for name ', + req.params.ensName, + ); + return res.status(404).send({ + error: + 'receiver address not found for name ' + + req.params.ensName, + }); + } - const idContactEnsName = await db.getIdEnsName( + //normalize the contact name + const contactEnsName = await normalizeEnsName( req.params.contactEnsName, ); - const newMessages = await getMessages( - db.getMessages, - keys.encryptionKeyPair, - idEnsName, - idContactEnsName, + //The new layout resolves conversations using a conversation id [addr(reiceiver),ensName(sender)] + const conversationId = getConversationId( + receiverAddress, + contactEnsName, ); - res.json(newMessages); + //Better 1000 than the previous fifty. This is a temporary solution until we implement pagination + const messages = await db.getMessages(conversationId, 0, 1000); + res.json(messages); } catch (e) { next(e); } @@ -83,8 +112,26 @@ export default ( //@ts-ignore async (req: express.Request, res, next) => { try { - const incomingMessages = await db.getIncomingMessages( + //retrieve the address for the contact name since it is used as a key in the db + const receiverAddress = await web3Provider.resolveName( req.params.ensName, + ); + //If the address is not found we return a 404. This should normally not happen since the receiver always is known to the delivery service + if (!receiverAddress) { + console.error( + 'receiver address not found for name ', + req.params.ensName, + ); + return res.status(404).send({ + error: + 'receiver address not found for name ' + + req.params.ensName, + }); + } + console.debug('get incoming messages for ', receiverAddress); + //TODO use address + const incomingMessages = await db.getIncomingMessages( + receiverAddress, //Fetch the last 10 messages per conversation //If we decide to add pagination for that endpoint we can pass this value as a param 1000, @@ -96,7 +143,7 @@ export default ( }, ); router.post( - '/messages/:ensName/syncAcknowledgments/', + '/messages/:ensName/syncAcknowledgements/', async (req, res, next) => { const hasValidParams = validateSchema( syncAcknowledgementParamsSchema, @@ -113,17 +160,31 @@ export default ( } try { - const ensName = await db.getIdEnsName(req.params.ensName); - console.log('lets go'); + const receiverAddress = await web3Provider.resolveName( + req.params.ensName, + ); + + //If the address is not found we return a 404. This should normally not happen since the receiver always is known to the delivery service + if (!receiverAddress) { + console.error( + 'receiver address not found for name ', + req.params.ensName, + ); + return res.status(404).send({ + error: + 'receiver address not found for name ' + + req.params.ensName, + }); + } await Promise.all( - req.body.acknowledgments.map( - async (ack: Acknowledgment) => { + req.body.acknowledgements.map( + async (ack: Acknowledgement) => { const contactEnsName = await db.getIdEnsName( ack.contactAddress, ); const conversationId = getConversationId( - ensName, + receiverAddress, contactEnsName, ); diff --git a/packages/delivery-service/src/index.ts b/packages/delivery-service/src/index.ts index d782743ab..84a072f52 100644 --- a/packages/delivery-service/src/index.ts +++ b/packages/delivery-service/src/index.ts @@ -1,3 +1,4 @@ +import { Session } from '@dm3-org/dm3-lib-delivery'; import { Auth, errorHandler, @@ -6,27 +7,28 @@ import { logError, logRequest, readKeysFromEnv, - socketAuth, } from '@dm3-org/dm3-lib-server-side'; import { NotificationChannelType, logInfo } from '@dm3-org/dm3-lib-shared'; import { Axios } from 'axios'; import bodyParser from 'body-parser'; import cors from 'cors'; import 'dotenv/config'; +import { ethers } from 'ethers'; import express from 'express'; import http from 'http'; import { Server } from 'socket.io'; +import webpush from 'web-push'; import winston from 'winston'; import { startCleanUpPendingMessagesJob } from './cleanup/cleanUpPendingMessages'; import { getDeliveryServiceProperties } from './config/getDeliveryServiceProperties'; import Delivery from './delivery'; import { onConnection } from './messaging'; import Notifications from './notifications'; -import { getDatabase } from './persistence/getDatabase'; -import Profile from './profile'; +import { IDatabase, getDatabase } from './persistence/getDatabase'; +import { Profile } from './profile/profile'; import RpcProxy from './rpc/rpc-proxy'; import { WebSocketManager } from './ws/WebSocketManager'; -import webpush from 'web-push'; +import { socketAuth } from './socketAuth'; const app = express(); app.use(express.json({ limit: '50mb' })); @@ -34,6 +36,58 @@ app.use(express.urlencoded({ limit: '50mb' })); const server = http.createServer(app); +// On the delivery-service side the address functions as an identifier for the account. +// The reason for that is that the DS should accept all messages directet to the address. Regardless of its ENS name. +// To use as much shared code as possible from lib/server-side the address is resolved to the account before each database call. +// using this wrapper around the IDatabase +const getDbWithAddressResolvedGetAccount = ( + db: IDatabase, + web3Provider: ethers.providers.JsonRpcProvider, +): IDatabase => { + const getAccountForEnsName = ( + web3Provider: ethers.providers.JsonRpcProvider, + getAccount: (ensName: string) => Promise, + ) => { + return async (ensName: string) => { + const address = await web3Provider.resolveName(ensName); + console.debug( + 'getDbWithAddressResolvedGetAccount resolved address for ens name: ', + ensName, + address, + ); + if (!address) { + console.info('no address found for ens name: ', ensName); + return null; + } + return getAccount(address); + }; + }; + + const hasAccountForEnsName = ( + web3Provider: ethers.providers.JsonRpcProvider, + hasAccount: (ensName: string) => Promise, + ) => { + return async (ensName: string) => { + const address = await web3Provider.resolveName(ensName); + console.debug( + 'getDbWithAddressResolvedHasAccount resolved address for ens name: ', + ensName, + address, + ); + if (!address) { + console.info('no address found for ens name: ', ensName); + return false; + } + return hasAccount(address); + }; + }; + + return { + ...db, + getAccount: getAccountForEnsName(web3Provider, db.getAccount), + hasAccount: hasAccountForEnsName(web3Provider, db.hasAccount), + }; +}; //TODO remove app.use(cors()); app.use(bodyParser.json()); @@ -50,8 +104,12 @@ global.logger = winston.createLogger({ (async () => { // load environment const deliveryServiceProperties = getDeliveryServiceProperties(); - const db = await getDatabase(); const web3Provider = await getCachedWebProvider(process.env); + + const db = getDbWithAddressResolvedGetAccount( + await getDatabase(), + web3Provider, + ); const keys = readKeysFromEnv(process.env); const serverSecret = getServerSecret(process.env); @@ -88,12 +146,16 @@ global.logger = winston.createLogger({ app.use(logRequest); app.get('/hello', (req, res) => { - return res.send('Hello DM3'); + return res.status(200).send('Hello DM3'); }); - app.use('/auth', Auth(db.getAccount as any, serverSecret)); - app.use('/profile', Profile(db, web3Provider, io, serverSecret)); - app.use('/delivery', Delivery(web3Provider, db, keys, serverSecret)); + //Auth + //socketAuth + //restAuth + + app.use('/auth', Auth(db, serverSecret, web3Provider)); + app.use('/profile', Profile(db, web3Provider, serverSecret)); + app.use('/delivery', Delivery(web3Provider, db, serverSecret)); app.use( '/notifications', Notifications( diff --git a/packages/delivery-service/src/message/MessageProcessor.test.ts b/packages/delivery-service/src/message/MessageProcessor.test.ts new file mode 100644 index 000000000..466022d0c --- /dev/null +++ b/packages/delivery-service/src/message/MessageProcessor.test.ts @@ -0,0 +1,793 @@ +import { EncryptionEnvelop, Postmark } from '@dm3-org/dm3-lib-messaging'; +import { + IWebSocketManager, + NotificationChannel, + NotificationChannelType, + sha256, + stringify, +} from '@dm3-org/dm3-lib-shared'; +import { BigNumber, ethers } from 'ethers'; +import { MessageProcessor } from './MessageProcessor'; + +import { checkSignature, decryptAsymmetric } from '@dm3-org/dm3-lib-crypto'; +import { + DeliveryServiceProperties, + Session, + spamFilter, +} from '@dm3-org/dm3-lib-delivery'; +import { UserProfile, normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { + MockDeliveryServiceProfile, + MockMessageFactory, + MockedUserProfile, + getMockDeliveryServiceProfile, + mockUserProfile, +} from '@dm3-org/dm3-lib-test-helper'; +import { IDatabase } from '../persistence/getDatabase'; +import { getAddress } from 'ethers/lib/utils'; + +jest.mock('nodemailer'); + +describe('MessageProcessor', () => { + let sender: MockedUserProfile; + let receiver: MockedUserProfile; + let receiverOnGno: MockedUserProfile; + let rando: MockedUserProfile; + + let ds: MockDeliveryServiceProfile; + + beforeEach(async () => { + //The receiver might use the same address for different networks. Hence we keep the wallet separate + + const receiverWallet = ethers.Wallet.createRandom(); + sender = await mockUserProfile( + ethers.Wallet.createRandom(), + 'bob.eth', + ['http://localhost:3000'], + ); + receiver = await mockUserProfile(receiverWallet, 'alice.eth', [ + 'http://localhost:3000', + ]); + receiverOnGno = await mockUserProfile(receiverWallet, 'alice.gno', [ + 'http://localhost:3000', + ]); + rando = await mockUserProfile( + ethers.Wallet.createRandom(), + 'rando.eth', + ['http://localhost:3000'], + ); + + ds = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'http://localhost:3000', + ); + }); + const getAccount = async ( + ensName: string, + socketId?: string, + ): Promise< + (Session & { spamFilterRules: spamFilter.SpamFilterRules }) | null + > => { + const emptyProfile: UserProfile = { + publicSigningKey: '', + publicEncryptionKey: '', + deliveryServices: [''], + }; + const isSender = getAddress(ensName) === sender.address; + const isReceiver = getAddress(ensName) === receiver.address; + + const session = ( + account: string, + token: string, + profile: UserProfile, + ): Session => ({ + account, + signedUserProfile: { + profile, + signature: '', + }, + token, + createdAt: new Date().getTime(), + profileExtension: { + encryptionAlgorithm: [], + notSupportedMessageTypes: [], + }, + socketId, + }); + + if (isSender) { + return { + ...session(sender.address, '123', emptyProfile), + spamFilterRules: {}, + }; + } + + if (isReceiver) { + return { + ...session(getAddress(receiver.address), 'abc', { + ...emptyProfile, + publicEncryptionKey: + receiver.profileKeys.encryptionKeyPair.publicKey, + }), + spamFilterRules: {}, + }; + } + + return null; + }; + + it('accepts an incoming message', async () => { + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + await expect(() => + messageProcessor.processEnvelop(incomingEnvelop), + ).not.toThrow(); + }); + + it('rejects an incoming message if it is to large', async () => { + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 1, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + await expect(() => + messageProcessor.processEnvelop(incomingEnvelop), + ).rejects.toEqual(Error('Message is too large')); + }); + it('rejects an incoming message if the receiver is unknown ', async () => { + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + resolveName: async (name: string) => { + if (name === 'rando.eth') { + return rando.address; + } + }, + } as any; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + rando, + ds, + ).createEncryptedEnvelop('hello rando'); + + await expect(() => + messageProcessor.processEnvelop(incomingEnvelop), + ).rejects.toEqual(Error('unknown session')); + }); + // //TODO remove skip once spam-filter is implemented + // //TODO remove skip once spam-filter is implemented + it.skip('rejects message if the senders nonce is below the threshold', async () => { + const _getAccount = async (address: string) => + ({ + ...(await getAccount(address)), + spamFilterRules: { minNonce: 2 }, + } as Session & { spamFilterRules: spamFilter.SpamFilterRules }); + + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount: _getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + getTransactionCount: async (_: string) => Promise.resolve(0), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + try { + await messageProcessor.processEnvelop(incomingEnvelop); + fail(); + } catch (err: any) { + expect(err.message).toBe('Message does not match spam criteria'); + } + }); + it.skip('rejects message if the senders eth balance is below the threshold', async () => { + const _getAccount = async (address: string) => + ({ + ...(await getAccount(address)), + spamFilterRules: { minBalance: '0xa' }, + } as Session & { spamFilterRules: spamFilter.SpamFilterRules }); + + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount: _getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + getBalance: async (_: string) => Promise.resolve(BigNumber.from(5)), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + try { + messageProcessor.processEnvelop(incomingEnvelop), fail(); + } catch (err: any) { + expect(err.message).toBe('Message does not match spam criteria'); + } + }); + // //TODO remove skip once spam-filter is implemented + it.skip('rejects message if the senders token balance is below the threshold', async () => { + const _getAccount = async (address: string) => + ({ + ...(await getAccount(address)), + spamFilterRules: { + minTokenBalance: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '0xa', + }, + }, + } as Session & { spamFilterRules: spamFilter.SpamFilterRules }); + + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount: _getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + _isProvider: true, + call: () => Promise.resolve(BigNumber.from(0).toHexString()), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as unknown as ethers.providers.JsonRpcProvider; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + try { + messageProcessor.processEnvelop(incomingEnvelop); + fail(); + } catch (err: any) { + expect(err.message).toBe('Message does not match spam criteria'); + } + }); + + it('send mail after incoming message', async () => { + const sendMessageViaSocketMock = jest.fn(); + const sendMailMock = jest.fn(); + + //mock nodemailer + const nodemailer = require('nodemailer'); //doesn't work with import. idk why + nodemailer.createTransport.mockReturnValue({ + sendMail: sendMailMock, + close: () => {}, + }); + + const getNotificationChannels = (user: string) => { + return Promise.resolve([ + { + type: NotificationChannelType.EMAIL, + config: { + recipientEmailId: 'joe12345@gmail.com', + isVerified: true, + isEnabled: true, + }, + }, + ]); + }; + + const db = { + createMessage: async () => {}, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: getNotificationChannels, + } as any as IDatabase; + + const dsNotificationChannels: NotificationChannel[] = [ + { + type: NotificationChannelType.EMAIL, + config: { + smtpHost: 'smtp.gmail.com', + smtpPort: 587, + smtpEmail: 'abc@gmail.com', + smtpUsername: 'abc@gmail.com', + smtpPassword: 'abcd1234', + }, + }, + ]; + + const web3Provider = { + getBalance: async (_: string) => Promise.resolve(BigNumber.from(5)), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: dsNotificationChannels, + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + await messageProcessor.processEnvelop(incomingEnvelop); + + expect(sendMailMock).toHaveBeenCalled(); + + //Check if the message was submitted to the socket + expect(sendMessageViaSocketMock).not.toBeCalled(); + }); + it('stores proper incoming message', async () => { + const sendMock = jest.fn(); + const createMessageMock = jest.fn(); + + const now = Date.now(); + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const db = { + createMessage: createMessageMock, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + getBalance: async (_: string) => Promise.resolve(BigNumber.from(5)), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + await messageProcessor.processEnvelop(incomingEnvelop); + + //createMessageCall + const [receiverAddress, _, actualEnvelop] = + createMessageMock.mock.calls[0]; + + expect(createMessageMock).toBeCalled(); + expect(receiverAddress).toBe(receiver.address); + expect(actualEnvelop['message']).toBe(incomingEnvelop.message); + + const actualPostmark = await decryptAsymmetric( + receiver.profileKeys.encryptionKeyPair, + JSON.parse(actualEnvelop['postmark']), + ); + + // check postmark + const { incommingTimestamp, messageHash, signature } = + JSON.parse(actualPostmark); + expect(incommingTimestamp).toBeGreaterThanOrEqual(now); + expect(incommingTimestamp).toBeLessThanOrEqual(Date.now()); + expect(messageHash).toBe( + ethers.utils.hashMessage(stringify(incomingEnvelop.message)), + ); + const postmarkWithoutSig: Omit = { + messageHash, + incommingTimestamp, + }; + expect( + await checkSignature( + ds.keys.signingKeyPair.publicKey, + sha256(stringify(postmarkWithoutSig)), + signature, + ), + ).toBe(true); + //Check if the message was submitted to the socket + expect(sendMock).not.toBeCalled(); + }); + it('stores proper incoming message using address', async () => { + const sendMock = jest.fn(); + const createMessageMock = jest.fn(); + + const now = Date.now(); + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(false); + }, + }; + + const db = { + createMessage: createMessageMock, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + getBalance: async (_: string) => Promise.resolve(BigNumber.from(5)), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + () => {}, + ); + + const incomingEnvelop1: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3 from ens'); + const incomingEnvelop2: EncryptionEnvelop = await MockMessageFactory( + sender, + receiverOnGno, + ds, + ).createEncryptedEnvelop('hello dm3 from gno'); + + await messageProcessor.processEnvelop(incomingEnvelop1); + await messageProcessor.processEnvelop(incomingEnvelop2); + + //createMessageCall + const [receiverAddress, _, actualEnvelop] = + createMessageMock.mock.calls[0]; + + expect(createMessageMock).toBeCalled(); + expect(createMessageMock).toBeCalledTimes(2); + expect(receiverAddress).toBe(receiver.address); + + expect(actualEnvelop['message']).toBe(incomingEnvelop1.message); + + const actualPostmark = await decryptAsymmetric( + receiver.profileKeys.encryptionKeyPair, + JSON.parse(actualEnvelop['postmark']), + ); + + // check postmark + const { incommingTimestamp, messageHash, signature } = + JSON.parse(actualPostmark); + expect(incommingTimestamp).toBeGreaterThanOrEqual(now); + expect(incommingTimestamp).toBeLessThanOrEqual(Date.now()); + expect(messageHash).toBe( + ethers.utils.hashMessage(stringify(incomingEnvelop1.message)), + ); + const postmarkWithoutSig: Omit = { + messageHash, + incommingTimestamp, + }; + expect( + await checkSignature( + ds.keys.signingKeyPair.publicKey, + sha256(stringify(postmarkWithoutSig)), + signature, + ), + ).toBe(true); + //Check if the message was submitted to the socket + expect(sendMock).not.toBeCalled(); + }); + + it('stores proper incoming message and submit it if receiver is connected to a socket', async () => { + const sendMock = jest.fn(); + const createMessageMock = jest.fn(); + + const now = Date.now(); + + const mockWsManager: IWebSocketManager = { + isConnected: function (ensName: string): Promise { + return Promise.resolve(true); + }, + }; + + const db = { + createMessage: createMessageMock, + getIdEnsName: () => '', + getAccount, + getUsersNotificationChannels: () => Promise.resolve([]), + } as any as IDatabase; + + const web3Provider = { + getBalance: async (_: string) => Promise.resolve(BigNumber.from(5)), + resolveName: async (name: string) => { + if (name === 'alice.eth' || name === 'alice.gno') { + return receiver.address; + } + }, + } as any; + + const deliveryServiceProperties: DeliveryServiceProperties = { + sizeLimit: 2 ** 14, + messageTTL: 1000, + notificationChannel: [], + }; + + const messageProcessor = new MessageProcessor( + db, + web3Provider, + mockWsManager, + deliveryServiceProperties, + ds.keys, + sendMock, + ); + + const incomingEnvelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + await messageProcessor.processEnvelop(incomingEnvelop); + + //createMessageCall + const [receiverAddress, _, actualEnvelop] = + createMessageMock.mock.calls[0]; + + expect(createMessageMock).toBeCalled(); + expect(receiverAddress).toBe(receiver.address); + expect(actualEnvelop['message']).toBe(incomingEnvelop.message); + + const actualPostmark = await decryptAsymmetric( + receiver.profileKeys.encryptionKeyPair, + JSON.parse(actualEnvelop['postmark']), + ); + + // check postmark + const { incommingTimestamp, messageHash, signature } = + JSON.parse(actualPostmark); + expect(incommingTimestamp).toBeGreaterThanOrEqual(now); + expect(incommingTimestamp).toBeLessThanOrEqual(Date.now()); + expect(messageHash).toBe( + ethers.utils.hashMessage(stringify(incomingEnvelop.message)), + ); + const postmarkWithoutSig: Omit = { + messageHash, + incommingTimestamp, + }; + expect( + await checkSignature( + ds.keys.signingKeyPair.publicKey, + sha256(stringify(postmarkWithoutSig)), + signature, + ), + ).toBe(true); + expect(sendMock).toBeCalled(); + }); +}); diff --git a/packages/delivery-service/src/message/MessageProcessor.ts b/packages/delivery-service/src/message/MessageProcessor.ts new file mode 100644 index 000000000..0a1820067 --- /dev/null +++ b/packages/delivery-service/src/message/MessageProcessor.ts @@ -0,0 +1,189 @@ +import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; +import { IWebSocketManager, stringify } from '@dm3-org/dm3-lib-shared'; +import { ethers } from 'ethers'; + +import { + addPostmark, + decryptDeliveryInformation, + DeliveryServiceProperties, + getConversationId, + NotificationBroker, + NotificationType, + spamFilter, +} from '@dm3-org/dm3-lib-delivery'; +import { + DeliveryInformation, + EncryptionEnvelop, + getEnvelopSize, +} from '@dm3-org/dm3-lib-messaging'; +import { IDatabase } from '../persistence/getDatabase'; + +type onSubmitMessage = (socketId: string, envelop: EncryptionEnvelop) => void; + +export class MessageProcessor { + private readonly db: IDatabase; + private readonly provider: ethers.providers.JsonRpcProvider; + private readonly webSocketManager: IWebSocketManager; + private readonly deliveryServiceProperties: DeliveryServiceProperties; + private readonly deliveryServiceProfileKeys: DeliveryServiceProfileKeys; + private readonly onSubmitMessage: onSubmitMessage; + + constructor( + db: IDatabase, + provider: ethers.providers.JsonRpcProvider, + webSocketManager: IWebSocketManager, + deliveryServiceProperties: DeliveryServiceProperties, + deliveryServiceProfileKeys: DeliveryServiceProfileKeys, + onSubmitMessage: onSubmitMessage, + ) { + this.db = db; + this.provider = provider; + this.webSocketManager = webSocketManager; + this.deliveryServiceProperties = deliveryServiceProperties; + this.deliveryServiceProfileKeys = deliveryServiceProfileKeys; + this.onSubmitMessage = onSubmitMessage; + } + + /** + * Handles an incoming message. + * Either stores the message or sends it directly to the receiver if a socketId is provided + * In order to be considered valid a incoming message has to meet the following criterias + * 1. The message size must be lower than the sizeLimit specified by the deliveryService {@see messageIsToLarge} + * 2. The DeliveryServiceToken used by the sender has to be valid + * 3. The receiver has to have a session at the deliveryService + * 4. The message must pass every {@see SpamFilterRule} the receiver declared + */ + public async processEnvelop(envelop: EncryptionEnvelop): Promise { + //Checks the size of the incoming message + if ( + this.messageIsTooLarge( + envelop, + this.deliveryServiceProperties.sizeLimit, + ) + ) { + console.error('Message is too large'); + throw Error('Message is too large'); + } + console.debug('process incomingMessage'); + + //Decrypts the encryptryInformation with the KeyPair of the deliveryService + const deliveryInformation: DeliveryInformation = + await decryptDeliveryInformation( + envelop, + this.deliveryServiceProfileKeys.encryptionKeyPair, + ); + console.debug( + 'incomingMessage delivery Information', + deliveryInformation, + ); + + //the delivery service has to accept any message to the receiver regardelss of the place they have choosen to host their profile. + //That means no matter what name the receiver has chosen to use, the delivery service has to resolve it to the correct address + //i.E if alice.eth resolves to 0x123 + //and alice.gno resolves to 0x123 aswell, the ds has to accept both + const receiverAddress = await this.provider.resolveName( + deliveryInformation.to, + ); + + if (!receiverAddress) { + console.debug( + 'unable to resolve address for ', + deliveryInformation.to, + ); + throw Error('unable to resolve receiver address'); + } + + console.debug( + `resolved address for ${deliveryInformation.to} to ${receiverAddress}`, + ); + + const conversationId = getConversationId( + //We use the receivers address as the first part of the conversationId + receiverAddress, + //We use the senders ens name as the second part of the conversationId. + //We do not use the address because the sender might have not set an resolver or the addr record might not be set. + //Its up to the client to resolve the ens name to the address + deliveryInformation.from, + ); + console.debug(conversationId, deliveryInformation); + + //Retrieves the session of the receiver + const receiverSession = await this.db.getAccount(receiverAddress); + if (!receiverSession) { + console.debug('unknown user ', deliveryInformation.to); + throw Error('unknown session'); + } + + //Checks if the message is spam + if ( + await spamFilter.isSpam( + this.provider, + receiverSession, + deliveryInformation, + ) + ) { + console.debug( + `incomingMessage from ${deliveryInformation.to} is spam`, + ); + throw Error('Message does not match spam criteria'); + } + + const receiverEncryptionKey = + receiverSession.signedUserProfile.profile.publicEncryptionKey; + + const envelopWithPostmark: EncryptionEnvelop = { + ...envelop, + metadata: { + ...envelop.metadata, + //Alwaays store the encrypted metadata + deliveryInformation, + }, + postmark: stringify( + await addPostmark( + envelop, + receiverEncryptionKey, + this.deliveryServiceProfileKeys.signingKeyPair.privateKey, + ), + ), + }; + console.debug('storeNewMessage', conversationId); + await this.db.createMessage( + receiverAddress, + conversationId, + envelopWithPostmark, + ); + + //If there is currently a webSocket connection open to the receiver, the message will be directly send. + if (await this.webSocketManager.isConnected(receiverAddress)) { + //Client is already connect to the delivery service and the message can be dispatched + //TODO MOVE send method to the WebSocketManager + this.onSubmitMessage( + receiverSession.socketId!, + envelopWithPostmark, + ); + + console.debug('WS send to socketId ', receiverSession.socketId); + //If not we're notifing the user that there is a new message waiting for them + } else { + try { + const { sendNotification } = NotificationBroker( + this.deliveryServiceProperties.notificationChannel, + NotificationType.NEW_MESSAGE, + ); + await sendNotification( + deliveryInformation, + this.db.getUsersNotificationChannels, + ); + } catch (err) { + console.log( + 'Unable to send Notification. There might be an error in the config.yml. Message has been received regardless', + ); + console.error(err); + } + } + } + + private messageIsTooLarge(envelop: EncryptionEnvelop, sizeLimit: number) { + return getEnvelopSize(envelop) > sizeLimit; + } +} diff --git a/packages/delivery-service/src/messaging.test.ts b/packages/delivery-service/src/messaging.test.ts index 8259e54ba..cb458edba 100644 --- a/packages/delivery-service/src/messaging.test.ts +++ b/packages/delivery-service/src/messaging.test.ts @@ -1,78 +1,91 @@ -import { createKeyPair } from '@dm3-org/dm3-lib-crypto'; import { Session } from '@dm3-org/dm3-lib-delivery'; +import { EncryptionEnvelop } from '@dm3-org/dm3-lib-messaging'; import { UserProfile } from '@dm3-org/dm3-lib-profile'; import { IWebSocketManager, ethersHelper } from '@dm3-org/dm3-lib-shared'; +import { + MockDeliveryServiceProfile, + MockMessageFactory, + MockedUserProfile, + getMockDeliveryServiceProfile, + mockUserProfile, +} from '@dm3-org/dm3-lib-test-helper'; +import { ethers } from 'ethers'; import { Socket } from 'socket.io'; -import winston from 'winston'; -import { testData } from '../../../test-data/encrypted-envelops.test'; import { onConnection } from './messaging'; -const SENDER_ADDRESS = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292'; -const RECEIVER_ADDRESS = '0xDd36ae7F9a8E34FACf1e110c6e9d37D0dc917855'; - -global.logger = winston.createLogger({ - transports: [new winston.transports.Console()], -}); - const serverSecret = 'secret'; const mockWsManager: IWebSocketManager = { isConnected: function (ensName: string): Promise { return Promise.resolve(false); }, }; -const keysA = { - encryptionKeyPair: { - publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', - privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', - }, - signingKeyPair: { - publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', - privateKey: - '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', - }, - storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', - storageEncryptionNonce: 0, -}; -const keyPair = createKeyPair(); +describe('Messaging', () => { + let sender: MockedUserProfile; + let receiver: MockedUserProfile; + let ds: MockDeliveryServiceProfile; -const getAccount = async (address: string) => { - const emptyProfile: UserProfile = { - publicSigningKey: '', - publicEncryptionKey: '', - deliveryServices: [''], - }; - const isSender = ethersHelper.formatAddress(address) === SENDER_ADDRESS; - const isReceiver = ethersHelper.formatAddress(address) === RECEIVER_ADDRESS; - - const session = (account: string, token: string, profile: UserProfile) => ({ - account, - signedUserProfile: { - profile, - signature: '', - }, - token, + beforeEach(async () => { + const receiverWallet = ethers.Wallet.createRandom(); + sender = await mockUserProfile( + ethers.Wallet.createRandom(), + 'bob.eth', + ['http://localhost:3000'], + ); + receiver = await mockUserProfile(receiverWallet, 'alice.eth', [ + 'http://localhost:3000', + ]); + ds = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'http://localhost:3000', + ); }); + const getAccount = async (address: string) => { + const emptyProfile: UserProfile = { + publicSigningKey: '', + publicEncryptionKey: '', + deliveryServices: [''], + }; + const isSender = ethersHelper.formatAddress(address) === sender.address; + const isReceiver = + ethersHelper.formatAddress(address) === receiver.address; - if (isSender) { - return session(SENDER_ADDRESS, '123', emptyProfile); - } - - if (isReceiver) { - return session(RECEIVER_ADDRESS, 'abc', { - ...emptyProfile, - publicEncryptionKey: (await keyPair).publicKey, + const session = ( + account: string, + token: string, + profile: UserProfile, + ) => ({ + account, + signedUserProfile: { + profile, + signature: '', + }, + token, }); - } - return null; -}; + if (isSender) { + return session(sender.address, '123', emptyProfile); + } -describe('Messaging', () => { - // prepare some mocks that are used by many tests - const web3Provider = { - resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', + if (isReceiver) { + return session(receiver.address, 'abc', { + ...emptyProfile, + publicEncryptionKey: + receiver.profileKeys.encryptionKeyPair.publicKey, + }); + } + + return null; }; + + const web3Provider = { + resolveName: async (name: string) => { + if (name === 'alice.eth') { + return receiver.address; + } + }, + } as any; + const db = { getAccount, createMessage: () => {}, @@ -86,11 +99,6 @@ describe('Messaging', () => { }), }, }; - //The same data used in Messages.test - const data = { - envelop: testData.envelopA, - //token: '123', - }; describe('submitMessage', () => { it('returns success if schema is valid', (done: any) => { @@ -112,8 +120,14 @@ describe('Messaging', () => { on: async (name: string, onSubmitMessage: any) => { //We just want to test the submitMessage callback fn if (name === 'submitMessage') { + const envelop: EncryptionEnvelop = + await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); await onSubmitMessage( - { ...data, token: '123' }, + { envelop, token: '123' }, callback, ); } @@ -125,7 +139,7 @@ describe('Messaging', () => { io as any, web3Provider as any, db as any, - keysA, + ds.keys, serverSecret, mockWsManager, )(getSocketMock()); @@ -150,7 +164,13 @@ describe('Messaging', () => { on: async (name: string, onSubmitMessage: any) => { //We just want to test the submitMessage callback fn if (name === 'submitMessage') { - await onSubmitMessage(data, callback); + const envelop: EncryptionEnvelop = + await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + await onSubmitMessage({ envelop }, callback); } }, } as unknown as Socket; @@ -160,7 +180,7 @@ describe('Messaging', () => { io as any, web3Provider as any, db as any, - keysA, + ds.keys, serverSecret, mockWsManager, )(getSocketMock()); @@ -196,7 +216,18 @@ describe('Messaging', () => { on: async (name: string, onSubmitMessage: any) => { //We just want to test the submitMessage callback fn if (name === 'submitMessage') { - await onSubmitMessage(data, callback); + const envelop: EncryptionEnvelop = + await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + await onSubmitMessage( + { + envelop, + }, + callback, + ); } }, } as unknown as Socket; @@ -206,7 +237,7 @@ describe('Messaging', () => { io as any, web3Provider as any, db as any, - keysA, + ds.keys, serverSecret, mockWsManager, )(getSocketMock()); @@ -222,7 +253,13 @@ describe('Messaging', () => { expect(e.error).toBe('invalid schema'); }); - const localData = { ...data }; + const envelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + + const localData = { envelop }; const metadata = localData.envelop.metadata as any; // Change the data so it becomes invalid in order to provoke the 'invalid schema' error delete metadata.deliveryInformation; @@ -243,7 +280,7 @@ describe('Messaging', () => { io as any, web3Provider as any, db as any, - keysA, + ds.keys, serverSecret, mockWsManager, )(getSocketMock()); diff --git a/packages/delivery-service/src/messaging.ts b/packages/delivery-service/src/messaging.ts index b028adf6c..bb6c2cf13 100644 --- a/packages/delivery-service/src/messaging.ts +++ b/packages/delivery-service/src/messaging.ts @@ -1,10 +1,10 @@ -import { incomingMessage } from '@dm3-org/dm3-lib-delivery'; import { EncryptionEnvelop, schema } from '@dm3-org/dm3-lib-messaging'; import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; import { IWebSocketManager, validateSchema } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; import { Server, Socket } from 'socket.io'; import { getDeliveryServiceProperties } from './config/getDeliveryServiceProperties'; +import { MessageProcessor } from './message/MessageProcessor'; import { IDatabase } from './persistence/getDatabase'; export function onConnection( @@ -51,7 +51,7 @@ export function onConnection( try { const deliveryServiceProperties = getDeliveryServiceProperties(); - global.logger.info({ + console.info({ method: 'WS INCOMING MESSAGE', }); @@ -63,37 +63,34 @@ export function onConnection( if (!isSchemaValid) { const error = 'invalid schema'; - global.logger.warn({ + console.warn({ method: 'WS SUBMIT MESSAGE', error, }); return callback({ error }); } - global.logger.info({ + console.info({ method: 'WS INCOMING MESSAGE', keys: keys.encryptionKeyPair.publicKey, }); - await incomingMessage( - data.envelop, - keys.signingKeyPair, - keys.encryptionKeyPair, - deliveryServiceProperties.sizeLimit, - deliveryServiceProperties.notificationChannel, - db.getAccount, - db.createMessage, + const messageProcessor = new MessageProcessor( + db, + web3Provider, + webSocketManager, + deliveryServiceProperties, + keys, (socketId: string, envelop: EncryptionEnvelop) => { io.sockets.to(socketId).emit('message', envelop); }, - web3Provider, - db.getIdEnsName, - db.getUsersNotificationChannels, - webSocketManager, - ), - callback({ response: 'success' }); + ); + await messageProcessor.processEnvelop(data.envelop); + console.log('callback'); + + callback({ response: 'success' }); } catch (error: any) { - global.logger.warn({ + console.warn({ method: 'WS SUBMIT MESSAGE', error: (error as Error).toString(), }); diff --git a/packages/delivery-service/src/notifications.test.ts b/packages/delivery-service/src/notifications.test.ts index 9e2a3bcd4..bb72ed3b9 100644 --- a/packages/delivery-service/src/notifications.test.ts +++ b/packages/delivery-service/src/notifications.test.ts @@ -1,12 +1,11 @@ -import { - DeliveryServiceProperties, - generateAuthJWT, -} from '@dm3-org/dm3-lib-delivery'; +import { DeliveryServiceProperties } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { NotificationChannelType } from '@dm3-org/dm3-lib-shared'; import bodyParser from 'body-parser'; import express from 'express'; import request from 'supertest'; import notifications from './notifications'; +import { hasAccount } from './persistence/account/hasAccount'; const serverSecret = 'secret'; @@ -37,122 +36,123 @@ describe('Notifications', () => { // db = await getDatabase(); // }); - describe('get NotificationChannels', () => { - it('Returns empty array as global notification is turned off', async () => { - const app = express(); - app.use(bodyParser.json()); - - const db = { - getAccount: async (ensName: string) => - Promise.resolve({ - challenge: '123', - token, - signedUserProfile: { - profile: { - publicSigningKey: - keysA.signingKeyPair.publicKey, - }, - }, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getUserStorage: async (addr: string) => { - return {}; - }, - getIdEnsName: async (ensName: string) => ensName, - getGlobalNotification: async (ensName: string) => - Promise.resolve({ isEnabled: false }), - getUsersNotificationChannels: async (ensName: string) => - Promise.resolve([]), - }; - - const web3Provider = { - resolveName: async () => - '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', - }; - - app.use( - notifications( - deliveryServiceProperties, - db as any, - web3Provider as any, - serverSecret, - ), - ); - - const token = generateAuthJWT('bob.eth', serverSecret); - - const { status, body } = await request(app) - .get(`/bob.eth`) - .set({ - authorization: `Bearer ${token}`, - }) - .send(); - - expect(status).toBe(200); - expect(body).toEqual({ - notificationChannels: [], - }); - }); - - it('Returns 200 with empty notification channels as global notification is turned on', async () => { - const app = express(); - app.use(bodyParser.json()); - const db = { - getAccount: async (ensName: string) => - Promise.resolve({ - challenge: '123', - token, - signedUserProfile: { - profile: { - publicSigningKey: - keysA.signingKeyPair.publicKey, - }, - }, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getUserStorage: async (addr: string) => { - return {}; - }, - getIdEnsName: async (ensName: string) => ensName, - getUsersNotificationChannels: async (ensName: string) => - Promise.resolve([]), - getGlobalNotification: async (ensName: string) => - Promise.resolve({ isEnabled: true }), - }; - - const web3Provider = { - resolveName: async () => - '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', - }; - - app.use( - notifications( - deliveryServiceProperties, - db as any, - web3Provider as any, - serverSecret, - ), - ); - - const token = generateAuthJWT('bob.eth', serverSecret); - - const { status, body } = await request(app) - .get(`/bob.eth`) - .set({ - authorization: `Bearer ${token}`, - }) - .send(); - - expect(status).toBe(200); - expect(body).toEqual({ - notificationChannels: [], - }); - }); - }); + // describe('get NotificationChannels', () => { + // it('Returns empty array as global notification is turned off', async () => { + // const app = express(); + // app.use(bodyParser.json()); + + // const db = { + // getAccount: async (ensName: string) => + // Promise.resolve({ + // challenge: '123', + // token, + // signedUserProfile: { + // profile: { + // publicSigningKey: + // keysA.signingKeyPair.publicKey, + // }, + // }, + // }), + // setAccount: async (_: string, __: any) => { + // return (_: any, __: any, ___: any) => {}; + // }, + // hasAccount: (_: string) => Promise.resolve(true), + // getUserStorage: async (addr: string) => { + // return {}; + // }, + // getIdEnsName: async (ensName: string) => ensName, + // getGlobalNotification: async (ensName: string) => + // Promise.resolve({ isEnabled: false }), + // getUsersNotificationChannels: async (ensName: string) => + // Promise.resolve([]), + // }; + + // const web3Provider = { + // resolveName: async () => + // '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', + // }; + + // app.use( + // notifications( + // deliveryServiceProperties, + // db as any, + // web3Provider as any, + // serverSecret, + // ), + // ); + + // const token = generateAuthJWT('bob.eth', serverSecret); + + // const { status, body } = await request(app) + // .get(`/bob.eth`) + // .set({ + // authorization: `Bearer ${token}`, + // }) + // .send(); + + // expect(status).toBe(200); + // expect(body).toEqual({ + // notificationChannels: [], + // }); + // }); + + // it('Returns 200 with empty notification channels as global notification is turned on', async () => { + // const app = express(); + // app.use(bodyParser.json()); + // const db = { + // getAccount: async (ensName: string) => + // Promise.resolve({ + // challenge: '123', + // token, + // signedUserProfile: { + // profile: { + // publicSigningKey: + // keysA.signingKeyPair.publicKey, + // }, + // }, + // }), + // setAccount: async (_: string, __: any) => { + // return (_: any, __: any, ___: any) => {}; + // }, + // getUserStorage: async (addr: string) => { + // return {}; + // }, + // getIdEnsName: async (ensName: string) => ensName, + // getUsersNotificationChannels: async (ensName: string) => + // Promise.resolve([]), + // getGlobalNotification: async (ensName: string) => + // Promise.resolve({ isEnabled: true }), + // }; + + // const web3Provider = { + // resolveName: async () => + // '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', + // }; + + // app.use( + // notifications( + // deliveryServiceProperties, + // db as any, + // web3Provider as any, + // serverSecret, + // ), + // ); + + // const token = generateAuthJWT('bob.eth', serverSecret); + + // const { status, body } = await request(app) + // .get(`/bob.eth`) + // .set({ + // authorization: `Bearer ${token}`, + // }) + // .send(); + + // expect(status).toBe(200); + // expect(body).toEqual({ + // notificationChannels: [], + // }); + // }); + // }); describe('Add Email as notification channel', () => { it('Returns 400 on setup email notifications as email ID is invalid', async () => { @@ -169,6 +169,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -216,6 +217,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -264,6 +266,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -333,6 +336,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -397,6 +401,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -466,6 +471,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getUserStorage: async (addr: string) => { return {}; }, @@ -520,6 +526,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, setGlobalNotification: setGlobalNotificationMock, @@ -570,6 +577,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, setGlobalNotification: setGlobalNotificationMock, @@ -619,6 +627,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, setGlobalNotification: setGlobalNotificationMock, @@ -666,6 +675,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -714,6 +724,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, resendOtp: resendOtpMock, @@ -782,6 +793,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -845,6 +857,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -896,6 +909,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -947,6 +961,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1034,6 +1049,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1088,6 +1104,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1136,6 +1153,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1185,6 +1203,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1234,6 +1253,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1283,6 +1303,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1350,6 +1371,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1426,6 +1448,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1487,6 +1510,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1534,6 +1558,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1595,6 +1620,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1656,6 +1682,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1711,6 +1738,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1765,6 +1793,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1822,6 +1851,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1879,6 +1909,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1926,6 +1957,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1974,6 +2006,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2041,6 +2074,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2105,6 +2139,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2166,6 +2201,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2213,6 +2249,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2282,6 +2319,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => diff --git a/packages/delivery-service/src/notifications.ts b/packages/delivery-service/src/notifications.ts index 21bb4aaa8..9a993cf01 100644 --- a/packages/delivery-service/src/notifications.ts +++ b/packages/delivery-service/src/notifications.ts @@ -2,7 +2,7 @@ import cors from 'cors'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import express from 'express'; -import { auth } from '@dm3-org/dm3-lib-server-side'; +import { authorize } from '@dm3-org/dm3-lib-server-side'; import { ethers } from 'ethers'; import { validateNewNotificationChannelData, @@ -35,7 +35,15 @@ export default ( // Adding a route parameter middleware named 'ensName' router.param('ensName', (req, res, next, ensName: string) => { - auth(req, res, next, ensName, db, web3Provider, serverSecret); + authorize( + req, + res, + next, + ensName, + db.hasAccount, + web3Provider, + serverSecret, + ); }); // Defining a route to enable/disable global notifications diff --git a/packages/delivery-service/src/persistence/account/getAccount.ts b/packages/delivery-service/src/persistence/account/getAccount.ts index adc54bdef..35d76769c 100644 --- a/packages/delivery-service/src/persistence/account/getAccount.ts +++ b/packages/delivery-service/src/persistence/account/getAccount.ts @@ -1,17 +1,18 @@ +import { Session } from '@dm3-org/dm3-lib-delivery'; +import { ethers } from 'ethers'; import { Redis, RedisPrefix } from '../getDatabase'; -import { Session, spamFilter } from '@dm3-org/dm3-lib-delivery'; -import { getIdEnsName } from '../getIdEnsName'; export function getAccount(redis: Redis) { - return async (ensName: string) => { - let session = await redis.get( - RedisPrefix.Account + (await getIdEnsName(redis)(ensName)), + return async (address: string) => { + const session = await redis.get( + RedisPrefix.Account + ethers.utils.getAddress(address), ); - return session - ? (JSON.parse(session) as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }) - : null; + if (!session) { + console.debug('there is no account for this address: ', address); + return null; + } + + return JSON.parse(session) as Session; }; } diff --git a/packages/delivery-service/src/persistence/account/hasAccount.ts b/packages/delivery-service/src/persistence/account/hasAccount.ts new file mode 100644 index 000000000..dc073088e --- /dev/null +++ b/packages/delivery-service/src/persistence/account/hasAccount.ts @@ -0,0 +1,18 @@ +import { Redis, RedisPrefix } from '../getDatabase'; +import { ethers } from 'ethers'; + +export function hasAccount(redis: Redis) { + return async (address: string) => { + if (!ethers.utils.isAddress(address)) { + console.debug('hasAccount: Invalid address: ', address); + throw Error('hasAccount: Invalid address'); + } + + let account = await redis.get( + RedisPrefix.Account + ethers.utils.getAddress(address), + ); + + // return true if account exists, false otherwise + return !!account; + }; +} diff --git a/packages/delivery-service/src/persistence/account/index.ts b/packages/delivery-service/src/persistence/account/index.ts index 37cd75311..e13cb3151 100644 --- a/packages/delivery-service/src/persistence/account/index.ts +++ b/packages/delivery-service/src/persistence/account/index.ts @@ -1,4 +1,5 @@ import { setAccount } from './setAccount'; import { getAccount } from './getAccount'; import { getIdEnsName } from '../getIdEnsName'; -export default { setAccount, getAccount, getIdEnsName }; +import { hasAccount } from './hasAccount'; +export default { setAccount, getAccount, getIdEnsName, hasAccount }; diff --git a/packages/delivery-service/src/persistence/account/setAccount.test.ts b/packages/delivery-service/src/persistence/account/setAccount.test.ts index 259a77c19..b5099c65b 100644 --- a/packages/delivery-service/src/persistence/account/setAccount.test.ts +++ b/packages/delivery-service/src/persistence/account/setAccount.test.ts @@ -1,14 +1,9 @@ import { Redis, IDatabase, getRedisClient, getDatabase } from '../getDatabase'; import { UserProfile } from '@dm3-org/dm3-lib-profile'; import { Session } from '@dm3-org/dm3-lib-delivery'; -import winston from 'winston'; const USER_ADDRESS = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292'; -global.logger = winston.createLogger({ - transports: [new winston.transports.Console()], -}); - describe('Set Session', () => { let redisClient: Redis; let db: IDatabase; @@ -52,6 +47,61 @@ describe('Set Session', () => { signature: 'foo', }); }); + it('Creates a new Session and uses normalized address', async () => { + const profile: UserProfile = { + publicEncryptionKey: '', + publicSigningKey: '', + deliveryServices: [], + }; + const session: Session = { + // Address is not normalized + account: USER_ADDRESS.toUpperCase(), + signedUserProfile: { profile, signature: 'foo' }, + token: '', + createdAt: 0, + profileExtension: { + notSupportedMessageTypes: [], + }, + }; + + const priorSetSession = await db.getAccount(USER_ADDRESS); + //User has no session yet + expect(priorSetSession).toBe(null); + await db.setAccount(USER_ADDRESS, session); + + const afterSetSession = await db.getAccount(USER_ADDRESS); + //User has no session yet + expect(afterSetSession?.signedUserProfile).toEqual({ + profile, + signature: 'foo', + }); + }); + + it('Rejects session with an invalid address', async () => { + const profile: UserProfile = { + publicEncryptionKey: '', + publicSigningKey: '', + deliveryServices: [], + }; + const session: Session = { + account: USER_ADDRESS, + signedUserProfile: { + profile, + signature: '', + }, + token: '', + createdAt: 0, + profileExtension: { + notSupportedMessageTypes: [], + }, + }; + try { + await db.setAccount('foo', session); + fail(); + } catch (e) { + expect(e).toStrictEqual(Error('Invalid address')); + } + }); it('Rejects session with an invalid schema', async () => { const invalidSession = {} as Session; diff --git a/packages/delivery-service/src/persistence/account/setAccount.ts b/packages/delivery-service/src/persistence/account/setAccount.ts index fd307296d..b380c6987 100644 --- a/packages/delivery-service/src/persistence/account/setAccount.ts +++ b/packages/delivery-service/src/persistence/account/setAccount.ts @@ -1,18 +1,27 @@ -import { Redis, RedisPrefix } from '../getDatabase'; import { Session, schema } from '@dm3-org/dm3-lib-delivery'; -import { validateSchema, stringify } from '@dm3-org/dm3-lib-shared'; -import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; -import { getIdEnsName } from '../getIdEnsName'; +import { stringify, validateSchema } from '@dm3-org/dm3-lib-shared'; +import { ethers } from 'ethers'; +import { Redis, RedisPrefix } from '../getDatabase'; export function setAccount(redis: Redis) { - return async (ensName: string, session: Session) => { + return async (address: string, session: Session) => { const isValid = validateSchema(schema.Session, session); + const isAddess = ethers.utils.isAddress(address); if (!isValid) { + console.debug('Invalid session: ', session); throw Error('Invalid session'); } + + if (!isAddess) { + console.debug('Invalid address: ', address); + throw Error('Invalid address'); + } + + console.debug('set account ', address, session); + await redis.set( - RedisPrefix.Account + (await getIdEnsName(redis)(ensName)), + RedisPrefix.Account + ethers.utils.getAddress(address), stringify(session), ); }; diff --git a/packages/delivery-service/src/persistence/getDatabase.ts b/packages/delivery-service/src/persistence/getDatabase.ts index 573277af7..def35c006 100644 --- a/packages/delivery-service/src/persistence/getDatabase.ts +++ b/packages/delivery-service/src/persistence/getDatabase.ts @@ -1,9 +1,4 @@ -import { - IGlobalNotification, - IOtp, - Session, - spamFilter, -} from '@dm3-org/dm3-lib-delivery'; +import { IGlobalNotification, IOtp, Session } from '@dm3-org/dm3-lib-delivery'; import { EncryptionEnvelop } from '@dm3-org/dm3-lib-messaging'; import { IAccountDatabase } from '@dm3-org/dm3-lib-server-side'; import { @@ -47,11 +42,11 @@ export async function getRedisClient() { ); client.on('error', (err) => { - global.logger.error('Redis error: ' + (err as Error).message); + console.error('Redis error: ' + (err as Error).message); }); - client.on('reconnecting', () => global.logger.info('Redis reconnection')); - client.on('ready', () => global.logger.info('Redis ready')); + client.on('reconnecting', () => console.info('Redis reconnection')); + client.on('ready', () => console.info('Redis ready')); await client.connect(); @@ -74,6 +69,7 @@ export async function getDatabase( // Account setAccount: Account.setAccount(redis), getAccount: Account.getAccount(redis), + hasAccount: Account.hasAccount(redis), getIdEnsName: getIdEnsName(redis), syncAcknowledge: syncAcknowledge(redis), //Notification @@ -98,15 +94,11 @@ export async function getDatabase( } export interface IDatabase extends IAccountDatabase { - setAccount: (ensName: string, session: Session) => Promise; - getAccount: (ensName: string) => Promise< - | (Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }) - | null - >; + setAccount: (address: string, session: Session) => Promise; + getAccount: (address: string) => Promise; + //TODO use address getIncomingMessages: ( - ensName: string, + address: string, limit: number, ) => Promise; getMessages: ( @@ -115,6 +107,7 @@ export interface IDatabase extends IAccountDatabase { limit: number, ) => Promise; createMessage: ( + receiverAddress: string, conversationId: string, envelop: EncryptionEnvelop, ) => Promise; diff --git a/packages/delivery-service/src/persistence/getIdEnsName.ts b/packages/delivery-service/src/persistence/getIdEnsName.ts index 533df28ba..409842429 100644 --- a/packages/delivery-service/src/persistence/getIdEnsName.ts +++ b/packages/delivery-service/src/persistence/getIdEnsName.ts @@ -1,15 +1,7 @@ -import { Redis, RedisPrefix } from './getDatabase'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { Redis } from './getDatabase'; +//Todo replace db function get Db name export function getIdEnsName(redis: Redis) { - const resolveAlias = async (ensName: string): Promise => { - const lowerEnsName = normalizeEnsName( - (await redis.get( - RedisPrefix.Account + 'alias:' + normalizeEnsName(ensName), - )) ?? ensName, - ); - - return lowerEnsName === ensName ? ensName : resolveAlias(lowerEnsName); - }; - return resolveAlias; + return (ensName: string) => Promise.resolve(normalizeEnsName(ensName)); } diff --git a/packages/delivery-service/src/persistence/messages/createMessage.test.ts b/packages/delivery-service/src/persistence/messages/createMessage.test.ts index 698399da0..3c9d43556 100644 --- a/packages/delivery-service/src/persistence/messages/createMessage.test.ts +++ b/packages/delivery-service/src/persistence/messages/createMessage.test.ts @@ -48,7 +48,7 @@ describe('Create Message', () => { expect(priorCreateMessages.length).toBe(0); - await db.createMessage(conversionId, envelop); + await db.createMessage(RECEIVER_ADDRESS, conversionId, envelop); const afterCreateMessages = await db.getMessages(conversionId, 0, 50); @@ -73,8 +73,12 @@ describe('Create Message', () => { const firstMessageConversionId = SENDER_ADDRESS + RECEIVER_ADDRESS; const secondMessageConversionId = RECEIVER_ADDRESS + RECEIVER_ADDRESS; - await db.createMessage(firstMessageConversionId, envelop); - await db.createMessage(secondMessageConversionId, { + await db.createMessage( + RECEIVER_ADDRESS, + firstMessageConversionId, + envelop, + ); + await db.createMessage(RECEIVER_ADDRESS, secondMessageConversionId, { ...envelop, message: 'foo', metadata: { @@ -98,7 +102,7 @@ describe('Create Message', () => { it('Rejcts message with an invalid schema', async () => { const invalidMessage = {} as EncryptionEnvelop; try { - await db.createMessage('foo', invalidMessage); + await db.createMessage(RECEIVER_ADDRESS, 'foo', invalidMessage); fail(); } catch (e) { expect(e).toStrictEqual(Error('Invalid message')); diff --git a/packages/delivery-service/src/persistence/messages/createMessage.ts b/packages/delivery-service/src/persistence/messages/createMessage.ts index 53c60ae4a..da9b69a65 100644 --- a/packages/delivery-service/src/persistence/messages/createMessage.ts +++ b/packages/delivery-service/src/persistence/messages/createMessage.ts @@ -1,12 +1,9 @@ +import { EncryptionEnvelop, schema } from '@dm3-org/dm3-lib-messaging'; +import { stringify, validateSchema } from '@dm3-org/dm3-lib-shared'; import { Redis, RedisPrefix } from '../getDatabase'; -import { - schema, - DeliveryInformation, - EncryptionEnvelop, -} from '@dm3-org/dm3-lib-messaging'; -import { validateSchema, stringify } from '@dm3-org/dm3-lib-shared'; export function createMessage(redis: Redis) { return async ( + receiverAddress: string, conversationId: string, envelop: EncryptionEnvelop, createdAt: number = new Date().getTime(), @@ -25,26 +22,12 @@ export function createMessage(redis: Redis) { value: stringify(envelop), }); - /** - * add a redis set key = envelop.metadata.deliveryInformation.to and value = conversationId - */ - /** - * We can assume that the deliveryInformation is always encrypted because the - * DS must've encrypted it before persisting the message to the database. - * - * - * In the future we have to refactor the DeliveryInformation Type - * to we can ensure that on compile time. https://github.com/corpus-io/dm3/issues/479 - */ - const encryptedDeliverInformation = envelop.metadata - .deliveryInformation as DeliveryInformation; + console.debug('store incoming conversation for', receiverAddress); - await redis.zAdd( - RedisPrefix.IncomingConversations + encryptedDeliverInformation.to, - { - score: createdAt, - value: conversationId, - }, - ); + //We've to keep track of every incoming conversations for the address + await redis.zAdd(RedisPrefix.IncomingConversations + receiverAddress, { + score: createdAt, + value: conversationId, + }); }; } diff --git a/packages/delivery-service/src/persistence/messages/deleteExpiredMessages.test.ts b/packages/delivery-service/src/persistence/messages/deleteExpiredMessages.test.ts index 5dbb09bb6..71371ea7f 100644 --- a/packages/delivery-service/src/persistence/messages/deleteExpiredMessages.test.ts +++ b/packages/delivery-service/src/persistence/messages/deleteExpiredMessages.test.ts @@ -22,6 +22,7 @@ describe('Delete Expired messages', () => { it('Delete all messages createdAt before a given time', async () => { await db.createMessage( + '', 'foox', { message: 'hello', @@ -36,6 +37,7 @@ describe('Delete Expired messages', () => { 1, ); await db.createMessage( + '', 'foox', { message: 'world', @@ -49,7 +51,9 @@ describe('Delete Expired messages', () => { }, 3, ); + await db.createMessage( + '', 'foox', { message: 'dm3', diff --git a/packages/delivery-service/src/persistence/messages/syncAcknowledge.test.ts b/packages/delivery-service/src/persistence/messages/syncAcknowledge.test.ts index a956140c7..d317d9ff8 100644 --- a/packages/delivery-service/src/persistence/messages/syncAcknowledge.test.ts +++ b/packages/delivery-service/src/persistence/messages/syncAcknowledge.test.ts @@ -64,8 +64,8 @@ describe('Sync Acknowledge', () => { expect(priorCreateMessages.length).toBe(0); - await db.createMessage(conversionId, envelop1); - await db.createMessage(conversionId, envelop2); + await db.createMessage(RECEIVER_ADDRESS, conversionId, envelop1); + await db.createMessage(RECEIVER_ADDRESS, conversionId, envelop2); const afterCreateMessages = await db.getIncomingMessages( RECEIVER_ADDRESS, @@ -112,7 +112,7 @@ describe('Sync Acknowledge', () => { expect(priorCreateMessages.length).toBe(0); - await db.createMessage(conversionId, envelop1); + await db.createMessage(RECEIVER_ADDRESS, conversionId, envelop1); const afterCreateMessages = await db.getIncomingMessages( RECEIVER_ADDRESS, diff --git a/packages/delivery-service/src/persistence/messages/syncAcknowledge.ts b/packages/delivery-service/src/persistence/messages/syncAcknowledge.ts index b4236aac9..be740eb2b 100644 --- a/packages/delivery-service/src/persistence/messages/syncAcknowledge.ts +++ b/packages/delivery-service/src/persistence/messages/syncAcknowledge.ts @@ -1,3 +1,4 @@ +import { stringify } from '@dm3-org/dm3-lib-shared'; import { Redis, RedisPrefix } from '../getDatabase'; import { getMessages } from './getMessages'; @@ -33,7 +34,7 @@ export function syncAcknowledge(redis: Redis) { //remove the message from the sorted set const res = await redis.zRem( RedisPrefix.Conversation + conversationId, - JSON.stringify(message), + stringify(message), ); //returns true if the message is removed successfully return !!res; diff --git a/packages/delivery-service/src/profile.test.ts b/packages/delivery-service/src/profile.test.ts deleted file mode 100644 index 164a4f0d7..000000000 --- a/packages/delivery-service/src/profile.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import bodyParser from 'body-parser'; - -import { - UserProfile, - getProfileCreationMessage, -} from '@dm3-org/dm3-lib-profile'; -import { stringify } from '@dm3-org/dm3-lib-shared'; -import { ethers } from 'ethers'; -import express from 'express'; -import request from 'supertest'; -import profile from './profile'; -import winston from 'winston'; - -async function getEnsTextRecord( - provider: ethers.providers.JsonRpcProvider, - ensName: string, - recordKey: string, -) { - try { - const resolver = await provider.getResolver(ensName); - if (resolver === null) { - return; - } - - return await resolver.getText(recordKey); - } catch (e) { - return undefined; - } -} -global.logger = winston.createLogger({ - transports: [new winston.transports.Console()], -}); - -describe('Profile', () => { - describe('getProfile', () => { - it('Returns 200 if schema is valid', async () => { - const db = { - getAccount: async (ensName: string) => ({ - signedUserProfile: {}, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getIdEnsName: async (ensName: string) => ensName, - }; - - const app = express(); - app.use(bodyParser.json()); - app.use(profile(db as any, {} as any, {} as any, 'my-secret')); - - const { status } = await request(app) - .get('/0x99C19AB10b9EC8aC6fcda9586E81f6B73a298870') - .send(); - - expect(status).toBe(200); - }); - }); - - describe('submitUserProfile', () => { - it('Returns 200 if schema is valid', async () => { - const web3Provider = { resolveName: async () => wallet.address }; - const db = { - getAccount: async (ensName: string) => Promise.resolve(null), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getIdEnsName: async (ensName: string) => ensName, - }; - const app = express(); - app.use(bodyParser.json()); - app.use( - profile(db as any, web3Provider as any, {} as any, 'my-secret'), - ); - - const mnemonic = - 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; - - const wallet = ethers.Wallet.fromMnemonic(mnemonic); - - const userProfile: UserProfile = { - publicSigningKey: '2', - publicEncryptionKey: '1', - deliveryServices: [], - }; - - const createUserProfileMessage = getProfileCreationMessage( - stringify(userProfile), - wallet.address, - ); - const signature = await wallet.signMessage( - createUserProfileMessage, - ); - - const signedUserProfile = { - profile: userProfile, - signature, - }; - - const { status } = await request(app) - .post(`/${wallet.address}`) - .send(signedUserProfile); - - expect(status).toBe(200); - }); - it('Returns 400 if schema is invalid', async () => { - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve(null), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getIdEnsName: async (ensName: string) => ensName, - }; - - const app = express(); - app.use(bodyParser.json()); - app.use(profile(db as any, {} as any, {} as any, 'my-secret')); - - const userProfile: UserProfile = { - publicSigningKey: '2', - publicEncryptionKey: '1', - deliveryServices: [], - }; - - const mnemonic = - 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; - - const wallet = ethers.Wallet.fromMnemonic(mnemonic); - - const signature = await wallet.signMessage(stringify(userProfile)); - - const signedUserProfile = { - profile: userProfile, - signature: null, - }; - - const { status } = await request(app) - .post(`/1234`) - .send(signedUserProfile); - - expect(status).toBe(400); - }); - }); -}); diff --git a/packages/delivery-service/src/profile.ts b/packages/delivery-service/src/profile.ts deleted file mode 100644 index 3ee70d6c1..000000000 --- a/packages/delivery-service/src/profile.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getUserProfile, submitUserProfile } from '@dm3-org/dm3-lib-delivery'; -import { normalizeEnsName, schema } from '@dm3-org/dm3-lib-profile'; -import { validateSchema } from '@dm3-org/dm3-lib-shared'; -import express from 'express'; -import { ethers } from 'ethers'; -import { Server } from 'socket.io'; -import { IDatabase } from './persistence/getDatabase'; - -export default ( - db: IDatabase, - web3Provider: ethers.providers.JsonRpcProvider, - io: Server, - serverSecret: string, -) => { - const router = express.Router(); - - router.get('/:ensName', async (req: express.Request, res, next) => { - try { - const ensName = normalizeEnsName(req.params.ensName); - - const profile = await getUserProfile(db.getAccount, ensName); - if (profile) { - res.json(profile); - } else { - res.sendStatus(404); - } - } catch (e) { - next(e); - } - }); - - router.post('/:ensName', async (req: express.Request, res, next) => { - try { - const schemaIsValid = validateSchema( - schema.SignedUserProfile, - req.body, - ); - - if (!schemaIsValid) { - global.logger.error({ message: 'invalid schema' }); - return res.status(400).send({ error: 'invalid schema' }); - } - const ensName = normalizeEnsName(req.params.ensName); - global.logger.debug({ - method: 'POST', - url: req.url, - ensName, - disableSessionCheck: - process.env.DISABLE_SESSION_CHECK === 'true', - }); - - const data = await submitUserProfile( - web3Provider, - db.getAccount, - db.setAccount, - ensName, - req.body, - serverSecret, - ); - global.logger.debug({ - message: 'POST profile', - ensName, - data, - }); - - res.json(data); - } catch (e) { - global.logger.warn({ - message: 'POST profile', - error: JSON.stringify(e), - }); - // eslint-disable-next-line no-console - console.log('POST PROFILE ERROR', e); - res.status(400).send({ - message: `Couldn't store profile`, - error: JSON.stringify(e), - }); - } - }); - - return router; -}; diff --git a/packages/delivery-service/src/profile/profile.test.ts b/packages/delivery-service/src/profile/profile.test.ts new file mode 100644 index 000000000..1b679d6ca --- /dev/null +++ b/packages/delivery-service/src/profile/profile.test.ts @@ -0,0 +1,303 @@ +import { Session } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; +import { + UserProfile, + getProfileCreationMessage, +} from '@dm3-org/dm3-lib-profile'; +import { stringify } from '@dm3-org/dm3-lib-shared'; +import bodyParser from 'body-parser'; +import { ethers } from 'ethers'; +import express from 'express'; +import request from 'supertest'; +import { Profile as profile } from './profile'; +import { IDatabase } from '../persistence/getDatabase'; + +const serverSecret = 'veryImportantSecretToGenerateAndValidateJSONWebTokens'; + +const token = generateAuthJWT('alice.eth', serverSecret); + +const setUpApp = async ( + app: express.Express, + db: IDatabase, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string = 'my-secret', +) => { + app.use(bodyParser.json()); + app.use(profile(db, web3Provider, serverSecret)); +}; + +const createDbMock = async () => { + const sessionMocked = { + challenge: '123', + token: 'deprecated token that is not used anymore', + signedUserProfile: {}, + } as Session; + + const dbMock = { + getAccount: async (ensName: string) => + Promise.resolve(sessionMocked), // returns some valid session + setAccount: async (_: string, __: Session) => {}, + getIdEnsName: async (ensName: string) => ensName, + }; + + return dbMock as any; +}; + +describe('Profile', () => { + describe('getProfile', () => { + it('Returns 200 if user profile exists', async () => { + const app = express(); + + const db = await createDbMock(); + + const _web3Provider = { + resolveName: async () => + '0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5', + } as unknown as ethers.providers.JsonRpcProvider; + // I don't know why this function is needed in this test. + // Remove it after storage migration. + db.getUserStorage = () => {}; + setUpApp(app, db, _web3Provider); + + const response = await request(app) + .get('/alice.eth') + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + const status = response.status; + + expect(status).toBe(200); + }); + it('Returns 404 if user profile not exists', async () => { + const app = express(); + + const db = { + ...(await createDbMock()), + getAccount: async (ensName: string) => Promise.resolve(null), + }; + + const _web3Provider = { + resolveName: async () => + '0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5', + } as unknown as ethers.providers.JsonRpcProvider; + // I don't know why this function is needed in this test. + // Remove it after storage migration. + setUpApp(app, db, _web3Provider); + + const response = await request(app) + .get('/rando.eth') + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + const status = response.status; + + expect(status).toBe(404); + }); + it('Returns 400 if the name could not be resolved', async () => { + const app = express(); + + const db = await createDbMock(); + + const _web3Provider = { + resolveName: async () => null, + } as unknown as ethers.providers.JsonRpcProvider; + // I don't know why this function is needed in this test. + // Remove it after storage migration. + db.getUserStorage = () => {}; + setUpApp(app, db, _web3Provider); + + const response = await request(app) + .get('/alice.eth') + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + const status = response.status; + + expect(status).toBe(400); + expect(response.body.message).toBe( + `could not get profile for alice.eth. Unable to resolve address`, + ); + }); + }); + + describe('submitUserProfile', () => { + it('Returns 200 if user profile creation was successful', async () => { + const mnemonic = + 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + + // this provider must return the address of the wallet when resolveName is called + const _web3ProviderMock = { + resolveName: async () => wallet.address, + }; + // the db must return null when getAccount is called + const _dbMock = { + getAccount: async (ensName: string) => Promise.resolve(null), + setAccount: async (_: string, __: any) => { + return (_: any, __: any, ___: any) => {}; + }, + getIdEnsName: async (ensName: string) => ensName, + }; + + const app = express(); + setUpApp(app, _dbMock as any, _web3ProviderMock as any); + + const userProfile: UserProfile = { + publicSigningKey: '2', + publicEncryptionKey: '1', + deliveryServices: [], + }; + + const createUserProfileMessage = getProfileCreationMessage( + stringify(userProfile), + wallet.address, + ); + const signature = await wallet.signMessage( + createUserProfileMessage, + ); + + const signedUserProfile = { + profile: userProfile, + signature, + }; + + const response = await request(app) + .post(`/${wallet.address}`) + .send(signedUserProfile); + + const status = response.status; + + expect(status).toBe(200); + }); + it('Returns 400 if profile signature is wrong', async () => { + const mnemonic = + 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + + // this provider must return the address of the wallet when resolveName is called + const _web3ProviderMock = { + resolveName: async () => wallet.address, + }; + // the db must return null when getAccount is called + const _dbMock = { + getAccount: async (ensName: string) => Promise.resolve(null), + setAccount: async (_: string, __: any) => { + return (_: any, __: any, ___: any) => {}; + }, + getIdEnsName: async (ensName: string) => ensName, + }; + + const app = express(); + setUpApp(app, _dbMock as any, _web3ProviderMock as any); + + const userProfile: UserProfile = { + publicSigningKey: '2', + publicEncryptionKey: '1', + deliveryServices: [], + }; + //sign something else + const signature = await wallet.signMessage('0x1234'); + + const signedUserProfile = { + profile: userProfile, + signature, + }; + + const response = await request(app) + .post(`/${wallet.address}`) + .send(signedUserProfile); + + const status = response.status; + + expect(status).toBe(400); + expect(response.body.message).toBe("Couldn't store profile"); + }); + it('Returns 400 if addr cannot be resolved', async () => { + const mnemonic = + 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + + // this provider must return the address of the wallet when resolveName is called + const _web3ProviderMock = { + resolveName: async () => null, + }; + // the db must return null when getAccount is called + const _dbMock = { + getAccount: async (ensName: string) => Promise.resolve(null), + setAccount: async (_: string, __: any) => { + return (_: any, __: any, ___: any) => {}; + }, + getIdEnsName: async (ensName: string) => ensName, + }; + + const app = express(); + setUpApp(app, _dbMock as any, _web3ProviderMock as any); + + const userProfile: UserProfile = { + publicSigningKey: '2', + publicEncryptionKey: '1', + deliveryServices: [], + }; + + const createUserProfileMessage = getProfileCreationMessage( + stringify(userProfile), + wallet.address, + ); + const signature = await wallet.signMessage( + createUserProfileMessage, + ); + + const signedUserProfile = { + profile: userProfile, + signature, + }; + + const response = await request(app) + .post(`/rando.eth`) + .send(signedUserProfile); + + const status = response.status; + + expect(status).toBe(400); + expect(response.body.message).toBe( + `could not submit profile for rando.eth. Unable to resolve address`, + ); + }); + + it('Returns 400 if schema is invalid', async () => { + const web3Provider = {} as ethers.providers.JsonRpcProvider; + const app = express(); + setUpApp(app, await createDbMock(), web3Provider); + + const userProfile: UserProfile = { + publicSigningKey: '2', + publicEncryptionKey: '1', + deliveryServices: [], + }; + + const mnemonic = + 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + + const signature = await wallet.signMessage(stringify(userProfile)); + + const signedUserProfile = { + profile: userProfile, + signature: null, + }; + + const { status } = await request(app) + .post(`/1234`) + .send(signedUserProfile); + + expect(status).toBe(400); + }); + }); +}); diff --git a/packages/backend/src/profile.ts b/packages/delivery-service/src/profile/profile.ts similarity index 64% rename from packages/backend/src/profile.ts rename to packages/delivery-service/src/profile/profile.ts index b15139ea4..870a86a48 100644 --- a/packages/backend/src/profile.ts +++ b/packages/delivery-service/src/profile/profile.ts @@ -3,9 +3,9 @@ import { normalizeEnsName, schema } from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; import express from 'express'; -import { IDatabase } from './persistence/getDatabase'; +import { IDatabase } from '../persistence/getDatabase'; -export default ( +export const Profile = ( db: IDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string, @@ -16,7 +16,18 @@ export default ( try { const ensName = normalizeEnsName(req.params.ensName); - const profile = await getUserProfile(db.getAccount, ensName); + //use the webProvider to resolve the address here + const address = await web3Provider.resolveName(ensName); + if (!address) { + const message = `could not get profile for ${ensName}. Unable to resolve address`; + console.error(message); + return res.status(400).send({ message }); + } + + const profile = await getUserProfile( + db.getAccount, + ethers.utils.getAddress(address), + ); if (profile) { res.json(profile); } else { @@ -43,20 +54,25 @@ export default ( console.error({ message: 'invalid schema' }); return res.status(400).send({ error: 'invalid schema' }); } + const ensName = normalizeEnsName(req.params.ensName); - console.debug({ - method: 'POST', - url: req.url, - ensName, - disableSessionCheck: - process.env.DISABLE_SESSION_CHECK === 'true', - }); + //use the webProvider to resolve the address here + const address = await web3Provider.resolveName(ensName); + if (!address) { + const message = `could not submit profile for ${ensName}. Unable to resolve address`; + console.error(message); + return res.status(400).send({ message }); + } + + console.debug( + `create profile for ${ensName} with address ${address}`, + ); const data = await submitUserProfile( - web3Provider, db.getAccount, db.setAccount, - ensName, + //use normalized address + ethers.utils.getAddress(address), req.body, serverSecret, ); diff --git a/packages/delivery-service/src/rpc/methods/handleSubmitMessage.ts b/packages/delivery-service/src/rpc/methods/handleSubmitMessage.ts index 2426aa3c8..96d7d3546 100644 --- a/packages/delivery-service/src/rpc/methods/handleSubmitMessage.ts +++ b/packages/delivery-service/src/rpc/methods/handleSubmitMessage.ts @@ -1,16 +1,17 @@ -import { - incomingMessage, - DeliveryServiceProperties, -} from '@dm3-org/dm3-lib-delivery'; +import { DeliveryServiceProperties } from '@dm3-org/dm3-lib-delivery'; import { EncryptionEnvelop, schema } from '@dm3-org/dm3-lib-messaging'; -import { validateSchema, logError } from '@dm3-org/dm3-lib-shared'; +import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; +import { + IWebSocketManager, + logError, + validateSchema, +} from '@dm3-org/dm3-lib-shared'; import 'dotenv/config'; +import { ethers } from 'ethers'; import express from 'express'; import { Server } from 'socket.io'; +import { MessageProcessor } from '../../message/MessageProcessor'; import { IDatabase } from '../../persistence/getDatabase'; -import { ethers } from 'ethers'; -import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; -import { IWebSocketManager } from '@dm3-org/dm3-lib-shared'; export async function handleSubmitMessage( req: express.Request, @@ -56,34 +57,22 @@ export async function handleSubmitMessage( return res.status(400).send({ error }); } + const messageProcessor = new MessageProcessor( + db, + web3Provider, + webSocketManager, + deliveryServiceProperties, + keys, + (socketId: string, envelop: EncryptionEnvelop) => { + io.sockets.to(socketId).emit('message', envelop); + }, + ); + try { - await incomingMessage( - envelop, - keys.signingKeyPair, - keys.encryptionKeyPair, - deliveryServiceProperties.sizeLimit, - deliveryServiceProperties.notificationChannel, - db.getAccount, - db.createMessage, - (socketId: string, envelop: EncryptionEnvelop) => { - io.sockets.to(socketId).emit('message', envelop); - }, - web3Provider, - db.getIdEnsName, - db.getUsersNotificationChannels, - webSocketManager, - ); - res.send(200); + await messageProcessor.processEnvelop(envelop); + res.sendStatus(200); } catch (error) { - global.logger.warn({ - method: 'RPC SUBMIT MESSAGE', - error: JSON.stringify(error), - }); - logError({ - text: '[handleSubmitMessage]', - error, - }); - console.log('handle submit message error'); + console.error('handle submit message error'); console.error(error); return res.status(400).send(); diff --git a/packages/delivery-service/src/rpc/rpc-proxy.test.ts b/packages/delivery-service/src/rpc/rpc-proxy.test.ts index 2f64278c0..ff19d50da 100644 --- a/packages/delivery-service/src/rpc/rpc-proxy.test.ts +++ b/packages/delivery-service/src/rpc/rpc-proxy.test.ts @@ -1,47 +1,98 @@ -import { createKeyPair } from '@dm3-org/dm3-lib-crypto'; import { normalizeEnsName, UserProfile } from '@dm3-org/dm3-lib-profile'; -import { IWebSocketManager, stringify } from '@dm3-org/dm3-lib-shared'; +import { + ethersHelper, + IWebSocketManager, + stringify, +} from '@dm3-org/dm3-lib-shared'; +import { + getMockDeliveryServiceProfile, + MockDeliveryServiceProfile, + MockedUserProfile, + MockMessageFactory, + mockUserProfile, +} from '@dm3-org/dm3-lib-test-helper'; import { Axios } from 'axios'; import bodyParser from 'body-parser'; +import { ethers } from 'ethers'; import express from 'express'; import request from 'supertest'; import winston from 'winston'; -import { testData } from '../../../../test-data/encrypted-envelops.test'; import RpcProxy from './rpc-proxy'; +import { EncryptionEnvelop } from '@dm3-org/dm3-lib-messaging'; global.logger = winston.createLogger({ transports: [new winston.transports.Console()], }); -const SENDER_NAME = 'alice.eth'; -const RECEIVER_NAME = 'bob.eth'; - -const SENDER_ADDRESS = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292'; -const RECEIVER_ADDRESS = '0xDd36ae7F9a8E34FACf1e110c6e9d37D0dc917855'; - const mockWsManager: IWebSocketManager = { isConnected: function (ensName: string): Promise { return Promise.resolve(false); }, }; -const keyPair = createKeyPair(); - -const keysA = { - encryptionKeyPair: { - publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', - privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', - }, - signingKeyPair: { - publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', - privateKey: - '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', - }, - storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', - storageEncryptionNonce: 0, -}; +const RECEIVER_NAME = 'alice.eth'; +const SENDER_NAME = 'bob.eth'; describe('rpc-Proxy', () => { + let sender: MockedUserProfile; + let receiver: MockedUserProfile; + let ds: MockDeliveryServiceProfile; + + beforeEach(async () => { + const receiverWallet = ethers.Wallet.createRandom(); + sender = await mockUserProfile( + ethers.Wallet.createRandom(), + SENDER_NAME, + ['http://localhost:3000'], + ); + receiver = await mockUserProfile(receiverWallet, RECEIVER_NAME, [ + 'http://localhost:3000', + ]); + ds = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'http://localhost:3000', + ); + }); + + const getAccount = async (address: string) => { + const emptyProfile: UserProfile = { + publicSigningKey: '', + publicEncryptionKey: '', + deliveryServices: [''], + }; + + const isSender = ethersHelper.formatAddress(address) === sender.address; + const isReceiver = + ethersHelper.formatAddress(address) === receiver.address; + + const session = ( + account: string, + token: string, + profile: UserProfile, + ) => ({ + account, + signedUserProfile: { + profile, + signature: '', + }, + token, + }); + + if (isSender) { + return session(sender.address, '123', emptyProfile); + } + + if (isReceiver) { + return session(RECEIVER_NAME, 'abc', { + ...emptyProfile, + publicEncryptionKey: + receiver.profileKeys.encryptionKeyPair.publicKey, + }); + } + + return null; + }; + describe('routing', () => { it('Should block non-dm3 related requests', async () => { const app = express(); @@ -53,7 +104,7 @@ describe('rpc-Proxy', () => { {} as any, {} as any, {} as any, - keysA, + ds.keys, mockWsManager, ), ); @@ -83,22 +134,25 @@ describe('rpc-Proxy', () => { post: mockPost, } as Partial; - const keys = { - signing: keysA.signingKeyPair, - encryption: keysA.encryptionKeyPair, - }; - process.env.SIGNING_PUBLIC_KEY = keys.signing.publicKey; - process.env.SIGNING_PRIVATE_KEY = keys.signing.privateKey; - process.env.ENCRYPTION_PUBLIC_KEY = keys.encryption.publicKey; - process.env.ENCRYPTION_PRIVATE_KEY = keys.encryption.privateKey; + process.env.SIGNING_PUBLIC_KEY = ds.keys.signingKeyPair.publicKey; + process.env.SIGNING_PRIVATE_KEY = ds.keys.signingKeyPair.privateKey; + process.env.ENCRYPTION_PUBLIC_KEY = + ds.keys.encryptionKeyPair.publicKey; + process.env.ENCRYPTION_PRIVATE_KEY = + ds.keys.encryptionKeyPair.privateKey; const deliveryServiceProperties = { sizeLimit: 2 ** 14, notificationChannel: [], }; + const web3Provider = { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - }; + resolveName: async (name: string) => { + if (name === 'alice.eth') { + return receiver.address; + } + }, + } as any; + const db = { createMessage: () => {}, getAccount, @@ -122,31 +176,23 @@ describe('rpc-Proxy', () => { io as any, web3Provider as any, db as any, - keysA, + ds.keys, mockWsManager, ), ); + const envelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); + const { status } = await request(app) .post('/') .send({ jsonrpc: '2.0', method: 'dm3_submitMessage', - params: [ - JSON.stringify({ - message: '', - metadata: { - deliveryInformation: stringify( - testData.deliveryInformation, - ), - signature: '', - encryptedMessageHash: '', - version: '', - encryptionScheme: 'x25519-chacha20-poly1305', - }, - }), - '123', - ], + params: [JSON.stringify(envelop), '123'], }); expect(mockPost).not.toBeCalled(); @@ -161,22 +207,25 @@ describe('rpc-Proxy', () => { post: mockPost, } as Partial; - const keys = { - signing: keysA.signingKeyPair, - encryption: keysA.encryptionKeyPair, - }; - process.env.SIGNING_PUBLIC_KEY = keys.signing.publicKey; - process.env.SIGNING_PRIVATE_KEY = keys.signing.privateKey; - process.env.ENCRYPTION_PUBLIC_KEY = keys.encryption.publicKey; - process.env.ENCRYPTION_PRIVATE_KEY = keys.encryption.privateKey; + process.env.SIGNING_PUBLIC_KEY = ds.keys.signingKeyPair.publicKey; + process.env.SIGNING_PRIVATE_KEY = ds.keys.signingKeyPair.privateKey; + process.env.ENCRYPTION_PUBLIC_KEY = + ds.keys.encryptionKeyPair.publicKey; + process.env.ENCRYPTION_PRIVATE_KEY = + ds.keys.encryptionKeyPair.privateKey; + const deliveryServiceProperties = { sizeLimit: 2 ** 14, notificationChannel: [], }; + const web3Provider = { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - }; + resolveName: async (name: string) => { + if (name === 'alice.eth') { + return receiver.address; + } + }, + } as any; const db = { createMessage: () => {}, getAccount, @@ -200,30 +249,22 @@ describe('rpc-Proxy', () => { io as any, web3Provider as any, db as any, - keysA, + ds.keys, mockWsManager, ), ); + const envelop: EncryptionEnvelop = await MockMessageFactory( + sender, + receiver, + ds, + ).createEncryptedEnvelop('hello dm3'); const { status } = await request(app) .post('/') .send({ jsonrpc: '2.0', method: 'dm3_submitMessage', - params: [ - JSON.stringify({ - message: '', - metadata: { - deliveryInformation: stringify( - testData.deliveryInformation, - ), - signature: '', - encryptedMessageHash: '', - version: '', - encryptionScheme: 'x25519-chacha20-poly1305', - }, - }), - ], + params: [JSON.stringify(envelop)], }); expect(mockPost).not.toBeCalled(); @@ -253,7 +294,7 @@ describe('rpc-Proxy', () => { {} as any, {} as any, {} as any, - keysA, + ds.keys, {} as any, ), ); @@ -293,7 +334,7 @@ describe('rpc-Proxy', () => { {} as any, {} as any, {} as any, - keysA, + ds.keys, mockWsManager, ), ); @@ -334,7 +375,7 @@ describe('rpc-Proxy', () => { {} as any, web3Provider as any, db as any, - keysA, + ds.keys, mockWsManager, ), ); @@ -399,7 +440,7 @@ describe('rpc-Proxy', () => { {} as any, web3Provider as any, db as any, - keysA, + ds.keys, mockWsManager, ), ); @@ -423,36 +464,3 @@ describe('rpc-Proxy', () => { }); }); }); - -const getAccount = async (ensName: string) => { - const emptyProfile: UserProfile = { - publicSigningKey: '', - publicEncryptionKey: '', - deliveryServices: [''], - }; - - const isSender = normalizeEnsName(ensName) === SENDER_NAME; - const isReceiver = normalizeEnsName(ensName) === RECEIVER_NAME; - - const session = (account: string, token: string, profile: UserProfile) => ({ - account, - signedUserProfile: { - profile, - signature: '', - }, - token, - }); - - if (isSender) { - return session(SENDER_ADDRESS, '123', emptyProfile); - } - - if (isReceiver) { - return session(RECEIVER_NAME, 'abc', { - ...emptyProfile, - publicEncryptionKey: (await keyPair).publicKey, - }); - } - - return null; -}; diff --git a/packages/delivery-service/src/socketAuth.ts b/packages/delivery-service/src/socketAuth.ts new file mode 100644 index 000000000..51d2cd8c5 --- /dev/null +++ b/packages/delivery-service/src/socketAuth.ts @@ -0,0 +1,55 @@ +/* eslint-disable no-console */ +import { checkToken } from '@dm3-org/dm3-lib-server-side'; +import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { ethers } from 'ethers'; +import { Socket } from 'socket.io'; +import { ExtendedError } from 'socket.io/dist/namespace'; +import type { IDatabase } from './persistence/getDatabase'; + +export function socketAuth( + db: IDatabase, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string, +) { + return async ( + socket: Socket, + next: (err?: ExtendedError | undefined) => void, + ) => { + try { + const ensName = normalizeEnsName( + socket.handshake.auth.account.ensName, + ); + console.log('Start WS auth for ', ensName, socket.id); + + if ( + !(await checkToken( + web3Provider, + db.hasAccount, + ensName, + socket.handshake.auth.token as string, + serverSecret, + )) + ) { + console.log('check token has failed for WS '); + return next(new Error('check token has failed for WS')); + } + const session = await db.getAccount(ensName); + if (!session) { + throw Error('Could not get session'); + } + //we use session.account here as a key for setAccount here. + //We can do this because the address is used as account when the Session has been created. + //That saves a address lookup via ENS + await db.setAccount(session.account, { + ...session, + socketId: socket.id, + }); + } catch (e) { + console.log('socket auth error'); + console.log(e); + next(e as Error); + } + + next(); + }; +} diff --git a/packages/delivery-service/src/ws/WebSocketManager.test.ts b/packages/delivery-service/src/ws/WebSocketManager.test.ts index aceed7abf..bf65316f8 100644 --- a/packages/delivery-service/src/ws/WebSocketManager.test.ts +++ b/packages/delivery-service/src/ws/WebSocketManager.test.ts @@ -2,7 +2,15 @@ import { io as Client } from 'socket.io-client'; import { Server as SocketIoServer } from 'socket.io'; import { createServer, Server as HttpServerType } from 'http'; import { AUTHORIZED, UNAUTHORIZED, WebSocketManager } from './WebSocketManager'; -import { generateAuthJWT } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; +import { + getMockDeliveryServiceProfile, + MockDeliveryServiceProfile, + MockedUserProfile, + mockUserProfile, +} from '@dm3-org/dm3-lib-test-helper'; +import { ethers } from 'ethers'; +import account from '../persistence/account'; const serverSecret = 'verySecretAndImportantServerSecret'; describe('WebSocketManager', () => { @@ -12,6 +20,13 @@ describe('WebSocketManager', () => { let httpServer; let socketIoServer; + let sender: MockedUserProfile; + let receiver: MockedUserProfile; + let receiverOnGno: MockedUserProfile; + let rando: MockedUserProfile; + + let ds: MockDeliveryServiceProfile; + beforeEach(async () => { httpServer = await mockHttpServer(4060); socketIoServer = new SocketIoServer(httpServer, { @@ -22,6 +37,31 @@ describe('WebSocketManager', () => { optionsSuccessStatus: 204, }, }); + + //The receiver might use the same address for different networks. Hence we keep the wallet separate + + const receiverWallet = ethers.Wallet.createRandom(); + sender = await mockUserProfile( + ethers.Wallet.createRandom(), + 'bob.eth', + ['http://localhost:3000'], + ); + receiver = await mockUserProfile(receiverWallet, 'alice.eth', [ + 'http://localhost:3000', + ]); + receiverOnGno = await mockUserProfile(receiverWallet, 'alice.gno', [ + 'http://localhost:3000', + ]); + rando = await mockUserProfile( + ethers.Wallet.createRandom(), + 'rando.eth', + ['http://localhost:3000'], + ); + + ds = await getMockDeliveryServiceProfile( + ethers.Wallet.createRandom(), + 'http://localhost:3000', + ); }); afterEach(() => { @@ -99,6 +139,7 @@ describe('WebSocketManager', () => { token: 'token', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; new WebSocketManager( @@ -134,15 +175,17 @@ describe('WebSocketManager', () => { describe('isConnected', () => { it('returns true if name has one session', async () => { const mockedWeb3Provider = { - resolveName: (_: string) => Promise.resolve('0x123'), + resolveName: (_: string) => Promise.resolve(receiver.address), } as any; const mockedDatabase = { getAccount: () => Promise.resolve({ + account: receiver.address, token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -155,9 +198,12 @@ describe('WebSocketManager', () => { client0 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'bob.eth', + ensName: receiver.account.ensName, }, - token: generateAuthJWT('bob.eth', serverSecret), + token: generateAuthJWT( + receiver.account.ensName, + serverSecret, + ), }, }); @@ -173,20 +219,22 @@ describe('WebSocketManager', () => { ]); expect(socket0IsConnected).toBe(true); - const isConnected = await wsManager.isConnected('bob.eth'); + const isConnected = await wsManager.isConnected(receiver.address); expect(isConnected).toBe(true); }); it('returns true if name has at least one session', async () => { const mockedWeb3Provider = { - resolveName: (_: string) => Promise.resolve('0x123'), + resolveName: (_: string) => Promise.resolve(receiver.address), } as any; const mockedDatabase = { getAccount: () => Promise.resolve({ + account: receiver.address, token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -199,17 +247,23 @@ describe('WebSocketManager', () => { client0 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'bob.eth', + ensName: receiver.account.ensName, }, - token: generateAuthJWT('bob.eth', serverSecret), + token: generateAuthJWT( + receiver.account.ensName, + serverSecret, + ), }, }); client1 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'bob.eth', + ensName: receiver.account.ensName, }, - token: generateAuthJWT('bob.eth', serverSecret), + token: generateAuthJWT( + receiver.account.ensName, + serverSecret, + ), }, }); @@ -234,25 +288,27 @@ describe('WebSocketManager', () => { expect(socket0IsConnected).toBe(true); expect(socket1IsConnected).toBe(true); - let isConnected = await wsManager.isConnected('bob.eth'); + let isConnected = await wsManager.isConnected(receiver.address); expect(isConnected).toBe(true); client0.close(); await wait(500); - isConnected = await wsManager.isConnected('bob.eth'); + isConnected = await wsManager.isConnected(receiver.address); expect(isConnected).toBe(true); }); it('returns false if name is unknown', async () => { const mockedWeb3Provider = { - resolveName: (_: string) => Promise.resolve('0x123'), + resolveName: (_: string) => Promise.resolve(receiver.address), } as any; const mockedDatabase = { getAccount: () => Promise.resolve({ + account: receiver.address, token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -265,9 +321,12 @@ describe('WebSocketManager', () => { client0 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'bob.eth', + ensName: receiver.account.ensName, }, - token: generateAuthJWT('bob.eth', serverSecret), + token: generateAuthJWT( + receiver.account.ensName, + serverSecret, + ), }, }); @@ -283,20 +342,43 @@ describe('WebSocketManager', () => { ]); expect(socket0IsConnected).toBe(true); - const isConnected = await wsManager.isConnected('alice.eth'); + const isConnected = await wsManager.isConnected(rando.address); expect(isConnected).toBe(false); }); it('keeps track of different independent sessions', async () => { const mockedWeb3Provider = { - resolveName: (_: string) => Promise.resolve('0x123'), + resolveName: (_: string) => { + if (_ === receiver.account.ensName) { + return Promise.resolve(receiver.address); + } + if (_ === sender.account.ensName) { + return Promise.resolve(sender.address); + } + if (_ === rando.account.ensName) { + return Promise.resolve(rando.address); + } + }, } as any; const mockedDatabase = { - getAccount: () => - Promise.resolve({ - token: 'old token that is not used anymore', - createdAt: new Date().getTime(), - }), + getAccount: (name: string) => { + if (name === receiver.account.ensName) { + return Promise.resolve({ + account: receiver.address, + }); + } + if (name === sender.account.ensName) { + return Promise.resolve({ + account: sender.address, + }); + } + if (name === rando.account.ensName) { + return Promise.resolve({ + account: rando.address, + }); + } + }, + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -309,27 +391,33 @@ describe('WebSocketManager', () => { client0 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'bob.eth', + ensName: receiver.account.ensName, }, - token: generateAuthJWT('bob.eth', serverSecret), + token: generateAuthJWT( + receiver.account.ensName, + serverSecret, + ), }, }); client1 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'alice.eth', + ensName: sender.account.ensName, }, - token: generateAuthJWT('alice.eth', serverSecret), + token: generateAuthJWT( + sender.account.ensName, + serverSecret, + ), }, }); client2 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'vitalik.eth', + ensName: rando.account.ensName, }, - token: generateAuthJWT('vitalik.eth', serverSecret), + token: generateAuthJWT(rando.account.ensName, serverSecret), }, }); @@ -365,27 +453,29 @@ describe('WebSocketManager', () => { expect(socket1IsConnected).toBe(true); expect(socket2IsConnected).toBe(true); - expect(await wsManager.isConnected('bob.eth')).toBe(true); - expect(await wsManager.isConnected('alice.eth')).toBe(true); - expect(await wsManager.isConnected('vitalik.eth')).toBe(true); + expect(await wsManager.isConnected(receiver.address)).toBe(true); + expect(await wsManager.isConnected(sender.address)).toBe(true); + expect(await wsManager.isConnected(rando.address)).toBe(true); client1.close(); await wait(500); - expect(await wsManager.isConnected('bob.eth')).toBe(true); - expect(await wsManager.isConnected('alice.eth')).toBe(false); - expect(await wsManager.isConnected('vitalik.eth')).toBe(true); + expect(await wsManager.isConnected(receiver.address)).toBe(true); + expect(await wsManager.isConnected(sender.address)).toBe(false); + expect(await wsManager.isConnected(rando.address)).toBe(true); }); it('returns false after the user has closed all its connections', async () => { const mockedWeb3Provider = { - resolveName: (_: string) => Promise.resolve('0x123'), + resolveName: (_: string) => Promise.resolve(receiver.address), } as any; const mockedDatabase = { getAccount: () => Promise.resolve({ + account: receiver.address, token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -398,9 +488,12 @@ describe('WebSocketManager', () => { client0 = await Client('http://localhost:4060', { auth: { account: { - ensName: 'bob.eth', + ensName: receiver.account.ensName, }, - token: generateAuthJWT('bob.eth', serverSecret), + token: generateAuthJWT( + receiver.account.ensName, + serverSecret, + ), }, }); @@ -416,12 +509,12 @@ describe('WebSocketManager', () => { ]); expect(socket0IsConnected).toBe(true); - let isConnected = await wsManager.isConnected('bob.eth'); + let isConnected = await wsManager.isConnected(receiver.address); expect(isConnected).toBe(true); client0.close(); await wait(500); - isConnected = await wsManager.isConnected('bob.eth'); + isConnected = await wsManager.isConnected(receiver.address); expect(isConnected).toBe(false); }); }); diff --git a/packages/delivery-service/src/ws/WebSocketManager.ts b/packages/delivery-service/src/ws/WebSocketManager.ts index 948e8888f..3b86d6638 100644 --- a/packages/delivery-service/src/ws/WebSocketManager.ts +++ b/packages/delivery-service/src/ws/WebSocketManager.ts @@ -1,4 +1,4 @@ -import { checkToken } from '@dm3-org/dm3-lib-delivery'; +import { checkToken } from '@dm3-org/dm3-lib-server-side'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { IWebSocketManager } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; @@ -39,11 +39,12 @@ export class WebSocketManager implements IWebSocketManager { /** * Checks if a user is connected. - * @param {string} ensName - The ENS name of the user. + * @param {string} address - The address of the user. * @returns {boolean} - Returns true if the user is connected with at least one socket, false otherwise. */ - public async isConnected(ensName: string) { - const connections = this.connections.get(ensName); + public async isConnected(address: string) { + const _address = ethers.utils.getAddress(address); + const connections = this.connections.get(_address); return !!(connections && connections.length > 0); } /** @@ -59,7 +60,7 @@ export class WebSocketManager implements IWebSocketManager { //Use the already existing function checkToken to check whether the token matches the provided ensName const hasSession = await checkToken( this.web3Provider, - this.db.getAccount, + this.db.hasAccount, ensName, token, this.serverSecret, @@ -74,14 +75,17 @@ export class WebSocketManager implements IWebSocketManager { return; } //Get the old connections and add the new one - const oldConnections = this.connections.get(ensName) || []; - this.connections.set(ensName, [...oldConnections, connection]); + const oldConnections = this.connections.get(session.account) || []; + this.connections.set(session.account, [ + ...oldConnections, + connection, + ]); //Send the authorized event connection.emit(AUTHORIZED); - console.log('connection established for ', ensName); + console.log('connection established for ', session.account); //When the socket disconnects we want them no longer in our connections List connection.on('disconnect', () => { - console.log('connection closed for ', ensName); + console.log('connection closed for ', session.account); this.removeConnection(connection); }); } catch (e) { @@ -96,17 +100,30 @@ export class WebSocketManager implements IWebSocketManager { * @private * @param {Socket} connection - The socket connection instance. */ - private removeConnection(connection: Socket) { + private async removeConnection(connection: Socket) { const ensName = normalizeEnsName( connection.handshake.auth.account.ensName, ); - const connections = this.connections.get(ensName); + + //the resolved address for the name + const address = await this.web3Provider.resolveName(ensName); + if (!address) { + console.debug('WS manager,could not resolve address for ', ensName); + return; + } + //the connections the address has created previously + const connections = this.connections.get(address); + + //if there are no known connections we return if (!connections) { return; } + //we find the connection that has disconnected and remove it from the list const newConnections = connections.filter( (c) => c.id !== connection.id, ); - this.connections.set(ensName, newConnections); + + //we assign the list conaining all others connections an address might have to the list without the disconnected connection + this.connections.set(address, newConnections); } } diff --git a/packages/delivery-service/tsconfig.json b/packages/delivery-service/tsconfig.json index b8721eeaa..0c91eb258 100644 --- a/packages/delivery-service/tsconfig.json +++ b/packages/delivery-service/tsconfig.json @@ -16,6 +16,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["src"], + "include": ["src",], "exclude": ["src/**/*.test.ts"] } diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 8a4403aab..b130e7283 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-integration-tests", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@dm3-org/dm3-lib-delivery": "workspace:^", "@dm3-org/dm3-lib-messaging": "workspace:^", diff --git a/packages/lib/billboard-api/package.json b/packages/lib/billboard-api/package.json index 35e6903d7..521426ec1 100644 --- a/packages/lib/billboard-api/package.json +++ b/packages/lib/billboard-api/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-billboard-client-api", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { diff --git a/packages/lib/crypto/package.json b/packages/lib/crypto/package.json index 1be741c02..23c70ac1f 100644 --- a/packages/lib/crypto/package.json +++ b/packages/lib/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-crypto", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", diff --git a/packages/lib/delivery-api/package.json b/packages/lib/delivery-api/package.json index 310efa96b..a8c22be60 100644 --- a/packages/lib/delivery-api/package.json +++ b/packages/lib/delivery-api/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-delivery-api", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { diff --git a/packages/lib/delivery-api/src/index.ts b/packages/lib/delivery-api/src/index.ts index 4f4b05b40..5379aad0c 100644 --- a/packages/lib/delivery-api/src/index.ts +++ b/packages/lib/delivery-api/src/index.ts @@ -1,6 +1,5 @@ export * from './auth-http'; export * from './profile-http'; -export * from './messaging-http'; export * from './messaging-ws'; export * from './ds-properties'; export * from './notification-http'; diff --git a/packages/lib/delivery-api/src/messaging-http.ts b/packages/lib/delivery-api/src/messaging-http.ts deleted file mode 100644 index 0fbef06d3..000000000 --- a/packages/lib/delivery-api/src/messaging-http.ts +++ /dev/null @@ -1,93 +0,0 @@ -import axios from 'axios'; -import { - Account, - getDeliveryServiceClient, - normalizeEnsName, -} from '@dm3-org/dm3-lib-profile'; -import { Acknowledgment } from '@dm3-org/dm3-lib-delivery'; -import { EncryptionEnvelop } from '@dm3-org/dm3-lib-messaging'; -import { ethers } from 'ethers'; -import { checkAccount, getAxiosConfig } from './utils'; - -const DELIVERY_PATH = process.env.REACT_APP_BACKEND + '/delivery'; - -//TOOD REMOVE AFTER STORAGE REFACTOR -export async function syncAcknoledgment( - provider: ethers.providers.JsonRpcProvider, - account: Account, - acknoledgments: Acknowledgment[], - token: string, - lastMessagePull: number, -): Promise { - const { profile } = checkAccount(account); - - const url = `${DELIVERY_PATH}/messages/${normalizeEnsName( - account!.ensName, - )}/syncAcknoledgment/${lastMessagePull}`; - - return getDeliveryServiceClient( - profile, - provider, - async (url: string) => (await axios.get(url)).data, - ).post(url, { acknoledgments }, getAxiosConfig(token)); -} -export type SyncAcknoledgment = typeof syncAcknoledgment; - -/** - * let the delivery service know that messages have been stored - * and can be deleted on the delivery service - * @param provider Ethers provider - * @param account The dm3 account - * @param acknoledgments Acknoledgment that messages have been stored - * @param token The auth token - * @param lastMessagePull Timestamp of the last message pull - */ -export async function syncAcknowledgment( - provider: ethers.providers.JsonRpcProvider, - account: Account, - acknoledgments: Acknowledgment[], - token: string, - lastSyncTime: number, -): Promise { - const { profile } = checkAccount(account); - - const url = `${DELIVERY_PATH}/messages/${normalizeEnsName( - account!.ensName, - )}/syncAcknowledgment/${lastSyncTime}`; - - return getDeliveryServiceClient( - profile, - provider, - async (url: string) => (await axios.get(url)).data, - ).post(url, { acknoledgments }, getAxiosConfig(token)); -} -export type SyncAcknowledgment = typeof syncAcknoledgment; - -/** - * returns the bufferd message which were send form contactEnsName - * @param account The dm3 account - * @param token The auth token - * @param contactEnsName The sender ENS name - * @param provider Ethers provider - */ -export async function getNewMessages( - account: Account, - token: string, - contactEnsName: string, - provider: ethers.providers.JsonRpcProvider, -): Promise { - const { profile } = checkAccount(account); - - const url = `${DELIVERY_PATH}/messages/${normalizeEnsName( - account!.ensName, - )}/contact/${contactEnsName}`; - - const { data } = await getDeliveryServiceClient( - profile, - provider, - async (url: string) => (await axios.get(url)).data, - ).get(url, getAxiosConfig(token)); - - return data; -} -export type GetNewMessages = typeof getNewMessages; diff --git a/packages/lib/delivery/package.json b/packages/lib/delivery/package.json index 769fb6126..e7f884a69 100644 --- a/packages/lib/delivery/package.json +++ b/packages/lib/delivery/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-delivery", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", @@ -15,6 +15,7 @@ "@dm3-org/dm3-lib-crypto": "workspace:^", "@dm3-org/dm3-lib-messaging": "workspace:^", "@dm3-org/dm3-lib-profile": "workspace:^", + "@dm3-org/dm3-lib-server-side": "workspace:^", "@dm3-org/dm3-lib-shared": "workspace:^", "@types/libsodium-wrappers": "^0.7.10", "ethers": "5.7.2", diff --git a/packages/lib/delivery/schemas.sh b/packages/lib/delivery/schemas.sh index 52d95b523..5a7aa09d4 100644 --- a/packages/lib/delivery/schemas.sh +++ b/packages/lib/delivery/schemas.sh @@ -1,4 +1,4 @@ yarn ts-json-schema-generator -f tsconfig.json --path Delivery.ts --type DeliveryServiceProperties -o ./src/schema/DeliveryServiceProperties.schema.json --no-type-check \ -&& yarn ts-json-schema-generator -f tsconfig.json --path Messages.ts --type Acknowledgment -o ./src/schema/Acknowledgment.schema.json --no-type-check \ +&& yarn ts-json-schema-generator -f tsconfig.json --path Messages.ts --type Acknowledgement -o ./src/schema/Acknowledgement.schema.json --no-type-check \ && yarn ts-json-schema-generator -f tsconfig.json --path Session.ts --type Session -o ./src/schema/Session.schema.json --no-type-check \ && yarn ts-json-schema-generator -f tsconfig.json --path notifications/types.ts --type NotificationChannel -o ./src/schema/NotificationChannel.schema.json --no-type-check \ diff --git a/packages/lib/delivery/src/Messages.test.ts b/packages/lib/delivery/src/Messages.test.ts deleted file mode 100644 index 342295e6c..000000000 --- a/packages/lib/delivery/src/Messages.test.ts +++ /dev/null @@ -1,822 +0,0 @@ -import { checkSignature, decryptAsymmetric } from '@dm3-org/dm3-lib-crypto'; -import { EncryptionEnvelop, Postmark } from '@dm3-org/dm3-lib-messaging'; -import { UserProfile, normalizeEnsName } from '@dm3-org/dm3-lib-profile'; -import { sha256 } from '@dm3-org/dm3-lib-shared'; -import { BigNumber, ethers } from 'ethers'; -import { testData } from '../../../../test-data/encrypted-envelops.test'; -import { stringify } from '../../shared/src/stringify'; -import { getConversationId, getMessages, incomingMessage } from './Messages'; -import { Session } from './Session'; -import { - IWebSocketManager, - NotificationChannel, - NotificationChannelType, -} from '@dm3-org/dm3-lib-shared'; -import { SpamFilterRules } from './spam-filter/SpamFilterRules'; - -const SENDER_NAME = 'alice.eth'; -const RECEIVER_NAME = 'bob.eth'; -const SENDER_ADDRESS = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292'; -const RECEIVER_ADDRESS = '0xDd36ae7F9a8E34FACf1e110c6e9d37D0dc917855'; - -const keysA = { - encryptionKeyPair: { - publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', - privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', - }, - signingKeyPair: { - publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', - privateKey: - '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', - }, - storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', - storageEncryptionNonce: 0, -}; - -const keysB = { - encryptionKeyPair: { - publicKey: 'GYZ1ZQZvyGyHb28CcAb2Ttq+Q1FV//pSaXRurAAUJgg=', - privateKey: 'OVZDqoByMGbEzhxdHcTurzpEwxxP2/1EMiNUx+ST5g4=', - }, - signingKeyPair: { - publicKey: '7mTFDrawl87je1NNnRhYEoV4tGVXhHlTPcadloqivm0=', - privateKey: - '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJbuZMUOtrCXzuN7U02dGFgShXi0ZVeEeVM9xp2WiqK+bQ==', - }, - storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJY=', - storageEncryptionNonce: 0, -}; - -const getAccount = async ( - ensName: string, - socketId?: string, -): Promise<(Session & { spamFilterRules: SpamFilterRules }) | null> => { - const emptyProfile: UserProfile = { - publicSigningKey: '', - publicEncryptionKey: '', - deliveryServices: [''], - }; - const isSender = normalizeEnsName(ensName) === SENDER_NAME; - const isReceiver = normalizeEnsName(ensName) === RECEIVER_NAME; - - const session = ( - account: string, - token: string, - profile: UserProfile, - ): Session => ({ - account, - signedUserProfile: { - profile, - signature: '', - }, - token, - createdAt: new Date().getTime(), - profileExtension: { - encryptionAlgorithm: [], - notSupportedMessageTypes: [], - }, - socketId, - }); - - if (isSender) { - return { - ...session(SENDER_NAME, '123', emptyProfile), - spamFilterRules: {}, - }; - } - - if (isReceiver) { - return { - ...session(RECEIVER_NAME, 'abc', { - ...emptyProfile, - publicEncryptionKey: keysB.encryptionKeyPair.publicKey, - }), - spamFilterRules: {}, - }; - } - - return null; -}; - -const getNotificationChannels = (user: string) => { - return Promise.resolve([]); -}; -jest.mock('nodemailer'); - -const sendMailMock = jest.fn(); - -const nodemailer = require('nodemailer'); //doesn't work with import. idk why -nodemailer.createTransport.mockReturnValue({ - sendMail: sendMailMock, - close: () => {}, -}); - -describe('Messages', () => { - describe('incomingMessage', () => { - it('accepts an incoming message', async () => { - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => {}; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - expect.assertions(1); - - await expect(() => - incomingMessage( - { - message: '', - metadata: { - version: '', - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - encryptedMessageHash: '', - signature: '', - }, - }, - - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - getAccount, - storeNewMessage, - () => {}, - { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any, - async () => '', - getNotificationChannels, - mockWsManager, - ), - ).not.toThrow(); - }); - - it('rejects an incoming message if it is to large', async () => { - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => {}; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - expect.assertions(1); - - await expect(() => - incomingMessage( - { - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: '', - encryptedMessageHash: '', - signature: '', - version: '', - }, - message: '', - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 1, - [], - getAccount, - storeNewMessage, - () => {}, - {} as ethers.providers.JsonRpcProvider, - async () => '', - getNotificationChannels, - mockWsManager, - ), - ).rejects.toEqual(Error('Message is too large')); - }); - it('rejects an incoming message if the receiver is unknown ', async () => { - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => {}; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - expect.assertions(1); - - await expect(() => - incomingMessage( - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - version: '', - encryptedMessageHash: '', - signature: '', - deliveryInformation: stringify( - testData.deliveryInformationB, - ), - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - getAccount, - storeNewMessage, - () => {}, - { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any, - async () => '', - getNotificationChannels, - mockWsManager, - ), - ).rejects.toEqual(Error('unknown session')); - }); - //TODO remove skip once spam-filter is implemented - it.skip('rejects message if the senders nonce is below the threshold', async () => { - let messageContainer: { - conversationId?: string; - envelop?: EncryptionEnvelop; - } = {}; - - const session = async (address: string) => - ({ - ...(await getAccount(address)), - spamFilterRules: { minNonce: 2 }, - } as Session & { spamFilterRules: SpamFilterRules }); - - const provider = { - getTransactionCount: async (_: string) => Promise.resolve(0), - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any; - - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => { - messageContainer = { conversationId, envelop }; - }; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - try { - await incomingMessage( - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - encryptedMessageHash: '', - signature: '', - version: '', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - session, - storeNewMessage, - () => {}, - provider, - async () => '', - getNotificationChannels, - mockWsManager, - ); - fail(); - } catch (err: any) { - expect(err.message).toBe( - 'Message does not match spam criteria', - ); - } - }); - it('rejects message if the senders eth balance is below the threshold', async () => { - let messageContainer: { - conversationId?: string; - envelop?: EncryptionEnvelop; - } = {}; - - const session = async (address: string) => - ({ - ...(await getAccount(address)), - spamFilterRules: { minBalance: '0xa' }, - } as Session & { spamFilterRules: SpamFilterRules }); - - const provider = { - getBalance: async (_: string) => - Promise.resolve(BigNumber.from(5)), - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any; - - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => { - messageContainer = { conversationId, envelop }; - }; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - try { - await incomingMessage( - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - version: '', - encryptedMessageHash: '', - signature: '', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - session, - storeNewMessage, - () => {}, - provider, - async () => '', - getNotificationChannels, - mockWsManager, - ); - fail(); - } catch (err: any) { - expect(err.message).toBe( - 'Message does not match spam criteria', - ); - } - }); - //TODO remove skip once spam-filter is implemented - it.skip('rejects message if the senders token balance is below the threshold', async () => { - let messageContainer: { - conversationId?: string; - envelop?: EncryptionEnvelop; - } = {}; - - const session = async (address: string) => - ({ - ...(await getAccount(address)), - spamFilterRules: { - minTokenBalance: { - address: - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - amount: '0xa', - }, - }, - } as Session & { spamFilterRules: SpamFilterRules }); - - const provider = { - _isProvider: true, - call: () => Promise.resolve(BigNumber.from(0).toHexString()), - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as unknown as ethers.providers.JsonRpcProvider; - - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => { - messageContainer = { conversationId, envelop }; - }; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - try { - await incomingMessage( - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - - version: '', - encryptedMessageHash: '', - signature: '', - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - session, - storeNewMessage, - () => {}, - provider, - async () => '', - getNotificationChannels, - mockWsManager, - ); - fail(); - } catch (err: any) { - expect(err.message).toBe( - 'Message does not match spam criteria', - ); - } - }); - - it('send mail after incoming message', async () => { - //Value stored at config - let messageContainer: { - conversationId?: string; - envelop?: EncryptionEnvelop; - } = {}; - - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => { - messageContainer = { conversationId, envelop }; - }; - - const sendMessageViaSocketMock = jest.fn(); - - const getNotificationChannels = (user: string) => { - return Promise.resolve([ - { - type: NotificationChannelType.EMAIL, - config: { - recipientEmailId: 'joe12345@gmail.com', - isVerified: true, - isEnabled: true, - }, - }, - ]); - }; - - const dsNotificationChannels: NotificationChannel[] = [ - { - type: NotificationChannelType.EMAIL, - config: { - smtpHost: 'smtp.gmail.com', - smtpPort: 587, - smtpEmail: 'abc@gmail.com', - smtpUsername: 'abc@gmail.com', - smtpPassword: 'abcd1234', - }, - }, - ]; - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - await incomingMessage( - { - message: '', - metadata: { - version: '', - encryptedMessageHash: '', - signature: '', - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - dsNotificationChannels, - getAccount, - storeNewMessage, - sendMessageViaSocketMock, - { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any, - async (ensName) => ensName, - getNotificationChannels, - mockWsManager, - ); - - expect(sendMailMock).toHaveBeenCalled(); - - //Check if the message was submitted to the socket - expect(sendMessageViaSocketMock).not.toBeCalled(); - }); - it('stores proper incoming message', async () => { - let messageContainer: { - conversationId?: string; - envelop?: EncryptionEnvelop; - } = {}; - - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => { - messageContainer = { conversationId, envelop }; - }; - - const sendMock = jest.fn(); - - const now = Date.now(); - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(false); - }, - }; - - await incomingMessage( - { - message: '', - metadata: { - version: '', - encryptedMessageHash: '', - signature: '', - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - getAccount, - storeNewMessage, - sendMock, - { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any, - async (ensName) => ensName, - getNotificationChannels, - mockWsManager, - ); - - const conversationId = getConversationId('alice.eth', 'bob.eth'); - - const actualPostmark = await decryptAsymmetric( - keysB.encryptionKeyPair, - JSON.parse(messageContainer.envelop?.postmark!), - ); - - //Check message - expect(messageContainer.conversationId).toEqual(conversationId); - - expect(messageContainer.envelop).toEqual( - expect.objectContaining({ - message: '', - metadata: { - version: '', - encryptedMessageHash: '', - signature: '', - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: { - from: 'alice.eth', - to: 'bob.eth', - }, - }, - }), - ); - - // check postmark - const { incommingTimestamp, messageHash, signature } = - JSON.parse(actualPostmark); - expect(incommingTimestamp).toBeGreaterThanOrEqual(now); - expect(incommingTimestamp).toBeLessThanOrEqual(Date.now()); - expect(messageHash).toBe( - '0xd7c617eb7ffee435e7d4e7f6b13d46ccdf88d2e5463148c50659e5cd88d248b5', - ); - const postmarkWithoutSig: Omit = { - messageHash, - incommingTimestamp, - }; - expect( - await checkSignature( - keysA.signingKeyPair.publicKey, - sha256(stringify(postmarkWithoutSig)), - signature, - ), - ).toBe(true); - //Check if the message was submitted to the socket - expect(sendMock).not.toBeCalled(); - }); - - it('stores proper incoming message and submit it if receiver is connected to a socket', async () => { - let messageContainer: { - conversationId?: string; - envelop?: EncryptionEnvelop; - } = {}; - - const storeNewMessage = async ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => { - messageContainer = { conversationId, envelop }; - }; - - const sendMock = jest.fn(); - - const _getAccount = (address: string) => getAccount(address, 'foo'); - const now = Date.now(); - - const mockWsManager: IWebSocketManager = { - isConnected: function (ensName: string): Promise { - return Promise.resolve(true); - }, - }; - - await incomingMessage( - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: stringify( - testData.deliveryInformation, - ), - version: '', - encryptedMessageHash: '', - signature: '', - }, - }, - keysA.signingKeyPair, - keysA.encryptionKeyPair, - 2 ** 14, - [], - _getAccount, - storeNewMessage, - sendMock, - { - resolveName: async () => - '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', - } as any, - async (ensName) => ensName, - getNotificationChannels, - mockWsManager, - ); - - const conversationId = getConversationId('alice.eth', 'bob.eth'); - - const actualPostmark = await decryptAsymmetric( - keysB.encryptionKeyPair, - JSON.parse(messageContainer.envelop?.postmark!), - ); - - //Check message - expect(messageContainer.conversationId).toEqual(conversationId); - - expect(messageContainer.envelop).toEqual( - expect.objectContaining({ - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: { - from: 'alice.eth', - to: 'bob.eth', - }, - version: '', - encryptedMessageHash: '', - signature: '', - }, - }), - ); - - // check postmark - const { incommingTimestamp, messageHash, signature } = - JSON.parse(actualPostmark); - expect(incommingTimestamp).toBeGreaterThanOrEqual(now); - expect(incommingTimestamp).toBeLessThanOrEqual(Date.now()); - expect(messageHash).toBe( - '0xd7c617eb7ffee435e7d4e7f6b13d46ccdf88d2e5463148c50659e5cd88d248b5', - ); - const postmarkWithoutSig: Omit = { - messageHash, - incommingTimestamp, - }; - expect( - await checkSignature( - keysA.signingKeyPair.publicKey, - sha256(stringify(postmarkWithoutSig)), - signature, - ), - ).toBe(true); - //Check if the message was submitted to the socket - expect(sendMock).toBeCalled(); - }); - }); - - describe('GetMessages', () => { - it('returns all messages of the user', async () => { - const conversationIdToUse = getConversationId( - 'alice.eth', - 'bob.eth', - ); - - const loadMessages = async ( - conversationId: string, - offset: number, - size: number, - ): Promise => { - return conversationId === conversationIdToUse - ? ([ - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: - testData.deliveryInformationUnecrypted, - version: '', - encryptedMessageHash: '', - signature: '', - }, - }, - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: - testData.deliveryInformationUnecrypted, - version: '', - encryptedMessageHash: '', - signature: '', - }, - }, - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: - testData.delvieryInformationBUnecrypted, - version: '', - encryptedMessageHash: '', - signature: '', - }, - }, - ] as EncryptionEnvelop[]) - : []; - }; - - expect( - await getMessages( - loadMessages, - keysA.encryptionKeyPair, - 'bob.eth', - 'alice.eth', - ), - ).toStrictEqual([ - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: - testData.deliveryInformationUnecrypted, - encryptedMessageHash: '', - signature: '', - version: '', - }, - }, - { - message: '', - metadata: { - encryptionScheme: 'x25519-chacha20-poly1305', - deliveryInformation: - testData.deliveryInformationUnecrypted, - version: '', - encryptedMessageHash: '', - signature: '', - }, - }, - ]); - }); - }); -}); diff --git a/packages/lib/delivery/src/Messages.ts b/packages/lib/delivery/src/Messages.ts index cc554168e..b03bee56b 100644 --- a/packages/lib/delivery/src/Messages.ts +++ b/packages/lib/delivery/src/Messages.ts @@ -3,11 +3,7 @@ import { normalizeEnsName, UserProfile, } from '@dm3-org/dm3-lib-profile'; -import { - IWebSocketManager, - NotificationChannel, - stringify, -} from '@dm3-org/dm3-lib-shared'; +import { stringify } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; import { @@ -20,203 +16,30 @@ import { import { DeliveryInformation, EncryptionEnvelop, - getEnvelopSize, Postmark, } from '@dm3-org/dm3-lib-messaging'; -import { logDebug, sha256 } from '@dm3-org/dm3-lib-shared'; -import { NotificationBroker } from './notifications'; -import { - GetNotificationChannels, - NotificationType, -} from './notifications/types'; -import { Session } from './Session'; -import { isSpam } from './spam-filter'; -import { SpamFilterRules } from './spam-filter/SpamFilterRules'; +import { sha256 } from '@dm3-org/dm3-lib-shared'; -export interface Acknowledgment { +export interface Acknowledgement { contactAddress: string; messageHash: string; } export function getConversationId(ensNameA: string, ensNameB: string): string { - return [normalizeEnsName(ensNameA), normalizeEnsName(ensNameB)] - .sort() - .join(); -} -// fetch new messages -export async function getMessages( - loadMessages: ( - conversationId: string, - offset: number, - size: number, - ) => Promise, - encryptionKeyPair: KeyPair, - ensName: string, - contactEnsName: string, -) { - const account = normalizeEnsName(ensName); - - const contact = normalizeEnsName(contactEnsName); - - const conversationId = getConversationId(contact, account); - - const receivedMessages: EncryptionEnvelop[] = await loadMessages( - conversationId, - 0, - 50, - ); - - const envelopContainers = await Promise.all( - receivedMessages.map(async (envelop) => ({ - to: normalizeEnsName( - JSON.parse(JSON.stringify(envelop.metadata.deliveryInformation)) - .to, - ), - envelop, - })), - ); - - return envelopContainers - .filter((envelopContainer) => envelopContainer.to === account) - .map((envelopContainer) => envelopContainer.envelop); + return [normalizeEnsName(ensNameA), normalizeEnsName(ensNameB)].join(); } -/** - * Handles an incoming message. - * Either stores the message or sends it directly to the receiver if a socketId is provided - * In order to be considered valid a incoming message has to meet the following criterias - * 1. The message size must be lower than the sizeLimit specified by the deliveryService {@see messageIsToLarge} - * 2. The DeliveryServiceToken used by the sender has to be valid - * 3. The receiver has to have a session at the deliveryService - * 4. The message must pass every {@see SpamFilterRule} the receiver declared - */ -export async function incomingMessage( - envelop: EncryptionEnvelop, - signingKeyPair: KeyPair, +export async function decryptDeliveryInformation( + encryptedEnvelop: EncryptionEnvelop, encryptionKeyPair: KeyPair, - sizeLimit: number, - dsNotificationChannels: NotificationChannel[], - getAccount: ( - accountAddress: string, - ) => Promise<(Session & { spamFilterRules: SpamFilterRules }) | null>, - storeNewMessage: ( - conversationId: string, - envelop: EncryptionEnvelop, - ) => Promise, - send: (socketId: string, envelop: EncryptionEnvelop) => void, - provider: ethers.providers.JsonRpcProvider, - getIdEnsName: (name: string) => Promise, - getUsersNotificationChannels: GetNotificationChannels, - wsManager: IWebSocketManager, -): Promise { - logDebug('incomingMessage'); - //Checks the size of the incoming message - if (messageIsTooLarge(envelop, sizeLimit)) { - throw Error('Message is too large'); - } - - //Decrypts the encrypted DeliveryInformation with the KeyPair of the deliveryService - - const deliveryInformation: DeliveryInformation = - await decryptDeliveryInformation(envelop, encryptionKeyPair); - logDebug({ text: 'incomingMessage', deliveryInformation }); - - const conversationId = getConversationId( - await getIdEnsName(deliveryInformation.from), - await getIdEnsName(deliveryInformation.to), - ); - logDebug({ text: 'incomingMessage', conversationId, deliveryInformation }); - - //Retrieves the session of the receiver - const receiverSession = await getAccount(deliveryInformation.to); - if (!receiverSession) { - logDebug({ - text: 'incomingMessage unknown session', - }); - throw Error('unknown session'); - } - logDebug({ - text: 'incomingMessage', - conversationId, - deliveryInformation, - receiverSession, - }); - - //Checks if the message is spam - if (await isSpam(provider, receiverSession, deliveryInformation)) { - logDebug({ - text: 'incomingMessage is spam', - }); - throw Error('Message does not match spam criteria'); - } - - const receiverEncryptionKey = - receiverSession.signedUserProfile.profile.publicEncryptionKey; - - const envelopWithPostmark: EncryptionEnvelop = { - ...envelop, - metadata: { - ...envelop.metadata, - //Alwaays store the encrypted metadata - deliveryInformation, - }, - postmark: stringify( - await addPostmark( - envelop, - receiverEncryptionKey, - signingKeyPair.privateKey, - ), +): Promise { + return JSON.parse( + await decryptAsymmetric( + encryptionKeyPair, + JSON.parse(encryptedEnvelop.metadata.deliveryInformation as string), ), - }; - logDebug({ - text: 'incomingMessage', - conversationId, - envelopWithPostmark, - }); - - if (process.env.DISABLE_MSG_BUFFER !== 'true') { - logDebug({ text: 'storeNewMessage', conversationId }); - await storeNewMessage(conversationId, envelopWithPostmark); - } else { - logDebug({ text: 'skip storeNewMessage', conversationId }); - } - - //If there is currently a webSocket connection open to the receiver, the message will be directly send. - if (await wsManager.isConnected(deliveryInformation.to)) { - //Client is already connect to the delivery service and the message can be dispatched - //TODO MOVE send method to the WebSocketManager - send(receiverSession.socketId!, envelopWithPostmark); - logDebug({ - text: 'WS send to socketId ', - receiverSessionSocketId: receiverSession.socketId, - }); - //If not we're notifing the user that there is a new message waiting for them - } else { - try { - const { sendNotification } = NotificationBroker( - dsNotificationChannels, - NotificationType.NEW_MESSAGE, - ); - await sendNotification( - deliveryInformation, - getUsersNotificationChannels, - ); - } catch (err) { - console.log( - 'Unable to send Notification. There might be an error in the config.yml. Message has been received regardless', - ); - console.error(err); - } - } -} - -function messageIsTooLarge( - envelop: EncryptionEnvelop, - sizeLimit: number, -): boolean { - return getEnvelopSize(envelop) > sizeLimit; + ); } - export async function handleIncomingMessage( encryptedEnvelop: EncryptionEnvelop, deliveryServiceKeys: DeliveryServiceProfileKeys, @@ -247,6 +70,13 @@ export async function addPostmark( receiverEncryptionKey: string, deliveryServiceSigningKey: string, ): Promise { + function signPostmark( + p: Omit, + signingKey: string, + ): Promise { + const postmarkHash = sha256(stringify(p)); + return sign(signingKey, postmarkHash); + } const postmarkWithoutSig: Omit = { messageHash: ethers.utils.hashMessage(stringify(message)), incommingTimestamp: new Date().getTime(), @@ -269,23 +99,3 @@ export async function addPostmark( ephemPublicKey, }; } - -export async function decryptDeliveryInformation( - encryptedEnvelop: EncryptionEnvelop, - encryptionKeyPair: KeyPair, -): Promise { - return JSON.parse( - await decryptAsymmetric( - encryptionKeyPair, - JSON.parse(encryptedEnvelop.metadata.deliveryInformation as string), - ), - ); -} - -function signPostmark( - p: Omit, - signingKey: string, -): Promise { - const postmarkHash = sha256(stringify(p)); - return sign(signingKey, postmarkHash); -} diff --git a/packages/lib/delivery/src/Session.test.ts b/packages/lib/delivery/src/Session.test.ts index e757ce9f8..562cfe4db 100644 --- a/packages/lib/delivery/src/Session.test.ts +++ b/packages/lib/delivery/src/Session.test.ts @@ -1,6 +1,5 @@ -import { checkToken, Session } from './Session'; +import { checkToken, generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { sign, verify } from 'jsonwebtoken'; -import { generateAuthJWT } from './Keys'; const serverSecret = 'veryImportantSecretToGenerateAndValidateJSONWebTokens'; // create valid jwt @@ -9,18 +8,14 @@ const token = generateAuthJWT('alice.eth', serverSecret); describe('Session', () => { describe('checkToken with state', () => { it('Should return true if the jwt is valid', async () => { - const getAccount = (_: string) => - Promise.resolve({ - token: token, - createdAt: new Date().getTime(), - } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const isValid = await checkToken( { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -30,14 +25,14 @@ describe('Session', () => { }); it('Should return false if no session exists for the account ', async () => { - const getAccount = (_: string) => Promise.resolve(null); + const hasAccount = (_: string) => Promise.resolve(false); const isValid = await checkToken( { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -48,15 +43,14 @@ describe('Session', () => { it('Should return false if the token is signed with a different secret ', async () => { const token = generateAuthJWT('alice.eth', 'attackersSecret'); - const getAccount = (_: string) => - Promise.resolve({ token: 'bar' } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const isValid = await checkToken( { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -66,8 +60,7 @@ describe('Session', () => { }); it('Should return false if a session exists but the token is expired ', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const oneMinuteAgo = new Date().getTime() / 1000 - 60; // this token expired a minute ago @@ -86,7 +79,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _token, serverSecret, @@ -96,8 +89,7 @@ describe('Session', () => { }); it('Should return false if token issuance date is in the future ', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -118,7 +110,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -142,7 +134,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _token, serverSecret, @@ -153,8 +145,7 @@ describe('Session', () => { }); describe('checkToken is not missing information', () => { it('Should return false if iat is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -175,7 +166,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -199,7 +190,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -209,8 +200,7 @@ describe('Session', () => { }); it('Should return false if nbf is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -231,7 +221,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -256,7 +246,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -265,9 +255,8 @@ describe('Session', () => { expect(isValid).toBe(false); }); - it('Should return false if account is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + it('Should return false if key "account" is missing', async () => { + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -288,7 +277,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -313,7 +302,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -323,8 +312,7 @@ describe('Session', () => { }); it('Should return false if exp is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -345,7 +333,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -370,7 +358,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -379,10 +367,9 @@ describe('Session', () => { expect(isValid).toBe(false); }); }); - describe('checkToken does not contain unexpeted keys', () => { + describe('checkToken does not contain unexpected keys', () => { it('Should return false if challenge is present', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -403,7 +390,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -429,7 +416,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -438,8 +425,7 @@ describe('Session', () => { expect(isValid).toBe(false); }); it('Should return false if some additional key is present', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -460,7 +446,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -486,7 +472,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, diff --git a/packages/lib/delivery/src/Session.ts b/packages/lib/delivery/src/Session.ts index cd7f2c646..c0b5fb63e 100644 --- a/packages/lib/delivery/src/Session.ts +++ b/packages/lib/delivery/src/Session.ts @@ -1,23 +1,9 @@ import { ProfileExtension, SignedUserProfile } from '@dm3-org/dm3-lib-profile'; -import { validateSchema } from '@dm3-org/dm3-lib-shared'; -import { ethers } from 'ethers'; -import { decode, verify } from 'jsonwebtoken'; +import { SpamFilterRules } from './spam-filter'; //1Year const TTL = 31536000000; -const authJwtPayloadSchema = { - type: 'object', - properties: { - account: { type: 'string' }, - iat: { type: 'number' }, - exp: { type: 'number' }, - nbf: { type: 'number' }, - }, - required: ['account', 'iat', 'exp', 'nbf'], - additionalProperties: false, -}; - export interface Session { account: string; signedUserProfile: SignedUserProfile; @@ -28,52 +14,5 @@ export interface Session { challenge?: string; profileExtension: ProfileExtension; //TODO use SpamFilterRules once spam-filer is ready - spamFilterRules?: unknown; -} - -export async function checkToken( - provider: ethers.providers.JsonRpcProvider, - getAccount: (ensName: string) => Promise, - ensName: string, - token: string, - serverSecret: string, -): Promise { - const session = await getAccount(ensName.toLocaleLowerCase()); - if (!session) { - console.debug('there is no account for this ens name: ', ensName); - return false; - } - - // check jwt for validity - try { - // will throw if signature is invalid or exp is in the past - const jwtPayload = verify(token, serverSecret, { - algorithms: ['HS256'], - }); - - // check if payload is well formed - if ( - typeof jwtPayload === 'string' || - !validateSchema(authJwtPayloadSchema, jwtPayload) - ) { - console.debug('jwt malformed'); - return false; - } - - if (!jwtPayload.iat || jwtPayload.iat > Date.now() / 1000) { - console.debug('jwt invalid: iat missing or in the future'); - return false; - } - - if (jwtPayload.account !== ensName) { - console.debug('jwt invalid: account mismatch'); - return false; - } - } catch (error) { - console.debug(`jwt invalid: ${error}`); - return false; - } - - // the token is valid only if all checks passed - return true; + spamFilterRules?: SpamFilterRules; } diff --git a/packages/lib/delivery/src/UserProfile.test.ts b/packages/lib/delivery/src/UserProfile.test.ts index b601bf91c..33617d077 100644 --- a/packages/lib/delivery/src/UserProfile.test.ts +++ b/packages/lib/delivery/src/UserProfile.test.ts @@ -49,10 +49,9 @@ describe('UserProfile', () => { await expect(async () => { await submitUserProfile( - { resolveName: () => RANDO_ADDRESS } as any, getAccount, setAccount, - RANDO_NAME, + RANDO_ADDRESS, singedUserProfile, 'my-secret', ); @@ -60,7 +59,7 @@ describe('UserProfile', () => { expect(setAccount).not.toBeCalled(); }); - it('rejects a userProfile that already exists', async () => { + it('override a userProfile that already exists but with other nonce', async () => { const setAccount = () => Promise.resolve(); const getAccount = async (address: string) => { const session = async ( @@ -81,21 +80,28 @@ describe('UserProfile', () => { }; }; - return session(SENDER_NAME, '123', emptyProfile); + return session(SENDER_ADDRESS, '123', emptyProfile); }; const singedUserProfile = await signProfile(emptyProfile); + await submitUserProfile( + getAccount, + setAccount, + SENDER_ADDRESS, + singedUserProfile, + 'my-secret', + ); + await expect(async () => { await submitUserProfile( - { resolveName: () => SENDER_ADDRESS } as any, getAccount, setAccount, - SENDER_NAME, + SENDER_ADDRESS, singedUserProfile, - 'my-secret', + 'my-new-secret', ); - }).rejects.toEqual(Error('Profile exists already')); + }).resolves; }); it('stores a newly created user profile', async () => { @@ -105,10 +111,9 @@ describe('UserProfile', () => { const singedUserProfile = await signProfile(emptyProfile); await submitUserProfile( - { resolveName: () => SENDER_ADDRESS } as any, getAccount, setAccount, - SENDER_NAME, + SENDER_ADDRESS, singedUserProfile, 'my-secret', ); diff --git a/packages/lib/delivery/src/UserProfile.ts b/packages/lib/delivery/src/UserProfile.ts index d323a4305..6b4944c12 100644 --- a/packages/lib/delivery/src/UserProfile.ts +++ b/packages/lib/delivery/src/UserProfile.ts @@ -1,45 +1,46 @@ import { SignedUserProfile, - checkUserProfile, + checkUserProfileWithAddress, getDefaultProfileExtension, normalizeEnsName, } from '@dm3-org/dm3-lib-profile'; import { logDebug } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; -import { generateAuthJWT } from './Keys'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { Session } from './Session'; export async function submitUserProfile( - provider: ethers.providers.JsonRpcProvider, getAccount: (accountAddress: string) => Promise, setAccount: (accountAddress: string, session: Session) => Promise, - ensName: string, + address: string, signedUserProfile: SignedUserProfile, serverSecret: string, ): Promise { - const account = normalizeEnsName(ensName); - - if (!(await checkUserProfile(provider, signedUserProfile, account))) { + if (!ethers.utils.isAddress(address)) { + logDebug('submitUserProfile - Invalid address'); + throw Error('Invalid address'); + } + //normalize the address + const _address = ethers.utils.getAddress(address); + // check if the submitted profile is has been signed by the adddress that want's to submit the profile + if (!(await checkUserProfileWithAddress(signedUserProfile, _address))) { logDebug('submitUserProfile - Signature invalid'); throw Error('Signature invalid.'); } - if (await getAccount(account)) { - logDebug('submitUserProfile - Profile exists already'); - throw Error('Profile exists already'); - } const session: Session = { - account, + account: _address, signedUserProfile, - token: generateAuthJWT(ensName, serverSecret), + token: generateAuthJWT(_address, serverSecret), createdAt: new Date().getTime(), profileExtension: getDefaultProfileExtension(), }; logDebug({ text: 'submitUserProfile', session }); - await setAccount(account.toLocaleLowerCase(), session); + await setAccount(_address, session); return session.token; } +// todo: remove this function (profiles should be loaded from chain and possibly cached) export async function getUserProfile( getAccount: (accountAddress: string) => Promise, ensName: string, diff --git a/packages/lib/delivery/src/index.ts b/packages/lib/delivery/src/index.ts index 75e0d91a4..9826ba909 100644 --- a/packages/lib/delivery/src/index.ts +++ b/packages/lib/delivery/src/index.ts @@ -1,21 +1,14 @@ -export { - createChallenge, - createNewSessionToken, - generateAuthJWT, -} from './Keys'; export { submitUserProfile, getUserProfile } from './UserProfile'; export { addPostmark, - getMessages, - incomingMessage, + decryptDeliveryInformation, handleIncomingMessage, } from './Messages'; -export type { Acknowledgment } from './Messages'; +export type { Acknowledgement } from './Messages'; export { getConversationId } from './Messages'; export type {} from './PublicMessages'; export * as schema from './schema'; -export * as spamFilter from './spam-filter'; -export { checkToken } from './Session'; +export * as spamFilter from './spam-filter/'; export type { Session } from './Session'; export type { DeliveryServiceProperties } from './Delivery'; export * from './notifications'; diff --git a/packages/lib/delivery/src/schema/index.ts b/packages/lib/delivery/src/schema/index.ts index 095feb766..2da911ccc 100644 --- a/packages/lib/delivery/src/schema/index.ts +++ b/packages/lib/delivery/src/schema/index.ts @@ -1,9 +1,10 @@ -import AcknowledgmentSchema from './Acknowledgment.schema.json'; +import AcknowledgementSchema from './Acknowledgement.schema.json'; import DeliveryServicePropertiesSchema from './DeliveryServiceProperties.schema.json'; import SessionSchema from './Session.schema.json'; import NotificationChannelSchema from './NotificationChannel.schema.json'; -export const Acknowledgment = AcknowledgmentSchema.definitions.Acknowledgment; +export const Acknowledgement = + AcknowledgementSchema.definitions.Acknowledgement; export const DeliveryServiceProperties = DeliveryServicePropertiesSchema; export const Session = SessionSchema; export const NotificationChannel = NotificationChannelSchema; diff --git a/packages/lib/delivery/src/spam-filter/index.ts b/packages/lib/delivery/src/spam-filter/index.ts index 768c4076c..32f890fe1 100644 --- a/packages/lib/delivery/src/spam-filter/index.ts +++ b/packages/lib/delivery/src/spam-filter/index.ts @@ -39,7 +39,7 @@ function compileSpamFilter(filter: SpamFilter[]) { */ function getUsersSpamFilters( provider: ethers.providers.BaseProvider, - { spamFilterRules }: Session & { spamFilterRules: SpamFilterRules }, + { spamFilterRules }: Session, ) { //User has not defined any rules if (!spamFilterRules) { @@ -55,7 +55,7 @@ function getUsersSpamFilters( */ export async function isSpam( provider: ethers.providers.BaseProvider, - session: Session & { spamFilterRules: SpamFilterRules }, + session: Session, deliveryInformation: DeliveryInformation, ) { const usersSpamFilters = getUsersSpamFilters(provider, session); diff --git a/packages/lib/delivery/src/spam-filter/spamfilter.test.ts b/packages/lib/delivery/src/spam-filter/spamfilter.test.ts index 386ea6596..bc1cb7afe 100644 --- a/packages/lib/delivery/src/spam-filter/spamfilter.test.ts +++ b/packages/lib/delivery/src/spam-filter/spamfilter.test.ts @@ -3,9 +3,8 @@ import { ethers } from 'ethers'; import { isSpam } from '.'; import { testData } from '../../../../../test-data/encrypted-envelops.test'; -import { Session } from '../Session'; - import { SpamFilterRules } from './SpamFilterRules'; +import { Session } from '@dm3-org/dm3-lib-delivery'; const keysA = { encryptionKeyPair: { diff --git a/packages/lib/messaging/package.json b/packages/lib/messaging/package.json index c7400ba14..9cf9b3008 100644 --- a/packages/lib/messaging/package.json +++ b/packages/lib/messaging/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-messaging", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", diff --git a/packages/lib/messaging/src/Message.ts b/packages/lib/messaging/src/Message.ts index fc3146a4a..a8fe7d212 100644 --- a/packages/lib/messaging/src/Message.ts +++ b/packages/lib/messaging/src/Message.ts @@ -44,7 +44,8 @@ export type MessageType = | 'EDIT' | 'REPLY' | 'REACTION' - | 'READ_RECEIPT' + | 'READ_OPENED' + | 'READ_RECEIVED' | 'RESEND_REQUEST'; export interface Postmark { @@ -253,6 +254,58 @@ export async function createReactionMessage( ); } +/** + * creats a read opened message and signs it + * @param to sender ENS name + * @param from receiver ENS name + * @param message the message content + * @param privateKey sender signing key + * @param referenceMessageHash reference to previous message + */ +export async function createReadOpenMessage( + to: string, + from: string, + message: string, + privateKey: string, + referenceMessageHash: string, +): Promise { + return internalCreateMessage( + to, + from, + message, + privateKey, + 'READ_OPENED', + [], + referenceMessageHash, + ); +} + +/** + * creates a read received message and signs it + * @param to sender ENS name + * @param from receiver ENS name + * @param message the message content + * @param privateKey sender signing key + * @param referenceMessageHash reference to previous message + */ +export async function createReadReceiveMessage( + to: string, + from: string, + message: string, + privateKey: string, + referenceMessageHash: string, +): Promise { + return internalCreateMessage( + to, + from, + message, + privateKey, + 'READ_RECEIVED', + [], + referenceMessageHash, + ); +} + export async function checkMessageSignature( message: Message, publicSigningKey: string, diff --git a/packages/lib/messaging/src/index.ts b/packages/lib/messaging/src/index.ts index 2d8bbeeaa..c3de845b8 100644 --- a/packages/lib/messaging/src/index.ts +++ b/packages/lib/messaging/src/index.ts @@ -11,6 +11,8 @@ export { createEditMessage, createReactionMessage, createReplyMessage, + createReadOpenMessage, + createReadReceiveMessage, createJsonRpcCallSubmitMessage, handleMessageOnDeliveryService, decryptEnvelop, diff --git a/packages/lib/offchain-resolver-api/package.json b/packages/lib/offchain-resolver-api/package.json index 331a6ca10..965767c5f 100644 --- a/packages/lib/offchain-resolver-api/package.json +++ b/packages/lib/offchain-resolver-api/package.json @@ -1,6 +1,6 @@ { "name": "@dm3-org/dm3-lib-offchain-resolver-api", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/lib/profile/package.json b/packages/lib/profile/package.json index 03de0fd40..8f6ed2031 100644 --- a/packages/lib/profile/package.json +++ b/packages/lib/profile/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-profile", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", diff --git a/packages/lib/profile/src/profileExtension/ProfileExtension.ts b/packages/lib/profile/src/profileExtension/ProfileExtension.ts index ce5857a39..6a21b3853 100644 --- a/packages/lib/profile/src/profileExtension/ProfileExtension.ts +++ b/packages/lib/profile/src/profileExtension/ProfileExtension.ts @@ -5,7 +5,8 @@ export type MessageType = | 'EDIT' | 'REPLY' | 'REACTION' - | 'READ_RECEIPT' + | 'READ_OPENED' + | 'READ_RECEIVED' | 'RESEND_REQUEST'; export interface ProfileExtension { diff --git a/packages/lib/server-side/package.json b/packages/lib/server-side/package.json index e9457ac50..4b360270b 100644 --- a/packages/lib/server-side/package.json +++ b/packages/lib/server-side/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-server-side", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", @@ -12,7 +12,6 @@ } }, "dependencies": { - "@dm3-org/dm3-lib-delivery": "workspace:^", "@dm3-org/dm3-lib-profile": "workspace:^", "@dm3-org/dm3-lib-shared": "workspace:^", "express": "^4.18.1" diff --git a/packages/lib/delivery/src/Keys.test.ts b/packages/lib/server-side/src/Keys.test.ts similarity index 60% rename from packages/lib/delivery/src/Keys.test.ts rename to packages/lib/server-side/src/Keys.test.ts index f52d4331e..812416481 100644 --- a/packages/lib/delivery/src/Keys.test.ts +++ b/packages/lib/server-side/src/Keys.test.ts @@ -1,8 +1,10 @@ import { sign } from '@dm3-org/dm3-lib-crypto'; -import { createChallenge, createNewSessionToken } from './Keys'; -import * as spamFilter from './spam-filter/SpamFilterRules'; -import { Session } from './Session'; +import { stringify } from '@dm3-org/dm3-lib-shared'; +import { mockUserProfile } from '@dm3-org/dm3-lib-test-helper'; +import { ethers } from 'ethers'; import { JsonWebTokenError } from 'jsonwebtoken'; +import { IAccountDatabase } from './iAccountDatabase'; +import { createChallenge, createNewSessionToken } from './Keys'; const RANDO_ADDRESS = '0xDd36ae7F9a8E34FACf1e110c6e9d37D0dc917855'; const SENDER_ADDRESS = '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1'; @@ -22,40 +24,74 @@ const keysA = { storageEncryptionNonce: 0, }; +const mockDbWithAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(true), +}; + +const mockDbWithOUTAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(false), +}; + describe('Keys', () => { - describe('CreateChallenge', () => { - it('Throws Exception if Session was not found', async () => { - const getAccount = () => Promise.resolve(null); - const setAccount = () => Promise.resolve(); + let user: any; + let expectedUserProfile: any; + let userAddress: string; + let mockWeb3Provider: ethers.providers.StaticJsonRpcProvider; + + beforeAll(async () => { + user = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + expectedUserProfile = user.signedUserProfile; + userAddress = user.wallet.address; + + const mockGetEnsResolver = (_: string) => + Promise.resolve({ + getText: (_: string) => + Promise.resolve( + 'data:application/json,' + + stringify(expectedUserProfile), + ), + }); + + mockWeb3Provider = { + getResolver: mockGetEnsResolver, + resolveName: async () => userAddress, + } as unknown as ethers.providers.StaticJsonRpcProvider; + }); + describe('CreateChallenge', () => { + it('Throws Exception if Account was not found', async () => { await expect(async () => { - await createChallenge(getAccount, RANDO_ADDRESS, SERVER_SECRET); - }).rejects.toEqual(Error('Session not found')); + await createChallenge( + mockDbWithOUTAccount, + RANDO_ADDRESS, + SERVER_SECRET, + ); + }).rejects.toEqual(Error("User account doesn't exist")); }); it('Ignores challenge field in database', async () => { - const getAccount = () => - Promise.resolve({ challenge: 'foo' } as Session); - const challenge = await createChallenge( - getAccount, + mockDbWithAccount, RANDO_ADDRESS, SERVER_SECRET, ); expect(challenge).not.toBe('foo'); }); - it('Creates a new challenge even if called multiple times', async () => { - const getAccount = () => Promise.resolve({} as Session); + it('Creates a new challenge even if called multiple times', async () => { const challenge1 = await createChallenge( - getAccount, + mockDbWithAccount, RANDO_ADDRESS, SERVER_SECRET, ); const challenge2 = await createChallenge( - getAccount, + mockDbWithAccount, RANDO_ADDRESS, SERVER_SECRET, ); @@ -65,100 +101,65 @@ describe('Keys', () => { expect(challenge1).not.toBe(challenge2); }); }); + describe('CreateNewSessionToken', () => { - it('Throws Exception if Session was not found', async () => { + it('Throws Exception if Account was not found', async () => { const getAccount = () => Promise.resolve(null); await expect(async () => { await createNewSessionToken( - getAccount, + mockDbWithOUTAccount, 'signature', 'challenge', RANDO_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); - }).rejects.toEqual(Error('Session not found')); + }).rejects.toEqual(Error("User account doesn't exist")); }); - it('Throws Exception if the challenge is not valid', async () => { - const getAccount = () => Promise.resolve({} as Session); + it('Throws Exception if the challenge is not valid', async () => { await expect(async () => { await createNewSessionToken( - getAccount, + mockDbWithAccount, 'signature', 'challenge', RANDO_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); }).rejects.toEqual(new JsonWebTokenError('jwt malformed')); }); it('Returns token if challenge was correct', async () => { - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - }, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; - - const getAccount = async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked); - // create valid challenge jwt const challenge = await createChallenge( - getAccount, + mockDbWithAccount, SENDER_ADDRESS, SERVER_SECRET, ); const signature = await sign( - keysA.signingKeyPair.privateKey, + user.profileKeys.signingKeyPair.privateKey, challenge, ); const token = await createNewSessionToken( - getAccount, + mockDbWithAccount, signature, challenge, SENDER_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); expect(token).not.toBeUndefined(); }); it('Throws Exception if challange was solved wrong', async () => { - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - }, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; - - const getAccount = async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked); - // create valid challenge jwt const challenge = await createChallenge( - getAccount, + mockDbWithAccount, SENDER_ADDRESS, SERVER_SECRET, ); @@ -167,11 +168,12 @@ describe('Keys', () => { await expect(async () => { await createNewSessionToken( - getAccount, + mockDbWithAccount, signature, challenge, SENDER_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); }).rejects.toEqual(TypeError('invalid signature length')); }); diff --git a/packages/lib/delivery/src/Keys.ts b/packages/lib/server-side/src/Keys.ts similarity index 68% rename from packages/lib/delivery/src/Keys.ts rename to packages/lib/server-side/src/Keys.ts index 61aa32461..8a6c16af2 100644 --- a/packages/lib/delivery/src/Keys.ts +++ b/packages/lib/server-side/src/Keys.ts @@ -1,9 +1,10 @@ -import { v4 as uuidv4 } from 'uuid'; -import { Session } from './Session'; import { checkSignature } from '@dm3-org/dm3-lib-crypto'; -import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { getUserProfile, normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; +import { ethers } from 'ethers'; import { sign, verify } from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; +import { IAccountDatabase } from './iAccountDatabase'; const challengeJwtPayloadSchema = { type: 'object', @@ -19,15 +20,14 @@ const challengeJwtPayloadSchema = { }; export async function createChallenge( - getAccount: (accountAddress: string) => Promise, + db: IAccountDatabase, ensName: string, serverSecret: string, ) { - const account = normalizeEnsName(ensName); - const session = await getAccount(account); + const accountName = normalizeEnsName(ensName); - if (!session) { - throw Error('Session not found'); + if (!(await db.hasAccount(accountName))) { + throw Error("User account doesn't exist"); } // generates a jwt with a new, unique challenge @@ -65,17 +65,31 @@ export function generateAuthJWT( }); } +/** + * Creates a new session token for the given ensName, after checking: + * - if the account exists + * - if the challenge is valid + * - if the signature is valid + * - if the user profile exists + * @param hasAccount function that checks if the account exists + * @param signature signature of the challenge + * @param challenge challenge that was created before + * @param ensName ens id of the account + * @param serverSecret secret value that is used to sign the JWT + * @returns JWT + */ export async function createNewSessionToken( - getAccount: (ensName: string) => Promise, + db: IAccountDatabase, signature: string, challenge: string, ensName: string, serverSecret: string, + web3Provider: ethers.providers.JsonRpcProvider, ): Promise { - const session = await getAccount(ensName); + const accountName = normalizeEnsName(ensName); - if (!session) { - throw Error('Session not found'); + if (!(await db.hasAccount(accountName))) { + throw Error("User account doesn't exist"); } // check the challenge jwt the user provided. It must be a valid @@ -92,10 +106,15 @@ export async function createNewSessionToken( throw Error('Provided challenge is not valid'); } + const signedProfile = await getUserProfile(web3Provider, ensName); + + if (!signedProfile) { + throw Error("User profile doesn't exist"); + } + if ( - // todo: get public signing key from public profile !(await checkSignature( - session.signedUserProfile.profile.publicSigningKey, + signedProfile.profile.publicSigningKey, // we expect the whole jwt to be signed, not just the challenge-part of the payload. // This way, the client does not have to understand the jwt. challenge, diff --git a/packages/lib/server-side/src/auth.test.ts b/packages/lib/server-side/src/auth.test.ts index ac202d941..b0e29db8e 100644 --- a/packages/lib/server-side/src/auth.test.ts +++ b/packages/lib/server-side/src/auth.test.ts @@ -1,46 +1,77 @@ -import { sign } from '@dm3-org/dm3-lib-crypto'; -import { - Session, - createChallenge, - spamFilter, -} from '@dm3-org/dm3-lib-delivery'; -import { SignedUserProfile } from '@dm3-org/dm3-lib-profile'; +import { stringify } from '@dm3-org/dm3-lib-shared'; +import { mockUserProfile } from '@dm3-org/dm3-lib-test-helper'; import bodyParser from 'body-parser'; import { ethers } from 'ethers'; import express from 'express'; import { verify } from 'jsonwebtoken'; import request from 'supertest'; import { Auth } from './auth'; +import { IAccountDatabase } from './iAccountDatabase'; +import { createChallenge } from './Keys'; +import { sign } from '@dm3-org/dm3-lib-crypto'; const serverSecret = 'testSecret'; +const mockDbWithAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(true), +}; + +const mockDbWithOUTAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(false), +}; + +const keysA = { + encryptionKeyPair: { + publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', + privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', + }, + signingKeyPair: { + publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', + privateKey: + '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', + }, + storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', + storageEncryptionNonce: 0, +}; + describe('Auth', () => { - const getAccountMock = async (ensName: string) => - Promise.resolve({ challenge: '123' }); - const setAccountMock = async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }; - - const keysA = { - encryptionKeyPair: { - publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', - privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', - }, - signingKeyPair: { - publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', - privateKey: - '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', - }, - storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', - storageEncryptionNonce: 0, - }; + let user: any; + let expectedUserProfile: any; + let userAddress: string; + let mockWeb3Provider: ethers.providers.StaticJsonRpcProvider; + + beforeAll(async () => { + user = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + expectedUserProfile = user.signedUserProfile; + userAddress = user.wallet.address; + + const mockGetEnsResolver = (_: string) => + Promise.resolve({ + getText: (_: string) => + Promise.resolve( + 'data:application/json,' + + stringify(expectedUserProfile), + ), + }); + + mockWeb3Provider = { + getResolver: mockGetEnsResolver, + resolveName: async () => userAddress, + } as unknown as ethers.providers.StaticJsonRpcProvider; + }); describe('getChallenge', () => { describe('schema', () => { it('Returns 200 and a jwt if schema is valid', async () => { const app = express(); app.use(bodyParser.json()); - app.use(Auth(getAccountMock, serverSecret)); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); const response = await request(app) .get( @@ -107,106 +138,101 @@ describe('Auth', () => { describe('createNewSessionToken', () => { describe('schema', () => { - it('Returns 400 if params is invalid', async () => { + it('Returns 400 if signature is invalid', async () => { const app = express(); - app.use(bodyParser.json()); - app.use(Auth(getAccountMock, serverSecret)); - const mnemonic = - 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; - - const wallet = ethers.Wallet.fromMnemonic(mnemonic); - - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: {}, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; + app.use(bodyParser.json()); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); // create the challenge jwt - const challengeJwt = createChallenge( - async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked), - '1234', + const challengeJwt = await createChallenge( + mockDbWithAccount, + 'bob.eth', serverSecret, ); - const signature = await wallet.signMessage('123'); + // signing with this keyA, but the server checks signature against user.profileKeys.signingKeyPair.privateKey, + const signature = await sign( + keysA.signingKeyPair.privateKey, + challengeJwt, + ); const { status } = await request(app).post(`/1234`).send({ - signature: 123, + signature, challenge: challengeJwt, }); expect(status).toBe(400); }); - it('Returns 400 if body is invalid', async () => { + + it('Returns 400 if params is invalid', async () => { const app = express(); + app.use(bodyParser.json()); - app.use(Auth(getAccountMock, serverSecret)); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); + + // create the challenge jwt + const challengeJwt = await createChallenge( + mockDbWithAccount, + 'bob.eth', + serverSecret, + ); - const mnemonic = - 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + const signature = await sign( + user.profileKeys.signingKeyPair.privateKey, + challengeJwt, + ); - const wallet = ethers.Wallet.fromMnemonic(mnemonic); + const { status } = await request(app).post(`/1234`).send({ + // we do not provide a signature + challenge: challengeJwt, + }); - const foo = await wallet.signMessage('123'); + expect(status).toBe(400); - const { status } = await request(app) - .post(`/${wallet.address}`) + const { status: status2 } = await request(app) + .post(`/1234`) .send({ - foo, + signature, + // we do not provide a challenge }); + expect(status2).toBe(400); + }); + it('Returns 400 if body is invalid', async () => { + const app = express(); + + app.use(bodyParser.json()); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); + + const { status } = await request(app).post(`/somename`).send({ + foo: 'some content', + }); expect(status).toBe(400); }); - it('Returns 200 if schema is valid', async () => { - const sessionMocked = { - challenge: 'deprecated challenge that is not used anymore', - token: 'deprecated token that is not used anymore', - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - } as SignedUserProfile, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; - - const getAccountMockLocal = async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked); - // async (ensName: string) => ({ - // challenge: 'my-Challenge', - // signedUserProfile: { - // profile: { - // publicSigningKey: keysA.signingKeyPair.publicKey, - // }, - // }, - // }); - + it('Returns 200 if schema and content is valid', async () => { const app = express(); + app.use(bodyParser.json()); - app.use(Auth(getAccountMockLocal, serverSecret)); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); // create the challenge jwt const challengeJwt = await createChallenge( - getAccountMockLocal, + mockDbWithAccount, '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', serverSecret, ); const signature = await sign( - keysA.signingKeyPair.privateKey, + user.profileKeys.signingKeyPair.privateKey, challengeJwt, ); diff --git a/packages/lib/server-side/src/auth.ts b/packages/lib/server-side/src/auth.ts index 90865f003..0901ec384 100644 --- a/packages/lib/server-side/src/auth.ts +++ b/packages/lib/server-side/src/auth.ts @@ -1,11 +1,10 @@ -import { - createChallenge, - createNewSessionToken, -} from '@dm3-org/dm3-lib-delivery'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; import cors from 'cors'; +import { ethers } from 'ethers'; import express from 'express'; +import { IAccountDatabase } from './iAccountDatabase'; +import { createChallenge, createNewSessionToken } from './Keys'; const getChallengeSchema = { type: 'object', @@ -35,8 +34,11 @@ const createNewSessionTokenBodySchema = { additionalProperties: false, }; -//@ts-ignore -export const Auth = (getAccount, serverSecret: string) => { +export const Auth = ( + db: IAccountDatabase, + serverSecret: string, + web3Provider: ethers.providers.JsonRpcProvider, +) => { const router = express.Router(); //TODO remove @@ -52,11 +54,11 @@ export const Auth = (getAccount, serverSecret: string) => { ); if (!schemaIsValid) { - return res.send(400); + return res.sendStatus(400); } const challenge = await createChallenge( - getAccount, + db, idEnsName, serverSecret, ); @@ -83,21 +85,27 @@ export const Auth = (getAccount, serverSecret: string) => { const schemaIsValid = paramsAreValid && bodyIsValid; if (!schemaIsValid) { - return res.send(400); + return res.sendStatus(400); } const jwt = await createNewSessionToken( - getAccount, + db, req.body.signature, req.body.challenge, idEnsName, serverSecret, + web3Provider, ); res.json(jwt); } catch (e) { - next(e); console.error('unable to create new session token ', e); + return res.status(400).json({ + error: + e instanceof Error + ? e.message + : 'Failed to create new session token', + }); } }); diff --git a/packages/lib/server-side/src/utils.test.ts b/packages/lib/server-side/src/authorize.test.ts similarity index 78% rename from packages/lib/server-side/src/utils.test.ts rename to packages/lib/server-side/src/authorize.test.ts index e8e91bfed..e1e0f9d66 100644 --- a/packages/lib/server-side/src/utils.test.ts +++ b/packages/lib/server-side/src/authorize.test.ts @@ -3,7 +3,7 @@ import express, { NextFunction, Request, Response } from 'express'; import { sign, verify } from 'jsonwebtoken'; import request from 'supertest'; import winston from 'winston'; -import { auth } from './utils'; +import { authorize } from './authorize'; const serverSecret = 'testSecret'; winston.loggers.add('default', { @@ -18,20 +18,7 @@ describe('Utils', () => { notBefore: 0, }); - const getAccount = async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'testToken', - createdAt: new Date().getTime(), - }); - const setAccount = async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }; - - const db = { - getAccount, - setAccount, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -50,12 +37,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -64,7 +51,7 @@ describe('Utils', () => { //Mock request auth protected router.get('/:address', (req, res) => { - return res.send(200); + return res.sendStatus(200); }); const { status, body } = await request(app) @@ -80,20 +67,7 @@ describe('Utils', () => { notBefore: 0, }); - const getAccount = async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'testToken', - createdAt: new Date().getTime(), - }); - const setAccount = async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }; - - const db = { - getAccount, - setAccount, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -112,12 +86,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -126,7 +100,7 @@ describe('Utils', () => { //Mock request auth protected router.get('/:address', (req, res) => { - return res.send(200); + return res.sendStatus(200); }); const { status, body } = await request(app) @@ -140,13 +114,8 @@ describe('Utils', () => { const token = sign({ account: 'alice.eth' }, serverSecret, { expiresIn: '1h', }); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve(null), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + + const hasAccount = (_: string) => Promise.resolve(false); const web3Provider = { resolveName: async () => @@ -165,12 +134,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -179,7 +148,7 @@ describe('Utils', () => { //Mock request auth protected router.get('/:address', (req, res) => { - return res.send(200); + return res.sendStatus(200); }); app.locals.web3Provider = { @@ -203,16 +172,7 @@ describe('Utils', () => { const token = sign({ account: 'some.other.name' }, serverSecret, { expiresIn: '1h', }); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'foo', - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -231,12 +191,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -245,7 +205,7 @@ describe('Utils', () => { //Mock request auth protected router.get('/:address', (req, res) => { - return res.send(200); + return res.sendStatus(200); }); const { status, body } = await request(app) @@ -281,17 +241,7 @@ describe('Utils', () => { serverSecret, ); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'foo', - createdAt: 1, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -310,12 +260,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -324,7 +274,7 @@ describe('Utils', () => { //Mock request auth protected router.get('/:address', (req, res) => { - return res.send(200); + return res.sendStatus(200); }); const { status, body } = await request(app) @@ -380,17 +330,7 @@ describe('Utils', () => { }, serverSecret, ); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'foo', - createdAt: 1, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -409,12 +349,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -423,7 +363,7 @@ describe('Utils', () => { //Mock request auth protected router.get('/:address', (req, res) => { - return res.send(200); + return res.sendStatus(200); }); const { status, body } = await request(app) diff --git a/packages/lib/server-side/src/authorize.ts b/packages/lib/server-side/src/authorize.ts new file mode 100644 index 000000000..724459bf6 --- /dev/null +++ b/packages/lib/server-side/src/authorize.ts @@ -0,0 +1,96 @@ +import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { validateSchema } from '@dm3-org/dm3-lib-shared'; +import ethers from 'ethers'; +import { NextFunction, Request, Response } from 'express'; +import { verify } from 'jsonwebtoken'; + +const authJwtPayloadSchema = { + type: 'object', + properties: { + account: { type: 'string' }, + iat: { type: 'number' }, + exp: { type: 'number' }, + nbf: { type: 'number' }, + }, + required: ['account', 'iat', 'exp', 'nbf'], + additionalProperties: false, +}; + +export async function authorize( + req: Request, + res: Response, + next: NextFunction, + ensName: string, + hasAccount: (ensName: string) => Promise, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string, +) { + const normalizedEnsName = normalizeEnsName(ensName); + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + if ( + token && + (await checkToken( + web3Provider, + hasAccount, + normalizedEnsName, + token, + serverSecret, + )) + ) { + next(); + } else { + console.warn('AUTH Token check failed for ', normalizedEnsName); + res.sendStatus(401); + } +} + +export async function checkToken( + provider: ethers.providers.JsonRpcProvider, + hasAccount: (ensName: string) => Promise, + ensName: string, + token: string, + serverSecret: string, +): Promise { + if (!(await hasAccount(ensName.toLocaleLowerCase()))) { + console.debug('there is no account for this ens name: ', ensName); + return false; + } + + console.debug('checkToken - ensName', ensName); + + // check jwt for validity + try { + // will throw if signature is invalid or exp is in the past + const jwtPayload = verify(token, serverSecret, { + algorithms: ['HS256'], + }); + + console.debug('checkToken - jwtPayload', jwtPayload); + + // check if payload is well formed + if ( + typeof jwtPayload === 'string' || + !validateSchema(authJwtPayloadSchema, jwtPayload) + ) { + console.debug('jwt malformed'); + return false; + } + + if (!jwtPayload.iat || jwtPayload.iat > Date.now() / 1000) { + console.debug('jwt invalid: iat missing or in the future'); + return false; + } + + if (jwtPayload.account !== ensName) { + console.debug('jwt invalid: account mismatch'); + return false; + } + } catch (error) { + console.debug(`jwt invalid: ${error}`); + return false; + } + + // the token is valid only if all checks passed + return true; +} diff --git a/packages/lib/server-side/src/iAccountDatabase.ts b/packages/lib/server-side/src/iAccountDatabase.ts new file mode 100644 index 000000000..0184e106a --- /dev/null +++ b/packages/lib/server-side/src/iAccountDatabase.ts @@ -0,0 +1,3 @@ +export interface IAccountDatabase { + hasAccount: (ensName: string) => Promise; +} diff --git a/packages/lib/server-side/src/iSessionDatabase.ts b/packages/lib/server-side/src/iSessionDatabase.ts deleted file mode 100644 index 422b9872f..000000000 --- a/packages/lib/server-side/src/iSessionDatabase.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Session as DSSession, spamFilter } from '@dm3-org/dm3-lib-delivery'; - -export interface IAccountDatabase { - setAccount: (ensName: string, session: DSSession) => Promise; - - getAccount: (ensName: string) => Promise< - | (DSSession & { - spamFilterRules: spamFilter.SpamFilterRules; - }) - | null - >; -} diff --git a/packages/lib/server-side/src/index.ts b/packages/lib/server-side/src/index.ts index 1742335be..2106f0f28 100644 --- a/packages/lib/server-side/src/index.ts +++ b/packages/lib/server-side/src/index.ts @@ -1,4 +1,6 @@ export { Auth } from './auth'; -export * from './utils'; +export * from './authorize'; export { getCachedWebProvider } from './web3Provider/getCachedWebProvider'; -export type { IAccountDatabase } from './iSessionDatabase'; +export type { IAccountDatabase } from './iAccountDatabase'; +export * from './utils'; +export * from './Keys'; diff --git a/packages/lib/server-side/src/utils.ts b/packages/lib/server-side/src/utils.ts index ed3e1782d..5af754431 100644 --- a/packages/lib/server-side/src/utils.ts +++ b/packages/lib/server-side/src/utils.ts @@ -1,91 +1,8 @@ /* eslint-disable no-console */ -import { checkToken } from '@dm3-org/dm3-lib-delivery'; -import { - DeliveryServiceProfileKeys, - normalizeEnsName, -} from '@dm3-org/dm3-lib-profile'; +import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; import { ethers } from 'ethers'; import { NextFunction, Request, Response } from 'express'; -import { Socket } from 'socket.io'; -import { ExtendedError } from 'socket.io/dist/namespace'; import winston from 'winston'; -import type { IAccountDatabase } from './iSessionDatabase'; - -export async function auth( - req: Request, - res: Response, - next: NextFunction, - ensName: string, - db: IAccountDatabase, - web3Provider: ethers.providers.JsonRpcProvider, - serverSecret: string, -) { - const normalizedEnsName = normalizeEnsName(ensName); - const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; - - if ( - token && - (await checkToken( - web3Provider, - db.getAccount, - normalizedEnsName, - token, - serverSecret, - )) - ) { - next(); - } else { - console.warn('AUTH Token check failed for ', normalizedEnsName); - res.sendStatus(401); - } -} - -export function socketAuth( - db: IAccountDatabase, - web3Provider: ethers.providers.JsonRpcProvider, - serverSecret: string, -) { - return async ( - socket: Socket, - next: (err?: ExtendedError | undefined) => void, - ) => { - try { - const ensName = normalizeEnsName( - socket.handshake.auth.account.ensName, - ); - console.log('Start WS auth for ', ensName, socket.id); - - if ( - !(await checkToken( - web3Provider, - db.getAccount, - ensName, - socket.handshake.auth.token as string, - serverSecret, - )) - ) { - console.log('check token has failed for WS '); - return next(new Error('check token has failed for WS')); - } - const session = await db.getAccount(ensName); - if (!session) { - throw Error('Could not get session'); - } - - await db.setAccount(ensName, { - ...session, - socketId: socket.id, - }); - } catch (e) { - console.log('socket auth error'); - console.log(e); - next(e as Error); - } - - next(); - }; -} export function logRequest(req: Request, res: Response, next: NextFunction) { winston.loggers.get('default').info({ @@ -102,7 +19,7 @@ export function logError( res: Response, next: NextFunction, ) { - winston.loggers.get('default').error({ + console.error({ method: req.method, url: req.url, error: error.toString(), diff --git a/packages/lib/shared/package.json b/packages/lib/shared/package.json index d47c82bc5..93c2d4fcf 100644 --- a/packages/lib/shared/package.json +++ b/packages/lib/shared/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-shared", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", diff --git a/packages/lib/shared/src/IBackendConnector.ts b/packages/lib/shared/src/IBackendConnector.ts index d62627860..11c87ca50 100644 --- a/packages/lib/shared/src/IBackendConnector.ts +++ b/packages/lib/shared/src/IBackendConnector.ts @@ -1,5 +1,9 @@ export interface IBackendConnector { - addConversation(ensName: string, encryptedContactName: string): void; + addConversation( + ensName: string, + encryptedContactName: string, + encryptedProfileLocation: string, + ): void; getConversations( ensName: string, size: number, @@ -7,6 +11,7 @@ export interface IBackendConnector { ): Promise< { contact: string; + encryptedProfileLocation: string; previewMessage: string; updatedAt: Date; }[] diff --git a/packages/lib/storage/package.json b/packages/lib/storage/package.json index 765479829..f28c6853f 100644 --- a/packages/lib/storage/package.json +++ b/packages/lib/storage/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-lib-storage", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", diff --git a/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts b/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts index 580d39ac3..ff7c0fde6 100644 --- a/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts +++ b/packages/lib/storage/src/new/cloudStorage/getCloudStorage.ts @@ -1,6 +1,11 @@ -import { IBackendConnector } from '@dm3-org/dm3-lib-shared'; +import { IBackendConnector, stringify } from '@dm3-org/dm3-lib-shared'; import { MessageRecord } from '../chunkStorage/ChunkStorageTypes'; -import { Encryption, StorageAPI, StorageEnvelopContainer } from '../types'; +import { + Encryption, + HaltedStorageEnvelopContainer, + StorageAPI, + StorageEnvelopContainer, +} from '../types'; //getCloudStorages is the interface to the cloud storage. //It encrypts and decrypts the data before sending/reciving it to/from the cloud storage of the DM3 backend export const getCloudStorage = ( @@ -8,13 +13,23 @@ export const getCloudStorage = ( ensName: string, encryption: Encryption, ): StorageAPI => { - const _addConversation = async (contactEnsName: string) => { + const _addConversation = async ( + contactEnsName: string, + contactProfileLocation: string[], + ) => { const encryptedContactName = await encryption.encryptSync( contactEnsName, ); + + const encryptedProfileLocation = await encryption.encryptSync( + stringify(contactProfileLocation), + ); + + console.log('add contact ', contactEnsName, contactProfileLocation); return await backendConnector.addConversation( ensName, encryptedContactName, + encryptedProfileLocation, ); }; @@ -29,14 +44,23 @@ export const getCloudStorage = ( conversations.map( async ({ contact, + encryptedProfileLocation, previewMessage, updatedAt, }: { contact: string; + encryptedProfileLocation: string; previewMessage: string | null; updatedAt: Date; }) => ({ contactEnsName: await encryption.decryptSync(contact), + contactProfileLocation: encryptedProfileLocation + ? JSON.parse( + await encryption.decryptSync( + encryptedProfileLocation, + ), + ) + : [], isHidden: false, messageCounter: 0, previewMessage: previewMessage @@ -83,11 +107,14 @@ export const getCloudStorage = ( message.encryptedEnvelopContainer, ); - return JSON.parse(decryptedEnvelopContainer); + return { + ...JSON.parse(decryptedEnvelopContainer), + messageId: message.messageId, + } as HaltedStorageEnvelopContainer; }), ); - return decryptedMessages as StorageEnvelopContainer[]; + return decryptedMessages; }; const clearHaltedMessages = async ( @@ -111,7 +138,7 @@ export const getCloudStorage = ( contactEnsName, ); const encryptedEnvelopContainer = await encryption.encryptAsync( - JSON.stringify(envelop), + stringify(envelop), ); //The client defines the createdAt timestamp for the message so it can be used to sort the messages @@ -142,7 +169,7 @@ export const getCloudStorage = ( async (storageEnvelopContainer: StorageEnvelopContainer) => { const encryptedEnvelopContainer = await encryption.encryptAsync( - JSON.stringify(storageEnvelopContainer), + stringify(storageEnvelopContainer), ); //The client defines the createdAt timestamp for the message so it can be used to sort the messages const createdAt = Date.now(); @@ -182,7 +209,7 @@ export const getCloudStorage = ( async (storageEnvelopContainer: StorageEnvelopContainer) => { const encryptedEnvelopContainer = await encryption.encryptAsync( - JSON.stringify(storageEnvelopContainer), + stringify(storageEnvelopContainer), ); return { encryptedEnvelopContainer, diff --git a/packages/lib/storage/src/new/types.ts b/packages/lib/storage/src/new/types.ts index 5c7401267..a342fe79a 100644 --- a/packages/lib/storage/src/new/types.ts +++ b/packages/lib/storage/src/new/types.ts @@ -7,7 +7,7 @@ export interface StorageAPI { pageSize: number, offset: number, ) => Promise; - getHaltedMessages: () => Promise; + getHaltedMessages: () => Promise; clearHaltedMessages: ( messageId: string, aliasName: string, @@ -22,7 +22,10 @@ export interface StorageAPI { ) => Promise; getNumberOfMessages: (contactEnsName: string) => Promise; getNumberOfConverations: () => Promise; - addConversation: (contactEnsName: string) => Promise; + addConversation: ( + contactEnsName: string, + contactProfileLocation: string[], + ) => Promise; addMessage: ( contactEnsName: string, envelop: StorageEnvelopContainer, @@ -39,9 +42,17 @@ export interface StorageEnvelopContainer { envelop: Envelop; } +export interface HaltedStorageEnvelopContainer { + messageState: MessageState; + envelop: Envelop; + messageId: string; +} + export interface Conversation { - //the contactEnsName is the ensName of the contact + //the contactEnsName is the ensName of the contact used as the id of the conversation contactEnsName: string; + //The contact might have certain tld associated with it + contactProfileLocation: string[]; //the previewMessage is the last message of the conversation previewMessage?: StorageEnvelopContainer; //isHidden is a flag to hide the conversation from the conversation list diff --git a/packages/lib/test-helper/mocks/mockUserProfile.ts b/packages/lib/test-helper/mocks/mockUserProfile.ts index 5d0773f51..9156e565e 100644 --- a/packages/lib/test-helper/mocks/mockUserProfile.ts +++ b/packages/lib/test-helper/mocks/mockUserProfile.ts @@ -13,6 +13,7 @@ import { } from '@dm3-org/dm3-lib-profile'; import { stringify } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; +import { getAddress } from 'ethers/lib/utils'; export type MockedUserProfile = { address: string; @@ -52,7 +53,7 @@ export const mockUserProfile = async ( return { wallet, - address: wallet.address, + address: getAddress(wallet.address), privateKey: wallet.privateKey, account: { ensName, diff --git a/packages/lib/test-helper/package.json b/packages/lib/test-helper/package.json index 17f6035ed..64f0e46c3 100644 --- a/packages/lib/test-helper/package.json +++ b/packages/lib/test-helper/package.json @@ -1,6 +1,6 @@ { "name": "@dm3-org/dm3-lib-test-helper", - "version": "1.5.0", + "version": "1.6.0", "main": "dist/index.js", "module": "dist-backend/index.js", "types": "dist/index.d.ts", diff --git a/packages/messenger-demo/package.json b/packages/messenger-demo/package.json index 29e4fdef8..f5c33bf0f 100644 --- a/packages/messenger-demo/package.json +++ b/packages/messenger-demo/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-messenger-demo", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@dm3-org/dm3-messenger-widget": "workspace:^", "@popperjs/core": "^2.11.8", diff --git a/packages/messenger-web/package.json b/packages/messenger-web/package.json index c0dec3451..bebf87096 100644 --- a/packages/messenger-web/package.json +++ b/packages/messenger-web/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-messenger-web", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@dm3-org/dm3-messenger-widget": "workspace:^", "@popperjs/core": "^2.11.8", diff --git a/packages/messenger-widget/package.json b/packages/messenger-widget/package.json index c3e79ace9..e8c78a4ca 100644 --- a/packages/messenger-widget/package.json +++ b/packages/messenger-widget/package.json @@ -1,7 +1,7 @@ { "name": "@dm3-org/dm3-messenger-widget", "license": "BSD-2-Clause", - "version": "1.5.0", + "version": "1.6.0", "main": "./lib/cjs/widget.js", "module": "./lib/esm/widget.js", "types": "./lib/esm/widget.d.ts", diff --git a/packages/messenger-widget/src/adapters/messages.ts b/packages/messenger-widget/src/adapters/messages.ts deleted file mode 100644 index 9f59c839d..000000000 --- a/packages/messenger-widget/src/adapters/messages.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { EncryptionEnvelop, Envelop } from '@dm3-org/dm3-lib-messaging'; -import { - Account, - getDeliveryServiceClient, - normalizeEnsName, -} from '@dm3-org/dm3-lib-profile'; -import { log } from '@dm3-org/dm3-lib-shared'; -import axios from 'axios'; -import { ethers } from 'ethers'; -import { Connection } from '../interfaces/utils'; -import { withAuthHeader } from './auth'; - -export async function sendMessage( - connection: Connection, - token: string, - envelop: Envelop | EncryptionEnvelop, - onSuccess: () => void, - onError: () => void, -): Promise { - if (!connection.socket) { - return; - } - connection.socket.emit( - 'submitMessage', - { - envelop, - token, - }, - (result: any) => { - if (result.response === 'success') { - log(`[sendMessage] success`, 'info'); - onSuccess(); - } else { - log(`[sendMessage] error `, 'error'); - onError(); - } - }, - ); -} - -export async function fetchNewMessages( - backendUrl: string, - mainnetProvider: ethers.providers.StaticJsonRpcProvider, - account: Account, - token: string, - contactAddress: string, -): Promise { - const deliveryPath = backendUrl + '/delivery'; - const url = `${deliveryPath}/messages/${normalizeEnsName( - account!.ensName, - )}/contact/${contactAddress}`; - - const { data } = await getDeliveryServiceClient( - account!.profile!, - mainnetProvider!, - async (url: string) => (await axios.get(url)).data, - ).get(url, withAuthHeader(token)); - - return data; -} - -export type SendMessage = typeof sendMessage; -export type GetNewMessages = typeof fetchNewMessages; diff --git a/packages/messenger-widget/src/components/AddConversation/AddConversation.tsx b/packages/messenger-widget/src/components/AddConversation/AddConversation.tsx index c019e844f..7d6c5ab47 100644 --- a/packages/messenger-widget/src/components/AddConversation/AddConversation.tsx +++ b/packages/messenger-widget/src/components/AddConversation/AddConversation.tsx @@ -22,7 +22,6 @@ export default function AddConversation() { const { addConversation, setSelectedContactName } = useContext(ConversationContext); const { ethAddress } = useContext(AuthContext); - const { resolveTLDtoAlias } = useContext(TLDContext); const { setSelectedLeftView, setSelectedRightView } = useContext(UiViewContext); const { @@ -31,7 +30,7 @@ export default function AddConversation() { setAddConversation, } = useContext(ModalContext); - const [name, setName] = useState(''); + const [tldName, setTldName] = useState(''); const [showError, setShowError] = useState(false); const [errorMsg, setErrorMsg] = useState(''); const [inputClass, setInputClass] = useState(INPUT_FIELD_CLASS); @@ -39,28 +38,29 @@ export default function AddConversation() { // handles new contact submission const submit = async (e: React.FormEvent) => { e.preventDefault(); - setName(name.trim()); - if (name.length) { + setTldName(tldName.trim()); + if (tldName.length) { // start loader setLoaderContent('Adding contact...'); startLoader(); const ensNameIsInvalid = ethAddress && - name.split('.')[0] && - ethAddress.toLowerCase() === name.split('.')[0].toLowerCase(); + tldName.split('.')[0] && + ethAddress.toLowerCase() === + tldName.split('.')[0].toLowerCase(); if (ensNameIsInvalid) { setErrorMsg('Please enter valid ENS name'); setShowError(true); return; } - //Checks wether the name entered, is an tld name. If yes, the TLD is substituded with the alias name - const aliasName = await resolveTLDtoAlias(name); + + const newContact = await addConversation(tldName); const addConversationData = { active: true, - ensName: aliasName, + ensName: newContact?.contactDetails.account.ensName, processed: false, }; @@ -72,8 +72,6 @@ export default function AddConversation() { // set right view to chat setSelectedRightView(RightViewSelected.Chat); - - const newContact = await addConversation(aliasName); if (!newContact) { //Maybe show a message that its not possible to add the users address as a contact setShowAddConversationModal(false); @@ -95,7 +93,7 @@ export default function AddConversation() { const handleNameChange = (e: React.ChangeEvent) => { setErrorMsg(''); setShowError(false); - setName(e.target.value); + setTldName(e.target.value); if (!ethers.utils.isValidName(e.target.value)) { setErrorMsg('Invalid address or ENS name'); setShowError(true); @@ -171,7 +169,7 @@ export default function AddConversation() { )} type="text" placeholder="Enter the name or address of the contact" - value={name} + value={tldName} onChange={( e: React.ChangeEvent, ) => handleNameChange(e)} @@ -188,10 +186,12 @@ export default function AddConversation() {