Security Auth Logic

Authentication Architecture: YubiKey & Hashed Passwords

This document explains the logic, security, and implementation of the “YubiKey-first, Password-fallback” authentication system used in this NixOS flake.

1. The Core Philosophy

The system is designed for High Security without sacrificing Convenience:

  • Primary Auth (YubiKey): A physical touch (U2F/FIDO2) is required for sudo and logins. It is fast and nearly impossible to phish or bypass remotely.
  • Fallback Auth (Hashed Password): A traditional password is used only if the YubiKey is missing or fails. This password is never stored in plain text.

2. The Logic of Hashed Password Files

What is a “Hash”?

In NixOS, we use users.users.<name>.hashedPasswordFile. Unlike a plain password, a hash is a cryptographic fingerprint.

The “Recipe” (Anatomy of a Hash String)

When you generate a hash using mkpasswd -m sha-512, you get a string like this: $6$rounds=656000$MyUniqueSalt$Z8x...HashedResult...

The system reads this as a recipe:

  1. $6$ (Algorithm): Use SHA-512, a military-grade hashing function.
  2. rounds=656000 (Work Factor): Run the calculation 656,000 times. This makes the process “expensive” for a hacker’s GPU to guess, but unnoticeable for a human login.
  3. MyUniqueSalt (The Salt): This is a random string added to your password before hashing.
  4. Z8x... (The Result): The final fingerprint.

Why is it Secure? (The “Salt” Defense)

Even if two users use the password "helloworld", their hash files will look completely different:

  • User A Salt: SaltAlpha + "helloworld" -> HashXYZ
  • User B Salt: SaltBeta + "helloworld" -> Hash123

Impact:

  • No Rainbow Tables: Attackers cannot use pre-computed lists of common passwords.
  • No Inference: An attacker cannot tell if two users have the same password just by looking at the hashes.
  • One-Way: You cannot “reverse” a hash. You can only verify a password by re-running the recipe.

3. Implementation Workflow

Step 1: Generate the Hash

On a secure machine, generate the hash for your fallback password:

mkpasswd -m sha-512 "your-simple-password"

Step 1b: Verify a Password Against a Hash

To manually check that a plaintext password matches an existing hash string, use Python’s crypt module — the hash itself contains the algorithm, rounds, and salt, so crypt.crypt() re-runs the same recipe:

python3 -c "
import crypt, sys, getpass
hashed = sys.argv[1]
password = getpass.getpass('Password: ')
if crypt.crypt(password, hashed) == hashed:
    print('MATCH')
else:
    print('NO MATCH')
" '$6$rounds=656000$MySalt$hashgoeshere...'

A compact one-liner when you don’t need the getpass safety:

python3 -c "import crypt; print(crypt.crypt('mypassword', '\$6\$rounds=656000\$MySalt\$hash...') == '\$6\$rounds=656000\$MySalt\$hash...')"

Note: escape each $ as \$ in the shell, or single-quote the whole hash string.

Step 2: Secret Management (Agenix)

Because NixOS stores configuration in the world-readable /nix/store, we never put the hash string directly in a .nix file. Instead:

  1. Encrypt the hash string into an .age file (e.g., secrets/bio-smart.HashedPasswordFile.age).
  2. This file is stored in your private nix-secrets repository.
  3. Only root can read the decrypted hash on the target host.

Step 3: Automated Mapping

The modules/security.nix file contains logic to automatically link these secrets:

user.hashedPasswordFile = let
  secretName = "${config.networking.hostName}.HashedPasswordFile";
in
  mkIf (hasAttr secretName config.age.secrets)
    config.age.secrets."${secretName}".path;

This ensures that every host automatically looks for its own unique password file.


4. PAM: The Authentication “Traffic Controller”

PAM (Pluggable Authentication Modules) is the system that decides the order of “Key” vs “Password” for user logins and sudo.

The Hardware Profile

When modules.profiles.hardware = [ "yubikey" ] is active, the following logic is applied in modules/profiles/hardware/yubikey.nix:

  1. security.pam.u2f.control = "sufficient":

    • The system first attempts to find a YubiKey.
    • It sends a “cue” (a prompt to touch the device).
    • If you touch it, authentication is sufficient—the process stops and you are logged in.
  2. Unix Fallback:

    • If no YubiKey is found or you don’t touch it, PAM moves to the next “module” in the stack: pam_unix.so.
    • pam_unix.so asks for your Password.
    • It reads your hashedPasswordFile, pulls the Salt, re-calculates the hash of your input, and compares it to the file.

The U2F Authfile (/etc/agenix/u2f_keys)

The bridge between your YubiKey hardware and PAM is a mapping file that lists which key handles are authorized for each user. Each line follows this format:

<username>:<keyHandle1>,<keyHandle2>,...
  • Key Handle: A cryptographic blob generated by pamu2fcfg during enrollment. It is not a secret in itself — it’s a public identifier that allows PAM to challenge the correct YubiKey.
  • Multiple keys: Append key handles with pamu2fcfg -n to register backup keys on the same line.

The file is encrypted with agenix and decrypted at runtime to /etc/agenix/u2f_keys (mode 0400, readable only by the target user). The NixOS module wires it via:

security.pam.u2f.settings.authfile = "/etc/agenix/u2f_keys";

For the full enrollment workflow (generating the mapping, encrypting, registering backup keys, revoking lost keys), see the YubiKey section in docs/security.md.


5. LUKS Disk Encryption (Systemd-Initrd)

For disk encryption, we use systemd-cryptsetup via disko. To avoid being locked out or stuck in emergency mode, we configure a structured fallback.

The “PIN + Timeout” Strategy

In storage.nix, the cryptroot is configured with these crypttabExtraOpts:

  • fido2-with-client-pin=yes: Enforces that the system asks for your security token PIN before the touch.
  • fido2-device-timeout=24s: (Kobe) If you don’t provide the PIN or touch the key within 24 seconds, the FIDO2 attempt times out.
  • fallbackToPassword = true: (Default) Once the FIDO2 attempt times out or fails, the system immediately drops to the standard LUKS passphrase prompt.

Next Steps for Implementation

1. Hardware Enrollment (Required)

You MUST manually enroll your YubiKey with the PIN requirement. This is a hardware-level change on the token itself. You do not need to re-format your disks, but you do need to update the LUKS key slot:

# Enroll your YubiKey with PIN and Touch requirement
sudo systemd-cryptenroll --fido2-device=auto \
                         --fido2-with-client-pin=true \
                         --fido2-with-user-presence=true \
                         /dev/disk/by-partlabel/CRYPTROOT

Note: Run sudo systemd-cryptenroll /dev/disk/by-partlabel/CRYPTROOT first to see existing slots. You may want to --wipe-slot=fido2 if an old FIDO2 slot exists.

2. Apply Configuration (NixOS)

To apply the crypttabExtraOpts (like the 24s timeout), you DO NOT need to re-run Disko format operations.

Instead, simply run your standard sync command:

hey sync

This command rebuilds your initrd (initial ramdisk) and bootloader configuration, which is where the timeout logic lives. After a reboot, the new 24s timeout will be active.


6. Operational Summary

ActionPrimary MethodFallback Method
SudoTouch YubiKeyType Password
LoginTouch YubiKeyType Password
SSHFIDO2/SSH Key(Usually disabled for passwords)

Troubleshooting

  • Lost YubiKey?: Use your fallback password.
  • Lost Password?: You must use a root recovery shell or re-install the hashedPasswordFile secret from another trusted machine.
  • Change Password?: Re-run mkpasswd, update the .age secret in nix-secrets, and run hey sync.