diff --git a/README.md b/README.md index 44873f0..ca544c8 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 | +| [`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 | Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them. @@ -35,7 +35,7 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test | | readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) | | control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; 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), measured 2026-09-30 | 46/46 (`node control-negativ-identity.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`), measured 2026-09-30 | 46/46 (`node control-negativ-identity.mjs`); compliance 13/13 (`node control-negativ-conformitate.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: 144 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 19, agents 39, identity 5, readiness 4). +MIT, see [LICENSE](LICENSE). Files: 147 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 19, agents 39, identity 8, readiness 4). diff --git a/identity/README.md b/identity/README.md index e3b4c55..911d4c4 100644 --- a/identity/README.md +++ b/identity/README.md @@ -81,6 +81,27 @@ clock. For a time the issuer does not choose, notarize: `proofOfCredential` and envelopes (`identity` and `authorization` kinds of `../proof-kinds`) that the Aere Proof API notarizes and that the AIP-23 reference verifier checks. +## Compliance without surveillance (`conformitate.mjs`) + +A verifier writes its compliance policy once: which issuers it believes, and which claims it needs (`equals`, `in`, `notIn`, +`atLeast`, `present`), for example "over 18, not in these jurisdictions, verification level at least 2". `checkCompliance` judges a +presentation against it: the presentation must be valid and bound to the verifier's audience and nonce, the issuer must be one of +the policy's, the credential status must be judged, not merely "not known to be revoked" (unless the policy says otherwise), and +every rule must hold. The policy's hash is recomputed from its normal form, so a looser policy cannot pass under a strict one's hash. + +``` +node identity-cli.mjs comply --presentation p.json --policy policy.json --audience https://exchange.example --nonce \ + --status-list list.json --record record.json [--pseudonym-key-file key] +``` + +The record (`complianceEnvelope`) is an AIP-23 `compliance` envelope that carries no personal data: the policy's hash, the result, the +digest of the presentation, and a pseudonym of the holder bound to this verifier. Without `--pseudonym-key-file` the pseudonym is a +SHA-256 over the holder's id and the audience, which anyone who knows both can recompute (it keeps the id out of the record, it does +not hide it from them); with a key it is an HMAC only the verifier can recompute. The verifier keeps the record, and can notarize it +for a time it does not choose, instead of keeping the data. What this is not: a zero-knowledge proof (a shown claim is shown whole; +"over 18" is a claim the issuer made), an AML or sanctions screening (it consults no list), or legal compliance with anything: it +says that one presentation met one policy at one time, as judged by the verifier. + ## 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 @@ -93,6 +114,8 @@ line is reported as not judged. The private key files are written with mode 0600 ``` node proba-identity.mjs # 43: the paths above, and each attack of the adversarial review as its own test node control-negativ-identity.mjs # on a copy, each of 46 guards removed -> its own named test turns red +node proba-conformitate.mjs # 14: compliance policies judged on real presentations, the record without personal data, the command line +node control-negativ-conformitate.mjs # on a copy, each of 13 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/conformitate.mjs b/identity/conformitate.mjs new file mode 100644 index 0000000..70e8d96 --- /dev/null +++ b/identity/conformitate.mjs @@ -0,0 +1,92 @@ +// AERE Identity, conformitatea fara supraveghere (roadmap master punctul 13, pista B, 2026-09-30): o POLITICA de conformitate a +// verificatorului (ce emitenti crede, ce afirmatii cere: varsta, jurisdictie, nivel de verificare, acreditare), judecata pe o +// prezentare de credential (identity.mjs), si o INREGISTRARE a judecatii ca plic AIP-23 `compliance` care NU poarta date personale: +// numai hash-ul politicii, un pseudonim al detinatorului legat de verificator si digestul dovezii. Verificatorul pastreaza plicul +// (notarizabil: momentul il da lantul), nu datele; datele raman la detinator si se arata numai cat cere politica. +// +// Ce NU este: nu e o dovada cu cunoastere zero (ce se arata se arata intreg; "peste 18" e o afirmatie emisa de emitent, nu calculata +// din data nasterii fara s-o vada nimeni); nu e o verificare AML sau de sanctiuni (nu consulta nicio lista); nu face pe nimeni conform +// cu vreo lege: spune ca o prezentare anume a indeplinit o politica anume, la un moment anume, judecata de verificator. +// +// O politica e { v:1, kind:'aere-compliance-policy', id, trustedIssuers:[id], requireStatus, maxAgeS, require:[regula] }, cu regula +// { claim, equals } | { claim, in:[...] } | { claim, notIn:[...] } | { claim, atLeast:number } | { claim, present:true }. Hash-ul ei +// se recalculeaza din forma canonica, deci o politica mai laxa nu poate trece sub hash-ul uneia stricte. +import crypto from 'node:crypto'; +import { verifyPresentation, canonical, idOf } from './identity.mjs'; + +const sha = (s) => '0x' + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); +const ID = /^aere-id:[0-9a-f]{40}$/; +const OPERATORI = ['equals', 'in', 'notIn', 'atLeast', 'present']; + +/** Forma normala a unei politici si hash-ul ei; refuza campuri necunoscute si reguli fara inteles. */ +export function definePolicy(p) { + const chei = ['v', 'kind', 'id', 'trustedIssuers', 'requireStatus', 'maxAgeS', 'require']; + const necunoscute = Object.keys(p || {}).filter((k) => !chei.includes(k)); + if (necunoscute.length) throw new Error('compliance policy: unknown field ' + necunoscute.join(', ')); + if (typeof p.id !== 'string' || !p.id) throw new Error('compliance policy: id is required'); + if (!Array.isArray(p.trustedIssuers) || !p.trustedIssuers.length) throw new Error('compliance policy: trustedIssuers is required (a policy that trusts anyone proves nothing)'); + const emitenti = [...new Set(p.trustedIssuers.map((x) => (typeof x === 'string' && ID.test(x) ? x : idOf(x))))].sort(); + if (!Array.isArray(p.require) || !p.require.length) throw new Error('compliance policy: require is a non-empty list of rules'); + const reguli = p.require.map((r) => { + const op = OPERATORI.filter((o) => Object.hasOwn(r, o)); + const extra = Object.keys(r).filter((k) => k !== 'claim' && !OPERATORI.includes(k)); + if (typeof r.claim !== 'string' || !r.claim || op.length !== 1 || extra.length) throw new Error('compliance policy: a rule is { claim, }'); + const o = op[0], v = r[o]; + if ((o === 'in' || o === 'notIn') && (!Array.isArray(v) || !v.length)) throw new Error(`compliance policy: ${o} needs a non-empty list`); + if (o === 'atLeast' && !Number.isFinite(v)) throw new Error('compliance policy: atLeast needs a number'); + if (o === 'present' && v !== true) throw new Error('compliance policy: present must be true'); + canonical(v); + return { claim: r.claim, [o]: (o === 'in' || o === 'notIn') ? [...v].sort((a, b) => (canonical(a) < canonical(b) ? -1 : 1)) : v }; + }).sort((a, b) => (canonical(a) < canonical(b) ? -1 : 1)); + const maxAgeS = p.maxAgeS == null ? 300 : Number(p.maxAgeS); + if (!Number.isInteger(maxAgeS) || maxAgeS < 1 || maxAgeS > 3600) throw new Error('compliance policy: maxAgeS must be 1..3600'); + const policy = { v: 1, kind: 'aere-compliance-policy', id: p.id, trustedIssuers: emitenti, requireStatus: p.requireStatus !== false, maxAgeS, require: reguli }; + return { policy, policyHash: sha(canonical(policy)) }; +} + +function regula(r, claims) { + const are = Object.hasOwn(claims, r.claim), v = claims[r.claim]; + if (Object.hasOwn(r, 'present')) return are; + if (!are) return false; + if (Object.hasOwn(r, 'equals')) return canonical(v) === canonical(r.equals); + if (Object.hasOwn(r, 'in')) return r.in.some((x) => canonical(x) === canonical(v)); + if (Object.hasOwn(r, 'notIn')) return !r.notIn.some((x) => canonical(x) === canonical(v)); + if (Object.hasOwn(r, 'atLeast')) return typeof v === 'number' && Number.isFinite(v) && v >= r.atLeast; + return false; +} + +/** + * Judeca o prezentare dupa o politica. Cere: prezentarea VALIDA, legata de publicul si nonce-ul verificatorului, emitentul intre cei + * ai politicii, starea credentialului JUDECATA (nu doar "nerevocat pentru ca nu stiu") cand politica o cere, si fiecare regula. + * Intoarce { compliant, reasons, policyHash, presentation } fara afirmatiile detinatorului (le are verificatorul in `presentation.claims` + * numai daca le cere explicit, cu `keepClaims`). + */ +export function checkCompliance(p, politica, { audience, nonce, now = new Date(), statusLists = [], revocations = [], keepClaims = false } = {}) { + const { policy, policyHash } = definePolicy(politica); + if (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce) throw new Error('compliance: the verifier\'s audience and nonce are required (without them a presentation can be replayed)'); + const v = verifyPresentation(p, { audience, nonce, now, trustedIssuers: policy.trustedIssuers, statusLists, revocations, maxAgeS: policy.maxAgeS }); + const motive = []; + if (!v.valid) motive.push('the presentation is not valid: ' + v.rows.filter((r) => r.pass === false).map((r) => r.name + (r.detail ? ' (' + r.detail + ')' : '')).join('; ')); + const status = v.rows.find((r) => /^credential: (not revoked|status)/.test(r.name)); + if (policy.requireStatus && !(status && status.pass === true)) motive.push('the policy requires the credential status to be judged: ' + (status ? status.detail || 'not judged' : 'no status row')); + const claims = v.claims || {}; + for (const r of policy.require) if (!regula(r, claims)) motive.push(`rule not met: ${canonical(r)}`); + const holder = p && p.credential && p.credential.statement && p.credential.statement.holder ? p.credential.statement.holder.id : null; + return { compliant: !motive.length, reasons: motive, policyHash, policyId: policy.id, holder, notJudged: v.notJudged, + presentationHash: sha(canonical(p || null)), ...(keepClaims && !motive.length ? { claims } : {}) }; +} + +/** + * Inregistrarea judecatii ca plic AIP-23 `compliance` (proof-kinds), FARA date personale: subject = un pseudonim al detinatorului + * legat de verificator, policy = hash-ul politicii, result = compliant | not-compliant, evidence = digestul prezentarii (verificatorul + * o poate pastra sau sterge; digestul ramane). Pseudonimul: fara `pseudonymKey`, sha256 peste id-ul detinatorului si public, pe care il + * poate reface oricine stie id-ul si publicul (tine id-ul afara din inregistrare, nu il ascunde de ei); cu `pseudonymKey` (un secret al + * verificatorului), HMAC-SHA256, pe care numai verificatorul il poate reface. + */ +export function complianceEnvelope(rez, { audience, createdAt = new Date().toISOString(), buildProof, pseudonymKey = null }) { + if (!rez || !rez.policyHash || !rez.presentationHash) throw new Error('compliance: a result of checkCompliance is required'); + if (typeof audience !== 'string' || !audience) throw new Error('compliance: the audience names the verifier the pseudonym is bound to'); + const intrare = `aere-compliance-subject|${rez.holder}|${audience}`; + const pseudonim = 'pseudonym:' + (pseudonymKey ? crypto.createHmac('sha256', pseudonymKey).update(intrare).digest('hex') : sha(intrare).slice(2)).slice(0, 40); + return buildProof('compliance', { subject: pseudonim, policy: rez.policyHash, result: rez.compliant ? 'compliant' : 'not-compliant', evidenceHash: rez.presentationHash, createdAt }); +} diff --git a/identity/control-negativ-conformitate.mjs b/identity/control-negativ-conformitate.mjs new file mode 100644 index 0000000..8ed7907 --- /dev/null +++ b/identity/control-negativ-conformitate.mjs @@ -0,0 +1,65 @@ +// Controlul negativ al conformitatii (conformitate.mjs, proba-conformitate.mjs): fiecare paznic scos intr-o COPIE trebuie sa +// inroseasca proba NUMITA, 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-conformitate.mjs iesire 0 = martorul verde si toate plantarile rosii pe proba lor +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const DEV_VERIFY = path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs'); +const VERIFY = process.env.AERE_VERIFY_PROOF || (fs.existsSync(DEV_VERIFY) ? DEV_VERIFY : ''); +const C = 'conformitate.mjs'; +const PLANTARI = [ + // [nume, tipar, inlocuire, proba (inceputul numelui ei)] + ['equals nu mai compara', "if (Object.hasOwn(r, 'equals')) return canonical(v) === canonical(r.equals);", "if (Object.hasOwn(r, 'equals')) return true;", 'neconform: jurisdictie interzisa'], + ['notIn nu mai compara', "if (Object.hasOwn(r, 'notIn')) return !r.notIn.some((x) => canonical(x) === canonical(v));", "if (Object.hasOwn(r, 'notIn')) return true;", 'neconform: jurisdictie interzisa'], + ['atLeast nu mai compara', "if (Object.hasOwn(r, 'atLeast')) return typeof v === 'number' && Number.isFinite(v) && v >= r.atLeast;", "if (Object.hasOwn(r, 'atLeast')) return true;", 'neconform: jurisdictie interzisa'], + ['o afirmatie ceruta si nearatata trece', 'if (!are) return false;', 'if (!are) return true;', 'neconform: o afirmatie ceruta nearatata'], + ['starea nejudecata trece drept nerevocata', 'if (policy.requireStatus && !(status && status.pass === true))', 'if (false)', 'neconform: starea ceruta'], + ['o prezentare invalida judecata conforma', 'if (!v.valid) motive.push(', 'if (false) motive.push(', 'neconform: emitent in afara'], + ['emitentii politicii nu mai ajung la verificare', 'trustedIssuers: policy.trustedIssuers,', 'trustedIssuers: null,', 'neconform: emitent in afara'], + ['judecata fara publicul si nonce-ul verificatorului', "if (typeof audience !== 'string' || !audience || typeof nonce !== 'string' || !nonce) throw new Error('compliance: the verifier", "if (false) throw new Error('compliance: the verifier", 'neconform: prezentare facuta pentru alt verificator'], + ['regulile nu mai sunt in forma normala', '}).sort((a, b) => (canonical(a) < canonical(b) ? -1 : 1));', '});', 'politica: forma normala'], + ['campuri necunoscute primite in politica', 'if (necunoscute.length) throw', 'if (false) throw', 'CONTROL: politica refuza'], + ['id-ul detinatorului scris in inregistrare', 'subject: pseudonim,', 'subject: rez.holder,', 'inregistrarea (plic AIP-23 compliance) NU poarta date personale'], + ['afirmatiile intoarse si pentru o judecata neconforma', '...(keepClaims && !motive.length ? { claims } : {})', '...(keepClaims ? { claims } : {})', 'rezultatul nu poarta afirmatiile'], + ['cheia verificatorului ignorata la pseudonim', '(pseudonymKey ? crypto.createHmac(', '(false ? crypto.createHmac(', 'pseudonimul'], +]; +const FISIERE = ['identity.mjs', 'identity-cli.mjs', C, 'proba-conformitate.mjs']; +function copie() { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-conf-ctl-')); + fs.mkdirSync(path.join(t, 'aere-identity')); fs.mkdirSync(path.join(t, 'proof-kinds')); + for (const f of FISIERE) fs.copyFileSync(path.join(AICI, f), path.join(t, 'aere-identity', f)); + fs.copyFileSync(path.join(AICI, '..', 'proof-kinds', 'proof-kinds.mjs'), path.join(t, 'proof-kinds', 'proof-kinds.mjs')); + return t; +} +function ruleaza(t) { + const env = { ...process.env }; if (VERIFY) env.AERE_VERIFY_PROOF = VERIFY; else delete env.AERE_VERIFY_PROOF; + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(t, 'aere-identity', 'proba-conformitate.mjs')], { env }); 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-compliance: \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, 'aere-identity', C); 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.rosii.length && (m.cod === 0 || (m.cod === 2 && !VERIFY))) console.log(' OK martorul: copia neatinsa verde' + (m.cod === 2 ? ' (plicul AIP-23 NEMASURAT: fara verificator)' : '')); +else { rele++; console.log(` STRICAT martorul nu e verde (cod ${m.cod}, ${m.rosii.length} rosii)`); } +const rez = new Array(PLANTARI.length); let i = 0; +await Promise.all(Array.from({ length: 4 }, async () => { while (i < PLANTARI.length) { const k = i++; rez[k] = await planteaza(PLANTARI[k]); } })); +for (const [bun, linie] of rez) { console.log(linie); if (!bun) rele++; } +console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `DOVEDIT: martorul verde, ${PLANTARI.length} din ${PLANTARI.length} paznici scosi -> proba lor rosie`); +process.exitCode = rele ? 1 : 0; diff --git a/identity/identity-cli.mjs b/identity/identity-cli.mjs index a8a54d5..7ce4e2f 100644 --- a/identity/identity-cli.mjs +++ b/identity/identity-cli.mjs @@ -12,6 +12,8 @@ // present --cred cred.json --reveal a,b --presenter-keys k.json [--delegation d1.json ...] --audience A --nonce N --out p.json // verify --presentation p.json [--audience A --nonce N] [--trust-issuer id|pub.json ...] [--status-list l.json ...] // [--revocation r.json ...] [--max-age S] [--json] +// 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 // O valoare de --claim se citeste ca JSON daca e JSON (true, 42, {"a":1}), altfel ca text. import fs from 'node:fs'; import path from 'node:path'; @@ -33,7 +35,7 @@ const scrie = (f, o, privat = false) => { const lista = (v) => (v === '*' ? '*' : String(v).split(',').map((x) => x.trim()).filter(Boolean)); const zile = (n) => new Date(Date.now() + Number(n) * 86400000).toISOString(); -function main() { +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; } @@ -90,8 +92,22 @@ function main() { } return r.valid ? 0 : 1; } - throw new Folosire('usage: identity-cli.mjs keygen|pub|id|issue|status-list|delegate|revoke|present|verify ... (see README.md)'); + if (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 r = checkCompliance(citeste(cere('--presentation')), citeste(cere('--policy')), { audience: cere('--audience'), nonce: cere('--nonce'), + statusLists: toate('--status-list').map(citeste), revocations: toate('--revocation').map(citeste) }); + console.log(r.compliant ? `COMPLIANT with ${r.policyId} (${r.policyHash})` : `NOT COMPLIANT with ${r.policyId}:\n ` + r.reasons.join('\n ')); + if (get('--record')) { + const { buildProof } = await import('../proof-kinds/proof-kinds.mjs'); + const k = get('--pseudonym-key-file') ? fs.readFileSync(get('--pseudonym-key-file')) : null; + scrie(get('--record'), complianceEnvelope(r, { audience: get('--audience'), buildProof, pseudonymKey: k })); + } + return r.compliant ? 0 : 1; + } + throw new Folosire('usage: identity-cli.mjs keygen|pub|id|issue|status-list|delegate|revoke|present|verify|comply ... (see README.md)'); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - try { process.exitCode = main(); } catch (e) { console.error('error: ' + (e.message || e)); process.exitCode = e instanceof Folosire ? 2 : 1; } + main().then((c) => { process.exitCode = c; }, (e) => { console.error('error: ' + (e.message || e)); process.exitCode = e instanceof Folosire ? 2 : 1; }); } diff --git a/identity/proba-conformitate.mjs b/identity/proba-conformitate.mjs new file mode 100644 index 0000000..0753aaa --- /dev/null +++ b/identity/proba-conformitate.mjs @@ -0,0 +1,120 @@ +// Proba conformitatii fara supraveghere (conformitate.mjs): politica, judecata pe prezentari reale (identity.mjs), si inregistrarea +// fara date personale. Fiecare afirmatie cu perechea ei negativa. Offline. Plicul AIP-23 se judeca si cu verificatorul de referinta +// (AERE_VERIFY_PROOF sau, in depozitul de dezvoltare, ../aere-proof-protocol/verify.mjs); fara el, acea proba iese NEMASURATA (cod 2). +// node proba-conformitate.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as I from './identity.mjs'; +import { definePolicy, checkCompliance, complianceEnvelope } from './conformitate.mjs'; +import { buildProof } from '../proof-kinds/proof-kinds.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const VERIFY = process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs'); +let treceri = 0, sarite = 0; const esecuri = []; +function test(nume, fn) { try { if (fn() === 'SARIT') return; treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' RAU ' + nume + ' -- ' + (e.message || e)); } } +const cere = (c, m) => { if (!c) throw new Error(m); }; +const arunca = (f) => { try { f(); return null; } catch (e) { return e.message; } }; + +const NOW = new Date('2026-09-30T08:00:00Z'), AUD = 'https://exchange.example', NONCE = 'c-1'; +const iss = I.generateKeys(), hol = I.generateKeys(), alt = I.generateKeys(); +const CLAIMS = { name: 'Ana Pop', birthdate: '1990-01-01', age_over_18: true, jurisdiction: 'RO', kyc_level: 2 }; +const emite = (emitent = iss, index = 3) => I.issueCredential({ issuer: emitent, holder: hol.public, type: 'KycCredential', claims: CLAIMS, + disclosable: ['name', 'birthdate', 'age_over_18', 'jurisdiction', 'kyc_level'], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:s:kyc', index }, now: NOW }); +const { credential, disclosures } = emite(); +const LISTA = I.createStatusList({ issuer: iss, id: 'urn:s:kyc', revoked: [9], validUntil: '2026-10-07T00:00:00Z', now: NOW }); +const POL = { id: 'exchange-onboarding-v1', trustedIssuers: [iss.id], require: [{ claim: 'age_over_18', equals: true }, { claim: 'jurisdiction', notIn: ['KP', 'IR'] }, { claim: 'kyc_level', atLeast: 2 }] }; +const prez = (reveal = ['age_over_18', 'jurisdiction', 'kyc_level'], o = {}) => I.present({ credential: o.credential || credential, disclosures: o.disclosures || disclosures, reveal, presenter: hol, audience: o.audience || AUD, nonce: NONCE, now: NOW }); +const judeca = (p, pol = POL, o = {}) => checkCompliance(p, pol, { audience: AUD, nonce: NONCE, now: NOW, statusLists: [LISTA], ...o }); + +test('politica: forma normala; aceeasi politica cu regulile si emitentii in alta ordine are acelasi hash; alta regula, alt hash', () => { + const a = definePolicy(POL), b = definePolicy({ ...POL, require: [...POL.require].reverse(), trustedIssuers: [iss.public] }); + cere(a.policyHash === b.policyHash && /^0x[0-9a-f]{64}$/.test(a.policyHash), `${a.policyHash} ${b.policyHash}`); + cere(definePolicy({ ...POL, require: [{ claim: 'kyc_level', atLeast: 1 }] }).policyHash !== a.policyHash, 'o politica mai laxa are acelasi hash'); +}); +test('CONTROL: politica refuza campuri necunoscute, lista de emitenti goala, o regula cu doi operatori sau fara niciunul', () => { + const m = [arunca(() => definePolicy({ ...POL, allowAll: true })), arunca(() => definePolicy({ ...POL, trustedIssuers: [] })), + arunca(() => definePolicy({ ...POL, require: [{ claim: 'x', equals: 1, in: [1] }] })), arunca(() => definePolicy({ ...POL, require: [{ claim: 'x' }] }))]; + cere(/unknown field/.test(m[0]) && /trustedIssuers is required/.test(m[1]) && /a rule is/.test(m[2]) && /a rule is/.test(m[3]), m.join(' | ')); +}); +test('conform: varsta, jurisdictia in afara celor interzise, nivelul de verificare; starea judecata', () => { + const r = judeca(prez()); cere(r.compliant && !r.reasons.length && r.notJudged === 0, JSON.stringify(r.reasons)); +}); +test('neconform: o afirmatie ceruta nearatata (kyc_level) -> motivul numeste regula', () => { + const r = judeca(prez(['age_over_18', 'jurisdiction'])); cere(!r.compliant && r.reasons.some((x) => /kyc_level/.test(x)), JSON.stringify(r.reasons)); +}); +test('neconform: jurisdictie interzisa (notIn); nivel sub prag (atLeast); valoare falsa (equals)', () => { + const c2 = I.issueCredential({ issuer: iss, holder: hol.public, type: 'KycCredential', claims: { ...CLAIMS, jurisdiction: 'KP', kyc_level: 1, age_over_18: false }, + disclosable: ['age_over_18', 'jurisdiction', 'kyc_level'], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:s:kyc', index: 4 }, now: NOW }); + const r = judeca(prez(undefined, c2)); + cere(!r.compliant && ['jurisdiction', 'kyc_level', 'age_over_18'].every((n) => r.reasons.some((x) => x.includes(n))), JSON.stringify(r.reasons)); +}); +test('neconform: emitent in afara celor ai politicii', () => { + const c3 = emite(alt); const r = judeca(prez(undefined, c3)); cere(!r.compliant && r.reasons.some((x) => /issuer trusted/.test(x)), JSON.stringify(r.reasons)); +}); +test('neconform: starea ceruta si lista nedata (nejudecat NU e "nerevocat"); fara cerinta de stare, conform cu randul nejudecat numarat', () => { + const r = judeca(prez(), POL, { statusLists: [] }); cere(!r.compliant && r.reasons.some((x) => /status to be judged/.test(x)), JSON.stringify(r.reasons)); + const r2 = judeca(prez(), { ...POL, requireStatus: false }, { statusLists: [] }); cere(r2.compliant && r2.notJudged === 1, JSON.stringify(r2)); +}); +test('neconform: credentialul revocat de emitent', () => { + const c4 = emite(iss, 9); const r = judeca(prez(undefined, c4)); cere(!r.compliant && r.reasons.some((x) => /revoked/.test(x)), JSON.stringify(r.reasons)); +}); +test('neconform: prezentare facuta pentru alt verificator (reluare); fara public si nonce, judecata refuza sa inceapa', () => { + const r = judeca(prez(undefined, { audience: 'https://other.example' })); cere(!r.compliant && r.reasons.some((x) => /made for/.test(x)), JSON.stringify(r.reasons)); + cere(/audience and nonce are required/.test(arunca(() => checkCompliance(prez(), POL, { now: NOW, statusLists: [LISTA] })) || ''), 'a judecat fara public'); +}); +test('rezultatul nu poarta afirmatiile detinatorului decat la cerere (keepClaims), si numai pentru o judecata conforma', () => { + const r = judeca(prez()); cere(!('claims' in r) && !JSON.stringify(r).includes('"RO"'), JSON.stringify(r)); + const k = judeca(prez(), POL, { keepClaims: true }); cere(k.claims && k.claims.jurisdiction === 'RO', JSON.stringify(k.claims)); + const kn = judeca(prez(['age_over_18']), POL, { keepClaims: true }); cere(!kn.compliant && !('claims' in kn), 'afirmatiile intoarse pentru o judecata neconforma'); +}); +test('inregistrarea (plic AIP-23 compliance) NU poarta date personale: nici valori, nici id-ul detinatorului', () => { + const e = complianceEnvelope(judeca(prez()), { audience: AUD, createdAt: NOW.toISOString(), buildProof }); + const s = JSON.stringify(e); + for (const x of ['Ana Pop', '1990-01-01', '"RO"', hol.id, hol.public.mldsa65.slice(0, 40)]) cere(!s.includes(x), 'plicul contine ' + x.slice(0, 30)); + cere(e.statement.result === 'compliant' && /^pseudonym:[0-9a-f]{40}$/.test(e.statement.subject) && e.statement.policy === definePolicy(POL).policyHash, JSON.stringify(e.statement)); +}); +test('pseudonimul: alt verificator (alt public) vede alt pseudonim; cu o cheie a verificatorului, altul decat forma publica refacuta de oricine', () => { + const r = judeca(prez()); + const a = complianceEnvelope(r, { audience: AUD, buildProof }).statement.subject, b = complianceEnvelope(r, { audience: 'https://bank.example', buildProof }).statement.subject; + const k = complianceEnvelope(r, { audience: AUD, buildProof, pseudonymKey: crypto.randomBytes(32) }).statement.subject; + const refacut = 'pseudonym:' + crypto.createHash('sha256').update(`aere-compliance-subject|${hol.id}|${AUD}`).digest('hex').slice(0, 40); + cere(a !== b && a === refacut && k !== refacut, `${a} ${b} ${k}`); +}); +test('plicul verifica la verificatorul AIP-23 de referinta; unul cu rezultatul rescris nu', () => { + if (!fs.existsSync(VERIFY)) { sarite++; console.log(` SARIT plicul AIP-23: verificatorul nu e la ${VERIFY}; AERE_VERIFY_PROOF=`); return 'SARIT'; } + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-conf-')); + try { + const v = (o) => { const f = path.join(T, crypto.randomUUID() + '.json'); fs.writeFileSync(f, JSON.stringify(o)); try { return JSON.parse(spawnSync(process.execPath, [VERIFY, f, '--json'], { encoding: 'utf8' }).stdout).verdict; } catch { return '?'; } }; + const e = complianceEnvelope(judeca(prez(['age_over_18'])), { audience: AUD, buildProof }); + const rescris = JSON.parse(JSON.stringify(e)); rescris.statement.result = 'compliant'; + cere(e.statement.result === 'not-compliant' && v(e) === 'VALID' && v(rescris) !== 'VALID', `${v(e)} ${v(rescris)}`); + } finally { fs.rmSync(T, { recursive: true, force: true }); } +}); + +test('linia de comanda: comply -> COMPLIANT (0) si inregistrarea scrisa fara date personale; o prezentare care nu arata destul -> NOT COMPLIANT (1)', () => { + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-conf-cli-')); const CLI = path.join(AICI, 'identity-cli.mjs'); + const run = (...a) => { const r = spawnSync(process.execPath, [CLI, ...a], { cwd: T, encoding: 'utf8' }); return { cod: r.status, out: (r.stdout || '') + (r.stderr || '') }; }; + try { + for (const n of ['iss', 'hol']) cere(run('keygen', '--out', n + '.keys.json').cod === 0, 'keygen'); + cere(run('pub', '--keys', 'hol.keys.json', '--out', 'hol.pub.json').cod === 0, 'pub'); + cere(run('issue', '--issuer-keys', 'iss.keys.json', '--holder-pub', 'hol.pub.json', '--type', 'KycCredential', '--claim', 'age_over_18=true', '--claim', 'jurisdiction=RO', + '--claim', 'name=Ana Pop', '--all-disclosable', '--status-list', 'urn:s:1', '--status-index', '2', '--out', 'cred.json').cod === 0, 'issue'); + cere(run('status-list', '--issuer-keys', 'iss.keys.json', '--id', 'urn:s:1', '--out', 'list.json').cod === 0, 'status-list'); + const issId = run('id', '--keys', 'iss.keys.json').out.trim(); + fs.writeFileSync(path.join(T, 'pol.json'), JSON.stringify({ id: 'p1', trustedIssuers: [issId], require: [{ claim: 'age_over_18', equals: true }, { claim: 'jurisdiction', notIn: ['KP'] }] })); + cere(run('present', '--cred', 'cred.json', '--reveal', 'age_over_18,jurisdiction', '--presenter-keys', 'hol.keys.json', '--audience', AUD, '--nonce', 'n1', '--out', 'p.json').cod === 0, 'present'); + const a = run('comply', '--presentation', 'p.json', '--policy', 'pol.json', '--audience', AUD, '--nonce', 'n1', '--status-list', 'list.json', '--record', 'rec.json'); + const rec = fs.readFileSync(path.join(T, 'rec.json'), 'utf8'); + cere(a.cod === 0 && /COMPLIANT/.test(a.out) && /"result": "compliant"/.test(rec) && !/Ana Pop|"RO"|aere-id:/.test(rec), a.out + rec); + cere(run('present', '--cred', 'cred.json', '--reveal', 'jurisdiction', '--presenter-keys', 'hol.keys.json', '--audience', AUD, '--nonce', 'n2', '--out', 'p2.json').cod === 0, 'present 2'); + const b = run('comply', '--presentation', 'p2.json', '--policy', 'pol.json', '--audience', AUD, '--nonce', 'n2', '--status-list', 'list.json'); + cere(b.cod === 1 && /NOT COMPLIANT/.test(b.out) && /age_over_18/.test(b.out), b.out); + } finally { fs.rmSync(T, { recursive: true, force: true }); } +}); + +console.log(`\naere-compliance: ${treceri}/${treceri + esecuri.length} cum trebuia${sarite ? `, ${sarite} NEMASURATE (fara verificatorul AIP-23)` : ''}`); +process.exitCode = esecuri.length ? 1 : (sarite ? 2 : 0);