347 lines
49 KiB
JavaScript
347 lines
49 KiB
JavaScript
#!/usr/bin/env node
|
|
// proba-operation.mjs: the probe of AERE Device Operation and of the reputation. Every key is made at the start of the run (lume-operation.mjs)
|
|
// and discarded at its end; the chain is not read (the blocks and the readings of this world are synthetic, said in lume-operation.mjs).
|
|
//
|
|
// node proba-operation.mjs the rows below; exit 0 all as required, 1 a row is not, 2 only rows not measured
|
|
// node proba-operation.mjs --json the same, one JSON object on the last line: { rows: [{ id, title, state, expects, kind }] }
|
|
//
|
|
// An ATTACK row requires the verdict INVALID and the EXACT set of rules that refused (`expects`); refused for another reason is a red
|
|
// row. A PARTIAL row requires that nothing refused and the EXACT set of causes named; a VALID row requires VALID and the exact set of
|
|
// notes. The negative control (control-negativ-operation.mjs) reads `expects`: with one rule planted out of a copy, the rows that expect
|
|
// it, and only they, must go red; the facts and
|
|
// the reputation rows are named by the plantings of the reputation, beside them in control-negativ-operation.mjs.
|
|
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url';
|
|
import * as ID from '../identity/identity.mjs';
|
|
import * as D from '../device-identity/device.mjs';
|
|
import { buildProof } from '../proof-kinds/proof-kinds.mjs';
|
|
import { ruleazaSuita, incarcaVectorii } from '../conformance/conformance.mjs';
|
|
import * as O from './operation.mjs';
|
|
import * as R from './reputation.mjs';
|
|
import { lume, bloc, ORA, LANT } from './lume-operation.mjs';
|
|
import { diferente, DIR as VECTORI } from './genereaza-vectori.mjs';
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
const DEVICE_CLI = fileURLToPath(new URL('../device-identity/device-cli.mjs', import.meta.url));
|
|
// a local JSON-RPC endpoint (its own process, on 127.0.0.1) that answers the two calls of the `block` command for the blocks it is given,
|
|
// as a chain does: what the command reads and writes is then the command's own, not a form typed here
|
|
const PUNCT_RPC = `const http = require('node:http'); const { chain, blocks } = JSON.parse(process.argv[1]);
|
|
http.createServer((q, s) => { let b = ''; q.on('data', (x) => { b += x; }); q.on('end', () => { let m = {}; try { m = JSON.parse(b); } catch { m = {}; }
|
|
let r = null; if (m.method === 'eth_chainId') r = '0x' + chain.toString(16);
|
|
else if (m.method === 'eth_getBlockByNumber') { const x = blocks.find((y) => y.block === parseInt(m.params[0], 16)); r = x ? { number: '0x' + x.block.toString(16), hash: x.hash, timestamp: '0x' + x.at.toString(16) } : null; }
|
|
s.setHeader('content-type', 'application/json'); s.end(JSON.stringify({ jsonrpc: '2.0', id: m.id, result: r })); }); }).listen(0, '127.0.0.1', function () { console.log('port ' + this.address().port); });`;
|
|
const JSON_OUT = process.argv.includes('--json');
|
|
const randuri = [];
|
|
const spune = (s) => { if (!JSON_OUT) console.log(s); };
|
|
const rand = (id, title, state, detail = '', expects = [], kind = 'check') => { randuri.push({ id, title, state, expects, kind }); spune(` [${state.padEnd(9)}] ${id} ${title}${state === 'VERDE' || !detail ? '' : ' -- ' + detail}`); };
|
|
const cer = (id, title, cond, detail = '', expects = []) => rand(id, title, cond ? 'VERDE' : 'ROSU', detail, expects);
|
|
const la = (a) => [...a].sort().join(',');
|
|
const clona = (x) => JSON.parse(JSON.stringify(x));
|
|
const python = () => { for (const c of [process.env.AERE_PYTHON, 'python3', 'python'].filter(Boolean)) { const r = spawnSync(c, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); if (r.status === 0 && /Python 3/.test(r.stdout + r.stderr)) return c; } return null; };
|
|
|
|
// ---------------------------------------------------------------- the world
|
|
const W = lume();
|
|
const { T0, iso, timpul, mfr, altMfr, strain, dev1, dev2, dev3, credential, M1, M2, lista, stare, jurnal, resemneaza, optiuni } = W;
|
|
const J = jurnal(), L = J.log, OP = optiuni(J);
|
|
const urmator0 = O.operationRecord({ device: dev2, log: L, state: 'operating', counter: 2500, now: new Date(timpul(24)) }); // the next record of the honest log
|
|
const V = (log, o) => O.verifyOperation(log, o);
|
|
const cu = (log, recs) => ({ ...log, records: recs });
|
|
const inloc = (log, i, r) => cu(log, log.records.map((x, k) => (k === i ? r : x)));
|
|
const toate = []; // every verdict of the rows: their facts feed the comparison of the two implementations
|
|
// a verifier that throws gives no verdict: the row is red with the exception (under a planting of the negative control this is how a removed guard shows)
|
|
const ARUNCAT = (e) => ({ verdict: 'THREW', rules: [], unjudged: [], rows: [{ pass: false, name: 'threw', detail: String(e && e.message).slice(0, 200) }], equivocations: [], facts: null });
|
|
const judeca = (log, o) => { let r; try { r = V(log, o); } catch (e) { return ARUNCAT(e); } toate.push(r); return r; };
|
|
const detaliu = (r) => `${r.verdict}, rules [${r.rules}], causes [${r.unjudged}]` + (r.rows.filter((x) => x.pass === false).slice(0, 2).map((x) => ' | ' + x.name + ': ' + x.detail.slice(0, 160)).join(''));
|
|
function valid(id, title, log, o, note = []) { const r = judeca(log, o); rand(id, title, r.verdict === 'VALID' && la(r.unjudged) === la(note) ? 'VERDE' : 'ROSU', detaliu(r), [], 'valid'); return r; }
|
|
function partial(id, title, log, o, cauze) { const r = judeca(log, o); rand(id, title, r.verdict === 'PARTIAL' && !r.rules.length && la(r.unjudged) === la(cauze) ? 'VERDE' : 'ROSU', detaliu(r), [], 'partial'); return r; }
|
|
function atac(id, title, log, o, reguli) { const r = judeca(log, o); rand(id, title, r.verdict === 'INVALID' && la(r.rules) === la(reguli) ? 'VERDE' : 'ROSU', detaliu(r), reguli, 'attack'); return r; }
|
|
/** an attack row that also requires `cond` of the verdict (what the facts carry), with `ce` saying what they carried when it fails */
|
|
function atacSi(id, title, log, o, reguli, cond, ce) { const r = judeca(log, o); const tine = !!r.facts && cond(r);
|
|
rand(id, title, r.verdict === 'INVALID' && la(r.rules) === la(reguli) && tine ? 'VERDE' : 'ROSU', detaliu(r) + (r.facts && !tine ? ' | ' + ce(r) : ''), reguli, 'attack'); return r; }
|
|
|
|
spune('1. VALID: everything brought was judged');
|
|
const rH = valid('V01', 'the honest log: 24 records, a rotation after record 12, four anchors read, every block read', L, OP);
|
|
valid('V02', 'no anchor and no block named: VALID, and the time is said to be the device\'s word', jurnal({ ancoreLa: [], dupa: false }).log, optiuni(jurnal({ ancoreLa: [], dupa: false })), ['time-unwitnessed']);
|
|
{ const j = jurnal({ rotireLa: null }); valid('V03', 'one key, no rotation', j.log, optiuni(j)); }
|
|
valid('V04', 'records handed beside the log that are the log\'s own records', L, { ...OP, otherRecords: [L.records[3], L.records[20]] });
|
|
{ const m1 = credential({ validUntil: iso(timpul(12) + 1800000) }); const j = jurnal({ m1 }); valid('V05', 'the first key\'s credential expired before the verifier\'s clock, after the last record it signed: the window is judged at each record', j.log, optiuni(j)); }
|
|
valid('V06', 'the manufacturer retired the first key after the anchor that covers its last record was read: its history is kept', L, { ...OP, statusRecords: [stare(M1, 'retired', timpul(17) + 1800000)] });
|
|
valid('V07', 'a record names a block the verifier did not read: no lower bound from it, named', L, { ...OP, beacons: J.beacons.filter((b) => b.block !== 1007) }, ['beacon-unread']);
|
|
valid('V08', 'a retirement dated in the verifier\'s future: not in effect yet, named', L, { ...OP, statusRecords: [stare(M1, 'retired', T0 + 40 * ORA)] }, ['status-future']);
|
|
{ const m1 = credential({ status: null }), m2 = credential({ deviceKey: dev2.publicKey, status: null }); const j = jurnal({ m1, m2 }); valid('V09', 'credentials that name no recall list', j.log, optiuni(j), ['manufacture-status-none']); }
|
|
{ const s = stare(M1, 'retired', timpul(3)); const st = { ...clona(s.statement), by: { id: strain.id, keys: strain.public } }; valid('V10', 'a retirement signed by a stranger does not retire the key', L, { ...OP, statusRecords: [{ statement: st, signature: D.signLink('status', st, strain) }] }); }
|
|
valid('V11', 'a suspension by the manufacturer concerns what the device may do, not what it signed', L, { ...OP, statusRecords: [stare(M1, 'suspended', timpul(3))] });
|
|
valid('V12', 'two readings of one anchor: the earliest bounds it', L, { ...OP, readings: [...J.readings, { ...J.readings[1], at: J.readings[1].at + 900 }] });
|
|
valid('V14', 'the first key signed a record for a position after the log rotated away from it: named, the log stands', L, { ...OP, otherRecords: [resemneaza(L.records[13], dev1, (st) => { st.deviceId = dev1.deviceId; })] }, ['rotated-key-used']);
|
|
valid('V15', 'the first key signed a record beyond the log, after the rotation: named as the old key in use, not as a longer log', L, { ...OP, otherRecords: [resemneaza(urmator0, dev1, (st) => { st.deviceId = dev1.deviceId; })] }, ['rotated-key-used']);
|
|
valid('V13', 'something handed beside the log that is not a record of its keys: ignored, named', L, { ...OP, otherRecords: [{ statement: {} }, resemneaza(L.records[4], dev3, (st) => { st.deviceId = dev3.deviceId; })] }, ['other-ignored']);
|
|
// two actors: the device, whose first key the manufacturer retired after the anchor that covers its last record (V06), and whoever copied
|
|
// that key, who signs a second record for position 3 after the retirement. Before 2026-10-20 the verifier took it for the device's
|
|
// equivocation: INVALID from record 3, and none of the 24 records counted.
|
|
valid('V16', 'the first key, retired after its history was witnessed, signs a second record for position 3 (whoever copied it may have, after the retirement): named, not an equivocation, and the history the retirement keeps stays counted',
|
|
L, { ...OP, statusRecords: [stare(M1, 'retired', timpul(17) + 1800000)], otherRecords: [resemneaza(L.records[3], dev1, (st) => { st.state = 'fault'; })] }, ['retired-key-used']);
|
|
// the same rule for a record beyond the log: the second key, retired twenty minutes after the anchor that covers its last record, signs
|
|
// record 24 after the retirement. Before 2026-10-20 that was "a longer log the verifier holds" (PARTIAL, records-beyond), at the will of
|
|
// whoever holds a copy of a retired key.
|
|
valid('V18', 'the second key, retired after its last record was witnessed, signs a record beyond the log after the retirement: named, not a longer log', L, { ...OP, statusRecords: [stare(M2, 'retired', timpul(23) + 1200000)], otherRecords: [urmator0] }, ['retired-key-used']);
|
|
valid('V17', 'block readings in the form the block command writes them (with its v and kind beside the four members): every block named is used, and gives its lower bound', L, { ...OP, beacons: J.beacons.map((b) => ({ v: 1, kind: O.BLOCK_READING_KIND, ...b })) });
|
|
|
|
spune('2. PARTIAL: nothing refused, and something was not brought or not read');
|
|
partial('P01', 'the manufacturers the verifier trusts were not given', L, { ...OP, trustedManufacturers: null }, ['manufacturer-trust']);
|
|
partial('P02', 'the manufacturer\'s registry was not read', L, { ...OP, statusRecords: null }, ['status-unread']);
|
|
partial('P03', 'the recall list the credentials name was not handed', L, { ...OP, statusLists: [] }, ['manufacture-status-unread']);
|
|
partial('P04', 'an anchor claims a notarization the verifier did not read: its records lose their upper bound', L, { ...OP, readings: J.readings.slice(0, 3) }, ['time-unwitnessed', 'witness-unread']);
|
|
partial('P05', 'a reading in milliseconds is not a reading', L, { ...OP, readings: [...J.readings.slice(0, 3), { ...J.readings[3], at: J.readings[3].at * 1000 }] }, ['time-unwitnessed', 'witness-unread']);
|
|
partial('P06', 'a reading that names no chain is not used', L, { ...OP, readings: [...J.readings.slice(0, 3), { statementHash: J.readings[3].statementHash, at: J.readings[3].at }] }, ['time-unwitnessed', 'witness-unread']);
|
|
const urmator = O.operationRecord({ device: dev2, log: L, state: 'operating', counter: 2500, now: new Date(timpul(24)) });
|
|
partial('P07', 'the verifier holds a later record of the log than the one shown', L, { ...OP, otherRecords: [urmator] }, ['records-beyond']);
|
|
const rRet = partial('P08', 'the manufacturer retired the first key half an hour after the rotation: the rotation record has no witness before it, and is not counted', L, { ...OP, statusRecords: [stare(M1, 'retired', timpul(12) + 1800000)] }, ['retired-unwitnessed']);
|
|
partial('P09', 'a log that carries no record', O.newLog({ manufacture: M1 }), { ...OP, readings: [], beacons: [] }, ['records-missing']);
|
|
{ const s = stare(M1, 'retired', timpul(3)); const st = { ...clona(s.statement), state: 'decommissioned' }; partial('P10', 'a record of the manufacturer in a state this verifier does not read: it may be a retirement', L, { ...OP, statusRecords: [{ statement: st, signature: D.signLink('status', st, mfr) }] }, ['status-unread']); }
|
|
partial('P11', 'something handed as a status record that is not one (a registry object)', L, { ...OP, statusRecords: [{ records: [] }] }, ['status-unread']);
|
|
{ const s = M1.statement; const { credential: hw } = ID.issueCredential({ issuer: mfr, holder: mfr.public, type: D.TYPE, disclosable: [], validFrom: s.validFrom, validUntil: s.validUntil, status: s.status, now: new Date(T0 - 30 * 24 * ORA),
|
|
claims: { ...s.claims, hardwareRoot: { kind: 'endorsement', note: 'carried, never judged here' } } });
|
|
const j = jurnal({ m1: hw }); partial('P12', 'a credential that carries a hardware root: not judged here', j.log, optiuni(j), ['hardware-root']); }
|
|
partial('P13', 'more readings than are judged', L, { ...OP, readings: [...J.readings, ...Array.from({ length: O.MAX_READINGS }, (_, i) => ({ statementHash: '0x' + String(i).padStart(64, '0'), at: 1, chainId: 1 }))] }, ['witness-unread']);
|
|
// the registry read as the device verifier reads it (its status link): before 2026-10-20 both records below were passed over in silence
|
|
{ const s = stare(M1, 'retired', timpul(3)); const st = { ...clona(s.statement), by: { id: altMfr.id, keys: altMfr.public } };
|
|
partial('P14', 'a retirement of the first key signed by another manufacturer the verifier trusts: not applied, and not passed over', L, { ...OP, trustedManufacturers: [mfr.id, altMfr.id], statusRecords: [{ statement: st, signature: D.signLink('status', st, altMfr) }] }, ['status-other-manufacturer']); }
|
|
{ const s = stare(M1, 'retired', timpul(3)); const st = { ...clona(s.statement), deviceId: s.statement.deviceId + ' ' };
|
|
partial('P15', 'a retirement by the manufacturer whose device id is written with a space after it: it may be meant for this key, so the registry is not read as clean', L, { ...OP, statusRecords: [{ statement: st, signature: D.signLink('status', st, mfr) }] }, ['status-unread']); }
|
|
|
|
spune('3. ATTACKS: each refused for exactly its rules');
|
|
atac('A01', 'a file that is not an operation log', { ...L, kind: 'aere-device-bundle' }, OP, ['form']);
|
|
atac('A02', 'a log with a member more', { ...L, note: 'x' }, OP, ['form']);
|
|
atac('A03', 'a log with more keys than are judged', { ...L, credentials: Array.from({ length: O.MAX_KEYS + 1 }, () => M1) }, OP, ['form']);
|
|
atac('A04', 'a record with a member more, signed again by the device', inloc(L, 4, resemneaza(L.records[4], dev1, (st) => { st.extra = 1; })), OP, ['record-form']);
|
|
atac('A05', 'a record in a state that is not one of the four', inloc(L, 4, resemneaza(L.records[4], dev1, (st) => { st.state = 'broken'; })), OP, ['record-form']);
|
|
atac('A06', 'a record dated without a zone', inloc(L, 4, resemneaza(L.records[4], dev1, (st) => { st.at = st.at.replace('Z', ''); })), OP, ['record-form']);
|
|
atac('A07', 'a record dated on a day that does not exist', inloc(L, 4, resemneaza(L.records[4], dev1, (st) => { st.at = '2026-10-32T04:05:00Z'; })), OP, ['record-form']);
|
|
atac('A08', 'a record that names the device, signed by another key', inloc(L, 4, resemneaza(L.records[4], dev3, () => {})), OP, ['record-signature']);
|
|
atac('A09', 'a record of the first key after the rotation', inloc(L, 13, resemneaza(L.records[13], dev1, (st) => { st.deviceId = dev1.deviceId; })), OP, ['record-key']);
|
|
atac('A10', 'a record of a stranger key after the rotation', inloc(L, 13, resemneaza(L.records[13], dev3, (st) => { st.deviceId = dev3.deviceId; })), OP, ['record-key', 'record-signature']);
|
|
atac('A11', 'a record whose state was changed after it was signed', inloc(L, 9, { ...L.records[9], statement: { ...L.records[9].statement, state: 'operating' } }), OP, ['record-signature']);
|
|
{ const v = L.records[9].signature.value; atac('A12', 'a signature in another base64 text (the same bytes)', inloc(L, 9, { ...L.records[9], signature: { ...L.records[9].signature, value: v.slice(0, 10) + '\n' + v.slice(10) } }), OP, ['record-signature']); }
|
|
atac('A13', 'a signature object with a member more', inloc(L, 9, { ...L.records[9], signature: { ...L.records[9].signature, note: 'x' } }), OP, ['record-signature']);
|
|
atac('A14', 'a record that names another position, signed again', inloc(L, 9, resemneaza(L.records[9], dev1, (st) => { st.seq = 10; })), OP, ['record-seq']);
|
|
atac('A15', 'a record taken out of the log', cu(L, L.records.filter((_, k) => k !== 7)), OP, ['record-prev', 'record-seq']);
|
|
atac('A16', 'two records put in another order', cu(L, L.records.map((x, k) => (k === 7 ? L.records[8] : k === 8 ? L.records[7] : x))), OP, ['record-prev', 'record-seq']);
|
|
atac('A17', 'the log shown under another credential of the same key', { ...L, credentials: [credential({ id: 'urn:other-credential' }), M2] }, OP, ['record-prev']);
|
|
atac('A18', 'a record dated before the one before it (and naming no block), signed again', inloc(L, 7, resemneaza(L.records[7], dev1, (st) => { st.at = iso(timpul(6) - 60000); st.after = null; })), OP, ['record-time']);
|
|
atac('A19', 'a record dated ahead of the verifier\'s clock', L, { ...OP, now: timpul(10) + 30000 }, ['record-time']);
|
|
atac('A20', 'a counter that goes down, signed again', inloc(L, 15, resemneaza(L.records[15], dev2, (st) => { st.counter = 50; })), OP, ['record-counter']);
|
|
{ const j = jurnal({ m1: credential({ validFrom: iso(timpul(2)) }) }); atac('A21', 'a record signed before its key\'s credential was valid', j.log, optiuni(j), ['key-window']); }
|
|
{ const j = jurnal({ m1: credential({ validUntil: iso(timpul(5)) }) }); atac('A22', 'a record signed after its key\'s credential ended (the last instant of the validity is inside it)', j.log, optiuni(j), ['key-window']); }
|
|
atac('A23', 'a record signed after the manufacturer retired its key', L, { ...OP, statusRecords: [stare(M1, 'retired', timpul(6) - 60000)] }, ['key-retired']);
|
|
{ const s = stare(M1, 'retired', timpul(3)); const st = { ...clona(s.statement), at: 'yesterday' }; atac('A24', 'a retirement whose date cannot be read holds from the start', L, { ...OP, statusRecords: [{ statement: st, signature: D.signLink('status', st, mfr) }] }, ['key-retired']); }
|
|
atac('A25', 'a rotation to a key no credential of the log certifies', { ...L, credentials: [M1] }, OP, ['rotation']);
|
|
{ const M2alt = D.manufacture({ manufacturer: altMfr, deviceKey: dev2.publicKey, model: 'AX-200', serial: 'SN-0001', validFrom: M2.statement.validFrom, validUntil: M2.statement.validUntil, now: new Date(T0 - 30 * 24 * ORA) });
|
|
atac('A26', 'a rotation to a key another manufacturer certified (both trusted)', { ...L, credentials: [M1, M2alt] }, { ...OP, trustedManufacturers: [mfr.id, altMfr.id] }, ['rotation']); }
|
|
atac('A27', 'a rotation to a key certified for another serial', { ...L, credentials: [M1, credential({ deviceKey: dev2.publicKey, serial: 'SN-0002', status: { list: W.LID, index: 4 } })] }, OP, ['rotation']);
|
|
atac('A27b', 'a rotation to a key certified for another model', { ...L, credentials: [M1, credential({ deviceKey: dev2.publicKey, model: 'AX-300', status: { list: W.LID, index: 4 } })] }, OP, ['rotation']);
|
|
{ const r12 = resemneaza(L.records[12], dev1, (st) => { st.next = { deviceId: dev1.deviceId, key: dev1.publicKey }; });
|
|
atac('A27c', 'a rotation to the key that signs it, its credential shown twice', { ...cu(L, [...L.records.slice(0, 12), r12]), anchors: L.anchors.slice(0, 2), credentials: [M1, M1] }, OP, ['rotation']); }
|
|
atac('A27d', 'a rotation that names a key other than the one the next credential of the log certifies', { ...L, credentials: [M1, credential({ deviceKey: dev3.publicKey, status: { list: W.LID, index: 4 } })] }, OP, ['rotation']);
|
|
atac('A28', 'a rotation that names a key its device id is not derived from', inloc(L, 12, resemneaza(L.records[12], dev1, (st) => { st.next = { deviceId: dev2.deviceId, key: dev3.publicKey }; })), OP, ['rotation']);
|
|
{ const altul = jurnal(); atac('A29', 'an anchor that commits to a record of another log', O.addAnchor(L, O.anchorEnvelope(altul.log.records[2])), OP, ['anchor-envelope']); }
|
|
{ const E = O.anchorEnvelope(L.records[2]); atac('A30', 'an anchor whose hash does not reproduce its statement', O.addAnchor(L, { ...E, statement: { ...E.statement, at: iso(timpul(1)) } }), OP, ['anchor-envelope']); }
|
|
atac('A31', 'an anchor that names another device as its source', O.addAnchor(L, buildProof('time', { subjectHash: O.recordHash(L.records[2]), source: dev3.deviceId, at: L.records[2].statement.at, createdAt: L.records[2].statement.at })), OP, ['anchor-envelope']);
|
|
atac('A32', 'a record dated after its anchor was already on the chain', L, { ...OP, readings: J.readings.map((x, k) => (k === 1 ? { ...x, at: Math.floor((timpul(11) - 1800000) / 1000) } : x)) }, ['witness-time']);
|
|
atac('A33', 'a record names a block with a hash the verifier did not read there', L, { ...OP, beacons: J.beacons.map((b) => (b.block === 1007 ? { ...b, hash: bloc(99) } : b)) }, ['beacon-hash']);
|
|
atac('A34', 'a record dated before the block it names existed', L, { ...OP, beacons: J.beacons.map((b) => (b.block === 1007 ? { ...b, at: b.at + 3600 } : b)) }, ['beacon-time']);
|
|
const sib8 = resemneaza(L.records[8], dev1, (st) => { st.state = 'fault'; });
|
|
const rEq = atac('A35', 'the first key signed a second record for position 8', L, { ...OP, otherRecords: [sib8] }, ['equivocation']);
|
|
atac('A36', 'the second key signed a second record for position 20', L, { ...OP, otherRecords: [resemneaza(L.records[20], dev2, (st) => { st.counter += 1; })] }, ['equivocation']);
|
|
atac('A37', 'a manufacture credential changed after it was signed', { ...L, credentials: [{ ...M1, statement: { ...M1.statement, claims: { ...M1.statement.claims, model: 'AX-300' } } }, M2] }, OP, ['identity']);
|
|
atac('A38', 'a manufacturer the verifier does not trust', L, { ...OP, trustedManufacturers: [altMfr.id] }, ['identity']);
|
|
atac('A39', 'the manufacturer recalled the first key\'s credential', L, { ...OP, statusLists: [lista([3])] }, ['identity']);
|
|
const rRec2 = atac('A40', 'the manufacturer recalled the second key\'s credential: the log is accepted up to the rotation', L, { ...OP, statusLists: [lista([4])] }, ['identity']);
|
|
{ const ed = crypto.generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); const s = M1.statement;
|
|
const { credential: bad } = ID.issueCredential({ issuer: mfr, holder: mfr.public, type: D.TYPE, disclosable: [], validFrom: s.validFrom, validUntil: s.validUntil, status: s.status, now: new Date(T0 - 30 * 24 * ORA),
|
|
claims: { deviceId: s.claims.deviceId, deviceKey: ed, model: s.claims.model, serial: s.claims.serial, hardwareRoot: null } });
|
|
atac('A41', 'a classical key under the ML-DSA-65 name in the credential', { ...L, credentials: [bad, M2] }, OP, ['identity']); }
|
|
// two positions, two records, one actor who does not hold the key: whoever shows the log moved the records and hands the device's real
|
|
// record 7 beside it. Before 2026-10-20 that was "another record for position 7", refused as an equivocation, counted by the reputation,
|
|
// and kept as a proof that verifyEquivocation refuses (the two records carry different positions).
|
|
atacSi('A42', 'a log shown with record 7 taken out, and the device\'s real record 7 handed beside it: whoever moved the records makes no equivocation of the device', cu(L, L.records.filter((_, k) => k !== 7)), { ...OP, otherRecords: [L.records[7]] },
|
|
['record-prev', 'record-seq'], (r) => r.facts.equivocations.length === 0 && r.equivocations.length === 0, (r) => `${r.facts.equivocations.length} equivocation(s) in the facts`);
|
|
// the facts of every verdict are of the form the reputation reads: before 2026-10-20 the three below were not, and the reputation of
|
|
// the log could not be computed at all
|
|
{ const m1 = credential({ validFrom: '1960-01-01T00:00:00Z' }); let L0 = O.newLog({ manufacture: m1 }); L0 = O.append(L0, O.operationRecord({ device: dev1, log: L0, state: 'idle', counter: 1, now: new Date(Date.UTC(1965, 0, 1)) }));
|
|
atacSi('A43', 'a record dated before 1970, under a credential valid from 1960: refused, so the facts carry times from 1970 only (the reputation reads no other)', L0, { ...OP, readings: [], beacons: [] }, ['record-form'], (r) => R.factsForm(r.facts) === null, (r) => R.factsForm(r.facts)); }
|
|
{ const c = clona(M1); c.statement.issuer.id = 'not-an-id';
|
|
atacSi('A44', 'a first credential whose manufacturer id is not an aere-id: refused by the device verifier, and the facts name no manufacturer rather than a text the formula refuses', { ...L, credentials: [c, M2] }, OP, ['identity'],
|
|
(r) => r.facts.manufacturer === null && R.factsForm(r.facts) === null, (r) => `manufacturer ${r.facts.manufacturer}; ${R.factsForm(r.facts)}`); }
|
|
atacSi('A45', 'a retirement dated before 1970 holds from the start, and the facts carry its time as one that cannot be read', L, { ...OP, statusRecords: [stare(M1, 'retired', Date.UTC(1965, 0, 1))] }, ['key-retired'],
|
|
(r) => R.factsForm(r.facts) === null && r.facts.retirements.length === 1 && r.facts.retirements[0].at === null, (r) => JSON.stringify(r.facts.retirements) + ' ' + R.factsForm(r.facts));
|
|
|
|
spune('4. the proof of equivocation, on its own');
|
|
const P = rEq.equivocations[0];
|
|
{ const v = P ? O.verifyEquivocation(P) : { ok: false }; cer('E01', 'the proof kept by A35 verifies with nothing but what it carries, and names the first key and position 8', !!P && v.ok && v.deviceId === dev1.deviceId && v.seq === 8, JSON.stringify(v)); }
|
|
cer('E02', 'a proof with one record changed after it was signed is not a proof', !!P && !O.verifyEquivocation({ ...P, b: { ...P.b, statement: { ...P.b.statement, counter: 1 } } }).ok);
|
|
cer('E03', 'a proof that names one record twice is not a proof', !!P && !O.verifyEquivocation({ ...P, b: P.a }).ok);
|
|
cer('E04', 'a proof under another key is not a proof', !!P && !O.verifyEquivocation({ ...P, key: dev3.publicKey }).ok);
|
|
cer('E05', 'a proof of two records at two positions is not a proof', !!P && !O.verifyEquivocation({ ...P, b: L.records[9] }).ok);
|
|
cer('E06', 'a proof whose key is not a device key is not a proof', !!P && !O.verifyEquivocation({ ...P, key: 'AAAA' }).ok);
|
|
{ const pastrate = toate.flatMap((r) => r.equivocations), rele = pastrate.filter((p) => !O.verifyEquivocation(p).ok);
|
|
cer('E07', `every proof of equivocation the verifier kept in the rows above verifies on its own (${pastrate.length} kept)`, pastrate.length > 0 && !rele.length, `${rele.length} kept proof(s) do not verify: ${rele.slice(0, 1).map((p) => O.verifyEquivocation(p).error).join('')}`); }
|
|
|
|
spune('5. what the producers refuse, and the separation of purposes');
|
|
{ const th = (f) => { try { f(); return false; } catch { return true; } };
|
|
cer('D01', 'the producer refuses a record by a key that is not the one in force, a time that goes back and a counter that goes down',
|
|
th(() => O.operationRecord({ device: dev1, log: L, state: 'idle', counter: 9999, now: new Date(timpul(24)) })) && th(() => O.operationRecord({ device: dev2, log: L, state: 'idle', counter: 9999, now: new Date(timpul(20)) }))
|
|
&& th(() => O.operationRecord({ device: dev2, log: L, state: 'idle', counter: 1, now: new Date(timpul(24)) }))); }
|
|
{ const r = L.records[2]; const k = crypto.createPublicKey({ key: Buffer.from(M1.statement.claims.deviceKey, 'base64'), format: 'der', type: 'spki' });
|
|
const caAtestare = crypto.verify(null, Buffer.from('aere-device/v1/attestation\n' + ID.canonical(r.statement), 'utf8'), k, Buffer.from(r.signature.value, 'base64'));
|
|
const X = D.attest({ device: dev1, nonce: 'n', audience: 'a', firmware: { digest: '0x' + '1'.repeat(64), sequence: 1 }, ownership: '0x' + '2'.repeat(64), action: 'telemetry.publish', now: new Date(timpul(2)) });
|
|
const atestareCaInregistrare = crypto.verify(null, Buffer.from('aere-device-operation/v1/record\n' + ID.canonical(X.statement), 'utf8'), k, Buffer.from(X.signature.value, 'base64'));
|
|
cer('D02', 'a record signature does not verify as a device attestation, and an attestation does not verify as a record', !caAtestare && !atestareCaInregistrare && O.signedBy(r, M1.statement.claims.deviceKey)); }
|
|
|
|
spune('6. the facts and the reputation');
|
|
{ const f = rH.facts, r5 = f.records[5], b5 = J.beacons.find((b) => b.block === 1005), a5 = J.readings[0];
|
|
cer('R01', 'the facts of the honest log: every record counted, the two keys in order, the manufacturer, each hash the hash of its record, the bounds from the readings',
|
|
f.records.length === 24 && f.uncounted === 0 && la(f.keys) === la([dev1.deviceId, dev2.deviceId]) && f.keys[0] === dev1.deviceId && f.manufacturer === mfr.id
|
|
&& f.records.every((x, i) => x.hash === O.recordHash(L.records[i])) && r5.lo === b5.at * 1000 && r5.hi === a5.at * 1000 && f.records[0].hi === a5.at * 1000 && f.records[0].lo === J.beacons[0].at * 1000
|
|
&& la(f.chains.anchors) === String(LANT) && la(f.chains.beacons) === String(LANT), JSON.stringify(f.records[5])); }
|
|
const P1 = { from: T0, to: T0 + 24 * ORA, windowS: 3600 };
|
|
{ const rep = R.reputation(rH.facts, P1), f = rH.facts;
|
|
// the evidence, judged here independently of the formula: each window's record is a counted record dated in that window, and its witnessed record's interval lies in it
|
|
const inFereastra = rep.evidence.windows.every(([i, c, w]) => { const s = P1.from + i * 3600000, e = Math.min(s + 3600000, P1.to); const rc = c === null ? null : f.records.find((x) => x.seq === c), rw = w === null ? null : f.records.find((x) => x.seq === w);
|
|
return (c === null || (rc && rc.at >= s && rc.at < e)) && (w === null || (rw && rw.lo >= s && rw.hi < e)); });
|
|
cer('R02', 'the reputation of the honest log over a day in hours: 24 of 24 windows by the device\'s clock, 4 witnessed (the anchored hours), no gap, the ages, and evidence that points at records inside their windows',
|
|
rep.availability.claimed === 24 && rep.availability.witnessed === 4 && rep.availability.witnessedPerMille === 166 && rep.gaps.count === 0 && rep.age.claimedS === 86100 && rep.age.witnessedS === (P1.to - J.readings[0].at * 1000) / 1000
|
|
&& rep.evidence.windows.length === 24 && inFereastra && rep.records.byState.fault === 1 && rep.factsHash === '0x' + crypto.createHash('sha256').update(ID.canonical(f)).digest('hex'),
|
|
JSON.stringify(rep.availability) + JSON.stringify(rep.age)); }
|
|
{ const rep = R.reputation(rEq.facts, P1);
|
|
cer('R03', 'the reputation of a log with an equivocation: the proof counted with its two hashes, the log counted up to the position before it',
|
|
rep.equivocations.count === 1 && rep.equivocations.list.length === 1 && rep.equivocations.list[0][1] === O.recordHash(L.records[8]) && rep.equivocations.list[0][2] === O.recordHash(sib8) && rEq.facts.records.length === 8 && rep.verdict === 'INVALID', JSON.stringify(rep.equivocations), ['equivocation']); }
|
|
{ const rep = R.reputation(rRet.facts, P1);
|
|
cer('R04', 'the reputation of a log whose first key was retired: the retirement and its time listed, the record with no witness before it not counted', rep.retirements.count === 1 && rep.retirements.list[0][0] === dev1.deviceId && rep.retirements.list[0][1] === timpul(12) + 1800000 && rep.records.uncounted === 1 && rep.availability.claimed === 23, JSON.stringify(rep.retirements)); }
|
|
{ const j = jurnal({ ore: [0, 1, 2, 3, 8, 9, 10, 20, 21, 22, 23], rotireLa: 5, ancoreLa: [3, 6, 10] }); const rep = R.reputation(judeca(j.log, optiuni(j)).facts, P1);
|
|
cer('R05', 'a machine silent from hour 4 to 7 and from 11 to 19: two gaps, of 4 and 9 windows', rep.gaps.count === 2 && JSON.stringify(rep.gaps.list) === JSON.stringify([[4, 4], [11, 9]]) && rep.gaps.longestWindows === 9 && rep.availability.claimed === 11, JSON.stringify(rep.gaps)); }
|
|
{ const e1 = R.checkReputation({ facts: rH.facts, params: P1, claimed: R.reputation(rH.facts, P1) }), c = R.reputation(rH.facts, P1);
|
|
const schimbari = [['availability', 'claimed'], ['gaps', 'count'], ['age', 'claimedS'], ['records', 'inRange']].map((p) => { const x = clona(c); x[p[0]][p[1]] += 1; return R.checkReputation({ facts: rH.facts, params: P1, claimed: x }); });
|
|
cer('R06', 'a stated reputation is checked: the formula\'s own VALID, each figure changed by one INVALID (figures)', e1.verdict === 'VALID' && schimbari.every((x) => x.verdict === 'INVALID' && la(x.rules) === 'figures'), JSON.stringify(schimbari.map((x) => x.rules))); }
|
|
{ const f = clona(rH.facts); f.records[1].at = f.records[0].at - 1; const x = R.checkReputation({ facts: f, params: P1, claimed: {} });
|
|
cer('R07', 'facts that are not of the form are refused before anything is computed (form)', x.verdict === 'INVALID' && la(x.rules) === 'form', JSON.stringify(x)); }
|
|
{ const j = jurnal({ ancoreLa: [], dupa: false }), Lx = { ...j.log, records: [...j.log.records.slice(0, 3), ...Array.from({ length: O.MAX_RECORDS - 1 }, () => null)] };
|
|
const r = judeca(Lx, optiuni(j));
|
|
cer('R13', `a log longer than the bound of what is judged (${Lx.records.length} records, the first 3 well formed): every record not counted is in uncounted, the ones beyond the bound included`,
|
|
!!r.facts && r.facts.records.length === 3 && r.facts.uncounted === Lx.records.length - 3, r.facts ? `uncounted ${r.facts.uncounted}, required ${Lx.records.length - 3}` : detaliu(r), ['record-form']); }
|
|
|
|
spune('7. two implementations of the formula');
|
|
const py = python();
|
|
{ const lista = []; for (const r of toate) for (const p of [P1, { from: T0 - 6 * ORA, to: T0 + 30 * ORA, windowS: 1800 }, { from: T0 + 3 * ORA, to: T0 + 9 * ORA, windowS: 7200 }]) lista.push({ facts: r.facts, params: p });
|
|
if (!py) rand('R08', 'the Python implementation gives the same text as the JavaScript one', 'NEMASURAT', 'no Python 3 here (AERE_PYTHON=<command>)');
|
|
else {
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-op-py-')), f = path.join(T, 'many.json'); fs.writeFileSync(f, JSON.stringify(lista));
|
|
const r = spawnSync(py, [path.join(AICI, 'reputation.py'), '--many', f], { encoding: 'utf8', maxBuffer: 256 << 20 }); fs.rmSync(T, { recursive: true, force: true });
|
|
// facts the formula does not read (a planted verifier can produce them) are FORM on both sides; the reasons are worded apart
|
|
const linii = String(r.stdout).split(/\r?\n/).filter(Boolean).map((x) => (x.startsWith('FORM: ') ? 'FORM' : x)), js = lista.map((e) => { try { return ID.canonical(R.reputation(e.facts, e.params)); } catch { return 'FORM'; } });
|
|
const diferite = js.map((t, k) => (t === linii[k] ? null : k)).filter((k) => k !== null);
|
|
cer('R08', `the Python implementation gives byte for byte the same text as the JavaScript one on the facts of every row above (${lista.length} computations, three windowings)`, r.status === 0 && linii.length === js.length && !diferite.length,
|
|
r.status !== 0 ? String(r.stderr).slice(-200) : `${diferite.length} differ (first: ${diferite[0]})`);
|
|
} }
|
|
{ let d; try { d = diferente(); } catch (e) { d = ['the generator threw: ' + String(e.message).slice(0, 160)]; } cer('R09', 'the vectors (vectori/) are what the source and the code derive now (the source exercises the counter, the equivocation and the retirement: a change of the verifier there changes the facts, and this row says so)', !d.length, d.slice(0, 3).join('; '), ['record-counter', 'equivocation']); }
|
|
{ const S = incarcaVectorii(VECTORI);
|
|
if (S.eroare) rand('R10', 'the vector set is whole', 'ROSU', S.eroare);
|
|
else {
|
|
const inv = S.vectori.filter((v) => v.expected.verdict === 'INVALID'), cnt = (r) => S.vectori.filter((v) => v.expected.failed.includes(r)).length;
|
|
cer('R10', `the vector set is whole and every INVALID vector is one operation away from a VALID one (${S.vectori.length} vectors: ${S.vectori.length - inv.length} VALID, ${cnt('reputation.figures')} figures, ${cnt('reputation.form')} form)`,
|
|
inv.every((v) => v.base && v.mutation && S.vectori.some((b) => b.id === v.base && b.expected.verdict === 'VALID')) && cnt('reputation.figures') > 0 && cnt('reputation.form') > 0);
|
|
const rj = ruleazaSuita({ impl: `node "${path.join(AICI, 'reputation.mjs')}"`, dir: VECTORI }), np = (s, st) => (s.rezultate || []).filter((x) => x.stare === st).length;
|
|
cer('R11', `the JavaScript implementation passes every vector through the suite's runner (${np(rj, 'PASS')} of ${S.vectori.length})`, !rj.eroare && np(rj, 'PASS') === S.vectori.length, rj.eroare || (rj.rezultate.find((x) => x.stare !== 'PASS') || {}).detaliu);
|
|
if (!py) rand('R12', 'the Python implementation passes every vector', 'NEMASURAT', 'no Python 3 here');
|
|
else { const rp = ruleazaSuita({ impl: `${py} "${path.join(AICI, 'reputation.py')}"`, dir: VECTORI });
|
|
cer('R12', `the Python implementation passes every vector through the suite's runner (${np(rp, 'PASS')} of ${S.vectori.length})`, !rp.eroare && np(rp, 'PASS') === S.vectori.length, rp.eroare || (rp.rezultate.find((x) => x.stare !== 'PASS') || {}).detaliu); }
|
|
} }
|
|
{ // margins of the JSON TEXT, which a vector made by JSON.stringify cannot carry: each is a vector file written here with the margin put into
|
|
// its text, judged by both implementations through the conformance contract (--batch), and required to give the verdict JavaScript's
|
|
// JSON.parse leads to. Before 2026-10-20 the Python one stopped on the first (int() refuses more than 4300 digits) and on the third
|
|
// (json.loads recurses once per level), and gave no verdict at all.
|
|
const B = JSON.parse(fs.readFileSync(path.join(VECTORI, 'device-reputation', 'honest.h1.valid.json'), 'utf8'));
|
|
const cuMargine = (f, brut) => { const c = clona(B.input); f(c, '@@MARGINE@@'); return JSON.stringify({ ...B, input: c }).replace('"@@MARGINE@@"', brut); };
|
|
const margini = [
|
|
['a whole number of 5000 digits in the claim', cuMargine((c, m) => { c.claimed.availability.claimed = m; }, '1'.repeat(5000)), 'INVALID reputation.figures'],
|
|
['a whole number of 5000 digits in the facts', cuMargine((c, m) => { c.facts.uncounted = m; }, '1'.repeat(5000)), 'INVALID reputation.form'],
|
|
['a claim nested 50,000 deep', cuMargine((c, m) => { c.claimed.evidence = m; }, '['.repeat(50000) + ']'.repeat(50000)), 'INVALID reputation.figures'],
|
|
['2^53 + 1 written in full (one double: 2^53, not a safe number)', cuMargine((c, m) => { c.facts.uncounted = m; }, '9007199254740993'), 'INVALID reputation.form'],
|
|
['a zero of the claim written as 1e-400 (the double 0)', cuMargine((c, m) => { c.claimed.gaps.count = m; }, '1e-400'), 'VALID '],
|
|
['a member of the claim written twice (the last counts)', JSON.stringify(B).replace('"formula":"aere-device-reputation/1"', '"formula":"x","formula":"aere-device-reputation/1"'), 'VALID '],
|
|
];
|
|
if (!py) rand('R14', 'the two implementations give the same verdict on margins of the JSON text', 'NEMASURAT', 'no Python 3 here');
|
|
else {
|
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-op-margini-')), lista = margini.map(([, text], k) => { const f = path.join(T, `m${k}.json`); fs.writeFileSync(f, text); return { file: f }; });
|
|
fs.writeFileSync(path.join(T, 'lista.json'), JSON.stringify(lista));
|
|
const linii = (r) => String(r.stdout).split(/\r?\n/).filter(Boolean).map((x) => { try { const o = JSON.parse(x); return `${o.verdict} ${o.failed.join(',')}`; } catch { return 'NOT A VERDICT'; } });
|
|
const rj = spawnSync(process.execPath, [path.join(AICI, 'reputation.mjs'), '--batch', path.join(T, 'lista.json')], { encoding: 'utf8', maxBuffer: 64 << 20 });
|
|
const rp = spawnSync(py, [path.join(AICI, 'reputation.py'), '--batch', path.join(T, 'lista.json')], { encoding: 'utf8', maxBuffer: 64 << 20 }); fs.rmSync(T, { recursive: true, force: true });
|
|
const lj = linii(rj), lp = linii(rp), rele = margini.map(([nume, , ceCere], k) => (lj[k] === ceCere && lp[k] === ceCere ? null : `${nume}: JS ${lj[k]}, Python ${lp[k] || 'no line (exit ' + rp.status + ': ' + String(rp.stderr).trim().split('\n').slice(-1)[0].slice(0, 120) + ')'}, required ${ceCere}`)).filter(Boolean);
|
|
cer('R14', `the two implementations give the verdict JavaScript's JSON.parse leads to on ${margini.length} margins of the JSON text (5000 digits in the claim and in the facts, 50,000 levels, 2^53 + 1, 1e-400, a member written twice)`, rj.status === 0 && rp.status === 0 && !rele.length, rele.slice(0, 2).join('; '));
|
|
} }
|
|
|
|
spune('8. damaged logs');
|
|
{ // every leaf of the honest log, one at a time: set to null, to a value of another type, or taken out. Each damaged log gets a verdict
|
|
// (no exception), and none counts a record the device did not sign.
|
|
const frunze = []; const umbla = (x, p) => { if (x !== null && typeof x === 'object') { for (const k of Object.keys(x)) umbla(x[k], [...p, Array.isArray(x) ? Number(k) : k]); } else frunze.push(p); };
|
|
umbla(L, []);
|
|
const semnate = new Set(L.records.map(O.recordHash)); let n = 0, cazute = [], straine = 0;
|
|
const pas = Math.max(1, Math.floor(frunze.length / 150));
|
|
for (let i = 0; i < frunze.length; i += pas) for (const fel of ['null', 'tip', 'scos']) {
|
|
const c = clona(L); let o = c; const p = frunze[i]; for (const k of p.slice(0, -1)) o = o[k]; const k = p[p.length - 1];
|
|
if (fel === 'null') o[k] = null; else if (fel === 'tip') o[k] = typeof o[k] === 'string' ? 7 : String(o[k]); else if (Array.isArray(o)) o.splice(k, 1); else delete o[k];
|
|
n++; try { const r = V(c, OP); if (r.facts.records.some((x) => !semnate.has(x.hash))) straine++; } catch (e) { cazute.push(p.join('.') + ' (' + fel + '): ' + e.message); }
|
|
}
|
|
cer('F01', `${n} damaged logs (every ${pas}th leaf of ${frunze.length}: set to null, to another type, taken out) each get a verdict, and none counts a record the device did not sign`, !cazute.length && !straine, cazute.slice(0, 2).join('; ') + (straine ? ` ${straine} counted a record not signed` : ''), ['identity', 'record-form', 'record-signature']);
|
|
}
|
|
|
|
spune('9. the command line, through files only');
|
|
{ const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-op-cli-')), f = (n) => path.join(T, n);
|
|
const run = (script, args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', cwd: T });
|
|
const dc = (args) => run(DEVICE_CLI, args), oc = (args) => run(path.join(AICI, 'operation-cli.mjs'), args);
|
|
try {
|
|
const pasi = [dc(['new-identity', '--out', f('maker.keys.json')]), dc(['new-device', '--out', f('d1.key.json')]), dc(['new-device', '--out', f('d2.key.json')]),
|
|
dc(['manufacture', '--manufacturer', f('maker.keys.json'), '--device', f('d1.key.json'), '--model', 'AX-200', '--serial', 'SN-7', '--days', '30', '--out', f('m1.json')]),
|
|
dc(['manufacture', '--manufacturer', f('maker.keys.json'), '--device', f('d2.key.json'), '--model', 'AX-200', '--serial', 'SN-7', '--days', '30', '--out', f('m2.json')])];
|
|
const mid = JSON.parse(fs.readFileSync(f('m1.json'), 'utf8')).statement.issuer.id;
|
|
const Tc = Math.ceil((Date.now() + 5000) / 60000) * 60000, t = (i) => String(Tc + i * 60000 + 30000); // record i: 30 s into minute i
|
|
pasi.push(oc(['start', '--manufacture', f('m1.json'), '--out', f('log.json')]));
|
|
const blocuri = [];
|
|
for (let i = 0; i < 6; i++) { blocuri.push({ chainId: LANT, block: 5000 + i, hash: bloc(5000 + i), at: Math.floor((Tc + i * 60000 + 10000) / 1000) }); pasi.push(oc(['record', '--device', f('d1.key.json'), '--log', f('log.json'), '--state', 'operating', '--counter', String(10 * i), '--measurement', '0x' + String(i).repeat(64), '--after', `${LANT}:${5000 + i}:${bloc(5000 + i)}`, '--at', t(i)])); }
|
|
pasi.push(oc(['rotate', '--device', f('d1.key.json'), '--next', f('d2.key.json'), '--manufacture', f('m2.json'), '--log', f('log.json'), '--state', 'idle', '--counter', '60', '--at', t(6)]));
|
|
for (let i = 7; i < 10; i++) pasi.push(oc(['record', '--device', f('d2.key.json'), '--log', f('log.json'), '--state', 'operating', '--counter', String(10 * i), '--at', t(i), ...(i === 9 ? ['--after', `${LANT}:5009:${bloc(5009)}`] : [])]));
|
|
blocuri.push({ chainId: LANT, block: 5009, hash: bloc(5009), at: Math.floor((Tc + 9 * 60000 + 10000) / 1000) });
|
|
pasi.push(oc(['anchor', '--log', f('log.json'), '--seq', '9', '--out', f('anchor.json')]));
|
|
const E = JSON.parse(fs.readFileSync(f('anchor.json'), 'utf8'));
|
|
fs.writeFileSync(f('notarized.json'), JSON.stringify({ ...E, notarization: { chainId: LANT, note: 'synthetic: this probe reads no chain' } }));
|
|
pasi.push(oc(['add-anchor', '--log', f('log.json'), '--envelope', f('notarized.json')]));
|
|
fs.writeFileSync(f('reading.json'), JSON.stringify({ v: 1, kind: 'aere-device-operation-anchor-reading', statementHash: E.statementHash, at: Math.floor((Tc + 9 * 60000 + 50000) / 1000), chainId: LANT, note: 'synthetic: the reading a verifier would make' }));
|
|
// the blocks are read by the `block` command itself, from a local endpoint that answers eth_chainId and eth_getBlockByNumber as a chain
|
|
// does (synthetic: the blocks of this probe). Before 2026-10-20 verify refused the form `block` writes, and no block it read bounded anything.
|
|
const punct = spawn(process.execPath, ['-e', PUNCT_RPC, JSON.stringify({ chain: LANT, blocks: blocuri })], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
try {
|
|
const port = await new Promise((da, nu) => { let o = ''; punct.stdout.on('data', (x) => { o += x; const m = /port (\d+)/.exec(o); if (m) da(Number(m[1])); }); punct.on('exit', () => nu(new Error('the local endpoint stopped'))); setTimeout(() => nu(new Error('the local endpoint did not start')), 15000); }).catch(() => null);
|
|
for (const b of blocuri) pasi.push(port === null ? { status: 'X' } : oc(['block', '--rpc', `http://127.0.0.1:${port}`, '--number', String(b.block), '--out', f(`block-${b.block}.json`)]));
|
|
} finally { punct.kill(); }
|
|
pasi.push(dc(['status', '--by', f('maker.keys.json'), '--manufacture', f('m1.json'), '--state', 'active', '--seq', '1', '--hours', '24', '--out', f('status.json')]));
|
|
const now = String(Tc + 12 * 60000), comun = ['--log', f('log.json'), '--now', now, '--trust', mid, '--status', f('status.json'), '--reading', f('reading.json'), ...blocuri.flatMap((b) => ['--block', f(`block-${b.block}.json`)])];
|
|
const ver = oc(['verify', ...comun]); const fa = oc(['facts', ...comun, '--out', f('facts.json')]);
|
|
const facuti = pasi.every((x) => x.status === 0) && fa.status === 0;
|
|
const from = String(Tc), to = String(Tc + 10 * 60000);
|
|
const js = oc(['reputation', '--facts', f('facts.json'), '--from', from, '--to', to, '--window', '60', '--canonical']);
|
|
const pyr = py ? spawnSync(py, [path.join(AICI, 'reputation.py'), '--facts', f('facts.json'), '--from', from, '--to', to, '--window', '60'], { encoding: 'utf8' }) : null;
|
|
const rep = js.status === 0 ? JSON.parse(js.stdout) : null;
|
|
rand('C01', 'two device keys, two credentials, ten records with a rotation, an anchor, and the blocks read by the block command from a local endpoint, all through files: verify says VALID (exit 0), 10 of 10 minutes covered and 1 witnessed, and the reputation is the same BYTES from both command lines (`operation-cli.mjs reputation --canonical` and `reputation.py --facts`, the line end included: before 2026-10-21 Python wrote CRLF on Windows and this row compared trimmed text)',
|
|
!py ? 'NEMASURAT' : facuti && ver.status === 0 && /^VALID/m.test(ver.stdout) && !!rep && rep.availability.claimed === 10 && rep.availability.windows === 10 && rep.availability.witnessed === 1 && pyr.status === 0 && pyr.stdout === js.stdout ? 'VERDE' : 'ROSU',
|
|
`steps ${pasi.map((x) => x.status).join('')}, verify ${ver.status}: ${String(ver.stdout).trim().split('\n').slice(-3).join(' | ')} ${String(ver.stderr).slice(0, 200)}` + (rep ? ' ' + JSON.stringify(rep.availability) : '') + (pyr && pyr.stdout !== js.stdout ? ` | the two outputs differ: JS ${Buffer.byteLength(js.stdout)} bytes ending ${JSON.stringify(js.stdout.slice(-2))}, Python ${Buffer.byteLength(pyr.stdout)} bytes ending ${JSON.stringify(pyr.stdout.slice(-2))}` : ''));
|
|
const Lf = JSON.parse(fs.readFileSync(f('log.json'), 'utf8')); Lf.records[3].statement.counter = 999; fs.writeFileSync(f('tampered.json'), JSON.stringify(Lf));
|
|
const t1 = oc(['verify', ...comun.map((x) => (x === f('log.json') ? f('tampered.json') : x))]), t2 = oc(['verify', ...comun.filter((x, i, a) => x !== '--status' && a[i - 1] !== '--status')]), t3 = oc(['verify', '--log', f('log.json')]);
|
|
const t4 = oc(['verify', '--log', f('log.json'), '--now', 'yesterday']);
|
|
cer('C03', 'the exit codes of verify: a changed record 1 (INVALID), the registry not read 2 (PARTIAL), no clock 3, a clock that is not a time 3', t1.status === 1 && t2.status === 2 && t3.status === 3 && t4.status === 3, `${t1.status} ${t2.status} ${t3.status} ${t4.status}`);
|
|
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
|
}
|
|
|
|
const verzi = randuri.filter((r) => r.state === 'VERDE').length, rosii = randuri.filter((r) => r.state === 'ROSU').length, nemas = randuri.length - verzi - rosii;
|
|
spune(`\ndevice operation: ${verzi} VERDE, ${rosii} ROSU, ${nemas} NEMASURAT of ${randuri.length} rows`);
|
|
if (JSON_OUT) console.log(JSON.stringify({ verzi, rosii, nemasurat: nemas, rows: randuri }));
|
|
process.exitCode = rosii ? 1 : nemas ? 2 : 0;
|