Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/scripts/lib/revocation-e2e.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
203 changes: 203 additions & 0 deletions .github/scripts/lifecycle-e2e.test.mjs
Original file line number Diff line number Diff line change
@@ -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
});
});
6 changes: 5 additions & 1 deletion .github/scripts/sign-status-list.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion site/verify-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading