HermesBackup

Even if we wanted to,
we couldn't read your backups.

Our database leaked tomorrow? Every backup is still unreadable. We published our threat model — read it and poke holes. Zero-knowledge isn't a marketing label here; it's a design constraint that makes the convenient path impossible.

Encryption architecture

We use age (X25519 asymmetric encryption), not passphrase-symmetric encryption. The distinction matters: naive zero-knowledge designs encrypt each backup with a passphrase — but cron can't type a passphrase, so the passphrase ends up stored in plaintext on disk, which defeats the point.

  1. At setup, the skill generates an age keypair on the user's machine.
  2. The public key is stored in local config and optionally mirrored to our server for dashboard display. It can only encrypt.
  3. Every backup is encrypted to the public key. Cron needs no secrets to encrypt — a compromised or unattended machine can create backups but a stolen upload can never be decrypted without the private key.
  4. The private key (AGE-SECRET-KEY-1...) is shown to the user exactly once, stored locally at ~/.hermes/hermesbackup/age-identity.txt (chmod 600) for convenient same-machine restores.
  5. Recovery kit: setup prints private key + account email + restore one-liner. The user (and the agent, loudly) tells the user to save it off-machine. Losing the machine and the key means the backups are permanently unreadable. That is the deal.

What the server ever sees

✓ What we store

Ciphertext blobs, sizes, SHA-256 of ciphertext, labels, timestamps, device names, public key (for display only).

✗ What we never see

Plaintext backups. Private keys. Passphrases. Decryption capabilities of any kind.

Transport & platform security

  • All traffic TLS 1.3 via Cloudflare.
  • Device tokens: 256-bit random, stored hashed (SHA-256) in D1, scoped per device, revocable from the dashboard.
  • Presigned URLs expire in 15 minutes (PUT) / 5 minutes (GET). Backup bytes never stream through the Worker.
  • R2 bucket is private — no public access.
  • Dashboard sessions via Clerk with MFA available.
  • Webhook endpoints (Stripe, Emailit) verify signatures.
  • Rate limiting per device token via Workers KV. init requests rate-limited to 30/hour/device.
  • Object keys are u/{user_id}/{backup_id}.age — no user-supplied path components, no traversal surface.
  • Cloudflare infrastructure logs: the Worker runs on Cloudflare's edge network. Cloudflare may retain console output (error messages, operational counters) and request metadata (URL, method, status, IP) in their logs per their standard platform logging. Worker code is written to log only aggregate counts and error types — no request-context metadata (device names, backup labels, user IDs) is emitted to console. R2 access logs include object keys derived from server-generated IDs; no user-supplied data is in key paths.

Abuse controls (the free tier must not become free Dropbox)

Blobs are opaque — we can't inspect content. Caps on count, size, and cadence are the enforcement surface. They're generous for real backup use while making generic file-hosting impractical:

LimitFreePro
Max object size2 GB2 GB
Uploads per day424
Stored objects60500
Init rate limit30/hour/device30/hour/device

Threat model & mitigations

Transparency is the brand. Below is the full set of threats we model and how each is mitigated. The audit snapshot is current as of July 2026 — a full third-party security audit is planned post-launch.

Server database leak Mitigated

D1 contains user metadata, device token hashes, backup metadata, and ciphertext SHA-256s — but no plaintext, no keys.

All sensitive material is encrypted client-side with age. A leaked D1 dump is useless for reading backups.

R2 bucket compromise Mitigated

R2 stores ciphertext blobs. An attacker with direct R2 access gets opaque .age files they cannot decrypt.

Private bucket, no public access. Private key never leaves the user's machine, so ciphertext is worthless without it.

Worker compromise Mitigated

The Worker never sees backup bytes (presigned URLs only) and has no decryption capability.

Even a fully compromised Worker can only issue presigned URLs and tamper with metadata. It cannot decrypt existing blobs and cannot forge new ones without the user's public key.

Device token theft Medium

A stolen device_token allows uploads/downloads/deletes on that device's behalf.

Tokens stored as SHA-256 hashes. User can revoke any device from the dashboard. Revocation is immediate. Pairing codes expire in 10 minutes. Tokens are per-device, not global.

Private key loss By design

If the user loses both their machine and their recovery kit, backups become permanently unreadable.

Recovery kit printed at setup, dashboard banner persists until confirmation, reminder email (pointedly does not contain the key). This is the cost of true zero-knowledge — we don't backdoor it.

MITM on presigned URL Low

Attacker intercepts a presigned PUT/GET URL within its expiry window.

TLS 1.3 everywhere. URLs expire in 15 min (PUT) / 5 min (GET). R2 verifies the signature. An attacker could upload to the slot but cannot read existing backups or forge a valid SHA-256 match.

Tampered ciphertext upload Mitigated

Attacker uploads different ciphertext to a presigned PUT slot.

POST /backups/{id}/complete makes the Worker HEAD the R2 object and verify size + SHA-256 against the value declared at init. Mismatch marks the backup as failed — it never reaches "stored" status.

Path traversal via object key Mitigated

Attacker attempts to write to arbitrary R2 paths.

R2 object keys are server-generated: u/{user_id}/{backup_id}.age. No user-supplied path components are accepted.

Quota bypass Mitigated

Attacker attempts to exceed storage limits.

init checks quota before issuing presigned URL. 507 returned if exceeded. Server never silently deletes — the client handles rotation per its configured policy.

Abuse of free tier for file hosting Mitigated

Attacker uses free tier as arbitrary encrypted storage.

Max object 2 GB, free 4 uploads/day + 60 objects. Content is opaque so we can't inspect it, but count/size/cadence caps make generic file-hosting impractical while remaining generous for real backup use.

Webhook forgery (Stripe / Emailit) Mitigated

Attacker sends fake webhook to flip plan or suppress bounce.

Both /webhooks/stripe and /webhooks/emailit verify signatures before acting. Invalid signatures are rejected.

Recovery-key email interception Low

Attacker reads the user's email to find the private key.

We never email the private key. Recovery kit is printed at setup and stored locally. Reminder emails exist but pointedly do not contain the key.

Audit status

A full internal audit (July 2026) verified the core zero-knowledge invariants against the Worker and client implementations. 18 issues found (2 HIGH, 6 MEDIUM, 10 LOW), 5 remediation cards created for the engineering team. Core invariants hold: no plaintext/keys/passphrases reach the server.

A third-party security audit is planned post-launch, before broader public charging. The audit report will be published here when complete.

Warrant canary

As of July 26, 2026, HermesBackup has received zero government requests for user data, backdoors, or key escrow. This statement will be updated weekly. Its absence is evidence.

Found a vulnerability?

Email [email protected] with details and reproduction steps. We respond within 72 hours and credit responsible disclosure. The client script is open-source and shipped in the skill — start there.