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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ Petite application PHP (sans dépendance) et base SQLite qui recueille les signa

1. La personne remplit le formulaire (`public/index.php`) : prénom, nom, e-mail, fonction et organisation facultatives, consentement à la publication.
2. Elle reçoit un e-mail qui récapitule les informations saisies, avec un lien valable 48 heures (double consentement). Le lien ouvre une page qui les récapitule à nouveau, avec un bouton : seul ce bouton (POST) confirme, pour que les antivirus de messagerie qui suivent les liens ne confirment pas à la place de la personne.
3. Après confirmation, un second e-mail contient le lien de retrait, qui supprime la signature et les données.
3. Après confirmation, un second e-mail contient le lien de retrait, qui supprime la signature et les données, et le lien vers sa **preuve horodatée** (`public/preuve.php`) :
- une attestation JSON de la signature (manifeste, nom, fonction, organisation, choix de publication, date de confirmation, sel aléatoire ; jamais l'adresse e-mail) est figée à la confirmation, et son empreinte SHA-256 est horodatée (RFC 3161) par l'autorité d'horodatage de **staging** d'OTSPI (`api.staging.open-eidas.eu`) — preuve non qualifiée, sans valeur juridique ;
- la page de preuve affiche la date certifiée et les détails du jeton, que le serveur vérifie sans dépendance (`src/horodatage.php` : signature CMS de l'unité d'horodatage, empreinte, usage « Time Stamping », émission par la chaîne épinglée `src/tsa-staging-ca.pem`), propose l'attestation (`.json`) et le jeton (`.tsr`) au téléchargement, et renvoie au vérificateur de demo.open-eidas.eu ou à `openssl ts -verify` ;
- si l'autorité ne répond pas (5 secondes au plus), la confirmation n'est pas bloquée : la tâche quotidienne réessaie. Elle horodate aussi les signatures antérieures et leur envoie leur preuve (100 e-mails au plus par passage). `php bin/horodatage.php` fait le même rattrapage à la demande.
4. Si la personne a accepté la publication, la signature attend une **modération** : l'adresse `contact` reçoit une notification avec un lien vers l'administration (connexion par clé de sécurité ; les anciens liens `moderation.php` y redirigent), ou en ligne de commande `php bin/moderation.php lister | valider ID | masquer ID | supprimer ID` (masquer : signature comptée, nom jamais publié ; supprimer : usurpation, abus). Seule l'adresse e-mail est vérifiée par l'application, pas l'identité déclarée.
5. **Administration** : `https://manifesto-sign.otspi.org/admin.php`, ouverte **uniquement avec une clé de sécurité** (WebAuthn / FIDO2, une YubiKey) **et son code PIN** : la vérification de l'utilisateur est exigée, ce qui fait deux facteurs (possession de la clé, PIN). Ni mot de passe ni lien de connexion. La page liste les signatures par état (à modérer, publiées, non publiées, en attente de confirmation) avec les actions valider, masquer et supprimer ; pour les demandes en attente de confirmation, renvoyer le lien de confirmation (nouveau lien de 48 heures, au plus toutes les 10 minutes) ou supprimer la demande. Actions en POST depuis le site, suppression confirmée.
- **Enregistrer la première clé** : tant qu'aucune clé n'existe, la page propose d'envoyer à la seule adresse `contact` un lien d'enregistrement valable 24 heures et à usage unique (au plus 3 envois par 10 minutes). Ensuite, ce bouton disparaît : une clé supplémentaire (recommandée, gardée en lieu sûr) s'ajoute depuis l'onglet « Clés de sécurité » d'une session ouverte, ou par un nouveau lien envoyé depuis le serveur avec `php bin/admin.php invitation` (clé perdue). Chaque enregistrement déclenche une alerte à l'adresse `contact`.
- **Clés** : l'onglet « Clés de sécurité » les liste (modèle par AAGUID, dernière utilisation) et permet de les révoquer, sauf la dernière ; la révocation ferme les sessions ouvertes avec la clé. `config.php` peut limiter les modèles acceptés (`admin_aaguids`). En ligne de commande : `php bin/admin.php cles | revoquer ID | deconnecter`.
- **Session** : cookie `__Host-` HttpOnly, Secure, SameSite=Lax, propre à l'administration ; fermée après 30 minutes d'inactivité, 8 heures au plus. Les défis WebAuthn sont signés (HMAC, 10 minutes), liés à leur usage et à usage unique ; le compteur de signatures de la clé est contrôlé.
6. `public/signataires.php` expose en JSON la liste publique : signatures confirmées, avec consentement à la publication et validées, **sans adresse e-mail**. Le total n'inclut pas les signatures en attente de modération. La page du manifeste de www.otspi.org la lit en direct (en-tête CORS limité à `https://www.otspi.org`) : une signature apparaît quelques minutes après sa validation (cache de 5 minutes).
7. `cron/purge.php` (tâche quotidienne) supprime les demandes non confirmées de plus de 7 jours, ainsi que les invitations, défis et sessions d'administration expirés.
7. `cron/purge.php` (tâche quotidienne) supprime les demandes non confirmées de plus de 7 jours, ainsi que les invitations, défis et sessions d'administration expirés ; il horodate les signatures en attente et envoie leur preuve.

Protections : jeton de formulaire signé avec délai minimal, à usage unique (aucun cookie, aucune session), **preuve de travail** calculée par le navigateur pendant la saisie (`assets/pow.js` : SHA-256 du jeton avec 18 bits nuls en tête, soit moins d'une seconde sur un ordinateur ; réglable par `pow_bits` ; aucun service tiers, JavaScript requis pour signer), refus des adresses d'e-mail jetables (`src/disposable-domains.txt`, sous-domaines compris), champ piège pour les robots, refus des envois venant d'un autre site (`Sec-Fetch-Site`, à défaut `Origin`), limitation de débit par empreinte salée de l'adresse IP ou du préfixe /64 en IPv6 (conservée une heure), plafond global de 200 e-mails de confirmation par heure (`mail_hourly_cap`), e-mails sans texte libre hors du récapitulatif et liens refusés dans les champs, une seule signature par boîte (les alias `+…` sont acceptés mais ignorés pour l'unicité), un seul e-mail de confirmation toutes les 10 minutes par adresse, réponse identique que l'adresse soit connue ou non, refus des caractères de contrôle, bidirectionnels et de largeur nulle dans les noms, requêtes préparées, échappement HTML, erreurs journalisées et jamais affichées, en-têtes de sécurité et CSP stricte.

Expand All @@ -24,7 +27,7 @@ Mesure d'audience : seuls le formulaire (`index.php`) et la page « signature co
1. Copier le dépôt hors du dossier web, le dossier `public/` étant la racine du site. Les dossiers `src/`, `data/`, `bin/` et `cron/` ne doivent pas être servis.
2. Copier `config.example.php` en `config.php` et le renseigner (secret aléatoire, adresse, expéditeur). `config.php` et `data/` ne sont jamais versionnés.
3. Planifier `php cron/purge.php` une fois par jour.
4. Prérequis : PHP 8.1 ou plus avec PDO SQLite ; la fonction `mail()` opérationnelle, avec SPF et DKIM sur le domaine d'expéditeur.
4. Prérequis : PHP 8.1 ou plus avec PDO SQLite et OpenSSL, `allow_url_fopen` activé (appel à l'autorité d'horodatage) ; la fonction `mail()` opérationnelle, avec SPF et DKIM sur le domaine d'expéditeur.

## Déploiement

Expand Down
16 changes: 16 additions & 0 deletions bin/horodatage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
// Horodatage des signatures (ligne de commande uniquement) : même rattrapage que le cron quotidien, à lancer
// par exemple juste après la mise en ligne pour horodater les signatures existantes et leur envoyer leur preuve.
// php bin/horodatage.php

declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
exit(1);
}
require __DIR__ . '/../src/lib.php';
require __DIR__ . '/../src/horodatage.php';

$done = proof_catch_up();
echo "{$done['horodatees']} signature(s) horodatée(s), {$done['echecs']} échec(s), {$done['envoyees']} preuve(s) envoyée(s)\n";
exit($done['echecs'] > 0 ? 1 : 0);
4 changes: 4 additions & 0 deletions config.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
'admin_aaguids' => [],
// Facultatif : difficulté de la preuve de travail anti-robots (bits nuls, 18 par défaut ; +1 double le calcul).
// 'pow_bits' => 18,
// Autorité d'horodatage (API JSON RFC 3161) et chaîne épinglée pour vérifier ses jetons. Par défaut : le
// staging d'OTSPI et src/tsa-staging-ca.pem. 'tsa_url' => '' désactive l'horodatage.
// 'tsa_url' => 'https://api.staging.open-eidas.eu',
// 'tsa_ca' => __DIR__ . '/src/tsa-staging-ca.pem',
// Pour les essais : ne pas envoyer, journaliser dans mail_log.
'mail_dry_run' => false,
'mail_log' => __DIR__ . '/data/mail.log',
Expand Down
9 changes: 7 additions & 2 deletions cron/purge.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
// Purge quotidienne (tâche cron) : supprime les demandes non confirmées de plus de 7 jours
// ainsi que les empreintes de limitation de débit, invitations, défis et sessions d'administration périmés.
// Tâche quotidienne (cron) : supprime les demandes non confirmées de plus de 7 jours
// ainsi que les empreintes de limitation de débit, invitations, défis et sessions d'administration périmés ;
// horodate les signatures qui ne le sont pas encore et envoie leur preuve (src/horodatage.php).

declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
exit(1);
}
require __DIR__ . '/../src/lib.php';
require __DIR__ . '/../src/horodatage.php';

$pdo = db();
$old = $pdo->prepare('DELETE FROM signatures WHERE confirmed_at IS NULL AND created_at < ?');
Expand All @@ -16,3 +18,6 @@
$pdo->prepare('DELETE FROM admin_tokens WHERE expires_at < ?')->execute([time()]);
$pdo->prepare('DELETE FROM form_tokens WHERE at < ?')->execute([time() - MAX_FILL_SECONDS]);
echo date('c') . ' purge : ' . $old->rowCount() . " demande(s) non confirmée(s) supprimée(s)\n";
// Horodatage : signatures en attente (autorité injoignable, signatures antérieures) et envoi de leur preuve.
$proofs = proof_catch_up();
echo date('c') . " horodatage : {$proofs['horodatees']} signature(s) horodatée(s), {$proofs['echecs']} échec(s), {$proofs['envoyees']} preuve(s) envoyée(s)\n";
4 changes: 2 additions & 2 deletions public/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ function resend_confirmation(int $id): string
. h($label) . ' <span class="count">' . $count . '</span></a>';
}

