identity: post-quantum credentials with selective disclosure, delegation that can only narrow, revocation and an issuer status list, checked offline from the files

This commit is contained in:
Aere Network 2026-09-30 09:18:53 +03:00
parent 8b608fe57f
commit b5e1628265
6 changed files with 1027 additions and 1 deletions

View File

@ -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 | | [`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 | | [`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 | | [`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. 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 | | 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) | | 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 | | 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 <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 | | 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 <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 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 ## 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).

99
identity/README.md Normal file
View File

@ -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 <the verifier's nonce> --out presentation.json
node identity-cli.mjs verify --presentation presentation.json --audience https://shop.example --nonce <nonce> \
--trust-issuer <issuer id> --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/<purpose>\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 <credential id> \
--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 <credential id> --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 <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=<verify-proof.mjs>`); without it that test is reported as
not measured and the suite exits 2. No third party has reviewed any of this.

View File

@ -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;

97
identity/identity-cli.mjs Normal file
View File

@ -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; }
}

417
identity/identity.mjs Normal file
View File

@ -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/<scop>\n' + text), deci o semnatura de delegare nu poate fi folosita drept semnatura
// de prezentare sau de revocare peste acelasi text. Textul semnat e forma CANONICA a declaratiei (chei sortate), refacuta de verificator.
// IDENTITATEA e legata de chei: 'aere-id:' + primii 20 de octeti din sha256(forma canonica a celor doua chei publice SPKI).
//
// DEZVALUIREA SELECTIVA, in felul SD-JWT (IETF RFC 9901), dar in JSON canonic si cu semnatura hibrida: fiecare afirmatie dezvaluibila
// devine [sare, nume, valoare] codat base64url; emitentul semneaza numai digestul sha256 al codarii (lista `sd`, sortata, cu momeli
// optionale care ascund cate afirmatii sunt), detinatorul arata numai ce alege. Afirmatiile nedezvaluibile stau in clar in `claims`.
// Nu e o dovada cu cunoastere zero: ce se arata se arata intreg (o varsta arata data, nu "peste 18"; emitentul poate pune insa o
// afirmatie derivata, `age_over_18: true`, pe care detinatorul o arata singura).
//
// PREZENTAREA e legata de verificator: detinatorul (sau delegatul lui) semneaza hash-ul credentialului, hash-ul dezvaluirilor alese,
// PUBLICUL (audience), NONCE-ul verificatorului si momentul; fara public si nonce ceruti de verificator, o prezentare se poate relua la
// oricine, si verificatorul spune asta (nejudecat), nu o trece drept verificata.
//
// DELEGAREA: detinatorul da unei alte chei (telefon, agent, cheie de sesiune de cateva minute) dreptul de a-i prezenta credentialele,
// intr-un SCOP (ce credentiale, ce afirmatii dezvaluibile, ce public), intr-o fereastra de timp si cu o adancime de re-delegare. Fiecare
// veriga e semnata de cel care da, numeste veriga-parinte prin hash, si poate numai INGUSTA: scop inclus, fereastra inclusa, adancime
// mai mica. Revocarea unei verigi e o declaratie semnata de cel care a dat-o sau de detinator; se aplica daca momentul ei a trecut pe
// ceasul VERIFICATORULUI.
//
// LISTA DE STARE a emitentului, in felul W3C Bitstring Status List v1.0: un sir de biti (bitul 0 = cel mai semnificativ bit al primului
// octet) comprimat gzip, semnat de emitent, cu o fereastra de valabilitate; credentialul numeste lista si pozitia. O lista lipsa, a
// altui emitent sau expirata inseamna "nejudecat", nu "nerevocat".
//
// TIMPUL, spus exact: issuedAt / validFrom / validUntil sunt declaratiile emitentului; momentul prezentarii e al celui care prezinta,
// marginit de verificator cu ceasul lui (maxAgeS, in ambele sensuri); momentul unei revocari e al celui care revoca. Verificatorul
// judeca totul pe ceasul lui. Notarizarea pe lant (plicurile AIP-23 de mai jos) da un moment pe care nu il alege emitentul.
//
// CE NU FACE: nu leaga o cheie de hardware (o cheie de dispozitiv e o cheie ca oricare; atestarea TPM / enclava nu e aici); nu
// roteste si nu recupereaza cheile (asta e registrul de chei post-cuantic cu pre-rotire); nu spune CINE e o identitate in lumea
// reala (asta o spune emitentul, pe care verificatorul alege daca il crede, prin trustedIssuers).
import crypto from 'node:crypto';
import zlib from 'node:zlib';
export const VERSION = 1;
export const ALG = 'ed25519+ml-dsa-65';
const DOMENIU = 'aere-identity/v1/';
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 });
}

309
identity/proba-identity.mjs Normal file
View File

@ -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=<verify-proof.mjs>, 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=<verify-proof.mjs>`); 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);