diff --git a/caesium.home.nix b/caesium.home.nix index bc11f00..3258e69 100644 --- a/caesium.home.nix +++ b/caesium.home.nix @@ -1,26 +1,160 @@ -{ config, pkgs, ... }: { +{ 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." + ''; + }; +in +{ imports = [ ./programs.vscode.nix ./programs.thunderbird.nix + ./module.accounts.nix + ./module.evolution.nix ]; + programs.firefox.enable = true; + home.packages = with pkgs; [ - firefox wowup-cf faugus-launcher + yubikeyEnroll + yubikeyUnenroll ]; - systemd.user.sessionVariables = { - ## Required to fix firefox unrendered addressbar and tabs - XDG_DATA_DIRS = "${config.home.homeDirectory}/.nix-profile/share:/usr/local/share:/usr/share"; - }; - 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" ]; }; diff --git a/caesium.nixos.nix b/caesium.nixos.nix index a5b73ca..a49a486 100644 --- a/caesium.nixos.nix +++ b/caesium.nixos.nix @@ -4,6 +4,7 @@ imports = [ ./caesium.hardware.nix # Crucial: Imports your UUIDs ./module.firewall.nix + ./module.yubikey.nix ]; time.timeZone = "America/New_York"; @@ -161,9 +162,25 @@ services.displayManager.sddm.enable = true; services.desktopManager.plasma6.enable = true; + # Registers the evolution-data-server D-Bus services/systemd user units so + # GNOME Calendar, GNOME Contacts and Planify can share calendars/contacts + # (see module.evolution.nix). Deliberately NOT programs.evolution.enable, + # which force-enables services.gnome.gnome-keyring and would fight kwallet + # for ownership of org.freedesktop.secrets. + services.gnome.evolution-data-server.enable = true; + # Tell SDDM to use Wayland for the login screen itself (Ultra Modern) services.displayManager.sddm.wayland.enable = true; + # GTK apps (Firefox, Thunderbird) read their theme through GtkSettings, which needs the + # org.gtk.Settings.* / org.gnome.desktop.interface GSettings schemas. Plasma is Qt-based so + # nothing pulls them into the system profile, and the Mozilla wrappers don't add them + # themselves — without this their chrome renders black. + environment.sessionVariables.XDG_DATA_DIRS = [ + "${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}" + "${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}" + ]; + fonts.packages = with pkgs; [ noto-fonts noto-fonts-cjk-sans @@ -219,6 +236,7 @@ python3 nodejs_26 home-manager + glib ]; programs.appimage = { diff --git a/module.accounts.nix b/module.accounts.nix new file mode 100644 index 0000000..c383e02 --- /dev/null +++ b/module.accounts.nix @@ -0,0 +1,55 @@ +{ ... }: +let + davHost = "cloud.thiccdata.io"; + davUser = "mbessette"; + + mkGmailAccount = address: realName: { + inherit address realName; + flavor = "gmail.com"; + thunderbird.enable = true; + }; + + mkCalendar = collection: { + remote = { + type = "caldav"; + url = "https://${davHost}/remote.php/dav/calendars/${davUser}/${collection}/"; + userName = davUser; + }; + }; + + mkAddressBook = book: { + remote = { + type = "carddav"; + url = "https://${davHost}/remote.php/dav/addressbooks/users/${davUser}/${book}/"; + userName = davUser; + }; + }; +in +{ + accounts.calendar.basePath = ".calendars"; + accounts.contact.basePath = ".contacts"; + + accounts.email.accounts = { + personal = mkGmailAccount "blade30912@gmail.com" "Matthew Bessette" // { + primary = true; + }; + bessette = mkGmailAccount "bessette.matthew94@gmail.com" "Matthew Bessette"; + house = mkGmailAccount "mbessette.house@gmail.com" "Matthew Bessette"; + joseph = mkGmailAccount "joseph.lamothe55@gmail.com" "Joseph Lamothe"; + }; + + # Calendars and the address book are consumed via evolution-data-server + # (see module.evolution.nix) rather than Thunderbird's own calendar/address + # book code. Only email accounts opt in to Thunderbird. + accounts.calendar.accounts = { + family = mkCalendar "test"; + matt = mkCalendar "matt-calendar" // { + primary = true; + }; + joseph-cal = mkCalendar "joseph-calendar"; + }; + + accounts.contact.accounts = { + contacts = mkAddressBook "contacts"; + }; +} diff --git a/module.evolution.nix b/module.evolution.nix new file mode 100644 index 0000000..b9bb2cb --- /dev/null +++ b/module.evolution.nix @@ -0,0 +1,153 @@ +{ config, lib, pkgs, ... }: +let + calendarAccounts = config.accounts.calendar.accounts; + contactAccounts = config.accounts.contact.accounts; + + # Nextcloud stores VTODOs in the same CalDAV collections as VEVENTs, so a + # calendar account can also be exposed to task-list clients (Planify) by + # emitting a second EDS source against the same collection. + tasksFrom = [ "matt" ]; + + # Per-calendar accent colors, previously set on the (now removed) + # `thunderbird.color` attrs in module.accounts.nix. + calendarColors = { + family = "#FED3D9"; + matt = "#FDF0D7"; + joseph-cal = "#CEF4F8"; + }; + + splitUrl = url: + let + stripped = lib.removePrefix "https://" url; + host = lib.head (lib.splitString "/" stripped); + in + { + inherit host; + resourcePath = lib.removePrefix host stripped; + }; + + mkAuth = remote: + let + s = splitUrl remote.url; + in + { + "Security" = { + Method = "tls"; + }; + "Authentication" = { + Host = s.host; + Port = "443"; + User = remote.userName; + Method = "plain/password"; + RememberPassword = "true"; + }; + "WebDAV Backend" = { + ResourcePath = s.resourcePath; + }; + "Offline" = { + StaySynchronized = "true"; + }; + "Refresh" = { + Enabled = "true"; + IntervalMinutes = "30"; + }; + }; + + toIni = sections: + lib.concatStringsSep "\n" ( + lib.mapAttrsToList + (section: keys: '' + [${section}] + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k}=${v}") keys)} + '') + sections + ); + + mkCalendarSource = name: account: + let + uid = "nextcloud-${name}"; + in + { + inherit uid; + text = toIni ({ + "Data Source" = { + DisplayName = name; + Enabled = "true"; + Parent = "caldav-stub"; + }; + "Calendar" = { + BackendName = "caldav"; + Color = calendarColors.${name} or "#3584E4"; + Selected = "true"; + }; + } // mkAuth account.remote); + }; + + mkTaskListSource = name: account: + let + uid = "nextcloud-${name}-tasks"; + in + { + inherit uid; + text = toIni ({ + "Data Source" = { + DisplayName = "${name} (Tasks)"; + Enabled = "true"; + Parent = "caldav-stub"; + }; + "Task List" = { + BackendName = "caldav"; + Color = calendarColors.${name} or "#3584E4"; + Selected = "true"; + }; + } // mkAuth account.remote); + }; + + mkContactSource = name: account: + let + uid = "nextcloud-${name}"; + in + { + inherit uid; + text = toIni ({ + "Data Source" = { + DisplayName = name; + Enabled = "true"; + Parent = "carddav-stub"; + }; + "Address Book" = { + BackendName = "carddav"; + }; + } // mkAuth account.remote); + }; + + calendarSources = lib.mapAttrsToList mkCalendarSource calendarAccounts; + taskListSources = lib.mapAttrsToList mkTaskListSource + (lib.filterAttrs (name: _: builtins.elem name tasksFrom) calendarAccounts); + contactSources = lib.mapAttrsToList mkContactSource contactAccounts; + + allSources = calendarSources ++ taskListSources ++ contactSources; + + sourceFile = src: pkgs.writeText "${src.uid}.source" src.text; +in +{ + home.packages = with pkgs; [ + gnome-calendar + gnome-contacts + planify + libsecret + ]; + + # evolution-data-server treats files under ~/.config/evolution/sources/ as + # writable/removable and rewrites them on connect and on any in-app tweak + # (color, visibility, ...). A /nix/store symlink can't be written back to, + # so these are copied into place as real files on every activation instead + # of managed via xdg.configFile. + home.activation.evolutionSources = lib.hm.dag.entryAfter [ "writeBoundary" ] ( + lib.concatMapStringsSep "\n" + (src: '' + install -Dm644 ${sourceFile src} "$HOME/.config/evolution/sources/${src.uid}.source" + '') + allSources + ); +} diff --git a/module.yubikey.nix b/module.yubikey.nix new file mode 100644 index 0000000..6f935ea --- /dev/null +++ b/module.yubikey.nix @@ -0,0 +1,43 @@ +# YubiKey (FIDO2/U2F) login via pam_u2f. +# +# `security.pam.u2f.enable = true` defaults every PAM service's `u2fAuth` to +# true, so this covers sudo, sddm/sddm-greeter, the Plasma lock screen (the +# `kde` service, added by services.desktopManager.plasma6), login, and su +# without listing them individually. `control = "sufficient"` means a touch +# skips the password, but the password always still works — losing both +# enrolled keys can never lock this account out. +# +# /etc/u2f_mappings starts empty. Enroll keys after the first switch with the +# `yubikey-enroll` / `yubikey-unenroll` scripts (defined in caesium.home.nix), +# which wrap the interactive pamu2fcfg touch ceremony and keep +# yubikey.u2f-mappings well-formed; then re-run nix-update. An empty/missing +# mapping just falls through to the password, so this is safe to deploy +# before enrollment. +{ pkgs, ... }: + +{ + services.pcscd.enable = true; + services.udev.packages = [ + pkgs.yubikey-personalization + pkgs.libfido2 + ]; + + security.pam.u2f = { + enable = true; + control = "sufficient"; + settings = { + cue = true; + authfile = "/etc/u2f_mappings"; + origin = "pam://mbessette"; + appid = "pam://mbessette"; + }; + }; + + environment.etc."u2f_mappings".source = ./yubikey.u2f-mappings; + + environment.systemPackages = with pkgs; [ + pam_u2f + yubikey-manager + libfido2 + ]; +} diff --git a/programs.thunderbird.nix b/programs.thunderbird.nix index 4df5548..ba896bf 100644 --- a/programs.thunderbird.nix +++ b/programs.thunderbird.nix @@ -1,31 +1,12 @@ { pkgs, ... }: let tbPkg = if pkgs.stdenv.isDarwin then pkgs.thunderbird-bin else pkgs.thunderbird; - - mkGmailAccount = address: realName: { - inherit address realName; - flavor = "gmail.com"; - thunderbird.enable = true; - }; - - mkCalendar = collection: { - remote = { - type = "caldav"; - url = "https://cloud.thiccdata.io/remote.php/dav/calendars/mbessette/${collection}/"; - userName = "mbessette"; - }; - }; - - mkAddressBook = book: { - remote = { - type = "carddav"; - url = "https://cloud.thiccdata.io/remote.php/dav/addressbooks/users/mbessette/${book}/"; - userName = "mbessette"; - }; - thunderbird.enable = true; - }; in { + # Account definitions (email/calendar/contact) live in module.accounts.nix, + # shared with evolution-data-server (module.evolution.nix). Only email + # accounts opt in to Thunderbird; calendars and the address book are + # consumed via GNOME/EDS instead, so Thunderbird is mail-only. programs.thunderbird = { enable = true; package = tbPkg; @@ -33,48 +14,23 @@ in profiles.default = { isDefault = true; accountsOrder = [ "personal" ]; - }; - }; - accounts.calendar.basePath = ".calendars"; - accounts.contact.basePath = ".contacts"; - - accounts.email.accounts = { - personal = mkGmailAccount "blade30912@gmail.com" "Matthew Bessette" // { - primary = true; - }; - bessette = mkGmailAccount "bessette.matthew94@gmail.com" "Matthew Bessette"; - house = mkGmailAccount "mbessette.house@gmail.com" "Matthew Bessette"; - joseph = mkGmailAccount "joseph.lamothe55@gmail.com" "Joseph Lamothe"; - }; - - accounts.calendar.accounts = { - family = mkCalendar "test" // { - thunderbird = { - enable = true; - color = "#FED3D9"; + settings = { + "mail.chat.enabled" = false; + "calendar.itip.showImipBar" = false; + "toolkit.legacyUserProfileCustomizations.stylesheets" = true; }; - }; - matt = mkCalendar "matt-calendar" // { - primary = true; - thunderbird = { - enable = true; - color = "#FDF0D7"; - settings = id: { - "calendar.registry.${id}.imip.identity.key" = - "id_${builtins.hashString "sha256" "personal"}"; - }; - }; - }; - joseph-cal = mkCalendar "joseph-calendar" // { - thunderbird = { - enable = true; - color = "#CEF4F8"; - }; - }; - }; - accounts.contact.accounts = { - contacts = mkAddressBook "contacts"; + # Hide the calendar/tasks/chat spaces so Thunderbird presents as an + # email-only client. The address book space stays (local/collected + # addresses + autocomplete still live there). + userChrome = '' + #calendarButton, + #tasksButton, + #chatButton { + display: none !important; + } + ''; + }; }; } diff --git a/programs.vscode.nix b/programs.vscode.nix index f821005..c998639 100644 --- a/programs.vscode.nix +++ b/programs.vscode.nix @@ -1,9 +1,9 @@ { config, pkgs, ... }: { xdg.configFile."oxlint/oxlintrc.json".source = - config.lib.file.mkOutOfStoreSymlink "/Users/matthew.bessette/nixos/config.oxlintrc.json"; + config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/nixos/config.oxlintrc.json"; xdg.configFile."biome/biome.json".source = - config.lib.file.mkOutOfStoreSymlink "/Users/matthew.bessette/nixos/config.biome.json"; + config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/nixos/config.biome.json"; programs.vscode = { enable = true; diff --git a/yubikey.u2f-mappings b/yubikey.u2f-mappings new file mode 100644 index 0000000..e723a65 --- /dev/null +++ b/yubikey.u2f-mappings @@ -0,0 +1 @@ +mbessette:PusBTtTXC1flzOLV/pqIiz0xoiYLfbd8sMiLPdMuqbvg/IM0pylvB1kNYYJYadlivlshkITYB41h6yv2ouiPHw==,wkf3QAvo+NMZecX4KZO2urNeKB2086cYx0k5vGzvwYxFAS4SD92POq3uBjNg8kMwLlqgQbyJS0aEAdClyvzchQ==,es256,+presence::ttMJESYFH+swvoq3DXA23U9gnvKi12yleSz3GoKZ1sb/+pusgz6rc2B2LE3AJ5o9L/Pj5eEMjFFv0VUjfBuv4w==,m/SxBY1LuX//kxIA3RcnfjX7cnfVkQMRyNGbplhtUnHVfivx4zGLdIqWPUWVXJHV/pXax1IGxwVZeyz2EuQr/A==,es256,+presence