Reproducible NixOS live ISO for offline-first security work. The image boots from a USB stick with all network interfaces disabled and a full nftables lockdown in place. A desktop toggle lets you go online only when you choose to.
- Offline-first — boots with all radios blocked, interfaces down, routes flushed, nftables lockdown active. Toggle online/offline from Sway or terminal.
- YubiKey / smartcard stack —
gnupg,opensc,yubikey-manager,yubico-piv-tool. pcscd owns the reader; scdaemon delegates via PC/SC (disable-ccid) so GPG and PC/SC tools share one view. Includessafe-yubikey-fetch-pubkeyfor downloading the public key from the URL stored on the card after a fresh boot. - Sway desktop — autostarts on TTY1 login;
footterminal,fuzzellauncher,i3statusbar,makonotifications. - Browser — LibreWolf (privacy-hardened Firefox).
- Editors — Emacs, Vim, Nano.
- Guides — step-by-step YubiKey/GPG lifecycle guides accessible
via
safe-docs(Mod+hin Sway). - Reproducible — nixpkgs pinned in
flake.lock; builder image pinned by SHA256 digest inDockerfile.builder.
A Linux host with:
- Podman or Docker
- make and Bash
Optional:
- YubiKey — for GPG output signing (
make sign)
Install on Fedora:
sudo dnf install podman makeFor YubiKey signing (make build / make sign):
sudo dnf install gnupg2 gnupg2-scdaemon pinentry-curses \
pcsc-lite pcsc-lite-ccid yubikey-managergit clone <this-repo> && cd safe-image
# Build the ISO (uses a cached Nix store volume by default)
make build-no-sign
# Smoke-test: boot headless in QEMU, verify offline mode
make test
# Interactive test: boot in a QEMU window (prompts for YubiKey passthrough)
make test-gui
# Sign outputs with YubiKey
make sign
# Verify the resulting *.asc signatures against the YubiKey's signing key
make verifyFirst-time signing on a fresh host: the local GPG keyring is empty, so
make build/make signwill try to fetch your public key from the URL stored on the card (gpg --card-edit > admin > url). If you have not set that URL, either set it once, import the pubkey from a file (gpg --import your-pubkey.asc), orgpg --recv-keys <fingerprint>from a keyserver.
| Target | Description |
|---|---|
make build |
Build ISO + sign outputs with YubiKey |
make build-no-sign |
Build ISO, skip signing |
make build-no-cache |
Build without the Nix store cache volume |
make sign |
Sign existing out/ artifacts with YubiKey |
make sign-test |
Sign a throwaway string to confirm YubiKey signing works (no build) |
make verify |
Verify out/*.asc signatures against the YubiKey's signing key |
make test |
Boot ISO in headless QEMU, verify offline mode |
make test-gui |
Boot ISO in a QEMU window for interactive testing |
make clean |
Remove out/ and result |
make clean-cache |
Remove the Nix store cache (re-prompts). Honors CACHE_VOLUME for bind-mount paths. |
make clean-all |
Reclaim everything: out/, leftover /tmp dirs, builder + base container images, cache (re-prompts). Honors CACHE_VOLUME. |
Environment variables:
SKIP_SIGN=1 make build # same as build-no-sign
ENGINE=docker make build # force Docker instead of Podman
USE_CACHE_VOLUME=0 make build # disable Nix store caching
GUI=1 ./si-test.sh # same as make test-gui
SPICE=1 make test-gui # use spice-app + remote-viewer (clipboard on Fedora)Clipboard in test-gui: the default
gtkdisplay only syncs clipboard when QEMU was compiled with-display gtk,clipboard=onsupport. Fedora's QEMU is not. On Fedora, runSPICE=1 make test-gui(requires thevirt-viewerpackage) — clipboard works via the SPICE vdagent channel.
out/
├── safe-live-nixos-sway-<label>-x86_64-linux.iso # live ISO
├── safe-live-nixos-sway-<label>-x86_64-linux.iso.asc # GPG signature (after sign)
├── SHA256SUMS # checksums
└── SHA256SUMS.asc # checksum signature (after sign)
The ISO is not committed to the repository. See Reproducibility.
The quick start above assumes a trusted host. If you suspect your OS or hardware
is compromised, build from a clean live USB instead — ideally from a previously
verified safe-image booted in online mode, which makes this project self-hosting.
The live ISO ships podman, make, git, gnupg, and nix, so the same make build
flow works inside it with no extra dependencies.
Full procedure, bare metal vs VM analysis, and post-build verification checklist: guides/building-safely.md
sudo dd if=out/safe-live-nixos-sway-*.iso of=/dev/sdX \
bs=4M status=progress oflag=syncReplace /dev/sdX with your USB device. For full write-back verification see
guides/building-safely.md.
.
├── flake.nix # Nix flake entry point
├── flake.lock # Pinned nixpkgs revision (commit this)
├── iso.nix # Top-level NixOS ISO configuration
├── modules/
│ ├── base.nix # Base packages, user, autologin, serial console
│ ├── builder.nix # Podman + make + git so the image can rebuild itself
│ ├── sway.nix # Sway WM, keybindings, fonts, dark mode
│ ├── netmode.nix # si-netmode, nftables, polkit, systemd service
│ ├── yubikey-gpg.nix # GPG, YubiKey udev rules, scdaemon PC/SC config
│ └── docs.nix # safe-docs command, in-image documentation
├── image/
│ ├── docs/ # Installed into ISO at /etc/safe-live/docs/
│ │ ├── README.md # Primary in-image guide
│ │ ├── network-modes.md
│ │ ├── gpg-yubikey.md
│ │ ├── host-setup.md
│ │ ├── runtime-install.md
│ │ └── guides/ # Full YubiKey lifecycle guides (01-08)
│ └── scripts/ # Installed into ISO at /etc/safe-live/scripts/
├── lib/
│ ├── yubikey-select.sh # YubiKey selection helper (sourced)
│ ├── yubikey-fetch-pubkey.sh # Card-URL pubkey fetch (sourced + shipped in image)
│ └── gpg-env.sh # Signing env: host first, isolated temp fallback (sourced)
├── Dockerfile.builder # nixos/nix builder image (digest-pinned)
├── si-build.sh # Build orchestration (Podman/Docker)
├── si-sign-outputs.sh # Host-side YubiKey GPG signing
├── si-sign-test.sh # Sign a throwaway string to confirm signing works
├── si-verify.sh # Verify out/*.asc against the YubiKey signing key
├── si-test.sh # QEMU smoke-test and GUI boot
└── Makefile
The live image boots in offline mode by default:
- All radios blocked via
rfkill. - All non-loopback interfaces brought down.
- IPv4/IPv6 routes flushed.
- nftables lockdown: input/forward/output drop (loopback excepted).
- NetworkManager stopped.
Toggle from Sway (Mod = Win/Super key):
| Keybinding | Action |
|---|---|
Mod+Return |
Terminal |
Mod+d |
App launcher |
Mod+h |
Open docs |
Mod+s |
System status |
Mod+Shift+o |
Go offline |
Mod+Shift+i |
Go online |
Mod+Arrow / Mod+Shift+Arrow |
Focus / move window |
Mod+1..9 / Mod+Shift+1..9 |
Switch / move to workspace |
Mod+f / Mod+r |
Fullscreen / resize mode |
Or from the terminal:
sudo si-netmode offline
sudo si-netmode online
si-netmode statusThe live image includes step-by-step guides at /etc/safe-live/docs/guides/:
| Guide | Topic |
|---|---|
01-generate-keys.md |
Ed25519 master key + subkeys |
02-yubikey-setup.md |
Move subkeys to card, change PINs |
03-daily-use.md |
Sign, encrypt, Git, SSH |
04-extend-expiry.md |
Renew subkeys |
05-revoke-keys.md |
Revoke master or subkeys |
06-recovery.md |
Backup, restore, factory reset |
07-linux-login.md |
FIDO2 login/sudo/screen unlock |
08-luks-unlock.md |
FIDO2 LUKS unlock at boot |
Open with safe-docs or Mod+h in Sway.
make sign-test # confirm signing works (signs a throwaway string)
make sign # sign existing out/ artifacts
make verify # verify the resulting *.asc filesRequires a YubiKey with a GPG signing subkey. The scripts detect connected
YubiKeys, ensure the public key is in the local keyring (auto-fetching it
from the URL stored on the card if needed — see below), run a smoke-test
signature, then sign the ISO and SHA256SUMS with detached .asc
signatures. YubiKey touch and PIN are required.
Signing, make sign-test, and make verify all prepare a GPG environment via
lib/gpg-env.sh:
- Host env, as-is — if your host GPG already reaches the card and can sign
(the normal case on a configured machine), it is used unchanged. Your
~/.gnupgconfig and keyring are never modified. - Isolated temp
GNUPGHOMEfallback — if the host env can't sign (typical on a fresh Fedora live OS), a throwaway home is created with the rightscdaemon.conf(disable-ccid,pcsc-shared), apinentry-cursesagent, and a reliable keyserver. Because only one scdaemon can hold the reader, the fallback restarts pcscd and takes over the card, then releases it on exit (the host scdaemon respawns on its next use). This path runs only when the host env already could not sign.
If neither works, the command aborts and prints exact setup steps (install the
scdaemon/pinentry stack, add disable-ccid, restart pcscd, get the public key
into the keyring). Run make sign-test to debug signing without a full build.
The YubiKey holds only the private key. GPG also needs the public half
in the local keyring before it can sign. make build / make sign /
make verify (and the in-image safe-yubikey-fetch-pubkey command) all
invoke gpg --card-edit > fetch, which tries two paths in order:
- Card URL — set with
gpg --card-edit > admin > url. Used if present. - Keyserver lookup by fingerprint — fallback when no URL is on the card.
Uses whichever keyserver is configured in
dirmngr.conf(the live image shipskeyserver hkp://keyserver.ubuntu.com:80).
URL-first is the right order: a URL you control (your own server, a
GitHub raw URL, etc.) is more durable than a public keyserver that may
hold stale data, go down, or strip identity packets. Set one with
gpg --card-edit > admin > url <https-url-to-pubkey> once and the
keyserver becomes a fallback rather than the primary path.
If your key was ever uploaded to the configured keyserver, fetch typically succeeds even without a URL on the card. If both attempts fail:
gpg --import /path/to/your-pubkey.asc # from a file
gpg --keyserver hkps://keys.openpgp.org --recv-keys <fingerprint> # try another servergpg --keyserver hkps://keys.openpgp.org --recv-keys <fingerprint>
gpg --verify out/safe-live-nixos-sway-*.iso.asc
gpg --verify out/SHA256SUMS.ascThe ISO is not committed to the repository. Instead, the inputs are pinned so that anyone can reproduce a bit-identical ISO from the same source:
| Anchor | Mechanism | File |
|---|---|---|
| nixpkgs revision | Git commit hash | flake.lock |
| Builder image | OCI SHA256 digest | Dockerfile.builder FROM line |
flake.lock must be committed. It is the reproducibility anchor — the
same flake.lock produces the same package closure, which produces the same
ISO. The GPG signatures prove that a specific key signed a build from a
specific source state.
For distribution, publish the ISO + .asc signatures as a release artifact.
Users verify the signature and, if they want, independently reproduce the ISO
from the same commit to confirm it matches.
Edit the relevant module in modules/ and rebuild:
make build-no-sign
make test-guiNixOS modules are declarative — add a package to environment.systemPackages
in the appropriate .nix file and it appears in the image.
nix flake update # rewrites flake.lock to latest nixpkgs
git add flake.lock
git commit -m "chore: bump nixpkgs to <new-rev>"
make build-no-signVerify the build and test before committing. The new flake.lock is the new
reproducibility anchor.
The builder image digest is pinned in Dockerfile.builder. To update:
- Find the new digest for
nixos/nixon Docker Hub. - Update the
FROM nixos/nix@sha256:...line inDockerfile.builder. - Rebuild and test.
- Commit
Dockerfile.builder.
- Commit
flake.lockwhenever you runnix flake update. - Never commit
out/— the ISO and signatures are build artifacts. - Commit
.ascsignatures only if you want them in the repo history (typically attach them to a GitHub Release instead).
Check Nix syntax: nix flake check (if you have Nix installed on the host).
Only the container build produces the final ISO.
pkgs.writeShellApplication— creates a checked shell script as a Nix package, withruntimeInputsautomatically added to PATH.pkgs.makeDesktopItem— generates a.desktopfile package.environment.etc."path".text— writes a file into/etc/in the image.lib.mkForce— overrides a value set by an imported NixOS module.
See DESIGN.md for the build pipeline, boot sequence, security model, and the rationale for choosing NixOS over a Fedora kickstart approach.
See LICENSE.