commit 49b4308028ad5957b6cdcd32eff56d8e440fb8b7 Author: interlope Date: Mon Nov 2 20:18:08 2026 +0100 Implement secure storage and TOTP authentication for BluditSecure diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3165ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/dist/ +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..27e27b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bludit Secure contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..21b224f --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# bludit-secure + +Plugin d’authentification pour **Bludit 3.22.0**. Version **0.1.0 expérimentale**, à valider sur une copie du site avant activation en production. + +## Fonctionnalités + +- Connexion locale avec le mot de passe Bludit. +- Bouton **Se connecter avec XYZ**, dont le nom est configurable, vers un fournisseur OIDC tel que Pocket ID. +- Liaison volontaire de l’identité SSO par chaque utilisateur connecté. Association sur **issuer + subject**, jamais automatiquement sur l’adresse e-mail. Aucun utilisateur ni rôle créé à partir des claims OIDC. +- TOTP par compte (SHA-1, six chiffres, 30 secondes), compatible avec le champ TOTP de Bitwarden. Activation après confirmation d’un code. Refus des codes déjà utilisés. +- Plusieurs passkeys par compte, avec nom et suppression individuelle. Compatible avec les passkeys synchronisées, dont celles conservées dans Bitwarden. Vérification utilisateur obligatoire. +- Choix personnel du second facteur : désactivé, TOTP, passkey, ou TOTP/passkey au choix. Possibilité de demander aussi ce facteur local après le SSO. +- Dix codes de récupération à usage unique, affichés une fois, renouvelables. + +Les passkeys locales sont enregistrées pour le domaine **Bludit**. La passkey de Pocket ID reste attachée au domaine **Pocket ID**. Ce sont deux inscriptions distinctes. Les passkeys locales servent ici de **second facteur**, après le mot de passe ou le SSO, et non de connexion locale sans mot de passe. + +## Prérequis + +- PHP **8.2 ou supérieur**, extensions sodium, curl, mbstring et openssl, plus les prérequis habituels de Bludit. +- Site configuré avec son URL publique **HTTPS** exacte dans Bludit ; certificat valide. Aucun mode dégradé HTTP. +- Le serveur PHP doit pouvoir joindre l’issuer Pocket ID et ses endpoints de découverte, clés et jetons. +- Accès au stockage `bl-content/databases/plugins/bludit-secure/` en écriture pour PHP. +- Un reverse proxy doit transmettre une adresse client fiable à PHP via `REMOTE_ADDR`. Ne jamais faire confiance directement aux en-têtes fournis par le client. + +## Installation + +1. Sauvegarder les fichiers et `bl-content` et préparer un accès SSH de récupération. +2. Décompresser le ZIP dans `bl-plugins/` pour obtenir `bl-plugins/bludit-secure/plugin.php`. Le ZIP fourni inclut `vendor/`. +3. Activer **Bludit Secure** dans les plugins. Les sessions Bludit précédentes devront se reconnecter. +4. Ouvrir **Sécurité de mon compte** dans la barre d’administration. + +Pour construire depuis les sources : + +```sh +composer install --no-dev --prefer-dist --optimize-autoloader +``` + +Ne pas activer une copie source sans ses dépendances : les accès admin seront refusés tant que le plugin ne peut pas fonctionner. + +## Configuration Pocket ID + +Dans Pocket ID, créer un client OIDC confidentiel avec son secret et enregistrer exactement l’URI de retour affichée dans les paramètres du plugin : + +```text +https://blog.example/admin/login?bs=oidc +``` + +Le chemin respecte le sous-répertoire et le chemin admin configurés dans Bludit. Reporter l’issuer exact (y compris sa convention de slash final), le Client ID et le Client secret dans le plugin. Choisir le nom du bouton, par exemple **Mon SSO**. Laisser le champ secret vide lors d’une modification conserve le secret existant. + +Chaque utilisateur se connecte d’abord avec son compte Bludit, ouvre **Sécurité de mon compte**, puis clique sur **Lier mon compte à …**. Après validation chez Pocket ID, le compte est lié. Un même couple issuer/subject ne peut pas être associé à deux comptes Bludit. + +Le plugin utilise le flux Authorization Code avec PKCE S256 et la bibliothèque jumbojett pour vérifier state, nonce, signature et claims. Les rôles restent ceux du compte Bludit. La déconnexion ferme la session Bludit, sans fermer la session Pocket ID ni celles des autres applications. Le logout OIDC global/back-channel n’est pas implémenté. + +## Facteurs personnels + +Dans **Sécurité de mon compte** : + +- **TOTP** : préparer le secret, copier l’URI `otpauth://` dans Bitwarden, puis confirmer avec un code. Pas de service QR externe. +- **Passkey** : donner un nom, ajouter une passkey et choisir Bitwarden dans le navigateur, ou un autre authentificateur compatible. +- **Second facteur** : sélectionner le mode souhaité et décider s’il s’applique aussi après le SSO. Le mode « au choix » nécessite l’inscription des deux facteurs. +- **Récupération** : générer les codes et les conserver à part. + +L’inscription du premier facteur active automatiquement son mode. Pour retirer un facteur exigé, sélectionner d’abord un mode compatible, ou désactiver le second facteur. Un facteur n’est jamais supprimé en désactivant implicitement la protection. + +La gestion des facteurs nécessite une connexion complète datant de moins de cinq minutes. Au-delà, se déconnecter puis se reconnecter avec les facteurs existants. Les modifications invalident les autres sessions du plugin. Les utilisateurs choisissent leurs facteurs ; aucune obligation globale de MFA n’est imposée par cette version. + +## Stockage et récupération + +Les secrets TOTP, clés publiques, identités liées, empreintes des codes de récupération et limites de tentatives sont conservés dans un fichier chiffré et authentifié avec sodium. La clé de chiffrement est séparée, dans le même répertoire privé du plugin. Les fichiers sont protégés par un préambule PHP et des permissions 0600. **Ce chiffrement ne protège pas d’un attaquant ayant accès aux fichiers et à la clé du serveur.** + +Sauvegarder ensemble `secure.key.php`, `secure.data.php` et les utilisateurs Bludit. Perdre la clé rend les facteurs irrécupérables. Ne pas réattribuer un ancien nom de compte à une autre personne sans retirer ses inscriptions de sécurité. Le client secret OIDC est stocké dans la configuration PHP habituelle du plugin : protéger également cette configuration et ses sauvegardes. + +Les connexions incomplètes expirent après cinq minutes, les challenges WebAuthn après deux minutes. Les vérifications disposent d’un budget persistant par compte et par IP : dix tentatives sur cinq minutes, avec budgets séparés pour la gestion et l’inscription. Les sessions natives et cookies « se souvenir de moi » ne suffisent pas à passer la protection du plugin. L’accès AJAX est contrôlé avant le dispatch Bludit. + +En cas de perte de tous les facteurs : utiliser un code de récupération. Sans code, un administrateur disposant de SSH peut désactiver temporairement le plugin en renommant son répertoire `bl-plugins/bludit-secure/` **hors de `bl-plugins`**. Cela rétablit l’authentification native pour tout le site : effectuer l’opération en maintenance avec l’accès `/admin` restreint, puis restaurer le plugin et réparer les inscriptions. Désinstaller le plugin depuis Bludit supprime ses données ; ne pas utiliser la désinstallation comme une simple désactivation. + +Le plugin protège les **sessions web `/admin`**. Il ne modifie pas l’authentification de l’API Bludit, des autres plugins ou des services externes. + +## Vérification + +```sh +php tests/core.php +composer validate --no-check-publish +composer audit --no-dev +``` + +Les tests HTTP dans `tests/http_totp.py` et `tests/http_webauthn.py` nécessitent une **instance jetable** de Bludit 3.22.0, installée avec `admin / TestPassword123`, le plugin activé, une URL publique de fixture `https://127.0.0.1:18765/`, et un serveur PHP local sur `http://127.0.0.1:18765/`. Le transport HTTP est réservé à la fixture ; les assertions WebAuthn simulent l’origine HTTPS. Ils ne remplacent pas les essais navigateur HTTPS. + +Variables : `TEST_PHP` (binaire PHP), `TEST_PHP_INI` (php.ini), `TEST_BLUDIT` (racine de la fixture). `TEST_BASE` est facultatif. Exécuter d’abord `http_totp.py` sur une fixture neuve, puis `http_webauthn.py`. **Le test WebAuthn efface les données de sécurité du plugin dans cette fixture**, jamais lui fournir un site réel. + +Vérifié : syntaxe PHP 8.4, vecteurs RFC 6238, stockage chiffré et altération, limites persistantes, transactions, CSRF, connexion locale, blocage dashboard/AJAX avant MFA, refus du rejeu TOTP/récupération, inscription WebAuthn et signatures d’assertion, rejet d’une origine ou signature invalide et d’un challenge consommé. `composer audit` ne signale aucun avis au moment de la construction. + +**À valider avant production** : connexion complète au Pocket ID réel, passkeys Bitwarden dans le navigateur avec HTTPS réel, reverse proxy, restauration de sauvegarde et compatibilité avec les autres plugins d’authentification. Aucune validation sur le site de production n’a été réalisée. diff --git a/assets/secure.css b/assets/secure.css new file mode 100644 index 0000000..db6104a --- /dev/null +++ b/assets/secure.css @@ -0,0 +1 @@ +:root{color-scheme:light dark;font:16px/1.5 system-ui,sans-serif;background:#111827;color:#f3f4f6}body{margin:0;padding:2rem 1rem}main{max-width:640px;margin:auto;background:#1f2937;border:1px solid #475569;border-radius:16px;padding:2rem}h1{font-size:1.7rem}h2{font-size:1.2rem;margin-top:2rem;border-top:1px solid #475569;padding-top:1rem}label{display:block;margin:.8rem 0}input:not([type=checkbox]),select{box-sizing:border-box;display:block;width:100%;padding:.65rem;background:#111827;color:#fff;border:1px solid #64748b;border-radius:6px}button{cursor:pointer;background:#93c5fd;color:#111827;border:0;border-radius:6px;padding:.65rem 1rem;margin:.4rem 0}button:disabled{opacity:.5}a{color:#93c5fd}code,pre{overflow-wrap:anywhere;white-space:pre-wrap}.secret{display:block;padding:1rem;border:1px dashed #94a3b8}*:focus-visible{outline:3px solid #fbbf24;outline-offset:3px}#status,[role=alert]{color:#fcd34d} diff --git a/assets/secure.js b/assets/secure.js new file mode 100644 index 0000000..6be1112 --- /dev/null +++ b/assets/secure.js @@ -0,0 +1,43 @@ +'use strict'; +(() => { + const decode = value => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); + const encode = value => btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const status = document.getElementById('status'); + for (const button of document.querySelectorAll('[data-key]')) { + button.addEventListener('click', async () => { + const box = button.closest('.webauthn'); + const create = button.dataset.key === 'create'; + const kind = create ? 'create' : 'get'; + const request = async (phase, fields = {}) => { + const url = new URL(box.dataset.base); + url.searchParams.set('bs', `key-${kind}-${phase}`); + const response = await fetch(url, {method: 'POST', credentials: 'same-origin', body: new URLSearchParams({secureCsrf: box.dataset.csrf, ...fields})}); + const result = await response.json(); + if (!response.ok || result.error) throw new Error(result.error || 'Vérification impossible.'); + return result; + }; + button.disabled = true; + try { + if (!window.PublicKeyCredential || !navigator.credentials) throw new Error('Ce navigateur ne prend pas en charge les passkeys.'); + const args = await request('options'); + args.publicKey.challenge = decode(args.publicKey.challenge); + if (args.publicKey.user) args.publicKey.user.id = decode(args.publicKey.user.id); + for (const field of ['allowCredentials', 'excludeCredentials']) { + for (const cred of args.publicKey[field] || []) cred.id = decode(cred.id); + } + const credential = await navigator.credentials[kind](args); + const fields = {id: encode(credential.rawId), clientDataJSON: encode(credential.response.clientDataJSON)}; + if (create) { + fields.attestationObject = encode(credential.response.attestationObject); + fields.label = box.querySelector('.key-label').value; + } else { + fields.authenticatorData = encode(credential.response.authenticatorData); + fields.signature = encode(credential.response.signature); + } + const result = await request('finish', fields); + window.location.assign(result.redirect); + } catch (error) { status.textContent = error.name === 'NotAllowedError' ? 'Vérification annulée ou délai dépassé.' : error.message; } + finally { button.disabled = false; } + }); + } +})(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2cd77f7 --- /dev/null +++ b/composer.json @@ -0,0 +1,12 @@ +{ + "name": "interlope/bludit-secure", + "description": "Per-user OIDC, TOTP and WebAuthn authentication for Bludit 3.22", + "type": "bludit-plugin", + "license": "MIT", + "require": { + "php": ">=8.2", "ext-sodium": "*", "ext-curl": "*", "ext-mbstring": "*", + "jumbojett/openid-connect-php": "^1.0", + "lbuchs/webauthn": "^2.2" + }, + "config": {"sort-packages": true, "allow-plugins": false} +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..f01b6b9 --- /dev/null +++ b/composer.lock @@ -0,0 +1,345 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d70473d880680a8159c2289ab0be5431", + "packages": [ + { + "name": "jumbojett/openid-connect-php", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/jumbojett/OpenID-Connect-PHP.git", + "reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jumbojett/OpenID-Connect-PHP/zipball/f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51", + "reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=7.0", + "phpseclib/phpseclib": "^3.0.7" + }, + "require-dev": { + "phpunit/phpunit": "<10", + "roave/security-advisories": "dev-latest", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Bare-bones OpenID Connect client", + "support": { + "issues": "https://github.com/jumbojett/OpenID-Connect-PHP/issues", + "source": "https://github.com/jumbojett/OpenID-Connect-PHP/tree/v1.0.2" + }, + "time": "2024-09-13T07:08:11+00:00", + "published-time": "2024-09-13T07:09:53+00:00" + }, + { + "name": "lbuchs/webauthn", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/lbuchs/WebAuthn.git", + "reference": "20adb4a240c3997bd8cac7dc4dde38ab0bea0ed1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lbuchs/WebAuthn/zipball/20adb4a240c3997bd8cac7dc4dde38ab0bea0ed1", + "reference": "20adb4a240c3997bd8cac7dc4dde38ab0bea0ed1", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "lbuchs\\WebAuthn\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukas Buchs", + "role": "Developer" + } + ], + "description": "A simple PHP WebAuthn (FIDO2) server library", + "homepage": "https://github.com/lbuchs/webauthn", + "keywords": [ + "Authentication", + "webauthn" + ], + "support": { + "issues": "https://github.com/lbuchs/WebAuthn/issues", + "source": "https://github.com/lbuchs/WebAuthn/tree/v2.2.0" + }, + "time": "2024-07-04T07:17:40+00:00", + "published-time": "2024-07-04T11:09:04+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00", + "published-time": "2025-09-24T15:07:11+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00", + "published-time": "2020-10-15T08:34:16+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.58", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "91f3a6436bb84040ac0cf49e761977f17d16a39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/91f3a6436bb84040ac0cf49e761977f17d16a39b", + "reference": "91f3a6436bb84040ac0cf49e761977f17d16a39b", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.58" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-11-02T10:45:48+00:00", + "published-time": "2026-11-02T10:50:21+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-sodium": "*", + "ext-curl": "*", + "ext-mbstring": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.11.0" +} diff --git a/languages/en.json b/languages/en.json new file mode 100644 index 0000000..499751b --- /dev/null +++ b/languages/en.json @@ -0,0 +1 @@ +{"plugin-data":{"name":"Bludit Secure","description":"OIDC SSO, passkeys and TOTP with per-account preferences."}} diff --git a/languages/fr_FR.json b/languages/fr_FR.json new file mode 100644 index 0000000..5b31c50 --- /dev/null +++ b/languages/fr_FR.json @@ -0,0 +1 @@ +{"plugin-data":{"name":"Bludit Secure","description":"SSO OIDC, passkeys et TOTP configurables par compte."}} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..5c66695 --- /dev/null +++ b/metadata.json @@ -0,0 +1 @@ +{"author":"Interlope","version":"0.1.0","releaseDate":"2026-11-02","license":"MIT","compatible":"3.22.0","notes":"PHP 8.2+, HTTPS, sodium, curl, mbstring. Preview: validate on staging before production."} diff --git a/plugin.php b/plugin.php new file mode 100644 index 0000000..b0f57e0 --- /dev/null +++ b/plugin.php @@ -0,0 +1,41 @@ +dbFields = ['ssoLabel'=>'Pocket ID', 'issuer'=>'', 'clientId'=>'', 'clientSecret'=>'']; + } + public function form() { + $html = '

