identity: standard SD-JWT (IETF RFC 9901) from the same keys - issue, present with key binding, verify

sdjwt.mjs: an issuer-signed JWT with the digests of the disclosable claims (_sd, list elements as {"...": digest}, _sd_alg sha-256),
the holder's key in cnf.jwk and exp, followed by the disclosures; the holder keeps the ones it picks and adds a Key Binding JWT
(kb+jwt, aud, nonce, iat, sd_hash); the verifier follows RFC 9901 sections 7.1 and 7.3 with the issuer key it chose, and requires its
audience and nonce, exp, and an explicitly typed token. Algorithms: ES256, EdDSA (Ed25519, the key every AERE identity has) and
ML-DSA-65 (JWK type AKP, names from the IETF draft draft-ietf-cose-dilithium, not yet a published standard).

Checked against the standard's own vectors (fixturi-rfc9901.json, from RFC 9901 Section 5 and Appendix A.5): the ten example
disclosure digests, the example SD-JWT signed by someone else with the A.5 key, and the example presentation with key binding, which
gives exactly the processed payload printed in the RFC. Not checked against another SD-JWT library.

An adversarial review before publication found ten defects, all fixed with a test and a planted negative control each (among them:
the issuer signed SD-JWT structure coming in claim values; the holder revealed an element with the same value from any list; audience
and nonce were not required). Tests: SD-JWT 22/22, negative control 28/28; identity 44/44.
This commit is contained in:
Aere Network 2026-09-30 12:37:17 +03:00
parent 6c42fb2c0b
commit 4ffec8d7e3
6 changed files with 706 additions and 6 deletions

View File

@ -15,7 +15,7 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; and the chain as the witness of a ledger: a notarized head, read through the AIP-23 verifier under a post-quantum certified anchor, bounds entry times from below (a backdated entry is caught) |
| [`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; and compliance without surveillance: a verifier's policy (issuers, required claims) judged on a presentation, recorded as an AIP-23 envelope that carries no personal data; and the Travel Rule between VASPs, the IVMS101 data sealed for the receiving VASP with a hybrid X25519 + ML-KEM-768 key encapsulation, signed, bound to the transfer and acknowledged |
| [`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; and compliance without surveillance: a verifier's policy (issuers, required claims) judged on a presentation, recorded as an AIP-23 envelope that carries no personal data; and the Travel Rule between VASPs, the IVMS101 data sealed for the receiving VASP with a hybrid X25519 + ML-KEM-768 key encapsulation, signed, bound to the transfer and acknowledged; and the same credentials as standard SD-JWT (IETF RFC 9901): issued with the issuer's Ed25519 key (EdDSA) or its ML-DSA-65 key, presented with a key binding JWT, and verified against the RFC's own example vectors |
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
@ -31,11 +31,11 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j
| pq-kms | 62/62 (`node test/proba.mjs`); HSM root on SoftHSM2 + OpenSC 20/20 (`test/proba-hsm.mjs`, Linux); sealed-file trust rules 7/7 (`test/proba-hsm-incredere.mjs`) | 16/16 (`node test/control-negativ.mjs`); sealed-file rules 2/2 in this repository (`test/control-negativ-hsm-incredere.mjs`) |
| pq-pki | 27/27 (`node test/proba.mjs`), each verdict compared with OpenSSL 3.5 | 22/22 (`node test/control-negativ.mjs`) |
| crypto-inventory | 37/37 (`node test/proba.mjs`); cost on hostile input 8/8 linear (`node test/proba-timp.mjs`) | 18/18 (`node test/control-negativ.mjs`); cost 3/3 in this repository (`node test/control-negativ-timp.mjs`; its fourth case compares with version 0.1.0 from the development history and is skipped here) |
| verify-layer | 44/44 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<verify-proof.mjs from aere-node> node proba-sidecar.mjs`); without it 39 run, 5 are reported as skipped and the exit code is 2; the Merkle tree 19/19 (`node proba-arbore.mjs`: the reference roots of Certificate Transparency, every proof up to 64 leaves, each attack), measured 2026-09-30 | 9/9 in this repository (`node control-negativ-sidecar.mjs`; its tenth case compares with the version from the development history and is skipped here); the tree 13/13 (`node control-negativ-arbore.mjs`) |
| verify-layer | 44/44 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<verify-proof.mjs from aere-node> node proba-sidecar.mjs`); without it 39 run, 5 are reported as skipped and the exit code is 2; the Merkle tree 20/20 (`node proba-arbore.mjs`: the reference roots of Certificate Transparency, every proof up to 64 leaves, each attack), measured 2026-09-30 | 9/9 in this repository (`node control-negativ-sidecar.mjs`; its tenth case compares with the version from the development history and is skipped here); the tree 14/14 (`node control-negativ-arbore.mjs`) |
| proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test |
| readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) |
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; the console viewer in a real Chromium, phone and desktop, 26/26 (`node proba-consola-web.mjs`, measured 2026-09-30; needs `playwright-core` and a Chromium, otherwise it exits 2) | remediation 7/7, compliance report 3/3 in this repository; viewer 8/8 (`node control-negativ-consola-web.mjs`) |
| 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), compliance 14/14 (`node proba-conformitate.mjs`), Travel Rule 18/18 (`node proba-travel-rule.mjs`), measured 2026-09-30 | 46/46 (`node control-negativ-identity.mjs`); compliance 13/13 (`node control-negativ-conformitate.mjs`); Travel Rule 18/18 (`node control-negativ-travel-rule.mjs`) |
| identity | 44/44 (`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), compliance 15/15 (`node proba-conformitate.mjs`), Travel Rule 19/19 (`node proba-travel-rule.mjs`), SD-JWT 22/22 (`node proba-sdjwt.mjs`, the RFC 9901 vectors included), measured 2026-09-30 | 49/49 (`node control-negativ-identity.mjs`); compliance 14/14 (`node control-negativ-conformitate.mjs`); Travel Rule 19/19 (`node control-negativ-travel-rule.mjs`); SD-JWT 28/28 (`node control-negativ-sdjwt.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, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), 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`); the chain as witness 11/11 (`node control-negativ-ancora.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
@ -45,4 +45,4 @@ interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ...
## Licence
MIT, see [LICENSE](LICENSE). Files: 154 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 11, readiness 4).
MIT, see [LICENSE](LICENSE). Files: 158 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 15, readiness 4).

View File

