From 0dcbbb02e93b26b09b7af398da2eab8667290300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Wed, 11 Nov 2026 01:38:12 +0800 Subject: [PATCH] feat(canokey): enroll new CanoKeys and retire per-host SSH keys (#524) Hosts now accept only the two CanoKeys' resident SSH keys for ryan, plus the romantic recovery key for root; per-host user keys stop working. Darwin builds work again: the shared base no longer sets nixpkgs.config next to nix-darwin's nixpkgs.pkgs. Assisted-by: DeepSeek Harness (claude-opus-5-5) Assisted-by: DeepSeek Harness (deepseek-flash) --- docs/SECURITY.md | 26 +- docs/security-keys.md | 227 +++++++++++------- home/base/tui/ssh.nix | 15 +- home/hosts/darwin/darwin-frieren.nix | 6 - home/hosts/linux/12kingdoms-shoukei.nix | 7 - home/hosts/linux/idols-ai.nix | 6 - modules/base/nix.nix | 3 - modules/nixos/base/nix.nix | 12 +- .../tests/nixpkgs-config/expected.nix | 11 + .../tests/nixpkgs-config/expr.nix | 7 + vars/default.nix | 37 +-- 11 files changed, 204 insertions(+), 153 deletions(-) create mode 100644 outputs/aarch64-darwin/tests/nixpkgs-config/expected.nix create mode 100644 outputs/aarch64-darwin/tests/nixpkgs-config/expr.nix diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3c62da78..2754d088 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -33,19 +33,19 @@ small, reversible controls and narrow exceptions; benchmark before adopting expe ## Architecture and trust boundaries -| Boundary | Current design | Limitation | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Administration | Key-based SSH; root key login retained for Colmena/MicroVM deployment; only keys declared in Nix are honoured, and no password or keyboard-interactive login; remote-builder host keys only reach `nix-daemon` | Deployment credentials are root-equivalent file keys, usable by any same-user process through the desktop SSH agent; one stolen key reaches every host | -| Host network | Shared nftables firewall trusts the home LAN, tailnet interface and local Podman bridge; IPv6 link-local is accepted | These are broad trust zones, not service-level least privilege; router exposure and tailnet ACLs require separate verification | -| Monitoring | node/smartctl exporters bind the host LAN IPv4 (9100/9633); firewall rules restrict exporter ports 9100, 9835 and 9633 to the monitoring host over IPv4. RAPL counters are exposed only on x86 server hosts, to `node-exporter` through the `rapl` group. | nvidia_gpu_exporter (9835) still binds wildcard; the IPv6 denial for those three ports is in the shared firewall; current running hosts require separate verification. The node_exporter account can measure package energy for local side channels; interactive users, desktops, and guests keep the kernel-default root-only permission. | -| VM hosts | shoryu, shushou and youko run guests behind `br0`; ruby/kana are libvirt guests | A bridge is not network isolation; host compromise affects its guests | -| k3s | Three control-plane and three worker MicroVMs; Cilium, API VIP and NFS CSI | Cluster-admin, privileged/host-mounted pods and writable NFS data are powerful trust boundaries | -| MicroVM storage | Host store shared read-only through virtiofs; guest `/etc`, `/var` and `/home` persisted in separate images | Read-only store does not isolate the host-side virtiofs implementation or protect writable guest state | -| Desktop apps | Selected apps use nixpak/bubblewrap; Podman is rootless | Rules and shared directories vary per app; user namespaces are needed for these sandboxes | -| Security keys | idols-ai/shoukei use a CanoKey for touch-based sudo (pam_u2f, password fallback; servers via pam_rssh and the forwarded agent), one resident `ed25519-sk` SSH key for GitHub and all hosts, FIDO2 LUKS unlock on shoukei and on-card OpenPGP subkeys; key management happens only in the phone app ([setup](./security-keys.md)) | Touch-only sudo can be triggered by any same-user process, and on a server by root through the forwarded agent; per-host file SSH keys and passphrases stay valid as fallback, so their weaknesses remain until retired | -| Secrets | agenix declarations here, encrypted data in the private nix-secrets repository | Recipients and plaintext file permissions must be reviewed; root on a recipient can read its decrypted secrets | -| Supply chain | Locked flake inputs; explicit binary-cache trust keys on NixOS; normal user is not a Nix trusted-user | A trusted cache or imported Nix module can supply code executed as root; a lock file is not a security audit | -| Recovery | NixOS generations, btrbk/restic, hourly read-only snapshots of the REST store, and documented backup procedures | A rollback does not undo compromised data or secrets; backup configuration alone does not prove restore works; the offsite immutable copy is still planned | +| Boundary | Current design | Limitation | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Administration | Key-based SSH; root key login retained for Colmena/MicroVM deployment; only keys declared in Nix are honoured, and no password or keyboard-interactive login; remote-builder host keys only reach `nix-daemon` | Deployment credentials are root-equivalent file keys, usable by any same-user process through the desktop SSH agent; one stolen key reaches every host | +| Host network | Shared nftables firewall trusts the home LAN, tailnet interface and local Podman bridge; IPv6 link-local is accepted | These are broad trust zones, not service-level least privilege; router exposure and tailnet ACLs require separate verification | +| Monitoring | node/smartctl exporters bind the host LAN IPv4 (9100/9633); firewall rules restrict exporter ports 9100, 9835 and 9633 to the monitoring host over IPv4. RAPL counters are exposed only on x86 server hosts, to `node-exporter` through the `rapl` group. | nvidia_gpu_exporter (9835) still binds wildcard; the IPv6 denial for those three ports is in the shared firewall; current running hosts require separate verification. The node_exporter account can measure package energy for local side channels; interactive users, desktops, and guests keep the kernel-default root-only permission. | +| VM hosts | shoryu, shushou and youko run guests behind `br0`; ruby/kana are libvirt guests | A bridge is not network isolation; host compromise affects its guests | +| k3s | Three control-plane and three worker MicroVMs; Cilium, API VIP and NFS CSI | Cluster-admin, privileged/host-mounted pods and writable NFS data are powerful trust boundaries | +| MicroVM storage | Host store shared read-only through virtiofs; guest `/etc`, `/var` and `/home` persisted in separate images | Read-only store does not isolate the host-side virtiofs implementation or protect writable guest state | +| Desktop apps | Selected apps use nixpak/bubblewrap; Podman is rootless | Rules and shared directories vary per app; user namespaces are needed for these sandboxes | +| Security keys | idols-ai/shoukei use a CanoKey for touch-based sudo (pam_u2f, password fallback; servers via pam_rssh and the forwarded agent), a resident `ed25519-sk` SSH key per CanoKey as the only user SSH keys for GitHub and all hosts, FIDO2 LUKS unlock on shoukei and on-card OpenPGP subkeys; key management happens only in the phone app ([setup](./security-keys.md)) | Touch-only sudo can be triggered by any same-user process, and on a server by root through the forwarded agent; losing both CanoKeys leaves only root logins with the `romantic` recovery key | +| Secrets | agenix declarations here, encrypted data in the private nix-secrets repository | Recipients and plaintext file permissions must be reviewed; root on a recipient can read its decrypted secrets | +| Supply chain | Locked flake inputs; explicit binary-cache trust keys on NixOS; normal user is not a Nix trusted-user | A trusted cache or imported Nix module can supply code executed as root; a lock file is not a security audit | +| Recovery | NixOS generations, btrbk/restic, hourly read-only snapshots of the REST store, and documented backup procedures | A rollback does not undo compromised data or secrets; backup configuration alone does not prove restore works; the offsite immutable copy is still planned | Sources: [host layout](../hosts/README.md), [k3s deployment](../hosts/k8s/README.md), [firewall](../modules/nixos/base/networking/firewall.nix), [SSH](../modules/nixos/base/ssh.nix), diff --git a/docs/security-keys.md b/docs/security-keys.md index 45f845a3..99cc43ae 100644 --- a/docs/security-keys.md +++ b/docs/security-keys.md @@ -18,18 +18,65 @@ Management and use happen on different devices. is unreachable from them ([hardening](../hardening/README.md)). 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](../hardening/nixpaks/default.nix) | FIDO2 PIN and/or touch, as the site asks | site recovery codes, other registered key | -| sudo, polkit | [canokey.nix](../modules/nixos/desktop/canokey.nix), registrations in [vars](../vars/default.nix) `canokeyU2fKeys` | touch | password | -| sudo via SSH | [ssh-agent-sudo.nix](../modules/nixos/server/ssh-agent-sudo.nix), keys in [vars](../vars/default.nix) `canokeySshKeys` | touch | password | -| SSH | [ssh.nix](../home/base/tui/ssh.nix), public keys in [vars](../vars/default.nix) `mainSshAuthorizedKeys` | touch per connection | per-host file keys | -| shoukei LUKS | [hardware-configuration.nix](../hosts/12kingdoms-shoukei/hardware-configuration.nix) `crypttabExtraOpts` | FIDO2 PIN + touch | passphrase (prompted after 10 s) | -| pass / GPG | [gpg](../home/base/tui/gpg/default.nix) `scdaemonSettings.disable-ccid` | OpenPGP PIN once per plug-in, then touch | off-card subkey backup | +| Use | Config | Fallback without the key | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Browsers | [nixpak FIDO2 passthrough](../hardening/nixpaks/default.nix) | site recovery codes, other registered key | +| sudo, polkit | [canokey.nix](../modules/nixos/desktop/canokey.nix), registrations in [vars](../vars/default.nix) `canokeyU2fKeys` | password | +| sudo via SSH | [ssh-agent-sudo.nix](../modules/nixos/server/ssh-agent-sudo.nix), keys in [vars](../vars/default.nix) `canokeySshKeys` | password | +| SSH | [ssh.nix](../home/base/tui/ssh.nix), public keys in [vars](../vars/default.nix) `mainSshAuthorizedKeys` | the other CanoKey; root also accepts the `romantic` recovery key | +| shoukei LUKS | [hardware-configuration.nix](../hosts/12kingdoms-shoukei/hardware-configuration.nix) `crypttabExtraOpts` | passphrase (prompted after 10 s) | +| pass / GPG | [gpg](../home/base/tui/gpg/default.nix) `scdaemonSettings.disable-ccid` | off-card subkey backup | Only sudo and polkit accept a touch. Login (greetd), the lock screen and every other PAM service stay password-based: the login password also unlocks GNOME Keyring. +### PIN and touch per operation + +Who decides differs by application: + +- **FIDO2** (WebAuthn, sudo, SSH, LUKS): the key stores no touch or PIN policy. Each request says + whether it needs a touch (user presence) and the FIDO2 PIN (user verification); the key only + enforces what was asked. The one on-key factor is that a FIDO2 PIN is set. +- **OpenPGP** (pass, GPG): the key enforces its own policies, whatever gpg asks for; see + [OpenPGP settings on the key](#openpgp-settings-on-the-key). + +Touch is enforced only over USB; over NFC nothing asks for one (section 1.2). Once section 2 is +done: + +| Operation | PIN | Touch | Decided by | +| ---------------------------------------- | -------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------- | +| WebAuthn in browsers | FIDO2 PIN, unless the site discourages user verification | every operation | request: the site's `userVerification`; "preferred" asks for the PIN once set | +| sudo, polkit (pam-u2f) | none | every prompt | request: registration `+presence`, no `pinverification` | +| sudo on a server (pam_rssh) | none | every sudo | request: same as SSH | +| SSH login, git over SSH | none; FIDO2 PIN only for `ssh-keygen` creation and `-K` export | every new connection | request: key created without `-O verify-required`; details in [SSH](#ssh) | +| shoukei LUKS | FIDO2 PIN | every boot | request: `systemd-cryptenroll` default | +| OpenPGP decrypt (pass, browserpass) | OpenPGP PIN, once per plug-in | every decryption | key: DEC touch, cache time | +| OpenPGP sign (`pass init`, `gpg --sign`) | OpenPGP PIN, **every signature** | every signature | key: signature PIN, SIG touch, cache time | +| OpenPGP authenticate | OpenPGP PIN, once per plug-in | every use | key: AUT touch, cache time; unused, SSH uses the FIDO2 key | + +Little here signs: git commit signing is off, and pass signs only `.gpg-id` on `pass init` (checking +that signature uses the public key, not the card). + +#### OpenPGP settings on the key + +Stored on the CanoKey, so they follow it to every machine and survive a gpg reinstall; an OpenPGP +application reset returns them to the defaults. + +| Setting | CanoKey default | Set to | Effect | Change with | +| --------------------- | ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Signature PIN | forced | forced | forced: PIN for every signature. Not forced: PIN for the first signature after plug-in | `gpg --card-edit` → `admin` → `forcesig` (toggles) | +| Touch for SIG/DEC/AUT | off | on | a touch for every operation with that key, unless cached | `gpg --card-edit` → `admin` → `uif 1 on` (`2` DEC, `3` AUT) | +| Touch cache time | 0 s (no cache) | 0 s | one value for all three keys, 0–255 s: after a touch, operations within that many seconds need none. 0: every operation needs a touch | phone app; gpg has no command for it | +| PIN retries | 3 (PIN, Admin PIN, RC) | 3 | see [1.1](#11-replace-every-default-secret-first) | firmware 3.1.0+ only (1–15), and it resets the PIN and Admin PIN | +| PIN state | — | — | after one PIN entry, decrypt and authenticate need none until the key is unplugged | not configurable | + +- Use `uif N on`, never `permanent`: a permanent touch policy cannot be turned off again, only + cleared by resetting the OpenPGP application. +- A non-zero cache time lets anything holding the gpg-agent socket (browserpass included) decrypt + silently for that window after each touch; 0 keeps one touch per decryption. +- Check the key: `gpg --card-status` shows `Signature PIN` and `UIF setting`, but not the cache + time. + ## 1. Device setup ### 1.1 Replace every default secret first @@ -40,21 +87,22 @@ firmware), and blocking one does not affect the others. The defaults are public (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 | — | — | +| Application | Secret | Default | Protects | Set it to | Typed | +| ----------- | --------------------- | ---------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------- | --------------- | +| Device | **CanoKey Admin PIN** | `123456` | settings, per-application and factory reset | random, 8+ characters (6–64); save offline | phone only | +| WebAuthn | **FIDO2 PIN** | unset | passkeys, LUKS unlock, SSH key creation and export | memorable, 6+ characters (4–63); memorize | daily, browsers | +| OpenPGP | **PIN** | `123456` | decrypt (pass), sign, authenticate | memorable, 6+ characters (6–64), not the FIDO2 PIN; memorize | daily, pinentry | +| OpenPGP | **Admin PIN** | `12345678` | key import (`keytocard`), touch policy, unblocking the PIN | random, 8+ 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. +- **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 @@ -65,26 +113,18 @@ CanoKey Admin PIN), which restores the defaults. 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](https://github.com/canokeys/canokey-core/tags). -2. Settings on released firmware (the user guide lists LED, NDEF and WebUSB login page): +Firmware must be 3.0.2 or later. Settings marked 3.1.0+ appear only on that firmware: - | 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 | +| 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 | read-only | default is enabled; anyone nearby can read it, and rewrite it if writable | +| OpenPGP / PIV over NFC (3.1.0+) | off, unless the phone needs OpenPGP | touch is not enforced over NFC | +| Password/HOTP keyboard output, PIV switches (3.1.0+) | off unless used | smaller attack surface | +| WebAuthn `alwaysUv` (3.1.0+) | off | on breaks U2F, which sudo uses | +| WebAuthn minimum PIN length (3.1.0+) | 6–8 | the PIN locks after a few failures | +| Long-press reset (3.1.0+) | off, unless the app documents it as PIN-gated | keeps reset a phone-side management action | ### 1.3 Touch feedback @@ -158,23 +198,38 @@ 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: +One SSH key per CanoKey, used for GitHub and every host, lives on it as a resident credential. +`` is the key's name (`tea` is the main key, `moe` the backup): ```bash -ssh-keygen -t ed25519-sk -O resident -O application=ssh:ryan4yin -C ryan@canokey- +ssh-keygen -t ed25519-sk -O resident -O application=ssh:ryan4yin- -C ryan@canokey- \ + -f ~/.ssh/canokey- ``` -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: +On each other desktop, export the handle of every key, one key plugged in at a time: ```bash 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 + && mv id_ed25519_sk_rk_ryan4yin- canokey- \ + && mv id_ed25519_sk_rk_ryan4yin-.pub canokey-.pub ``` +[ssh.nix](../home/base/tui/ssh.nix) lists `~/.ssh/canokey-tea`, then `~/.ssh/canokey-moe`, as the +identities for every host and GitHub. Whichever key is plugged in answers; ssh skips the handle of +an absent key. + +What the CanoKey stores, and so what the phone app lists, is fixed by OpenSSH +([sk-usbhid.c](https://github.com/openssh/openssh-portable/blob/master/sk-usbhid.c)): + +- **RP ID** = `-O application`, the only label you choose. It must start with `ssh:` + (`ssh-keygen -K` loads only those), and CanoKey keeps at most 32 bytes of it. Naming it per key + keeps the exported files of two keys apart. +- **User name and display name** are always `openssh`; no ssh-keygen option changes them. +- The `-C` comment is **not** stored on the key, only in the `.pub` and handle files. + How it works, and what the prompts mean: -- The private key never leaves the CanoKey. `~/.ssh/id_ed25519_sk` holds only the credential ID, +- The private key never leaves the CanoKey. `~/.ssh/canokey-` 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 @@ -190,9 +245,8 @@ How it works, and what the prompts mean: for each signature. Add the public key to `canokeySshKeys` in [vars/default.nix](../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. +`mainSshAuthorizedKeys`, and to GitHub. These are the only user keys: without a CanoKey, only root +logins with the `romantic` recovery key (`secondaryAuthorizedKeys`) remain. ### shoukei LUKS @@ -209,27 +263,41 @@ also finds the key's FIDO applet through pcscd (`pcsc://slot0`) and `auto` stops 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. +travels with the laptop. The passphrase slot stays. Reboot to verify. A reset key leaves a dead slot +behind; remove every FIDO2 slot with `--wipe-slot=fido2` (the passphrase slot stays), then enroll +each key again. ### pass (OpenPGP) The password store is encrypted to the `[E]` subkey `0xEF824EB73CFD6CC7`; the primary key is -offline. +offline. The off-card backup of the three subkeys is the agenix secret +`/etc/agenix/ryan4yin-gpg-subkeys.priv.age` on the desktops: root-only, and still age-encrypted with +a passphrase. Write the subkeys onto each key from a throwaway `GNUPGHOME`, so the daily keyring +only ever holds stubs. With only that key plugged in: -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 '!'`) and run `gpg --card-status` so gpg records - stubs for the card. +```bash +export GNUPGHOME=$(mktemp -d) +echo disable-ccid > "$GNUPGHOME/scdaemon.conf" # reach the key through pcscd, as the daily config does +sudo cat /etc/agenix/ryan4yin-gpg-subkeys.priv.age | age --decrypt | gpg --import +gpg --card-status # shows this key's serial +gpg --edit-key 0xCC88591C033F1924 +``` -`keytocard` is the only step that types the OpenPGP Admin PIN on a PC; change it again in the phone -app afterwards. +1. In `gpg --edit-key`, for each subkey: `key N` (the `[S]`, `[E]` or `[A]` one), `keytocard`, pick + the slot of the same usage, `key N` again to deselect; then `save`. This asks for the subkey + passphrase and the OpenPGP Admin PIN. +2. Require touch: `gpg --card-edit` → `admin` → `uif 1 on`, `uif 2 on`, `uif 3 on` (or the phone + app). 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. +3. Drop the throwaway keyring: `gpgconf --kill all && rm -rf "$GNUPGHOME" && unset GNUPGHOME`. +4. In the daily keyring, point the stubs at the plugged-in key: + `gpg-connect-agent "scd serialno" "learn --force" /bye`. Check with `pass show ` (PIN, + then touch). On a machine that still holds on-disk subkeys, delete them first + (`gpg --delete-secret-keys '!'`). + +For the next key, start again from `export GNUPGHOME=…`: `save` turned the throwaway copies into +stubs. `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 @@ -237,14 +305,14 @@ A second CanoKey or a YubiKey works the same way; the module name is CanoKey-spe 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` | +| 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 and its own ``; add its public key to `canokeySshKeys` and GitHub, and its handle file to the `IdentityFile` lists | +| shoukei LUKS | Run the same `systemd-cryptenroll --fido2-device=/dev/hidrawN` with only the backup plugged in; it takes the next slot | +| OpenPGP | The same throwaway-`GNUPGHOME` procedure. The stubs remember the card serial; after swapping cards run `gpg-connect-agent "scd serialno" "learn --force" /bye` | ## 4. Lost key or reset @@ -258,19 +326,6 @@ it is. On a YubiKey, manage settings with Yubico Authenticator or `ykman`; OpenP 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](https://docs.canokeys.org/userguide/): settings, WebAuthn, OpenPGP, PIV diff --git a/home/base/tui/ssh.nix b/home/base/tui/ssh.nix index 1a03b1c4..08c58113 100644 --- a/home/base/tui/ssh.nix +++ b/home/base/tui/ssh.nix @@ -26,12 +26,14 @@ in { home.file.".ssh/romantic.pub".source = "${mysecrets}/public/romantic.pub"; - # One SSH key for GitHub and every host lives on the CanoKey as a resident - # FIDO2 credential, exported on each desktop to ~/.ssh/id_ed25519_sk (ssh's - # default identity); every use needs a touch. The per-host keys stay - # authorized as the backup. Setup: docs/security-keys.md. + # Each CanoKey holds a resident FIDO2 SSH key for GitHub and every host, its + # handle exported on each desktop to ~/.ssh/canokey-; every use needs a + # touch, and ssh skips the handle of an unplugged key. Setup: + # docs/security-keys.md. programs.ssh = { enable = true; + # macOS's ssh has no FIDO provider ("Cannot download keys without provider"). + package = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin pkgs.openssh; enableDefaultConfig = false; # Keep this one definition: Nix merges a `settings.""` attribute @@ -39,6 +41,11 @@ in # so the generated LAN patterns are merged inside it with `//` instead. settings = { "*" = { + # Main key first, then the backup. + IdentityFile = map (name: "${config.home.homeDirectory}/.ssh/${name}") [ + "canokey-tea" + "canokey-moe" + ]; ForwardAgent = false; AddKeysToAgent = "yes"; Compression = true; diff --git a/home/hosts/darwin/darwin-frieren.nix b/home/hosts/darwin/darwin-frieren.nix index 4f36733b..3c70a4b3 100644 --- a/home/hosts/darwin/darwin-frieren.nix +++ b/home/hosts/darwin/darwin-frieren.nix @@ -1,9 +1,3 @@ -{ config, ... }: -let - hostName = "frieren"; -in { imports = [ ../../darwin ]; - - programs.ssh.settings."github.com".IdentityFile = "${config.home.homeDirectory}/.ssh/${hostName}"; } diff --git a/home/hosts/linux/12kingdoms-shoukei.nix b/home/hosts/linux/12kingdoms-shoukei.nix index ef5ab73b..8661ec1e 100644 --- a/home/hosts/linux/12kingdoms-shoukei.nix +++ b/home/hosts/linux/12kingdoms-shoukei.nix @@ -1,6 +1,5 @@ { config, ... }: let - hostName = "shoukei"; # Define your hostname. mkSymlink = config.lib.file.mkOutOfStoreSymlink; in { @@ -11,12 +10,6 @@ in modules.desktop.vinput.enable = true; - # CanoKey key first (docs/security-keys.md), this host's key as backup. - programs.ssh.settings."github.com".IdentityFile = [ - "${config.home.homeDirectory}/.ssh/id_ed25519_sk" - "${config.home.homeDirectory}/.ssh/${hostName}" - ]; - modules.desktop.gaming.enable = false; modules.desktop.niri.enable = true; diff --git a/home/hosts/linux/idols-ai.nix b/home/hosts/linux/idols-ai.nix index 0adb3d65..af7cc74c 100644 --- a/home/hosts/linux/idols-ai.nix +++ b/home/hosts/linux/idols-ai.nix @@ -12,12 +12,6 @@ in modules.desktop.vinput.enable = true; - # CanoKey key first (docs/security-keys.md), this host's key as backup. - programs.ssh.settings."github.com".IdentityFile = [ - "${config.home.homeDirectory}/.ssh/id_ed25519_sk" - "${config.home.homeDirectory}/.ssh/idols-ai" - ]; - modules.desktop.gaming.enable = true; modules.desktop.niri.enable = true; diff --git a/modules/base/nix.nix b/modules/base/nix.nix index 0b9fa9cd..add12138 100644 --- a/modules/base/nix.nix +++ b/modules/base/nix.nix @@ -1,12 +1,9 @@ { pkgs, config, - nixpkgsConfig, ... }: { - nixpkgs.config = nixpkgsConfig; - nix.settings = { # enable flakes globally experimental-features = [ diff --git a/modules/nixos/base/nix.nix b/modules/nixos/base/nix.nix index ddc7b14b..93b8e511 100644 --- a/modules/nixos/base/nix.nix +++ b/modules/nixos/base/nix.nix @@ -1,4 +1,9 @@ -{ lib, pkgs, ... }: +{ + lib, + pkgs, + nixpkgsConfig, + ... +}: { # Nix 2.35 is newer than nixpkgs' nixVersions.stable (still 2.34), so opt into # the latest released version until stable catches up. @@ -9,8 +14,9 @@ # https://lix.systems/add-to-config/ # nix.package = pkgs.lix; - # to install chrome, you need to enable unfree packages - nixpkgs.config.allowUnfree = lib.mkDefault true; + # NixOS only: darwin's nixpkgs.pkgs instance already carries it (lib/macosSystem.nix), + # and nixpkgs rejects nixpkgs.config next to nixpkgs.pkgs. + nixpkgs.config = nixpkgsConfig; # do garbage collection weekly to keep disk usage low nix.gc = { diff --git a/outputs/aarch64-darwin/tests/nixpkgs-config/expected.nix b/outputs/aarch64-darwin/tests/nixpkgs-config/expected.nix new file mode 100644 index 00000000..f6ae3909 --- /dev/null +++ b/outputs/aarch64-darwin/tests/nixpkgs-config/expected.nix @@ -0,0 +1,11 @@ +{ + lib, + outputs, +}: +let + hostsNames = builtins.attrNames outputs.darwinConfigurations; + # nix-darwin passes an external instance through `nixpkgs.pkgs`, and nixpkgs + # asserts that `nixpkgs.config` stays empty in that case. + expected = lib.genAttrs hostsNames (_: true); +in +expected diff --git a/outputs/aarch64-darwin/tests/nixpkgs-config/expr.nix b/outputs/aarch64-darwin/tests/nixpkgs-config/expr.nix new file mode 100644 index 00000000..75c45a1e --- /dev/null +++ b/outputs/aarch64-darwin/tests/nixpkgs-config/expr.nix @@ -0,0 +1,7 @@ +{ + lib, + outputs, +}: +lib.genAttrs (builtins.attrNames outputs.darwinConfigurations) ( + name: outputs.darwinConfigurations.${name}.config.nixpkgs.config == { } +) diff --git a/vars/default.nix b/vars/default.nix index 0202774f..548191d3 100644 --- a/vars/default.nix +++ b/vars/default.nix @@ -1,11 +1,13 @@ { lib }: let - # CanoKey resident SSH keys: they log in like the main keys, and they are the - # only keys pam_rssh accepts for sudo on servers through the forwarded agent + # CanoKey resident SSH keys: the only user keys, and the only keys pam_rssh + # accepts for sudo on servers through the forwarded agent # (modules/nixos/server/ssh-agent-sudo.nix). canokeySshKeys = [ - # CanoKey Canary (backup) - "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIKgfNy/wJgTw6auO4RmRHR9kh1Mt5TYNkomMDFLJ4Kw6AAAADHNzaDpyeWFuNHlpbg== ryan@canokey-moe" + # canokey-tea (main) + "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAINuvSs4skjxNCTvN5k8rihUXOCst/XChw37aDeOmz+vvAAAAEHNzaDpyeWFuNHlpbi10ZWE= ryan@canokey-tea" + # canokey-moe (backup) + "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEZKUA4pNWvXKCpPQZHgctlR6O/71QirBQNt2RC5QsdfAAAAEHNzaDpyeWFuNHlpbi1tb2U= ryan@canokey-moe" ]; in { @@ -21,30 +23,15 @@ in # Purpose: system login password only # https://man.archlinux.org/man/crypt.5.en initialHashedPassword = "$y$jFT$RBapCH3F6bc0uSF.FaUGB.$rvhiVvcCKxkkumDFLONV5zFP1lsv1VyZ1ErH.r2rNk3"; - # Public Keys that can be used to login to all my PCs, Macbooks, and servers. - # - # Since its authority is so large, we must strengthen its security: - # 1. The corresponding private key must be: - # 1. Generated locally on every trusted client via: - # ```bash - # # KDF: bcrypt with 256 rounds, takes 2s on Apple M2): - # # Passphrase: digits + letters + symbols, 12+ chars - # ssh-keygen -t ed25519 -a 256 -C "ryan@xxx" -f ~/.ssh/xxx - # ``` - # 2. Never leave the device and never sent over the network. - # 2. Or just use hardware security keys like Yubikey/CanoKey. - mainSshAuthorizedKeys = [ - # The main ssh keys for daily usage - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIKlN+Q/GxvwxDX/OAjJHaNFEznEN4Tw4E4TwqQu/eD6 ryan@idols-ai" - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJwoI5MAogEa726jwwHL5EgM1X/i2A5d2pgV7i7t8fzB ryan@shoukei" - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDc1PNTXzzvd93E+e9LXvnEzqgUI5gMTEF/IitvzgmL+ ryan@frieren" - ] - ++ canokeySshKeys; + # Public keys that log in to all my PCs, Macbooks, and servers. + mainSshAuthorizedKeys = canokeySshKeys; # CanoKey U2F registrations for sudo (modules/nixos/desktop/canokey.nix). # Key handles and public keys only, so they are safe to commit. canokeyU2fKeys = [ - # CanoKey Canary (backup) - "DwXDxVU+NDTUZs/d2EjpsPAh4mrg7dqdg/mAozkw/mUAAdEYDZIbm8kTBUGA49wXW0ZvSpF9VNYh2w84Xz9nAIAW////+Q==,6hqEjWBJ+ueBKBJaNijNkUsmlgEHMgv0KdpprqdJjHKoU/FKpeqo12SWlvdgiIVWvqTaGfe4fNu/ckcGY5mlsA==,es256,+presence" + # canokey-tea (main) + "1pS52FIPHfevn97Hiz5+OOFe0OaPBPqIrsD0r/mC/YIAAdEYDZIbm8kTBUGA49wXW0ZvSpF9VNYh2w84Xz9nAIAW////+Q==,LkVavaZRhu8nHhIj1r2N78BwOlU32Y/AAGkekzn5DLQCztnMs4uslIAvzzaNNCpaDu49xwZm4TqPBDKg0IuIlA==,es256,+presence" + # canokey-moe (backup) + "dxdeYeJTx40VhgPll05COx1Vn3WRKuXNgtHCTpTSyq0AAdEYDZIbm8kTBUGA49wXW0ZvSpF9VNYh2w84Xz9nAIAW////+Q==,cpBHKCdambMTGau3+eXVyv4JWQK7ZQ+8AbRt0EevCL0DeftDdXX6aatHSpbiXAhjip3R8pfeXi6GTW67YpdCPw==,es256,+presence" ]; secondaryAuthorizedKeys = [ # the backup ssh keys for disaster recovery