Chaque utilisateur gère ses facteurs dans Sécurité de mon compte.

'; + foreach (['ssoLabel'=>'Nom du SSO (Se connecter avec …)', 'issuer'=>'URL HTTPS de Pocket ID / issuer OIDC', 'clientId'=>'Client ID', 'clientSecret'=>'Client secret'] as $key=>$label) { + $html .= '
'; + } + return $html.'

Secret vide : conserver le secret existant. URI de retour OIDC : '.htmlspecialchars(DOMAIN_ADMIN.'login?bs=oidc').'. Aucun compte n’est associé automatiquement par e-mail : chaque utilisateur lie son identité depuis son espace personnel.

'; + } + public function post() { + if (empty($_POST['clientSecret'])) { unset($_POST['clientSecret']); } + $issuer = trim($_POST['issuer'] ?? ''); + if ($issuer !== '' && (parse_url($issuer, PHP_URL_SCHEME) !== 'https' || parse_url($issuer, PHP_URL_USER) || parse_url($issuer, PHP_URL_QUERY) || parse_url($issuer, PHP_URL_FRAGMENT))) { return false; } + return parent::post(); + } + /** Runs while plugins are built, BEFORE Bludit's AJAX dispatch and native login. */ + public function prepare() { + global $url; + if ($url->whereAmI() !== 'admin') { return; } + try { + require_once __DIR__.'/vendor/autoload.php'; + require_once __DIR__.'/src/Store.php'; + require_once __DIR__.'/src/Totp.php'; + require_once __DIR__.'/src/App.php'; + (new \BluditSecure\App($this))->run(); + } catch (\Throwable $e) { + error_log('bludit-secure: '.get_class($e).' '.$e->getMessage()); + http_response_code(503); + header('Cache-Control: no-store'); + exit('Bludit Secure indisponible. Consulter les journaux du serveur.'); + } + } + public function adminSidebar() { + return 'Sécurité de mon compte'; + } +} diff --git a/src/App.php b/src/App.php new file mode 100644 index 0000000..669b2b0 --- /dev/null +++ b/src/App.php @@ -0,0 +1,343 @@ +plugin = $plugin; + $this->store = new Store($plugin->phpPathDB()); + $this->base = DOMAIN_ADMIN.'login'; + $parts = parse_url(DOMAIN_ADMIN); + if (($parts['scheme'] ?? '') !== 'https') { throw new UserError('HTTPS is required'); } + $this->origin = 'https://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : ''); + } + private function cfg(string $key): string { return trim($this->plugin->getValue($key, false)); } + private function ssoEnabled(): bool { return $this->cfg('issuer') !== '' && $this->cfg('clientId') !== '' && $this->cfg('clientSecret') !== ''; } + private function e(string $v): string { return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); } + private function go(string $action = ''): never { header('Location: '.$this->base.($action ? '?bs='.$action : ''), true, 303); exit; } + private function form(string $action, string $body): string { + return '
'.$body.'
'; + } + private function page(string $title, string $body): never { + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store'); header('Referrer-Policy: no-referrer'); header('X-Frame-Options: DENY'); + header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"); + $asset = $this->plugin->htmlPath().'assets/'; + echo ''.$this->e($title).'

