215 lines
		
	
	
	
		
			7.5 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			215 lines
		
	
	
	
		
			7.5 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?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.8–8.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);
 | ||
| }
 | ||
| 
 |