Merge branch 'release-2_7_0' into dlt_connector_direct_usage

This commit is contained in:
einhornimmond 2025-10-20 16:02:07 +02:00
commit 1230799a2e
64 changed files with 1426 additions and 598 deletions

1
.bun-version Normal file
View File

@ -0,0 +1 @@
1.3.0

View File

@ -55,7 +55,7 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.23
bun-version-file: '.bun-version'
- name: install dependencies
run: |

View File

@ -56,6 +56,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |
@ -81,6 +83,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |

View File

@ -31,6 +31,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: bun install --filter config-schema --frozen-lockfile

View File

@ -32,6 +32,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |

View File

@ -53,6 +53,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |
@ -76,7 +78,9 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |
bun install --filter database --frozen-lockfile

View File

@ -53,6 +53,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |

View File

@ -17,6 +17,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: Boot up test system | docker-compose mariadb mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb mailserver
@ -120,6 +122,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: Boot up test system | docker-compose mariadb mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb mailserver
@ -203,6 +207,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: Boot up test system | docker-compose mariadb mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb mailserver

View File

@ -53,6 +53,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |

View File

@ -52,6 +52,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: bun install --filter frontend --frozen-lockfile
@ -77,7 +79,9 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: |
bun install --filter frontend --frozen-lockfile

View File

@ -30,6 +30,8 @@ jobs:
- name: install bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
- name: install dependencies
run: bun install --filter shared --frozen-lockfile

View File

