mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
feat(secrets): switch pass to an OpenPGP key on CanoKeys (#525)
Assisted-by: DeepSeek Harness (claude-opus-5-5)
This commit is contained in:
1 parent
c703b590c1
commit
c909b1340f
10 files changed
+910
-440
No files matched your search
@@ -67,4 +67,9 @@ extend-ignore-re = [
|
||||
# ROCm's `HSA_OVERRIDE_GFX_VERSION` (Heterogeneous System Architecture); the
|
||||
# dictionary wants "HAS".
|
||||
"HSA",
|
||||
|
||||
# OpenPGP/GnuPG's `fpr` record and `FPR` for fingerprint; the checker wants
|
||||
# "for"/"far"/"fps".
|
||||
"fpr",
|
||||
"FPR",
|
||||
]
|
||||
+67
-29
@@ -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 |
|
||||
| SSH | [ssh.nix](../home/base/tui/ssh.nix), public keys in [vars](../vars/default.nix) `mainSshAuthorizedKeys` | the other CanoKey; root also accepts the `romantic` recovery key |
|
||||
| shoukei LUKS | [hardware-configuration.nix](../hosts/12kingdoms-shoukei/hardware-configuration.nix) `crypttabExtraOpts` | passphrase (prompted after 10 s) |
|
||||
| pass / GPG | [gpg](../home/base/tui/gpg/default.nix) `scdaemonSettings.disable-ccid` | off-card subkey backup |
|
||||
| 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
|
||||
stay password-based: the login password also unlocks GNOME Keyring.
|
||||
@@ -269,35 +269,73 @@ each key again.
|
||||
|
||||
### pass (OpenPGP)
|
||||
|
||||
The password store is encrypted to the `[E]` subkey `0xEF824EB73CFD6CC7`; the primary key is
|
||||
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:
|
||||
The password store is encrypted to the `[E]` subkey named by `PASSWORD_STORE_KEY` in
|
||||
[password-store](../home/base/tui/password-store/default.nix). The key is a `[C]` primary that never
|
||||
leaves its backup, with `[S]` and `[E]` subkeys that both CanoKeys hold. The secret key exists only
|
||||
on the cards and in age-encrypted backups, which also carry the revocation certificate. The key has
|
||||
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
|
||||
export GNUPGHOME=$(mktemp -d)
|
||||
echo disable-ccid > "$GNUPGHOME/scdaemon.conf" # reach the key through pcscd, as the daily config does
|
||||
sudo cat /etc/agenix/ryan4yin-gpg-subkeys.priv.age | age --decrypt | gpg --import
|
||||
gpg --card-status # shows this key's serial
|
||||
gpg --edit-key 0xCC88591C033F1924
|
||||
```
|
||||
A new key replaces the old one outright: the old key decrypts the store once, during the switch, and
|
||||
is then retired.
|
||||
|
||||
1. In `gpg --edit-key`, for each subkey: `key N` (the `[S]`, `[E]` or `[A]` one), `keytocard`, pick
|
||||
the slot of the same usage, `key N` again to deselect; then `save`. This asks for the subkey
|
||||
passphrase and the OpenPGP Admin PIN.
|
||||
2. Require touch: `gpg --card-edit` → `admin` → `uif 1 on`, `uif 2 on`, `uif 3 on` (or the phone
|
||||
app). Decrypt touch matters most: browserpass gives browsers the GPG agent socket, and without
|
||||
touch a compromised browser could decrypt the whole store silently after the first PIN.
|
||||
3. Drop the throwaway keyring: `gpgconf --kill all && rm -rf "$GNUPGHOME" && unset GNUPGHOME`.
|
||||
4. In the daily keyring, point the stubs at the plugged-in key:
|
||||
`gpg-connect-agent "scd serialno" "learn --force" /bye`. Check with `pass show <entry>` (PIN,
|
||||
then touch). On a machine that still holds on-disk subkeys, delete them first
|
||||
(`gpg --delete-secret-keys '<subkey-fingerprint>!'`).
|
||||
**Prepare.** Nothing changes for pass yet.
|
||||
|
||||
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.
|
||||
1. Turn on signature and decryption touch on every CanoKey in the phone app; `card` refuses a key
|
||||
without them. Decryption 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.
|
||||
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
|
||||
|
||||
@@ -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` |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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 |
|
||||
| FIDO2 PIN forgotten or locked | Phone app: reset the WebAuthn application (needs the Admin PIN) | all passkeys, the SSH resident key, LUKS and sudo enrollments; redo section 2 |
|
||||
| OpenPGP PIN locked | `gpg --card-edit` → `admin` → `passwd`, unblock with the OpenPGP Admin PIN or Reset Code | nothing |
|
||||
| All OpenPGP PINs and Reset Code lost | Phone app: reset the OpenPGP application | on-card subkeys; re-import from the backup |
|
||||
| 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 |
|
||||
|
||||
After any reset, rerun sections 1 and 2 for the affected applications.
|
||||
|
||||
Generated
+3
-3
@@ -493,10 +493,10 @@
|
||||
"mysecrets": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1793275857,
|
||||
"narHash": "sha256-Ut8ohwrRRiIsQdWEzVNtysY7g34uzYS/+sWnQK7c3O0=",
|
||||
"lastModified": 1794375216,
|
||||
"narHash": "sha256-AFc1wbFkjMDE8M5MOoTN+LtjTCeO9eKbEji+l+iPBMQ=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "7f93490f165543532f34f388f346af91db5715ac",
|
||||
"rev": "77c8376104042b2052ce345a7151c645e4684a51",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "ssh://git@github.com/ryan4yin/nix-secrets.git"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Encryption
|
||||
|
||||
We have GnuPG & password-store installed by default, mainly for password management, authentication
|
||||
& communication encryption.
|
||||
We have GnuPG & password-store installed by default, mainly for password management and
|
||||
communication encryption.
|
||||
|
||||
We also have LUKS2 for disk encryption on Linux, and [rclone](https://rclone.org/crypt/) for
|
||||
cross-platform data encryption & syncing.
|
||||
|
||||
+60
-365
@@ -29,11 +29,24 @@ tool that is simple enough, functional enough, and widely adopted.
|
||||
|
||||
Currently I use both age & GnuPG:
|
||||
|
||||
1. Age for secrets encryption(ssh key & other secret files), it's simple and easy to use.
|
||||
2. GnuPG for password-store and email encryption.
|
||||
1. Age for secrets encryption, it's simple and easy to use.
|
||||
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
|
||||
> [hardware security keys](../../../../docs/security-keys.md#pass-openpgp).
|
||||
SSH no longer goes through GnuPG: each CanoKey holds a FIDO2 SSH key instead (no `[A]` subkey).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
one keypair, or a keyring/keychain(which contains multiple sub key-pairs).
|
||||
|
||||
Let's generate a keypair interactively:
|
||||
|
||||
> 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.
|
||||
The script creates the key non-interactively, in a keyring on tmpfs:
|
||||
|
||||
```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
|
||||
info, and a strong passphrase to protect your PGP key. e.g.
|
||||
|
||||
```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]
|
||||
```
|
||||
The primary key only certifies: it signs its own subkeys and revocations, so it can stay offline
|
||||
while the subkeys do the daily work. The key does not expire; the revocation certificate in every
|
||||
backup is the way to retire it.
|
||||
|
||||
### 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>
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
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).
|
||||
2. Then generate 3 sub keys with `E`, `S` and `A` usage flag respectively.
|
||||
3. **The Primary Key is extremely important**, Backup the primary key to somewhere absolutely
|
||||
safe(such as two encryptd USB drivers, keep them in different places), and then **delete it from
|
||||
your computer immediately**.
|
||||
4. The sub key is also important, but you can generate a new one and replace it easily. You can
|
||||
backup it to somewhere else, and import it to another machine to use your keypair.
|
||||
5. Backup your Primary key's revocation certificate to somewhere safe, it's the last way to rescure
|
||||
your safety if your primary key is compromised!
|
||||
6. It's a big problem if your revocation certificate is compromised, but not the biggest one.
|
||||
because it's only used to revoke your keypair, your data is still safe. But you should generate a
|
||||
new keypair and revoke the old one immediately.
|
||||
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
|
||||
...
|
||||
```
|
||||
1. One primary key with only the `C` flag, and one subkey per job: `S` and `E` here. Without a GPG
|
||||
SSH key there is no `A` subkey.
|
||||
2. **The primary key is extremely important**: keep it only in offline backups, never in a daily
|
||||
keyring. Here it lives in age-encrypted backups on two drives kept in different places.
|
||||
3. Keep the subkeys on hardware keys, one copy per key, so losing one CanoKey loses nothing. A
|
||||
subkey can be replaced with a new one signed by the primary.
|
||||
4. Back up the revocation certificate together with the primary key, and never delete it: it is the
|
||||
last way out if the primary key is compromised.
|
||||
5. A leaked revocation certificate can only revoke the key; your data stays safe, but generate a new
|
||||
key anyway.
|
||||
6. A compromised primary key without a revocation certificate is the worst case, and even with one,
|
||||
OpenPGP has no good way to distribute it (see section 7).
|
||||
|
||||
### 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
|
||||
gpg --armor --export ryan4yin@linux.com > ryan4yin-gpg-keys.pub
|
||||
# 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
|
||||
› gpg --armor --export-secret-keys <primary-fingerprint>! | nix run nixpkgs#pgpdump
|
||||
...
|
||||
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)
|
||||
Iterated and salted string-to-key(s2k 3):
|
||||
Hash alg - SHA1(hash 2)
|
||||
Salt - 8c 78 58 c0 87 83 8c 2c
|
||||
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
|
||||
find that gpg ignored the `--s2k-count` option we specified when generating the keypair, and the
|
||||
`--s2k` related options we specified in `~/.gnupg/gpg.conf`, the exported primary key is protectd by
|
||||
`SHA1` and `AES128`, which is not secure enough!
|
||||
As [Predictable, Passphrase-Derived PGP Keys][Predictable, Passphrase-Derived PGP Keys] says, gpg
|
||||
ignores the `--s2k-*` options from `~/.gnupg/gpg.conf` here: the exported key is protected by `SHA1`
|
||||
and `AES128`. age derives its key with `scrypt`, so the backup takes age's strength while the GPG
|
||||
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
|
||||
algorithm, I choose `age` here(which use `scrypt` to encrypt the file key with a provided
|
||||
passphrase):
|
||||
Restore the secret key into a keyring on tmpfs, never into `~/.gnupg`:
|
||||
|
||||
```bash
|
||||
# for simplicity, use the same passphrase as your gpg keypair here
|
||||
age --passphrase -o ryan4yin-primary-key.priv.age ryan4yin-primary-key.priv
|
||||
rm ryan4yin-primary-key.priv
|
||||
age --decrypt <backup> | tar -xOf - secret.asc | gpg --import
|
||||
```
|
||||
|
||||
Export Sub Keys one by one(The exported keys is still encrypted by your passphrase):
|
||||
|
||||
```bash
|
||||
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]
|
||||
```
|
||||
`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
|
||||
read-only anyway).
|
||||
|
||||
### 5. Signing & Verification
|
||||
|
||||
@@ -597,8 +292,8 @@ But in OpenPGP:
|
||||
person(such as Linus), which is very insecure.
|
||||
- **key server** is not recommend to use.
|
||||
- GnuPG will generate revocation certificate when generating
|
||||
keypair(`~/.gnupg/private-keys-v1.d/<Key-ID.rev>`), anyone can import this certificate to revoke
|
||||
the keypair. But OpenPGP standard **DO NOT provide a way to distribute this certificate to
|
||||
keypair(`~/.gnupg/openpgp-revocs.d/<fingerprint>.rev`), anyone can import this certificate to
|
||||
revoke the keypair. But OpenPGP standard **DO NOT provide a way to distribute this certificate to
|
||||
others**.
|
||||
- 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
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
source = "${mysecrets}/public/ryan4yin-gpg-keys-2014-01-27.pub";
|
||||
trust = 5;
|
||||
} # 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
|
||||
|
||||
@@ -7,44 +7,12 @@
|
||||
- Android: <https://github.com/android-password-store/Android-Password-Store>
|
||||
- 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.
|
||||
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>
|
||||
```
|
||||
Once the old key is deleted, older revisions in the store's git history can no longer be decrypted.
|
||||
@@ -25,14 +25,14 @@ in
|
||||
# Hexadecimal key signature is recommended.
|
||||
# Multiple keys may be specified separated by spaces.
|
||||
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
|
||||
# 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.
|
||||
# The init command will keep signatures of .gpg-id files up to date.
|
||||
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_GENERATED_LENGTH = "12";
|
||||
|
||||
Executable
+591
@@ -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())
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user