Files
nix-config-ryan4yin/docs/security-keys.md
T
ryan4yin | 二花 7cfda39c2c feat: log in and sudo over SSH with a CanoKey touch
The CanoKey resident SSH key (`ryan@canokey-moe`, the backup once a new primary key is enrolled) is now authorized for ryan and root on every host, and sudo on server and agent hosts accepts its touch through the forwarded agent (`pam_rssh`). Without the key or a forwarded agent, sudo asks for the password and login falls back to the file keys. Only `canokeySshKeys` can satisfy sudo.

Risks to weigh:

- Colmena deploys need one touch per host connection, since the agent offers the CanoKey key first.
- Desktops now forward the agent to every `hostsAddr` alias, as they already did for `192.168.*`; while a session is open, root there can request signatures, each needing a touch.

### Verification

- `just test` exits 0; CI passed on the first commit.
- Evaluated: sudo PAM stack `rssh` → `unix` → `deny` on shoryu, kana, akane and k3s-test-1-master-1, ai-niri keeps `pam_u2f`; shoryu root and ryan list `ryan@canokey-moe`.
- Not yet verified on a running host.

### Manual step after merge

Deploy the servers, then `just niri`; close old multiplexed connections (`ssh -O exit <host>`) and try `sudo true` over SSH.

AI assistance: DeepSeek Harness (claude-opus-5-5).

Assisted-by: DeepSeek Harness (claude-opus-5-5)
2026-11-06 03:26:59 -06:00

22 KiB
Raw Blame History

Hardware security keys

How the CanoKey (or a YubiKey) is set up and used on the desktops: initial setup, adding a backup key, and recovery. Commands here are the single source; the Nix files only point to this page.

Scope: idols-ai and shoukei (modules.desktop.canokey.enable). frieren only shares the GnuPG scdaemon setting. Servers never see a key; they only ask the forwarded agent for sudo.

Principle

Management and use happen on different devices.

  • PCs only use the key: WebAuthn/passkeys, sudo, SSH, LUKS unlock and OpenPGP (pass).
  • Management happens only in the phone app (CanoKey Console on iOS/Android): every PIN change, touch policies, application and factory reset, device settings. The CanoKey Admin PIN is never typed on a PC; the OpenPGP Admin PIN is typed once, for keytocard.
  • Sandboxed browsers see only the FIDO HID interface, never raw USB or CCID, so the WebUSB console is unreachable from them (hardening). Unsandboxed software is trusted; if it is malicious, the PC is compromised regardless.
Use Config Daily use Fallback without the key
Browsers nixpak FIDO2 passthrough FIDO2 PIN and/or touch, as the site asks site recovery codes, other registered key
sudo canokey.nix, registrations in vars canokeyU2fKeys touch password
sudo via SSH ssh-agent-sudo.nix, keys in vars canokeySshKeys touch password
SSH ssh.nix, public keys in vars mainSshAuthorizedKeys touch per connection per-host file keys
shoukei LUKS hardware-configuration.nix crypttabExtraOpts FIDO2 PIN + touch passphrase (prompted after 10 s)
pass / GPG gpg scdaemonSettings.disable-ccid OpenPGP PIN once per plug-in, then touch off-card subkey backup

Login (greetd) stays password-based: the password also unlocks GNOME Keyring.

1. Device setup

1.1 Replace every default secret first

Each application has its own secrets: separate values, separate retry counters (3 on released firmware), and blocking one does not affect the others. The defaults are public (from the CanoKey user guide), so change all of them in the phone app (CanoKey Console) before using the key for anything, including applications you do not use: with a default PIN or management key, anyone holding the key can use or rewrite that application.

