diff --git a/README.md b/README.md index 592e82b..5c24a61 100644 --- a/README.md +++ b/README.md @@ -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= 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= 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 `) | 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). diff --git a/identity/README.md b/identity/README.md index 9c1142d..23cdb85 100644 --- a/identity/README.md +++ b/identity/README.md @@ -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=`); without it that test is reported as diff --git a/identity/control-negativ-sdjwt.mjs b/identity/control-negativ-sdjwt.mjs new file mode 100644 index 0000000..afd07ea --- /dev/null +++ b/identity/control-negativ-sdjwt.mjs @@ -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; diff --git a/identity/fixturi-rfc9901.json b/identity/fixturi-rfc9901.json new file mode 100644 index 0000000..ceb6dc9 --- /dev/null +++ b/identity/fixturi-rfc9901.json @@ -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" + } +} diff --git a/identity/proba-sdjwt.mjs b/identity/proba-sdjwt.mjs new file mode 100644 index 0000000..2a35934 --- /dev/null +++ b/identity/proba-sdjwt.mjs @@ -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; diff --git a/identity/sdjwt.mjs b/identity/sdjwt.mjs new file mode 100644 index 0000000..f39c6fd --- /dev/null +++ b/identity/sdjwt.mjs @@ -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: /, '')); } +}