249 lines
8.3 KiB
Nix
249 lines
8.3 KiB
Nix
{ 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 <index> [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 <hex-secret> [--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
|
|
'';
|
|
}
|
|
|