Files
Investidor-V2/tools/portal-qa-mfa.php
T
nala 6ddafc95ba
tests / ci (push) Canceled after 0s
oportunidades 4
2026-10-10 07:03:59 +02:00

122 lines
8.3 KiB
PHP

<?php
// QA diagnostics and synthetic actor only. Never print keys, secrets, cookies or OTPs.
use App\Models\User;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
$root = dirname(__DIR__);
require_once $root.'/tools/portal-qa-environment.php';
$target = portalQaEnvironment();
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$identity = portalQaAssertTarget($target); // Before any write or session invalidation.
$directory = $root.'/storage/app/private/investmoz-v2/portal-audit';
$original = DB::table('users')->where('id', 13)->first();
$originalHash = hash('sha256', json_encode($original, JSON_THROW_ON_ERROR));
$mode = $argv[1] ?? 'diagnose';
if ($mode === 'diagnose') {
$env = Dotenv\Dotenv::parse(file_get_contents($root.'/.env'));
$sourceKey = $env['APP_KEY'] ?? '';
$sourceKey = str_starts_with($sourceKey, 'base64:') ? base64_decode(substr($sourceKey, 7)) : $sourceKey;
$checks = [];
foreach (['two_factor_secret', 'two_factor_recovery_codes'] as $field) {
foreach (['qa' => app('encrypter'), 'source_env' => new Encrypter($sourceKey, config('app.cipher'))] as $label => $encrypter) {
try {
$value = $encrypter->decrypt($original->$field);
$checks[$field][$label] = is_string($value);
unset($value);
} catch (DecryptException $exception) {
$checks[$field][$label] = false;
}
}
}
$proof = ['target' => $identity, 'account_id' => 13, 'synthetic_account' => str_ends_with($original->email, '@example.invalid'),
'encrypted_field_checks' => $checks, 'qa_key_fingerprint' => hash('sha256', app('encrypter')->getKey()),
'source_key_differs' => ! hash_equals($sourceKey, app('encrypter')->getKey()), 'config_cache_loaded' => $app->configurationIsCached(),
'active_config_cache_touched' => false, 'source_env_file_sha256' => hash_file('sha256', $root.'/.env'),
'cache_store' => config('cache.default'), 'session_driver_web' => 'file', 'account_13_row_sha256' => $originalHash,
'active_writes' => 0, 'captured_at' => gmdate('c')];
unset($env, $sourceKey);
file_put_contents($directory.'/mfa-key-diagnosis-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof, JSON_THROW_ON_ERROR).PHP_EOL;
} elseif ($mode === 'invalidate') {
$sessions = realpath($root.'/storage/app/private/investmoz-v2/qa-opportunity-resources-sessions');
if (! $sessions || ! str_starts_with($sessions, realpath($root.'/storage/app/private').DIRECTORY_SEPARATOR)) {
throw new RuntimeException('Private QA session root required.');
}
$removed = 0;
foreach (new DirectoryIterator($sessions) as $file) {
if (! $file->isFile() || $file->isLink() || ! preg_match('/^[a-zA-Z0-9]{40}$/D', $file->getFilename())) {
continue;
}
$data = json_decode(file_get_contents($file->getPathname()), true, 512, JSON_THROW_ON_ERROR);
$belongs = is_array($data) && (int) ($data['login']['id'] ?? $data['login.id'] ?? 0) === 13;
foreach (is_array($data) ? $data : [] as $key => $value) {
$belongs = $belongs || (str_starts_with((string) $key, 'login_web_') && (int) $value === 13);
}
if ($belongs) {
$path = realpath($file->getPathname());
if (! $path || ! str_starts_with($path, $sessions.DIRECTORY_SEPARATOR)) {
throw new RuntimeException('Session path outside QA refused.');
}
if (! unlink($path)) {
throw new RuntimeException('QA session invalidation failed.');
}
$removed++;
}
}
$proof = ['target' => $identity, 'account_id' => 13, 'qa_sessions_invalidated' => $removed, 'account_row_unchanged' => hash_equals($originalHash, hash('sha256', json_encode(DB::table('users')->where('id', 13)->first(), JSON_THROW_ON_ERROR))),
'session_ids_exported' => false, 'active_writes' => 0, 'captured_at' => gmdate('c')];
file_put_contents($directory.'/mfa-session-invalidation-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
} elseif ($mode === 'prepare') {
$file = $directory.'/investor-mfa-qa-private.json';
$credentials = is_file($file) ? json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR)
: ['email' => 'investor-mfa-qa@example.invalid', 'password' => 'QA-'.bin2hex(random_bytes(24))];
$actor = User::firstOrCreate(['email' => $credentials['email']], ['name' => 'Synthetic investor MFA QA', 'password' => Hash::make($credentials['password'])]);
$actor->forceFill(['email_verified_at' => $actor->email_verified_at ?? now()])->save();
$credentials['actor_id'] = $actor->id;
file_put_contents($file, json_encode($credentials, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
chmod($file, 0600);
$negativeFile = $directory.'/mfa-negative-qa-private.json';
$negative = is_file($negativeFile) ? json_decode(file_get_contents($negativeFile), true, 512, JSON_THROW_ON_ERROR)
: ['email' => 'mfa-negative-qa@example.invalid', 'password' => 'QA-'.bin2hex(random_bytes(24))];
$failedActor = User::firstOrCreate(['email' => $negative['email']], ['name' => 'Synthetic wrong-key MFA negative control QA', 'password' => Hash::make($negative['password'])]);
if ($failedActor->two_factor_secret === null) {
$foreign = new Encrypter(random_bytes(32), 'AES-256-CBC');
$failedActor->forceFill(['email_verified_at' => now(), 'two_factor_secret' => $foreign->encrypt('SYNTHETICQAONLY'),
'two_factor_recovery_codes' => $foreign->encrypt('["synthetic-only"]'), 'two_factor_confirmed_at' => now()])->save();
}
$negative['actor_id'] = $failedActor->id;
file_put_contents($negativeFile, json_encode($negative, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
chmod($negativeFile, 0600);
echo json_encode(['target' => $identity, 'actor_id' => $actor->id, 'synthetic' => true, 'mfa_enrollment' => 'must use normal authenticated Fortify endpoints',
'negative_control_actor_id' => $failedActor->id, 'original_account_13_preserved' => hash_equals($originalHash, hash('sha256', json_encode(DB::table('users')->where('id', 13)->first(), JSON_THROW_ON_ERROR))), 'active_writes' => 0]).PHP_EOL;
} elseif ($mode === 'verify') {
$diagnosis = json_decode(file_get_contents($directory.'/mfa-key-diagnosis-qa.json'), true, 512, JSON_THROW_ON_ERROR);
$credentials = json_decode(file_get_contents($directory.'/investor-mfa-qa-private.json'), true, 512, JSON_THROW_ON_ERROR);
$actor = User::findOrFail($credentials['actor_id']);
$secret = app('encrypter')->decrypt($actor->two_factor_secret);
$codes = json_decode(app('encrypter')->decrypt($actor->two_factor_recovery_codes), true, 512, JSON_THROW_ON_ERROR);
$proof = ['target' => $identity, 'actor_id' => $actor->id, 'mfa_confirmed' => $actor->two_factor_confirmed_at !== null,
'normal_web_secret_decrypts_in_cli' => hash_equals($credentials['totp_secret'], $secret), 'recovery_code_count' => count($codes),
'key_fingerprint_unchanged' => hash_equals($diagnosis['qa_key_fingerprint'], hash('sha256', app('encrypter')->getKey())),
'source_env_unchanged' => hash_equals($diagnosis['source_env_file_sha256'], hash_file('sha256', $root.'/.env')),
'original_account_13_unchanged' => hash_equals($diagnosis['account_13_row_sha256'], $originalHash),
'key_generated_or_rotated' => false, 'mfa_disabled' => false, 'active_writes' => 0, 'captured_at' => gmdate('c')];
unset($secret, $codes, $credentials);
if (! $proof['mfa_confirmed'] || ! $proof['normal_web_secret_decrypts_in_cli'] || ! $proof['key_fingerprint_unchanged']
|| ! $proof['source_env_unchanged'] || ! $proof['original_account_13_unchanged'] || $proof['recovery_code_count'] !== 8) {
throw new RuntimeException('QA MFA persistence/config/preservation failed.');
}
file_put_contents($directory.'/mfa-persistence-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
} else {
throw new RuntimeException('Unknown QA operation.');
}