diff --git a/identity/README.md b/identity/README.md index ef020fd..d0a5a14 100644 --- a/identity/README.md +++ b/identity/README.md @@ -81,7 +81,10 @@ issuer, a bit set in any of them means revoked. A list is decompressed with a ce `issuedAt`, `validFrom` and `validUntil` are the issuer's statements; the presentation time is the presenter's, bounded by the verifier's clock (`--max-age`, default 300 s, both ways); a revocation time is the revoker's. Everything is judged on the verifier's -clock. For a time the issuer does not choose, notarize: `proofOfCredential` and `proofOfDelegation` (in `identity.mjs`) build AIP-23 +clock, which is never exactly the issuer's: starts (a credential's or a status list's `validFrom`, a delegation's `notBefore`) are +accepted up to 60 s in the verifier's future (`clockSkewS`), and a revocation dated up to 60 s ahead already applies; ends +(`validUntil`, `notAfter`) get no allowance, since that would extend a validity. (Measured 2026-09-30: without it, a credential issued +on a machine whose clock was a second ahead was "not yet valid" at a verifier synchronized by NTP.) For a time the issuer does not choose, notarize: `proofOfCredential` and `proofOfDelegation` (in `identity.mjs`) build AIP-23 envelopes (`identity` and `authorization` kinds of `../proof-kinds`) that the Aere Proof API notarizes and that the AIP-23 reference verifier checks. @@ -147,8 +150,8 @@ line is reported as not judged. The private key files are written with mode 0600 ## Tests ``` -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-identity.mjs # 44: the paths above, and each attack of the adversarial review as its own test +node control-negativ-identity.mjs # on a copy, each of 49 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 node proba-travel-rule.mjs # 19: two VASPs with registry credentials, the whole exchange, and each attack of the review diff --git a/identity/control-negativ-identity.mjs b/identity/control-negativ-identity.mjs index 1c56953..e5f85eb 100644 --- a/identity/control-negativ-identity.mjs +++ b/identity/control-negativ-identity.mjs @@ -13,6 +13,10 @@ 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 L = 'identity.mjs', C = 'identity-cli.mjs'; const PLANTARI = [ + // B-30: forma publicata in aec0ccb (fara toleranta de ceas pe inceputuri, revocarea numai pe ceasul exact) + ['fara toleranta pe validFrom (B-30)', L, 'vf - sk <= acum && acum <= vu,', 'vf <= acum && acum <= vu,', 'B-30'], + ['toleranta si pe sfarsit (validUntil prelungit)', L, 'vf - sk <= acum && acum <= vu,', 'vf - sk <= acum && acum <= vu + sk,', 'B-30'], + ['revocarea fara toleranta prudenta (B-30)', L, 'ok(`${et}: not revoked`, at > acum + sk,', 'ok(`${et}: not revoked`, at > acum,', 'revocarea: detinatorul revoca'], // [nume, fisier, tipar, inlocuire, proba (inceputul numelui ei)] ['o dezvaluire nesemnata de emitent primita', L, 'if (!sd.has(dg)) {', 'if (false) {', 'ATAC: o dezvaluire fabricata'], ['aceeasi dezvaluire primita de doua ori', L, 'if (vazuteD.has(dg) || vazuteN.has(d.name)) {', 'if (false) {', 'ATAC: aceeasi dezvaluire de doua ori'], @@ -33,7 +37,7 @@ const PLANTARI = [ ['id-ul emitentului nu mai e derivat din chei', L, "ok('credential: the issuer id is the id of its keys', idE && idE === S.issuer.id,", "ok('credential: the issuer id is the id of its keys', true,", 'ATAC: id-ul emitentului schimbat'], ['tipul cheii publice nu se mai verifica', L, 'if (k.asymmetricKeyType !== tip) throw', 'if (false) throw', 'ATAC: o cheie ML-DSA pusa in campul ed25519'], ['emitentii de incredere nu se mai cer', L, "ok('credential: issuer trusted', idsIncredere.includes(S.issuer.id),", "ok('credential: issuer trusted', true,", 'CONTROL: emitent in afara celor de incredere'], - ['fereastra credentialului nu se mai cere', L, 'vf <= acum && acum <= vu,', 'true,', 'CONTROL: emitent in afara celor de incredere'], + ['fereastra credentialului nu se mai cere', L, 'vf - sk <= acum && acum <= vu,', 'true,', 'CONTROL: emitent in afara celor de incredere'], ['un fisier de chei cu partea publica a altcuiva primit', L, 'if (canonical(keys.public) !== canonical(j.public) || keys.id !== j.id) throw', 'if (false) throw', 'cheile: exportKeys'], ['bitul de revocare ignorat', L, '!rev,', 'true,', 'lista de stare: bitul 42'], ['cu doua liste, ultima castiga (revocarea se ridica)', L, 'rev = statusBit(L, S.status.index) || rev;', 'rev = statusBit(L, S.status.index);', 'lista de stare: bitul 42'], @@ -52,9 +56,9 @@ const PLANTARI = [ ['scopul nu mai limiteaza afirmatiile aratate', L, 'ok(`${et}: covers the disclosed claims`, !afara.length,', 'ok(`${et}: covers the disclosed claims`, true,', 'ATAC: delegatul arata o afirmatie din afara scopului'], ['scopul nu mai limiteaza publicul', L, 'permite(sc.audiences, B.audience),', 'true,', 'ATAC: delegatul arata o afirmatie din afara scopului'], ['scopul nu mai limiteaza credentialul', L, 'permite(sc.credentials, S.id),', 'true,', 'ATAC: delegatul arata o afirmatie din afara scopului'], - ['fereastra verigii nu se mai cere', L, 'nb <= acum && acum <= na && nb <= t && t <= na,', 'true,', 'ATAC: veriga expirata'], + ['fereastra verigii nu se mai cere', L, 'nb - sk <= acum && acum <= na && nb - sk <= t && t <= na,', 'true,', 'ATAC: veriga expirata'], ['oricine poate revoca o veriga', L, 'const autor = R.by && (R.by.id === D.from.id || R.by.id === S.holder.id);', 'const autor = true;', 'revocarea: detinatorul revoca'], - ['o revocare se aplica inainte de momentul ei', L, 'ok(`${et}: not revoked`, at > acum,', 'ok(`${et}: not revoked`, false,', 'revocarea: detinatorul revoca'], + ['o revocare se aplica inainte de momentul ei', L, 'ok(`${et}: not revoked`, at > acum + sk,', 'ok(`${et}: not revoked`, false,', 'revocarea: detinatorul revoca'], ['revocarea acceptata sub scopul delegarii', L, "verifyText('revocation', canonical(R), r.signature, R.by.keys)", "verifyText('delegation', canonical(R), r.signature, R.by.keys)", 'ATAC: o revocare cu semnatura detinatorului dar alt scop'], ['o lista stricata acuza credentialul', L, "verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch { return false; } });", "verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch (e) { throw e; } });", 'intrari stricate date verificatorului'], ['o revocare stricata acuza prezentarea', L, "try { canonical(R); } catch { nejudecat(`${et}: a revocation`, 'ignored: not readable'); continue; }", '', 'intrari stricate date verificatorului'], diff --git a/identity/identity.mjs b/identity/identity.mjs index 92c68e5..30a20d8 100644 --- a/identity/identity.mjs +++ b/identity/identity.mjs @@ -280,11 +280,18 @@ export function present({ credential, disclosures = [], reveal = [], presenter, * Verifica o prezentare. Fiecare verificare e un rand { name, pass, detail }: pass=true tine, false nu tine, null NEJUDECAT (spus de ce). * valid = niciun rand fals. `claims` (afirmatiile in clar si cele dezvaluite) se intorc numai pentru o prezentare valida. */ -export function verifyPresentation(p, { audience = null, nonce = null, now = new Date(), trustedIssuers = null, statusLists = [], revocations = [], maxAgeS = 300 } = {}) { +// B-30 (2026-09-30, masurat prin API-ul Cloud): ceasul emitentului si al verificatorului difera cu secunde; un credential emis ACUM de +// un emitent cu ceasul inainte cu o secunda era 'not yet valid' la un verificator exact. Toleranta de ceas se da NUMAI pe inceputuri +// (validFrom al credentialului si al listei de stare, notBefore al delegarii) si in sensul prudent pe revocari (o revocare cu momentul +// pana la clockSkewS in viitor se aplica deja); niciodata pe sfarsituri (validUntil, notAfter), unde ar prelungi o valabilitate. +export const CLOCK_SKEW_S = 60; +export function verifyPresentation(p, { audience = null, nonce = null, now = new Date(), trustedIssuers = null, statusLists = [], revocations = [], maxAgeS = 300, clockSkewS = CLOCK_SKEW_S } = {}) { const rows = []; 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(); + if (!Number.isInteger(clockSkewS) || clockSkewS < 0 || clockSkewS > 600) throw new Error('verifyPresentation: clockSkewS is 0..600 seconds'); + const sk = clockSkewS * 1000; // 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 }; }; @@ -309,7 +316,7 @@ export function verifyPresentation(p, { audience = null, nonce = null, now = new } // 2. fereastra, pe ceasul verificatorului const vf = data(S.validFrom, 'validFrom'), vu = data(S.validUntil, 'validUntil'); - ok(`credential: valid at ${iso(acum)}`, vf <= acum && acum <= vu, `valid from ${S.validFrom} until ${S.validUntil}`); + ok(`credential: valid at ${iso(acum)}`, vf - sk <= acum && acum <= vu, `valid from ${S.validFrom} until ${S.validUntil}`); // 3. starea (revocarea) credentialului if (!S.status) nejudecat('credential: status', 'the credential names no status list, so its issuer cannot revoke it'); else { @@ -318,7 +325,7 @@ export function verifyPresentation(p, { audience = null, nonce = null, now = new const aEmitentului = liste.filter((l) => { try { return l.statement.kind === 'aere-status-list' && l.statement.issuer && l.statement.issuer.id === S.issuer.id && canonical(l.statement.issuer.keys) === canonical(S.issuer.keys) && verifyText('status-list', canonical(l.statement), l.signature, S.issuer.keys); } catch { return false; } }); // numai listele emitentului valabile ACUM; cu mai multe, un bit pus in oricare inseamna revocat (revocarea nu se ridica) - const curente = aEmitentului.filter((l) => { try { return data(l.statement.validFrom, 'status validFrom') <= acum && acum <= data(l.statement.validUntil, 'status validUntil'); } catch { return false; } }); + const curente = aEmitentului.filter((l) => { try { return data(l.statement.validFrom, 'status validFrom') - sk <= acum && acum <= data(l.statement.validUntil, 'status validUntil'); } catch { return false; } }); if (!aEmitentului.length) nejudecat(`credential: status in ${S.status.list}`, liste.length ? 'the status list(s) given with this id are not signed by the issuer: ignored' : 'the status list was not handed to the verifier; a revocation it was not handed cannot be seen'); else if (!curente.length) nejudecat(`credential: status in ${S.status.list}`, `the issuer's status list(s) given are not valid at ${iso(acum)}: fetch a current one`); else { @@ -372,7 +379,7 @@ export function verifyPresentation(p, { audience = null, nonce = null, now = new let sc = null; try { sc = scopNormal(D.scope); } catch (e) { ok(`${et}: scope`, false, e.message); } if (sc && canonical(sc) !== canonical(D.scope)) ok(`${et}: scope in normal form`, false, 'the scope is not sorted or has duplicates'); const nb = data(D.notBefore, 'notBefore'), na = data(D.notAfter, 'notAfter'); - ok(`${et}: valid at ${iso(acum)} and when the presentation was made`, nb <= acum && acum <= na && nb <= t && t <= na, `valid from ${D.notBefore} until ${D.notAfter}`); + ok(`${et}: valid at ${iso(acum)} and when the presentation was made`, nb - sk <= acum && acum <= na && nb - sk <= t && t <= na, `valid from ${D.notBefore} until ${D.notAfter}`); ok(`${et}: allows the links after it`, Number.isInteger(D.maxDepth) && D.maxDepth >= lant.length - 1 - i, `maxDepth ${D.maxDepth}, ${lant.length - 1 - i} link(s) after it`); if (i > 0 && sc) { const P = lant[i - 1].statement; @@ -394,7 +401,7 @@ export function verifyPresentation(p, { audience = null, nonce = null, now = new let idR = null; try { idR = idOf(R.by.keys); } catch { /* ignorata */ } if (!autor || idR !== R.by.id || !verifyText('revocation', canonical(R), r.signature, R.by.keys)) { nejudecat(`${et}: a revocation`, `ignored: not signed by who gave the link or by the holder (${R.by && R.by.id})`); continue; } let at = null; try { at = data(R.at, 'revocation at'); } catch { nejudecat(`${et}: a revocation`, 'ignored: its time is not an RFC 3339 UTC time'); continue; } - ok(`${et}: not revoked`, at > acum, `revoked by ${R.by.id} at ${R.at}`); + ok(`${et}: not revoked`, at > acum + sk, `revoked by ${R.by.id} at ${R.at}`); } } if (lant.length && !revocations.length) nejudecat('delegation: revocations', 'none given; a revocation the verifier was not handed cannot be seen'); diff --git a/identity/proba-identity.mjs b/identity/proba-identity.mjs index abbd77b..787795e 100644 --- a/identity/proba-identity.mjs +++ b/identity/proba-identity.mjs @@ -242,6 +242,20 @@ test('ATAC: lantul scos sau inversat dupa semnare -> INVALID', () => { const p2 = clon(p); p2.delegations = [p.delegations[1], p.delegations[0]]; const r2 = I.verifyPresentation(p2, toate); cere(picaPe(r2, /exactly this delegation chain/), fals(r2) || 'inversat trecut'); }); +// B-30 (2026-09-30): masurat prin API-ul viu, un credential emis pe un laptop cu ceasul inainte cu ~1 s era 'not yet valid' la rpc2 (NTP exact) +test('B-30: toleranta de ceas pe INCEPUTURI (60 s): validFrom al credentialului si al listei de stare cu 30 s in viitor -> VALID; cu 90 s -> INVALID; sfarsiturile fara toleranta', () => { + const in30 = new Date(NOW.getTime() + 30000), in90 = new Date(NOW.getTime() + 90000); + const c30 = I.issueCredential({ issuer: iss, holder: hol.public, type: 'EmployeeCredential', claims: CLAIMS, disclosable: ['age_over_18'], validUntil: '2027-09-30T00:00:00Z', status: { list: 'urn:example:status:30', index: 1 }, now: in30 }); + const L30 = I.createStatusList({ issuer: iss, id: 'urn:example:status:30', revoked: [], validUntil: '2026-10-07T00:00:00Z', now: in30 }); + const p30 = I.present({ ...c30, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }); + const r = I.verifyPresentation(p30, { ...toate, statusLists: [L30] }); cere(r.valid && r.notJudged === 0, 'emis cu 30 s in viitor: ' + (fals(r) || 'nejudecate ' + r.notJudged)); + const c90 = I.issueCredential({ issuer: iss, holder: hol.public, type: 'EmployeeCredential', claims: CLAIMS, disclosable: ['age_over_18'], validUntil: '2027-09-30T00:00:00Z', now: in90 }); + const p90 = I.present({ ...c90, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }); + cere(picaPe(I.verifyPresentation(p90, toate), /valid at/), 'emis cu 90 s in viitor a trecut'); + const cExp = I.issueCredential({ issuer: iss, holder: hol.public, type: 'EmployeeCredential', claims: CLAIMS, disclosable: ['age_over_18'], validFrom: '2026-09-01T00:00:00Z', validUntil: new Date(NOW.getTime() - 30000).toISOString(), now: new Date('2026-09-01T00:00:00Z') }); + const pExp = I.present({ ...cExp, reveal: ['age_over_18'], presenter: hol, audience: AUD, nonce: NONCE, now: NOW }); + cere(picaPe(I.verifyPresentation(pExp, toate), /valid at/), 'expirat de 30 s a trecut: sfarsitul a primit toleranta'); +}); test('revocarea: detinatorul revoca veriga telefonului -> INVALID; revocarea unui strain -> ignorata si spusa; una din viitor -> inca nerevocat', () => { const rv = I.revokeDelegation({ by: hol, delegation: d1, now: NOW }); const r = I.verifyPresentation(prezD(), { ...toate, revocations: [rv] }); cere(picaPe(r, /not revoked/), fals(r) || 'trecut'); @@ -249,6 +263,9 @@ test('revocarea: detinatorul revoca veriga telefonului -> INVALID; revocarea unu const r2 = I.verifyPresentation(prezD(), { ...toate, revocations: [rs] }); cere(r2.valid && r2.rows.some((x) => x.pass === null && /ignored/.test(x.detail)), fals(r2) || 'strainul a revocat'); const rf = I.revokeDelegation({ by: phone, delegation: d2, at: '2026-09-30T07:00:00Z', now: NOW }); const r3 = I.verifyPresentation(prezD(), { ...toate, revocations: [rf] }); cere(r3.valid, 'revocarea din viitor s-a aplicat acum: ' + fals(r3)); + // B-30: o revocare cu momentul la 30 s in viitor (ceasul celui care revoca inainte) se aplica deja: toleranta merge in sensul prudent + const rp30 = I.revokeDelegation({ by: hol, delegation: d1, at: new Date(NOW.getTime() + 30000).toISOString(), now: NOW }); + cere(picaPe(I.verifyPresentation(prezD(), { ...toate, revocations: [rp30] }), /not revoked/), 'B-30: revocarea de peste 30 s nu s-a aplicat'); const r4 = I.verifyPresentation(prezD(), { ...toate, now: new Date('2026-09-30T07:00:01Z'), revocations: [rf] }); cere(picaPe(r4, /not revoked/), 'dupa momentul ei, revocarea nu s-a aplicat: ' + fals(r4)); });