1
0
Fork 0

Initial commit

This commit is contained in:
Miguel Jacq 2025-10-14 17:40:53 +11:00
commit b10e7b0f5d
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
22 changed files with 1153 additions and 0 deletions

215
tests/test_sqlcipher.php Normal file
View file

@ -0,0 +1,215 @@
<?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);
}