$rows = $pdo->query('SELECT id, email, prenom, nom, fonction, organisation, publier, lang, created_at, last_mail_at, confirmed_at, approved_at FROM signatures WHERE '
$rows = $pdo->query('SELECT id, email, prenom, nom, fonction, organisation, publier, lang, created_at, last_mail_at, confirmed_at, approved_at, proof_at FROM signatures WHERE '
. FILTERS[$filter][1] . ' ORDER BY COALESCE(confirmed_at, created_at) DESC, id DESC LIMIT 500')->fetchAll();

$lines = '';
Expand All @@ -356,7 +356,7 @@ function resend_confirmation(int $id): string
$actions .= $button((int) $row['id'], 'supprimer', 'Supprimer…', 'danger');
$dates = 'Demande : ' . when((int) $row['created_at']) . '<br>'
. ($row['confirmed_at'] !== null
? 'Confirmée : ' . when((int) $row['confirmed_at'])
? 'Confirmée : ' . when((int) $row['confirmed_at']) . '<br>Horodatée : ' . when($row['proof_at'] === null ? null : (int) $row['proof_at'])
: 'Dernier e-mail : ' . when((int) $row['last_mail_at']) . '<br>Purge : ' . when((int) $row['created_at'] + UNCONFIRMED_TTL));
$lines .= '<tr><td>' . (int) $row['id'] . '</td>'
. '<td><strong>' . h(trim($row['prenom'] . ' ' . $row['nom'])) . '</strong>'
Expand Down
11 changes: 9 additions & 2 deletions public/confirm.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

declare(strict_types=1);
require __DIR__ . '/../src/lib.php';
require __DIR__ . '/../src/horodatage.php';

$lang = lang();
$token = (string) ($_GET['t'] ?? $_POST['t'] ?? '');
Expand All @@ -27,10 +28,16 @@
$withdraw = new_token();
// Condition sur confirmed_at : de deux confirmations simultanées, une seule aboutit.
$update = $pdo->prepare('UPDATE signatures SET confirmed_at = ?, confirm_hash = ?, withdraw_hash = ? WHERE id = ? AND confirmed_at IS NULL');
$update->execute([time(), token_hash(new_token()), token_hash($withdraw), $signature['id']]);
$now = time();
$update->execute([$now, token_hash(new_token()), token_hash($withdraw), $signature['id']]);
if ($update->rowCount() === 1) {
$l = $signature['lang'] === 'en' ? 'en' : 'fr';
send_mail($signature['email'], t('mail_done_subject', $l), sprintf(t('mail_done_body', $l), url('withdraw.php', ['t' => $withdraw, 'lang' => $l])));
// Preuve : attestation figée puis horodatée ; si l'autorité ne répond pas, le cron réessaie et le lien
// affiche « horodatage en cours ». proof_mailed_at évite au cron un second envoi.
$proof = proof_link($signature + ['confirmed_at' => $now]);
db()->prepare('UPDATE signatures SET proof_mailed_at = ? WHERE id = ?')->execute([$now, $signature['id']]);
timestamp_signature((int) $signature['id']);
send_mail($signature['email'], t('mail_done_subject', $l), sprintf(t('mail_done_body', $l), url('withdraw.php', ['t' => $withdraw, 'lang' => $l]), $proof));
if ((int) $signature['publier'] === 1) {
// Rien n'est publié avant validation : on prévient la personne qui modère, qui se connecte à
// l'administration avec sa clé de sécurité (la ligne de commande bin/moderation.php reste possible).
Expand Down
88 changes: 88 additions & 0 deletions public/preuve.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
// Preuve horodatée d'une signature, par le lien personnel reçu par e-mail : attestation, jeton RFC 3161 vérifié
// par le serveur (src/horodatage.php), téléchargements (?f=json, ?f=tsr) et vérification indépendante sur la
// démonstration d'OTSPI ou avec OpenSSL. Adresse porteuse d'un jeton : jamais de mesure d'audience.

declare(strict_types=1);
require __DIR__ . '/../src/lib.php';
require __DIR__ . '/../src/horodatage.php';

const DEMO_VERIFIER = 'https://demo.open-eidas.eu/#verifier';

$lang = lang();
$token = (string) ($_GET['t'] ?? '');
$signature = false;
if (preg_match('/^[0-9a-f]{64}$/', $token) === 1) {
$row = db()->prepare('SELECT id, prenom, nom, fonction, organisation, publier, proof_json, proof_token FROM signatures WHERE proof_hash = ? AND confirmed_at IS NOT NULL');
$row->execute([token_hash($token)]);
$signature = $row->fetch();
}
if ($signature === false || $signature['proof_json'] === null) {
http_response_code(404);
page(t('proof_title', $lang), '<h1>' . h(t('proof_title', $lang)) . '</h1><p>' . h(t('proof_invalid', $lang)) . '</p>' . back_to_form($lang), $lang);
exit;
}

$name = 'signature-otspi-' . (int) $signature['id'];
$file = (string) ($_GET['f'] ?? '');
if ($file === 'json' || ($file === 'tsr' && $signature['proof_token'] !== null)) {
header('Content-Type: ' . ($file === 'json' ? 'application/json; charset=UTF-8' : 'application/timestamp-reply'));
header('Content-Disposition: attachment; filename="' . $name . '.' . $file . '"');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('Cache-Control: no-store');
echo $file === 'json' ? $signature['proof_json'] : base64_decode($signature['proof_token']);
exit;
}

$digest = hash('sha256', $signature['proof_json']);
$details = null;
$status = '<p class="proof-status pending" role="status">' . h(t('proof_pending', $lang)) . '</p>';
if ($signature['proof_token'] !== null) {
try {
$details = tsa_verify(base64_decode($signature['proof_token']), hex2bin($digest));
$status = '<p class="proof-status ok" role="status"><span aria-hidden="true">✓</span> ' . h(t('proof_ok', $lang)) . '</p>';
} catch (TimestampInvalid $e) {
error_log('otspi-signatures : preuve de la signature #' . (int) $signature['id'] . ' invalide (' . $e->getMessage() . ')');
$status = '<p class="proof-status error" role="alert">' . h(t('proof_error', $lang)) . '</p>';
}
}

$other = $lang === 'fr' ? 'en' : 'fr';
$link = static fn (array $query): string => h(url('preuve.php', ['t' => $token] + $query));
$body = '<p class="lang"><a href="' . $link(['lang' => $other]) . '">' . strtoupper($other) . '</a></p>'
. '<h1>' . h(t('proof_title', $lang)) . '</h1>' . $status
. '<p>' . h(t('proof_intro', $lang)) . '</p>'
. recap_html($signature, $lang);

if ($details !== null) {
$paris = (new DateTimeImmutable('@' . $details['gen_time']))->setTimezone(new DateTimeZone('Europe/Paris'));
$rows = [
t('proof_date', $lang) => $paris->format($lang === 'fr' ? 'd/m/Y à H:i:s' : 'Y-m-d, H:i:s') . ' (Paris) — ' . $details['gen_time_utc'],
t('proof_digest', $lang) => $digest,
t('proof_serial', $lang) => $details['serial'],
t('proof_unit', $lang) => $details['unit'],
t('proof_issuer', $lang) => $details['issuer'],
t('proof_policy', $lang) => $details['policy'],
];
$body .= '<h2>' . h(t('proof_timestamp', $lang)) . '</h2><dl class="recap proof-details">';
foreach ($rows as $label => $value) {
$body .= '<dt>' . h($label) . '</dt><dd><code>' . h($value) . '</code></dd>';
}
$body .= '</dl><h2>' . h(t('proof_checks', $lang)) . '</h2><ul class="checks">'
. '<li>' . h(t('proof_check_signature', $lang)) . '</li>'
. '<li>' . h(t('proof_check_digest', $lang)) . '</li>'
. '<li>' . h(t('proof_check_chain', $lang)) . '</li></ul>'
. '<h2>' . h(t('proof_self', $lang)) . '</h2>'
. '<div class="moderation-actions">'
. '<a class="button secondary" href="' . $link(['f' => 'json']) . '" download>' . h(t('proof_download_json', $lang)) . '</a>'
. '<a class="button secondary" href="' . $link(['f' => 'tsr']) . '" download>' . h(t('proof_download_tsr', $lang)) . '</a></div>'
. '<p>' . h(t('proof_demo', $lang)) . '</p>'
. '<p><a class="button" href="' . h(DEMO_VERIFIER) . '" rel="noopener">' . h(t('proof_demo_link', $lang)) . '</a></p>'
. '<p>' . h(t('proof_openssl', $lang)) . ' <a href="' . h(tsa_url() . '/api/v1/certificate') . '">' . h(t('proof_chain_link', $lang)) . '</a></p>'
. '<pre><code>openssl ts -verify -data ' . $name . '.json -in ' . $name . '.tsr -CAfile chaine.pem</code></pre>';
}
$body .= '<p class="notice">' . h(t('proof_staging', $lang)) . '</p>';

page(t('proof_title', $lang), $body, $lang);
Loading
Loading