소스 검색

adding pgp-decrypt

Josef Straßl 3 일 전
부모
커밋
5114427803
6개의 변경된 파일412개의 추가작업 그리고 0개의 파일을 삭제
  1. 8 0
      pgp-decrypt/.gitignore
  2. 14 0
      pgp-decrypt/.htaccess
  3. 43 0
      pgp-decrypt/README.md
  4. 78 0
      pgp-decrypt/decrypt.sh
  5. 1 0
      pgp-decrypt/passphrase.txt.example
  6. 268 0
      pgp-decrypt/pgp-decrypt.php

+ 8 - 0
pgp-decrypt/.gitignore

@@ -0,0 +1,8 @@
+# Secrets and decrypted output — never commit these
+passphrase.txt
+pgp-secret-keys.asc
+*.pgp
+*.gpg
+
+# Decrypted result(s)
+*.pdf

+ 14 - 0
pgp-decrypt/.htaccess

@@ -0,0 +1,14 @@
+# Block direct web access to the private key, passphrase, encrypted files,
+# the CLI script and the templates. Only pgp-decrypt.php should be reachable.
+
+<FilesMatch "\.(asc|pgp|gpg|sh|example)$|^passphrase\.txt$|^\.gitignore$">
+    # Apache 2.4+
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    # Apache 2.2 fallback
+    <IfModule !mod_authz_core.c>
+        Order allow,deny
+        Deny from all
+    </IfModule>
+</FilesMatch>

+ 43 - 0
pgp-decrypt/README.md

@@ -0,0 +1,43 @@
+# pgp-decrypt
+
+Decrypts a PGP-encrypted attachment using the private key stored in this folder.
+The private key is passphrase-protected, and the passphrase is kept in a
+**separate file** (`passphrase.txt`) so it never lives inside the script.
+
+## Files
+
+| File                     | Purpose                                                        |
+| ------------------------ | -------------------------------------------------------------- |
+| `decrypt.sh`             | The decryption script.                                         |
+| `pgp-secret-keys.asc`    | The passphrase-protected private key (CHECK24 Datenschutz).    |
+| `passphrase.txt`         | The key passphrase — **you fill this in**. Git-ignored.        |
+| `passphrase.txt.example` | Template for `passphrase.txt`.                                 |
+| `*.pgp`                  | The encrypted attachment(s) to decrypt.                        |
+
+## Setup
+
+1. Install GnuPG if needed: `brew install gnupg`
+2. Put the real passphrase into `passphrase.txt` (replace the placeholder):
+   ```sh
+   printf '%s' 'your-real-passphrase' > passphrase.txt
+   ```
+
+## Usage
+
+```sh
+# Auto-detect the single *.pgp in this folder, write the decrypted file next to it:
+./decrypt.sh
+
+# Or specify input and output explicitly:
+./decrypt.sh "Anschreiben Check24_ 251102-0536-IP6054.pdf.pgp" out.pdf
+```
+
+## Notes / security
+
+- The script imports the key into a **throwaway, isolated GnuPG home**
+  (`mktemp -d`), so your real `~/.gnupg` keyring is never touched, and the temp
+  keyring is deleted on exit.
+- `.gitignore` excludes the passphrase, the private key, the `*.pgp` inputs and
+  decrypted `*.pdf` output so secrets don't get committed. Adjust to taste.
+- The passphrase is passed to `gpg` via a file descriptor (`--passphrase-fd`),
+  not the command line, so it doesn't show up in the process list.

+ 78 - 0
pgp-decrypt/decrypt.sh

@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+#
+# decrypt.sh — Decrypt a PGP-encrypted attachment using the private key in this
+# folder. The private key is passphrase-protected; the passphrase is read from a
+# separate file (default: passphrase.txt).
+#
+# Usage:
+#   ./decrypt.sh [ENCRYPTED_FILE] [OUTPUT_FILE]
+#
+#   ENCRYPTED_FILE  Path to the .pgp/.gpg/.asc file to decrypt.
+#                   Defaults to the single *.pgp file in this folder.
+#   OUTPUT_FILE     Where to write the decrypted result.
+#                   Defaults to ENCRYPTED_FILE with its .pgp/.gpg suffix removed.
+#
+# The script imports the key into a throwaway, isolated GnuPG home so it never
+# touches your real ~/.gnupg keyring, then removes it on exit.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+KEY_FILE="${KEY_FILE:-$SCRIPT_DIR/pgp-secret-keys.asc}"
+PASSPHRASE_FILE="${PASSPHRASE_FILE:-$SCRIPT_DIR/passphrase.txt}"
+
+die() { echo "Error: $*" >&2; exit 1; }
+
+command -v gpg >/dev/null 2>&1 || die "gpg is not installed (try: brew install gnupg)"
+[ -f "$KEY_FILE" ]        || die "private key not found: $KEY_FILE"
+[ -f "$PASSPHRASE_FILE" ] || die "passphrase file not found: $PASSPHRASE_FILE (create it and put the key passphrase inside)"
+
+# --- Resolve the encrypted input file ---------------------------------------
+ENC_FILE="${1:-}"
+if [ -z "$ENC_FILE" ]; then
+  # Auto-pick the single *.pgp in this folder.
+  shopt -s nullglob
+  candidates=("$SCRIPT_DIR"/*.pgp)
+  shopt -u nullglob
+  case "${#candidates[@]}" in
+    0) die "no *.pgp file found in $SCRIPT_DIR — pass the file as the first argument" ;;
+    1) ENC_FILE="${candidates[0]}" ;;
+    *) die "multiple *.pgp files found — pass the one to decrypt as the first argument" ;;
+  esac
+fi
+[ -f "$ENC_FILE" ] || die "encrypted file not found: $ENC_FILE"
+
+# --- Resolve the output file -------------------------------------------------
+OUT_FILE="${2:-}"
+if [ -z "$OUT_FILE" ]; then
+  case "$ENC_FILE" in
+    *.pgp) OUT_FILE="${ENC_FILE%.pgp}" ;;
+    *.gpg) OUT_FILE="${ENC_FILE%.gpg}" ;;
+    *.asc) OUT_FILE="${ENC_FILE%.asc}" ;;
+    *)     OUT_FILE="${ENC_FILE}.decrypted" ;;
+  esac
+fi
+
+# Read passphrase from the separate file (strip a single trailing newline).
+PASSPHRASE="$(cat "$PASSPHRASE_FILE")"
+[ -n "$PASSPHRASE" ] || die "passphrase file is empty: $PASSPHRASE_FILE"
+
+# --- Isolated, throwaway keyring --------------------------------------------
+GNUPGHOME="$(mktemp -d)"
+export GNUPGHOME
+chmod 700 "$GNUPGHOME"
+cleanup() { rm -rf "$GNUPGHOME"; }
+trap cleanup EXIT
+
+echo "Importing private key ..." >&2
+gpg --batch --quiet --import "$KEY_FILE"
+
+echo "Decrypting: $(basename "$ENC_FILE")" >&2
+gpg --batch --yes --quiet \
+    --pinentry-mode loopback \
+    --passphrase-fd 3 \
+    --output "$OUT_FILE" \
+    --decrypt "$ENC_FILE" 3<<<"$PASSPHRASE"
+
+echo "Decrypted -> $OUT_FILE" >&2

+ 1 - 0
pgp-decrypt/passphrase.txt.example

@@ -0,0 +1 @@
+REPLACE_ME_WITH_THE_PRIVATE_KEY_PASSPHRASE

+ 268 - 0
pgp-decrypt/pgp-decrypt.php

@@ -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 &amp; 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>