aere-quantum/device-identity/device.mjs

604 lines
66 KiB
JavaScript

// AERE Device Identity (roadmap 31, 2026-10-05): what a machine is, said by a chain of signed statements that a stranger checks
// offline. Node 24 only (node:crypto: Ed25519 and ML-DSA-65, FIPS 204); the hybrid identities are those of ../aere-identity.
//
// THE CHAIN, each link signed and bound to the one before it:
// 1. manufacture the manufacturer (a hybrid identity, Ed25519 + ML-DSA-65, both required) issues a credential of type
// DeviceManufacture: it made the device whose key is K (ML-DSA-65), of this model, with this serial. The device id
// is derived from K. The credential can name a status list of the manufacturer (a recall).
// 2. firmware the manufacturer signs a release: model, version, a sequence number that only grows, the sha256 of the image,
// optionally the hash of a Proof of Software attestation of that image. It also signs a short-lived firmware
// policy: the lowest sequence still allowed and the digests it retracted. A release below the floor or
// retracted is refused.
// 3. ownership the manufacturer is the first owner. Ownership moves by transfers, each signed by the owner before it and
// naming the hash of the link before it (the manufacture credential, then the previous transfer). The current
// owner signs an authorization: what the device may do, until when, under which ownership head.
// 4. status a registry of signed records (active, suspended, retired). The manufacturer's records always count, and its
// retirement is never lifted. The current owner's retirement and suspension count whatever ownership head
// they name (also none) and hold while it owns the device; its "active" counts only under the current head.
// The records of a PREVIOUS owner do not bind the owner of now (a seller can sign, after the sale, a
// retirement dated before it, and without a time witness the two cannot be told apart): they are named, with
// the state they say, and the verdict stands. A suspension is lifted only by a later record of the one who
// suspended; "active" is believed only while its record is current. A registry that was not read leaves the
// verdict PARTIAL.
// 5. attestation the device signs with K a fresh statement: the verifier's nonce and audience, its clock, the digest and the
// sequence of the firmware it runs, the ownership head it knows, the action it asks for. The attestation has an
// AIP-23 envelope (kind `device`) that can be notarized; a reading of that notarization under a post-quantum
// certified anchor bounds the attestation's time from above with a clock nobody in this chain chose.
//
// THE VERDICT. verifyDevice makes every check it can reach and names every rule that refused (REGULI; each check carries a PAZA
// marker). INVALID: a rule refused. PARTIAL: nothing refused, and something was not brought or not read (a link missing from the
// bundle, the verifier's nonce, the manufacturers it trusts, a firmware policy, the status registry, a notarization nobody read):
// the causes are named (NEJUDECATE; those in PARTIALE keep the verdict from VALID, those in NOTATE are named and the verdict
// stands). VALID: every link was brought and judged.
//
// WHAT IT IS NOT. K is a software key. Nothing here binds K to hardware: no TPM, no secure element, no TEE. Whoever can read the
// device's storage can copy K, and a compromised device signs whatever it is told, including the digest of a firmware it does not run.
// The field `hardwareRoot` exists in the manufacture claims and in the attestation, is null in everything this module produces,
// and a value in it is reported as not judged (the verdict is then PARTIAL): it is where an endorsement of K by a hardware root and
// a quote over the measured boot would be carried. See README.md.
import crypto from 'node:crypto';
import { canonical, publicKeyObjects, idOf, issueCredential, credentialHash, verifyText, statusBit, ALG as ALG_HIBRID } from '../identity/identity.mjs';
export const VERSION = 1;
export const ALG_DEVICE = 'ml-dsa-65';
export const TYPE = 'DeviceManufacture';
export const STATES = ['active', 'suspended', 'retired'];
export const MAX_TRANSFERS = 64;
export const CLOCK_SKEW_S = 60;
// How many of the things a verifier hands are judged. Beyond the bound the rest is NOT read, and the verdict says so (PARTIAL): a
// registry padded with records cannot make the verifier work without end, nor push a retirement out of sight in silence.
export const MAX_STATUS_RECORDS = 512, MAX_FIRMWARE_POLICIES = 64, MAX_STATUS_LISTS = 64, MAX_READINGS = 64;
const DOMENIU = 'aere-device/v1/';
const SCOPURI = new Set(['firmware', 'firmware-policy', 'transfer', 'authorization', 'status', 'attestation']);
const H32 = /^0x[0-9a-f]{64}$/;
const DIGEST_SCRIS = /^0x[0-9a-f]{64}$/i; // a digest as somebody's tooling may have written it: the same digest in upper case
const DEV = /^aere-device:[0-9a-f]{40}$/;
const DEV_SCRIS = /^aere-device:([0-9a-fA-F]{40})$/; // a device id as somebody's tooling may have written it in a status record: its hex in upper case
/** The device id a status record names, in its one text; null when what is written there is not a device id at all. */
const idScris = (x) => { const m = typeof x === 'string' ? DEV_SCRIS.exec(x) : null; return m ? 'aere-device:' + m[1].toLowerCase() : null; };
const NUME = /^[a-z][a-z0-9_.:-]{0,63}$/;
const DATA = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const AERE_ID = /^aere-id:[0-9a-f]{40}$/;
const sha = (b) => crypto.createHash('sha256').update(b).digest();
const hex0x = (b) => '0x' + b.toString('hex');
const hashOf = (o) => hex0x(sha(Buffer.from(canonical(o), 'utf8')));
const ob = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
const sir = (x) => typeof x === 'string' && x.length > 0 && x.length <= 256;
const iso = (d) => new Date(d).toISOString();
function data(s, ce) { if (typeof s !== 'string' || !DATA.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`aere-device: ${ce} is not an RFC 3339 UTC time`); return Date.parse(s); }
// `a` is an identity that a form check, or the signature check of a record, already required to be an object; `b` may be absent (a
// chain that keeps no owner)
const aceeasi = (a, b) => ob(b) && a.id === b.id && canonical(a.keys) === canonical(b.keys);
/** The id of a set of public keys, or NaN when they are not keys: NaN equals nothing, so an id written beside keys that derive none is never "the id of its keys". */
const idDin = (keys) => { try { return idOf(keys); } catch { return NaN; } };
/** A signature is carried in canonical base64: its bytes, encoded again, are the text given. One signature has one text, so one link has one hash. */
const b64canonic = (s) => typeof s === 'string' && Buffer.from(s, 'base64').toString('base64') === s;
const hibridCanonic = (sig) => b64canonic(sig.ed25519) && b64canonic(sig.mldsa65);
// The hash of a link covers the whole { statement, signature }. The statement is under the signature; the two objects around it are
// not. So a link is EXACTLY { statement, signature } and a signature exactly its members: one member more, added by anybody, would be
// another text, and another hash, for the same signed statement.
const numai = (o, chei) => ob(o) && Object.keys(o).every((k) => chei.includes(k));
const LEGATURA = ['statement', 'signature'], SEMN_HIBRID = ['alg', 'ed25519', 'mldsa65'], SEMN_DISPOZITIV = ['alg', 'value'];
/** The hash of a signed link ({ statement, signature }): what the next link names. */
export const linkHash = (l) => hashOf(l);
/**
* Text that came with a bundle (a model, an id, a date) and is about to be shown to a person: every C0 and C1 control character, DEL,
* the Unicode line and paragraph separators, the bidirectional controls and the byte order mark are replaced by a visible form
* (\xNN, \uNNNN), and a backslash is doubled so that the form cannot be forged. What is printed stays on its line, and a terminal
* executes nothing a bundle wrote. The rows of verifyDevice carry the text as it came: print them through this.
*/
// the code points replaced: C0, DEL and C1, the two marks of direction, the line and paragraph separators with the bidirectional
// embeddings and overrides after them, the bidirectional isolates, the byte order mark
const DE_ARATAT = [[0x00, 0x1f], [0x7f, 0x9f], [0x200e, 0x200f], [0x2028, 0x202e], [0x2066, 0x2069], [0xfeff, 0xfeff]];
export function visible(s) {
let out = '';
for (const c of String(s)) { const n = c.codePointAt(0);
if (n === 0x5c) out += '\\\\';
else if (DE_ARATAT.some(([de, la]) => n >= de && n <= la)) { const h = n.toString(16); out += h.length <= 2 ? '\\x' + h.padStart(2, '0') : '\\u' + h.padStart(4, '0'); }
else out += c; }
return out;
}
// ---------------------------------------------------------------- the device key K (ML-DSA-65 alone)
function cheiaDispozitivului(b64) {
// a text that is not the canonical base64 of an ML-DSA-65 SPKI does not pass the three lines below: no check of its alphabet is needed before them
const k = crypto.createPublicKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'spki' });
if (k.asymmetricKeyType !== ALG_DEVICE) throw new Error(`aere-device: the device key is a ${k.asymmetricKeyType} key, not ${ALG_DEVICE}`);
if (k.export({ type: 'spki', format: 'der' }).toString('base64') !== b64) throw new Error('aere-device: the device key is not in its canonical form');
return k;
}
/** The device id, derived from K: nobody can claim an id with another key. Throws when K is not a canonical ML-DSA-65 SPKI. */
export function deviceIdOf(b64) { cheiaDispozitivului(b64); return 'aere-device:' + sha(Buffer.from(b64, 'base64')).toString('hex').slice(0, 40); }
function dinCheie(priv) {
const publicKey = crypto.createPublicKey(priv).export({ type: 'spki', format: 'der' }).toString('base64');
const d = { deviceId: deviceIdOf(publicKey), publicKey };
Object.defineProperty(d, 'privat', { value: priv, enumerable: false });
return d;
}
/** A new device key. The private half is not enumerable (it does not leave through a JSON.stringify by mistake). */
export function generateDeviceKey() { return dinCheie(crypto.generateKeyPairSync(ALG_DEVICE).privateKey); }
/** The file form of a device key (private half included: written 0600, never beside what is published). */
export function exportDeviceKey(d) {
if (!d || !d.privat) throw new Error('aere-device: this is not a device private key');
return { v: VERSION, kind: 'aere-device-key', deviceId: d.deviceId, publicKey: d.publicKey, private: d.privat.export({ type: 'pkcs8', format: 'der' }).toString('base64') };
}
export function importDeviceKey(j) {
if (!j || j.kind !== 'aere-device-key' || j.v !== VERSION || typeof j.private !== 'string') throw new Error('aere-device: not an aere-device-key file');
const priv = crypto.createPrivateKey({ key: Buffer.from(j.private, 'base64'), format: 'der', type: 'pkcs8' });
if (priv.asymmetricKeyType !== ALG_DEVICE) throw new Error('aere-device: the private key is not ml-dsa-65');
const d = dinCheie(priv);
if (d.publicKey !== j.publicKey || d.deviceId !== j.deviceId) throw new Error('aere-device: the public key or the id in the file are not those of its private key');
return d;
}
// ---------------------------------------------------------------- signatures, with purpose separation
function mesaj(scop, text) { if (!SCOPURI.has(scop)) throw new Error('aere-device: unknown signing purpose ' + scop); return Buffer.from(DOMENIU + scop + '\n' + text, 'utf8'); }
/** Hybrid signature of an identity (manufacturer, owner): Ed25519 and ML-DSA-65 over the same message, both required. */
function semneaza(scop, statement, keys) {
if (!keys || !keys.privat) throw new Error('aere-device: signing needs private keys');
const m = mesaj(scop, canonical(statement));
return { alg: ALG_HIBRID, ed25519: crypto.sign(null, m, keys.privat.ed25519).toString('base64'), mldsa65: crypto.sign(null, m, keys.privat.mldsa65).toString('base64') };
}
function verifica(scop, statement, sig, pub) {
try {
if (sig.alg !== ALG_HIBRID) return false; // a signature that is absent or is not an object throws here, and is no signature
if (!hibridCanonic(sig)) return false; // the same bytes in another base64 text are not this signature
if (!numai(sig, SEMN_HIBRID)) return false; // nor is the same signature with a member more beside it
const k = publicKeyObjects(pub); const m = mesaj(scop, canonical(statement));
return crypto.verify(null, m, k.ed25519, Buffer.from(sig.ed25519, 'base64')) && crypto.verify(null, m, k.mldsa65, Buffer.from(sig.mldsa65, 'base64'));
} catch { return false; }
}
const cine = (keys) => ({ id: keys.id, keys: keys.public });
/** The signing formulas, exported: a link is { statement, signature } with signature = signLink(purpose, statement, keys). */
export const signLink = semneaza;
export const verifyLink = verifica;
/** The device's signature over an attestation statement: ML-DSA-65 with K, purpose `attestation`. */
export function signAttestation(statement, device) {
if (!device || !device.privat) throw new Error('aere-device: attesting needs the device private key');
return { alg: ALG_DEVICE, value: crypto.sign(null, mesaj('attestation', canonical(statement)), device.privat).toString('base64') };
}
// ---------------------------------------------------------------- producers
/** Link 1. The manufacturer's credential for a device key. `status`: { list, index } in a status list of the manufacturer. */
export function manufacture({ manufacturer, deviceKey, model, serial, validFrom, validUntil, status = null, now = new Date(), id = null }) {
if (!sir(model) || !sir(serial)) throw new Error('aere-device: model and serial are required');
const { credential } = issueCredential({ issuer: manufacturer, holder: manufacturer.public, type: TYPE, disclosable: [], validFrom, validUntil, status, now, id,
claims: { deviceId: deviceIdOf(deviceKey), deviceKey, model, serial, hardwareRoot: null } });
return credential;
}
/** Link 2. A firmware release, signed by the manufacturer. Give `image` (bytes) or `digest`; `softwareAttestation` is a Proof of Software attestation of the image. */
export function releaseFirmware({ signer, model, version, sequence, image = null, digest = null, softwareAttestation = null, now = new Date() }) {
const dg = image != null ? hex0x(sha(Buffer.from(image))) : String(digest).toLowerCase();
if (!sir(model) || !sir(version) || !Number.isInteger(sequence) || sequence < 0 || !H32.test(dg)) throw new Error('aere-device: a release needs model, version, a sequence (integer >= 0) and the image or its digest');
if (softwareAttestation != null && !softwareCovers(softwareAttestation, dg)) throw new Error('aere-device: the Proof of Software attestation does not cover this image');
const statement = { v: VERSION, kind: 'aere-device-firmware', model, version, sequence, digest: dg, signer: cine(signer), issuedAt: iso(now),
...(softwareAttestation != null ? { softwareAttestationHash: softwareAttestation.statementHash } : {}) };
return { statement, signature: semneaza('firmware', statement, signer) };
}
/** Does a Proof of Software attestation hash to its statementHash and list an artifact with this digest? */
export function softwareCovers(att, digest) {
try {
// anything that is not such an attestation throws on the way and is not one (the catch below)
return att.statement.kind === 'aere-proof-of-software' && hex0x(sha(Buffer.from(JSON.stringify(att.statement), 'utf8'))) === att.statementHash
&& att.statement.artifacts.some((a) => String(Object(a).sha256).toLowerCase() === digest);
} catch { return false; }
}
/** The manufacturer's firmware policy for a model: the lowest sequence still allowed and the digests retracted. Short-lived on purpose. */
export function firmwarePolicy({ manufacturer, model, minSequence = 0, retracted = [], validFrom, validUntil, now = new Date() }) {
if (!sir(model) || !Number.isInteger(minSequence) || minSequence < 0) throw new Error('aere-device: a firmware policy needs a model and minSequence (integer >= 0)');
const r = [...new Set(retracted.map((x) => String(x).toLowerCase()))].sort(); if (r.some((x) => !H32.test(x))) throw new Error('aere-device: retracted is a list of digests');
const vf = validFrom || iso(now); if (data(vf, 'validFrom') >= data(validUntil, 'validUntil')) throw new Error('aere-device: validFrom must be before validUntil');
const statement = { v: VERSION, kind: 'aere-device-firmware-policy', model, minSequence, retracted: r, issuer: cine(manufacturer), validFrom: vf, validUntil, issuedAt: iso(now) };
return { statement, signature: semneaza('firmware-policy', statement, manufacturer) };
}
/** The ownership head: the hash of the last transfer, or of the manufacture credential when the device was never transferred. */
export function ownershipHead(manufactureCredential, transfers = []) { return transfers.length ? linkHash(transfers[transfers.length - 1]) : credentialHash(manufactureCredential); }
/** Link 3a. A transfer of ownership, signed by the owner before it. `transfers` is the chain so far. */
export function transferOwnership({ from, to, manufacture: m, transfers = [], now = new Date() }) {
publicKeyObjects(to);
const statement = { v: VERSION, kind: 'aere-device-transfer', deviceId: m.statement.claims.deviceId, seq: transfers.length + 1, prev: ownershipHead(m, transfers),
from: cine(from), to: { id: idOf(to), keys: to }, at: iso(now) };
return { statement, signature: semneaza('transfer', statement, from) };
}
/** Link 3b. What the device may do, until when, said by its owner under an ownership head. */
export function authorize({ owner, deviceId, ownership, actions, notBefore, notAfter, now = new Date() }) {
if (!DEV.test(String(deviceId)) || !H32.test(String(ownership))) throw new Error('aere-device: an authorization names a device id and an ownership head');
if (!Array.isArray(actions) || !actions.length || actions.some((a) => typeof a !== 'string' || !NUME.test(a))) throw new Error('aere-device: actions is a list of action names (lower case)');
const nb = notBefore || iso(now); if (data(nb, 'notBefore') >= data(notAfter, 'notAfter')) throw new Error('aere-device: notBefore must be before notAfter');
const statement = { v: VERSION, kind: 'aere-device-authorization', deviceId, ownership, owner: cine(owner), actions: [...new Set(actions)].sort(), notBefore: nb, notAfter, issuedAt: iso(now) };
return { statement, signature: semneaza('authorization', statement, owner) };
}
/**
* Link 4. A status record. There is no default for whose record it is:
* the manufacturer signs with `manufacture` (its own manufacture credential of the device) and names no ownership head;
* anybody else names the ownership head it signs under (`ownership`, from ownershipHead) and the `deviceId`.
*/
export function statusRecord({ by, deviceId = null, state, seq, ownership, manufacture: m = null, validUntil, reason = null, now = new Date() }) {
if (!by || !by.privat) throw new Error('aere-device: a status record needs the private keys of who signs it');
const eProducator = m != null;
if (eProducator) {
if (!ob(m.statement) || !ob(m.statement.claims) || !aceeasi(cine(by), m.statement.issuer)) throw new Error('aere-device: `manufacture` is given by the manufacturer of that credential when it signs a status record; an owner names `ownership` instead');
if (ownership != null) throw new Error('aere-device: a status record of the manufacturer names no ownership head');
if (deviceId != null && deviceId !== m.statement.claims.deviceId) throw new Error('aere-device: the device id given is not the one of the manufacture credential');
deviceId = m.statement.claims.deviceId;
} else if (ownership == null) throw new Error('aere-device: a status record of anyone but the manufacturer names the ownership head it is signed under (`ownership`); the manufacturer signs with `manufacture` instead');
const cap = eProducator || ownership == null ? null : ownership;
if (cap !== null && !H32.test(String(cap))) throw new Error('aere-device: ownership is an ownership head (0x and 64 hex digits)');
if (!DEV.test(String(deviceId)) || !STATES.includes(state) || !Number.isInteger(seq) || seq < 0) throw new Error('aere-device: a status record needs a device id, a state (' + STATES.join(', ') + ') and a seq');
const at = iso(now); if (data(at, 'at') >= data(validUntil, 'validUntil')) throw new Error('aere-device: validUntil must be after the record');
const statement = { v: VERSION, kind: 'aere-device-status', deviceId, state, seq, ownership: cap, by: cine(by), at, validUntil, ...(reason ? { reason: String(reason) } : {}) };
return { statement, signature: semneaza('status', statement, by) };
}
/** Link 5. The device's fresh statement, signed with K. `firmware`: a release, or { digest, sequence } as the device measured it. */
export function attest({ device, nonce, audience, firmware, ownership, action, now = new Date() }) {
if (!device || !device.privat) throw new Error('aere-device: attesting needs the device private key');
if (!sir(nonce) || !sir(audience)) throw new Error('aere-device: an attestation needs the verifier\'s nonce and audience');
const f = firmware && firmware.statement ? firmware.statement : firmware;
if (!f || !H32.test(String(f.digest)) || !Number.isInteger(f.sequence)) throw new Error('aere-device: an attestation names the digest and the sequence of the firmware that runs');
if (!H32.test(String(ownership)) || typeof action !== 'string' || !NUME.test(action)) throw new Error('aere-device: an attestation names an ownership head and an action');
const statement = { v: VERSION, kind: 'aere-device-attestation', deviceId: device.deviceId, key: device.publicKey, audience, nonce, at: iso(now),
firmware: { digest: f.digest, sequence: f.sequence }, ownership, action, hardwareRoot: null };
return { statement, signature: signAttestation(statement, device) };
}
/** The bundle a verifier is handed: the links that exist, nothing else. */
export function bundle({ manufacture: m = null, firmware = null, transfers = [], authorization = null, attestation = null, anchor = null } = {}) {
return { v: VERSION, kind: 'aere-device-bundle', ...(m ? { manufacture: m } : {}), ...(firmware ? { firmware } : {}), transfers,
...(authorization ? { authorization } : {}), ...(attestation ? { attestation } : {}), ...(anchor ? { anchor } : {}) };
}
/** The hash an AIP-23 `device` envelope commits to: sha256 of the canonical text of the signed attestation. */
export const attestationHash = (attestation) => hashOf(attestation);
/**
* The AIP-23 envelope (kind `device`) of an attestation. `buildProof` is the one of ../proof-kinds, given by the caller. The envelope
* commits to the signed attestation by hash; nothing of the attestation but the device id and the hash of K goes into it.
*/
export function proofOfDevice(attestation, buildProof) {
const Q = attestation.statement;
return buildProof('device', { deviceId: Q.deviceId, attestation: canonical(attestation), publicKey: Q.key, posture: 'firmware-sequence-' + Q.firmware.sequence, createdAt: Q.at });
}
// ---------------------------------------------------------------- the verifier
// Every refusal names its rule. REGULI is the list the verifier can name (its helper throws on any other); every check of a rule
// carries a PAZA marker with the rule id. A row is { link, name, pass, detail, rule }; a row that was not judged is
// { link, name, pass: null, detail, unjudged: <cause> } with the cause from NEJUDECATE. OPRIRI are the rules after whose refusal
// other checks were NOT made (the verifier returned, or dropped the link).
export const REGULI = ['form',
'manufacture-form', 'manufacturer-id', 'manufacture-holder', 'manufacture-signature', 'manufacturer-trusted', 'manufacture-window', 'manufacture-status-list', 'manufacture-revoked', 'device-key', 'device-id',
'firmware-form', 'firmware-signer', 'firmware-signature', 'firmware-model', 'firmware-retracted', 'firmware-rollback', 'firmware-software',
'chain-length', 'transfer-form', 'transfer-device', 'transfer-ids', 'transfer-from', 'transfer-prev', 'transfer-signature', 'transfer-time',
'authorization-form', 'authorization-device', 'authorization-owner', 'authorization-ownership', 'authorization-signature', 'authorization-window', 'authorization-action',
'status-retired', 'status-suspended',
'attestation-form', 'attestation-key', 'attestation-device', 'attestation-signature', 'audience', 'nonce', 'freshness', 'attestation-firmware', 'attestation-ownership',
'witness-envelope', 'witness-time', 'unreadable'];
export const NEJUDECATE = ['manufacture-missing', 'manufacturer-trust', 'manufacture-status-none', 'manufacture-status-unread', 'firmware-missing', 'firmware-policy-unread', 'software-unread',
'ownership-unrooted', 'authorization-missing', 'status-unread', 'status-other-manufacturer', 'status-ignored', 'status-prior-ownership', 'status-future', 'attestation-missing', 'audience', 'nonce', 'hardware-root', 'witness-unread'];
/**
* The causes that are NAMED (in `unjudged` and in a row) and do not keep the verdict from VALID: a credential that names no status
* list cannot be recalled through one; a status record that is not the manufacturer's or the current owner's is ignored; a status
* record of a previous owner does not bind the owner of now; a status record dated in the verifier's future is not in effect yet.
*/
export const NOTATE = ['manufacture-status-none', 'status-ignored', 'status-prior-ownership', 'status-future'];
/** The causes that keep the verdict from VALID: every other one. */
export const PARTIALE = NEJUDECATE.filter((c) => !NOTATE.includes(c));
export const OPRIRI = ['form', 'manufacture-form', 'firmware-form', 'chain-length', 'transfer-form', 'authorization-form', 'attestation-form', 'unreadable'];
export const LINKS = ['manufacture', 'firmware', 'ownership', 'authorization', 'status', 'attestation', 'witness'];
class Nedeclarata extends Error {}
/**
* Verifies a device bundle with what the VERIFIER brings: its clock (`now`), its `nonce` and `audience`, the manufacturers it trusts
* (ids or public keys), the manufacturer's status lists (identity status lists), the firmware policies and the status records it
* read, a Proof of Software attestation of the firmware, and `anchors`: readings { statementHash, at (unix seconds) } it made itself
* of the notarized envelope (the AIP-23 reference verifier, under a post-quantum certified anchor).
* Returns { verdict, valid, rows, rules, unjudged, links, subject }: `valid` is "no rule refused"; `verdict` is VALID only when
* nothing was left out either. `subject` (who, what, whose, the state, what the records of previous owners say, and the witness
* reading used) is returned only when no rule refused.
* The attestation must not be dated ahead of the verifier's clock by more than `clockSkewS`, nor be older than `maxAgeS`.
* A Proof of Software attestation is checked for being the one the release names and for listing the image digest; its own
* signature and its SBOM are judged by the Proof of Software verifier, not here.
*/
export function verifyDevice(b, { now = new Date(), nonce = null, audience = null, trustedManufacturers = null, statusLists = [], firmwarePolicies = [], statusRecords = [], softwareAttestation = null, anchors = [], maxAgeS = 300, clockSkewS = CLOCK_SKEW_S } = {}) {
const rows = [];
const ok = (rule, link, name, pass, detail = '') => { if (!REGULI.includes(rule)) throw new Nedeclarata('aere-device: undeclared rule ' + rule); rows.push({ link, name, pass: !!pass, detail: pass ? '' : detail, rule }); return !!pass; };
const nejudecat = (cauza, link, name, detail) => { if (!NEJUDECATE.includes(cauza)) throw new Nedeclarata('aere-device: undeclared cause ' + cauza); rows.push({ link, name, pass: null, detail, unjudged: cauza }); };
// the verifier's own configuration is validated first (a mistake of the verifier is not an invalid bundle)
const acum = new Date(now).getTime(); if (Number.isNaN(acum)) throw new Error('verifyDevice: now is not a time');
if (!Number.isInteger(clockSkewS) || clockSkewS < 0 || clockSkewS > 600) throw new Error('verifyDevice: clockSkewS is 0..600 seconds');
if (!Number.isInteger(maxAgeS) || maxAgeS < 1) throw new Error('verifyDevice: maxAgeS is a positive number of seconds');
if (trustedManufacturers != null && !Array.isArray(trustedManufacturers)) throw new Error('verifyDevice: trustedManufacturers is a list of ids or public keys');
const incredere = trustedManufacturers == null ? null : trustedManufacturers.map((x) => { const id = typeof x === 'string' ? x : idDin(x);
if (!AERE_ID.test(String(id))) throw new Error('verifyDevice: a trusted manufacturer is given by its id (aere-id: and 40 hex digits) or by its public keys'); return id; });
if (![statusLists, firmwarePolicies, statusRecords, anchors].every(Array.isArray)) throw new Error('verifyDevice: statusLists, firmwarePolicies, statusRecords and anchors are lists');
const sk = clockSkewS * 1000;
let subject = null;
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 links = Object.fromEntries(LINKS.map((l) => { const a = rows.filter((r) => r.link === l);
return [l, a.some((r) => r.pass === false) ? 'FAILED' : a.some((r) => /-missing$/.test(r.unjudged)) ? 'NOT_SUPPLIED' : a.some((r) => PARTIALE.includes(r.unjudged)) ? 'NOT_JUDGED' : a.length ? 'PASSED' : 'ABSENT']; })); // rows, none refused and none left unjudged: PASSED (a note never stands alone in a link)
const verdict = rules.length ? 'INVALID' : unjudged.some((c) => PARTIALE.includes(c)) ? 'PARTIAL' : 'VALID';
return { verdict, valid: !rules.length, rows, rules, unjudged, links, subject: rules.length ? null : subject };
};
try {
if (!((ob(b) && b.kind === 'aere-device-bundle' && b.v === VERSION && (b.transfers === undefined || Array.isArray(b.transfers))) /*PAZA:form*/)) { ok('form', 'bundle', 'bundle: well formed', false, 'not an aere-device-bundle'); return gata(); }
// ---- what the verifier handed: bounded, and nothing in it is skipped in silence. An element that is not the object read here (a
// list inside the list, a registry object { records: [...] }, a file of another kind, null) holds things that were NOT read: it is
// named with the cause of its kind and the verdict is not VALID. So is what lies beyond the bound on how many are judged.
const adus = (lista, max, cauza, link, ce, eBun) => {
if (lista.length > max) nejudecat(cauza, link, `${link}: 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, link, `${link}: 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 (a list inside the list, a registry object, another kind of file): nothing in them was read`);
return bune;
};
const deFel = (kind) => (x) => ob(x) && ob(x.statement) && x.statement.kind === kind;
const SL = adus(statusLists, MAX_STATUS_LISTS, 'manufacture-status-unread', 'manufacture', 'recall list', deFel('aere-status-list'));
const FP = adus(firmwarePolicies, MAX_FIRMWARE_POLICIES, 'firmware-policy-unread', 'firmware', 'firmware policy', deFel('aere-device-firmware-policy'));
const SR = adus(statusRecords, MAX_STATUS_RECORDS, 'status-unread', 'status', 'status record', deFel('aere-device-status'));
const AN = adus(anchors, MAX_READINGS, 'witness-unread', 'witness', 'reading', (a) => ob(a) && typeof a.statementHash === 'string');
// ---- 1. manufacture: who made it, with which key
const M = b.manufacture; let S = null, cheieM = null; // S: the usable manufacture statement; cheieM: the device id derived from the key it names
if (M == null) nejudecat('manufacture-missing', 'manufacture', 'manufacture: credential', 'the bundle brings no manufacture credential: nothing says who made this device or which key is its key');
else {
const s = M.statement;
if (!((ob(s) && s.kind === 'aere-credential' && s.v === 1 && s.type === TYPE && ob(s.issuer) && ob(s.holder) && Array.isArray(s.sd) && s.sd.length === 0 && ob(s.claims)
&& ['deviceId', 'model', 'serial'].every((k) => sir(s.claims[k])) && typeof s.claims.deviceKey === 'string') /*PAZA:manufacture-form*/)) ok('manufacture-form', 'manufacture', 'manufacture: well formed', false, `not a ${TYPE} credential with deviceId, deviceKey, model and serial in the clear`);
else {
S = s;
ok('manufacturer-id', 'manufacture', 'manufacture: the manufacturer id is the id of its keys', idDin(S.issuer.keys) === S.issuer.id /*PAZA:manufacturer-id*/, `manufacturer id ${S.issuer.id} is not derived from the keys in the credential`);
ok('manufacture-holder', 'manufacture', 'manufacture: held by the manufacturer (the first owner)', aceeasi(S.holder, S.issuer) /*PAZA:manufacture-holder*/, 'the credential is held by somebody else: ownership leaves the manufacturer only by a signed transfer');
ok('manufacture-signature', 'manufacture', `manufacture: signed by ${String(S.issuer.id)} (Ed25519 and ML-DSA-65, both)`, numai(M, LEGATURA) && numai(M.signature, SEMN_HIBRID) && hibridCanonic(M.signature) && verifyText('credential', canonical(S), M.signature, S.issuer.keys) /*PAZA:manufacture-signature*/, 'the hybrid signature does not verify with the manufacturer\'s keys, or is not in canonical base64');
if (incredere == null) nejudecat('manufacturer-trust', 'manufacture', 'manufacture: manufacturer trusted', 'not judged: anyone can issue a manufacture credential with their own keys; pass trustedManufacturers to require who made the device');
else ok('manufacturer-trusted', 'manufacture', 'manufacture: manufacturer trusted', incredere.includes(S.issuer.id) /*PAZA:manufacturer-trusted*/, `${S.issuer.id} is not among the ${incredere.length} trusted manufacturer(s)`);
const vf = data(S.validFrom, 'validFrom'), vu = data(S.validUntil, 'validUntil');
ok('manufacture-window', 'manufacture', `manufacture: valid at ${iso(acum)}`, vf - sk <= acum && acum <= vu /*PAZA:manufacture-window*/, `valid from ${S.validFrom} until ${S.validUntil}`);
if (!S.status) nejudecat('manufacture-status-none', 'manufacture', 'manufacture: recall', 'the credential names no status list, so the manufacturer cannot recall it through one');
else {
const ale = SL.filter((l) => { try { return l.statement.id === S.status.list && aceeasi(l.statement.issuer, S.issuer) && verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch { return false; } });
// a list of the manufacturer whose validity is not a time cannot be placed in time: it is not read as "not recalled", also beside a current one
const curente = []; let listeNecitite = 0;
for (const l of ale) { let inV = null; try { inV = data(l.statement.validFrom, 'status validFrom') - sk <= acum && acum <= data(l.statement.validUntil, 'status validUntil'); } catch { /* not a time: counted below */ }
if (inV === null) listeNecitite++; else if (inV) curente.push(l); }
if (listeNecitite) nejudecat('manufacture-status-unread', 'manufacture', 'manufacture: a recall list that cannot be read', `${listeNecitite} status list(s) signed by the manufacturer under this id carry a validity that is not a time: what they recall cannot be seen`);
if (!curente.length) { if (!listeNecitite) nejudecat('manufacture-status-unread', 'manufacture', `manufacture: recall list ${S.status.list}`, ale.length ? `the manufacturer's status list(s) given are not valid at ${iso(acum)}: fetch a current one` : 'no status list of the manufacturer with this id was handed to the verifier; a recall it was not handed cannot be seen'); }
else {
let rev = false, citite = 0;
for (const L of curente) { try { rev = statusBit(L, S.status.index) || rev; citite++; } catch (e) { if (!(false /*PAZA:manufacture-status-list*/)) ok('manufacture-status-list', 'manufacture', `manufacture: recall list ${S.status.list}`, false, String(e.message)); } }
if (citite) ok('manufacture-revoked', 'manufacture', `manufacture: not recalled (status list ${S.status.list}, index ${S.status.index})`, !rev /*PAZA:manufacture-revoked*/, 'the manufacturer recalled this manufacture credential');
}
}
try { cheieM = deviceIdOf(S.claims.deviceKey); } catch { /* the row says */ }
ok('device-key', 'manufacture', 'manufacture: the device key is an ML-DSA-65 key', cheieM !== null /*PAZA:device-key*/, 'the device key named by the manufacturer is not a canonical ML-DSA-65 public key (a classical key is not a post-quantum one because of the field it sits in)');
if (cheieM !== null) ok('device-id', 'manufacture', 'manufacture: the device id is the id of the device key', cheieM === S.claims.deviceId /*PAZA:device-id*/, `device id ${S.claims.deviceId} is not derived from the device key`);
if (S.claims.hardwareRoot != null) nejudecat('hardware-root', 'manufacture', 'manufacture: hardware root', 'the credential carries a hardware root; this verifier does not judge one, so nothing here says the key lives in hardware');
}
}
// ---- 2. firmware: which bytes, which version, signed by whom, still allowed
const F = b.firmware; let R = null;
if (F == null) nejudecat('firmware-missing', 'firmware', 'firmware: release', 'the bundle brings no firmware release: the digest the device attests names nothing a manufacturer signed');
else {
const r = F.statement;
if (!((ob(r) && r.kind === 'aere-device-firmware' && r.v === VERSION && sir(r.model) && sir(r.version) && Number.isInteger(r.sequence) && r.sequence >= 0 && typeof r.digest === 'string' && H32.test(r.digest) && ob(r.signer)
&& (r.softwareAttestationHash === undefined || (typeof r.softwareAttestationHash === 'string' && H32.test(r.softwareAttestationHash)))) /*PAZA:firmware-form*/)) ok('firmware-form', 'firmware', 'firmware: well formed', false, 'not an aere-device-firmware release with model, version, sequence and digest');
else {
R = r;
ok('firmware-signer', 'firmware', 'firmware: released by the manufacturer of the device', idDin(R.signer.keys) === R.signer.id && (S === null || aceeasi(R.signer, S.issuer)) /*PAZA:firmware-signer*/, `released by ${R.signer.id}` + (S ? `, the device was made by ${S.issuer.id}` : ', an id not derived from its keys'));
ok('firmware-signature', 'firmware', 'firmware: signed by who released it (Ed25519 and ML-DSA-65, both)', numai(F, LEGATURA) && verifica('firmware', R, F.signature, R.signer.keys) /*PAZA:firmware-signature*/, 'the hybrid signature does not verify with the signer\'s keys');
if (S) ok('firmware-model', 'firmware', `firmware: built for model ${S.claims.model}`, R.model === S.claims.model /*PAZA:firmware-model*/, `the release is for model ${R.model}`);
// The policies signed by the SAME signer for this model. Of those, the ones in force on the verifier's clock that can be READ
// (the version this verifier reads, a floor that is a whole number, a list of digests) are judged; with several, the strictest
// holds (a retraction is not lifted). One that is signed, not out of its validity, and cannot be read is NOT read as a clean
// policy: what it retracts cannot be seen, and the verdict says so (PARTIAL), whatever the other policies say.
const ale = FP.filter((p) => { try { const q = p.statement; return q.model === R.model && aceeasi(q.issuer, R.signer) && verifica('firmware-policy', q, p.signature, R.signer.keys); } catch { return false; } });
const curente = [], necitite = [];
for (const p of ale) { const q = p.statement; let inVigoare = null;
if (q.v !== VERSION) { necitite.push('it is of a version this verifier does not read'); continue; }
try { inVigoare = data(q.validFrom, 'policy validFrom') - sk <= acum && acum <= data(q.validUntil, 'policy validUntil'); } catch { /* a validity that is not a time: below */ }
if (inVigoare === false) continue; // expired, or not yet in force: it says nothing about now
if (inVigoare === null) necitite.push('its validity is not a time');
else if (!(Number.isInteger(q.minSequence) && q.minSequence >= 0)) necitite.push('its floor is not a whole number from 0 up');
else if (!(Array.isArray(q.retracted) && q.retracted.every((x) => typeof x === 'string' && DIGEST_SCRIS.test(x)))) necitite.push('its retracted list has an entry that is not a digest (0x and 64 hex digits)');
else curente.push(p); }
if (necitite.length) nejudecat('firmware-policy-unread', 'firmware', 'firmware: a policy that cannot be read', `${necitite.length} of the firmware policies signed by the manufacturer for this model cannot be read (${[...new Set(necitite)].join('; ')}): what such a policy retracts and the floor it sets cannot be seen, so it is not read as a policy that allows this release`);
if (!curente.length) { if (!necitite.length) nejudecat('firmware-policy-unread', 'firmware', 'firmware: still allowed', ale.length ? `the firmware policies given are not valid at ${iso(acum)}: fetch a current one` : 'no firmware policy of the manufacturer for this model was handed to the verifier; a retracted or outdated firmware cannot be seen'); }
else {
const scris = (x) => String(x).toLowerCase(); // a digest written in upper case by the manufacturer's own tooling is the same digest
const retras = curente.some((p) => p.statement.retracted.some((x) => scris(x) === R.digest)), prag = Math.max(...curente.map((p) => p.statement.minSequence));
ok('firmware-retracted', 'firmware', 'firmware: not retracted', !retras /*PAZA:firmware-retracted*/, `the manufacturer retracted the firmware ${R.digest}`);
ok('firmware-rollback', 'firmware', `firmware: sequence ${R.sequence} is at or above the floor ${prag}`, R.sequence >= prag /*PAZA:firmware-rollback*/, `version ${R.version} (sequence ${R.sequence}) is older than the lowest sequence the manufacturer still allows (${prag})`);
}
if (R.softwareAttestationHash !== undefined) {
if (softwareAttestation == null) nejudecat('software-unread', 'firmware', 'firmware: Proof of Software', 'the release names a Proof of Software attestation that was not handed to the verifier');
else ok('firmware-software', 'firmware', 'firmware: the Proof of Software attestation named covers this image', softwareAttestation.statementHash === R.softwareAttestationHash && softwareCovers(softwareAttestation, R.digest) /*PAZA:firmware-software*/, 'the attestation given is not the one the release names, or does not list an artifact with the firmware digest');
}
}
}
// ---- 3a. ownership: from the manufacturer, by transfers each signed by the owner before
const T = b.transfers || []; let owner = S ? S.holder : null, head = S ? credentialHash(M) : null, lantBun = true;
const idDispozitiv = S ? S.claims.deviceId : null;
if (!S && T.length) nejudecat('ownership-unrooted', 'ownership', 'ownership: root', 'without a manufacture credential the first transfer has nothing to start from; the chain is checked only against itself');
if (!(T.length <= MAX_TRANSFERS /*PAZA:chain-length*/)) { ok('chain-length', 'ownership', 'ownership: chain length', false, `${T.length} transfers, at most ${MAX_TRANSFERS}`); lantBun = false; }
else {
let tInainte = S ? data(S.issuedAt, 'issuedAt') - sk : -Infinity, dev0 = idDispozitiv; // with no credential beside the chain the first transfer has no time before it
for (let i = 0; i < T.length; i++) {
const D = T[i] && T[i].statement, et = `transfer ${i + 1}/${T.length}`;
if (!((ob(D) && D.kind === 'aere-device-transfer' && D.v === VERSION && typeof D.deviceId === 'string' && Number.isInteger(D.seq) && typeof D.prev === 'string' && ob(D.from) && ob(D.to)) /*PAZA:transfer-form*/)) { ok('transfer-form', 'ownership', `${et}: well formed`, false, 'not an aere-device-transfer'); lantBun = false; break; }
if (dev0 === null) dev0 = D.deviceId;
ok('transfer-device', 'ownership', `${et}: for this device`, D.deviceId === dev0 /*PAZA:transfer-device*/, `a transfer of ${D.deviceId}`);
ok('transfer-ids', 'ownership', `${et}: from and to are the ids of their keys`, idDin(D.from.keys) === D.from.id && idDin(D.to.keys) === D.to.id /*PAZA:transfer-ids*/, 'an id not derived from its keys');
if (owner) ok('transfer-from', 'ownership', `${et}: given by ${i === 0 ? 'the manufacturer' : 'the owner before'}`, aceeasi(D.from, owner) /*PAZA:transfer-from*/, `given by ${D.from.id}, the owner then was ${owner.id}`);
ok('transfer-prev', 'ownership', `${et}: names the link before it`, D.seq === i + 1 && (head === null || D.prev === head) /*PAZA:transfer-prev*/, 'the sequence number or the hash of the link before is not the one of this chain');
ok('transfer-signature', 'ownership', `${et}: signed by who gave it (Ed25519 and ML-DSA-65, both)`, numai(T[i], LEGATURA) && verifica('transfer', D, T[i].signature, D.from.keys) /*PAZA:transfer-signature*/, 'the hybrid signature does not verify');
const t = data(D.at, 'transfer at');
ok('transfer-time', 'ownership', `${et}: its time is after the link before and not in the future`, t >= tInainte && t <= acum + sk /*PAZA:transfer-time*/, `dated ${D.at}`);
tInainte = t; owner = D.to; head = linkHash(T[i]);
}
}
if (!lantBun) { owner = null; head = null; }
if (S && T.length === 0) rows.push({ link: 'ownership', name: 'ownership: never transferred, the manufacturer is the owner', pass: true, detail: '' });
// ---- 5 (read first: the authorization is judged for the action the device asks). attestation: the device speaks, now
const X = b.attestation; let Q = null;
if (X == null) nejudecat('attestation-missing', 'attestation', 'attestation: fresh statement', 'the bundle brings no attestation: nothing here was said by the device, now, to this verifier');
else {
const q = X.statement;
if (!((ob(q) && q.kind === 'aere-device-attestation' && q.v === VERSION && typeof q.deviceId === 'string' && typeof q.key === 'string' && sir(q.nonce) && sir(q.audience) && typeof q.at === 'string'
&& ob(q.firmware) && typeof q.firmware.digest === 'string' && H32.test(q.firmware.digest) && Number.isInteger(q.firmware.sequence) && typeof q.ownership === 'string' && H32.test(q.ownership) && typeof q.action === 'string' && NUME.test(q.action) && ob(X.signature)) /*PAZA:attestation-form*/)) ok('attestation-form', 'attestation', 'attestation: well formed', false, 'not an aere-device-attestation with a nonce, an audience, a time, a firmware, an ownership head and an action');
else {
Q = q;
let K = null, kd = NaN; try { K = cheiaDispozitivului(Q.key); kd = deviceIdOf(Q.key); } catch { /* the row says */ }
ok('attestation-key', 'attestation', 'attestation: the key is an ML-DSA-65 key and the device id is derived from it', kd === Q.deviceId /*PAZA:attestation-key*/, `the key in the attestation is not a canonical ML-DSA-65 public key, or the device id ${Q.deviceId} is not derived from it`);
if (S) ok('attestation-device', 'attestation', 'attestation: by the device the manufacturer made', Q.deviceId === S.claims.deviceId && Q.key === S.claims.deviceKey /*PAZA:attestation-device*/, `attested by ${Q.deviceId} with a key that is not the key the manufacturer certified for ${S.claims.deviceId}`);
if (K) { let semn = false; try { semn = numai(X, LEGATURA) && numai(X.signature, SEMN_DISPOZITIV) && X.signature.alg === ALG_DEVICE && b64canonic(X.signature.value) && crypto.verify(null, mesaj('attestation', canonical(Q)), K, Buffer.from(X.signature.value, 'base64')); } catch { semn = false; }
ok('attestation-signature', 'attestation', 'attestation: signed with the device key (ML-DSA-65)', semn /*PAZA:attestation-signature*/, 'the signature does not verify with the device key, or is not in canonical base64'); }
if (audience == null) nejudecat('audience', 'attestation', 'attestation: audience', 'not judged: without the verifier\'s own audience an attestation made for another verifier is accepted');
else ok('audience', 'attestation', `attestation: made for ${audience}`, Q.audience === audience /*PAZA:audience*/, `made for ${Q.audience}`);
if (nonce == null) nejudecat('nonce', 'attestation', 'attestation: nonce', 'not judged: without the verifier\'s nonce an old attestation can be replayed');
else ok('nonce', 'attestation', 'attestation: carries the verifier\'s nonce', Q.nonce === nonce /*PAZA:nonce*/, 'another nonce (a replay)');
// two bounds on the verifier's clock: not ahead of it by more than the tolerance (a clock that runs fast, never a date chosen
// in the future), and not older than maxAgeS
const tq = data(Q.at, 'attestation at');
ok('freshness', 'attestation', `attestation: dated at most ${clockSkewS} s ahead of the verifier's clock and at most ${maxAgeS} s before it`, tq <= acum + sk && acum - tq <= maxAgeS * 1000 /*PAZA:freshness*/, `dated ${Q.at}, ${Math.round((tq - acum) / 1000)} s from the verifier's clock (a positive number is ahead of it): at most ${clockSkewS} s ahead and at most ${maxAgeS} s before are allowed`);
if (R) ok('attestation-firmware', 'attestation', 'attestation: runs the firmware of the release shown', Q.firmware.digest === R.digest && Q.firmware.sequence === R.sequence /*PAZA:attestation-firmware*/, `the device says it runs ${Q.firmware.digest} (sequence ${Q.firmware.sequence}), the release shown is ${R.digest} (sequence ${R.sequence})`);
if (head !== null) ok('attestation-ownership', 'attestation', 'attestation: names the current ownership head', Q.ownership === head /*PAZA:attestation-ownership*/, 'the device names another ownership head than the chain shown (an older owner, or another chain)');
if (q.hardwareRoot != null) nejudecat('hardware-root', 'attestation', 'attestation: hardware root', 'the attestation carries a hardware root; this verifier does not judge one, so nothing here says the key lives in hardware');
}
}
// the device the authorization and the registry are about: the one the manufacturer made; with no credential, the one that attests
const dev = idDispozitiv !== null ? idDispozitiv : Q !== null ? Q.deviceId : null;
// ---- 3b. authorization: what it may do, said by the owner of NOW
const A = b.authorization; let Z = null;
if (A == null) nejudecat('authorization-missing', 'authorization', 'authorization: grant', 'the bundle brings no authorization: nothing says what this device may do');
else {
const z = A.statement;
if (!((ob(z) && z.kind === 'aere-device-authorization' && z.v === VERSION && typeof z.deviceId === 'string' && typeof z.ownership === 'string' && ob(z.owner) && Array.isArray(z.actions) && z.actions.length > 0
&& z.actions.every((x) => typeof x === 'string' && NUME.test(x)) && canonical(z.actions) === canonical([...new Set(z.actions)].sort())) /*PAZA:authorization-form*/)) ok('authorization-form', 'authorization', 'authorization: well formed', false, 'not an aere-device-authorization with a sorted list of action names');
else {
Z = z;
if (dev !== null) ok('authorization-device', 'authorization', 'authorization: for this device', Z.deviceId === dev /*PAZA:authorization-device*/, `an authorization of ${Z.deviceId}`);
ok('authorization-owner', 'authorization', 'authorization: granted by the current owner', idDin(Z.owner.keys) === Z.owner.id && (owner === null || aceeasi(Z.owner, owner)) /*PAZA:authorization-owner*/, `granted by ${Z.owner.id}` + (owner ? `, the owner now is ${owner.id}` : ', an id not derived from its keys'));
if (head !== null) ok('authorization-ownership', 'authorization', 'authorization: given under the current ownership head', Z.ownership === head /*PAZA:authorization-ownership*/, 'given under another ownership head (before a transfer, or for another chain)');
ok('authorization-signature', 'authorization', 'authorization: signed by who granted it (Ed25519 and ML-DSA-65, both)', numai(A, LEGATURA) && verifica('authorization', Z, A.signature, Z.owner.keys) /*PAZA:authorization-signature*/, 'the hybrid signature does not verify');
const nb = data(Z.notBefore, 'notBefore'), na = data(Z.notAfter, 'notAfter'), tq = Q ? data(Q.at, 'attestation at') : acum;
ok('authorization-window', 'authorization', `authorization: valid at ${iso(acum)}` + (Q ? ' and when the device attested' : ''), nb - sk <= acum && acum <= na && nb - sk <= tq && tq <= na /*PAZA:authorization-window*/, `valid from ${Z.notBefore} until ${Z.notAfter}`);
if (Q) ok('authorization-action', 'authorization', `authorization: allows ${Q.action}`, Z.actions.includes(Q.action) /*PAZA:authorization-action*/, `${Q.action} is not among the actions granted (${Z.actions.join(', ')})`);
}
}
// ---- 4. status: the registry the verifier read. Whose record counts:
// the manufacturer's always, whatever ownership head the record names. Its retirement is never lifted.
// the current owner's "retired" and "suspended" whatever ownership head the record names, and with none: the owner of now
// can only harm itself by it. "active" only under the current ownership head. Its retirement holds
// while it owns the device.
// a previous owner's does not bind the owner of now. A seller can sign, AFTER the sale, a retirement dated before it, and
// without a time witness the two cannot be told apart: if it bound, anyone who once owned a device
// could disable it for ever. It is NAMED (status-prior-ownership) with the state that owner's records
// say, and the verdict stands.
// anybody else's ignored, and named (status-ignored).
let stare = 'unknown'; const subFosti = new Map();
if (dev !== null) {
const vederi = new Map(); let retras = null, numarate = 0;
const fosti = lantBun ? T.map((t) => t.statement.from) : []; // the giver of every transfer was the owner before it
const devScris = idScris(dev); // the id of this device in its one text (a bundle that writes it in another form is refused by the rules above)
for (const r of SR) {
const s = r.statement;
// WHICH device a record is about: the hex of a device id is compared like a digest, without regard to case (a registry tool may
// have written it in upper case). A record that names ANOTHER device is not about this one and is passed over. A record whose
// device id is not a device id at all (a space after it, another form) may have been meant for this device: see below.
const idInreg = idScris(s.deviceId);
if (idInreg !== null && idInreg !== devScris) continue;
// The signer's id is derived from its keys HERE as well. The comparisons that follow (aceeasi) compare ids too, against
// identities whose id a rule already derived (manufacturer-id, transfer-ids), so either of the two alone refuses a record
// under an id its keys do not derive: defence in depth, and the negative control takes BOTH out to see the row go red.
let semnat = false; try { semnat = idDin(s.by.keys) === s.by.id && verifica('status', s, r.signature, s.by.keys); } catch { semnat = false; } // a record with no signer throws here, and is not signed
const rol = !semnat ? null : S !== null && aceeasi(s.by, S.issuer) ? 'manufacturer' : aceeasi(s.by, owner) ? 'owner' : fosti.some((x) => aceeasi(s.by, x)) ? 'previous' : null;
if (rol === null) {
// a manufacturer the verifier TRUSTS, other than the one of the credential shown, says something about this device key. It is not
// applied (a manufacturer does not retire the devices of another), and it is not passed over: this verifier cannot judge by what
// right it speaks about this key, so the verdict is not VALID.
if (semnat && incredere !== null && incredere.includes(s.by.id)) nejudecat('status-other-manufacturer', 'status', 'status: a record of another trusted manufacturer', `${s.by.id}, a manufacturer the verifier trusts and not the one of the credential shown, signed a status record about this device: not applied, and not judged (the same key may carry another manufacture credential)`);
else nejudecat('status-ignored', 'status', 'status: a record', `ignored: not a signed record of the manufacturer, of the current owner or of an owner before it (${ob(s.by) ? String(s.by.id) : 'no signer'})`);
continue;
}
const aLui = (rol === 'manufacturer' || rol === 'owner'); // who may retire or suspend this device
if (idInreg === null) { // signed by one of the three, and the device id written in it is not a device id
if (aLui) nejudecat('status-unread', 'status', 'status: a record whose device id cannot be read', `a record signed by ${s.by.id} (the ${rol}) names a device id that is not a device id: it may be meant for this device, so the registry is not read as clean`);
else nejudecat('status-ignored', 'status', 'status: a record', `ignored: a record of a previous owner (${s.by.id}) that names a device id that is not a device id`);
continue;
}
// signed by who may give it, and not readable here (another version, a state or a number this verifier does not know): it may be a
// retirement, so the registry is NOT read as clean. Of a previous owner it would not be applied anyway: counted with its others.
const citibil = s.v === VERSION && STATES.includes(s.state) && Number.isInteger(s.seq);
if (rol === 'previous') { // gathered per signer: one row for each previous owner, whatever the number of its records
const l = subFosti.get(s.by.id) || { citite: [], necitite: 0 }; if (citibil) l.citite.push({ state: s.state, seq: s.seq }); else l.necitite++; subFosti.set(s.by.id, l);
continue;
}
if (!citibil) { nejudecat('status-unread', 'status', 'status: a record of who may give it that cannot be read', `a record signed by ${s.by.id} (the ${rol}) is of a version, a state or a number this verifier does not read: what it says cannot be seen, so the registry is not read as clean`); continue; }
if (rol === 'owner' && s.state === 'active' && s.ownership !== head) { nejudecat('status-ignored', 'status', 'status: an "active" record of the owner under another ownership head', `ignored: the owner's "active" (record ${s.seq}) is believed only under the current ownership head; its "suspended" and "retired" count under any`); continue; }
let at = null, vu = null; try { at = data(s.at, 'status at'); vu = data(s.validUntil, 'status validUntil'); } catch { at = null; }
if (at === null) { // a retirement or a suspension by who may give it is not undone by a time that cannot be read; an "active" is not believed
if (s.state === 'active') { nejudecat('status-ignored', 'status', 'status: an "active" record whose time cannot be read', `ignored: record ${s.seq} of ${s.by.id} carries a time or a validity that is not a time`); continue; }
at = -Infinity; vu = -Infinity;
}
if (at > acum + sk) { nejudecat('status-future', 'status', `status: a record dated in the verifier's future (${s.by.id})`, `"${s.state}" (record ${s.seq}) is dated ${s.at}, more than ${clockSkewS} s ahead of the verifier's clock: not in effect yet, and not applied`); continue; }
numarate++;
if (s.state === 'retired') { if (retras === null || rol === 'manufacturer') retras = { by: s.by.id, at: String(s.at), rol }; continue; }
const v = vederi.get(s.by.id);
if (!v || s.seq > v.seq) vederi.set(s.by.id, { seq: s.seq, suspendat: s.state === 'suspended', pana: vu, by: s.by.id });
else if (s.seq === v.seq) { v.suspendat = v.suspendat || s.state === 'suspended'; v.pana = Math.min(v.pana, vu); }
}
const susp = [...vederi.values()].find((v) => v.suspendat), proaspat = [...vederi.values()].some((v) => v.pana >= acum); // read only when nobody suspended: then every view left is an "active"
if (numarate) {
ok('status-retired', 'status', 'status: not retired', retras === null /*PAZA:status-retired*/, retras ? `retired by ${retras.by} at ${retras.at}; ` + (retras.rol === 'manufacturer' ? 'a retirement by the manufacturer is never lifted' : 'a retirement by the owner holds while it owns the device') : '');
ok('status-suspended', 'status', 'status: not suspended', !susp /*PAZA:status-suspended*/, susp ? `suspended by ${susp.by} (its record ${susp.seq} is its latest)` : '');
}
stare = retras ? 'retired' : susp ? 'suspended' : proaspat ? 'active' : 'unknown';
if (!retras && !susp && !proaspat) nejudecat('status-unread', 'status', 'status: registry', numarate ? `the status records given say "active" but none is current at ${iso(acum)}: read the registry again` : 'no status record of the manufacturer or of the current owner that can be applied was handed to the verifier; a suspension or a retirement it was not handed cannot be seen');
}
// what the records of each previous owner say: retired if any of them says so, else the state of its highest record (the stricter of
// two with one number). ONE row per previous owner, with the number of its records. They do not bind the owner of now.
const subAnteriori = [];
for (const [id, l] of subFosti) {
let spune = null;
if (l.citite.length) { const m = Math.max(...l.citite.map((x) => x.seq));
spune = l.citite.some((x) => x.state === 'retired') ? 'retired' : l.citite.some((x) => x.seq === m && x.state === 'suspended') ? 'suspended' : 'active';
subAnteriori.push({ owner: id, state: spune, records: l.citite.length }); }
nejudecat('status-prior-ownership', 'status', `status: ${l.citite.length + l.necitite} record(s) of a previous owner (${id})`,
(spune ? `${l.citite.length} record(s) signed by an owner before the current one say "${spune}"` : 'signed by an owner before the current one') + (l.necitite ? `; ${l.necitite} of its records this verifier cannot read (version, state or number)` : '')
+ ': they do not bind the owner of now. A record signed before the sale cannot be told from one signed after it and dated back without a time witness, which is not built here');
}
// ---- the witness: the attestation's envelope, and a reading of its notarization that the verifier made
const E = b.anchor; let martor = null;
if (E != null) {
let plic = false; try { plic = E.kind === 'aere-proof-of-device-attestation' && E.statement.kind === 'aere-proof-of-device'
&& hex0x(sha(Buffer.from(JSON.stringify(E.statement), 'utf8'))) === E.statementHash && (Q === null || (E.statement.deviceId === Q.deviceId && E.statement.attestationHash === attestationHash(X))); } catch { plic = false; }
ok('witness-envelope', 'witness', 'witness: the envelope commits to this attestation', plic /*PAZA:witness-envelope*/, 'the envelope is not an aere-proof-of-device attestation whose hash reproduces and which names this device and the hash of this attestation');
if (plic) {
// the readings made for THIS envelope; of those, the ones whose time is a whole number of seconds (a time in milliseconds is not one:
// taken for seconds it would lie thousands of years ahead and bound nothing)
const pentruEl = AN.filter((a) => a.statementHash === E.statementHash), citiri = pentruEl.filter((a) => Number.isInteger(a.at) && a.at > 0 && a.at < 1e11);
if (pentruEl.length > citiri.length) nejudecat('witness-unread', 'witness', 'witness: a reading that cannot be read', `${pentruEl.length - citiri.length} reading(s) handed for this envelope carry a time that is not a whole number of seconds since 1970 (milliseconds are not seconds): not used`);
if (!citiri.length) { if (E.notarization != null && !pentruEl.length) nejudecat('witness-unread', 'witness', 'witness: notarization', 'the envelope claims a notarization that the verifier did not read on the chain (a claim is not a reading)'); }
else if (Q) { const prima = citiri.reduce((x, y) => (y.at < x.at ? y : x)), cel = prima.at * 1000; martor = { at: prima.at, chainId: prima.chainId === undefined ? null : prima.chainId };
ok('witness-time', 'witness', `witness: the attestation is not dated after its envelope was on the chain (${iso(cel)})`, data(Q.at, 'attestation at') <= cel + sk /*PAZA:witness-time*/, `the device dated its attestation ${Q.at}, and its envelope was already notarized at ${iso(cel)}`); }
}
}
subject = { deviceId: dev, model: S ? S.claims.model : null, serial: S ? S.claims.serial : null, manufacturer: S ? S.issuer.id : null, owner: owner ? owner.id : null, transfers: T.length,
firmware: R ? { version: R.version, sequence: R.sequence, digest: R.digest } : null, action: Q ? Q.action : null, state: stare,
// what the records of owners BEFORE the current one say (named, not applied): [{ owner, state, records }]
priorOwnership: subAnteriori,
// the reading the verifier brought, with the chain it says it read: a time witnessed on a testnet is that testnet's
witness: martor };
return gata();
} catch (e) {
if (e instanceof Nedeclarata) throw e;
if (!(false /*PAZA:unreadable*/)) ok('unreadable', 'bundle', 'bundle: readable', false, String(e instanceof Error ? e.message : e).slice(0, 200));
return gata();
}
}