caesium: accounts and yubikey

This commit is contained in:
2026-08-09 01:34:25 -04:00
parent 8ba7972cda
commit 0cabe5af02
8 changed files with 432 additions and 72 deletions
+141 -7
View File
@@ -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 <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."
'';
};
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"
];
};
+18
View File
@@ -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 = {
+55
View File
@@ -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";
};
}
+153
View File
@@ -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
);
}
+43
View File
@@ -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
];
}
+19 -63
View File
@@ -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;
}
'';
};
};
}
+2 -2
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
mbessette:PusBTtTXC1flzOLV/pqIiz0xoiYLfbd8sMiLPdMuqbvg/IM0pylvB1kNYYJYadlivlshkITYB41h6yv2ouiPHw==,wkf3QAvo+NMZecX4KZO2urNeKB2086cYx0k5vGzvwYxFAS4SD92POq3uBjNg8kMwLlqgQbyJS0aEAdClyvzchQ==,es256,+presence::ttMJESYFH+swvoq3DXA23U9gnvKi12yleSz3GoKZ1sb/+pusgz6rc2B2LE3AJ5o9L/Pj5eEMjFFv0VUjfBuv4w==,m/SxBY1LuX//kxIA3RcnfjX7cnfVkQMRyNGbplhtUnHVfivx4zGLdIqWPUWVXJHV/pXax1IGxwVZeyz2EuQr/A==,es256,+presence