'.$this->e($title).'

'; + if (isset($_SESSION['bs']['notice'])) { echo '

'.$this->e($_SESSION['bs']['notice']).'

'; unset($_SESSION['bs']['notice']); } + echo $body.'

'; exit; + } + private function json(mixed $data, int $status = 200): never { + http_response_code($status); header('Content-Type: application/json'); header('Cache-Control: no-store'); echo json_encode($data, JSON_THROW_ON_ERROR); exit; + } + private function csrf(): void { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !hash_equals($_SESSION['bs']['csrf'], (string)($_POST['secureCsrf'] ?? ''))) { throw new UserError('Invalid CSRF'); } + } + private function user(string $name): array { + global $users; + $u = $users->getUserDB($name); + if (!$u || ($u['password'] ?? '!') === '!') { throw new UserError('Account unavailable'); } + return $u; + } + private function allowed(array $a, string $factor): bool { + return $a['mode'] === 'either' || $a['mode'] === $factor; + } + private function requiresMfa(array $a, string $via): bool { return $a['mode'] !== 'off' && ($via !== 'sso' || $a['ssoMfa']); } + private function pending(string $name, string $via): void { + $this->user($name); + session_regenerate_id(true); + unset($_SESSION['bs']['auth'], $_SESSION['bs']['challenge']); + $a = $this->store->get($name); + $_SESSION['bs']['pending'] = ['name'=>$name, 'via'=>$via, 'expires'=>time()+300, 'revision'=>$a['revision']]; + if (!$this->requiresMfa($a, $via)) { $this->finish($name); } + $this->go('mfa'); + } + private function finish(string $name): never { + global $login, $security; + $u = $this->user($name); $a = $this->store->get($name); + $login->setLogin($name, $u['role']); + $security->generateTokenCSRF(); + $_SESSION['bs']['auth'] = ['name'=>$name, 'at'=>time(), 'revision'=>$a['revision']]; + unset($_SESSION['bs']['pending'], $_SESSION['bs']['challenge']); + $this->go('account'); + } + private function authenticated(): ?string { + global $login; + $auth = $_SESSION['bs']['auth'] ?? null; + if (!$auth || !$login->isLogged() || $login->username() !== $auth['name']) { return null; } + $a = $this->store->get($auth['name']); + return $auth['revision'] === $a['revision'] ? $auth['name'] : null; + } + private function recent(): string { + $name = $this->authenticated(); + if (!$name || $_SESSION['bs']['auth']['at'] < time()-300) { throw new UserError('Reconnectez-vous avant de modifier la sécurité de votre compte.'); } + return $name; + } + private function pendingUser(): array { + $p = $_SESSION['bs']['pending'] ?? null; + if (!$p || $p['expires'] < time()) { unset($_SESSION['bs']['pending']); throw new UserError('Connexion expirée. Recommencez.'); } + $this->user($p['name']); + if ($this->store->get($p['name'])['revision'] !== $p['revision']) { unset($_SESSION['bs']['pending']); throw new UserError('Les facteurs ont changé. Recommencez la connexion.'); } + return $p; + } + private function budget(string $name, string $scope = 'auth'): void { + global $security; + if ($security->isBlocked() || !$this->store->attempt($scope.':'.$name, $scope.':'.($_SERVER['REMOTE_ADDR'] ?? 'unknown'))) { throw new UserError('Trop de tentatives. Réessayez dans cinq minutes.'); } + } + public function run(): void { + global $login; + $_SESSION['bs'] ??= []; + $_SESSION['bs']['csrf'] ??= bin2hex(random_bytes(32)); + $action = (string)($_GET['bs'] ?? ''); + $name = $this->authenticated(); + // Native sessions, remember cookies and direct AJAX cannot bypass this marker. + if (!$name) { + foreach (['username','role','fingerPrint','sessionTime'] as $k) { \Session::set($k, null); } + } + if ($action === '' && $name) { return; } + try { + if ($action === 'oidc') { $this->oidc(); } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->csrf(); + if ($action === 'password') { $this->password(); } + if ($action === 'logout') { unset($_SESSION['bs']); $login->logout(); $this->go(); } + if ($action === 'sso' || $action === 'link') { $this->startOidc($action === 'link'); } + if ($action === 'mfa') { $this->verifyCode(); } + if (str_starts_with($action, 'key-')) { $this->webauthn($action); } + if ($action === 'manage') { $this->manage(); } + } + if ($action === 'mfa' || (!$name && isset($_SESSION['bs']['pending']))) { $this->mfaPage(); } + if ($name) { $this->accountPage($name); } + $this->loginPage(); + } catch (\Throwable $e) { + error_log('bludit-secure: '.get_class($e).' '.$e->getMessage()); + $msg = $e instanceof UserError ? $e->getMessage() : 'Vérification impossible. Recommencez ou consultez les journaux.'; + // Never expose protocol payloads, tokens or secrets to the browser. + if (str_starts_with($action, 'key-')) { $this->json(['error'=>'Vérification impossible. Reconnectez-vous ou réessayez.'], 400); } + $_SESSION['bs']['notice'] = mb_substr($msg, 0, 200); + $this->go($this->authenticated() ? 'account' : (isset($_SESSION['bs']['pending']) ? 'mfa' : '')); + } + } + private function password(): void { + global $users; + $name = trim((string)($_POST['username'] ?? '')); $pw = (string)($_POST['password'] ?? ''); + $this->budget($name); + $u = $users->getUserDB($name); + if (!$u || $u['password'] === '!' || !hash_equals($u['password'], $users->generatePasswordHash($pw, $u['salt']))) { throw new UserError('Identifiants incorrects.'); } + $this->pending($name, 'password'); + } + private function loginPage(): never { + $body = $this->form('password', ''); + if ($this->ssoEnabled()) { $body .= $this->form('sso', ''); } + $this->page('Connexion à Bludit', $body); + } + private function mfaPage(): never { + $p = $this->pendingUser(); $a = $this->store->get($p['name']); + $body = '

