From 95b0c286f7df7e9ffb55e6f2916bcfd96507f3be Mon Sep 17 00:00:00 2001 From: Jintu Das Date: Mon, 13 Jan 2025 16:26:41 +0530 Subject: [PATCH] Moved the verification and config loading to AllowedArchiversManager class --- allowed-archivers.json | 8 +- scripts/archiver_config_sign.ts | 8 +- src/API.ts | 161 +---- src/GlobalAccount.ts | 2 + src/LostArchivers.ts | 2 + src/State.ts | 6 +- src/Utils.ts | 37 -- src/server.ts | 12 +- src/shardeum/allowedArchiversManager.ts | 151 +++++ .../src/shardeum/archiverWhitelist.test.ts | 549 ++++++------------ 10 files changed, 382 insertions(+), 554 deletions(-) create mode 100644 src/shardeum/allowedArchiversManager.ts diff --git a/allowed-archivers.json b/allowed-archivers.json index 9aced174..72d7ed07 100644 --- a/allowed-archivers.json +++ b/allowed-archivers.json @@ -11,15 +11,15 @@ "publicKey": "e8a5c26b9e2c3c31eb7c7d73eaed9484374c16d983ce95f3ab18a62521964a94" } ], - "allowedAccounts": [ - "0x002D3a2BfE09E3E29b6d38d58CaaD16EEe4C9BC5" - ], + "allowedAccounts": { + "0x002D3a2BfE09E3E29b6d38d58CaaD16EEe4C9BC5": 3 + }, "counter": 1, "minSigRequired": 1, "signatures": [ { "owner": "0x002D3a2BfE09E3E29b6d38d58CaaD16EEe4C9BC5", - "sig": "0x95b32d241e1885b9db2f2a5850c99a10a6a1df7f8142b0914715a1a5bdfd9bed09753f32948cb9fee69b20cfefee89d3f90be183de721d1b5d88157a6affda821b" + "sig": "0xc4f25a2156c9151c0515d1e5636b94de5ab2e21cc924207a2d2b5d679d8b3dec0f3b7aab78a0dfa444ee4794cf3a61aa6b9a70f8a51ebac46b9560d50cb019b51b" } ] } \ No newline at end of file diff --git a/scripts/archiver_config_sign.ts b/scripts/archiver_config_sign.ts index 43ed20b3..41390a59 100644 --- a/scripts/archiver_config_sign.ts +++ b/scripts/archiver_config_sign.ts @@ -1,18 +1,14 @@ import { ethers } from 'ethers'; import * as fs from 'fs'; -import { Utils as StringUtils } from '@shardus/types'; +import { Utils as StringUtils } from '@shardeum-foundation/lib-types'; interface ConfigData { allowedArchivers: string[]; - allowedAccounts: string[]; - minSigRequired: number; counter: number; } interface SignaturePayload { allowedArchivers: string[]; - allowedAccounts: string[]; - minSigRequired: number; counter: number; } @@ -33,8 +29,6 @@ async function generateSignature(): Promise { // Create payload const rawPayload: SignaturePayload = { allowedArchivers: configData.allowedArchivers, - allowedAccounts: configData.allowedAccounts, - minSigRequired: configData.minSigRequired, counter: configData.counter }; diff --git a/src/API.ts b/src/API.ts index 216e733a..8a9df2a3 100644 --- a/src/API.ts +++ b/src/API.ts @@ -35,13 +35,8 @@ import { failureReceiptCount, } from './primary-process' import * as ServiceQueue from './ServiceQueue' -import { readFileSync } from 'fs' -import { join } from 'path' import ticketRoutes from './routes/tickets' -import path = require('path') -import fs = require('fs') -import { verifyMultiSigs } from './Utils' -import { ethers } from 'ethers' +import { allowedArchiversManager } from './shardeum/allowedArchiversManager' const { version } = require('../package.json') // eslint-disable-line @typescript-eslint/no-var-requires const TXID_LENGTH = 64 @@ -54,8 +49,6 @@ const { } = config.REQUEST_LIMIT let reachabilityAllowed = true -let previousConfigHash = '' -let lastSeenCounter = 0 export function registerRoutes(server: FastifyInstance): void { type Request = FastifyRequest<{ @@ -287,67 +280,17 @@ export function registerRoutes(server: FastifyInstance { + server.get('/allowed-archivers', async (_request, reply) => { try { - // Get path to allowed archivers config file - const allowedArchiversPath = path.resolve(__dirname, '../allowed-archivers.json') - - // Load and parse initial config - const loadConfig = () => { - const data = fs.readFileSync(allowedArchiversPath, 'utf8') - return StringUtils.safeJsonParse(data) - } - - let allowedArchivers = loadConfig() - - // Watch for config file changes - fs.watchFile(allowedArchiversPath, (curr, prev) => { - if (curr.mtime !== prev.mtime) { - allowedArchivers = loadConfig() - } - }) - - // Extract payload fields for signature verification - const payload = { - allowedArchivers: allowedArchivers.allowedArchivers, - allowedAccounts: allowedArchivers.allowedAccounts, - minSigRequired: allowedArchivers.minSigRequired, - counter: allowedArchivers.counter - } - - // Verify signatures on config - const isValidList = verifyMultiSigs( - payload, - allowedArchivers.signatures, - allowedArchivers.allowedAccounts, - allowedArchivers.minSigRequired - ) - - if (!isValidList) { - return reply.status(403).send({ - error: 'Forbidden' // Validators will use previous valid list + const config = allowedArchiversManager.getCurrentConfig() + if (!config) { + return reply.status(500).send({ + error: 'Internal server error' }) } - - const payload_hash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(payload))) - if (previousConfigHash == '') { - previousConfigHash = payload_hash - } else if (previousConfigHash != payload_hash) { - // New list found; increment counter and update previous hash - if (payload.counter > lastSeenCounter) { - lastSeenCounter += 1 - previousConfigHash = payload_hash - } - else { // New list without incrementing counter should be rejected - Logger.mainLogger.error('Forbidden: counter is not incrementing') - return reply.status(403).send({ - error: 'Forbidden' // Validators will use previous valid list - }) - } - } - return reply.send(allowedArchivers) + return reply.send(config) } catch (error) { - Logger.mainLogger.error('Error reading/validating allowed-archivers.json:', error) + Logger.mainLogger.error('Error serving allowed-archivers:', error) return reply.status(500).send({ error: 'Internal server error' }) @@ -1369,91 +1312,29 @@ export const validateRequestData = ( Logger.mainLogger.error('Data sender publicKey and sign owner key does not match') return { success: false, error: 'Data sender publicKey and sign owner key does not match' } } - if (!Crypto.verify(data)) { Logger.mainLogger.error('Invalid signature', data) return { success: false, error: 'Invalid signature' } } - if (!skipArchiverCheck && config.limitToArchiversOnly) { - try { - // Load and validate allowed archivers config - const allowedArchiversPath = path.resolve(__dirname, '../allowed-archivers.json') - const allowedArchiversConfig = StringUtils.safeJsonParse( - fs.readFileSync(allowedArchiversPath, 'utf8') - ) - - // Extract payload for signature verification - const payload = { - allowedArchivers: allowedArchiversConfig.allowedArchivers, - allowedAccounts: allowedArchiversConfig.allowedAccounts, - minSigRequired: allowedArchiversConfig.minSigRequired, - counter: allowedArchiversConfig.counter - } + // Check if the sender is in the allowed archivers list + const isAllowedArchiver = allowedArchiversManager.isArchiverAllowed(data.sender) - // Verify signatures on config - const isValidConfig = Utils.verifyMultiSigs( - payload, - allowedArchiversConfig.signatures, - allowedArchiversConfig.allowedAccounts, - allowedArchiversConfig.minSigRequired - ) - - if (!isValidConfig) { - Logger.mainLogger.error('Invalid allowed-archivers.json signatures') - return { - success: false, - error: 'Invalid archiver configuration' - } - } - - const payload_hash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(payload))) - if (previousConfigHash == '') { - previousConfigHash = payload_hash - } - - if (previousConfigHash != payload_hash) { - if (payload.counter > lastSeenCounter) { - lastSeenCounter = payload.counter - previousConfigHash = payload_hash - } - else { - Logger.mainLogger.error('Forbidden: counter is not incrementing') - return { - success: false, - error: 'Forbidden: counter is not incrementing' - } - } - } - - // Check if sender is in the allowed archivers list - const isAllowedArchiver = allowedArchiversConfig.allowedArchivers.some( - (archiver) => archiver.publicKey === data.sender - ) - - // Check if the sender is in the active archiver list or is the devPublicKey - const isActiveArchiver = State.activeArchivers.some( - (archiver) => archiver.publicKey === data.sender - ) - - const approvedSender = - (isAllowedArchiver && isActiveArchiver) || - config.DevPublicKey === data.sender + // Check if the sender is in the active archiver list or is the devPublicKey + const isActiveArchiver = State.activeArchivers.some( + (archiver) => archiver.publicKey === data.sender + ) - if (!approvedSender) { - return { - success: false, - error: isAllowedArchiver - ? 'Archiver is not active' - : 'Data request sender is not an authorized archiver' - } - } + const approvedSender = + (isAllowedArchiver && isActiveArchiver) || + config.DevPublicKey === data.sender - } catch (error) { - Logger.mainLogger.error('Error checking allowed archivers:', error) + if (!approvedSender) { return { success: false, - error: 'Failed to verify archiver authorization' + error: isAllowedArchiver + ? 'Archiver is not active' + : 'Data request sender is not an authorized archiver' } } } diff --git a/src/GlobalAccount.ts b/src/GlobalAccount.ts index fa1625bc..173664e4 100644 --- a/src/GlobalAccount.ts +++ b/src/GlobalAccount.ts @@ -8,6 +8,7 @@ import { postJson, getJson } from './P2P' import { robustQuery, deepCopy } from './Utils' import { isDeepStrictEqual } from 'util' import { accountSpecificHash } from './shardeum/calculateAccountHash' +import { allowedArchiversManager } from './shardeum/allowedArchiversManager' let cachedGlobalNetworkAccount: AccountDB.AccountsCopy let cachedGlobalNetworkAccountHash: string @@ -35,6 +36,7 @@ export function getGlobalNetworkAccount(hash: boolean): object | string { export function setGlobalNetworkAccount(account: AccountDB.AccountsCopy): void { cachedGlobalNetworkAccount = rfdc()(account) cachedGlobalNetworkAccountHash = account.hash + allowedArchiversManager.setGlobalAccountConfig(account.data?.listOfChanges?.dev?.change?.debug?.multisigKeys, account.data?.listOfChanges?.dev?.change?.debug?.minMultiSigRequiredForGlobalTxs) } interface NetworkConfigChanges { diff --git a/src/LostArchivers.ts b/src/LostArchivers.ts index 00669bc3..d26ffe6d 100644 --- a/src/LostArchivers.ts +++ b/src/LostArchivers.ts @@ -8,6 +8,7 @@ import { calcIncomingTimes } from './Data/Data' import { postJson } from './P2P' import { sign } from './Crypto' import { SignedObject } from '@shardeum-foundation/lib-types/build/src/p2p/P2PTypes' +import { allowedArchiversManager } from './shardeum/allowedArchiversManager' let shouldSendRefutes = false @@ -101,5 +102,6 @@ function die(): void { Logger.mainLogger.debug( 'Archiver was found in `removedArchivers` and will exit now without sending a leave request' ) + allowedArchiversManager.stopWatching() process.exit(2) } diff --git a/src/State.ts b/src/State.ts index ae8af4a2..b0ea4db8 100644 --- a/src/State.ts +++ b/src/State.ts @@ -11,6 +11,7 @@ import { publicKey, secretKey, curvePublicKey, curveSecretKey } from '@shardeum- import fetch from 'node-fetch' import { getAdjacentLeftAndRightArchivers } from './Data/GossipData' import { closeDatabase } from './dbstore' +import { allowedArchiversManager } from './shardeum/allowedArchiversManager' export interface ArchiverNodeState { ip: string @@ -136,6 +137,7 @@ export async function exitArchiver(): Promise { } Logger.mainLogger.debug('Archiver will exit in 3 seconds.') setTimeout(() => { + allowedArchiversManager.stopWatching() process.exit() }, 3000) } catch (e) { @@ -148,6 +150,7 @@ export function addSigListeners(sigint = true, sigterm = true): void { process.on('SIGINT', async () => { Logger.mainLogger.debug('Exiting on SIGINT', process.pid) await closeDatabase() + allowedArchiversManager.stopWatching() if (isActive) exitArchiver() else process.exit(0) }) @@ -156,11 +159,12 @@ export function addSigListeners(sigint = true, sigterm = true): void { process.on('SIGTERM', async () => { Logger.mainLogger.debug('Exiting on SIGTERM', process.pid) await closeDatabase() + allowedArchiversManager.stopWatching() if (isActive) exitArchiver() else process.exit(0) }) } - Logger.mainLogger.debug('Registerd exit signal listeners.') + Logger.mainLogger.debug('Registered exit signal listeners.') } export function addArchiver(archiver: ArchiverNodeInfo): void { diff --git a/src/Utils.ts b/src/Utils.ts index f80d9803..c8db7b0c 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -3,7 +3,6 @@ import * as path from 'path' import { Utils as StringUtils } from '@shardeum-foundation/lib-types' import * as util from 'util' import * as Logger from './Logger' -import { ethers } from 'ethers' export interface CountSchema { count: string @@ -569,39 +568,3 @@ export function createDirectories(pathname: string): void { pathname = pathname.replace(/^\.*\/|\/?[^/]+\.[a-z]+|\/$/g, '') // Remove leading directory markers, and remove ending /file-name.extension fs.mkdirSync(path.resolve(__dirname, pathname), { recursive: true }) // eslint-disable-line security/detect-non-literal-fs-filename } - -/** -@param rawPayload: any - The original payload stripped of the signatures -@param sigs: Sign[] - The signatures to verify -@param allowedPubkeys: string[] - The public keys that are allowed to sign the payload -@param minSigRequired: number - The minimum number of signatures required -@returns boolean - True if the payload is signed by the required number of authorized public keys -**/ -export function verifyMultiSigs( - rawPayload: object, - sigs: any[], - allowedPubkeys: string[], - minSigRequired: number -): boolean { - if (!rawPayload || !sigs || !allowedPubkeys || !Array.isArray(sigs)) { - return false - } - if (sigs.length < minSigRequired) return false - if (sigs.length > allowedPubkeys.length) return false - let validSigs = 0 - const payload_hash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(rawPayload))) - const seen = new Set() - for (const sig of sigs) { - if ( - !seen.has(sig.owner) && - allowedPubkeys.includes(sig.owner) && - ethers.verifyMessage(payload_hash, sig.sig).toLowerCase() === sig.owner.toLowerCase() - ) { - validSigs++ - seen.add(sig.owner) - } - if (validSigs >= minSigRequired) break - } - - return validSigs >= minSigRequired -} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 33837c1f..66e4a473 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,8 +45,10 @@ import { healthCheckRouter } from './routes/healthCheck' import { initializeTickets } from './routes/tickets'; import { initAjvSchemas } from './types/ajv/Helpers' import { initializeSerialization } from './utils/serialization/SchemaHelpers' +import { allowedArchiversManager } from './shardeum/allowedArchiversManager' const configFile = join(process.cwd(), 'archiver-config.json') +const allowedArchiversConfigPath = join(__dirname, '../allowed-archivers.json') let logDir: string const cluster = clusterModule as unknown as clusterModule.Cluster @@ -54,6 +56,8 @@ async function start(): Promise { overrideDefaultConfig(configFile) initAjvSchemas(); initializeSerialization() + // Initialize allowed archivers manager + allowedArchiversManager.initialize(allowedArchiversConfigPath) // Set crypto hash keys from config const hashKey = config.ARCHIVER_HASH_KEY Crypto.setCryptoHashKey(hashKey) @@ -77,7 +81,7 @@ async function start(): Promise { }); process.on('unhandledRejection', (reason, promise) => { - Logger.mainLogger.error('Unhandled Rejection - Global:', promise, 'reason:', reason); + Logger.mainLogger.error('Unhandled Rejection - Global:', promise, 'reason:', reason); }); // Initialize storage @@ -502,10 +506,10 @@ async function startServer(): Promise { // Add this before starting the server try { - initializeTickets(); + initializeTickets(); } catch (err) { - console.error('Failed to initialize tickets. Server startup aborted:', err); - process.exit(1); + console.error('Failed to initialize tickets. Server startup aborted:', err); + process.exit(1); } start() diff --git a/src/shardeum/allowedArchiversManager.ts b/src/shardeum/allowedArchiversManager.ts new file mode 100644 index 00000000..43b78d8a --- /dev/null +++ b/src/shardeum/allowedArchiversManager.ts @@ -0,0 +1,151 @@ +import path = require('path') +import fs = require('fs') +import { ethers } from 'ethers' +import { Utils as StringUtils } from '@shardeum-foundation/lib-types' +import * as Logger from '../Logger' +import { verifyMultiSigs } from '../services/ticketVerification' +import { DevSecurityLevel } from '../types/security' +import { getGlobalNetworkAccount } from '../GlobalAccount' +import { Sign } from '../schemas/ticketSchema' + +interface AllowedArchiversConfig { + allowedArchivers: Array<{ + ip: string + port: number + publicKey: string + }> + allowedAccounts: { [pubkey: string]: DevSecurityLevel } + minSigRequired: number + counter: number + signatures: Sign[] +} + +class AllowedArchiversManager { + private currentConfig: AllowedArchiversConfig | null = null + private previousConfigHash: string = '' + private lastSeenCounter: number = 0 + private configPath: string + private isInitialized: boolean = false + private useGlobalAccount: boolean = false + private globalAccountAllowedSigners: { [key: string]: number } = {} + private globalAccountMinSigRequired: number = 0 + + constructor() { + this.configPath = '' + } + + public initialize(configPath: string): void { + if (this.isInitialized) return + + try { + this.configPath = path.resolve(configPath) + + // Load initial configuration + this.loadAndVerifyConfig() + + // Watch for file changes + fs.watchFile(this.configPath, (curr, prev) => { + if (curr.mtime !== prev.mtime) { + this.loadAndVerifyConfig() + } + }) + + this.isInitialized = true + } catch (error) { + Logger.mainLogger.error('Failed to initialize AllowedArchiversManager:', error) + } + } + + public stopWatching(): void { + if (this.isInitialized) { + fs.unwatchFile(this.configPath) + this.isInitialized = false + } + } + + public setGlobalAccountConfig(allowedSigners: { [key: string]: DevSecurityLevel }, minSigRequired: number): void { + this.globalAccountAllowedSigners = allowedSigners + this.globalAccountMinSigRequired = minSigRequired + this.useGlobalAccount = true + } + + private getSignerConfig(): { + allowedAccounts: { [key: string]: DevSecurityLevel }, minSigRequired: number, signatures: Sign[], counter: number, allowedArchivers: { ip: string, port: number, publicKey: string }[] + } { + try { + const data = fs.readFileSync(this.configPath, 'utf8') + const newConfig = StringUtils.safeJsonParse(data) + const allowedAccounts = this.useGlobalAccount ? this.globalAccountAllowedSigners : newConfig.allowedAccounts + const minSigRequired = this.useGlobalAccount ? this.globalAccountMinSigRequired : newConfig.minSigRequired + return { + allowedAccounts: allowedAccounts, + minSigRequired: minSigRequired, + signatures: newConfig.signatures, + counter: newConfig.counter, + allowedArchivers: newConfig.allowedArchivers + } + } catch (error) { + throw new Error('Failed to read configuration from file') + } + } + + private loadAndVerifyConfig(): void { + try { + + const getArchiverConfig = this.getSignerConfig() + const payload = { + allowedArchivers: getArchiverConfig.allowedArchivers, + counter: getArchiverConfig.counter + } + + const isValidList = verifyMultiSigs( + payload, + getArchiverConfig.signatures, + getArchiverConfig.allowedAccounts, + getArchiverConfig.minSigRequired, + DevSecurityLevel.HIGH + ) + if (!isValidList.isValid) { + Logger.mainLogger.error('Invalid signatures in new config') + return + } + + const payloadHash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(payload))) + if (this.previousConfigHash === '') { + this.previousConfigHash = payloadHash + this.currentConfig = getArchiverConfig + this.lastSeenCounter = payload.counter // Needed in case of archiver restart + return + } + + if (this.previousConfigHash !== payloadHash) { + if (payload.counter > this.lastSeenCounter) { + this.lastSeenCounter = payload.counter + this.previousConfigHash = payloadHash + this.currentConfig = getArchiverConfig + } else { + Logger.mainLogger.error('Rejected config update: counter not incrementing') + } + } + } catch (error) { + Logger.mainLogger.error('Error loading/verifying config:', error) + } + } + + public getCurrentConfig(): AllowedArchiversConfig | null { + if (!this.currentConfig) { + Logger.mainLogger.error('No current config found') + return null + } + return this.currentConfig + } + + public isArchiverAllowed(publicKey: string): boolean { + if (!this.currentConfig) return false + return this.currentConfig.allowedArchivers.some( + archiver => archiver.publicKey === publicKey + ) + } +} + +export const allowedArchiversManager = new AllowedArchiversManager() \ No newline at end of file diff --git a/test/unit/src/shardeum/archiverWhitelist.test.ts b/test/unit/src/shardeum/archiverWhitelist.test.ts index 5f0e0018..21467e9a 100644 --- a/test/unit/src/shardeum/archiverWhitelist.test.ts +++ b/test/unit/src/shardeum/archiverWhitelist.test.ts @@ -1,381 +1,208 @@ -import { jest } from '@jest/globals'; -import axios from 'axios'; -import { ethers } from 'ethers'; -import { Utils as StringUtils } from '@shardus/types'; -import { verifyMultiSigs } from '../../../../src/Utils'; - -// Mock Logger +import * as path from 'path' +import * as fs from 'fs' +import { ethers } from 'ethers' +import { Utils as StringUtils } from '@shardeum-foundation/lib-types' +import { allowedArchiversManager } from '../../../../src/shardeum/allowedArchiversManager' +import * as Logger from '../../../../src/Logger' + +// Mock the fs module +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + unlinkSync: jest.fn(), + watchFile: jest.fn(), + unwatchFile: jest.fn(), +})) + +// Mock the Logger to prevent actual logging during tests jest.mock('../../../../src/Logger', () => ({ mainLogger: { - debug: jest.fn(), error: jest.fn(), - info: jest.fn() - } -})); - -// Mock State -jest.mock('../../../../src/State', () => ({ - get activeArchivers() { - return [ - { publicKey: 'mockSender', ip: '127.0.0.1', port: 8080, curvePk: 'mockCurvePk' } - ]; - } -})); + debug: jest.fn(), + }, +})) + +// Helper function to create mock Stats object +function createMockStats(mtime: Date): fs.Stats { + return { + mtime, + isFile: () => true, + isDirectory: () => false, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + dev: 0, + ino: 0, + mode: 0, + nlink: 0, + uid: 0, + gid: 0, + rdev: 0, + size: 0, + blksize: 0, + blocks: 0, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, + atime: new Date(), + ctime: new Date(), + birthtime: new Date() + } as fs.Stats +} -// Mock Utils -jest.mock('../../../../src/Utils', () => ({ - verifyMultiSigs: jest.fn().mockImplementation((rawPayload, sigs, allowedPubkeys, minSigRequired) => { - // Simulate the behavior of the actual verifyMultiSigs function - if (!rawPayload || !sigs || !allowedPubkeys || !Array.isArray(sigs)) { - return false; - } - if (sigs.length < minSigRequired) return false; - if (sigs.length > allowedPubkeys.length) return false; - let validSigs = 0; - const seen = new Set(); - for (const sig of sigs) { - if (!seen.has(sig.owner) && allowedPubkeys.includes(sig.owner)) { - validSigs++; - seen.add(sig.owner); - } - if (validSigs >= minSigRequired) break; - } - return validSigs >= minSigRequired; - }), - validateTypes: jest.fn().mockImplementation((inp, def) => { - if (inp === undefined) return 'input is undefined'; - if (inp === null) return 'input is null'; - if (typeof inp !== 'object') return 'input must be object, not ' + typeof inp; - const map = { - string: 's', - number: 'n', - boolean: 'b', - bigint: 'B', - array: 'a', - object: 'o', - }; - const imap = { - s: 'string', - n: 'number', - b: 'boolean', - B: 'bigint', - a: 'array', - o: 'object', - }; - const fields = Object.keys(def); - for (const name of fields) { - const types = def[name] as string; - const opt = types.substr(-1, 1) === '?' ? 1 : 0; - if (inp[name] === undefined && !opt) return name + ' is required'; - if (inp[name] !== undefined) { - if (inp[name] === null && !opt) return name + ' cannot be null'; - let found = 0; - let be = ''; - for (let t = 0; t < types.length - opt; t++) { - let it = map[typeof inp[name]]; - it = Array.isArray(inp[name]) ? 'a' : it; - const is = types.substr(t, 1); - if (it === is) { - found = 1; - break; - } else be += ', ' + imap[is]; - } - if (!found) return name + ' must be' + be; - } - } - return ''; - }) -})); +describe('AllowedArchiversManager', () => { + // Generate random wallet for testing + const wallet = ethers.Wallet.createRandom() -// Mock @shardus/types -jest.mock('@shardus/types', () => ({ - Utils: { - safeJsonParse: jest.fn(obj => JSON.parse(obj as string)), - safeStringify: jest.fn(obj => JSON.stringify(obj)) + const rawPayload = { + allowedArchivers: [ + { ip: '127.0.0.1', port: 4000, publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' }, + { ip: '127.0.0.1', port: 4001, publicKey: 'e8a5c26b9e2c3c31eb7c7d73eaed9484374c16d983ce95f3ab18a62521964a94' }, + ], + counter: 1 } -})); -// Mock axios -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -// Create test wallets -const signer1 = new ethers.Wallet('0x' + '1'.repeat(64)); -const signer2 = new ethers.Wallet('0x' + '2'.repeat(64)); -const signer3 = new ethers.Wallet('0x' + '3'.repeat(64)); - -// Helper function to create signatures -async function createSignature(wallet: ethers.Wallet, payload: object): Promise { - const message = StringUtils.safeStringify(payload); - const hash = ethers.keccak256(ethers.toUtf8Bytes(message)); - return wallet.signMessage(hash); -} + // Generate hash and signature + const payloadHash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(rawPayload))) + const actualConfig = { + allowedArchivers: [ + { ip: '127.0.0.1', port: 4000, publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' }, + { ip: '127.0.0.1', port: 4001, publicKey: 'e8a5c26b9e2c3c31eb7c7d73eaed9484374c16d983ce95f3ab18a62521964a94' }, + ], + allowedAccounts: { + [wallet.address]: 3 + }, + counter: rawPayload.counter, + minSigRequired: 1, + signatures: [{ + owner: wallet.address, + sig: wallet.signMessageSync(payloadHash) + }] + } -describe('Allowed Archivers Tests', () => { + const configPath = path.resolve(__dirname, '../../../../allowed-archivers.json') beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Signature Verification', () => { - it('should verify valid signatures with minimum required signers', async () => { - const rawPayload = { - allowedArchivers: [{ - ip: '127.0.0.1', - port: 4000, - publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' - }], - allowedAccounts: [signer1.address, signer2.address], - counter: 1, - minSigRequired: 2 - }; - - const sig1 = await createSignature(signer1, rawPayload); - const sig2 = await createSignature(signer2, rawPayload); - - const signatures = [ - { owner: signer1.address, sig: sig1 }, - { owner: signer2.address, sig: sig2 } - ]; - - const isValid = verifyMultiSigs( - rawPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(true); - }); - - it('should reject when signatures are less than required', async () => { - const rawPayload = { - allowedArchivers: [{ - ip: '127.0.0.1', - port: 4000, - publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' - }], - allowedAccounts: [signer1.address, signer2.address], - counter: 1, - minSigRequired: 2 - }; - - const sig1 = await createSignature(signer1, rawPayload); - const signatures = [ - { owner: signer1.address, sig: sig1 } - ]; - - const isValid = verifyMultiSigs( - rawPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(false); - }); - - it('should reject signatures from unauthorized signers', async () => { - const rawPayload = { - allowedArchivers: [{ - ip: '127.0.0.1', - port: 4000, - publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' - }], - allowedAccounts: [signer1.address], - counter: 1, - minSigRequired: 1 - }; - - const sig = await createSignature(signer3, rawPayload); - - const signatures = [ - { owner: signer3.address, sig: sig } - ]; - - const isValid = verifyMultiSigs( - rawPayload, - signatures, - [signer1.address], - 1 - ); - - expect(isValid).toBe(false); - }); - - it('should handle duplicate signatures from same signer', async () => { - const rawPayload = { - allowedArchivers: [{ - ip: '127.0.0.1', - port: 4000, - publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' - }], - allowedAccounts: [signer1.address], - counter: 1, - minSigRequired: 1 - }; - - const sig = await createSignature(signer1, rawPayload); - - const signatures = [ - { owner: signer1.address, sig: sig }, - { owner: signer1.address, sig: sig } - ]; - - const isValid = verifyMultiSigs( - rawPayload, - signatures, - [signer1.address], - 1 - ); - - expect(isValid).toBe(false); - }); - }); - - describe('Archiver Management', () => { - const basePayload = { - allowedArchivers: [{ - ip: '127.0.0.1', - port: 4000, - publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3' - }], - allowedAccounts: [signer1.address, signer2.address], - counter: 1, - minSigRequired: 2 - }; - - it('should add new archiver with valid signatures', async () => { - const newPayload = { - ...basePayload, - allowedArchivers: [ - ...basePayload.allowedArchivers, - { - ip: '127.0.0.2', - port: 4001, - publicKey: 'newArchiverKey' - } - ], - counter: 2 - }; - - const sig1 = await createSignature(signer1, newPayload); - const sig2 = await createSignature(signer2, newPayload); - const signatures = [ - { owner: signer1.address, sig: sig1 }, - { owner: signer2.address, sig: sig2 } - ]; - - const isValid = verifyMultiSigs( - newPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(true); - }); - - it('should reject adding archiver without sufficient signatures', async () => { - const newPayload = { - ...basePayload, - allowedArchivers: [ - ...basePayload.allowedArchivers, - { - ip: '127.0.0.2', - port: 4001, - publicKey: 'newArchiverKey' - } - ], - counter: 2 - }; + jest.clearAllMocks() + // Mock readFileSync to return our test config + jest.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(actualConfig)) + }) - const sig1 = await createSignature(signer1, newPayload); - const signatures = [ - { owner: signer1.address, sig: sig1 } - ]; + afterEach(() => { + // Stop watching the config file + allowedArchiversManager.stopWatching() + }) - const isValid = verifyMultiSigs( - newPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(false); - }); + test('should initialize and load config', () => { + allowedArchiversManager.initialize(configPath) + expect(allowedArchiversManager.getCurrentConfig()).toEqual(actualConfig) + expect(fs.readFileSync).toHaveBeenCalledWith(expect.any(String), 'utf8') + }) - it('should remove archiver with valid signatures', async () => { - const newPayload = { - ...basePayload, - allowedArchivers: [], - counter: 2 - }; + test('should verify if an archiver is allowed', () => { + allowedArchiversManager.initialize(configPath) + expect(allowedArchiversManager.isArchiverAllowed('758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3')).toBe(true) + expect(allowedArchiversManager.isArchiverAllowed('publicKey3')).toBe(false) + }) - const sig1 = await createSignature(signer1, newPayload); - const sig2 = await createSignature(signer2, newPayload); - const signatures = [ - { owner: signer1.address, sig: sig1 }, - { owner: signer2.address, sig: sig2 } - ]; + test('should log error if config has invalid signatures', () => { + const invalidConfig = { ...actualConfig, signatures: [] } + jest.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig)) + allowedArchiversManager.initialize(configPath) + expect(Logger.mainLogger.error).toHaveBeenCalledWith('Invalid signatures in new config') + }) - const isValid = verifyMultiSigs( - newPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(true); - }); + test('should reject config with invalid signatures when counter is modified', () => { + allowedArchiversManager.initialize(configPath) + const newConfig = { + ...actualConfig, + counter: actualConfig.counter + 1, + } + jest.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(newConfig)) + // Simulate file change + const watchCallback = jest.mocked(fs.watchFile).mock.calls[0][1] + watchCallback( + createMockStats(new Date()), + createMockStats(new Date(Date.now() - 1000)) + ) + expect(Logger.mainLogger.error).toHaveBeenCalledWith('Invalid signatures in new config') + }) - it('should reject removing archiver without sufficient signatures', async () => { - const newPayload = { - ...basePayload, - allowedArchivers: [], - counter: 2 - }; + test('should reject config update with non-incrementing counter', () => { + allowedArchiversManager.initialize(configPath) + const newPayload = { + ...rawPayload, + counter: rawPayload.counter - 1 + } + const newPayloadHash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(newPayload))) + const newConfig = { + ...newPayload, + signatures: [{ + owner: wallet.address, + sig: wallet.signMessageSync(newPayloadHash) + }] + } + jest.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(newConfig)) + // Simulate file change + const watchCallback = jest.mocked(fs.watchFile).mock.calls[0][1] + watchCallback( + createMockStats(new Date()), + createMockStats(new Date(Date.now() - 1000)) + ) + expect(Logger.mainLogger.error).toHaveBeenCalledWith('Invalid signatures in new config') + }) - const sig1 = await createSignature(signer1, newPayload); - const signatures = [ - { owner: signer1.address, sig: sig1 } - ]; + test('should accept valid config update with incremented counter', () => { + allowedArchiversManager.initialize(configPath) + const newPayload = { + ...rawPayload, + counter: rawPayload.counter + 1 + } + const newPayloadHash = ethers.keccak256(ethers.toUtf8Bytes(StringUtils.safeStringify(newPayload))) + const newConfig = { + ...actualConfig, + counter: newPayload.counter, + signatures: [{ + owner: wallet.address, + sig: wallet.signMessageSync(newPayloadHash) + }] + } + jest.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(newConfig)) + // Simulate file change + const watchCallback = jest.mocked(fs.watchFile).mock.calls[0][1] + watchCallback( + createMockStats(new Date()), + createMockStats(new Date(Date.now() - 1000)) + ) + expect(allowedArchiversManager.getCurrentConfig()).toEqual(newConfig) + }) - const isValid = verifyMultiSigs( - newPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(false); - }); + test('should handle file read errors gracefully', () => { + jest.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File read error') + }) + allowedArchiversManager.initialize(configPath) + expect(Logger.mainLogger.error).toHaveBeenCalledWith('Error loading/verifying config:', expect.any(Error)) + }) - it('should handle multiple simultaneous archiver updates', async () => { - const newPayload = { - ...basePayload, - allowedArchivers: [ - { - ip: '127.0.0.2', - port: 4001, - publicKey: 'archiver2' - }, - { - ip: '127.0.0.3', - port: 4002, - publicKey: 'archiver3' - } - ], - counter: 2 - }; + test('should handle invalid JSON in config file', () => { + jest.mocked(fs.readFileSync).mockReturnValue('invalid json') + allowedArchiversManager.initialize(configPath) + expect(Logger.mainLogger.error).toHaveBeenCalledWith('Error loading/verifying config:', expect.any(Error)) + }) - const sig1 = await createSignature(signer1, newPayload); - const sig2 = await createSignature(signer2, newPayload); - const signatures = [ - { owner: signer1.address, sig: sig1 }, - { owner: signer2.address, sig: sig2 } - ]; + test('should not reinitialize if already initialized', () => { + allowedArchiversManager.initialize(configPath) + const firstCallCount = jest.mocked(fs.watchFile).mock.calls.length + allowedArchiversManager.initialize(configPath) + expect(jest.mocked(fs.watchFile).mock.calls.length).toBe(firstCallCount) + }) - const isValid = verifyMultiSigs( - newPayload, - signatures, - [signer1.address, signer2.address], - 2 - ); - expect(isValid).toBe(true); - }); - }); -}); + test('should properly clean up watchers when stopping', () => { + allowedArchiversManager.initialize(configPath) + allowedArchiversManager.stopWatching() + expect(fs.unwatchFile).toHaveBeenCalled() + }) +}) \ No newline at end of file