// AERE Identity (roadmap master punctul 12, pista B, 2026-09-30): credentiale post-cuantice cu dezvaluire selectiva, legate de cheia // detinatorului, cu delegare in lant (dispozitive, agenti, chei de sesiune), revocare si lista de stare, verificabile oricand si de // oricine, fara incredere in Aere si fara retea. Numai Node 24 (node:crypto: Ed25519 si ML-DSA-65, FIPS 204), fara dependinte. // // SEMNATURA: HIBRIDA, Ed25519 + ML-DSA-65 peste acelasi mesaj, AMANDOUA cerute (daca oricare schema cade, cealalta tine). Mesajul are // separare de domeniu pe SCOP ('aere-identity/v1/\n' + text), deci o semnatura de delegare nu poate fi folosita drept semnatura // de prezentare sau de revocare peste acelasi text. Textul semnat e forma CANONICA a declaratiei (chei sortate), refacuta de verificator. // IDENTITATEA e legata de chei: 'aere-id:' + primii 20 de octeti din sha256(forma canonica a celor doua chei publice SPKI). // // DEZVALUIREA SELECTIVA, in felul SD-JWT (IETF RFC 9901), dar in JSON canonic si cu semnatura hibrida: fiecare afirmatie dezvaluibila // devine [sare, nume, valoare] codat base64url; emitentul semneaza numai digestul sha256 al codarii (lista `sd`, sortata, cu momeli // optionale care ascund cate afirmatii sunt), detinatorul arata numai ce alege. Afirmatiile nedezvaluibile stau in clar in `claims`. // Nu e o dovada cu cunoastere zero: ce se arata se arata intreg (o varsta arata data, nu "peste 18"; emitentul poate pune insa o // afirmatie derivata, `age_over_18: true`, pe care detinatorul o arata singura). // // PREZENTAREA e legata de verificator: detinatorul (sau delegatul lui) semneaza hash-ul credentialului, hash-ul dezvaluirilor alese, // PUBLICUL (audience), NONCE-ul verificatorului si momentul; fara public si nonce ceruti de verificator, o prezentare se poate relua la // oricine, si verificatorul spune asta (nejudecat), nu o trece drept verificata. // // DELEGAREA: detinatorul da unei alte chei (telefon, agent, cheie de sesiune de cateva minute) dreptul de a-i prezenta credentialele, // intr-un SCOP (ce credentiale, ce afirmatii dezvaluibile, ce public), intr-o fereastra de timp si cu o adancime de re-delegare. Fiecare // veriga e semnata de cel care da, numeste veriga-parinte prin hash, si poate numai INGUSTA: scop inclus, fereastra inclusa, adancime // mai mica. Revocarea unei verigi e o declaratie semnata de cel care a dat-o sau de detinator; se aplica daca momentul ei a trecut pe // ceasul VERIFICATORULUI. // // LISTA DE STARE a emitentului, in felul W3C Bitstring Status List v1.0: un sir de biti (bitul 0 = cel mai semnificativ bit al primului // octet) comprimat gzip, semnat de emitent, cu o fereastra de valabilitate; credentialul numeste lista si pozitia. O lista lipsa, a // altui emitent sau expirata inseamna "nejudecat", nu "nerevocat". // // TIMPUL, spus exact: issuedAt / validFrom / validUntil sunt declaratiile emitentului; momentul prezentarii e al celui care prezinta, // marginit de verificator cu ceasul lui (maxAgeS, in ambele sensuri); momentul unei revocari e al celui care revoca. Verificatorul // judeca totul pe ceasul lui. Notarizarea pe lant (plicurile AIP-23 de mai jos) da un moment pe care nu il alege emitentul. // // CE NU FACE: nu leaga o cheie de hardware (o cheie de dispozitiv e o cheie ca oricare; atestarea TPM / enclava nu e aici); nu // roteste si nu recupereaza cheile (asta e registrul de chei post-cuantic cu pre-rotire); nu spune CINE e o identitate in lumea // reala (asta o spune emitentul, pe care verificatorul alege daca il crede, prin trustedIssuers). import crypto from 'node:crypto'; import zlib from 'node:zlib'; export const VERSION = 1; export const ALG = 'ed25519+ml-dsa-65'; const DOMENIU = 'aere-identity/v1/'; // 2026-09-30: si scopurile Travel Rule (travel-rule.mjs): legarea cheilor KEM de un VASP, mesajul, confirmarea const SCOPURI = new Set(['credential', 'presentation', 'delegation', 'revocation', 'status-list', 'kem-binding', 'travel-rule', 'travel-rule-receipt']); const REZERVATE = new Set(['__proto__', 'constructor', 'prototype']); const NUME = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/; const ID = /^aere-id:[0-9a-f]{40}$/; const B64U = /^[A-Za-z0-9_-]+$/; const DATA = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/; export const MAX_CHAIN = 8; export const MAX_STATUS_BITS = 1 << 24; // 2 MB de biti decomprimati, cel mult export const MIN_STATUS_BITS = 131072; // 16 KB, marimea minima ceruta de W3C pentru intimitate const sha = (b) => crypto.createHash('sha256').update(b).digest(); const hex0x = (b) => '0x' + b.toString('hex'); const b64u = (b) => Buffer.from(b).toString('base64url'); /** JSON canonic: chei sortate, fara spatii; refuza ce JSON nu poate spune exact (undefined, NaN, Infinity, functii). */ export function canonical(v) { if (v === null) return 'null'; if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error('aere-identity: a number that is not finite'); return JSON.stringify(v); } if (typeof v === 'string' || typeof v === 'boolean') return JSON.stringify(v); if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; if (typeof v === 'object') { return '{' + Object.keys(v).sort().map((k) => { if (v[k] === undefined) throw new Error(`aere-identity: ${k} is undefined`); return JSON.stringify(k) + ':' + canonical(v[k]); }).join(',') + '}'; } throw new Error('aere-identity: a value JSON cannot carry (' + typeof v + ')'); } const hashOf = (o) => hex0x(sha(Buffer.from(canonical(o), 'utf8'))); // ---------------------------------------------------------------- chei si identitate function cheiePublica(b64, tip) { if (typeof b64 !== 'string' || !/^[A-Za-z0-9+/]+=*$/.test(b64)) throw new Error(`aere-identity: the ${tip} public key is not base64`); const k = crypto.createPublicKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'spki' }); if (k.asymmetricKeyType !== tip) throw new Error(`aere-identity: the ${tip} public key is a ${k.asymmetricKeyType} key`); // forma unica: SPKI-ul refacut din cheie trebuie sa fie exact octetii dati (altfel doua texte ar numi aceeasi cheie) if (k.export({ type: 'spki', format: 'der' }).toString('base64') !== b64) throw new Error(`aere-identity: the ${tip} public key is not in its canonical form`); return k; } /** Valideaza un obiect de chei publice { alg, ed25519, mldsa65 } si intoarce cheile Node. */ export function publicKeyObjects(pub) { if (!pub || typeof pub !== 'object' || pub.alg !== ALG) throw new Error(`aere-identity: public keys must be ${ALG}`); const extra = Object.keys(pub).filter((k) => !['alg', 'ed25519', 'mldsa65'].includes(k)); if (extra.length) throw new Error('aere-identity: unknown field in public keys: ' + extra.join(', ')); return { ed25519: cheiePublica(pub.ed25519, 'ed25519'), mldsa65: cheiePublica(pub.mldsa65, 'ml-dsa-65') }; } /** Identitatea derivata din chei: nimeni nu poate pretinde un id cu alte chei. */ export function idOf(pub) { publicKeyObjects(pub); return 'aere-id:' + sha(Buffer.from(canonical(pub), 'utf8')).toString('hex').slice(0, 40); } function dinObiecte(ed, ml) { const pub = { alg: ALG, ed25519: ed.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), mldsa65: ml.publicKey.export({ type: 'spki', format: 'der' }).toString('base64') }; const keys = { id: idOf(pub), public: pub }; Object.defineProperty(keys, 'privat', { value: { ed25519: ed.privateKey, mldsa65: ml.privateKey }, enumerable: false }); return keys; } /** O pereche noua de chei hibride. Partea privata nu e enumerabila (nu iese dintr-un JSON.stringify din greseala). */ export function generateKeys() { return dinObiecte(crypto.generateKeyPairSync('ed25519'), crypto.generateKeyPairSync('ml-dsa-65')); } /** Forma de fisier a cheilor (partea privata inclusa: se scrie cu drepturi 0600, niciodata langa ce se publica). */ export function exportKeys(keys) { if (!keys || !keys.privat) throw new Error('aere-identity: these are not private keys'); return { v: VERSION, kind: 'aere-identity-keys', id: keys.id, public: keys.public, private: { ed25519: keys.privat.ed25519.export({ type: 'pkcs8', format: 'der' }).toString('base64'), mldsa65: keys.privat.mldsa65.export({ type: 'pkcs8', format: 'der' }).toString('base64') } }; } /** Citeste un fisier de chei si cere ca partea publica sa fie exact cea derivata din cea privata. */ export function importKeys(j) { if (!j || j.kind !== 'aere-identity-keys' || j.v !== VERSION || !j.private) throw new Error('aere-identity: not an aere-identity-keys file'); const edP = crypto.createPrivateKey({ key: Buffer.from(String(j.private.ed25519), 'base64'), format: 'der', type: 'pkcs8' }); const mlP = crypto.createPrivateKey({ key: Buffer.from(String(j.private.mldsa65), 'base64'), format: 'der', type: 'pkcs8' }); if (edP.asymmetricKeyType !== 'ed25519' || mlP.asymmetricKeyType !== 'ml-dsa-65') throw new Error('aere-identity: the private keys are not ed25519 and ml-dsa-65'); const keys = dinObiecte({ publicKey: crypto.createPublicKey(edP), privateKey: edP }, { publicKey: crypto.createPublicKey(mlP), privateKey: mlP }); if (canonical(keys.public) !== canonical(j.public) || keys.id !== j.id) throw new Error('aere-identity: the public keys or the id in the file are not those of its private keys'); return keys; } // ---------------------------------------------------------------- semnatura hibrida, cu separare de domeniu function mesaj(scop, text) { if (!SCOPURI.has(scop)) throw new Error('aere-identity: unknown signing purpose ' + scop); return Buffer.from(DOMENIU + scop + '\n' + text, 'utf8'); } export function signText(scop, text, keys) { if (!keys || !keys.privat) throw new Error('aere-identity: signing needs private keys'); const m = mesaj(scop, text); return { alg: ALG, ed25519: crypto.sign(null, m, keys.privat.ed25519).toString('base64'), mldsa65: crypto.sign(null, m, keys.privat.mldsa65).toString('base64') }; } /** Adevarat numai daca AMANDOUA semnaturile verifica, cu cheile date, pe scopul dat. */ export function verifyText(scop, text, sig, pub) { try { if (!sig || sig.alg !== ALG || typeof sig.ed25519 !== 'string' || typeof sig.mldsa65 !== 'string') return false; const k = publicKeyObjects(pub); const m = mesaj(scop, text); 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; } } // ---------------------------------------------------------------- timp function data(s, ce) { if (typeof s !== 'string' || !DATA.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`aere-identity: ${ce} is not an RFC 3339 UTC time`); return Date.parse(s); } const iso = (d) => new Date(d).toISOString(); // ---------------------------------------------------------------- dezvaluiri function numeValid(n) { return typeof n === 'string' && NUME.test(n) && !REZERVATE.has(n); } function dezvaluire(nume, valoare) { canonical(valoare); return b64u(Buffer.from(JSON.stringify([b64u(crypto.randomBytes(16)), nume, valoare]), 'utf8')); } /** Digestul unei dezvaluiri: sha256 peste textul ei base64url, ca in SD-JWT. */ export const digestOf = (enc) => b64u(sha(Buffer.from(String(enc), 'ascii'))); /** Citeste o dezvaluire; refuza orice forma care nu e cea unica (base64url fara umplutura, trei elemente, sare de cel putin 16 octeti). */ export function decodeDisclosure(enc) { if (typeof enc !== 'string' || !B64U.test(enc) || enc.length > 65536) throw new Error('a disclosure that is not base64url'); const b = Buffer.from(enc, 'base64url'); if (b.toString('base64url') !== enc) throw new Error('a disclosure not in its canonical base64url form'); let a; try { a = JSON.parse(b.toString('utf8')); } catch { throw new Error('a disclosure that is not JSON'); } if (!Array.isArray(a) || a.length !== 3) throw new Error('a disclosure that is not [salt, name, value]'); if (typeof a[0] !== 'string' || !B64U.test(a[0]) || Buffer.from(a[0], 'base64url').length < 16) throw new Error('a disclosure with a salt under 16 bytes'); if (!numeValid(a[1])) throw new Error('a disclosure whose name is not allowed'); canonical(a[2]); return { salt: a[0], name: a[1], value: a[2] }; } // ---------------------------------------------------------------- credentialul /** * Emite un credential. `disclosable`: numele afirmatiilor dezvaluibile (null = toate); celelalte stau in clar. * Intoarce { credential, disclosures }: dezvaluirile sunt ale DETINATORULUI (ii dau puterea de a arata), nu se publica. */ export function issueCredential({ issuer, holder, type, claims = {}, disclosable = null, validFrom, validUntil, status = null, decoys = 0, now = new Date(), id = null }) { if (!issuer || !issuer.privat) throw new Error('aere-identity: the issuer needs private keys'); publicKeyObjects(holder); if (typeof type !== 'string' || !NUME.test(type)) throw new Error('aere-identity: type must be a name'); if (!claims || typeof claims !== 'object' || Array.isArray(claims)) throw new Error('aere-identity: claims must be an object'); const nume = Object.keys(claims).sort(); for (const n of nume) if (!numeValid(n)) throw new Error('aere-identity: claim name not allowed: ' + n); const vf = validFrom || iso(now), vu = validUntil; if (data(vf, 'validFrom') >= data(vu, 'validUntil')) throw new Error('aere-identity: validFrom must be before validUntil'); if (disclosable != null) for (const n of disclosable) if (!nume.includes(n)) throw new Error('aere-identity: disclosable names a claim that is not there: ' + n); if (!Number.isInteger(decoys) || decoys < 0 || decoys > 64) throw new Error('aere-identity: decoys must be 0..64'); const plain = {}; const disclosures = []; for (const n of nume) { if (disclosable == null || disclosable.includes(n)) disclosures.push(dezvaluire(n, claims[n])); else { canonical(claims[n]); plain[n] = claims[n]; } } const sd = [...disclosures.map(digestOf), ...Array.from({ length: decoys }, () => b64u(sha(crypto.randomBytes(32))))].sort(); if (status != null) { if (typeof status.list !== 'string' || !status.list || !Number.isInteger(status.index) || status.index < 0 || status.index >= MAX_STATUS_BITS) throw new Error('aere-identity: status needs a list id and an index'); } const statement = { v: VERSION, kind: 'aere-credential', id: id || 'urn:uuid:' + crypto.randomUUID(), type, issuer: { id: issuer.id, keys: issuer.public }, holder: { id: idOf(holder), keys: holder }, claims: plain, sd, sdAlg: 'sha-256', issuedAt: iso(now), validFrom: vf, validUntil: vu, ...(status != null ? { status: { list: status.list, index: status.index } } : {}) }; return { credential: { statement, signature: signText('credential', canonical(statement), issuer) }, disclosures }; } export const credentialHash = (c) => hashOf(c); // ---------------------------------------------------------------- delegarea const TOT = '*'; function scopNormal(s) { if (!s || typeof s !== 'object') throw new Error('aere-identity: a delegation needs a scope'); const o = {}; for (const k of ['credentials', 'claims', 'audiences']) { const v = s[k]; if (v === TOT) { o[k] = TOT; continue; } if (!Array.isArray(v) || v.some((x) => typeof x !== 'string' || !x)) throw new Error(`aere-identity: scope.${k} must be "*" or a list of strings`); o[k] = [...new Set(v)].sort(); } const extra = Object.keys(s).filter((k) => !['credentials', 'claims', 'audiences'].includes(k)); if (extra.length) throw new Error('aere-identity: unknown scope field: ' + extra.join(', ')); return o; } const inclus = (copil, parinte) => parinte === TOT || (copil !== TOT && copil.every((x) => parinte.includes(x))); const permite = (lista, x) => lista === TOT || lista.includes(x); export const delegationHash = (d) => hashOf(d); /** * Da cheilor `to` dreptul de a prezenta credentialele celui care semneaza (`from`), in `scope`, intre notBefore si notAfter. * Cu `parent`, re-delegheaza: from trebuie sa fie delegatul verigii-parinte, iar scopul, fereastra si adancimea pot numai sa scada. */ export function delegate({ from, to, scope, notBefore, notAfter, maxDepth = 0, parent = null, now = new Date() }) { if (!from || !from.privat) throw new Error('aere-identity: the delegator needs private keys'); publicKeyObjects(to); const sc = scopNormal(scope); const nb = notBefore || iso(now); if (data(nb, 'notBefore') >= data(notAfter, 'notAfter')) throw new Error('aere-identity: notBefore must be before notAfter'); if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth >= MAX_CHAIN) throw new Error(`aere-identity: maxDepth must be 0..${MAX_CHAIN - 1}`); if (parent) { const P = parent.statement; if (P.to.id !== from.id) throw new Error('aere-identity: only the delegate of the parent link can re-delegate it'); if (P.maxDepth < 1 || maxDepth > P.maxDepth - 1) throw new Error('aere-identity: the parent link allows no deeper delegation'); for (const k of ['credentials', 'claims', 'audiences']) if (!inclus(sc[k], P.scope[k])) throw new Error(`aere-identity: scope.${k} is wider than the parent link's`); if (data(nb, 'notBefore') < data(P.notBefore, 'parent notBefore') || data(notAfter, 'notAfter') > data(P.notAfter, 'parent notAfter')) throw new Error('aere-identity: the window is wider than the parent link\'s'); } const statement = { v: VERSION, kind: 'aere-delegation', id: 'urn:uuid:' + crypto.randomUUID(), from: { id: from.id, keys: from.public }, to: { id: idOf(to), keys: to }, parent: parent ? delegationHash(parent) : null, scope: sc, notBefore: nb, notAfter, maxDepth, issuedAt: iso(now) }; return { statement, signature: signText('delegation', canonical(statement), from) }; } /** Revoca o veriga de delegare (dupa hash). Conteaza daca e semnata de cel care a dat veriga sau de detinatorul credentialului. */ export function revokeDelegation({ by, delegation, at = null, reason = null, now = new Date() }) { if (!by || !by.privat) throw new Error('aere-identity: revoking needs private keys'); const statement = { v: VERSION, kind: 'aere-revocation', by: { id: by.id, keys: by.public }, target: typeof delegation === 'string' ? delegation : delegationHash(delegation), at: at || iso(now), ...(reason ? { reason: String(reason) } : {}) }; data(statement.at, 'at'); return { statement, signature: signText('revocation', canonical(statement), by) }; } // ---------------------------------------------------------------- lista de stare a emitentului export function createStatusList({ issuer, id, size = MIN_STATUS_BITS, revoked = [], validFrom, validUntil, now = new Date() }) { if (!issuer || !issuer.privat) throw new Error('aere-identity: the status list is signed by the issuer'); if (!Number.isInteger(size) || size < 8 || size % 8 || size > MAX_STATUS_BITS) throw new Error('aere-identity: size must be a multiple of 8, at most ' + MAX_STATUS_BITS); const biti = Buffer.alloc(size / 8); for (const i of revoked) { if (!Number.isInteger(i) || i < 0 || i >= size) throw new Error('aere-identity: index out of the list: ' + i); biti[i >> 3] |= 0x80 >> (i & 7); } const vf = validFrom || iso(now); if (data(vf, 'validFrom') >= data(validUntil, 'validUntil')) throw new Error('aere-identity: validFrom must be before validUntil'); const statement = { v: VERSION, kind: 'aere-status-list', id, purpose: 'revocation', issuer: { id: issuer.id, keys: issuer.public }, size, encodedList: b64u(zlib.gzipSync(biti, { level: 9 })), validFrom: vf, validUntil, issuedAt: iso(now) }; return { statement, signature: signText('status-list', canonical(statement), issuer) }; } /** Bitul `index` al listei, decomprimat cu plafon (o lista nu se poate umfla peste marimea declarata, nici peste MAX_STATUS_BITS). */ export function statusBit(list, index) { const S = list.statement; if (!Number.isInteger(S.size) || S.size < 8 || S.size % 8 || S.size > MAX_STATUS_BITS) throw new Error('the list declares a size it may not have'); if (typeof S.encodedList !== 'string' || !B64U.test(S.encodedList)) throw new Error('the list is not base64url'); let biti; try { biti = zlib.gunzipSync(Buffer.from(S.encodedList, 'base64url'), { maxOutputLength: S.size / 8 }); } catch { throw new Error('the list decompresses beyond its declared size, or is not gzip'); } if (biti.length !== S.size / 8) throw new Error('the list does not decompress to its declared size'); if (!Number.isInteger(index) || index < 0 || index >= S.size) throw new Error('the index is outside the list'); return (biti[index >> 3] & (0x80 >> (index & 7))) !== 0; } // ---------------------------------------------------------------- prezentarea /** * Prezinta un credential: arata numai afirmatiile din `reveal` si leaga totul de publicul si nonce-ul verificatorului. * `presenter` e detinatorul, sau, cu `delegations` (lantul de la detinator la el), delegatul. */ export function present({ credential, disclosures = [], reveal = [], presenter, delegations = [], audience, nonce, now = new Date() }) { if (!presenter || !presenter.privat) throw new Error('aere-identity: presenting needs the presenter\'s private keys'); if (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce) throw new Error('aere-identity: a presentation needs the verifier\'s audience and nonce'); const dupa = new Map(disclosures.map((e) => [decodeDisclosure(e).name, e])); const alese = []; // o afirmatie in clar se vede oricum: numele ei in `reveal` nu cere nimic const inClar = (credential && credential.statement && credential.statement.claims) || {}; for (const n of [...new Set(reveal)].sort()) { if (Object.hasOwn(inClar, n)) continue; if (!dupa.has(n)) throw new Error('aere-identity: no disclosure for ' + n); alese.push(dupa.get(n)); } const binding = { v: VERSION, kind: 'aere-presentation-binding', credentialHash: credentialHash(credential), disclosuresHash: hashOf(alese), delegations: delegations.map(delegationHash), audience, nonce, createdAt: iso(now), presenter: { id: presenter.id, keys: presenter.public } }; return { v: VERSION, kind: 'aere-presentation', credential, disclosures: alese, delegations, binding, signature: signText('presentation', canonical(binding), presenter) }; } /** * Verifica o prezentare. Fiecare verificare e un rand { name, pass, detail }: pass=true tine, false nu tine, null NEJUDECAT (spus de ce). * valid = niciun rand fals. `claims` (afirmatiile in clar si cele dezvaluite) se intorc numai pentru o prezentare valida. */ export function verifyPresentation(p, { audience = null, nonce = null, now = new Date(), trustedIssuers = null, statusLists = [], revocations = [], maxAgeS = 300 } = {}) { const rows = []; const ok = (name, pass, detail = '') => { rows.push({ name, pass: !!pass, detail: pass ? '' : detail }); return !!pass; }; const nejudecat = (name, detail) => rows.push({ name, pass: null, detail }); const acum = new Date(now).getTime(); const gata = (claims = null) => { const valid = rows.every((r) => r.pass !== false); return { valid, rows, notJudged: rows.filter((r) => r.pass === null).length, claims: valid ? claims : null }; }; // configuratia verificatorului se valideaza inainte (o cheie de incredere stricata e o greseala a lui, nu o prezentare invalida) const idsIncredere = trustedIssuers == null ? null : trustedIssuers.map((x) => (typeof x === 'string' && ID.test(x) ? x : idOf(x))); try { if (!p || p.kind !== 'aere-presentation' || p.v !== VERSION || !p.credential || !p.binding || !Array.isArray(p.disclosures) || !Array.isArray(p.delegations)) { ok('presentation: well formed', false, 'not an aere-presentation'); return gata(); } const c = p.credential, S = c.statement, B = p.binding; if (!S || S.kind !== 'aere-credential' || S.v !== VERSION || !S.issuer || !S.holder || !Array.isArray(S.sd) || !S.claims || typeof S.claims !== 'object' || Array.isArray(S.claims)) { ok('credential: well formed', false, 'not an aere-credential'); return gata(); } // 1. emitentul si detinatorul, legati de chei let idE = null, idH = null; try { idE = idOf(S.issuer.keys); idH = idOf(S.holder.keys); } catch (e) { /* randurile de mai jos spun */ } ok('credential: the issuer id is the id of its keys', idE && idE === S.issuer.id, `issuer id ${S.issuer.id} is not derived from the keys in the credential`); ok('credential: the holder id is the id of its keys', idH && idH === S.holder.id, `holder id ${S.holder.id} is not derived from the keys in the credential`); ok(`credential: signed by ${String(S.issuer.id)} (Ed25519 and ML-DSA-65, both)`, verifyText('credential', canonical(S), c.signature, S.issuer.keys), 'the hybrid signature does not verify with the issuer\'s keys'); if (trustedIssuers == null) nejudecat('credential: issuer trusted', 'not judged: anyone can issue a credential with their own keys; pass trustedIssuers (ids or public keys) to require who issued'); else { ok('credential: issuer trusted', idsIncredere.includes(S.issuer.id), `${S.issuer.id} is not among the ${idsIncredere.length} trusted issuer(s)`); } // 2. fereastra, pe ceasul verificatorului const vf = data(S.validFrom, 'validFrom'), vu = data(S.validUntil, 'validUntil'); ok(`credential: valid at ${iso(acum)}`, vf <= acum && acum <= vu, `valid from ${S.validFrom} until ${S.validUntil}`); // 3. starea (revocarea) credentialului if (!S.status) nejudecat('credential: status', 'the credential names no status list, so its issuer cannot revoke it'); else { const liste = statusLists.filter((l) => l && l.statement && l.statement.id === S.status.list); // o lista stricata (adusa de pe retea, de pilda) nu acuza credentialul: e ignorata, ca una a altui emitent const aEmitentului = liste.filter((l) => { try { return l.statement.kind === 'aere-status-list' && l.statement.issuer && l.statement.issuer.id === S.issuer.id && canonical(l.statement.issuer.keys) === canonical(S.issuer.keys) && verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch { return false; } }); // numai listele emitentului valabile ACUM; cu mai multe, un bit pus in oricare inseamna revocat (revocarea nu se ridica) const curente = aEmitentului.filter((l) => { try { return data(l.statement.validFrom, 'status validFrom') <= acum && acum <= data(l.statement.validUntil, 'status validUntil'); } catch { return false; } }); if (!aEmitentului.length) nejudecat(`credential: status in ${S.status.list}`, liste.length ? 'the status list(s) given with this id are not signed by the issuer: ignored' : 'the status list was not handed to the verifier; a revocation it was not handed cannot be seen'); else if (!curente.length) nejudecat(`credential: status in ${S.status.list}`, `the issuer's status list(s) given are not valid at ${iso(acum)}: fetch a current one`); else { let rev = false, citite = 0; for (const L of curente) { try { rev = statusBit(L, S.status.index) || rev; citite++; } catch (e) { ok(`credential: status in ${S.status.list}`, false, String(e.message)); } } if (citite) ok(`credential: not revoked (status list ${S.status.list}, index ${S.status.index})`, !rev, 'the issuer revoked this credential'); } } // 4. dezvaluirile: fiecare semnata (digestul in sd), o singura data, fara sa acopere o afirmatie in clar const sd = new Set(S.sd); ok('credential: the digests it signs are distinct', sd.size === S.sd.length, 'a digest appears twice in sd'); const dezvaluite = []; const vazuteD = new Set(), vazuteN = new Set(); let bune = true; for (const enc of p.disclosures) { let d; try { d = decodeDisclosure(enc); } catch (e) { bune = ok('disclosure: readable', false, e.message); continue; } const dg = digestOf(enc); if (!sd.has(dg)) { bune = ok(`disclosure ${d.name}: signed by the issuer`, false, 'its digest is not in the credential'); continue; } if (vazuteD.has(dg) || vazuteN.has(d.name)) { bune = ok(`disclosure ${d.name}: shown once`, false, 'the same claim is disclosed twice'); continue; } if (Object.hasOwn(S.claims, d.name)) { bune = ok(`disclosure ${d.name}: does not cover a plain claim`, false, 'the credential has this claim in the clear too'); continue; } vazuteD.add(dg); vazuteN.add(d.name); dezvaluite.push(d); } if (bune) ok(`disclosures: ${dezvaluite.length} shown, each signed by the issuer, once`, true); for (const n of Object.keys(S.claims)) if (!numeValid(n)) ok(`credential: claim name ${n}`, false, 'a claim name that is not allowed'); // 5. legatura prezentarii: ce credential, ce dezvaluiri, ce lant, cine prezinta if (!B || B.kind !== 'aere-presentation-binding' || B.v !== VERSION || !B.presenter) { ok('presentation: binding well formed', false, 'not an aere-presentation-binding'); return gata(); } ok('presentation: names this credential', B.credentialHash === credentialHash(c), 'the binding names another credential'); ok('presentation: names exactly these disclosures', B.disclosuresHash === hashOf(p.disclosures), 'disclosures added, removed or changed after signing'); ok('presentation: names exactly this delegation chain', Array.isArray(B.delegations) && canonical(B.delegations) === canonical(p.delegations.map(delegationHash)), 'the delegation chain is not the one signed'); const lant = p.delegations; const asteptat = lant.length ? lant[lant.length - 1].statement && lant[lant.length - 1].statement.to : S.holder; const cine = lant.length ? 'the last delegate' : 'the holder'; ok(`presentation: presented by ${cine}`, asteptat && B.presenter.id === asteptat.id && canonical(B.presenter.keys) === canonical(asteptat.keys), `presented by ${B.presenter.id}, expected ${asteptat && asteptat.id}`); ok('presentation: signed by the presenter (Ed25519 and ML-DSA-65, both)', verifyText('presentation', canonical(B), p.signature, B.presenter.keys), 'the hybrid signature does not verify with the presenter\'s keys'); // 6. publicul, nonce-ul, prospetimea if (audience == null) nejudecat('presentation: audience', 'not judged: without the verifier\'s own audience a presentation made for another verifier is accepted'); else ok(`presentation: made for ${audience}`, B.audience === audience, `made for ${B.audience}`); if (nonce == null) nejudecat('presentation: nonce', 'not judged: without the verifier\'s nonce an old presentation can be replayed'); else ok('presentation: carries the verifier\'s nonce', B.nonce === nonce, 'another nonce'); const t = data(B.createdAt, 'createdAt'); ok(`presentation: made within ${maxAgeS} s of the verifier's clock`, Math.abs(acum - t) <= maxAgeS * 1000, `made at ${B.createdAt}`); // 7. lantul de delegare if (lant.length > MAX_CHAIN) { ok('delegation: chain length', false, `${lant.length} links, at most ${MAX_CHAIN}`); return gata(); } for (let i = 0; i < lant.length; i++) { const D = lant[i] && lant[i].statement, et = `delegation ${i + 1}/${lant.length}`; if (!D || D.kind !== 'aere-delegation' || D.v !== VERSION || !D.from || !D.to || !D.scope) { ok(`${et}: well formed`, false, 'not an aere-delegation'); continue; } const dela = i === 0 ? S.holder : lant[i - 1].statement.to; let idF = null, idT = null; try { idF = idOf(D.from.keys); idT = idOf(D.to.keys); } catch { /* spus mai jos */ } ok(`${et}: from and to are the ids of their keys`, idF === D.from.id && idT === D.to.id, 'an id not derived from its keys'); ok(`${et}: given by ${i === 0 ? 'the holder' : 'the previous delegate'}`, dela && D.from.id === dela.id && canonical(D.from.keys) === canonical(dela.keys), `given by ${D.from.id}`); ok(`${et}: names its parent link`, D.parent === (i === 0 ? null : delegationHash(lant[i - 1])), 'the parent hash is not the previous link'); ok(`${et}: signed by who gave it (Ed25519 and ML-DSA-65, both)`, verifyText('delegation', canonical(D), lant[i].signature, D.from.keys), 'the hybrid signature does not verify'); let sc = null; try { sc = scopNormal(D.scope); } catch (e) { ok(`${et}: scope`, false, e.message); } if (sc && canonical(sc) !== canonical(D.scope)) ok(`${et}: scope in normal form`, false, 'the scope is not sorted or has duplicates'); const nb = data(D.notBefore, 'notBefore'), na = data(D.notAfter, 'notAfter'); ok(`${et}: valid at ${iso(acum)} and when the presentation was made`, nb <= acum && acum <= na && nb <= t && t <= na, `valid from ${D.notBefore} until ${D.notAfter}`); ok(`${et}: allows the links after it`, Number.isInteger(D.maxDepth) && D.maxDepth >= lant.length - 1 - i, `maxDepth ${D.maxDepth}, ${lant.length - 1 - i} link(s) after it`); if (i > 0 && sc) { const P = lant[i - 1].statement; const ingust = ['credentials', 'claims', 'audiences'].every((k) => inclus(sc[k], P.scope[k])); ok(`${et}: only narrows the previous link`, ingust && nb >= data(P.notBefore, 'notBefore') && na <= data(P.notAfter, 'notAfter') && D.maxDepth <= P.maxDepth - 1, 'a wider scope, a wider window or a deeper delegation than the link it comes from'); } if (sc) { ok(`${et}: covers this credential`, permite(sc.credentials, S.id), `${S.id} is outside its scope`); const afara = dezvaluite.map((d) => d.name).filter((n) => !permite(sc.claims, n)); ok(`${et}: covers the disclosed claims`, !afara.length, 'outside its scope: ' + afara.join(', ')); ok(`${et}: covers the audience`, permite(sc.audiences, B.audience), `${B.audience} is outside its scope`); } // revocarile: semnate de cel care a dat veriga sau de detinator, pe ceasul verificatorului const h = delegationHash(lant[i]); for (const r of revocations) { const R = r && r.statement; if (!R || R.kind !== 'aere-revocation' || R.target !== h) continue; try { canonical(R); } catch { nejudecat(`${et}: a revocation`, 'ignored: not readable'); continue; } const autor = R.by && (R.by.id === D.from.id || R.by.id === S.holder.id); let idR = null; try { idR = idOf(R.by.keys); } catch { /* ignorata */ } if (!autor || idR !== R.by.id || !verifyText('revocation', canonical(R), r.signature, R.by.keys)) { nejudecat(`${et}: a revocation`, `ignored: not signed by who gave the link or by the holder (${R.by && R.by.id})`); continue; } let at = null; try { at = data(R.at, 'revocation at'); } catch { nejudecat(`${et}: a revocation`, 'ignored: its time is not an RFC 3339 UTC time'); continue; } ok(`${et}: not revoked`, at > acum, `revoked by ${R.by.id} at ${R.at}`); } } if (lant.length && !revocations.length) nejudecat('delegation: revocations', 'none given; a revocation the verifier was not handed cannot be seen'); const claims = Object.fromEntries([...Object.entries(S.claims), ...dezvaluite.map((d) => [d.name, d.value])].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); return gata(claims); } catch (e) { ok('presentation: readable', false, String(e && e.message || e).slice(0, 200)); return gata(); } } // ---------------------------------------------------------------- plicuri AIP-23 (notarizare: un moment pe care nu il alege emitentul) // `buildProof` e cel din proof-kinds (../proof-kinds/proof-kinds.mjs), dat de cine cheama, ca modulul de fata sa nu depinda de el. /** Plicul `identity` al unui credential: legatura id-chei a detinatorului si digestul credentialului, datat cu issuedAt. */ export function proofOfCredential(credential, buildProof) { const S = credential.statement; return buildProof('identity', { subjectId: S.holder.id, publicKey: canonical(S.holder.keys), subjectHash: credentialHash(credential), method: ALG, createdAt: S.issuedAt }); } /** Plicul `authorization` al unei verigi de delegare: cine, cui, ce scop, pana cand; politica = digestul verigii. */ export function proofOfDelegation(delegation, buildProof) { const D = delegation.statement; return buildProof('authorization', { grantor: D.from.id, grantee: D.to.id, scope: canonical(D.scope), expiresAt: D.notAfter, policyHash: delegationHash(delegation), createdAt: D.issuedAt }); }