Enhance security and functionality of Bludit Secure plugin

- Added userHandle support in secure.js for WebAuthn credential response.
- Implemented authorization check for SSO configuration changes in plugin.php.
- Improved user session management and validation in App.php, including userStamp for enhanced security.
- Updated Store.php to ensure secure data handling and improved error handling during transactions.
- Removed outdated test files for auto-passkey and core functionality.
- Added OidcClient.php to enforce strict validation of OpenID Connect requests and responses.
- Enhanced error handling and logging throughout the application for better debugging.
This commit is contained in:
spleenftw committed 2026-11-03 10:59:31 +01:00
1 parent 0b983bcfbb
commit 1742084b45
12 files changed
+192 -228

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
/vendor/
/dist/
/.tools/
__pycache__/
+8 -18
View File
@@ -28,6 +28,8 @@ Local passkeys belong to the **Bludit domain**. Your identity provider's passkey
## Installation
Download the installable release ZIP and extract its `bludit-secure/` directory into `bl-plugins/`. Release packages include the required Composer dependencies. If installing from a source archive or checkout, install the dependencies before activating the plugin.
1. Back up your site and `bl-content`; retain server access for recovery.
2. Extract the packaged ZIP so the entry point is `bl-plugins/bludit-secure/plugin.php`.
3. Verify that `bl-plugins/bludit-secure/vendor/autoload.php` exists.
@@ -76,6 +78,8 @@ Each user first signs in to their existing Bludit account, opens **Security**, t
Authorization Code flow uses PKCE S256. The jumbojett library checks state, nonce, token signature and claims. Bludit account roles remain authoritative; the plugin does not create accounts or assign roles from provider claims.
The plugin requires advertised PKCE S256 support, a matching nonce, integer `iat` and `exp` claims, and a matching authorized party (`azp`) for multiple audiences. State is checked before contacting the token endpoint. Discovery, token and signing-key endpoints must use direct HTTPS URLs: HTTP and endpoint redirects are rejected. Responses are limited to 1 MiB. Authorization starts have a separate persistent attempt budget. Changing the global SSO configuration requires an administrator sign-in within the last five minutes.
Logging out ends the Bludit session only. Provider-wide logout and OIDC back-channel logout are not implemented.
## Per-user security settings
@@ -91,12 +95,16 @@ Registering your first factor automatically enables its corresponding verificati
Changes require a completed sign-in within the last five minutes. After that, use **Log out** in the sidebar and sign in again with your existing factors. Changes invalidate other plugin sessions for that account. There is no site-wide mandatory MFA policy in this version.
Native password, password salt or role changes invalidate plugin sessions and pending sign-ins on their next request. A changed role takes effect after a fresh sign-in.
## Storage and recovery
TOTP secrets, passkey public keys, linked identities, recovery-code hashes and rate-limit records are stored in an authenticated, sodium-encrypted file. The encryption key is stored separately in the same private plugin data directory. Files have a PHP exit preamble and permissions of 0600. Encryption does not protect against an attacker who can read both the server files and the key.
Back up `secure.key.php`, `secure.data.php` and the Bludit user database together. Losing the key makes stored factors unrecoverable. Do not reuse an old account name for another person without removing its security registrations. The OIDC client secret is stored in the regular plugin configuration; protect that file and its backups too.
The encryption key and encrypted database are created together. If either file subsequently disappears while the other remains, authentication fails closed; restore the matching pair from backup. A legacy installation containing a key but no database also requires manual recovery. The plugin cannot distinguish deletion of both files from a fresh installation.
Incomplete sign-ins expire after five minutes; WebAuthn challenges expire after two minutes. Persistent verification budgets allow ten attempts per account and IP over five minutes, with separate budgets for management and enrollment. The plugin checks authentication before Bludit dispatches admin AJAX requests.
If all factors are lost, use a recovery code. Without one, a server administrator can temporarily move `bl-plugins/bludit-secure/` **outside `bl-plugins`** to restore native login. Restrict admin access during this maintenance operation, then restore the plugin and repair registrations. Uninstalling the plugin through Bludit deletes its data; it is not a temporary disable operation.
@@ -105,24 +113,6 @@ The plugin protects **web sessions under `/admin`**. It does not change authenti
JavaScript URLs include a content hash so updated passkey behavior is not hidden by stale asset caches.
## Validation
```sh
php tests/core.php
composer validate --no-check-publish
composer audit --no-dev
# With Node.js installed:
node tests/auto-passkey.cjs
```
HTTP tests in `tests/http_totp.py` and `tests/http_webauthn.py` require a **disposable** Bludit 3.22.0 instance with the plugin activated and test credentials `admin / TestPassword123`. The fixture uses a configured public URL of `https://127.0.0.1:18765/` and a PHP test server at `http://127.0.0.1:18765/`. HTTP transport and simulated HTTPS origins are fixture-only and do not replace real browser testing.
Set `TEST_PHP` (PHP executable), `TEST_PHP_INI` (php.ini) and `TEST_BLUDIT` (fixture root). `TEST_BASE` is optional. Run `http_totp.py` on a fresh fixture, then `http_webauthn.py`. **The WebAuthn test deletes plugin security records in that fixture. Never point it at a real site.**
Local checks cover PHP 8.4 syntax, RFC 6238 vectors, encrypted storage and tampering, transactions, persistent limits, native login CSRF, pending-session dashboard/AJAX protection, TOTP and recovery-code replay rejection, signed WebAuthn registration and assertions, invalid origins/signatures, consumed challenges, native admin integration and automatic prompting without cancellation loops.
The password-to-passkey browser flow over HTTPS was also reported working by the user. A complete live Pocket ID flow, backup restoration and compatibility with other authentication plugins still require validation. The latest local dependency audit reported no known advisories; rerun it before release.
## Changes in 0.1.5
- Retains the native login page and integrates Security into the dashboard.
+1
View File
@@ -33,6 +33,7 @@
} else {
fields.authenticatorData = encode(credential.response.authenticatorData);
fields.signature = encode(credential.response.signature);
if (credential.response.userHandle !== null && credential.response.userHandle !== undefined) fields.userHandle = encode(credential.response.userHandle);
}
const result = await request('finish', fields);
window.location.assign(result.redirect);
+8 -1
View File
@@ -15,6 +15,12 @@ class pluginBluditSecure extends Plugin
return $html.'<p>Leave the secret blank to keep the existing value. OIDC redirect URI: <code>'.htmlspecialchars(DOMAIN_ADMIN.'login?bs=oidc').'</code>. Accounts are never linked automatically by email. Each user must link their SSO identity from Security.</p>';
}
public function post() {
try { (new \BluditSecure\App($this))->authorizeConfiguration(); }
catch (\BluditSecure\UserError $e) {
http_response_code(403);
header('Content-Type: text/plain; charset=UTF-8');
exit('Sign in again as an administrator before changing SSO configuration.');
}
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; }
@@ -29,9 +35,10 @@ class pluginBluditSecure extends Plugin
require_once __DIR__.'/src/Store.php';
require_once __DIR__.'/src/Totp.php';
require_once __DIR__.'/src/App.php';
require_once __DIR__.'/src/OidcClient.php';
(new \BluditSecure\App($this))->run();
} catch (\Throwable $e) {
error_log('bludit-secure: '.get_class($e).' '.$e->getMessage());
error_log('bludit-secure: '.get_class($e));
http_response_code(503);
header('Cache-Control: no-store');
exit('Bludit Secure is unavailable. Check the server logs.');
+70 -38
View File
@@ -44,45 +44,65 @@ final class App
if (!$u || ($u['password'] ?? '!') === '!') { throw new UserError('Account unavailable'); }
return $u;
}
/** Bind authentication to the native credentials and role, without storing them in the session. */
private function userStamp(array $user): string {
return hash('sha256', json_encode([$user['password'], $user['salt'] ?? '', $user['role']], JSON_THROW_ON_ERROR));
}
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']);
private function pending(string $name, string $via, ?int $revision = null, ?string $userStamp = null): void {
$user = $this->user($name);
$a = $this->store->get($name);
$_SESSION['bs']['pending'] = ['name'=>$name, 'via'=>$via, 'expires'=>time()+300, 'revision'=>$a['revision']];
if (($revision !== null && $a['revision'] !== $revision) || ($userStamp !== null && $this->userStamp($user) !== $userStamp)) { throw new UserError('Account changed. Please sign in again.'); }
session_regenerate_id(true);
// Do not carry enrollment secrets, recovery codes or linking flows across sign-ins.
$_SESSION['bs'] = ['csrf'=>bin2hex(random_bytes(32))];
$_SESSION['bs']['pending'] = ['name'=>$name, 'via'=>$via, 'expires'=>time()+300, 'revision'=>$a['revision'], 'userStamp'=>$this->userStamp($user)];
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->completePending($name);
header('Location: '.DOMAIN_ADMIN, true, 303); exit;
}
private function completePending(string $name): void {
global $login, $security;
$p = $this->pendingUser();
if ($p['name'] !== $name) { throw new UserError('Invalid sign-in account.'); }
// Recheck under the storage lock: a concurrent factor change must revoke this login.
$this->store->transaction(function (&$d) use ($name, $p, $login, $security) {
$u = $this->user($name); $a = Store::account($d, $name);
if ($a['revision'] !== $p['revision'] || $this->userStamp($u) !== $p['userStamp']) { throw new UserError('Account changed. Please sign in again.'); }
$login->setLogin($name, $u['role']);
$security->generateTokenCSRF();
$_SESSION['bs']['auth'] = ['name'=>$name, 'at'=>time(), 'revision'=>$p['revision'], 'userStamp'=>$p['userStamp']];
});
unset($_SESSION['bs']['pending'], $_SESSION['bs']['challenge']);
$_SESSION['bs']['csrf'] = bin2hex(random_bytes(32));
}
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;
return $auth['revision'] === $a['revision'] && ($auth['userStamp'] ?? '') === $this->userStamp($this->user($auth['name'])) ? $auth['name'] : null;
}
private function recent(): string {
$name = $this->authenticated();
if (!$name || $_SESSION['bs']['auth']['at'] < time()-300) { throw new UserError('Sign in again before changing your account security.'); }
return $name;
}
public function authorizeConfiguration(): void {
$name = $this->recent();
if ($this->user($name)['role'] !== 'admin') { throw new UserError('Administrator access is required.'); }
}
private function pendingUser(): array {
$p = $_SESSION['bs']['pending'] ?? null;
if (!$p || $p['expires'] < time()) { unset($_SESSION['bs']['pending']); throw new UserError('Sign-in attempt expired. Please start again.'); }
$this->user($p['name']);
if ($this->store->get($p['name'])['revision'] !== $p['revision']) { unset($_SESSION['bs']['pending']); throw new UserError('Authentication methods have changed. Please sign in again.'); }
$user = $this->user($p['name']);
if ($this->store->get($p['name'])['revision'] !== $p['revision'] || ($p['userStamp'] ?? '') !== $this->userStamp($user)) { unset($_SESSION['bs']['pending']); throw new UserError('Account or authentication methods have changed. Please sign in again.'); }
return $p;
}
private function budget(string $name, string $scope = 'auth'): void {
@@ -91,12 +111,15 @@ final class App
}
public function run(): void {
global $login, $security;
header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
$_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) {
if (isset($_SESSION['bs']['auth'])) { $_SESSION['bs'] = ['csrf'=>bin2hex(random_bytes(32))]; }
foreach (['username','role','fingerPrint','sessionTime'] as $k) { \Session::set($k, null); }
}
if (!$name && ($GLOBALS['layout']['slug'] ?? '') === 'ajax') { $this->json(['error'=>'Complete authentication is required.'], 401); }
@@ -121,7 +144,8 @@ final class App
// Bludit renders its existing login theme and view.
return;
} catch (\Throwable $e) {
error_log('bludit-secure: '.get_class($e).' '.$e->getMessage());
// Provider exceptions can contain tokens or response bodies.
error_log('bludit-secure: '.get_class($e));
$msg = $e instanceof UserError ? $e->getMessage() : 'Verification failed. Try again or check the server logs.';
// Never expose protocol payloads, tokens or secrets to the browser.
if (str_starts_with($action, 'key-')) { $this->json(['error'=>'Verification failed. Sign in again or retry.'], 400); }
@@ -135,7 +159,7 @@ final class App
$this->budget($name);
$u = $users->getUserDB($name);
if (!$u || $u['password'] === '!' || !hash_equals($u['password'], $users->generatePasswordHash($pw, $u['salt']))) { throw new UserError('Incorrect username or password.'); }
$this->pending($name, 'password');
$this->pending($name, 'password', null, $this->userStamp($u));
}
/** Enhance the native login form in place; keep the theme, branding and layout. */
public function renderLogin(string $html): string {
@@ -171,7 +195,8 @@ final class App
}
private function verifyCode(): never {
$p = $this->pendingUser(); $this->budget($p['name']);
$ok = $this->store->update($p['name'], function (&$a) {
$ok = $this->store->update($p['name'], function (&$a) use ($p) {
if ($a['revision'] !== $p['revision']) { throw new UserError('Account changed. Please sign in again.'); }
$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; } }
@@ -242,12 +267,14 @@ final class App
}
private function manage(): never {
$name = $this->recent(); $this->budget($name, 'manage'); $op = (string)($_POST['op'] ?? '');
$expectedRevision = $_SESSION['bs']['auth']['revision'];
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) {
$revision = $this->store->update($name, function (&$a) use ($op, $name, $codes, $expectedRevision) {
if ($a['revision'] !== $expectedRevision) { throw new UserError('Account changed. Please sign in again.'); }
switch ($op) {
case 'totp-confirm':
$en = $_SESSION['bs']['enroll'] ?? null;
@@ -276,9 +303,9 @@ final class App
if ($codes) { $_SESSION['bs']['codes'] = $codes; }
$_SESSION['bs']['notice'] = 'Changes saved.'; $this->go('account');
}
private function oidcClient(): \Jumbojett\OpenIDConnectClient {
private function oidcClient(): OidcClient {
if (!$this->ssoEnabled() || parse_url($this->cfg('issuer'), PHP_URL_SCHEME) !== 'https') { throw new UserError('SSO is not configured.'); }
$oidc = new \Jumbojett\OpenIDConnectClient($this->cfg('issuer'), $this->cfg('clientId'), $this->cfg('clientSecret'));
$oidc = new OidcClient($this->cfg('issuer'), $this->cfg('clientId'), $this->cfg('clientSecret'));
$oidc->setRedirectURL($this->base.'?bs=oidc');
$oidc->setCodeChallengeMethod('S256');
$oidc->setAllowImplicitFlow(false);
@@ -287,17 +314,19 @@ final class App
}
private function startOidc(bool $link): never {
$name = $link ? $this->recent() : null;
$_SESSION['bs']['oidc'] = ['link'=>$name, 'expires'=>time()+300];
$this->budget(hash('sha256', session_id()), 'oidc');
$_SESSION['bs']['oidc'] = ['link'=>$name, 'expires'=>time()+300, 'config'=>$this->oidcConfigStamp(), 'revision'=>$name !== null ? $_SESSION['bs']['auth']['revision'] : null];
unset($_SESSION['bs']['pending']);
$this->oidcClient()->authenticate();
$this->oidcClient()->authenticateRequest([]);
throw new UserError('Unable to redirect to SSO.');
}
private function oidc(): never {
$flow = $_SESSION['bs']['oidc'] ?? null;
if (!$flow || $flow['expires'] < time() || !isset($_GET['code'], $_GET['state'])) { throw new UserError('SSO sign-in expired or was cancelled.'); }
unset($_SESSION['bs']['oidc']);
if (!$flow || $flow['expires'] < time() || ($flow['config'] ?? '') !== $this->oidcConfigStamp() || !isset($_GET['code'], $_GET['state'])) { throw new UserError('SSO sign-in expired or was cancelled.'); }
// The library verifies state, nonce, JWT signature, issuer, audience and expiry.
$oidc = $this->oidcClient();
$oidc->authenticate();
$oidc->authenticateRequest(['code'=>$_GET['code'], 'state'=>$_GET['state']]);
unset($_SESSION['bs']['oidc']);
$sub = $oidc->getVerifiedClaims('sub');
$iss = $oidc->getVerifiedClaims('iss');
@@ -306,38 +335,44 @@ final class App
if ($flow['link'] !== null) {
$name = $this->recent();
if ($name !== $flow['link']) { throw new UserError('Invalid account-linking session.'); }
$rev = $this->store->transaction(function (&$d) use ($name, $identity) {
$rev = $this->store->transaction(function (&$d) use ($name, $identity, $flow) {
foreach ($d['accounts'] as $other=>$a) { if ($other !== $name && $a['sso'] === $identity) { throw new UserError('This identity is already linked to another account.'); } }
$a = Store::account($d, $name); $a['sso'] = $identity; ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision'];
$a = Store::account($d, $name);
if ($a['revision'] !== $flow['revision']) { throw new UserError('Account changed. Please start linking again.'); }
$a['sso'] = $identity; ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision'];
});
$_SESSION['bs']['auth']['revision'] = $rev;
$_SESSION['bs']['notice'] = 'SSO identity linked.'; $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;
$match = $this->store->transaction(function (&$d) use ($identity) {
foreach ($d['accounts'] as $name=>$a) { if ($a['sso'] === $identity) { return ['name'=>$name, 'revision'=>$a['revision']]; } } return null;
});
if ($name === null) { throw new UserError('This SSO identity is not linked to a Bludit account. Sign in with your password to link it.'); }
$this->budget($name); $this->pending($name, 'sso');
if ($match === null) { throw new UserError('This SSO identity is not linked to a Bludit account. Sign in with your password to link it.'); }
$this->budget($match['name']); $this->pending($match['name'], 'sso', $match['revision']);
exit;
}
private function keyButton(bool $create, bool $automatic = false): string {
return '<div class="webauthn"'.($automatic ? ' data-auto-passkey="true"' : '').' data-base="'.$this->e($this->base).'" data-csrf="'.$this->e($_SESSION['bs']['csrf']).'">'.($create?'<label>Passkey name<input class="key-label" maxlength="80" value="My passkey"></label>':'').'<button type="button" data-key="'.($create?'create':'get').'">'.($create?'Add passkey':'Try passkey again').'</button></div>';
}
private function oidcConfigStamp(): string {
return hash('sha256', json_encode([$this->cfg('issuer'), $this->cfg('clientId'), $this->cfg('clientSecret')], JSON_THROW_ON_ERROR));
}
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'); }
if (strlen($raw) > 100000 || !preg_match('/^[A-Za-z0-9_-]*$/D', $raw)) { throw new UserError('Invalid binary data'); }
$result = base64_decode(strtr($raw, '-_', '+/'), true);
if ($result === false) { throw new UserError('Invalid binary data'); } return $result;
if ($result === false || self::b64($result) !== $raw) { 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'); }
if (!is_array($data) || ($data['origin'] ?? null) !== $this->origin || (array_key_exists('crossOrigin', $data) && $data['crossOrigin'] !== false)) { 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('Unknown action'); }
$name = $create ? $this->recent() : $this->pendingUser()['name'];
$this->budget($name, $create ? 'enroll' : 'auth'); $a = $this->store->get($name);
if ($create && $a['revision'] !== $_SESSION['bs']['auth']['revision']) { throw new UserError('Account changed. Please sign in again.'); }
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')) {
@@ -368,6 +403,7 @@ final class App
$this->json(['redirect'=>$this->accountUrl()]);
}
$id = (string)($_POST['id'] ?? '');
if (isset($_POST['userHandle']) && !hash_equals(hash('sha256', 'bludit:'.$name, true), self::unb64((string)$_POST['userHandle']))) { throw new UserError('Invalid passkey user handle'); }
$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];
@@ -378,11 +414,7 @@ final class App
$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->completePending($name);
$this->json(['redirect'=>DOMAIN_ADMIN]);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace BluditSecure;
/** Keep protocol validation explicit rather than relying on optional library checks. */
class OidcClient extends \Jumbojett\OpenIDConnectClient
{
private ?string $contentType = null;
public function authenticateRequest(array $parameters): bool {
$previous = $_REQUEST;
$_REQUEST = $parameters;
try { return $this->authenticate(); }
finally { $_REQUEST = $previous; }
}
public function authenticate(): bool {
if (isset($_REQUEST['code'])) {
$state = $this->getState();
if (!is_string($_REQUEST['code']) || $_REQUEST['code'] === '' || !is_string($_REQUEST['state'] ?? null) || !is_string($state) || $state === '' || !hash_equals($state, $_REQUEST['state'])) {
throw new UserError('Invalid SSO state.');
}
if (!is_string($this->getCodeVerifier()) || $this->getCodeVerifier() === '') { throw new UserError('Missing SSO PKCE verifier.'); }
}
return parent::authenticate();
}
protected function getProviderConfigValue(string $param, $default = null) {
$value = parent::getProviderConfigValue($param, $default);
if ($param === 'code_challenge_methods_supported' && (!is_array($value) || !in_array('S256', $value, true))) {
throw new UserError('The SSO provider must advertise PKCE S256 support.');
}
return $value;
}
protected function verifyJWTClaims($claims, ?string $accessToken = null): bool {
$nonce = $this->getNonce();
$now = time();
if (!is_object($claims) || !is_string($claims->iss ?? null) || !is_string($claims->sub ?? null) || $claims->sub === '' ||
!is_int($claims->exp ?? null) || !is_int($claims->iat ?? null) || $claims->exp < $now - 60 || $claims->iat > $now + 60 || $claims->exp <= $claims->iat ||
!is_string($nonce) || $nonce === '' || !is_string($claims->nonce ?? null) || !hash_equals($nonce, $claims->nonce)) { return false; }
$audiences = is_string($claims->aud ?? null) ? [$claims->aud] : ($claims->aud ?? null);
if (!is_array($audiences) || !array_is_list($audiences) || !$audiences || !in_array($this->getClientID(), $audiences, true)) { return false; }
foreach ($audiences as $audience) { if (!is_string($audience) || $audience === '') { return false; } }
if ((count($audiences) > 1 || isset($claims->azp)) && ($claims->azp ?? null) !== $this->getClientID()) { return false; }
return parent::verifyJWTClaims($claims, $accessToken);
}
private function httpsUrl(string $url): void {
$parts = parse_url($url);
if ($parts === false || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f]/', $url)) {
throw new UserError('SSO endpoints must use HTTPS without user information or fragments.');
}
}
public function redirect(string $url) {
$this->httpsUrl($url);
parent::redirect($url);
}
protected function fetchURL(string $url, ?string $post_body = null, array $headers = []) {
$this->httpsUrl($url);
$ch = curl_init($url);
$body = '';
try {
$options = [CURLOPT_PROTOCOLS=>CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS=>CURLPROTO_HTTPS,
CURLOPT_FOLLOWLOCATION=>false, CURLOPT_SSL_VERIFYHOST=>2, CURLOPT_SSL_VERIFYPEER=>true,
CURLOPT_TIMEOUT=>$this->getTimeout(), CURLOPT_CONNECTTIMEOUT=>10,
CURLOPT_USERAGENT=>$this->getUserAgent(), CURLOPT_WRITEFUNCTION=>static function ($handle, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > 1048576) { return 0; }
$body .= $chunk; return strlen($chunk);
}];
if ($post_body !== null) {
$options[CURLOPT_POST] = true; $options[CURLOPT_POSTFIELDS] = $post_body;
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
}
$options[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $options);
$ok = curl_exec($ch);
$this->responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$this->contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: null;
// Never forward a client secret across endpoint redirects.
if ($ok === false || $this->responseCode < 200 || $this->responseCode >= 300) { throw new UserError('SSO endpoint request failed.'); }
return $body;
} finally { curl_close($ch); }
}
public function getResponseContentType(): ?string { return $this->contentType; }
}
+23 -8
View File
@@ -11,25 +11,38 @@ final class Store
}
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'); }
if (!$lock) { throw new \RuntimeException('Storage locked'); }
if (!flock($lock, LOCK_EX)) { fclose($lock); throw new \RuntimeException('Storage locked'); }
$key = null;
try {
chmod($this->dir.'/secure.lock.php', 0600);
$kp = $this->dir.'/secure.key.php';
$path = $this->dir.'/secure.data.php';
$prefix = "<?php exit; ?>\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)));
if (file_exists($path)) { throw new \RuntimeException('Missing encryption key'); }
$initialKey = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
try {
$this->write($kp, $prefix.base64_encode($initialKey));
$nonce = random_bytes(24);
$this->write($path, $prefix.base64_encode($nonce.sodium_crypto_secretbox('{"accounts":[],"limits":[]}', $nonce, $initialKey)));
} finally { sodium_memzero($initialKey); }
}
$key = base64_decode(substr(file_get_contents($kp), strlen($prefix)), true);
if (!file_exists($path)) { throw new \RuntimeException('Missing security data'); }
$keyFile = file_get_contents($kp);
if ($keyFile === false || !str_starts_with($keyFile, $prefix)) { throw new \RuntimeException('Invalid encryption key header'); }
$key = base64_decode(substr($keyFile, 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);
$dataFile = file_get_contents($path);
if ($dataFile === false || !str_starts_with($dataFile, $prefix)) { throw new \RuntimeException('Invalid storage header'); }
$raw = base64_decode(substr($dataFile, 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);
if (!is_array($data) || !isset($data['accounts'], $data['limits']) || !is_array($data['accounts']) || !is_array($data['limits'])) { throw new \RuntimeException('Invalid storage structure'); }
}
$before = $data;
$result = $fn($data);
@@ -37,9 +50,11 @@ final class Store
$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); }
} finally {
if (is_string($key)) { sodium_memzero($key); }
flock($lock, LOCK_UN); fclose($lock);
}
}
private function write(string $path, string $data): void {
$tmp = tempnam($this->dir, '.secure-');
-35
View File
@@ -1,35 +0,0 @@
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const path = require('node:path');
(async () => {
const source = fs.readFileSync(path.join(__dirname, '../assets/secure.js'), 'utf8');
let prompts = 0, requests = 0, retry;
const status = {textContent: ''};
const box = {dataset: {base: 'https://blog.test/admin/login', csrf: 'test'}};
const button = {
dataset: {key: 'get'}, disabled: false,
closest: () => box,
addEventListener: (event, handler) => { retry = handler; }
};
vm.runInNewContext(source, {
URL, URLSearchParams, Uint8Array, atob, btoa,
document: {getElementById: () => status, querySelectorAll: () => [button]},
window: {PublicKeyCredential: function () {}},
navigator: {credentials: {get: async () => {
prompts++;
const error = new Error('cancel'); error.name = 'NotAllowedError'; throw error;
}}},
fetch: async () => {
requests++;
return {ok: true, json: async () => ({publicKey: {challenge: 'AQID', allowCredentials: []}})};
}
});
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(prompts, 1); assert.equal(requests, 1); assert.equal(button.disabled, false);
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(prompts, 1, 'cancellation must not trigger a prompt loop');
await retry();
assert.equal(prompts, 2, 'explicit retry must remain available');
console.log('OK automatic passkey prompt, cancellation without loop, explicit retry');
})().catch(error => { console.error(error); process.exitCode = 1; });
-29
View File
@@ -1,29 +0,0 @@
<?php
require __DIR__.'/../src/Store.php';
require __DIR__.'/../src/Totp.php';
use BluditSecure\Store;
use BluditSecure\Totp;
function check($condition, $label) { if (!$condition) { throw new RuntimeException($label); } echo "OK $label\n"; }
// RFC 6238 SHA-1 vectors truncated to 6 digits.
$secret = Totp::encode('12345678901234567890');
foreach ([59=>'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); }
-22
View File
@@ -1,22 +0,0 @@
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)
-34
View File
@@ -1,34 +0,0 @@
from http_helpers import *
s,b,h=req();check('id="jsusername"' in b and 'login-card' in b,'native Bludit login preserved');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)
native_token=re.search(r'name="tokenCSRF"[^>]*value="([^"]+)"', b)[1]
s,b,h=req('/admin/login',{'tokenCSRF':'invalid','username':'admin','password':'TestPassword123'});check(s==303,'native form rejects invalid CSRF')
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',{'tokenCSRF':native_token,'username':'admin','password':'TestPassword123','remember':'true'});check(s==303 and h['Location'].endswith('/admin/'),'native login form completes on dashboard when MFA disabled')
s,b,h=req('/admin/plugin/pluginbluditsecure');check('Security' in b,'personal security page');token=csrf(b)
check('sidebar col-lg-2' in b and 'bludit.bootstrap.css' in b,'account uses native Bludit admin layout')
check('assets/secure.css' not in b and b.lower().count('<!doctype html>') == 1,'no standalone account theme')
check(re.search(r'assets/secure\.js\?v=[0-9a-f]{16}', b), 'dashboard script URL has a content hash')
check('class="custom-select"' in b and 'btn btn-outline-primary' in b,'account uses Bootstrap controls')
s,legacy,headers=req('/admin/login?bs=account');check(s==303 and '/plugin/pluginbluditsecure' in headers['Location'],'legacy account URL redirects to dashboard page')
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-start'});s,b,h=req('/admin/plugin/pluginbluditsecure');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/plugin/pluginbluditsecure');check('TOTP is enabled' in b,'TOTP enrollment confirmed')
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'recovery'});s,b,h=req('/admin/plugin/pluginbluditsecure');codes=re.search(r'<pre[^>]*>(.*?)</pre>',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)
native_token=re.search(r'name="tokenCSRF"[^>]*value="([^"]+)"', b)[1]
s,b,h=req('/admin/login',{'tokenCSRF':native_token,'username':'admin','password':'TestPassword123','remember':'true'});check('bs=mfa' in h['Location'],'native password form only creates pending state')
s,b,h=req('/admin/ajax/dashboard');check(s==401,'pending cannot access AJAX')
s,b,h=req('/admin/');check('Two-step verification' 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('Two-step verification' in b,'reject already used TOTP')
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});check(h['Location'].endswith('/admin/'),'recovery completes MFA on dashboard')
s,b,h=req('/admin/plugin/pluginbluditsecure');check('9 recovery 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('Two-step verification' in b,'recovery replay rejected')
# Direct AJAX without a session must also remain unauthenticated.
cookies.clear();s,b,h=req('/admin/ajax/dashboard');check(s==401,'anonymous AJAX intercepted before native dispatch')
print('Integration checks complete')
-43
View File
@@ -1,43 +0,0 @@
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/plugin/pluginbluditsecure');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/plugin/pluginbluditsecure');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('Two-step verification' in b and 'login-card' in b,'MFA uses native login layout')
check('data-auto-passkey="true"' in b,'passkey prompt starts automatically')
check(re.search(r'assets/secure\.js\?v=[0-9a-f]{16}', b), 'login script URL has a content hash to invalidate stale caches')
check('name="password"' not in b and 'assets/secure.css' not in b,'native password form replaced only during second phase')
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/plugin/pluginbluditsecure');check('Security' 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')