#!/usr/bin/env node import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); const { Boss, PrivateKey, PublicKey, SymmetricKey, decode64, encode64, randomBytes, unicryptoReady } = require('universa-core2'); const projectDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const topologyFile = resolve(projectDirectory, 'src/universa.json'); const requestTimeoutMs = 5000; const clientVersion = 3; function describeError(error) { if (error instanceof Error) return error.message; try { return JSON.stringify(error); } catch { return String(error); } } function loadTopology() { const topology = JSON.parse(readFileSync(topologyFile, 'utf8')); const nodes = Array.isArray(topology) ? topology : topology.list; if (!Array.isArray(nodes) || nodes.length === 0) { throw new Error(`${topologyFile} must contain a non-empty node list`); } for (const node of nodes) { if (!node.number || !node.key || !node.direct_urls?.length) { throw new Error(`Invalid node entry in ${topologyFile}`); } } return nodes; } async function request(fetchImpl, method, url, params) { const options = { method, signal: AbortSignal.timeout(requestTimeoutMs) }; if (method === 'POST') { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify({ requestData64: encode64(Boss.dump(params)) }); } const response = await fetchImpl(url, options); if (!response.ok) throw new Error(`${method} ${url} returned HTTP ${response.status}`); const answer = Boss.load(new Uint8Array(await response.arrayBuffer())); if (answer?.result !== 'ok') throw answer || new Error(`Invalid response from ${url}`); return answer.response; } function packedNodeId(node) { const key = typeof node.key === 'string' ? node.key : encode64(node.key); return `${node.number}:${key}`; } async function verifySignedTopology(fetchImpl, node, publicKey, expectedNodeIds) { const response = await request(fetchImpl, 'GET', `${node.direct_urls[0]}/topology`); const packed = response?.packed_data; const signature = response?.signature; if (!packed || !signature || !await publicKey.verifyExtended(signature, packed)) { throw new Error('invalid topology signature'); } const advertised = Boss.load(packed)?.nodes; if (!Array.isArray(advertised)) throw new Error('signed topology has no node list'); const advertisedNodeIds = new Set(advertised.map(packedNodeId)); for (const expectedNodeId of expectedNodeIds) { if (!advertisedNodeIds.has(expectedNodeId)) { throw new Error('signed topology does not contain every configured node'); } } } async function openSession(fetchImpl, node, publicKey, privateKey) { const baseUrl = node.direct_urls[0]; const signatureOptions = { pssHash: 'sha512' }; const clientNonce = randomBytes(47); const clientKey = await privateKey.publicKey.pack(); const connection = await request(fetchImpl, 'POST', `${baseUrl}/connect`, { client_key: clientKey, client_version: clientVersion }); const serverVersion = connection.server_version || 1; const version = Math.min(serverVersion, clientVersion); const sessionId = connection.session_id; const authenticationData = Boss.dump({ client_nonce: clientNonce, server_nonce: connection.server_nonce, client_version: clientVersion, server_version: serverVersion }); const token = await request(fetchImpl, 'POST', `${baseUrl}/get_token`, { data: authenticationData, signature: await privateKey.sign(authenticationData, signatureOptions), session_id: sessionId }); if (!await publicKey.verify(token.data, token.signature, signatureOptions)) { throw new Error('invalid authentication signature'); } const tokenData = Boss.load(token.data); if (encode64(tokenData.client_nonce) !== encode64(clientNonce)) { throw new Error('authentication nonce mismatch'); } const decryptedToken = Boss.load(await privateKey.decrypt(tokenData.encrypted_token)); const sessionKey = new SymmetricKey({ keyBytes: decryptedToken.sk }); return async (name, params = {}) => { const data = Boss.dump({ command: name, params }); const encrypted = version >= 2 ? await sessionKey.etaEncrypt(data) : await sessionKey.encrypt(data); const response = await request(fetchImpl, 'POST', `${baseUrl}/command`, { command: 'command', params: encrypted, session_id: sessionId }); const decrypted = version >= 2 ? await sessionKey.etaDecrypt(response.result) : await sessionKey.decrypt(response.result); const result = Boss.load(decrypted); if (result.error) throw result.error; return result.result; }; } async function testNode(fetchImpl, node, privateKey, expectedNodeIds) { const keyBytes = typeof node.key === 'string' ? decode64(node.key) : node.key; const publicKey = await PublicKey.unpack(keyBytes); await verifySignedTopology(fetchImpl, node, publicKey, expectedNodeIds); const command = await openSession(fetchImpl, node, publicKey, privateKey); const hello = await command('hello'); if (hello?.status !== 'OK') throw new Error(`hello returned ${hello?.status || 'no status'}`); const ping = await command('sping'); if (ping?.sping !== 'spong') throw new Error('sping did not return spong'); } async function main(fetchImpl) { const nodes = loadTopology(); const expectedNodeIds = new Set(nodes.map(packedNodeId)); const quorum = Math.max(1, Math.ceil(nodes.length * 0.4)); const privateKey = await PrivateKey.generate({ strength: 2048 }); console.log(`Testing ${nodes.length} Universa nodes from ${topologyFile}`); const results = await Promise.all(nodes.map(async (node) => { try { await testNode(fetchImpl, node, privateKey, expectedNodeIds); console.log(`PASS ${node.name}: signed topology, authenticated hello, sping=spong`); return true; } catch (error) { console.error(`FAIL ${node.name}: ${describeError(error)}`); return false; } })); const passed = results.filter(Boolean).length; if (passed < quorum) { throw new Error(`Universa network quorum failed: ${passed}/${nodes.length} nodes passed; ${quorum} required`); } console.log(`Universa network is functioning: ${passed}/${nodes.length} nodes passed (${quorum} required)`); } try { await unicryptoReady; const fetchImpl = globalThis.fetch; if (!fetchImpl) throw new Error('This test requires the Node.js fetch API'); await main(fetchImpl); } catch (error) { console.error(describeError(error)); process.exitCode = 1; }