@ -2,7 +2,8 @@
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).
dependencies (`node:crypto` provides Ed25519 and ML-DSA-65, FIPS 204). The same keys also issue and verify standard SD-JWT (IETF
RFC 9901), below.
```
node identity-cli.mjs keygen --out issuer.keys.json
@ -38,7 +39,8 @@ credential or a delegation that names an id not derived from the keys it carries
**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.
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
(for standard SD-JWT from the same keys, see `sdjwt.mjs` below).
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.
@ -142,6 +144,41 @@ the same signed and sealed message could otherwise be opened any number of times
of the exchange. What it does not do: validate the IVMS101 schema (it carries the object as given and requires `originator` and
`beneficiary`), find the beneficiary's VASP from an address (that is a discovery protocol's job), or say that a transfer is lawful.
## SD-JWT, the IETF standard (`sdjwt.mjs`)
The same issuer and holder keys, in the format of the standard: Selective Disclosure for JSON Web Tokens, IETF RFC 9901 (November
2025), compact serialization. Checked against the RFC's own vectors, not against another SD-JWT library (below).
```js
import { issueSdJwt, presentSdJwt, verifySdJwt } from './sdjwt.mjs';
// issuer: an issuer-signed JWT (digests of the disclosable claims in _sd, list elements as {"...": digest}, _sd_alg sha-256, the holder's
// key in cnf.jwk) followed by the disclosures, joined by ~
const { sdJwt } = issueSdJwt({ issuer, holder: holderPublicJwkOrKeys, claims, disclosable: ['given_name', 'age_over_18'],
arrayDisclosable: ['nationalities'], iss: 'https://issuer.example', exp, alg: 'EdDSA' }); // or alg: 'ML-DSA-65'
// holder: keep only the chosen disclosures and add a Key Binding JWT (typ kb+jwt, aud, nonce, iat, sd_hash) signed with the holder key
// a name reveals a top-level claim; { element, in } an element of the top-level list named (only that list)
const shown = presentSdJwt({ sdJwt, reveal: ['age_over_18', { element: 'RO', in: 'nationalities' }], holder, audience, nonce });
// verifier: the steps of RFC 9901 sections 7.1 and 7.3, with the issuer key the verifier chose (never one taken from the token);
// its audience and nonce are required, exp is required, the token must be explicitly typed ...+sd-jwt (expectedTyp to change it)
const r = verifySdJwt(shown, { issuerKey, audience, nonce, expectedIssuer: 'https://issuer.example' }); // { valid, reason, payload, disclosed, keyBinding }
```
Checked against the standard's own vectors (`fixturi-rfc9901.json`, extracted from RFC 9901 Section 5 and Appendix A.5; IETF code
components, Revised BSD License): the digest of each of the ten example disclosures; the example SD-JWT, signed ES256 by someone
else with the key in A.5, verifies and rebuilds every input claim; the example presentation with its Key Binding JWT verifies and
gives exactly the processed payload printed in the RFC; the same presentation for another audience, another nonce, an hour late,
with a disclosure removed or added after key binding, or with another issuer key, is refused.
Algorithms: ES256 (the RFC examples), EdDSA / Ed25519 (RFC 8037; the key every AERE identity has) and ML-DSA-65 with the JWK key
type AKP. The ML-DSA JOSE names come from the IETF draft `draft-ietf-cose-dilithium`, not yet a published standard, so a verifier
that does not know them rejects an ML-DSA-65 SD-JWT; for today's libraries, issue with EdDSA. A hybrid (two signatures in one token)
would need the general JWS JSON serialization and is not done here. The algorithm must match the key the verifier gives (no `none`,
no algorithm confusion); a disclosure not bound to a digest, a digest seen twice, a disclosure naming `_sd`, `...` or `__proto__`, or
one that overwrites a claim, or a claim named `__proto__` anywhere, is refused; the issuer refuses claim values that already carry SD-JWT
structure (`_sd`, `_sd_alg`, `{"...": digest}`), which would let a holder disclose claims the issuer never saw; `exp` gets no allowance and `nbf`/`iat` get 60 s, as for AERE credentials. What it is not:
SD-JWT VC (no `vct`, no type metadata), and no status list inside the token (revocation stays with the AERE credential's list).
Checked against the RFC's vectors, not against another SD-JWT library: that is not measured.
## 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
@ -158,6 +195,8 @@ node proba-conformitate.mjs # 15: compliance policies judged on real pr
node control-negativ-conformitate.mjs # on a copy, each of 14 guards removed -> its own named test turns red
node proba-travel-rule.mjs # 19: two VASPs with registry credentials, the whole exchange, and each attack of the review
node control-negativ-travel-rule.mjs # on a copy, each of 19 guards removed -> its own named test turns red
node proba-sdjwt.mjs # 22: the RFC 9901 vectors, SD-JWTs issued with AERE keys (EdDSA and ML-DSA-65), each attack
node control-negativ-sdjwt.mjs # on a copy, each of 28 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

View File

@ -0,0 +1,77 @@
// Controlul negativ al probei SD-JWT (sdjwt.mjs, proba-sdjwt.mjs): fiecare paznic se strica intr-o COPIE, proba ruleaza pe copie si proba
// NUMITA trebuie sa iasa rosie, cu proba chiar rulata; pe copia neatinsa, verde. Trei stari: un tipar care nu apare exact o data sau o
// proba care nu ajunge la rezumat e STRICAT si se numara esec.
// node control-negativ-sdjwt.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 S = 'sdjwt.mjs';
const PLANTARI = [
// [nume, tipar, inlocuire, proba (inceputul numelui ei)]
['alg none primit', 'if (!ALGS.includes(antet.alg)) throw', "if (antet.alg === 'nimic') throw", 'ATAC: alg none'],
['algoritmul nu mai trebuie sa fie al cheii (confuzia)', 'if (!potriveste(antet.alg, key)) throw', 'if (false) throw', 'ATAC: alg none'],
['o dezvaluire nelegata de niciun digest primita', 'if (folosite.size !== dupaDigest.size) return', 'if (false) return', 'ATAC: o dezvaluire cu valoarea schimbata'],
['aceeasi dezvaluire de doua ori primita', "if (dupaDigest.has(dg)) return refuz('the same disclosure is given twice');", '', 'ATAC: o dezvaluire cu valoarea schimbata'],
['numele _sd, ... sau __proto__ primite', "if (c[1] === '_sd' || c[1] === '...' || c[1] === '__proto__') throw", 'if (false) throw', 'ATAC: un emitent care pune in _sd'],
['o afirmatie dezvaluita peste una in clar primita', 'if (Object.hasOwn(out, c[1])) throw', 'if (false) throw', 'ATAC: un emitent care pune in _sd'],
['acelasi digest de doua ori primit', 'const vezi = (dg) => { if (vazute.has(dg)) throw', 'const vezi = (dg) => { if (false) throw', 'ATAC: un emitent care pune in _sd'],
['sd_hash nu se mai compara', 'if (sha256b64u(baza) !== K.sd_hash) return', 'if (false) return', 'RFC 9901: o dezvaluire scoasa'],
['tipul KB (kb+jwt) nu se mai cere', "k = verificaJws(kb, payload.cnf.jwk, { typ: 'kb+jwt' });", 'k = verificaJws(kb, payload.cnf.jwk, { typ: null });', 'ATAC: KB semnat de alta cheie'],
['publicul KB nu se mai compara', 'if (K.aud !== audience) return', 'if (false) return', 'RFC 9901: aceeasi prezentare pentru ALT verificator'],
['nonce-ul KB nu se mai compara', 'if (K.nonce !== nonce) return', 'if (false) return', 'RFC 9901: aceeasi prezentare pentru ALT verificator'],
['KB vechi primit', 'if (Math.abs(acum - K.iat) > maxAgeS) return', 'if (false) return', 'RFC 9901: aceeasi prezentare pentru ALT verificator'],
['exp nu se mai cere', 'if (payload.exp !== undefined && acum >= payload.exp) return', 'if (false) return', 'timpul'],
['fara toleranta de ceas pe nbf', 'if (payload.nbf !== undefined && payload.nbf - clockSkewS > acum) return', 'if (payload.nbf !== undefined && payload.nbf > acum) return', 'timpul'],
['Key Binding cerut, dar lipsa primita', "if (requireKeyBinding) return refuz('key binding is required", "if (false) return refuz('key binding is required", 'RFC 9901: SD-JWT-ul standardului cu ALTA cheie'],
// revizuirea adversariala din 30 sept: forma de dinainte a fiecarei reparatii
['emitentul semneaza structura SD-JWT din valori', 'if (areStructuraSd(claims)) throw', 'if (false) throw', 'REVIZUIRE: emitentul refuza structura'],
['prezentarea arata elementul din orice lista', 'obiect(r) && r.in === l.lista && c.length === 2', 'obiect(r) && c.length === 2', 'REVIZUIRE: prezentarea arata elementul'],
['publicul si nonce-ul nu se mai cer', "if ((requireKeyBinding || kb !== '') && (typeof audience !== 'string'", "if (false && (typeof audience !== 'string'", 'REVIZUIRE: fara publicul si nonce-ul'],
['exp nu se mai cere implicit', 'if (requireExp && payload.exp === undefined) return', 'if (false) return', 'REVIZUIRE: exp cerut implicit'],
['tipul jetonului nu se mai verifica implicit', "if (cerut === undefined) return typeof typ === 'string' && /(^|\\+)sd-jwt$/.test(typ);", 'if (cerut === undefined) return true;', 'REVIZUIRE: tipul jetonului'],
['expectedIssuer nu se mai compara', 'if (expectedIssuer != null && corp.iss !== expectedIssuer) return', 'if (false) return', 'REVIZUIRE: tipul jetonului'],
['crit primit', "if (Object.hasOwn(antet, 'crit')) throw", 'if (false) throw', 'REVIZUIRE: crit'],
['base64url necanonic primit', "if (b.toString('base64url') !== s) throw", 'if (false) throw', 'REVIZUIRE: crit'],
['exp-ul KB nu se mai cere', 'if (K.exp !== undefined && (!Number.isFinite(K.exp) || acum >= K.exp)) return', 'if (false) return', 'REVIZUIRE: crit'],
['_sd_alg scos de la orice nivel', "!(sus && k === '_sd_alg')", "k !== '_sd_alg'", 'REVIZUIRE: crit'],
['cheia publica Node nu mai e primita', "(k.type === 'public' ? k : crypto.createPublicKey(k))", 'crypto.createPublicKey(k)', 'REVIZUIRE: cheia emitentului'],
['o afirmatie __proto__ primita (prototipul schimbat)', "if (Object.hasOwn(x, '__proto__')) throw", 'if (false) throw', 'ATAC: o afirmatie numita __proto__'],
['elementele de lista nedezvaluite pastrate', 'if (!c) continue; // nedezvaluit: elementul se scoate', "if (!c) { out.push(e); continue; }", 'AERE -> SD-JWT (EdDSA'],
];
function copie() {
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-sdjwt-ctl-'));
for (const f of [S, 'identity.mjs', 'proba-sdjwt.mjs', 'fixturi-rfc9901.json']) fs.copyFileSync(path.join(AICI, f), path.join(t, f));
return t;
}
function ruleaza(t) {
return new Promise((resolve) => {
const c = spawn(process.execPath, [path.join(t, 'proba-sdjwt.mjs')]); let out = '';
const ceas = setTimeout(() => c.kill(), 180000);
c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', (x) => { out += x; });
c.on('close', (cod) => { clearTimeout(ceas); resolve({ cod, rulat: /aere-sd-jwt: \d+\/\d+/.test(out), rosii: out.split('\n').filter((l) => l.startsWith(' RAU ')) }); });
});
}
async function planteaza([nume, din, inl, tinta]) {
const t = copie();
try {
const f = path.join(t, S); 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`];
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 }); }
}
let rele = 0;
const t0 = copie(); const m = await ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true });
if (m.rulat && m.cod === 0 && !m.rosii.length) console.log(' OK martorul: copia neatinsa verde'); 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;

