From b2e3c4fdda4ef5a8fd28f2709faab0c3a5a27716 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 24 Jul 2026 14:27:19 +0200 Subject: [PATCH 1/2] fix(revocation): sign status list with the same key URL as the credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sign-status-list hard-coded the status list's `kid` under SITE_BASE (verify.ai-driven-dev.fr), but the public key is only published under DATA_BASE (Pages). A verifier fetching the status list's key from SITE_BASE fails, so checkRevocation returns checked:false and the badge falls through to VALID with statusUnknown — a REVOKED (RGPD-removed) badge would read "valide" in prod. - Route the status list kid through keyUrl(kid), the same helper/base as issuance, so the two signed artifacts can't diverge again. status/1 is regenerated on every emit, so this fixes revocation for all badges with no per-credential re-signing. - Fail-safe in the reference verify page: a VALID signature whose revocation is unverifiable (statusUnknown) renders as a cautious "révocation non vérifiée" (warn), never a plain "valide". - Add an end-to-end regression test: signing status + credential with the same helper detects REVOKED; a status list kid on another host reproduces the statusUnknown fail-open the local single-host harness couldn't catch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PC9TRnYr5AXe7GmBmHjkBy --- .github/scripts/lib/revocation-e2e.test.mjs | 70 +++++++++++++++++++++ .github/scripts/sign-status-list.mjs | 6 +- site/verify-page.mjs | 8 ++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/lib/revocation-e2e.test.mjs 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/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 From ae38d7a5899c197a44249f6956c531f8d3fe3f11 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 24 Jul 2026 14:34:22 +0200 Subject: [PATCH 2/2] test(lifecycle): end-to-end journey across every state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the real functions through the whole journey (the GitHub workflows — events/Apps — aren't locally runnable, but the logic they wrap is): filled form -> member record (identity = issue author) -> signed issuance -> display VALID; then EXPIRED, NOT_YET_VALID, REVOKED, RGPD removal (out of directory + exported proof reads revoked + no personal data), tampered -> INVALID, status unreachable -> fail-safe (never a plain "valide"), and form validation rejects. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PC9TRnYr5AXe7GmBmHjkBy --- .github/scripts/lifecycle-e2e.test.mjs | 203 +++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 .github/scripts/lifecycle-e2e.test.mjs 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 + }); +});