Application Secret Default Protects Set it to Typed
Device CanoKey Admin PIN 123456 settings, per-application and factory reset random, 12+ characters (6–64); save offline phone only
WebAuthn FIDO2 PIN unset passkeys, LUKS unlock, SSH key creation and export memorable, 8+ characters (4–63); memorize daily, browsers
OpenPGP PIN 123456 decrypt (pass), sign, authenticate memorable, 8+ characters (6–64), not the FIDO2 PIN; memorize daily, pinentry
OpenPGP Admin PIN 12345678 key import (keytocard), touch policy, unblocking the PIN random, 12+ characters (8–64); save offline setup only
OpenPGP Reset Code unset unblocking the PIN without the Admin PIN leave unset (the Admin PIN also unblocks), or random and save offline recovery only
PIV PIN / PUK 123456 / 12345678 PIV keys / unblocking the PIV PIN 8 random digits each (maximum 8); do not save never
PIV management key 0102030405060708 × 3 writing PIV keys and certificates the app's random management key button; do not save never
OATH none: no password — anyone holding the key can read its TOTP/HOTP codes — —

Every PIN locks after 3 wrong attempts, so length matters less than not reusing a value. Generate the random values with the phone's password manager; "save offline" means that password manager or paper, never pass: pass itself is decrypted by this key. The FIDO2 PIN is typed into browsers, the least trusted place, so it must not also unlock pass.

PIV is unused, so its secrets are random and discarded: PIV is then locked rather than open with public defaults. To use PIV later, reset just the PIV application in the phone app (needs only the CanoKey Admin PIN), which restores the defaults.

1.2 Firmware and settings (phone app)

