SSH as a Cyborg: Logging In With the Key in My Hand

SSH as a Cyborg: Logging In With the Key in My Hand

Hand on the reader, and I'm in

Now that I have the programmable VivoKey Apex Flex implant, I want to put it tu use.

So now log in to my servers over SSH with a private key that lives on my implant. With my hand on the NFC reader,sshasks for my PIN and opens a shell. Without it, the login is refused.

This is a follow-up to my post on putting my OpenPGP keys on a VivoKey Apex Flex implant. There, I moved three RSA 2048 subkeys onto the implant's SmartPGP applet. One of them is an authentication subkey, and this post turns it into an SSH key.

No new hardware or keys are needed. GnuPG's agent can act as an SSH agent and route the login signature to the implant.

How it works

SSH public-key login is a signature. The server sends a challenge, the client signs it with the private key, and the server checks the signature against authorized_keys.

Here, the signing happens on the implant:

  1. ssh asks the agent behind SSH_AUTH_SOCK for a signature. That socket belongs to gpg-agent, not to Apple's ssh-agent.
  2. gpg-agent launches pinentry for the User PIN, then passes the request to scdaemon.
  3. scdaemon talks to the implant through macOS PC/SC and the ACR1252 reader's contactless PICC interface.
  4. The [A] authentication subkey on the implant signs the challenge. The private key never leaves the chip.

The server only ever sees a normal ssh-rsa public key. It cannot tell that the private half is implanted.

Before you start

Everything from the first post must already work on the Mac. Check these four things with your hand on the reader:

  • gpg --card-status shows the card and an Authentication key fingerprint.
  • ~/.gnupg/scdaemon.conf contains disable-ccid and reader-port "ACS ACR1252 Dual Reader PICC", so scdaemon uses PC/SC and the contactless reader.
  • ~/.gnupg/gpg-agent.conf sets pinentry-program to pinentry-mac. SSH has no terminal to hand to a text pinentry, so a GUI prompt avoids a whole class of errors.
  • gpg -K lists the subkeys as ssb>, meaning their secret parts are on the card. On a new Mac, one gpg --card-status with the implant on the reader creates these stubs.

My authentication subkey is rsa2048/95C8E4DA45D4F42D.

Export the SSH public key and install it on the server

GnuPG converts the authentication subkey into OpenSSH's public key format:

mkdir -p ~/.ssh && chmod 700 ~/.ssh
gpg --export-ssh-key 13D6298D59F23B7120870CC46F80068AFE6B4035 > ~/.ssh/id_vivokey.pub
chmod 644 ~/.ssh/id_vivokey.pub

The argument is my primary key fingerprint; --export-ssh-key picks the subkey with the [A] capability. The file holds a single ssh-rsa AAAA… line. There is no private key file to go with it, and there never will be.

authorized_keys lives on the server, not on the Mac. Install the line there through any login that already works:

ssh-copy-id -f -i ~/.ssh/id_vivokey.pub user@server.example.com

Or without ssh-copy-id:

cat ~/.ssh/id_vivokey.pub | ssh user@server.example.com 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

-f skips ssh-copy-id's check for an already installed key, which would otherwise try to log in with the new key first.

Point SSH at gpg-agent

Three pieces of configuration make ssh talk to gpg-agent instead of Apple's agent.

1. Turn on the agent's SSH support

grep -q '^enable-ssh-support' ~/.gnupg/gpg-agent.conf || echo enable-ssh-support >> ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent

On macOS and Linux the agent always speaks the SSH agent protocol, per the GnuPG manual. enable-ssh-support is the documented switch for it, and it does no harm to keep.

2. Set the socket and start the agent in every shell

zsh (~/.zshrc):

export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
gpgconf --launch gpg-agent
gpg-connect-agent updatestartuptty /bye >/dev/null

fish (~/.config/fish/config.fish):

set -gx SSH_AUTH_SOCK (gpgconf --list-dirs agent-ssh-socket)
gpgconf --launch gpg-agent
gpg-connect-agent updatestartuptty /bye > /dev/null

