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
76 changes: 76 additions & 0 deletions .github/scripts/demo.mjs
Original file line number Diff line number Diff line change
@@ -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.');
});
3 changes: 2 additions & 1 deletion .github/scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions .github/scripts/sign-credential.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ node_modules/
dist/
build/
.DS_Store

# Démo locale
.demo-site/
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) ;
Expand Down Expand Up @@ -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).
Loading