220 lines
7.5 KiB
JavaScript
220 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { basename, dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const {
|
|
Boss,
|
|
PrivateKey,
|
|
PublicKey,
|
|
SymmetricKey,
|
|
decode64,
|
|
encode64,
|
|
randomBytes
|
|
} = require('unicrypto');
|
|
|
|
const projectDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const topologyFile = resolve(projectDirectory, 'src/universa.json');
|
|
const cryptoDist = resolve(projectDirectory, 'node_modules/unicrypto/dist');
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function withWasmLoader(callback) {
|
|
const savedFetch = globalThis.fetch;
|
|
const savedRead = globalThis.read;
|
|
const savedReadBinary = globalThis.readBinary;
|
|
|
|
globalThis.fetch = undefined;
|
|
globalThis.read = (fileName, binary) => {
|
|
const data = readFileSync(resolve(cryptoDist, basename(fileName)));
|
|
return binary ? data : data.toString('binary');
|
|
};
|
|
globalThis.readBinary = (fileName) => (
|
|
readFileSync(resolve(cryptoDist, basename(fileName)))
|
|
);
|
|
|
|
try {
|
|
return await callback(savedFetch);
|
|
} finally {
|
|
globalThis.fetch = savedFetch;
|
|
if (savedRead === undefined) delete globalThis.read;
|
|
else globalThis.read = savedRead;
|
|
if (savedReadBinary === undefined) delete globalThis.readBinary;
|
|
else globalThis.readBinary = savedReadBinary;
|
|
}
|
|
}
|
|
|
|
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 withWasmLoader(async (fetchImpl) => {
|
|
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;
|
|
}
|