{ pkgs, ... }: let # Wraps the interactive pamu2fcfg ceremony (touch required, can't be # scripted away) so the repo's yubikey.u2f-mappings file always ends up # well-formed: one line per user, credentials colon-separated. See # module.yubikey.nix for how the file is consumed. yubikeyEnroll = pkgs.writeShellApplication { name = "yubikey-enroll"; runtimeInputs = [ pkgs.pam_u2f pkgs.gawk pkgs.gnugrep ]; text = '' REPO="''${YUBIKEY_REPO:-$HOME/nixos}" MAP_FILE="$REPO/yubikey.u2f-mappings" USERNAME="''${1:-$(whoami)}" if [[ ! -f "$MAP_FILE" ]]; then echo "error: $MAP_FILE not found (expected the nixos repo checkout at $REPO)" >&2 exit 1 fi existing_line="$(grep "^''${USERNAME}:" "$MAP_FILE" || true)" echo "Insert the YubiKey to enroll, then touch it when it blinks." if [[ -z "$existing_line" ]]; then new_line="$(pamu2fcfg -u "$USERNAME" -o pam://mbessette -i pam://mbessette)" else cred="$(pamu2fcfg -n -o pam://mbessette -i pam://mbessette)" new_line="''${existing_line}:''${cred}" fi if [[ "$(printf '%s\n' "$new_line" | wc -l)" -ne 1 ]]; then echo "error: unexpected multi-line credential output, aborting without writing" >&2 exit 1 fi tmp="$(mktemp)" if [[ -z "$existing_line" ]]; then { grep -v "^''${USERNAME}:" "$MAP_FILE" || true; printf '%s\n' "$new_line"; } > "$tmp" else awk -F: -v u="$USERNAME" -v line="$new_line" 'BEGIN{OFS=":"} $1==u {print line; next} {print}' "$MAP_FILE" > "$tmp" fi n=$(grep -c "^''${USERNAME}:" "$tmp") if [[ "$n" -ne 1 ]]; then echo "error: expected exactly one line for $USERNAME, got $n; not writing" >&2 rm -f "$tmp" exit 1 fi mv "$tmp" "$MAP_FILE" count=$(awk -F: -v u="$USERNAME" '$1==u{print NF-1}' "$MAP_FILE") echo "Enrolled. $USERNAME now has $count credential(s) in $MAP_FILE." echo "Review the diff, then run nix-update to install it to /etc/u2f_mappings." ''; }; yubikeyUnenroll = pkgs.writeShellApplication { name = "yubikey-unenroll"; runtimeInputs = [ pkgs.gawk pkgs.gnugrep pkgs.coreutils ]; text = '' REPO="''${YUBIKEY_REPO:-$HOME/nixos}" MAP_FILE="$REPO/yubikey.u2f-mappings" if [[ ! -f "$MAP_FILE" ]]; then echo "error: $MAP_FILE not found (expected the nixos repo checkout at $REPO)" >&2 exit 1 fi list_credentials() { local user="$1" line line="$(grep "^''${user}:" "$MAP_FILE" || true)" if [[ -z "$line" ]]; then echo "$user: no credentials enrolled" return fi awk -F: -v u="$user" '$1==u { for (i=2;i<=NF;i++) { split($i, f, ","); printf " %d: %s...\n", i-1, substr(f[1],1,16) } }' <<<"$line" } if [[ $# -eq 0 ]]; then echo "usage: yubikey-unenroll [username] (no args: list credentials)" while IFS= read -r user; do list_credentials "$user" done < <(awk -F: '{print $1}' "$MAP_FILE" | sort -u) exit 0 fi INDEX="$1" USERNAME="''${2:-$(whoami)}" if ! [[ "$INDEX" =~ ^[0-9]+$ ]]; then echo "error: index must be a positive integer" >&2 exit 1 fi line="$(grep "^''${USERNAME}:" "$MAP_FILE" || true)" if [[ -z "$line" ]]; then echo "error: no entry for $USERNAME in $MAP_FILE" >&2 exit 1 fi count=$(( $(awk -F: '{print NF}' <<<"$line") - 1 )) if [[ "$INDEX" -lt 1 || "$INDEX" -gt "$count" ]]; then echo "error: $USERNAME has $count credential(s); index must be 1..$count" >&2 list_credentials "$USERNAME" exit 1 fi new_line="$(awk -F: -v OFS=: -v idx="$INDEX" '{ out=$1; for (i=2;i<=NF;i++) if (i-1!=idx) out=out OFS $i; print out }' <<<"$line")" tmp="$(mktemp)" if [[ "$count" -eq 1 ]]; then awk -F: -v u="$USERNAME" '$1!=u' "$MAP_FILE" > "$tmp" else awk -F: -v u="$USERNAME" -v line="$new_line" 'BEGIN{OFS=":"} $1==u {print line; next} {print}' "$MAP_FILE" > "$tmp" fi mv "$tmp" "$MAP_FILE" echo "Removed credential #$INDEX for $USERNAME. $((count - 1)) credential(s) remain." if [[ "$count" -eq 1 ]]; then echo "Note: $USERNAME now has zero credentials -- pam_u2f falls through to password (control=sufficient), not a lockout." fi echo "Review the diff in $MAP_FILE, then run nix-update to apply it." ''; }; # Programs OTP slot 2 for KeePassXC HMAC-SHA1 challenge-response (see # programs.keepassxc.nix). Unlike yubikey-enroll/-unenroll, this never # touches the repo: the shared secret must stay off disk entirely, so it's # only ever printed to the terminal for the user to record offline. yubikeyChalresp = pkgs.writeShellApplication { name = "yubikey-chalresp"; runtimeInputs = [ pkgs.yubikey-manager pkgs.openssl ]; text = '' usage() { cat >&2 <<'EOF' usage: yubikey-chalresp generate Print one random 40-char hex secret. Record it offline (e.g. in the KeePassXC database itself once it exists) -- it is never written to disk by this script. yubikey-chalresp apply [--force] Program the inserted YubiKey's OTP slot 2 with the given secret, requiring touch to respond. Run once per key with the SAME secret so any enrolled key can unlock the database. Refuses to overwrite an already-programmed slot 2 unless --force is given. yubikey-chalresp verify Run a fixed test challenge against the inserted key's slot 2 and print the response. Run this on every enrolled key and compare the printed responses by eye -- they must all match before any key is trusted with a real database. EOF } require_hex_secret() { local secret="$1" if ! [[ "$secret" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "error: secret must be exactly 40 hex characters (20 bytes)" >&2 exit 1 fi } case "''${1:-}" in generate) openssl rand -hex 20 echo "Record this secret offline now -- it is the only credential" >&2 echo "for a hardware-key-only KeePassXC database. It was not saved" >&2 echo "to disk anywhere by this script." >&2 ;; apply) SECRET="''${2:-}" FORCE="''${3:-}" [[ -n "$SECRET" ]] || { usage; exit 1; } require_hex_secret "$SECRET" if ykman otp info | grep -q "Slot 2: programmed" && [[ "$FORCE" != "--force" ]]; then echo "error: slot 2 is already programmed; pass --force to overwrite" >&2 exit 1 fi ykman otp chalresp --touch --force 2 "$SECRET" echo "Slot 2 programmed. Repeat on every other YubiKey with the same secret." ;; verify) echo "Touch the key when it blinks..." >&2 ykman otp calculate 2 "deadbeef" echo "Compare this response against the other enrolled keys." >&2 ;; *) usage exit 1 ;; esac ''; }; in { imports = [ ./programs.vscode.nix ./programs.thunderbird.nix ./module.accounts.nix ./module.evolution.nix ./programs.keepassxc.nix ]; programs.firefox.enable = true; home.packages = with pkgs; [ wowup-cf faugus-launcher yubikeyEnroll yubikeyUnenroll yubikeyChalresp ]; services.flatpak = { # nix-flatpak's home-manager module always manages the "user" flatpak # installation, independent of NixOS's services.flatpak.enable (system # scope) below in caesium.nixos.nix. Standalone home-manager has no # osConfig to inherit from, so this must be set explicitly. enable = true; uninstallUnmanaged = true; packages = [ "com.spotify.Client" "com.discordapp.Discord" "org.signal.Signal" "io.openrct2.OpenRCT2" "io.github.enginkirmaci.lumux" ]; }; programs.zsh.shellAliases = { claude = "nix run github:sadjow/claude-code-nix"; }; home.sessionVariables = { NPM_CONFIG_PREFIX = "/mnt/Projects/.npm-global/.npm-global"; }; home.sessionPath = [ "/mnt/Projects/.npm-global/bin" ]; home.file.".npmrc".text = '' prefix=/mnt/Projects/.npm-global/.npm-global ''; }