Merge pull request #3510 from gradido/3505-feature-introduce-encrypted-jwts-in-backend-federation-communication

feat(backend): introduce encrypted jwts in backend federation communication
This commit is contained in:
clauspeterhuebner 2025-07-17 23:05:04 +02:00 committed by GitHub
commit 76f904e9df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 3428 additions and 3651 deletions

View File

@ -1,72 +0,0 @@
import { SignJWT, decodeJwt, jwtVerify } from 'jose'
import { LogError } from '@/server/LogError'
import { JwtPayloadType } from './payloadtypes/JwtPayloadType'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger } from 'log4js'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT`)
export const verify = async (token: string, signkey: string): Promise<JwtPayloadType | null> => {
if (!token) {
throw new LogError('401 Unauthorized')
}
logger.info('JWT.verify... token, signkey=', token, signkey)
try {
/*
const { KeyObject } = await import('node:crypto')
const cryptoKey = await crypto.subtle.importKey('raw', signkey, { name: 'RS256' }, false, [
'sign',
])
const keyObject = KeyObject.from(cryptoKey)
logger.info('JWT.verify... keyObject=', keyObject)
logger.info('JWT.verify... keyObject.asymmetricKeyDetails=', keyObject.asymmetricKeyDetails)
logger.info('JWT.verify... keyObject.asymmetricKeyType=', keyObject.asymmetricKeyType)
logger.info('JWT.verify... keyObject.asymmetricKeySize=', keyObject.asymmetricKeySize)
*/
const secret = new TextEncoder().encode(signkey)
const { payload } = await jwtVerify(token, secret, {
issuer: 'urn:gradido:issuer',
audience: 'urn:gradido:audience',
})
logger.info('JWT.verify after jwtVerify... payload=', payload)
return payload as JwtPayloadType
} catch (err) {
logger.error('JWT.verify after jwtVerify... error=', err)
return null
}
}
export const encode = async (payload: JwtPayloadType, signkey: string): Promise<string> => {
logger.info('JWT.encode... payload=', payload)
logger.info('JWT.encode... signkey=', signkey)
try {
const secret = new TextEncoder().encode(signkey)
const token = await new SignJWT({ payload, 'urn:gradido:claim': true })
.setProtectedHeader({
alg: 'HS256',
})
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime(payload.expiration)
.sign(secret)
return token
} catch (e) {
logger.error('Failed to sign JWT:', e)
throw e
}
}
export const verifyJwtType = async (token: string, signkey: string): Promise<string> => {
const payload = await verify(token, signkey)
return payload ? payload.tokentype : 'unknown token type'
}
export const decode = (token: string): JwtPayloadType => {
const { payload } = decodeJwt(token)
return payload as JwtPayloadType
}

View File

@ -42,7 +42,7 @@ export const schema = Joi.object({
OPENAI_ACTIVE,
PRODUCTION,
COMMUNITY_REDEEM_URL: Joi.string()
COMMUNITY_REDEEM_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.description('The url for redeeming link transactions, must start with frontend base url')
.default('http://0.0.0.0/redeem/')

View File

@ -1,61 +1,78 @@
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database'
import { CommunityLoggingView, Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, FederatedCommunityLoggingView, getHomeCommunity } from 'database'
import { validate as validateUUID, version as versionUUID } from 'uuid'
import { randombytes_random } from 'sodium-native'
import { CONFIG } from '@/config'
import { AuthenticationClient as V1_0_AuthenticationClient } from '@/federation/client/1_0/AuthenticationClient'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { getLogger } from 'log4js'
import { OpenConnectionArgs } from './client/1_0/model/OpenConnectionArgs'
import { AuthenticationClientFactory } from './client/AuthenticationClientFactory'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { encryptAndSign, OpenConnectionJwtPayloadType } from 'shared'
import { getLogger } from 'log4js'
import { AuthenticationClientFactory } from './client/AuthenticationClientFactory'
import { EncryptedTransferArgs } from 'core'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities`)
export async function startCommunityAuthentication(
foreignFedCom: DbFederatedCommunity,
fedComB: DbFederatedCommunity,
): Promise<void> {
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
const homeFedCom = await DbFederatedCommunity.findOneByOrFail({
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities.startCommunityAuthentication`)
const handshakeID = randombytes_random().toString()
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.FEDERATION_BACKEND_SEND_ON_API,
})
const foreignCom = await DbCommunity.findOneByOrFail({ publicKey: foreignFedCom.publicKey })
logger.debug(
'Authentication: started with foreignFedCom:',
foreignFedCom.endPoint,
foreignFedCom.publicKey.toString('hex'),
)
// check if communityUuid is a valid v4Uuid and not still a temporary onetimecode
if (
foreignCom &&
((foreignCom.communityUuid === null && foreignCom.authenticatedAt === null) ||
(foreignCom.communityUuid !== null &&
!validateUUID(foreignCom.communityUuid) &&
versionUUID(foreignCom.communityUuid) !== 4))
) {
try {
const client = AuthenticationClientFactory.getInstance(foreignFedCom)
methodLogger.debug('homeFedComA', new FederatedCommunityLoggingView(homeFedComA))
const comB = await DbCommunity.findOneByOrFail({ publicKey: fedComB.publicKey })
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) {
const args = new OpenConnectionArgs()
args.publicKey = homeCom.publicKey.toString('hex')
// TODO encrypt url with foreignCom.publicKey and sign it with homeCom.privateKey
args.url = ensureUrlEndsWithSlash(homeFedCom.endPoint).concat(homeFedCom.apiVersion)
logger.debug(
'Authentication: before client.openConnection() args:',
homeCom.publicKey.toString('hex'),
args.url,
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),
)
if (await client.openConnection(args)) {
logger.debug(`Authentication: successful initiated at community:`, foreignFedCom.endPoint)
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 {
logger.error(`Authentication: can't initiate at community:`, foreignFedCom.endPoint)
methodLogger.error(`can't initiate at community:`, fedComB.endPoint)
}
}
} catch (err) {
logger.error(`Error:`, err)
} 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)
}
methodLogger.removeContext('handshakeID')
}

View File

