#!/usr/bin/env node // identity-cli.mjs: linia de comanda a lui AERE Identity (identity.mjs). Iesiri: 0 bun / VALID, 1 INVALID, 2 folosire gresita. // keygen --out keys.json (0600; refuza sa suprascrie) // pub --keys keys.json [--out pub.json] partea publica, de dat altora // id --pub pub.json | --keys keys.json // issue --issuer-keys k.json --holder-pub h.json --type T --claim name=value ... [--disclosable a,b | --all-disclosable] // [--valid-days N] [--status-list ID --status-index I] [--decoys N] --out cred.json // status-list --issuer-keys k.json --id ID [--size BITS] [--revoke i,j] [--valid-days N] --out list.json // delegate --from-keys k.json --to-pub p.json [--parent d.json] [--credentials ids|*] [--claims names|*] [--audiences a|*] // [--valid-minutes M] [--max-depth D] --out d.json // revoke --keys k.json --delegation d.json [--reason R] --out r.json // present --cred cred.json --reveal a,b --presenter-keys k.json [--delegation d1.json ...] --audience A --nonce N --out p.json // verify --presentation p.json [--audience A --nonce N] [--trust-issuer id|pub.json ...] [--status-list l.json ...] // [--revocation r.json ...] [--max-age S] [--at T] [--json] --at: judge at time T (RFC 3339 UTC), not now // comply --presentation p.json --policy policy.json --audience A --nonce N [--status-list l.json ...] [--revocation r.json ...] // [--record record.json] [--pseudonym-key-file k] a compliance policy judged; the record carries no personal data // [--json [--with-record]] [--at T] the whole result as one JSON object (with the record inside) // verify-sdjwt --sd-jwt f.txt --issuer-key k.json [--issuer-alg EdDSA|ML-DSA-65|ES256] --audience A --nonce N // [--expected-issuer I] [--expected-typ T] [--max-age S] [--at T] [--json] an SD-JWT+KB (IETF RFC 9901), sdjwt.mjs; // the issuer key is a public JWK or the public keys of an AERE identity (the output of pub) // Coduri de iesire: 0 VALID / COMPLIANT, 1 INVALID / NOT COMPLIANT, 2 intrare respinsa (motivul pe stderr, `error: ...`). // O valoare de --claim se citeste ca JSON daca e JSON (true, 42, {"a":1}), altfel ca text. import fs from 'node:fs'; import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as I from './identity.mjs'; class Folosire extends Error {} const argv = process.argv.slice(2); const cmd = argv[0]; const get = (n) => { const i = argv.indexOf(n); return i === -1 ? null : (argv[i + 1] ?? null); }; const toate = (n) => argv.flatMap((a, i) => (a === n && argv[i + 1] != null ? [argv[i + 1]] : [])); const are = (n) => argv.includes(n); const cere = (n) => { const v = get(n); if (v == null) throw new Folosire(`${cmd} needs ${n}`); return v; }; const citeste = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${f}: ${e.message}`); } }; const scrie = (f, o, privat = false) => { if (privat && fs.existsSync(f)) throw new Folosire(`${f} exists; a key file is never overwritten`); fs.writeFileSync(f, JSON.stringify(o, null, 1) + '\n', privat ? { mode: 0o600, flag: 'wx' } : undefined); }; const lista = (v) => (v === '*' ? '*' : String(v).split(',').map((x) => x.trim()).filter(Boolean)); const zile = (n) => new Date(Date.now() + Number(n) * 86400000).toISOString(); // 2026-09-30: --at judeca pe ceasul dat, nu pe al masinii: un verdict dat la un moment (de pilda de API-ul Cloud) se // reface mai tarziu identic; fara --at, acum. Ceasul e al verificatorului oricum (cine ruleaza unealta il alege), deci nu slabeste nimic. const momentul = () => { const a = get('--at'); if (a == null) return new Date(); if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(a) || Number.isNaN(Date.parse(a))) throw new Folosire('--at is an RFC 3339 UTC time (2026-09-30T08:00:00Z)'); return new Date(a); }; async function main() { if (cmd === 'keygen') { const k = I.generateKeys(); scrie(cere('--out'), I.exportKeys(k), true); console.log(k.id); return 0; } if (cmd === 'pub') { const k = I.importKeys(citeste(cere('--keys'))); const o = get('--out'); if (o) scrie(o, k.public); else console.log(JSON.stringify(k.public)); return 0; } if (cmd === 'id') { const p = get('--pub') ? citeste(get('--pub')) : I.importKeys(citeste(cere('--keys'))).public; console.log(I.idOf(p)); return 0; } if (cmd === 'issue') { const issuer = I.importKeys(citeste(cere('--issuer-keys'))); const claims = {}; for (const c of toate('--claim')) { const i = c.indexOf('='); if (i < 1) throw new Folosire('--claim is name=value'); const n = c.slice(0, i), v = c.slice(i + 1); let val; try { val = JSON.parse(v); } catch { val = v; } if (Object.hasOwn(claims, n)) throw new Folosire('--claim ' + n + ' given twice'); claims[n] = val; } const disclosable = are('--all-disclosable') ? null : (get('--disclosable') ? lista(get('--disclosable')) : []); const sl = get('--status-list'); const r = I.issueCredential({ issuer, holder: citeste(cere('--holder-pub')), type: cere('--type'), claims, disclosable, validUntil: zile(get('--valid-days') ?? 365), status: sl ? { list: sl, index: Number(cere('--status-index')) } : null, decoys: Number(get('--decoys') ?? 0) }); scrie(cere('--out'), { v: 1, kind: 'aere-credential-with-disclosures', credential: r.credential, disclosures: r.disclosures }); console.log(`issued ${r.credential.statement.id} to ${r.credential.statement.holder.id}: ${Object.keys(r.credential.statement.claims).length} plain claim(s), ${r.disclosures.length} disclosable (the file holds the disclosures: give it to the holder only)`); return 0; } if (cmd === 'status-list') { const issuer = I.importKeys(citeste(cere('--issuer-keys'))); const l = I.createStatusList({ issuer, id: cere('--id'), size: Number(get('--size') ?? I.MIN_STATUS_BITS), revoked: get('--revoke') ? lista(get('--revoke')).map(Number) : [], validUntil: zile(get('--valid-days') ?? 7) }); scrie(cere('--out'), l); console.log(`status list ${l.statement.id}: ${l.statement.size} entries, valid until ${l.statement.validUntil}`); return 0; } if (cmd === 'delegate') { const from = I.importKeys(citeste(cere('--from-keys'))); const d = I.delegate({ from, to: citeste(cere('--to-pub')), parent: get('--parent') ? citeste(get('--parent')) : null, scope: { credentials: lista(get('--credentials') ?? '*'), claims: lista(get('--claims') ?? '*'), audiences: lista(get('--audiences') ?? '*') }, notAfter: new Date(Date.now() + Number(get('--valid-minutes') ?? 60) * 60000).toISOString(), maxDepth: Number(get('--max-depth') ?? 0) }); scrie(cere('--out'), d); console.log(`delegated ${I.delegationHash(d)}: ${d.statement.from.id} -> ${d.statement.to.id} until ${d.statement.notAfter}`); return 0; } if (cmd === 'revoke') { const r = I.revokeDelegation({ by: I.importKeys(citeste(cere('--keys'))), delegation: citeste(cere('--delegation')), reason: get('--reason') }); scrie(cere('--out'), r); console.log(`revoked ${r.statement.target} at ${r.statement.at}`); return 0; } if (cmd === 'present') { const c = citeste(cere('--cred')); if (c.kind !== 'aere-credential-with-disclosures') throw new Folosire('--cred is the file written by issue'); const p = I.present({ credential: c.credential, disclosures: c.disclosures, reveal: get('--reveal') ? lista(get('--reveal')) : [], presenter: I.importKeys(citeste(cere('--presenter-keys'))), delegations: toate('--delegation').map(citeste), audience: cere('--audience'), nonce: cere('--nonce') }); scrie(cere('--out'), p); console.log(`presentation for ${p.binding.audience}: ${p.disclosures.length} claim(s) disclosed`); return 0; } if (cmd === 'verify') { const p = citeste(cere('--presentation')); // 2026-09-30: un emitent de incredere stricat e o greseala a verificatorului (cod 2, cu motivul), nu o cadere fara verdict const trusted = toate('--trust-issuer').map((t) => { if (/^aere-id:/.test(t)) { if (!/^aere-id:[0-9a-f]{40}$/.test(t)) throw new Folosire(`--trust-issuer ${t} is not an aere-id (aere-id: + 40 hex)`); return t; } const pub = citeste(t); try { I.idOf(pub); } catch { throw new Folosire(`--trust-issuer ${path.basename(t)} is not a public key file (the output of pub)`); } return pub; }); const maxAgeS = Number(get('--max-age') ?? 300); if (!Number.isInteger(maxAgeS) || maxAgeS < 1 || maxAgeS > 3600) throw new Folosire('--max-age is a number of seconds, 1..3600'); const r = I.verifyPresentation(p, { audience: get('--audience'), nonce: get('--nonce'), trustedIssuers: trusted.length ? trusted : null, statusLists: toate('--status-list').map(citeste), revocations: toate('--revocation').map(citeste), maxAgeS, now: momentul() }); if (are('--json')) console.log(JSON.stringify(r, null, 1)); else { for (const x of r.rows) console.log(`${x.pass === true ? 'ok' : x.pass === false ? 'FAIL' : '--'} ${x.name}${x.detail ? ': ' + x.detail : ''}`); console.log(r.valid ? `VALID: every present claim holds${r.notJudged ? `; ${r.notJudged} not judged (the -- lines)` : ''}` : 'INVALID'); if (r.valid) console.log('claims: ' + JSON.stringify(r.claims)); } return r.valid ? 0 : 1; } if (cmd === 'comply') { // conformitatea fara supraveghere (conformitate.mjs): politica verificatorului judecata pe o prezentare; --record scrie // inregistrarea (plic AIP-23 compliance) fara date personale, cu proof-kinds de langa (../proof-kinds) const { checkCompliance, complianceEnvelope } = await import('./conformitate.mjs'); const pr = citeste(cere('--presentation')), po = citeste(cere('--policy')); const audience = cere('--audience'), nonce = cere('--nonce'), statusLists = toate('--status-list').map(citeste), revocations = toate('--revocation').map(citeste); // 2026-09-30: o politica stricata e o greseala a verificatorului (cod 2, cu motivul politicii), nu o cadere fara verdict const acum = momentul(); let r; try { r = checkCompliance(pr, po, { audience, nonce, statusLists, revocations, now: acum }); } catch (e) { throw new Folosire(e.message); } let env = null; if (get('--record') || are('--with-record')) { const { buildProof } = await import('../proof-kinds/proof-kinds.mjs'); const k = get('--pseudonym-key-file') ? fs.readFileSync(get('--pseudonym-key-file')) : null; env = complianceEnvelope(r, { audience, buildProof, pseudonymKey: k, createdAt: acum.toISOString() }); if (get('--record')) scrie(get('--record'), env); } // --json: rezultatul intreg (si inregistrarea, cu --record sau --with-record) intr-un singur obiect, pentru masini (API-ul Cloud) if (are('--json')) console.log(JSON.stringify({ ...r, ...(env ? { record: env } : {}) }, null, 1)); else console.log(r.compliant ? `COMPLIANT with ${r.policyId} (${r.policyHash})` : `NOT COMPLIANT with ${r.policyId}:\n ` + r.reasons.join('\n ')); return r.compliant ? 0 : 1; } if (cmd === 'verify-sdjwt') { // 2026-09-30: SD-JWT (IETF RFC 9901) cu sdjwt.mjs; jetonul dintr-un fisier text (serializarea compacta, fara spatii) const { verifySdJwt, publicJwk } = await import('./sdjwt.mjs'); let jeton; try { jeton = fs.readFileSync(cere('--sd-jwt'), 'utf8').trim(); } catch (e) { throw new Folosire(`cannot read --sd-jwt: ${e.message}`); } const cheie = citeste(cere('--issuer-key')); const alg = get('--issuer-alg') ?? 'EdDSA'; if (!['EdDSA', 'Ed25519', 'ES256', 'ML-DSA-65'].includes(alg)) throw new Folosire('--issuer-alg is EdDSA, ES256 or ML-DSA-65'); // revizuirea din 30 sept: cheia emitentului e intrarea VERIFICATORULUI; una care nu se citeste, e privata sau nu e a algoritmului cerut e o // greseala a lui (cod 2, cu motivul), nu un credential INVALID (cheile stricate ieseau INVALID cu mesajul Node) try { const jwk = publicJwk(cheie, alg); if ('d' in jwk || 'priv' in jwk) throw new Error('a private JWK was given where a public key belongs'); const k = crypto.createPublicKey({ key: jwk, format: 'jwk' }); const cere2 = alg === 'ES256' ? k.asymmetricKeyType === 'ec' && k.asymmetricKeyDetails.namedCurve === 'prime256v1' : alg === 'ML-DSA-65' ? k.asymmetricKeyType === 'ml-dsa-65' : k.asymmetricKeyType === 'ed25519'; if (!cere2) throw new Error(`the key is a ${k.asymmetricKeyType} key, not one for ${alg}`); } catch (e) { throw new Folosire('--issuer-key: ' + String(e.message || e).replace(/^sd-jwt: /, '').slice(0, 160)); } const maxAgeS = Number(get('--max-age') ?? 300); if (!Number.isInteger(maxAgeS) || maxAgeS < 1 || maxAgeS > 3600) throw new Folosire('--max-age is a number of seconds, 1..3600'); const tip = get('--expected-typ'); const r = verifySdJwt(jeton, { issuerKey: cheie, issuerAlg: alg, audience: cere('--audience'), nonce: cere('--nonce'), expectedIssuer: get('--expected-issuer'), maxAgeS, now: momentul(), ...(tip != null ? { expectedTyp: tip } : {}) }); // JSON compact: cu indentare, iesirea creste cu patratul adancimii afirmatiilor (un jeton de 7 KB imbricat de 2.500 de ori dadea 6 MB) if (are('--json')) console.log(JSON.stringify(r)); else console.log(r.valid ? `VALID: issued by ${r.issuer}${r.issuerChecked ? '' : ' (not checked against an expected issuer)'}; disclosed: ${r.disclosed.join(', ') || 'nothing'}` : 'INVALID: ' + r.reason); return r.valid ? 0 : 1; } throw new Folosire('usage: identity-cli.mjs keygen|pub|id|issue|status-list|delegate|revoke|present|verify|comply|verify-sdjwt ... (see README.md)'); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().then((c) => { process.exitCode = c; }, (e) => { console.error('error: ' + (e.message || e)); process.exitCode = e instanceof Folosire ? 2 : 1; }); }