Merge pull request #3555 from gradido/federation_handshake_own_table

feat(federation): use own table for handshake state
This commit is contained in:
einhornimmond 2025-10-23 09:02:26 +02:00 committed by GitHub
commit 974fe032e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 884 additions and 313 deletions

View File

@ -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<void> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities.startCommunityAuthentication`)
): Promise<StartCommunityAuthenticationResult> {
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
}

View File

@ -103,7 +103,7 @@ describe('validate Communities', () => {
return {
data: {
getPublicKey: {
publicKey: 'somePubKey',
publicKey: '2222222222222222222222222222222222222222222222222222222222222222',
},
},
} as Response<unknown>
@ -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'),
)
})
})

View File

@ -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<void> {
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<void> {
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) {

View File

@ -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=="],

View File

@ -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<JwtPayloadType | null> => {
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
}

View File

@ -22,4 +22,5 @@ export * from './util/calculateSenderBalance'
export * from './util/utilities'
export * from './validation/user'
export * from './config/index'
export * from './logic'

View File

@ -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<boolean> {
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
}
}

View File

@ -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)
})
})

View File

@ -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
}

2
core/src/logic/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './CommunityHandshakeState.logic'
export * from './community.logic'

View File

@ -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.
*

View File

@ -0,0 +1,20 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
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<Array<any>>) {
await queryFn(`DROP TABLE community_handshake_states;`)
}

View File

@ -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
}

View File

@ -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,

View File

@ -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'
}

View File

@ -0,0 +1 @@
export * from './CommunityHandshakeStateType'

View File

@ -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'

View File

@ -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),
}
}
}

View File

@ -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)
),
}
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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<DbCommunity | null> {
// 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<DbCommunity> {
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<DbCommunity> {
return await DbCommunity.findOneOrFail({
where: { foreign: true, publicKey: publicKey.asBuffer(), federatedCommunities: { apiVersion } },
relations: { federatedCommunities: true },
})
}
export async function getCommunityByPublicKeyOrFail(publicKey: Ed25519PublicKey): Promise<DbCommunity> {
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<DbCommunity>
): Promise<DbCommunity[]> {
return await DbCommunity.find({
where: { authenticatedAt: IsNull(), foreign: true },
order,
})
}

View File

@ -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
})
})
})

View File

@ -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<CommunityHandshakeState | null> {
return CommunityHandshakeState.findOne({
where: {
publicKey: publicKey.asBuffer(),
apiVersion,
status: status || Not(In([
CommunityHandshakeStateType.EXPIRED,
CommunityHandshakeStateType.FAILED,
CommunityHandshakeStateType.SUCCESS
]))
},
})
}
export function findPendingCommunityHandshakeOrFailByOneTimeCode(
oneTimeCode: number
): Promise<CommunityHandshakeState> {
return CommunityHandshakeState.findOneOrFail({
where: { oneTimeCode },
})
}

View File

@ -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`

View File

@ -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<Community> {
/**
* 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<Community> {
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<FederatedCommunity> {
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
}

View File

@ -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<boolean> {
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<boolean> {
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<string | null> {
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
}
}

View File

@ -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<void> {
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<void> {
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')
}

View File

@ -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)
}
})
})
}

View File

@ -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": {

View File

@ -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'
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'
// 10 minutes
export const FEDERATION_AUTHENTICATION_TIMEOUT_MS = 60 * 1000 * 10

View File

@ -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')
}
}
}

View File

@ -1 +1,3 @@
export * from './updateField'
export * from './updateField'
export * from './BinaryData'
export * from './onShutdown'

View File

@ -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<void>) {
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(''))
}

View File

@ -1,5 +1,6 @@
export * from './schema'
export * from './enum'
export * from './const'
export * from './helper'
export * from './logic/decay'
export * from './jwt/JWT'

View File

@ -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
}

View File

@ -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)
export const uint32Schema = number().positive().lte(4294967295)

View File

@ -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)
})
})

View File

@ -0,0 +1,7 @@
import { object, date, array, string } from 'zod'
import { uuidv4Schema } from './base.schema'
export const communityAuthenticatedSchema = object({
communityUuid: uuidv4Schema,
authenticatedAt: date(),
})

View File

@ -1,2 +1,3 @@
export * from './user.schema'
export * from './base.schema'
export * from './base.schema'
export * from './community.schema'