diff --git a/.github/scripts/lib/revocation-e2e.test.mjs b/.github/scripts/lib/revocation-e2e.test.mjs new file mode 100644 index 0000000..06bd896 --- /dev/null +++ b/.github/scripts/lib/revocation-e2e.test.mjs @@ -0,0 +1,70 @@ +// Régression du split de host sur le kid du status list. +// Signe un credential + un BitstringStatusListCredential avec la MÊME clé, puis +// vérifie via verifyBadge (fetchers injectés). Pages ne sert les clés que sous +// DATA_BASE : si le kid du status list pointe ailleurs, sa signature n'est pas +// vérifiable -> révocation non concluante -> un badge révoqué passerait pour valide. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + generateKeyPair, exportJWK, calculateJwkThumbprint, SignJWT, +} from 'jose'; +import { buildCredential, keyUrl } from './credential.mjs'; +import { buildStatusListCredential } from './status-list.mjs'; +import { verifyBadge, STATE } from '../../../site/verify.mjs'; + +async function keypairKid() { + const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true }); + const kid = await calculateJwkThumbprint(await exportJWK(privateKey)); + return { privateKey, publicJwk: await exportJWK(publicKey), kid }; +} + +async function signCredential(member, certifiedOn, privateKey, kid) { + const c = buildCredential(member, { certifiedOn }); + return new SignJWT(c) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid) }) + .setIssuer(c.issuer.id).setSubject(c.credentialSubject.id).setJti(c.id) + .setExpirationTime(Math.floor(new Date(c.validUntil).getTime() / 1000)) + .sign(privateKey); +} + +async function signStatus(revoked, privateKey, kidHeaderUrl) { + const s = await buildStatusListCredential(revoked, { issuedOn: new Date('2026-02-01T00:00:00Z') }); + return new SignJWT(s) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: kidHeaderUrl }) + .setIssuer(s.issuer.id).setIssuedAt(Math.floor(Date.parse('2026-02-01T00:00:00Z') / 1000)) + .sign(privateKey); +} + +describe('révocation de bout en bout (status list + verifyBadge)', () => { + const member = { handle: 'jrevoke', name: 'J Revoke', statusIndex: 3 }; + const certifiedOn = new Date('2026-01-01T00:00:00Z'); + const now = new Date('2026-06-01T00:00:00Z'); // dans la fenêtre de validité + + it('détecte REVOKED quand le status list utilise le même helper que le credential (DATA_BASE)', async () => { + const { privateKey, publicJwk, kid } = await keypairKid(); + const credJwt = await signCredential(member, certifiedOn, privateKey, kid); + const statusJwt = await signStatus([3], privateKey, keyUrl(kid)); + // Pages ne sert les clés que sous DATA_BASE. + const fetchKey = async (url) => { if (url === keyUrl(kid)) return publicJwk; throw new Error('404'); }; + const fetchStatus = async () => statusJwt; + + const res = await verifyBadge(credJwt, { fetchKey, fetchStatus, now }); + + assert.equal(res.state, STATE.REVOKED); + }); + + it('révocation NON concluante si le kid du status list pointe un autre host (forme du bug)', async () => { + const { privateKey, publicJwk, kid } = await keypairKid(); + const credJwt = await signCredential(member, certifiedOn, privateKey, kid); + const statusJwt = await signStatus([3], privateKey, `https://verify.ai-driven-dev.fr/keys/${kid}.json`); + const fetchKey = async (url) => { if (url === keyUrl(kid)) return publicJwk; throw new Error('404'); }; + const fetchStatus = async () => statusJwt; + + const res = await verifyBadge(credJwt, { fetchKey, fetchStatus, now }); + + // Signature du credential OK (pas INVALID), mais révocation invérifiable : + // c'est le fail-open que le consommateur DOIT traiter (statusUnknown -> état prudent). + assert.equal(res.state, STATE.VALID); + assert.equal(res.statusUnknown, true); + }); +}); diff --git a/.github/scripts/lifecycle-e2e.test.mjs b/.github/scripts/lifecycle-e2e.test.mjs new file mode 100644 index 0000000..8c199ce --- /dev/null +++ b/.github/scripts/lifecycle-e2e.test.mjs @@ -0,0 +1,203 @@ +// Cycle de vie de bout en bout, au niveau des vraies fonctions (les workflows +// GitHub — events/Apps — ne sont pas exécutables en local, mais toute la LOGIQUE +// qu'ils enveloppent l'est). On déroule le parcours réel : +// formulaire rempli -> record -> émission signée -> affichage (valide / expiré / +// pas encore) -> révocation -> retrait RGPD (annuaire + preuve exportée) -> +// preuve falsifiée -> statut injoignable -> validations du formulaire. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPair, exportJWK, calculateJwkThumbprint, SignJWT } from 'jose'; +import { buildMemberRecord, toMemberYaml, RequestError } from './lib/certification-request.mjs'; +import { buildCredential, keyUrl, parseMemberYaml } from './lib/credential.mjs'; +import { buildStatusListCredential } from './lib/status-list.mjs'; +import { addIndex, parseLedger, serializeLedger } from './lib/revocation.mjs'; +import { buildDirectory } from './lib/directory.mjs'; +import { verifyBadge, STATE } from '../../site/verify.mjs'; +import { render } from '../../site/verify-page.mjs'; + +const YEAR_MS = 365 * 24 * 3600 * 1000; + +// --- Fabrique de formulaire d'inscription (rendu Issue Form) --- +function issueForm({ login, name, linkedin, website = '_No response_', description = '_No response_', photo }) { + const body = [ + '### Nom complet', name, '', + '### Profil LinkedIn', linkedin, '', + '### Site web', website, '', + '### Description', description, '', + '### Photo', photo, '', + ].join('\n'); + return { user: { login }, body, number: 42 }; +} + +// --- Signature (mêmes helpers/base que la prod : keyUrl) --- +async function keypair() { + const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true }); + const kid = await calculateJwkThumbprint(await exportJWK(privateKey)); + return { privateKey, publicJwk: await exportJWK(publicKey), kid }; +} +async function signCredential(member, certifiedOn, privateKey, kid) { + const c = buildCredential(member, { certifiedOn }); + const jwt = await new SignJWT(c) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid) }) + .setIssuer(c.issuer.id).setSubject(c.credentialSubject.id).setJti(c.id) + .setNotBefore(Math.floor(new Date(c.validFrom).getTime() / 1000)) + .setExpirationTime(Math.floor(new Date(c.validUntil).getTime() / 1000)) + .sign(privateKey); + return { jwt, credential: c }; +} +async function signStatus(revoked, privateKey, kidHeaderUrl) { + const s = await buildStatusListCredential(revoked, { issuedOn: new Date('2026-02-01T00:00:00Z') }); + return new SignJWT(s) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: kidHeaderUrl }) + .setIssuer(s.issuer.id).setIssuedAt(Math.floor(Date.parse('2026-02-01T00:00:00Z') / 1000)) + .sign(privateKey); +} +// Pages ne sert les clés que sous DATA_BASE ; verifyBadge s'appuie dessus. +const keyServer = (publicJwk, kid) => async (url) => { + if (url === keyUrl(kid)) return publicJwk; + throw new Error(`404 ${url}`); +}; +function display(result) { + const root = {}; + render(root, result, '/u/jdupont', 'https://verify.ai-driven-dev.fr'); + return root.innerHTML; +} + +describe('cycle de vie du badge — de bout en bout', () => { + const filled = () => issueForm({ + login: 'jdupont', + name: 'Jean Dupont', + linkedin: 'https://www.linkedin.com/in/jdupont', + description: 'Lead dev, agents en prod.', + photo: '![photo](https://example.com/p.png)', + }); + + it('1. le formulaire rempli devient un record valide (identité = auteur)', () => { + const record = buildMemberRecord(filled(), 0); + assert.equal(record.handle, 'jdupont'); + assert.equal(record.name, 'Jean Dupont'); + assert.equal(record.linkedin, 'https://www.linkedin.com/in/jdupont'); + assert.equal(record.photoUrl, 'https://example.com/p.png'); + assert.equal(record.statusIndex, 0); + // roundtrip YAML -> membre signable + const member = parseMemberYaml(toMemberYaml(record)); + assert.equal(member.github, 'jdupont'); + assert.equal(member.status_index, '0'); + }); + + it('2. émission -> affichage VALIDE', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 0 }; + const { jwt } = await signCredential(member, new Date('2026-01-01T00:00:00Z'), privateKey, kid); + const status = await signStatus([], privateKey, keyUrl(kid)); + const res = await verifyBadge(jwt, { + fetchKey: keyServer(publicJwk, kid), fetchStatus: async () => status, now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.VALID); + assert.equal(res.statusUnknown, false); + assert.match(display(res), /Badge valide/); + }); + + it('3. badge EXPIRÉ (émis il y a plus d’un an)', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 0 }; + const old = new Date(Date.parse('2026-06-01') - 400 * 24 * 3600 * 1000); + const { jwt } = await signCredential(member, old, privateKey, kid); + const status = await signStatus([], privateKey, keyUrl(kid)); + const res = await verifyBadge(jwt, { + fetchKey: keyServer(publicJwk, kid), fetchStatus: async () => status, now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.EXPIRED); + assert.match(display(res), /expiré/i); + }); + + it('4. badge PAS ENCORE VALIDE (émis dans le futur)', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 0 }; + const { jwt } = await signCredential(member, new Date('2027-01-01T00:00:00Z'), privateKey, kid); + const status = await signStatus([], privateKey, keyUrl(kid)); + const res = await verifyBadge(jwt, { + fetchKey: keyServer(publicJwk, kid), fetchStatus: async () => status, now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.NOT_YET_VALID); + }); + + it('5. RÉVOCATION -> affichage RÉVOQUÉ', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 7 }; + const { jwt } = await signCredential(member, new Date('2026-01-01T00:00:00Z'), privateKey, kid); + // le registre passe l'index à révoqué, le status list est re-signé + const ledger = serializeLedger(addIndex(parseLedger('{"revoked":[]}'), 7)); + const status = await signStatus(parseLedger(ledger), privateKey, keyUrl(kid)); + const res = await verifyBadge(jwt, { + fetchKey: keyServer(publicJwk, kid), fetchStatus: async () => status, now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.REVOKED); + assert.match(display(res), /révoqué/i); + }); + + it('6. RETRAIT RGPD : hors annuaire, preuve exportée révoquée, aucune donnée perso', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 7 }; + // preuve déjà exportée par le membre AVANT le retrait + const { jwt: exported } = await signCredential(member, new Date('2026-01-01T00:00:00Z'), privateKey, kid); + + // retrait = index ajouté au registre + fiche supprimée (ici : absente de la liste) + const ledger = parseLedger(serializeLedger(addIndex([], 7))); + const remainingMembers = [ + { github: 'autre', name: 'Autre Membre', role: 'certifie', status_index: 0 }, + ]; // jdupont n'a plus de record + const directory = buildDirectory(remainingMembers.map((m) => ({ ...m }))); + assert.equal(directory.members.some((e) => e.handle === 'jdupont'), false); // hors annuaire + + // la preuve exportée qui circule encore : révoquée + const status = await signStatus(ledger, privateKey, keyUrl(kid)); + const res = await verifyBadge(exported, { + fetchKey: keyServer(publicJwk, kid), fetchStatus: async () => status, now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.REVOKED); + // la page de vérif ne divulgue aucune donnée perso : @handle, pas le nom + const html = display(res); + assert.match(html, /@jdupont/); + assert.doesNotMatch(html, /Jean Dupont/); + }); + + it('7. preuve FALSIFIÉE -> INVALIDE', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 0 }; + const { jwt } = await signCredential(member, new Date('2026-01-01T00:00:00Z'), privateKey, kid); + const tampered = jwt.slice(0, -4) + (jwt.slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'); + const res = await verifyBadge(tampered, { + fetchKey: keyServer(publicJwk, kid), fetchStatus: async () => '', now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.INVALID); + assert.match(display(res), /invalide/i); + }); + + it('8. RÉVOCATION INVÉRIFIABLE (status injoignable) -> fail-safe, jamais "valide" plein', async () => { + const { privateKey, publicJwk, kid } = await keypair(); + const member = { handle: 'jdupont', name: 'Jean Dupont', statusIndex: 0 }; + const { jwt } = await signCredential(member, new Date('2026-01-01T00:00:00Z'), privateKey, kid); + const res = await verifyBadge(jwt, { + fetchKey: keyServer(publicJwk, kid), + fetchStatus: async () => { throw new Error('status injoignable'); }, + now: new Date('2026-06-01'), + }); + assert.equal(res.state, STATE.VALID); + assert.equal(res.statusUnknown, true); + const html = display(res); + assert.match(html, /révocation non vérifiée/i); + assert.doesNotMatch(html, /^(?!.*non vérifiée).*Badge valide/s); // pas de "valide" plein sans caveat + }); + + it('9. validations du formulaire (rejets)', () => { + const bad = (over) => issueForm({ + login: 'jdupont', name: 'Jean Dupont', + linkedin: 'https://www.linkedin.com/in/jdupont', photo: '![p](https://x/p.png)', ...over, + }); + assert.throws(() => buildMemberRecord(bad({ name: '' }), 0), RequestError); // nom requis + assert.throws(() => buildMemberRecord(bad({ linkedin: 'https://twitter.com/x' }), 0), RequestError); // pas linkedin + assert.throws(() => buildMemberRecord(bad({ photo: '_No response_' }), 0), RequestError); // photo requise + assert.throws(() => buildMemberRecord(issueForm({ login: 'bad handle!', name: 'X', linkedin: 'https://www.linkedin.com/in/x', photo: '![p](https://x/p.png)' }), 0), RequestError); // handle invalide + }); +}); diff --git a/.github/scripts/sign-status-list.mjs b/.github/scripts/sign-status-list.mjs index 61d21b8..cfaa60b 100644 --- a/.github/scripts/sign-status-list.mjs +++ b/.github/scripts/sign-status-list.mjs @@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { importPKCS8, SignJWT } from 'jose'; import { buildStatusListCredential } from './lib/status-list.mjs'; +import { keyUrl } from './lib/credential.mjs'; import { LEDGER_PATH, parseLedger } from './lib/revocation.mjs'; function fail(m) { console.error(m); process.exit(2); } @@ -32,8 +33,11 @@ async function main() { const revoked = collectRevokedIndices(); const credential = await buildStatusListCredential(revoked, { issuedOn }); + // kid = MÊME helper/base que l'émission du credential (DATA_BASE, la clé y est publiée). + // Diverger ici casse la vérif de révocation : le vérifieur ne trouverait pas la clé du + // status list -> contrôle non concluant -> un badge révoqué passerait pour valide. const jwt = await new SignJWT(credential) - .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: `https://verify.ai-driven-dev.fr/keys/${kid}.json` }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid) }) .setIssuer(credential.issuer.id) .setIssuedAt(Math.floor(issuedOn.getTime() / 1000)) .sign(privateKey); diff --git a/site/verify-page.mjs b/site/verify-page.mjs index db0c9b9..ecd9c11 100644 --- a/site/verify-page.mjs +++ b/site/verify-page.mjs @@ -23,7 +23,13 @@ const fmtDate = (iso) => { * téléchargement. Pur DOM, testable via une racine injectée. */ export function render(root, result, base = '.', origin = '') { - const label = LABELS[result.state] || LABELS[STATE.INVALID]; + // Fail-safe : signature valide MAIS révocation non vérifiée (status list injoignable + // ou non authentifiable) -> on n'affiche pas un « valide » plein. Ne jamais affirmer + // valide sans avoir pu écarter une révocation (un badge retiré passerait pour valide). + const label = + result.state === STATE.VALID && result.statusUnknown + ? { title: 'Signature valide · révocation non vérifiée', tone: 'warn' } + : LABELS[result.state] || LABELS[STATE.INVALID]; const d = result.details || {}; const proofUrl = `${origin}${base}/credential.jwt`; const rows = result.state === STATE.INVALID