2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); openssl_pkey_export($key, $private_key); $public_key = openssl_pkey_get_details($key)['key']; $payload = "\n"; $xml .= " \n"; $xml .= " Untrusted Package\n"; $xml .= " Mallory\n"; $xml .= " http://example.invalid\n"; $xml .= " mallory@example.invalid\n"; $xml .= " \n"; $xml .= " \n"; $xml .= " scripts\n"; $xml .= " \n"; $xml .= " \n"; $xml .= " \n"; $xml .= " scripts/planted.php\n"; openssl_sign($payload, $file_sig, $private_key, $algorithm); $xml .= ' ' . base64_encode($payload) . "\n"; $xml .= ' ' . base64_encode($file_sig) . "\n"; $xml .= " \n"; $xml .= " \n"; $xml .= " Mallory\n"; if ($names_key) { $xml .= ' ' . base64_encode($public_key) . "\n"; } openssl_sign($xml . " \n", $base_sig, $private_key, $algorithm); $xml .= ' ' . base64_encode($base_sig) . "\n"; $path = sys_get_temp_dir() . '/cacti_pkg_trust_' . bin2hex(random_bytes(4)) . '.xml.gz'; $f = fopen("compress.zlib://$path", 'wb'); fwrite($f, $xml, strlen($xml)); fclose($f); return $path; } function importSourceFile(string $name): string { $src = file_get_contents(dirname(__DIR__, 4) . "/$name"); expect($src)->not->toBeFalse("Failed to read $name"); return $src; } beforeEach(function () { $this->package_key = ''; $this->package = selfSignedPackage($this->package_key); }); afterEach(function () { if (file_exists($this->package)) { unlink($this->package); } }); // --- the gate --- test('import_validate_signature returns a strict bool', function () { // The array return was the defect: any populated result read as a pass. expect((string) (new ReflectionFunction('import_validate_signature'))->getReturnType())->toBe('bool'); }); test('a package signed with its own key is not trusted', function () { expect(import_validate_signature($this->package))->toBeFalse(); }); test('a package that names no key is not trusted', function () { // import_package_get_public_key() substitutes the built in Cacti key when // the element is absent, so the gate must not ask it for the verdict. $key = ''; $package = selfSignedPackage($key, false); try { expect(import_get_package_info($package)['pubkey'])->toBe(''); expect(import_validate_signature($package))->toBeFalse(); } finally { unlink($package); } }); test('the shipped device packages pass the trust gate', function () { // Every install/templates Package is signed with the Cacti key and is // imported by cli/import_package.php on a fresh install. Rejecting them // breaks the install, so the whole set is checked rather than a sample. $packages = glob(dirname(__DIR__, 4) . '/install/templates/*.xml.gz'); expect($packages)->not->toBeEmpty(); foreach ($packages as $package) { expect(import_validate_signature($package))->toBeTrue(basename($package) . ' is not trusted'); } }); test('a shipped package key differs from the built in key only in trailing whitespace', function () { // This is what a strict compare of the raw bytes tripped over. $package = dirname(__DIR__, 4) . '/install/templates/Local_Linux_Machine.xml.gz'; $pubkey = import_get_package_info($package)['pubkey']; expect($pubkey)->not->toBe(get_public_key_sha256()); expect(trim($pubkey))->toBe(get_public_key_sha256()); }); test('a real import of a self-signed package is refused', function () { $public_key = ''; expect(import_read_package_data($this->package, $public_key, false))->toBeFalse(); }); test('a preview of an untrusted package is still readable', function () { // A preview writes neither files nor rows, so it stays available to let an // operator inspect a Package before deciding to trust its signer. $public_key = ''; $data = import_read_package_data($this->package, $public_key, true); expect($data)->toBeArray(); expect($data['files']['file']['name'])->toBe('scripts/planted.php'); }); test('the gate no longer short circuits before it reads the verdict', function () { expect(importSourceFile('lib/import.php')) ->not->toContain('!import_validate_signature($xmlfile) && !$preview'); }); // --- accepted keys, and why auto-accepting them was the second hole --- test('a key in package_public_keys makes the same package importable', function () { 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)'); // import_validate_public_key() stores the pubkey off the Package verbatim, // so an already accepted row carries the trailing newline. expect(str_ends_with($this->package_key, "\n"))->toBeTrue(); $insert = $conn->prepare('INSERT INTO package_public_keys (public_key) VALUES (?)'); $insert->execute([$this->package_key]); $database_sessions[$session] = $conn; try { $public_key = ''; expect(import_validate_signature($this->package))->toBeTrue(); expect(import_read_package_data($this->package, $public_key, false))->toBeArray(); } finally { if ($prior === null) { unset($database_sessions[$session]); } else { $database_sessions[$session] = $prior; } } }); test('a trusted key cannot make a SHA-1 signed package importable', function () { global $database_sessions, $database_hostname, $database_port, $database_default; $publicKey = ''; $package = selfSignedPackage($publicKey, true, OPENSSL_ALGO_SHA1); $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 (?)'); $insert->execute([$publicKey]); $database_sessions[$session] = $conn; try { // Trust identifies an accepted signer; it does not override the // cryptographic SHA-256 verification performed while reading. expect(import_validate_signature($package))->toBeTrue(); $validatedKey = ''; expect(import_read_package_data($package, $validatedKey, false))->toBeFalse(); } finally { unlink($package); if ($prior === null) { unset($database_sessions[$session]); } else { $database_sessions[$session] = $prior; } } }); test('trust_signer no longer reaches the import', function () { expect(importSourceFile('package_import.php'))->not->toContain('trust_signer'); expect(importSourceFile('include/global_settings.php'))->not->toContain('trust_signer'); }); test('a key is force trusted only by the accept action', function () { $src = importSourceFile('package_import.php'); $accept = strpos($src, 'function package_accept_key('); expect($accept)->not->toBeFalse(); $offset = 0; $found = 0; while (($pos = strpos($src, 'import_validate_public_key($xmlfile, true)', $offset)) !== false) { expect($pos)->toBeGreaterThan($accept); $found++; $offset = $pos + 1; } expect($found)->toBeGreaterThan(0); });