I tried using TPM to authenticate SSH to GitHub without placing the private key on disk
This page has been translated by machine translation. View original
Introduction
I'm Fujii (Da) from the Manufacturing Business Technology Division.
Where do you keep your SSH private keys?
If you store them as files in ~/.ssh, malware could read them and gain unauthorized access to servers and repositories where those keys are registered.
At Classmethod, every employee is provided with 1Password, so I normally keep private keys there and avoid placing them on disk.
The Ubuntu machine I set up this time is one I access via SSH from my local environment, and it needed access to private GitHub repositories.
It's not worth installing 1Password just for GitHub. Using HTTPS authentication with the GitHub CLI is an option, but that only solves the GitHub part.
Since I plan to SSH into other servers from this machine as well, the question of what to do with private keys keeps coming up.
When I consulted an AI about this, it told me: "With TPM, you can generate the private key inside the TPM, never let it leave, and only have it perform signing operations."
With this approach, no key file exists on disk. Furthermore, the key cannot be taken out of this machine. If the machine is powered off, a PIN is required to get a stolen machine to perform signing.
No additional devices like a YubiKey are needed. TPM 2.0 is built into most modern machines.
The mechanism works like this: tpm2-pkcs11 exposes the key inside the TPM as a PKCS#11 token, and OpenSSH reads it as a PKCS11Provider.
OpenSSH --PKCS#11--> libtpm2_pkcs11.so.1 --> /dev/tpmrm0 --> Key inside TPM
From OpenSSH's perspective, it's standard public key authentication, with only the signing computation happening inside the TPM.
TL;DR
- Create a key inside the TPM using
tpm2-pkcs11and use it from OpenSSH as aPKCS11Provider - No private key file exists on disk, and the key cannot be taken out of this machine
- In a configuration where ssh logs into the TPM by itself, PIN input requires an interactive terminal or an askpass helper. Calling it from places like Claude Code that have neither will fail
- If you load the key into ssh-agent, you only need to enter the PIN once at that time
- When things don't work, checking the TPM lockout counter helps determine whether PIN is the cause
Trying It Out
Setting Up the Environment
The environment I tested with is as follows:
- Ubuntu 24.04 (x86_64)
- OpenSSH 9.6p1
- tpm2-tools 5.6-1build4
- libtpm2-pkcs11 1.9.0-0.2build4
The /usr/lib/x86_64-linux-gnu/ paths that appear below will vary by architecture. You can check with ldconfig -p | grep libtpm2_pkcs11.
First, check the TPM device files.
ls -l /dev/tpm*
# crw-rw---- 1 tss root 10, 224 /dev/tpm0
# crw-rw---- 1 tss tss 252, 65536 /dev/tpmrm0
If not found, either the TPM is disabled in firmware settings, or a vTPM may not be assigned to the virtual machine.
Install the packages.
sudo apt install tpm2-tools libtpm2-pkcs11-1 libtpm2-pkcs11-tools
The tpm2_ptool command used later is from python3-tpm2-pkcs11-tools and was installed along with the above command.
Since /dev/tpmrm0 belongs to the tss group, add yourself to it. A re-login is required for this to take effect.
sudo usermod -aG tss $USER
Creating the Key
Create a key inside the TPM.
tpm2_ptool init
tpm2_ptool addtoken --pid=<id output by init> --label=github --userpin='<PIN>' --sopin='<SO-PIN>'
tpm2_ptool addkey --algorithm=ecc256 --label=github --userpin='<PIN>'
init creates ~/.tpm2_pkcs11/tpm2_pkcs11.sqlite3 and a primary object, and outputs its id. Pass this value to --pid.
It was 1 in this case, but it is not always 1.
For addtoken, --pid, --sopin, --userpin, and --label are all required.
<PIN> and <SO-PIN> are values you decide yourself.
Despite the name PIN, they don't need to be numeric — the examples in the official documentation use strings like myuserpin. You can use a passphrase generated by a password manager directly. However, since the command wraps them in single quotes, avoid including single quotes in the PIN itself.
The user PIN you set here will be asked every time you ssh.
The SO-PIN is for resetting it, so use a different value. Keep both in a password manager. If you forget the user PIN, you'll need the SO-PIN to reset it, and if you forget the SO-PIN too, you'll have to recreate the key from scratch.
Be aware that the PIN appears in command-line arguments. It will remain in ps output and shell history.
Verify what was created.
tpm2_ptool listtokens --pid=1 # 1 in this environment
# - id: 1
# label: github
tpm2_ptool listobjects --label=github
# - CKA_CLASS: CKO_PRIVATE_KEY / CKA_KEY_TYPE: CKK_EC
# - CKA_CLASS: CKO_PUBLIC_KEY / CKA_KEY_TYPE: CKK_EC
Extract the public key. Since the output of ssh-keygen -D doesn't include a comment, sed is used to add one.
ssh-keygen -D /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1 \
| sed 's/$/ tpm-github@dev-machine/' > ~/.ssh/id_tpm_github.pub
chmod 644 ~/.ssh/id_tpm_github.pub
Registering with GitHub
Paste the public key at https://github.com/settings/ssh/new.
You can verify the pasted key matches your local one by comparing fingerprints with ssh-keygen -lf ~/.ssh/id_tpm_github.pub.
Verifying the Connection
Write ~/.ssh/config.
Host github.com
PKCS11Provider /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1
IdentityFile ~/.ssh/id_tpm_github.pub
IdentitiesOnly yes
The IdentityFile pointing to the public key file is intentional — removing it will break things. This specification is required to keep the PKCS#11 key as a candidate while maintaining IdentitiesOnly yes.
Next, verify the connection. Run this in a normal terminal window.
The first time, you'll be asked whether to trust the host key — confirm the fingerprint matches the value in the GitHub official documentation and answer yes.
ssh -T git@github.com
# Enter PIN for 'github':
# Hi <username>! You've successfully authenticated, but GitHub does not provide shell access.
Authentication succeeded with no private key file on disk.
Note that ssh -T returns exit code 1 even on successful authentication. This is because GitHub does not provide shell access. When checking success or failure in scripts, do not rely on the exit code alone.
Fails When There's Nowhere to Enter the PIN
The connection verification above actually didn't work at first.
I was running it as !ssh -T git@github.com inside a Claude Code session, and it failed three times.
When ssh is called from environments where no interactive terminal is allocated — such as AI coding tools, editor Git features, or CI — ssh looks for an askpass helper to prompt for the PIN.
Without a helper, this happens:
ssh_askpass: exec(): No such file or directory
debug1: pkcs11_login_slot: no pin specified
login failed
pkcs11_get_key failed
sign_and_send_pubkey: signing failed for ECDSA "": error in libcrypto
Since error in libcrypto appears, it looks like a cryptography issue, but it's simply that the PIN couldn't be passed.
In a normal terminal, /dev/tty is available, so Enter PIN for 'github': appears.
Installing ssh-askpass-gnome apparently allows input via a GUI dialog, but I didn't install it this time.
Eliminating PIN Entry Every Time
Entering a PIN on every connection is too cumbersome, so I set up OpenSSH's ssh-agent to run persistently and load the key just once.
Since another agent is also running in Ubuntu's desktop session, I specify a dedicated socket to keep them separate.
Create ~/.config/systemd/user/ssh-agent.service.
[Unit]
Description=OpenSSH ssh-agent (PKCS#11 / TPM key support)
Documentation=man:ssh-agent(1)
[Service]
Type=simple
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
Point SSH_AUTH_SOCK to this agent. I configured it in two places so it's visible from both the login session and interactive shells.
~/.config/environment.d/10-ssh-agent.confwithSSH_AUTH_SOCK=${XDG_RUNTIME_DIR}/ssh-agent.socket- Same value
exported in~/.bashrc
systemctl --user daemon-reload
systemctl --user enable --now ssh-agent.service
ssh-add -s /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1 # Enter PIN once here
# Card added: ...
With this, you can use it without re-entering the PIN until the agent restarts or the key is removed.
Since the agent holds the key, PKCS11Provider in ~/.ssh/config is no longer needed.
If left in place, ssh will try to log into the TPM itself and output a pin required line. Authentication still succeeds via the agent's key so it's not an error, but it's confusing, so remove it.
Host github.com
IdentityFile ~/.ssh/id_tpm_github.pub
IdentitiesOnly yes
ssh-add -l
# 256 SHA256:Wid1C+... /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1.9.0 (ECDSA)
ssh -o BatchMode=yes -T git@github.com
# Hi <username>! You've successfully authenticated, but GitHub does not provide shell access.
It worked with BatchMode=yes, so authentication is possible even from environments without a terminal.
However, removing PKCS11Provider means authentication will fail if no key is loaded in the agent.
If it's left in, even when the agent is empty, ssh will log into the TPM itself, so entering the PIN would work. The agent starts automatically via systemd but the key is not loaded automatically, so forgetting to run ssh-add -s after login will result in immediate authentication failure. It's a matter of preference whether to check for keys with ssh-add -l before working, or to leave PKCS11Provider in place and ignore the pin required output.
Specifying a Symbolic Link Name with -P Gets Rejected
The unit above doesn't include -P. I actually had it at first and got stuck there.
-P is the path pattern for PKCS#11 providers the agent is allowed to load. When I tried to explicitly restrict the provider by writing the same path passed to ssh-add, it was rejected. This is because the agent resolves the given path with realpath() before matching.
Path passed to ssh-add : /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1 ← symbolic link
Actual path agent matches: /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1.9.0
Writing the link name as-is in -P doesn't match the real name, so ssh-add -s produces this:
Enter passphrase for PKCS#11:
Could not add card "...libtpm2_pkcs11.so.1": agent refused operation
Since the PIN prompt appears before the rejection, I suspected the PIN, but the PIN never reached the TPM.
This rejection is at the verbose log level, so it doesn't appear in the journal.
Appending * to match the real name works. Since systemd doesn't expand * in ExecStart, it's passed as-is to ssh-agent.
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket -P /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so*
The fact that ssh-add -l after success shows the real path confirms that realpath() is being applied.
However, -P wasn't needed in the first place.
man ssh-agent documents the default as usr/lib*/*,/usr/local/lib*/*. The first entry lacks a leading slash, which makes it seem like it wouldn't match /usr/lib/x86_64-linux-gnu/..., yet removing -P made it work.
grep ExecStart ~/.config/systemd/user/ssh-agent.service
# ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket
ssh-add -s /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1
# Enter passphrase for PKCS#11:
# Card added: /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1
This was a typo in the man page. Looking at the same 9.6p1 source, the implementation's default value does include the leading slash.
# define DEFAULT_ALLOWED_PROVIDERS "/usr/lib*/*,/usr/local/lib*/*"
The current upstream has also corrected the man page. Since the default pattern works, -P is unnecessary, and you would only specify it explicitly when you want to restrict further, using a form that matches the real name.
When Things Don't Work
Both cases above produce PIN-related errors, but PIN is not the cause.
To distinguish this, check the TPM lockout counter. Compare values before and after attempting.
tpm2_getcap properties-variable | grep LOCKOUT_COUNTER
| Counter change | What to suspect |
|---|---|
| Not increased | The authentication process didn't reach the TPM. Check agent's -P, where SSH_AUTH_SOCK points, presence of askpass |
| Increased | TPM rejected the authentication request. Suspect the PIN, including possible confusion between user PIN and SO-PIN |
The counter also changes with other software using the TPM. Don't use this alone to determine the cause — use it as a reference by looking at the difference before and after.
In practice, after three failures with askpass, the counter remained at 0.
tpm2_getcap properties-variable | grep -iE "lockout|maxAuthFail"
# TPM2_PT_LOCKOUT_COUNTER: 0x0
# TPM2_PT_MAX_AUTH_FAIL: 0x20 ← lockout threshold is 32
When the counter hasn't increased, check these in order:
echo $SSH_AUTH_SOCK # Is it pointing to the persistent agent?
systemctl --user show ssh-agent.service -p MainPID # Are we looking at the right process?
grep ^Groups /proc/<PID from above>/status # Does it include 105 (tss)?
Be careful when searching for the process with pgrep ssh-agent. Since gnome-keyring starts a separate ssh-agent, hitting the first result may mean you're examining the wrong process.
If still unclear, adding -d to ExecStart on startup will output the rejection reason to the journal.
What It Protects Against and What It Doesn't
What this configuration prevents is the extraction of key files. It prevents keys from being taken from ~/.ssh or backups, and prevents copying only the ~/.tpm2_pkcs11 store to another machine and using it there.
On the other hand, if the running machine is compromised, that's a different story. The private key cannot be extracted, but once the key is loaded into the agent, signing can be requested without a PIN. man ssh-agent also notes that the agent's socket can be abused by the same user or root. What the TPM protects is key extraction, not the key being used for signing on that machine.
If you want to limit the time window for signing, you can set an expiry with ssh-add -t 8h -s .... It won't prevent abuse during the compromised window, but it shortens the time the key is left available. I didn't try this this time.
There are also operational constraints.
- The key can only be used on this machine and cannot be backed up. If the TPM fails, the motherboard is replaced, or the TPM is cleared, the key will be lost. Make sure to have another way to access GitHub, or you'll be locked out
- Both
usermod -aG tssandenvironment.drequire re-login. If things don't work right after setup, this is usually why - If
SSH_AUTH_SOCKpoints to gnome-keyring, the persistent agent won't be used - Deleting
~/.tpm2_pkcs11/tpm2_pkcs11.sqlite3will make the key inside the TPM inaccessible. It's metadata, not the key itself, but if deleted you'll need to start over. Copying it to another machine or an environment where the TPM has been cleared won't let you use the key. As a precaution for cases where the same TPM remains intact, it's worth including~/.tpm2_pkcs11in your backup targets - Brute-force PIN attempts are stopped by TPM Dictionary Attack protection. If locked, either wait for the recovery time or run
tpm2_dictionarylockout --clear-lockoutwith lockout authorization to unlock. The SO-PIN set during key creation is for resetting the user PIN and cannot be used to unlock TPM lockout
Conclusion
I was able to authenticate to GitHub via SSH with no private key file on disk. From the perspective of the side doing git push, it's standard SSH, so the same approach can be used for connections to other servers as well.
However, sharing the same key for everything means revocation and auditing happen all at once. Since tpm2_ptool addkey can create any number of keys, it's better to separate them by purpose.
The two places where I got stuck were both failures where the PIN didn't reach the TPM.
The PIN prompt appearing and the PIN being verified are two different things. The symptoms alone don't distinguish them, so use the lockout counter to narrow it down.
I hope this serves as a useful option to keep in mind when you're unsure where to store your private keys.