From b5e1628265533583c16c545602aa949c072ea0d5 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Wed, 30 Sep 2026 09:18:53 +0300 Subject: [PATCH] identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files --- README.md | 4 +- identity/README.md | 99 ++++++ identity/control-negativ-identity.mjs | 102 +++++++ identity/identity-cli.mjs | 97 ++++++ identity/identity.mjs | 417 ++++++++++++++++++++++++++ identity/proba-identity.mjs | 309 +++++++++++++++++++ 6 files changed, 1027 insertions(+), 1 deletion(-) create mode 100644 identity/README.md create mode 100644 identity/control-negativ-identity.mjs create mode 100644 identity/identity-cli.mjs create mode 100644 identity/identity.mjs create mode 100644 identity/proba-identity.mjs diff --git a/README.md b/README.md index 5fe4787..1d6fcd8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP | [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass | | [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again | | [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence | +| [`identity/`](identity/) | post-quantum credentials: an issuer signs claims (hybrid Ed25519 + ML-DSA-65, both required), the holder shows only the claims it picks (selective disclosure in the manner of SD-JWT, RFC 9901, in a format of its own), bound to the verifier's audience and nonce; delegation to a phone, an agent or a ten-minute session key that can only narrow; revocation and an issuer status list in the manner of W3C Bitstring Status List; all checked offline from the files | Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them. @@ -34,6 +35,7 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test | | readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) | | control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8 | remediation 7/7, compliance report 3/3 in this repository | +| identity | 43/43 (`node proba-identity.mjs`, with the AIP-23 verifier for its envelope test; without it that test is reported as skipped and the exit code is 2), measured 2026-09-30 | 46/46 (`node control-negativ-identity.mjs`) | | agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc `) | 26/26 (`node control-negativ-aprobare.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository | Code comments, most function and variable names (also many exported between the files of a component), test names and control @@ -43,4 +45,4 @@ interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ... ## Licence -MIT, see [LICENSE](LICENSE). Files: 132 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 17, agents 34, readiness 4). +MIT, see [LICENSE](LICENSE). Files: 137 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 17, agents 34, identity 5, readiness 4). diff --git a/identity/README.md b/identity/README.md new file mode 100644 index 0000000..e3b4c55 --- /dev/null +++ b/identity/README.md @@ -0,0 +1,99 @@ +# Aere Identity + +Post-quantum credentials with selective disclosure, bound to the holder's key, with delegation to devices, agents and short-lived +session keys, revocation and an issuer status list. Everything verifies offline, by anyone, from the files alone. Node.js 24, no +dependencies (`node:crypto` provides Ed25519 and ML-DSA-65, FIPS 204). + +``` +node identity-cli.mjs keygen --out issuer.keys.json +node identity-cli.mjs keygen --out holder.keys.json +node identity-cli.mjs pub --keys holder.keys.json --out holder.pub.json +node identity-cli.mjs issue --issuer-keys issuer.keys.json --holder-pub holder.pub.json --type MemberCredential \ + --claim member=true --claim tier=gold --claim org=Example --disclosable member,tier \ + --status-list urn:example:status:1 --status-index 5 --out cred.json # give cred.json to the holder only +node identity-cli.mjs status-list --issuer-keys issuer.keys.json --id urn:example:status:1 --revoke 7 --out list.json +node identity-cli.mjs present --cred cred.json --reveal member --presenter-keys holder.keys.json \ + --audience https://shop.example --nonce --out presentation.json +node identity-cli.mjs verify --presentation presentation.json --audience https://shop.example --nonce \ + --trust-issuer --status-list list.json +``` + +`verify` prints one line per check (`ok`, `FAIL`, or `--` for not judged, with the reason) and `VALID` or `INVALID`; it exits 0 on +VALID, 1 on INVALID, 2 on a usage error. The claims it returns are the plain ones plus the ones the holder chose to show. + +## What is signed, and by whom + +**Signatures are hybrid**: Ed25519 and ML-DSA-65 over the same message, and both are required, so a break of either scheme alone +forges nothing. Every message is domain-separated by purpose (`aere-identity/v1/\n` + text, purposes `credential`, +`presentation`, `delegation`, `revocation`, `status-list`), so a signature made for one purpose is not valid for another over the same +text. The signed text is the canonical JSON of the statement (keys sorted), rebuilt by the verifier. + +**An identity is its keys**: `aere-id:` + the first 20 bytes of SHA-256 over the canonical form of the two public keys (SPKI). A +credential or a delegation that names an id not derived from the keys it carries fails. + +**Selective disclosure** works the way SD-JWT does (IETF RFC 9901), in a format of its own: each disclosable claim becomes +`[salt, name, value]`, base64url-encoded; the issuer signs only the SHA-256 digests of those encodings (the `sd` list, sorted, with +optional decoy digests so the number of claims is hidden), and the holder shows only the ones it picks. Claims that are not disclosable +sit in the clear. This is not SD-JWT: the signatures are hybrid and the encoding is canonical JSON, so SD-JWT wallets do not read it. +It is also not a zero-knowledge proof: a shown claim is shown whole, so a birth date shows the date; an issuer that wants "over 18" +issues `age_over_18: true` as its own claim. + +**A presentation is bound to one verifier**: the presenter signs the credential's hash, the hash of the disclosures it shows, the +delegation chain, the verifier's audience and nonce, and the time. Without the verifier's own audience and nonce a presentation made +for someone else would be accepted, so `verify` reports those checks as not judged instead of passing them. A nonce is the +verifier's to make fresh and to accept once: a verifier that reuses one accepts a replay within the presentation's age limit. + +Inputs the verifier fetched and cannot read (a malformed status list or revocation) are ignored and reported, never counted against +the credential; a malformed trusted issuer key is the verifier's own error and stops the verification. + +## Delegation + +A holder can let another key present its credentials: a phone, an AI agent, a session key valid for ten minutes. + +``` +node identity-cli.mjs delegate --from-keys holder.keys.json --to-pub phone.pub.json --credentials \ + --claims member,tier --audiences '*' --valid-minutes 1440 --max-depth 1 --out phone.delegation.json +node identity-cli.mjs delegate --from-keys phone.keys.json --to-pub session.pub.json --parent phone.delegation.json \ + --credentials --claims member --audiences https://shop.example --valid-minutes 10 --out session.delegation.json +node identity-cli.mjs present --cred cred.json --reveal member --presenter-keys session.keys.json \ + --delegation phone.delegation.json --delegation session.delegation.json --audience https://shop.example --nonce --out p.json +node identity-cli.mjs revoke --keys holder.keys.json --delegation phone.delegation.json --out revocation.json +``` + +Each link is signed by whoever gives it, names its parent link by hash, and can only narrow: its scope (which credentials, which +disclosable claims, which audiences) is inside the parent's, its window inside the parent's window, its depth below the parent's. The +first link is given by the credential's holder, and the presenter must be the last delegate. A link is revoked by a statement signed by +whoever gave it or by the holder; it applies once its time has passed on the verifier's clock, and `verify` takes the revocations it is +handed with `--revocation` (a revocation it was not handed cannot be seen, and the verdict says so). + +## Status list + +The issuer's status list follows W3C Bitstring Status List v1.0: a bitstring (bit 0 is the most significant bit of the first byte), +gzip-compressed, signed by the issuer, with a validity window; the credential names the list and its index. A list that is missing, +signed by someone else, or outside its window leaves the status not judged, never "not revoked". With several current lists of the +issuer, a bit set in any of them means revoked. A list is decompressed with a ceiling at its declared size (at most 2^24 bits). + +## Time, said exactly + +`issuedAt`, `validFrom` and `validUntil` are the issuer's statements; the presentation time is the presenter's, bounded by the +verifier's clock (`--max-age`, default 300 s, both ways); a revocation time is the revoker's. Everything is judged on the verifier's +clock. For a time the issuer does not choose, notarize: `proofOfCredential` and `proofOfDelegation` (in `identity.mjs`) build AIP-23 +envelopes (`identity` and `authorization` kinds of `../proof-kinds`) that the Aere Proof API notarizes and that the AIP-23 reference +verifier checks. + +## What it does not do + +It does not bind a key to hardware: a device key is a key like any other, and no TPM or secure-enclave attestation is checked here. It +does not rotate or recover keys. It does not say who an identity is in the world: the issuer says that, and the verifier chooses which +issuers it believes (`--trust-issuer`). Without `--trust-issuer`, anyone can issue a credential with their own keys, and the issuer +line is reported as not judged. The private key files are written with mode 0600, which Windows does not apply. + +## Tests + +``` +node proba-identity.mjs # 43: the paths above, and each attack of the adversarial review as its own test +node control-negativ-identity.mjs # on a copy, each of 46 guards removed -> its own named test turns red +``` + +The envelope test needs the AIP-23 reference verifier (`AERE_VERIFY_PROOF=`); without it that test is reported as +not measured and the suite exits 2. No third party has reviewed any of this. diff --git a/identity/control-negativ-identity.mjs b/identity/control-negativ-identity.mjs new file mode 100644 index 0000000..1c56953 --- /dev/null +++ b/identity/control-negativ-identity.mjs @@ -0,0 +1,102 @@ +// Controlul negativ al probei AERE Identity: fiecare paznic se strica intr-o COPIE a dosarului, proba ruleaza pe copie si proba NUMITA +// trebuie sa iasa rosie, cu proba chiar rulata (rezumatul ei exista); pe copia neatinsa, verde. Trei stari: o plantare al carei tipar +// nu apare exact o data, sau o proba care nu ajunge la rezumat, e STRICAT si se numara esec (rosul ei nu masoara nimic). +// node control-negativ-identity.mjs iesire 0 = martorul verde si toate plantarile rosii pe proba lor +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const DEV_VERIFY = path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs'); +const VERIFY = process.env.AERE_VERIFY_PROOF || (fs.existsSync(DEV_VERIFY) ? DEV_VERIFY : ''); +const L = 'identity.mjs', C = 'identity-cli.mjs'; +const PLANTARI = [ + // [nume, fisier, tipar, inlocuire, proba (inceputul numelui ei)] + ['o dezvaluire nesemnata de emitent primita', L, 'if (!sd.has(dg)) {', 'if (false) {', 'ATAC: o dezvaluire fabricata'], + ['aceeasi dezvaluire primita de doua ori', L, 'if (vazuteD.has(dg) || vazuteN.has(d.name)) {', 'if (false) {', 'ATAC: aceeasi dezvaluire de doua ori'], + ['o dezvaluire care acopera o afirmatie in clar primita', L, 'if (Object.hasOwn(S.claims, d.name)) {', 'if (false) {', 'ATAC: un emitent semneaza in sd o afirmatie'], + ['digesturile dublate din sd primite', L, "ok('credential: the digests it signs are distinct', sd.size === S.sd.length,", "ok('credential: the digests it signs are distinct', true,", 'ATAC: un digest de doua ori in sd'], + ['numele rezervate (__proto__) primite', L, "return typeof n === 'string' && NUME.test(n) && !REZERVATE.has(n);", "return typeof n === 'string' && NUME.test(n);", 'ATAC: o dezvaluire cu numele __proto__'], + ['alta codare base64url a aceleiasi dezvaluiri primita', L, "if (b.toString('base64url') !== enc) throw", 'if (false) throw', 'ATAC: o dezvaluire in alta codare base64url'], + ['publicul verificatorului nu se mai compara', L, 'B.audience === audience,', 'true,', 'ATAC: reluata la alt verificator'], + ['nonce-ul verificatorului nu se mai compara', L, 'B.nonce === nonce,', 'true,', 'ATAC: reluata la alt verificator'], + ['prospetimea prezentarii nu se mai cere', L, 'Math.abs(acum - t) <= maxAgeS * 1000,', 'true,', 'ATAC: prezentare veche'], + ['oricine poate prezenta credentialul detinatorului', L, 'asteptat && B.presenter.id === asteptat.id && canonical(B.presenter.keys) === canonical(asteptat.keys),', 'true,', 'ATAC: un strain prezinta'], + ['prezentatorul nu mai trebuie sa fie ultimul delegat', L, 'asteptat && B.presenter.id === asteptat.id && canonical(B.presenter.keys) === canonical(asteptat.keys),', 'true,', 'ATAC: detinatorul prezinta singur'], + ['dezvaluirile nu mai sunt legate de semnatura prezentarii', L, 'B.disclosuresHash === hashOf(p.disclosures),', 'true,', 'ATAC: dezvaluiri adaugate dupa semnare'], + ['lantul de delegare nu mai e legat de semnatura prezentarii', L, 'canonical(B.delegations) === canonical(p.delegations.map(delegationHash)),', 'true,', 'ATAC: lantul scos sau inversat'], + ['fara separare de domeniu (acelasi mesaj pe toate scopurile)', L, "return Buffer.from(DOMENIU + scop + '\\n' + text, 'utf8');", "return Buffer.from(text, 'utf8');", 'ATAC: semnatura de DELEGARE'], + ['una din cele doua semnaturi ajunge', L, "Buffer.from(sig.ed25519, 'base64')) && crypto.verify(", "Buffer.from(sig.ed25519, 'base64')) || crypto.verify(", 'ATAC: partea ML-DSA a semnaturii'], + ['algoritmul declarat nu se mai cere', L, "if (!sig || sig.alg !== ALG || typeof sig.ed25519 !== 'string'", "if (!sig || typeof sig.ed25519 !== 'string'", 'ATAC: partea Ed25519 lipsa sau alg retrogradat'], + ['id-ul emitentului nu mai e derivat din chei', L, "ok('credential: the issuer id is the id of its keys', idE && idE === S.issuer.id,", "ok('credential: the issuer id is the id of its keys', true,", 'ATAC: id-ul emitentului schimbat'], + ['tipul cheii publice nu se mai verifica', L, 'if (k.asymmetricKeyType !== tip) throw', 'if (false) throw', 'ATAC: o cheie ML-DSA pusa in campul ed25519'], + ['emitentii de incredere nu se mai cer', L, "ok('credential: issuer trusted', idsIncredere.includes(S.issuer.id),", "ok('credential: issuer trusted', true,", 'CONTROL: emitent in afara celor de incredere'], + ['fereastra credentialului nu se mai cere', L, 'vf <= acum && acum <= vu,', 'true,', 'CONTROL: emitent in afara celor de incredere'], + ['un fisier de chei cu partea publica a altcuiva primit', L, 'if (canonical(keys.public) !== canonical(j.public) || keys.id !== j.id) throw', 'if (false) throw', 'cheile: exportKeys'], + ['bitul de revocare ignorat', L, '!rev,', 'true,', 'lista de stare: bitul 42'], + ['cu doua liste, ultima castiga (revocarea se ridica)', L, 'rev = statusBit(L, S.status.index) || rev;', 'rev = statusBit(L, S.status.index);', 'lista de stare: bitul 42'], + ['bitul 0 numarat de la coada octetului', L, 'biti[i >> 3] |= 0x80 >> (i & 7);', 'biti[i >> 3] |= 1 << (i & 7);', 'lista de stare: bitul 0'], + ['o lista a altui emitent crezuta', L, "try { return l.statement.kind === 'aere-status-list'", "try { return true || l.statement.kind === 'aere-status-list'", 'ATAC: o lista cu acelasi id semnata de ALT emitent'], + ['o lista expirata tacuta', L, 'else if (!curente.length) nejudecat(', 'else if (false) nejudecat(', 'lista expirata sau lipsa'], + ['lista decomprimata fara plafon', L, "{ maxOutputLength: S.size / 8 }", '{}', 'ATAC: o lista care se decomprima peste'], + ['marimea declarata a listei nu mai e marginita', L, 'if (!Number.isInteger(S.size) || S.size < 8 || S.size % 8 || S.size > MAX_STATUS_BITS) throw new Error(\'the list declares', 'if (false) throw new Error(\'the list declares', 'ATAC: o lista care se decomprima peste'], + ['veriga poate fi data de oricine', L, 'dela && D.from.id === dela.id && canonical(D.from.keys) === canonical(dela.keys),', 'true,', 'ATAC: lantul rupt'], + ['prima veriga poate fi data de altcineva decat detinatorul', L, 'dela && D.from.id === dela.id && canonical(D.from.keys) === canonical(dela.keys),', 'true,', 'ATAC: prima veriga data de altcineva'], + ['semnatura verigii nu se mai verifica', L, "verifyText('delegation', canonical(D), lant[i].signature, D.from.keys),", 'true,', 'ATAC: o veriga in numele telefonului semnata de un strain'], + ['veriga-parinte nu se mai compara', L, 'D.parent === (i === 0 ? null : delegationHash(lant[i - 1])),', 'true,', 'ATAC: veriga-parinte numita gresit'], + ['adancimea nu se mai cere', L, 'Number.isInteger(D.maxDepth) && D.maxDepth >= lant.length - 1 - i,', 'true,', 'ATAC: adancimea'], + ['re-delegarea poate largi', L, "ok(`${et}: only narrows the previous link`, ingust &&", "ok(`${et}: only narrows the previous link`, true ||", 'ATAC: re-delegarea largeste scopul'], + ['biblioteca scrie o re-delegare mai larga', L, "if (!inclus(sc[k], P.scope[k])) throw", 'if (false) throw', 'ATAC: re-delegarea largeste scopul'], + ['scopul nu mai limiteaza afirmatiile aratate', L, 'ok(`${et}: covers the disclosed claims`, !afara.length,', 'ok(`${et}: covers the disclosed claims`, true,', 'ATAC: delegatul arata o afirmatie din afara scopului'], + ['scopul nu mai limiteaza publicul', L, 'permite(sc.audiences, B.audience),', 'true,', 'ATAC: delegatul arata o afirmatie din afara scopului'], + ['scopul nu mai limiteaza credentialul', L, 'permite(sc.credentials, S.id),', 'true,', 'ATAC: delegatul arata o afirmatie din afara scopului'], + ['fereastra verigii nu se mai cere', L, 'nb <= acum && acum <= na && nb <= t && t <= na,', 'true,', 'ATAC: veriga expirata'], + ['oricine poate revoca o veriga', L, 'const autor = R.by && (R.by.id === D.from.id || R.by.id === S.holder.id);', 'const autor = true;', 'revocarea: detinatorul revoca'], + ['o revocare se aplica inainte de momentul ei', L, 'ok(`${et}: not revoked`, at > acum,', 'ok(`${et}: not revoked`, false,', 'revocarea: detinatorul revoca'], + ['revocarea acceptata sub scopul delegarii', L, "verifyText('revocation', canonical(R), r.signature, R.by.keys)", "verifyText('delegation', canonical(R), r.signature, R.by.keys)", 'ATAC: o revocare cu semnatura detinatorului dar alt scop'], + ['o lista stricata acuza credentialul', L, "verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch { return false; } });", "verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch (e) { throw e; } });", 'intrari stricate date verificatorului'], + ['o revocare stricata acuza prezentarea', L, "try { canonical(R); } catch { nejudecat(`${et}: a revocation`, 'ignored: not readable'); continue; }", '', 'intrari stricate date verificatorului'], + ['linia de comanda suprascrie o cheie', C, 'if (privat && fs.existsSync(f)) throw new Folosire(', 'if (false) throw new Folosire(', 'linia de comanda'], + ['verify din linia de comanda iese 0 si pe INVALID', C, 'return r.valid ? 0 : 1;', 'return 0;', 'linia de comanda'], +]; +const FISIERE = [L, C, 'proba-identity.mjs']; +function copie() { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-id-ctl-')); + fs.mkdirSync(path.join(t, 'aere-identity')); fs.mkdirSync(path.join(t, 'proof-kinds')); + for (const f of FISIERE) fs.copyFileSync(path.join(AICI, f), path.join(t, 'aere-identity', f)); + fs.copyFileSync(path.join(AICI, '..', 'proof-kinds', 'proof-kinds.mjs'), path.join(t, 'proof-kinds', 'proof-kinds.mjs')); + return t; +} +function ruleaza(t) { + const env = { ...process.env }; if (VERIFY) env.AERE_VERIFY_PROOF = VERIFY; else delete env.AERE_VERIFY_PROOF; + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(t, 'aere-identity', 'proba-identity.mjs')], { env }); let out = ''; + const ceas = setTimeout(() => c.kill(), 240000); + c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', (x) => { out += x; }); + c.on('close', (cod) => { clearTimeout(ceas); resolve({ cod, rulat: /aere-identity: \d+\/\d+/.test(out), rosii: out.split('\n').filter((l) => l.startsWith(' RAU ')) }); }); + }); +} +async function planteaza([nume, fisier, din, inl, tinta]) { + const t = copie(); + try { + const f = path.join(t, 'aere-identity', fisier); const src = fs.readFileSync(f, 'utf8'); + if (src.split(din).length !== 2) return [false, ` STRICAT ${nume}: tiparul apare de ${src.split(din).length - 1} ori in ${fisier}`]; + fs.writeFileSync(f, src.replace(din, inl)); + const r = await ruleaza(t); + if (!r.rulat) return [false, ` STRICAT ${nume}: proba nu a ajuns la rezumat (cod ${r.cod})`]; + if (r.rosii.some((l) => l.startsWith(' RAU ' + tinta))) return [true, ` ROSU cum trebuia ${nume} (proba '${tinta}' pica)`]; + return [false, ` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde (${r.rosii.length} rosii altundeva)`]; + } finally { fs.rmSync(t, { recursive: true, force: true }); } +} +const t0 = copie(); const m = await ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true }); +let rele = 0; +if (m.rulat && m.cod === 0 && !m.rosii.length) console.log(' OK martorul: copia neatinsa verde'); +else if (m.rulat && m.cod === 2 && !m.rosii.length && !VERIFY) console.log(' OK martorul: copia neatinsa verde (plicurile AIP-23 NEMASURATE: fara verificator)'); +else { rele++; console.log(` STRICAT martorul nu e verde (cod ${m.cod}, ${m.rosii.length} rosii)`); } +const rez = new Array(PLANTARI.length); let i = 0; +await Promise.all(Array.from({ length: 4 }, async () => { while (i < PLANTARI.length) { const k = i++; rez[k] = await planteaza(PLANTARI[k]); } })); +for (const [bun, linie] of rez) { console.log(linie); if (!bun) rele++; } +console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `DOVEDIT: martorul verde, ${PLANTARI.length} din ${PLANTARI.length} paznici scosi -> proba lor rosie`); +process.exitCode = rele ? 1 : 0; diff --git a/identity/identity-cli.mjs b/identity/identity-cli.mjs new file mode 100644 index 0000000..a8a54d5 --- /dev/null +++ b/identity/identity-cli.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// identity-cli.mjs: linia de comanda a lui AERE Identity (identity.mjs). Iesiri: 0 bun / VALID, 1 INVALID, 2 folosire gresita. +// keygen --out keys.json (0600; refuza sa suprascrie) +// pub --keys keys.json [--out pub.json] partea publica, de dat altora +// id --pub pub.json | --keys keys.json +// issue --issuer-keys k.json --holder-pub h.json --type T --claim name=value ... [--disclosable a,b | --all-disclosable] +// [--valid-days N] [--status-list ID --status-index I] [--decoys N] --out cred.json +// status-list --issuer-keys k.json --id ID [--size BITS] [--revoke i,j] [--valid-days N] --out list.json +// delegate --from-keys k.json --to-pub p.json [--parent d.json] [--credentials ids|*] [--claims names|*] [--audiences a|*] +// [--valid-minutes M] [--max-depth D] --out d.json +// revoke --keys k.json --delegation d.json [--reason R] --out r.json +// present --cred cred.json --reveal a,b --presenter-keys k.json [--delegation d1.json ...] --audience A --nonce N --out p.json +// verify --presentation p.json [--audience A --nonce N] [--trust-issuer id|pub.json ...] [--status-list l.json ...] +// [--revocation r.json ...] [--max-age S] [--json] +// O valoare de --claim se citeste ca JSON daca e JSON (true, 42, {"a":1}), altfel ca text. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as I from './identity.mjs'; + +class Folosire extends Error {} +const argv = process.argv.slice(2); +const cmd = argv[0]; +const get = (n) => { const i = argv.indexOf(n); return i === -1 ? null : (argv[i + 1] ?? null); }; +const toate = (n) => argv.flatMap((a, i) => (a === n && argv[i + 1] != null ? [argv[i + 1]] : [])); +const are = (n) => argv.includes(n); +const cere = (n) => { const v = get(n); if (v == null) throw new Folosire(`${cmd} needs ${n}`); return v; }; +const citeste = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${f}: ${e.message}`); } }; +const scrie = (f, o, privat = false) => { + if (privat && fs.existsSync(f)) throw new Folosire(`${f} exists; a key file is never overwritten`); + fs.writeFileSync(f, JSON.stringify(o, null, 1) + '\n', privat ? { mode: 0o600, flag: 'wx' } : undefined); +}; +const lista = (v) => (v === '*' ? '*' : String(v).split(',').map((x) => x.trim()).filter(Boolean)); +const zile = (n) => new Date(Date.now() + Number(n) * 86400000).toISOString(); + +function main() { + if (cmd === 'keygen') { const k = I.generateKeys(); scrie(cere('--out'), I.exportKeys(k), true); console.log(k.id); return 0; } + if (cmd === 'pub') { const k = I.importKeys(citeste(cere('--keys'))); const o = get('--out'); if (o) scrie(o, k.public); else console.log(JSON.stringify(k.public)); return 0; } + if (cmd === 'id') { const p = get('--pub') ? citeste(get('--pub')) : I.importKeys(citeste(cere('--keys'))).public; console.log(I.idOf(p)); return 0; } + if (cmd === 'issue') { + const issuer = I.importKeys(citeste(cere('--issuer-keys'))); + const claims = {}; + for (const c of toate('--claim')) { + const i = c.indexOf('='); if (i < 1) throw new Folosire('--claim is name=value'); + const n = c.slice(0, i), v = c.slice(i + 1); let val; try { val = JSON.parse(v); } catch { val = v; } + if (Object.hasOwn(claims, n)) throw new Folosire('--claim ' + n + ' given twice'); + claims[n] = val; + } + const disclosable = are('--all-disclosable') ? null : (get('--disclosable') ? lista(get('--disclosable')) : []); + const sl = get('--status-list'); + const r = I.issueCredential({ issuer, holder: citeste(cere('--holder-pub')), type: cere('--type'), claims, disclosable, + validUntil: zile(get('--valid-days') ?? 365), status: sl ? { list: sl, index: Number(cere('--status-index')) } : null, decoys: Number(get('--decoys') ?? 0) }); + scrie(cere('--out'), { v: 1, kind: 'aere-credential-with-disclosures', credential: r.credential, disclosures: r.disclosures }); + console.log(`issued ${r.credential.statement.id} to ${r.credential.statement.holder.id}: ${Object.keys(r.credential.statement.claims).length} plain claim(s), ${r.disclosures.length} disclosable (the file holds the disclosures: give it to the holder only)`); + return 0; + } + if (cmd === 'status-list') { + const issuer = I.importKeys(citeste(cere('--issuer-keys'))); + const l = I.createStatusList({ issuer, id: cere('--id'), size: Number(get('--size') ?? I.MIN_STATUS_BITS), revoked: get('--revoke') ? lista(get('--revoke')).map(Number) : [], validUntil: zile(get('--valid-days') ?? 7) }); + scrie(cere('--out'), l); console.log(`status list ${l.statement.id}: ${l.statement.size} entries, valid until ${l.statement.validUntil}`); return 0; + } + if (cmd === 'delegate') { + const from = I.importKeys(citeste(cere('--from-keys'))); + const d = I.delegate({ from, to: citeste(cere('--to-pub')), parent: get('--parent') ? citeste(get('--parent')) : null, + scope: { credentials: lista(get('--credentials') ?? '*'), claims: lista(get('--claims') ?? '*'), audiences: lista(get('--audiences') ?? '*') }, + notAfter: new Date(Date.now() + Number(get('--valid-minutes') ?? 60) * 60000).toISOString(), maxDepth: Number(get('--max-depth') ?? 0) }); + scrie(cere('--out'), d); console.log(`delegated ${I.delegationHash(d)}: ${d.statement.from.id} -> ${d.statement.to.id} until ${d.statement.notAfter}`); return 0; + } + if (cmd === 'revoke') { + const r = I.revokeDelegation({ by: I.importKeys(citeste(cere('--keys'))), delegation: citeste(cere('--delegation')), reason: get('--reason') }); + scrie(cere('--out'), r); console.log(`revoked ${r.statement.target} at ${r.statement.at}`); return 0; + } + if (cmd === 'present') { + const c = citeste(cere('--cred')); + if (c.kind !== 'aere-credential-with-disclosures') throw new Folosire('--cred is the file written by issue'); + const p = I.present({ credential: c.credential, disclosures: c.disclosures, reveal: get('--reveal') ? lista(get('--reveal')) : [], + presenter: I.importKeys(citeste(cere('--presenter-keys'))), delegations: toate('--delegation').map(citeste), audience: cere('--audience'), nonce: cere('--nonce') }); + scrie(cere('--out'), p); console.log(`presentation for ${p.binding.audience}: ${p.disclosures.length} claim(s) disclosed`); return 0; + } + if (cmd === 'verify') { + const p = citeste(cere('--presentation')); + const trusted = toate('--trust-issuer').map((t) => (/^aere-id:/.test(t) ? t : citeste(t))); + const r = I.verifyPresentation(p, { audience: get('--audience'), nonce: get('--nonce'), trustedIssuers: trusted.length ? trusted : null, + statusLists: toate('--status-list').map(citeste), revocations: toate('--revocation').map(citeste), maxAgeS: Number(get('--max-age') ?? 300) }); + if (are('--json')) console.log(JSON.stringify(r, null, 1)); + else { + for (const x of r.rows) console.log(`${x.pass === true ? 'ok' : x.pass === false ? 'FAIL' : '--'} ${x.name}${x.detail ? ': ' + x.detail : ''}`); + console.log(r.valid ? `VALID: every present claim holds${r.notJudged ? `; ${r.notJudged} not judged (the -- lines)` : ''}` : 'INVALID'); + if (r.valid) console.log('claims: ' + JSON.stringify(r.claims)); + } + return r.valid ? 0 : 1; + } + throw new Folosire('usage: identity-cli.mjs keygen|pub|id|issue|status-list|delegate|revoke|present|verify ... (see README.md)'); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { process.exitCode = main(); } catch (e) { console.error('error: ' + (e.message || e)); process.exitCode = e instanceof Folosire ? 2 : 1; } +} diff --git a/identity/identity.mjs b/identity/identity.mjs new file mode 100644 index 0000000..92d3d2b --- /dev/null +++ b/identity/identity.mjs @@ -0,0 +1,417 @@ +// 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/'; +const SCOPURI = new Set(['credential', 'presentation', 'delegation', 'revocation', 'status-list']); +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 }); +} diff --git a/identity/proba-identity.mjs b/identity/proba-identity.mjs new file mode 100644 index 0000000..abbd77b --- /dev/null +++ b/identity/proba-identity.mjs @@ -0,0 +1,309 @@ +// Proba AERE Identity: fiecare afirmatie cu perechea ei negativa, si fiecare atac al revizuirii adversariale ca proba numita. +// Offline. Plicurile AIP-23 se judeca cu verificatorul de referinta (AERE_VERIFY_PROOF=, sau, in depozitul de +// dezvoltare, ../aere-proof-protocol/verify.mjs); fara el, acele probe ies NEMASURATE si codul de iesire e 2. +// node proba-identity.mjs iesire 0 = toate cum trebuia, 1 = o proba rosie, 2 = verde dar cu probe nemasurate +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import crypto from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as I from './identity.mjs'; +import { buildProof } from '../proof-kinds/proof-kinds.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const VERIFY = process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs'); +let treceri = 0, sarite = 0; const esecuri = []; +// o proba care intoarce 'SARIT' nu a masurat nimic: nu se numara nici trecuta, nici picata (se numara in `sarite`) +function test(nume, fn) { try { if (fn() === 'SARIT') return; treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' RAU ' + nume + ' -- ' + (e.message || e)); } } +const cere = (c, m) => { if (!c) throw new Error(m); }; +const arunca = (f) => { try { f(); return null; } catch (e) { return e.message; } }; +const clon = (o) => JSON.parse(JSON.stringify(o)); +const fals = (r) => r.rows.filter((x) => x.pass === false).map((x) => x.name + (x.detail ? ': ' + x.detail : '')).join(' | '); +const rand = (r, re) => r.rows.find((x) => re.test(x.name)); +const picaPe = (r, re) => !r.valid && r.rows.some((x) => x.pass === false && re.test(x.name)); + +const NOW = new Date('2026-09-30T06:00:00Z'); +const AUD = 'https://shop.example', NONCE = 'n-7f3a'; +const iss = I.generateKeys(), hol = I.generateKeys(), phone = I.generateKeys(), sess = I.generateKeys(), strain = I.generateKeys(), iss2 = I.generateKeys(); +const CLAIMS = { employer: 'Example Ltd', name: 'Ana Pop', birthdate: '1990-01-01', age_over_18: true, role: 'engineer' }; +const { credential, disclosures } = I.issueCredential({ issuer: iss, holder: hol.public, type: 'EmployeeCredential', claims: CLAIMS, + disclosable: ['name', 'birthdate', 'age_over_18', 'role'], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:example:status:1', index: 42 }, decoys: 3, now: NOW }); +const LISTA = I.createStatusList({ issuer: iss, id: 'urn:example:status:1', revoked: [7], validUntil: '2026-10-07T00:00:00Z', now: NOW }); +const toate = { audience: AUD, nonce: NONCE, now: NOW, trustedIssuers: [iss.id], statusLists: [LISTA] }; +const prez = (o = {}) => I.present({ credential, disclosures, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW, ...o }); +// resemneaza legatura unei prezentari modificate (ca atacatorul sa nu fie prins de semnatura, ci de regula masurata) +const resemneaza = (p, cheie = hol) => { p.binding.credentialHash = I.credentialHash(p.credential); p.binding.disclosuresHash = '0x' + crypto.createHash('sha256').update(I.canonical(p.disclosures)).digest('hex'); + p.binding.delegations = p.delegations.map(I.delegationHash); p.signature = I.signText('presentation', I.canonical(p.binding), cheie); return p; }; +// un emitent rau-intentionat: resemneaza declaratia credentialului dupa ce o schimba +const reemite = (c, cheie = iss) => { c.signature = I.signText('credential', I.canonical(c.statement), cheie); return c; }; + +// ---------------------------------------------------------------- drumul bun +test('detinatorul arata numai age_over_18: VALID, afirmatiile = cele in clar + cea aratata, nimic nejudecat', () => { + const r = I.verifyPresentation(prez(), toate); + cere(r.valid && r.notJudged === 0, fals(r) || 'nejudecate ' + r.notJudged); + cere(JSON.stringify(r.claims) === '{"age_over_18":true,"employer":"Example Ltd"}', JSON.stringify(r.claims)); +}); +test('momelile: sd are cate un digest pentru fiecare afirmatie dezvaluibila plus 3 momeli, si nimic nu arata care e care', () => { + cere(credential.statement.sd.length === 7 && disclosures.length === 4, `${credential.statement.sd.length} digesturi, ${disclosures.length} dezvaluiri`); + cere(!('name' in credential.statement.claims) && !JSON.stringify(credential.statement).includes('Ana Pop'), 'numele e in clar in credential'); +}); +test('fara emitenti de incredere, public si nonce: VALID, dar cele trei randuri sunt NEJUDECATE, spuse, nu trecute', () => { + const r = I.verifyPresentation(prez(), { now: NOW, statusLists: [LISTA] }); + cere(r.valid && r.notJudged === 3 && rand(r, /issuer trusted/).pass === null && rand(r, /audience/).pass === null && rand(r, /nonce/).pass === null, JSON.stringify(r.rows.filter((x) => x.pass !== true))); +}); + +// ---------------------------------------------------------------- dezvaluirile +test('ATAC: o dezvaluire fabricata (acelasi nume, alta sare) -> INVALID, nu e semnata de emitent', () => { + const p = prez(); p.disclosures = [Buffer.from(JSON.stringify([crypto.randomBytes(16).toString('base64url'), 'age_over_18', true])).toString('base64url')]; + const r = I.verifyPresentation(resemneaza(p), toate); cere(picaPe(r, /signed by the issuer/), fals(r) || 'trecut'); +}); +test('ATAC: valoarea unei dezvaluiri schimbata (role=admin) -> INVALID', () => { + const p = prez({ reveal: ['role'] }); const [s] = JSON.parse(Buffer.from(p.disclosures[0], 'base64url').toString()); + p.disclosures = [Buffer.from(JSON.stringify([s, 'role', 'admin'])).toString('base64url')]; + const r = I.verifyPresentation(resemneaza(p), toate); cere(picaPe(r, /signed by the issuer/), fals(r) || 'trecut'); +}); +test('ATAC: aceeasi dezvaluire de doua ori -> INVALID', () => { + const p = prez(); p.disclosures = [p.disclosures[0], p.disclosures[0]]; + const r = I.verifyPresentation(resemneaza(p), toate); cere(picaPe(r, /shown once/), fals(r) || 'trecut'); +}); +test('ATAC: un emitent semneaza in sd o afirmatie pe care o are si in clar (doua valori pentru acelasi nume) -> INVALID', () => { + const c = clon(credential); const d = Buffer.from(JSON.stringify([crypto.randomBytes(16).toString('base64url'), 'employer', 'Other Ltd'])).toString('base64url'); + c.statement.sd = [...c.statement.sd, I.digestOf(d)].sort(); reemite(c); + // present() nu o arata (afirmatia e in clar), deci prezentarea se face de mana, cum ar face-o atacatorul + const p = prez(); p.credential = c; p.disclosures = [d]; + const r = I.verifyPresentation(resemneaza(p), toate); cere(picaPe(r, /does not cover a plain claim/), fals(r) || 'trecut'); +}); +test('ATAC: un digest de doua ori in sd -> INVALID', () => { + const c = clon(credential); c.statement.sd = [...c.statement.sd, c.statement.sd[0]].sort(); reemite(c); + const p = I.present({ credential: c, disclosures, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }); + const r = I.verifyPresentation(p, toate); cere(picaPe(r, /digests it signs are distinct/), fals(r) || 'trecut'); +}); +test('ATAC: o dezvaluire cu numele __proto__ -> refuzata; emiterea cu o asemenea afirmatie -> refuzata', () => { + const p = prez(); p.disclosures = [Buffer.from(JSON.stringify([crypto.randomBytes(16).toString('base64url'), '__proto__', { valid: true }])).toString('base64url')]; + const r = I.verifyPresentation(resemneaza(p), toate); cere(picaPe(r, /readable/), fals(r) || 'trecut'); + const m = arunca(() => I.issueCredential({ issuer: iss, holder: hol.public, type: 'T', claims: JSON.parse('{"__proto__": 1}'), validUntil: '2027-01-01T00:00:00Z', now: NOW })); + cere(/not allowed/.test(m || ''), 'emiterea: ' + m); +}); +test('ATAC: o dezvaluire in alta codare base64url (cu umplutura, sau biti de coada schimbati) -> refuzata', () => { + const e = prez({ reveal: ['role'] }).disclosures[0]; // 44 de octeti: base64url cu biti de coada liberi (45 n-ar avea) + cere(/canonical|not base64url/.test(arunca(() => I.decodeDisclosure(e + '=')) || ''), 'cu umplutura trecea'); + // bitii de coada sunt bitii de JOS ai ultimului caracter: acelasi caracter cu ei schimbati da aceiasi octeti + const AB = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_', ult = e[e.length - 1]; + const alt = [1, 2, 3].map((k) => AB[AB.indexOf(ult) ^ k]).find((x) => Buffer.from(e.slice(0, -1) + x, 'base64url').equals(Buffer.from(e, 'base64url'))); + cere(alt, 'nicio alta codare a acelorasi octeti: proba nu masoara nimic'); + cere(/canonical/.test(arunca(() => I.decodeDisclosure(e.slice(0, -1) + alt)) || ''), 'bitii de coada schimbati treceau'); +}); + +// ---------------------------------------------------------------- legatura prezentarii +test('ATAC: reluata la alt verificator (alt public) -> INVALID; cu alt nonce -> INVALID', () => { + const r1 = I.verifyPresentation(prez(), { ...toate, audience: 'https://other.example' }); cere(picaPe(r1, /made for/), fals(r1) || 'trecut'); + const r2 = I.verifyPresentation(prez(), { ...toate, nonce: 'alt' }); cere(picaPe(r2, /nonce/), fals(r2) || 'trecut'); +}); +test('ATAC: prezentare veche (301 s) sau din viitor (301 s) -> INVALID', () => { + const r1 = I.verifyPresentation(prez(), { ...toate, now: new Date(NOW.getTime() + 301000) }); cere(picaPe(r1, /within 300 s/), fals(r1) || 'veche trecuta'); + const r2 = I.verifyPresentation(prez(), { ...toate, now: new Date(NOW.getTime() - 301000) }); cere(picaPe(r2, /within 300 s/), fals(r2) || 'viitoare trecuta'); +}); +test('ATAC: un strain prezinta credentialul detinatorului cu cheile lui -> INVALID', () => { + const p = prez(); p.binding.presenter = { id: strain.id, keys: strain.public }; + const r = I.verifyPresentation(resemneaza(p, strain), toate); cere(picaPe(r, /presented by the holder/), fals(r) || 'trecut'); +}); +test('ATAC: dezvaluiri adaugate dupa semnare -> INVALID', () => { + const p = prez(); p.disclosures = [...p.disclosures, disclosures.find((d) => I.decodeDisclosure(d).name === 'name')]; + const r = I.verifyPresentation(p, toate); cere(picaPe(r, /exactly these disclosures/), fals(r) || 'trecut'); +}); +test('ATAC: semnatura de DELEGARE a detinatorului peste textul legaturii pusa drept semnatura de prezentare -> INVALID (separarea de domeniu)', () => { + const p = prez(); p.signature = I.signText('delegation', I.canonical(p.binding), hol); + const r = I.verifyPresentation(p, toate); cere(picaPe(r, /signed by the presenter/), fals(r) || 'trecut'); +}); + +// ---------------------------------------------------------------- semnatura hibrida si identitatea +test('ATAC: partea ML-DSA a semnaturii emitentului facuta cu alta cheie -> INVALID (amandoua cerute)', () => { + const c = clon(credential); c.signature.mldsa65 = I.signText('credential', I.canonical(c.statement), iss2).mldsa65; + const r = I.verifyPresentation(I.present({ credential: c, disclosures, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }), toate); + cere(picaPe(r, /signed by aere-id/), fals(r) || 'trecut'); +}); +test('ATAC: partea Ed25519 lipsa sau alg retrogradat la ml-dsa-65 -> INVALID', () => { + for (const f of [(c) => { delete c.signature.ed25519; }, (c) => { c.signature.alg = 'ml-dsa-65'; }]) { + const c = clon(credential); f(c); + const r = I.verifyPresentation(I.present({ credential: c, disclosures, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }), toate); + cere(picaPe(r, /signed by aere-id/), fals(r) || 'trecut'); + } +}); +test('ATAC: id-ul emitentului schimbat cu id-ul unui emitent de incredere (cheile raman ale lui) -> INVALID', () => { + const c = clon(credential); c.statement.issuer.id = iss2.id; reemite(c); + const r = I.verifyPresentation(I.present({ credential: c, disclosures, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }), { ...toate, trustedIssuers: [iss2.id] }); + cere(picaPe(r, /issuer id is the id of its keys/), fals(r) || 'trecut'); +}); +test('ATAC: o cheie ML-DSA pusa in campul ed25519 (confuzie de tip) -> refuzata', () => { + cere(/is a ml-dsa-65 key/.test(arunca(() => I.idOf({ alg: I.ALG, ed25519: hol.public.mldsa65, mldsa65: hol.public.mldsa65 })) || ''), 'acceptata'); +}); +test('CONTROL: emitent in afara celor de incredere -> INVALID; credential expirat sau inca nevalabil -> INVALID', () => { + const r1 = I.verifyPresentation(prez(), { ...toate, trustedIssuers: [iss2.id] }); cere(picaPe(r1, /issuer trusted/), fals(r1) || 'trecut'); + const later = new Date('2027-10-01T00:00:00Z'); + const r2 = I.verifyPresentation(I.present({ credential, disclosures, reveal: [], presenter: hol, audience: AUD, nonce: NONCE, now: later }), { ...toate, now: later }); cere(picaPe(r2, /valid at/), fals(r2) || 'expirat trecut'); + const early = new Date('2026-09-29T00:00:00Z'); + const r3 = I.verifyPresentation(I.present({ credential, disclosures, reveal: [], presenter: hol, audience: AUD, nonce: NONCE, now: early }), { ...toate, now: early }); cere(picaPe(r3, /valid at/), fals(r3) || 'inainte trecut'); +}); +test('cheile: exportKeys -> importKeys pastreaza id-ul; un fisier cu partea publica a altcuiva e refuzat', () => { + const j = I.exportKeys(hol); cere(I.importKeys(clon(j)).id === hol.id, 'id schimbat'); + const r = clon(j); r.public = strain.public; cere(/not those of its private keys/.test(arunca(() => I.importKeys(r)) || ''), 'acceptat'); + cere(!JSON.stringify(hol).includes('PRIVATE') && !('privat' in JSON.parse(JSON.stringify(hol))), 'partea privata iese la JSON.stringify'); +}); + +// ---------------------------------------------------------------- lista de stare +test('lista de stare: bitul 42 nepus -> nerevocat; o lista cu 42 pus -> INVALID ("the issuer revoked")', () => { + const rev = I.createStatusList({ issuer: iss, id: 'urn:example:status:1', revoked: [42], validUntil: '2026-10-07T00:00:00Z', now: NOW }); + const r = I.verifyPresentation(prez(), { ...toate, statusLists: [rev] }); cere(picaPe(r, /not revoked/), fals(r) || 'trecut'); + const r2 = I.verifyPresentation(prez(), { ...toate, statusLists: [rev, LISTA] }); cere(picaPe(r2, /not revoked/), 'cu doua liste, cea care revoca a pierdut: ' + fals(r2)); +}); +test('lista de stare: bitul 0 e bitul cel mai semnificativ al primului octet (W3C), numarat independent', () => { + const l = I.createStatusList({ issuer: iss, id: 'x', size: 16, revoked: [0, 9], validUntil: '2026-10-07T00:00:00Z', now: NOW }); + const b = zlib.gunzipSync(Buffer.from(l.statement.encodedList, 'base64url')); + cere(b[0] === 0x80 && b[1] === 0x40 && I.statusBit(l, 0) && I.statusBit(l, 9) && !I.statusBit(l, 1), b.toString('hex')); +}); +test('ATAC: o lista cu acelasi id semnata de ALT emitent (bitul nepus) -> nu e luata: NEJUDECAT, nu "nerevocat"', () => { + const alta = I.createStatusList({ issuer: iss2, id: 'urn:example:status:1', revoked: [], validUntil: '2026-10-07T00:00:00Z', now: NOW }); + const r = I.verifyPresentation(prez(), { ...toate, statusLists: [alta] }); const x = rand(r, /status in/); + cere(x && x.pass === null && /not signed by the issuer/.test(x.detail), JSON.stringify(x)); +}); +test('lista expirata sau lipsa -> NEJUDECAT, spus', () => { + const r = I.verifyPresentation(prez(), { ...toate, now: new Date('2026-10-08T00:00:00Z'), statusLists: [LISTA] }); + cere(rand(r, /status in/) && rand(r, /status in/).pass === null && /fetch a current one/.test(rand(r, /status in/).detail), JSON.stringify(r.rows.filter((x) => x.pass !== true))); + const r2 = I.verifyPresentation(prez(), { ...toate, statusLists: [] }); cere(rand(r2, /status in/).pass === null, 'lipsa trecuta'); +}); +test('ATAC: o lista care se decomprima peste marimea declarata (bomba gzip) -> refuzata, fara sa se umfle', () => { + const l = clon(LISTA); l.statement.encodedList = zlib.gzipSync(Buffer.alloc(64 * 1024 * 1024)).toString('base64url'); l.signature = I.signText('status-list', I.canonical(l.statement), iss); + const t0 = Date.now(); const r = I.verifyPresentation(prez(), { ...toate, statusLists: [l] }); + cere(picaPe(r, /status in/) && /beyond its declared size/.test(fals(r)) && Date.now() - t0 < 5000, fals(r) || 'trecut'); + const l2 = clon(LISTA); l2.statement.size = I.MAX_STATUS_BITS * 8; l2.signature = I.signText('status-list', I.canonical(l2.statement), iss); + cere(/may not have/.test(arunca(() => I.statusBit(l2, 1)) || ''), 'marimea uriasa primita'); +}); + +// ---------------------------------------------------------------- delegarea +const CID = credential.statement.id; +const d1 = I.delegate({ from: hol, to: phone.public, scope: { credentials: [CID], claims: ['age_over_18', 'role'], audiences: '*' }, notAfter: '2026-12-31T00:00:00Z', maxDepth: 1, now: NOW }); +const d2 = I.delegate({ from: phone, to: sess.public, parent: d1, scope: { credentials: [CID], claims: ['age_over_18'], audiences: [AUD] }, notAfter: '2026-09-30T06:10:00Z', now: NOW }); +const prezD = (lant = [d1, d2], cheie = sess, reveal = ['age_over_18'], o = {}) => I.present({ credential, disclosures, reveal, presenter: cheie, delegations: lant, audience: AUD, nonce: NONCE, now: NOW, ...o }); +// o veriga facuta de mana (fara gardurile bibliotecii), semnata de cine o da: asa lucreaza un atacator +const verigaDeMana = (from, to, parent, scope, extra = {}) => { const st = { v: 1, kind: 'aere-delegation', id: 'urn:uuid:' + crypto.randomUUID(), from: { id: from.id, keys: from.public }, to: { id: to.id, keys: to.public }, + parent: parent ? I.delegationHash(parent) : null, scope, notBefore: '2026-09-30T05:00:00Z', notAfter: '2026-12-31T00:00:00Z', maxDepth: 0, issuedAt: '2026-09-30T05:00:00Z', ...extra }; + return { statement: st, signature: I.signText('delegation', I.canonical(st), from) }; }; + +test('delegare: detinator -> telefon -> cheie de sesiune de 10 minute, arata age_over_18 la magazin: VALID', () => { + const r = I.verifyPresentation(prezD(), toate); + cere(r.valid && r.notJudged === 1 && rand(r, /revocations/).pass === null && r.claims.age_over_18 === true, fals(r) || JSON.stringify(r.rows.filter((x) => x.pass !== true))); +}); +test('ATAC: re-delegarea largeste scopul (claims "*" sub ["age_over_18","role"]) -> INVALID; biblioteca refuza sa o scrie', () => { + const w = verigaDeMana(phone, sess, d1, { credentials: [CID], claims: '*', audiences: '*' }); + const r = I.verifyPresentation(prezD([d1, w], sess, ['name']), toate); cere(picaPe(r, /only narrows/), fals(r) || 'trecut'); + cere(/wider than the parent/.test(arunca(() => I.delegate({ from: phone, to: sess.public, parent: d1, scope: { credentials: [CID], claims: '*', audiences: '*' }, notAfter: '2026-10-01T00:00:00Z', now: NOW })) || ''), 'biblioteca a scris-o'); +}); +test('ATAC: lantul rupt (veriga 2 data de un strain, nu de telefon) -> INVALID', () => { + const w = verigaDeMana(strain, sess, d1, d2.statement.scope); + const r = I.verifyPresentation(prezD([d1, w]), toate); cere(picaPe(r, /given by the previous delegate/), fals(r) || 'trecut'); +}); +test('ATAC: prima veriga data de altcineva decat detinatorul credentialului -> INVALID', () => { + const w = verigaDeMana(strain, sess, null, { credentials: '*', claims: '*', audiences: '*' }); + const r = I.verifyPresentation(prezD([w]), toate); cere(picaPe(r, /given by the holder/), fals(r) || 'trecut'); +}); +test('ATAC: o veriga in numele telefonului semnata de un strain -> INVALID', () => { + const st = clon(d2.statement); const w = { statement: st, signature: I.signText('delegation', I.canonical(st), strain) }; + const r = I.verifyPresentation(prezD([d1, w]), toate); cere(picaPe(r, /signed by who gave it/), fals(r) || 'trecut'); +}); +test('ATAC: veriga-parinte numita gresit (parent = alt hash) -> INVALID', () => { + const w = verigaDeMana(phone, sess, null, d2.statement.scope, { parent: '0x' + '11'.repeat(32) }); + const r = I.verifyPresentation(prezD([d1, w]), toate); cere(picaPe(r, /names its parent link/), fals(r) || 'trecut'); +}); +test('ATAC: adancimea: d1 cu maxDepth 0 si inca o veriga dupa ea -> INVALID', () => { + const d1z = verigaDeMana(hol, phone, null, d1.statement.scope, { maxDepth: 0 }); + const w = verigaDeMana(phone, sess, d1z, d2.statement.scope); + const r = I.verifyPresentation(prezD([d1z, w]), toate); cere(picaPe(r, /allows the links after it/), fals(r) || 'trecut'); +}); +test('ATAC: delegatul arata o afirmatie din afara scopului (role) -> INVALID; alt public -> INVALID; alt credential -> INVALID', () => { + const r1 = I.verifyPresentation(prezD([d1, d2], sess, ['role']), toate); cere(picaPe(r1, /covers the disclosed claims/), fals(r1) || 'role trecut'); + const r2 = I.verifyPresentation(prezD([d1, d2], sess, ['age_over_18'], { audience: 'https://other.example' }), { ...toate, audience: 'https://other.example' }); cere(picaPe(r2, /covers the audience/), fals(r2) || 'public trecut'); + const w = verigaDeMana(hol, sess, null, { credentials: ['urn:uuid:alt'], claims: '*', audiences: '*' }); + const r3 = I.verifyPresentation(prezD([w]), toate); cere(picaPe(r3, /covers this credential/), fals(r3) || 'credential trecut'); +}); +test('ATAC: veriga expirata (sesiunea de 10 minute, la minutul 11) sau inca nevalabila -> INVALID', () => { + const t = new Date(NOW.getTime() + 11 * 60000); + const r = I.verifyPresentation(prezD([d1, d2], sess, ['age_over_18'], { now: t }), { ...toate, now: t }); cere(picaPe(r, /valid at/), fals(r) || 'expirata trecuta'); + const w = verigaDeMana(hol, sess, null, { credentials: '*', claims: '*', audiences: '*' }, { notBefore: '2026-10-01T00:00:00Z' }); + const r2 = I.verifyPresentation(prezD([w]), toate); cere(picaPe(r2, /valid at/), fals(r2) || 'inainte trecuta'); +}); +test('ATAC: detinatorul prezinta singur dar lista lantul (prezentatorul nu e ultimul delegat) -> INVALID', () => { + const r = I.verifyPresentation(prezD([d1, d2], hol), toate); cere(picaPe(r, /presented by the last delegate/), fals(r) || 'trecut'); +}); +test('ATAC: lantul scos sau inversat dupa semnare -> INVALID', () => { + const p = prezD(); const p1 = clon(p); p1.delegations = []; + const r1 = I.verifyPresentation(p1, toate); cere(picaPe(r1, /exactly this delegation chain/), fals(r1) || 'scos trecut'); + const p2 = clon(p); p2.delegations = [p.delegations[1], p.delegations[0]]; + const r2 = I.verifyPresentation(p2, toate); cere(picaPe(r2, /exactly this delegation chain/), fals(r2) || 'inversat trecut'); +}); +test('revocarea: detinatorul revoca veriga telefonului -> INVALID; revocarea unui strain -> ignorata si spusa; una din viitor -> inca nerevocat', () => { + const rv = I.revokeDelegation({ by: hol, delegation: d1, now: NOW }); + const r = I.verifyPresentation(prezD(), { ...toate, revocations: [rv] }); cere(picaPe(r, /not revoked/), fals(r) || 'trecut'); + const rs = I.revokeDelegation({ by: strain, delegation: d1, now: NOW }); + const r2 = I.verifyPresentation(prezD(), { ...toate, revocations: [rs] }); cere(r2.valid && r2.rows.some((x) => x.pass === null && /ignored/.test(x.detail)), fals(r2) || 'strainul a revocat'); + const rf = I.revokeDelegation({ by: phone, delegation: d2, at: '2026-09-30T07:00:00Z', now: NOW }); + const r3 = I.verifyPresentation(prezD(), { ...toate, revocations: [rf] }); cere(r3.valid, 'revocarea din viitor s-a aplicat acum: ' + fals(r3)); + const r4 = I.verifyPresentation(prezD(), { ...toate, now: new Date('2026-09-30T07:00:01Z'), revocations: [rf] }); + cere(picaPe(r4, /not revoked/), 'dupa momentul ei, revocarea nu s-a aplicat: ' + fals(r4)); +}); +test('ATAC: o revocare cu semnatura detinatorului dar alt scop (semnatura de delegare) -> ignorata', () => { + const rv = I.revokeDelegation({ by: hol, delegation: d1, now: NOW }); rv.signature = I.signText('delegation', I.canonical(rv.statement), hol); + const r = I.verifyPresentation(prezD(), { ...toate, revocations: [rv] }); cere(r.valid && r.rows.some((x) => x.pass === null && /ignored/.test(x.detail)), fals(r) || 'aplicata'); +}); + +test('intrari stricate date verificatorului (lista fara chei, revocare cu o valoare necitibila): ignorate si spuse, nu INVALID; o cheie de incredere stricata e o eroare a lui', () => { + const l = clon(LISTA); delete l.statement.issuer.keys; + const r = I.verifyPresentation(prez(), { ...toate, statusLists: [l] }); cere(r.valid && rand(r, /status in/).pass === null, fals(r) || JSON.stringify(rand(r, /status in/))); + const rv = I.revokeDelegation({ by: hol, delegation: d1, now: NOW }); rv.statement.reason = { x: undefined, y: 1 / 0 }; + const r2 = I.verifyPresentation(prezD(), { ...toate, revocations: [rv] }); cere(r2.valid && r2.rows.some((x) => x.pass === null && /not readable/.test(x.detail)), fals(r2) || 'aplicata'); + cere(/public keys must be/.test(arunca(() => I.verifyPresentation(prez(), { ...toate, trustedIssuers: [{ alg: 'x' }] })) || ''), 'cheia de incredere stricata primita'); +}); +test('o afirmatie in clar numita in reveal nu cere dezvaluire (se vede oricum)', () => { + const r = I.verifyPresentation(I.present({ credential, disclosures, reveal: ['employer', 'age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }), toate); + cere(r.valid && r.claims.employer === 'Example Ltd' && r.claims.age_over_18 === true && !('name' in r.claims), fals(r) || JSON.stringify(r.claims)); +}); + +// ---------------------------------------------------------------- plicurile AIP-23 +test('plicurile AIP-23 (identity pentru credential, authorization pentru delegare) verifica la verificatorul de referinta; unul atins nu', () => { + if (!fs.existsSync(VERIFY)) { sarite += 1; console.log(` SARIT plicurile AIP-23: verificatorul nu e la ${VERIFY}; AERE_VERIFY_PROOF=`); return 'SARIT'; } + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-id-')); + try { + const verdict = (o) => { const f = path.join(T, crypto.randomUUID() + '.json'); fs.writeFileSync(f, JSON.stringify(o)); const r = spawnSync(process.execPath, [VERIFY, f, '--json'], { encoding: 'utf8' }); try { return JSON.parse(r.stdout).verdict; } catch { return '?'; } }; + const e1 = I.proofOfCredential(credential, buildProof), e2 = I.proofOfDelegation(d1, buildProof); + cere(e1.statement.subjectId === hol.id && e2.statement.grantor === hol.id && e2.statement.grantee === phone.id, 'campurile plicurilor'); + cere(verdict(e1) === 'VALID' && verdict(e2) === 'VALID', `${verdict(e1)} ${verdict(e2)}`); + const rau = clon(e2); rau.statement.grantee = strain.id; cere(verdict(rau) !== 'VALID', 'plicul atins a iesit VALID'); + } finally { fs.rmSync(T, { recursive: true, force: true }); } +}); + +// ---------------------------------------------------------------- linia de comanda, cap la cap +test('linia de comanda: keygen, pub, issue, status-list, delegate, present, verify (VALID 0, alt public 1, cheie suprascrisa 2)', () => { + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-id-cli-')); const CLI = path.join(AICI, 'identity-cli.mjs'); + const run = (...a) => { const r = spawnSync(process.execPath, [CLI, ...a], { cwd: T, encoding: 'utf8' }); return { cod: r.status, out: (r.stdout || '') + (r.stderr || '') }; }; + try { + for (const n of ['iss', 'hol', 'dev']) cere(run('keygen', '--out', n + '.keys.json').cod === 0, 'keygen ' + n); + cere(run('keygen', '--out', 'iss.keys.json').cod === 2, 'keygen a suprascris o cheie'); + cere(run('pub', '--keys', 'hol.keys.json', '--out', 'hol.pub.json').cod === 0 && run('pub', '--keys', 'dev.keys.json', '--out', 'dev.pub.json').cod === 0, 'pub'); + const iss1 = run('issue', '--issuer-keys', 'iss.keys.json', '--holder-pub', 'hol.pub.json', '--type', 'MemberCredential', '--claim', 'member=true', '--claim', 'tier=gold', '--claim', 'org=Example', + '--disclosable', 'member,tier', '--status-list', 'urn:s:1', '--status-index', '5', '--out', 'cred.json'); + cere(iss1.cod === 0, iss1.out); + cere(run('status-list', '--issuer-keys', 'iss.keys.json', '--id', 'urn:s:1', '--out', 'list.json').cod === 0, 'status-list'); + const cid = JSON.parse(fs.readFileSync(path.join(T, 'cred.json'), 'utf8')).credential.statement.id; + cere(run('delegate', '--from-keys', 'hol.keys.json', '--to-pub', 'dev.pub.json', '--credentials', cid, '--claims', 'member', '--audiences', 'https://a.example', '--valid-minutes', '5', '--out', 'd.json').cod === 0, 'delegate'); + cere(run('present', '--cred', 'cred.json', '--reveal', 'member', '--presenter-keys', 'dev.keys.json', '--delegation', 'd.json', '--audience', 'https://a.example', '--nonce', 'x1', '--out', 'p.json').cod === 0, 'present'); + const issId = run('id', '--keys', 'iss.keys.json').out.trim(); + const v = run('verify', '--presentation', 'p.json', '--audience', 'https://a.example', '--nonce', 'x1', '--trust-issuer', issId, '--status-list', 'list.json'); + cere(v.cod === 0 && /VALID/.test(v.out) && /"member":true/.test(v.out) && !/"tier"/.test(v.out), v.out); + const v2 = run('verify', '--presentation', 'p.json', '--audience', 'https://b.example', '--nonce', 'x1', '--trust-issuer', issId, '--status-list', 'list.json'); + cere(v2.cod === 1 && /INVALID/.test(v2.out), v2.out); + } finally { fs.rmSync(T, { recursive: true, force: true }); } +}); + +console.log(`\naere-identity: ${treceri}/${treceri + esecuri.length} cum trebuia${sarite ? `, ${sarite} NEMASURATE (fara verificatorul AIP-23)` : ''}`); +process.exitCode = esecuri.length ? 1 : (sarite ? 2 : 0);