|
|
@@ -0,0 +1,268 @@
|
|
|
+<?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>
|