{"id":48679134,"url":"https://github.com/dotcoocoo/hermitstash","last_synced_at":"2026-05-30T05:01:40.378Z","repository":{"id":350544808,"uuid":"1207284152","full_name":"dotCooCoo/hermitstash","owner":"dotCooCoo","description":"Post-quantum encrypted, self-hosted file sharing. ML-KEM-1024 + P-384 hybrid crypto, zero plaintext on disk, one-command deploy. 🦀","archived":false,"fork":false,"pushed_at":"2026-04-18T04:15:54.000Z","size":1573,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-18T04:36:43.252Z","etag":null,"topics":["argon2","cryptography","docker","encryption","end-to-end-encryption","fido2","file-sharing","file-upload","ml-kem","nodejs","passkeys","post-quantum","privacy","security","self-hosted","sqlite","webauthn","xchacha20","zero-dependency","zero-knowledge"],"latest_commit_sha":null,"homepage":"https://hermitstash.com","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dotCooCoo.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"ko_fi":"dotcoocoo"}},"created_at":"2026-04-10T19:19:07.000Z","updated_at":"2026-04-18T04:15:57.000Z","dependencies_parsed_at":null,"dependency_job_id":"07763092-7668-4981-92b6-c3d8e785ec06","html_url":"https://github.com/dotCooCoo/hermitstash","commit_stats":null,"previous_names":["dotcoocoo/hermitstash"],"tags_count":90,"template":false,"template_full_name":null,"purl":"pkg:github/dotCooCoo/hermitstash","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dotCooCoo","download_url":"https://codeload.github.com/dotCooCoo/hermitstash/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dotCooCoo%2Fhermitstash/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32117798,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-22T00:31:26.853Z","status":"online","status_checked_at":"2026-04-22T02:00:05.693Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["argon2","cryptography","docker","encryption","end-to-end-encryption","fido2","file-sharing","file-upload","ml-kem","nodejs","passkeys","post-quantum","privacy","security","self-hosted","sqlite","webauthn","xchacha20","zero-dependency","zero-knowledge"],"created_at":"2026-04-10T22:02:38.854Z","updated_at":"2026-05-30T05:01:40.362Z","avatar_url":"https://github.com/dotCooCoo.png","language":"JavaScript","funding_links":["https://ko-fi.com/dotcoocoo"],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\u003cimg src=\"https://raw.githubusercontent.com/dotCooCoo/hermitstash/main/public/img/logos/purple.svg\" width=\"120\" alt=\"HermitStash\"\u003e\u003c/p\u003e\n\u003ch1 align=\"center\"\u003eHermitStash\u003c/h1\u003e\n\u003cp align=\"center\"\u003e\u003cstrong\u003eStash it quietly. Share it instantly.\u003c/strong\u003e\u003cbr\u003ePost-quantum encrypted, self-hosted file upload server.\u003c/p\u003e\n\u003cp align=\"center\"\u003e\u003ca href=\"https://github.com/dotCooCoo/hermitstash-sync\"\u003eHermitStash Sync\u003c/a\u003e — companion desktop sync client\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://apps.umbrel.com/app/hermitstash\"\u003e\n    \u003cpicture\u003e\n      \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"https://apps.umbrel.com/badge-light.svg\"\u003e\n      \u003cimg src=\"https://apps.umbrel.com/badge-dark.svg\" alt=\"Get it on the Umbrel App Store\" height=\"46\"\u003e\n    \u003c/picture\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://blamejs.com\"\u003e\n    \u003cimg src=\"https://raw.githubusercontent.com/blamejs/blamejs/main/assets/BlameJS_Logo.png\" width=\"96\" alt=\"blamejs\"\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\u003cp align=\"center\"\u003e\n  Built on \u003ca href=\"https://blamejs.com\"\u003e\u003cstrong\u003eblamejs\u003c/strong\u003e\u003c/a\u003e — the vendored Node framework that owns its stack.\u003cbr\u003e\n  Crypto envelopes, session storage, mTLS CA management, vault sealing, body parsers, rate limiting, and the WebSocket layer all come from \u003ca href=\"https://github.com/blamejs/blamejs\"\u003eblamejs\u003c/a\u003e; HermitStash composes them into a product.\n\u003c/p\u003e\n\n---\n\n\u003e **A note before you dive in.**\n\u003e\n\u003e HermitStash is my first public repo. It started as a weekend project to solve my own problem — sharing files with clients without trusting third-party cloud storage — and grew from there. I use it daily and it works for me, but I'm sharing it publicly knowing that \"works for me\" and \"is fit for your use case\" are different things.\n\u003e\n\u003e A few things I want to be honest about:\n\u003e\n\u003e - **I'm not a cryptographer.** I've used well-reviewed primitives and tried to assemble them carefully, but I haven't had this audited, and there are almost certainly things I don't know that I don't know. I've written [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) as a detailed design document specifically so that review is *possible* — it describes every protocol, lists known limitations honestly, and includes specific questions for cryptographers. If that's you: thank you, and please open an issue.\n\u003e - **This is a personal project.** I maintain it solo, in my spare time, and I can't promise fast response times or backwards compatibility.\n\u003e - **I'm not currently accepting code contributions** (more on that below), but bug reports, security findings, and feedback are genuinely welcome — they're how I learn.\n\u003e\n\u003e If HermitStash is useful to you, that's wonderful. If you're considering it for anything where the consequences of a security flaw matter, please weigh that against the fact that no professional has reviewed this code.\n\u003e\n\u003e — .CooCoo ([@dotCooCoo](https://github.com/dotCooCoo))\n\n\u003e **Status:** Personal project · Not audited · API may change · Use at your own risk\n\n---\n\n## Quick Start\n\n```bash\ngit clone https://github.com/dotCooCoo/hermitstash.git\ncd hermitstash\nnode server.js\n```\n\nNo config files. No build step. No `npm install` — all dependencies are vendored in the repo with zero npm runtime packages. First run generates the vault keypair and creates default accounts. Configure everything from the admin panel.\n\n**Default admin:** `admin@hermitstash.com` with a **randomly-generated password printed to stdout** on first boot. The same password is also written to `data/initial-admin-password.txt` (mode 0600) so it survives container restart. Read it from your `docker logs` or the file, log in, and complete the setup wizard — the wizard walks you through changing the admin email and password, setting your site name, configuring the passkey relying party (rpOrigin/rpId), and generating a session secret. The plaintext password file is deleted automatically when setup completes.\n\n## Why HermitStash?\n\n- **Post-quantum encryption** — your files are protected against both today's computers and tomorrow's quantum computers\n- **Zero plaintext** — every database field, every file, every audit log entry is encrypted or hashed before touching disk\n- **Self-hosted** — your server, your keys, your data. No third-party cloud\n- **Zero dependencies at runtime** — `node server.js` is the entire setup. All crypto libraries are vendored and committed\n- **One-command deploy** — Docker or bare metal, no build step, no config files needed\n\n## Built on blamejs\n\n[\u003cimg align=\"right\" src=\"https://raw.githubusercontent.com/blamejs/blamejs/main/assets/BlameJS_Logo.png\" width=\"120\" alt=\"blamejs\"\u003e](https://blamejs.com)\n\nHermitStash is composed on top of [**blamejs**](https://blamejs.com) — a Node framework that vendors its standard library instead of pulling it from npm at runtime, with security defaults wired in from line zero. Every primitive HermitStash uses for crypto, transport, storage, and identity flows through the framework:\n\n- `b.crypto` — envelope versioning, ML-KEM-1024 + P-384 hybrid encrypt/decrypt, XChaCha20-Poly1305 packed format, SHA3-512, namespaced hashes\n- `b.vault` + `b.vaultWrap` + `b.cryptoField` — vault key load/seal/unseal, passphrase-wrapped at-rest sealing, sealed-column registry for the data layer\n- `b.session` — sessions with PQC sealed cookies, /24 IPv4 + /64 IPv6 fingerprint binding, tmpfs-backed `localDbThin` storage, sid-rotation on login\n- `b.mtlsCa` + `b.mtlsEngine` — mTLS CA generation, client cert issuance, sealed-PEM at-rest, generation tracking\n- `b.middleware.{apiEncrypt, rateLimit, bodyParser, cors, csrfProtect, securityHeaders, botGuard}` — the request pipeline\n- `b.parsers.{json, multipart}` — RFC 7578 / RFC 5987 / POISONED_KEYS / HPE_* hardened body parsing\n- `b.objectStore` — SigV4 S3-compatible backend (AWS, DigitalOcean Spaces, MinIO, Backblaze)\n- `b.scheduler`, `b.backup`, `b.router`, `b.websocket`, `b.auth.password` (Argon2id), `b.auth.totp` (SHA-512), `b.safeUrl`, `b.sanitize`, `b.atomicFile`, `b.requestHelpers`, `b.constants`\n\nThe framework's source tree lives at [`lib/vendor/blamejs/`](lib/vendor/blamejs/) — committed at a pinned tag (see [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json)), refreshed via [`scripts/vendor-update.sh blamejs \u003ctag\u003e`](scripts/vendor-update.sh) which shallow-clones the release tag from [github.com/blamejs/blamejs](https://github.com/blamejs/blamejs). Zero npm runtime packages — `package.json` has no `dependencies` block at all.\n\n## Crypto Suite\n\nFor the detailed design — protocols, security goals, non-goals, adversary model, key hierarchy, known limitations, and open questions for reviewers — see [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md). The table below is a summary.\n\nAll cryptographic operations use NIST-standardized post-quantum algorithms:\n\n| Layer | Algorithm | Standard | Purpose |\n|-------|-----------|----------|---------|\n| **KEM** | ML-KEM-1024 + P-384 ECDH hybrid | FIPS 203 + NIST P-384 | Key encapsulation (PQC + classical) |\n| **Symmetric** | XChaCha20-Poly1305 | RFC 8439 extended | Data encryption (192-bit nonce, constant-time) |\n| **KDF** | SHAKE256 | FIPS 202 (XOF) | Key derivation from KEM shared secrets |\n| **Hash** | SHA3-512 | FIPS 202 | Integrity, email/IP hashing, checksums |\n| **HMAC** | HMAC-SHA3-512 | FIPS 202 | Webhook signing, token verification |\n| **Password** | Argon2id | RFC 9106 | Memory-hard password hashing |\n| **Signatures** | SLH-DSA-SHAKE-256f (default) / ML-DSA-87 (legacy) | FIPS 205 / 204 | Digital signatures. New keys default to SLH-DSA-SHAKE-256f; existing ML-DSA-87 keys continue to verify (algorithm auto-detected from key PEM) |\n| **Random** | SHA3-512(entropy) | FIPS 202 | All random generation via centralized KDF |\n\n### Envelope versioning\n\nEvery encrypted blob starts with a 4-byte header encoding the algorithms used:\n\n```\nbyte 0: 0xE1  (envelope magic)\nbyte 1: KEM   (0x02 ML-KEM-1024 / 0x03 ML-KEM-1024+P-384)\nbyte 2: Cipher(0x02 XChaCha20-Poly1305)\nbyte 3: KDF   (0x02 SHAKE256)\n```\n\nAny component can be swapped independently without re-encrypting existing data. When HQC or future algorithms are standardized, assign a new ID and existing blobs remain readable.\n\nAPI payload encryption runs through two coexisting protocols, both PQC end-to-end:\n\n- **blamejs apiEncrypt (per-session)** — `/drop/init`, `/drop/finalize/:bundleId`, `/sync/rename`. Clients fetch the server keypair from `GET /.well-known/blamejs-pubkey` (plain JSON `{publicKey, ecPublicKey, kemId, cipherId, kdfId}`), generate a session key, wrap it to the server keypair via the framework envelope (ML-KEM-1024 + P-384 ECDH hybrid → SHAKE256 → XChaCha20-Poly1305), and send `_ek` on first request. Subsequent requests carry `_sid` + monotonically-increasing `_ctr` against an in-memory session store.\n- **Legacy api-encrypt** — every other JSON route for cookie-authenticated browser clients. Per-session XChaCha20-Poly1305 key vault-sealed in the session table; key embedded into HTML templates via `res._apiKey` for browser-side JS to encrypt subsequent requests. The legacy `_ek` field has its own version byte (`0x01` = ML-KEM-1024 + P-384 + HKDF-SHA3-512 + XChaCha20-Poly1305). Bearer-authenticated callers (sync clients, API key holders) skip the legacy layer — TLS + mTLS + Bearer is the transport guarantee for those, and JSON-bodied operations route through blamejs apiEncrypt instead.\n\nFuture KEMs get a new version byte / new envelope ID — old blobs remain readable, new wires use the new primitive. The two protocols can be migrated independently.\n\n### Hybrid KEM\n\n```\nML-KEM-1024 encapsulate  --\u003e  shared_secret_1 (32 bytes)\nP-384 ephemeral ECDH     --\u003e  shared_secret_2 (48 bytes)\n                               |\n                     SHAKE256(ss1 || ss2, 32)\n                               |\n                     XChaCha20-Poly1305(key, nonce=24)  --\u003e  ciphertext\n```\n\nProtects against both quantum (ML-KEM) and classical (P-384) attacks. If either is broken, the other still holds.\n\n## Encryption Architecture\n\nZero plaintext anywhere. Every piece of data is encrypted or hashed before touching disk:\n\n```\nML-KEM-1024 + P-384 (vault.key)\n  |\n  +-- vault.seal() = hybrid KEM --\u003e SHAKE256 KDF --\u003e XChaCha20-Poly1305\n  |\n  +-- Wraps per-file XChaCha20-Poly1305 keys (file encryption at rest)\n  +-- Wraps per-session XChaCha20-Poly1305 keys (API payload encryption)\n  +-- API payload encryption: blamejs apiEncrypt envelope (ML-KEM-1024 + P-384 ECDH + SHAKE256) for sync writes\n  +--   and legacy ECIES (ML-KEM-1024 + P-384 + HKDF-SHA3-512) for cookie-authed browsers\n  +-- Wraps database file XChaCha20-Poly1305 key (DB encryption at rest)\n  +-- Directly seals ALL database fields (not just PII)\n  +-- Directly seals session cookie values\n```\n\n### Automatic field-level encryption\n\nRoutes never touch `vault.seal()` directly. A centralized **field-crypto middleware** (`lib/field-crypto.js`) intercepts all database operations:\n\n```\nRoutes pass PLAINTEXT\n       |\n  Collection.insert() / update() / find()\n       |\n  field-crypto.js (automatic middleware)\n       |\n  +-- sealDoc() on write ---\u003e vault.seal() per field ---\u003e DB stores ciphertext\n  +-- unsealDoc() on read ---\u003e vault.unseal() per field ---\u003e routes get plaintext\n  +-- derived hashes --------\u003e emailHash, shareIdHash auto-computed\n  +-- _translateQuery() -----\u003e { email: \"x\" } becomes { emailHash: sha3(\"hs-email:x\") }\n```\n\nEvery field in every table is classified as `seal` (encrypted), `hash` (one-way lookup), `derived` (auto-computed from another field), or `raw` (IDs, timestamps, counters). The schema is defined once in `FIELD_SCHEMA` and enforced on every database operation.\n\n### What gets encrypted and how\n\n| Data | Encryption | Key Protection |\n|------|-----------|----------------|\n| **File contents** | XChaCha20-Poly1305 (random key per file) | Key sealed with hybrid ML-KEM-1024 + P-384 vault |\n| **Vault files** | ML-KEM-1024 + SHAKE256 + XChaCha20-Poly1305 (client-side) | Key derived from passkey (never leaves browser) |\n| **API request/response bodies** | XChaCha20-Poly1305 (random key per session) | Key sealed with hybrid vault |\n| **Database file on disk** | XChaCha20-Poly1305 (random key) | Key sealed with hybrid vault |\n| **Session cookies** | Hybrid KEM + XChaCha20-Poly1305 | Direct vault.seal() per cookie |\n| **All user fields** (email, name, avatar, googleId) | Hybrid KEM + XChaCha20-Poly1305 | Auto vault.seal() per field |\n| **All file metadata** (names, paths, MIME, storage) | Hybrid KEM + XChaCha20-Poly1305 | Auto vault.seal() per field |\n| **Audit log fields** (action, emails, details) | Hybrid KEM + XChaCha20-Poly1305 | Auto vault.seal() per field |\n| **Audit log IPs** | SHA3-512 hash then vault-sealed | One-way hashed, then auto-sealed |\n| **Passwords** | Argon2id | One-way hash (no key needed) |\n| **Email/IP lookups** | SHA3-512 | One-way hash for indexed queries |\n\n### Anti-attack protections\n\n| Attack | Protection |\n|--------|-----------|\n| Quantum computer key recovery | Hybrid ML-KEM-1024 + P-384 ECDH (dual protection) |\n| Harvest-now-decrypt-later | ML-KEM-1024 post-quantum KEM + envelope versioning for algorithm agility |\n| Classical-only TLS downgrade | ClientHello PQC gate rejects connections without hybrid key exchange groups |\n| Brute-force passwords | Argon2id (64MB memory, 3 iterations) |\n| Brute-force login | Rate limiting (5 attempts / 15 min per IP) |\n| Brute-force share IDs | 256-bit SHA3-derived IDs (2^256 search space) |\n| Session hijacking | Hybrid KEM encrypted cookies, per-session keys |\n| API replay attacks | Timestamp validation (30-second window) |\n| API payload tampering | XChaCha20-Poly1305 authentication (Poly1305 MAC) |\n| Database file theft | XChaCha20-Poly1305 encrypted at rest, key requires vault.key |\n| PII exposure from DB dump | Every field vault-sealed, IPs one-way hashed |\n| Nonce collision | XChaCha20 192-bit nonce eliminates birthday-bound risk |\n| AES-NI side channels | XChaCha20 is constant-time in software, no hardware dependency |\n| Brute-force bundle passwords | Exponential backoff lockout after 5 failed attempts |\n| Email enumeration on bundles | Identical response regardless of whether email is in allow list |\n| Brute-force access codes | 5 attempt limit per code, rate limiting, 10-minute expiry |\n| CSRF on API endpoints | Per-session XChaCha20-Poly1305 key binds JSON requests to session; form POSTs validated with constant-time CSRF token |\n| Logout CSRF | Logout is POST-only with CSRF token validation — cross-site `\u003cimg\u003e` or `\u003ca\u003e` tags cannot force logout |\n| WebSocket credential leakage | API keys accepted only via Authorization header — query string tokens rejected to prevent proxy/log/Referer leaks |\n| Session key interception | Hybrid ECIES key exchange — session key encrypted via ML-KEM-1024 + ECDH P-384, never plaintext in HTTP |\n| CSV formula injection | Export values sanitized to prevent spreadsheet code execution |\n| DNS rebinding via webhooks | Pre-validated IP pinned to outbound connection |\n| SSRF via webhooks | Blocks localhost, RFC 1918, RFC 6598 CGNAT, link-local, IPv6 private ranges |\n| Disguised file uploads | Magic byte validation rejects files whose content doesn't match extension |\n| Malicious filenames | Backend sanitization strips control chars, path traversal, dot attacks, HTML injection |\n| ZIP path traversal (Zip Slip) | Entry names sanitized to remove `..` segments; paths normalized on both upload and archive |\n| Anonymous storage abuse | Per-IP upload quota with 24-hour rolling window |\n| Stored XSS via uploads | User-controlled names auto-escaped in templates; raw output reserved for admin-set values only |\n| Weak bundle/stash passwords | Minimum 4-character requirement enforced server-side |\n| Automated scanners and bots | Request fingerprinting (missing Accept-Language + known automation User-Agents) blocks non-browser clients on public routes — survives PQC TLS adoption |\n| NPM supply chain | All dependencies vendored as committed bundles — zero npm runtime packages |\n| Admin settings injection | Type-safe settings schema (lib/settings-schema.js) sanitizes on save (strip control chars, trim, type-specific normalization) and validates (format, range, enum) — bad data rejected at the gate with clear error messages |\n| Stale config after admin change | Config reset registry (config.onReset) invalidates cached clients (S3, upload paths, etc.) when dependent settings change at runtime |\n| Timing attack on access codes | SHA3-512 hash comparison uses constant-time `timingSafeEqual` on all security-sensitive comparisons (access codes, CSRF, TOTP) |\n| Crash during backup restore | Pre-restore snapshots of vault.key, db.key.enc, hermitstash.db.enc enable rollback if restore is interrupted |\n\nBuilt on Node.js 24.14.1+ (LTS) with ML-KEM-1024, SLH-DSA-SHAKE-256f (default signature) and ML-DSA-87 (legacy) via OpenSSL 3.5, XChaCha20-Poly1305 and SHAKE256 via vendored blamejs (which bundles @noble/ciphers and @noble/post-quantum), Argon2id via Node 24+'s built-in `crypto.argon2` (no native binding required), WebAuthn via vendored blamejs (which bundles @simplewebauthn/server), and built-in SQLite via `node:sqlite`. Zero npm runtime dependencies.\n\n## Features\n\n**Authentication**\n- Argon2id local auth, Google OAuth, WebAuthn passkeys -- all simultaneous\n- TOTP 2FA with single-use backup codes — HMAC-SHA-512, 128-byte secret, 8-digit codes (legacy SHA-1 enrollments are forced through a one-time re-pair on next login)\n- Email verification with SHA3-hashed tokens\n- Hybrid KEM encrypted session cookies\n- Per-session XChaCha20-Poly1305 encrypted API payloads with anti-replay and anti-tamper\n- Hybrid PQC payload encryption for API clients -- ML-KEM-1024 + ECDH P-384 hybrid envelope (SHAKE256 KDF, XChaCha20-Poly1305 wrap) via blamejs apiEncrypt for sync write paths and `/drop/init` / `/drop/finalize/:bundleId`; legacy ECIES with HKDF-SHA3-512 retained for cookie-authenticated browsers. Server keypair is published as plain JSON at `/.well-known/blamejs-pubkey` and vault-sealed at rest\n- Rate limiting on login (5/15min), registration (10/15min), 2FA verify (5/5min), passkey login (10/min)\n- Account lockout after 10 consecutive failed password attempts (30-minute cooldown)\n- Password reset flow with single-use, 1-hour-expiry tokens and anti-enumeration (always returns success)\n- User invitation system -- admin invites by email with role assignment, 48-hour expiry\n- Configurable session idle timeout (default 30 minutes, server-side enforcement)\n- OAuth CSRF state validation on Google callback\n- Password change automatically revokes all other sessions\n\n**File Management**\n- Public folder drops -- drag entire trees, no login required\n- Per-file XChaCha20-Poly1305 encryption, keys sealed with hybrid ML-KEM-1024 + P-384\n- Chunked uploads for large files (\u003e10MB auto-split, server reassembly)\n- Pause/resume/cancel uploads, per-file progress bars\n- Password-protected share links with exponential backoff lockout (2^n × 30s after 5 failed attempts), persisted per-share in the database so counters survive restart\n- Email-gated access -- restrict bundles to specific recipient emails, verified by one-time code (anti-enumeration, rate limited, SHA3-hashed codes)\n- Dual protection mode -- require both email verification and password for maximum security\n- Custom expiry per bundle (1d, 7d, 30d, 90d, never)\n- Bundle messages, multiple recipient emails\n- Bundle naming -- name bundles during upload, rename inline from dashboard\n- Inline rename for files and bundles with backend-enforced sanitization (dot attack protection, path traversal prevention, extension preservation)\n- Magic byte content validation -- uploaded files verified against claimed extension (15 format signatures)\n- File preview with SVG sanitization, HTML/JS forced download\n- Shareable links -- browse folders or download as ZIP\n- Subfolder ZIP download -- download individual subdirectories from a bundle\n- Safe Content-Disposition headers with RFC 5987 encoding for non-ASCII filenames\n\n**Zero-Knowledge Vault**\n- Client-side ML-KEM-1024 + SHAKE256 KDF + XChaCha20-Poly1305 encryption in the browser\n- Passkey-gated access (Touch ID, Face ID, YubiKey, FIDO2)\n- PRF mode for true zero-knowledge (no seed touches the server)\n- Stealth mode hides vault operations from audit logs\n- Self-access links for direct vault file download with passkey auth\n- Vault key rotation with atomic re-encryption of all files\n- Batch upload and batch delete with client-generated batch IDs\n- Folder structure preserved in vault uploads and batch ZIP downloads\n- Inline rename for vault batches and individual vault files\n- Force-reset recovery mode for vault lockout (deletes all vault files, clears vault state)\n- ML-KEM-1024 only (ML-KEM-768 fully removed — server rejects 768 keys at startup)\n\n**Customer Stash — Branded Upload Portals**\n- Create custom-branded upload pages at `/stash/:slug` for clients and partners\n- Per-page branding -- custom title, instructions, accent color, and logo\n- Per-page upload constraints -- max file size, max files, default expiry, allowed extensions\n- Password-protected stash pages with Argon2 hashing and rate-limited unlock\n- Email/domain-gated stash access -- restrict by specific emails or entire domains (@acme.com), verified by one-time code\n- Dual protection mode -- require both email verification and password on stash pages\n- Simplified upload form -- message and files only (no name/email fields)\n- Bundle naming during stash upload\n- Dynamic slug validation with automatic reserved-word detection from registered routes\n- Upload stats tracked per stash page (bundle count, total bytes)\n- Custom logo upload per stash page with magic-byte validation\n- Dedicated admin page with bundle drill-down -- view bundles, browse files, inline rename, delete, purge all\n- Admin management -- create, edit, toggle, copy link, delete stash pages\n\n**Teams**\n- Create teams, add/remove members with role-based access\n- Team-scoped file visibility -- cross-team isolation enforced\n- Team admin and member roles\n\n**Profile**\n- Self-service email change with password re-authentication\n- Self-service account deletion (files reassigned, sessions revoked, last admin protected)\n\n**Admin Dashboard**\n- Stats with computed totals (size, downloads), activity feed\n- Row-based bundle lists with file drill-down (My Stash + Personal Vault)\n- Paginated file/bundle browser with search\n- User management -- create, suspend, delete, role toggle\n- Audit log -- searchable, filterable, date range\n- Settings panel -- 9 tabs (Branding, General, Auth, Uploads, Storage, Theme, Email, Environment, Backup)\n- API keys with scoped permissions (upload, read, admin, webhook) validated against canonical enum\n- Webhooks with HMAC-SHA3-512 signed payloads, per-hook delivery log, enable/disable toggle\n- IP blocklist\n- Database backup (serves encrypted-at-rest copy), CSV exports (with formula injection protection)\n- Automated off-site backup to S3-compatible storage (AWS, R2, MinIO, B2, DO Spaces) with passphrase-encrypted vault key, incremental file manifests, configurable retention, and manual trigger from admin UI. Full-scope backups include all storage objects (bundles and vault files)\n- Backup restore with pre-restore snapshots -- critical files (vault.key, db.key.enc, hermitstash.db.enc) are snapshotted before overwrite for crash recovery\n- Scheduled tasks with watchdog timeouts -- file expiry, audit retention, stale upload cleanup, token cleanup, invite cleanup, daily SQLite vacuum, automated backup. Hung jobs auto-reset after 10 minutes\n- Danger Zone -- factory reset, purge all sessions, purge all users, purge all files (typed confirmation required)\n- Custom logo upload with magic-byte validation and SVG sanitization\n- Reverse proxy auto-detection with config snippet generator (nginx, Caddy, Apache)\n- Per-user storage quotas (separate from global quota) and per-IP public upload quota (24h rolling window)\n- Configurable upload concurrency, retry count, timeout, and file extension allowlist\n- Admin email list for auto-promoting OAuth users to admin role\n- Maintenance mode -- blocks non-admin access with 503 page\n- Announcement banner -- site-wide text displayed on all pages\n\n**Email**\n- SMTP or Resend API backend (switchable from admin)\n- Dual-mode failover -- SMTP-primary/Resend-fallback or Resend-primary/SMTP-fallback\n- Resend quota enforcement (daily/monthly limits per plan tier)\n- Email template customization -- subject, header, footer with named placeholders ({siteName}, {uploaderName}, {fileCount}, {totalSize})\n- Upload confirmations, admin notifications, verification emails\n- All email send/fail/quota events audit-logged\n\n**Sync and API**\n- Mutable sync bundles -- `bundleType: \"sync\"` creates persistent, mutable bundles that accept file additions, replacements, and deletions after creation\n- File replace -- uploading to a sync bundle with an existing `relativePath` overwrites the file with a new encryption key (old key and blob fully removed)\n- File rename/move -- `POST /sync/rename` updates relativePath without re-uploading the file (metadata-only, emits `file_renamed` WebSocket event). Sync client detects local renames by checksum matching within the debounce window\n- File delete -- individual files can be removed from sync bundles with tombstone-based soft delete (30-day cleanup)\n- Per-file change tracking -- `seq` monotonic counter and `updatedAt` timestamp on files and bundles for sync change feeds\n- JSON content negotiation on bundle view -- `Accept: application/json` returns file list with checksums and metadata\n- Structured audit log events for file mutations (JSON details with action, bundleId, checksum, size)\n- Shared access control middleware (`require-access.js`) -- centralized lock checks for bundles and stash\n- JSON-aware auth -- API/sync clients receive 401 JSON, browsers get login redirect\n- WebSocket sync channel -- `GET /sync/ws` with auth during upgrade handshake, scoped to single bundle\n- Real-time file change events over WebSocket (file_added, file_replaced, file_removed, heartbeat — sent immediately on connect, then every 30s)\n- Catch-up on reconnect via seq cursor (`?since=N` on WebSocket upgrade)\n- PQC TLS enforcement -- ClientHello inspection rejects connections without PQC hybrid key exchange groups\n- PQC gate architecture -- TCP proxy inspects `supported_groups` extension before TLS handshake completes\n- Localhost bypass for Docker health probes (127.0.0.1/::1 skip PQC check)\n- `PQC_ENFORCE=false` disables gate for transition periods (PQC preferred but not required)\n- PQC TLS -- conditional HTTPS with SecP384r1MLKEM1024 + X25519MLKEM768 + SecP256r1MLKEM768 hybrid key exchange (TLS 1.3 only, Level 5 preferred)\n- Certificate auto-reload on Let's Encrypt renewal (hourly file poll)\n- PQC outbound HTTPS agent -- all S3, SMTP, Resend, webhook, OAuth calls use PQC hybrid TLS groups\n- `PQC_OUTBOUND_ENFORCE=false` allows classical fallback for outbound connections\n- mTLS for sync clients -- server acts as its own Certificate Authority (ECDSA P-384)\n- Client certificate generation on sync token creation with one-click PEM bundle download\n- Certificate revocation table with SHA3-512 hashed fingerprint lookups\n- WebSocket upgrade validates mTLS cert + API key (dual auth, neither alone sufficient)\n- When a CA exists, WebSocket mTLS is **required by default**. Set `MTLS_REQUIRED=false` as an explicit bring-up escape to permit API-key-only upgrades; per-key cert binding is still enforced when `api_keys.certFingerprint` is set, so a cert-bound key cannot be downgraded.\n- New `sync` API key scope for WebSocket connections and sync bundle operations\n- Resource-scoped API keys -- `boundStashId` and `boundBundleId` columns restrict keys to specific resources\n- Stash-scoped sync tokens -- admin generates tokens that grant sync access to a single stash only\n- One-time enrollment codes -- admin generates a short code (e.g. `HSTASH-A4K9-XMWP-7RB2`), client redeems it to get API key + mTLS certs automatically (no file transfer needed, 1-hour expiry)\n- Stash sync mode -- persistent mutable bundle per stash for desktop sync clients\n- Admin UI: sync toggle per stash, one-click sync token generation with copy button\n- Desktop sync client: [hermitstash-sync](https://github.com/dotCooCoo/hermitstash-sync) — watches a local folder and syncs via WebSocket + PQC TLS\n- **Enforce mTLS** mode — restricts the web UI to clients that present a valid CA-signed certificate. Sync clients, Bearer-authenticated API calls, `/sync/*`, and `/health` always pass through.\n  - **Soft** (Admin → Auth → Enforce mTLS): instant toggle, no restart. Non-mTLS connections are dropped at the app layer via `socket.destroy()` — no HTTP response rendered, no information leakage.\n  - **Hard** (env `ENFORCE_MTLS_STRICT=true`, boot-time): TLS handshake itself rejects non-mTLS clients. Requires restart to change.\n  - **Escape hatch** (env `ENFORCE_MTLS_STRICT=false`): forces all enforcement off at boot regardless of DB setting. Use when locked out.\n- **Browser certificates** — admin panel on `/admin` (Browser Certificates section) issues a PKCS#12 for install in OS / browser cert stores. AES-256-CBC + SHA-512 MAC + 2M PBKDF2 iterations. See \"Installing a browser certificate\" below.\n- **mTLS CA regeneration** — Admin → General → Danger Zone → Regenerate mTLS CA rolls the CA to the current algorithm envelope (SHA-384 cert signatures, 2M iterations, SHA-512 PRF). Every CA is version-tagged (`OU=CAv{N}` in the subject DN); boot-time and UI banners surface when the on-disk CA is a legacy generation. Active sync clients receive new certificates via a WebSocket `ca:rotation` message and ack back before the server auto-restarts — offline sync clients must re-enroll, browser certs must be re-downloaded. Operators who want a preview without committing can POST `{ confirm: \"REGEN\", skipRestart: true }` to `/admin/api/mtls-ca/regenerate`.\n\n**Security Hardening**\n- Security headers on all responses (CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, COOP, CORP)\n- HSTS with preload auto-enabled when rpOrigin uses HTTPS\n- Content Security Policy with no external domains -- fonts vendored locally, `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'none'`\n- 256-bit SHA3-derived share IDs (no brute-force, no collisions)\n- CSRF protection: JSON requests bound by per-session encryption key; form POSTs validated with constant-time CSRF token; non-JSON/non-exempt POSTs rejected\n- Logout is POST-only with CSRF token validation (no GET logout CSRF)\n- Bot guard middleware -- blocks automated scanners on public routes via a missing-Accept-Language check plus a known-automation User-Agent deny-list (curl, wget, python-requests, …); a missing Sec-Fetch-Mode is advisory only, so browsers reaching the app over plain HTTP (LAN / reverse-proxy origins) are not refused\n- WebSocket API keys accepted only via Authorization header -- query string tokens rejected to prevent proxy/log/Referer leaks\n- CSV formula injection protection on all exports\n- CORS configurable via admin (wildcard disallowed with credentials)\n- Canonical origin policy -- all URLs generated from rpOrigin, never from Host header\n- Webhook DNS pinning -- resolved IP reused for outbound connection, preventing TOCTOU rebinding\n- Input length limits on all free-text fields\n- Pagination capped at 200 results\n- X-Forwarded-For only trusted from configured proxies\n- Safe redirects (relative paths only)\n- SSRF protection covers all RFC 1918, RFC 6598 CGNAT, link-local, metadata, and IPv6 ranges\n- All crypto and font dependencies vendored from npm -- zero external CDN requests, zero runtime packages\n- Restrictive CSP on user-uploaded logo directory (defense-in-depth against SVG XSS)\n\n**Storage**\n- Local disk, NAS mount, or any S3-compatible bucket (MinIO, Cloudflare R2, DigitalOcean Spaces, Backblaze B2)\n- S3 direct downloads with pre-signed URLs (configurable expiry, AWS Signature V4)\n- All file operations (uploads, vault, backups) go through a unified storage abstraction -- local and S3 backends are transparent to the application\n- Per-file XChaCha20-Poly1305 encryption at rest, keys sealed with hybrid vault\n\n**SEO and Legal**\n- Open Graph and Twitter card meta tags with dynamic site name and origin\n- Canonical URL tag derived from rpOrigin\n- robots.txt blocks admin, dashboard, vault, and auth pages from search engines\n- Dynamic sitemap.xml (`GET /sitemap.xml`) with public pages\n- noindex/nofollow meta tag on all authenticated pages\n- Configurable Privacy Policy, Terms of Service, and Cookie Policy pages\n- Default legal page templates with sensible content for self-hosted deployments\n- Footer links to all legal pages\n- Configurable analytics script injection -- paste any provider's `\u003cscript\u003e` tag (Plausible, Umami, Matomo, Fathom, PostHog, Google Analytics)\n- Analytics injected on public pages only (admin/dashboard excluded)\n- API encryption scoped to same-origin -- external analytics and third-party fetches pass through unmodified\n- Auto-detected CSP domains from analytics script with manual override\n\n**Accessibility**\n- Skip-to-content link for keyboard navigation\n- ARIA labels on interactive controls (theme toggle, icon buttons)\n- Alt text on all logo and avatar images\n- Semantic HTML with `\u003cmain\u003e` landmark on all pages\n\n**Zero Configuration**\n- No `.env` file -- settings stored in encrypted database\n- No build step -- vanilla Node.js\n- `node server.js` is the entire setup -- no npm install needed\n- `process.env` overrides available for Docker/containers\n- Health check endpoint (`GET /health`) for load balancers, container probes, and PQC gateway status checks (subject to the global CORS allowlist)\n- Zero external CDN dependencies -- fonts vendored locally, no requests to Google, Cloudflare, or any third-party on page load\n- PWA web app manifest with dynamic site name and theme colors\n- Automatic database schema migrations on startup\n- Startup invariant checks -- validates vault key, warns on default credentials/secrets, checks directory permissions\n\n## Installing a browser certificate\n\nWhen Enforce mTLS is on, every browser session needs a client certificate signed by HermitStash's internal CA. Generate one from **Admin → Browser Certificates → Issue + Download**. The server returns a `.p12` file (AES-256-CBC + SHA-512 MAC + 2M PBKDF2 iterations). Install it per your OS:\n\n**macOS** — double-click the `.p12`, Keychain Access opens, enter the password. The cert is installed to the **login** keychain. When you next visit HermitStash, Safari / Chrome / Firefox will offer it in the cert picker.\n\n**Windows** — double-click the `.p12`, Certificate Import Wizard opens. Choose **Current User → Personal store**, enter the password. Edge / Chrome pick it up automatically; Firefox uses its own store (see below).\n\n**Linux (Chrome/Chromium)** — use NSS command-line:\n```bash\npk12util -i hermitstash-browser-\u003ccn\u003e.p12 -d sql:$HOME/.pki/nssdb\n```\n\n**Firefox (any OS)** — Preferences → Privacy \u0026 Security → Certificates → **View Certificates** → **Your Certificates** tab → **Import** → pick the `.p12` → enter password.\n\nAfter install, visit your HermitStash URL. The browser will prompt you to select a certificate (pick \"HermitStash: \\\u003ccn\\\u003e\"), then you'll land on the login page as normal.\n\n## Docker Deployment\n\n### Quick start (pre-built image)\n\n```bash\ndocker pull ghcr.io/dotcoocoo/hermitstash:1\ndocker run -d --name hermitstash \\\n  -p 3000:3000 \\\n  -v ./data:/app/data \\\n  -v ./uploads:/app/uploads \\\n  --shm-size=256m \\\n  ghcr.io/dotcoocoo/hermitstash:1\n```\n\n### Image tag scheme\n\nTags published per release:\n\n| Tag | Example | Behaviour |\n|-----|---------|-----------|\n| `:1` | major-version pin | Gets bug fixes and features within the major version (no breaking changes) |\n| `:1.7` | minor-version pin | Gets only patch updates within the minor series |\n| `:1.7.x` | exact pin | Pin to a specific patch (`:1.7.12`, `:1.7.13`, etc.) — never updates |\n| `:latest` | rolling | Always the newest published image — follows the default branch |\n| `:sha-\u003ccommit\u003e` | per-commit | Reproducible pin to the exact commit |\n\nPick the level of stability you want — `:1` is the recommended default for production deployments.\n\nOr with docker compose (using pre-built image):\n\n```yaml\nservices:\n  hermitstash:\n    image: ghcr.io/dotcoocoo/hermitstash:1\n    init: true\n    ports: [\"3000:3000\"]\n    volumes:\n      - ./data:/app/data\n      - ./uploads:/app/uploads\n    shm_size: 256m\n    security_opt:\n      - no-new-privileges:true\n    environment:\n      PUID: \"99\"                   # default 99 (Unraid). Set 1000 for standard Linux.\n      PGID: \"100\"                  # default 100 (Unraid). Set 1000 for standard Linux.\n      TZ: \"Etc/UTC\"                # e.g. America/New_York\n      TRUST_PROXY: \"true\"\n      RP_ORIGIN: \"\"              # https://your-domain.com\n    restart: unless-stopped\n```\n\n### Quick start (build from source)\n\n```bash\ngit clone https://github.com/dotCooCoo/hermitstash.git\ncd hermitstash\ndocker compose up -d\n```\n\nUses `cgr.dev/chainguard/node:latest-dev` — a wolfi-based, glibc-dynamic Node image rebuilt continuously by Chainguard when upstream CVE fixes land, so the image's CVE count at any given digest is typically near zero. Node 24.14.1+ is still required for PQC (OpenSSL 3.5) plus cumulative 24.x security patches. No config files needed — all dependencies vendored, no `npm install`. Starts with defaults and generates the vault keypair on first run. Configure everything from the admin panel at `/admin` once running.\n\n### Image details\n\n| | |\n|---|---|\n| **Base image** | `cgr.dev/chainguard/node:latest-dev` (wolfi, glibc — continuously rebuilt for CVE fixes) |\n| **Node.js** | 24.14.1+ (required for ML-KEM-1024, SLH-DSA-SHAKE-256f, ML-DSA-87 via OpenSSL 3.5 + cumulative 24.x security patches) |\n| **User** | Runs as `hermit` (non-root) via `su-exec` (installed at build time) — PUID/PGID env vars remap UID/GID at runtime (default 99:100, standard Linux 1000:1000) |\n| **Tmpfs** | `HERMITSTASH_TMPDIR=/dev/shm` — plaintext DB held in memory, never on disk. Set `shm_size: 256m` in compose. Also consider `CHUNK_SCRATCH_DIR=/dev/shm/hermitstash-chunks` for RAM-backed chunked-upload staging. |\n| **Volumes** | `/app/data` (encrypted DB, vault keys, TLS certs), `/app/uploads` (files if using local storage) |\n| **Port** | 3000 (configurable via `PORT` env var) |\n| **Health check** | Built-in: `GET /health` every 30s, 5s timeout, 3 retries, 30s start period |\n| **Security** | `init: true` (tini PID 1), `no-new-privileges`, `cap_drop: ALL` + minimal `cap_add` |\n| **Entrypoint** | `docker-entrypoint.sh` — remaps PUID/PGID, sets TZ/UMASK, chowns volumes, drops to `hermit` via `su-exec` |\n\n### docker-compose.yml\n\nThe included `docker-compose.yml` provides a production-ready starting point:\n\n```yaml\nservices:\n  hermitstash:\n    build: .\n    init: true\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./data:/app/data       # encrypted DB, vault keys, TLS certs\n      - ./uploads:/app/uploads  # files (local storage only)\n    shm_size: 256m              # /dev/shm for plaintext DB in memory\n    security_opt:\n      - no-new-privileges:true\n    cap_drop:\n      - ALL\n    cap_add:\n      - CHOWN\n      - SETUID\n      - SETGID\n      - DAC_OVERRIDE\n    environment:\n      PUID: \"99\"                # default 99 (Unraid). Set 1000 for standard Linux.\n      PGID: \"100\"               # default 100 (Unraid). Set 1000 for standard Linux.\n      TZ: \"Etc/UTC\"             # e.g. America/New_York\n      UMASK: \"022\"              # 755 dirs, 644 files. Use 000 for Unraid nobody:users sharing.\n      NODE_ENV: production\n      HERMITSTASH_TMPDIR: /dev/shm\n      PORT: 3000\n      TRUST_PROXY: \"true\"       # set if behind nginx/Cloudflare/Coolify\n      RP_ORIGIN: \"\"             # https://your-domain.com (required for passkeys + HSTS)\n    restart: unless-stopped\n```\n\nAll other settings (auth, email, S3, branding) are best configured via the admin panel at `/admin` so credentials are vault-sealed in the encrypted database. Environment variables override DB settings and are visible in Admin \u003e Settings \u003e Environment tab.\n\n**Hardened / rootless variant:** [`docker-compose.rootless.yml`](docker-compose.rootless.yml) runs the container as UID 1000 with `read_only: true` and zero Linux capabilities. You pre-chown `./data` and `./uploads` on the host; the container never needs `CHOWN`/`SETUID`/`SETGID`/`DAC_OVERRIDE`. Use this if you're comfortable managing host UIDs.\n\n### Coolify / managed Docker hosts\n\nWorks out of the box with Coolify, Portainer, CapRover, and similar platforms:\n\n1. Point the platform at the git repo (or Dockerfile)\n2. Set the `RP_ORIGIN` env var to your domain's full URL (e.g., `https://app.hermitstash.com`)\n3. Mount persistent volumes for `/app/data` and `/app/uploads`\n4. Set `shm_size: 256m` (or equivalent in the platform's container config)\n5. The built-in health check works with any orchestrator that supports `HEALTHCHECK`\n\n### TLS / HTTPS\n\nThe server can terminate TLS itself (for PQC enforcement) or sit behind a reverse proxy:\n\n- **Behind Cloudflare/nginx (recommended):** Set `TRUST_PROXY=true`. The proxy terminates TLS; the server runs HTTP internally. PQC TLS between browser and Cloudflare is handled by Cloudflare's edge. Set `PQC_ENFORCE=false` if the proxy→server leg is plain HTTP.\n- **Direct TLS (PQC enforced):** Mount TLS certs at `/app/data/tls/fullchain.pem` and `/app/data/tls/privkey.pem` (or set `TLS_CERT` and `TLS_KEY` env vars). The PQC gate inspects ClientHello and rejects non-PQC connections. The server negotiates `SecP384r1MLKEM1024 \u003e X25519MLKEM768 \u003e SecP256r1MLKEM768` (strongest available hybrid group). Certificate auto-reload on Let's Encrypt renewal (hourly file poll via `fs.watchFile`).\n\n### Persistent data\n\n| Path | Contents | Backup? |\n|------|----------|---------|\n| `/app/data/hermitstash.db.enc` | Vault-encrypted SQLite database (users, files, settings, audit log) | Yes — automated S3 backup available |\n| `/app/data/vault.key` | ML-KEM-1024 + P-384 hybrid keypair (encrypts all DB fields). Plaintext JSON, mode 0600. *Not present when passphrase protection is enabled — see below.* | **Critical** — lose this and all sealed data is unrecoverable |\n| `/app/data/vault.key.sealed` | Passphrase-wrapped vault key (only when `VAULT_PASSPHRASE_MODE=required`). | **Critical** — needs both the file AND the passphrase to recover |\n| `/app/data/tls/` | TLS certificates (if using direct TLS) | Regenerated by Let's Encrypt |\n| `/app/uploads/` | Uploaded files (if using local storage; not needed with S3) | Optional — files are re-uploadable |\n\n### Passphrase protection (opt-in)\n\nBy default, `/app/data/vault.key` is plaintext JSON protected only by filesystem permissions (0600). An attacker with a disk snapshot — a stolen backup, a leaked volume dump, an errant `rsync` — can recover the vault key and decrypt everything HermitStash has stored. This is the single largest limitation in the default configuration and is documented as L2 in [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).\n\nv1.9+ adds an **opt-in** layer that wraps the vault key with an Argon2id-derived XChaCha20-Poly1305 encryption. When enabled, the on-disk file is a ciphertext blob that requires the passphrase to unwrap.\n\n**Default behavior is unchanged** — if you don't enable this feature, your existing plaintext `vault.key` keeps working identically.\n\n#### Enabling\n\n1. **Store the passphrase somewhere safe first.** Loss of the passphrase = loss of all encrypted data. HermitStash has no recovery mechanism. Use a password manager.\n\n2. Stop the server.\n\n3. Run the migration tool:\n   ```bash\n   # Interactive prompt (simplest for manual setup):\n   docker exec -it hermitstash node scripts/vault-passphrase-setup.js\n\n   # Or with an env-var passphrase:\n   VAULT_PASSPHRASE='your-strong-passphrase' \\\n   node scripts/vault-passphrase-setup.js\n\n   # Or from a file (Docker/K8s secrets idiom — recommended for production):\n   VAULT_PASSPHRASE_FILE=/run/secrets/vault-passphrase \\\n   node scripts/vault-passphrase-setup.js\n   ```\n   The tool wraps `vault.key` into `vault.key.sealed`, verifies round-trip, then atomically deletes the plaintext. Pass `--keep-plaintext` if you want to preserve the plaintext file as a manual rollback backup.\n\n4. Set `VAULT_PASSPHRASE_MODE=required` in the server's environment, plus one of:\n   - `VAULT_PASSPHRASE=\u003csame-passphrase\u003e` (env var), or\n   - `VAULT_PASSPHRASE_FILE=/path/to/secret` (file — preferred for orchestration).\n\n5. Restart the server. Expected startup lines:\n   ```\n   [vault] Unsealing vault.key.sealed...\n   [vault] Unsealed successfully.\n   ```\n\n#### Rotating the passphrase\n\nOnce protection is enabled, rotate the passphrase periodically or whenever exposure is suspected:\n\n```bash\n# Interactive (recommended for local ops):\ndocker exec -it hermitstash node scripts/vault-passphrase-rotate.js\n\n# Scripted (secrets-manager / CI friendly):\nVAULT_PASSPHRASE_OLD='current' VAULT_PASSPHRASE_NEW='new-value' \\\n  docker exec hermitstash node scripts/vault-passphrase-rotate.js\n```\n\nRotation reads the current sealed file, unwraps with the OLD passphrase, re-wraps with the NEW one using a fresh Argon2id salt and XChaCha20 nonce, verifies round-trip in-process, and atomically replaces the sealed file. After success, update the server's `VAULT_PASSPHRASE` (or `_FILE`) to the new value and restart.\n\n**Important caveat — what passphrase rotation does and does NOT protect:**\n\nPassphrase rotation protects the **future**, not the past. If an attacker already captured both the sealed file AND the old passphrase, they already have the vault key. Changing the passphrase after the fact doesn't undo that. For suspected vault-key compromise (not just passphrase compromise) use **full vault key rotation** (v1.9.3+) — see below.\n\n### Full vault key rotation (v1.9.3+)\n\nWhen you suspect the vault keypair itself has been compromised — not just the passphrase — run the offline full rotation tool. It generates a brand new ML-KEM-1024 + P-384 hybrid keypair and re-encrypts every vault-sealed value in the data directory: every sealed DB column, every per-file XChaCha20 key index, the SQLite file's wrapping key, and the vault key itself. File blobs in the upload directory are NOT re-encrypted — their per-file keys are, so rotation completes in seconds even for multi-terabyte upload directories.\n\n```bash\n# stop the server first, then:\ndocker exec -it hermitstash node scripts/vault-key-rotate.js\n\n# scripted (wrapped mode):\nVAULT_PASSPHRASE_OLD='current' VAULT_PASSPHRASE_NEW='new' \\\n  docker exec hermitstash node scripts/vault-key-rotate.js\n\n# dry-run — exercise everything except the final swap:\ndocker exec hermitstash node scripts/vault-key-rotate.js --dry-run\n```\n\nThe tool builds a complete rotated copy of `data/` at `data.rotating/`, verifies it round-trips, then atomically swaps `data/` → `data.old.\u003cISO timestamp\u003e/` and `data.rotating/` → `data/`. A crash at any point leaves `data/` either fully pre-rotation or fully post-rotation, never partial — server boot recovery handles every interruption point.\n\nAfter success:\n1. `data.old.\u003cISO timestamp\u003e/` is retained (delete with `rm -rf` once you've verified the rotated state)\n2. If the passphrase changed, update `VAULT_PASSPHRASE` / `VAULT_PASSPHRASE_FILE` to the new value\n3. Restart the server\n4. Verify access: `docker exec hermitstash node scripts/vault-key-verify.js`\n\nSessions are invalidated by the required server restart (sessions live in tmpfs by design).\n\n**Performance:** ~500 sealed-column-values rotated per second per CPU core. A typical 100k-row DB with ~5 sealed columns per row (~500k values) takes ~15 minutes; 1M rows takes ~90 minutes. Bottleneck is per-value PQC envelope crypto, not I/O.\n\n**⚠ When to use:**\n- Suspected vault-key compromise (sealed file + passphrase both leaked)\n- Annual key rotation per compliance policy\n- Investigating? Run `--dry-run` first; it does everything except the final swap\n\nFor just changing the passphrase (e.g. the old passphrase leaked but the sealed file did NOT), keep using `vault-passphrase-rotate.js` — it's a faster operation that only re-wraps the same vault key.\n\n### PEM at-rest sealing for CA + TLS keys (v1.9.4+, opt-in)\n\nv1.9.0 closed the disk-snapshot threat for `data/vault.key`. v1.9.4 extends the same protection to two other long-lived plaintext PEM files:\n\n- **`data/ca.key`** — the mTLS root of trust. Whoever reads it can mint trusted client certs forever; rotation never undoes that. This is the most important PEM to seal.\n- **`data/tls/privkey.pem`** — the TLS server private key. Lower long-term risk because it rotates via Let's Encrypt every 60-90 days, but a snapshot during the renewal window still enables MITM until cert expiry.\n\nBoth are independent opt-in via env var:\n\n| Env var | Default | When `=required` |\n|---|---|---|\n| `CA_KEY_SEALED` | `auto` (load whichever exists) | Refuse to operate on plaintext `ca.key`; require `ca.key.sealed` |\n| `TLS_KEY_SEALED` | `auto` | Refuse to boot on plaintext `tls/privkey.pem`; require `tls/privkey.pem.sealed` |\n\n#### CA key\n\n```bash\n# stop the server (or leave running — CA is loaded lazily on cert ops)\ndocker exec hermitstash node scripts/ca-key-seal.js\n# then set CA_KEY_SEALED=required and restart\n```\n\nTo revert: `docker exec hermitstash node scripts/ca-key-unseal.js` (do this BEFORE downgrading to v1.9.3 or earlier; older versions don't understand `.sealed` files).\n\n#### TLS server key — ACME-friendly\n\n```bash\ndocker exec hermitstash node scripts/tls-key-seal.js\n# set TLS_KEY_SEALED=required and restart\n```\n\nAfter enabling sealed mode, **certbot / acme.sh hooks need no changes**. The running server's cert watcher polls every minute and **auto-seals plaintext `privkey.pem` files that ACME tools drop into `tls/`**. Renewal flow: ACME writes plaintext → watcher detects within ~1 min → vault-seals → deletes plaintext → reloads `setSecureContext`.\n\nFor ACME hooks that need immediate effect (no 1-minute wait), call `scripts/tls-key-seal.js --reload` from your hook — it sends `SIGHUP` to the running server's PID file (`data/hermitstash.pid`) which triggers an immediate reload.\n\nTo revert: `scripts/tls-key-unseal.js`.\n\n#### What sealing does and does NOT protect\n\n- ✅ Closes the disk-snapshot gap for the CA + TLS keys (no longer recoverable from a stolen volume snapshot without the vault keypair)\n- ✅ ACME workflows continue without modification\n- ❌ Does NOT protect a running server (key is in process memory once unsealed, recoverable by any code-execution attacker — same N1 caveat as the vault key itself)\n- ❌ Does NOT survive vault key loss — these PEMs are now downstream of the vault. If the vault is unrecoverable, the CA is too (every existing client cert becomes invalid; users must re-enroll). Trade-off: same risk profile as every other vault-sealed value.\n\n### Security overview at a glance (v1.9.5+)\n\nThe admin **Settings → Security** tab shows the live status of every security-related setting in one view, with **Enable/Disable buttons** (v1.9.9+) for the three sealable layers.\n\n- Vault key passphrase wrapping (status of `VAULT_PASSPHRASE_MODE` + whether `vault.key.sealed` exists)\n- mTLS CA private key sealing (status of `CA_KEY_SEALED` + whether `ca.key.sealed` exists)\n- TLS server private key sealing (status of `TLS_KEY_SEALED` + whether `privkey.pem.sealed` exists)\n- mTLS enforcement strictness (`ENFORCE_MTLS_STRICT` mode and whether mTLS is currently active at TLS or app layer)\n- TLS / HTTPS configuration\n\nEach row shows a ✓ / · / ! indicator, the current effective value (masked for sensitive bits), a short explanation of what the setting does, and operator guidance for the right way to configure it.\n\n**Boot-time secrets must come from environment variables** (or `*_FILE` variants for Docker secrets), never the admin UI. The Action buttons in v1.9.9+ create the sealed file artifacts but they CANNOT write your `.env` / Docker secret / Kubernetes Secret for you — those live on the host, outside the container's mount. The vault-passphrase-enable wizard surfaces a copyable env-var snippet tailored to your deployment style (Docker Compose with Secrets, Compose with .env, Kubernetes, or systemd) so you can paste it into your config before sealing.\n\n#### How the Enable wizards work (v1.9.9+)\n\nFor **vault key passphrase wrapping** — a 4-step wizard:\n\n1. Pick deployment style → wizard renders the right env-var snippet\n2. Copy the snippet, paste it into your deployment config, save it\n3. Three-checkbox confirmation: env vars added, passphrase stored safely, you understand loss-of-passphrase = loss-of-data\n4. Enter the passphrase + confirm → server seals `vault.key` → success message\n\nAfter the wizard completes, the next server restart will use the configured env var to unwrap. **If you skip the env-var setup step, the next restart will fail with \"passphrase rejected\"** — an explicit failure mode, not silent breakage. Run `scripts/vault-passphrase-remove.js` BEFORE restart to revert if you're not ready.\n\nFor **CA key sealing** and **TLS server key sealing** — single confirmation modals (no operator-side env config needed; the vault key is already in memory, so the dispatch picks up the sealed file automatically). TLS sealing also triggers an immediate cert reload via SIGHUP.\n\n#### Two env-var conventions in use\n\n1. **Tristate `*_MODE` / `*_SEALED`** — `auto` (default; load whichever exists) / `required` (refuse to operate on plaintext) / `disabled` (refuse to operate on sealed). Used by `VAULT_PASSPHRASE_MODE`, `CA_KEY_SEALED`, `TLS_KEY_SEALED`. Newer convention introduced in v1.9.x.\n2. **Binary `ENFORCE_MTLS_STRICT`** — `true` (hard enforcement at TLS handshake) / `false` (escape hatch — disables ALL mTLS, use only for locked-out recovery) / unset (soft enforcement at app layer, default). Predates the tristate convention; kept for backwards compat. The Security tab labels both styles clearly so operators don't need to remember which is which.\n\n#### Recommended secure defaults\n\nFor any deployment beyond a personal homelab where the host is fully trusted:\n\n```bash\n# .env or compose environment\nVAULT_PASSPHRASE_MODE=required\nVAULT_PASSPHRASE_FILE=/run/secrets/vault-passphrase   # Docker secrets\nCA_KEY_SEALED=required\nTLS_KEY_SEALED=required\nENFORCE_MTLS_STRICT=true                              # if mTLS is configured\n```\n\nRun the corresponding seal scripts once to migrate existing plaintext keys: `vault-passphrase-setup.js`, `ca-key-seal.js`, `tls-key-seal.js`.\n\n### Backup configuration — v1.9.4 recovery for v1.9.3-affected deployments\n\nA bug in v1.9.0–v1.9.3 silently blanked the saved backup passphrase whenever the operator edited any other backup setting (schedule, timezone, retention, S3 endpoint) without re-typing the passphrase. The form pre-populated empty for the passphrase field, the form submitted that empty value on save, the backend overwrote the stored passphrase. Once blanked, the scheduled backup job silently skipped on every tick with only a stderr log line — no audit log, no admin UI surface.\n\nIf you upgraded from v1.9.3 or earlier and your `BACKUP_PASSPHRASE` may have been silently cleared:\n\n1. Open the admin Backup section\n2. Re-enter your passphrase in the Backup Passphrase field\n3. Click Save Backup\n\nAfter v1.9.4, the form pre-populates with bullets when a passphrase is saved, and the submission round-trip preserves the saved value when the bullets aren't replaced. The fix also adds a diagnostic block to the Backup History section that surfaces \"Backups are silently skipping because: $reason\" when scheduled backup is misconfigured — instead of a bare \"No backups found\" with no clue why.\n\n#### Reverting\n\nStop the server, then run:\n```bash\nVAULT_PASSPHRASE='your-passphrase' node scripts/vault-passphrase-remove.js\n```\nUnset `VAULT_PASSPHRASE_MODE` (or set to `disabled`), restart. The plaintext `vault.key` is restored byte-for-byte.\n\n#### What this protects against\n\n- Stolen disk snapshots, leaked backups, accidental `rsync` exposure, cloud-provider storage compromise.\n\n#### What this does NOT protect against\n\n- **Live host compromise.** Once the server has unsealed the key into memory, any attacker with code execution on the host recovers it. This is unavoidable for any at-rest encryption on a running service.\n- **Leakage of the passphrase itself.** If `VAULT_PASSPHRASE` sits in an `.env` file alongside the sealed vault, both can be stolen together. The `VAULT_PASSPHRASE_FILE` path is preferred because it lets you put the passphrase on a different filesystem or managed secret store.\n- **Downgrade to v1.8.x** — earlier versions don't understand the wrapped format. Run the removal tool first if you need to downgrade.\n\nSee [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) §5.2 and §9 L2 for the full threat analysis.\n\n### Health check\n\n`GET /health` returns `{ status, uptime, timestamp }` — works with Docker HEALTHCHECK, Kubernetes liveness probes, load balancers, and the [PQC gateway](https://github.com/dotCooCoo/hermitstash-web) status check.\n\nProbes from the same origin as the app (container HEALTHCHECK on `localhost`, a Kubernetes liveness probe inside the pod, a TLS-terminating reverse proxy without CORS) need no extra config. A browser-driven probe from a *different* origin — for example the static PQC entry page at `hermitstash.com` checking `app.hermitstash.com/health` before redirecting — needs that origin added to `CORS_ORIGINS` (env var or Admin \u003e Settings \u003e Security). Without the listing, the response is `403` and the browser rejects the result, even though the underlying request succeeded.\n\n### Reverse proxy\n\nDrop-in configs for the three common proxies live in [`deploy/reverse-proxy/`](deploy/reverse-proxy/) — [`Caddyfile`](deploy/reverse-proxy/Caddyfile), [`nginx.conf`](deploy/reverse-proxy/nginx.conf), and [`apache.conf`](deploy/reverse-proxy/apache.conf). All three terminate TLS, forward `/sync/ws` WebSocket upgrades, match the 100MB upload limit, and pass `X-Forwarded-*` headers through for `TRUST_PROXY=true`.\n\nThe admin panel (Settings \u003e Uploads) auto-detects your proxy and generates a ready-to-paste snippet reflecting your current `MAX_FILE_SIZE` if you'd rather tune body limits from the UI.\n\nIf you use the sync client's mTLS mode, see the [reverse-proxy README](deploy/reverse-proxy/README.md#mtls-sync-clients) — TLS-terminating proxies strip the client cert, so you need TCP passthrough or a dedicated bypass port.\n\nPasskey sign-in uses the browser's WebAuthn API, which is exposed only over HTTPS or on `localhost`. On a plain-HTTP origin (e.g. a LAN hostname) the passkey option hides itself and reappears once the app is served over HTTPS; password sign-in and the rest of the app work over plain HTTP regardless.\n\n### S3 storage\n\nConfigure S3-compatible storage (AWS, MinIO, Cloudflare R2, DigitalOcean Spaces, Backblaze B2) from Admin \u003e Settings \u003e Storage tab. All credentials are vault-sealed and validated by the settings schema on save. For R2, set the endpoint to `https://\u003caccount-id\u003e.r2.cloudflarestorage.com` and region to `auto`.\n\n### Other platforms\n\n**Umbrel:** Available in the [Umbrel App Store](https://apps.umbrel.com/app/hermitstash) — open the App Store on your Umbrel, search HermitStash, and click Install. Volumes, ports, and shared memory are configured automatically.\n\n**Coolify / Portainer:** Paste `ghcr.io/dotcoocoo/hermitstash:1` as the image. Set port 3000, mount `/app/data` and `/app/uploads` as persistent volumes, set shared memory to 256MB, add `TRUST_PROXY=true` and `RP_ORIGIN=https://your-domain.com`.\n\n**Unraid:** Docker → Add Container → paste this template URL:\n```\nhttps://raw.githubusercontent.com/dotCooCoo/hermitstash/main/unraid-template.xml\n```\nPre-fills icon, ports, volumes, and `--shm-size=256m` automatically.\n\n**Synology / QNAP:** Container Manager → Registry → add `ghcr.io`, search `dotcoocoo/hermitstash`, download `latest`. Create container with port 3000 and folder mappings for `/app/data` and `/app/uploads`. For `--shm-size` use SSH: `docker run -d --shm-size=256m ...`\n\n**Kubernetes:**\n```bash\ncurl -O https://raw.githubusercontent.com/dotCooCoo/hermitstash/main/deploy/kubernetes.yml\n# Edit RP_ORIGIN, PVC sizes, and optional Ingress\nkubectl apply -f kubernetes.yml\nkubectl port-forward -n hermitstash svc/hermitstash 3000:3000\n```\nIncludes: Namespace, PVCs, Deployment (liveness/readiness probes, resource limits, memory-backed `/dev/shm`), Service, and commented-out Ingress template. See [`deploy/kubernetes.yml`](deploy/kubernetes.yml).\n\n**TrueNAS SCALE:** Apps → Custom App → image `ghcr.io/dotcoocoo/hermitstash`, tag `latest`. Add host path datasets for `/app/data` and `/app/uploads`. Add shared memory volume: type emptyDir, medium Memory, size 256Mi, mount at `/dev/shm`.\n\n**Ubuntu / Debian (native install):**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/dotCooCoo/hermitstash/main/deploy/install.sh | sudo bash\n# Or with auto-update enabled from the start:\ncurl -fsSL https://raw.githubusercontent.com/dotCooCoo/hermitstash/main/deploy/install.sh | sudo HERMITSTASH_AUTO_UPDATE=yes bash\n```\nInstalls Node.js 24, creates a `hermit` system user, sets up tmpfs (256MB) for the in-memory database, and registers a systemd service using the checked-in [`deploy/hermitstash.service`](deploy/hermitstash.service) unit. Re-running the script `git pull`s the latest code and restarts the service. See [`deploy/install.sh`](deploy/install.sh). Uninstall with [`deploy/uninstall.sh`](deploy/uninstall.sh) — data is preserved unless you pass `--purge`.\n\n**Terraform (DigitalOcean):**\n```bash\ncd deploy/terraform\ncp terraform.tfvars.example terraform.tfvars  # edit with your API token + SSH key\nterraform init \u0026\u0026 terraform apply\n```\nProvisions a droplet, configures a firewall (SSH + HTTP/S + 3000), and optional DNS. See [`deploy/terraform/`](deploy/terraform/).\n\n**Ansible:**\n```bash\nansible-playbook -i \"your-server,\" deploy/ansible-playbook.yml\n```\nSupports both Docker (`-e hermitstash_deploy=docker`) and native (`-e hermitstash_deploy=native`) deployment modes. See [`deploy/ansible-playbook.yml`](deploy/ansible-playbook.yml).\n\n**Proxmox LXC:**\n```bash\n# Run on the Proxmox host\nbash deploy/proxmox-lxc.sh\n```\nCreates an unprivileged Debian 12 LXC container with Docker and HermitStash. Configurable via environment variables (`CTID`, `MEMORY`, `DISK`, `IP`). See [`deploy/proxmox-lxc.sh`](deploy/proxmox-lxc.sh).\n\n**LXD / Incus:**\n```bash\n# Run on the host (auto-detects LXD or Incus)\nbash deploy/lxd-incus.sh\n```\nCreates a Debian 12 system container with Docker nested inside. Forwards port 3000 from host to container via proxy device. Configurable via environment variables (`CONTAINER_NAME`, `MEMORY`, `DISK`, `CPU`, `PORT`). See [`deploy/lxd-incus.sh`](deploy/lxd-incus.sh).\n\n**Podman (RHEL / Fedora / Rocky / Alma):**\n```bash\nbash deploy/podman.sh\n```\nDrop-in Docker alternative — works rootless or rootful. Automatically generates a systemd unit via `podman generate systemd` (user unit for rootless, system unit for rootful). Volumes use `:Z` for SELinux relabeling. Pass `AUTO_UPDATE=true` to opt into `podman-auto-update.timer`. See [`deploy/podman.sh`](deploy/podman.sh).\n\n**Systemd (manual):** If you already have Node.js 24+ installed, copy [`deploy/hermitstash.service`](deploy/hermitstash.service) to `/etc/systemd/system/` and adjust paths. The unit includes `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp`, and scoped `ReadWritePaths`.\n\n### Upgrading\n\n```bash\n# Back up vault key (critical — loss = all data unrecoverable)\ncp data/vault.key data/vault.key.bak\n\n# Pull new image and restart\ndocker pull ghcr.io/dotcoocoo/hermitstash:1\ndocker compose up -d\n```\n\nDatabase migrations run automatically on startup — no manual steps needed. The server logs applied migrations at startup. If something goes wrong, restore `vault.key` and `hermitstash.db.enc` from your backup and restart with the previous image version.\n\n### Envelope migration (v1.9.16 → v1.9.18+) — automatic at boot\n\nStarting in **v1.9.18**, HermitStash auto-migrates its on-disk sealed-value envelope from `0xE1` to `0xE2` (NIST SP 800-56C r2 / RFC 9180 FixedInfo binding) at the first boot after upgrading from v1.9.17 or earlier. The vendored framework (blamejs) bumped the envelope magic in its 0.8.41 release and refuses legacy `0xE1` blobs on decrypt; the auto-migrate hook runs in-process during server startup and converts every `vault:`-prefixed sealed value before any other module reads it.\n\n```bash\ndocker compose pull \u0026\u0026 docker compose up -d\n# First boot logs:\n#   [envelope-migrate] detected 0xE1 sealed data — converting to 0xE2 ...\n#   [envelope-migrate] [ok] api-encrypt-keypair.sealed\n#   [envelope-migrate] [ok] users — 1 rows migrated\n#   [envelope-migrate] [ok] audit_log — 91 rows migrated\n#   ... (remaining tables) ...\n#   [envelope-migrate] [ok] db.key.enc\n#   [envelope-migrate] complete — 2 sealed files + 130 DB rows migrated to 0xE2\n```\n\nSubsequent boots probe `data/db.key.enc`'s envelope magic byte and skip the migration entirely. Migration scope: `data/ca.key.sealed`, `data/tls/privkey.pem.sealed`, `data/api-encrypt-keypair.sealed`, `data/db.key.enc`, and every `vault:`-prefixed cell in the encrypted DB. Cross-version-compatible formats (DB file `encryptPacked`, per-file storage blobs, backup blobs) are **not** touched — they read identically across versions.\n\nFor operators who prefer manual control or want to dry-run before committing, the standalone CLI at `scripts/envelope-migrate-0xE1-to-0xE2.js` is still shipped:\n\n```bash\ndocker compose down\nnode scripts/envelope-migrate-0xE1-to-0xE2.js                # dry-run, no writes\nnode scripts/envelope-migrate-0xE1-to-0xE2.js --apply        # actual migration\ndocker compose up -d                                         # boot — auto-migrate detects 0xE2, no-ops\n```\n\nCrash safety: marker file at `data/envelope-migration.marker` tracks completed steps. A killed migration resumes from the last completed step on next boot. The migration refuses to re-run on already-migrated data (which would otherwise trip `lib/db`'s auto-regenerate fallback). Restore from backup before re-running if needed.\n\nContainer orchestrators with aggressive startup health-check timeouts: raise the startup probe timeout to 5 minutes for the v1.9.17 → v1.9.18 jump. Worst-case migration time is ~3 minutes for ~100k sealed cells; typical small deployments measure in seconds.\n\n### Auto-update (opt-in)\n\nAuto-update is off by default on every deployment method. Turn it on when you want it.\n\n**Docker / Compose:** a 3-line root cron is enough — no extra container, no Docker socket to mount:\n```cron\n# /etc/cron.d/hermitstash-update  (root)\n17 4 * * *  cd /opt/hermitstash \u0026\u0026 docker compose pull \u0026\u0026 docker compose up -d --remove-orphans\n```\nThe `ghcr.io/dotcoocoo/hermitstash:1` tag is a moving major-version pointer that every v1.* release updates, so this stays on v1.x forever. If you're on Coolify, Portainer, or CapRover, use the platform's built-in auto-deploy instead — they already watch the registry and handle rollout without needing a cron.\n\n**Podman:** pass `AUTO_UPDATE=true` to `deploy/podman.sh`. The script adds the `io.containers.autoupdate=registry` label and enables `podman-auto-update.timer`. Preview with `podman auto-update --dry-run`.\n\n**Native install (systemd):** pass `HERMITSTASH_AUTO_UPDATE=yes` to the installer, or enable it later:\n```bash\nsudo systemctl enable --now hermitstash-update.timer\n```\nThe timer fires [`deploy/update.sh`](deploy/update.sh) daily with a randomized 4-hour delay. The script reads the installed version from `package.json`, fetches the latest matching release tag from the GitHub API, and performs a `git fetch \u0026\u0026 git checkout vX.Y.Z` followed by `systemctl restart hermitstash`. If `/health` doesn't come back within 60 seconds, it rolls back to the prior commit and restarts again. Auto-update stays on your current major version — going from v1.x to v2.x is an operator-initiated action. Dry-run with `sudo DRY_RUN=1 /opt/hermitstash/deploy/update.sh`.\n\n**Kubernetes:** there's no HermitStash-provided controller. Use your cluster's standard tooling — ArgoCD, Flux, or Keel — to watch the `:1` image tag for updates.\n\n**Signed releases:** the native updater has pluggable strategies — `UPDATE_STRATEGY=git` today, with stubs for `release-tarball` (checksum-verified) and `signed-tarball` (P-384 ECDSA signed against keys in `/etc/hermitstash/trusted-keys.d/`) so the transport can be hardened later without rewriting the timer, rollback, or health-check paths.\n\n### Release provenance\n\nEvery release tag (`vX.Y.Z`) and every commit on `main` is signed at push time, and GitHub rejects unsigned pushes at the repository edge. Tags are also immutable — they cannot be deleted or moved once published.\n\nThe simplest verification path: open `https://github.com/dotCooCoo/hermitstash/releases/tag/vX.Y.Z` and confirm the **Verified** badge on the tag. GitHub checks the signature against the maintainer's registered signing keys (`gh api users/dotCooCoo/ssh_signing_keys`).\n\nLocal verification, for operators who prefer not to trust GitHub's view:\n\n```bash\n# One-time: pin the maintainer's signing key locally\nmkdir -p ~/.config/git\ncat \u003e ~/.config/git/allowed_signers \u003c\u003c'EOF'\n* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPiE/PETpyiVPd8aMygJ+S9CsSVolp4HQZaAuiYVwbBa\nEOF\ngit config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers\n\n# Then for any release tag\ngit fetch --tags\ngit tag --verify vX.Y.Z\n```\n\nTag-integrity is enforced at the GitHub edge by the repository ruleset:\nunsigned tag pushes, tag deletion, and tag force-update are rejected\nserver-side and cannot be bypassed. Combined with the SLSA L3\nprovenance + ML-DSA-65 sidecar + cosign image signature below, every\nrelease artifact carries a verifiable chain back to the maintainer's\nSSH signing key or to Sigstore's transparency log.\n\nEach release attaches a fixed asset bundle to the GitHub Release page,\ncreated atomically by the workflow at tag-push time:\n\n| File | Purpose | Verify with |\n|------|---------|-------------|\n| `hermitstash-vX.Y.Z.image.tar.sha256` | SHA-256 of the saved image | `sha256sum -c \u003cfile\u003e` |\n| `hermitstash-vX.Y.Z.image.tar.sha3-512` | SHA3-512 of the saved image | `openssl dgst -sha3-512 \u003ctarball\u003e` |\n| `hermitstash-vX.Y.Z.image.tar.mldsa.sig` | ML-DSA-65 PQC signature over the image bytes | see below |\n| `hermitstash-vX.Y.Z.vex.json` | CSAF 2.1 VEX document | attached when CVE assessments exist |\n\nThe OCI image itself carries SLSA L3 provenance (via the SLSA generic\ncontainer generator) plus a Sigstore-keyless cosign signature. Verify both\nwith:\n\n```bash\nslsa-verifier verify-image ghcr.io/dotcoocoo/hermitstash@\u003cdigest\u003e \\\n  --source-uri github.com/dotCooCoo/hermitstash \\\n  --source-tag vX.Y.Z\n\ncosign verify ghcr.io/dotcoocoo/hermitstash@\u003cdigest\u003e \\\n  --certificate-identity-regexp 'https://github.com/dotCooCoo/hermitstash/' \\\n  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com'\n```\n\nTo verify the ML-DSA-65 signature, fetch the release-signing public key\nfrom `keys/release-pqc-pub.json` in the repo, then verify against the\nsaved-image bytes. The public-key file is committed in-tree and signed via\nthe commit signature chain, so the trust root is the maintainer's SSH\nsigning key (same as for tag verification). When `vex/statements.json` has\nno entries the VEX attestation is omitted — most releases will fall into\nthat bucket.\n\n#### One-time release-signing key setup (maintainer only)\n\nThe release workflow looks for `RELEASE_PQC_SIGNING_KEY` in its `release`\nenvironment. To generate the keypair:\n\n```bash\nnode scripts/generate-release-signing-key.js\n# Writes keys/release-pqc-pub.json (commit this file).\n# Prints the private key — set as the env secret:\ngh secret set RELEASE_PQC_SIGNING_KEY --env release \\\n  --repo dotCooCoo/hermitstash --body \"\u003cpaste\u003e\"\n```\n\nThe signer self-verifies against the in-tree public key before writing\nthe `.mldsa.sig` sidecar, refusing to ship an unverifiable signature when\nthe secret drifts from the committed pubkey. The workflow gracefully\nskips the sidecar with a warning when either piece is missing — the\nrelease still ships with the L3 + cosign + digest sidecars + VEX.\n\n### Boot-time clock check\n\nOn startup HermitStash queries NTP (`time.cloudflare.com`, then\n`pool.ntp.org`) to verify the system clock is sane — audit log timestamps,\nsession/token expirations, backup naming, and cert validity all depend on\nit. Outcomes:\n\n| Drift | Default | `BLAMEJS_NTP_STRICT=1` |\n|-------|---------|------------------------|\n| \u003c 5 min | log info, continue | log info, continue |\n| 5 min – 1 hr | log warning, continue | log warning, continue |\n| ≥ 1 hr | log warning, continue | refuse to boot |\n| NTP unreachable (UDP/123 egress blocked) | log warning, continue | log warning, continue |\n\nSet `BLAMEJS_NTP_STRICT=1` in the environment when you want a misconfigured\nclock to fail the deployment loudly instead of producing a misleading audit\ntrail. Leave unset for the default — useful when your container host has no\nUDP/123 egress but the host OS's NTP daemon is keeping the clock correct.\n\n### Maintenance mode\n\nToggle from Admin \u003e Settings \u003e Branding. Blocks all non-admin access and serves a 503 page. Admin routes, auth routes, and API keys with admin scope still work during maintenance.\n\n## API Keys\n\nAPI keys enable programmatic access. Manage them in the admin panel under the **API Keys** collapsible section.\n\n### Creating a key\n\nGenerate a key from the admin panel or via the API:\n\n```bash\ncurl -X POST https://your-domain/admin/apikeys/create \\\n  -H \"Authorization: Bearer \u003cadmin-api-key\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\": \"CI Pipeline\", \"permissions\": \"upload\"}'\n```\n\nResponse (key shown once, then SHA3-hashed -- never retrievable):\n```json\n{ \"success\": true, \"key\": \"hs_a1b2c3d4e5f6...\", \"prefix\": \"hs_a1b2\" }\n```\n\n### Error responses\n\nNon-HTML clients (Bearer-authenticated API, sync client, anything that doesn't\nsend `Accept: text/html`) receive errors as RFC 9457 problem-details:\n\n```\nHTTP/1.1 400 Bad Request\nContent-Type: application/problem+json\nCache-Control: no-store\n\n{\n  \"type\":   \"https://hermitstash.com/problems/validation-error\",\n  \"title\":  \"Validation Error\",\n  \"status\": 400,\n  \"detail\": \"name must be a non-empty string\"\n}\n```\n\n`type` identifies the problem class; `title` is the human-readable summary\nof the class; `status` mirrors the HTTP status; `detail` carries the\ninstance-specific message. 5xx responses omit `detail` so internal failure\ntext never reaches clients. HTML clients still receive the standard error\ntemplate — the content negotiation is `Accept`-driven.\n\nMigrating from the pre-v1.10.1 `{ \"error\": \"...\" }` shape: read `.detail`\ninstead of `.error`, branch on `.status` rather than HTTP status alone if\nyou want type-stable error codes (`.type` is the long-lived identifier).\n\n### Idempotency-Key (retry-safe writes)\n\nFive mutating POST endpoints honor an optional `Idempotency-Key` header\n(draft-ietf-httpapi-idempotency-key):\n\n- `POST /admin/apikeys/create`\n- `POST /admin/webhooks/create`\n- `POST /admin/users/invite`\n- `POST /drop/init`\n- `POST /drop/finalize/:bundleId`\n\nSend any opaque string (UUID is the typical choice) as the header value.\nThe server caches the first response for 24h; if your retry-loop fires\nthe same request again with the same header value, the cached response\nis replayed without re-executing the handler — no duplicate API key, no\nduplicate bundle, no duplicate invite email.\n\n```bash\nKEY=$(uuidgen)\ncurl -X POST https://your-domain/admin/apikeys/create \\\n  -H \"Authorization: Bearer \u003cadmin-api-key\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $KEY\" \\\n  -d '{\"name\": \"CI Pipeline\", \"permissions\": \"upload\"}'\n```\n\nSending the same `Idempotency-Key` with a different request body returns\n422 + `application/problem+json` carrying type\n`https://hermitstash.com/problems/idempotency/key-reuse-mismatch` —\nthat's a client-side mistake (same key reused across genuinely different\noperations) and the spec mandates the refusal.\n\nThe header is optional. Clients that don't send it skip the cache\nentirely; the handler runs every time. Existing API clients keep working\nunchanged.\n\n### Authentication\n\nInclude the key as a Bearer token:\n\n```\nAuthorization: Bearer hs_a1b2c3d4e5f6...\n```\n\n### Permission scopes\n\n| Scope | Access |\n|-------|--------|\n| `upload` | Create bundles, upload files via `/drop` endpoints |\n| `read` | List and download files, view bundles |\n| `admin` | Full admin access (settings, users, webhooks, keys) |\n| `webhook` | Manage webhooks |\n\n### Upload endpoints\n\nPublic upload endpoints accept API key authentication. When authenticated, uploads are assigned to the key owner's account.\n\n| Endpoint | Method | Description |\n|----------|--------|-------------|\n| `GET  /.well-known/blamejs-pubkey` | Server keypair for the blamejs apiEncrypt envelope. Plain JSON `{publicKey, ecPublicKey, kemId, cipherId, kdfId}`. No auth, no encryption. Cache at the client; re-fetch only when the server keypair rotates |\n| `POST /drop/init` | Initialize a bundle. **Blamejs-encrypted.** Decrypted body: `{ uploaderName, uploaderEmail, password, message, bundleName, expiryDays, fileCount, ... }`. Decrypted response: `{ bundleId, shareId, finalizeToken }` |\n| `POST /drop/file/:bundleId` | Upload a file (multipart/form-data, field: `file`). Body bypasses encryption (multipart not JSON). Response is plaintext JSON for Bearer clients |\n| `POST /drop/chunk/:bundleId` | Upload a chunk for large files (multipart, fields: `chunk`, `filename`, `chunkIndex`, `totalChunks`). Same encryption shape as `/drop/file/:bundleId` |\n| `POST /drop/finalize/:bundleId` | Finalize the bundle. **Blamejs-encrypted.** Decrypted body: `{ finalizeToken }`. Decrypted response: `{ success, shareId, shareUrl, emailSent }` |\n| `GET  /b/:shareId` | Bundle metadata (with `Accept: application/json`). Plaintext for Bearer clients |\n| `POST /sync/rename` | Sync file rename. **Blamejs-encrypted.** Decrypted body: `{ bundleId, oldRelativePath, newRelativePath }` |\n| `DELETE /files/:fileId` | Sync file delete. Plaintext request and response. Sync-guards enforce scope + cert binding + bundle ownership |\n\n### Example: programmatic upload\n\n`POST /drop/init` and `POST /drop/finalize/:bundleId` require the blamejs apiEncrypt envelope (`_ek` / `_ct` / `_ts` / `_nonce`) — plain-JSON callers receive `400 encrypted-payload-required`. Use a blamejs-aware HTTP client; the easiest is `b.httpClient.encrypted({ pubkey, baseUrl, keying: \"per-session\" })` from a blamejs-bundled app, fetching `pubkey` once from `GET /.well-known/blamejs-pubkey`. Multipart upload (`/drop/file/:bundleId`, `/drop/chunk/:bundleId`) bypasses the encryption layer for body content — the file bytes are application-encrypted at rest after upload.\n\n```js\n// minimal Node example using vendored blamejs\nvar b = require(\"./vendor/blamejs\");\nvar pubkey = await fetch(\"https://your-domain/.well-known/blamejs-pubkey\")\n  .then(r =\u003e r.json());\nvar enc = b.httpClient.encrypted({\n  pubkey, baseUrl: \"https://your-domain\", keying: \"per-session\",\n  headers: { Authorization: \"Bearer \" + process.env.API_KEY },\n});\n\nvar init = await enc.request({ method: \"POST\", path: \"/drop/init\",\n  body: { password: \"\", message: \"Automated upload\", expiryDays: 7 } });\nvar { bundleId, finalizeToken, shareId } = init.body;\n\n// Multipart upload — plain HTTPS, response is plaintext for Bearer clients\n// (omitted for brevity; field name is \"file\")\n\nawait enc.request({ method: \"POST\", path: \"/drop/finalize/\" + bundleId,\n  body: { finalizeToken } });\n\nconsole.log(\"Share link: https://your-domain/b/\" + shareId);\n```\n\n### Admin endpoints\n\nAll require `admin` scope:\n\n| Endpoint | Method | Description |\n|----------|--------|-------------|\n| `GET /admin/apikeys/api` | List all API keys (hashes hidden) |\n| `POST /admin/apikeys/create` | Generate new key. Body: `{ \"name\": \"...\", \"permissions\": \"upload\" }` |\n| `POST /admin/apikeys/:id/revoke` | Revoke a key permanently |\n| `GET /admin/settings` | Get all settings (sensitive values masked) |\n| `POST /admin/settings` | Update settings. Body: `{ \"siteName\": \"...\", ... }` |\n| `GET /admin/environment` | Runtime info (Node.js, OpenSSL, Docker, env overrides) |\n\n## Webhooks\n\nWebhooks send signed HTTP POST requests when events occur. Manage them in the admin panel under the **Webhooks** collapsible section.\n\n### Creating a webhook\n\n```bash\ncurl -X POST https://your-domain/admin/webhooks/create \\\n  -H \"Authorization: Bearer \u003cadmin-api-key\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\": \"https://example.com/hook\", \"events\": \"*\"}'\n```\n\nResponse (secret shown once):\n```json\n{ \"success\": true, \"secret\": \"a1b2c3d4...\" }\n```\n\n### Events\n\n| Event | Trigger | Payload |\n|-------|---------|---------|\n| `bundle_finalized` | Bundle upload completed and finalized | `{ shareId, uploaderName, files, size }` |\n\nEvent filter: set to `*` for all events, or a specific event name. Additional events may be added in future releases.\n\n### Payload format\n\n```json\n{\n  \"event\": \"bundle_finalized\",\n  \"data\": {\n    \"shareId\": \"a1b2c3d4e5f6...\",\n    \"uploaderName\": \"Anonymous\",\n    \"files\": 3,\n    \"size\": 1048576\n  },\n  \"timestamp\": \"2026-04-09T12:00:00.000Z\"\n}\n```\n\n### Signature verification\n\nEvery webhook request includes an `X-Webhook-Signature` header containing an HMAC-SHA3-512 hex digest of the raw JSON body, signed with the webhook secret:\n\n```\nX-Webhook-Signature: a1b2c3d4e5f6...\n```\n\nVerify in your handler:\n\n```javascript\nconst crypto = require(\"crypto\");\n\nfunction verifyWebhook(body, signature, secret) {\n  const expected = crypto\n    .createHmac(\"sha3-512\", secret)\n    .update(body)\n    .digest(\"hex\");\n  return crypto.timingSafeEqual(\n    Buffer.from(signature, \"hex\"),\n    Buffer.from(expected, \"hex\")\n  );\n}\n```\n\n```python\nimport hmac, hashlib\n\ndef verify_webhook(body: bytes, signature: str, secret: str) -\u003e bool:\n    expected = hmac.new(secret.encode(), body, hashlib.sha3_512).hexdigest()\n    return hmac.compare_digest(signature, expected)\n```\n\n### SSRF protection\n\nWebhook URLs are validated against:\n- Private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n- Link-local addresses (169.254.0.0/16, fe80::/10)\n- IPv6 private ranges (fc00::/7, ::1)\n- Cloud metadata endpoints (169.254.169.254)\n- Non-HTTPS schemes are rejected in production\n\n## Critical Files\n\nAll in the `data/` directory (gitignored):\n\n| File | What it is | Lose it? |\n|------|-----------|----------|\n| `data/vault.key` | ML-KEM-1024 + P-384 hybrid keypair | **All encrypted data permanently unrecoverable** |\n| `data/db.key.enc` | DB file encryption key (vault-sealed) | Database file unreadable |\n| `data/hermitstash.db.enc` | Encrypted database at rest | All settings, users, audit logs lost |\n\n**Back up `data/vault.key`.** This is the root of the entire encryption chain. Every sealed value, every encrypted file, every protected key traces back to this keypair. It cannot be regenerated.\n\n## Vendored Dependencies\n\nAll runtime dependencies are committed to the repo -- no `npm install` needed. As of v1.9.12, every server-side crypto / identity dependency is vendored as a single framework — **blamejs** — at `lib/vendor/blamejs/`. Browser-side bundles continue to ship individually until blamejs grows browser builds.\n\nManaged via `scripts/vendor-update.sh`:\n\n```bash\n./scripts/vendor-update.sh blamejs                # refresh the framework bundle\n./scripts/vendor-update.sh --check                # see what's outdated (browser bundles)\n./scripts/vendor-update.sh --diff @noble/ciphers  # see changelog (browser bundles)\n```\n\n| Vendored | Version | Author | Purpose |\n|----------|---------|--------|---------|\n| [`blamejs`](https://github.com/blamejs/blamejs) | 0.8.0 | blamejs contributors (Apache-2.0) | Server-side framework: XChaCha20-Poly1305, ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f, Argon2id (Node 24+ built-in), WebAuthn, mTLS CA, envelope versioning, audit chain, etc. Bundles every server-side crypto/identity dep transitively (see `lib/vendor/MANIFEST.json` `packages.blamejs.components`) |\n| [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) (browser only) | 2.1.1 | [Paul Miller](https://github.com/paulmillr) (MIT) | XChaCha20-Poly1305 in the browser vault + outbox flows |\n| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) (browser only) | 2.0.1 | [Paul Miller](https://github.com/paulmillr) (MIT) | SHAKE256 KDF in the browser |\n| [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) (browser only) | 0.6.0 | [Paul Miller](https://github.com/paulmillr) (MIT) | ML-KEM-1024 in the browser vault flow |\n\nblamejs internally vendors @noble/ciphers, @noble/post-quantum, @simplewebauthn/server,\n@peculiar/x509 + pkijs (peculiar-pki bundle), and the SecLists top-10000 password list —\neach tracked under `packages.blamejs.components` in `lib/vendor/MANIFEST.json` so\nTrivy / Grype can flag CVEs against any nested dep.\n\nArgon2id derivation runs through Node 24+'s built-in `crypto.argon2` API via blamejs's\n`lib/argon2-builtin.js` wrapper — the @ranisalt/argon2 native binding (and its 8-platform\nprebuilds) is no longer vendored.\n\nThese libraries are exceptional work. HermitStash wouldn't exist without them.\n\n## Architecture\n\n~180 JS files, 25 HTML templates, 21 database tables. Small files, one job each.\n\n```\nserver.js             Bootstrap, middleware, scheduled tasks, default accounts\nlib/\n  crypto.js           PQC crypto: ML-KEM-1024+P-384, XChaCha20, SHAKE256,\n                      SLH-DSA-SHAKE-256f (default sig), ML-DSA-87 (legacy),\n                      envelope versioning\n  vault.js            Hybrid keypair management, seal/unseal, auto key upgrade\n  field-crypto.js     FIELD_SCHEMA: auto seal/unseal/hash for all DB fields\n  db.js               SQLite + auto field crypto + DB file encryption\n  api-crypto.js       API payload XChaCha20-Poly1305 encrypt/decrypt\n  session.js          Hybrid KEM encrypted cookies, LRU eviction\n  storage.js          Local/S3 + XChaCha20-Poly1305 file encryption + pre-signed URLs\n                      saveRaw/getRawBuffer for pre-encrypted data (vault files)\n  cert-utils.js       Certificate fingerprint hashing + indexed revocation checks\n  config.js           Settings from encrypted DB, env fallback, onReset registry\n  settings-schema.js  Type-safe settings sanitization + validation (77 settings)\n  audit.js            Audit logging with auto-sealed entries\n  rate-limit.js       Per-IP rate limiting with proxy validation\n  ip-quota.js         Per-IP storage quota for anonymous uploads\n  email.js            SMTP + Resend API with dual failover + quota tracking\n  router.js           HTTP server, routing, pre-compiled patterns\n  multipart.js        Multipart + JSON body parser (shared accumulator)\n  template.js         Custom template engine with caching\n  sanitize.js         Filename sanitization + HTML escaping\n  sanitize-svg.js     SVG sanitizer (strips scripts, events, dangerous tags)\n  totp.js             TOTP generation/verification (HMAC-SHA-512 default,\n                      legacy HMAC-SHA-1 verification retained), backup codes\n  google-auth.js      Google OAuth2 (OpenID Connect, CSRF state)\n  constants.js        Paths, versions, theme, hash prefixes, time constants\n  zip.js              ZIP writer with Deflate compression\n  expiry.js           File expiry cleanup\n  scheduler.js        Task scheduler with watchdog timeouts\n  webhook.js          Webhook dispatch queue\n  pqc-gate.js         ClientHello PQC group inspection at TCP level\n  pqc-agent.js        PQC-only outbound HTTPS agent\n  vendor/blamejs/     Vendored framework (server-side crypto + identity primitives)\n\napp/\n  bootstrap/          Startup invariant checks\n  data/               Repositories + migration runner\n  domain/             Services (auth, uploads, teams, admin, webhooks, email)\n  http/               Request validators (upload magic bytes, auth, admin)\n  security/           CSRF, CORS, SSRF, scope, origin policies\n  domain/uploads/     Shared upload handler, bundle service, chunk service\n  jobs/               Background jobs (expiry, audit retention, webhook dispatch)\n  shared/             Errors, logger, validation helpers, filename sanitization\n\nscripts/              vendor-update.sh, vendor-font.js, sync-to-public.sh\nroutes/               19 route files (includes stash.js for Customer Stash)\nmiddleware/           15 files (auth, CORS, CSRF, API encryption, security headers, bot guard, require-access, require-admin, require-auth)\nviews/                25 templates\npublic/               CSS, JS, logos, icons, vendored fonts\n```\n\n## Contributing\n\nI want to be straightforward about this: **I'm not currently accepting code contributions**, and I want to explain why rather than just saying no.\n\nHermitStash is a security-focused project maintained by one person. Reviewing external code contributions to a cryptographic system is something I don't feel I can do responsibly right now — I'm still learning, and I'd rather not merge code I can't fully evaluate myself. Accepting PRs would mean either rubber-stamping changes I don't understand (bad) or asking contributors to wait indefinitely while I figure it out (also bad). The honest answer is that I'm not set up for it yet.\n\nThat said, there are a lot of ways to help that I genuinely welcome:\n\n- **Bug reports.** If something doesn't work, or works in a way that surprises you, please open an issue. Steps to reproduce help a lot.\n- **Security findings.** If you spot a cryptographic issue, a misuse of a primitive, or anything that contradicts a security claim in the README, please report it privately — see [SECURITY.md](SECURITY.md) for how.\n- **Feature requests.** Open an issue describing the use case. I can't promise I'll build it, but I want to hear what people would find useful.\n- **Documentation feedback.** If something in the README is unclear, wrong, or missing, an issue is great. Documentation issues are some of the most useful kinds of feedback I get.\n- **Questions.** If you're trying to use HermitStash and something isn't clear, asking is welcome.\n\nIf you've built something on top of HermitStash, or you're running it somewhere interesting, I'd love to hear about that too — feel free to open an issue just to say hi.\n\nThis may change in the future. If HermitStash grows to a point where I can responsibly review external code, I'll update this section. Until then: thank you for understanding, and thank you for being interested enough to consider contributing in the first place.\n\n## License\n\n[AGPL-3.0-or-later](LICENSE)\n\n## A final note\n\nIf you've read this far — thank you. Building and sharing HermitStash has been one of the most rewarding things I've worked on, and the fact that you took the time to look at it means a lot.\n\nIf HermitStash has been useful to you and you'd like to buy me a coffee, you can do so at [ko-fi.com/dotcoocoo](https://ko-fi.com/dotcoocoo). It's never expected, always appreciated.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdotcoocoo%2Fhermitstash","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdotcoocoo%2Fhermitstash","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdotcoocoo%2Fhermitstash/lists"}