Validez le second facteur de votre compte.

'; + if ($a['totp'] && $this->allowed($a, 'totp')) { $body .= $this->form('mfa', ''); } + if ($a['keys'] && $this->allowed($a, 'passkey')) { $body .= $this->keyButton(false); } + if ($a['recovery']) { $body .= $this->form('mfa', ''); } + $body .= $this->form('logout', ''); + $this->page('Vérification en deux étapes', $body); + } + private function verifyCode(): never { + $p = $this->pendingUser(); $this->budget($p['name']); + $ok = $this->store->update($p['name'], function (&$a) { + $recovery = trim((string)($_POST['recovery'] ?? '')); + if ($recovery !== '') { + foreach ($a['recovery'] as $i=>$hash) { if (hash_equals($hash, hash('sha256', $recovery))) { unset($a['recovery'][$i]); $a['recovery'] = array_values($a['recovery']); return true; } } + return false; + } + if (!$a['totp'] || !$this->allowed($a, 'totp')) { return false; } + $step = Totp::verify($a['totp'], trim((string)($_POST['code'] ?? '')), $a['lastStep']); + if ($step === null) { return false; } $a['lastStep'] = $step; return true; + }); + if (!$ok) { throw new UserError('Code invalide, expiré ou déjà utilisé.'); } + $this->finish($p['name']); + } + private function accountPage(string $name): never { + $a = $this->store->get($name); + $body = '

Compte : '.$this->e($name).'. Retour à l’administration

'; + if ($_SESSION['bs']['auth']['at'] < time()-300) { + $this->page('Sécurité de mon compte', $body.'

