- 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.
132 lines
11 KiB
JavaScript
132 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// identity-cli.mjs: linia de comanda a lui AERE Identity (identity.mjs). Iesiri: 0 bun / VALID, 1 INVALID, 2 folosire gresita.
|
|
// keygen --out keys.json (0600; refuza sa suprascrie)
|
|
// pub --keys keys.json [--out pub.json] partea publica, de dat altora
|
|
// id --pub pub.json | --keys keys.json
|
|
// issue --issuer-keys k.json --holder-pub h.json --type T --claim name=value ... [--disclosable a,b | --all-disclosable]
|
|
// [--valid-days N] [--status-list ID --status-index I] [--decoys N] --out cred.json
|
|
// status-list --issuer-keys k.json --id ID [--size BITS] [--revoke i,j] [--valid-days N] --out list.json
|
|
// delegate --from-keys k.json --to-pub p.json [--parent d.json] [--credentials ids|*] [--claims names|*] [--audiences a|*]
|
|
// [--valid-minutes M] [--max-depth D] --out d.json
|
|
// revoke --keys k.json --delegation d.json [--reason R] --out r.json
|
|
// present --cred cred.json --reveal a,b --presenter-keys k.json [--delegation d1.json ...] --audience A --nonce N --out p.json
|
|
// verify --presentation p.json [--audience A --nonce N] [--trust-issuer id|pub.json ...] [--status-list l.json ...]
|
|
// [--revocation r.json ...] [--max-age S] [--at T] [--json] --at: judge at time T (RFC 3339 UTC), not now
|
|
// comply --presentation p.json --policy policy.json --audience A --nonce N [--status-list l.json ...] [--revocation r.json ...]
|
|
// [--record record.json] [--pseudonym-key-file k] a compliance policy judged; the record carries no personal data
|
|
// [--json [--with-record]] [--at T] the whole result as one JSON object (with the record inside)
|
|
// 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';
|
|
import { fileURLToPath } from 'node:url';
|
|
import * as I from './identity.mjs';
|
|
|
|
class Folosire extends Error {}
|
|
const argv = process.argv.slice(2);
|
|
const cmd = argv[0];
|
|
const get = (n) => { const i = argv.indexOf(n); return i === -1 ? null : (argv[i + 1] ?? null); };
|
|
const toate = (n) => argv.flatMap((a, i) => (a === n && argv[i + 1] != null ? [argv[i + 1]] : []));
|
|
const are = (n) => argv.includes(n);
|
|
const cere = (n) => { const v = get(n); if (v == null) throw new Folosire(`${cmd} needs ${n}`); return v; };
|
|
const citeste = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${f}: ${e.message}`); } };
|
|
const scrie = (f, o, privat = false) => {
|
|
if (privat && fs.existsSync(f)) throw new Folosire(`${f} exists; a key file is never overwritten`);
|
|
fs.writeFileSync(f, JSON.stringify(o, null, 1) + '\n', privat ? { mode: 0o600, flag: 'wx' } : undefined);
|
|
};
|
|
const lista = (v) => (v === '*' ? '*' : String(v).split(',').map((x) => x.trim()).filter(Boolean));
|
|
const zile = (n) => new Date(Date.now() + Number(n) * 86400000).toISOString();
|
|
// 2026-09-30: --at <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; }
|
|
if (cmd === 'pub') { const k = I.importKeys(citeste(cere('--keys'))); const o = get('--out'); if (o) scrie(o, k.public); else console.log(JSON.stringify(k.public)); return 0; }
|
|
if (cmd === 'id') { const p = get('--pub') ? citeste(get('--pub')) : I.importKeys(citeste(cere('--keys'))).public; console.log(I.idOf(p)); return 0; }
|
|
if (cmd === 'issue') {
|
|
const issuer = I.importKeys(citeste(cere('--issuer-keys')));
|
|
const claims = {};
|
|
for (const c of toate('--claim')) {
|
|
const i = c.indexOf('='); if (i < 1) throw new Folosire('--claim is name=value');
|
|
const n = c.slice(0, i), v = c.slice(i + 1); let val; try { val = JSON.parse(v); } catch { val = v; }
|
|
if (Object.hasOwn(claims, n)) throw new Folosire('--claim ' + n + ' given twice');
|
|
claims[n] = val;
|
|
}
|
|
const disclosable = are('--all-disclosable') ? null : (get('--disclosable') ? lista(get('--disclosable')) : []);
|
|
const sl = get('--status-list');
|
|
const r = I.issueCredential({ issuer, holder: citeste(cere('--holder-pub')), type: cere('--type'), claims, disclosable,
|
|
validUntil: zile(get('--valid-days') ?? 365), status: sl ? { list: sl, index: Number(cere('--status-index')) } : null, decoys: Number(get('--decoys') ?? 0) });
|
|
scrie(cere('--out'), { v: 1, kind: 'aere-credential-with-disclosures', credential: r.credential, disclosures: r.disclosures });
|
|
console.log(`issued ${r.credential.statement.id} to ${r.credential.statement.holder.id}: ${Object.keys(r.credential.statement.claims).length} plain claim(s), ${r.disclosures.length} disclosable (the file holds the disclosures: give it to the holder only)`);
|
|
return 0;
|
|
}
|
|
if (cmd === 'status-list') {
|
|
const issuer = I.importKeys(citeste(cere('--issuer-keys')));
|
|
const l = I.createStatusList({ issuer, id: cere('--id'), size: Number(get('--size') ?? I.MIN_STATUS_BITS), revoked: get('--revoke') ? lista(get('--revoke')).map(Number) : [], validUntil: zile(get('--valid-days') ?? 7) });
|
|
scrie(cere('--out'), l); console.log(`status list ${l.statement.id}: ${l.statement.size} entries, valid until ${l.statement.validUntil}`); return 0;
|
|
}
|
|
if (cmd === 'delegate') {
|
|
const from = I.importKeys(citeste(cere('--from-keys')));
|
|
const d = I.delegate({ from, to: citeste(cere('--to-pub')), parent: get('--parent') ? citeste(get('--parent')) : null,
|
|
scope: { credentials: lista(get('--credentials') ?? '*'), claims: lista(get('--claims') ?? '*'), audiences: lista(get('--audiences') ?? '*') },
|
|
notAfter: new Date(Date.now() + Number(get('--valid-minutes') ?? 60) * 60000).toISOString(), maxDepth: Number(get('--max-depth') ?? 0) });
|
|
scrie(cere('--out'), d); console.log(`delegated ${I.delegationHash(d)}: ${d.statement.from.id} -> ${d.statement.to.id} until ${d.statement.notAfter}`); return 0;
|
|
}
|
|
if (cmd === 'revoke') {
|
|
const r = I.revokeDelegation({ by: I.importKeys(citeste(cere('--keys'))), delegation: citeste(cere('--delegation')), reason: get('--reason') });
|
|
scrie(cere('--out'), r); console.log(`revoked ${r.statement.target} at ${r.statement.at}`); return 0;
|
|
}
|
|
if (cmd === 'present') {
|
|
const c = citeste(cere('--cred'));
|
|
if (c.kind !== 'aere-credential-with-disclosures') throw new Folosire('--cred is the file written by issue');
|
|
const p = I.present({ credential: c.credential, disclosures: c.disclosures, reveal: get('--reveal') ? lista(get('--reveal')) : [],
|
|
presenter: I.importKeys(citeste(cere('--presenter-keys'))), delegations: toate('--delegation').map(citeste), audience: cere('--audience'), nonce: cere('--nonce') });
|
|
scrie(cere('--out'), p); console.log(`presentation for ${p.binding.audience}: ${p.disclosures.length} claim(s) disclosed`); return 0;
|
|
}
|
|
if (cmd === 'verify') {
|
|
const p = citeste(cere('--presentation'));
|
|
// 2026-09-30: un emitent de incredere stricat e o greseala a verificatorului (cod 2, cu motivul), nu o cadere fara verdict
|
|
const trusted = toate('--trust-issuer').map((t) => {
|
|
if (/^aere-id:/.test(t)) { if (!/^aere-id:[0-9a-f]{40}$/.test(t)) throw new Folosire(`--trust-issuer ${t} is not an aere-id (aere-id: + 40 hex)`); return t; }
|
|
const pub = citeste(t); try { I.idOf(pub); } catch { throw new Folosire(`--trust-issuer ${path.basename(t)} is not a public key file (the output of pub)`); } return pub;
|
|
});
|
|
const maxAgeS = Number(get('--max-age') ?? 300);
|
|
if (!Number.isInteger(maxAgeS) || maxAgeS < 1 || maxAgeS > 3600) throw new Folosire('--max-age is a number of seconds, 1..3600');
|
|
const r = I.verifyPresentation(p, { audience: get('--audience'), nonce: get('--nonce'), trustedIssuers: trusted.length ? trusted : null,
|
|
statusLists: toate('--status-list').map(citeste), revocations: toate('--revocation').map(citeste), maxAgeS, now: momentul() });
|
|
if (are('--json')) console.log(JSON.stringify(r, null, 1));
|
|
else {
|
|
for (const x of r.rows) console.log(`${x.pass === true ? 'ok' : x.pass === false ? 'FAIL' : '--'} ${x.name}${x.detail ? ': ' + x.detail : ''}`);
|
|
console.log(r.valid ? `VALID: every present claim holds${r.notJudged ? `; ${r.notJudged} not judged (the -- lines)` : ''}` : 'INVALID');
|
|
if (r.valid) console.log('claims: ' + JSON.stringify(r.claims));
|
|
}
|
|
return r.valid ? 0 : 1;
|
|
}
|
|
if (cmd === 'comply') {
|
|
// conformitatea fara supraveghere (conformitate.mjs): politica verificatorului judecata pe o prezentare; --record scrie
|
|
// inregistrarea (plic AIP-23 compliance) fara date personale, cu proof-kinds de langa (../proof-kinds)
|
|
const { checkCompliance, complianceEnvelope } = await import('./conformitate.mjs');
|
|
const pr = citeste(cere('--presentation')), po = citeste(cere('--policy'));
|
|
const audience = cere('--audience'), nonce = cere('--nonce'), statusLists = toate('--status-list').map(citeste), revocations = toate('--revocation').map(citeste);
|
|
// 2026-09-30: o politica stricata e o greseala a verificatorului (cod 2, cu motivul politicii), nu o cadere fara verdict
|
|
const acum = momentul();
|
|
let r; try { r = checkCompliance(pr, po, { audience, nonce, statusLists, revocations, now: acum }); } catch (e) { throw new Folosire(e.message); }
|
|
let env = null;
|
|
if (get('--record') || are('--with-record')) {
|
|
const { buildProof } = await import('../proof-kinds/proof-kinds.mjs');
|
|
const k = get('--pseudonym-key-file') ? fs.readFileSync(get('--pseudonym-key-file')) : null;
|
|
env = complianceEnvelope(r, { audience, buildProof, pseudonymKey: k, createdAt: acum.toISOString() });
|
|
if (get('--record')) scrie(get('--record'), env);
|
|
}
|
|
// --json: rezultatul intreg (si inregistrarea, cu --record sau --with-record) intr-un singur obiect, pentru masini (API-ul Cloud)
|
|
if (are('--json')) console.log(JSON.stringify({ ...r, ...(env ? { record: env } : {}) }, null, 1));
|
|
else console.log(r.compliant ? `COMPLIANT with ${r.policyId} (${r.policyHash})` : `NOT COMPLIANT with ${r.policyId}:\n ` + r.reasons.join('\n '));
|
|
return r.compliant ? 0 : 1;
|
|
}
|
|
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)) {
|
|
main().then((c) => { process.exitCode = c; }, (e) => { console.error('error: ' + (e.message || e)); process.exitCode = e instanceof Folosire ? 2 : 1; });
|
|
}
|