commit
cf25e28c28
156 files changed
+38669
No files matched your search
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate independent Laravel CBC envelopes and optionally verify using PHP OpenSSL.
|
||||
|
||||
Run: python tests/crypto_interop.py [--check-php]
|
||||
Requires Python cryptography. The optional PHP check requires PHP's openssl extension.
|
||||
Fixtures exercise Laravel's encryptString envelope, not the entire Laravel framework.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
|
||||
def generate():
|
||||
fixtures = []
|
||||
for key in (b"0123456789abcdef", b"0123456789abcdef0123456789abcdef"):
|
||||
for plaintext in ("", "Laravel encrypted field", "Unicode 🔐 café"):
|
||||
iv = bytes(range(16))
|
||||
padder = PKCS7(128).padder()
|
||||
padded = padder.update(plaintext.encode()) + padder.finalize()
|
||||
encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
|
||||
value = base64.b64encode(encryptor.update(padded) + encryptor.finalize()).decode()
|
||||
encoded_iv = base64.b64encode(iv).decode()
|
||||
mac = hmac.new(key, (encoded_iv + value).encode(), hashlib.sha256).hexdigest()
|
||||
payload = dict(iv=encoded_iv, value=value, mac=mac, tag="")
|
||||
envelope = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode()
|
||||
fixtures.append(dict(cipher=f"AES-{len(key)*8}-CBC", app_key="base64:" + base64.b64encode(key).decode(), plaintext=plaintext, envelope=envelope))
|
||||
return fixtures
|
||||
|
||||
|
||||
def check_php(fixtures):
|
||||
php = shutil.which("php")
|
||||
if not php:
|
||||
print("SKIP PHP OpenSSL: php executable unavailable")
|
||||
return
|
||||
code = r'''
|
||||
$rows = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach ($rows as $row) {
|
||||
$key = base64_decode(substr($row['app_key'], 7), true);
|
||||
$payload = json_decode(base64_decode($row['envelope']), true);
|
||||
$mac = hash_hmac('sha256', $payload['iv'].$payload['value'], $key);
|
||||
if (!hash_equals($mac, $payload['mac'])) { throw new Exception('MAC mismatch'); }
|
||||
$plain = openssl_decrypt($payload['value'], $row['cipher'], $key, 0, base64_decode($payload['iv']));
|
||||
if ($plain !== $row['plaintext']) { throw new Exception('decrypt mismatch'); }
|
||||
$encrypted = openssl_encrypt($row['plaintext'], $row['cipher'], $key, 0, base64_decode($payload['iv']));
|
||||
if ($encrypted !== $payload['value']) { throw new Exception('encrypt mismatch'); }
|
||||
}
|
||||
echo 'PASS PHP OpenSSL: '.count($rows).' fixtures'.PHP_EOL;
|
||||
'''
|
||||
subprocess.run([php, "-r", code], input=json.dumps(fixtures), text=True, check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check-php", action="store_true")
|
||||
args = parser.parse_args()
|
||||
rows = generate()
|
||||
target = Path(__file__).parent / "fixtures" / "laravel_cbc.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(rows, indent=2, ensure_ascii=False) + "\n")
|
||||
print(f"Generated {len(rows)} independent Python AES CBC / HMAC fixtures: {target}")
|
||||
if args.check_php:
|
||||
check_php(rows)
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real PHP-issued device codes / Rust tokens on a disposable Laravel SQLite copy.
|
||||
No source database is modified. Device codes, keys, secrets and tokens are not printed.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PHP_SETUP = r'''<?php
|
||||
require getenv('APP_BASE_PATH').'/vendor/autoload.php';
|
||||
$app=require getenv('APP_BASE_PATH').'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$user=new App\Models\User;
|
||||
$user->name='Device fixture';$user->email='device@example.invalid';$user->password='Synthetic-device-password-4620';$user->save();
|
||||
$clients=app(Laravel\Passport\ClientRepository::class);
|
||||
$client=$clients->createAuthorizationCodeGrantClient('Device fixture',[],false,null,true);
|
||||
$other=$clients->createAuthorizationCodeGrantClient('Other device fixture',[],false,null,true);
|
||||
$server=app(League\OAuth2\Server\AuthorizationServer::class);
|
||||
$request=(new GuzzleHttp\Psr7\ServerRequest('POST',getenv('APP_URL').'/oauth/device/code'))->withParsedBody(['client_id'=>(string)$client->id,'scope'=>'*']);
|
||||
$response=$server->respondToDeviceAuthorizationRequest($request,new GuzzleHttp\Psr7\Response);
|
||||
$code=json_decode((string)$response->getBody(),true);
|
||||
echo json_encode(['client_id'=>(string)$client->id,'other_id'=>(string)$other->id,'user_id'=>(string)$user->id,'device_code'=>$code['device_code'],'user_code'=>$code['user_code']]);
|
||||
'''
|
||||
PHP_OPERATION = r'''<?php
|
||||
require getenv('APP_BASE_PATH').'/vendor/autoload.php';
|
||||
$app=require getenv('APP_BASE_PATH').'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$input=json_decode(file_get_contents(getenv('DEVICE_CONTROL')),true);
|
||||
if ($input['operation']==='approve' || $input['operation']==='deny') {
|
||||
app(League\OAuth2\Server\AuthorizationServer::class)->completeDeviceAuthorizationRequest($input['device_code'],$input['user_id'],$input['operation']==='approve');
|
||||
echo json_encode(['completed'=>true]);
|
||||
} elseif ($input['operation']==='read') {
|
||||
$model=Laravel\Passport\Passport::deviceCode()->findOrFail($input['device_code']);
|
||||
$entity=app(Laravel\Passport\Bridge\DeviceCodeRepository::class)->getDeviceCodeEntityByDeviceCode($input['device_code']);
|
||||
echo json_encode(['client_id'=>(string)$model->client_id,'user_code'=>$model->user_code,'scopes'=>$model->scopes,'revoked'=>(bool)$model->revoked,'entity_readable'=>$entity!==null]);
|
||||
} elseif ($input['operation']==='validate') {
|
||||
$request=(new GuzzleHttp\Psr7\ServerRequest('GET',getenv('APP_URL').'/api/v1/user'))->withHeader('Authorization','Bearer '.$input['access_token']);
|
||||
$request=app(League\OAuth2\Server\ResourceServer::class)->validateAuthenticatedRequest($request);
|
||||
echo json_encode(['user_id'=>(string)$request->getAttribute('oauth_user_id'),'scopes'=>$request->getAttribute('oauth_scopes')]);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('binary', nargs='?', type=Path, default=ROOT/'target/debug/twofauth-rs')
|
||||
parser.add_argument('--php', type=Path, default=Path(os.environ.get('PHP_BINARY','/tmp/twofauth-reference-runtime/php')))
|
||||
parser.add_argument('--source', type=Path, default=ROOT.parent/'upstream')
|
||||
parser.add_argument('--output', type=Path)
|
||||
args=parser.parse_args()
|
||||
source=args.source.resolve();php=args.php.resolve();binary=args.binary.resolve()
|
||||
if not (source/'vendor/autoload.php').is_file():parser.error('pinned upstream Composer dependencies required')
|
||||
with tempfile.TemporaryDirectory(prefix='device-php-rust-') as directory:
|
||||
work=Path(directory);app=work/'reference'
|
||||
shutil.copytree(source,app,ignore=shutil.ignore_patterns('vendor','node_modules','.env*','cache','logs','sessions','testing'))
|
||||
(app/'vendor').symlink_to(source/'vendor',target_is_directory=True)
|
||||
for path in ('bootstrap/cache','storage/logs','storage/framework/cache/data','storage/framework/sessions','storage/framework/views'):(app/path).mkdir(parents=True,exist_ok=True)
|
||||
with socket.socket() as reserve:reserve.bind(('127.0.0.1',0));port=reserve.getsockname()[1]
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
runtime={name:os.environ[name] for name in ('PATH','PHPRC','LD_LIBRARY_PATH') if name in os.environ}
|
||||
app_key='base64:'+base64.b64encode(b'0123456789abcdef0123456789abcdef').decode()
|
||||
reference_db=work/'reference.sqlite';candidate_db=work/'candidate.sqlite'
|
||||
env=dict(runtime,APP_BASE_PATH=str(app),APP_ENV='local',APP_DEBUG='false',APP_KEY=app_key,APP_URL=base,DB_CONNECTION='sqlite',DB_DATABASE=str(reference_db),SESSION_DRIVER='file',CACHE_STORE='file',MAIL_MAILER='array')
|
||||
def command(argv,environment=env,cwd=app):
|
||||
result=subprocess.run([str(value) for value in argv],env=environment,cwd=cwd,capture_output=True)
|
||||
if result.returncode:raise RuntimeError('fixture command failed')
|
||||
return result.stdout
|
||||
command([php,'artisan','migrate','--force']);command([php,'artisan','passport:keys','--force'])
|
||||
setup=work/'setup.php';setup.write_text(PHP_SETUP)
|
||||
fixture=json.loads(command([php,setup]))
|
||||
with sqlite3.connect(reference_db) as original,sqlite3.connect(candidate_db) as candidate:original.backup(candidate)
|
||||
rust_env=dict(runtime,DATABASE_URL='sqlite://'+str(candidate_db),APP_KEY=app_key,APP_URL=base,PASSPORT_KEY_PATH=str(app/'storage'),RUST_COOKIE_SECURE='false',RUST_PUBLIC_DIR=str(ROOT/'public'))
|
||||
command([binary,'prepare-sqlite'],rust_env,work)
|
||||
operation=work/'operation.php';operation.write_text(PHP_OPERATION)
|
||||
control=work/'control.json'
|
||||
php_env=dict(env,DB_DATABASE=str(candidate_db),DEVICE_CONTROL=str(control))
|
||||
def php_operation(name,**values):
|
||||
control.write_text(json.dumps({'operation':name,**values}));control.chmod(0o600)
|
||||
return json.loads(command([php,operation],php_env))
|
||||
def request(path,values=None):
|
||||
data=None if values is None else urllib.parse.urlencode(values).encode()
|
||||
req=urllib.request.Request(base+path,data=data,headers={'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'})
|
||||
try:response=urllib.request.urlopen(req,timeout=15)
|
||||
except urllib.error.HTTPError as error:response=error
|
||||
with response:
|
||||
body=response.read()
|
||||
try:payload=json.loads(body)
|
||||
except ValueError:payload=None
|
||||
return response.status,payload,response.headers
|
||||
urn='urn:ietf:params:oauth:grant-type:device_code'
|
||||
def poll(code,client=None):return request('/oauth/token',{'grant_type':urn,'client_id':client or fixture['client_id'],'device_code':code})
|
||||
def issue():
|
||||
status,body,headers=request('/oauth/device/code',{'client_id':fixture['client_id'],'scope':'*'})
|
||||
assert status==200,'Rust device issuance failed'
|
||||
assert re.fullmatch('[0-9a-f]{80}',body['device_code']) and re.fullmatch('[BCDFGHJKLMNPQRSTVWXZ]{8}',body['user_code'])
|
||||
assert body['verification_uri']==base+'/oauth/device' and body['verification_uri_complete']==base+'/oauth/device?user_code='+body['user_code']
|
||||
assert 590<=body['expires_in']<=600 and body['interval']==5
|
||||
assert headers['Cache-Control']=='no-store, private' and headers['Pragma']=='no-cache'
|
||||
return body
|
||||
checks={}
|
||||
with (work/'rust.log').open('w') as log:
|
||||
process=subprocess.Popen([str(binary),'serve','--listen',f'127.0.0.1:{port}'],env=rust_env,cwd=work,stdout=log,stderr=subprocess.STDOUT)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if process.poll() is not None:raise RuntimeError('Rust exited before readiness')
|
||||
try:
|
||||
if request('/up')[0]==200:break
|
||||
except OSError:time.sleep(.05)
|
||||
else:raise RuntimeError('Rust startup timed out')
|
||||
status,body,_=poll(fixture['device_code']);assert status==400 and body['error']=='authorization_pending' and body['hint']==''
|
||||
status,body,_=poll(fixture['device_code']);assert status==400 and body['error']=='slow_down' and body['hint']==''
|
||||
checks['php_issued_device_pending_backoff']=True
|
||||
php_operation('approve',device_code=fixture['device_code'],user_id=fixture['user_id'])
|
||||
status,tokens,_=poll(fixture['device_code']);assert status==200 and tokens.get('refresh_token'),'approved device exchange failed'
|
||||
validated=php_operation('validate',access_token=tokens['access_token']);assert validated=={'user_id':fixture['user_id'],'scopes':[]}
|
||||
checks['php_approval_rust_token_php_resource_server']=True
|
||||
status,body,_=poll(fixture['device_code']);assert status==400 and body['error']=='invalid_grant' and body['hint']==''
|
||||
assert php_operation('read',device_code=fixture['device_code'])['revoked'] is True
|
||||
checks['device_code_revocation_replay']=True
|
||||
issued=issue();read=php_operation('read',device_code=issued['device_code'])
|
||||
assert read=={'client_id':fixture['client_id'],'user_code':issued['user_code'],'scopes':['*'],'revoked':False,'entity_readable':True}
|
||||
checks['rust_issued_device_php_repository_readable']=True
|
||||
status,body,_=poll(issued['device_code'],fixture['other_id']);assert status==400 and body['hint']=='Device code was not issued to this client'
|
||||
php_operation('approve',device_code=issued['device_code'],user_id=fixture['user_id'])
|
||||
status,tokens,_=poll(issued['device_code']);assert status==200,'PHP approval of Rust-issued device failed'
|
||||
assert php_operation('validate',access_token=tokens['access_token'])=={'user_id':fixture['user_id'],'scopes':[]}
|
||||
checks['rust_device_php_approval_and_token_validation']=True
|
||||
denied=issue();php_operation('deny',device_code=denied['device_code'],user_id=fixture['user_id'])
|
||||
status,body,_=poll(denied['device_code']);assert status==401 and body['error']=='access_denied' and 'hint' not in body
|
||||
checks['php_denied_device_rejected']=True
|
||||
expired=issue()
|
||||
with sqlite3.connect(candidate_db) as db:db.execute("UPDATE oauth_device_codes SET expires_at=datetime('now','-1 second') WHERE id=?",(expired['device_code'],))
|
||||
status,body,_=poll(expired['device_code']);assert status==400 and body['error']=='expired_token' and body['hint']=='device_code'
|
||||
checks['expired_device_rejected']=True
|
||||
with sqlite3.connect(reference_db) as db:
|
||||
assert db.execute('SELECT revoked,last_polled_at FROM oauth_device_codes WHERE id=?',(fixture['device_code'],)).fetchone()==(0,None)
|
||||
checks['source_database_unchanged']=True
|
||||
record={'upstream_commit':'3516c776a1fcbd83d234efc007e3a49807d6ed91','candidate_sha256':hashlib.sha256(binary.read_bytes()).hexdigest(),'php_version':command([php,'-r','echo PHP_VERSION;']).decode().strip(),'checks':checks,'passed':all(checks.values())}
|
||||
if args.output:args.output.write_text(json.dumps(record,indent=2)+'\n')
|
||||
print(json.dumps(record))
|
||||
finally:
|
||||
process.terminate()
|
||||
try:process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:process.kill();process.wait()
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for stateful comparison semantics; these are not PHP parity tests."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
TOOL = Path(__file__).resolve().parents[1] / 'tools/differential_http.py'
|
||||
spec = importlib.util.spec_from_file_location('differential_http', TOOL)
|
||||
harness = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(harness)
|
||||
|
||||
class HarnessTests(unittest.TestCase):
|
||||
def test_form_transport_preserves_repeated_values_and_reserved_characters(self):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args): pass
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers['Content-Length']))
|
||||
values = urllib.parse.parse_qs(body.decode(), keep_blank_values=True)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'type': self.headers['Content-Type'], 'values': values}).encode())
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f'http://127.0.0.1:{server.server_port}'
|
||||
case = {'path': '/oauth/token', 'method': 'POST', 'form': {'scope': ['a+b', 'c d'], 'empty': '', 'code': 'x&y=z'}}
|
||||
result = harness.fetch(base, case, 'candidate', 2)
|
||||
self.assertEqual(result['status'], 200)
|
||||
self.assertEqual(json.loads(result['body']), {'type': 'application/x-www-form-urlencoded', 'values': {'scope': ['a+b', 'c d'], 'empty': [''], 'code': ['x&y=z']}})
|
||||
with self.assertRaises(ValueError):
|
||||
harness.fetch(base, dict(case, json={}), 'candidate', 2)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
def test_typed_captures_and_pointer_escaping(self):
|
||||
variables = {}
|
||||
harness.capture_values({'body': b'{"a/b":{"~key":[42]}}'}, {'item': '/a~1b/~0key/0'}, variables)
|
||||
self.assertEqual(harness.resolve({'path': '/items/{{item}}', 'json': ['{{item}}']}, variables), {'path': '/items/42', 'json': [42]})
|
||||
with self.assertRaises(KeyError): harness.resolve('{{missing}}', variables)
|
||||
with self.assertRaises(ValueError): harness.validate_path('//remote')
|
||||
|
||||
def test_json_url_normalization_after_unescaping(self):
|
||||
response = {'body': b'{"url":"http:\\/\\/localhost:1234","nested":["http:\\/\\/localhost:1234/path"]}', 'content_type': 'application/json'}
|
||||
self.assertEqual(harness.comparable(response, 'http://localhost:1234', {}), {'url': '<BASE>', 'nested': ['<BASE>/path']})
|
||||
|
||||
def test_json_assertions_are_independent_of_equality(self):
|
||||
response = {'body': b'{"count":0}'}
|
||||
self.assertFalse(harness.assert_json_response(response, {'expected_json': {'/count':1}}))
|
||||
self.assertFalse(harness.assert_json_response(response, {'absent_json_pointers':['/count']}))
|
||||
self.assertTrue(harness.assert_json_response(response, {'absent_json_pointers':['/secret']}))
|
||||
|
||||
def test_csrf_shape_rejects_json_or_html_documents(self):
|
||||
self.assertTrue(harness.is_text_token({'status':200,'content_type':'text/html','body':b'A'*40}))
|
||||
self.assertFalse(harness.is_text_token({'status':200,'content_type':'application/json','body':b'A'*40}))
|
||||
self.assertFalse(harness.is_text_token({'status':200,'content_type':'text/html','body':b'<html>'+b'A'*40+b'</html>'}))
|
||||
|
||||
def test_cookie_rotation_capture_and_state_effects(self):
|
||||
servers = []
|
||||
for fixture_id in (14, 37):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
item_id = fixture_id
|
||||
saved = False
|
||||
def log_message(self, *args): pass
|
||||
def do_GET(self):
|
||||
if self.path == '/session':
|
||||
self.send_response(200)
|
||||
self.send_header('Set-Cookie', 'session=fixture; Path=/')
|
||||
self.send_header('Set-Cookie', 'XSRF-TOKEN=encoded%2Btoken; Path=/')
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{}')
|
||||
elif self.path == f'/items/{self.item_id}' and self.saved:
|
||||
self.reply(200, {'value': 'saved'})
|
||||
else: self.reply(404, {})
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||
if self.headers.get('Cookie', '').find('session=fixture') < 0 or self.headers.get('X-XSRF-TOKEN') != 'encoded+token' or self.headers.get('X-Compat') != 'fixture':
|
||||
self.reply(419, {})
|
||||
elif body == {'value': 'saved'}:
|
||||
type(self).saved = True
|
||||
self.reply(201, {'id': self.item_id})
|
||||
else: self.reply(422, {})
|
||||
def reply(self, status, payload):
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode())
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
servers.append(server)
|
||||
try:
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
cases = Path(folder) / 'cases.json'
|
||||
cases.write_text(json.dumps([
|
||||
{'path': '/session', 'expected_status': 200},
|
||||
{'method': 'POST', 'path': '/items', 'headers': {'X-Compat': 'fixture'}, 'json': {'value': 'saved'}, 'headers_from_cookies': {'X-XSRF-TOKEN': 'XSRF-TOKEN'}, 'capture_json': {'id': '/id'}, 'ignore_json_pointers': ['/id'], 'expected_status': 201},
|
||||
{'path': '/items/{{id}}', 'expected_status': 200},
|
||||
]))
|
||||
command = [sys.executable, str(TOOL), '--reference', f'http://127.0.0.1:{servers[0].server_port}', '--candidate', f'http://127.0.0.1:{servers[1].server_port}', '--cases', str(cases), '--allow-side-effects', '--sessions']
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual([row['result'] for row in map(json.loads, result.stdout.splitlines())], ['equal'] * 3)
|
||||
self.assertNotIn('encoded', result.stdout)
|
||||
cases.write_text(json.dumps([{'path': '/session', 'expected_status': 201}]))
|
||||
mismatch = subprocess.run(command, capture_output=True, text=True)
|
||||
self.assertEqual(mismatch.returncode, 1)
|
||||
self.assertFalse(json.loads(mismatch.stdout)['checks']['expected_status'])
|
||||
finally:
|
||||
for server in servers:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// Run with the PHP version required by the pinned upstream composer.lock:
|
||||
// php tests/fixtures/generate_laravel_sessions.php /path/to/upstream/vendor/autoload.php
|
||||
require $argv[1] ?? __DIR__.'/../../../upstream/vendor/autoload.php';
|
||||
|
||||
use Illuminate\Auth\SessionGuard;
|
||||
use Illuminate\Cookie\CookieValuePrefix;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\EncryptedStore;
|
||||
use Illuminate\Session\Store;
|
||||
|
||||
$key = '0123456789abcdef0123456789abcdef';
|
||||
$cipher = new Encrypter($key, 'AES-256-CBC');
|
||||
$guard = (new ReflectionClass(SessionGuard::class))->newInstanceWithoutConstructor();
|
||||
(new ReflectionProperty(SessionGuard::class, 'name'))->setValue($guard, 'web-guard');
|
||||
$id = str_repeat('F', 40);
|
||||
$cookie = static fn ($name, $value) => rawurlencode($cipher->encryptString(CookieValuePrefix::create($name, $key).$value));
|
||||
$fixtures = [];
|
||||
foreach (['json', 'php'] as $serialization) {
|
||||
foreach ([false, true] as $encrypted) {
|
||||
$handler = new ArraySessionHandler(129600);
|
||||
$store = $encrypted
|
||||
? new EncryptedStore('2fauth_session', $handler, $cipher, $id, $serialization)
|
||||
: new Store('2fauth_session', $handler, $id, $serialization);
|
||||
$store->start();
|
||||
$store->put('_token', 'real-php-csrf-🔐');
|
||||
$store->put($guard->getName(), 7);
|
||||
$store->put('preferences', ['language' => 'fr', 'enabled' => true]);
|
||||
$store->save();
|
||||
$fixtures[] = [
|
||||
'php_version' => PHP_VERSION,
|
||||
'framework_version' => Composer\InstalledVersions::getPrettyVersion('laravel/framework'),
|
||||
'app_key' => $key,
|
||||
'serialization' => $serialization,
|
||||
'encrypted' => $encrypted,
|
||||
'session_id' => $id,
|
||||
'login_key' => $guard->getName(),
|
||||
'recaller_name' => $guard->getRecallerName(),
|
||||
'payload' => $handler->read($id),
|
||||
'database_payload' => base64_encode($handler->read($id)),
|
||||
'session_cookie' => $cookie('2fauth_session', $id),
|
||||
'csrf_cookie' => $cookie('XSRF-TOKEN', $store->token()),
|
||||
'recaller_cookie' => $cookie($guard->getRecallerName(), '7|remember-fixture|password-hash'),
|
||||
'csrf' => $store->token(),
|
||||
];
|
||||
}
|
||||
}
|
||||
echo json_encode($fixtures, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n";
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"cipher": "AES-128-CBC",
|
||||
"app_key": "base64:MDEyMzQ1Njc4OWFiY2RlZg==",
|
||||
"plaintext": "",
|
||||
"envelope": "eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0xEQTBPRHc9PSIsInZhbHVlIjoieXRwbENUdmY3ZUpQYmZYYllLNU9HQT09IiwibWFjIjoiM2YxNjA4NmQ0NGQ2YTlhMmM0NmE1ODgwYTM5NGZmZTkxMDkzMzQ3NDI4YzdjYzA1ZmY4OTNjYTc3NzkzN2E2YiIsInRhZyI6IiJ9"
|
||||
},
|
||||
{
|
||||
"cipher": "AES-128-CBC",
|
||||
"app_key": "base64:MDEyMzQ1Njc4OWFiY2RlZg==",
|
||||
"plaintext": "Laravel encrypted field",
|
||||
"envelope": "eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0xEQTBPRHc9PSIsInZhbHVlIjoiUkZBNFdLUlovUXlRSGRmNHR2Qk80UTdRd2Z0VE9zNHBieXlMMnh1Nm95RT0iLCJtYWMiOiI3NmIwNDc3YjI2OGNhMDMyZTkwYWNiNDM1N2EyZTExYzM2OGI3MmZhZmYyNGNlODRjZWNiZWQ4MTEyZmQ2NDUxIiwidGFnIjoiIn0="
|
||||
},
|
||||
{
|
||||
"cipher": "AES-128-CBC",
|
||||
"app_key": "base64:MDEyMzQ1Njc4OWFiY2RlZg==",
|
||||
"plaintext": "Unicode 🔐 café",
|
||||
"envelope": "eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0xEQTBPRHc9PSIsInZhbHVlIjoiMkZOVUNnRWJOT3YyeW1MSW5ZR3YrQkVmbHJMZ2RHd1JIRXFuVzEwbW81az0iLCJtYWMiOiI3NTkyNzg4YmQyMWEyMzE3YjQyYWU4NTRhYmI3M2Y3YWM0MDdjNGIyNGU5Y2YzM2U1YjNkYjExYjcyMTk0MTY3IiwidGFnIjoiIn0="
|
||||
},
|
||||
{
|
||||
"cipher": "AES-256-CBC",
|
||||
"app_key": "base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
"plaintext": "",
|
||||
"envelope": "eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0xEQTBPRHc9PSIsInZhbHVlIjoiYjdHZlNkcnFuUjFjTW15NGUyZmM2QT09IiwibWFjIjoiZmZhNmRmZDgxMTkyYmU4MGEyOTBmZmIyMWRmMTg3ZGQ4MTYyNDM0MjUxNmM2ODViODQ1YmQ4MTExYTZkOGZkYyIsInRhZyI6IiJ9"
|
||||
},
|
||||
{
|
||||
"cipher": "AES-256-CBC",
|
||||
"app_key": "base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
"plaintext": "Laravel encrypted field",
|
||||
"envelope": "eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0xEQTBPRHc9PSIsInZhbHVlIjoiNkRRc09uRWp3UWswamlxb2lCdURnSklaeWQ4ZEZScko1R2JiR09waUZEUT0iLCJtYWMiOiI0YWQ3ZjAzODYxMDJkM2MxMGZiYTg2Njc2ZjBjNmZkMGVmM2I0YzczZTIwMDhjNzMyZjY4YjExNjIzMjk4ZGZjIiwidGFnIjoiIn0="
|
||||
},
|
||||
{
|
||||
"cipher": "AES-256-CBC",
|
||||
"app_key": "base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
"plaintext": "Unicode 🔐 café",
|
||||
"envelope": "eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0xEQTBPRHc9PSIsInZhbHVlIjoiRndtblljRTBpdFdyM2o5a2kxT2NHY0lieEVFamtWamliMkVEUW16VExwQT0iLCJtYWMiOiI1NGVmYjg3NWI2NGRlMGMwNDM2NzlhZTQ4NmQxNTI4MWJhZjk2NzAyNGQxNTViYmM5YTYxMjUxMjkzNzYxNTBkIiwidGFnIjoiIn0="
|
||||
}
|
||||
]
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"php_version": "8.4.27",
|
||||
"framework_version": "v13.29.0",
|
||||
"app_key": "0123456789abcdef0123456789abcdef",
|
||||
"serialization": "json",
|
||||
"encrypted": false,
|
||||
"session_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
"login_key": "login_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"recaller_name": "remember_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"payload": "{\"_token\":\"real-php-csrf-\\ud83d\\udd10\",\"login_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d\":7,\"preferences\":{\"language\":\"fr\",\"enabled\":true},\"_flash\":{\"old\":[],\"new\":[]}}",
|
||||
"database_payload": "eyJfdG9rZW4iOiJyZWFsLXBocC1jc3JmLVx1ZDgzZFx1ZGQxMCIsImxvZ2luX3dlYi1ndWFyZF81OWJhMzZhZGRjMmIyZjk0MDE1ODBmMDE0YzdmNThlYTRlMzA5ODlkIjo3LCJwcmVmZXJlbmNlcyI6eyJsYW5ndWFnZSI6ImZyIiwiZW5hYmxlZCI6dHJ1ZX0sIl9mbGFzaCI6eyJvbGQiOltdLCJuZXciOltdfX0=",
|
||||
"session_cookie": "eyJpdiI6Ik9JbEdWcXg2bGlqalVMWW5OYTNpRUE9PSIsInZhbHVlIjoicHl4R1NtejByVlljdHVsUnpLT29pVjFHdHFmY1EzK3NySklqQUZSR3lseElXWVVDMDAzWUJDamdwdW5HRUs5U09oMG4vQmNLVDk1eFFxNFV4ektsRk1lZGt4QUxkUDFUMEZIKzgwVkxSQWU0eXh4UXN0ekNoSGp6elJaQmlNckoiLCJtYWMiOiJmNTFjNGM2ODY2YTY2MGZjNDdlZTQ5NTdhM2NkYmI3YzNhM2EyNjA5Mzg2MDhhM2NkMjY1YzNhZWMwZTJiY2NhIiwidGFnIjoiIn0%3D",
|
||||
"csrf_cookie": "eyJpdiI6Ink1N0dFSlNiaTZXclhZZlBXaUxFd3c9PSIsInZhbHVlIjoiMDhQczNtYXNqYU9Bb0JqdVFQVEIrR1RsQUY2eVh6R2N4QUI4UkMrbFRRd2hYQjBsYUxGMDV6U2FQdUQ4OHVkUlFxSkNOT0doQlNTZjF5bWNKUWFSOVE9PSIsIm1hYyI6IjBjNTRjYmY0ZGVjZDk2NTA1YWI1ZjNkYWFmMzZkYzJhMmU2OTFkNTUzYjIwMDAxMjYwNjY1ODNlODlkMDQ2ZTkiLCJ0YWciOiIifQ%3D%3D",
|
||||
"recaller_cookie": "eyJpdiI6Inl0MmxQaUpNMndXNTNZVGtDd3JtYVE9PSIsInZhbHVlIjoidEZHWjIyd3JaT0ZENW0zUWRSU21nd1pYS1RwZm5jZ0FtY2V5NkNrSjBCL0d1dVlwTFF1SkY1VVBWWnJCbnBNSzlqeWorZ0J1Kyt5bXBDVFYvWURpbEVCNHF3bzJDeDlmNHlMMEh5YklRMWc9IiwibWFjIjoiYzhiMmZiYzBjZDQ0NDJkMjY4ZDFmYmMwNjFlZjVmMDIxY2NmMzBiODJjYmYzODk4ZjJlMTIyNzZiY2FkMDAyYiIsInRhZyI6IiJ9",
|
||||
"csrf": "real-php-csrf-🔐"
|
||||
},
|
||||
{
|
||||
"php_version": "8.4.27",
|
||||
"framework_version": "v13.29.0",
|
||||
"app_key": "0123456789abcdef0123456789abcdef",
|
||||
"serialization": "json",
|
||||
"encrypted": true,
|
||||
"session_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
"login_key": "login_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"recaller_name": "remember_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"payload": "eyJpdiI6InB2TjlWUDZZdmY5TVhoTHRia3hXQWc9PSIsInZhbHVlIjoiS2V5cjhSalovUU9TWlVQTVZBWVJaUXo4VFVFeDFIVktJRTBIYzJrL0Vpdmg3NWhBSnRLdjdsdTBDK0g1aVZpTFQ2WmNzcnY5S25TUjJoaHRlbkR6ZHlVSnppYzY2SVhvc0c3cnBFRlJsWi91WmF0TjJmMGNZdG1zeWRObGRpYkN6VGRtSURLWFZ4b3NiNGJFYkdsVDkzNWxFQmY0dC94ZWdTeDQ1UVJya0IwbWZtbFp3NEFGMlpHa3ZkNStlVHlhSFVVZEJiSllmYlkzcTVRTFNxMVBHRlBTeDA1QnMzLzRwaFNGckZXbG41K3ArdWNsdkpYbDlKbmI1UEQ5VHRONCIsIm1hYyI6Ijc4MjQ3YjY3NmRlMDQ1MzBkMjQxNWYyNWNiZGI4ODlkYTc1YmNmODFmZmMwNzY1MjkxNTYxYjA4MjVmNDVkN2EiLCJ0YWciOiIifQ==",
|
||||
"database_payload": "ZXlKcGRpSTZJbkIyVGpsV1VEWlpkbVk1VFZob1RIUmlhM2hYUVdjOVBTSXNJblpoYkhWbElqb2lTMlY1Y2poU2Fsb3ZVVTlUV2xWUVRWWkJXVkphVVhvNFZGVkZlREZJVmt0SlJUQklZekpyTDBWcGRtZzNOV2hCU25STGRqZHNkVEJESzBnMWFWWnBURlEyV21OemNuWTVTMjVUVWpKb2FIUmxia1I2WkhsVlNucHBZelkyU1ZodmMwYzNjbkJGUmxKc1dpOTFXbUYwVGpKbU1HTlpkRzF6ZVdST2JHUnBZa042VkdSdFNVUkxXRlo0YjNOaU5HSkZZa2RzVkRrek5XeEZRbVkwZEM5NFpXZFRlRFExVVZKeWEwSXdiV1p0YkZwM05FRkdNbHBIYTNaa05TdGxWSGxoU0ZWVlpFSmlTbGxtWWxremNUVlJURk54TVZCSFJsQlRlREExUW5Nekx6UndhRk5HY2taWGJHNDFLM0FyZFdOc2RrcFliRGxLYm1JMVVFUTVWSFJPTkNJc0ltMWhZeUk2SWpjNE1qUTNZalkzTm1SbE1EUTFNekJrTWpReE5XWXlOV05pWkdJNE9EbGtZVGMxWW1ObU9ERm1abU13TnpZMU1qa3hOVFl4WWpBNE1qVm1ORFZrTjJFaUxDSjBZV2NpT2lJaWZRPT0=",
|
||||
"session_cookie": "eyJpdiI6IjRuRUY4QWt0YTVjdnkrWDg0VnZ2elE9PSIsInZhbHVlIjoiNXZMdHRKL0w1a2gwdnFYRlF5clhUb3BRUEVtRnFyYXVjOXdhMFR1ZTJWZU84a01HaFFYdWdJMTVPTHl3c0NLcHNmVUsraVhzNGVYS0Q1MWYydlM5WDNEL2ltU1NMYXc5Qjk5dnBaU0NDUmxGMUpCMVFDdjB6R0QwZUUzMDdsbEEiLCJtYWMiOiJmZjliM2ZkYmJiNzFmZDZiZDI0YmEzZTVlM2EyZDllOWE5OWJmN2I2YzMyN2I1NzIzMTU0MDkwYTRjZWYzYTM5IiwidGFnIjoiIn0%3D",
|
||||
"csrf_cookie": "eyJpdiI6Iko1VzU5by9lYVVqc2NIUUpiVEJpa3c9PSIsInZhbHVlIjoibE1DTHRvLzNoUGJIZHhPaHprb0VzNDdNSU5rWUVPMHdTL1BIT2dMZnNOVzR5bGNTVXAyL1htSXZ6enRXVTZwVlVPV3ljckJJaVNDcHRFKzRmbEV3ZFE9PSIsIm1hYyI6IjliNDMwMTQ3NjUyNTU0NDIxNzk2YmU5OWQxNzEzMjc5MjkxNzgwMmNhM2RiMmY4ZThkNjQ0ZjQ4NzkyMGY3MTMiLCJ0YWciOiIifQ%3D%3D",
|
||||
"recaller_cookie": "eyJpdiI6IkM3bTRrOHVzVWNUVTRvSEZ1Zmx2MWc9PSIsInZhbHVlIjoiMGpRaTVlb0dFb250N2dEUW1BQnl1K1U3V2pMU0RoNE5oOWtRT09nWnFGZUM5czAxaFdVMW9zOVhWNkZJcDlYVmtDeGpnLzJ4dVczeHc5NkpnemtqZGZxaVpTUDUvUW01cmVBUTJSMW10WGs9IiwibWFjIjoiNzUwZmQ5Y2NkYmM0YWNhODlmM2ZkN2NlMzBmOTY3MTg4NGJlMzBkOGMwYjNhZjcxNjg0MWQ5YjgwMTk0NDY1YiIsInRhZyI6IiJ9",
|
||||
"csrf": "real-php-csrf-🔐"
|
||||
},
|
||||
{
|
||||
"php_version": "8.4.27",
|
||||
"framework_version": "v13.29.0",
|
||||
"app_key": "0123456789abcdef0123456789abcdef",
|
||||
"serialization": "php",
|
||||
"encrypted": false,
|
||||
"session_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
"login_key": "login_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"recaller_name": "remember_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"payload": "a:4:{s:6:\"_token\";s:18:\"real-php-csrf-🔐\";s:56:\"login_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d\";i:7;s:11:\"preferences\";a:2:{s:8:\"language\";s:2:\"fr\";s:7:\"enabled\";b:1;}s:6:\"_flash\";a:2:{s:3:\"old\";a:0:{}s:3:\"new\";a:0:{}}}",
|
||||
"database_payload": "YTo0OntzOjY6Il90b2tlbiI7czoxODoicmVhbC1waHAtY3NyZi3wn5SQIjtzOjU2OiJsb2dpbl93ZWItZ3VhcmRfNTliYTM2YWRkYzJiMmY5NDAxNTgwZjAxNGM3ZjU4ZWE0ZTMwOTg5ZCI7aTo3O3M6MTE6InByZWZlcmVuY2VzIjthOjI6e3M6ODoibGFuZ3VhZ2UiO3M6MjoiZnIiO3M6NzoiZW5hYmxlZCI7YjoxO31zOjY6Il9mbGFzaCI7YToyOntzOjM6Im9sZCI7YTowOnt9czozOiJuZXciO2E6MDp7fX19",
|
||||
"session_cookie": "eyJpdiI6IkdKVjlodWFhcXNrMisyUjBhZlRGckE9PSIsInZhbHVlIjoiTjczUWJpTlQ2dndOV1J0N2o3U0YzR2lvNUNOWmtxSnVuamIwUDBkb0NIU0JGZjY2NllxU01oQ0RWYkV6ZUROdWRQa1R3Y0ZQT0xMem1HVVM2alpkeUNNU0VxR0lsZnNvTTUwZVBhaEV5b1NhSDU2QkE3bVNhekZjY1FSVzVQM28iLCJtYWMiOiJjY2ZhMjRmNmJkMGY5MTIyMTJlYWQ4YjY2MGRkYjU3MTYwNzU3MmViMzQ5OTlmYzIzMTUyNjg4N2Q3OTA5NDNkIiwidGFnIjoiIn0%3D",
|
||||
"csrf_cookie": "eyJpdiI6IkJIWHBRTzBtWlQrY0dVcnVmeEUyU3c9PSIsInZhbHVlIjoibTA4UzRDVDIzd1VTTEpEaDhQdkVyeTVsQjhXalMxaWF3cWRUWWFRQ0tMOVpHZ3R1TkRXQkNnWUs4ZTRsdmxxRVo4Y2FvSUdpald4YlRhelFDaGZXZ3c9PSIsIm1hYyI6IjYwYTQ4M2YwYzIyOWZhNDA2OTFiYjhhYjJjMDMyOWQwY2U2NGE1OTA5NTVmMGU1MjNlNjhlYTM4YzI3MzRlMGEiLCJ0YWciOiIifQ%3D%3D",
|
||||
"recaller_cookie": "eyJpdiI6Ik9KRG9SVnJKY28zcXVVODR6d0xNeGc9PSIsInZhbHVlIjoiWTllL2ROOUNURnQrNWF4Q2pLMXpDNklUeG5EeFBYWGszRGQ1MEtGT1J5T1hGdUc5OWNrRVlQTTM5cFg1OTBaaW1HeU96b24yOGsvMDBPL3dUMXNBQlpBY1JmQzZqV1lHaWFqdms2MXliUUU9IiwibWFjIjoiNzQ2MjIxMDkyMmI4MGEzM2I0ZjAzZWQzZWEyYWYxMGU3ZmMzZmJmNGRhMTJjZjhiMTYwZDQ2NzZlMjNmOTJkMCIsInRhZyI6IiJ9",
|
||||
"csrf": "real-php-csrf-🔐"
|
||||
},
|
||||
{
|
||||
"php_version": "8.4.27",
|
||||
"framework_version": "v13.29.0",
|
||||
"app_key": "0123456789abcdef0123456789abcdef",
|
||||
"serialization": "php",
|
||||
"encrypted": true,
|
||||
"session_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
"login_key": "login_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"recaller_name": "remember_web-guard_59ba36addc2b2f9401580f014c7f58ea4e30989d",
|
||||
"payload": "eyJpdiI6IjRoQnYyckJKYTBIQXlQUHBlaml1QUE9PSIsInZhbHVlIjoiQ2NyeUF1Nm0zQmIyOEdBZ2xQWEtPbmlLL0dQbkxlNEZpdmRMY2RvbVYvbU55ZFdUZ3VJK2VtalVUeVV0U2JjeEoySjl5djdSWXkvK3g2d0J1QnJpMG9vajMvU2xSWitucm9XMXh4WHFBUWdwOVVOcytpL1VQblJRazBsNG8xTzNRa2U0OTlUUEhXbFpLSGUyaHd0eFpNNUlkK3MybVBHcU5VRFhtVHNXK0tRR3JrN2lqQmhvL3RNeUxqQktiV1VVZEJWTnVkcEdXdVBjSUtpRU9IN1I2VUNQZnFyTitvR2hQVENMYlZWTWtWL2Y5bTR5N3J2SmdRT0VVVWhxeHArTVZKemVVS2xhU0M4SUwzWFhBNjgvbDU5K09kZzhCK3lrR3JnaXdmMXFZSUlGaU42MStOSHZ4QlJ4ZUNHelV0RTZZaE0wYzc3RXRVTjhydXc1MlRhcnRBPT0iLCJtYWMiOiJlZTRjMWFmYzkyNDIxYjA2ZGRiNjJhOWE1ZTNmNDg2ZDVkYTJmZmJlZWJjZjA4NzEwZjY0YWQzYTdjNTFmZDk0IiwidGFnIjoiIn0=",
|
||||
"database_payload": "ZXlKcGRpSTZJalJvUW5ZeWNrSktZVEJJUVhsUVVIQmxhbWwxUVVFOVBTSXNJblpoYkhWbElqb2lRMk55ZVVGMU5tMHpRbUl5T0VkQloyeFFXRXRQYm1sTEwwZFFia3hsTkVacGRtUk1ZMlJ2YlZZdmJVNTVaRmRVWjNWSksyVnRhbFZVZVZWMFUySmplRW95U2psNWRqZFNXWGt2SzNnMmQwSjFRbkpwTUc5dmFqTXZVMnhTV2l0dWNtOVhNWGg0V0hGQlVXZHdPVlZPY3l0cEwxVlFibEpSYXpCc05HOHhUek5SYTJVME9UbFVVRWhYYkZwTFNHVXlhSGQwZUZwTk5VbGtLM015YlZCSGNVNVZSRmh0VkhOWEswdFJSM0pyTjJscVFtaHZMM1JOZVV4cVFrdGlWMVZWWkVKV1RuVmtjRWRYZFZCalNVdHBSVTlJTjFJMlZVTlFabkZ5VGl0dlIyaFFWRU5NWWxaV1RXdFdMMlk1YlRSNU4zSjJTbWRSVDBWVlZXaHhlSEFyVFZaS2VtVlZTMnhoVTBNNFNVd3pXRmhCTmpndmJEVTVLMDlrWnpoQ0szbHJSM0puYVhkbU1YRlpTVWxHYVU0Mk1TdE9TSFo0UWxKNFpVTkhlbFYwUlRaWmFFMHdZemMzUlhSVlRqaHlkWGMxTWxSaGNuUkJQVDBpTENKdFlXTWlPaUpsWlRSak1XRm1Zemt5TkRJeFlqQTJaR1JpTmpKaE9XRTFaVE5tTkRnMlpEVmtZVEptWm1KbFpXSmpaakE0TnpFd1pqWTBZV1F6WVRkak5URm1aRGswSWl3aWRHRm5Jam9pSW4wPQ==",
|
||||
"session_cookie": "eyJpdiI6IjF0RWEzNTZ4cDREZ0tibFRjQTZxK3c9PSIsInZhbHVlIjoicDh3RnVTZTcwdW40enpBMEpZeUV3YitieEpnSGg1dXJ1UUh6RFhzMVVXME52QkUxcStIaksvcFBtZ2lkeUdBcEdPbHFjM0NPSTRwZ1YzZ1k3WTFJZWlnMlUvS1pQai9FaFpJRllCa0hUODlIM0UzN3cxQjJaNURGcFhaazk2L0UiLCJtYWMiOiI5ZWUzNTk5MWQ5YzA4YmVkYTRkMzk3ZDJmYzlhYjAxNThlODVhZWI2N2ZmYTVlNDhmYmRmYTQ0NzMxOWIzYzhlIiwidGFnIjoiIn0%3D",
|
||||
"csrf_cookie": "eyJpdiI6IkRLVERLOVdYUTRmdVhuemR0ajZpVGc9PSIsInZhbHVlIjoiMWtWSHRKSzJvRVZQVUY3SzZuN3ZEc3pFWDN3K2hWOHBkbi94aW5Wc0MreElrRmJlenk2YUxheXJGaXFSMFNLR3lPSVRFaTRCdXF1ZVlGU1BkUnBWL1E9PSIsIm1hYyI6IjkxZmNhNWMxNmQ3NzU1NTQ4MDdkMzI0YmM5ZTEwYmUxYmI5ZmE2OGFjZTY1NGZjZGEyMmY5NGY0MGUxNTA5MTgiLCJ0YWciOiIifQ%3D%3D",
|
||||
"recaller_cookie": "eyJpdiI6IlA3ZEZKSWIveGMyOHg3a0VRVCtNNVE9PSIsInZhbHVlIjoiWldCMWNwbFB2RWQ3ZUNVV1JWbFZmdFRoc3A0Yjg4MGc1LzVlbVN1cG1YWGw5NXhBQkthdHdTc0Npb0Jxa004cG5yczY2WUpwaWRmbVdyRklDMk5UeSsxVUMrL0Q5UXhZQzRFbGRvd09lb289IiwibWFjIjoiNDc3YWIwZWNhZTZmMjI1MWQ1ZmFkM2E2YmMyZThhOTU0MmZhNDQwMDQxZGUwYTQzMmRhYWNiMDYyYTI2MTI1YSIsInRhZyI6IiJ9",
|
||||
"csrf": "real-php-csrf-🔐"
|
||||
}
|
||||
]
|
||||
+1
@@ -0,0 +1 @@
|
||||
eyJpdiI6InF5Z3B4YUx5OEJtU3FjV3hxMk9HVHc9PSIsInZhbHVlIjoiSXZUcExVWFB6ekRCaVFKOE5KTllUVmJ6RHRXcXRGa0UwdXJnU1NNS0RnbFk1WUxWOTJ5ZDFKWVVQenJCMFN4aWVTY3N5SHRsdXVKWnlWTUNDWjFvUlAxMXF0Q0UyNTNuTkNYZW83dUFWaDhDTnVhWFNMWnIwdnZGVWJWOWY3VStzNkdNS1pPKzgwZDBjZS9La055d2RnVmRRbVh5RkZxVUlCc1MwTFVjVzFBZ2ZpMkhpZENwbTFmUFRrZXZvQy90N3pSejV0bU5WMlRqTXhxVTBVOVFFSkdNblVCa0s1anhXMStyTElkdFFDZmdiMGFNeG5hVE04VllrN21Sd2czNyIsIm1hYyI6IjBiM2YxMmQxZWFkN2ZjY2NmNzYxZmJkMWIwOWE0NmZiYzY5YmRlMTJkMTgzNzU5NjkxMmI2NzE0NTY5YjA3OGUiLCJ0YWciOiIifQ==
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7178IxdRG58LwZZk
|
||||
Xg9yQP4t/DKpdujw3cBDlhF/XNqhRANCAAT+za5CCkZmGVLRojW4r9HHnjNfZPAZ
|
||||
jeizNmztcl/zTsjlN+QbpYfNlqQ03ZpiDchSD3mV4jAjjJULjCle3+Cv
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch the built binary against disposable synthetic data; never use a real DB."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
binary = Path(sys.argv[1] if len(sys.argv) > 1 else root / 'target/debug/twofauth-rs').resolve()
|
||||
with tempfile.TemporaryDirectory(prefix='twofauth-rs-smoke-') as folder:
|
||||
work = Path(folder)
|
||||
password = work / 'password'
|
||||
password.write_text('Synthetic-password-4620')
|
||||
password.chmod(0o600)
|
||||
env = dict(os.environ, DATABASE_URL=f'sqlite://{work}/test.sqlite', RUST_COOKIE_SECURE='false', RUST_PUBLIC_DIR=str(root / 'public'))
|
||||
env.pop('APP_SUBDIRECTORY', None)
|
||||
env.pop('APP_KEY', None)
|
||||
env.pop('APP_PREVIOUS_KEYS', None)
|
||||
subprocess.run([str(binary), 'init'], env=env, check=True, capture_output=True, cwd=work)
|
||||
duplicate = subprocess.run([str(binary), 'init'], env=env, capture_output=True, cwd=work)
|
||||
assert duplicate.returncode != 0, 'init must refuse an existing users table'
|
||||
subprocess.run([str(binary), 'create-user', '--name', 'Synthetic', '--email', 'smoke@example.invalid', '--password-file', str(password)], env=env, check=True, capture_output=True, cwd=work)
|
||||
password.unlink()
|
||||
with socket.socket() as reserve:
|
||||
reserve.bind(('127.0.0.1', 0))
|
||||
port = reserve.getsockname()[1]
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
process = subprocess.Popen([str(binary), 'serve', '--listen', f'127.0.0.1:{port}'], env=env, cwd=work, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
def request(path, method='GET', value=None, csrf=None):
|
||||
data = None if value is None else json.dumps(value).encode()
|
||||
headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
|
||||
if csrf is not None:
|
||||
headers['X-XSRF-TOKEN'] = csrf
|
||||
req = urllib.request.Request(base + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
response = opener.open(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
body = response.read()
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
payload = body.decode()
|
||||
return response.status, payload, response.headers
|
||||
for _ in range(100):
|
||||
if process.poll() is not None:
|
||||
raise AssertionError('server exited during startup')
|
||||
try:
|
||||
if request('/up')[0] == 200:
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError('server startup timed out')
|
||||
assert request('/api/v1/twofaccounts')[0] == 401
|
||||
status, html, headers = request('/')
|
||||
assert status == 200 and 'var appSettings' in html
|
||||
assert headers['Content-Security-Policy']
|
||||
csrf = next(cookie.value for cookie in jar if cookie.name == 'XSRF-TOKEN')
|
||||
assert request('/user/login', 'POST', {'email': 'smoke@example.invalid', 'password': 'Synthetic-password-4620'})[0] == 419
|
||||
status, login, _ = request('/user/login', 'POST', {'email': 'SMOKE@example.invalid', 'password': 'Synthetic-password-4620'}, csrf)
|
||||
assert status == 200
|
||||
csrf = next(cookie.value for cookie in jar if cookie.name == 'XSRF-TOKEN')
|
||||
assert request('/api/v1/user')[1]['email'] == 'smoke@example.invalid'
|
||||
account = {'otp_type': 'hotp', 'account': 'Synthetic', 'secret': base64.b32encode(b'12345678901234567890').decode(), 'counter': 0, 'digits': 6, 'algorithm': 'sha1'}
|
||||
assert request('/api/v1/twofaccounts', 'POST', account)[0] == 419
|
||||
status, created, _ = request('/api/v1/twofaccounts', 'POST', account, csrf)
|
||||
assert status == 201
|
||||
account_id = created['id']
|
||||
listed = request('/api/v1/twofaccounts')[1]
|
||||
assert isinstance(listed, list) and len(listed) == 1 and 'secret' not in listed[0]
|
||||
for counter in range(2):
|
||||
status, otp, _ = request(f'/api/v1/twofaccounts/{account_id}/otp')
|
||||
digest = hmac.new(b'12345678901234567890', counter.to_bytes(8, 'big'), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 15
|
||||
expected = f'{(int.from_bytes(digest[offset:offset+4], "big") & 0x7fffffff) % 1000000:06d}'
|
||||
assert status == 200 and otp['password'] == expected
|
||||
assert request('/api/v1/settings')[0] == 403
|
||||
assert request('/user/logout')[0] == 419
|
||||
assert request('/user/logout', csrf=csrf)[0] == 200
|
||||
assert request('/api/v1/twofaccounts')[0] == 401
|
||||
print(json.dumps({'cli_init': 'passed', 'existing_db_refusal': 'passed', 'test_user': 'passed', 'http_login_crud_hotp_logout': 'passed', 'php_differential': 'not_run'}))
|
||||
finally:
|
||||
process.send_signal(signal.SIGINT)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Process-level Laravel browser migration checks using real pinned PHP fixtures.
|
||||
|
||||
Usage: python3 tests/laravel_session_http.py /path/to/twofauth-rs
|
||||
All databases, users, cookies and session files are disposable synthetic fixtures.
|
||||
Regenerate fixtures with tests/fixtures/generate_laravel_sessions.php.
|
||||
"""
|
||||
import base64
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NATIVE_COOKIE = "rust_2fauth_session"
|
||||
|
||||
|
||||
class Browser:
|
||||
def __init__(self, base, cookies=None):
|
||||
self.base = base
|
||||
self.cookies = dict(cookies or {})
|
||||
|
||||
def request(self, path, method="GET", csrf=None, update=True):
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.cookies:
|
||||
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
|
||||
if csrf is not None:
|
||||
headers["X-XSRF-TOKEN"] = csrf
|
||||
request = urllib.request.Request(self.base + path, headers=headers, method=method)
|
||||
try:
|
||||
response = urllib.request.urlopen(request, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
raw = response.read()
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = raw.decode()
|
||||
if update:
|
||||
for header in response.headers.get_all("Set-Cookie", []):
|
||||
parsed = SimpleCookie()
|
||||
parsed.load(header)
|
||||
for name, value in parsed.items():
|
||||
if value["max-age"] == "0":
|
||||
self.cookies.pop(name, None)
|
||||
else:
|
||||
self.cookies[name] = value.value
|
||||
return response.status, body, response.headers
|
||||
|
||||
|
||||
def run_case(binary, fixture, driver):
|
||||
label = f"{driver}/{fixture['serialization']}/encrypt={fixture['encrypted']}"
|
||||
with tempfile.TemporaryDirectory(prefix="laravel-browser-") as folder:
|
||||
work = Path(folder)
|
||||
storage = work / "storage"
|
||||
sessions = storage / "framework/sessions"
|
||||
sessions.mkdir(parents=True)
|
||||
env = dict(os.environ, DATABASE_URL=f"sqlite://{work}/test.sqlite",
|
||||
APP_KEY="base64:" + base64.b64encode(fixture["app_key"].encode()).decode(),
|
||||
APP_NAME="2FAuth", SESSION_COOKIE="2fauth_session", SESSION_DRIVER=driver,
|
||||
SESSION_ENCRYPT=str(fixture["encrypted"]).lower(), SESSION_TABLE="sessions",
|
||||
SESSION_SECURE_COOKIE="false", SESSION_PATH="/", SESSION_DOMAIN="",
|
||||
STORAGE_PATH=str(storage), RUST_COOKIE_SECURE="false",
|
||||
RUST_PUBLIC_DIR=str(ROOT / "public"))
|
||||
for name in ("APP_SUBDIRECTORY", "APP_PREVIOUS_KEYS", "AUTH_GUARD", "AUTHENTICATION_GUARD"):
|
||||
env.pop(name, None)
|
||||
subprocess.run([str(binary), "init"], env=env, cwd=work, check=True, capture_output=True)
|
||||
db = sqlite3.connect(work / "test.sqlite")
|
||||
db.execute("INSERT INTO users(id,name,email,password,remember_token) VALUES(7,'PHP fixture','fixture@example.invalid','unused','remember-fixture')")
|
||||
db.execute("CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY,user_id INTEGER,ip_address TEXT,user_agent TEXT,payload TEXT,last_activity INTEGER)")
|
||||
sid = fixture["session_id"]
|
||||
source = sessions / sid
|
||||
if driver == "file":
|
||||
source.write_text(fixture["payload"])
|
||||
else:
|
||||
db.execute("INSERT INTO sessions(id,user_id,payload,last_activity) VALUES(?,7,?,?)",
|
||||
(sid, fixture["database_payload"], int(time.time())))
|
||||
db.commit()
|
||||
with socket.socket() as reserve:
|
||||
reserve.bind(("127.0.0.1", 0))
|
||||
port = reserve.getsockname()[1]
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
process = subprocess.Popen([str(binary), "serve", "--listen", f"127.0.0.1:{port}"],
|
||||
env=env, cwd=work, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
anonymous = Browser(base)
|
||||
for _ in range(100):
|
||||
if process.poll() is not None:
|
||||
raise AssertionError(f"{label}: server exited during startup")
|
||||
try:
|
||||
if anonymous.request("/up")[0] == 200:
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError(f"{label}: startup timed out")
|
||||
original = {"2fauth_session": fixture["session_cookie"],
|
||||
"XSRF-TOKEN": fixture["csrf_cookie"]}
|
||||
|
||||
# A browser need not visit the SPA before refreshing its Passport cookie.
|
||||
direct = Browser(base, original)
|
||||
assert direct.request("/oauth/token/refresh", "POST")[0] == 419, label
|
||||
status, body, _ = direct.request("/oauth/token/refresh", "POST", fixture["csrf_cookie"])
|
||||
assert status == 200 and body == "Refreshed.", (label, status, body)
|
||||
assert "laravel_token" in direct.cookies, label
|
||||
status, user, _ = direct.request("/api/v1/user", csrf=fixture["csrf_cookie"])
|
||||
assert status == 200 and user["id"] == 7, (label, status, user)
|
||||
|
||||
# Start another browser with exactly the PHP session + recaller: no
|
||||
# laravel_token and no native-session cookie exist before root GET.
|
||||
original[fixture["recaller_name"]] = fixture["recaller_cookie"]
|
||||
browser = Browser(base, original)
|
||||
assert "laravel_token" not in browser.cookies and NATIVE_COOKIE not in browser.cookies
|
||||
status, html, _ = browser.request("/")
|
||||
assert status == 200 and "var appSettings" in html, (label, status)
|
||||
assert NATIVE_COOKIE in browser.cookies, label
|
||||
assert browser.cookies["XSRF-TOKEN"] != fixture["csrf_cookie"], label
|
||||
assert "2fauth_session" not in browser.cookies, label
|
||||
assert fixture["recaller_name"] not in browser.cookies, label
|
||||
if driver == "file":
|
||||
assert not source.exists(), f"{label}: source PHP file was not destroyed"
|
||||
else:
|
||||
assert db.execute("SELECT count(*) FROM sessions WHERE id=?", (sid,)).fetchone()[0] == 0, label
|
||||
status, user, _ = browser.request("/api/v1/user")
|
||||
assert status == 200 and user["id"] == 7, (label, status, user)
|
||||
assert db.execute("SELECT user_id FROM rust_sessions").fetchone()[0] == 7, label
|
||||
|
||||
# Minting a fresh Passport cookie must keep the native browser usable.
|
||||
csrf = browser.cookies["XSRF-TOKEN"]
|
||||
status, body, _ = browser.request("/oauth/token/refresh", "POST", csrf)
|
||||
assert status == 200 and body == "Refreshed.", (label, status, body)
|
||||
assert "laravel_token" in browser.cookies, label
|
||||
for header in (None, csrf):
|
||||
status, user, _ = browser.request("/api/v1/user", csrf=header)
|
||||
assert status == 200 and user["id"] == 7, (label, status, user)
|
||||
native_before_logout = browser.cookies[NATIVE_COOKIE]
|
||||
assert browser.request("/user/logout", "POST", csrf)[0] == 200, label
|
||||
assert db.execute("SELECT remember_token FROM users WHERE id=7").fetchone()[0] != "remember-fixture", label
|
||||
assert browser.request("/api/v1/user")[0] == 401, label
|
||||
replay = Browser(base, original)
|
||||
# Exercise the actual import path: the API alone never reads a
|
||||
# Laravel session, so an API-only replay would miss a valid recaller.
|
||||
assert replay.request("/")[0] == 200, label
|
||||
assert replay.request("/api/v1/user", csrf=replay.cookies.get("XSRF-TOKEN"))[0] == 401, f"{label}: PHP session/recaller replay accepted"
|
||||
assert Browser(base, {NATIVE_COOKIE: native_before_logout}).request("/api/v1/user")[0] == 401, f"{label}: native session replay accepted"
|
||||
return label
|
||||
finally:
|
||||
db.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def main():
|
||||
binary = Path(sys.argv[1] if len(sys.argv) > 1 else ROOT / "target/debug/twofauth-rs").resolve()
|
||||
fixtures = json.loads((ROOT / "tests/fixtures/laravel_sessions.json").read_text())
|
||||
passed = [run_case(binary, fixture, driver) for fixture in fixtures for driver in ("file", "database")]
|
||||
print(json.dumps({"php_session_browser_migration": "passed", "cases": passed}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disposable OAuth integration test against the pinned PHP cryptographic wire format."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
PHP=os.environ.get('PHP_BINARY','/tmp/twofauth-reference-runtime/bin/php')
|
||||
AUTOLOAD=ROOT.parent/'upstream/vendor/autoload.php'
|
||||
|
||||
def php(code,*args):
|
||||
return subprocess.check_output([PHP,'-r',f'require {json.dumps(str(AUTOLOAD))};'+code,*args],text=True).strip()
|
||||
|
||||
def main():
|
||||
binary=Path(sys.argv[1] if len(sys.argv)>1 else ROOT/'target/debug/twofauth-rs').resolve()
|
||||
with tempfile.TemporaryDirectory(prefix='oauth-wire-') as d:
|
||||
work=Path(d)
|
||||
subprocess.run(['openssl','genrsa','-out',str(work/'oauth-private.key'),'2048'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
subprocess.run(['openssl','rsa','-in',str(work/'oauth-private.key'),'-pubout','-out',str(work/'oauth-public.key')],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
app_key='0123456789abcdef0123456789abcdef'
|
||||
env=dict(os.environ,DATABASE_URL=f'sqlite://{work}/test.sqlite',APP_KEY='base64:'+base64.b64encode(app_key.encode()).decode(),PASSPORT_KEY_PATH=d,RUST_COOKIE_SECURE='false',RUST_PUBLIC_DIR=str(ROOT/'public'))
|
||||
env.pop('APP_SUBDIRECTORY',None)
|
||||
subprocess.run([str(binary),'init'],check=True,env=env,cwd=work,capture_output=True)
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||
proc=subprocess.Popen([str(binary),'serve','--listen',f'127.0.0.1:{port}'],env=env,cwd=work,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
def request(values,path='/oauth/token'):
|
||||
data=urllib.parse.urlencode(values).encode() if values is not None else None
|
||||
req=urllib.request.Request(f'http://127.0.0.1:{port}'+path,data=data,headers={'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'})
|
||||
try:r=urllib.request.urlopen(req,timeout=10)
|
||||
except urllib.error.HTTPError as e:r=e
|
||||
with r:return r.status,json.loads(r.read())
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
if request({'grant_type':'bogus'})[1].get('error')=='unsupported_grant_type':break
|
||||
except (OSError,ValueError):time.sleep(.05)
|
||||
else:raise AssertionError('OAuth route unavailable')
|
||||
con=sqlite3.connect(work/'test.sqlite')
|
||||
secret=php('echo password_hash("secret",PASSWORD_BCRYPT);')
|
||||
con.execute("INSERT INTO users(id,name,email,password) VALUES(1,'OAuth','oauth@example.invalid','unused')")
|
||||
con.execute("INSERT INTO oauth_clients(id,name,secret,redirect_uris,grant_types) VALUES(1,'public',NULL,'[\"https://example.test/cb\"]','[\"authorization_code\",\"refresh_token\"]')")
|
||||
con.execute("INSERT INTO oauth_clients(id,name,secret,redirect_uris,grant_types) VALUES(2,'machine',?,'[]','[\"client_credentials\"]')",(secret,))
|
||||
verifier='a'*43
|
||||
payload={'client_id':'1','auth_code_id':'php-auth-code','user_id':'1','scopes':[],'redirect_uri':'https://example.test/cb','expire_time':int(time.time())+600,'code_challenge':base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('='),'code_challenge_method':'S256'}
|
||||
code=php('echo Defuse\\Crypto\\Crypto::encryptWithPassword($argv[1],$argv[2]);',json.dumps(payload),app_key)
|
||||
con.execute("INSERT INTO oauth_auth_codes(id,user_id,client_id,scopes,revoked,expires_at) VALUES('php-auth-code',1,1,'[]',0,datetime('now','+10 minutes'))")
|
||||
con.commit()
|
||||
params={'grant_type':'authorization_code','client_id':'1','redirect_uri':payload['redirect_uri'],'code':code,'code_verifier':'b'*43}
|
||||
assert request(params)[1]['error']=='invalid_grant'
|
||||
params['code_verifier']=verifier
|
||||
status,tokens=request(params);assert status==200,(status,tokens)
|
||||
assert request(params)[1]['error']=='invalid_grant','authorization code replay accepted'
|
||||
claims=json.loads(base64.urlsafe_b64decode(tokens['access_token'].split('.')[1]+'=='))
|
||||
assert claims['sub']=='1' and claims['scopes']==[]
|
||||
verified=php('$p=explode(".",$argv[1]);echo openssl_verify($p[0].".".$p[1],base64_decode(strtr($p[2],"-_","+/")),file_get_contents($argv[2]),OPENSSL_ALGO_SHA256);',tokens['access_token'],str(work/'oauth-public.key'))
|
||||
assert verified=='1','PHP rejected Rust JWT signature'
|
||||
decoded=json.loads(php('echo Defuse\\Crypto\\Crypto::decryptWithPassword($argv[1],$argv[2]);',tokens['refresh_token'],app_key))
|
||||
assert decoded['user_id']=='1' and decoded['access_token_id']==claims['jti']
|
||||
# PHP decrypts and re-encrypts the Rust refresh payload before redemption.
|
||||
php_refresh=php('echo Defuse\\Crypto\\Crypto::encryptWithPassword($argv[1],$argv[2]);',json.dumps(decoded),app_key)
|
||||
refresh={'grant_type':'refresh_token','client_id':'1','refresh_token':php_refresh}
|
||||
status,rotated=request(refresh);assert status==200,(status,rotated)
|
||||
assert request(refresh)[1]['error']=='invalid_grant','refresh replay accepted'
|
||||
assert con.execute('SELECT revoked FROM oauth_access_tokens WHERE id=?',(claims['jti'],)).fetchone()[0]==1
|
||||
status,machine=request({'grant_type':'client_credentials','client_id':'2','client_secret':'secret','scope':'*'})
|
||||
assert status==200,(status,machine)
|
||||
assert 'refresh_token' not in machine
|
||||
machine_claims=json.loads(base64.urlsafe_b64decode(machine['access_token'].split('.')[1]+'=='))
|
||||
assert machine_claims['sub']=='2' and machine_claims['scopes']==['*']
|
||||
assert con.execute('SELECT user_id FROM oauth_access_tokens WHERE id=?',(machine_claims['jti'],)).fetchone()[0] is None
|
||||
print(json.dumps({'php_authorization_code_pkce':'passed','rust_jwt_php_signature':'passed','rust_refresh_php_decryption':'passed','php_refresh_rotation_replay':'passed','client_credentials_wildcard':'passed'}))
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:proc.kill();proc.wait()
|
||||
|
||||
if __name__=='__main__':main()
|
||||
Reference in new issue
Block a user