Touch policies are enforced only over USB, not NFC. OpenPGP and PIV stay reachable over NFC on released firmware, but each NFC tap is a new card session, so their PINs are still required.

  1. Check the firmware version. U2F (sudo) does not work on 3.0.0; 3.0.2 and later are fine. The newest public release is canokey-core 3.0.3.

  2. Settings on released firmware (the user guide lists LED, NDEF and WebUSB login page):

    Setting Choice Why
    LED on an unexpected blink means something requested a touch
    WebUSB login page off the console is not used on PCs
    NDEF off, or read-only if the app offers it default is enabled; anyone nearby can read it, and rewrite it if writable
  3. The user guide also documents options for firmware 3.1.0, which has no public release yet (only the docs mention it; canokey-core's dev branch is unversioned). If the app shows them:

    Option Choice Why
    OpenPGP / PIV over NFC off, unless the phone needs OpenPGP touch is not enforced over NFC
    Password/HOTP keyboard output, PIV switches off unless used smaller attack surface
    WebAuthn alwaysUv off on breaks U2F, which sudo uses
    WebAuthn minimum PIN length 6–8 the PIN locks after a few failures
    Long-press reset off, unless the app documents it as PIN-gated keeps reset a phone-side management action

1.3 Touch feedback

The LED is easy to miss, so touch requests also raise a desktop notification: a transient toast (and a notification sound, this sender only) that closes itself once the touch is done. programs.yubikey-touch-detector (canokey.nix) reads the FIDO interface's hidraw reports for touch-waiting signals, and proxies gpg-agent for smartcard-backed OpenPGP operations; the sound comes from Noctalia's notification filter (config.toml).

Flow Notified Why
sudo (pam-u2f) yes CTAPHID keepalive / U2F 0x6985 from the FIDO hidraw
ssh/scp/rsync/git over ssh (FIDO2 key) yes same
sudo on a server (pam_rssh) yes same; the forwarded agent signs on the local key
WebAuthn in browsers yes same; browsers read the same hidraw interface
pass / GPG decrypt and sign yes gpg-agent proxy; heuristic, see below
PIV / OATH no the detector has no source for those applets
shoukei LUKS unlock no initrd, before any desktop user service
PIN prompts no a PIN is not a touch; pinentry shows that prompt

A switch only enables the service for the next login: a running session does not start newly deployed user services, so after the first deploy start it once with systemctl --user start yubikey-touch-detector.

Check it: systemctl --user status yubikey-touch-detector, then run something that asks for a touch (sudo true, or ssh to a host) and watch for the toast. Logs: journalctl --user -u yubikey-touch-detector -f. The GPG detector only starts once private-keys-v1.d holds on-card stubs, and it treats an operation pending for 400 ms outside pinentry as a touch request. It proxies only the standard gpg-agent and $SSH_AUTH_SOCK sockets, so clients that use S.gpg-agent.extra or S.gpg-agent.browser get no notification.

2. Enroll a key

Only after section 1.1. Do these in order; each step can be rolled back on its own. Only the OpenPGP step is irreversible.

Browsers (WebAuthn / passkeys)

Nothing to enroll on the PC. Register the key on each site, preferably as a passkey. After a first deploy via switch without reboot, see the FIDO2 notes in the hardening README.

sudo

# pamu2fcfg is installed once canokey.nix is deployed; before that:
# Long options: some nix versions expand short flags such as -i after --command.
nix shell nixpkgs#pam_u2f --command \
  pamu2fcfg --nouser --origin=pam://ryan4yin   # touch the key

Add the printed KeyHandle,PublicKey,es256,+presence string to canokeyU2fKeys in vars/default.nix (public data; a leading : is accepted) and deploy. sudo then shows "Please touch the device"; without the key it asks for the password. While the list is empty, sudo is unchanged.

Every host shares the origin pam://ryan4yin, so one registration covers idols-ai and shoukei.

sudo via SSH

Servers and agent hosts (modules/nixos/server/ssh-agent-sudo.nix) run pam_rssh: sudo there sends a challenge back through the forwarded agent, and the CanoKey signs it after a touch. Nothing to enroll beyond the SSH key below. Only the canokeySshKeys count, so a file key in the agent cannot satisfy sudo. Agent forwarding is on for 192.168.* and the hostsAddr aliases (ssh.nix); without a forwarded agent, or with the key unplugged, sudo asks for the password.

While a session is open, root on that server can also use the forwarded agent. Each signature still needs a touch, so an unexpected blink is the signal to refuse it.

SSH

One SSH key, used for GitHub and every host, lives on the key as a resident credential:

ssh-keygen -t ed25519-sk -O resident -O application=ssh:ryan4yin -C ryan@canokey-<name>

This writes ~/.ssh/id_ed25519_sk on the machine that runs it. On each other desktop, export the key handle to ssh's default identity:

cd ~/.ssh && ssh-keygen -K \
  && mv id_ed25519_sk_rk_ryan4yin id_ed25519_sk && mv id_ed25519_sk_rk_ryan4yin.pub id_ed25519_sk.pub

How it works, and what the prompts mean:

  • The private key never leaves the CanoKey. ~/.ssh/id_ed25519_sk holds only the credential ID, application and public key. Every authentication sends the server's challenge to the key, which signs it after a touch; with the key unplugged, ssh skips this identity and tries the next one.
  • FIDO2 PIN: asked only when creating the credential and when exporting it with -K, not for each login. Adding -O verify-required at creation would require the PIN for every login too.
  • Touch: required for every new connection, including each git push/fetch. Existing sessions and reused connections need none: LAN hosts multiplex for 30 minutes (ControlMaster in ssh.nix). A deploy that opens one connection per host needs one touch per host.
  • File passphrase: leave it empty. Reading the file already takes your user session, and software running as you is trusted (see Principle); it could also capture the passphrase the next time you type it. A passphrase would cost one prompt per login, not per connection: AddKeysToAgent yes keeps the unlocked handle in the agent, which still asks the key for each signature.

Add the public key to canokeySshKeys in vars/default.nix, which is part of mainSshAuthorizedKeys, and to GitHub. GitHub tries id_ed25519_sk before the per-host key; other hosts try it as a default identity. Keys already loaded in ssh-agent are offered first, so the per-host keys can still win until they are retired.

shoukei LUKS

Enroll before deploying the crypttab change:

systemd-cryptenroll --fido2-device=list   # as the user: shows the key's hidraw node
sudo systemd-cryptenroll --fido2-device=/dev/hidrawN /dev/disk/by-uuid/1c37820e-2501-46e4-bec4-27c28691a5b4
sudo systemd-cryptenroll /dev/disk/by-uuid/1c37820e-2501-46e4-bec4-27c28691a5b4   # list slots

Name the hidraw node instead of --fido2-device=auto: libfido2 is built with PC/SC support, so root also finds the key's FIDO applet through pcscd (pcsc://slot0) and auto stops with "More than one FIDO device found". Unlocking at boot is unaffected: pcscd does not run in the initrd.

The default enrollment requires the FIDO2 PIN and a touch; keep it that way, since the key often travels with the laptop. The passphrase slot stays. Reboot to verify.

pass (OpenPGP)

The password store is encrypted to the [E] subkey 0xEF824EB73CFD6CC7; the primary key is offline.

  1. Verify the off-card backup of all three subkeys first: keytocard replaces the on-disk copy with a stub.
  2. Move the subkeys: gpg --edit-key 0xCC88591C033F1924, then key 1 → keytocard (encryption), and likewise for the signing and authentication subkeys; save.
  3. Require touch for SIG, DEC and AUT in the phone app's OpenPGP page (or on the PC: gpg --card-edit → admin → uif 1 on, uif 2 on, uif 3 on). Decrypt touch matters most: browserpass gives browsers the GPG agent socket, and without touch a compromised browser could decrypt the whole store silently after the first PIN.
  4. On other machines that still hold on-disk copies, delete them (gpg --delete-secret-keys '<subkey-fingerprint>!') and run gpg --card-status so gpg records stubs for the card.

keytocard is the only step that types the OpenPGP Admin PIN on a PC; change it again in the phone app afterwards.

3. Add a backup key

A second CanoKey or a YubiKey works the same way; the module name is CanoKey-specific but nothing in it is. On a YubiKey, manage settings with Yubico Authenticator or ykman; OpenPGP touch is ykman openpgp keys set-touch dec on (and sig, aut).

Use Backup key step
Device Section 1 again
Browsers Register it on every site as an additional security key
sudo pamu2fcfg --nouser --origin=pam://ryan4yin with only the backup plugged in; append to canokeyU2fKeys
SSH Create a resident key on it with the same command; add its public key to canokeySshKeys and GitHub. Use it with ssh-add -K (loads resident keys into the agent) instead of exporting a file
shoukei LUKS Run the same systemd-cryptenroll --fido2-device=auto with only the backup plugged in; it takes the next slot
OpenPGP Import the subkeys from the backup into a fresh, temporary GNUPGHOME, keytocard them onto the backup key, then delete that GNUPGHOME. The stubs remember the card serial; after swapping cards run gpg-connect-agent "scd serialno" "learn --force" /bye

4. Lost key or reset

Situation Action Lost
Key lost Remove its canokeyU2fKeys entry and SSH public keys; systemd-cryptenroll --wipe-slot=<n>; remove it on each site nothing, if a backup key or the passwords remain
FIDO2 PIN forgotten or locked Phone app: reset the WebAuthn application (needs the Admin PIN) all passkeys, the SSH resident key, LUKS and sudo enrollments; redo section 2
OpenPGP PIN locked gpg --card-edit → admin → passwd, unblock with the OpenPGP Admin PIN or Reset Code nothing
All OpenPGP PINs and Reset Code lost Phone app: reset the OpenPGP application on-card subkeys; re-import from the backup
Admin PIN forgotten Exhaust the Admin PIN, then in the app tap Reset and touch the key while the LED blinks, repeating until it stops all user data; serial and attestation keys stay

After any reset, rerun sections 1 and 2 for the affected applications.

Enrollment status

Key Firmware sudo SSH shoukei LUKS OpenPGP Browsers
CanoKey Canary (backup, canokey-moe) 3.0.3 works on idols-ai public key in canokeySshKeys; host login not verified enrolled; unlock not verified on the card WebAuthn works in Firefox/Chrome

shoukei's first boots with the FIDO2 slot asked for the PIN, then fell back to the passphrase: USB only came up after switch-root, because on linux-asahi 7.2 its dwc3/PHY depend on the Thunderbolt (ACIO) nodes, whose thunderbolt_apple and reset_apple_cio drivers were missing from the initrd. nixos-apple-silicon added both upstream (275e570ea8), and the input now includes it; unlocking with the key is not yet verified. Also not verified: the CanoKey Admin PIN retry count, and how touch is performed when resetting over NFC.

References

  • CanoKey user guide: settings, WebAuthn, OpenPGP, PIV
  • NixOS wiki: YubiKey: pam_u2f, disable-ccid
  • pam-u2f, systemd-cryptenroll(1), crypttab(5)
  • Comparable NixOS configs: cloud-gouv/securix (modules/pam/u2f.nix), kclejeune/system, joshsymonds/nix-config, wimpysworld/nix-config