| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- <?php
- declare(strict_types=1);
- /**
- * Web-based PGP decryptor.
- *
- * Decrypts a PGP-encrypted attachment using the predefined private key in this
- * folder (pgp-secret-keys.asc). The key is passphrase-protected; the passphrase
- * is read from a SEPARATE file (passphrase.txt) that is never web-served.
- *
- * The private key is imported into a throwaway, isolated GnuPG home per request,
- * which is deleted afterwards, so the server's real keyring is never touched.
- */
- const KEY_FILE = __DIR__ . '/pgp-secret-keys.asc';
- const PASSPHRASE_FILE = __DIR__ . '/passphrase.txt';
- const MAX_UPLOAD = 25 * 1024 * 1024; // 25 MB
- /** Result of a decryption attempt. */
- final class DecryptResult
- {
- public bool $ok = false;
- public string $error = '';
- public string $plaintext = '';
- public string $filename = 'decrypted.bin';
- public string $log = '';
- }
- /**
- * Run gpg to decrypt $cipherPath, feeding the passphrase over stdin.
- * Returns the plaintext or an error.
- */
- function decrypt_file(string $cipherPath, string $outName): DecryptResult
- {
- $res = new DecryptResult();
- $res->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;
- }
- }
- ?>
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>PGP Decryptor</title>
- <style>
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- max-width: 1000px; margin: 40px auto; padding: 0 20px; background: #f5f5f5; color: #333;
- }
- h1 { color: #333; }
- .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
- .warn { background: #fff3e0; color: #e65100; padding: 12px 15px; border-radius: 5px; margin-bottom: 20px; font-size: 14px; }
- form.card { background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 6px; padding: 20px; margin-bottom: 20px; }
- form.card h2 { margin-top: 0; font-size: 18px; }
- input[type=file], select {
- padding: 10px; font-size: 15px; border: 1px solid #ccc; border-radius: 5px; background: white; width: 100%; box-sizing: border-box;
- }
- .btn {
- display: inline-block; margin-top: 14px; padding: 10px 20px; background: #2196F3; color: white;
- text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
- }
- .btn:hover { background: #1976D2; }
- .btn:disabled { background: #b0bec5; cursor: not-allowed; }
- .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
- details { margin-top: 12px; }
- summary { cursor: pointer; font-weight: 600; color: #1976D2; }
- pre { background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px; font-size: 12px; overflow-x: auto; line-height: 1.5; }
- .muted { color: #888; font-size: 12px; }
- </style>
- </head>
- <body>
- <h1>🔓 PGP Decryptor</h1>
- <div class="info">
- Decrypts a PGP-encrypted attachment using the <strong>predefined private key</strong> stored on the
- server (<code><?= htmlspecialchars(basename(KEY_FILE)) ?></code>). The key is passphrase-protected;
- the passphrase is read from a <strong>separate file</strong> 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.
- </div>
- <?php if ($error !== ''): ?>
- <div class="error"><?= htmlspecialchars($error) ?></div>
- <?php if ($log !== ''): ?>
- <details open><summary>gpg output</summary><pre><?= htmlspecialchars($log) ?></pre></details>
- <?php endif; ?>
- <?php endif; ?>
- <?php if (!is_readable(PASSPHRASE_FILE)): ?>
- <div class="warn">
- ⚠️ Passphrase file <code><?= htmlspecialchars(basename(PASSPHRASE_FILE)) ?></code> is missing.
- Create it in this folder and put the private-key passphrase inside before decrypting.
- </div>
- <?php endif; ?>
- <form class="card" method="POST" enctype="multipart/form-data">
- <h2>Decrypt an uploaded file</h2>
- <input type="file" name="cipher" accept=".pgp,.gpg,.asc,application/pgp-encrypted">
- <p class="muted">Max <?= (int) (MAX_UPLOAD / 1024 / 1024) ?> MB. The file must be encrypted to the key held on this server.</p>
- <button type="submit" class="btn">🔓 Decrypt & download</button>
- </form>
- <p class="muted">
- Keep <code><?= htmlspecialchars(basename(PASSPHRASE_FILE)) ?></code> and
- <code><?= htmlspecialchars(basename(KEY_FILE)) ?></code> out of the web root or blocked from direct
- access (see the bundled <code>.htaccess</code>). Anyone who can reach this page can decrypt files with this key.
- </p>
- </body>
- </html>
|