diff --git a/.github/scripts/demo.mjs b/.github/scripts/demo.mjs new file mode 100644 index 0000000..2936798 --- /dev/null +++ b/.github/scripts/demo.mjs @@ -0,0 +1,76 @@ +// Démo locale (#test sans DNS) : signe un membre fictif avec une base localhost, +// assemble le site et le sert. Ouvre http://localhost:8000/u/demo pour voir la +// page de vérification marcher EN VRAI dans le navigateur, sans domaine ni DNS. +// +// Usage : node .github/scripts/demo.mjs (puis ouvrir l'URL affichée) +import { createServer } from 'node:http'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, extname } from 'node:path'; +import { generateKeyPair, exportJWK, calculateJwkThumbprint, SignJWT } from 'jose'; +import { buildCredential, keyUrl, statusListUrl, isoSeconds } from './lib/credential.mjs'; +import { buildStatusListCredential } from './lib/status-list.mjs'; +import { generateQrSvg } from './lib/qr.mjs'; + +const PORT = Number(process.env.PORT || 8000); +const BASE = `http://localhost:${PORT}`; +const HANDLE = 'demo'; +const root = join(dirname(fileURLToPath(import.meta.url)), '../../.demo-site'); +const site = join(dirname(fileURLToPath(import.meta.url)), '../../site'); + +function write(rel, content) { + const p = join(root, rel); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, content); +} + +async function build() { + // Clé jetable (démo). En vrai : secret d'environnement CI. + const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true }); + const pub = await exportJWK(publicKey); // clé PUBLIQUE (jamais la privée) + const kid = await calculateJwkThumbprint(pub); + pub.kid = kid; pub.alg = 'RS256'; pub.use = 'sig'; + + const now = new Date(); + const credential = buildCredential( + { handle: HANDLE, name: 'Membre Démo', statusIndex: 0 }, + { certifiedOn: now, base: BASE }, + ); + const jwt = await new SignJWT(credential) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid, BASE) }) + .setIssuer(credential.issuer.id).setSubject(credential.credentialSubject.id).setJti(credential.id) + .setIssuedAt(Math.floor(now / 1000)).setExpirationTime(Math.floor(new Date(credential.validUntil) / 1000)) + .sign(privateKey); + + // Status list (aucune révocation) signée avec la même clé. + const statusCred = await buildStatusListCredential([], { issuedOn: now, base: BASE }); + const statusJwt = await new SignJWT(statusCred) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid, BASE) }) + .setIssuer(statusCred.issuer.id).setIssuedAt(Math.floor(now / 1000)).sign(privateKey); + + // Assemble le site de démo. + write(`keys/${kid}.json`, JSON.stringify(pub, null, 2)); + write('issuer.json', JSON.stringify(credential.issuer, null, 2)); + write(`u/${HANDLE}/credential.jwt`, jwt); + write(`u/${HANDLE}/qr.svg`, await generateQrSvg(HANDLE, BASE)); + write('status/1', statusJwt); + for (const f of ['verify.mjs', 'verify-page.mjs', 'bitstring.mjs']) write(f, readFileSync(join(site, f))); + write(`u/${HANDLE}/index.html`, readFileSync(join(site, 'badge-page.html'))); + return { kid }; +} + +const TYPES = { '.mjs': 'text/javascript', '.json': 'application/json', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.jwt': 'text/plain' }; + +const { kid } = await build(); +createServer((req, res) => { + let path = decodeURIComponent(req.url.split('?')[0]); + if (path.endsWith('/')) path += 'index.html'; + const file = join(root, path); + if (!file.startsWith(root) || !existsSync(file)) { res.writeHead(404); res.end('not found'); return; } + res.writeHead(200, { 'content-type': TYPES[extname(file)] || 'application/octet-stream' }); + res.end(readFileSync(file)); +}).listen(PORT, () => { + console.log(`Démo servie. kid=${kid}`); + console.log(`\n Ouvre → ${BASE}/u/${HANDLE}\n`); + console.log('Ctrl+C pour arrêter.'); +}); diff --git a/.github/scripts/package.json b/.github/scripts/package.json index dc57fa4..d94ab2d 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -4,7 +4,8 @@ "type": "module", "description": "Scripts d'intake et d'émission des badges AIDD", "scripts": { - "test": "node --test" + "test": "node --test", + "demo": "node demo.mjs" }, "dependencies": { "jose": "^6.2.3", diff --git a/.github/scripts/sign-credential.mjs b/.github/scripts/sign-credential.mjs index 7bde140..20109d9 100644 --- a/.github/scripts/sign-credential.mjs +++ b/.github/scripts/sign-credential.mjs @@ -36,10 +36,12 @@ async function main() { // Le thumbprint ignore `d` → identique au kid de la clé publique publiée. const kid = await calculateJwkThumbprint(await exportJWK(privateKey)); - const credential = buildCredential({ handle: member.github, name: member.name, statusIndex }, { certifiedOn: issuedAt }); + // BASE permet de tester en local (ex. http://localhost:8000) ; défaut = domaine réel. + const base = process.env.BASE || DEFAULT_BASE; + const credential = buildCredential({ handle: member.github, name: member.name, statusIndex }, { certifiedOn: issuedAt, base }); const jwt = await new SignJWT(credential) - .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid) }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyUrl(kid, base) }) .setIssuer(credential.issuer.id) .setSubject(credential.credentialSubject.id) .setJti(credential.id) diff --git a/.gitignore b/.gitignore index 4e33ed9..31c8f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ node_modules/ dist/ build/ .DS_Store + +# Démo locale +.demo-site/ diff --git a/README.md b/README.md index 8c7727b..35146d1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,20 @@ sur LinkedIn et ailleurs. Les flux détaillés (avec diagrammes) : **[`docs/PROCESS.md`](docs/PROCESS.md)**. +## Pourquoi ces choix + +- **Open Badges 3.0, pas une image.** Un badge doit prouver *qui l'a émis*. Une image se + copie ; une signature cryptographique, non. On émet une preuve signée, pas un joli PNG. +- **Vérification indépendante.** Un employeur méfiant ne devrait pas avoir à *nous* faire + confiance. La signature se vérifie dans son navigateur, ou avec un outil tiers (validateur + public 1EdTech), sans passer par nos serveurs. +- **Git-natif.** Pas d'appli à héberger : le registre est des fichiers, l'autorité est le + *merge* d'un mainteneur, l'audit est l'historique Git. Simple, transparent, durable. +- **Aucun email.** L'identité, c'est le compte GitHub (prouvé à l'inscription). Pas de + magasin d'emails à sécuriser ni à effacer — moins de données, moins de risque RGPD. +- **La clé privée ne bouge pas.** Elle ne sert qu'à signer, en CI, dans un environnement + gaté. Jamais dans le dépôt, jamais sur un serveur exposé. Sa fuite = badges forgés. + ## Architecture Deux dépôts. **Celui-ci** = la machinerie et les données (signature, vérif, flux) ; @@ -47,7 +61,18 @@ consommant `directory.json`. 3. **Le dépôt reste public** — les verrous de confiance (protection de branche + environnement) l'exigent au plan gratuit (CT-14). +## Tester en local (sans DNS) + +```bash +cd .github/scripts && npm ci +npm test # ~140 tests (intake, émission, vérif, révocation, annuaire) +npm run demo # signe un badge de démo et sert tout en localhost +``` + +`npm run demo` ouvre `http://localhost:8000/u/demo` : la **page de vérification réelle**, +qui contrôle la signature dans le navigateur — sans domaine ni DNS. + ## Statut v1 en place : inscription → émission → vérification → révocation → retrait, signé et testé. -Reste : le domaine `verify.ai-driven-dev.fr` (DNS) et le rendu `/communaute` (autre dépôt). +Reste : le domaine de vérification (DNS) et le rendu `/communaute` (autre dépôt).