// AERE Device Operation (roadmap 26, 2026-10-20): what a machine with an identity (../aere-device-identity) says about its own // operation over time, in a form a stranger judges without trusting the machine. Node 24 only (node:crypto: ML-DSA-65, FIPS 204). // // THE LOG. The device signs, at intervals, an operation record with its device key K (ML-DSA-65, the key the manufacturer certified): // seq its position in the log (0, 1, 2, ... across key rotations) // prev the hash of the record before it; record 0 names the hash of the manufacture credential of the first key, so the log is // rooted in one identity and cannot be shown under another credential of the same key // at the device's clock (its word, unless a witness bounds it: below) // state operating | idle | fault | maintenance // counter a number the device keeps and never lowers (cycles, energy, distance, seconds of operation) // measurement the sha256 of what it measured, or null: the data stays with the device or its owner, only the digest is signed // after a block the device read before signing ({ chainId, block, hash }), or null: a block hash cannot be known before the block // exists, so with the verifier's own reading of that block the record cannot be older than the block (a lower bound) // next a rotation ({ deviceId, key }), or null: from the next record on the log is signed by that key, which a manufacture // credential of the same manufacturer, for the same model and serial, must certify (the old key consents by signing the // rotation, the manufacturer by certifying the new key) // A record is exactly { statement, signature }; the signature is ML-DSA-65 with K over a domain of its own ('aere-device-operation/v1/ // record'), so it verifies as nothing else the device signs (its attestations use 'aere-device/v1/attestation'). // // THE WITNESS. An anchor is an AIP-23 envelope of the existing kind `time` whose subjectHash is the hash of a record: notarized, and read // back by the VERIFIER through the AIP-23 reference verifier under a post-quantum certified anchor, it says that record (and every record // before it, which it names through `prev`) already existed at the moment the chain wrote. That is an upper bound on time; `after` gives a // lower bound. Without either, the time of a record is the device's word, and the verdict says so. // // THE VERDICT. verifyOperation makes every check of each record it reaches and names every rule that refused (REGULI; each check carries // a PAZA marker). It does not turn a failure of its own into a refusal: an exception is a defect of the verifier, and it is thrown. The log is accepted up to the first record that a rule refuses; what comes after is not judged, and counted. INVALID: a // rule refused. PARTIAL: nothing refused, and something was not brought or not read (the manufacturers the verifier trusts, the recall // list, the manufacturer's registry, a notarization nobody read, records beyond the log shown). VALID: everything brought was judged. // The identity of each key is judged by the device verifier (../aere-device-identity/device.mjs), not again here. // // WHAT IT DOES NOT SAY. That the machine did the physical work its records describe: a record is what the holder of K signed, and K is a // software key (nothing here binds it to a chip). A copied K signs records as well as the device. See README.md. import crypto from 'node:crypto'; import { canonical, idOf, credentialHash } from '../identity/identity.mjs'; import * as D from '../device-identity/device.mjs'; import { buildProof } from '../proof-kinds/proof-kinds.mjs'; export const VERSION = 1; export const ALG = 'ml-dsa-65'; export const RECORD_KIND = 'aere-device-operation'; export const LOG_KIND = 'aere-device-operation-log'; export const EQUIVOCATION_KIND = 'aere-device-operation-equivocation'; export const FACTS_KIND = 'aere-device-operation-facts'; export const ANCHOR_KIND = 'aere-proof-of-time'; // the AIP-23 kind `time` of ../proof-kinds /** The kind of a block reading as the `block` command writes it (operation-cli.mjs): the one place its form is named. */ export const BLOCK_READING_KIND = 'aere-device-operation-block-reading'; export const STATES = ['operating', 'idle', 'fault', 'maintenance']; export const CLOCK_SKEW_S = 60; // How much of what the log carries and what the verifier hands is judged. Beyond the bound the rest is NOT read, and the verdict says so. export const MAX_KEYS = 16, MAX_RECORDS = 100000, MAX_ANCHORS = 4096, MAX_READINGS = 4096, MAX_BEACONS = 100000, MAX_OTHER = 4096, MAX_STATUS_RECORDS = 512; const DOMENIU = 'aere-device-operation/v1/record\n'; const H32 = /^0x[0-9a-f]{64}$/; const DEV = /^aere-device:[0-9a-f]{40}$/; const AERE_ID = /^aere-id:[0-9a-f]{40}$/; // the device id a status record names, read as the device verifier reads it (../aere-device-identity/device.mjs, idScris): its hex without // regard to case; null when what is written there is not a device id at all (a space after it, another form) const DEV_SCRIS = /^aere-device:([0-9a-fA-F]{40})$/; const idScris = (x) => { const m = typeof x === 'string' ? DEV_SCRIS.exec(x) : null; return m ? 'aere-device:' + m[1].toLowerCase() : null; }; const DATA = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/; const CAMPURI = ['v', 'kind', 'deviceId', 'seq', 'prev', 'at', 'state', 'counter', 'measurement', 'after', 'next']; const sha = (b) => crypto.createHash('sha256').update(b).digest(); const hex0x = (b) => '0x' + b.toString('hex'); const ob = (x) => !!x && typeof x === 'object' && !Array.isArray(x); const iso = (d) => new Date(d).toISOString(); const numai = (o, chei) => ob(o) && Object.keys(o).every((k) => chei.includes(k)); const toate = (o, chei) => ob(o) && chei.every((k) => k in o) && numai(o, chei); const b64canonic = (s) => typeof s === 'string' && s.length > 0 && Buffer.from(s, 'base64').toString('base64') === s; const intreg = (x, min = 0) => Number.isSafeInteger(x) && x >= min; /** The day a text of the form of DATA names exists (a date parser MOVES 2026-02-30 to March 2): the same rule as the device verifier's. */ const exista = (s) => new Date(Date.parse(s)).getUTCDate() === Number(String(s).slice(8, 10)); const eTimp = (s) => typeof s === 'string' && DATA.test(s) && exista(s); function data(s, ce) { if (!eTimp(s)) throw new Error(`aere-device-operation: ${ce} is not an RFC 3339 UTC time`); return Date.parse(s); } const aceeasi = (a, b) => ob(a) && ob(b) && a.id === b.id && canonical(a.keys) === canonical(b.keys); const idDin = (keys) => { try { return idOf(keys); } catch { return NaN; } }; const cheieObiect = (b64) => crypto.createPublicKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'spki' }); const mesaj = (statement) => Buffer.from(DOMENIU + canonical(statement), 'utf8'); /** The device's signature over a record statement (the published formula): ML-DSA-65 with K over the domain of operation records. */ export function signRecord(statement, device) { if (!device || !device.privat) throw new Error('aere-device-operation: signing needs the device private key'); return { alg: ALG, value: crypto.sign(null, mesaj(statement), device.privat).toString('base64') }; } /** The hash of a record ({ statement, signature }): what the record after it names in `prev`. */ export const recordHash = (r) => D.linkHash(r); /** A measurement as the record carries it: a digest (kept, lower case) or bytes (hashed). Nothing else: the record carries no raw data. */ export function measurementDigest(m) { if (m == null) return null; if (typeof m === 'string' && /^0x[0-9a-fA-F]{64}$/.test(m)) return m.toLowerCase(); if (Buffer.isBuffer(m) || m instanceof Uint8Array) return hex0x(sha(Buffer.from(m))); throw new Error('aere-device-operation: a measurement is a digest (0x and 64 hex digits) or bytes'); } // ---------------------------------------------------------------- producers /** A new log, rooted in the manufacture credential of the device's first key. */ export function newLog({ manufacture }) { if (!ob(manufacture) || !ob(manufacture.statement) || !ob(manufacture.statement.claims) || !DEV.test(String(manufacture.statement.claims.deviceId))) throw new Error('aere-device-operation: a log starts from a manufacture credential of the device'); const radacina = [manufacture]; // the credential of the first key: the root of the log return { v: VERSION, kind: LOG_KIND, credentials: radacina, records: [], anchors: [] }; } /** The device id that signs the next record of a log: the first key, then the key each rotation names. */ export function keyInForce(log) { let k = log.credentials[0].statement.claims.deviceId; for (const r of log.records) if (r.statement.next) k = r.statement.next.deviceId; return k; } /** * The next operation record of a log, signed with the device key. `after`: { chainId, block, hash } of a block the device read; `next`: * the public half of the device key that signs from the following record on ({ deviceId, publicKey }). */ export function operationRecord({ device, log, state, counter, measurement = null, after = null, next = null, now = new Date() }) { if (!device || !device.privat) throw new Error('aere-device-operation: a record needs the device private key'); if (!ob(log) || log.kind !== LOG_KIND || !Array.isArray(log.records)) throw new Error('aere-device-operation: not an operation log'); if (device.deviceId !== keyInForce(log)) throw new Error(`aere-device-operation: the key that signs this log now is ${keyInForce(log)}, not ${device.deviceId}`); if (!STATES.includes(state)) throw new Error('aere-device-operation: state is one of ' + STATES.join(', ')); if (!intreg(counter)) throw new Error('aere-device-operation: counter is a whole number from 0 up'); const ultim = log.records.length ? log.records[log.records.length - 1].statement : null; const at = iso(now); if (ultim && Date.parse(at) < Date.parse(ultim.at)) throw new Error('aere-device-operation: the time of a record does not go back'); if (ultim && counter < ultim.counter) throw new Error('aere-device-operation: the counter does not go down'); if (after !== null && !(toate(after, ['chainId', 'block', 'hash']) && intreg(after.chainId, 1) && intreg(after.block) && H32.test(String(after.hash).toLowerCase()))) throw new Error('aere-device-operation: after is { chainId, block, hash } of a block the device read'); let nx = null; if (next !== null) { if (!next || D.deviceIdOf(next.publicKey) !== next.deviceId || next.deviceId === device.deviceId) throw new Error('aere-device-operation: next is the public half of another device key'); nx = { deviceId: next.deviceId, key: next.publicKey }; } const statement = { v: VERSION, kind: RECORD_KIND, deviceId: device.deviceId, seq: log.records.length, prev: log.records.length ? recordHash(log.records[log.records.length - 1]) : credentialHash(log.credentials[0]), at, state, counter, measurement: measurementDigest(measurement), after: after === null ? null : { chainId: after.chainId, block: after.block, hash: String(after.hash).toLowerCase() }, next: nx }; return { statement, signature: signRecord(statement, device) }; } /** The log with one record more (and, for a rotation, the manufacture credential of the new key). Pure: the log given is not changed. */ export function append(log, record, credential = null) { return { ...log, credentials: credential ? [...log.credentials, credential] : log.credentials, records: [...log.records, record] }; } /** The AIP-23 envelope (kind `time`) that commits to a record by hash: notarize it, and a verifier's reading of it bounds the time from above. */ export function anchorEnvelope(record) { const s = record.statement; return buildProof('time', { subjectHash: recordHash(record), source: s.deviceId, at: s.at, createdAt: s.at }); } /** The log with an anchor envelope more (as notarized, with its `notarization`, or not yet). */ export const addAnchor = (log, envelope) => ({ ...log, anchors: [...(log.anchors || []), envelope] }); // ---------------------------------------------------------------- one record: its form and its signature /** * The form of a record, as this verifier reads it. Its time is from 1970: the facts carry every time as milliseconds since 1970 (the * reputation reads no other), so a record dated before it would be accepted here and its facts refused by the formula. */ export function wellFormed(r) { if (!(toate(r, ['statement', 'signature']) && toate(r.statement, CAMPURI))) return false; const s = r.statement, a = s.after, n = s.next; return s.v === VERSION && s.kind === RECORD_KIND && typeof s.deviceId === 'string' && DEV.test(s.deviceId) && intreg(s.seq) && typeof s.prev === 'string' && H32.test(s.prev) && eTimp(s.at) && !(Date.parse(s.at) < 0) && STATES.includes(s.state) && intreg(s.counter) && (s.measurement === null || (typeof s.measurement === 'string' && H32.test(s.measurement))) && (a === null || (toate(a, ['chainId', 'block', 'hash']) && intreg(a.chainId, 1) && intreg(a.block) && typeof a.hash === 'string' && H32.test(a.hash))) && (n === null || (toate(n, ['deviceId', 'key']) && typeof n.deviceId === 'string' && typeof n.key === 'string')); } /** Is the record signed with this device key (a canonical ML-DSA-65 SPKI in base64), exactly { alg, value }, in canonical base64? */ export function signedBy(r, keyB64) { try { const k = cheieObiect(keyB64); return toate(r.signature, ['alg', 'value']) && r.signature.alg === ALG && b64canonic(r.signature.value) && crypto.verify(null, mesaj(r.statement), k, Buffer.from(r.signature.value, 'base64')); } catch { return false; } } // ---------------------------------------------------------------- equivocation: two records of one key for one position /** The proof that one device key signed two different records for the same position. `key`: the device key (SPKI, base64). */ export const equivocationProof = (a, b, key) => ({ v: VERSION, kind: EQUIVOCATION_KIND, seq: a.statement.seq, key, a, b }); /** Checks a proof of equivocation with nothing but what it carries: both records well formed, of the device the key derives, at one position, different, both signed. */ export function verifyEquivocation(p) { if (!toate(p, ['v', 'kind', 'seq', 'key', 'a', 'b']) || p.v !== VERSION || p.kind !== EQUIVOCATION_KIND || !intreg(p.seq)) return { ok: false, error: 'not an ' + EQUIVOCATION_KIND }; let id = null; try { id = D.deviceIdOf(p.key); } catch { return { ok: false, error: 'the key is not a canonical ML-DSA-65 public key' }; } for (const r of [p.a, p.b]) { if (!wellFormed(r)) return { ok: false, error: 'a record is not a well formed operation record' }; if (r.statement.deviceId !== id || r.statement.seq !== p.seq) return { ok: false, error: 'the two records are not of the device the key derives, at the position named' }; if (!signedBy(r, p.key)) return { ok: false, error: 'a record is not signed with the key' }; } if (recordHash(p.a) === recordHash(p.b)) return { ok: false, error: 'the two records are the same' }; return { ok: true, deviceId: id, seq: p.seq }; } // ---------------------------------------------------------------- the verifier export const REGULI = ['form', 'identity', 'record-form', 'record-key', 'record-signature', 'record-seq', 'record-prev', 'record-time', 'record-counter', 'key-window', 'key-retired', 'rotation', 'anchor-envelope', 'witness-time', 'beacon-hash', 'beacon-time', 'equivocation']; export const NEJUDECATE = ['manufacturer-trust', 'manufacture-status-unread', 'manufacture-status-none', 'hardware-root', 'status-unread', 'status-other-manufacturer', 'status-future', 'records-missing', 'records-unread', 'records-beyond', 'retired-unwitnessed', 'witness-unread', 'beacon-unread', 'time-unwitnessed', 'other-ignored', 'rotated-key-used', 'retired-key-used', 'after-refusal']; /** * The causes that are NAMED and do not keep the verdict from VALID: a credential with no recall list; a retirement dated in the verifier's * future (not in effect yet); a block named by a record that the verifier did not read (no lower bound from it); records whose time no * witness bounds (the device's word); records handed beside the log that are not records of its keys; a record signed by a key after * the log rotated away from it (somebody still holds and uses the old key: said, since the log itself does not depend on it); a record * handed beside the log that is signed by a key the manufacturer retired (whoever copied the key may have signed it after the retirement: * it proves nothing against the device, which is what the retirement is for); records after a refused one (the verdict is INVALID already). */ export const NOTATE = ['manufacture-status-none', 'status-future', 'beacon-unread', 'time-unwitnessed', 'other-ignored', 'rotated-key-used', 'retired-key-used', 'after-refusal']; export const PARTIALE = NEJUDECATE.filter((c) => !NOTATE.includes(c)); export const OPRIRI = ['form', 'record-form']; // the causes of the device verifier's manufacture link, carried over under the same names const CAUZE_IDENTITATE = ['manufacturer-trust', 'manufacture-status-unread', 'manufacture-status-none', 'hardware-root']; class Nedeclarata extends Error {} /** * Verifies an operation log with what the VERIFIER brings: its clock, the manufacturers it trusts, the manufacturer's recall lists and * status records it read (`statusRecords`: null when it read none, a list, empty included, when it did), its own readings of the notarized * anchors ({ statementHash, at: unix seconds, chainId }), its own readings of blocks ({ chainId, block, hash, at: unix seconds }), and * records of this machine it holds from elsewhere (`otherRecords`: a sibling signed by the same key for a position of the log is an * equivocation). Returns { verdict, valid, rows, rules, unjudged, accepted, counted, equivocations, facts }. */ export function verifyOperation(L, { now = new Date(), clockSkewS = CLOCK_SKEW_S, trustedManufacturers = null, statusLists = [], statusRecords = null, readings = [], beacons = [], otherRecords = [] } = {}) { const rows = []; const ok = (rule, scope, name, pass, detail = '') => { if (!REGULI.includes(rule)) throw new Nedeclarata('aere-device-operation: undeclared rule ' + rule); rows.push({ scope, name, pass: !!pass, detail: pass ? '' : detail, rule }); return !!pass; }; const nejudecat = (cauza, scope, name, detail) => { if (!NEJUDECATE.includes(cauza)) throw new Nedeclarata('aere-device-operation: undeclared cause ' + cauza); rows.push({ scope, name, pass: null, detail, unjudged: cauza }); }; // the verifier's own configuration first: a mistake of the verifier is not an invalid log const acum = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : eTimp(now) ? Date.parse(now) : NaN; if (!Number.isFinite(acum)) throw new Error('verifyOperation: now is not a time (a Date, milliseconds since 1970, or an RFC 3339 UTC text)'); if (!Number.isInteger(clockSkewS) || clockSkewS < 0 || clockSkewS > 600) throw new Error('verifyOperation: clockSkewS is 0..600 seconds'); if (trustedManufacturers != null && !Array.isArray(trustedManufacturers)) throw new Error('verifyOperation: trustedManufacturers is a list of ids or public keys'); if (![statusLists, readings, beacons, otherRecords].every(Array.isArray) || !(statusRecords === null || Array.isArray(statusRecords))) throw new Error('verifyOperation: statusLists, readings, beacons and otherRecords are lists; statusRecords is a list or null (not read)'); try { D.verifyDevice({}, { now: acum, clockSkewS, trustedManufacturers, statusLists }); } catch (e) { throw new Error('verifyOperation: ' + String(e.message).replace(/^verifyDevice: /, '')); } // the ids of the manufacturers the verifier trusts (the device verifier above refused anything else): a status record of one of them about // a key of this log that is not the key's own manufacturer is named, as the device verifier names it const incredere = trustedManufacturers == null ? null : trustedManufacturers.map((x) => (typeof x === 'string' ? x : idDin(x))); const sk = clockSkewS * 1000; let accepted = [], counted = [], equivocations = [], chei = [], recs = [], toateInreg = 0, numarate = null, lanturi = { anchors: new Set(), beacons: new Set() }; // the facts are always of the form the reputation reads (reputation.mjs, factsForm): the manufacturer is an aere-id or null (a credential // the device verifier refused may carry any text there), and `uncounted` is every record of the log not counted, the ones beyond the // bound of what is judged included const facts = () => ({ v: VERSION, kind: FACTS_KIND, verdict: null, rules: [], unjudged: [], manufacturer: chei.length && chei[0].S && AERE_ID.test(String(chei[0].S.issuer.id)) ? chei[0].S.issuer.id : null, keys: [...new Set(accepted.map((x) => x.deviceId))], records: (numarate || counted).map((x) => ({ seq: x.seq, hash: x.hash, at: x.at, lo: x.lo, hi: x.hi, state: x.state, counter: x.counter })), uncounted: toateInreg - (numarate || counted).length, equivocations: equivocations.map((p) => ({ seq: p.seq, a: recordHash(p.a), b: recordHash(p.b) })).sort((x, y) => x.seq - y.seq || (x.a < y.a ? -1 : x.a > y.a ? 1 : x.b < y.b ? -1 : x.b > y.b ? 1 : 0)), retirements: chei.filter((k) => k.S && k.retras !== null).map((k) => ({ deviceId: k.S.deviceId, at: Number.isFinite(k.retras) ? k.retras : null })), chains: { anchors: [...lanturi.anchors].sort((x, y) => x - y), beacons: [...lanturi.beacons].sort((x, y) => x - y) } }); const gata = () => { const rules = [...new Set(rows.filter((r) => r.pass === false).map((r) => r.rule))].sort(), unjudged = [...new Set(rows.filter((r) => r.pass === null).map((r) => r.unjudged))].sort(); const verdict = rules.length ? 'INVALID' : unjudged.some((c) => PARTIALE.includes(c)) ? 'PARTIAL' : 'VALID'; const f = facts(); f.verdict = verdict; f.rules = rules; f.unjudged = unjudged; return { verdict, valid: !rules.length, rows, rules, unjudged, accepted: accepted.length, counted: (numarate || counted).length, equivocations, facts: f }; }; { if (!((ob(L) && L.kind === LOG_KIND && L.v === VERSION && Array.isArray(L.credentials) && L.credentials.length >= 1 && L.credentials.length <= MAX_KEYS && L.credentials.every(ob) && Array.isArray(L.records) && (L.anchors === undefined || Array.isArray(L.anchors)) && numai(L, ['v', 'kind', 'credentials', 'records', 'anchors'])) /*PAZA:form*/)) { ok('form', 'log', 'log: well formed', false, `not an ${LOG_KIND} with 1 to ${MAX_KEYS} manufacture credentials, a list of records and a list of anchors`); return gata(); } // ---- what the verifier handed: bounded, and nothing in it is skipped in silence (the device verifier's rule, here too) const adus = (lista, max, cauza, scope, ce, eBun) => { if (lista.length > max) nejudecat(cauza, scope, `${scope}: more ${ce}s than are judged`, `${lista.length} ${ce}s were handed and the first ${max} are judged: the others were not read`); const judecate = lista.slice(0, max), bune = judecate.filter(eBun); if (bune.length < judecate.length) nejudecat(cauza, scope, `${scope}: something handed as a ${ce} is not one`, `${judecate.length - bune.length} of the ${judecate.length} element(s) handed as ${ce}s are not ${ce}s: nothing in them was read`); return bune; }; const SR = statusRecords === null ? null : adus(statusRecords, MAX_STATUS_RECORDS, 'status-unread', 'status', 'status record', (x) => ob(x) && ob(x.statement) && x.statement.kind === 'aere-device-status'); const AN = adus(readings, MAX_READINGS, 'witness-unread', 'witness', 'reading', (x) => ob(x) && typeof x.statementHash === 'string'); // a block reading is { chainId, block, hash, at }, and may carry the v and kind the `block` command writes beside them (and nothing else) const BK = adus(beacons, MAX_BEACONS, 'beacon-unread', 'beacon', 'block reading', (x) => ob(x) && ['chainId', 'block', 'hash', 'at'].every((k) => k in x) && numai(x, ['v', 'kind', 'chainId', 'block', 'hash', 'at']) && (!('v' in x) || x.v === 1) && (!('kind' in x) || x.kind === BLOCK_READING_KIND) && intreg(x.chainId, 1) && intreg(x.block) && typeof x.hash === 'string' && H32.test(x.hash) && intreg(x.at, 1) && x.at < 1e11); const OT = adus(otherRecords, MAX_OTHER, 'other-ignored', 'equivocation', 'record', (x) => ob(x)); // ---- 1. identity: each manufacture credential, judged by the device verifier. The window of a credential is judged at the time of // each record (below), not on the verifier's clock: a log signed while the credential was valid stays a log of that time. chei = L.credentials.map((M, i) => { const r = D.verifyDevice({ v: 1, kind: 'aere-device-bundle', manufacture: M, transfers: [] }, { now: acum, clockSkewS, trustedManufacturers, statusLists }); const refuzate = r.rows.filter((x) => (x.link === 'manufacture' || x.link === 'bundle') && x.pass === false && x.rule !== 'manufacture-window'); const bun = ok('identity', 'identity', `key ${i + 1}/${L.credentials.length}: the manufacture credential, judged by the device verifier`, refuzate.length === 0 /*PAZA:identity*/, refuzate.map((x) => `device.${x.rule}: ${x.detail}`).join('; ').slice(0, 600)); for (const c of CAUZE_IDENTITATE) { const x = r.rows.find((y) => y.link === 'manufacture' && y.unjudged === c); if (x) nejudecat(c, 'identity', `key ${i + 1}: ${x.name}`, x.detail); } let S = null; if (!r.rows.some((x) => x.rule === 'manufacture-form' || x.rule === 'unreadable')) { const s = M.statement; S = { deviceId: s.claims.deviceId, key: s.claims.deviceKey, model: s.claims.model, serial: s.claims.serial, issuer: s.issuer, vf: data(s.validFrom, 'validFrom'), vu: data(s.validUntil, 'validUntil') }; } return { bun, S, retras: null }; }); // ---- 2. the manufacturer's registry: a retirement of a key, with its time. A record counts when it names the key's device id, says // `retired` and is signed by the manufacturer of that key's credential. Its time is the manufacturer's word (a status record carries the // date its signer chose); a record dated in the verifier's future is not in effect yet. One the manufacturer signed and this verifier // cannot read may be a retirement: the registry is not read as clean. Which key a record is about, and what is done with one this // verifier does not apply, is read as the device verifier reads it (../aere-device-identity, its status link): a record that names // another device is passed over; one of the key's manufacturer whose device id is not a device id at all may be meant for this key; and // a retirement by ANOTHER manufacturer the verifier trusts is not applied (a manufacturer does not retire the keys of another) and not // passed over (the same key may carry that manufacturer's credential too): both leave the registry not read as clean. if (SR === null) nejudecat('status-unread', 'status', 'status: the manufacturer\'s registry', 'not read (statusRecords was not given): a retirement of a key cannot be seen'); else for (const k of chei) { if (!k.S) continue; for (const x of SR) { const s = x.statement, idR = idScris(s.deviceId); if (idR !== null && idR !== k.S.deviceId) continue; // about another device let semnat = false; try { semnat = aceeasi(s.by, k.S.issuer) && idDin(s.by.keys) === s.by.id && D.verifyLink('status', s, x.signature, k.S.issuer.keys); } catch { semnat = false; } if (!semnat) { // not this key's manufacturer: applied by nobody here, and named when a manufacturer the verifier trusts retires the key let deAltul = false; if (incredere !== null && ob(s.by) && incredere.includes(s.by.id) && !(s.v === 1 && D.STATES.includes(s.state) && s.state !== 'retired')) { try { deAltul = idDin(s.by.keys) === s.by.id && D.verifyLink('status', s, x.signature, s.by.keys); } catch { deAltul = false; } } if (deAltul) nejudecat('status-other-manufacturer', 'status', `status: a record of another trusted manufacturer (${k.S.deviceId})`, `${s.by.id}, a manufacturer the verifier trusts and not the one of this key's credential, signed a retirement of this key or a record this verifier cannot read: not applied, and not judged (the same key may carry that manufacturer's credential too)`); continue; } if (idR === null) { nejudecat('status-unread', 'status', `status: a record of the manufacturer whose device id cannot be read (${k.S.deviceId})`, 'a record signed by the manufacturer of this key names a device id that is not a device id: it may be meant for this key, so the registry is not read as clean'); continue; } if (!(s.v === 1 && D.STATES.includes(s.state))) { nejudecat('status-unread', 'status', `status: a record of the manufacturer that cannot be read (${k.S.deviceId})`, 'a record signed by the manufacturer for this key is of a version or a state this verifier does not read: it may be a retirement'); continue; } if (s.state !== 'retired') continue; // a suspension concerns what the device may do, not what it signed (README) // a retirement whose date cannot be read holds from the start; so does one dated before 1970, before any record can be dated (the // facts carry times from 1970 only) let t = -Infinity; try { t = data(s.at, 'status at'); } catch { t = -Infinity; } if (t < 0) t = -Infinity; if (t > acum + sk) { nejudecat('status-future', 'status', `status: a retirement dated in the verifier's future (${k.S.deviceId})`, `dated ${s.at}: not in effect yet`); continue; } if (k.retras === null || t < k.retras) k.retras = t; } } // ---- 3. the records, their hashes, and what the log and the verifier say about them recs = L.records.slice(0, MAX_RECORDS); toateInreg = L.records.length; if (L.records.length > MAX_RECORDS) nejudecat('records-unread', 'log', 'log: more records than are judged', `${L.records.length} records, the first ${MAX_RECORDS} are judged: the others were not read`); if (!recs.length) nejudecat('records-missing', 'log', 'log: records', 'the log carries no operation record: nothing here says anything about the machine\'s operation'); const hashes = recs.map((r) => { try { return recordHash(r); } catch { return null; } }); // indexes, so that the work stays linear in what is judged (a lookup per record or per anchor over everything handed would not) const pozitie = new Map(); hashes.forEach((h, i) => { if (h !== null && !pozitie.has(h)) pozitie.set(h, i); }); const grupa = (lista, cheie) => { const m = new Map(); for (const x of lista) { const c = cheie(x); const l = m.get(c); if (l) l.push(x); else m.set(c, [x]); } return m; }; const citiriDe = grupa(AN, (x) => x.statementHash), blocuri = grupa(BK, (x) => x.chainId + ':' + x.block); const cheieDe = (id) => chei.find((k) => k.bun && k.S && k.S.deviceId === id) || null; // the anchors the log carries, and the verifier's readings of them: the earliest reading of the earliest anchor of a record bounds it const ancore = new Map(); // record index -> { at (ms), chainId } for (const [n, E] of (L.anchors || []).slice(0, MAX_ANCHORS).entries()) { let j = -1, plic = false; try { plic = ob(E) && E.v === 1 && E.kind === ANCHOR_KIND + '-attestation' && ob(E.statement) && E.statement.v === 1 && E.statement.kind === ANCHOR_KIND && hex0x(sha(Buffer.from(JSON.stringify(E.statement), 'utf8'))) === E.statementHash && typeof E.statement.subjectHash === 'string' && (j = pozitie.has(E.statement.subjectHash) ? pozitie.get(E.statement.subjectHash) : -1) >= 0 && E.statement.source === recs[j].statement.deviceId; } catch { plic = false; } ok('anchor-envelope', 'witness', `witness: anchor ${n + 1} commits to a record of this log`, plic /*PAZA:anchor-envelope*/, 'not an aere-proof-of-time attestation whose hash reproduces, whose subjectHash is the hash of a record of this log and whose source is that record\'s device'); if (!plic) continue; const pentru = citiriDe.get(E.statementHash) || [], citiri = pentru.filter((a) => Number.isInteger(a.at) && a.at > 0 && a.at < 1e11 && intreg(a.chainId, 1)); if (pentru.length > citiri.length) nejudecat('witness-unread', 'witness', 'witness: a reading that cannot be used', `${pentru.length - citiri.length} reading(s) of anchor ${n + 1} carry no time in whole seconds since 1970 or no chain id: not used`); if (!citiri.length) { if (E.notarization != null && !pentru.length) nejudecat('witness-unread', 'witness', `witness: anchor ${n + 1}`, 'the envelope claims a notarization that the verifier did not read on the chain (a claim is not a reading)'); continue; } const prima = citiri.reduce((x, y) => (y.at < x.at ? y : x)); const v = ancore.get(j); if (!v || prima.at * 1000 < v.at) ancore.set(j, { at: prima.at * 1000, chainId: prima.chainId }); } if ((L.anchors || []).length > MAX_ANCHORS) nejudecat('witness-unread', 'witness', 'witness: more anchors than are judged', `${L.anchors.length} anchors, the first ${MAX_ANCHORS} are judged`); // records of this machine handed from elsewhere: a sibling signed by the same key for a position of the log is an equivocation. One // signed by a key of the log for a position held by another key, or beyond the log, is judged after the walk, which knows the rotations. // Two conditions keep a proof from being made by somebody who does not hold the key: // - the log's record at that index must CARRY that position (seq): a log whose records were moved by whoever shows it puts the // device's record n+1 at index n, and its real record n handed beside it would otherwise be "another record for position n"; // - a key the manufacturer RETIRED proves nothing beside the log: whoever copied it may sign a sibling after the retirement, and nothing // here can show a record handed beside the log was signed before it. A retirement that keeps a key's history (README, Revocation) // would otherwise be undone by one record of the thief. Named (retired-key-used), not counted. let furca = Infinity, ignorate = 0, retrase = 0; const deJudecat = []; for (const o of OT) { if (!wellFormed(o)) { ignorate++; continue; } const k = cheieDe(o.statement.deviceId); if (!k || !signedBy(o, k.S.key)) { ignorate++; continue; } const q = o.statement.seq, h = recordHash(o); if (q < recs.length && h === hashes[q]) continue; // the same record if (k.retras !== null) { retrase++; continue; } if (q >= recs.length) { deJudecat.push(o); continue; } const r = recs[q]; if (wellFormed(r) && r.statement.seq === q && r.statement.deviceId === o.statement.deviceId && signedBy(r, k.S.key)) { if (!equivocations.some((p) => p.seq === q && recordHash(p.b) === h)) equivocations.push(equivocationProof(r, o, k.S.key)); furca = Math.min(furca, q); } else if (wellFormed(r) && r.statement.deviceId !== o.statement.deviceId) deJudecat.push(o); else ignorate++; // the log's own record at that position is of this key and not signed by it: the walk below says why } // ---- the walk: each record against the one before it, the key in force, the time, the witness. The log is accepted up to the first // record a rule refuses; what follows is not judged. let idx = 0, prevHash = credentialHash(L.credentials[0]), prevAt = -Infinity, prevCounter = 0, lo = null, oprit = -1; for (let i = 0; i < recs.length; i++) { const r = recs[i], et = `record ${i}`, inainte = rows.length; if (!(wellFormed(r) /*PAZA:record-form*/)) { ok('record-form', 'record', `${et}: well formed`, false, `not an ${RECORD_KIND} record of exactly { statement, signature } with v, kind, deviceId, seq, prev, at (RFC 3339 UTC), state (${STATES.join(', ')}), counter, measurement, after and next`); oprit = i; break; } const s = r.statement, k = chei[idx]; if (!k || !k.bun || !k.S) { oprit = i; break; } // the credential of the key in force was refused (the identity row says why): nothing it signed counts const t = Date.parse(s.at); ok('record-key', 'record', `${et}: signed by the key in force (${k.S.deviceId})`, s.deviceId === k.S.deviceId /*PAZA:record-key*/, `the record names ${s.deviceId}` + (idx ? ' (after a rotation)' : '')); // the signature is checked with the key the record names when that key is certified in the log, else with the key in force const kn = cheieDe(s.deviceId) || k; ok('record-signature', 'record', `${et}: signed with the device key (ML-DSA-65)`, signedBy(r, kn.S.key) /*PAZA:record-signature*/, 'the signature does not verify with the device key, is not in canonical base64, or the signature object is not exactly { alg, value }'); ok('record-seq', 'record', `${et}: carries its position`, s.seq === i /*PAZA:record-seq*/, `seq ${s.seq} at position ${i}`); ok('record-prev', 'record', `${et}: names the record before it`, s.prev === prevHash /*PAZA:record-prev*/, i ? 'prev is not the hash of the record before (a record was removed, changed or put in another order)' : 'prev of the first record is not the hash of the manufacture credential of the first key'); ok('record-time', 'record', `${et}: its time does not go back and is not ahead of the verifier's clock`, t >= prevAt && t <= acum + sk /*PAZA:record-time*/, `dated ${s.at}: the record before is dated ${Number.isFinite(prevAt) ? iso(prevAt) : 'nothing'}, the verifier's clock is ${iso(acum)} (at most ${clockSkewS} s ahead)`); ok('record-counter', 'record', `${et}: the counter does not go down`, s.counter >= prevCounter /*PAZA:record-counter*/, `counter ${s.counter} after ${prevCounter}`); ok('key-window', 'record', `${et}: inside the validity of its key's manufacture credential`, k.S.vf - sk <= t && t <= k.S.vu /*PAZA:key-window*/, `dated ${s.at}, the credential is valid from ${iso(k.S.vf)} until ${iso(k.S.vu)}`); if (k.retras !== null) ok('key-retired', 'record', `${et}: dated before the manufacturer retired its key`, t < k.retras /*PAZA:key-retired*/, `dated ${s.at}; the manufacturer retired ${k.S.deviceId} ${Number.isFinite(k.retras) ? 'at ' + iso(k.retras) : 'at a time that cannot be read (it holds from the start)'}`); if (s.next !== null) { const nk = chei[idx + 1]; let nid = null; try { nid = D.deviceIdOf(s.next.key); } catch { nid = null; } ok('rotation', 'record', `${et}: a rotation to a key the same manufacturer certified for this machine`, !!nk && !!nk.S && nid === s.next.deviceId && s.next.deviceId === nk.S.deviceId && s.next.key === nk.S.key && nk.S.deviceId !== k.S.deviceId && aceeasi(nk.S.issuer, k.S.issuer) && nk.S.model === k.S.model && nk.S.serial === k.S.serial /*PAZA:rotation*/, 'the next key is not a canonical ML-DSA-65 key of the device id named, or the next manufacture credential of the log is not of the same manufacturer, for the same model and serial, certifying that key'); } let citit = null; if (s.after !== null) { const b = blocuri.get(s.after.chainId + ':' + s.after.block) || []; if (!b.length) nejudecat('beacon-unread', 'record', `${et}: the block it names`, `block ${s.after.block} of chain ${s.after.chainId} was not read by the verifier: no lower bound from it`); else { citit = b.find((x) => x.hash === s.after.hash) || null; ok('beacon-hash', 'record', `${et}: names a block the verifier read, with its hash`, !!citit /*PAZA:beacon-hash*/, `the record names block ${s.after.block} of chain ${s.after.chainId} with a hash the verifier did not read there`); if (citit) ok('beacon-time', 'record', `${et}: not dated before the block it names existed`, t >= citit.at * 1000 - sk /*PAZA:beacon-time*/, `dated ${s.at}, the block it names is dated ${iso(citit.at * 1000)}`); } } const a = ancore.get(i); if (a) ok('witness-time', 'record', `${et}: not dated after it was already on the chain (${iso(a.at)})`, t <= a.at + sk /*PAZA:witness-time*/, `dated ${s.at}, its anchor was already notarized at ${iso(a.at)}`); if (i === furca) ok('equivocation', 'record', `${et}: no other record of its key for this position`, i !== furca /*PAZA:equivocation*/, `the same key signed another record for position ${i} (the proof is kept)`); if (rows.slice(inainte).some((x) => x.pass === false)) { oprit = i; break; } if (citit) { lo = lo === null ? citit.at * 1000 : Math.max(lo, citit.at * 1000); lanturi.beacons.add(citit.chainId); } accepted.push({ seq: i, hash: hashes[i], at: t, lo, hi: null, state: s.state, counter: s.counter, deviceId: s.deviceId, key: idx }); prevHash = hashes[i]; prevAt = t; prevCounter = s.counter; if (s.next !== null) idx++; } if (oprit >= 0 && oprit + 1 < recs.length) nejudecat('after-refusal', 'log', 'log: records after the first refused one', `${recs.length - oprit - 1} record(s) after record ${oprit} were not judged: the log is accepted up to the first record a rule refuses`); if (oprit >= 0 && !rows.some((x) => x.pass === false)) throw new Error('the walk stopped without a refusal'); // the records handed beside the log that the pre-pass left: signed by a key AFTER the log rotated away from it (the old key is still in // use), beyond the log by the key in force at its end (the log shown is not the latest), or neither (ignored) const rotitLa = new Map(accepted.filter((x) => recs[x.seq].statement.next !== null).map((x) => [x.deviceId, x.seq])); const laCapat = chei[idx] && chei[idx].S ? chei[idx].S.deviceId : null; let folosite = 0, dincolo = 0; for (const o of deJudecat) { const p = rotitLa.get(o.statement.deviceId); if (p !== undefined && o.statement.seq > p) folosite++; else if (o.statement.seq >= recs.length && o.statement.deviceId === laCapat && oprit < 0) dincolo++; else ignorate++; } if (ignorate) nejudecat('other-ignored', 'equivocation', 'equivocation: records handed beside the log', `${ignorate} record(s) handed beside the log are not well formed, not signed by a key certified in it, or stand beside a record of the log that is not of their key or does not carry its position: ignored`); if (folosite) nejudecat('rotated-key-used', 'equivocation', 'equivocation: a key used after the log rotated away from it', `${folosite} record(s) handed beside the log are signed by a key for a position after the record that rotated the log away from it: somebody still holds and uses that key`); if (retrase) nejudecat('retired-key-used', 'equivocation', 'equivocation: records of a retired key beside the log', `${retrase} record(s) handed beside the log, other than the log's own, are signed by a key the manufacturer retired, and nothing shows they were signed before the retirement: whoever copied the key may have signed them after it, so they are neither a proof of equivocation nor a longer log`); if (dincolo) nejudecat('records-beyond', 'equivocation', 'equivocation: records beyond the log shown', `${dincolo} record(s) signed by the key in force at the end of this log stand at a position after its last record: the log shown is not the latest the verifier holds, so judge the longer one`); // the upper bound of each accepted record: the earliest anchor read at it or after it (a later record names it through prev) let hi = null; for (let j = accepted.length - 1; j >= 0; j--) { const a = ancore.get(accepted[j].seq); if (a) { hi = hi === null ? a.at : Math.min(hi, a.at); lanturi.anchors.add(a.chainId); } accepted[j].hi = hi; } // what counts: an accepted record; when the manufacturer retired its key, only if a witness shows it existed before the retirement counted = accepted.filter((x) => { const T = chei[x.key].retras; return T === null || (x.hi !== null && x.hi <= T); }); const nu = accepted.length - counted.length; if (nu) nejudecat('retired-unwitnessed', 'status', 'status: records of a retired key with no witness before the retirement', `${nu} record(s) are dated before the manufacturer retired their key, and no anchor read shows they existed before it: a copied key can date a record as it likes, so they are not counted`); const farSus = counted.filter((x) => x.hi === null).length, farJos = counted.filter((x) => x.lo === null).length; if (farSus || farJos) nejudecat('time-unwitnessed', 'witness', 'witness: time', `${farSus} of ${counted.length} counted record(s) have no witnessed upper bound (no anchor read at or after them) and ${farJos} no witnessed lower bound (no block read at or before them): there, the time is the device's word`); numarate = counted; return gata(); } }