Une nouvelle connexion est nécessaire pour modifier vos facteurs.

'.$this->form('logout', '')); + } + $body .= '

Authentification à deux facteurs

Choisissez le facteur demandé après votre mot de passe. « Au choix » permet de présenter un TOTP ou une passkey.

'; + $select = ''; + $body .= $this->form('manage', $select); + $body .= '

TOTP

'.($a['totp'] ? 'TOTP activé.' : 'Aucun TOTP enregistré.').'

'; + if (!$a['totp']) { + $body .= $this->form('manage', ''); + $en = $_SESSION['bs']['enroll'] ?? null; + if ($en && $en['expires'] >= time() && $en['name'] === $name) { + $uri = 'otpauth://totp/'.rawurlencode('Bludit:'.$name).'?secret='.$en['secret'].'&issuer=Bludit&algorithm=SHA1&digits=6&period=30'; + $body .= '

Dans Bitwarden, collez cette URI dans le champ « Clé d’authentification (TOTP) » :

'.$this->e($uri).''; + $body .= $this->form('manage', ''); + } + } else { $body .= $this->form('manage', ''); } + $body .= '

Passkeys

Enregistrez une passkey pour ce site dans Bitwarden ou votre authentificateur.

'.$this->keyButton(true); + foreach ($a['keys'] as $id=>$k) { $body .= $this->form('manage', ''.$this->e($k['label']).' '); } + $body .= '

SSO

'; + if ($a['sso']) { $body .= '

Identité SSO liée.

'.$this->form('manage', ''); } + elseif ($this->ssoEnabled()) { $body .= $this->form('link', ''); } + else { $body .= '

Le SSO doit être configuré par un administrateur.

'; } + $body .= '

Récupération

'.count($a['recovery']).' code(s) restant(s). En générer de nouveaux invalide les précédents. Conservez-les hors de votre compte.

'.$this->form('manage', ''); + if (isset($_SESSION['bs']['codes'])) { + $body .= '

Ces codes ne seront affichés qu’une fois :

