On this page
- What setup changes on your Mac
- Privileged commands
- Commands from a lokal.toml
- The certificate authority
- If the CA’s private key leaks
- Network exposure
- The .lokal TLD
- Firefox
- Uninstalling
- On Linux
- What setup changes on Linux
- Privileged commands on Linux
- The port setting
- Trust and browsers on Linux
- Uninstalling on Linux
- Reporting a vulnerability
lokal asks for a lot of trust. It adds a certificate authority to your Mac’s System keychain, and it runs a background service that terminates TLS for you. This page covers what lokal changes, what it protects, and what risks remain. Most of it applies on Linux too. What setup changes there, and how trust works, is under On Linux.
On this page, <base> stands for lokal’s data directory, ~/Library/Application Support/lokal
(~/.local/share/lokal on Linux).
What setup changes on your Mac#
| What | Where | Owner | Removed by |
|---|---|---|---|
| Root CA certificate | <base>/ca/root.pem (0644) | you | uninstall --purge |
| Root CA private key | <base>/ca/root-key.pem (0600) | you | uninstall --purge |
Certificate bundle for lokal env | <base>/ca/bundle.pem | you | uninstall |
| Trust setting for the CA | /Library/Keychains/System.keychain | root (sudo) | uninstall |
Resolver file for .lokal | /etc/resolver/lokal (nameserver 127.0.0.1, port 53535) | root (sudo) | uninstall |
| LaunchAgent | ~/Library/LaunchAgents/dev.lokal.daemon.plist | you | uninstall |
| Copy of the lokal binary that the agent runs | <base>/bin/lokal | you | uninstall |
| Install record (what setup did) | <base>/install.json | you | uninstall |
| Projects and services | <base>/state.toml | you | uninstall --purge |
| Firefox preference (only if Firefox is installed) | user.js in each Firefox profile | you | uninstall |
| Logs | ~/Library/Logs/lokal/ | you | uninstall --purge |
Approvals of lokal.toml files (lokal up) | <base>/trusted/ (0700; one 0600 file per approved file) | you | uninstall --purge |
lokal up’s lock per project | ~/Library/Logs/lokal/up/<project>/up.lock (0600, in a 0700 folder) | you | uninstall --purge |
lokal up’s log per started service | ~/Library/Logs/lokal/up/<project>/<sub>.log and <sub>.log.1 (0600, in a 0700 folder) | you | uninstall --purge |
The data directory is created with mode 0700. lokal doesn’t touch /etc/hosts, pf, the Application Firewall,
other launchd jobs or any other keychain item. Files and paths describes
every file in detail.
Privileged commands#
lokal runs a fixed set of commands as root, and only in lokal setup and lokal uninstall. It prints each one
before it runs it, and lokal setup --dry-run shows them without running anything. lokal runs them through
sudo with an argument vector. lokal never builds a command line from strings, and runs a shell (sh -c) in one
place only, below. Two commands start programs you name, as you and never as root:
lokal runstarts the command after--, with the arguments you typed, where{port}becomes the port. No shell is involved.lokal upstarts each service’srunfrom a repo’slokal.tomlas/bin/sh -c '<run>', the one shell lokal runs, with{port}replaced by a number and nothing else added. It does so only after the trust check.
lokal setup, on a fresh Mac:
sudo /usr/bin/security add-trusted-cert -d -r trustRoot -p ssl -k /Library/Keychains/System.keychain <base>/ca/root.pem
sudo /bin/mkdir -p /etc/resolver
sudo /usr/bin/install -m 0644 <base>/resolver/lokal /etc/resolver/lokallokal uninstall, and lokal setup when it replaces an outdated CA:
sudo /usr/bin/security remove-trusted-cert -d <old root.pem>
sudo /usr/bin/security delete-certificate -Z <SHA-1 of the old CA> /Library/Keychains/System.keychain
sudo /bin/rm -f /etc/resolver/lokal # uninstall onlySetup checks that sudo can run (a terminal is attached, and LOKAL_FORBID_SUDO isn’t set) before it changes
anything. A setup that can’t finish therefore never leaves a half-replaced CA behind. Setup and uninstall refuse
to run as root.
After setup, nothing runs as root. The background service is a per-user LaunchAgent. launchd binds ports 443 and 80 and hands the sockets to the service, so the service itself never needs privileges.
Commands from a lokal.toml#
A lokal.toml runs commands, and the repo it’s in may be someone else’s. So lokal up runs them only after a
person has approved the file’s exact bytes, or when the caller passes --yes. Without either, it changes nothing:
no state change, no route, no command.
- When it asks: the first time it runs a file, and on any run after the file’s bytes change. It lists every
service’s
run,cwdandenv, then asksrun these commands? [y/N]. Anything butyexits 1. Control characters and invisible characters in the listing are shown escaped, and each extra line of a multi-line value starts with↳, so nothing in the file can hide what runs. - What it stores: an approval in
<base>/trusted/, a file named by the SHA-256 of thelokal.toml’s real path that holds the SHA-256 of its bytes and nothing else. The file is 0600 and the folder 0700. The hash is taken from the bytes lokal parsed, so the bytes approved are the bytes that run. Keying it by the real path means a symlink or a..leads to the real file’s approval, never to another file’s. --yesruns the commands without asking. It still prints them, but records no approval, so a stored approval always means a person said yes. It’s for agents and CI working in your own repos.- No terminal and no
--yes:lokal upprints the path and the commands, and exits 1. The hint tells a person to review the file and runlokal upin a terminal. It never suggests--yes, because an agent reading it would just add the flag. - What it doesn’t cover: what the commands themselves run.
npm startruns the scripts inpackage.json, and every command runs the repo’s own code. Approving alokal.tomlmeans trusting the repo, the same model as direnv’sallowand mise’strust. - Restarting a service: while
lokal upruns, it listens on~/Library/Logs/lokal/up/<project>/up.sock, mode 0600 in a 0700 folder, forlokal restart <sub>. The socket accepts only restarts of services in the file. A restart runs only the commandslokal upstarted with, which passed the check above: it never readslokal.tomlagain. Nothing on the socket can run another command, change a route or stoplokal up.
The certificate authority#
lokal’s root CA is created on your Mac, by you, during setup:
| Field | Value |
|---|---|
| Key | ECDSA P-256, generated locally |
| Subject | CN=lokal development CA (<user>@<host>), O=lokal |
| Validity | 10 years |
| Basic constraints | critical, CA:TRUE, pathLen:0, so it can’t create intermediate CAs |
| Key usage | critical, keyCertSign, cRLSign |
| Name constraints | critical. Permitted DNS names: lokal, local. Excluded IP ranges: 0.0.0.0/0, ::/0 |
The name constraints are the most important control. A conforming certificate verifier rejects any certificate
this CA signs for a name outside .lokal and .local, or for any IP address. (lokal has served only .lokal
names since 0.6.0. The CA still permits local, which it served before, because narrowing it would mean a new CA,
and a password prompt, for everyone.) This was tested with macOS’s own
verifier (security verify-cert), in Chrome, and with OpenSSL (openssl verify), the library that Node and
Python use. Firefox’s NSS library supports name constraints too. The evidence is in the
v0.1 build log (iterations #13 and #14).
IP addresses are excluded explicitly because DNS-name constraints alone don’t constrain them: RFC 5280 constrains
each name form separately. Without the exclusion, macOS and Chrome accepted a 127.0.0.1 certificate signed by
the CA.
The trust setting is limited to HTTPS, too. Setup trusts the CA with -p ssl, so macOS accepts it only when it
checks a server’s TLS certificate. Name constraints cover only DNS names and IP addresses, so without that limit
a certificate the CA signs would also pass for email (S/MIME, for any address), code signing and TLS client
authentication. lokal doctor warns if the CA is trusted for more than HTTPS (ca.trust_scope), and
lokal setup narrows it.
The constraints cover exactly the two TLDs lokal serves. That’s why lokal doesn’t offer .test or .internal:
the CA is trusted system-wide, and a leaked key could then sign names that companies use for real internal hosts.
Leaf certificates are issued in memory by the background service, one per configured name. Each has exactly one
DNS name, serverAuth usage and a 90-day validity. They are never written to disk or to the keychain. The
service presents a certificate only for names you configured, names under a
wildcard service you configured (each issued on its first
handshake, still for exactly that one name), and the setup self-test name while the self-test runs. lokal never
issues a certificate whose name contains *. A TLS handshake for any other name, or with no SNI, fails.
The CA’s fields are pinned by a test. Any change to them means every user has to trust a new CA, so it needs a deliberate decision by the maintainer.
If the CA’s private key leaks#
The key is a file that only your user account (and root) can read. Any program running as you can read it, just as it can read your SSH keys or browser cookies. lokal never copies, logs or transmits it.
If someone gets the key, they can:
- sign certificates for any
.lokalor.localname that every account on this Mac would trust. That includes.localdevices on your network, such as a NAS or printer advertised over Bonjour, so an attacker who can also intercept your traffic could impersonate them over HTTPS on this Mac.
They can’t:
- impersonate any public website, or any name under another TLD (a company’s
.internalor.testhosts, for example); - impersonate an IP address (
https://192.168.1.1); - sign email (S/MIME), code or TLS client certificates that this Mac accepts, because the CA is trusted for HTTPS only (on Linux, only the browsers have that limit: see trust on Linux);
- create an intermediate CA that escapes these limits (
pathLen:0, and name constraints apply to the whole chain); - affect other computers, because the CA is trusted only on this Mac.
These guarantees rest on the client enforcing name constraints. The verifiers tested above do. Very old or unusual TLS stacks might not.
If you think the key leaked, run lokal uninstall --purge and then lokal setup. This untrusts and deletes the
old CA, and creates a new one.
Network exposure#
| Listener | Address | Reachable from |
|---|---|---|
| HTTPS proxy | 127.0.0.1:443, [::1]:443 (TCP) | this Mac only |
| HTTP redirect to HTTPS | 127.0.0.1:80, [::1]:80 (TCP) | this Mac only |
.lokal DNS server | 127.0.0.1:53535 (UDP) | this Mac only |
| IPC | <base>/lokal.sock, a Unix socket with mode 0600 | your user account |
- lokal announces nothing on your network. Since 0.6.0 it serves only
.lokalnames, from its own DNS server, and registers nothing with mDNSResponder or any other multicast DNS service. - The DNS server answers only names under
lokal, withA 127.0.0.1andAAAA ::1and a TTL of 0. It refuses everything else, so it can’t be used to resolve or redirect other names. - The proxy never sends
Strict-Transport-Securityheaders. - Any account on this Mac can connect to the loopback listeners. A connection that isn’t proxying a request is closed after a deadline (20s before its first request, 60s idle), and client and upstream connections are capped below the daemon’s open-file limit, with the idle longest closed first when the cap is full. So idle connections can’t use up lokal’s file descriptors and block it for everyone.
- Upstreams default to
localhost. lokal warns when you point a service at a host that isn’t loopback, and refuses loopback ports 443 and 80, because they would loop back into lokal itself. - Outbound traffic: each
lokal doctorrun sends one DNS query to a public root server (a.root-servers.netorb.root-servers.net) to check that.lokalisn’t a real TLD. lokal sends nothing else off your Mac. It has no telemetry and no update checks.
The .lokal TLD#
.local is reserved for multicast DNS (RFC 6762). .lokal isn’t reserved. It isn’t delegated in the public DNS
today, but in theory it could become a real top-level domain one day. If that happened, lokal would hide real
.lokal websites on your Mac, because /etc/resolver/lokal sends those lookups to lokal. On Linux, lokal’s DNS
delegate for systemd-resolved does the same.
lokal doctor’s tld.lokal check asks a DNS root server whether .lokal exists, and fails if it does. lokal
serves only .lokal names, so there’s no other TLD to move your projects to. If that check ever fails,
lokal uninstall removes the resolver config that sends .lokal lookups to lokal, so real .lokal websites
resolve again, and please report it.
.lokal names covers how the names resolve in daily use, and why .local names
were removed in 0.6.0.
Firefox#
Firefox keeps its own certificate store. If Firefox is installed, setup adds
user_pref("security.enterprise_roots.enabled", true); to user.js in each Firefox profile, tagged
// added by lokal, and uninstall removes exactly that line.
That preference makes Firefox trust every root CA that a user or administrator added to the macOS keychain, not just lokal’s. If you don’t want that, delete the line. lokal then won’t work in Firefox, but it keeps working everywhere else.
On Linux, lokal sets no Firefox preference. It adds only its own CA to Firefox’s certificate database (trust and browsers on Linux).
Uninstalling#
lokal uninstall follows install.json. It removes the LaunchAgent and its binary, untrusts and deletes every
lokal CA from the System keychain (including retired ones), removes /etc/resolver/lokal and removes the Firefox
line. It succeeds, with warnings, if some pieces are already gone.
It picks CAs by certificate, so deleting the key file first doesn’t hide a trusted CA from it. If the CA files
are gone altogether, it finds the CA that install.json records in the keychain, by the SHA-1 it computes
itself, and untrusts it. lokal setup does the same before it creates a new CA: it never writes over a
certificate that may still be trusted.
A plain uninstall keeps state.toml and the CA files, so lokal setup can restore everything without creating
a new CA. Once the CA is no longer trusted, the key on disk can’t make anything trusted. That includes Node,
Python and curl: lokal env and lokal ca path print nothing once setup is gone, so an eval "$(lokal env)"
line left in a shell profile no longer exports the CA. Uninstall also deletes bundle.pem and reminds you to
remove that line. If you’re done with lokal, lokal uninstall --purge deletes the data directory, the key
included.
Upgrade and uninstall has the steps.
On Linux#
lokal on Linux is a preview (since 0.5.0). The CA, its name constraints, the leaf certificates, the listeners,
the IPC socket and the lokal.toml trust check work exactly as above. Setup reaches the same result with
different system pieces: the system certificate store instead of the keychain, a systemd-resolved DNS delegate
instead of /etc/resolver/lokal, and systemd user units instead of a LaunchAgent. Only .lokal names work.
What setup changes on Linux#
<base> is ~/.local/share/lokal ($XDG_DATA_HOME/lokal). Everything the macOS table lists under <base>
(state.toml, the CA files, bundle.pem, install.json, trusted/) is there too. The rest differs:
| What | Where | Owner | Removed by |
|---|---|---|---|
| The CA in the system store | lokal-<sha1>.crt in the anchors folder: /usr/local/share/ca-certificates/ (Debian, Ubuntu), /etc/pki/ca-trust/source/anchors/ (Fedora) or /etc/ca-certificates/trust-source/anchors/ (Arch). The distribution’s update tool then rebuilds the system bundle | root (sudo) | uninstall |
DNS delegate for .lokal | /etc/systemd/dns-delegate.d/lokal.dns-delegate (DNS=127.0.0.1:53535, Domains=~lokal) | root (sudo) | uninstall |
| The lowest port user services may bind | /etc/sysctl.d/50-lokal.conf (net.ipv4.ip_unprivileged_port_start = 80), and the running kernel’s setting | root (sudo) | uninstall, which sets 1024 back |
| systemd user units | ~/.config/systemd/user/dev.lokal.daemon.service and three socket units beside it | you | uninstall |
| Copy of the lokal binary that the units run | <base>/bin/lokal | you | uninstall |
| The CA in browsers’ certificate databases | Chrome’s and Chromium’s ~/.pki/nssdb, and each Firefox profile (deb, snap and flatpak) | you | uninstall |
Logs, and lokal up’s locks, sockets and logs | ~/.local/state/lokal/ ($XDG_STATE_HOME/lokal) | you | uninstall --purge |
lokal doesn’t change /etc/hosts, /etc/nsswitch.conf or /etc/resolv.conf. If programs on the machine don’t
ask systemd-resolved for names, setup says what to install and stops before it changes anything.
Files and paths shows each file’s contents.
Privileged commands on Linux#
lokal setup, on a fresh Ubuntu machine:
sudo /usr/bin/install -m 0644 <base>/ca/root.pem /usr/local/share/ca-certificates/lokal-<sha1>.crt
sudo /usr/sbin/update-ca-certificates
sudo /usr/bin/install -D -m 0644 <base>/resolver/lokal /etc/systemd/dns-delegate.d/lokal.dns-delegate
sudo /usr/bin/systemctl restart systemd-resolved
sudo /usr/bin/install -m 0644 <base>/resolver/ports.conf /etc/sysctl.d/50-lokal.conf
sudo /usr/sbin/sysctl -w net.ipv4.ip_unprivileged_port_start=80On Fedora and Arch, the CA goes into the p11-kit anchors folder and the update tool is update-ca-trust. The last
two commands appear only if the kernel’s setting is above 80.
lokal uninstall:
sudo /usr/bin/rm -f /usr/local/share/ca-certificates/lokal-<sha1>.crt
sudo /usr/sbin/update-ca-certificates
sudo /usr/bin/rm -f /etc/systemd/dns-delegate.d/lokal.dns-delegate
sudo /usr/bin/systemctl restart systemd-resolved
sudo /usr/bin/rm -f /etc/sysctl.d/50-lokal.conf
sudo /usr/sbin/sysctl -w net.ipv4.ip_unprivileged_port_start=1024The last two run only if /etc/sysctl.d/50-lokal.conf is there. When setup replaces an outdated CA, it removes
the old CA’s anchor file the same way, in the same password prompt.
As on macOS, these are the only commands lokal runs as root, only in setup and uninstall, through sudo with
an argument vector, and each program by its absolute path. Setup checks first that systemd-resolved is running
and supports DNS delegates (systemd 258 or later), that programs ask it for names, and that the system has a trust
tool. If one is missing, it stops before it changes anything. You type your password once: there’s no second
dialog.
After setup, nothing runs as root. The background service is a set of systemd user units that run as you. Your
user’s systemd binds ports 443 and 80 on 127.0.0.1 and ::1, and UDP port 53535 on 127.0.0.1, and hands the
sockets to the service, as launchd does on macOS. Every listener is on loopback.
The port setting#
A user’s systemd units can’t bind ports below net.ipv4.ip_unprivileged_port_start, which is 1024 by default.
lokal’s socket units hold ports 443 and 80, so setup lowers the setting to 80: in /etc/sysctl.d/50-lokal.conf,
which applies at boot, and with sysctl -w for the running kernel.
The setting is machine-wide. While lokal is set up, every local user and program can bind ports 80 to 1023,
not only lokal. lokal uninstall removes the file and sets the kernel’s default, 1024, back. The alternative was
a system unit, which would keep the default but need root for lokal restart and lokal stop.
Trust and browsers on Linux#
- The system store. Setup copies the CA into the anchors folder as
lokal-<sha1>.crt, and the update tool rebuilds the bundle that OpenSSL, curl and rustls read (/etc/ssl/certs/ca-certificates.crton Debian and Ubuntu,/etc/pki/tls/certs/ca-bundle.crton Fedora). An anchor there has no per-use trust setting, so there’s nothing to narrow to HTTPS, andca.trust_scopedoesn’t apply. A program that uses the system bundle to check something other than a server’s TLS certificate trusts lokal’s CA for that too. The name constraints still apply: the CA can’t sign for names outside.lokaland.local, or for IP addresses. - Browsers. Chrome, Chromium and Firefox don’t read the system bundle. They trust what their NSS certificate
databases hold. Setup adds lokal’s CA to each one with
certutil -A -t C,,, as you and never as root, trusted only to issue TLS server certificates. Its nickname islokal development CAfollowed by the first 12 hex digits of its SHA-1. If Chrome is installed but has never run, setup creates its database first. lokal adds only its own CA and changes no browser setting. Withoutcertutil(libnss3-toolson Debian and Ubuntu,nss-toolson Fedora), setup warns, and browsers don’t trust lokal.
Uninstalling on Linux#
lokal uninstall follows install.json, as on macOS. It stops, disables and removes the systemd units and their
copy of lokal, removes the anchor file of every lokal CA it finds and runs the update tool, removes the DNS
delegate and restarts systemd-resolved, and, if setup lowered it, sets the port setting back to 1024. It also
removes every entry whose nickname starts with lokal development CA from each browser database. A plain
uninstall keeps state.toml and the CA files, and --purge deletes ~/.local/share/lokal/ and
~/.local/state/lokal/.
Reporting a vulnerability#
Please don’t open a public issue. Report it privately through GitHub instead:
- Go to Security → Report a vulnerability.
- Describe the problem, how to reproduce it, and what an attacker could do with it.
- Include
lokal --versionand your macOS version (sw_vers), or on Linux your distribution (/etc/os-release).
The maintainer aims to acknowledge reports within a week. Once a fix is ready, it ships in a release, and the advisory is published with credit to you, unless you prefer otherwise. lokal is pre-1.0, so only the latest release gets security fixes.
Anything that breaks a guarantee on this page is in scope, for example:
- getting lokal’s CA, or a leaf it issues, accepted for a name outside
.lokaland.local, or for an IP address; - making lokal issue a certificate for a name nobody configured;
- reading the CA’s private key through lokal, for example through logs, IPC, error pages or file permissions;
- reaching lokal’s listeners (443, 80, 53535/udp) or its IPC socket from another machine or another user account;
- making
setuporuninstallrun a privileged command other than the ones they print, or with arguments an attacker controls; - making lokal’s DNS server answer for names outside
.lokal; - leaving the system trusting a lokal CA after
lokal uninstall; - making
lokal uprun a command from alokal.tomlthat nobody approved (noyon a terminal, no--yes), or something other than what its listing showed.
Out of scope: attacks that need code already running as your user account (that code can read the CA key
directly, and the name constraints are what limit the damage), reports that .lokal isn’t a reserved TLD (a
documented trade-off that lokal doctor checks for), vulnerabilities in your own dev servers that lokal
proxies to, and what the commands in an approved lokal.toml do (approving it means trusting the repo).