Recovering an SSH key from GnuPG
Scenario
- You use
gpg-agent
as your SSH agent - You have managed to damage or lose your original SSH key from within
~/.ssh/id_ed25519
- You can still use this key via GPG agent (which has cached a copy of it)
- You want to re-generate the lost OpenSSH PEM file from GPG Agent’s cache
Process overview
- Remove the passphrase protection from GPG’s cache of your key
- Read the raw key information from the file in
~/.gnupg/private-keys-v1.d/
- Use the Python cryptography library to re-export an OpenSSH PEM file from the raw key information
- Re-encrypt the key to protect it
NOTE: This process does involve storing one or two unencrypted copies of your SSH key in your filesystem temporarily. Take appropriate precautions to mitigate this risk.
Further, this process has only been tested thus far with Ed25519 SSH keys. I’ll try to test with other key types as time permits.
Step-by-step
I assume you’ll be starting with a passphrase-protected SSH key in GPG’s key store:
$ cat ~/.gnupg/private-keys-v1.d/4A462E5C7E073BCCFB91B818A461CDB772DCE87E.key
Key: (protected-private-key (ecc (curve Ed25519)(flags eddsa)(q
#4099AE0997EA02C5C9D89ABB36EFAED8A49B608CAD12706057270C77455E41F3D2#)
(protected openpgp-s2k3-ocb-aes ((sha1 #60885828FED83E64#
"369405952")#197F7D1DB946B42416869278#)#079BBD148421FF6F2BDF6F9FB5A72
444E704A9BB65BC1D5F752A22CECF90574AC466D8791C04CE1310858650D665B5C9B9F
101FA37471714FDAA6877#)(protected-at "20250520T221645"))(comment
scratch))
-
Decrypt the key:
Note: I’m using
pinentry-tty
so you can see the prompts for my passphrase(s). You may get these prompts via a GUI or text pop-up.$ echo 'PASSWD 4A462E5C7E073BCCFB91B818A461CDB772DCE87E' | gpg-connect-agent Please enter your passphrase, so that the secret key can be unlocked for this session Passphrase: Please enter the new passphrase Passphrase: Repeat: You have not entered a passphrase - this is in general a bad idea! Please confirm that you do not want to have any protection on your key. Yes, protection is not needed Enter new passphrase [ye]? y S CACHE_NONCE FCC27C0B4E74D8270B8BFF77 OK
The same SSH key, now stored unencrypted:
$ cat 4A462E5C7E073BCCFB91B818A461CDB772DCE87E.key Key: (private-key (ecc (curve Ed25519)(flags eddsa)(q #4099AE0997EA02C5C9D89ABB36EFAED8A49B608CAD12706057270C77455E41F3D2#) (d #A8004CC61CF0A45F0C29BA8A037ACDBCD8578BB2F4B8D10453604B0F1EBF4C7F#) )(comment scratch))
-
Create a Python script with the below content. This script converts the unencrypted key from GPG to OpenSSH PEM format. Note that you’ll need to
pip install cryptography
(or install it from your OS package manager, e.g.sudo apt install python3-cryptography
).#!/usr/bin/env python # /// script # requires-python = ">=3.13" # dependencies = [ # "cryptography", # ] # /// import os import pathlib import sys import cryptography.hazmat.primitives.serialization import cryptography.hazmat.primitives.asymmetric keygrip = sys.argv[1] gpg_file = pathlib.Path("~/.gnupg/private-keys-v1.d").expanduser() / f"{keygrip}.key" with open(gpg_file, "r") as f: gpg_data = f.read() # Rather than elegantly parse the S-expression in the .key file, split it on # the hashes which surround the key material, and pull out the public (q) and # private (d) parts. (_, gpg_q, _, gpg_d, _) = gpg_data.split("#") gpg_d = bytes.fromhex(gpg_d) d = cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes( gpg_d ) private_bytes = d.private_bytes( encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM, format=cryptography.hazmat.primitives.serialization.PrivateFormat.OpenSSH, encryption_algorithm=cryptography.hazmat.primitives.serialization.NoEncryption(), ) os.umask(0o0077) ssh_key_file = pathlib.Path("~/.ssh").expanduser() / f"id_ed25519.{keygrip}" with open(ssh_key_file, "w") as f: f.write(private_bytes.decode()) print(f"SSH key written to {ssh_key_file}")
-
Run the script, passing it the keygrip of your key as an argument:
$ python ./convert-gpg-to-ssh.py 4A462E5C7E073BCCFB91B818A461CDB772DCE87E SSH key written to /home/username/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E
-
Review the resultant SSH key:
$ cat ~/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQyNTUx OQAAACCZrgmX6gLFydiauzbvrtikm2CMrRJwYFcnDHdFXkHz0gAAAIiS2lEvktpRLwAAAAtzc2gt ZWQyNTUxOQAAACCZrgmX6gLFydiauzbvrtikm2CMrRJwYFcnDHdFXkHz0gAAAECoAEzGHPCkXwwp uooDes282FeLsvS40QRTYEsPHr9Mf5muCZfqAsXJ2Jq7Nu+u2KSbYIytEnBgVycMd0VeQfPSAAAA AAECAwQF -----END OPENSSH PRIVATE KEY-----
-
This key has no comment, because we didn’t convert that. Let’s give it a comment and passphrase-protect it:
$ ssh-keygen -c -f ~/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E Old comment: New comment: laptop key Comment 'laptop key' applied $ ssh-keygen -p -f ~/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E Key has comment 'laptop key' Enter new passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved with the new passphrase.
-
If all of this worked correctly, we should now be able to delete the (currently unprotected) key from GPG’s key store, and re-add it. Out of caution let’s just move the key file aside first:
$ mv ~/.gnupg/private-keys-v1.d/4A462E5C7E073BCCFB91B818A461CDB772DCE87E.key \ ~/.gnupg/private-keys-v1.d/4A462E5C7E073BCCFB91B818A461CDB772DCE87E.key.disabled $ ssh-add ~/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E Enter passphrase for /home/username/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E: Please enter a passphrase to protect the received secret key MD5:b7:0d:e4:9b:4c:2b:26:df:5c:cc:aa:ce:51:4e:dd:ce laptop key within gpg-agent's key storage Passphrase: Repeat: Identity added: /home/username/.ssh/id_ed25519.4A462E5C7E073BCCFB91B818A461CDB772DCE87E (laptop key)
-
Confirm you can use this key (as stored in GPG agent) to authenticate to a host, and then you can securely delete the unprotected version of the key:
$ shred -u ~/.gnupg/private-keys-v1.d/4A462E5C7E073BCCFB91B818A461CDB772DCE87E.key.disabled