Skip to content

skeeto/passphrase2pgp

Repository files navigation

Predictable, passphrase-based PGP key generator

passphrase2pgp generates, in OpenPGP format, an EdDSA signing key and Curve25519 encryption subkey entirely from a passphrase, essentially allowing you to store a backup of your PGP keys in your brain. At any time you can re-run the tool and re-enter the passphrase to reproduce the original keys.

The keys are derived from the passphrase and User ID (as salt) using Argon2id (memory=1GB and time=8) and RFC 8032. It's aggressive enough to protect from offline brute force attacks passphrases short enough to be memorable by humans.

See also: Long Key ID Collider

Installation

$ go install nullprogram.com/x/passphrase2pgp@latest

Remember to add $(go env GOPATH)/bin (or $GOBIN) to your $PATH.

Usage

Quick start: Provide a user ID (-u) and pipe the output into GnuPG.

$ passphrase2pgp -u "Real Name <[email protected]>" | gpg --import

Either --uid (-u) or --load (-l) is required.

  • The --uid (-u) option supplies the user ID string for the key to be generated. If --uid is missing, the REALNAME and EMAIL environmental variables are used to construct a user ID, but only if both are present.

  • The --load (-l) option loads a previously generated key for use in other operations (signature creation, ASCII-armored public key, etc.).

There are three commands:

  • Key generation (--key, -K) [default]: Writes a key to standard output. This is a secret key by default, but --public (-p) restricts it to a public key.

  • Detached signatures (--sign, -S): Signs one or more input files. Unless --load is used, also generates a key, but that key is not output. If no files are given, signs standard input to standard output. Otherwise for each argument file creates file.sig with a detached signature. If armor is enabled (--armor, -a), the file is named file.asc.

  • Cleartext signature (--clearsign, -T): Cleartext signs standard input to standard output, or from a file to standard output. The usual cleartext signature caveats apply.

Use --help (-h) for a full option listing:

Usage:
   passphrase2pgp <-u id|-l key> [-hv] [-c id] [-i pwfile] [--pinentry[=cmd]]
       -K [-anps] [-e[n]] [-f pgp|ssh|x509|signify] [-r n] [-t secs] [-x[spec]]
       -S [-a] [-r n] [files...]
       -T [-r n] >doc-signed.txt <doc.txt
Commands:
   -K, --key                 output a key (default)
   -S, --sign                output detached signatures
   -T, --clearsign           output a cleartext signature
Options:
   -a, --armor               encode output in ASCII armor
   -c, --check KEYID         require last Key ID bytes to match
   -e, --protect[=ASKS]      protect private key with S2K
   -f, --format FORMAT       select key format [pgp]
   -h, --help                print this help message
   -i, --input FILE          read passphrase from file
   -l, --load FILE           load key from file instead of generating
   -n, --now                 use current time as creation date
   --pinentry[=CMD]          use pinentry to read the passphrase
   -p, --public              only output the public key
   -r, --repeat N            number of repeated passphrase prompts
   -s, --subkey              also output an encryption subkey
   -t, --time SECONDS        key creation date (unix epoch seconds)
   -u, --uid USERID          user ID for the key
   -v, --verbose             print additional information
   --version                 print version information
   -x, --expires[=SPEC]      set key expiration [2y]

Per the OpenPGP specification, the Key ID is a hash over both the key and its creation date. Therefore using a different date with the same passphrase/ID will result in a different Key ID, despite the underlying key being the same. For this reason, passphrase2pgp uses Unix epoch 0 (January 1, 1970) as the default creation date. You can override this with --time (-t) or --now (-n), but, to regenerate the same key in the future, you will need to use --time to reenter the exact time. If 1970 is a problem, then choose another memorable date.

The --check (-c) causes passphrase2pgp to abort if the final bytes of the Key ID do not match the hexadecimal argument. If this option is not provided, the KEYID environment variable is used if available. In either case, --repeat (-r) is set to zero unless it was explicitly provided. The additional passphrase check is unnecessary if they Key ID is being checked.

The --protect option uses OpenPGP's S2K feature to encrypt the private key in the exported format. Rather than prompt for an S2K passphrase, passphrase2pgp will reuse your derivation passphrase as the protection passphrase. However, keep in mind that the S2K algorithm is much weaker than the algorithm used to derive the asymmetric key, Argon2id. Given an optional numeric argument, --protect will prompt that many times (like --repeat) for a separate S2K passphrase.

By default keys are not given an expiration date and do not expire. To retire a key, you would need to use another OpenPGP implementation to import your key and generate a revocation certificate. Alternatively, the --expires (-x) option sets an expiration date, defaulting to two years from now. As an optional argument, it accepts a time specification similar to GnuPG: days (d), weeks (w), months (m), and years (y). For example, --expires=10y or -x10y sets the expiration date to 10 years from now. Without a suffix, the value is interpreted as a specific unix epoch timestamp.

