Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A locally-hosted password manager, in the spirit of SearXNG. Self-hosted, no external account, nothing sent off your machine.

This README covers what the tool actually does, how "locally-hosted" works mechanically, and how to avoid a 403 Permission denied from nginx that trips people up on Fedora specifically and has nothing to do with normal file permissions.

For the exact command-by-command install steps, see SETUP.md. This document explains the why. That one is the how. Keeping them separate means they don't drift out of sync with each other.


What this is

PasXNG is a single HTML file (pasxng.html). No backend, no database, no build step. Everything runs in your browser: the UI, the encryption, the storage.

  • Setup: the first time you open it, you set a password. That password never gets stored anywhere, in any form.
  • Encryption: your saved entries are encrypted with AES-256-GCM, using a key derived from your password via Argon2id, the same password-hashing algorithm most modern password managers use because it's deliberately slow and memory-hard against brute-force and GPU-cracking attempts.
  • Storage: encrypted data lives in your browser's localStorage. Nothing is ever written in plaintext, and nothing is ever sent over the network. There is no server component that could receive it even if you wanted it to.
  • The threat model: if someone steals the device (or a USB drive you copied the file onto) and opens PasXNG, they hit the same password prompt you do. Without your password, what's stored is ciphertext, and Argon2id's deliberate slowness makes guessing it computationally expensive, not just inconvenient.
  • A generator: adjustable length, character sets, and an ambiguous-character filter, with the exact entropy (in bits) shown plainly rather than a decorative "strength meter."
  • Encrypted export: a "Download vault" option that produces a separate encrypted file, locked with its own password, which can differ from your vault password. Useful for a backup or moving to another machine.

Where things live in this repository

If you're browsing or cloning the GitHub repo, here's the layout:

  • The app itself: /pasxng/page/pasxng.html
  • The logo: /pasxng/logo.png
  • The nginx config: /pasxng/pasxng.conf

The logo path isn't just where the file happens to sit. pasxng.html hardcodes src="/pasxng/logo.png" directly in its own markup, on both the landing page and inside the vault once you're logged in, so the app genuinely expects to find it there at runtime. If it's missing, the app doesn't break either place, but the fallback isn't identical: the landing page explicitly swaps in the text "PasXNG," while the smaller vault mark just falls back to the browser's default broken-image handling of its alt text, which may or may not render clearly depending on the browser. If you're rearranging files, /pasxng/logo.png is the path worth keeping intact either way.

These are repo paths, not server paths, and that applies to pasxng.conf as much as it does to pasxng.html. Once you're actually deploying with nginx, SETUP.md has you copy pasxng.html to /usr/share/nginx/pasxng/ and pasxng.conf to /etc/nginx/conf.d/pasxng.conf, both completely different locations for completely different reasons (the SELinux fix and nginx's own config-loading convention, both explained below).


Why it refuses to run non-locally

PasXNG checks window.location.hostname on load and only unlocks the real setup flow if it's localhost, 127.0.0.1, or ::1. If you're not on one of those, the "Begin" button stays disabled and a warning explains why.

This is a deliberate design decision, not a security boundary. It's genuinely trivial to bypass with browser devtools, and it doesn't need to be hardened, because the actual security of your data was never resting on it. It exists to make a point in the interface itself: this tool isn't meant to be deployed somewhere reachable over a network. The real protection is the encryption described above, and that holds no matter where the page happens to be served from.


How "locally-hosted" actually works here

It's worth being precise about what changes and what doesn't when you set this up with nginx and systemd, because it's easy to conflate two different things.

What nginx and systemd get you: the page itself, the HTML, CSS, and JS, becomes available at http://127.0.0.1:8080/ automatically, every time you boot and log in, without you needing to manually run a server command first. That's the SearXNG-style convenience you asked about. nginx serves the file as a background systemd-managed service, the same way SearXNG's own systemd unit keeps it running without manual intervention.

What it doesn't change: everything described above, the encryption, the storage, the fact that nothing leaves your machine, was already true the moment you opened pasxng.html directly in a browser with a file:// URL and no server at all. nginx doesn't make PasXNG more local. The file was never non-local to begin with. What nginx actually adds is the automatic-on-boot piece, plus serving over a real http:// origin instead of file://. That second part matters for one practical reason: this tool doesn't currently rely on it, but some browser APIs and browser-extension interactions behave more predictably from http://127.0.0.1 than from a bare file:// path, so it's worth knowing about.

The one thing this setup does not make local: the Argon2id library still loads from a public CDN (cdnjs.cloudflare.com) at runtime, in your browser, on each fresh page load. That's independent of nginx. nginx serves pasxng.html, and then pasxng.html's own <script src="https://cdnjs..."> tag reaches out separately. So this setup gets you "boots locally and automatically," but not yet "works with zero internet access." Making it fully offline would mean downloading that library and serving it from the same nginx setup instead of a CDN. That's a reasonable next step, just genuinely separate work from what's covered here.

The pieces, concretely:

