Implement secure storage and TOTP authentication for BluditSecure

This commit is contained in:
interlope committed 2026-11-02 20:18:08 +01:00
commit 49b4308028
18 files changed
+1127

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
<?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
@@ -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)
+26
View File
@@ -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'<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)
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')
+40
View File
@@ -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')