A standalone, self-hosted Flask web interface for GPG encrypt/decrypt operations. SQLite-backed message storage with multi-user support — everything stays on your machine.
- Encrypt/Decrypt — PGP message encryption and decryption in the browser
- Multi-user — per-user GPG homedirs, message databases, and keyrings
- Inbox — messages delivered to recipients on the same server, lazy decrypt
- Key Management — import, list, and delete public keys; auto-import on message receipt
- Dark/Light Mode — toggle with persistent cookie
- Admin Panel — create/delete users, reset passwords, view audit log, unlock accounts
- Confirmation Guard — optional passphrase before encrypt/decrypt operations
- CSRF Protection — double-submit cookie pattern on all forms
- REST API — full message CRUD via Bearer token auth
- HTTPS by default — self-signed CA + server cert auto-generated on first launch
- Mobile-friendly — responsive layout adapts to phone, tablet, and desktop
git clone https://github.com/Echo-Computing/echo-pgp-webui
cd echo-pgp-webui
pip install -r requirements-server.txt
# Create an admin account (password must be 8+ characters)
python pgp_webui.py --init-admin admin yourpassword admin@example.com
# Start the server — HTTPS certs are auto-generated on first launch
python3 pgp_webui.py
# Opens https://localhost:8765HTTPS note: The server uses a self-signed certificate. Your browser will show "Not private" or "Unsafe" on first visit — click Advanced → Proceed to localhost (unsafe). To suppress the warning on other devices, download the CA cert from Settings and install it.
- Python 3.8+ — python.org or Windows Store
- GnuPG — see OS-specific instructions below
- Flask + flask-cors + zeroconf —
pip install -r requirements-server.txt
Verify GPG is installed:
gpg --versionWindows (option A — Git Bash / MSYS2)
GPG is bundled with Git. If you installed Git, you already have GPG:
gpg --version
# Should show "gpg (GnuPG) 2.4.x" from "C:\Program Files\Git\usr\bin\gpg.exe"Windows (option B — GnuPG standalone)
- Download from gpg4win.org and install
- The web UI auto-detects
C:\Program Files (x86)\GnuPG\bin\gpg.exe
WSL (Windows Subsystem for Linux)
sudo apt update && sudo apt install gnupg
gpg --versionSet your GPG home dir to a WSL-native path:
export GNUPGHOME="$HOME/.gnupg"
export PGP_DIR="$HOME/.gnupg"WSL Note: If you switch between Windows GPG and WSL GPG with the same homedir, key permissions and agent sockets can conflict. Use separate homedirs per environment.
macOS
brew install gnupg
gpg --versionLinux (Debian/Ubuntu)
sudo apt update && sudo apt install gnupg
gpg --versionLinux (Fedora/RHEL)
sudo dnf install gnupg
gpg --versionLinux (Arch)
sudo pacman -S gnupg
gpg --version| Variable | Required | Default | Description |
|---|---|---|---|
PGP_DIR |
No | Script's parent directory | Root directory for keys, certs, databases |
PGP_DB_PATH |
No | PGP_DIR/messages.db |
Path to legacy shared message DB |
PGP_WEBUI_PORT |
No | 8765 |
Port to listen on |
PGP_CLIPBOARD_CLEAR_SECONDS |
No | 30 |
Auto-clear clipboard after N seconds |
PGP_MAX_ATTEMPTS |
No | 5 |
Failed login attempts before lockout |
PGP_CORS_ORIGINS |
No | (empty) | Comma-separated allowed origins for API CORS |
SECRET_KEY |
No | random | Flask session secret |
PGP Vault supports multiple users with per-user isolation:
- Per-user GPG homedir — each user's private keys are isolated at
users/{username}/.gnupg/ - Per-user message database — each user has their own
users/{username}/messages.db - Per-user public keys — each user's keyring is separate; importing a key = adding a contact
- Per-user key passphrase — new keys are generated with a random 64-char passphrase, stored in
users/{username}/.gpg_passphrase - Admin panel — create/delete users, reset passwords, assign admin role
- Brute-force protection — 5 failed login attempts per IP triggers a 15-minute lockout
# Create the first admin user (password must be 8+ characters)
python pgp_webui.py --init-admin admin yourpassword admin@example.comAfter that, create additional users via the Admin → Users page in the web UI. Each new user automatically gets:
- A GPG keypair (RSA-4096, passphrase-protected)
- An isolated GPG homedir and message database
- Other users' public keys auto-imported into their keyring
PGP Vault works with anyone who uses PGP, not just people on your server. Here's how to communicate with friends, colleagues, or contacts on other platforms:
PGP uses public/private key pairs. You share your public key with others; they use it to encrypt messages that only your private key can decrypt. The reverse is also true — you encrypt with their public key, and they decrypt with their private key. The server never sees plaintext.
Your public key is at PGP_DIR/users/yourname/pubkey.asc — or go to Keys in the web UI to view and copy it. Share it via any channel:
- Email it as an attachment
- Post it on a keyserver (
gpg --send-keys YOUR_KEY_ID) - Share it via Signal, Telegram, USB drive, etc.
When someone sends you their public key:
- Go to Keys → Import a Friend's Public Key
- Paste the
-----BEGIN PGP PUBLIC KEY BLOCK-----block - Click Import Key
Their email now appears in your Recipient dropdown on the Compose page.
- Go to Compose
- Select your friend from the Recipient dropdown
- Type your message
- Click Encrypt & Send
The encrypted .asc file is stored in your Sent log. Copy or download the ciphertext and send it to your friend via any channel — email, Signal, Telegram, USB drive, carrier pigeon. Only they can decrypt it.
When someone sends you an encrypted .asc file:
- Copy the
-----BEGIN PGP MESSAGE-----block - Go to Compose → Decrypt panel
- Paste the ciphertext
- Click Decrypt — the plaintext appears below
Your friends don't need to install PGP Vault. They just need GPG on their own machine:
# They encrypt to you:
echo "Secret message" | gpg --armor --encrypt --recipient your@email.com --output message.asc
# They decrypt from you:
gpg --decrypt message.ascOr they can use any PGP-compatible tool: GPG4Win, Kleopatra, Mailvelope, OpenKeychain (Android), etc.
Use the Web UI as a local API for AI pipelines that need encrypted I/O.
All /api/* endpoints require a Bearer token (shown on the Settings page):
TOKEN=$(cat PGP_DIR/.auth_token)
curl -H "Authorization: Bearer $TOKEN" https://localhost:8765/api/messagesEncrypt and store a new message.
curl -X POST https://localhost:8765/api/messages \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"recipient": "friend@example.com", "plaintext": "Hello!", "subject": "Hi"}'List messages. Supports ?recipient=, ?since=, ?limit=, ?offset=.
Decrypt and return a single message by ID.
Delete a message from DB and disk.
Mark a message as read.
Kill GPG agent, wipe all messages and DB. Requires {"confirm": "yes"} and admin session.
| Route | Methods | Description |
|---|---|---|
/ |
GET | Redirect to /compose |
/login |
GET, POST | Login page |
/logout |
POST | Logout and clear session |
/compose |
GET, POST | Encrypt or decrypt messages |
/inbox |
GET | View received messages (lazy decrypt) |
/inbox/decrypt_file/<filename> |
GET | Decrypt a file inline (AJAX) |
/inbox/raw/<filename> |
GET | Serve raw .asc file content |
/inbox/delete_file/<filename> |
POST | Delete a .asc file from inbox/sent |
/sent |
GET | View sent messages |
/sent/clear |
POST | Clear the sent log (admin only) |
/keys |
GET, POST | List, import, or delete public keys |
/settings |
GET, POST | Confirmation guard, dark mode, environment info |
/settings/ca-cert |
GET | Download CA certificate |
/settings/regen-token |
POST | Regenerate API auth token (admin only) |
/settings/kill-agent |
POST | Kill gpg-agent and clear passphrase cache |
/admin/users |
GET, POST | Create, delete users, reset passwords |
/admin/audit |
GET | View failed login attempts |
/admin/unlock |
POST | Unlock locked IPs/usernames |
/toggle-dark |
POST | Toggle dark mode |
The Settings → Confirmation Guard adds a passphrase prompt before encrypt/decrypt operations. Useful on shared machines.
The GPG Agent (gpg-agent) caches your private key passphrase in memory. PGP Vault manages the passphrase automatically — each user's key is protected with a random 64-character passphrase stored in users/{username}/.gpg_passphrase (file permissions 0600).
To manually manage the agent:
gpgconf --kill gpg-agent # stop the agent
gpg-agent --daemon # start freshThe Settings → Kill Agent button in the web UI does the same thing.
Decrypted plaintext is copied to clipboard and auto-clears after 30 seconds (configurable via PGP_CLIPBOARD_CLEAR_SECONDS).
5 failed login attempts per IP triggers a 15-minute lockout. Admins can unlock from Admin → Audit.
echo-pgp-webui/
├── pgp_webui.py # Flask application
├── requirements-server.txt # pip install -r requirements-server.txt
├── README.md
├── LICENSE
└── .gitignore
PGP_DIR/ # set via PGP_DIR env var (default: script's parent directory)
├── sessions.db # SQLite — users, sessions, login attempts
├── .auth_token # Bearer token for API access
├── pgpvault.crt # TLS server certificate (auto-generated)
├── pgpvault.key # TLS server private key
├── pgpvault-ca.crt # TLS CA certificate
├── pgpvault-ca.key # TLS CA private key
└── users/
└── {username}/
├── .gnupg/ # per-user GPG homedir (private keys isolated)
├── .gpg_passphrase # per-user key passphrase (0600 permissions)
├── messages.db # per-user SQLite — message metadata + encrypted payloads
├── pubkey.asc # user's exported public key
├── inbox/ # received .asc files
└── sent/ # sent .asc files
You haven't imported your friend's public key yet. Go to Keys → Import a Friend's Public Key and paste their .asc block.
The key block may be malformed. Make sure you copied the full -----BEGIN PGP PUBLIC KEY BLOCK----- through -----END PGP PUBLIC KEY BLOCK----- lines.
GPG isn't in your system PATH. This is auto-detected in the latest version — make sure you're running the latest release.
PGP Vault manages passphrases automatically via --pinentry-mode loopback. If you see passphrase prompts, make sure you're on v2.2.0+ which handles this automatically.
pip install gunicorn
gunicorn --workers 2 --bind 0.0.0.0:8765 --keyfile PGP_DIR/pgpvault.key --certfile PGP_DIR/pgpvault.crt pgp_webui:appserver {
listen 443 ssl;
server_name pgpvault.example.com;
ssl_certificate /path/to/pgpvault-ca.crt;
ssl_certificate_key /path/to/pgpvault.key;
location / {
proxy_pass http://127.0.0.1:8765;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}When running behind a reverse proxy, add ProxyFix to pgp_webui.py:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)[Unit]
Description=PGP Vault Web UI
After=network.target
[Service]
Type=simple
User=pgpvault
WorkingDirectory=/opt/echo-pgp-webui
ExecStart=/opt/echo-pgp-webui/venv/bin/gunicorn --workers 2 --bind 0.0.0.0:8765 pgp_webui:app
Restart=on-failure
Environment=PGP_DIR=/opt/pgpvault-data
[Install]
WantedBy=multi-user.targetFROM python:3.12-slim
RUN apt-get update && apt-get install -y gnupg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements-server.txt .
RUN pip install --no-cache-dir -r requirements-server.txt gunicorn
COPY pgp_webui.py .
EXPOSE 8765
ENV PGP_DIR=/data
VOLUME /data
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:8765", "pgp_webui:app"]docker build -t pgpvault .
docker run -d -p 8765:8765 -v /path/to/pgp-data:/data pgpvault- Find your server's LAN IP (shown on startup, or
ip addr/ifconfig) - Install the CA certificate (
PGP_DIR/pgpvault-ca.crt) on client devices:- Windows: Double-click the
.crt→ Install Certificate → Local Machine → Trusted Root CA - macOS: Double-click → Add to Keychain → set to "Always Trust"
- Linux:
sudo cp pgpvault-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates - Android: Settings → Security → Install from storage
- iOS: Open the
.crtin Safari → Install Profile
- Windows: Double-click the
- Open
https://YOUR-LAN-IP:8765on the client device
| Concern | Mitigation |
|---|---|
| CSRF attacks | Double-submit cookie pattern on all POST routes |
| XSS attacks | html.escape() on all user input in HTML responses |
| SQL injection | Parameterized queries throughout |
| Brute force login | 5 failed attempts per IP → 15-minute lockout |
| Path traversal | Path(filename).name strips directory components |
| GPG injection | List-form subprocess args, regex-validated usernames/emails |
| Key passphrase leakage | --passphrase-file with temp files (never on command line) |
| Secret key exposure | Per-user users/{username}/.gnupg/ isolation |
| Message tampering | SHA-256 content hashes stored with each message |
| Auth token timing attacks | secrets.compare_digest() instead of == |
| Session hijacking | HttpOnly + Secure + SameSite=Lax cookies, 7-day expiry |
| Stack trace leakage | Generic error messages, GPG errors logged server-side only |
| CORS | Restricted to PGP_CORS_ORIGINS env var (empty by default) |
MIT — See LICENSE for details.