Piece Role
pasxng.html The entire application: UI, crypto, storage logic. Static, and never modified by the server.
nginx Serves that one file over HTTP, bound to 127.0.0.1 only. Never reachable from other devices on your network.
pasxng.conf Tells nginx how to serve it: which port, which directory, and to fall back to pasxng.html for any path, since this is a single-page app with no real server-side routes.
systemd (nginx.service) Starts nginx automatically on boot/login and keeps it running, restarting it if it ever crashes. This is the actual "boots up with your computer" piece.
SELinux A separate, mandatory layer (see below) that decides whether nginx is even allowed to read the file, independent of everything above.

The 403 Permission denied issue, and how to avoid it

If you're on Fedora (or another SELinux-enforcing distro like RHEL, CentOS, Rocky, or AlmaLinux) and you serve pasxng.html from somewhere under your home directory, nginx will return 403 Permission denied, even if the file's normal Unix permissions look completely fine. chmod 644, owned by the right user, everything correct. This confuses people because every instinct says "check the permissions," and the permissions genuinely aren't the problem.

Why this happens

SELinux is a mandatory access control layer built into the Linux kernel, and it's enabled by default on Fedora. It works in addition to normal Unix permissions, not instead of them, so a process can pass a normal permission check and still get blocked by SELinux. That's exactly what's happening here.

Every process runs under an SELinux domain, and every file carries an SELinux type label. nginx runs under the httpd_t domain, and Fedora's default SELinux policy only lets httpd_t read files labeled with one of a specific set of httpd_* types, most commonly httpd_sys_content_t.

Files in your home directory are labeled user_home_t by default, a completely different type. So here's what actually happens, step by step:

  1. nginx (as httpd_t) tries to open ~/Downloads/pasxng.html.
  2. Normal Unix permissions check out fine. This process's user and group can read the file.
  3. But SELinux policy says httpd_t just isn't allowed to touch user_home_t files, full stop.
  4. The kernel blocks it before nginx's own permission logic ever gets a say, and nginx reports that up as a 403.

That's why chmod 777-ing the file, which is usually the instinctive first fix, does nothing here. SELinux isn't even reading the permission bits in this rejection path.

The fix (and why it has to be done this specific way)

SETUP.md already builds around avoiding this from the start, by placing the file at /usr/share/nginx/pasxng/ instead of anywhere under your home directory, and by running:

sudo semanage fcontext -a -t httpd_sys_content_t "/usr/share/nginx/pasxng(/.*)?"
sudo restorecon -Rv /usr/share/nginx/pasxng

Two things are worth understanding about this pair of commands, because it's easy to end up with something that looks right and quietly breaks later:

  • semanage fcontext writes a persistent policy rule. It tells SELinux "anything under this path should be labeled httpd_sys_content_t from now on," including files that don't exist yet. This is the part that survives reboots and filesystem relabels.
  • restorecon then actually applies that rule to what's currently on disk. You need both. semanage fcontext alone doesn't retroactively relabel existing files, and restorecon alone has nothing to restore to without a matching policy rule already in place.

What not to do instead: chcon -t httpd_sys_content_t pasxng.html looks like it fixes the same problem, and for a little while it does. But chcon only sets the label on that one file, one time, with no underlying policy rule behind it. The next time SELinux relabels the filesystem, whether that's routine maintenance or something triggered by restorecon running elsewhere, that label gets silently overwritten back to user_home_t. The 403 comes back with no obvious cause, which is especially confusing if it happens weeks after you thought you'd already fixed it.

If you ever move the file somewhere else, to a different directory than /usr/share/nginx/pasxng/, the fix moves with it. Just rerun both commands with the new path. There's no way to "fix SELinux" globally that isn't tied to the specific path you're serving from. That's the whole point of httpd_sys_content_t: it's scoped, not a permission you flip on universally.

And more broadly: setenforce 0, disabling SELinux entirely, is the fix you'll see suggested in a lot of quick-answer forum posts. It works, in the sense that the 403 goes away. But it does that by disabling a real, working security layer on your entire system, not just for this one directory. For a tool whose whole reason for existing is being careful about security, turning off a kernel-level access control system just to make it run is the wrong tradeoff. The two-command fix above is barely more effort and doesn't cost you that.

Quick diagnostic, if you hit this anyway

ausearch comes from the audit package, which isn't always present on a default Fedora Workstation install. Confirm it's there first (installing it if not costs nothing and is a one-time step):

sudo dnf install audit
sudo systemctl enable --now auditd

Then:

sudo ausearch -m avc -ts recent

If SELinux is the cause, you'll see an avc: denied entry naming httpd_t and whatever the file's actual current type is, something like tcontext=...:user_home_t:s0 if the file is still sitting somewhere under your home directory. If that command shows nothing relevant, the 403 has a different cause. Check the nginx error log at /var/log/nginx/error.log, and confirm the path in pasxng.conf actually matches where you put the file.


Setup

The full, ordered install steps (installing nginx, placing the file, applying the SELinux fix above, adding the config, enabling the systemd service, and verifying it's working) are in SETUP.md. Follow it top to bottom. Each step assumes the previous one succeeded.

Releases

Contributors

Languages