@ -5,7 +5,7 @@ import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger } from 'log4js'
import { OpenConnectionArgs } from './model/OpenConnectionArgs'
import { EncryptedTransferArgs } from 'core/src/graphql/model/EncryptedTransferArgs'
import { openConnection } from './query/openConnection'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.client.1_0.AuthenticationClient`)
@ -27,7 +27,7 @@ export class AuthenticationClient {
})
}
async openConnection(args: OpenConnectionArgs): Promise<boolean | undefined> {
async openConnection(args: EncryptedTransferArgs): Promise<boolean | undefined> {
logger.debug(`openConnection at ${this.endpoint} for args:`, args)
try {
const { data } = await this.client.rawRequest<{ openConnection: boolean }>(openConnection, {

View File

@ -13,6 +13,7 @@ export class PublicCommunityInfoLoggingView extends AbstractLoggingView {
description: this.self.description,
creationDate: this.dateToString(this.self.creationDate),
publicKey: this.self.publicKey,
publicJwtKey: this.self.publicJwtKey,
}
}
}

View File

@ -6,5 +6,5 @@ export class OpenConnectionArgs {
publicKey: string
@Field(() => String)
url: string
jwt: string
}

View File

@ -3,4 +3,5 @@ export interface PublicCommunityInfo {
description: string
creationDate: Date
publicKey: string
publicJwtKey: string
}

View File

@ -7,6 +7,7 @@ export const getPublicCommunityInfo = gql`
description
creationDate
publicKey
publicJwtKey
}
}
`

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
export const openConnection = gql`
mutation ($args: OpenConnectionArgs!) {
mutation ($args: EncryptedTransferArgs!) {
openConnection(data: $args)
}
`

View File

@ -343,7 +343,7 @@ describe('validate Communities', () => {
})
it('logs unsupported api for community with api 2_0 ', () => {
expect(logger.debug).toBeCalledWith(
'dbCom with unsupported apiVersion',
'dbFedComB with unsupported apiVersion',
dbCom.endPoint,
'2_0',
)

View File

@ -2,18 +2,20 @@ import {
Community as DbCommunity,
FederatedCommunity as DbFederatedCommunity,
FederatedCommunityLoggingView,
getHomeCommunity,
} from 'database'
import { IsNull } from 'typeorm'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { FederationClient as V1_0_FederationClient } from '@/federation/client/1_0/FederationClient'
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 { getLogger } from 'log4js'
import { startCommunityAuthentication } from './authenticateCommunities'
import { PublicCommunityInfoLoggingView } from './client/1_0/logging/PublicCommunityInfoLogging.view'
import { ApiVersionType } from './enum/apiVersionType'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.validateCommunities`)
@ -34,6 +36,7 @@ export async function startValidateCommunities(timerInterval: number): Promise<v
}
export async function validateCommunities(): Promise<void> {
// search all foreign federated communities which are still not verified or have not been verified since last dht-announcement
const dbFederatedCommunities: DbFederatedCommunity[] =
await DbFederatedCommunity.createQueryBuilder()
.where({ foreign: true, verifiedAt: IsNull() })
@ -41,32 +44,36 @@ export async function validateCommunities(): Promise<void> {
.getMany()
logger.debug(`found ${dbFederatedCommunities.length} dbCommunities`)
for (const dbCom of dbFederatedCommunities) {
logger.debug('dbCom', new FederatedCommunityLoggingView(dbCom))
for (const dbFedComB of dbFederatedCommunities) {
logger.debug('dbFedComB', new FederatedCommunityLoggingView(dbFedComB))
const apiValueStrings: string[] = Object.values(ApiVersionType)
logger.debug(`suppported ApiVersions=`, apiValueStrings)
if (!apiValueStrings.includes(dbCom.apiVersion)) {
logger.debug('dbCom with unsupported apiVersion', dbCom.endPoint, dbCom.apiVersion)
if (!apiValueStrings.includes(dbFedComB.apiVersion)) {
logger.debug('dbFedComB with unsupported apiVersion', dbFedComB.endPoint, dbFedComB.apiVersion)
continue
}
try {
const client = FederationClientFactory.getInstance(dbCom)
const client = FederationClientFactory.getInstance(dbFedComB)
if (client instanceof V1_0_FederationClient) {
const pubKey = await client.getPublicKey()
if (pubKey && pubKey === dbCom.publicKey.toString('hex')) {
await DbFederatedCommunity.update({ id: dbCom.id }, { verifiedAt: new Date() })
logger.debug(`verified community with:`, dbCom.endPoint)
if (pubKey && pubKey === dbFedComB.publicKey.toString('hex')) {
await DbFederatedCommunity.update({ id: dbFedComB.id }, { verifiedAt: new Date() })
logger.debug(`verified dbFedComB with:`, dbFedComB.endPoint)
const pubComInfo = await client.getPublicCommunityInfo()
if (pubComInfo) {
await writeForeignCommunity(dbCom, pubComInfo)
await startCommunityAuthentication(dbCom)
logger.debug(`write publicInfo of community: name=${pubComInfo.name}`)
await writeForeignCommunity(dbFedComB, pubComInfo)
logger.debug(`wrote response of getPublicCommunityInfo in dbFedComB ${dbFedComB.endPoint}`)
try {
await startCommunityAuthentication(dbFedComB)
} catch (err) {
logger.warn(`Warning: Authentication of community ${dbFedComB.endPoint} still ongoing:`, err)
}
} else {
logger.debug('missing result of getPublicCommunityInfo')
}
} else {
logger.debug('received not matching publicKey:', pubKey, dbCom.publicKey.toString('hex'))
logger.debug('received not matching publicKey:', pubKey, dbFedComB.publicKey.toString('hex'))
}
}
} catch (err) {
@ -75,6 +82,36 @@ export async function validateCommunities(): Promise<void> {
}
}
export async function writeJwtKeyPairInHomeCommunity(): Promise<DbCommunity> {
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity`)
try {
// check for existing homeCommunity entry
let homeCom = await getHomeCommunity()
if (homeCom) {
if (!homeCom.publicJwtKey && !homeCom.privateJwtKey) {
// Generate key pair using jose library
const { publicKey, privateKey } = await createKeyPair();
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity publicKey=`, publicKey);
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity privateKey=`, privateKey.slice(0, 20));
homeCom.publicJwtKey = publicKey;
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity publicJwtKey.length=`, homeCom.publicJwtKey.length);
homeCom.privateJwtKey = privateKey;
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity privateJwtKey.length=`, homeCom.privateJwtKey.length);
await DbCommunity.save(homeCom)
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity done`)
} else {
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity: keypair already exists`)
}
} else {
throw new Error(`Error! A HomeCommunity-Entry still not exist! Please start the DHT-Modul first.`)
}
return homeCom
} catch (err) {
throw new Error(`Error writing JwtKeyPair in HomeCommunity-Entry: ${err}`)
}
}
async function writeForeignCommunity(
dbCom: DbFederatedCommunity,
pubInfo: PublicCommunityInfo,
@ -96,6 +133,7 @@ async function writeForeignCommunity(
com.foreign = true
com.name = pubInfo.name
com.publicKey = dbCom.publicKey
com.publicJwtKey = pubInfo.publicJwtKey
com.url = dbCom.endPoint
await DbCommunity.save(com)
}

View File

@ -1,7 +1,7 @@
import { Decimal } from 'decimal.js-light'
import { Field, ObjectType } from 'type-graphql'
import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType'
import { RedeemJwtPayloadType } from 'shared'
import { Community } from './Community'
import { User } from './User'

View File

@ -27,8 +27,6 @@ import { Decimal } from 'decimal.js-light'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { RIGHTS } from '@/auth/RIGHTS'
import { decode, encode, verify } from '@/auth/jwt/JWT'
import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType'
import {
EVENT_CONTRIBUTION_LINK_REDEEM,
EVENT_TRANSACTION_LINK_CREATE,
@ -39,13 +37,12 @@ import { LogError } from '@/server/LogError'
import { Context, getClientTimezoneOffset, getUser } from '@/server/context'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { TRANSACTION_LINK_LOCK } from '@/util/TRANSACTION_LINK_LOCK'
import { calculateDecay } from 'shared'
import { fullName } from '@/util/utilities'
import { calculateBalance } from '@/util/validate'
import { calculateDecay, decode, DisburseJwtPayloadType, encode, RedeemJwtPayloadType, verify } from 'shared'
import { DisburseJwtPayloadType } from '@/auth/jwt/payloadtypes/DisburseJwtPayloadType'
import { Logger, getLogger } from 'log4js'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger, Logger } from 'log4js'
import { executeTransaction } from './TransactionResolver'
import {
getAuthenticatedCommunities,
@ -573,7 +570,7 @@ export class TransactionLinkResolver {
throw new LogError('Sender community UUID is not set')
}
// now with the sender community UUID the jwt token can be verified
const verifiedJwtPayload = await verify(code, senderCom.communityUuid)
const verifiedJwtPayload = await verify('handshakeID', code, senderCom.communityUuid)
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedJwtPayload=',
verifiedJwtPayload,

View File

@ -4,12 +4,14 @@ import { getLogger } from 'log4js'
import { CONFIG } from './config'
import { startValidateCommunities } from './federation/validateCommunities'
import { createServer } from './server/createServer'
import { writeJwtKeyPairInHomeCommunity } from './federation/validateCommunities'
import { initLogging } from './server/logger'
async function main() {
initLogging()
const { app } = await createServer(getLogger('apollo'))
await writeJwtKeyPairInHomeCommunity()
app.listen(CONFIG.PORT, () => {
// biome-ignore lint/suspicious/noConsole: no need for logging the start message
console.log(`Server is running at http://localhost:${CONFIG.PORT}`)

View File

@ -6,6 +6,7 @@
"dependencies": {
"auto-changelog": "^2.4.0",
"cross-env": "^7.0.3",
"jose": "^4.14.4",
"turbo": "^2.5.0",
"uuid": "^8.3.2",
},

View File

@ -27,12 +27,15 @@
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@types/node": "^17.0.21",
"type-graphql": "^1.1.1",
"typescript": "^4.9.5"
},
"dependencies": {
"database": "*",
"esbuild": "^0.25.2",
"jose": "^4.14.4",
"log4js": "^6.9.1",
"shared": "*",
"zod": "^3.25.61"
},
"engines": {

View File

@ -1 +1 @@
export const LOG4JS_BASE_CATEGORY_NAME = 'core'
export const LOG4JS_BASE_CATEGORY_NAME = 'core'

View File

@ -0,0 +1,42 @@
import { EncryptedTransferArgs } from '../model/EncryptedTransferArgs'
import { 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.resolver.util.interpretEncryptedTransferArgs`)
export const interpretEncryptedTransferArgs = async (args: EncryptedTransferArgs): Promise<JwtPayloadType | null> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.util.interpretEncryptedTransferArgs-method`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('interpretEncryptedTransferArgs()... args:', args)
// first find with args.publicKey the community 'requestingCom', which starts the request
const requestingCom = await DbCommunity.findOneBy({ publicKey: Buffer.from(args.publicKey, 'hex') })
if (!requestingCom) {
const errmsg = `unknown requesting community with publicKey ${args.publicKey}`
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
if (!requestingCom.publicJwtKey) {
const errmsg = `missing publicJwtKey of requesting community with publicKey ${args.publicKey}`
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
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 ${args.publicKey}`
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
methodLogger.debug('jwtPayload', jwtPayload)
methodLogger.removeContext('handshakeID')
return jwtPayload
}

View File

@ -1,10 +1,13 @@
import { Field, InputType } from 'type-graphql'
@InputType()
export class OpenConnectionArgs {
export class EncryptedTransferArgs {
@Field(() => String)
handshakeID: string
@Field(() => String)
publicKey: string
@Field(() => String)
url: string
jwt: string
}

View File

@ -1 +1,3 @@
export * from './validation/user'
export * from './validation/user'
export * from './graphql/logic/interpretEncryptedTransferArgs'
export * from './graphql/model/EncryptedTransferArgs'

View File

@ -0,0 +1,18 @@
/* MIGRATION TO ADD JWT-KEYPAIR IN COMMUNITY TABLE
*
* This migration adds fields for the jwt-keypair in the community.table
*/
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `communities` ADD COLUMN `public_jwt_key` varchar(512) DEFAULT NULL AFTER `gms_api_key`;',
)
await queryFn(
'ALTER TABLE `communities` ADD COLUMN `private_jwt_key` varchar(2048) DEFAULT NULL AFTER `public_jwt_key`;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `communities` DROP COLUMN `public_jwt_key`;')
await queryFn('ALTER TABLE `communities` DROP COLUMN `private_jwt_key`;')
}

View File

@ -54,6 +54,12 @@ export class Community extends BaseEntity {
@Column({ name: 'gms_api_key', type: 'varchar', length: 512, nullable: true, default: null })
gmsApiKey: string | null
@Column({ name: 'public_jwt_key', type: 'varchar', length: 512, nullable: true })
publicJwtKey: string | null
@Column({ name: 'private_jwt_key', type: 'varchar', length: 2048, nullable: true })
privateJwtKey: string | null
@Column({
name: 'location',
type: 'geometry',

View File

@ -13,6 +13,7 @@ export class CommunityLoggingView extends AbstractLoggingView {
foreign: this.self.foreign,
url: this.self.url,
publicKey: this.self.publicKey.toString(this.bufferStringFormat),
publicJwtKey: this.self.publicJwtKey,
communityUuid: this.self.communityUuid,
authenticatedAt: this.dateToString(this.self.authenticatedAt),
name: this.self.name,

View File

@ -42,6 +42,7 @@
"await-semaphore": "0.1.3",
"class-validator": "^0.13.2",
"config-schema": "*",
"core": "*",
"cors": "2.8.5",
"database": "*",
"decimal.js-light": "^2.5.1",

View File

@ -1,15 +1,12 @@
import { FederatedCommunity as DbFederatedCommunity } from 'database'
import { GraphQLClient } from 'graphql-request'
import { getLogger } from 'log4js'
import { getLogger, Logger } from 'log4js'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AuthenticationArgs } from '@/graphql/api/1_0/model/AuthenticationArgs'
import { OpenConnectionCallbackArgs } from '@/graphql/api/1_0/model/OpenConnectionCallbackArgs'
import { EncryptedTransferArgs } from 'core/src/graphql/model/EncryptedTransferArgs'
import { authenticate } from './query/authenticate'
import { openConnectionCallback } from './query/openConnectionCallback'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.client.1_0.AuthenticationClient`)
export class AuthenticationClient {
dbCom: DbFederatedCommunity
endpoint: string
@ -29,36 +26,41 @@ export class AuthenticationClient {
})
}
async openConnectionCallback(args: OpenConnectionCallbackArgs): Promise<boolean> {
logger.debug('openConnectionCallback with endpoint', this.endpoint, args)
async openConnectionCallback(args: EncryptedTransferArgs): Promise<boolean> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.client.1_0.AuthenticationClient.openConnectionCallback`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('openConnectionCallback with endpoint', this.endpoint, args)
try {
const { data } = await this.client.rawRequest<any>(openConnectionCallback, { args })
methodLogger.debug('after openConnectionCallback: data:', data)
if (data && data.openConnectionCallback) {
logger.warn('openConnectionCallback without response data from endpoint', this.endpoint)
if (!data || !data.openConnectionCallback) {
methodLogger.warn('openConnectionCallback without response data from endpoint', this.endpoint)
return false
}
logger.debug('openConnectionCallback successfully started with endpoint', this.endpoint)
methodLogger.debug('openConnectionCallback successfully started with endpoint', this.endpoint)
return true
} catch (err) {
logger.error('error on openConnectionCallback', err)
methodLogger.error('error on openConnectionCallback', err)
}
return false
}
async authenticate(args: AuthenticationArgs): Promise<string | null> {
logger.debug('authenticate with endpoint=', this.endpoint)
async authenticate(args: EncryptedTransferArgs): Promise<string | null> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.client.1_0.AuthenticationClient.authenticate`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('authenticate with endpoint=', this.endpoint)
try {
const { data } = await this.client.rawRequest<any>(authenticate, { args })
logger.debug('after authenticate: data:', data)
methodLogger.debug('after authenticate: data:', data)
const authUuid: string = data?.authenticate
if (authUuid) {
logger.debug('received authenticated uuid', authUuid)
methodLogger.debug('received authenticated uuid', authUuid)
return authUuid
}
} catch (err) {
logger.error('authenticate failed', {
methodLogger.error('authenticate failed', {
endpoint: this.endpoint,
err,
})

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
export const authenticate = gql`
mutation ($args: AuthenticationArgs!) {
mutation ($args: EncryptedTransferArgs!) {
authenticate(data: $args)
}
`

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
export const openConnectionCallback = gql`
mutation ($args: OpenConnectionCallbackArgs!) {
mutation ($args: EncryptedTransferArgs!) {
openConnectionCallback(data: $args)
}
`

View File

@ -12,6 +12,7 @@ export class GetPublicCommunityInfoResultLoggingView extends AbstractLoggingView
description: this.self.description,
creationDate: this.dateToString(this.self.creationDate),
publicKey: this.self.publicKey,
publicJwtKey: this.self.publicJwtKey,
}
}
}

View File

@ -6,6 +6,9 @@ import { Field, ObjectType } from 'type-graphql'
export class GetPublicCommunityInfoResult {
constructor(dbCom: DbCommunity) {
this.publicKey = dbCom.publicKey.toString('hex')
if (dbCom.publicJwtKey) {
this.publicJwtKey = dbCom.publicJwtKey
}
this.name = dbCom.name
this.description = dbCom.description
this.creationDate = dbCom.creationDate
@ -22,4 +25,7 @@ export class GetPublicCommunityInfoResult {
@Field(() => String)
publicKey: string
@Field(() => String)
publicJwtKey: string
}

View File

@ -1,10 +0,0 @@
import { Field, InputType } from 'type-graphql'
@InputType()
export class OpenConnectionCallbackArgs {
@Field(() => String)
oneTimeCode: string
@Field(() => String)
url: string
}

View File

@ -1,17 +1,16 @@
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { EncryptedTransferArgs, interpretEncryptedTransferArgs } from 'core'
import {
CommunityLoggingView,
Community as DbCommunity,
FederatedCommunity as DbFedCommunity,
FederatedCommunityLoggingView,
getHomeCommunity,
} from 'database'
import { getLogger } from 'log4js'
import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, OpenConnectionJwtPayloadType } from 'shared'
import { Arg, Mutation, Resolver } from 'type-graphql'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AuthenticationArgs } from '../model/AuthenticationArgs'
import { OpenConnectionArgs } from '../model/OpenConnectionArgs'
import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs'
import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver`)
@ -21,67 +20,118 @@ export class AuthenticationResolver {
@Mutation(() => Boolean)
async openConnection(
@Arg('data')
args: OpenConnectionArgs,
args: EncryptedTransferArgs,
): Promise<boolean> {
const pubKeyBuf = Buffer.from(args.publicKey, 'hex')
logger.debug(`openConnection() via apiVersion=1_0:`, args)
// first find with args.publicKey the community 'comA', which starts openConnection request
const comA = await DbCommunity.findOneBy({
publicKey: pubKeyBuf, // Buffer.from(args.publicKey),
})
if (!comA) {
throw new LogError(`unknown requesting community with publicKey`, pubKeyBuf.toString('hex'))
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.openConnection`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`openConnection() via apiVersion=1_0:`, args)
const openConnectionJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionJwtPayloadType
methodLogger.debug('openConnectionJwtPayload', openConnectionJwtPayload)
if (!openConnectionJwtPayload) {
const errmsg = `invalid OpenConnection payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
logger.debug(`found requestedCom:`, new CommunityLoggingView(comA))
// biome-ignore lint/complexity/noVoid: no await to respond immediately and invoke callback-request asynchronously
void startOpenConnectionCallback(args, comA, CONFIG.FEDERATION_API)
if (openConnectionJwtPayload.tokentype !== OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE) {
const errmsg = `invalid tokentype of community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
if (!openConnectionJwtPayload.url) {
const errmsg = `invalid url of community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
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))
if (!openConnectionJwtPayload.url.startsWith(fedComA.endPoint)) {
const errmsg = `invalid url of community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
// no await to respond immediately and invoke callback-request asynchronously
void startOpenConnectionCallback(args.handshakeID, args.publicKey, CONFIG.FEDERATION_API)
methodLogger.debug('openConnection() successfully initiated callback and returns true immediately...')
methodLogger.removeContext('handshakeID')
return true
}
@Mutation(() => Boolean)
async openConnectionCallback(
@Arg('data')
args: OpenConnectionCallbackArgs,
args: EncryptedTransferArgs,
): Promise<boolean> {
logger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args)
// TODO decrypt args.url with homeCom.privateKey and verify signing with callbackFedCom.publicKey
const endPoint = args.url.slice(0, args.url.lastIndexOf('/') + 1)
const apiVersion = args.url.slice(args.url.lastIndexOf('/') + 1, args.url.length)
logger.debug(`search fedComB per:`, endPoint, apiVersion)
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.openConnectionCallback`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args)
// 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)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
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 fedComB = await DbFedCommunity.findOneBy({ endPoint, apiVersion })
if (!fedComB) {
throw new LogError(`unknown callback community with url`, args.url)
const errmsg = `unknown callback community with url` + openConnectionCallbackJwtPayload.url
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
logger.debug(
methodLogger.debug(
`found fedComB and start authentication:`,
new FederatedCommunityLoggingView(fedComB),
)
// biome-ignore lint/complexity/noVoid: no await to respond immediately and invoke authenticate-request asynchronously
void startAuthentication(args.oneTimeCode, fedComB)
// 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.removeContext('handshakeID')
return true
}
@Mutation(() => String)
async authenticate(
@Arg('data')
args: AuthenticationArgs,
args: EncryptedTransferArgs,
): Promise<string | null> {
logger.debug(`authenticate() via apiVersion=1_0 ...`, args)
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: args.oneTimeCode })
logger.debug('found authCom:', new CommunityLoggingView(authCom))
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.authenticate`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`authenticate() via apiVersion=1_0 ...`, args)
const authArgs = await interpretEncryptedTransferArgs(args) as AuthenticationJwtPayloadType
if (!authArgs) {
const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: authArgs.oneTimeCode })
methodLogger.debug('found authCom:', new CommunityLoggingView(authCom))
if (authCom) {
// TODO decrypt args.uuid with authCom.publicKey
authCom.communityUuid = args.uuid
authCom.communityUuid = authArgs.uuid
authCom.authenticatedAt = new Date()
await DbCommunity.save(authCom)
logger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom))
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
// TODO encrypt homeCom.uuid with homeCom.privateKey
if (homeCom.communityUuid) {
return homeCom.communityUuid
methodLogger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom))
const homeComB = await getHomeCommunity()
if (homeComB?.communityUuid) {
const responseArgs = new AuthenticationResponseJwtPayloadType(args.handshakeID,homeComB.communityUuid)
const responseJwt = await encryptAndSign(responseArgs, homeComB.privateJwtKey!, authCom.publicJwtKey!)
methodLogger.removeContext('handshakeID')
return responseJwt
}
}
methodLogger.removeContext('handshakeID')
return null
}
}

