identity: verify and comply as a service can judge (--json, --at), subject in the result, refused input exits 2

- verify: --at <RFC 3339 UTC> judges on that clock (the verifier's clock in any case), so a verdict given at one moment can be checked
  again later with the same result; a broken --trust-issuer or --max-age exits 2 with the reason instead of failing without a verdict;
  the JSON result names the subject (type, credential, issuer, holder, presenter, delegations) when no check failed
- comply: --json [--with-record] prints the result and the record in one object; with --at the record's time is the same, so the same
  command gives the same record byte for byte; a refused policy exits 2 with its reason
- Aere Cloud runs this command line unmodified behind POST /v1/identity/verify and POST /v1/compliance/check

Tests: identity 43/43, negative control 46/46.
This commit is contained in:
Aere Network 2026-09-30 11:16:37 +03:00
parent 8f5f0bd00d
commit aec0ccbead
3 changed files with 41 additions and 11 deletions

View File

@ -19,7 +19,11 @@ node identity-cli.mjs verify --presentation presentation.json --audience https:/
```
`verify` prints one line per check (`ok`, `FAIL`, or `--` for not judged, with the reason) and `VALID` or `INVALID`; it exits 0 on
VALID, 1 on INVALID, 2 on a usage error. The claims it returns are the plain ones plus the ones the holder chose to show.
VALID, 1 on INVALID, 2 on a usage error (a broken `--trust-issuer` or `--max-age` included). The claims it returns are the plain ones
plus the ones the holder chose to show. `--json` prints the whole result as one object, with `subject` (type, credential, issuer,
holder, presenter, number of delegations) when no check failed. `--at <RFC 3339 UTC time>` judges on that clock instead of now: the
clock is the verifier's in any case, and a verdict given at one moment (by a service, for instance) can then be checked again later
with the same result.
## What is signed, and by whom
@ -94,6 +98,12 @@ node identity-cli.mjs comply --presentation p.json --policy policy.json --audien
--status-list list.json --record record.json [--pseudonym-key-file key]
```
`comply --json [--with-record] [--at T]` prints the result and, with `--with-record`, the record in one object; with `--at` the
record's time is T too, so the same command gives the same record byte for byte. It exits 0 compliant, 1 not compliant, 2 when the
policy or an option is refused (with the reason). Aere Cloud runs this command line, unmodified, behind `POST /v1/identity/verify`
and `POST /v1/compliance/check`, and each answer names the SHA-256 of the files that judged and the command, with `--at`, that
reproduces it.
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

View File

@ -11,9 +11,11 @@
// 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] [--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)
// 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 path from 'node:path';
@ -34,6 +36,9 @@ 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();
// 2026-09-30: --at <RFC 3339 UTC> 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; }
@ -81,9 +86,15 @@ async function main() {
}
if (cmd === 'verify') {
const p = citeste(cere('--presentation'));
const trusted = toate('--trust-issuer').map((t) => (/^aere-id:/.test(t) ? t : citeste(t)));
// 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: Number(get('--max-age') ?? 300) });
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 : ''}`);
@ -96,14 +107,21 @@ async function main() {
// 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 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;
scrie(get('--record'), complianceEnvelope(r, { audience: get('--audience'), buildProof, pseudonymKey: k }));
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;
}
throw new Folosire('usage: identity-cli.mjs keygen|pub|id|issue|status-list|delegate|revoke|present|verify|comply ... (see README.md)');

View File

@ -285,7 +285,9 @@ export function verifyPresentation(p, { audience = null, nonce = null, now = new
const ok = (name, pass, detail = '') => { rows.push({ name, pass: !!pass, detail: pass ? '' : detail }); return !!pass; };
const nejudecat = (name, detail) => rows.push({ name, pass: null, detail });
const acum = new Date(now).getTime();
const gata = (claims = null) => { const valid = rows.every((r) => r.pass !== false); return { valid, rows, notJudged: rows.filter((r) => r.pass === null).length, claims: valid ? claims : null }; };
// 2026-09-30 (API-ul Identity din Cloud): o prezentare VALIDA spune si despre cine e (tip, credential, emitent, detinator, cine a
// prezentat), ca verificatorul care nu a dat trustedIssuers sa vada pe cine ar trebui sa creada; numai pe drumul care ajunge la capat
const gata = (claims = null, subject = null) => { const valid = rows.every((r) => r.pass !== false); return { valid, rows, notJudged: rows.filter((r) => r.pass === null).length, claims: valid ? claims : null, subject: valid ? subject : null }; };
// configuratia verificatorului se valideaza inainte (o cheie de incredere stricata e o greseala a lui, nu o prezentare invalida)
const idsIncredere = trustedIssuers == null ? null : trustedIssuers.map((x) => (typeof x === 'string' && ID.test(x) ? x : idOf(x)));
try {
@ -397,7 +399,7 @@ export function verifyPresentation(p, { audience = null, nonce = null, now = new
}
if (lant.length && !revocations.length) nejudecat('delegation: revocations', 'none given; a revocation the verifier was not handed cannot be seen');
const claims = Object.fromEntries([...Object.entries(S.claims), ...dezvaluite.map((d) => [d.name, d.value])].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
return gata(claims);
return gata(claims, { type: S.type, credential: S.id, issuer: S.issuer.id, holder: S.holder.id, presenter: B.presenter.id, delegations: lant.length });
} catch (e) {
ok('presentation: readable', false, String(e && e.message || e).slice(0, 200));
return gata();