(If you want to skip to the part I ask the question, scroll down to the last section with the heading "Concrete question".)
I’m working on a home lab / learning project and would appreciate a design review and ideas for improving one specific part of it.
Context / setup
I’m self-hosting a Vaultwarden instance on a Linux box. Instead of the Docker setup, I’ve pulled out the Vaultwarden binary and wired it to use Postgres so I can practice replicas / backups later.
Security / networking basics:
Host is Linux.
All inbound ports are closed except SSH on 22.
SSH:
- Passkey-only auth.
- Root login disabled.
- I know I could restrict IPs further, but I explicitly don’t want to (multiple client machines).
- Please don’t focus on how insecure that is; I understand and accept the risks.
I’m using
ufwwith poddmo/ufw-blocklist at level 2+ for IP blocking.Outbound:
Cloudflared is used so the machine doesn’t need exposed inbound ports for the service.
Outbound is restricted except for:
- Package repositories,
- Cloudflared traffic,
- GitHub (for updates), etc.
Cloudflare side is rate-limited to 300 requests/min/IP.
How I’m handling Vaultwarden secrets
Vaultwarden reads a .env file that includes the ADMIN_TOKEN/ROCKET_SECRET_KEY/DATABASE_URL etc. For simplicity, think of a single secret_key used to encrypt/decrypt DB values.
My plan:
Store this
.envencrypted with GPG.At “unlock” time:
- Decrypt the
.envin memory only. - Pass the values directly to the Vaultwarden process via CLI or environment (e.g.
vaultwarden SECRET_KEY=<decrypted_secret_key> ...). - Never write the decrypted file to disk.
- Decrypt the
I want this decrypt/launch step to be remotely authorized from my phone using ntfy.
ntfy-based authorization flow
On machine boot:
A custom script starts:
- A small Python HTTP server listening on a specific route (fronted by Cloudflared).
Script sends an ntfy notification to a secret topic/channel.
On my phone, I see the notification and can:
- Approve → trigger decrypt+start Vaultwarden.
- Deny → the server shuts itself down.
To bind this to me specifically, not just “anyone who sees the ntfy topic,” I’m using:
An Ed25519 key pair:
- Public key stored on the host.
- Private key stored only on my phone.
The host sends a challenge via ntfy.
The phone signs/answers the challenge and returns:
- The challenge solution (proving possession of the private key).
- Plus some secret material used in the decryption process.
Current passphrase split idea
To avoid storing the full GPG passphrase on the host, I designed a 2-part scheme:
- Part 1 (P1): stored on the host as a random 32-byte hex string.
- Part 2 (P2): stored on my phone.
- The full GPG passphrase = some combination of P1 and P2 (e.g., concatenation).
Flow:
On reboot, host sends ntfy challenge.
Phone:
- Verifies the request looks legit.
- Signs/answers the challenge with its Ed25519 private key.
- Includes P2 in the response.
Host:
- Verifies challenge/signature with stored public key.
- Combines P1 (local) + P2 (remote) → full passphrase.
- Uses passphrase to decrypt
.envin memory and start Vaultwarden.
Problem / threat I’m stuck on
If an attacker gains unauthorized access to the host (say via an exploit, or via SSH with stolen keys, etc.) and can snoop on traffic/process memory on the host, then:
They can see P1 (it’s on disk or in memory).
When I legitimately approve the unlock from my phone, they can:
- See P2 in transit or in memory on the host.
- Reconstruct the full passphrase (P1 + P2).
Once they have the full passphrase, they can decrypt the
.envthemselves later, even without my phone.
So the current design is vulnerable to an attacker who already controls the host and can observe the ntfy-based exchange.
What I’m trying to achieve
I’m looking for a design that satisfies:
Remote approval via ntfy (or similar)
- I want to push a button on my phone to authorize the Vaultwarden unlock.
- That part is already reasonably handled with public/private keys and a challenge–response.
The host alone must not be able to reconstruct the long-term secret
Even if the host is compromised at a random later time, the attacker shouldn’t be able to:
- Derive the GPG passphrase (or equivalent long-term secret),
- Or decrypt the
.envoutside of a real-time, interactive approval from my phone.
An attacker who controls the host and watches traffic shouldn’t learn the underlying long-term secret
It’s acceptable that they can:
- See that an unlock happened,
- See that Vaultwarden is running and can be queried while it’s unlocked.
What I’d like to avoid is:
- Them being able to extract enough information from that single approved session to later decrypt the
.envor re-derive the same key without my phone.
- Them being able to extract enough information from that single approved session to later decrypt the
Concrete question
Is there a practical way (within the constraints of a home setup) to:
Use ntfy (or another notification / web hook mechanism) for remote “approve to unlock” from my phone,
Use public/private key auth so only my phone can authorize,
But structure the cryptography so that:
- The host never learns a reusable, long-term passphrase or key in full,
- And a host compromise, even with full ability to intercept traffic and inspect memory, does not give the attacker enough to later decrypt the
.envoffline?
Roughly, I’m imagining something like:
My phone holds a long-term master secret.
The host holds some static state.
Each unlock uses a fresh ephemeral key or derived secret, such that:
- The
.envis decrypted only in a “session” that depends on both parties, - But the base secret on the phone is not reconstructible from what the host sees.
- The
If this is fundamentally impossible given “host is fully compromised” assumptions, I’d like to understand the exact limits and what the best I can realistically do is.