pgp-decrypt.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Web-based PGP decryptor.
  5. *
  6. * Decrypts a PGP-encrypted attachment using the predefined private key in this
  7. * folder (pgp-secret-keys.asc). The key is passphrase-protected; the passphrase
  8. * is read from a SEPARATE file (passphrase.txt) that is never web-served.
  9. *
  10. * The private key is imported into a throwaway, isolated GnuPG home per request,
  11. * which is deleted afterwards, so the server's real keyring is never touched.
  12. */
  13. const KEY_FILE = __DIR__ . '/pgp-secret-keys.asc';
  14. const PASSPHRASE_FILE = __DIR__ . '/passphrase.txt';
  15. const MAX_UPLOAD = 25 * 1024 * 1024; // 25 MB
  16. /** Result of a decryption attempt. */
  17. final class DecryptResult
  18. {
  19. public bool $ok = false;
  20. public string $error = '';
  21. public string $plaintext = '';
  22. public string $filename = 'decrypted.bin';
  23. public string $log = '';
  24. }
  25. /**
  26. * Run gpg to decrypt $cipherPath, feeding the passphrase over stdin.
  27. * Returns the plaintext or an error.
  28. */
  29. function decrypt_file(string $cipherPath, string $outName): DecryptResult
  30. {
  31. $res = new DecryptResult();
  32. $res->filename = $outName;
  33. $gpg = trim((string) @shell_exec('command -v gpg 2>/dev/null'));
  34. if ($gpg === '') {
  35. $res->error = 'gpg is not installed on this server.';
  36. return $res;
  37. }
  38. if (!is_readable(KEY_FILE)) {
  39. $res->error = 'Private key not found: ' . basename(KEY_FILE);
  40. return $res;
  41. }
  42. if (!is_readable(PASSPHRASE_FILE)) {
  43. $res->error = 'Passphrase file not found: ' . basename(PASSPHRASE_FILE)
  44. . ' — create it and put the key passphrase inside.';
  45. return $res;
  46. }
  47. $passphrase = rtrim((string) file_get_contents(PASSPHRASE_FILE), "\r\n");
  48. if ($passphrase === '') {
  49. $res->error = 'Passphrase file is empty.';
  50. return $res;
  51. }
  52. // Isolated, throwaway keyring.
  53. $home = sys_get_temp_dir() . '/pgpdec_' . bin2hex(random_bytes(8));
  54. if (!mkdir($home, 0700) && !is_dir($home)) {
  55. $res->error = 'Could not create temporary keyring.';
  56. return $res;
  57. }
  58. try {
  59. // 1) Import the private key into the isolated home.
  60. $import = run_gpg($gpg, $home, ['--batch', '--quiet', '--import', KEY_FILE], '');
  61. if ($import['code'] !== 0 && stripos($import['stderr'], 'secret key imported') === false) {
  62. $res->error = 'Key import failed.';
  63. $res->log = $import['stderr'];
  64. return $res;
  65. }
  66. // 2) Decrypt. Ciphertext is a file argument; passphrase comes via stdin.
  67. $dec = run_gpg($gpg, $home, [
  68. '--batch', '--yes', '--quiet',
  69. '--pinentry-mode', 'loopback',
  70. '--passphrase-fd', '0',
  71. '--decrypt', $cipherPath,
  72. ], $passphrase, true);
  73. if ($dec['code'] !== 0) {
  74. $res->error = 'Decryption failed — check the passphrase and that this key can decrypt the file.';
  75. $res->log = $dec['stderr'];
  76. return $res;
  77. }
  78. $res->ok = true;
  79. $res->plaintext = $dec['stdout'];
  80. $res->log = $dec['stderr'];
  81. return $res;
  82. } finally {
  83. rrmdir($home);
  84. }
  85. }
  86. /**
  87. * Invoke gpg with an isolated GNUPGHOME. Passphrase/other input goes to stdin.
  88. * @return array{code:int,stdout:string,stderr:string}
  89. */
  90. function run_gpg(string $gpg, string $home, array $args, string $stdin, bool $binaryOut = false): array
  91. {
  92. $cmd = escapeshellarg($gpg);
  93. foreach ($args as $a) {
  94. $cmd .= ' ' . escapeshellarg($a);
  95. }
  96. $descriptors = [
  97. 0 => ['pipe', 'r'],
  98. 1 => ['pipe', 'w'],
  99. 2 => ['pipe', 'w'],
  100. ];
  101. $env = ['GNUPGHOME' => $home, 'LC_ALL' => 'C', 'PATH' => getenv('PATH') ?: '/usr/bin:/bin:/usr/local/bin'];
  102. $proc = proc_open($cmd, $descriptors, $pipes, $home, $env);
  103. if (!is_resource($proc)) {
  104. return ['code' => 127, 'stdout' => '', 'stderr' => 'Failed to start gpg.'];
  105. }
  106. fwrite($pipes[0], $stdin);
  107. fclose($pipes[0]);
  108. $stdout = stream_get_contents($pipes[1]);
  109. $stderr = stream_get_contents($pipes[2]);
  110. fclose($pipes[1]);
  111. fclose($pipes[2]);
  112. $code = proc_close($proc);
  113. return ['code' => $code, 'stdout' => (string) $stdout, 'stderr' => (string) $stderr];
  114. }
  115. /** Recursively remove a directory. */
  116. function rrmdir(string $dir): void
  117. {
  118. if (!is_dir($dir)) {
  119. return;
  120. }
  121. foreach (scandir($dir) ?: [] as $entry) {
  122. if ($entry === '.' || $entry === '..') {
  123. continue;
  124. }
  125. $path = $dir . '/' . $entry;
  126. is_dir($path) ? rrmdir($path) : @unlink($path);
  127. }
  128. @rmdir($dir);
  129. }
  130. /** Strip a .pgp/.gpg/.asc suffix for the output filename. */
  131. function output_name(string $name): string
  132. {
  133. $base = basename($name);
  134. foreach (['.pgp', '.gpg', '.asc'] as $ext) {
  135. if (str_ends_with(strtolower($base), $ext)) {
  136. return substr($base, 0, -strlen($ext));
  137. }
  138. }
  139. return $base . '.decrypted';
  140. }
  141. // ---------------------------------------------------------------------------
  142. // Request handling
  143. // ---------------------------------------------------------------------------
  144. $error = '';
  145. $log = '';
  146. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  147. $result = null;
  148. // Mode A: uploaded file.
  149. if (!empty($_FILES['cipher']['tmp_name']) && is_uploaded_file($_FILES['cipher']['tmp_name'])) {
  150. if (($_FILES['cipher']['size'] ?? 0) > MAX_UPLOAD) {
  151. $error = 'Uploaded file is too large (max ' . (MAX_UPLOAD / 1024 / 1024) . ' MB).';
  152. } else {
  153. $result = decrypt_file(
  154. $_FILES['cipher']['tmp_name'],
  155. output_name((string) ($_FILES['cipher']['name'] ?? 'upload'))
  156. );
  157. }
  158. } else {
  159. $error = 'Choose a file to decrypt.';
  160. }
  161. if ($result !== null) {
  162. if ($result->ok) {
  163. // Stream the decrypted content as a download.
  164. header('Content-Type: application/octet-stream');
  165. header('Content-Disposition: attachment; filename="' . str_replace('"', '', $result->filename) . '"');
  166. header('Content-Length: ' . strlen($result->plaintext));
  167. header('X-Content-Type-Options: nosniff');
  168. echo $result->plaintext;
  169. exit;
  170. }
  171. $error = $result->error;
  172. $log = $result->log;
  173. }
  174. }
  175. ?>
  176. <!DOCTYPE html>
  177. <html lang="en">
  178. <head>
  179. <meta charset="UTF-8">
  180. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  181. <title>PGP Decryptor</title>
  182. <style>
  183. body {
  184. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  185. max-width: 1000px; margin: 40px auto; padding: 0 20px; background: #f5f5f5; color: #333;
  186. }
  187. h1 { color: #333; }
  188. .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
  189. .warn { background: #fff3e0; color: #e65100; padding: 12px 15px; border-radius: 5px; margin-bottom: 20px; font-size: 14px; }
  190. form.card { background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 6px; padding: 20px; margin-bottom: 20px; }
  191. form.card h2 { margin-top: 0; font-size: 18px; }
  192. input[type=file], select {
  193. padding: 10px; font-size: 15px; border: 1px solid #ccc; border-radius: 5px; background: white; width: 100%; box-sizing: border-box;
  194. }
  195. .btn {
  196. display: inline-block; margin-top: 14px; padding: 10px 20px; background: #2196F3; color: white;
  197. text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
  198. }
  199. .btn:hover { background: #1976D2; }
  200. .btn:disabled { background: #b0bec5; cursor: not-allowed; }
  201. .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
  202. details { margin-top: 12px; }
  203. summary { cursor: pointer; font-weight: 600; color: #1976D2; }
  204. pre { background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px; font-size: 12px; overflow-x: auto; line-height: 1.5; }
  205. .muted { color: #888; font-size: 12px; }
  206. </style>
  207. </head>
  208. <body>
  209. <h1>🔓 PGP Decryptor</h1>
  210. <div class="info">
  211. Decrypts a PGP-encrypted attachment using the <strong>predefined private key</strong> stored on the
  212. server (<code><?= htmlspecialchars(basename(KEY_FILE)) ?></code>). The key is passphrase-protected;
  213. the passphrase is read from a <strong>separate file</strong> and is never displayed or sent to the browser.
  214. Decryption runs in an isolated, throwaway keyring and the result is streamed back as a download.
  215. </div>
  216. <?php if ($error !== ''): ?>
  217. <div class="error"><?= htmlspecialchars($error) ?></div>
  218. <?php if ($log !== ''): ?>
  219. <details open><summary>gpg output</summary><pre><?= htmlspecialchars($log) ?></pre></details>
  220. <?php endif; ?>
  221. <?php endif; ?>
  222. <?php if (!is_readable(PASSPHRASE_FILE)): ?>
  223. <div class="warn">
  224. ⚠️ Passphrase file <code><?= htmlspecialchars(basename(PASSPHRASE_FILE)) ?></code> is missing.
  225. Create it in this folder and put the private-key passphrase inside before decrypting.
  226. </div>
  227. <?php endif; ?>
  228. <form class="card" method="POST" enctype="multipart/form-data">
  229. <h2>Decrypt an uploaded file</h2>
  230. <input type="file" name="cipher" accept=".pgp,.gpg,.asc,application/pgp-encrypted">
  231. <p class="muted">Max <?= (int) (MAX_UPLOAD / 1024 / 1024) ?> MB. The file must be encrypted to the key held on this server.</p>
  232. <button type="submit" class="btn">🔓 Decrypt &amp; download</button>
  233. </form>
  234. <p class="muted">
  235. Keep <code><?= htmlspecialchars(basename(PASSPHRASE_FILE)) ?></code> and
  236. <code><?= htmlspecialchars(basename(KEY_FILE)) ?></code> out of the web root or blocked from direct
  237. access (see the bundled <code>.htaccess</code>). Anyone who can reach this page can decrypt files with this key.
  238. </p>
  239. </body>
  240. </html>