univera-js-sample/scripts/test-network.js

195 lines
6.6 KiB
JavaScript

#!/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;
}