View File

@ -1,46 +1,50 @@
import { EncryptedTransferArgs } from 'core'
import {
CommunityLoggingView,
Community as DbCommunity,
FederatedCommunity as DbFedCommunity,
FederatedCommunityLoggingView,
getHomeCommunity,
} from 'database'
import { getLogger } from 'log4js'
import { OpenConnectionArgs } from '../model/OpenConnectionArgs'
import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs'
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 { AuthenticationArgs } from '../model/AuthenticationArgs'
import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, verifyAndDecrypt } from 'shared'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity`)
export async function startOpenConnectionCallback(
args: OpenConnectionArgs,
comA: DbCommunity,
handshakeID: string,
publicKey: string,
api: string,
): Promise<void> {
logger.debug(`startOpenConnectionCallback() with:`, {
args,
comA: new CommunityLoggingView(comA),
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startOpenConnectionCallback`)
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug(`Authentication: startOpenConnectionCallback() with:`, {
publicKey,
})
try {
const homeFedCom = await DbFedCommunity.findOneByOrFail({
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,
})
const oneTimeCode = randombytes_random()
const oneTimeCode = randombytes_random().toString()
// store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier
comA.communityUuid = oneTimeCode.toString()
comA.communityUuid = oneTimeCode
await DbCommunity.save(comA)
logger.debug(
methodLogger.debug(
`Authentication: stored oneTimeCode in requestedCom:`,
new CommunityLoggingView(comA),
)
@ -48,68 +52,94 @@ export async function startOpenConnectionCallback(
const client = AuthenticationClientFactory.getInstance(fedComA)
if (client instanceof V1_0_AuthenticationClient) {
const callbackArgs = new OpenConnectionCallbackArgs()
callbackArgs.oneTimeCode = oneTimeCode.toString()
// TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey
callbackArgs.url = homeFedCom.endPoint.endsWith('/')
? homeFedCom.endPoint + homeFedCom.apiVersion
: homeFedCom.endPoint + '/' + homeFedCom.apiVersion
logger.debug(`Authentication: start openConnectionCallback with args:`, callbackArgs)
if (await client.openConnectionCallback(callbackArgs)) {
logger.debug('startOpenConnectionCallback() successful:', callbackArgs)
const url = homeFedComB.endPoint.endsWith('/')
? homeFedComB.endPoint + homeFedComB.apiVersion
: homeFedComB.endPoint + '/' + homeFedComB.apiVersion
const callbackArgs = new OpenConnectionCallbackJwtPayloadType(handshakeID, oneTimeCode, 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 args = new EncryptedTransferArgs()
args.publicKey = homeComB!.publicKey.toString('hex')
args.jwt = jwt
args.handshakeID = handshakeID
const result = await client.openConnectionCallback(args)
if (result) {
methodLogger.debug('startOpenConnectionCallback() successful:', jwt)
} else {
logger.error('startOpenConnectionCallback() failed:', callbackArgs)
methodLogger.error('startOpenConnectionCallback() failed:', jwt)
}
}
} catch (err) {
logger.error('error in startOpenConnectionCallback:', err)
methodLogger.error('error in startOpenConnectionCallback:', err)
}
methodLogger.removeContext('handshakeID')
}
export async function startAuthentication(
handshakeID: string,
oneTimeCode: string,
fedComB: DbFedCommunity,
): Promise<void> {
logger.debug(`startAuthentication()...`, {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startAuthentication`)
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug(`startAuthentication()...`, {
oneTimeCode,
fedComB: new FederatedCommunityLoggingView(fedComB),
})
try {
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
const homeComA = await getHomeCommunity()
const comB = await DbCommunity.findOneByOrFail({
foreign: true,
publicKey: fedComB.publicKey,
})
if (!comB.publicJwtKey) {
throw new Error('Public JWT key still not exist for foreign community')
}
// TODO encrypt homeCom.uuid with homeCom.privateKey and sign it with callbackFedCom.publicKey
const client = AuthenticationClientFactory.getInstance(fedComB)
if (client instanceof V1_0_AuthenticationClient) {
const authenticationArgs = new AuthenticationArgs()
authenticationArgs.oneTimeCode = oneTimeCode
// TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey
if (homeCom.communityUuid) {
authenticationArgs.uuid = homeCom.communityUuid
}
logger.debug(`invoke authenticate() with:`, authenticationArgs)
const fedComUuid = await client.authenticate(authenticationArgs)
logger.debug(`response of authenticate():`, fedComUuid)
if (fedComUuid !== null) {
logger.debug(
`received communityUUid for callbackFedCom:`,
fedComUuid,
const authenticationArgs = new AuthenticationJwtPayloadType(handshakeID, oneTimeCode, homeComA!.communityUuid!)
// 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.jwt = jwt
args.handshakeID = handshakeID
methodLogger.debug(`invoke authenticate() with:`, args)
const responseJwt = await client.authenticate(args)
methodLogger.debug(`response of authenticate():`, responseJwt)
if (responseJwt !== null) {
const payload = await verifyAndDecrypt(handshakeID, responseJwt, homeComA!.privateJwtKey!, comB.publicJwtKey!) as AuthenticationResponseJwtPayloadType
methodLogger.debug(
`received payload from authenticate ComB:`,
payload,
new FederatedCommunityLoggingView(fedComB),
)
const callbackCom = await DbCommunity.findOneByOrFail({
foreign: true,
publicKey: fedComB.publicKey,
})
// TODO decrypt fedComUuid with callbackFedCom.publicKey
callbackCom.communityUuid = fedComUuid
callbackCom.authenticatedAt = new Date()
await DbCommunity.save(callbackCom)
logger.debug('Community Authentication successful:', new CommunityLoggingView(callbackCom))
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)
}
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)
}
comB.communityUuid = payload.uuid
comB.authenticatedAt = new Date()
await DbCommunity.save(comB)
methodLogger.debug('Community Authentication successful:', new CommunityLoggingView(comB))
} else {
logger.error('Community Authentication failed:', authenticationArgs)
methodLogger.error('Community Authentication failed:', authenticationArgs)
}
}
} catch (err) {
logger.error('error in startAuthentication:', err)
methodLogger.error('error in startAuthentication:', err)
}
methodLogger.removeContext('handshakeID')
}

View File

@ -31,6 +31,7 @@
"dependencies": {
"auto-changelog": "^2.4.0",
"cross-env": "^7.0.3",
"jose": "^4.14.4",
"turbo": "^2.5.0",
"uuid": "^8.3.2"
},

View File

@ -34,6 +34,7 @@
"dependencies": {
"decimal.js-light": "^2.5.1",
"esbuild": "^0.25.2",
"jose": "^4.14.4",
"log4js": "^6.9.1",
"zod": "^3.25.61"
},

View File

@ -1,2 +1,3 @@
export const DECAY_START_TIME = new Date('2021-05-13T17:46:31Z')
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'

View File

@ -1,3 +1,12 @@
export * from './schema'
export * from './enum'
export * from './logic/decay'
export * from './jwt/JWT'
export * from './jwt/payloadtypes/AuthenticationJwtPayloadType'
export * from './jwt/payloadtypes/AuthenticationResponseJwtPayloadType'
export * from './jwt/payloadtypes/DisburseJwtPayloadType'
export * from './jwt/payloadtypes/EncryptedJWEJwtPayloadType'
export * from './jwt/payloadtypes/JwtPayloadType'
export * from './jwt/payloadtypes/OpenConnectionJwtPayloadType'
export * from './jwt/payloadtypes/OpenConnectionCallbackJwtPayloadType'
export * from './jwt/payloadtypes/RedeemJwtPayloadType'

167
shared/src/jwt/JWT.test.ts Normal file
View File

@ -0,0 +1,167 @@
// import { testEnvironment } from '@test/helpers'
// import { logger } from '@test/testSetup'
import { createKeyPair, decode, decrypt, encode, encrypt, encryptAndSign, verify, verifyAndDecrypt } from './JWT'
import { EncryptedJWEJwtPayloadType } from './payloadtypes/EncryptedJWEJwtPayloadType'
import { OpenConnectionJwtPayloadType } from './payloadtypes/OpenConnectionJwtPayloadType'
// let con: DataSource
// let testEnv: {
// mutate: ApolloServerTestClient['mutate']
// query: ApolloServerTestClient['query']
// con: DataSource
// }
let keypairComA: { publicKey: string; privateKey: string }
let keypairComB: { publicKey: string; privateKey: string }
beforeAll(async () => {
// testEnv = await testEnvironment(logger)
// con = testEnv.con
// await cleanDB()
keypairComA = await createKeyPair()
keypairComB = await createKeyPair()
})
afterAll(async () => {
// await cleanDB()
// await con.destroy()
})
describe('test JWS creation and verification', () => {
let jwsComA: string
let jwsComB: string
beforeEach(async () => {
jest.clearAllMocks()
jwsComA = await encode(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComA.privateKey)
jwsComB = await encode(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5002/api/'), keypairComB.privateKey)
})
it('decode jwsComA', async () => {
const decodedJwsComA = await decode(jwsComA)
expect(decodedJwsComA).toEqual({
expiration: '10m',
handshakeID: 'handshakeID',
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
})
})
it('decode jwsComB', async () => {
const decodedJwsComB = await decode(jwsComB)
expect(decodedJwsComB).toEqual({
expiration: '10m',
handshakeID: 'handshakeID',
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
})
})
it('verify jwsComA', async () => {
const verifiedJwsComA = await verify('handshakeID', jwsComA, keypairComA.publicKey)
expect(verifiedJwsComA).toEqual(expect.objectContaining({
payload: expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
})
}))
})
it('verify jwsComB', async () => {
const verifiedJwsComB = await verify('handshakeID', jwsComB, keypairComB.publicKey)
expect(verifiedJwsComB).toEqual(expect.objectContaining({
payload: expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
})
}))
})
})
describe('test JWE encryption and decryption', () => {
let jweComA: string
let jweComB: string
beforeEach(async () => {
jest.clearAllMocks()
jweComA = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComB.publicKey)
jweComB = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5002/api/'), keypairComA.publicKey)
})
it('decrypt jweComA', async () => {
const decryptedAJwT = await decrypt('handshakeID', jweComA, keypairComB.privateKey)
expect(JSON.parse(decryptedAJwT)).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
handshakeID: 'handshakeID',
}))
})
it('decrypt jweComB', async () => {
const decryptedBJwT = await decrypt('handshakeID', jweComB, keypairComA.privateKey)
expect(JSON.parse(decryptedBJwT)).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
handshakeID: 'handshakeID',
}))
})
})
describe('test encrypted and signed JWT', () => {
let jweComA: string
let jwsComA: string
let jweComB: string
let jwsComB: string
beforeEach(async () => {
jest.clearAllMocks()
jweComA = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComB.publicKey)
jwsComA = await encode(new EncryptedJWEJwtPayloadType('handshakeID', jweComA), keypairComA.privateKey)
jweComB = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5002/api/'), keypairComA.publicKey)
jwsComB = await encode(new EncryptedJWEJwtPayloadType('handshakeID', jweComB), keypairComB.privateKey)
})
it('verify jwsComA', async () => {
const verifiedJwsComA = await verify('handshakeID', jwsComA, keypairComA.publicKey)
expect(verifiedJwsComA).toEqual(expect.objectContaining({
payload: expect.objectContaining({
jwe: jweComA,
tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE,
handshakeID: 'handshakeID',
})
}))
})
it('verify jwsComB', async () => {
const verifiedJwsComB = await verify('handshakeID', jwsComB, keypairComB.publicKey)
expect(verifiedJwsComB).toEqual(expect.objectContaining({
payload: expect.objectContaining({
jwe: jweComB,
tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE,
handshakeID: 'handshakeID',
})
}))
})
it('decrypt jweComA', async () => {
expect(JSON.parse(await decrypt('handshakeID', jweComA, keypairComB.privateKey))).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
handshakeID: 'handshakeID',
}))
})
it('decrypt jweComB', async () => {
expect(JSON.parse(await decrypt('handshakeID', jweComB, keypairComA.privateKey))).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
handshakeID: 'handshakeID',
}))
})
})
describe('test encryptAndSign and verifyAndDecrypt', () => {
let jwtComA: string
beforeEach(async () => {
jest.clearAllMocks()
jwtComA = await encryptAndSign(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComA.privateKey, keypairComB.publicKey)
})
it('verifyAndDecrypt jwtComA', async () => {
const verifiedAndDecryptedPayload = await verifyAndDecrypt('handshakeID', jwtComA, keypairComB.privateKey, keypairComA.publicKey)
expect(verifiedAndDecryptedPayload).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
handshakeID: 'handshakeID',
}))
})
})

176
shared/src/jwt/JWT.ts Normal file
View File

@ -0,0 +1,176 @@
import { generateKeyPair, exportSPKI, exportPKCS8, SignJWT, decodeJwt, importPKCS8, importSPKI, jwtVerify, CompactEncrypt, compactDecrypt } from 'jose'
import { LOG4JS_BASE_CATEGORY_NAME } from '../const'
import { getLogger } from 'log4js'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT`)
import { JwtPayloadType } from './payloadtypes/JwtPayloadType'
import { EncryptedJWEJwtPayloadType } from './payloadtypes/EncryptedJWEJwtPayloadType'
export const createKeyPair = async (): Promise<{ publicKey: string; privateKey: string }> => {
// Generate key pair using jose library
const keyPair = await generateKeyPair('RS256', {
modulusLength: 2048, // recommended key size
extractable: true,
});
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity generated keypair...`);
// Convert keys to PEM format for storage in database
const publicKeyPem = await exportSPKI(keyPair.publicKey);
const privateKeyPem = await exportPKCS8(keyPair.privateKey);
return { publicKey: publicKeyPem, privateKey: privateKeyPem };
}
export const verify = async (handshakeID: string, token: string, publicKey: string): Promise<JwtPayloadType | null> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.verify`)
methodLogger.addContext('handshakeID', handshakeID)
if (!token) {
methodLogger.error('verify... token is empty')
throw new Error('401 Unauthorized')
}
methodLogger.debug('verify... token, publicKey=', token, publicKey)
try {
const importedKey = await importSPKI(publicKey, 'RS256')
// Convert the key to JWK format if needed
const secret = typeof importedKey === 'string'
? JSON.parse(importedKey)
: importedKey;
// const secret = new TextEncoder().encode(publicKey)
const { payload } = await jwtVerify(token, secret, {
issuer: JwtPayloadType.ISSUER,
audience: JwtPayloadType.AUDIENCE,
})
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
}
}
export const encode = async (payload: JwtPayloadType, privatekey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.encode`)
methodLogger.addContext('handshakeID', payload.handshakeID)
methodLogger.debug('encode... payload=', payload)
methodLogger.debug('encode... privatekey=', privatekey.substring(0, 20))
try {
const importedKey = await importPKCS8(privatekey, 'RS256')
const secret = typeof importedKey === 'string'
? JSON.parse(importedKey)
: importedKey;
// const secret = new TextEncoder().encode(privatekey)
const token = await new SignJWT({ payload, 'urn:gradido:claim': true })
.setProtectedHeader({
alg: 'RS256',
})
.setIssuedAt()
.setIssuer(JwtPayloadType.ISSUER)
.setAudience(JwtPayloadType.AUDIENCE)
.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
}
}
export const verifyJwtType = async (handshakeID: string, token: string, publicKey: string): Promise<string> => {
const payload = await verify(handshakeID, token, publicKey)
return payload ? payload.tokentype : 'unknown token type'
}
export const decode = (token: string): JwtPayloadType => {
const { payload } = decodeJwt(token)
return payload as JwtPayloadType
}
export const encrypt = async (payload: JwtPayloadType, publicKey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.encrypt`)
methodLogger.addContext('handshakeID', payload.handshakeID)
methodLogger.debug('encrypt... payload=', payload)
methodLogger.debug('encrypt... publicKey=', publicKey)
try {
const encryptKey = await importSPKI(publicKey, 'RSA-OAEP-256')
// Convert the key to JWK format if needed
const recipientKey = typeof encryptKey === 'string'
? JSON.parse(encryptKey)
: encryptKey;
const jwe = await new CompactEncrypt(
new TextEncoder().encode(JSON.stringify(payload)),
)
.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
}
}
export const decrypt = async(handshakeID: string, jwe: string, privateKey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.decrypt`)
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug('decrypt... jwe=', jwe)
methodLogger.debug('decrypt... privateKey=', privateKey.substring(0, 10))
try {
const decryptKey = await importPKCS8(privateKey, 'RSA-OAEP-256')
const { plaintext, protectedHeader } =
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
}
}
export const encryptAndSign = async (payload: JwtPayloadType, privateKey: string, publicKey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.encryptAndSign`)
methodLogger.addContext('handshakeID', payload.handshakeID)
const jwe = await encrypt(payload, publicKey)
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
}
export const verifyAndDecrypt = async (handshakeID: string, token: string, privateKey: string, publicKey: string): Promise<JwtPayloadType | null> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.verifyAndDecrypt`)
methodLogger.addContext('handshakeID', handshakeID)
const jweVerifyResult = await verify(handshakeID, token, publicKey)
if (!jweVerifyResult) {
return null
}
const jwePayload = jweVerifyResult.payload as EncryptedJWEJwtPayloadType
methodLogger.debug('verifyAndDecrypt... jwePayload=', jwePayload)
if (!jwePayload) {
return null
}
const jwePayloadType = jwePayload.tokentype
if (jwePayloadType !== EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE) {
return null
}
const jwe = jwePayload.jwe
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

@ -0,0 +1,19 @@
import { JwtPayloadType } from './JwtPayloadType'
export class AuthenticationJwtPayloadType extends JwtPayloadType {
static AUTHENTICATION_TYPE = 'authentication'
oneTimeCode: string
uuid: string
constructor(
handshakeID: string,
oneTimeCode: string,
uuid: string,
) {
super(handshakeID)
this.tokentype = AuthenticationJwtPayloadType.AUTHENTICATION_TYPE
this.oneTimeCode = oneTimeCode
this.uuid = uuid
}
}

View File

@ -0,0 +1,16 @@
import { JwtPayloadType } from './JwtPayloadType'
export class AuthenticationResponseJwtPayloadType extends JwtPayloadType {
static AUTHENTICATION_RESPONSE_TYPE = 'authenticationResponse'
uuid: string
constructor(
handshakeID: string,
uuid: string,
) {
super(handshakeID)
this.tokentype = AuthenticationResponseJwtPayloadType.AUTHENTICATION_RESPONSE_TYPE
this.uuid = uuid
}
}

View File

@ -30,7 +30,7 @@ export class DisburseJwtPayloadType extends JwtPayloadType {
recipientAlias: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
super('handshakeID')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = DisburseJwtPayloadType.DISBURSE_ACTIVATION_TYPE
this.sendercommunityuuid = senderCommunityUuid

View File

@ -0,0 +1,18 @@
import { JwtPayloadType } from './JwtPayloadType'
export class EncryptedJWEJwtPayloadType extends JwtPayloadType {
static ENCRYPTED_JWE_TYPE = 'encrypted-jwe'
jwe: string
constructor(
handshakeID: string,
jwe: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE
this.jwe = jwe
}
}

View File

@ -1,8 +1,11 @@
import { JWTPayload } from 'jose'
import { CONFIG } from '@/config'
import { REDEEM_JWT_TOKEN_EXPIRATION } from '../../const'
export class JwtPayloadType implements JWTPayload {
static ISSUER = 'urn:gradido:issuer'
static AUDIENCE = 'urn:gradido:audience'
iat?: number | undefined
exp?: number | undefined
nbf?: number | undefined
@ -12,10 +15,12 @@ export class JwtPayloadType implements JWTPayload {
iss?: string | undefined;
[propName: string]: unknown
handshakeID: string // used as logger context during authentication handshake between comA and comB
tokentype: string
expiration: string // in minutes (format: 10m for ten minutes)
constructor() {
constructor(handshakeID: string) {
this.tokentype = 'unknown jwt type'
this.expiration = CONFIG.REDEEM_JWT_TOKEN_EXPIRATION || '10m'
this.expiration = REDEEM_JWT_TOKEN_EXPIRATION || '10m'
this.handshakeID = handshakeID
}
}

View File

@ -0,0 +1,21 @@
import { JwtPayloadType } from './JwtPayloadType'
export class OpenConnectionCallbackJwtPayloadType extends JwtPayloadType {
static OPEN_CONNECTION_CALLBACK_TYPE = 'open-connection-callback'
oneTimeCode: string
url: string
constructor(
handshakeID: string,
oneTimeCode: string,
url: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = OpenConnectionCallbackJwtPayloadType.OPEN_CONNECTION_CALLBACK_TYPE
this.oneTimeCode = oneTimeCode
this.url = url
}
}

View File

@ -0,0 +1,18 @@
import { JwtPayloadType } from './JwtPayloadType'
export class OpenConnectionJwtPayloadType extends JwtPayloadType {
static OPEN_CONNECTION_TYPE = 'open-connection'
url: string
constructor(
handshakeID: string,
url: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE
this.url = url
}
}

View File

@ -1,4 +1,3 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class RedeemJwtPayloadType extends JwtPayloadType {
@ -22,7 +21,7 @@ export class RedeemJwtPayloadType extends JwtPayloadType {
validUntil: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
super('handshakeID')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE
this.sendercommunityuuid = senderCom

5961
yarn.lock

File diff suppressed because it is too large Load Diff