feat(secrets): switch pass to an OpenPGP key on CanoKeys (#525)

Assisted-by: DeepSeek Harness (claude-opus-5-5)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-11 13:42:14 +08:00
1 parent c703b590c1
commit c909b1340f
10 files changed
+910 -440

No files matched your search

+5
View File
@@ -67,4 +67,9 @@ extend-ignore-re = [
# ROCm's `HSA_OVERRIDE_GFX_VERSION` (Heterogeneous System Architecture); the # ROCm's `HSA_OVERRIDE_GFX_VERSION` (Heterogeneous System Architecture); the
# dictionary wants "HAS". # dictionary wants "HAS".
"HSA", "HSA",
# OpenPGP/GnuPG's `fpr` record and `FPR` for fingerprint; the checker wants
# "for"/"far"/"fps".
"fpr",
"FPR",
] ]
+67 -29
View File
@@ -25,7 +25,7 @@ Management and use happen on different devices.
| sudo via SSH | [ssh-agent-sudo.nix](../modules/nixos/server/ssh-agent-sudo.nix), keys in [vars](../vars/default.nix) `canokeySshKeys` | 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 | | 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) | | 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 | | pass / GPG | [gpg](../home/base/tui/gpg/default.nix) `scdaemonSettings.disable-ccid` | the age-encrypted key 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.
@@ -269,35 +269,73 @@ 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 named by `PASSWORD_STORE_KEY` in
offline. The off-card backup of the three subkeys is the agenix secret [password-store](../home/base/tui/password-store/default.nix). The key is a `[C]` primary that never
`/etc/agenix/ryan4yin-gpg-subkeys.priv.age` on the desktops: root-only, and still age-encrypted with leaves its backup, with `[S]` and `[E]` subkeys that both CanoKeys hold. The secret key exists only
a passphrase. Write the subkeys onto each key from a throwaway `GNUPGHOME`, so the daily keyring on the cards and in age-encrypted backups, which also carry the revocation certificate. The key has
only ever holds stubs. With only that key plugged in: its own GPG passphrase too: it stays protected in the tmpfs keyrings whenever a backup is opened,
and age's scrypt covers gpg's weaker S2K against offline guessing.
[gpg_canokey.py](../scripts/gpg_canokey.py) does each step in throwaway keyrings on tmpfs.
```bash A new key replaces the old one outright: the old key decrypts the store once, during the switch, and
export GNUPGHOME=$(mktemp -d) is then retired.
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
```
1. In `gpg --edit-key`, for each subkey: `key N` (the `[S]`, `[E]` or `[A]` one), `keytocard`, pick **Prepare.** Nothing changes for pass yet.
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 1. Turn on signature and decryption touch on every CanoKey in the phone app; `card` refuses a key
stubs. `keytocard` is the only step that types the OpenPGP Admin PIN on a PC; change it again in the without them. Decryption touch matters most: browserpass gives browsers the GPG agent socket, and
phone app afterwards. without touch a compromised browser could decrypt the whole store silently after the first PIN.
2. Create the key, with two backup drives mounted:
```bash
python3 scripts/gpg_canokey.py generate --uid '<name> (pass) <address>' \
--dest /run/media/$USER/<drive-1> --dest /run/media/$USER/<drive-2> \
--public-key ~/codes/nix-secrets/public/<name>.pub
```
Each command lists its prompts before the first one and announces each prompt as it comes; age
asks again after a mistyped passphrase. Here gpg asks for the new GPG passphrase in three
dialogs, one per key, twice each, and once more to export the key; then age asks for the backup
passphrase twice, and once more to prove that the backup restores. A key without a GPG passphrase
is refused. Make both passphrases long and random and save them offline like the Admin PIN, never
in pass. Keep the drives offline and apart.
3. Load each CanoKey, with only that key plugged in:
`python3 scripts/gpg_canokey.py card --backup <drive>/gpg-<fingerprint>.tar.age`. It prints the
`gpg --edit-key` commands to type, then signs and decrypts once through the card. `keytocard` is
the only step that types the OpenPGP Admin PIN on a PC; change it again in the phone app
afterwards.
4. Prepare, but do not deploy, the configuration change: add the public key to
`programs.gpg.publicKeys` in [gpg](../home/base/tui/gpg/default.nix), set `PASSWORD_STORE_KEY` to
the `[E]` fingerprint and `PASSWORD_STORE_SIGNING_KEY` to the primary fingerprint (pass checks
signatures only against full 40-digit fingerprints).
**Switch**, in one sitting:
1. `python3 scripts/gpg_canokey.py pass --backup <drive>/gpg-<fingerprint>.tar.age`. It decrypts
every entry with the old key from `/etc/agenix/ryan4yin-gpg-subkeys.priv.age` before changing
anything, re-encrypts with `pass init`, then checks that every entry opens with the new key alone
and kept its content.
2. Deploy the configuration change to every host that uses pass. Until a host is deployed it cannot
read the store, and `pass insert` there still encrypts to the old key.
3. On each host, with a CanoKey plugged in, point the daily keyring's stubs at it:
`gpg-connect-agent "scd serialno" "learn --force" /bye`, then check `pass show <entry>` (PIN,
then touch). Push the store after that.
Until the old key is retired, rolling back is `git -C <store> reset --hard <commit>` with the commit
`pass` printed, then reverting the configuration change.
**Retire the old key** once every CanoKey passes `pass show` on every host:
1. Delete its stubs from the daily keyring, `gpg --delete-secret-keys '<subkey-fingerprint>!'` for
each old subkey, then remove its public key from `programs.gpg.publicKeys`.
2. Stop deploying its subkeys: remove `ryan4yin-gpg-subkeys.priv.age` from
[nixos.nix](../secrets/nixos.nix) and [darwin.nix](../secrets/darwin.nix), then delete its
ciphertext and public key from nix-secrets. Older revisions in the store's git history stay
encrypted to the old key and become unreadable.
To revoke the current key: `age --decrypt <backup> | tar -xOf - revocation.asc | gpg --import`, then
publish the public key.
## 3. Add a backup key ## 3. Add a backup key
@@ -312,7 +350,7 @@ it is. On a YubiKey, manage settings with Yubico Authenticator or `ykman`; OpenP
| 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 and its own `<name>`; add its public key to `canokeySshKeys` and GitHub, and its handle file to the `IdentityFile` lists | | 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=/dev/hidrawN` 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 | The same throwaway-`GNUPGHOME` procedure. The stubs remember the card serial; after swapping cards run `gpg-connect-agent "scd serialno" "learn --force" /bye` | | OpenPGP | `gpg_canokey.py card` with only the backup plugged in. 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
@@ -321,7 +359,7 @@ it is. On a YubiKey, manage settings with Yubico Authenticator or `ykman`; OpenP
| 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 | | 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 | | 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 | | 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 | | All OpenPGP PINs and Reset Code lost | Phone app: reset the OpenPGP application | on-card subkeys; run `gpg_canokey.py card` again |
| 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 | | 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. After any reset, rerun sections 1 and 2 for the affected applications.
Generated
+3 -3
View File
@@ -493,10 +493,10 @@
"mysecrets": { "mysecrets": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1793275857, "lastModified": 1794375216,
"narHash": "sha256-Ut8ohwrRRiIsQdWEzVNtysY7g34uzYS/+sWnQK7c3O0=", "narHash": "sha256-AFc1wbFkjMDE8M5MOoTN+LtjTCeO9eKbEji+l+iPBMQ=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "7f93490f165543532f34f388f346af91db5715ac", "rev": "77c8376104042b2052ce345a7151c645e4684a51",
"shallow": true, "shallow": true,
"type": "git", "type": "git",
"url": "ssh://git@github.com/ryan4yin/nix-secrets.git" "url": "ssh://git@github.com/ryan4yin/nix-secrets.git"
+2 -2
View File
@@ -1,7 +1,7 @@
# Encryption # Encryption
We have GnuPG & password-store installed by default, mainly for password management, authentication We have GnuPG & password-store installed by default, mainly for password management and
& communication encryption. communication encryption.
We also have LUKS2 for disk encryption on Linux, and [rclone](https://rclone.org/crypt/) for We also have LUKS2 for disk encryption on Linux, and [rclone](https://rclone.org/crypt/) for
cross-platform data encryption & syncing. cross-platform data encryption & syncing.
+60 -365
View File
@@ -29,11 +29,24 @@ tool that is simple enough, functional enough, and widely adopted.
Currently I use both age & GnuPG: Currently I use both age & GnuPG:
1. Age for secrets encryption(ssh key & other secret files), it's simple and easy to use. 1. Age for secrets encryption, it's simple and easy to use.
2. GnuPG for password-store and email encryption. 2. GnuPG for password-store, because pass, browserpass and Android Password Store all speak OpenPGP.
> Moving the subkeys onto a hardware key (CanoKey/YubiKey) is covered in SSH no longer goes through GnuPG: each CanoKey holds a FIDO2 SSH key instead (no `[A]` subkey).
> [hardware security keys](../../../../docs/security-keys.md#pass-openpgp).
## My key
- An ed25519 `[C]` primary that never leaves its backup, an ed25519 `[S]` and a cv25519 `[E]` subkey
on both CanoKeys. No secret key lives in any computer's keyring: `~/.gnupg` holds only the public
key (from Home Manager) and stubs that point at the cards.
- The full key and its revocation certificate exist only in an age-encrypted backup on offline
drives. The key also has its own GPG passphrase: it stays protected in a keyring whenever the
backup is opened, and age's scrypt covers gpg's weak S2K (see section 0) against offline guessing.
- [`scripts/gpg_canokey.py`](../../../../scripts/gpg_canokey.py) generates the key, switches the
password store to it and loads the cards, all in throwaway keyrings on tmpfs. The procedure is in
[hardware security keys](../../../../docs/security-keys.md#pass-openpgp).
The sections below explain the GnuPG concepts behind that setup.
## Practical Cryptography for Developers ## Practical Cryptography for Developers
@@ -101,76 +114,18 @@ primary keypair and then zero or more additional subordinate keypairs. The prima
keypairs are bundled to facilitate key management and the bundle can often be considered simply as keypairs are bundled to facilitate key management and the bundle can often be considered simply as
one keypair, or a keyring/keychain(which contains multiple sub key-pairs). one keypair, or a keyring/keychain(which contains multiple sub key-pairs).
Let's generate a keypair interactively: The script creates the key non-interactively, in a keyring on tmpfs:
> Now in 2024, GnuPG 2.4.1 defaults to ECC algorithm (9) and Curve 25519 for ECC, which is modern
> and safe, I would recommend to use these defaults directly.
```bash ```bash
gpg --full-gen-key gpg --quick-generate-key '<name> (pass) <address>' ed25519 cert never # [C] primary
gpg --quick-add-key <primary-fingerprint> ed25519 sign never # [S]
gpg --quick-add-key <primary-fingerprint> cv25519 encr never # [E]
gpg --passwd <primary-fingerprint> # its GPG passphrase
``` ```
This command will ask you for some algorithm related settings(ECC & Curve 25519), your personal The primary key only certifies: it signs its own subkeys and revocations, so it can stay offline
info, and a strong passphrase to protect your PGP key. e.g. while the subkeys do the daily work. The key does not expire; the revocation certificate in every
backup is the way to retire it.
```bash
› gpg --full-gen-key
gpg (GnuPG) 2.4.1; Copyright (C) 2023 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
gpg: directory '/Users/ryan/.gnupg' created
Please select what kind of key you want:
(1) RSA and RSA
(2) DSA and Elgamal
(3) DSA (sign only)
(4) RSA (sign only)
(9) ECC (sign and encrypt) *default*
(10) ECC (sign only)
(14) Existing key from card
Your selection? 9
Please select which elliptic curve you want:
(1) Curve 25519 *default*
(4) NIST P-384
(6) Brainpool P-256
Your selection? 1
Please specify how long the key should be valid.
0 = key does not expire
<n> = key expires in n days
<n>w = key expires in n weeks
<n>m = key expires in n months
<n>y = key expires in n years
Key is valid for? (0) 10y
Key expires at 一 1/ 4 13:50:31 2044 CST
Is this correct? (y/N) y
GnuPG needs to construct a user ID to identify your key.
Real name:
Email address:
Comment:
You selected this USER-ID:
"Ryan Yin (For pass For Work ssh only) <ryan4yin@linux.com>"
Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? O
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
gpg: /Users/ryan/.gnupg/trustdb.gpg: trustdb created
gpg: directory '/Users/ryan/.gnupg/openpgp-revocs.d' created
gpg: revocation certificate stored as '/Users/ryan/.gnupg/openpgp-revocs.d/C8D84EBC5F82494F432ACEF042E49B284C30A0DA.rev'
public and secret key created and signed.
pub ed25519 2024-01-09 [SC] [expires: 2034-01-04]
C8D84EBC5F82494F432ACEF042E49B284C30A0DA
uid Ryan Yin (For pass For Work ssh only) <ryan4yin@linux.com>
sub cv25519 2024-01-09 [E] [expires: 2034-01-04]
```
### 2. Configuration Files ### 2. Configuration Files
@@ -204,9 +159,10 @@ The functions of most files are quite clear at a glance, but the `trustdb.gpg` i
difficult to understand. Here are the details: <https://www.gnupg.org/gph/en/manual/x334.html> difficult to understand. Here are the details: <https://www.gnupg.org/gph/en/manual/x334.html>
Home Manager will manage all the things in `~/.gnupg/` EXCEPT `~/.gnupg/openpgp-revocs.d/` and Home Manager will manage all the things in `~/.gnupg/` EXCEPT `~/.gnupg/openpgp-revocs.d/` and
`~/.gnupg/private-keys-v1.d/`, which is expected. `~/.gnupg/private-keys-v1.d/`, which is expected. With the subkeys on a CanoKey,
`private-keys-v1.d/` holds only stubs that name the card.
### 3. Sub Key Generation & Best Practice ### 3. Usage Flags & Best Practice
In PGP, every keys has a **usage flag** to indicate its usage: In PGP, every keys has a **usage flag** to indicate its usage:
@@ -217,316 +173,55 @@ In PGP, every keys has a **usage flag** to indicate its usage:
- `A` means this key can be used to **Authenticate** data with various non-GnuPG programs. The key - `A` means this key can be used to **Authenticate** data with various non-GnuPG programs. The key
can be used as e.g. an **SSH key**. can be used as e.g. an **SSH key**.
The **best practice** is: The **best practice**, and what this setup does:
1. Generate a primary key with strong cryptography arguments(such as ECC + Curve 25519). 1. One primary key with only the `C` flag, and one subkey per job: `S` and `E` here. Without a GPG
2. Then generate 3 sub keys with `E`, `S` and `A` usage flag respectively. SSH key there is no `A` subkey.
3. **The Primary Key is extremely important**, Backup the primary key to somewhere absolutely 2. **The primary key is extremely important**: keep it only in offline backups, never in a daily
safe(such as two encryptd USB drivers, keep them in different places), and then **delete it from keyring. Here it lives in age-encrypted backups on two drives kept in different places.
your computer immediately**. 3. Keep the subkeys on hardware keys, one copy per key, so losing one CanoKey loses nothing. A
4. The sub key is also important, but you can generate a new one and replace it easily. You can subkey can be replaced with a new one signed by the primary.
backup it to somewhere else, and import it to another machine to use your keypair. 4. Back up the revocation certificate together with the primary key, and never delete it: it is the
5. Backup your Primary key's revocation certificate to somewhere safe, it's the last way to rescure last way out if the primary key is compromised.
your safety if your primary key is compromised! 5. A leaked revocation certificate can only revoke the key; your data stays safe, but generate a new
6. It's a big problem if your revocation certificate is compromised, but not the biggest one. key anyway.
because it's only used to revoke your keypair, your data is still safe. But you should generate a 6. A compromised primary key without a revocation certificate is the worst case, and even with one,
new keypair and revoke the old one immediately. OpenPGP has no good way to distribute it (see section 7).
7. It will be a big problem if your primary key is compromised, and you don't have a revocation
certificate to revoke it. But since OpenPGP do not have a good way to distribute revocation
certificate, even you have a revocation certificate, it's still hard to distribute it to
others...
To keep your keypair safe, you should backup your keypair according to the following steps.
Now let's add the sub keys to the keypair we generated above:
> `E` sub key is already generated by default, so we only need to generate `S` and `A` sub keys.
> GnuPG will ask you to input your passphrase to unlock your primary key.
```bash
› gpg --expert --edit-key ryan4yin@linux.com
gpg (GnuPG) 2.4.1; Copyright (C) 2023 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Secret key is available.
sec ed25519/42E49B284C30A0DA
created: 2024-01-09 expires: 2034-01-04 usage: SC
trust: ultimate validity: ultimate
ssb cv25519/6CB4A81FFB3C99B6
created: 2024-01-09 expires: 2034-01-04 usage: E
[ultimate] (1). Ryan Yin (For pass For Work ssh only) <ryan4yin@linux.com>
gpg> addkey
Please select what kind of key you want:
(3) DSA (sign only)
(4) RSA (sign only)
(5) Elgamal (encrypt only)
(6) RSA (encrypt only)
(7) DSA (set your own capabilities)
(8) RSA (set your own capabilities)
(10) ECC (sign only)
(11) ECC (set your own capabilities)
(12) ECC (encrypt only)
(13) Existing key
(14) Existing key from card
Your selection? 10
Please select which elliptic curve you want:
(1) Curve 25519 *default*
(2) Curve 448
(3) NIST P-256
(4) NIST P-384
(5) NIST P-521
(6) Brainpool P-256
(7) Brainpool P-384
(8) Brainpool P-512
(9) secp256k1
Your selection? 1
Please specify how long the key should be valid.
0 = key does not expire
<n> = key expires in n days
<n>w = key expires in n weeks
<n>m = key expires in n months
<n>y = key expires in n years
Key is valid for? (0) 10y
Key expires at Mon Jan 4 17:47:24 2044 CST
Is this correct? (y/N) y
Really create? (y/N) y
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
sec ed25519/42E49B284C30A0DA
created: 2024-01-09 expires: 2034-01-04 usage: SC
trust: ultimate validity: ultimate
ssb cv25519/6CB4A81FFB3C99B6
created: 2024-01-09 expires: 2034-01-04 usage: E
ssb ed25519/A42813E03A10F504
created: 2024-01-09 expires: 2034-01-04 usage: S
[ultimate] (1). Ryan Yin (For pass For Work ssh only) <ryan4yin@linux.com>
gpg> addkey
Please select what kind of key you want:
(3) DSA (sign only)
(4) RSA (sign only)
(5) Elgamal (encrypt only)
(6) RSA (encrypt only)
(7) DSA (set your own capabilities)
(8) RSA (set your own capabilities)
(10) ECC (sign only)
(11) ECC (set your own capabilities)
(12) ECC (encrypt only)
(13) Existing key
(14) Existing key from card
Your selection? 11
Possible actions for this ECC key: Sign Authenticate
Current allowed actions: Sign
(S) Toggle the sign capability
(A) Toggle the authenticate capability
(Q) Finished
Your selection? S
Possible actions for this ECC key: Sign Authenticate
Current allowed actions:
(S) Toggle the sign capability
(A) Toggle the authenticate capability
(Q) Finished
Your selection? A
Possible actions for this ECC key: Sign Authenticate
Current allowed actions: Authenticate
(S) Toggle the sign capability
(A) Toggle the authenticate capability
(Q) Finished
Your selection? Q
Please select which elliptic curve you want:
(1) Curve 25519 *default*
(2) Curve 448
(3) NIST P-256
(4) NIST P-384
(5) NIST P-521
(6) Brainpool P-256
(7) Brainpool P-384
(8) Brainpool P-512
(9) secp256k1
Your selection? 1
Please specify how long the key should be valid.
0 = key does not expire
<n> = key expires in n days
<n>w = key expires in n weeks
<n>m = key expires in n months
<n>y = key expires in n years
Key is valid for? (0) 10y
Key expires at Mon Jan 4 17:48:27 2044 CST
Is this correct? (y/N) y
Really create? (y/N) y
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
sec ed25519/42E49B284C30A0DA
created: 2024-01-09 expires: 2034-01-04 usage: SC
trust: ultimate validity: ultimate
ssb cv25519/6CB4A81FFB3C99B6
created: 2024-01-09 expires: 2034-01-04 usage: E
ssb ed25519/A42813E03A10F504
created: 2024-01-09 expires: 2034-01-04 usage: S
ssb ed25519/5469C4FACC81B60F
created: 2024-01-09 expires: 2034-01-04 usage: A
[ultimate] (1). Ryan Yin (For pass For Work ssh only) <ryan4yin@linux.com>
gpg> save
```
Check the secret keys and public keys we generated:
```bash
› gpg --list-secret-keys --with-subkey-fingerprint
[keyboxd]
---------
sec ed25519 2024-01-09 [SC] [expires: 2034-01-04]
C8D84EBC5F82494F432ACEF042E49B284C30A0DA
uid [ultimate] Ryan Yin (For pass For Work ssh only) <ryan4yin@linux.com>
ssb cv25519 2024-01-09 [E] [expires: 2034-01-04]
1146D48B93C2177C92D186026CB4A81FFB3C99B6
ssb ed25519 2024-01-09 [S] [expires: 2034-01-04]
DF64002A822948B17783BBB1A42813E03A10F504
ssb ed25519 2024-01-09 [A] [expires: 2034-01-04]
65E2C6C1C3559362ABB7047C5469C4FACC81B60F
› gpg --list-public-keys
...
```
### 4. Backup & Restore ### 4. Backup & Restore
Export Public Keys(Both Primary Key & Sub Keys): `generate` writes `gpg-<fingerprint>.tar.age` to every backup drive: a tar holding `secret.asc` (the
primary key and both subkeys, still protected by the GPG passphrase) and `revocation.asc`, encrypted
with an age passphrase. It then restores the copy it wrote into a fresh keyring, checks that the key
has a passphrase, and imports the revocation certificate to prove that it revokes the key.
The age layer is there because GnuPG's own export protection is weak. Exporting a secret key and
inspecting it shows:
```bash ```bash
gpg --armor --export ryan4yin@linux.com > ryan4yin-gpg-keys.pub › gpg --armor --export-secret-keys <primary-fingerprint>! | nix run nixpkgs#pgpdump
# check what we have exported, we should see 4 public keys
nix run nixpkgs#pgpdump ryan4yin-gpg-keys.pub
```
Export Primary Key(The exported key is still encrypted by your passphrase):
> the `!` at the end of the key ID is to force GnuPG to export only the specified key, not the
> subkeys.
> GnuPG will ask you to input your passphrase to unlock your keypair, because GnuPG need to convert
> the secret key's format from its internal protection format to the one specified by the OpenPGP
> protocol.
```bash
# replace the key ID with your own sec key's ID
gpg --armor --export-secret-keys C8D84EBC5F82494F432ACEF042E49B284C30A0DA! > ryan4yin-primary-key.priv
# Check the exported primary key's detail info,
nix run nixpkgs#pgpdump ryan4yin-primary-key.priv
... ...
Old: Secret Key Packet(tag 5)(134 bytes)
Ver 4 - new
Public key creation time - Sat Jan 27 14:13:13 CST 2024
Pub alg - EdDSA Edwards-curve Digital Signature Algorithm(pub 22)
Elliptic Curve - Ed25519 (0x2B 06 01 04 01 DA 47 0F 01)
EdDSA Q(263 bits) - ...
Sym alg - AES with 128-bit key(sym 7) Sym alg - AES with 128-bit key(sym 7)
Iterated and salted string-to-key(s2k 3): Iterated and salted string-to-key(s2k 3):
Hash alg - SHA1(hash 2) Hash alg - SHA1(hash 2)
Salt - 8c 78 58 c0 87 83 8c 2c
Count - 65011712(coded count 255) Count - 65011712(coded count 255)
IV - xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx
Encrypted EdDSA x
Encrypted SHA1 hash
... ...
``` ```
As [Predictable, Passphrase-Derived PGP Keys][Predictable, Passphrase-Derived PGP Keys] says, we'll As [Predictable, Passphrase-Derived PGP Keys][Predictable, Passphrase-Derived PGP Keys] says, gpg
find that gpg ignored the `--s2k-count` option we specified when generating the keypair, and the ignores the `--s2k-*` options from `~/.gnupg/gpg.conf` here: the exported key is protected by `SHA1`
`--s2k` related options we specified in `~/.gnupg/gpg.conf`, the exported primary key is protectd by and `AES128`. age derives its key with `scrypt`, so the backup takes age's strength while the GPG
`SHA1` and `AES128`, which is not secure enough! passphrase still protects the key once age has decrypted it.
So to increase the security of the exported primary key, we need to encrypt it again with a stronger Restore the secret key into a keyring on tmpfs, never into `~/.gnupg`:
algorithm, I choose `age` here(which use `scrypt` to encrypt the file key with a provided
passphrase):
```bash ```bash
# for simplicity, use the same passphrase as your gpg keypair here age --decrypt <backup> | tar -xOf - secret.asc | gpg --import
age --passphrase -o ryan4yin-primary-key.priv.age ryan4yin-primary-key.priv
rm ryan4yin-primary-key.priv
``` ```
Export Sub Keys one by one(The exported keys is still encrypted by your passphrase): `gpg_canokey.py card` does this to load a new CanoKey. Public keys come from Home Manager's
`programs.gpg.publicKeys`; do not import them by hand (`mutableKeys = false` makes the keyring
```bash read-only anyway).
gpg --armor --export-secret-subkeys > ryan4yin-gpg-subkeys.priv
# Check the exported primary key's detail info,
nix run nixpkgs#pgpdump ryan4yin-gpg-subkeys.priv
# encrypt it again with age(scrypt)
age --passphrase -o ryan4yin-gpg-subkeys.priv.age ryan4yin-gpg-subkeys.priv
rm ryan4yin-gpg-subkeys.priv
```
Your can import the exported Private Key via `gpg --import <keyfile>` to restore it, but you need to
decrypt it via age first.
As for Public Keys, please import your publicKeys via Home Manager's `programs.gpg.publicKeys`
option, DO NOT import it manually(via `gpg --import <keyfile>`).
To ensure security, delete the master key and revoke the certificate immediately after the backup is
completed:
```bash
# delete the primary key and all its sub keys
gpg --delete-secret-keys ryan4yin@linux.com
# delete the revocation certificate
rm ~/.gnupg/openpgp-revocs.d/C8D84EBC5F82494F432ACEF042E49B284C30A0DA.rev
# import our subkeys back
age --decrypt -o ryan4yin-gpg-subkeys.priv ryan4yin-gpg-subkeys.priv.age
gpg --import ryan4yin-gpg-subkeys.priv
```
Now check the secret keys and public keys again:
> A `#` at the end of the key ID means that the key is not available, because we have deleted it.
```bash
› gpg --list-secret-keys --keyid-format=long
/home/ryan/.gnupg/pubring.kbx
-----------------------------
sec# ed25519/D1C5FFA3118A41FC 2024-01-09 [SC] [expires: 2034-01-04]
Key fingerprint = E267 943C 33AD C5AF 3D76 4D96 D1C5 FFA3 118A 41FC
uid [ unknown] Ryan Yin (Personal) <ryan4yin@linux.com>
ssb cv25519/62526A4A0CF43E33 2024-01-09 [E] [expires: 2034-01-04]
ssb ed25519/433A66D63805BD1A 2024-01-09 [S] [expires: 2034-01-04]
ssb ed25519/441E3D8FBD313BF2 2024-01-09 [A] [expires: 2034-01-04]
› gpg --list-public-keys --keyid-format=long
/home/ryan/.gnupg/pubring.kbx
-----------------------------
pub ed25519/D1C5FFA3118A41FC 2024-01-09 [SC] [expires: 2034-01-04]
Key fingerprint = E267 943C 33AD C5AF 3D76 4D96 D1C5 FFA3 118A 41FC
uid [ unknown] Ryan Yin (Personal) <ryan4yin@linux.com>
sub cv25519/62526A4A0CF43E33 2024-01-09 [E] [expires: 2034-01-04]
sub ed25519/433A66D63805BD1A 2024-01-09 [S] [expires: 2034-01-04]
sub ed25519/441E3D8FBD313BF2 2024-01-09 [A] [expires: 2034-01-04]
```
### 5. Signing & Verification ### 5. Signing & Verification
@@ -597,8 +292,8 @@ But in OpenPGP:
person(such as Linus), which is very insecure. person(such as Linus), which is very insecure.
- **key server** is not recommend to use. - **key server** is not recommend to use.
- GnuPG will generate revocation certificate when generating - GnuPG will generate revocation certificate when generating
keypair(`~/.gnupg/private-keys-v1.d/<Key-ID.rev>`), anyone can import this certificate to revoke keypair(`~/.gnupg/openpgp-revocs.d/<fingerprint>.rev`), anyone can import this certificate to
the keypair. But OpenPGP standard **DO NOT provide a way to distribute this certificate to revoke the keypair. But OpenPGP standard **DO NOT provide a way to distribute this certificate to
others**. others**.
- Not to mention some key status query protocol like OCSP in Web PKI. - Not to mention some key status query protocol like OCSP in Web PKI.
- Users has to publish their revocation certificate to their blog, github profile or somewhere - Users has to publish their revocation certificate to their blog, github profile or somewhere
+4
View File
@@ -24,6 +24,10 @@
source = "${mysecrets}/public/ryan4yin-gpg-keys-2014-01-27.pub"; source = "${mysecrets}/public/ryan4yin-gpg-keys-2014-01-27.pub";
trust = 5; trust = 5;
} # ultimate trust, my own keys. } # ultimate trust, my own keys.
{
source = "${mysecrets}/public/ryan4yin-gpg-keys-2026-11-11.pub";
trust = 5;
}
]; ];
# This configuration is based on the tutorial below, it allows for a robust setup # This configuration is based on the tutorial below, it allows for a robust setup
+7 -39
View File
@@ -7,44 +7,12 @@
- Android: <https://github.com/android-password-store/Android-Password-Store> - Android: <https://github.com/android-password-store/Android-Password-Store>
- Browsers(Chrome/Firefox): <https://github.com/browserpass/browserpass-extension> - Browsers(Chrome/Firefox): <https://github.com/browserpass/browserpass-extension>
## How to change the gpg key of the pass password store? ## Changing the GPG key
To ensure security, we should change the GPG key every two or three years. Here is how to do this. Change the key every two or three years. `python3 scripts/gpg_canokey.py pass` switches the store:
it decrypts every entry with the old key before changing anything, runs `pass init`, then checks
that each entry opens with the new key alone and kept its content. The whole procedure, from
generating the key to retiring the old one, is in
[hardware security keys](../../../../docs/security-keys.md#pass-openpgp).
1. Create a new GPG key pair and backup it to a safe place. Once the old key is deleted, older revisions in the store's git history can no longer be decrypted.
2. Ensure you can access both the old and new GPG keys.
3. Update `./default.nix` to use the new GPG sub keys.
4. Check which Key `pass` currently uses:
```bash
cd ~/.local/share/password-store/
# check which key is used by pass
cat .gpg-id
# check which key is really used to encrypt the password
gpg --list-packets path/to/any/password.gpg
```
5. Change the key used by `pass`:
```bash
# change the key used by pass, see `man pass` for more details
# you will be asked to enter the password of both the new and old keys
# then pass will re-encrypt all the passwords with the new key
pass init <new-key-id>
```
If `pass init` fails, fix the error and run it again before continuing.
6. Check if the key is changed:
```bash
# check which key is used by pass
cat .gpg-id
# check which key is really used to encrypt the password
gpg --list-packets path/to/any/password.gpg
```
If `.gpg-id` still lists the old key, `pass init` did not apply the change. Re-run it from the
password store directory and confirm `.gpg-id` contains the new key.
7. Delete the old GPG key pair, but only after both checks in step 6 show the new key. Files still
encrypted to the old key cannot be decrypted after you delete it.
```bash
# delete the old key pair
gpg --delete-secret-keys <old-key-id>
gpg --delete-keys <old-key-id>
```
+2 -2
View File
@@ -25,14 +25,14 @@ in
# Hexadecimal key signature is recommended. # Hexadecimal key signature is recommended.
# Multiple keys may be specified separated by spaces. # Multiple keys may be specified separated by spaces.
PASSWORD_STORE_KEY = lib.strings.concatStringsSep " " [ PASSWORD_STORE_KEY = lib.strings.concatStringsSep " " [
"EF824EB73CFD6CC7" # E - Ryan Yin (For pass & ssh only) <ryan4yin@linux.com> "51976F7777BE4C99DBE39205311ACB2ABE0597DC" # E - Ryan Yin (pass) <ryan4yin@linux.com>
]; ];
# all .gpg-id files and non-system extension files must be signed using a detached signature using the GPG key specified by # all .gpg-id files and non-system extension files must be signed using a detached signature using the GPG key specified by
# the full 40 character upper-case fingerprint in this variable. # the full 40 character upper-case fingerprint in this variable.
# If multiple fingerprints are specified, each separated by a whitespace character, then signatures must match at least one. # If multiple fingerprints are specified, each separated by a whitespace character, then signatures must match at least one.
# The init command will keep signatures of .gpg-id files up to date. # The init command will keep signatures of .gpg-id files up to date.
PASSWORD_STORE_SIGNING_KEY = lib.strings.concatStringsSep " " [ PASSWORD_STORE_SIGNING_KEY = lib.strings.concatStringsSep " " [
"C2A313F98166C942" # S - Ryan Yin (For pass & ssh only) <ryan4yin@linux.com> "BB8DFBAC2E9BE8F15649E3AA1F2440D500288E48" # C - Ryan Yin (pass) <ryan4yin@linux.com>
]; ];
PASSWORD_STORE_CLIP_TIME = "60"; PASSWORD_STORE_CLIP_TIME = "60";
PASSWORD_STORE_GENERATED_LENGTH = "12"; PASSWORD_STORE_GENERATED_LENGTH = "12";
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env python3
"""Switch pass to a new hardware-backed OpenPGP key: create it, switch the store, load the CanoKeys.
Steps, in order (docs/security-keys.md, "pass (OpenPGP)"):
generate create the key and write its age-encrypted backup to every --dest
pass re-encrypt the password store from the old key to the new one
card move the [S] and [E] subkeys onto the plugged-in CanoKey
Each step builds throwaway keyrings on tmpfs ($XDG_RUNTIME_DIR) and deletes them on exit. `pass` and
`card` restore the key from the backup, so a backup that does not restore stops them before the
store or a card changes.
"""
from __future__ import annotations
import argparse
import contextlib
import hashlib
import io
import os
import subprocess
import sys
import tarfile
import tempfile
import time
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from pathlib import Path
GPG = "gpg"
AGE = "age"
AGE_ATTEMPTS = 3
SECRET_MEMBER = "secret.asc"
REVOCATION_MEMBER = "revocation.asc"
SIG_SLOT = 1
DEC_SLOT = 2
class Abort(Exception):
"""A check failed; the message says what is wrong."""
def say(message: str) -> None:
print(f"==> {message}", flush=True)
class Prompts:
"""Lists a command's prompts up front, then announces each one and what follows it."""
def __init__(self, *steps: str) -> None:
self.steps = steps
self.done = 0
say(f"this asks for {len(steps)} things, in this order:")
for number, step in enumerate(steps, 1):
print(f" {number}. {step}", flush=True)
def next(self) -> None:
self.done += 1
then = self.steps[self.done] if self.done < len(self.steps) else "nothing more"
say(f"[{self.done}/{len(self.steps)}] now: {self.steps[self.done - 1]}")
print(f" after that: {then}", flush=True)
def run(
command: list[str],
*,
env: dict[str, str] | None = None,
data: bytes | None = None,
interactive: bool = False,
) -> bytes:
"""Return the command's stdout; `interactive` leaves stderr on the terminal for prompts."""
result = subprocess.run(
command,
input=data,
stdout=subprocess.PIPE,
stderr=None if interactive else subprocess.PIPE,
env=env,
check=False,
)
if result.returncode != 0:
lines = (result.stderr or b"").decode(errors="replace").strip().splitlines()
reason = lines[-1] if lines else f"exit code {result.returncode}"
raise Abort(f"`{' '.join(command[:2])}` failed: {reason}")
return result.stdout
def age_prompting(*args: str, data: bytes) -> bytes:
"""Run age, asking again when a passphrase is mistyped, so a typo does not abort the command."""
for attempt in range(1, AGE_ATTEMPTS + 1):
try:
return run([AGE, *args], data=data, interactive=True)
except Abort:
if attempt == AGE_ATTEMPTS:
raise
say(f"age failed (see above); try again, attempt {attempt + 1} of {AGE_ATTEMPTS}")
raise AssertionError("unreachable")
def age_encrypt(data: bytes) -> bytes:
"""Encrypt with a passphrase that age asks for on the terminal."""
return age_prompting("--passphrase", data=data)
def age_decrypt(data: bytes) -> bytes:
return age_prompting("--decrypt", data=data)
# --------------------------------------------------------------------------- #
# keyrings
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Keyring:
home: Path
@property
def env(self) -> dict[str, str]:
return os.environ | {"GNUPGHOME": str(self.home), "LC_ALL": "C"}
def gpg(self, *args: str, data: bytes | None = None) -> bytes:
return run([GPG, *args], env=self.env, data=data)
def text(self, *args: str) -> str:
return self.gpg(*args).decode()
@contextlib.contextmanager
def scratch_keyring() -> Iterator[Keyring]:
"""An empty GNUPGHOME on tmpfs, deleted together with its agent on exit."""
runtime = os.environ.get("XDG_RUNTIME_DIR", "")
if not runtime:
raise Abort("XDG_RUNTIME_DIR is unset; run this from a desktop session")
fstype = run(["stat", "--file-system", "--format=%T", runtime]).decode().strip()
if fstype != "tmpfs":
raise Abort(f"{runtime} is {fstype}, not tmpfs")
with tempfile.TemporaryDirectory(prefix="gpg-canokey-", dir=runtime) as home:
# Reach the CanoKey through pcscd, as the daily keyring does.
Path(home, "scdaemon.conf").write_text("disable-ccid\n")
ring = Keyring(Path(home))
try:
yield ring
finally:
subprocess.run(
["gpgconf", "--kill", "all"], env=ring.env, capture_output=True, check=False
)
@dataclass(frozen=True)
class Key:
primary: str
sign: str
encrypt: str
@dataclass(frozen=True)
class SecretKey:
usage: str
fingerprint: str
token: str # the card's AID when the secret is on a card, "+" when it is on disk
def secret_keys(ring: Keyring) -> list[list[SecretKey]]:
"""Each secret key: the primary, then its subkeys in edit-key order."""
keys: list[list[SecretKey]] = []
usage = token = ""
for line in ring.text("--list-secret-keys", "--with-colons").splitlines():
fields = line.split(":")
if fields[0] in ("sec", "ssb"):
usage = "primary" if fields[0] == "sec" else fields[11]
token = fields[14]
if fields[0] == "sec":
keys.append([])
elif fields[0] == "fpr" and keys:
keys[-1].append(SecretKey(usage, fields[9], token))
return keys
def read_key(ring: Keyring) -> Key:
"""The keyring's only secret key; its subkeys are found by usage."""
keys = secret_keys(ring)
if len(keys) != 1:
raise Abort(f"expected one secret key, found {len(keys)}")
usages = {part.usage: part.fingerprint for part in keys[0]}
if not {"primary", "s", "e"} <= usages.keys():
raise Abort("the key lacks an [S] or an [E] subkey")
return Key(primary=usages["primary"], sign=usages["s"], encrypt=usages["e"])
def import_secret(ring: Keyring, secret: bytes) -> Key:
"""Import the key and trust it ultimately, as the daily keyring trusts its own key."""
ring.gpg("--batch", "--import", data=secret)
key = read_key(ring)
ring.gpg("--import-ownertrust", data=f"{key.primary}:6:\n".encode())
return key
# --------------------------------------------------------------------------- #
# backup archive
# --------------------------------------------------------------------------- #
def pack(secret: bytes, revocation: bytes) -> bytes:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, data in ((SECRET_MEMBER, secret), (REVOCATION_MEMBER, revocation)):
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = 0o600
info.mtime = int(time.time())
archive.addfile(info, io.BytesIO(data))
return buffer.getvalue()
def unpack(blob: bytes) -> tuple[bytes, bytes]:
"""(secret key, revocation certificate) from a decrypted backup."""
try:
with tarfile.open(fileobj=io.BytesIO(blob)) as archive:
secret, revocation = (
archive.extractfile(name).read() # type: ignore[union-attr]
for name in (SECRET_MEMBER, REVOCATION_MEMBER)
)
except (tarfile.TarError, KeyError, AttributeError):
raise Abort("the backup is not an archive written by `generate`") from None
return secret, revocation
def unprotected(ring: Keyring) -> list[str]:
"""Keygrips of secret keys stored without a passphrase."""
listing = ring.text("--list-secret-keys", "--with-colons", "--with-keygrip")
grips = [line.split(":")[9] for line in listing.splitlines() if line.startswith("grp:")]
info = run(["gpg-connect-agent", *(f"KEYINFO {grip}" for grip in grips), "/bye"], env=ring.env)
# S KEYINFO <keygrip> <type> <serial> <idstr> <cached> <protection: P, C or -> ...
return [
fields[2]
for fields in (line.split() for line in info.decode().splitlines())
if fields[:2] == ["S", "KEYINFO"] and fields[7] != "P"
]
def check_backup(blob: bytes, key: Key) -> None:
"""The archive restores `key` with a passphrase, and its revocation certificate revokes it."""
secret, revocation = unpack(blob)
with scratch_keyring() as ring:
if import_secret(ring, secret) != key:
raise Abort("the backup holds a different key")
if unprotected(ring):
raise Abort("the backup's secret key has no GPG passphrase")
ring.gpg("--batch", "--import", data=revocation)
listing = ring.text("--list-keys", "--with-colons", key.primary).splitlines()
validity = next(line.split(":")[1] for line in listing if line.startswith("pub:"))
if validity != "r":
raise Abort("the backup's revocation certificate does not revoke the key")
def restore(ring: Keyring, backup: Path) -> Key:
say(f"restoring the key from {backup}")
secret, _ = unpack(age_decrypt(backup.read_bytes()))
return import_secret(ring, secret)
# --------------------------------------------------------------------------- #
# generate
# --------------------------------------------------------------------------- #
def cmd_generate(args: argparse.Namespace) -> None:
dests = [Path(dest).expanduser() for dest in args.dest]
for dest in dests:
if not dest.is_dir():
raise Abort(f"{dest} is not a directory; is the drive mounted?")
public_key = Path(args.public_key).expanduser()
if public_key.exists():
raise Abort(f"{public_key} already exists")
prompts = Prompts(
"a new GPG passphrase, in 3 pinentry dialogs (the [C], [S] and [E] keys); "
"type the same one twice in each",
"the GPG passphrase once more, in pinentry, to export the key",
"the backup's age passphrase (the L0 passphrase), twice in this terminal",
"the age passphrase once more, to check that the backup restores",
)
batch = ("--batch", "--pinentry-mode", "loopback", "--passphrase", "")
with scratch_keyring() as ring:
say("generating an ed25519 [C] key with ed25519 [S] and cv25519 [E] subkeys")
ring.gpg(*batch, "--quick-generate-key", args.uid, "ed25519", "cert", "never")
primary = secret_keys(ring)[0][0].fingerprint
ring.gpg(*batch, "--quick-add-key", primary, "ed25519", "sign", "never")
ring.gpg(*batch, "--quick-add-key", primary, "cv25519", "encr", "never")
key = read_key(ring)
# It keeps the key protected on tmpfs whenever the age backup is opened; age's scrypt
# outside covers gpg's weaker S2K against offline guessing.
prompts.next()
ring.gpg("--batch", "--passwd", key.primary)
prompts.next()
secret = ring.gpg("--batch", "--armor", "--export-secret-keys", key.primary)
public = ring.gpg("--armor", "--export", key.primary)
stored = (ring.home / "openpgp-revocs.d" / f"{key.primary}.rev").read_bytes()
# gpg puts a colon before the armor header so the certificate cannot be imported by accident.
revocation = stored.replace(b":-----BEGIN", b"-----BEGIN", 1)
prompts.next()
sealed = age_encrypt(pack(secret, revocation))
copies = [dest / f"gpg-{key.primary}.tar.age" for dest in dests]
for copy in copies:
copy.write_bytes(sealed)
os.sync()
if any(copy.read_bytes() != sealed for copy in copies):
raise Abort("a backup copy reads back different bytes")
prompts.next()
check_backup(age_decrypt(copies[0].read_bytes()), key)
public_key.write_bytes(public)
say(f"primary {key.primary}")
say(f"sign {key.sign}")
say(f"encrypt {key.encrypt}")
for copy in copies:
say(f"backup {copy}")
say(f"public {public_key}")
# --------------------------------------------------------------------------- #
# pass
# --------------------------------------------------------------------------- #
def store_entries(store: Path) -> list[Path]:
return sorted(
path for path in store.rglob("*.gpg") if ".git" not in path.relative_to(store).parts
)
def git(store: Path, *args: str) -> str:
return run(["git", "-C", str(store), *args]).decode().strip()
def decrypt_digest(ring: Keyring, entry: Path) -> str:
return hashlib.sha256(ring.gpg("--batch", "--decrypt", str(entry))).hexdigest()
def read_old_backup(path: Path) -> bytes:
"""The retired key's subkeys, from their passphrase-encrypted agenix copy."""
try:
sealed = path.read_bytes()
except PermissionError:
sealed = run(["sudo", "cat", str(path)], interactive=True)
return age_decrypt(sealed)
def check_store(store: Path, ring: Keyring, key: Key, digests: dict[Path, str]) -> list[str]:
"""Problems with the re-encrypted store, judged with a keyring that holds only the new key."""
problems = []
entries = store_entries(store)
if entries != sorted(digests):
problems.append("the set of entries changed during re-encryption")
for entry in entries:
try:
if decrypt_digest(ring, entry) != digests.get(entry):
problems.append(f"{entry}: the content changed")
except Abort as error:
problems.append(f"{entry}: {error}")
if (store / ".gpg-id").read_text().split() != [key.encrypt]:
problems.append(f".gpg-id does not name {key.encrypt}")
# pass accepts the signature when VALIDSIG names a configured key as signer or as primary.
try:
status = ring.text(
"--batch",
"--status-fd=1",
"--verify",
str(store / ".gpg-id.sig"),
str(store / ".gpg-id"),
)
signed = any(
line.startswith("[GNUPG:] VALIDSIG ") and line.split()[-1] == key.primary
for line in status.splitlines()
)
except Abort:
signed = False
if not signed:
problems.append(".gpg-id.sig is not a signature by the new key")
if git(store, "status", "--porcelain"):
problems.append("pass left uncommitted changes")
return problems
def cmd_pass(args: argparse.Namespace) -> None:
store = Path(args.store).expanduser()
if git(store, "status", "--porcelain"):
raise Abort(f"{store} has uncommitted changes; commit or discard them first")
head = git(store, "rev-parse", "HEAD")
entries = store_entries(store)
prompts = Prompts(
"the new key backup's age passphrase",
"the new key's GPG passphrase, in pinentry, to copy it into a work keyring",
"sudo if the old backup is root-only, then the old backup's age passphrase",
"the old key's GPG passphrase, in pinentry, once to decrypt every entry",
"the new key's GPG passphrase, in pinentry, for pass init to sign .gpg-id",
"the new key's GPG passphrase, in pinentry, once to check every entry",
)
with scratch_keyring() as new, scratch_keyring() as work:
prompts.next()
key = restore(new, Path(args.backup).expanduser())
prompts.next()
import_secret(work, new.gpg("--batch", "--armor", "--export-secret-keys", key.primary))
prompts.next()
work.gpg("--batch", "--import", data=read_old_backup(Path(args.old_backup)))
prompts.next()
say(f"decrypting all {len(entries)} entries before changing any")
digests, unreadable = {}, []
for entry in entries:
try:
digests[entry] = decrypt_digest(work, entry)
except Abort as error:
unreadable.append(f"{entry}: {error}")
if unreadable:
raise Abort("nothing changed; these entries do not open:\n" + "\n".join(unreadable))
prompts.next()
say(f"re-encrypting with `pass init {key.encrypt}`")
env = work.env | {
"PASSWORD_STORE_DIR": str(store),
"PASSWORD_STORE_KEY": key.encrypt,
"PASSWORD_STORE_SIGNING_KEY": key.primary,
}
run(["pass", "init", key.encrypt], env=env)
prompts.next()
say("checking every entry with a keyring that holds only the new key")
problems = check_store(store, new, key, digests)
if problems:
shown = "\n".join(problems[:10])
raise Abort(
f"{len(problems)} problems:\n{shown}\n"
f"rerun to retry, or roll back with: git -C {store} reset --hard {head}"
)
say(f"all {len(entries)} entries open with the new key alone and match their old content")
say(f"pass committed the switch; review with: git -C {store} log --stat {head}..")
say(f"until the old key is retired, roll back with: git -C {store} reset --hard {head}")
# --------------------------------------------------------------------------- #
# card
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Card:
serial: str
slots: dict[int, str]
touch: dict[int, bool]
def read_card(ring: Keyring) -> Card:
serial, slots, touch = "", {}, {}
for line in ring.text("--card-status", "--with-colons").splitlines():
fields = line.split(":")
if fields[0] == "serial":
serial = fields[1]
elif fields[0] == "fpr":
slots = {slot: fields[slot] for slot in (SIG_SLOT, DEC_SLOT)}
elif fields[0] == "uif":
touch = {slot: fields[slot] not in ("", "0") for slot in (SIG_SLOT, DEC_SLOT)}
return Card(serial, slots, touch)
def cmd_card(args: argparse.Namespace) -> None:
# scdaemon opens the card exclusively; stop the daily one so the scratch keyring's can.
subprocess.run(["gpgconf", "--kill", "scdaemon"], capture_output=True, check=False)
with scratch_keyring() as ring:
card = read_card(ring)
say(
f"card {card.serial}: signature slot {card.slots.get(SIG_SLOT) or 'empty'}, "
f"decryption slot {card.slots.get(DEC_SLOT) or 'empty'}"
)
if not (card.touch.get(SIG_SLOT) and card.touch.get(DEC_SLOT)):
raise Abort(
"turn on touch for signing and decryption first: in the phone app, or "
"`gpg --card-edit` → admin → `uif 1 on`, `uif 2 on`"
)
prompts = Prompts(
"the backup's age passphrase",
"in gpg, at each of the two keytocard: the GPG passphrase in pinentry, then the "
"OpenPGP Admin PIN, and yes to replace a key already on the card",
"the OpenPGP PIN, and a touch when the key blinks, to decrypt a probe",
"the OpenPGP PIN if asked again, and a touch, to sign a probe",
)
prompts.next()
key = restore(ring, Path(args.backup).expanduser())
number = {part.fingerprint: index for index, part in enumerate(secret_keys(ring)[0])}
sign, encrypt = number[key.sign], number[key.encrypt]
prompts.next()
say("in gpg, type these:")
for command, effect in (
(f"key {sign}", "select the [S] subkey"),
("keytocard", "choose 1, Signature key"),
(f"key {sign}", "deselect it"),
(f"key {encrypt}", "select the [E] subkey"),
("keytocard", "choose 2, Encryption key"),
("save", ""),
):
print(f" {command:<12}{effect}", flush=True)
subprocess.run([GPG, "--edit-key", key.primary], env=ring.env, check=False)
card = read_card(ring)
tokens = {part.fingerprint: part.token for part in secret_keys(ring)[0]}
moved = card.serial and all(card.serial in tokens[fpr] for fpr in (key.sign, key.encrypt))
if card.slots != {SIG_SLOT: key.sign, DEC_SLOT: key.encrypt} or not moved:
raise Abort("the [S] and [E] subkeys are not on this card, or gpg did not `save`")
check_card(ring, key, prompts)
say(f"card {card.serial} signs and decrypts with the new key")
def check_card(ring: Keyring, key: Key, prompts: Prompts) -> None:
"""Sign and decrypt once through the card; the keyring holds only stubs after keytocard."""
probe = b"gpg-canokey probe\n"
sealed = ring.gpg("--batch", "--encrypt", "--recipient", key.encrypt, data=probe)
prompts.next()
if ring.gpg("--batch", "--decrypt", data=sealed) != probe:
raise Abort("the card decrypted the probe to different bytes")
prompts.next()
signature = ring.home / "probe.sig"
signature.write_bytes(
ring.gpg("--batch", "--local-user", key.primary, "--detach-sign", data=probe)
)
status = run(
[GPG, "--batch", "--status-fd=1", "--verify", str(signature), "-"], env=ring.env, data=probe
).decode()
if not any(line.startswith(f"[GNUPG:] VALIDSIG {key.sign} ") for line in status.splitlines()):
raise Abort("the card's signature does not verify against the [S] subkey")
# --------------------------------------------------------------------------- #
# entry point
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
commands = parser.add_subparsers(dest="command", required=True)
def add(
name: str, handler: Callable[[argparse.Namespace], None], help: str
) -> argparse.ArgumentParser:
command = commands.add_parser(name, help=help)
command.set_defaults(handler=handler)
return command
generate = add("generate", cmd_generate, "create the key and its backup")
generate.add_argument("--uid", required=True, help="such as 'Name (pass) <address>'")
generate.add_argument(
"--dest", action="append", required=True, help="directory for one backup copy; repeat it"
)
generate.add_argument("--public-key", required=True, help="new file for the public key")
store = add("pass", cmd_pass, "re-encrypt the password store to the new key")
store.add_argument("--backup", required=True, help="backup written by `generate`")
store.add_argument(
"--old-backup",
default="/etc/agenix/ryan4yin-gpg-subkeys.priv.age",
help="the retired key's subkeys",
)
store.add_argument(
"--store",
default=os.environ.get("PASSWORD_STORE_DIR", "~/.local/share/password-store"),
)
card = add("card", cmd_card, "load the subkeys onto the plugged-in CanoKey")
card.add_argument("--backup", required=True, help="backup written by `generate`")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
args.handler(args)
except (Abort, OSError) as error:
print(f"gpg_canokey: {error}", file=sys.stderr, flush=True)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+169
View File
@@ -0,0 +1,169 @@
"""Runs `generate` and `pass` against real gpg, age and pass; `card` needs a CanoKey."""
from __future__ import annotations
import contextlib
import os
import shutil
import tempfile
import unittest
from collections.abc import Iterator
from pathlib import Path
from unittest import mock
from scripts import gpg_canokey
from scripts.gpg_canokey import (
Keyring,
import_secret,
main,
run,
secret_keys,
store_entries,
unpack,
)
TOOLS = ("gpg", "gpg-connect-agent", "age", "age-keygen", "pass", "git")
PASSPHRASE = "test passphrase"
OLD_KEY = ("--batch", "--pinentry-mode", "loopback", "--passphrase", PASSPHRASE)
# Answers every pinentry request with PASSPHRASE, in place of the desktop's dialog.
FAKE_PINENTRY = f"""#!/bin/sh
echo OK
while read -r command rest; do
case $command in
GETPIN) echo "D {PASSPHRASE}"; echo OK ;;
BYE) echo OK; exit 0 ;;
*) echo OK ;;
esac
done
"""
@unittest.skipUnless(all(map(shutil.which, TOOLS)), f"needs {', '.join(TOOLS)}")
class SwitchTest(unittest.TestCase):
def setUp(self) -> None:
# tmpfs, like the script itself.
self.root = Path(tempfile.mkdtemp(dir=os.environ.get("XDG_RUNTIME_DIR")))
self.addCleanup(shutil.rmtree, self.root)
# A passphrase needs a terminal, so the tests encrypt to an age identity instead.
identity = self.root / "age-identity"
run(["age-keygen", "-o", str(identity)])
recipient = run(["age-keygen", "-y", str(identity)]).decode().strip()
self.age_encrypt = lambda data: run(["age", "-r", recipient], data=data)
self.age_decrypt = lambda data: run(["age", "-d", "-i", str(identity)], data=data)
for name, fake in (("age_encrypt", self.age_encrypt), ("age_decrypt", self.age_decrypt)):
patcher = mock.patch.object(gpg_canokey, name, fake)
patcher.start()
self.addCleanup(patcher.stop)
git_env = {
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "test",
"GIT_AUTHOR_EMAIL": "test@example.org",
"GIT_COMMITTER_NAME": "test",
"GIT_COMMITTER_EMAIL": "test@example.org",
}
patcher = mock.patch.dict(os.environ, git_env)
patcher.start()
self.addCleanup(patcher.stop)
pinentry = self.root / "pinentry"
pinentry.write_text(FAKE_PINENTRY)
pinentry.chmod(0o700)
real_keyring = gpg_canokey.scratch_keyring
@contextlib.contextmanager
def scratch_keyring() -> Iterator[Keyring]:
with real_keyring() as ring:
(ring.home / "gpg-agent.conf").write_text(f"pinentry-program {pinentry}\n")
yield ring
self.scratch_keyring = scratch_keyring
patcher = mock.patch.object(gpg_canokey, "scratch_keyring", scratch_keyring)
patcher.start()
self.addCleanup(patcher.stop)
self.store = self.root / "store"
self.old_backup = self.root / "old-subkeys.age"
def generate(self) -> Path:
usb, second = self.root / "usb", self.root / "second"
usb.mkdir()
second.mkdir()
argv = ["generate", "--uid", "New (test) <new@example.org>", "--public-key"]
argv += [str(self.root / "new.pub"), "--dest", str(usb), "--dest", str(second)]
self.assertEqual(main(argv), 0)
[backup] = usb.glob("*.tar.age")
self.assertEqual(backup.read_bytes(), (second / backup.name).read_bytes())
return backup
def make_store(self, plaintexts: dict[str, bytes]) -> None:
"""A store encrypted to an old key whose subkeys are backed up like the retired one."""
with self.scratch_keyring() as old:
old.gpg(
*OLD_KEY,
"--quick-generate-key",
"Old <old@example.org>",
"ed25519",
"cert,sign",
"never",
)
primary = secret_keys(old)[0][0].fingerprint
old.gpg(*OLD_KEY, "--quick-add-key", primary, "cv25519", "encr", "never")
for name, plaintext in plaintexts.items():
entry = self.store / f"{name}.gpg"
entry.parent.mkdir(parents=True, exist_ok=True)
entry.write_bytes(
old.gpg(
"--batch",
"--trust-model",
"always",
"--encrypt",
"-r",
primary,
data=plaintext,
)
)
subkeys = old.gpg(*OLD_KEY, "--armor", "--export-secret-subkeys", primary)
self.old_backup.write_bytes(self.age_encrypt(subkeys))
(self.store / ".gpg-id").write_text(primary + "\n")
run(["git", "-C", str(self.store), "init", "--quiet"])
run(["git", "-C", str(self.store), "add", "-A"])
run(["git", "-C", str(self.store), "commit", "--quiet", "-m", "old store"])
def switch(self, backup: Path) -> int:
argv = ["pass", "--backup", str(backup), "--old-backup", str(self.old_backup)]
return main([*argv, "--store", str(self.store)])
def test_store_moves_to_the_new_key(self) -> None:
backup = self.generate()
plaintexts = {"alpha": b"secret one\n", "web/beta": b"secret two\nuser: x\n"}
self.make_store(plaintexts)
self.assertEqual(self.switch(backup), 0)
with self.scratch_keyring() as only_new:
secret, _ = unpack(self.age_decrypt(backup.read_bytes()))
import_secret(only_new, secret)
for name, plaintext in plaintexts.items():
entry = self.store / f"{name}.gpg"
self.assertEqual(only_new.gpg("--batch", "--decrypt", str(entry)), plaintext)
# A rerun, as after an interrupted switch, finds nothing left to do.
self.assertEqual(self.switch(backup), 0)
def test_unreadable_entry_stops_before_any_change(self) -> None:
backup = self.generate()
self.store.mkdir()
(self.store / "broken.gpg").write_bytes(b"not an OpenPGP message")
self.make_store({"alpha": b"secret\n"})
before = {entry: entry.read_bytes() for entry in store_entries(self.store)}
head = run(["git", "-C", str(self.store), "rev-parse", "HEAD"])
self.assertEqual(self.switch(backup), 1)
self.assertEqual({entry: entry.read_bytes() for entry in before}, before)
self.assertEqual(run(["git", "-C", str(self.store), "rev-parse", "HEAD"]), head)
if __name__ == "__main__":
unittest.main()