View File

@ -0,0 +1,104 @@
{
"source": "IETF RFC 9901, Section 5 (example SD-JWT, presentation with key binding) and Appendix A.5 (the issuer key); code components of IETF documents are under the Revised BSD License (IETF Trust Legal Provisions)",
"issuerJwk": {
"kty": "EC",
"crv": "P-256",
"x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ",
"y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8"
},
"inputClaims": {
"sub": "user_42",
"given_name": "John",
"family_name": "Doe",
"email": "johndoe@example.com",
"phone_number": "+1-202-555-0101",
"phone_number_verified": true,
"address": {
"street_address": "123 Main St",
"locality": "Anytown",
"region": "Anystate",
"country": "US"
},
"birthdate": "1940-01-01",
"updated_at": 1570000000,
"nationalities": [
"US",
"DE"
]
},
"sdJwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBbIkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tBTmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFrb2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpnbGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjogInVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VHZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.MczwjBFGtzf-6WMT-hIvYbkb11NrV1WMO-jTijpMPNbswNzZ87wY2uHz-CXo6R04b7jYrpj9mNRAvVssXou1iw~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd~WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ~WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgInBob25lX251bWJlciIsICIrMS0yMDItNTU1LTAxMDEiXQ~WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgInBob25lX251bWJlcl92ZXJpZmllZCIsIHRydWVd~WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZRIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0~WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInVwZGF0ZWRfYXQiLCAxNTcwMDAwMDAwXQ~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0~WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0~",
"disclosures": [
{
"digest": "jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4",
"disclosure": "WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd"
},
{
"digest": "TGf4oLbgwd5JQaHyKVQZU9UdGE0w5rtDsrZzfUaomLo",
"disclosure": "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd"
},
{
"digest": "JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE",
"disclosure": "WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ"
},
{
"digest": "PorFbpKuVu6xymJagvkFsFXAbRoc2JGlAUA2BA4o7cI",
"disclosure": "WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgInBob25lX251bWJlciIsICIrMS0yMDItNTU1LTAxMDEiXQ"
},
{
"digest": "XQ_3kPKt1XyX7KANkqVR6yZ2Va5NrPIvPYbyMvRKBMM",
"disclosure": "WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgInBob25lX251bWJlcl92ZXJpZmllZCIsIHRydWVd"
},
{
"digest": "XzFrzwscM6Gn6CJDc6vVK8BkMnfG8vOSKfpPIZdAfdE",
"disclosure": "WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0"
},
{
"digest": "gbOsI4Edq2x2Kw-w5wPEzakob9hV1cRD0ATN3oQL9JM",
"disclosure": "WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZRIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0"
},
{
"digest": "CrQe7S5kqBAHt-nMYXgc6bdt2SH5aTY1sU_M-PgkjPI",
"disclosure": "WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInVwZGF0ZWRfYXQiLCAxNTcwMDAwMDAwXQ"
},
{
"digest": "pFndjkZ_VCzmyTa6UjlZo3dh-ko8aIKQc9DlGzhaVYo",
"disclosure": "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0"
},
{
"digest": "7Cf6JkPudry3lcbwHgeZ8khAv1U1OSlerP0VkBJrWZ0",
"disclosure": "WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0"
}
],
"presentation": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBbIkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tBTmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFrb2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpnbGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjogInVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VHZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.MczwjBFGtzf-6WMT-hIvYbkb11NrV1WMO-jTijpMPNbswNzZ87wY2uHz-CXo6R04b7jYrpj9mNRAvVssXou1iw~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd~WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodHRwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwgImlhdCI6IDE3NDg1MzcyNDQsICJzZF9oYXNoIjogIjBfQWYtMkItRWhMV1g1eWRoX3cyeHp3bU82aU02NkJfMlFDRWFuSTRmVVkifQ.T3SIus2OidNl41nmVkTZVCKKhOAX97aOldMyHFiYjHm261eLiJ1YiuONFiMN8QlCmYzDlBLAdPvrXh52KaLgUQ",
"keyBindingPayload": {
"nonce": "1234567890",
"aud": "https://verifier.example.org",
"iat": 1748537244,
"sd_hash": "0_Af-2B-EhLWX5ydh_w2xzwmO6iM66B_2QCEanI4fUY"
},
"processedPayload": {
"iss": "https://issuer.example.com",
"iat": 1683000000,
"exp": 1883000000,
"sub": "user_42",
"nationalities": [
"US"
],
"cnf": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc",
"y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ"
}
},
"family_name": "Doe",
"address": {
"street_address": "123 Main St",
"locality": "Anytown",
"region": "Anystate",
"country": "US"
},
"given_name": "John"
}
}

197
identity/proba-sdjwt.mjs Normal file
View File

