419 lines
36 KiB
JavaScript
419 lines
36 KiB
JavaScript
// 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/<scop>\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 });
|
|
}
|