Each line prevents a specific failure:

  • SSH_AUTH_SOCK overrides the macOS default, which points at Apple's agent.
  • gpgconf --launch matters because ssh-add and ssh never start gpg-agent; only gpg commands do. After a gpgconf --kill, the socket simply does not exist until something launches the agent again.
  • updatestartuptty tells the agent which terminal to use for a text pinentry, which ssh does not pass along.

3. Pin the key per host

In ~/.ssh/config, IdentityFile can point at the public key. OpenSSH then asks the agent for the matching private key:

Host myserver
    HostName server.example.com
    User user
    IdentityFile ~/.ssh/id_vivokey.pub
    IdentitiesOnly yes

IdentitiesOnly yes stops SSH from offering every other key the agent holds.

Open a new shell and check with your hand on the reader:

ssh-add -L

The output should match ~/.ssh/id_vivokey.pub, with a comment naming the card (cardno:…).

Logging in: hand on, hand off

With my hand resting on the reader, ssh myserver pops up the pinentry-mac dialog. I type my User PIN, the implant signs, and the shell opens.

Without my hand on the reader, the same command fails:

$ ssh myserver
Load key "/Users/sascha/.ssh/id_vivokey.pub": invalid format
user@server.example.com: Permission denied (publickey).

Hand on the reader

Hand off the reader

gpg-agent offers the card key

Yes

No: there is no card to read

What SSH does

Asks the agent to sign

Tries to load id_vivokey.pub as a private key file

Result

PIN prompt, then a shell

invalid format, then Permission denied (publickey)

The "invalid format" line looks alarming but is expected. IdentityFile points at a public key, so when the agent has no matching key, OpenSSH has nothing it can sign with. The server then refuses the login, which is exactly what should happen without the implant.

Troubleshooting

Three errors came up while I set this up on two MacBooks.

Error

What it means

Fix

ssh-add -L: Error connecting to agent: No such file or directory

SSH_AUTH_SOCK points at a socket that does not exist, usually because gpg-agent is not running

gpgconf --launch gpg-agent, and check that echo $SSH_AUTH_SOCK matches gpgconf --list-dirs agent-ssh-socket

sign_and_send_pubkey: signing failed for RSA … from agent: agent refused operation

SSH found the key, but gpg-agent could not complete the signature

Hand on the reader; confirm gpg --card-statusand echo test | gpg --clearsign work; run gpg-connect-agent updatestartuptty /bye

Load key "…/id_vivokey.pub": invalid format

The agent offers no card key, so SSH falls back to the public key file

Put your hand on the reader and retry

"agent refused operation" is the vague one. It covers anything between the agent and the chip: scdaemon not set up on that Mac, no stubs yet, pinentry unable to show a prompt, or no card in range. Work outwards from the card: if gpg itself cannot sign, SSH cannot either.

For deeper digging, gpg-agent.conf accepts log-file /tmp/gpg-agent.log and debug-level guru, and ssh -vshows which keys SSH offers.

Security notes

A login now needs two factors: the implant in range of the reader, and the User PIN.

  • Nothing to steal on the laptop. The Mac holds only the public key and stubs that point to the card. A copied ~/.ssh or ~/.gnupg cannot log in anywhere.
  • The PIN is rate-limited by the chip. The card allows three wrong User PIN attempts before it blocks the PIN.
  • Reading the chip is not using it. Anyone close enough with a reader can read the card's public data without a PIN. A signature, and so a login, still requires the PIN.
  • Revocation is per server. If the implant or the PIN were ever compromised, I would remove the id_vivokey.publine from authorized_keys on every server. The OpenPGP subkey itself can be revoked with the primary key from my offline backup.
  • The public key is public. The authorized_keys line can be shared freely; it is derived from the same OpenPGP key I published on keys.openpgp.org.

Sources

  • GnuPG manual: Agent Options: enable-ssh-support, updatestartuptty, starting the agent for SSH, and the order in which keys are offered (active cards first)
  • OpenBSD ssh_config(5): IdentityFile pointing at a public key for an agent-held private key, and IdentitiesOnly
  • SmartPGP README: the OpenPGP card applet on the implant

→ Disclaimer