identity: compliance without surveillance - a verifier's policy judged on a presentation, recorded as an AIP-23 envelope that carries no personal data

This commit is contained in:
Aere Network 2026-09-30 09:52:16 +03:00
parent 01c0c5e211
commit 154c424e94
6 changed files with 322 additions and 6 deletions

View File

@ -15,7 +15,7 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass | | [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again | | [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; 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) | | [`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. 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 | | proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test |
| readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) | | readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) |
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; 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`) | | 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 <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 | | agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc <solc>`) | 26/26 (`node control-negativ-aprobare.mjs`); the chain as witness 11/11 (`node control-negativ-ancora.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository |
Code comments, most function and variable names (also many exported between the files of a component), test names and control 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 ## 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).

View File

@ -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 envelopes (`identity` and `authorization` kinds of `../proof-kinds`) that the Aere Proof API notarizes and that the AIP-23 reference
verifier checks. 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 <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 ## 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 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 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 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=<verify-proof.mjs>`); without it that test is reported as The envelope test needs the AIP-23 reference verifier (`AERE_VERIFY_PROOF=<verify-proof.mjs>`); without it that test is reported as

92
identity/conformitate.mjs Normal file
View File

@ -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, <one of ' + OPERATORI.join('|') + '> }');
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 });
}

View File

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

View File

@ -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 // 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 ...] // verify --presentation p.json [--audience A --nonce N] [--trust-issuer id|pub.json ...] [--status-list l.json ...]
// [--revocation r.json ...] [--max-age S] [--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. // O valoare de --claim se citeste ca JSON daca e JSON (true, 42, {"a":1}), altfel ca text.
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; 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 lista = (v) => (v === '*' ? '*' : String(v).split(',').map((x) => x.trim()).filter(Boolean));
const zile = (n) => new Date(Date.now() + Number(n) * 86400000).toISOString(); 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 === '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 === '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 === '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; 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)) { 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; });
} }

View File

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