Unfortunately there's a bug in the way GnuPG processes key expiration dates that affect passphrase2pgp. Keys with a zero creation date are incorrectly considered never to expire despite an explicit expiration date. This means if you use passphrase2pgp's default creation date, the --expires (-x) may appear not to work, and GnuPG will incorrectly verify signatures from your expired keys. Further, GnuPG generally doesn't compute expiration dates correctly. OpenPGP allows expiration dates beyond the year 2106, and, unlike GnuPG, passphrase2pgp will allow you construct such keys, but GnuPG will use an incorrect (earlier) date.

Examples

Generate a private key and send it to GnuPG (no protection passphrase):

$ passphrase2pgp --uid "..." | gnupg --import

Or, with --protect, reuse the derivation passphrase as the protection passphrase so that the key is encrypted on the GnuPG keyring using your derivation passphrase:

$ passphrase2pgp --protect --uid "..." | gnupg --import

Or to prompt (once) for a different passphrase to use as the protection passphrase:

$ passphrase2pgp --protect=1 --uid "..." | gnupg --import

Create an armored public key for publishing and sharing:

$ passphrase2pgp --uid "..." --armor --public > Real-Name.asc

Since passing --uid every time you need it is tedious, that argument can be supplied implicitly via two environment variables, REALNAME and EMAIL. The remaining examples assume these variables are set.

$ export REALNAME="Real Name"
$ export EMAIL="[email protected]"
$ passphrase2pgp -ap > Real-Name.asc

Create detached signatures (-S) for some files:

$ passphrase2pgp -S document.txt avatar.jpg

This will create document.txt.sig and avatar.jpg.sig. The other end would use GnuPG to verify the signatures like so:

$ gpg --import Real-Name.asc
$ gpg --verify document.txt.sig
$ gpg --verify avatar.jpg.sig

Normally each command must derive keys from scratch from the passphrase, requiring the user to re-enter it for each command and wait. To avoid this, save the secret key to a file in OpenPGP format and then load (--load) it for other commands. This will save an unprotected version:

$ passphrase2pgp > secret.pgp

Then you can sign files without re-entering your passphrase:

$ passphrase2pgp -S --load secret.pgp document.txt avatar.jpg

If you used an S2K protection passphrase (--protect), passphrase2pgp will prompt for it when loading such keys.

More signatures, but ASCII-armored:

$ passphrase2pgp -S -lsecret.pgp --armor document.txt avatar.jpg
$ gpg --verify document.txt.asc
$ gpg --verify avatar.jpg.asc

Create a cleartext-signed (-T) text document:

$ passphrase2pgp -T >signed-doc.txt <doc.txt

Intended Workflow

There are two usage patterns: "lite" and "full".

When a "lite" user sets up a new computer, they run passphrase2pgp just once and send the key straight into GnuPG. After this they use GnuPG for everything OpenPGP-related. This command installs the secret key in GnuPG with a separate, more convenient, protection passphrase:

$ passphrase2pgp -u '...' -e1 | gpg --import

This user will not need to backup their keyring since they can always regenerate their key in the future. They're also free to destroy their keyring at any moment, such as before their computer is accessed by untrusted people (border agents, etc.).

A "full" user will use passphrase2pgp directly for signatures and will never store the secret key permanently. They derive the key on demand only when needed. To make this convenient, this users sets REALNAME, EMAIL, and KEYID in their .profile. This means they never have to supply --uid, and there's no passphrase confirmation prompt.

For example, supposed John Doe is a "full" user setting up for the first time with the passphrase "boa trusted stew critics dispute asked naming gyms". First he sets his user ID in his .profile:

export REALNAME="John Doe"
export EMAIL="[email protected]"

Then he generates his public key and gets the fingerprint:

$ passphrase2pgp --verbose --public >John-Doe.asc
User ID: John Doe <[email protected]>
passphrase: 
passphrase (repeat): 
Key ID: C8A22A0535AF18BC83D7AE21406CC07F8DABE73B

He publishes John-Doe.asc and adds the fingerprint to his .profile:

export KEYID=C8A22A0535AF18BC83D7AE21406CC07F8DABE73B

This is the actual key for that user ID and passphrase, so you can try each of these commands yourself. Later if he, say, needs to clearsign a message:

$ echo The swallow flies at midnight >message.txt
$ passphrase2pgp -T <message.txt
passphrase: 
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

The swallow flies at midnight
-----BEGIN PGP SIGNATURE-----