@ -0,0 +1,197 @@
// Proba SD-JWT (sdjwt.mjs): intai pe vectorii STANDARDULUI (RFC 9901 s.5 si A.5, extrasi in fixturi-rfc9901.json: facuti de altii, cu
// cheia si semnaturile lor), apoi pe jetoane emise cu chei AERE (Ed25519 si ML-DSA-65), apoi fiecare atac ca proba numita. Offline.
// node proba-sdjwt.mjs iesire 0 = toate cum trebuia
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import * as I from './identity.mjs';
import * as J from './sdjwt.mjs';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const F = JSON.parse(fs.readFileSync(path.join(AICI, 'fixturi-rfc9901.json'), 'utf8'));
let treceri = 0; const esecuri = [];
function test(nume, fn) { try { fn(); 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 sortat = (o) => (Array.isArray(o) ? o.map(sortat) : o && typeof o === 'object' ? Object.fromEntries(Object.keys(o).sort().map((k) => [k, sortat(o[k])])) : o);
const egal = (a, b) => JSON.stringify(sortat(a)) === JSON.stringify(sortat(b));
const b64u = (x) => Buffer.from(x).toString('base64url');
const KB = F.keyBindingPayload, LA_KB = new Date(KB.iat * 1000);
const refuz = (r, re) => !r.valid && re.test(r.reason);
// ---------------------------------------------------------------- vectorii RFC 9901
test('RFC 9901 s.5.1: fiecare din cele 10 dezvaluiri are digestul SHA-256 base64url scris in standard', () => {
for (const d of F.disclosures) cere(crypto.createHash('sha256').update(Buffer.from(d.disclosure, 'ascii')).digest('base64url') === d.digest, d.digest);
cere(F.disclosures.length === 10, String(F.disclosures.length));
});
test('RFC 9901 s.5.1: SD-JWT-ul standardului (ES256, cheia din A.5) verifica si se reface in afirmatiile de intrare, toate', () => {
const r = J.verifySdJwt(F.sdJwt, { issuerKey: F.issuerJwk, requireKeyBinding: false, now: new Date('2026-09-30T00:00:00Z') });
cere(r.valid, r.reason);
for (const [k, v] of Object.entries(F.inputClaims)) cere(egal(r.payload[k], v), 'afirmatia ' + k);
cere(r.payload.iss === 'https://issuer.example.com' && r.alg === 'ES256' && !('_sd' in r.payload) && !('_sd_alg' in r.payload), JSON.stringify(Object.keys(r.payload)));
});
test('RFC 9901 s.5.2: prezentarea standardului cu Key Binding JWT verifica (aud, nonce, iat, sd_hash, cheia din cnf) si da exact payload-ul procesat din standard', () => {
const r = J.verifySdJwt(F.presentation, { issuerKey: F.issuerJwk, audience: KB.aud, nonce: KB.nonce, now: LA_KB });
cere(r.valid, r.reason);
cere(egal(r.payload, F.processedPayload), JSON.stringify(r.payload));
cere(r.keyBinding && r.keyBinding.nonce === '1234567890' && r.keyBinding.aud === 'https://verifier.example.org' && r.keyBinding.alg === 'ES256', JSON.stringify(r.keyBinding));
});
test('RFC 9901: aceeasi prezentare pentru ALT verificator, cu ALT nonce, sau judecata la o ora dupa iat -> refuzata', () => {
cere(refuz(J.verifySdJwt(F.presentation, { issuerKey: F.issuerJwk, audience: 'https://other.example', nonce: KB.nonce, now: LA_KB }), /made for/), 'alt aud');
cere(refuz(J.verifySdJwt(F.presentation, { issuerKey: F.issuerJwk, audience: KB.aud, nonce: 'x', now: LA_KB }), /nonce/), 'alt nonce');
cere(refuz(J.verifySdJwt(F.presentation, { issuerKey: F.issuerJwk, audience: KB.aud, nonce: KB.nonce, now: new Date((KB.iat + 3600) * 1000) }), /outside 300 s/), 'veche');
});
test('RFC 9901: SD-JWT-ul standardului cu ALTA cheie de emitent (P-256 a noastra) -> refuzat; fara Key Binding cand e cerut -> refuzat', () => {
const alta = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).publicKey.export({ format: 'jwk' });
cere(refuz(J.verifySdJwt(F.presentation, { issuerKey: alta, audience: KB.aud, nonce: KB.nonce, now: LA_KB }), /does not verify/), 'alta cheie');
cere(refuz(J.verifySdJwt(F.sdJwt, { issuerKey: F.issuerJwk, audience: KB.aud, nonce: KB.nonce, now: LA_KB }), /key binding is required/), 'fara KB');
});
test('RFC 9901: o dezvaluire scoasa din prezentare dupa semnarea KB (sd_hash) sau una adaugata -> refuzata', () => {
const p = F.presentation.split('~'); const kb = p.pop();
const scoasa = [...p.slice(0, 2), ...p.slice(3), kb].join('~');
cere(refuz(J.verifySdJwt(scoasa, { issuerKey: F.issuerJwk, audience: KB.aud, nonce: KB.nonce, now: LA_KB }), /sd_hash/), 'scoasa: ' + J.verifySdJwt(scoasa, { issuerKey: F.issuerJwk, now: LA_KB }).reason);
const adaugata = [...p, F.disclosures[2].disclosure, kb].join('~');
cere(refuz(J.verifySdJwt(adaugata, { issuerKey: F.issuerJwk, audience: KB.aud, nonce: KB.nonce, now: LA_KB }), /sd_hash/), 'adaugata');
});
// ---------------------------------------------------------------- jetoane emise cu chei AERE
const NOW = Math.floor(Date.parse('2026-09-30T08:00:00Z') / 1000), LA = new Date(NOW * 1000);
const iss = I.generateKeys(), hol = I.generateKeys(), strain = I.generateKeys();
const CL = { given_name: 'Ana', family_name: 'Pop', age_over_18: true, nationalities: ['RO', 'DE'], employer: 'Example Ltd' };
const emite = (o = {}) => J.issueSdJwt({ issuer: iss, holder: hol, claims: CL, disclosable: ['given_name', 'family_name', 'age_over_18'], arrayDisclosable: ['nationalities'], iss: 'https://issuer.aere.example', iat: NOW, exp: NOW + 86400, decoys: 2, ...o });
const AUD = 'https://verifier.example', NONCE = 'n-' + crypto.randomBytes(4).toString('hex');
const arata = (sd, reveal, o = {}) => J.presentSdJwt({ sdJwt: sd, reveal, holder: hol, audience: AUD, nonce: NONCE, iat: NOW, ...o });
const judeca = (p, o = {}) => J.verifySdJwt(p, { issuerKey: iss, audience: AUD, nonce: NONCE, now: LA, ...o });
const E = emite();
test('AERE -> SD-JWT (EdDSA, cheia Ed25519 a emitentului AERE): detinatorul arata age_over_18 si RO, verificatorul vede numai atat plus afirmatiile in clar', () => {
const r = judeca(arata(E.sdJwt, ['age_over_18', { element: 'RO', in: 'nationalities' }]));
cere(r.valid, r.reason);
cere(r.payload.age_over_18 === true && !('given_name' in r.payload) && egal(r.payload.nationalities, ['RO']) && r.payload.employer === 'Example Ltd' && r.alg === 'EdDSA', JSON.stringify(r.payload));
});
test('AERE -> SD-JWT in forma standardului: typ explicit, _sd sortat (cu momeli), _sd_alg sha-256, cnf.jwk = cheia Ed25519 a detinatorului, dezvaluiri [sare, nume, valoare]', () => {
const [h, p] = E.sdJwt.split('~')[0].split('.').slice(0, 2).map((x) => JSON.parse(Buffer.from(x, 'base64url').toString('utf8')));
cere(h.alg === 'EdDSA' && h.typ === J.TYP, JSON.stringify(h));
cere(Array.isArray(p._sd) && p._sd.length === 5 && egal([...p._sd].sort(), p._sd) && p._sd_alg === 'sha-256', JSON.stringify(p._sd));
cere(p.cnf.jwk.kty === 'OKP' && p.cnf.jwk.crv === 'Ed25519' && p.cnf.jwk.x === J.publicJwk(hol).x && !('d' in p.cnf.jwk), JSON.stringify(p.cnf));
const c = JSON.parse(Buffer.from(E.disclosures[0].disclosure, 'base64url').toString('utf8'));
cere(c.length === 3 && typeof c[0] === 'string' && c[0].length >= 22 && c[1] === 'given_name', JSON.stringify(c));
});
test('AERE -> SD-JWT post-cuantic (ML-DSA-65, cheia AKP): verifica cu cheia ML-DSA-65 a emitentului, nu cu cea Ed25519; detinatorul poate semna KB tot cu ML-DSA-65', () => {
const M = emite({ alg: 'ML-DSA-65', holderAlg: 'ML-DSA-65' });
const p = arata(M.sdJwt, ['age_over_18'], { holderAlg: 'ML-DSA-65' });
const r = judeca(p, { issuerAlg: 'ML-DSA-65' }); cere(r.valid && r.alg === 'ML-DSA-65' && r.keyBinding.alg === 'ML-DSA-65', r.reason);
cere(refuz(judeca(p, { issuerAlg: 'EdDSA' }), /does not match/), 'cheia Ed25519 a primit un jeton ML-DSA-65');
});
// ---------------------------------------------------------------- atacuri
const jwtDin = (antet, corp, cheie) => { const cap = b64u(JSON.stringify(antet)) + '.' + b64u(JSON.stringify(corp)); return cap + '.' + b64u(crypto.sign(null, Buffer.from(cap), cheie)); };
const corpDe = (sd) => JSON.parse(Buffer.from(sd.split('~')[0].split('.')[1], 'base64url').toString('utf8'));
test('ATAC: alg none si alg ES256 cu cheie Ed25519 (confuzia algoritmului) -> refuzate', () => {
const corp = corpDe(E.sdJwt);
const none = b64u(JSON.stringify({ alg: 'none', typ: J.TYP })) + '.' + b64u(JSON.stringify(corp)) + '.';
cere(refuz(J.verifySdJwt(none + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /not accepted/), 'none a trecut');
const conf = jwtDin({ alg: 'ES256', typ: J.TYP }, corp, iss.privat.ed25519);
cere(refuz(J.verifySdJwt(conf + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /does not match/), 'confuzia a trecut');
});
test('ATAC: o dezvaluire cu valoarea schimbata (alt digest, nelegata) -> refuzata; aceeasi dezvaluire de doua ori -> refuzata', () => {
const p = arata(E.sdJwt, ['given_name']).split('~');
const c = JSON.parse(Buffer.from(p[1], 'base64url').toString('utf8')); c[2] = 'Maria';
const schimbat = [p[0], b64u(JSON.stringify(c)), ''].join('~');
cere(refuz(J.verifySdJwt(schimbat, { issuerKey: iss, requireKeyBinding: false, now: LA }), /not referenced/), 'valoarea schimbata a trecut');
const dublu = [p[0], p[1], p[1], ''].join('~');
cere(refuz(J.verifySdJwt(dublu, { issuerKey: iss, requireKeyBinding: false, now: LA }), /twice/), 'dublura a trecut');
});
test('ATAC: un emitent care pune in _sd o dezvaluire cu numele "_sd", una care acopera o afirmatie in clar, sau acelasi digest de doua ori -> refuzat', () => {
const faci = (dezvaluiri, extra = {}) => { const ds = dezvaluiri.map((x) => b64u(JSON.stringify(x))); const sd = ds.map((d) => crypto.createHash('sha256').update(d).digest('base64url'));
return jwtDin({ alg: 'EdDSA', typ: J.TYP }, { _sd: extra.dublu ? [...sd, sd[0]] : sd, _sd_alg: 'sha-256', iss: 'x', ...(extra.clar || {}) }, iss.privat.ed25519) + '~' + ds.map((d) => d + '~').join(''); };
cere(refuz(J.verifySdJwt(faci([['s1', '_sd', ['x']]]), { issuerKey: iss, requireKeyBinding: false, now: LA }), /may not name _sd/), '_sd a trecut');
cere(refuz(J.verifySdJwt(faci([['s1', 'role', 'admin']], { clar: { role: 'user' } }), { issuerKey: iss, requireKeyBinding: false, now: LA }), /already exists/), 'coliziunea a trecut');
cere(refuz(J.verifySdJwt(faci([['s1', 'a', 1]], { dublu: true }), { issuerKey: iss, requireKeyBinding: false, now: LA }), /more than once/), 'digestul dublu a trecut');
cere(refuz(J.verifySdJwt(faci([['s1', '__proto__', { admin: true }]]), { issuerKey: iss, requireKeyBinding: false, now: LA }), /may not name __proto__/), '__proto__ a trecut');
});
test('ATAC: o afirmatie numita __proto__ in clar sau intr-o valoare dezvaluita (prototipul payload-ului schimbat) -> refuzata', () => {
const cap = (corp) => jwtDin({ alg: 'EdDSA', typ: J.TYP }, corp, iss.privat.ed25519);
const clar = JSON.parse('{"iss":"x","_sd_alg":"sha-256","__proto__":{"admin":true}}');
cere(refuz(J.verifySdJwt(cap(clar) + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /__proto__/), 'in clar a trecut');
const d = b64u('["s1","profil",' + '{"__proto__":{"admin":true}}' + ']');
const sd = crypto.createHash('sha256').update(d).digest('base64url');
cere(refuz(J.verifySdJwt(cap({ _sd: [sd], _sd_alg: 'sha-256', iss: 'x' }) + '~' + d + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /__proto__/), 'in valoarea dezvaluita a trecut');
});
test('ATAC: KB semnat de alta cheie decat cnf.jwk, KB cu typ gresit, KB fara nonce -> refuzate', () => {
const alt = J.presentSdJwt({ sdJwt: E.sdJwt, reveal: ['age_over_18'], holder: strain, audience: AUD, nonce: NONCE, iat: NOW });
cere(refuz(judeca(alt), /key binding: the signature does not verify/), 'cheia straina a trecut');
const baza = arata(E.sdJwt, ['age_over_18']).split('~').slice(0, -1).join('~') + '~';
const sh = crypto.createHash('sha256').update(Buffer.from(baza, 'ascii')).digest('base64url');
const tip = jwtDin({ alg: 'EdDSA', typ: 'JWT' }, { iat: NOW, aud: AUD, nonce: NONCE, sd_hash: sh }, hol.privat.ed25519);
cere(refuz(judeca(baza + tip), /typ/), 'typ gresit a trecut');
const faraNonce = jwtDin({ alg: 'EdDSA', typ: 'kb+jwt' }, { iat: NOW, aud: AUD, sd_hash: sh }, hol.privat.ed25519);
cere(refuz(judeca(baza + faraNonce), /required/), 'fara nonce a trecut');
});
test('timpul: exp trecut -> refuzat (fara toleranta); nbf peste 30 s -> admis (toleranta de ceas), peste 90 s -> refuzat', () => {
cere(refuz(judeca(arata(emite({ exp: NOW - 1 }).sdJwt, ['age_over_18'])), /expired/), 'expirat a trecut');
cere(judeca(arata(emite({ nbf: NOW + 30 }).sdJwt, ['age_over_18'])).valid, 'nbf +30 s refuzat');
cere(refuz(judeca(arata(emite({ nbf: NOW + 90 }).sdJwt, ['age_over_18'])), /not valid before/), 'nbf +90 s a trecut');
});
// revizuirea din 30 sept: fara public si nonce, o prezentare facuta pentru altul trecea (numai un steag spunea ca nu s-au comparat)
test('REVIZUIRE: fara publicul si nonce-ul verificatorului, o prezentare cu Key Binding NU se judeca (RFC 9901 s.7.3)', () => {
cere(refuz(J.verifySdJwt(arata(E.sdJwt, ['age_over_18']), { issuerKey: iss, now: LA }), /must give its audience and nonce/), 'fara aud/nonce a trecut');
cere(refuz(J.verifySdJwt(arata(E.sdJwt, ['age_over_18']), { issuerKey: iss, audience: AUD, now: LA }), /must give its audience and nonce/), 'fara nonce a trecut');
});
test('REVIZUIRE: emitentul refuza structura SD-JWT venita in valori (_sd imbricat, _sd de sus, element {"...": d}) - altfel detinatorul dezvaluie ce emitentul n-a vazut', () => {
const h = crypto.createHash('sha256').update(b64u(JSON.stringify(['s', 'country', 'US']))).digest('base64url');
const incearca = (claims) => { try { J.issueSdJwt({ issuer: iss, holder: hol, claims, iss: 'x', iat: NOW }); return null; } catch (e) { return e.message; } };
cere(/SD-JWT structure/.test(incearca({ address: { street: 'a', _sd: [h] } }) || ''), '_sd imbricat emis');
cere(/SD-JWT structure/.test(incearca({ name: 'x', _sd: [h] }) || ''), '_sd de sus emis');
cere(/SD-JWT structure/.test(incearca({ roles: ['user', { '...': h }] }) || ''), 'element ... emis');
cere(incearca({ roles: ['user', { note: '...' }] }) === null, 'o valoare obisnuita refuzata');
});
test('REVIZUIRE: prezentarea arata elementul NUMAI din lista numita (acelasi RO in doua liste), iar o alegere fara corespondent e o eroare', () => {
const D = J.issueSdJwt({ issuer: iss, holder: hol, claims: { nationalities: ['RO', 'DE'], criminal_record_countries: ['RO', 'FR'] }, arrayDisclosable: ['nationalities', 'criminal_record_countries'], iss: 'x', iat: NOW });
const r = judeca(arata(D.sdJwt, [{ element: 'RO', in: 'nationalities' }]));
cere(r.valid && egal(r.payload.nationalities, ['RO']) && egal(r.payload.criminal_record_countries, []), JSON.stringify(r.payload));
let m = null; try { arata(D.sdJwt, [{ element: 'IT', in: 'nationalities' }]); } catch (e) { m = e.message; } cere(/nothing to reveal/.test(m || ''), 'alegerea fara corespondent a tacut');
});
test('REVIZUIRE: cheia emitentului data ca KeyObject public sau ca identitate AERE publica (keys.public) e primita; detinatorul dat ca cheie publica', () => {
const pubKO = crypto.createPublicKey(iss.privat.ed25519);
const D = J.issueSdJwt({ issuer: iss, holder: crypto.createPublicKey(hol.privat.ed25519), claims: { age_over_18: true }, disclosable: ['age_over_18'], iss: 'x', iat: NOW });
const p = arata(D.sdJwt, ['age_over_18']);
cere(J.verifySdJwt(p, { issuerKey: pubKO, audience: AUD, nonce: NONCE, now: LA }).valid, 'KeyObject public refuzat');
cere(J.verifySdJwt(p, { issuerKey: iss.public, audience: AUD, nonce: NONCE, now: LA }).valid, 'identitatea AERE publica refuzata');
});
test('REVIZUIRE: exp cerut implicit (un exp dezvaluibil si retinut de detinator nu mai lasa jetonul fara expirare); requireExp:false il lasa', () => {
const dExp = b64u(JSON.stringify(['s', 'exp', NOW - 10]));
const corp = { _sd: [crypto.createHash('sha256').update(dExp).digest('base64url')], _sd_alg: 'sha-256', iss: 'x' };
const jwt = jwtDin({ alg: 'EdDSA', typ: J.TYP }, corp, iss.privat.ed25519);
cere(refuz(J.verifySdJwt(jwt + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /exp is required/), 'fara exp a trecut');
cere(refuz(J.verifySdJwt(jwt + '~' + dExp + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /expired/), 'exp dezvaluit si trecut a trecut');
cere(J.verifySdJwt(jwt + '~', { issuerKey: iss, requireKeyBinding: false, requireExp: false, now: LA }).valid, 'requireExp:false a refuzat');
});
test('REVIZUIRE: tipul jetonului: un KB-JWT sau un JWT cu typ JWT dat drept jeton de emitent -> refuzat implicit; expectedIssuer altul -> refuzat', () => {
const corp = { iss: 'https://issuer.aere.example', exp: NOW + 100, _sd_alg: 'sha-256' };
cere(refuz(J.verifySdJwt(jwtDin({ alg: 'EdDSA', typ: 'kb+jwt' }, corp, iss.privat.ed25519) + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /typ/), 'kb+jwt drept emitent a trecut');
const simplu = jwtDin({ alg: 'EdDSA', typ: 'JWT' }, corp, iss.privat.ed25519) + '~';
cere(refuz(J.verifySdJwt(simplu, { issuerKey: iss, requireKeyBinding: false, now: LA }), /typ/), 'typ JWT a trecut implicit');
cere(J.verifySdJwt(simplu, { issuerKey: iss, requireKeyBinding: false, expectedTyp: null, now: LA }).valid, 'expectedTyp:null a refuzat un JWT simplu');
const bun = J.verifySdJwt(arata(E.sdJwt, ['age_over_18']), { issuerKey: iss, audience: AUD, nonce: NONCE, now: LA, expectedIssuer: 'https://issuer.aere.example' });
cere(bun.valid && bun.issuerChecked, 'emitentul asteptat refuzat');
cere(refuz(J.verifySdJwt(arata(E.sdJwt, ['age_over_18']), { issuerKey: iss, audience: AUD, nonce: NONCE, now: LA, expectedIssuer: 'https://altul.example' }), /issued by/), 'alt emitent a trecut');
});
test('REVIZUIRE: crit (orice forma), base64url necanonic al semnaturii, KB expirat -> refuzate; _sd_alg imbricat ramane afirmatie', () => {
const corp = { iss: 'x', exp: NOW + 100, _sd_alg: 'sha-256', meta: { _sd_alg: 'x', note: 1 } };
for (const crit of [[], 'b64', ['b64']]) cere(refuz(J.verifySdJwt(jwtDin({ alg: 'EdDSA', typ: J.TYP, crit, b64: false }, corp, iss.privat.ed25519) + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /crit/), 'crit ' + JSON.stringify(crit));
const j = jwtDin({ alg: 'EdDSA', typ: J.TYP }, corp, iss.privat.ed25519);
const r = J.verifySdJwt(j + '~', { issuerKey: iss, requireKeyBinding: false, now: LA });
cere(r.valid && r.payload.meta._sd_alg === 'x' && !('_sd_alg' in r.payload), '_sd_alg imbricat: ' + JSON.stringify(r.payload));
const ALF = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const u = j[j.length - 1], alt = ALF[ALF.indexOf(u) ^ 1];
const nec = j.slice(0, -1) + alt;
cere(Buffer.from(nec.split('.')[2], 'base64url').equals(Buffer.from(j.split('.')[2], 'base64url')), 'proba: scrierea alternativa trebuie sa dea aceiasi octeti');
cere(refuz(J.verifySdJwt(nec + '~', { issuerKey: iss, requireKeyBinding: false, now: LA }), /canonical/), 'semnatura necanonica a trecut');
const baza = arata(E.sdJwt, ['age_over_18']).split('~').slice(0, -1).join('~') + '~';
const sh = crypto.createHash('sha256').update(Buffer.from(baza, 'ascii')).digest('base64url');
const kbExp = jwtDin({ alg: 'EdDSA', typ: 'kb+jwt' }, { iat: NOW, aud: AUD, nonce: NONCE, sd_hash: sh, exp: NOW - 100 }, hol.privat.ed25519);
cere(refuz(judeca(baza + kbExp), /key binding: expired/), 'KB expirat a trecut');
});
console.log(`\naere-sd-jwt: ${treceri}/${treceri + esecuri.length} cum trebuia`);
process.exitCode = esecuri.length ? 1 : 0;

283
identity/sdjwt.mjs Normal file
View File

@ -0,0 +1,283 @@
// AERE Identity, SD-JWT (IETF RFC 9901, noiembrie 2025): acelasi emitent si acelasi detinator ca in identity.mjs, in formatul standardului
// (roadmap master punctul 12, "interoperare SD-JWT"; pista B, 2026-09-30). Judecat pe vectorii standardului (RFC 9901 s.5 si A.5), NU
// contra altei biblioteci SD-JWT: interoperarea cu o implementare straina e NEMASURATA (spus in README).
//
// CE FACE:
// issueSdJwt emitentul face un SD-JWT in serializarea compacta: JWT semnat de emitent, cu digesturile afirmatiilor dezvaluibile in `_sd`
// (si `...` pentru elemente de lista), `_sd_alg` sha-256, cheia detinatorului in `cnf.jwk`, `exp`, plus dezvaluirile, unite prin `~`
// presentSdJwt detinatorul alege dezvaluirile (afirmatii de sus dupa nume, elemente dupa lista LOR si valoare) si adauga un Key Binding
// JWT (typ kb+jwt, aud, nonce, iat, sd_hash) semnat cu cheia lui
// verifySdJwt verificatorul face pasii din RFC 9901 s.7.1 si s.7.3 cu cheia EMITENTULUI data de el (niciodata din jeton), cu publicul si
// nonce-ul lui CERUTE cand exista (sau se cere) Key Binding, cu `exp` cerut, cu tipul explicit al jetonului verificat
//
// ALGORITMII (JWS): ES256 (P-256, cel din exemplele RFC 9901), EdDSA / Ed25519 (RFC 8037) si ML-DSA-65 (post-cuantic; numele si forma
// cheii JWK "AKP" dupa draftul IETF draft-ietf-cose-dilithium, NU inca un standard publicat). Un emitent AERE semneaza cu cheia lui Ed25519
// (algoritm inregistrat, cunoscut bibliotecilor JOSE) sau cu cheia ML-DSA-65 (numai pentru verificatorii care stiu numele din draft); o
// semnatura hibrida in acelasi jeton ar cere serializarea JSON generala a JWS si nu e facuta aici.
//
// Revizuirea adversariala din 2026-09-30 (inainte de publicare) a gasit si s-au reparat: emitentul semna structura SD-JWT venita in valorile
// afirmatiilor (`_sd`, `{"...": d}`), deci detinatorul putea dezvalui afirmatii pe care emitentul nu le-a vazut; prezentarea arata un element
// cu aceeasi valoare din ORICE lista; publicul si nonce-ul nu erau cerute implicit; `exp` nu se putea cere; tipul jetonului nu se verifica
// implicit (un KB-JWT trecea drept jeton de emitent); o cheie publica Node nu era primita; exp/nbf ale KB, `crit` stricat, base64url
// necanonic si `_sd_alg` imbricat. Fiecare are proba lui in proba-sdjwt.mjs si plantarea lui in control-negativ-sdjwt.mjs.
//
// CE NU FACE: nu e SD-JWT VC (nu cere `vct` si nu verifica metadatele de tip ale emitentului); nu citeste liste de stare in jeton (pentru
// revocare, credentialul AERE din identity.mjs are lista lui); nu dezvaluie recursiv la emitere (verificarea stie dezvaluiri recursive).
import crypto from 'node:crypto';
const b64u = (b) => Buffer.from(b).toString('base64url');
// base64url CANONIC: decodat si recodat trebuie sa dea acelasi text (altfel aceeasi semnatura are mai multe scrieri, si un depozit de
// reluari care tine sirul jetonului e ocolit schimbatnd bitii nefolositi ai ultimului caracter)
const dinB64u = (s, ce) => {
if (typeof s !== 'string' || !/^[A-Za-z0-9_-]*$/.test(s)) throw new Error(`sd-jwt: ${ce} is not base64url`);
const b = Buffer.from(s, 'base64url');
if (b.toString('base64url') !== s) throw new Error(`sd-jwt: ${ce} is not canonical base64url`);
return b;
};
const jsonB64u = (s, ce) => { let o; try { o = JSON.parse(dinB64u(s, ce).toString('utf8')); } catch (e) { throw new Error(/canonical/.test(e.message) ? e.message : `sd-jwt: ${ce} is not base64url JSON`); } return o; };
const sha256b64u = (s) => crypto.createHash('sha256').update(Buffer.from(s, 'ascii')).digest('base64url');
const obiect = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
export const ALGS = ['ES256', 'EdDSA', 'Ed25519', 'ML-DSA-65'];
export const TYP = 'aere+sd-jwt';
const AERE_ALG = 'ed25519+ml-dsa-65';
// ---------------------------------------------------------------- chei JWK si semnaturi JWS
/**
* Cheia PUBLICA ca JWK, din: o pereche de chei AERE (identity.mjs; `privat`), cheile publice ale unei identitati AERE ({alg, ed25519,
* mldsa65}), un KeyObject public sau privat, sau un JWK public. `alg` alege cheia AERE: 'EdDSA' (Ed25519) sau 'ML-DSA-65'.
*/
export function publicJwk(k, alg = 'EdDSA') {
const ml = alg === 'ML-DSA-65';
if (k && k.privat) k = ml ? k.privat.mldsa65 : k.privat.ed25519;
else if (obiect(k) && k.alg === AERE_ALG && typeof k.ed25519 === 'string') k = crypto.createPublicKey({ key: Buffer.from(ml ? k.mldsa65 : k.ed25519, 'base64'), format: 'der', type: 'spki' });
if (k instanceof crypto.KeyObject) return (k.type === 'public' ? k : crypto.createPublicKey(k)).export({ format: 'jwk' });
if (obiect(k) && k.kty) return k;
throw new Error('sd-jwt: not a key (a JWK, a Node key object, or AERE identity keys)');
}
function cheiePublica(jwk) {
if (!obiect(jwk) || !['EC', 'OKP', 'AKP'].includes(jwk.kty)) throw new Error('sd-jwt: the key is not an EC, OKP or AKP JWK');
if ('d' in jwk || 'priv' in jwk) throw new Error('sd-jwt: a private JWK was given where a public key belongs');
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
}
// algoritmul trebuie sa fie al cheii: un jeton nu alege el cu ce se verifica (atacul de confuzie a algoritmului, RFC 8725 s.2.1)
function potriveste(alg, key) {
const t = key.asymmetricKeyType;
if (alg === 'ES256') return t === 'ec' && key.asymmetricKeyDetails && key.asymmetricKeyDetails.namedCurve === 'prime256v1';
if (alg === 'EdDSA' || alg === 'Ed25519') return t === 'ed25519';
if (alg === 'ML-DSA-65') return t === 'ml-dsa-65';
return false;
}
// tipul: un sir exact; sau `undefined` = orice tip explicit de SD-JWT ("...+sd-jwt" sau "sd-jwt"); sau null = orice, in afara de kb+jwt
function tipBun(typ, cerut) {
if (cerut === undefined) return typeof typ === 'string' && /(^|\+)sd-jwt$/.test(typ);
if (cerut === null) return typ !== 'kb+jwt';
return typ === cerut;
}
function verificaJws(jws, jwk, { typ } = {}) {
const parti = String(jws).split('.');
if (parti.length !== 3) throw new Error('sd-jwt: a JWS has three parts');
const antet = jsonB64u(parti[0], 'the JWS header'), corp = jsonB64u(parti[1], 'the JWS payload');
if (!obiect(antet) || !obiect(corp)) throw new Error('sd-jwt: the JWS header and payload must be objects');
if (!ALGS.includes(antet.alg)) throw new Error(`sd-jwt: alg ${JSON.stringify(antet.alg)} is not accepted (accepted: ${ALGS.join(', ')}; never none)`);
if (!tipBun(antet.typ, typ)) throw new Error(`sd-jwt: typ ${JSON.stringify(antet.typ)} is not the type expected (${typ === undefined ? 'an explicit ...+sd-jwt' : typ === null ? 'anything but kb+jwt' : typ})`);
// `crit` numeste parametri pe care verificatorul TREBUIE sa-i inteleaga; nu intelegem niciunul, deci orice `crit` (bun sau stricat) e refuz
if (Object.hasOwn(antet, 'crit')) throw new Error('sd-jwt: critical header parameters (crit) are not understood');
const key = cheiePublica(jwk);
if (!potriveste(antet.alg, key)) throw new Error(`sd-jwt: alg ${antet.alg} does not match the ${key.asymmetricKeyType} key given`);
const date = Buffer.from(parti[0] + '.' + parti[1], 'ascii'), sig = dinB64u(parti[2], 'the JWS signature');
let ok = false;
try { ok = antet.alg === 'ES256' ? sig.length === 64 && crypto.verify('sha256', date, { key, dsaEncoding: 'ieee-p1363' }, sig) : crypto.verify(null, date, key, sig); } catch { ok = false; }
if (!ok) throw new Error('sd-jwt: the signature does not verify with the key given');
return { antet, corp };
}
function semneazaJws(antet, corp, priv) {
const cap = b64u(JSON.stringify(antet)) + '.' + b64u(JSON.stringify(corp));
const date = Buffer.from(cap, 'ascii');
const sig = antet.alg === 'ES256' ? crypto.sign('sha256', date, { key: priv, dsaEncoding: 'ieee-p1363' }) : crypto.sign(null, date, priv);
return cap + '.' + b64u(sig);
}
const cheiePrivata = (k, alg) => (k && k.privat ? (alg === 'ML-DSA-65' ? k.privat.mldsa65 : k.privat.ed25519) : k);
// structura SD-JWT intr-o valoare: o cheie `_sd`/`_sd_alg` intr-un obiect sau un element {"...": x} intr-o lista, oriunde in adancime
function areStructuraSd(v) {
const stiva = [v];
while (stiva.length) {
const x = stiva.pop();
if (Array.isArray(x)) { for (const e of x) { if (obiect(e) && Object.hasOwn(e, '...')) return true; stiva.push(e); } }
else if (obiect(x)) { if (Object.hasOwn(x, '_sd') || Object.hasOwn(x, '_sd_alg') || Object.hasOwn(x, '__proto__')) return true; for (const k of Object.keys(x)) stiva.push(x[k]); }
}
return false;
}
// ---------------------------------------------------------------- emitere si prezentare
/**
* @param {{issuer, claims:object, disclosable?:string[], arrayDisclosable?:string[], holder, iss:string, iat?:number, exp?:number|null, nbf?:number,
* alg?:'EdDSA'|'ML-DSA-65'|'ES256', holderAlg?:'EdDSA'|'ML-DSA-65'|'ES256', decoys?:number}} o
* issuer: chei AERE sau KeyObject privat. holder: cheia PUBLICA a detinatorului (sau cheile lui AERE). disclosable: numele afirmatiilor de
* sus dezvaluibile; arrayDisclosable: listele de sus ale caror ELEMENTE sunt dezvaluibile una cate una. exp implicit: un an de la iat
* (null: fara exp, pe care verifySdJwt il refuza implicit). Intoarce { sdJwt, disclosures:[{name, value, disclosure}] }.
*/
export function issueSdJwt({ issuer, claims, disclosable = [], arrayDisclosable = [], holder, iss, iat = Math.floor(Date.now() / 1000), exp, nbf, alg = 'EdDSA', holderAlg = 'EdDSA', decoys = 0 }) {
if (!obiect(claims)) throw new Error('sd-jwt: claims must be an object');
if (typeof iss !== 'string' || !iss) throw new Error('sd-jwt: iss is required');
if (!ALGS.includes(alg) || !ALGS.includes(holderAlg)) throw new Error('sd-jwt: unknown alg');
// RFC 9901 s.4.1 (regula 7 a emitentului): emitentul nu semneaza digesturi pe care nu le-a facut el; o valoare care poarta deja `_sd`,
// `_sd_alg` sau un element {"...": d} ar lasa detinatorul sa dezvaluie afirmatii nevazute de emitent
if (areStructuraSd(claims)) throw new Error('sd-jwt: a claim value carries SD-JWT structure (_sd, _sd_alg or a {"...": digest} element) or a __proto__ key; the issuer signs only digests it made');
for (const n of [...disclosable, ...arrayDisclosable]) if (!Object.hasOwn(claims, n) || ['_sd', '...', '_sd_alg', 'cnf', 'iss', 'iat', 'exp', 'nbf'].includes(n)) throw new Error(`sd-jwt: cannot make ${n} disclosable`);
for (const n of arrayDisclosable) if (!Array.isArray(claims[n])) throw new Error(`sd-jwt: ${n} is not a list`);
const sare = () => crypto.randomBytes(16).toString('base64url');
const dez = [], corp = {}, sd = [];
for (const [n, v] of Object.entries(claims)) {
if (disclosable.includes(n)) { const d = b64u(JSON.stringify([sare(), n, v])); dez.push({ name: n, value: v, disclosure: d }); sd.push(sha256b64u(d)); }
else if (arrayDisclosable.includes(n)) corp[n] = v.map((e) => { const d = b64u(JSON.stringify([sare(), e])); dez.push({ name: n + '[]', value: e, disclosure: d }); return { '...': sha256b64u(d) }; });
else corp[n] = v;
}
for (let i = 0; i < decoys; i++) sd.push(sha256b64u(b64u(crypto.randomBytes(32))));
if (sd.length) corp._sd = sd.sort();
corp._sd_alg = 'sha-256';
corp.iss = iss; corp.iat = iat;
const e = exp === undefined ? iat + 365 * 86400 : exp; if (e != null) corp.exp = e;
if (nbf != null) corp.nbf = nbf;
corp.cnf = { jwk: publicJwk(holder, holderAlg) };
const jwt = semneazaJws({ alg, typ: TYP }, corp, cheiePrivata(issuer, alg));
return { sdJwt: jwt + '~' + dez.map((x) => x.disclosure + '~').join(''), disclosures: dez };
}
/**
* Detinatorul pastreaza numai dezvaluirile alese si leaga prezentarea de verificator. reveal: un sir = afirmatia de SUS cu acel nume;
* { element, in } = elementul cu acea valoare din lista de SUS `in` (numai din ea). O alegere care nu gaseste nimic e o eroare, nu o tacere.
*/
export function presentSdJwt({ sdJwt, reveal = [], holder, holderAlg = 'EdDSA', audience, nonce, iat = Math.floor(Date.now() / 1000) }) {
if (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce) throw new Error('sd-jwt: a presentation needs the verifier\'s audience and nonce');
const parti = String(sdJwt).split('~');
if (parti[parti.length - 1] !== '') throw new Error('sd-jwt: this SD-JWT already ends with a key binding JWT');
const jwt = parti[0], dez = parti.slice(1, -1);
const corp = jsonB64u(jwt.split('.')[1] || '', 'the issuer-signed JWT payload');
// unde sta fiecare digest in JWT-ul emitentului: proprietate de sus, sau element al unei liste de sus (cu numele listei)
const loc = new Map();
for (const dg of Array.isArray(corp._sd) ? corp._sd : []) loc.set(dg, { tip: 'prop' });
for (const [n, v] of Object.entries(corp)) if (Array.isArray(v)) for (const e of v) if (obiect(e) && typeof e['...'] === 'string') loc.set(e['...'], { tip: 'elem', lista: n });
const gasite = new Set();
const alese = dez.filter((d) => {
const l = loc.get(sha256b64u(d)); if (!l) return false;
const c = JSON.parse(Buffer.from(d, 'base64url').toString('utf8'));
for (let i = 0; i < reveal.length; i++) {
const r = reveal[i];
const bun = l.tip === 'prop' ? typeof r === 'string' && c.length === 3 && c[1] === r
: obiect(r) && r.in === l.lista && c.length === 2 && JSON.stringify(r.element) === JSON.stringify(c[1]);
if (bun) { gasite.add(i); return true; }
}
return false;
});
const lipsa = reveal.filter((_, i) => !gasite.has(i));
if (lipsa.length) throw new Error('sd-jwt: nothing to reveal for ' + JSON.stringify(lipsa) + ' (a name reveals a top-level claim; {element, in} an element of the top-level list named)');
const baza = jwt + '~' + alese.map((d) => d + '~').join('');
const kb = semneazaJws({ alg: holderAlg, typ: 'kb+jwt' }, { iat, aud: audience, nonce, sd_hash: sha256b64u(baza) }, cheiePrivata(holder, holderAlg));
return baza + kb;
}
// ---------------------------------------------------------------- verificare (RFC 9901 s.7.1, s.7.3)
/**
* @param {string} sdJwt SD-JWT sau SD-JWT+KB, serializarea compacta
* @param {{issuerKey, issuerAlg?, expectedIssuer?:string, audience?:string, nonce?:string, requireKeyBinding?:boolean, requireExp?:boolean,
* now?:Date, maxAgeS?:number, clockSkewS?:number, expectedTyp?:string|null}} o
* issuerKey: cheia EMITENTULUI data de verificator (JWK, KeyObject, cheile AERE ale emitentului); niciodata din jeton. audience si nonce
* sunt ale verificatorului si se CER cand exista (sau se cere) Key Binding. expectedTyp: implicit orice tip explicit "...+sd-jwt".
* Intoarce { valid, reason, payload, disclosed:[nume], issuer, issuerChecked, keyBinding:{aud, nonce, iat, alg}|null }.
*/
export function verifySdJwt(sdJwt, { issuerKey, issuerAlg = 'EdDSA', expectedIssuer = null, audience = null, nonce = null, requireKeyBinding = true, requireExp = true, now = new Date(), maxAgeS = 300, clockSkewS = 60, expectedTyp } = {}) {
const refuz = (reason) => ({ valid: false, reason, payload: null, disclosed: [], keyBinding: null });
try {
if (typeof sdJwt !== 'string' || !sdJwt.includes('~')) return refuz('not an SD-JWT (no ~)');
const parti = sdJwt.split('~');
const jwt = parti[0], kb = parti[parti.length - 1], dez = parti.slice(1, -1);
if (dez.some((d) => d === '')) return refuz('an empty disclosure between two ~');
// RFC 9901 s.7.3 pasul 5: publicul si nonce-ul verificatorului se compara; fara ele, o prezentare facuta pentru altul ar trece
if ((requireKeyBinding || kb !== '') && (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce)) {
return refuz('the verifier must give its audience and nonce to judge a key binding (RFC 9901 s.7.3)');
}
// 2. JWT-ul emitentului
const { antet, corp } = verificaJws(jwt, publicJwk(issuerKey, issuerAlg), { typ: expectedTyp });
if (expectedIssuer != null && corp.iss !== expectedIssuer) return refuz(`issued by ${JSON.stringify(corp.iss)}, not ${expectedIssuer}`);
const alg = corp._sd_alg === undefined ? 'sha-256' : corp._sd_alg;
if (alg !== 'sha-256') return refuz(`_sd_alg ${JSON.stringify(corp._sd_alg)} is not understood (only sha-256)`);
// 3. dezvaluirile: digest -> continut
const dupaDigest = new Map();
for (const d of dez) {
const dg = sha256b64u(d);
if (dupaDigest.has(dg)) return refuz('the same disclosure is given twice');
let c; try { c = JSON.parse(dinB64u(d, 'a disclosure').toString('utf8')); } catch { return refuz('a disclosure is not base64url JSON'); }
if (!Array.isArray(c) || (c.length !== 2 && c.length !== 3) || typeof c[0] !== 'string') return refuz('a disclosure is not [salt, name, value] or [salt, value]');
dupaDigest.set(dg, c);
}
const vazute = new Set(), folosite = new Set();
const vezi = (dg) => { if (vazute.has(dg)) throw new Error('the digest ' + dg.slice(0, 12) + '.. appears more than once'); vazute.add(dg); };
const proceseaza = (x, sus = false) => {
if (Array.isArray(x)) {
const out = [];
for (const e of x) {
if (obiect(e) && Object.keys(e).length === 1 && typeof e['...'] === 'string') {
vezi(e['...']);
const c = dupaDigest.get(e['...']);
if (!c) continue; // nedezvaluit: elementul se scoate
if (c.length !== 2) throw new Error('an array element disclosure must be [salt, value]');
folosite.add(e['...']); out.push(proceseaza(c[1]));
} else out.push(proceseaza(e));
}
return out;
}
if (!obiect(x)) return x;
// JSON.parse face din "__proto__" o proprietate proprie, iar `out[k] = v` pe un obiect obisnuit i-ar schimba PROTOTIPUL (afirmatii
// mostenite, nevazute de Object.keys); o afirmatie cu numele asta, in clar sau intr-o valoare dezvaluita, se refuza
if (Object.hasOwn(x, '__proto__')) throw new Error('a claim may not be named __proto__');
const out = {};
// `_sd_alg` se scoate NUMAI de sus (RFC 9901 s.7.1 pasul 3.f); mai jos e o afirmatie ca oricare
for (const [k, v] of Object.entries(x)) if (k !== '_sd' && !(sus && k === '_sd_alg')) out[k] = proceseaza(v);
if (x._sd !== undefined) {
if (!Array.isArray(x._sd) || !x._sd.every((s) => typeof s === 'string')) throw new Error('_sd is not a list of strings');
for (const dg of x._sd) {
vezi(dg);
const c = dupaDigest.get(dg);
if (!c) continue;
if (c.length !== 3 || typeof c[1] !== 'string') throw new Error('an object property disclosure must be [salt, name, value]');
if (c[1] === '_sd' || c[1] === '...' || c[1] === '__proto__') throw new Error(`a disclosure may not name ${c[1]}`);
if (Object.hasOwn(out, c[1])) throw new Error(`the disclosed claim ${c[1]} already exists at its level`);
folosite.add(dg); out[c[1]] = proceseaza(c[2]);
}
}
return out;
};
let payload;
try { payload = proceseaza(corp, true); } catch (e) { return refuz(e.message); }
// 5. o dezvaluire nelegata de niciun digest = refuz
if (folosite.size !== dupaDigest.size) return refuz('a disclosure is not referenced by any digest of the issuer-signed JWT');
const disclosed = [...dupaDigest.entries()].map(([, c]) => (c.length === 3 ? c[1] : '[]'));
// 6. valabilitatea pe ceasul verificatorului (exp cerut si fara toleranta; nbf si iat cu toleranta numai pe inceput, ca identity.mjs)
const acum = Math.floor(new Date(now).getTime() / 1000);
for (const k of ['exp', 'nbf', 'iat']) if (payload[k] !== undefined && !Number.isFinite(payload[k])) return refuz(`${k} is not a number`);
if (requireExp && payload.exp === undefined) return refuz('exp is required and the processed payload has none (RFC 9901 s.9.7)');
if (payload.exp !== undefined && acum >= payload.exp) return refuz(`expired at ${payload.exp}`);
if (payload.nbf !== undefined && payload.nbf - clockSkewS > acum) return refuz(`not valid before ${payload.nbf}`);
if (payload.iat !== undefined && payload.iat - clockSkewS > acum) return refuz(`issued in the future (${payload.iat})`);
const rez = { valid: true, reason: '', payload, disclosed, issuer: corp.iss, issuerChecked: expectedIssuer != null, alg: antet.alg };
// s.7.3: Key Binding JWT
if (kb === '') {
if (requireKeyBinding) return refuz('key binding is required and the SD-JWT has none (it ends with ~)');
return { ...rez, keyBinding: null };
}
if (!obiect(payload.cnf) || !obiect(payload.cnf.jwk)) return refuz('a key binding JWT is given but the SD-JWT names no holder key (cnf.jwk)');
let k;
try { k = verificaJws(kb, payload.cnf.jwk, { typ: 'kb+jwt' }); } catch (e) { return refuz('key binding: ' + e.message.replace(/^sd-jwt: /, '')); }
const K = k.corp;
if (typeof K.nonce !== 'string' || typeof K.aud !== 'string' || !Number.isFinite(K.iat) || typeof K.sd_hash !== 'string') return refuz('key binding: aud, nonce, iat and sd_hash are required');
if (K.aud !== audience) return refuz(`key binding: made for ${K.aud}, not ${audience}`);
if (K.nonce !== nonce) return refuz('key binding: another nonce');
if (Math.abs(acum - K.iat) > maxAgeS) return refuz(`key binding: made at ${K.iat}, outside ${maxAgeS} s of this clock`);
// RFC 9901 s.7.3 pasul 5.h: si exp/nbf ale KB-JWT, daca le poarta (RFC 7519)
if (K.exp !== undefined && (!Number.isFinite(K.exp) || acum >= K.exp)) return refuz(`key binding: expired at ${K.exp}`);
if (K.nbf !== undefined && (!Number.isFinite(K.nbf) || K.nbf - clockSkewS > acum)) return refuz(`key binding: not valid before ${K.nbf}`);
const baza = sdJwt.slice(0, sdJwt.length - kb.length);
if (sha256b64u(baza) !== K.sd_hash) return refuz('key binding: sd_hash is not the hash of what was presented');
return { ...rez, keyBinding: { aud: K.aud, nonce: K.nonce, iat: K.iat, alg: k.antet.alg } };
} catch (e) { return refuz(String(e.message || e).replace(/^sd-jwt: /, '')); }
}