'.$this->e(implode("\n", $_SESSION['bs']['codes'])).'
'; unset($_SESSION['bs']['codes']); + } + $body .= $this->form('logout', ''); + $this->page('Sécurité de mon compte', $body); + } + private function manage(): never { + $name = $this->recent(); $this->budget($name, 'manage'); $op = (string)($_POST['op'] ?? ''); + if ($op === 'totp-start') { + $_SESSION['bs']['enroll'] = ['name'=>$name, 'secret'=>Totp::secret(), 'expires'=>time()+300]; $this->go('account'); + } + $codes = []; + if ($op === 'recovery') { for ($i=0; $i<10; ++$i) { $codes[] = bin2hex(random_bytes(10)); } } + $revision = $this->store->update($name, function (&$a) use ($op, $name, $codes) { + switch ($op) { + case 'totp-confirm': + $en = $_SESSION['bs']['enroll'] ?? null; + if (!$en || $en['name'] !== $name || $en['expires'] < time() || $a['totp']) { throw new UserError('Enregistrement expiré ou TOTP déjà actif.'); } + $step = Totp::verify($en['secret'], trim($_POST['code'] ?? ''), -1); + if ($step === null) { throw new UserError('Code de confirmation incorrect.'); } + $a['totp'] = $en['secret']; $a['lastStep'] = $step; + if ($a['mode'] === 'off') { $a['mode'] = 'totp'; } + unset($_SESSION['bs']['enroll']); break; + case 'totp-delete': $a['totp'] = null; $a['lastStep'] = -1; break; + case 'key-delete': unset($a['keys'][(string)($_POST['key'] ?? '')]); break; + case 'unlink': $a['sso'] = null; break; + case 'recovery': $a['recovery'] = array_map(fn($c)=>hash('sha256',$c), $codes); break; + case 'preferences': + $mode = $_POST['mode'] ?? ''; + if (!in_array($mode, ['off','totp','passkey','either'], true)) { throw new UserError('Choix invalide.'); } + if (($mode === 'totp' && !$a['totp']) || ($mode === 'passkey' && !$a['keys']) || ($mode === 'either' && (!$a['totp'] || !$a['keys']))) { throw new UserError('Enregistrez d’abord les facteurs choisis.'); } + $a['mode'] = $mode; $a['ssoMfa'] = isset($_POST['ssoMfa']); break; + default: throw new UserError('Action inconnue.'); + } + // Deleting a required factor must not silently disable MFA. + if (($a['mode'] === 'totp' && !$a['totp']) || ($a['mode'] === 'passkey' && !$a['keys']) || ($a['mode'] === 'either' && (!$a['totp'] || !$a['keys']))) { throw new UserError('Changez le mode de vérification avant de supprimer ce facteur.'); } + return ++$a['revision']; + }); + $_SESSION['bs']['auth']['revision'] = $revision; + if ($codes) { $_SESSION['bs']['codes'] = $codes; } + $_SESSION['bs']['notice'] = 'Modification enregistrée.'; $this->go('account'); + } + private function oidcClient(): \Jumbojett\OpenIDConnectClient { + if (!$this->ssoEnabled() || parse_url($this->cfg('issuer'), PHP_URL_SCHEME) !== 'https') { throw new UserError('SSO non configuré.'); } + $oidc = new \Jumbojett\OpenIDConnectClient($this->cfg('issuer'), $this->cfg('clientId'), $this->cfg('clientSecret')); + $oidc->setRedirectURL($this->base.'?bs=oidc'); + $oidc->setCodeChallengeMethod('S256'); + $oidc->setAllowImplicitFlow(false); + $oidc->setTimeout(15); + return $oidc; + } + private function startOidc(bool $link): never { + $name = $link ? $this->recent() : null; + $_SESSION['bs']['oidc'] = ['link'=>$name, 'expires'=>time()+300]; + unset($_SESSION['bs']['pending']); + $this->oidcClient()->authenticate(); + throw new UserError('Redirection SSO impossible.'); + } + private function oidc(): never { + $flow = $_SESSION['bs']['oidc'] ?? null; + if (!$flow || $flow['expires'] < time() || !isset($_GET['code'], $_GET['state'])) { throw new UserError('Connexion SSO expirée ou annulée.'); } + // The library verifies state, nonce, JWT signature, issuer, audience and expiry. + $oidc = $this->oidcClient(); + $oidc->authenticate(); + unset($_SESSION['bs']['oidc']); + $sub = $oidc->getVerifiedClaims('sub'); + $iss = $oidc->getVerifiedClaims('iss'); + if (!is_string($sub) || $sub === '' || !is_string($iss) || $iss !== $this->cfg('issuer')) { throw new UserError('Identité SSO invalide.'); } + $identity = ['issuer'=>$iss, 'subject'=>$sub]; + if ($flow['link'] !== null) { + $name = $this->recent(); + if ($name !== $flow['link']) { throw new UserError('Session de liaison invalide.'); } + $rev = $this->store->transaction(function (&$d) use ($name, $identity) { + foreach ($d['accounts'] as $other=>$a) { if ($other !== $name && $a['sso'] === $identity) { throw new UserError('Identité déjà liée à un autre compte.'); } } + $a = Store::account($d, $name); $a['sso'] = $identity; ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision']; + }); + $_SESSION['bs']['auth']['revision'] = $rev; + $_SESSION['bs']['notice'] = 'Identité SSO liée.'; $this->go('account'); + } + $name = $this->store->transaction(function (&$d) use ($identity) { + foreach ($d['accounts'] as $name=>$a) { if ($a['sso'] === $identity) { return $name; } } return null; + }); + if ($name === null) { throw new UserError('Ce SSO n’est lié à aucun compte Bludit. Connectez-vous avec votre mot de passe pour le lier.'); } + $this->budget($name); $this->pending($name, 'sso'); + exit; + } + private function keyButton(bool $create): string { + return '
'.($create?'':'').'
'; + } + private static function b64(string $raw): string { return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } + private static function unb64(string $raw): string { + if (!preg_match('/^[A-Za-z0-9_-]*$/D', $raw) || strlen($raw) > 100000) { throw new UserError('Invalid binary data'); } + $result = base64_decode(strtr($raw, '-_', '+/'), true); + if ($result === false) { throw new UserError('Invalid binary data'); } return $result; + } + private function checkOrigin(string $client): void { + $data = json_decode($client, true, 16, JSON_THROW_ON_ERROR); + if (($data['origin'] ?? null) !== $this->origin || !empty($data['crossOrigin'])) { throw new UserError('Invalid WebAuthn origin'); } + } + private function webauthn(string $action): never { + $create = in_array($action, ['key-create-options','key-create-finish'], true); + if (!in_array($action, ['key-create-options','key-create-finish','key-get-options','key-get-finish'], true)) { throw new UserError('Action inconnue'); } + $name = $create ? $this->recent() : $this->pendingUser()['name']; + $this->budget($name, $create ? 'enroll' : 'auth'); $a = $this->store->get($name); + if (!$create && (!$this->allowed($a, 'passkey') || !$a['keys'])) { throw new UserError('Passkey unavailable'); } + $wa = new \lbuchs\WebAuthn\WebAuthn('Bludit', parse_url($this->origin, PHP_URL_HOST), ['none'], true); + if (str_ends_with($action, '-options')) { + $ids = array_map(fn($id)=>self::unb64($id), array_keys($a['keys'])); + if ($create) { + if (count($ids) >= 20) { throw new UserError('Maximum 20 passkeys'); } + $args = $wa->getCreateArgs(hash('sha256', 'bludit:'.$name, true), $name, $name, 60, true, true, null, $ids); + } else { $args = $wa->getGetArgs($ids, 60, true, true, true, true, true, true); } + $_SESSION['bs']['challenge'] = ['value'=>self::b64($wa->getChallenge()->getBinaryString()), 'name'=>$name, 'create'=>$create, 'expires'=>time()+120, 'revision'=>$a['revision']]; + $this->json($args); + } + $ch = $_SESSION['bs']['challenge'] ?? null; unset($_SESSION['bs']['challenge']); + if (!$ch || $ch['name'] !== $name || $ch['create'] !== $create || $ch['expires'] < time() || $ch['revision'] !== $a['revision']) { throw new UserError('Challenge expired'); } + $client = self::unb64((string)($_POST['clientDataJSON'] ?? '')); $this->checkOrigin($client); + $challenge = self::unb64($ch['value']); + if ($create) { + $result = $wa->processCreate($client, self::unb64((string)($_POST['attestationObject'] ?? '')), $challenge, true, true, false); + $id = self::b64($result->credentialId); + $revision = $this->store->transaction(function (&$d) use ($name, $id, $result, $ch) { + foreach ($d['accounts'] as $a) { if (isset($a['keys'][$id])) { throw new UserError('Credential already registered'); } } + $a = Store::account($d, $name); + if ($a['revision'] !== $ch['revision']) { throw new UserError('Account changed'); } + $a['keys'][$id] = ['publicKey'=>$result->credentialPublicKey, 'counter'=>$result->signatureCounter ?? 0, 'label'=>mb_substr(trim((string)($_POST['label'] ?? 'Passkey')), 0, 80) ?: 'Passkey']; + if ($a['mode'] === 'off') { $a['mode'] = 'passkey'; } + ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision']; + }); + $_SESSION['bs']['auth']['revision'] = $revision; + $this->json(['redirect'=>$this->base.'?bs=account']); + } + $id = (string)($_POST['id'] ?? ''); + $this->store->update($name, function (&$a) use ($id, $ch, $wa, $client, $challenge) { + if ($a['revision'] !== $ch['revision'] || !isset($a['keys'][$id])) { throw new UserError('Unknown credential'); } + $key = $a['keys'][$id]; + $wa->processGet($client, self::unb64((string)($_POST['authenticatorData'] ?? '')), self::unb64((string)($_POST['signature'] ?? '')), $key['publicKey'], $challenge, $key['counter'], true, true); + $a['keys'][$id]['counter'] = $wa->getSignatureCounter() ?? $key['counter']; + }); + // Finish through an ordinary navigation so the browser receives regenerated session cookies. + $this->finishJson($name); + } + private function finishJson(string $name): never { + global $login, $security; + $u = $this->user($name); $a = $this->store->get($name); + $login->setLogin($name, $u['role']); $security->generateTokenCSRF(); + $_SESSION['bs']['auth'] = ['name'=>$name, 'at'=>time(), 'revision'=>$a['revision']]; + unset($_SESSION['bs']['pending'], $_SESSION['bs']['challenge']); + $this->json(['redirect'=>$this->base.'?bs=account']); + } +} diff --git a/src/Store.php b/src/Store.php new file mode 100644 index 0000000..e72699d --- /dev/null +++ b/src/Store.php @@ -0,0 +1,75 @@ +dir = $dir; + if (!is_dir($dir) && !mkdir($dir, 0700, true)) { throw new \RuntimeException('Storage unavailable'); } + } + public function transaction(callable $fn): mixed { + $lock = fopen($this->dir.'/secure.lock.php', 'c+'); + if (!$lock || !flock($lock, LOCK_EX)) { throw new \RuntimeException('Storage locked'); } + try { + chmod($this->dir.'/secure.lock.php', 0600); + $kp = $this->dir.'/secure.key.php'; + $prefix = "\n"; + if (!file_exists($kp)) { + if (file_exists($this->dir.'/secure.data.php')) { throw new \RuntimeException('Missing encryption key'); } + $this->write($kp, $prefix.base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES))); + } + $key = base64_decode(substr(file_get_contents($kp), strlen($prefix)), true); + if ($key === false || strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw new \RuntimeException('Invalid encryption key'); } + $path = $this->dir.'/secure.data.php'; + $data = ['accounts'=>[], 'limits'=>[]]; + if (file_exists($path)) { + $raw = base64_decode(substr(file_get_contents($path), strlen($prefix)), true); + if ($raw === false || strlen($raw) < 40) { throw new \RuntimeException('Invalid storage'); } + $plain = sodium_crypto_secretbox_open(substr($raw, 24), substr($raw, 0, 24), $key); + if ($plain === false) { throw new \RuntimeException('Storage authentication failed'); } + $data = json_decode($plain, true, 64, JSON_THROW_ON_ERROR); + } + $before = $data; + $result = $fn($data); + if ($data !== $before) { + $nonce = random_bytes(24); + $this->write($path, $prefix.base64_encode($nonce.sodium_crypto_secretbox(json_encode($data, JSON_THROW_ON_ERROR), $nonce, $key))); + } + sodium_memzero($key); + return $result; + } finally { flock($lock, LOCK_UN); fclose($lock); } + } + private function write(string $path, string $data): void { + $tmp = tempnam($this->dir, '.secure-'); + if ($tmp === false) { throw new \RuntimeException('Storage unavailable'); } + try { + chmod($tmp, 0600); + if (file_put_contents($tmp, $data) !== strlen($data) || !rename($tmp, $path)) { throw new \RuntimeException('Storage write failed'); } + } finally { if (file_exists($tmp)) { unlink($tmp); } } + } + public static function account(array $data, string $name): array { + return $data['accounts'][$name] ?? ['totp'=>null, 'lastStep'=>-1, 'keys'=>[], 'recovery'=>[], 'mode'=>'off', 'sso'=>null, 'ssoMfa'=>true, 'revision'=>0]; + } + public function get(string $name): array { return $this->transaction(fn(&$d) => self::account($d, $name)); } + public function update(string $name, callable $fn): mixed { + return $this->transaction(function (&$d) use ($name, $fn) { + $a = self::account($d, $name); $result = $fn($a); $d['accounts'][$name] = $a; return $result; + }); + } + /** Persistent per-account + per-IP budget, independent of sessions. */ + public function attempt(string $name, string $ip): bool { + return $this->transaction(function (&$d) use ($name, $ip) { + $now = time(); + foreach ($d['limits'] as $k=>$v) { if ($v['until'] <= $now) { unset($d['limits'][$k]); } } + $ids = [hash('sha256', 'account:'.$name), hash('sha256', 'ip:'.$ip)]; + foreach ($ids as $id) { if (($d['limits'][$id]['count'] ?? 0) >= 10) { return false; } } + foreach ($ids as $id) { + $d['limits'][$id] ??= ['count'=>0, 'until'=>$now+300]; + ++$d['limits'][$id]['count']; + } + return true; + }); + } +} diff --git a/src/Totp.php b/src/Totp.php new file mode 100644 index 0000000..8e03218 --- /dev/null +++ b/src/Totp.php @@ -0,0 +1,30 @@ + $lastStep && hash_equals(self::code($secret, $n), $code)) { return $n; } + } + return null; + } +} diff --git a/tests/core.php b/tests/core.php new file mode 100644 index 0000000..e5f5b1f --- /dev/null +++ b/tests/core.php @@ -0,0 +1,29 @@ +'287082',1111111109=>'081804',1111111111=>'050471',1234567890=>'005924',2000000000=>'279037',20000000000=>'353130'] as $time=>$expected) { check(Totp::code($secret, intdiv($time,30)) === $expected, 'RFC vector '.$time); } +check(Totp::verify($secret, '287082', -1, 59) === 1, 'valid TOTP'); +check(Totp::verify($secret, '287082', 1, 59) === null, 'reject replay'); +check(Totp::verify($secret, '287082', -1, 180) === null, 'reject expired TOTP'); +check(Totp::verify($secret, '287082x', -1, 59) === null, 'reject malformed TOTP'); +$dir = sys_get_temp_dir().'/bludit-secure-test-'.bin2hex(random_bytes(8)); +$s = new Store($dir); +try { + $s->update('admin', function (&$a) use ($secret) { $a['totp'] = $secret; ++$a['revision']; }); + check($s->get('admin')['totp'] === $secret, 'encrypted round trip'); + check(!str_contains(file_get_contents($dir.'/secure.data.php'), $secret), 'no plaintext secret'); + check((fileperms($dir.'/secure.key.php') & 0777) === 0600, 'private encryption key'); + try { $s->update('admin', function (&$a) { $a['totp'] = 'bad'; throw new RuntimeException('rollback'); }); } catch (RuntimeException $e) {} + check($s->get('admin')['totp'] === $secret, 'failed transaction rolls back'); + for ($i=0;$i<10;$i++) { check($s->attempt('admin', '127.0.0.1'), 'attempt '.$i); } + check(!$s->attempt('admin', '127.0.0.2'), 'per-account limit across IPs'); + check(!$s->attempt('other', '127.0.0.1'), 'per-IP limit across accounts'); + $path = $dir.'/secure.data.php'; $raw = file_get_contents($path); $raw[40] = $raw[40] === 'A' ? 'B' : 'A'; file_put_contents($path, $raw); + $failed = false; try { $s->get('admin'); } catch (Throwable $e) { $failed = true; } + check($failed, 'tampered ciphertext fails closed'); +} finally { foreach (glob($dir.'/*') as $file) { unlink($file); } rmdir($dir); } diff --git a/tests/http_helpers.py b/tests/http_helpers.py new file mode 100644 index 0000000..ca9d195 --- /dev/null +++ b/tests/http_helpers.py @@ -0,0 +1,22 @@ +import os,urllib.request,urllib.parse,urllib.error,http.cookies,re,json,subprocess +BASE=os.environ.get('TEST_BASE', 'http://127.0.0.1:18765') +PHP=os.environ['TEST_PHP'] +INI=os.environ['TEST_PHP_INI'] +PLUGIN=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIXTURE=os.environ['TEST_BLUDIT'] +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args): return None +opener=urllib.request.build_opener(NoRedirect) +cookies={} +def req(path='/admin/',data=None): + headers={'User-Agent':'BluditSecureTest','Cookie':'; '.join(k+'='+v for k,v in cookies.items())} + q=urllib.request.Request(BASE+path,data=urllib.parse.urlencode(data).encode() if data is not None else None,headers=headers) + try:r=opener.open(q) + except urllib.error.HTTPError as e:r=e + for val in r.headers.get_all('Set-Cookie',[]): + c=http.cookies.SimpleCookie();c.load(val) + for k,v in c.items():cookies[k]=v.value + return r.status,r.read().decode(),r.headers + +def csrf(body):return re.search(r'name="secureCsrf" value="([^"]+)"',body)[1] +def check(v,label):assert v,label;print('OK',label) diff --git a/tests/http_totp.py b/tests/http_totp.py new file mode 100644 index 0000000..ec6ac88 --- /dev/null +++ b/tests/http_totp.py @@ -0,0 +1,26 @@ +from http_helpers import * +s,b,h=req();check('Connexion à Bludit' in b,'plugin intercepts native login');token=csrf(b) +s,b,h=req('/admin/login?bs=password',{'secureCsrf':'bad','username':'admin','password':'TestPassword123'});check(s==303,'invalid CSRF rejected') +s,b,h=req();token=csrf(b) +s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'wrong'});check(s==303,'bad password rejected') +s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});check(s==303 and 'bs=account' in h['Location'],'local login completes when MFA disabled') +s,b,h=req('/admin/login?bs=account');check('Sécurité de mon compte' in b,'personal security page');token=csrf(b) +s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-start'});s,b,h=req('/admin/login?bs=account');secret=re.search(r'secret=([A-Z2-7]+)',b)[1] +def totp(secret): + return subprocess.check_output([PHP,'-c',INI,'-r',f'require "{PLUGIN}/src/Totp.php"; echo \\BluditSecure\\Totp::code("{secret}", intdiv(time(),30));']).decode() +code=totp(secret) +s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-confirm','code':code});s,b,h=req('/admin/login?bs=account');check('TOTP activé' in b,'TOTP enrollment confirmed') +s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'recovery'});s,b,h=req('/admin/login?bs=account');codes=re.search(r'
(.*?)
',b,re.S)[1].splitlines();check(len(codes)==10,'ten recovery codes') +s,b,h=req('/admin/login?bs=logout',{'secureCsrf':token});s,b,h=req();token=csrf(b) +s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});check('bs=mfa' in h['Location'],'password only creates pending state') +s,b,h=req('/admin/ajax/dashboard');check('Vérification en deux étapes' in b,'pending cannot access AJAX') +s,b,h=req('/admin/');check('Vérification en deux étapes' in b,'pending cannot access dashboard');token=csrf(b) +s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'code':code});s,b,h=req('/admin/');check('Vérification en deux étapes' in b,'reject already used TOTP') +s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});check('bs=account' in h['Location'],'recovery completes MFA') +s,b,h=req('/admin/login?bs=account');check('9 code(s)' in b,'recovery atomically consumed');token=csrf(b) +s,b,h=req('/admin/login?bs=logout',{'secureCsrf':token});s,b,h=req();token=csrf(b) +s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});s,b,h=req('/admin/');token=csrf(b) +s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});s,b,h=req('/admin/');check('Vérification en deux étapes' in b,'recovery replay rejected') +# Direct AJAX without a session must also remain unauthenticated. +cookies.clear();s,b,h=req('/admin/ajax/dashboard');check('Connexion à Bludit' in b,'anonymous AJAX intercepted before native dispatch') +print('Integration checks complete') diff --git a/tests/http_webauthn.py b/tests/http_webauthn.py new file mode 100644 index 0000000..7b40678 --- /dev/null +++ b/tests/http_webauthn.py @@ -0,0 +1,40 @@ +from http_helpers import * +import base64,hashlib,struct,pathlib +# Reset only the disposable plugin fixture. +for p in (pathlib.Path(FIXTURE)/'bl-content/databases/plugins/bludit-secure').glob('secure.*.php'):p.unlink() +PHP_CMD=[PHP,'-c',INI] +b64=lambda x:base64.urlsafe_b64encode(x).decode().rstrip('=') +def cbor(x): + def head(major,n): + return bytes([major*32+n]) if n<24 else bytes([major*32+24,n]) if n<256 else bytes([major*32+25])+struct.pack('>H',n) + if isinstance(x,int):return head(0,x) if x>=0 else head(1,-x-1) + if isinstance(x,bytes):return head(2,len(x))+x + if isinstance(x,str):return head(3,len(x.encode()))+x.encode() + if isinstance(x,dict):return head(5,len(x))+b''.join(cbor(k)+cbor(v) for k,v in x.items()) +keyfile=str(pathlib.Path(FIXTURE)/'test-key.pem') +raw=subprocess.check_output(PHP_CMD+['-r',f'$k=openssl_pkey_new(["private_key_type"=>OPENSSL_KEYTYPE_EC,"curve_name"=>"prime256v1"]);openssl_pkey_export($k,$pem);file_put_contents("{keyfile}",$pem);$d=openssl_pkey_get_details($k);echo json_encode([base64_encode($d["ec"]["x"]),base64_encode($d["ec"]["y"])]);']) +x,y=map(base64.b64decode,json.loads(raw)) +s,b,h=req();token=csrf(b) +s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});s,b,h=req('/admin/login?bs=account');token=csrf(b) +def post(action,**fields):return req('/admin/login?bs='+action,{'secureCsrf':token,**fields}) +s,b,h=post('key-create-options');options=json.loads(b);check(s==200,'registration options') +cred=b'bludit-test-key-1';rp=hashlib.sha256(b'127.0.0.1').digest() +auth=rp+b'\x45'+struct.pack('>I',0)+b'\0'*16+struct.pack('>H',len(cred))+cred+cbor({1:2,3:-7,-1:1,-2:x,-3:y}) +client=json.dumps({'type':'webauthn.create','challenge':options['publicKey']['challenge'],'origin':'https://127.0.0.1:18765'}).encode() +s,b,h=post('key-create-finish',clientDataJSON=b64(client),attestationObject=b64(cbor({'fmt':'none','attStmt':{},'authData':auth})),label='Test passkey');check(s==200,'signed registration accepted') +s,b,h=req('/admin/login?bs=account');check('Test passkey' in b,'registered passkey listed');token=csrf(b) +post('logout');s,b,h=req();token=csrf(b);post('password',username='admin',password='TestPassword123');s,b,h=req('/admin/');token=csrf(b);check('Vérification en deux étapes' in b,'passkey enabled as second factor') +def assertion(origin,valid=True): + s,b,h=post('key-get-options');check(s==200,'assertion options');opts=json.loads(b) + client=json.dumps({'type':'webauthn.get','challenge':opts['publicKey']['challenge'],'origin':origin}).encode() + auth=rp+b'\x05'+struct.pack('>I',1) + msg=auth+hashlib.sha256(client).digest() + signature=subprocess.check_output(['openssl','dgst','-sha256','-sign',keyfile],input=msg) + return post('key-get-finish',id=b64(cred),clientDataJSON=b64(client),authenticatorData=b64(auth),signature=b64(signature if valid else b'bad')) +s,b,h=assertion('https://evil.example');check(s==400,'wrong origin rejected') +s,b,h=assertion('https://127.0.0.1:18765',False);check(s==400,'bad signature rejected') +s,b,h=assertion('https://127.0.0.1:18765');check(s==200 and 'redirect' in json.loads(b),'valid signed assertion completes MFA') +s,b,h=req('/admin/login?bs=account');check('Sécurité de mon compte' in b,'WebAuthn authenticated session') +# A finish request cannot reuse a consumed challenge. +s,b,h=post('key-get-finish',id=b64(cred));check(s==400,'consumed challenge rejected') +print('WebAuthn integration checks complete')