wnUEARYIACcFAl1UG1gJEEBswH+Nq+c7FiEEyKIqBTWvGLyD164hQGzAf42r5zsA
ADWqAP9KfoQm02q+AXE5brS9lNZ8LVjFs6CefMA4C/83Da7E4wD/QnYNyFmpmTOm
B6w1UvDnxyD0ksjmyj6NDiRs25b20gk=
=0gf5
-----END PGP SIGNATURE-----

Again, this was all done without ever storing the secret key in the file system, even in protected form.

GnuPG Trust

Trust is stored external to keys, so imported keys are always initially untrusted. You will likely want to mark your newly-imported primary key as trusted. Or use the --trusted-key option in gpg.conf.

$ gpg --edit-key "Real Name"
gpg> trust

Similarly, to allow gpgv to verify your signatures, append your public key to its trusted keyring:

$ passphrase2pgp -p >> ~/.gnupg/trustedkeys.kbx

Signing Git tags and commits

It's even possible to use passphrase2pgp directly to sign your Git tags and commits. Just set gpg.program to passphrase2pgp:

$ git config --global gpg.program passphrase2pgp

When passphrase2pgp detects that it's been invoked via Git, it presents a GnuPG-like interface to Git. When asked to verify tags and commits (git verify-tag, git verify-commit), it delegates to the program named gpg.

OpenSSH format

Despite the name, passphrase2pgp can output a key in OpenSSH format, selected by --format (-f). Passphrase protection (bcrypt) is supported. When using this format, the --armor (-a), --now (-n), --subkey (-s), and --time (-t) options are ignored since they do not apply. The user ID becomes the key comment and is still used as the salt.

$ passphrase2pgp --format ssh | (umask 077; tee id_ed25519)

This will be exactly the same key pair as when generating an OpenPGP key. It's just written out in a different format. The public key will be harmlessly appended to the private key, but it could also be regenerated with ssh-keygen:

$ ssh-keygen -y -f id_ed25519 > id_ed25519.pub

With the --public (-p) option, only the public key will be output.

You may want to add a protection key to the generated key, which, again, can be done with ssh-keygen:

$ ssh-keygen -p -f id_ed25519

Generally you really should have a unique SSH key per host, and this sort of long-term key is both unnecessary and undesired. If you loose access to that computer — theft, retirement, etc. — then you can remove just that host's key as an authorized key without affecting other hosts. In general, SSH keys need not and should not be backed up, including in your brain.

However, there is at least one case where a long-term, important SSH key could be useful. Suppose you have a vital, remote system with password authentication disabled. If you loose access to all of the authorized keys, you can no longer remotely log into that system. Correcting this problem may require traveling to the computer's location or using some inconvenient means to regain access.

Instead, you could use passphrase2pgp to generate an emergency SSH key and install it as an authorized key on the remote host. But don't actually store the private key anywhere and don't normally use this key! When you're in a pinch, use passphrase2pgp to regenerate the emergency key, recover your access, then immediately destroy the emergency key.

Setting up the emergency key ahead of time:

$ passphrase2pgp -u emergency -f ssh -p > ~/.ssh/emergency.pub
$ ssh-copy-id -f -i ~/.ssh/emergency.pub important.example.com

Later, when in dire straits, generate the private key, and use it to install a non-emergency key as a new authorized key:

$ passphrase2pgp -u emergency -f ssh | ssh-add -
$ ssh-copy-id -i ~/.ssh/id_ed25519 important.example.com

Other formats

The --format option also supports:

  • x509: self-signed TLS certificates (--protect unsupported)
  • signify: OpenBSD signify(1) keys, including --protect

Justification

Isn't generating a key from a passphrase foolish? If you can reproduce your key from a passphrase, so can any one else!

In 2019, the fastest available implementation of Argon2id running on the best available cloud hardware takes just over 6 seconds with passphrase2pgp's default parameters. That's 6 seconds of a dedicated single CPU core and 1GB of RAM for a single guess. This means that at the current cloud computing rates it costs around US$50 to make 2^20 (~1 million) passphrase guesses.

A randomly-generated password of length 8 composed of the 95 printable ASCII characters has ~52.6 bits of entropy. Therefore it would cost around US$ 158 billion to for just a 50% chance of cracking that passphrase. If your passphrase is generated by a random process, and it's at least this long, it is not the weak point in this system.

Regarding the encryption subkey

Since OpenPGP encryption is neither good nor useful anymore, I considered not generating an encryption subkey. The "privacy" portion of OpenPGP has become the least important part. However, the upcoming update to OpenPGP, rfc4880bis, adds AEAD encryption, and this could make encryption interesting again.

OpenPGP digital signatures still have some limited use, mostly due to the lack of adoption of the alternatives. The OpenPGP specification is too flexible and is loaded with legacy cruft. Further, GnuPG is honestly not a great OpenPGP implementation, and I do not have high confidence in it.

Roadmap

  • AEAD (tag 20) encryption and limited decryption

References