@ -4,8 +4,41 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v2.7.0](https://github.com/gradido/gradido/compare/v2.7.0...v2.7.0)
- fixes [`414ff8a`](https://github.com/gradido/gradido/commit/414ff8ac5a7477109f80123ccca5c4c8ed4511b2)
#### [v2.7.0](https://github.com/gradido/gradido/compare/2.6.1...v2.7.0)
> 15 October 2025
- feat(frontend): gradido id under avatar instead of email [`#3543`](https://github.com/gradido/gradido/pull/3543)
- refactor(backend): add and use template function updateAllDefinedAndChanged in update user and community [`#3546`](https://github.com/gradido/gradido/pull/3546)
- fix(backend): check for openai thread timeout [`#3549`](https://github.com/gradido/gradido/pull/3549)
- feat(frontend): paste community and user in recipient field of gdd send dialog [`#3542`](https://github.com/gradido/gradido/pull/3542)
- fix(backend): allow reading gmsApiKey admins only [`#3547`](https://github.com/gradido/gradido/pull/3547)
- feat(frontend): make link send confirmation dialog more accurate [`#3548`](https://github.com/gradido/gradido/pull/3548)
- fix(federation): fix bug [`#3545`](https://github.com/gradido/gradido/pull/3545)
- feat(frontend): modify frontend for cross community redeem link disbursement [`#3537`](https://github.com/gradido/gradido/pull/3537)
- fix(frontend): decimal comma to point on contribution form [`#3539`](https://github.com/gradido/gradido/pull/3539)
- feat(backend): introduce security in disbursement handshake [`#3523`](https://github.com/gradido/gradido/pull/3523)
- feat(frontend): update texts for overview and contribution message [`#3532`](https://github.com/gradido/gradido/pull/3532)
- feat(frontend): add link to gdd in overview [`#3531`](https://github.com/gradido/gradido/pull/3531)
- feat(other): add infos for using logging in tests [`#3530`](https://github.com/gradido/gradido/pull/3530)
- feat(frontend): increase memo [`#3527`](https://github.com/gradido/gradido/pull/3527)
- feat(admin): add hiero topic id like gms api key [`#3524`](https://github.com/gradido/gradido/pull/3524)
- fix(other): publish only on release [`#3529`](https://github.com/gradido/gradido/pull/3529)
#### [2.6.1](https://github.com/gradido/gradido/compare/2.3.1...2.6.1)
> 14 August 2025
- fix(other): remove mariadb from publish workflow [`#3528`](https://github.com/gradido/gradido/pull/3528)
- fix(database): docker setup [`#3526`](https://github.com/gradido/gradido/pull/3526)
- fix(other): fix problems with bun and e2e [`#3525`](https://github.com/gradido/gradido/pull/3525)
- feat(backend): introduce security in x com tx handshake [`#3520`](https://github.com/gradido/gradido/pull/3520)
- feat(backend): openid connect routes [`#3518`](https://github.com/gradido/gradido/pull/3518)
- chore(release): v2.6.1 beta [`#3521`](https://github.com/gradido/gradido/pull/3521)
- refactor(frontend): transaction and contribution form [`#3519`](https://github.com/gradido/gradido/pull/3519)
- fix(federation): fix some attack vectors in communities handshake [`#3517`](https://github.com/gradido/gradido/pull/3517)
- fix(other): start sh when called from webhook [`#3515`](https://github.com/gradido/gradido/pull/3515)

View File

@ -3,7 +3,7 @@
"description": "Administration Interface for Gradido",
"main": "index.js",
"author": "Gradido Academy - https://www.gradido.net",
"version": "2.6.1",
"version": "2.7.0",
"license": "Apache-2.0",
"scripts": {
"dev": "vite",
@ -61,6 +61,7 @@
"@vue/test-utils": "^2.4.6",
"config-schema": "*",
"cross-env": "^7.0.3",
"dotenv": "^10.0.0",
"dotenv-webpack": "^7.0.3",
"eslint": "8.57.1",
"eslint-config-prettier": "^10.1.1",

View File

@ -1,6 +1,6 @@
{
"name": "backend",
"version": "2.6.1",
"version": "2.7.0",
"private": false,
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"repository": "https://github.com/gradido/gradido/backend",
@ -41,6 +41,7 @@
"@swc/cli": "^0.7.3",
"@swc/core": "^1.11.24",
"@swc/helpers": "^0.5.17",
"@types/cors": "^2.8.19",
"@types/email-templates": "^10.0.4",
"@types/express": "^4.17.21",
"@types/faker": "^5.5.9",

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

@ -48,6 +48,9 @@ beforeAll(async () => {
query = testEnv.query
con = testEnv.con
await cleanDB()
// reset id auto increment
await DbCommunity.clear()
await DbFederatedCommunity.clear()
})
afterAll(async () => {

665
bun.lock

File diff suppressed because it is too large Load Diff

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install]
linker = "hoisted"

View File

@ -1,6 +1,6 @@
{
"name": "config-schema",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido Config for validate config",
"main": "./build/index.js",
"types": "./src/index.ts",

View File

@ -1,6 +1,6 @@
{
"name": "core",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido Core Code, High-Level Shared Code, with dependencies on other modules",
"main": "./build/index.js",
"types": "./src/index.ts",
@ -28,6 +28,7 @@
"database": "*",
"esbuild": "^0.25.2",
"i18n": "^0.15.1",
"joi": "^17.13.3",
"jose": "^4.14.4",
"log4js": "^6.9.1",
"shared": "*",
@ -42,6 +43,7 @@
"@types/sodium-native": "^2.3.5",
"config-schema": "*",
"decimal.js-light": "^2.5.1",
"dotenv": "^10.0.0",
"graphql-request": "5.0.0",
"jest": "27.2.4",
"type-graphql": "^1.1.1",

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

@ -13,7 +13,7 @@ import { Decimal } from 'decimal.js-light'
import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const'
import { PendingTransactionState } from 'shared'
// import { LogError } from '@/server/LogError'
import { calculateSenderBalance } from 'core'
import { calculateSenderBalance } from '../../util/calculateSenderBalance'
import { TRANSACTIONS_LOCK, getLastTransaction } from 'database'
import { getLogger } from 'log4js'

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

@ -1,6 +1,6 @@
{
"name": "database",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido Database Tool to execute database migrations",
"main": "./build/index.js",
"types": "./src/index.ts",
@ -40,6 +40,7 @@
"@types/faker": "^5.5.9",
"@types/geojson": "^7946.0.13",
"@types/jest": "27.0.2",
"@types/mysql": "^2.15.27",
"@types/node": "^18.7.14",
"await-semaphore": "^0.1.3",
"crypto-random-bigint": "^2.1.1",
@ -57,6 +58,7 @@
"esbuild": "^0.25.2",
"geojson": "^0.5.0",
"log4js": "^6.9.1",
"mysql": "^2.18.1",
"mysql2": "^2.3.0",
"reflect-metadata": "^0.1.13",
"shared": "*",

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 { AppDatabase } from './AppDatabase'
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 { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from '..'
import { AppDatabase } from '../AppDatabase'
import { getCommunityByPublicKeyOrFail, getHomeCommunity, getHomeCommunityWithFederatedCommunityOrFail, getReachableCommunities } from './communities'
import { createCommunity, createVerifiedFederatedCommunity } from '../seeds/community'
import { getHomeCommunity, getReachableCommunities } from './communities'
import { Ed25519PublicKey } from 'shared'
const db = AppDatabase.getInstance()
@ -39,7 +40,37 @@ describe('community.queries', () => {
expect(community?.privateKey).toStrictEqual(homeCom.privateKey)
})
})
describe('getReachableCommunities', () => {
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)
expect(await getReachableCommunities(1000)).toHaveLength(1)

View File

@ -1,6 +1,6 @@
import { urlSchema, uuidv4Schema } from 'shared'
import { FindOptionsOrder, FindOptionsWhere, IsNull, MoreThanOrEqual, Not } from 'typeorm'
import { Community as DbCommunity } from '../entity'
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 },
})
}
@ -44,7 +51,23 @@ export async function getCommunityWithFederatedCommunityByIdentifier(
})
}
// returns all reachable communities
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(
authenticationTimeoutMs: number,
@ -63,3 +86,12 @@ 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

@ -6,5 +6,6 @@ export * from './pendingTransactions'
export * from './transactionLinks'
export * from './transactions'
export * from './user'
export * from './communityHandshakes'
export const LOG4JS_QUERIES_CATEGORY_NAME = `${LOG4JS_BASE_CATEGORY_NAME}.queries`

View File

@ -2,41 +2,55 @@ import { randomBytes } from 'node:crypto'
import { v4 as uuidv4 } from 'uuid'
import { Community, FederatedCommunity } from '../entity'
export async function createCommunity(foreign: boolean, save: boolean = true): Promise<Community> {
const community = new Community()
community.publicKey = randomBytes(32)
community.communityUuid = uuidv4()
community.name = 'HomeCommunity-name'
community.creationDate = new Date()
if (foreign) {
community.foreign = true
community.name = 'ForeignCommunity-name'
community.description = 'ForeignCommunity-description'
community.url = `http://foreign-${Math.random()}/api`
community.authenticatedAt = new Date()
} else {
community.foreign = false
// todo: generate valid public/private key pair (ed25519)
community.privateKey = randomBytes(64)
/**
* 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()
community.name = 'HomeCommunity-name'
community.description = 'HomeCommunity-description'
community.url = 'http://localhost/api'
}
return save ? await community.save() : community
community.creationDate = new Date()
if(foreign) {
community.foreign = true
community.name = 'ForeignCommunity-name'
community.description = 'ForeignCommunity-description'
community.url = `http://foreign-${Math.random()}/api`
community.authenticatedAt = new Date()
} else {
community.foreign = false
// todo: generate valid public/private key pair (ed25519)
community.privateKey = randomBytes(64)
community.name = 'HomeCommunity-name'
community.description = 'HomeCommunity-description'
community.url = 'http://localhost/api'
}
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,
apiVersion: string,
verifiedBeforeMs: number,
community: Community,
store: boolean = true
): Promise<FederatedCommunity> {
const federatedCommunity = new FederatedCommunity()
federatedCommunity.apiVersion = apiVersion
federatedCommunity.endPoint = community.url
federatedCommunity.publicKey = community.publicKey
federatedCommunity.community = community
federatedCommunity.verifiedAt = new Date(Date.now() - verifiedBeforeMs)
return save ? await federatedCommunity.save() : federatedCommunity
const federatedCommunity = new FederatedCommunity()
federatedCommunity.apiVersion = apiVersion
federatedCommunity.endPoint = community.url
federatedCommunity.publicKey = community.publicKey
federatedCommunity.community = community
federatedCommunity.verifiedAt = new Date(Date.now() - verifiedBeforeMs)
return store ? await federatedCommunity.save() : federatedCommunity
}

View File

@ -1,6 +1,6 @@
{
"name": "dht-node",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido dht-node module",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/",
@ -38,7 +38,7 @@
"@types/uuid": "^8.3.4",
"config-schema": "*",
"database": "*",
"dotenv": "10.0.0",
"dotenv": "^10.0.0",
"esbuild": "^0.25.3",
"jest": "27.5.1",
"joi": "17.13.3",

View File

@ -1,6 +1,6 @@
{
"name": "federation",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido federation module providing Gradido-Hub-Federation and versioned API for inter community communication",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/federation",
@ -31,6 +31,7 @@
"@swc/cli": "^0.7.3",
"@swc/core": "^1.11.24",
"@swc/helpers": "^0.5.17",
"@types/cors": "^2.8.19",
"@types/express": "4.17.21",
"@types/jest": "27.0.2",
"@types/lodash.clonedeep": "^4.5.6",
@ -46,7 +47,8 @@
"cors": "2.8.5",
"database": "*",
"decimal.js-light": "^2.5.1",
"dotenv": "10.0.0",
"dotenv": "^10.0.0",
"esbuild": "^0.25.3",
"express": "^4.17.21",
"express-slow-down": "^2.0.1",
"graphql": "15.10.1",
@ -61,8 +63,10 @@
"nodemon": "^2.0.7",
"prettier": "^3.5.3",
"reflect-metadata": "^0.1.13",
"shared": "*",
"source-map-support": "^0.5.21",
"ts-jest": "27.0.5",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.1.1",
"type-graphql": "^1.1.1",
"typeorm": "^0.3.25",

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

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "2.6.1",
"version": "2.7.0",
"private": true,
"scripts": {
"dev": "concurrently \"yarn watch-scss\" \"vite\"",
@ -80,10 +80,11 @@
"@vitest/coverage-v8": "^2.0.5",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.6",
"chokidar-cli": "^3.0.0",
"chokidar": "^4.0.3",
"concurrently": "^9.1.2",
"config-schema": "*",
"cross-env": "^7.0.3",
"dotenv": "^10.0.0",
"dotenv-webpack": "^7.0.3",
"eslint": "8.57.1",
"eslint-config-prettier": "^10.1.1",

View File

@ -1,6 +1,6 @@
{
"name": "gradido",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido",
"main": "index.js",
"repository": "git@github.com:gradido/gradido.git",
@ -21,7 +21,7 @@
],
"scripts": {
"release": "bumpp --no-commit --no-push -r",
"version": "auto-changelog -p && git add CHANGELOG.md",
"version": "auto-changelog -p --commit-limit 0 && git add CHANGELOG.md",
"installAll": "bun run install",
"docker": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose -f docker-compose.yml up",
"docker:rebuild": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose -f docker-compose.yml build",
@ -38,7 +38,8 @@
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@types/minimatch": "6.0.0"
"@types/minimatch": "6.0.0",
"bbump": "^1.0.2"
},
"engines": {
"node": ">=18"

View File

@ -1,6 +1,6 @@
{
"name": "shared",
"version": "2.6.1",
"version": "2.7.0",
"description": "Gradido Shared Code, Low-Level Shared Code, without dependencies on other modules",
"main": "./build/index.js",
"types": "./src/index.ts",
@ -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'