$bits, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); } return $keys[$bits]; } function keyFloorPublicPem(OpenSSLAsymmetricKey $key): string { return openssl_pkey_get_details($key)['key']; } /* * Build a Package the way lib/package.php does, carrying one scripts/ file, * and signed with $key. Returns the .xml.gz path. */ function keyFloorPackage(OpenSSLAsymmetricKey $key, string $script): string { openssl_pkey_export($key, $private_key); $payload = "\n"; $xml .= " \n"; $xml .= " Key Floor Package\n"; $xml .= " Key Floor\n"; $xml .= " http://example.invalid\n"; $xml .= " keyfloor@example.invalid\n"; $xml .= " \n"; $xml .= " \n"; $xml .= " scripts\n"; $xml .= " \n"; $xml .= " \n"; $xml .= " \n"; $xml .= " $script\n"; openssl_sign($payload, $file_sig, $private_key, OPENSSL_ALGO_SHA256); $xml .= ' ' . base64_encode($payload) . "\n"; $xml .= ' ' . base64_encode($file_sig) . "\n"; $xml .= " \n"; $xml .= " \n"; $xml .= " Key Floor\n"; $xml .= ' ' . base64_encode(keyFloorPublicPem($key)) . "\n"; openssl_sign($xml . " \n", $base_sig, $private_key, OPENSSL_ALGO_SHA256); $xml .= ' ' . base64_encode($base_sig) . "\n"; $path = sys_get_temp_dir() . '/cacti_pkg_floor_' . bin2hex(random_bytes(4)) . '.xml.gz'; $f = fopen("compress.zlib://$path", 'wb'); fwrite($f, $xml, strlen($xml)); fclose($f); return $path; } function keyFloorSource(string $name): string { $src = file_get_contents(dirname(__DIR__, 4) . "/$name"); expect($src)->not->toBeFalse("Failed to read $name"); return $src; } /* * Run $body with package_public_keys holding $trusted, restoring the prior * database session afterwards. */ function keyFloorWithTrustedKeys(array $trusted, callable $body): void { global $database_sessions, $database_hostname, $database_port, $database_default; $session = "$database_hostname:$database_port:$database_default"; $prior = $database_sessions[$session] ?? null; $conn = new FakeMySQLPDO(); $conn->exec('CREATE TABLE package_public_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT)'); $insert = $conn->prepare('INSERT INTO package_public_keys (public_key) VALUES (?)'); foreach ($trusted as $key) { $insert->execute([$key]); } $database_sessions[$session] = $conn; try { $body(); } finally { if ($prior === null) { unset($database_sessions[$session]); } else { $database_sessions[$session] = $prior; } } } beforeEach(function () { $this->script = 'scripts/key_floor_' . bin2hex(random_bytes(4)) . '.php'; $this->target = CACTI_PATH_BASE . '/' . $this->script; $this->files = []; }); afterEach(function () { foreach ($this->files as $file) { if (file_exists($file)) { unlink($file); } } if (file_exists($this->target)) { unlink($this->target); } }); // --- the built in keys --- test('the legacy 512-bit Cacti key is not trusted', function () { expect(is_cacti_public_key(get_public_key()))->toBeFalse(); expect(is_cacti_public_key(get_public_key_sha1()))->toBeFalse(); }); test('the 2048-bit Cacti key is trusted with or without a trailing newline', function () { expect(is_cacti_public_key(get_public_key_sha256()))->toBeTrue(); expect(is_cacti_public_key(get_public_key_sha256() . "\n"))->toBeTrue(); }); test('the legacy Cacti key fails the floor and the current one passes', function () { expect(import_public_key_meets_minimum(get_public_key()))->toBeFalse(); expect(import_public_key_meets_minimum(get_public_key_sha256()))->toBeTrue(); }); // --- the helper --- test('the floor rejects RSA keys below 2048 bits', function (int $bits) { expect(import_public_key_meets_minimum(keyFloorPublicPem(keyFloorRsaKey($bits))))->toBeFalse(); })->with([512, 1024]); test('the floor accepts RSA keys of 2048 bits and more', function (int $bits) { expect(import_public_key_meets_minimum(keyFloorPublicPem(keyFloorRsaKey($bits))))->toBeTrue(); })->with([2048, 4096]); test('the floor rejects a key that is not RSA', function () { $key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); expect(import_public_key_meets_minimum(keyFloorPublicPem($key)))->toBeFalse(); }); test('the floor rejects input that is not a public key', function (string $input) { expect(import_public_key_meets_minimum($input))->toBeFalse(); })->with([ 'empty' => '', 'garbage' => 'not a key', 'header' => "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----", ]); test('the floor is one constant shared with 1.2.x', function () { expect(IMPORT_PACKAGE_KEY_MIN_BITS)->toBe(2048); }); // --- the import path --- test('a package signed with a trusted 1024-bit key is refused and writes no files', function () { $key = keyFloorRsaKey(1024); $package = keyFloorPackage($key, $this->script); $this->files[] = $package; keyFloorWithTrustedKeys([keyFloorPublicPem($key)], function () use ($package) { $public_key = ''; expect(import_validate_signature($package))->toBeFalse(); expect(import_read_package_data($package, $public_key, false))->toBeFalse(); expect(import_package($package, 1, false, false, false, false, false))->toBeFalse(); }); expect(file_exists($this->target))->toBeFalse(); }); test('a preview of a package signed with a 1024-bit key is refused', function () { // A preview skips the trust gate, so the floor must hold on its own. $package = keyFloorPackage(keyFloorRsaKey(1024), $this->script); $this->files[] = $package; $public_key = ''; expect(import_read_package_data($package, $public_key, true))->toBeFalse(); expect(import_package($package, 1, false, false, true, false, false))->toBeFalse(); expect(file_exists($this->target))->toBeFalse(); }); test('a package signed with a trusted 2048-bit key still imports', function () { $key = keyFloorRsaKey(2048); $package = keyFloorPackage($key, $this->script); $this->files[] = $package; keyFloorWithTrustedKeys([keyFloorPublicPem($key)], function () use ($package) { expect(import_validate_signature($package))->toBeTrue(); expect(import_package($package, 1, false, false, false, false, false))->toBeArray(); }); expect(file_exists($this->target))->toBeTrue(); }); test('the shipped device packages still verify', function () { $packages = glob(dirname(__DIR__, 4) . '/install/templates/*.xml.gz'); expect($packages)->not->toBeEmpty(); foreach ($packages as $package) { expect(import_package($package, 1, false, false, false, true, false)) ->toBeArray(basename($package) . ' no longer verifies'); } }); // --- every verify and every stored key goes through the floor --- test('import_validate_public_key checks the floor before it trusts or stores a key', function () { $src = keyFloorSource('package_import.php'); $start = strpos($src, 'function import_validate_public_key('); expect($start)->not->toBeFalse(); $end = strpos($src, "\nfunction ", $start + 1); $body = substr($src, $start, $end - $start); $floor = strpos($body, 'import_public_key_meets_minimum($info[\'pubkey\'])'); $lookup = strpos($body, 'FROM package_public_keys'); $legacy = strpos($body, '$public_key1 == $info[\'pubkey\']'); $insert = strpos($body, 'INSERT IGNORE INTO package_public_keys'); // The legacy key comparison is only safe because the floor runs first. expect($floor)->not->toBeFalse(); expect($floor)->toBeLessThan($lookup); expect($floor)->toBeLessThan($legacy); expect($floor)->toBeLessThan($insert); }); test('every openssl_verify of a package is guarded by the floor', function (string $file) { $src = keyFloorSource($file); $verify = preg_match_all('/openssl_verify\(/', $src); $guarded = preg_match_all('/import_public_key_meets_minimum\(\$public_key\)/', $src); expect($verify)->toBeGreaterThan(0); expect($guarded)->toBeGreaterThanOrEqual($verify); })->with(['lib/import.php', 'package_import.php']);