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)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-11 01:38:12 +08:00
1 parent 96fc0e3502
commit 0dcbbb02e9
11 files changed
+181 -130

No files matched your search

+2 -2
View File
@@ -34,7 +34,7 @@ small, reversible controls and narrow exceptions; benchmark before adopting expe
## Architecture and trust boundaries ## Architecture and trust boundaries
| Boundary | Current design | Limitation | | 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 | | 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 | | 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. | | 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. |
@@ -42,7 +42,7 @@ small, reversible controls and narrow exceptions; benchmark before adopting expe
| 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 |
+129 -74
View File
@@ -18,18 +18,65 @@ Management and use happen on different devices.
is unreachable from them ([hardening](../hardening/README.md)). Unsandboxed software is trusted; is unreachable from them ([hardening](../hardening/README.md)). Unsandboxed software is trusted;
if it is malicious, the PC is compromised regardless. if it is malicious, the PC is compromised regardless.
| Use | Config | Daily use | Fallback without the key | | Use | Config | 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 | | 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` | touch | password | | 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` | touch | 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` | touch per connection | per-host file keys | | 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` | FIDO2 PIN + touch | passphrase (prompted after 10 s) | | 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` | OpenPGP PIN once per plug-in, then touch | off-card subkey backup | | 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 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. 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. Device setup
### 1.1 Replace every default secret first ### 1.1 Replace every default secret first
@@ -41,20 +88,21 @@ firmware), and blocking one does not affect the others. The defaults are public
a default PIN or management key, anyone holding the key can use or rewrite that application. a default PIN or management key, anyone holding the key can use or rewrite that application.
| Application | Secret | Default | Protects | Set it to | Typed | | 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 | | 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, 8+ characters (4–63); memorize | daily, browsers | | 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, 8+ characters (6–64), not the FIDO2 PIN; memorize | daily, pinentry | | 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, 12+ characters (8–64); save offline | setup only | | 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 | | 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 | 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 | | 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 | — | — | | 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 - **Every PIN locks after 3 wrong attempts, so length matters less than not reusing a value.
the random values with the phone's password manager; "save offline" means that password manager or - Generate the random values with the phone's password manager;
paper, never `pass`: `pass` itself is decrypted by this key. The FIDO2 PIN is typed into browsers, - "save offline" means that password manager or paper, never `pass`: `pass` itself is decrypted by
the least trusted place, so it must not also unlock pass. 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 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 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 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. 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 Firmware must be 3.0.2 or later. Settings marked 3.1.0+ appear only on that firmware:
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):
| Setting | Choice | Why | | Setting | Choice | Why |
| ----------------- | -------------------------------------- | ------------------------------------------------------------------------- | | ---------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------- |
| LED | on | an unexpected blink means something requested a touch | | LED | on | an unexpected blink means something requested a touch |
| WebUSB login page | off | the console is not used on PCs | | 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 | | 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 |
3. The user guide also documents options for **firmware 3.1.0**, which has no public release yet | Password/HOTP keyboard output, PIV switches (3.1.0+) | off unless used | smaller attack surface |
(only the docs mention it; canokey-core's `dev` branch is unversioned). If the app shows them: | 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 |
| Option | Choice | Why | | Long-press reset (3.1.0+) | off, unless the app documents it as PIN-gated | keeps reset a phone-side management action |
| ------------------------------------------- | --------------------------------------------- | ------------------------------------------ |
| 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 ### 1.3 Touch feedback
@@ -158,23 +198,38 @@ needs a touch, so an unexpected blink is the signal to refuse it.
### SSH ### 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.
`<name>` is the key's name (`tea` is the main key, `moe` the backup):
```bash ```bash
ssh-keygen -t ed25519-sk -O resident -O application=ssh:ryan4yin -C ryan@canokey-<name> ssh-keygen -t ed25519-sk -O resident -O application=ssh:ryan4yin-<name> -C ryan@canokey-<name> \
-f ~/.ssh/canokey-<name>
``` ```
This writes `~/.ssh/id_ed25519_sk` on the machine that runs it. On each other desktop, export the On each other desktop, export the handle of every key, one key plugged in at a time:
key handle to ssh's default identity:
```bash ```bash
cd ~/.ssh && ssh-keygen -K \ 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-<name> canokey-<name> \
&& mv id_ed25519_sk_rk_ryan4yin-<name>.pub canokey-<name>.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: 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-<name>` holds only the credential ID,
application and public key. Every authentication sends the server's challenge to the key, which 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. 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 - **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. for each signature.
Add the public key to `canokeySshKeys` in [vars/default.nix](../vars/default.nix), which is part of 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 `mainSshAuthorizedKeys`, and to GitHub. These are the only user keys: without a CanoKey, only root
hosts try it as a default identity. Keys already loaded in `ssh-agent` are offered first, so the logins with the `romantic` recovery key (`secondaryAuthorizedKeys`) remain.
per-host keys can still win until they are retired.
### shoukei LUKS ### 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. 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 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) ### pass (OpenPGP)
The password store is encrypted to the `[E]` subkey `0xEF824EB73CFD6CC7`; the primary key is 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 ```bash
with a stub. export GNUPGHOME=$(mktemp -d)
2. Move the subkeys: `gpg --edit-key 0xCC88591C033F1924`, then `key 1` → `keytocard` (encryption), echo disable-ccid > "$GNUPGHOME/scdaemon.conf" # reach the key through pcscd, as the daily config does
and likewise for the signing and authentication subkeys; `save`. sudo cat /etc/agenix/ryan4yin-gpg-subkeys.priv.age | age --decrypt | gpg --import
3. Require touch for SIG, DEC and AUT in the phone app's OpenPGP page (or on the PC: gpg --card-status # shows this key's serial
`gpg --card-edit` → `admin` → `uif 1 on`, `uif 2 on`, `uif 3 on`). Decrypt touch matters most: gpg --edit-key 0xCC88591C033F1924
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 1. In `gpg --edit-key`, for each subkey: `key N` (the `[S]`, `[E]` or `[A]` one), `keytocard`, pick
app afterwards. 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 <entry>` (PIN,
then touch). On a machine that still holds on-disk subkeys, delete them first
(`gpg --delete-secret-keys '<subkey-fingerprint>!'`).
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 ## 3. Add a backup key
@@ -238,13 +306,13 @@ it is. On a YubiKey, manage settings with Yubico Authenticator or `ykman`; OpenP
`ykman openpgp keys set-touch dec on` (and `sig`, `aut`). `ykman openpgp keys set-touch dec on` (and `sig`, `aut`).
| Use | Backup key step | | Use | Backup key step |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Device | Section 1 again | | Device | Section 1 again |
| Browsers | Register it on every site as an additional security key | | 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` | | 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 | | SSH | Create a resident key on it with the same command and its own `<name>`; 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=auto` with only the backup plugged in; it takes the next slot | | shoukei LUKS | Run the same `systemd-cryptenroll --fido2-device=/dev/hidrawN` 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` | | 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 ## 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. 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 ## References
- [CanoKey user guide](https://docs.canokeys.org/userguide/): settings, WebAuthn, OpenPGP, PIV - [CanoKey user guide](https://docs.canokeys.org/userguide/): settings, WebAuthn, OpenPGP, PIV
+11 -4
View File
@@ -26,12 +26,14 @@ in
{ {
home.file.".ssh/romantic.pub".source = "${mysecrets}/public/romantic.pub"; 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 # Each CanoKey holds a resident FIDO2 SSH key for GitHub and every host, its
# FIDO2 credential, exported on each desktop to ~/.ssh/id_ed25519_sk (ssh's # handle exported on each desktop to ~/.ssh/canokey-<name>; every use needs a
# default identity); every use needs a touch. The per-host keys stay # touch, and ssh skips the handle of an unplugged key. Setup:
# authorized as the backup. Setup: docs/security-keys.md. # docs/security-keys.md.
programs.ssh = { programs.ssh = {
enable = true; 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; enableDefaultConfig = false;
# Keep this one definition: Nix merges a `settings."<pattern>"` attribute # Keep this one definition: Nix merges a `settings."<pattern>"` attribute
@@ -39,6 +41,11 @@ in
# so the generated LAN patterns are merged inside it with `//` instead. # so the generated LAN patterns are merged inside it with `//` instead.
settings = { settings = {
"*" = { "*" = {
# Main key first, then the backup.
IdentityFile = map (name: "${config.home.homeDirectory}/.ssh/${name}") [
"canokey-tea"
"canokey-moe"
];
ForwardAgent = false; ForwardAgent = false;
AddKeysToAgent = "yes"; AddKeysToAgent = "yes";
Compression = true; Compression = true;
-6
View File
@@ -1,9 +1,3 @@
{ config, ... }:
let
hostName = "frieren";
in
{ {
imports = [ ../../darwin ]; imports = [ ../../darwin ];
programs.ssh.settings."github.com".IdentityFile = "${config.home.homeDirectory}/.ssh/${hostName}";
} }
-7
View File
@@ -1,6 +1,5 @@
{ config, ... }: { config, ... }:
let let
hostName = "shoukei"; # Define your hostname.
mkSymlink = config.lib.file.mkOutOfStoreSymlink; mkSymlink = config.lib.file.mkOutOfStoreSymlink;
in in
{ {
@@ -11,12 +10,6 @@ in
modules.desktop.vinput.enable = true; 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.gaming.enable = false;
modules.desktop.niri.enable = true; modules.desktop.niri.enable = true;
-6
View File
@@ -12,12 +12,6 @@ in
modules.desktop.vinput.enable = true; 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.gaming.enable = true;
modules.desktop.niri.enable = true; modules.desktop.niri.enable = true;
-3
View File
@@ -1,12 +1,9 @@
{ {
pkgs, pkgs,
config, config,
nixpkgsConfig,
... ...
}: }:
{ {
nixpkgs.config = nixpkgsConfig;
nix.settings = { nix.settings = {
# enable flakes globally # enable flakes globally
experimental-features = [ experimental-features = [
+9 -3
View File
@@ -1,4 +1,9 @@
{ lib, pkgs, ... }: {
lib,
pkgs,
nixpkgsConfig,
...
}:
{ {
# Nix 2.35 is newer than nixpkgs' nixVersions.stable (still 2.34), so opt into # Nix 2.35 is newer than nixpkgs' nixVersions.stable (still 2.34), so opt into
# the latest released version until stable catches up. # the latest released version until stable catches up.
@@ -9,8 +14,9 @@
# https://lix.systems/add-to-config/ # https://lix.systems/add-to-config/
# nix.package = pkgs.lix; # nix.package = pkgs.lix;
# to install chrome, you need to enable unfree packages # NixOS only: darwin's nixpkgs.pkgs instance already carries it (lib/macosSystem.nix),
nixpkgs.config.allowUnfree = lib.mkDefault true; # and nixpkgs rejects nixpkgs.config next to nixpkgs.pkgs.
nixpkgs.config = nixpkgsConfig;
# do garbage collection weekly to keep disk usage low # do garbage collection weekly to keep disk usage low
nix.gc = { nix.gc = {
@@ -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
@@ -0,0 +1,7 @@
{
lib,
outputs,
}:
lib.genAttrs (builtins.attrNames outputs.darwinConfigurations) (
name: outputs.darwinConfigurations.${name}.config.nixpkgs.config == { }
)
+12 -25
View File
@@ -1,11 +1,13 @@
{ lib }: { lib }:
let let
# CanoKey resident SSH keys: they log in like the main keys, and they are the # CanoKey resident SSH keys: the only user keys, and the only keys pam_rssh
# only keys pam_rssh accepts for sudo on servers through the forwarded agent # accepts for sudo on servers through the forwarded agent
# (modules/nixos/server/ssh-agent-sudo.nix). # (modules/nixos/server/ssh-agent-sudo.nix).
canokeySshKeys = [ canokeySshKeys = [
# CanoKey Canary (backup) # canokey-tea (main)
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIKgfNy/wJgTw6auO4RmRHR9kh1Mt5TYNkomMDFLJ4Kw6AAAADHNzaDpyeWFuNHlpbg== ryan@canokey-moe" "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 in
{ {
@@ -21,30 +23,15 @@ in
# Purpose: system login password only # Purpose: system login password only
# https://man.archlinux.org/man/crypt.5.en # https://man.archlinux.org/man/crypt.5.en
initialHashedPassword = "$y$jFT$RBapCH3F6bc0uSF.FaUGB.$rvhiVvcCKxkkumDFLONV5zFP1lsv1VyZ1ErH.r2rNk3"; initialHashedPassword = "$y$jFT$RBapCH3F6bc0uSF.FaUGB.$rvhiVvcCKxkkumDFLONV5zFP1lsv1VyZ1ErH.r2rNk3";
# Public Keys that can be used to login to all my PCs, Macbooks, and servers. # Public keys that log in to all my PCs, Macbooks, and servers.
# mainSshAuthorizedKeys = canokeySshKeys;
# 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;
# CanoKey U2F registrations for sudo (modules/nixos/desktop/canokey.nix). # CanoKey U2F registrations for sudo (modules/nixos/desktop/canokey.nix).
# Key handles and public keys only, so they are safe to commit. # Key handles and public keys only, so they are safe to commit.
canokeyU2fKeys = [ canokeyU2fKeys = [
# CanoKey Canary (backup) # canokey-tea (main)
"DwXDxVU+NDTUZs/d2EjpsPAh4mrg7dqdg/mAozkw/mUAAdEYDZIbm8kTBUGA49wXW0ZvSpF9VNYh2w84Xz9nAIAW////+Q==,6hqEjWBJ+ueBKBJaNijNkUsmlgEHMgv0KdpprqdJjHKoU/FKpeqo12SWlvdgiIVWvqTaGfe4fNu/ckcGY5mlsA==,es256,+presence" "1pS52FIPHfevn97Hiz5+OOFe0OaPBPqIrsD0r/mC/YIAAdEYDZIbm8kTBUGA49wXW0ZvSpF9VNYh2w84Xz9nAIAW////+Q==,LkVavaZRhu8nHhIj1r2N78BwOlU32Y/AAGkekzn5DLQCztnMs4uslIAvzzaNNCpaDu49xwZm4TqPBDKg0IuIlA==,es256,+presence"
# canokey-moe (backup)
"dxdeYeJTx40VhgPll05COx1Vn3WRKuXNgtHCTpTSyq0AAdEYDZIbm8kTBUGA49wXW0ZvSpF9VNYh2w84Xz9nAIAW////+Q==,cpBHKCdambMTGau3+eXVyv4JWQK7ZQ+8AbRt0EevCL0DeftDdXX6aatHSpbiXAhjip3R8pfeXi6GTW67YpdCPw==,es256,+presence"
]; ];
secondaryAuthorizedKeys = [ secondaryAuthorizedKeys = [
# the backup ssh keys for disaster recovery # the backup ssh keys for disaster recovery