122 lines
8.3 KiB
PHP
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.');
|
|
}
|