1
0
Fork 0
php-sqlcipher/tests/test_sqlcipher.php
2025-10-14 17:40:53 +11:00

215 lines
7.5 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/**
* SQLCipher verification script
*
* WHAT IT CHECKS
* 1) PRAGMA key is correctly applied (and cipher_version is non-empty).
* 2) PRAGMA cipher_integrity_check returns NO ROWS (OK).
* 3) File header is NOT the plain "SQLite format 3\0".
* 4) Inserting a known plaintext marker does NOT make that marker appear in the raw file.
* 5) Opening with a wrong key fails to validate via cipher_integrity_check.
* 6) First-page entropy is high (sanity check).
*/
// ---------------------------- Utilities ----------------------------
function makePrivateTempDir(?string $base = null): string {
$base = $base ?? sys_get_temp_dir();
$oldUmask = umask(0077);
try {
for ($i = 0; $i < 5; $i++) {
$name = bin2hex(random_bytes(16));
$dir = $base . DIRECTORY_SEPARATOR . "php-$name";
if (@mkdir($dir, 0700)) {
return realpath($dir) ?: $dir;
}
}
throw new RuntimeException("Could not create a unique temp directory");
} finally {
umask($oldUmask);
}
}
/** Simple pretty assertion helper */
function check(string $label, bool $ok, ?string $detail = null): void {
$prefix = $ok ? "[OK] " : "[FAIL] ";
echo $prefix . $label . ($detail ? "$detail" : "") . "\n";
if (!$ok) {
exit(1);
}
}
/** Return true if the file begins with the plain SQLite header */
function hasPlainSQLiteHeader(string $path): bool {
$fh = @fopen($path, 'rb');
if (!$fh) {
throw new RuntimeException("Cannot open $path");
}
$hdr = fread($fh, 16);
fclose($fh);
return $hdr === "SQLite format 3\0";
}
/** Shannon entropy (bits/byte) */
function shannonEntropy(string $bytes): float {
$len = strlen($bytes);
if ($len === 0) return 0.0;
$freq = count_chars($bytes, 1);
$h = 0.0;
foreach ($freq as $n) {
$p = $n / $len;
$h -= $p * log($p, 2);
}
return $h;
}
/** Read first page (minus the first 16 bytes salt) and estimate entropy */
function firstPageEntropy(string $path, int $pageSize = 4096): float {
$fh = @fopen($path, 'rb');
if (!$fh) throw new RuntimeException("Cannot open $path");
$page = fread($fh, $pageSize);
fclose($fh);
if ($page === false || strlen($page) === 0) return 0.0;
// SQLCipher uses first 16 bytes as salt at offset 0
$slice = substr($page, 16);
return shannonEntropy($slice);
}
/** Open PDO with SQLCipher key applied */
function pdoWithKey(string $path, string $passphrase): PDO {
$dbh = new PDO("sqlite:" . $path);
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$dbh->exec("PRAGMA key = " . $dbh->quote($passphrase));
$dbh->exec("PRAGMA cipher_memory_security = ON");
return $dbh;
}
/** Return a single scalar from "PRAGMA xyz;" */
function pragmaOne(PDO $dbh, string $pragma): ?string {
$stmt = $dbh->query("PRAGMA $pragma;");
$row = $stmt ? $stmt->fetch(PDO::FETCH_NUM) : false;
return $row && isset($row[0]) ? (string)$row[0] : null;
}
/**
* cipher_integrity_check returns:
* - zero rows => OK (externally consistent)
* - one or more rows => errors found (or wrong key)
*/
function cipherIntegrityOk(PDO $dbh): bool {
$stmt = $dbh->query("PRAGMA cipher_integrity_check;");
if (!$stmt) return false; // query failed -> not OK
$row = $stmt->fetch(PDO::FETCH_NUM);
return ($row === false); // no rows == OK
}
// ---------------------------- Main actions ----------------------------
function createAndPopulate(string $path, string $passphrase, string $marker): void {
$dbh = pdoWithKey($path, $passphrase);
// Basic SQLCipher sanity: cipher_version should be non-empty
$cipherVersion = pragmaOne($dbh, "cipher_version");
check("cipher_version available", !empty($cipherVersion), $cipherVersion ?? "(empty)");
// Create table and insert two rows (one is the plaintext 'marker')
$dbh->exec("CREATE TABLE IF NOT EXISTS users (name TEXT NOT NULL)");
$stmt = $dbh->prepare("INSERT INTO users(name) VALUES(:name)");
$stmt->execute([":name" => "mig5"]);
$stmt->execute([":name" => $marker]);
// Confirm we can read back with the correct key
$names = $dbh->query("SELECT name FROM users ORDER BY rowid")->fetchAll(PDO::FETCH_COLUMN, 0);
check("Read back inserted rows", in_array("mig5", $names, true) && in_array($marker, $names, true));
// Integrity check should be OK (i.e., returns no rows)
$ok = cipherIntegrityOk($dbh);
check("cipher_integrity_check", $ok, $ok ? "no rows (OK)" : "reported errors");
// Capture page_size (for entropy sampling)
$pageSize = (int)(pragmaOne($dbh, "page_size") ?? 4096);
$dbh = null; // close
// Store page size to a sidecar file so we can read it after closing the DB
file_put_contents($path . ".pagesize", (string)$pageSize);
}
function probeWrongKey(string $path, string $wrongKey): void {
try {
$dbh = pdoWithKey($path, $wrongKey);
// With the wrong key, integrity should NOT be OK (either rows returned or errors)
$okWrong = false;
try {
$okWrong = cipherIntegrityOk($dbh); // true means "no rows" => (unexpected)
} catch (Throwable $e) {
// Expected scenarios with wrong key can throw; treat as failure (which is good here)
$okWrong = false;
}
if ($okWrong) {
check("Wrong key probe (unexpectedly OK)", false);
} else {
check("Wrong key probe (integrity fails as expected)", true);
}
} catch (Throwable $e) {
// Exception creating/using the handle is also acceptable evidence key is wrong
check("Wrong key probe (exception on use)", true, get_class($e) . ": " . $e->getMessage());
}
}
function scanForPlaintextMarker(string $path, string $marker): void {
$raw = file_get_contents($path);
$found = ($raw !== false) && (strpos($raw, $marker) !== false);
check("Plaintext marker NOT present in raw file", !$found, $found ? "marker leaked to disk" : null);
}
function headerIsNotPlainSQLite(string $path): void {
$plain = hasPlainSQLiteHeader($path);
check("Header is not plain 'SQLite format 3\\0'", !$plain, $plain ? "found plain SQLite header" : null);
}
function entropyCheck(string $path): void {
$pageSizePath = $path . ".pagesize";
$pageSize = 4096;
if (is_file($pageSizePath)) {
$ps = (int)trim((string)file_get_contents($pageSizePath));
if ($ps > 0) $pageSize = $ps;
}
$H = firstPageEntropy($path, $pageSize);
// Encrypted data typically ~7.88.0 bits/byte. Use a lenient threshold.
$ok = $H >= 7.5;
check("First-page entropy high (>= 7.5 bits/byte)", $ok, sprintf("H=%.3f (page=%d)", $H, $pageSize));
@unlink($pageSizePath);
}
// ---------------------------- Run ----------------------------
try {
$dir = makePrivateTempDir();
$file = $dir . DIRECTORY_SEPARATOR . 'test.db';
$passphrase = "super-secret-passphrase-for-sqlcipher-pragma";
$wrongKey = "definitely-the-wrong-key";
$marker = "PLAINTEXT_MARKER_" . bin2hex(random_bytes(8));
echo "Creating DB at: $file\n";
createAndPopulate($file, $passphrase, $marker);
headerIsNotPlainSQLite($file);
scanForPlaintextMarker($file, $marker);
probeWrongKey($file, $wrongKey);
entropyCheck($file);
echo "\nAll checks passed.\n";
} catch (Throwable $e) {
fwrite(STDERR, "Error: " . get_class($e) . ": " . $e->getMessage() . "\n");
exit(1);
} finally {
// Cleanup
if (isset($file) && is_file($file)) @unlink($file);
if (isset($dir) && is_dir($dir)) @rmdir($dir);
}