diff --git a/.github/workflows/welcome-certified.yml b/.github/workflows/welcome-certified.yml new file mode 100644 index 0000000..4c2c1d9 --- /dev/null +++ b/.github/workflows/welcome-certified.yml @@ -0,0 +1,49 @@ +# Accueil du nouveau certifié, au merge de sa PR d'inscription. +# Aucun email n'est collecté (identité = compte GitHub) : la remise se fait par un +# commentaire qui @mentionne le membre -> GitHub le notifie, avec le lien de son kit. +name: Bienvenue au certifié + +on: + pull_request: + types: [closed] + +permissions: + pull-requests: write + +jobs: + welcome: + # Uniquement les PR d'inscription réellement mergées. + if: >- + github.event.pull_request.merged == true + && startsWith(github.event.pull_request.title, 'Inscription :') + runs-on: ubuntu-latest + steps: + - name: Commenter le kit au nouveau certifié + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + SITE: https://verify.ai-driven-dev.fr + run: | + set -euo pipefail + # Handle = le @login dans le titre « Inscription : Nom (@handle) ». + handle="$(printf '%s' "$TITLE" | grep -oE '@[A-Za-z0-9-]+' | head -1 | tr -d '@' || true)" + if [ -z "$handle" ]; then + echo "Handle introuvable dans le titre, on n'accueille pas." + exit 0 + fi + body="$(cat < Rien n'est public tant que la PR n'est pas mergée. Le merge **est** l'autorisation. +> Au merge, le membre est **accueilli en commentaire** sur sa PR (GitHub le notifie), avec le lien de son kit — aucun email requis. ## Traiter un retrait (RGPD) diff --git a/README.md b/README.md index e302ad7..1b361f9 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ npm run demo # signe un badge de démo et le sert -> http://localhost:8000/u/de | Chemin | Contenu | |---|---| -| `docs/ARCHITECTURE.md` | L'architecture et les flux (diagrammes) | +| `docs/ARCHITECTURE.md` | Architecture, flux et **diagrammes** (deux hôtes, émission, vérif, retrait, cycle de vie de la clé) | +| `docs/urls.md` | Cartographie des URLs (permanent gravé vs présentation) | +| `docs/verification.md` | Vérifier un badge indépendamment (page, 1EdTech, hors ligne) | | `MAINTAINERS.md` | Guide des mainteneurs, étape par étape | -| `docs/PRD.md` | Spécification + contraintes techniques (CT-1…CT-14) | +| `docs/PRD.md` | Référence : exigences + contraintes (CT-1…CT-14), rationale | | `docs/rgpd/` | Information (art. 13) + justification non-AIPD | | `.github/scripts/` | Intake, émission, révocation, annuaire (+ tests) | | `site/` | Code de vérification navigateur (+ tests) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f8479e..06df5c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -52,13 +52,18 @@ flowchart TD B --> C["récupère la clé via le kid (Pages)"] C --> D{"Signature valide ?"} D -->|non| INV["Invalide"] - D -->|oui| E{"Révoqué ? (status list)"} + D -->|oui| S{"Statut vérifiable ?
(status list joignable + signée)"} + S -->|non| UNK["Signature valide,
révocation non vérifiée (prudence)"] + S -->|oui| E{"Révoqué ?"} E -->|oui| REV["Révoqué"] E -->|non| F{"Expiré ?"} F -->|oui| EXP["Expiré"] F -->|non| OK["Valide"] ``` +> Fail-safe : on n'affirme jamais « valide » sans avoir écarté une révocation. Si le +> status list est injoignable ou non authentifiable, l'état est **prudent**, pas valide. + ## Retrait RGPD ```mermaid @@ -80,9 +85,28 @@ sequenceDiagram RGPD = `rm -rf data/members//`. La révocation survit dans `data/revoked.json` (juste des entiers, non personnels). Schéma des champs : `PRD.md`. -## Clé de signature +## Clé de signature — cycle de vie + +Une paire RS256. Le `kid` = thumbprint RFC 7638 de la clé publique, donc il ne peut +jamais diverger de la clé (l'émission le re-dérive à chaque build). + +```mermaid +flowchart LR + Gen["Cérémonie de clé
(workflow manuel, gate Habilité)"] + Gen -->|"privée (PKCS8)"| Priv["secret env 'signing'
(jamais dans le dépôt)"] + Gen -->|"publique (JWK nu)"| Pub["keys/<kid>.json
publiée par PR"] + Priv -->|"au merge, en CI"| Sign["signe credentials + status list
kid = URL Pages de la clé"] + Pub -->|"à vie — CT-7"| Verif["déréférencée par le kid des badges"] + Gen -. "rotation = re-run" .-> New["nouvelle kid
l'ancienne publique RESTE publiée"] +``` -Une paire RS256. La **privée** vit en secret d'environnement CI (`signing`), utilisée -seulement au merge ; jamais dans le dépôt. La **publique** est publiée à vie -(`keys/.json`) : retirer une ancienne clé rendrait ses badges invérifiables. -Rotation : un workflow manuel gaté par une review Habilité (`key-ceremony`). +- **Génération** : workflow manuel `key-ceremony`, gaté par une review *Habilité*. La + privée part en secret `SIGNING_PRIVATE_KEY` (env `signing`) ; la publique est publiée + par une PR de clé. L'émission ne se fait qu'après le merge de cette PR. +- **Rotation** : re-jouer la cérémonie. Une nouvelle `kid` sert les émissions suivantes. +- **Révoquer une clé — nuance** : on ne supprime **jamais** `keys/.json`. Retirer + une clé publique rendrait invérifiables **tous** les badges signés avec (CT-7). Donc : + - « retirer » une clé = **rotation** (arrêter de signer avec l'ancienne) ; + - **compromission** = rotation **et** révocation, via la status list, de **tous** les + badges signés par la clé compromise (un attaquant pourrait forger avec elle), puis + ré-émission des membres légitimes sous la nouvelle clé. diff --git a/docs/PRD.md b/docs/PRD.md index 34c56f1..039554c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -203,12 +203,12 @@ déjà exportée garde son ancienne échéance et reste valide jusque-là. **Retrait RGPD (#36).** Un retrait **supprime tout le dossier** `data/members//` (fiche + photo LFS) — l'effacement, pas une réécriture d'historique. Pour que le badge reste révoqué à vie malgré la disparition de la fiche, son `status_index` est inscrit dans un **registre de révocation** `data/revoked.json` (une liste d'entiers — pas de donnée personnelle). La liste de statuts (CT-4) se construit à partir de ce registre. Une preuve déjà exportée reste présentable mais apparaît **révoquée** à la vérification (CT-5, seule voie). -Le site public `ai-driven-dev.fr` (`/communaute`) affiche l'annuaire ; il consomme le flux **`directory.json`** publié par la CI sur `verify.ai-driven-dev.fr` (nom, LinkedIn, photo, site, description, URL de vérif). Les photos sont servies sur `verify.ai-driven-dev.fr/photos/.webp`. Les membres révoqués sont exclus du flux. +Le site public `ai-driven-dev.fr` (`/communaute`) affiche l'annuaire ; il consomme le flux **`directory.json`** publié par la CI sur l'ancre Pages `ai-driven-dev.github.io/badges` (nom, LinkedIn, photo, site, description, URL de vérif). Les photos sont servies sur `ai-driven-dev.github.io/badges/photos/.webp`. Les membres révoqués sont exclus du flux. (Hôtes : cf. `ARCHITECTURE.md`.) -**Généré au merge, pas saisi** (le job d'émission le calcule à partir de la date de merge, pour qu'un demandeur ne fixe pas lui-même sa validité) : +**Généré à l'émission, pas saisi** (calculé par la CI pour qu'un demandeur ne fixe pas lui-même sa validité) : - `badge_id` — identifiant unique du credential ; -- `certified_on` — date d'émission (= date de merge) ; +- `certified_on` — date d'émission = **date du commit qui a ajouté le record** (déterministe ; `renewed_on` prime au renouvellement) ; - `expires_on` — `certified_on` + 1 an (CT-8). Ces valeurs ne figurent pas dans le YAML d'intake : elles sont scellées dans le credential signé et exposées par la page de vérification. diff --git a/docs/spikes/sp1-domain-hosting/README.md b/docs/spikes/sp1-domain-hosting/README.md index 14de3fb..13bf159 100644 --- a/docs/spikes/sp1-domain-hosting/README.md +++ b/docs/spikes/sp1-domain-hosting/README.md @@ -1,5 +1,11 @@ # SP1 — Domaine + hébergement +> **⚠️ Superseded — spike historique.** Décrit l'ancien schéma **mono-host** +> (`verify.ai-driven-dev.fr` = Pages). Le système a depuis **splitté en deux hôtes** : +> ancre permanente sur `ai-driven-dev.github.io/badges` (Pages), présentation sur +> `verify.ai-driven-dev.fr` (VPS). **Source à jour : [ARCHITECTURE.md](../../ARCHITECTURE.md) +> + [urls.md](../../urls.md).** Conservé pour la traçabilité, pas comme référence. + **Statut : tranché.** Décision écrite ci-dessous. Débloque E1 (socle/infra) et E4 (vérification). ## Décision diff --git a/docs/urls.md b/docs/urls.md new file mode 100644 index 0000000..d34bd2a --- /dev/null +++ b/docs/urls.md @@ -0,0 +1,60 @@ +# Cartographie des URLs + +Deux hôtes, deux rôles. Ne jamais confondre : les uns sont **gravés à vie** dans les +badges, les autres sont juste de la **présentation** remplaçable. + +| Base | Rôle | Hébergement | +| --- | --- | --- | +| `DATA_BASE` = `https://ai-driven-dev.github.io/badges` | **Ancre permanente** : clés, statut, issuer, preuves, annuaire, photos. Doit vivre à vie. | GitHub Pages (statique, dispo max) | +| `SITE_BASE` = `https://verify.ai-driven-dev.fr` | **Présentation** : la belle page de vérif, le kit, les images dérivées. | VPS (Astro SSR) | + +> `DATA_BASE`/`SITE_BASE` sont définis dans `.github/scripts/lib/credential.mjs`. +> En **prod**, la signature grave `DATA_BASE` (Pages) dans chaque credential — donc un +> badge reste **vérifiable même si le VPS tombe**. Le VPS ne fait que lire Pages et l'habiller. +> En **local**, `npm run demo` (`.github/scripts/demo.mjs`) signe un badge de démo et le sert +> pour tester sans DNS ; ce n'est PAS le schéma de prod. + +## 1. Permanent — gravé dans chaque badge (immuable, à vie) + +Changer une seule de ces URLs **invalide tous les badges déjà émis**. Servi sous `DATA_BASE`. + +| URL | Contenu | +| --- | --- | +| `/issuer.json` | Profil de l'émetteur (`type: Profile`) — `issuer.id` du credential. | +| `/keys/.json` | 1 JWK **nu** par clé (pas de JWKS array). Publié à vie, même après rotation (CT-7). | +| `/status/1` | Bitstring Status List (révocation, CT-4). Référencé par `credentialStatus`. | +| `/achievements/certified-member` | Définition de l'achievement. | +| `/u//credential.jwt` | La preuve VC-JWT signée, téléchargeable (CT-6). | + +## 2. Flux public — régénéré par la CI (pas gravé, mais public) + +Servi sous `DATA_BASE`. Reconstruit à chaque émission / retrait. + +| URL | Contenu | +| --- | --- | +| `/directory.json` | Annuaire des certifiés **actifs** (révoqués exclus, cf. `directory.mjs` `isActive`). Consommé par le site (`/communaute`). | +| `/photos/.webp` | Photo du membre (copiée depuis LFS au build). | + +## 3. Présentation — le site (remplaçable, non gravé) + +Servi sous `SITE_BASE` (VPS). Lit les données ci-dessus et les rend au design. Peut évoluer +librement sans toucher aux badges. Les images sont rendues serveur (sharp). + +| URL | Contenu | Params | +| --- | --- | --- | +| `/u/` | Page de vérification publique (vérif crypto **dans le navigateur**). | — | +| `/u//kit` | Kit self-service du membre (assets à copier / télécharger). | — | +| `/u//badge.png` | Sceau **générique** (favicon, avatar). | `size` (16–1024), `role` (`certifie`\|`habilite`) | +| `/u//og.png` | Carte d'aperçu de lien (Open Graph, 1200×630) — déplie le lien collé en carte de marque. Sert aussi de média certificat. | — | +| `/u//linkedin.png` | Photo du membre cerclée de l'anneau + pastille de vérification. | `size` (120–1200), `check` (`0` = sans pastille) | +| `/u//banner.png` | Coin « certifié » transparent à poser sur SA bannière. | — | +| `/u//qr.png` | QR code vers la page de preuve. | `size` | +| `/u//photo.png` | Photo du membre en rond, même origine (aperçus, signature email). | `size` (48–1024) | + +## Règle d'or + +- Une URL de la **section 1** est **irréversible** : `issuer.id`, les URLs de `kid`, les ids + d'achievement et de statut sont fixés à l'émission. Le domaine `DATA_BASE` doit être renouvelé + indéfiniment. +- Les URLs de la **section 3** sont **cosmétiques** : on peut changer le rendu, ajouter des + formats, migrer d'hôte — aucun badge n'en dépend pour être vérifié. diff --git a/docs/verification.md b/docs/verification.md index 0e4ff51..f4a9a9c 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -11,9 +11,12 @@ ne fait que servir des fichiers statiques ; il ne « valide » rien. ## Avec un outil que vous choisissez (#32) -1. Téléchargez la preuve : `https://verify.ai-driven-dev.fr/u//credential.jwt`. +Les URL permanentes sont sur l'**ancre Pages** (`ai-driven-dev.github.io/badges`), +gravées dans le badge — pas sur le site de présentation (cf. [ARCHITECTURE.md](ARCHITECTURE.md)). + +1. Téléchargez la preuve : `https://ai-driven-dev.github.io/badges/u//credential.jwt`. 2. Soumettez-la au **validateur public 1EdTech** : (verifier « Open Badges 3.0 »). -3. Il récupère la clé publique via le `kid` du JWT (`https://verify.ai-driven-dev.fr/keys/.json`, +3. Il récupère la clé publique via le `kid` du JWT (`https://ai-driven-dev.github.io/badges/keys/.json`, un JWK nu) et confirme la signature — **sans passer par notre page**. La conformité au format est prouvée : voir `docs/spikes/sp4-conformance/`. @@ -30,5 +33,6 @@ vérifiable même après rotation. - **Signature** : le badge a été émis par la clé privée d'AIDD, non forgé ni altéré. - **Titulaire** : le sujet est un compte GitHub (`https://github.com/`) — le contrôle du compte a été prouvé à l'émission (CT-3). -- **Validité** : dates d'émission et d'expiration (1 an), et statut de révocation - (Bitstring Status List, à venir #25). +- **Validité** : dates d'émission et d'expiration (1 an), et statut de **révocation** + (Bitstring Status List v1.0). Si le status list est injoignable, la page reste + **prudente** — jamais « valide » sans avoir écarté une révocation.