filename = $outName; $gpg = trim((string) @shell_exec('command -v gpg 2>/dev/null')); if ($gpg === '') { $res->error = 'gpg is not installed on this server.'; return $res; } if (!is_readable(KEY_FILE)) { $res->error = 'Private key not found: ' . basename(KEY_FILE); return $res; } if (!is_readable(PASSPHRASE_FILE)) { $res->error = 'Passphrase file not found: ' . basename(PASSPHRASE_FILE) . ' — create it and put the key passphrase inside.'; return $res; } $passphrase = rtrim((string) file_get_contents(PASSPHRASE_FILE), "\r\n"); if ($passphrase === '') { $res->error = 'Passphrase file is empty.'; return $res; } // Isolated, throwaway keyring. $home = sys_get_temp_dir() . '/pgpdec_' . bin2hex(random_bytes(8)); if (!mkdir($home, 0700) && !is_dir($home)) { $res->error = 'Could not create temporary keyring.'; return $res; } try { // 1) Import the private key into the isolated home. $import = run_gpg($gpg, $home, ['--batch', '--quiet', '--import', KEY_FILE], ''); if ($import['code'] !== 0 && stripos($import['stderr'], 'secret key imported') === false) { $res->error = 'Key import failed.'; $res->log = $import['stderr']; return $res; } // 2) Decrypt. Ciphertext is a file argument; passphrase comes via stdin. $dec = run_gpg($gpg, $home, [ '--batch', '--yes', '--quiet', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '--decrypt', $cipherPath, ], $passphrase, true); if ($dec['code'] !== 0) { $res->error = 'Decryption failed — check the passphrase and that this key can decrypt the file.'; $res->log = $dec['stderr']; return $res; } $res->ok = true; $res->plaintext = $dec['stdout']; $res->log = $dec['stderr']; return $res; } finally { rrmdir($home); } } /** * Invoke gpg with an isolated GNUPGHOME. Passphrase/other input goes to stdin. * @return array{code:int,stdout:string,stderr:string} */ function run_gpg(string $gpg, string $home, array $args, string $stdin, bool $binaryOut = false): array { $cmd = escapeshellarg($gpg); foreach ($args as $a) { $cmd .= ' ' . escapeshellarg($a); } $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $env = ['GNUPGHOME' => $home, 'LC_ALL' => 'C', 'PATH' => getenv('PATH') ?: '/usr/bin:/bin:/usr/local/bin']; $proc = proc_open($cmd, $descriptors, $pipes, $home, $env); if (!is_resource($proc)) { return ['code' => 127, 'stdout' => '', 'stderr' => 'Failed to start gpg.']; } fwrite($pipes[0], $stdin); fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($proc); return ['code' => $code, 'stdout' => (string) $stdout, 'stderr' => (string) $stderr]; } /** Recursively remove a directory. */ function rrmdir(string $dir): void { if (!is_dir($dir)) { return; } foreach (scandir($dir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir . '/' . $entry; is_dir($path) ? rrmdir($path) : @unlink($path); } @rmdir($dir); } /** Strip a .pgp/.gpg/.asc suffix for the output filename. */ function output_name(string $name): string { $base = basename($name); foreach (['.pgp', '.gpg', '.asc'] as $ext) { if (str_ends_with(strtolower($base), $ext)) { return substr($base, 0, -strlen($ext)); } } return $base . '.decrypted'; } // --------------------------------------------------------------------------- // Request handling // --------------------------------------------------------------------------- $error = ''; $log = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $result = null; // Mode A: uploaded file. if (!empty($_FILES['cipher']['tmp_name']) && is_uploaded_file($_FILES['cipher']['tmp_name'])) { if (($_FILES['cipher']['size'] ?? 0) > MAX_UPLOAD) { $error = 'Uploaded file is too large (max ' . (MAX_UPLOAD / 1024 / 1024) . ' MB).'; } else { $result = decrypt_file( $_FILES['cipher']['tmp_name'], output_name((string) ($_FILES['cipher']['name'] ?? 'upload')) ); } } else { $error = 'Choose a file to decrypt.'; } if ($result !== null) { if ($result->ok) { // Stream the decrypted content as a download. header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . str_replace('"', '', $result->filename) . '"'); header('Content-Length: ' . strlen($result->plaintext)); header('X-Content-Type-Options: nosniff'); echo $result->plaintext; exit; } $error = $result->error; $log = $result->log; } } ?>
= htmlspecialchars(basename(KEY_FILE)) ?>). The key is passphrase-protected;
the passphrase is read from a separate file and is never displayed or sent to the browser.
Decryption runs in an isolated, throwaway keyring and the result is streamed back as a download.
= htmlspecialchars($log) ?>
= htmlspecialchars(basename(PASSPHRASE_FILE)) ?> is missing.
Create it in this folder and put the private-key passphrase inside before decrypting.
Keep = htmlspecialchars(basename(PASSPHRASE_FILE)) ?> and
= htmlspecialchars(basename(KEY_FILE)) ?> out of the web root or blocked from direct
access (see the bundled .htaccess). Anyone who can reach this page can decrypt files with this key.