// 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: } 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(); } }