636 lines
69 KiB
JavaScript
636 lines
69 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();
|
|
/**
|
|
* The day a text of the form of DATA names exists. A date parser MOVES a day that does not exist (2026-02-30 becomes March 2, T24:00:00
|
|
* the next day) and reads nothing from what it cannot (month 13, second 60): in every case the day of the instant read is not the day
|
|
* written (an hour or a month cannot overflow without moving the day), and a text read as nothing has no day at all.
|
|
*/
|
|
const exista = (s) => new Date(Date.parse(s)).getUTCDate() === Number(String(s).slice(8, 10));
|
|
function data(s, ce) { if (typeof s !== 'string' || !DATA.test(s) || !exista(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 arabic letter mark and the two marks of direction (with the embeddings, overrides and
|
|
// isolates below, the characters Unicode gives the property Bidi_Control), the line and paragraph separators, the byte order mark
|
|
const DE_ARATAT = [[0x00, 0x1f], [0x7f, 0x9f], [0x061c, 0x061c], [0x200e, 0x200f], [0x2028, 0x202e], [0x2066, 0x2069], [0xfeff, 0xfeff]];
|
|
const deAratat = (n) => DE_ARATAT.some(([de, la]) => n >= de && n <= la);
|
|
export function visible(s) {
|
|
let out = '';
|
|
for (const c of String(s)) { const n = c.codePointAt(0);
|
|
if (n === 0x5c) out += '\\\\';
|
|
else if (deAratat(n)) { const h = n.toString(16); out += h.length <= 2 ? '\\x' + h.padStart(2, '0') : '\\u' + h.padStart(4, '0'); }
|
|
else out += c; }
|
|
return out;
|
|
}
|
|
/**
|
|
* A value as JSON text in which every code point visible() replaces is written as a \u escape. JSON.stringify escapes only C0: DEL, C1,
|
|
* the marks of direction and the line separators would reach the terminal as they came. The text parses to exactly the same value.
|
|
* (What it replaces from DEL up can stand only inside a string; the C0 left in the text are the line feeds of its layout.)
|
|
*/
|
|
export function visibleJson(v, spatiu = 1) {
|
|
let out = '';
|
|
for (const c of JSON.stringify(v, null, spatiu)) { const n = c.codePointAt(0); out += n >= 0x7f && deAratat(n) ? '\\u' + n.toString(16).padStart(4, '0') : 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)
|
|
// the verifier's clock: a Date, a number of milliseconds since 1970, or an RFC 3339 UTC text, and nothing else. A date parser reads a
|
|
// text without a zone as local time and '1' as the year 2001: a clock read so would judge on a time nobody chose
|
|
const acum = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : typeof now === 'string' && DATA.test(now) && exista(now) ? Date.parse(now) : NaN;
|
|
if (!Number.isFinite(acum)) throw new Error('verifyDevice: 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('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
|
|
// 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. What is judged is
|
|
// the release shown or, when the bundle brings none, the firmware the device attests (`fw`: model, signer, digest, sequence).
|
|
const judecaPolitici = (fw, t) => {
|
|
const ale = FP.filter((p) => { try { const q = p.statement; return q.model === fw.model && aceeasi(q.issuer, fw.signer) && verifica('firmware-policy', q, p.signature, fw.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 ${t.ce}`);
|
|
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) === fw.digest)), prag = Math.max(...curente.map((p) => p.statement.minSequence));
|
|
ok('firmware-retracted', 'firmware', t.neretras, !retras /*PAZA:firmware-retracted*/, t.retras);
|
|
ok('firmware-rollback', 'firmware', t.prag(prag), fw.sequence >= prag /*PAZA:firmware-rollback*/, t.subPrag(prag));
|
|
}
|
|
};
|
|
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}`);
|
|
judecaPolitici({ model: R.model, signer: R.signer, digest: R.digest, sequence: R.sequence }, {
|
|
ce: 'this release', neretras: 'firmware: not retracted', retras: `the manufacturer retracted the firmware ${R.digest}`,
|
|
prag: (p) => `firmware: sequence ${R.sequence} is at or above the floor ${p}`, subPrag: (p) => `version ${R.version} (sequence ${R.sequence}) is older than the lowest sequence the manufacturer still allows (${p})` });
|
|
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');
|
|
}
|
|
}
|
|
// ---- 2 (again), when the bundle brings no release: the firmware the DEVICE says it runs, judged against the policies of the
|
|
// manufacturer of the credential for its model. Leaving the release out keeps the verdict from VALID (firmware-missing); it does not
|
|
// hide a digest the manufacturer retracted, nor a sequence below its floor, that the device itself attests.
|
|
if (F == null && S !== null && Q !== null) judecaPolitici({ model: S.claims.model, signer: S.issuer, digest: Q.firmware.digest, sequence: Q.firmware.sequence }, {
|
|
ce: 'the firmware the device attests', neretras: 'firmware: the firmware the device attests is not retracted (no release was brought)', retras: `the device says it runs ${Q.firmware.digest}, which the manufacturer retracted`,
|
|
prag: (p) => `firmware: the sequence the device attests (${Q.firmware.sequence}) is at or above the floor ${p} (no release was brought)`, subPrag: (p) => `the device says it runs sequence ${Q.firmware.sequence}, older than the lowest sequence the manufacturer still allows (${p})` });
|
|
|
|
// 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();
|
|
}
|
|
}
|