diff --git a/backend/src/federation/authenticateCommunities.ts b/backend/src/federation/authenticateCommunities.ts index ad2d91469..9c87da3c0 100644 --- a/backend/src/federation/authenticateCommunities.ts +++ b/backend/src/federation/authenticateCommunities.ts @@ -1,78 +1,110 @@ -import { CommunityLoggingView, Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, FederatedCommunityLoggingView, getHomeCommunity } from 'database' -import { validate as validateUUID, version as versionUUID } from 'uuid' +import { + CommunityHandshakeState as DbCommunityHandshakeState, + CommunityHandshakeStateLoggingView, + FederatedCommunity as DbFederatedCommunity, + findPendingCommunityHandshake, + getHomeCommunityWithFederatedCommunityOrFail, + CommunityHandshakeStateType, + getCommunityByPublicKeyOrFail, +} from 'database' import { randombytes_random } from 'sodium-native' -import { CONFIG as CONFIG_CORE } from 'core' import { AuthenticationClient as V1_0_AuthenticationClient } from '@/federation/client/1_0/AuthenticationClient' -import { ensureUrlEndsWithSlash } from 'core' +import { ensureUrlEndsWithSlash, getFederatedCommunityWithApiOrFail } from 'core' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' -import { encryptAndSign, OpenConnectionJwtPayloadType } from 'shared' +import { communityAuthenticatedSchema, encryptAndSign, OpenConnectionJwtPayloadType } from 'shared' import { getLogger } from 'log4js' import { AuthenticationClientFactory } from './client/AuthenticationClientFactory' import { EncryptedTransferArgs } from 'core' +import { CommunityHandshakeStateLogic } from 'core' +import { Ed25519PublicKey } from 'shared' -const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities`) +const createLogger = (functionName: string) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities.${functionName}`) + +export enum StartCommunityAuthenticationResult { + ALREADY_AUTHENTICATED = 'already authenticated', + ALREADY_IN_PROGRESS = 'already in progress', + SUCCESSFULLY_STARTED = 'successfully started', +} export async function startCommunityAuthentication( fedComB: DbFederatedCommunity, -): Promise { - const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities.startCommunityAuthentication`) +): Promise { + const methodLogger = createLogger('startCommunityAuthentication') const handshakeID = randombytes_random().toString() + const fedComBPublicKey = new Ed25519PublicKey(fedComB.publicKey) methodLogger.addContext('handshakeID', handshakeID) - methodLogger.debug(`startCommunityAuthentication()...`, { - fedComB: new FederatedCommunityLoggingView(fedComB), - }) - const homeComA = await getHomeCommunity() - methodLogger.debug('homeComA', new CommunityLoggingView(homeComA!)) - const homeFedComA = await DbFederatedCommunity.findOneByOrFail({ - foreign: false, - apiVersion: CONFIG_CORE.FEDERATION_BACKEND_SEND_ON_API, - }) - methodLogger.debug('homeFedComA', new FederatedCommunityLoggingView(homeFedComA)) - const comB = await DbCommunity.findOneByOrFail({ publicKey: fedComB.publicKey }) - methodLogger.debug('started with comB:', new CommunityLoggingView(comB)) + methodLogger.debug(`start with public key ${fedComBPublicKey.asHex()}`) + const homeComA = await getHomeCommunityWithFederatedCommunityOrFail(fedComB.apiVersion) + // methodLogger.debug('homeComA', new CommunityLoggingView(homeComA)) + const homeFedComA = getFederatedCommunityWithApiOrFail(homeComA, fedComB.apiVersion) + + const comB = await getCommunityByPublicKeyOrFail(fedComBPublicKey) + // methodLogger.debug('started with comB:', new CommunityLoggingView(comB)) // check if communityUuid is not a valid v4Uuid - try { - if ( - comB && - ((comB.communityUuid === null && comB.authenticatedAt === null) || - (comB.communityUuid !== null && - (!validateUUID(comB.communityUuid) || - versionUUID(comB.communityUuid!) !== 4))) - ) { - methodLogger.debug('comB.uuid is null or is a not valid v4Uuid...', comB.communityUuid || 'null', comB.authenticatedAt || 'null') - const client = AuthenticationClientFactory.getInstance(fedComB) - - if (client instanceof V1_0_AuthenticationClient) { - if (!comB.publicJwtKey) { - throw new Error('Public JWT key still not exist for comB ' + comB.name) - } - //create JWT with url in payload encrypted by foreignCom.publicJwtKey and signed with homeCom.privateJwtKey - const payload = new OpenConnectionJwtPayloadType(handshakeID, - ensureUrlEndsWithSlash(homeFedComA.endPoint).concat(homeFedComA.apiVersion), - ) - methodLogger.debug('payload', payload) - const jws = await encryptAndSign(payload, homeComA!.privateJwtKey!, comB.publicJwtKey!) - methodLogger.debug('jws', jws) - // prepare the args for the client invocation - const args = new EncryptedTransferArgs() - args.publicKey = homeComA!.publicKey.toString('hex') - args.jwt = jws - args.handshakeID = handshakeID - methodLogger.debug('before client.openConnection() args:', args) - const result = await client.openConnection(args) - if (result) { - methodLogger.debug(`successful initiated at community:`, fedComB.endPoint) - } else { - methodLogger.error(`can't initiate at community:`, fedComB.endPoint) - } - } - } else { - methodLogger.debug(`comB.communityUuid is already a valid v4Uuid ${ comB.communityUuid || 'null' } and was authenticated at ${ comB.authenticatedAt || 'null'}`) - } - } catch (err) { - methodLogger.error(`Error:`, err) + + // communityAuthenticatedSchema.safeParse return true + // - if communityUuid is a valid v4Uuid and + // - if authenticatedAt is a valid date + if (communityAuthenticatedSchema.safeParse(comB).success) { + methodLogger.debug(`comB.communityUuid is already a valid v4Uuid ${ comB.communityUuid || 'null' } and was authenticated at ${ comB.authenticatedAt || 'null'}`) + return StartCommunityAuthenticationResult.ALREADY_AUTHENTICATED } - methodLogger.removeContext('handshakeID') + /*methodLogger.debug('comB.uuid is null or is a not valid v4Uuid...', + comB.communityUuid || 'null', comB.authenticatedAt || 'null' + )*/ + + // check if a authentication is already in progress + const existingState = await findPendingCommunityHandshake(fedComBPublicKey, fedComB.apiVersion) + if (existingState) { + const stateLogic = new CommunityHandshakeStateLogic(existingState) + // retry on timeout or failure + if (!(await stateLogic.isTimeoutUpdate())) { + // authentication with community and api version is still in progress and it is not timeout yet + methodLogger.debug('existingState, so we exit here', new CommunityHandshakeStateLoggingView(existingState)) + return StartCommunityAuthenticationResult.ALREADY_IN_PROGRESS + } + } + + const client = AuthenticationClientFactory.getInstance(fedComB) + + if (client instanceof V1_0_AuthenticationClient) { + if (!comB.publicJwtKey) { + throw new Error(`Public JWT key still not exist for comB ${comB.name}`) + } + const state = new DbCommunityHandshakeState() + state.publicKey = fedComBPublicKey.asBuffer() + state.apiVersion = fedComB.apiVersion + state.status = CommunityHandshakeStateType.START_COMMUNITY_AUTHENTICATION + state.handshakeId = parseInt(handshakeID) + await state.save() + methodLogger.debug('[START_COMMUNITY_AUTHENTICATION] community handshake state created') + + //create JWT with url in payload encrypted by foreignCom.publicJwtKey and signed with homeCom.privateJwtKey + const payload = new OpenConnectionJwtPayloadType(handshakeID, + ensureUrlEndsWithSlash(homeFedComA.endPoint).concat(homeFedComA.apiVersion), + ) + // methodLogger.debug('payload', payload) + const jws = await encryptAndSign(payload, homeComA!.privateJwtKey!, comB.publicJwtKey!) + // methodLogger.debug('jws', jws) + // prepare the args for the client invocation + const args = new EncryptedTransferArgs() + const homeComAPublicKey = new Ed25519PublicKey(homeComA!.publicKey) + args.publicKey = homeComAPublicKey.asHex() + args.jwt = jws + args.handshakeID = handshakeID + // methodLogger.debug('before client.openConnection() args:', args) + const result = await client.openConnection(args) + if (result) { + methodLogger.debug(`successful initiated at community:`, fedComB.endPoint) + } else { + const errorMsg = `can't initiate at community: ${fedComB.endPoint}` + methodLogger.error(errorMsg) + state.status = CommunityHandshakeStateType.FAILED + state.lastError = errorMsg + } + await state.save() + } + return StartCommunityAuthenticationResult.SUCCESSFULLY_STARTED } diff --git a/backend/src/federation/validateCommunities.test.ts b/backend/src/federation/validateCommunities.test.ts index 255f8f180..949195652 100644 --- a/backend/src/federation/validateCommunities.test.ts +++ b/backend/src/federation/validateCommunities.test.ts @@ -103,7 +103,7 @@ describe('validate Communities', () => { return { data: { getPublicKey: { - publicKey: 'somePubKey', + publicKey: '2222222222222222222222222222222222222222222222222222222222222222', }, }, } as Response @@ -170,8 +170,8 @@ describe('validate Communities', () => { it('logs not matching publicKeys', () => { expect(logger.debug).toBeCalledWith( 'received not matching publicKey:', - 'somePubKey', - expect.stringMatching('11111111111111111111111111111111'), + '2222222222222222222222222222222222222222222222222222222222222222', + expect.stringMatching('1111111111111111111111111111111100000000000000000000000000000000'), ) }) }) diff --git a/backend/src/federation/validateCommunities.ts b/backend/src/federation/validateCommunities.ts index 36c9985c9..10088cf35 100644 --- a/backend/src/federation/validateCommunities.ts +++ b/backend/src/federation/validateCommunities.ts @@ -1,7 +1,6 @@ import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, - FederatedCommunityLoggingView, getHomeCommunity, } from 'database' import { IsNull } from 'typeorm' @@ -11,7 +10,7 @@ import { FederationClient as V1_0_FederationClient } from '@/federation/client/1 import { PublicCommunityInfo } from '@/federation/client/1_0/model/PublicCommunityInfo' import { FederationClientFactory } from '@/federation/client/FederationClientFactory' import { LogError } from '@/server/LogError' -import { createKeyPair } from 'shared' +import { createKeyPair, Ed25519PublicKey } from 'shared' import { getLogger } from 'log4js' import { startCommunityAuthentication } from './authenticateCommunities' import { PublicCommunityInfoLoggingView } from './client/1_0/logging/PublicCommunityInfoLogging.view' @@ -45,27 +44,31 @@ export async function validateCommunities(): Promise { logger.debug(`found ${dbFederatedCommunities.length} dbCommunities`) for (const dbFedComB of dbFederatedCommunities) { - logger.debug('dbFedComB', new FederatedCommunityLoggingView(dbFedComB)) + logger.debug(`verify federation community: ${dbFedComB.endPoint}${dbFedComB.apiVersion}`) const apiValueStrings: string[] = Object.values(ApiVersionType) - logger.debug(`suppported ApiVersions=`, apiValueStrings) if (!apiValueStrings.includes(dbFedComB.apiVersion)) { logger.debug('dbFedComB with unsupported apiVersion', dbFedComB.endPoint, dbFedComB.apiVersion) + logger.debug(`supported ApiVersions=`, apiValueStrings) continue } try { const client = FederationClientFactory.getInstance(dbFedComB) if (client instanceof V1_0_FederationClient) { - const pubKey = await client.getPublicKey() - if (pubKey && pubKey === dbFedComB.publicKey.toString('hex')) { + // throw if key isn't valid hex with length 64 + const clientPublicKey = new Ed25519PublicKey(await client.getPublicKey()) + // throw if key isn't valid hex with length 64 + const fedComBPublicKey = new Ed25519PublicKey(dbFedComB.publicKey) + if (clientPublicKey.isSame(fedComBPublicKey)) { await DbFederatedCommunity.update({ id: dbFedComB.id }, { verifiedAt: new Date() }) - logger.debug(`verified dbFedComB with:`, dbFedComB.endPoint) + // logger.debug(`verified dbFedComB with:`, dbFedComB.endPoint) const pubComInfo = await client.getPublicCommunityInfo() if (pubComInfo) { await writeForeignCommunity(dbFedComB, pubComInfo) logger.debug(`wrote response of getPublicCommunityInfo in dbFedComB ${dbFedComB.endPoint}`) try { - await startCommunityAuthentication(dbFedComB) + const result = await startCommunityAuthentication(dbFedComB) + logger.info(`${dbFedComB.endPoint}${dbFedComB.apiVersion} verified, authentication state: ${result}`) } catch (err) { logger.warn(`Warning: Authentication of community ${dbFedComB.endPoint} still ongoing:`, err) } @@ -73,7 +76,7 @@ export async function validateCommunities(): Promise { logger.debug('missing result of getPublicCommunityInfo') } } else { - logger.debug('received not matching publicKey:', pubKey, dbFedComB.publicKey.toString('hex')) + logger.debug('received not matching publicKey:', clientPublicKey.asHex(), fedComBPublicKey.asHex()) } } } catch (err) { diff --git a/bun.lock b/bun.lock index 5a76238fe..c7810fb83 100644 --- a/bun.lock +++ b/bun.lock @@ -449,6 +449,7 @@ "esbuild": "^0.25.2", "jose": "^4.14.4", "log4js": "^6.9.1", + "yoctocolors-cjs": "^2.1.2", "zod": "^3.25.61", }, "devDependencies": { @@ -1780,7 +1781,7 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], @@ -2816,7 +2817,7 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - "ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -3636,8 +3637,6 @@ "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -3724,20 +3723,22 @@ "@jest/source-map/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "@jest/transform/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "@jest/transform/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "@jest/transform/write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", "signal-exit": "^3.0.2", "typedarray-to-buffer": "^3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="], "@jest/types/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@morev/utils/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + "@morev/utils/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "@nuxt/kit/consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "@nuxt/kit/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@nuxt/kit/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "@nuxt/kit/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "@nuxt/kit/pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], @@ -3886,8 +3887,6 @@ "c12/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], - "c12/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "c12/pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], @@ -4248,6 +4247,8 @@ "unplugin-vue-components/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "v8-to-istanbul/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "vee-validate/@vue/devtools-api": ["@vue/devtools-api@7.7.7", "", { "dependencies": { "@vue/devtools-kit": "^7.7.7" } }, "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg=="], "vee-validate/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/core/src/graphql/logic/interpretEncryptedTransferArgs.ts b/core/src/graphql/logic/interpretEncryptedTransferArgs.ts index 301f6da16..576a2b3b8 100644 --- a/core/src/graphql/logic/interpretEncryptedTransferArgs.ts +++ b/core/src/graphql/logic/interpretEncryptedTransferArgs.ts @@ -1,42 +1,41 @@ import { EncryptedTransferArgs } from '../model/EncryptedTransferArgs' -import { JwtPayloadType } from 'shared' +import { Ed25519PublicKey, JwtPayloadType } from 'shared' import { Community as DbCommunity } from 'database' import { getLogger } from 'log4js' import { CommunityLoggingView, getHomeCommunity } from 'database' import { verifyAndDecrypt } from 'shared' import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const' -const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.logic.interpretEncryptedTransferArgs`) +const createLogger = (functionName: string) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.logic.interpretEncryptedTransferArgs.${functionName}`) export const interpretEncryptedTransferArgs = async (args: EncryptedTransferArgs): Promise => { - const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.logic.interpretEncryptedTransferArgs-method`) + const methodLogger = createLogger('interpretEncryptedTransferArgs') methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.debug('interpretEncryptedTransferArgs()... args:', args) + const argsPublicKey = new Ed25519PublicKey(args.publicKey) // first find with args.publicKey the community 'requestingCom', which starts the request - const requestingCom = await DbCommunity.findOneBy({ publicKey: Buffer.from(args.publicKey, 'hex') }) + // TODO: maybe use community from caller instead of loading it separately + const requestingCom = await DbCommunity.findOneBy({ publicKey: argsPublicKey.asBuffer() }) if (!requestingCom) { - const errmsg = `unknown requesting community with publicKey ${Buffer.from(args.publicKey, 'hex')}` + const errmsg = `unknown requesting community with publicKey ${argsPublicKey.asHex()}` methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') throw new Error(errmsg) } if (!requestingCom.publicJwtKey) { - const errmsg = `missing publicJwtKey of requesting community with publicKey ${Buffer.from(args.publicKey, 'hex')}` + const errmsg = `missing publicJwtKey of requesting community with publicKey ${argsPublicKey.asHex()}` methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') throw new Error(errmsg) } methodLogger.debug(`found requestingCom:`, new CommunityLoggingView(requestingCom)) // verify the signing of args.jwt with homeCom.privateJwtKey and decrypt args.jwt with requestingCom.publicJwtKey + // TODO: maybe use community from caller instead of loading it separately const homeCom = await getHomeCommunity() const jwtPayload = await verifyAndDecrypt(args.handshakeID, args.jwt, homeCom!.privateJwtKey!, requestingCom.publicJwtKey) as JwtPayloadType if (!jwtPayload) { - const errmsg = `invalid payload of community with publicKey ${Buffer.from(args.publicKey, 'hex')}` + const errmsg = `invalid payload of community with publicKey ${argsPublicKey.asHex()}` methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') throw new Error(errmsg) } methodLogger.debug('jwtPayload', jwtPayload) - methodLogger.removeContext('handshakeID') return jwtPayload } diff --git a/core/src/index.ts b/core/src/index.ts index a355bb9bd..a10eb6caa 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -22,4 +22,5 @@ export * from './util/calculateSenderBalance' export * from './util/utilities' export * from './validation/user' export * from './config/index' +export * from './logic' diff --git a/core/src/logic/CommunityHandshakeState.logic.ts b/core/src/logic/CommunityHandshakeState.logic.ts new file mode 100644 index 000000000..52422be15 --- /dev/null +++ b/core/src/logic/CommunityHandshakeState.logic.ts @@ -0,0 +1,33 @@ +import { CommunityHandshakeState, CommunityHandshakeStateType } from 'database' +import { FEDERATION_AUTHENTICATION_TIMEOUT_MS } from 'shared' + +export class CommunityHandshakeStateLogic { + public constructor(private self: CommunityHandshakeState) {} + + /** + * Check for expired state and if not, check timeout and update (write into db) to expired state + * @returns true if the community handshake state is expired + */ + public async isTimeoutUpdate(): Promise { + const timeout = this.isTimeout() + if (timeout && this.self.status !== CommunityHandshakeStateType.EXPIRED) { + this.self.status = CommunityHandshakeStateType.EXPIRED + await this.self.save() + } + return timeout + } + + public isTimeout(): boolean { + if (this.self.status === CommunityHandshakeStateType.EXPIRED) { + return true + } + if ((Date.now() - this.self.updatedAt.getTime()) > FEDERATION_AUTHENTICATION_TIMEOUT_MS) { + return true + } + return false + } + + public isFailed(): boolean { + return this.self.status === CommunityHandshakeStateType.FAILED + } +} diff --git a/core/src/logic/CommunityHandshakeStateLogic.test.ts b/core/src/logic/CommunityHandshakeStateLogic.test.ts new file mode 100644 index 000000000..52d11d8fb --- /dev/null +++ b/core/src/logic/CommunityHandshakeStateLogic.test.ts @@ -0,0 +1,22 @@ +import { CommunityHandshakeState } from 'database' +import { CommunityHandshakeStateLogic } from './CommunityHandshakeState.logic' +import { CommunityHandshakeStateType } from 'database' +import { FEDERATION_AUTHENTICATION_TIMEOUT_MS } from 'shared' + +describe('CommunityHandshakeStateLogic', () => { + it('isTimeout', () => { + const state = new CommunityHandshakeState() + state.updatedAt = new Date(Date.now() - FEDERATION_AUTHENTICATION_TIMEOUT_MS * 2) + state.status = CommunityHandshakeStateType.START_AUTHENTICATION + const logic = new CommunityHandshakeStateLogic(state) + expect(logic.isTimeout()).toEqual(true) + }) + + it('isTimeout return false', () => { + const state = new CommunityHandshakeState() + state.updatedAt = new Date(Date.now()) + state.status = CommunityHandshakeStateType.START_AUTHENTICATION + const logic = new CommunityHandshakeStateLogic(state) + expect(logic.isTimeout()).toEqual(false) + }) +}) \ No newline at end of file diff --git a/core/src/logic/community.logic.ts b/core/src/logic/community.logic.ts new file mode 100644 index 000000000..8fb9cd59b --- /dev/null +++ b/core/src/logic/community.logic.ts @@ -0,0 +1,12 @@ +import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' + +export function getFederatedCommunityWithApiOrFail( + community: DbCommunity, + apiVersion: string +): DbFederatedCommunity { + const fedCom = community.federatedCommunities?.find((fedCom) => fedCom.apiVersion === apiVersion) + if (!fedCom) { + throw new Error(`Missing federated community with api version ${apiVersion}`) + } + return fedCom +} diff --git a/core/src/logic/index.ts b/core/src/logic/index.ts new file mode 100644 index 000000000..7d7a943bf --- /dev/null +++ b/core/src/logic/index.ts @@ -0,0 +1,2 @@ +export * from './CommunityHandshakeState.logic' +export * from './community.logic' \ No newline at end of file diff --git a/core/src/util/utilities.ts b/core/src/util/utilities.ts index 0e8a8da85..be32736fc 100644 --- a/core/src/util/utilities.ts +++ b/core/src/util/utilities.ts @@ -36,6 +36,11 @@ export const delay = promisify(setTimeout) export const ensureUrlEndsWithSlash = (url: string): string => { return url.endsWith('/') ? url : url.concat('/') } +export function splitUrlInEndPointAndApiVersion(url: string): { endPoint: string, apiVersion: string } { + const endPoint = url.slice(0, url.lastIndexOf('/') + 1) + const apiVersion = url.slice(url.lastIndexOf('/') + 1, url.length) + return { endPoint, apiVersion } +} /** * Calculates the date representing the first day of the month, a specified number of months prior to a given date. * diff --git a/database/migration/migrations/0095-add_community_handshake_states_table.ts b/database/migration/migrations/0095-add_community_handshake_states_table.ts new file mode 100644 index 000000000..45e5b29a8 --- /dev/null +++ b/database/migration/migrations/0095-add_community_handshake_states_table.ts @@ -0,0 +1,20 @@ +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + CREATE TABLE community_handshake_states ( + id int unsigned NOT NULL AUTO_INCREMENT, + handshake_id int unsigned NOT NULL, + one_time_code int unsigned NULL DEFAULT NULL, + public_key binary(32) NOT NULL, + api_version varchar(255) NOT NULL, + status varchar(255) NOT NULL DEFAULT 'OPEN_CONNECTION', + last_error text, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_public_key (public_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`DROP TABLE community_handshake_states;`) +} diff --git a/database/src/entity/CommunityHandshakeState.ts b/database/src/entity/CommunityHandshakeState.ts new file mode 100644 index 000000000..eca9c07e7 --- /dev/null +++ b/database/src/entity/CommunityHandshakeState.ts @@ -0,0 +1,37 @@ +import { CommunityHandshakeStateType } from '../enum' +import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' + +@Entity('community_handshake_states') +export class CommunityHandshakeState extends BaseEntity { + @PrimaryGeneratedColumn({ unsigned: true }) + id: number + + @Column({ name: 'handshake_id', type: 'int', unsigned: true }) + handshakeId: number + + @Column({ name: 'one_time_code', type: 'int', unsigned: true, default: null, nullable: true }) + oneTimeCode?: number + + @Column({ name: 'public_key', type: 'binary', length: 32 }) + publicKey: Buffer + + @Column({ name: 'api_version', type: 'varchar', length: 255 }) + apiVersion: string + + @Column({ + type: 'varchar', + length: 255, + default: CommunityHandshakeStateType.START_COMMUNITY_AUTHENTICATION, + nullable: false, + }) + status: CommunityHandshakeStateType + + @Column({ name: 'last_error', type: 'text', nullable: true }) + lastError?: string + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt: Date + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt: Date +} \ No newline at end of file diff --git a/database/src/entity/index.ts b/database/src/entity/index.ts index 01195c37e..32bbe239b 100644 --- a/database/src/entity/index.ts +++ b/database/src/entity/index.ts @@ -7,6 +7,7 @@ import { Event } from './Event' import { FederatedCommunity } from './FederatedCommunity' import { LoginElopageBuys } from './LoginElopageBuys' import { Migration } from './Migration' +import { CommunityHandshakeState } from './CommunityHandshakeState' import { OpenaiThreads } from './OpenaiThreads' import { PendingTransaction } from './PendingTransaction' import { ProjectBranding } from './ProjectBranding' @@ -18,6 +19,7 @@ import { UserRole } from './UserRole' export { Community, + CommunityHandshakeState, Contribution, ContributionLink, ContributionMessage, @@ -25,7 +27,7 @@ export { Event, FederatedCommunity, LoginElopageBuys, - Migration, + Migration, ProjectBranding, OpenaiThreads, PendingTransaction, @@ -38,6 +40,7 @@ export { export const entities = [ Community, + CommunityHandshakeState, Contribution, ContributionLink, ContributionMessage, diff --git a/database/src/enum/CommunityHandshakeStateType.ts b/database/src/enum/CommunityHandshakeStateType.ts new file mode 100644 index 000000000..e41913cc0 --- /dev/null +++ b/database/src/enum/CommunityHandshakeStateType.ts @@ -0,0 +1,9 @@ +export enum CommunityHandshakeStateType { + START_COMMUNITY_AUTHENTICATION = 'START_COMMUNITY_AUTHENTICATION', + START_OPEN_CONNECTION_CALLBACK = 'START_OPEN_CONNECTION_CALLBACK', + START_AUTHENTICATION = 'START_AUTHENTICATION', + + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', + EXPIRED = 'EXPIRED' +} \ No newline at end of file diff --git a/database/src/enum/index.ts b/database/src/enum/index.ts new file mode 100644 index 000000000..c1d445299 --- /dev/null +++ b/database/src/enum/index.ts @@ -0,0 +1 @@ +export * from './CommunityHandshakeStateType' \ No newline at end of file diff --git a/database/src/index.ts b/database/src/index.ts index 56dec24ee..45b60530c 100644 --- a/database/src/index.ts +++ b/database/src/index.ts @@ -1,64 +1,9 @@ import { latestDbVersion } from './detectLastDBVersion' -import { Community } from './entity/Community' -import { Contribution } from './entity/Contribution' -import { ContributionLink } from './entity/ContributionLink' -import { ContributionMessage } from './entity/ContributionMessage' -import { DltTransaction } from './entity/DltTransaction' -import { Event } from './entity/Event' -import { FederatedCommunity } from './entity/FederatedCommunity' -import { LoginElopageBuys } from './entity/LoginElopageBuys' -import { Migration } from './entity/Migration' -import { OpenaiThreads } from './entity/OpenaiThreads' -import { PendingTransaction } from './entity/PendingTransaction' -import { ProjectBranding } from './entity/ProjectBranding' -import { Transaction } from './entity/Transaction' -import { TransactionLink } from './entity/TransactionLink' -import { User } from './entity/User' -import { UserContact } from './entity/UserContact' -import { UserRole } from './entity/UserRole' - -export { - Community, - Contribution, - ContributionLink, - ContributionMessage, - DltTransaction, - Event, - FederatedCommunity, - LoginElopageBuys, - Migration, - ProjectBranding, - OpenaiThreads, - PendingTransaction, - Transaction, - TransactionLink, - User, - UserContact, - UserRole, -} - -export const entities = [ - Community, - Contribution, - ContributionLink, - ContributionMessage, - DltTransaction, - Event, - FederatedCommunity, - LoginElopageBuys, - Migration, - ProjectBranding, - OpenaiThreads, - PendingTransaction, - Transaction, - TransactionLink, - User, - UserContact, - UserRole, -] - export { latestDbVersion } + +export * from './entity' export * from './logging' export * from './queries' -export * from './util' +export * from './util' +export * from './enum' export { AppDatabase } from './AppDatabase' diff --git a/database/src/logging/CommunityHandshakeStateLogging.view.ts b/database/src/logging/CommunityHandshakeStateLogging.view.ts new file mode 100644 index 000000000..b7df2452d --- /dev/null +++ b/database/src/logging/CommunityHandshakeStateLogging.view.ts @@ -0,0 +1,21 @@ +import { CommunityHandshakeState } from '..' +import { AbstractLoggingView } from './AbstractLogging.view' + +export class CommunityHandshakeStateLoggingView extends AbstractLoggingView { + public constructor(private self: CommunityHandshakeState) { + super() + } + + public toJSON(): any { + return { + id: this.self.id, + handshakeId: this.self.handshakeId, + oneTimeCode: this.self.oneTimeCode, + publicKey: this.self.publicKey.toString(this.bufferStringFormat), + status: this.self.status, + lastError: this.self.lastError, + createdAt: this.dateToString(this.self.createdAt), + updatedAt: this.dateToString(this.self.updatedAt), + } + } +} \ No newline at end of file diff --git a/database/src/logging/CommunityLogging.view.ts b/database/src/logging/CommunityLogging.view.ts index c06a4db41..1d675828c 100644 --- a/database/src/logging/CommunityLogging.view.ts +++ b/database/src/logging/CommunityLogging.view.ts @@ -1,5 +1,5 @@ import { Community } from '../entity' - +import { FederatedCommunityLoggingView } from './FederatedCommunityLogging.view' import { AbstractLoggingView } from './AbstractLogging.view' export class CommunityLoggingView extends AbstractLoggingView { @@ -21,6 +21,9 @@ export class CommunityLoggingView extends AbstractLoggingView { creationDate: this.dateToString(this.self.creationDate), createdAt: this.dateToString(this.self.createdAt), updatedAt: this.dateToString(this.self.updatedAt), + federatedCommunities: this.self.federatedCommunities?.map( + (federatedCommunity) => new FederatedCommunityLoggingView(federatedCommunity) + ), } } } diff --git a/database/src/logging/index.ts b/database/src/logging/index.ts index c19bd9a57..522fc3b56 100644 --- a/database/src/logging/index.ts +++ b/database/src/logging/index.ts @@ -11,6 +11,7 @@ import { TransactionLoggingView } from './TransactionLogging.view' import { UserContactLoggingView } from './UserContactLogging.view' import { UserLoggingView } from './UserLogging.view' import { UserRoleLoggingView } from './UserRoleLogging.view' +import { CommunityHandshakeStateLoggingView } from './CommunityHandshakeStateLogging.view' export { AbstractLoggingView, @@ -24,6 +25,7 @@ export { UserContactLoggingView, UserLoggingView, UserRoleLoggingView, + CommunityHandshakeStateLoggingView, } export const logger = getLogger(LOG4JS_BASE_CATEGORY_NAME) diff --git a/database/src/queries/communities.test.ts b/database/src/queries/communities.test.ts index 18975256c..b435c3649 100644 --- a/database/src/queries/communities.test.ts +++ b/database/src/queries/communities.test.ts @@ -1,8 +1,9 @@ import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from '..' import { AppDatabase } from '../AppDatabase' -import { getHomeCommunity, getReachableCommunities } from './communities' +import { getCommunityByPublicKeyOrFail, getHomeCommunity, getHomeCommunityWithFederatedCommunityOrFail, getReachableCommunities } from './communities' import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest' import { createCommunity, createVerifiedFederatedCommunity } from '../seeds/community' +import { Ed25519PublicKey } from 'shared' const db = AppDatabase.getInstance() @@ -39,6 +40,36 @@ describe('community.queries', () => { expect(community?.privateKey).toStrictEqual(homeCom.privateKey) }) }) + describe('getHomeCommunityWithFederatedCommunityOrFail', () => { + it('should return the home community with federated communities', async () => { + const homeCom = await createCommunity(false) + await createVerifiedFederatedCommunity('1_0', 100, homeCom) + const community = await getHomeCommunityWithFederatedCommunityOrFail('1_0') + expect(community).toBeDefined() + expect(community?.federatedCommunities).toHaveLength(1) + }) + + it('should throw if no home community exists', async () => { + expect(() => getHomeCommunityWithFederatedCommunityOrFail('1_0')).rejects.toThrow() + }) + + it('should throw if no federated community exists', async () => { + await createCommunity(false) + expect(() => getHomeCommunityWithFederatedCommunityOrFail('1_0')).rejects.toThrow() + }) + + it('load community by public key returned from getHomeCommunityWithFederatedCommunityOrFail', async () => { + const homeCom = await createCommunity(false) + await createVerifiedFederatedCommunity('1_0', 100, homeCom) + const community = await getHomeCommunityWithFederatedCommunityOrFail('1_0') + expect(community).toBeDefined() + expect(community?.federatedCommunities).toHaveLength(1) + const ed25519PublicKey = new Ed25519PublicKey(community.federatedCommunities![0].publicKey) + const communityByPublicKey = await getCommunityByPublicKeyOrFail(ed25519PublicKey) + expect(communityByPublicKey).toBeDefined() + expect(communityByPublicKey?.communityUuid).toBe(homeCom.communityUuid) + }) + }) describe('getReachableCommunities', () => { it('home community counts also to reachable communities', async () => { await createCommunity(false) diff --git a/database/src/queries/communities.ts b/database/src/queries/communities.ts index e216f8af6..81cd12765 100644 --- a/database/src/queries/communities.ts +++ b/database/src/queries/communities.ts @@ -1,6 +1,6 @@ import { FindOptionsOrder, FindOptionsWhere, IsNull, MoreThanOrEqual, Not } from 'typeorm' import { Community as DbCommunity } from '../entity' -import { urlSchema, uuidv4Schema } from 'shared' +import { Ed25519PublicKey, urlSchema, uuidv4Schema } from 'shared' /** * Retrieves the home community, i.e., a community that is not foreign. @@ -10,7 +10,14 @@ export async function getHomeCommunity(): Promise { // TODO: Put in Cache, it is needed nearly always // TODO: return only DbCommunity or throw to reduce unnecessary checks, because there should be always a home community return await DbCommunity.findOne({ - where: { foreign: false }, + where: { foreign: false } + }) +} + +export async function getHomeCommunityWithFederatedCommunityOrFail(apiVersion: string): Promise { + return await DbCommunity.findOneOrFail({ + where: { foreign: false, federatedCommunities: { apiVersion } }, + relations: { federatedCommunities: true }, }) } @@ -42,6 +49,22 @@ export async function getCommunityWithFederatedCommunityByIdentifier( }) } +export async function getCommunityWithFederatedCommunityWithApiOrFail( + publicKey: Ed25519PublicKey, + apiVersion: string +): Promise { + return await DbCommunity.findOneOrFail({ + where: { foreign: true, publicKey: publicKey.asBuffer(), federatedCommunities: { apiVersion } }, + relations: { federatedCommunities: true }, + }) +} + +export async function getCommunityByPublicKeyOrFail(publicKey: Ed25519PublicKey): Promise { + return await DbCommunity.findOneOrFail({ + where: { publicKey: publicKey.asBuffer() }, + }) +} + // returns all reachable communities // home community and all federated communities which have been verified within the last authenticationTimeoutMs export async function getReachableCommunities( @@ -60,4 +83,13 @@ export async function getReachableCommunities( ], order, }) +} + +export async function getNotReachableCommunities( + order?: FindOptionsOrder +): Promise { + return await DbCommunity.find({ + where: { authenticatedAt: IsNull(), foreign: true }, + order, + }) } \ No newline at end of file diff --git a/database/src/queries/communityHandshakes.test.ts b/database/src/queries/communityHandshakes.test.ts new file mode 100644 index 000000000..372fb1293 --- /dev/null +++ b/database/src/queries/communityHandshakes.test.ts @@ -0,0 +1,71 @@ +import { AppDatabase } from '../AppDatabase' +import { + CommunityHandshakeState as DbCommunityHandshakeState, + Community as DbCommunity, + FederatedCommunity as DbFederatedCommunity, + findPendingCommunityHandshake, + CommunityHandshakeStateType +} from '..' +import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest' +import { createCommunity, createVerifiedFederatedCommunity } from '../seeds/community' +import { Ed25519PublicKey } from 'shared' +import { randomBytes } from 'node:crypto' + +const db = AppDatabase.getInstance() + +beforeAll(async () => { + await db.init() +}) +afterAll(async () => { + await db.destroy() +}) + +async function createCommunityHandshakeState(publicKey: Buffer) { + const state = new DbCommunityHandshakeState() + state.publicKey = publicKey + state.apiVersion = '1_0' + state.status = CommunityHandshakeStateType.START_COMMUNITY_AUTHENTICATION + state.handshakeId = 1 + await state.save() +} + +describe('communityHandshakes', () => { + // clean db for every test case + beforeEach(async () => { + await DbCommunity.clear() + await DbFederatedCommunity.clear() + await DbCommunityHandshakeState.clear() + }) + + it('should find pending community handshake by public key', async () => { + const com1 = await createCommunity(false) + await createVerifiedFederatedCommunity('1_0', 100, com1) + await createCommunityHandshakeState(com1.publicKey) + const communityHandshakeState = await findPendingCommunityHandshake(new Ed25519PublicKey(com1.publicKey), '1_0') + expect(communityHandshakeState).toBeDefined() + expect(communityHandshakeState).toMatchObject({ + publicKey: com1.publicKey, + apiVersion: '1_0', + status: CommunityHandshakeStateType.START_COMMUNITY_AUTHENTICATION, + handshakeId: 1 + }) + }) + + it('update state', async () => { + const publicKey = new Ed25519PublicKey(randomBytes(32)) + await createCommunityHandshakeState(publicKey.asBuffer()) + const communityHandshakeState = await findPendingCommunityHandshake(publicKey, '1_0') + expect(communityHandshakeState).toBeDefined() + communityHandshakeState!.status = CommunityHandshakeStateType.START_OPEN_CONNECTION_CALLBACK + await communityHandshakeState!.save() + const communityHandshakeState2 = await findPendingCommunityHandshake(publicKey, '1_0') + const states = await DbCommunityHandshakeState.find() + expect(communityHandshakeState2).toBeDefined() + expect(communityHandshakeState2).toMatchObject({ + publicKey: publicKey.asBuffer(), + apiVersion: '1_0', + status: CommunityHandshakeStateType.START_OPEN_CONNECTION_CALLBACK, + handshakeId: 1 + }) + }) +}) \ No newline at end of file diff --git a/database/src/queries/communityHandshakes.ts b/database/src/queries/communityHandshakes.ts new file mode 100644 index 000000000..9dc83118b --- /dev/null +++ b/database/src/queries/communityHandshakes.ts @@ -0,0 +1,35 @@ +import { Not, In } from 'typeorm' +import { CommunityHandshakeState, CommunityHandshakeStateType} from '..' +import { Ed25519PublicKey } from 'shared' + +/** + * Find a pending community handshake by public key. + * @param publicKey The public key of the community. + * @param apiVersion The API version of the community. + * @param status The status of the community handshake. Optional, if not set, it will find a pending community handshake. + * @returns The CommunityHandshakeState with associated federated community and community. + */ +export function findPendingCommunityHandshake( + publicKey: Ed25519PublicKey, apiVersion: string, status?: CommunityHandshakeStateType +): Promise { + return CommunityHandshakeState.findOne({ + where: { + publicKey: publicKey.asBuffer(), + apiVersion, + status: status || Not(In([ + CommunityHandshakeStateType.EXPIRED, + CommunityHandshakeStateType.FAILED, + CommunityHandshakeStateType.SUCCESS + ])) + }, + }) +} + +export function findPendingCommunityHandshakeOrFailByOneTimeCode( + oneTimeCode: number +): Promise { + return CommunityHandshakeState.findOneOrFail({ + where: { oneTimeCode }, + }) +} + \ No newline at end of file diff --git a/database/src/queries/index.ts b/database/src/queries/index.ts index 1fec568bf..73a2cc15b 100644 --- a/database/src/queries/index.ts +++ b/database/src/queries/index.ts @@ -5,5 +5,6 @@ export * from './communities' export * from './pendingTransactions' export * from './transactions' export * from './transactionLinks' +export * from './communityHandshakes' export const LOG4JS_QUERIES_CATEGORY_NAME = `${LOG4JS_BASE_CATEGORY_NAME}.queries` diff --git a/database/src/seeds/community.ts b/database/src/seeds/community.ts index 4db872398..12a5bd67f 100644 --- a/database/src/seeds/community.ts +++ b/database/src/seeds/community.ts @@ -2,7 +2,13 @@ import { Community, FederatedCommunity } from '../entity' import { randomBytes } from 'node:crypto' import { v4 as uuidv4 } from 'uuid' -export async function createCommunity(foreign: boolean, save: boolean = true): Promise { +/** + * Creates a community. + * @param foreign + * @param store if true, write to db, default: true + * @returns + */ +export async function createCommunity(foreign: boolean, store: boolean = true): Promise { const community = new Community() community.publicKey = randomBytes(32) community.communityUuid = uuidv4() @@ -23,14 +29,22 @@ export async function createCommunity(foreign: boolean, save: boolean = true): P community.description = 'HomeCommunity-description' community.url = 'http://localhost/api' } - return save ? await community.save() : community + return store ? await community.save() : community } +/** + * Creates a verified federated community. + * @param apiVersion + * @param verifiedBeforeMs time in ms before the current time + * @param community + * @param store if true, write to db, default: true + * @returns + */ export async function createVerifiedFederatedCommunity( apiVersion: string, verifiedBeforeMs: number, community: Community, - save: boolean = true + store: boolean = true ): Promise { const federatedCommunity = new FederatedCommunity() federatedCommunity.apiVersion = apiVersion @@ -38,5 +52,5 @@ export async function createVerifiedFederatedCommunity( federatedCommunity.publicKey = community.publicKey federatedCommunity.community = community federatedCommunity.verifiedAt = new Date(Date.now() - verifiedBeforeMs) - return save ? await federatedCommunity.save() : federatedCommunity + return store ? await federatedCommunity.save() : federatedCommunity } diff --git a/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts b/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts index 4c14360e9..d22c816ff 100644 --- a/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts +++ b/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts @@ -1,19 +1,31 @@ -import { CONFIG } from '@/config' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' -import { EncryptedTransferArgs, interpretEncryptedTransferArgs } from 'core' +import { CommunityHandshakeStateLogic, EncryptedTransferArgs, interpretEncryptedTransferArgs, splitUrlInEndPointAndApiVersion } from 'core' import { - CommunityLoggingView, - Community as DbCommunity, + CommunityHandshakeStateLoggingView, + CommunityHandshakeState as DbCommunityHandshakeState, + CommunityHandshakeStateType, FederatedCommunity as DbFedCommunity, - FederatedCommunityLoggingView, getHomeCommunity, + findPendingCommunityHandshakeOrFailByOneTimeCode, + getCommunityByPublicKeyOrFail, } from 'database' import { getLogger } from 'log4js' -import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, OpenConnectionJwtPayloadType, uint32Schema, uuidv4Schema } from 'shared' +import { + AuthenticationJwtPayloadType, + AuthenticationResponseJwtPayloadType, + Ed25519PublicKey, + encryptAndSign, + OpenConnectionCallbackJwtPayloadType, + OpenConnectionJwtPayloadType, + uint32Schema, + uuidv4Schema +} from 'shared' import { Arg, Mutation, Resolver } from 'type-graphql' import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity' -const createLogger = (method: string ) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.${method}`) +// TODO: think about the case, when we have a higher api version, which still use this resolver +const apiVersion = '1_0' +const createLogger = (method: string ) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.${apiVersion}.resolver.AuthenticationResolver.${method}`) @Resolver() export class AuthenticationResolver { @@ -24,45 +36,38 @@ export class AuthenticationResolver { ): Promise { const methodLogger = createLogger('openConnection') methodLogger.addContext('handshakeID', args.handshakeID) - methodLogger.debug(`openConnection() via apiVersion=1_0:`, args) + const argsPublicKey = new Ed25519PublicKey(args.publicKey) + methodLogger.debug(`start via apiVersion=${apiVersion}, public key: ${argsPublicKey.asHex()}`) try { const openConnectionJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionJwtPayloadType - methodLogger.debug('openConnectionJwtPayload', openConnectionJwtPayload) + methodLogger.debug(`openConnectionJwtPayload url: ${openConnectionJwtPayload.url}`) if (!openConnectionJwtPayload) { - const errmsg = `invalid OpenConnection payload of requesting community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return true + throw new Error(`invalid OpenConnection payload of requesting community with publicKey ${argsPublicKey.asHex()}`) } if (openConnectionJwtPayload.tokentype !== OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE) { - const errmsg = `invalid tokentype of community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return true + throw new Error(`invalid tokentype: ${openConnectionJwtPayload.tokentype} of community with publicKey ${argsPublicKey.asHex()}`) } if (!openConnectionJwtPayload.url) { - const errmsg = `invalid url of community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return true + throw new Error(`invalid url of community with publicKey ${argsPublicKey.asHex()}`) } - methodLogger.debug(`vor DbFedCommunity.findOneByOrFail()...`, { publicKey: args.publicKey }) - const fedComA = await DbFedCommunity.findOneByOrFail({ publicKey: Buffer.from(args.publicKey, 'hex') }) - methodLogger.debug(`nach DbFedCommunity.findOneByOrFail()...`, fedComA) - methodLogger.debug('fedComA', new FederatedCommunityLoggingView(fedComA)) + // methodLogger.debug(`before DbFedCommunity.findOneByOrFail()...`, { publicKey: argsPublicKey.asHex() }) + const fedComA = await DbFedCommunity.findOneByOrFail({ publicKey: argsPublicKey.asBuffer() }) + // methodLogger.debug(`after DbFedCommunity.findOneByOrFail()...`, new FederatedCommunityLoggingView(fedComA)) if (!openConnectionJwtPayload.url.startsWith(fedComA.endPoint)) { - const errmsg = `invalid url of community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return true + throw new Error(`invalid url of community with publicKey ${argsPublicKey.asHex()}`) + } + if (fedComA.apiVersion !== apiVersion) { + throw new Error(`invalid apiVersion: ${fedComA.apiVersion} of community with publicKey ${argsPublicKey.asHex()}`) } // no await to respond immediately and invoke callback-request asynchronously - void startOpenConnectionCallback(args.handshakeID, args.publicKey, CONFIG.FEDERATION_API) + // important: startOpenConnectionCallback must catch all exceptions them self, or server will crash! + void startOpenConnectionCallback(args.handshakeID, argsPublicKey, fedComA) methodLogger.debug('openConnection() successfully initiated callback and returns true immediately...') return true } catch (err) { methodLogger.error('invalid jwt token:', err) + // no infos to the caller return true } } @@ -74,37 +79,29 @@ export class AuthenticationResolver { ): Promise { const methodLogger = createLogger('openConnectionCallback') methodLogger.addContext('handshakeID', args.handshakeID) - methodLogger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args) + methodLogger.debug(`start via apiVersion=${apiVersion}, public key: ${args.publicKey}`) try { // decrypt args.url with homeCom.privateJwtKey and verify signing with callbackFedCom.publicKey const openConnectionCallbackJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionCallbackJwtPayloadType if (!openConnectionCallbackJwtPayload) { - const errmsg = `invalid OpenConnectionCallback payload of requesting community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return true + throw new Error(`invalid OpenConnectionCallback payload of requesting community with publicKey ${args.publicKey}`) } - - const endPoint = openConnectionCallbackJwtPayload.url.slice(0, openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1) - const apiVersion = openConnectionCallbackJwtPayload.url.slice(openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1, openConnectionCallbackJwtPayload.url.length) - methodLogger.debug(`search fedComB per:`, endPoint, apiVersion) + const { endPoint, apiVersion } = splitUrlInEndPointAndApiVersion(openConnectionCallbackJwtPayload.url) + // methodLogger.debug(`search fedComB per:`, endPoint, apiVersion) const fedComB = await DbFedCommunity.findOneBy({ endPoint, apiVersion }) if (!fedComB) { - const errmsg = `unknown callback community with url` + openConnectionCallbackJwtPayload.url - methodLogger.error(errmsg) - // no infos to the caller - return true + throw new Error(`unknown callback community for ${endPoint}${apiVersion}`) } methodLogger.debug( - `found fedComB and start authentication:`, - new FederatedCommunityLoggingView(fedComB), + `found fedComB and start authentication: ${fedComB.endPoint}${fedComB.apiVersion}`, ) // no await to respond immediately and invoke authenticate-request asynchronously void startAuthentication(args.handshakeID, openConnectionCallbackJwtPayload.oneTimeCode, fedComB) - methodLogger.debug('openConnectionCallback() successfully initiated authentication and returns true immediately...') + // methodLogger.debug('openConnectionCallback() successfully initiated authentication and returns true immediately...') return true } catch (err) { methodLogger.error('invalid jwt token:', err) + // no infos to the caller return true } } @@ -116,51 +113,80 @@ export class AuthenticationResolver { ): Promise { const methodLogger = createLogger('authenticate') methodLogger.addContext('handshakeID', args.handshakeID) - methodLogger.debug(`authenticate() via apiVersion=1_0 ...`, args) + methodLogger.debug(`start via apiVersion=${apiVersion}, public key: ${args.publicKey}`) + let state: DbCommunityHandshakeState | null = null + const argsPublicKey = new Ed25519PublicKey(args.publicKey) try { const authArgs = await interpretEncryptedTransferArgs(args) as AuthenticationJwtPayloadType + // methodLogger.debug(`interpreted authentication payload...authArgs:`, authArgs) if (!authArgs) { - const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey - methodLogger.error(errmsg) - // no infos to the caller - return null + methodLogger.debug(`interpretEncryptedTransferArgs was called with`, args) + throw new Error(`invalid authentication payload of requesting community with publicKey ${argsPublicKey.asHex()}`) } - if (!uint32Schema.safeParse(Number(authArgs.oneTimeCode)).success) { - const errmsg = `invalid oneTimeCode: ${authArgs.oneTimeCode} for community with publicKey ${authArgs.publicKey}, expect uint32` - methodLogger.error(errmsg) - // no infos to the caller - return null + const validOneTimeCode = uint32Schema.safeParse(Number(authArgs.oneTimeCode)) + if (!validOneTimeCode.success) { + throw new Error( + `invalid oneTimeCode: ${authArgs.oneTimeCode} for community with publicKey ${argsPublicKey.asHex()}, expect uint32` + ) } - const authCom = await DbCommunity.findOneByOrFail({ communityUuid: authArgs.oneTimeCode }) + + state = await findPendingCommunityHandshakeOrFailByOneTimeCode(validOneTimeCode.data) + const stateLogic = new CommunityHandshakeStateLogic(state) + if ( + (await stateLogic.isTimeoutUpdate()) || + state.status !== CommunityHandshakeStateType.START_OPEN_CONNECTION_CALLBACK + ) { + throw new Error('No valid pending community handshake found') + } + state.status = CommunityHandshakeStateType.SUCCESS + await state.save() + methodLogger.debug('[SUCCESS] community handshake state updated') + + // methodLogger.debug(`search community per oneTimeCode:`, authArgs.oneTimeCode) + const authCom = await getCommunityByPublicKeyOrFail(argsPublicKey) if (authCom) { - methodLogger.debug('found authCom:', new CommunityLoggingView(authCom)) - if (authCom.publicKey !== authArgs.publicKey) { - const errmsg = `corrupt authentication call detected, oneTimeCode: ${authArgs.oneTimeCode} doesn't belong to caller: ${authArgs.publicKey}` - methodLogger.error(errmsg) - // no infos to the caller - return null + methodLogger.debug(`found authCom ${authCom.name}`) + const authComPublicKey = new Ed25519PublicKey(authCom.publicKey) + // methodLogger.debug('authCom.publicKey', authComPublicKey.asHex()) + // methodLogger.debug('args.publicKey', argsPublicKey.asHex()) + if (!authComPublicKey.isSame(argsPublicKey)) { + throw new Error( + `corrupt authentication call detected, oneTimeCode: ${authArgs.oneTimeCode} doesn't belong to caller: ${argsPublicKey.asHex()}` + ) } const communityUuid = uuidv4Schema.safeParse(authArgs.uuid) if (!communityUuid.success) { - const errmsg = `invalid uuid: ${authArgs.uuid} for community with publicKey ${authArgs.publicKey}` - methodLogger.error(errmsg) - // no infos to the caller - return null + throw new Error( + `invalid uuid: ${authArgs.uuid} for community with publicKey ${authComPublicKey.asHex()}` + ) } authCom.communityUuid = communityUuid.data authCom.authenticatedAt = new Date() - await DbCommunity.save(authCom) - methodLogger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom)) + await authCom.save() + methodLogger.debug(`update authCom.uuid successfully with ${authCom.communityUuid} at ${authCom.authenticatedAt}`) + const homeComB = await getHomeCommunity() if (homeComB?.communityUuid) { const responseArgs = new AuthenticationResponseJwtPayloadType(args.handshakeID,homeComB.communityUuid) const responseJwt = await encryptAndSign(responseArgs, homeComB.privateJwtKey!, authCom.publicJwtKey!) return responseJwt } + } else { + throw new Error(`community with publicKey ${argsPublicKey.asHex()} not found`) } return null } catch (err) { - methodLogger.error('invalid jwt token:', err) + if (state) { + try { + state.status = CommunityHandshakeStateType.FAILED + state.lastError = String(err) + await state.save() + } catch (err) { + methodLogger.error(`failed to save state`, new CommunityHandshakeStateLoggingView(state), err) + } + } + methodLogger.error(`failed`, err) + // no infos to the caller return null } } diff --git a/federation/src/graphql/api/1_0/util/authenticateCommunity.ts b/federation/src/graphql/api/1_0/util/authenticateCommunity.ts index 33f725737..4b95898ba 100644 --- a/federation/src/graphql/api/1_0/util/authenticateCommunity.ts +++ b/federation/src/graphql/api/1_0/util/authenticateCommunity.ts @@ -1,85 +1,118 @@ -import { EncryptedTransferArgs } from 'core' +import { CommunityHandshakeStateLogic, EncryptedTransferArgs, ensureUrlEndsWithSlash } from 'core' import { - CommunityLoggingView, + CommunityHandshakeStateLoggingView, Community as DbCommunity, FederatedCommunity as DbFedCommunity, - FederatedCommunityLoggingView, + findPendingCommunityHandshake, + getCommunityByPublicKeyOrFail, getHomeCommunity, + getHomeCommunityWithFederatedCommunityOrFail, } from 'database' import { getLogger } from 'log4js' -import { validate as validateUUID, version as versionUUID } from 'uuid' import { AuthenticationClientFactory } from '@/client/AuthenticationClientFactory' import { randombytes_random } from 'sodium-native' import { AuthenticationClient as V1_0_AuthenticationClient } from '@/client/1_0/AuthenticationClient' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' -import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, uuidv4Schema, verifyAndDecrypt } from 'shared' +import { + AuthenticationJwtPayloadType, + AuthenticationResponseJwtPayloadType, + Ed25519PublicKey, + encryptAndSign, + OpenConnectionCallbackJwtPayloadType, + uuidv4Schema, + verifyAndDecrypt +} from 'shared' +import { CommunityHandshakeState as DbCommunityHandshakeState, CommunityHandshakeStateType } from 'database' +import { getFederatedCommunityWithApiOrFail } from 'core' -const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity`) +const createLogger = (method: string ) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.${method}`) export async function startOpenConnectionCallback( handshakeID: string, - publicKey: string, - api: string, + publicKey: Ed25519PublicKey, + fedComA: DbFedCommunity, ): Promise { - const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startOpenConnectionCallback`) + const methodLogger = createLogger('startOpenConnectionCallback') methodLogger.addContext('handshakeID', handshakeID) - methodLogger.debug(`Authentication: startOpenConnectionCallback() with:`, { - publicKey, - }) + methodLogger.debug(`start`) + const api = fedComA.apiVersion + + let state: DbCommunityHandshakeState | null = null try { - const homeComB = await getHomeCommunity() - const homeFedComB = await DbFedCommunity.findOneByOrFail({ - foreign: false, - apiVersion: api, - }) - const comA = await DbCommunity.findOneByOrFail({ publicKey: Buffer.from(publicKey, 'hex') }) - const fedComA = await DbFedCommunity.findOneByOrFail({ - foreign: true, - apiVersion: api, - publicKey: comA.publicKey, - }) - // store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier - // prevent overwriting valid UUID with oneTimeCode, because this request could be initiated at any time from federated community - if (uuidv4Schema.safeParse(comA.communityUuid).success) { - throw new Error('Community UUID is already a valid UUID') + const pendingState = await findPendingCommunityHandshake(publicKey, api, CommunityHandshakeStateType.START_OPEN_CONNECTION_CALLBACK) + if (pendingState) { + const stateLogic = new CommunityHandshakeStateLogic(pendingState) + // retry on timeout or failure + if (!(await stateLogic.isTimeoutUpdate())) { + // authentication with community and api version is still in progress and it is not timeout yet + methodLogger.debug('existingState, so we exit here', new CommunityHandshakeStateLoggingView(pendingState)) + return + } } + // load comA and comB parallel + // load with joined federated community of given api version + const [homeComB, comA] = await Promise.all([ + getHomeCommunityWithFederatedCommunityOrFail(api), + getCommunityByPublicKeyOrFail(publicKey), + ]) + // get federated communities with correct api version + // simply check and extract federated community from community of given api version or throw error if not found + const homeFedComB = getFederatedCommunityWithApiOrFail(homeComB, api) + // TODO: make sure it is unique - const oneTimeCode = randombytes_random().toString() - comA.communityUuid = oneTimeCode - await DbCommunity.save(comA) - methodLogger.debug( - `Authentication: stored oneTimeCode in requestedCom:`, - new CommunityLoggingView(comA), - ) + const oneTimeCode = randombytes_random() + const oneTimeCodeString = oneTimeCode.toString() + + // Create new community handshake state + state = new DbCommunityHandshakeState() + state.publicKey = publicKey.asBuffer() + state.apiVersion = api + state.status = CommunityHandshakeStateType.START_OPEN_CONNECTION_CALLBACK + state.handshakeId = parseInt(handshakeID) + state.oneTimeCode = oneTimeCode + state = await state.save() + methodLogger.debug('[START_OPEN_CONNECTION_CALLBACK] community handshake state created') const client = AuthenticationClientFactory.getInstance(fedComA) if (client instanceof V1_0_AuthenticationClient) { - const url = homeFedComB.endPoint.endsWith('/') - ? homeFedComB.endPoint + homeFedComB.apiVersion - : homeFedComB.endPoint + '/' + homeFedComB.apiVersion + const url = ensureUrlEndsWithSlash(homeFedComB.endPoint) + homeFedComB.apiVersion - const callbackArgs = new OpenConnectionCallbackJwtPayloadType(handshakeID, oneTimeCode, url) - methodLogger.debug(`Authentication: start openConnectionCallback with args:`, callbackArgs) + const callbackArgs = new OpenConnectionCallbackJwtPayloadType(handshakeID, oneTimeCodeString, url) + // methodLogger.debug(`Authentication: start openConnectionCallback with args:`, callbackArgs) // encrypt callbackArgs with requestedCom.publicJwtKey and sign it with homeCom.privateJwtKey - const jwt = await encryptAndSign(callbackArgs, homeComB!.privateJwtKey!, comA.publicJwtKey!) + const jwt = await encryptAndSign(callbackArgs, homeComB.privateJwtKey!, comA.publicJwtKey!) const args = new EncryptedTransferArgs() - args.publicKey = homeComB!.publicKey.toString('hex') + args.publicKey = new Ed25519PublicKey(homeComB.publicKey).asHex() args.jwt = jwt args.handshakeID = handshakeID + methodLogger.debug(`invoke openConnectionCallback(), oneTimeCode: ${oneTimeCodeString}`) const result = await client.openConnectionCallback(args) if (result) { - methodLogger.debug('startOpenConnectionCallback() successful:', jwt) + methodLogger.debug(`startOpenConnectionCallback() successful`) } else { - methodLogger.error('startOpenConnectionCallback() failed:', jwt) + methodLogger.debug(`jwt: ${jwt}`) + const errorString = 'startOpenConnectionCallback() failed' + methodLogger.error(errorString) + state.status = CommunityHandshakeStateType.FAILED + state.lastError = errorString + state = await state.save() } } } catch (err) { - methodLogger.error('error in startOpenConnectionCallback:', err) + methodLogger.error('error in startOpenConnectionCallback', err) + if (state) { + try { + state.status = CommunityHandshakeStateType.FAILED + state.lastError = String(err) + state = await state.save() + } catch(e) { + methodLogger.error('error on saving CommunityHandshakeState', e) + } + } } - methodLogger.removeContext('handshakeID') } export async function startAuthentication( @@ -87,21 +120,31 @@ export async function startAuthentication( oneTimeCode: string, fedComB: DbFedCommunity, ): Promise { - const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startAuthentication`) + const methodLogger = createLogger('startAuthentication') methodLogger.addContext('handshakeID', handshakeID) - methodLogger.debug(`startAuthentication()...`, { - oneTimeCode, - fedComB: new FederatedCommunityLoggingView(fedComB), - }) + methodLogger.debug(`startAuthentication()... oneTimeCode: ${oneTimeCode}`) + let state: DbCommunityHandshakeState | null = null try { + const fedComBPublicKey = new Ed25519PublicKey(fedComB.publicKey) const homeComA = await getHomeCommunity() const comB = await DbCommunity.findOneByOrFail({ foreign: true, - publicKey: fedComB.publicKey, + publicKey: fedComBPublicKey.asBuffer(), }) if (!comB.publicJwtKey) { throw new Error('Public JWT key still not exist for foreign community') } + state = await findPendingCommunityHandshake(fedComBPublicKey, fedComB.apiVersion, CommunityHandshakeStateType.START_COMMUNITY_AUTHENTICATION) + if (!state) { + throw new Error('No pending community handshake found') + } + const stateLogic = new CommunityHandshakeStateLogic(state) + if ((await stateLogic.isTimeoutUpdate())) { + methodLogger.debug('invalid state', new CommunityHandshakeStateLoggingView(state)) + throw new Error('No valid pending community handshake found') + } + state.status = CommunityHandshakeStateType.START_AUTHENTICATION + await state.save() const client = AuthenticationClientFactory.getInstance(fedComB) @@ -110,41 +153,55 @@ export async function startAuthentication( // encrypt authenticationArgs.uuid with fedComB.publicJwtKey and sign it with homeCom.privateJwtKey const jwt = await encryptAndSign(authenticationArgs, homeComA!.privateJwtKey!, comB.publicJwtKey!) const args = new EncryptedTransferArgs() - args.publicKey = homeComA!.publicKey.toString('hex') + args.publicKey = new Ed25519PublicKey(homeComA!.publicKey).asHex() args.jwt = jwt args.handshakeID = handshakeID - methodLogger.debug(`invoke authenticate() with:`, args) + methodLogger.debug(`invoke authenticate(), publicKey: ${args.publicKey}`) const responseJwt = await client.authenticate(args) - methodLogger.debug(`response of authenticate():`, responseJwt) + // methodLogger.debug(`response of authenticate():`, responseJwt) + if (responseJwt !== null) { const payload = await verifyAndDecrypt(handshakeID, responseJwt, homeComA!.privateJwtKey!, comB.publicJwtKey!) as AuthenticationResponseJwtPayloadType - methodLogger.debug( + /*methodLogger.debug( `received payload from authenticate ComB:`, payload, new FederatedCommunityLoggingView(fedComB), - ) + )*/ if (payload.tokentype !== AuthenticationResponseJwtPayloadType.AUTHENTICATION_RESPONSE_TYPE) { - const errmsg = `Invalid tokentype in authenticate-response of community with publicKey` + comB.publicKey - methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') - throw new Error(errmsg) + throw new Error(`Invalid tokentype in authenticate-response of community with publicKey ${fedComBPublicKey.asHex()}`) } - if (!payload.uuid || !validateUUID(payload.uuid) || versionUUID(payload.uuid) !== 4) { - const errmsg = `Invalid uuid in authenticate-response of community with publicKey` + comB.publicKey - methodLogger.error(errmsg) - methodLogger.removeContext('handshakeID') - throw new Error(errmsg) + const parsedUuidv4 = uuidv4Schema.safeParse(payload.uuid) + if (!parsedUuidv4.success) { + throw new Error(`Invalid uuid in authenticate-response of community with publicKey ${fedComBPublicKey.asHex()}`) } - comB.communityUuid = payload.uuid + methodLogger.debug('received uuid from authenticate ComB:', parsedUuidv4.data) + comB.communityUuid = parsedUuidv4.data comB.authenticatedAt = new Date() - await DbCommunity.save(comB) - methodLogger.debug('Community Authentication successful:', new CommunityLoggingView(comB)) + await DbCommunity.save(comB) + state.status = CommunityHandshakeStateType.SUCCESS + await state.save() + methodLogger.debug('[SUCCESS] community handshake state updated') + const endTime = new Date() + const duration = endTime.getTime() - state.createdAt.getTime() + methodLogger.debug(`Community Authentication successful in ${duration} ms`) } else { + state.status = CommunityHandshakeStateType.FAILED + state.lastError = 'Community Authentication failed, empty response' + await state.save() methodLogger.error('Community Authentication failed:', authenticationArgs) } } } catch (err) { methodLogger.error('error in startAuthentication:', err) + if (state) { + try { + state.status = CommunityHandshakeStateType.FAILED + state.lastError = String(err) + await state.save() + } catch(e) { + methodLogger.error('error on saving CommunityHandshakeState', e) + } + } + } - methodLogger.removeContext('handshakeID') } diff --git a/federation/src/index.ts b/federation/src/index.ts index 4492f24fb..c9f58b0e5 100644 --- a/federation/src/index.ts +++ b/federation/src/index.ts @@ -6,8 +6,10 @@ import { getLogger } from 'log4js' // config import { CONFIG } from './config' import { LOG4JS_BASE_CATEGORY_NAME } from './config/const' +import { onShutdown, printServerCrashAsciiArt, ShutdownReason } from 'shared' async function main() { + const startTime = new Date() // init logger const log4jsConfigFileName = CONFIG.LOG4JS_CONFIG_PLACEHOLDER.replace('%v', CONFIG.FEDERATION_API) initLogger( @@ -27,6 +29,16 @@ async function main() { `GraphIQL available at ${CONFIG.FEDERATION_COMMUNITY_URL}/api/${CONFIG.FEDERATION_API}`, ) } + onShutdown(async (reason, error) => { + if (ShutdownReason.SIGINT === reason || ShutdownReason.SIGTERM === reason) { + logger.info(`graceful shutdown: ${reason}`) + } else { + const endTime = new Date() + const duration = endTime.getTime() - startTime.getTime() + printServerCrashAsciiArt('Server Crash', `reason: ${reason}`, `server was ${duration}ms online`) + logger.error(error) + } + }) }) } diff --git a/shared/package.json b/shared/package.json index 9f15c67bc..d18fa29cf 100644 --- a/shared/package.json +++ b/shared/package.json @@ -37,6 +37,7 @@ "esbuild": "^0.25.2", "jose": "^4.14.4", "log4js": "^6.9.1", + "yoctocolors-cjs": "^2.1.2", "zod": "^3.25.61" }, "engines": { diff --git a/shared/src/const/index.ts b/shared/src/const/index.ts index e6fb80990..97fe8f306 100644 --- a/shared/src/const/index.ts +++ b/shared/src/const/index.ts @@ -1,4 +1,6 @@ export const DECAY_START_TIME = new Date('2021-05-13T17:46:31Z') export const SECONDS_PER_YEAR_GREGORIAN_CALENDER = 31556952.0 export const LOG4JS_BASE_CATEGORY_NAME = 'shared' -export const REDEEM_JWT_TOKEN_EXPIRATION = '10m' \ No newline at end of file +export const REDEEM_JWT_TOKEN_EXPIRATION = '10m' +// 10 minutes +export const FEDERATION_AUTHENTICATION_TIMEOUT_MS = 60 * 1000 * 10 \ No newline at end of file diff --git a/shared/src/helper/BinaryData.ts b/shared/src/helper/BinaryData.ts new file mode 100644 index 000000000..37d63b156 --- /dev/null +++ b/shared/src/helper/BinaryData.ts @@ -0,0 +1,51 @@ +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY_NAME } from '../const' + +const logging = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.helper.BinaryData`) + +/** + * Class mainly for handling ed25519 public keys, + * to make sure we have always the correct Format (Buffer or Hex String) + */ +export class BinaryData { + private buf: Buffer + private hex: string + + constructor(input: Buffer | string | undefined) { + if (typeof input === 'string') { + this.buf = Buffer.from(input, 'hex') + this.hex = input + } else if (Buffer.isBuffer(input)) { + this.buf = input + this.hex = input.toString('hex') + } else { + this.buf = Buffer.from('') + this.hex = '' + } + } + + asBuffer(): Buffer { + return this.buf + } + + asHex(): string { + return this.hex + } + + isSame(other: BinaryData): boolean { + if (other === undefined || !(other instanceof BinaryData)) { + logging.error('other is invalid', other) + return false + } + return this.buf.compare(other.asBuffer()) === 0 + } +} + +export class Ed25519PublicKey extends BinaryData { + constructor(input: Buffer | string | undefined) { + super(input) + if (this.asBuffer().length !== 32) { + throw new Error('Invalid ed25519 public key length') + } + } +} \ No newline at end of file diff --git a/shared/src/helper/index.ts b/shared/src/helper/index.ts index abfe2c8dc..9170aad49 100644 --- a/shared/src/helper/index.ts +++ b/shared/src/helper/index.ts @@ -1 +1,3 @@ -export * from './updateField' \ No newline at end of file +export * from './updateField' +export * from './BinaryData' +export * from './onShutdown' \ No newline at end of file diff --git a/shared/src/helper/onShutdown.ts b/shared/src/helper/onShutdown.ts new file mode 100644 index 000000000..a27c931a6 --- /dev/null +++ b/shared/src/helper/onShutdown.ts @@ -0,0 +1,51 @@ +import { Logger } from 'log4js' +import colors from 'yoctocolors-cjs' + +export enum ShutdownReason { + SIGINT = 'SIGINT', + SIGTERM = 'SIGTERM', + UNCAUGHT_EXCEPTION = 'UNCAUGHT_EXCEPTION', + UNCAUGHT_REJECTION = 'UNCAUGHT_REJECTION', +} + + +/** + * Setup graceful shutdown for the process + * @param gracefulShutdown will be called if process is terminated + */ +export function onShutdown(shutdownHandler: (reason: ShutdownReason, error?: Error | any) => Promise) { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'] + signals.forEach(sig => { + process.on(sig, async () => { + await shutdownHandler(sig as ShutdownReason) + process.exit(0) + }) + }) + + process.on('uncaughtException', async (err) => { + await shutdownHandler(ShutdownReason.UNCAUGHT_EXCEPTION, err) + process.exit(1) + }) + + process.on('unhandledRejection', async (err) => { + await shutdownHandler(ShutdownReason.UNCAUGHT_REJECTION, err) + process.exit(1) + }) + + if (process.platform === "win32") { + const rl = require("readline").createInterface({ + input: process.stdin, + output: process.stdout, + }) + rl.on("SIGINT", () => { + process.emit("SIGINT" as any) + }) + } +} + +export function printServerCrashAsciiArt(msg1: string, msg2: string, msg3: string) { + console.error(colors.redBright(` /\\_/\\ ${msg1}`)) + console.error(colors.redBright(`( x.x ) ${msg2}`)) + console.error(colors.redBright(` > < ${msg3}`)) + console.error(colors.redBright('')) +} \ No newline at end of file diff --git a/shared/src/index.ts b/shared/src/index.ts index a9e070d7f..1e44e2c48 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,5 +1,6 @@ export * from './schema' export * from './enum' +export * from './const' export * from './helper' export * from './logic/decay' export * from './jwt/JWT' diff --git a/shared/src/jwt/JWT.ts b/shared/src/jwt/JWT.ts index 1af50f5bd..7c8fd799d 100644 --- a/shared/src/jwt/JWT.ts +++ b/shared/src/jwt/JWT.ts @@ -43,11 +43,9 @@ export const verify = async (handshakeID: string, token: string, publicKey: stri }) payload.handshakeID = handshakeID methodLogger.debug('verify after jwtVerify... payload=', payload) - methodLogger.removeContext('handshakeID') return payload as JwtPayloadType } catch (err) { methodLogger.error('verify after jwtVerify... error=', err) - methodLogger.removeContext('handshakeID') return null } } @@ -74,11 +72,9 @@ export const encode = async (payload: JwtPayloadType, privatekey: string): Promi .setExpirationTime(payload.expiration) .sign(secret) methodLogger.debug('encode... token=', token) - methodLogger.removeContext('handshakeID') return token } catch (e) { methodLogger.error('Failed to sign JWT:', e) - methodLogger.removeContext('handshakeID') throw e } } @@ -111,11 +107,9 @@ export const encrypt = async (payload: JwtPayloadType, publicKey: string): Promi .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) .encrypt(recipientKey) methodLogger.debug('encrypt... jwe=', jwe) - methodLogger.removeContext('handshakeID') return jwe.toString() } catch (e) { methodLogger.error('Failed to encrypt JWT:', e) - methodLogger.removeContext('handshakeID') throw e } } @@ -131,11 +125,9 @@ export const decrypt = async(handshakeID: string, jwe: string, privateKey: strin await compactDecrypt(jwe, decryptKey) methodLogger.debug('decrypt... plaintext=', plaintext) methodLogger.debug('decrypt... protectedHeader=', protectedHeader) - methodLogger.removeContext('handshakeID') return new TextDecoder().decode(plaintext) } catch (e) { methodLogger.error('Failed to decrypt JWT:', e) - methodLogger.removeContext('handshakeID') throw e } } @@ -147,7 +139,6 @@ export const encryptAndSign = async (payload: JwtPayloadType, privateKey: string methodLogger.debug('encryptAndSign... jwe=', jwe) const jws = await encode(new EncryptedJWEJwtPayloadType(payload.handshakeID, jwe), privateKey) methodLogger.debug('encryptAndSign... jws=', jws) - methodLogger.removeContext('handshakeID') return jws } @@ -171,6 +162,5 @@ export const verifyAndDecrypt = async (handshakeID: string, token: string, priva methodLogger.debug('verifyAndDecrypt... jwe=', jwe) const payload = await decrypt(handshakeID, jwe as string, privateKey) methodLogger.debug('verifyAndDecrypt... payload=', payload) - methodLogger.removeContext('handshakeID') return JSON.parse(payload) as JwtPayloadType } diff --git a/shared/src/schema/base.schema.ts b/shared/src/schema/base.schema.ts index ee9383dd2..2f158b1b0 100644 --- a/shared/src/schema/base.schema.ts +++ b/shared/src/schema/base.schema.ts @@ -4,4 +4,4 @@ import { validate, version } from 'uuid' export const uuidv4Schema = string().refine((val: string) => validate(val) && version(val) === 4, 'Invalid uuid') export const emailSchema = string().email() export const urlSchema = string().url() -export const uint32Schema = number().positive().lte(4294967295) \ No newline at end of file +export const uint32Schema = number().positive().lte(4294967295) diff --git a/shared/src/schema/community.schema.test.ts b/shared/src/schema/community.schema.test.ts new file mode 100644 index 000000000..a46ea9c94 --- /dev/null +++ b/shared/src/schema/community.schema.test.ts @@ -0,0 +1,35 @@ +import { v4 as uuidv4 } from 'uuid' +import { communityAuthenticatedSchema } from './community.schema' +import { describe, it, expect } from 'bun:test' + + +describe('communityAuthenticatedSchema', () => { + it('should return an error if communityUuid is not a uuidv4', () => { + const data = communityAuthenticatedSchema.safeParse({ + communityUuid: '1234567890', + authenticatedAt: new Date(), + }) + + expect(data.success).toBe(false) + expect(data.error?.issues[0].path).toEqual(['communityUuid']) + }) + + it('should return an error if authenticatedAt is not a date', () => { + const data = communityAuthenticatedSchema.safeParse({ + communityUuid: uuidv4(), + authenticatedAt: '2022-01-01', + }) + + expect(data.success).toBe(false) + expect(data.error?.issues[0].path).toEqual(['authenticatedAt']) + }) + + it('should return no error for valid data and valid uuid4', () => { + const data = communityAuthenticatedSchema.safeParse({ + communityUuid: uuidv4(), + authenticatedAt: new Date(), + }) + + expect(data.success).toBe(true) + }) +}) diff --git a/shared/src/schema/community.schema.ts b/shared/src/schema/community.schema.ts new file mode 100644 index 000000000..6957b16b0 --- /dev/null +++ b/shared/src/schema/community.schema.ts @@ -0,0 +1,7 @@ +import { object, date, array, string } from 'zod' +import { uuidv4Schema } from './base.schema' + +export const communityAuthenticatedSchema = object({ + communityUuid: uuidv4Schema, + authenticatedAt: date(), +}) diff --git a/shared/src/schema/index.ts b/shared/src/schema/index.ts index d8c9f9e4c..83455fb73 100644 --- a/shared/src/schema/index.ts +++ b/shared/src/schema/index.ts @@ -1,2 +1,3 @@ export * from './user.schema' -export * from './base.schema' \ No newline at end of file +export * from './base.schema' +export * from './community.schema' \ No newline at end of file