Release Bludit Secure 0.1.5
This commit is contained in:
1 parent
49b4308028
commit
0b983bcfbb
10 files changed
+320
-154
No files matched your search
@@ -0,0 +1,35 @@
|
||||
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; });
|
||||
+22
-14
@@ -1,26 +1,34 @@
|
||||
from http_helpers import *
|
||||
s,b,h=req();check('Connexion à Bludit' in b,'plugin intercepts native login');token=csrf(b)
|
||||
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?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]
|
||||
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/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=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)
|
||||
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)
|
||||
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('Vérification en deux étapes' in b,'recovery replay rejected')
|
||||
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('Connexion à Bludit' in b,'anonymous AJAX intercepted before native dispatch')
|
||||
cookies.clear();s,b,h=req('/admin/ajax/dashboard');check(s==401,'anonymous AJAX intercepted before native dispatch')
|
||||
print('Integration checks complete')
|
||||
@@ -15,15 +15,18 @@ 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)
|
||||
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/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')
|
||||
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()
|
||||
@@ -34,7 +37,7 @@ def assertion(origin,valid=True):
|
||||
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')
|
||||
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')
|
||||
Reference in new issue
Block a user