$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']);