Ken Muse

Automatic SSH Commit Signing With Dotfiles


I previously talked about how dotfiles can improve the development experience. By automating the processing of setting up your environment, you are free to focus on more important things. One of the more mundane tasks for developers is setting up commit signing and verification. By doing this, others can verify that you are the author of a specific commit. It just requires some setup, especially if you want automatic support in your dev containers. For these examples, I’m going to use SSH-based commit signing. It’s a common approach, and it doesn’t require sharing a private key between environments.

It’s important to know that your local machine’s SSH settings can be pulled directly into your dev containers. This fact simplifies the setup. Since local dev containers run on a trusted environment, we don’t need to generate or configure a separate set of keys. Codespaces uses a different mechanism to support commit signing. It natively supports GPG commit signing and verification. While the process I describe here can be used in Codespaces to allow for SSH signing, I won’t explore on that approach.

Let’s understand the process we’ll use for this automation.

Creating keys

A general practice with SSH is to always create a new key for a new environment. This makes it easier to invalidate a key if an environment is compromised. This isn’t a mandatory requirement; like PGP keys, this is intended to represent your identity. To support automated processes, it helps to have a consistent convention for the comments applied to your keys. This makes them easier to discover. Naturally, for Git you’ll want to associate the key with the email you intend to use. Optionally, a prefix can make it easier to understand the purpose for a key. For example, git:user@domain.

To create a key, you just need to execute:

1ssh-keygen -t ed25519 -b 4096 -C $key_comment -f $keyfile

This process of creating a key with ssh-keygen needs a passphrase to use for encrypting the private key (rather than storing it in plain text). If you add -P $phrase to the command line, you can dynamically provide the passphrase. You can even generate a pseudo-random phrase:

1phrase=$(dd if=/dev/urandom bs=21 count=1 2>/dev/null | base64)

Registering the public key

The GitHub CLI can be used to register the public key with GitHub. This allows GitHub to recognize it as a valid key associated with the user. This will require you to sign in to GitHub (or to provide a PAT using the environment variableGH_TOKEN).

1machine=$(uname -n)
2gh auth refresh -h github.com -s admin:ssh_signing_key
3gh ssh-key add $keyfile -t "Signing Key for ${machine}" --type signing

The SSH agent

To use SSH, an agent (or Windows service) must be started and the required keys added to it. The passphrase for the key will be needed for this process. This allows SSH to unencrypt the private key and enable it encryption. On macOS, the Apple Keychain optionally store the passphrase to avoid re-prompting; you just need to add the argument --apple-use-keychain. To dynamically take advantage of this, you can add the argument to a script if you’re running on macOS:

1declare -a keyopts
2if [ $(uname) = "Darwin" ]; then
3  keyopts+=("--apple-use-keychain")
4fi
5keyopts+=($keyfile)

Similarly, the OpenSSH implementation that ships with Windows 10 securely stores the passphrase details automatically. It does not require an argument to enable that feature — it’s always on. In both cases, signing in to the system unlocks the keys.

Linux takes a different approach, requiring you to always provide the passphrase once per login session. For a non-shared environment, it’s possible to have a single ssh-agent process running, allowing you to login one time after a restart. Using Linux under Windows (with WSL2), you generally follow the Linux approach. It is possible to proxy to the Windows OpenSSH service agent, but that’s beyond the scope of this article (and it’s still not the most seamless experience).

Windows allows the OpenSSH service to be started automatically. For both macOS and Linux, the ssh-agent process must be started manually. To automate starting the service, it can be added to the script in $HOME/.bash_profile (for Bash) or $HOME/.zprofile (for Zsh). Microsoft provides this script to automate starting a single agent per machine:

 1if [ -z "$SSH_AUTH_SOCK" ]; then
 2   # Check for a currently running instance of the agent
 3   RUNNING_AGENT="`ps -ax | grep 'ssh-agent -s' | grep -v grep | wc -l | tr -d '[:space:]'`"
 4   if [ "$RUNNING_AGENT" = "0" ]; then
 5        # Output the script and environment variables for the agent
 6        # if there isn't already a running instance.
 7        ssh-agent -s &> $HOME/.ssh/ssh-agent
 8   fi
 9   # Run the script to start or connect to the agent
10   eval `cat $HOME/.ssh/ssh-agent`
11fi

If you need to detect the shell dynamically to write the updated profile, the environment variable SHELL can be used. If you’re using a dotfiles repository that contains your profile scripts, just add these few lines to the end of the script.

Adding keys with passphrases

If is possible to add a key to the agent and programmatically provide the password. If you are using an environment that allows you to securely distribute secrets, you can silently provide the passphrase during the ssd-add using a script or executable that is responsible for providing the secret. The command line would look like this:

1SSH_ASKPASS_REQUIRE=force SSH_ASKPASS="./setpass.sh" PASSWORD=$phrase ssh-add ${keyopts[@]}

Where $keyopts is an array of arguments for the ssh-add application. The setpass.sh script can be something as simple as this:

1echo $PASSWORD

The ssh-add command will use the SSH_ASKPASS environment variable to identify a script or executable to run to retrieve the password. SSH_ASKPASS_REQUIRE instructs ssh-add to use the program rather than tty to retrieve the passphrase.

Configuring Git

The final part of the automation process is to configure Git to do commit signing with the SSH key. If you’re using a preconfigured .gitconfig in your dotfiles, then most of this is handled for you (aside from any copying of private keys). You can safely reference the public key in the .gitconfig – after all, that’s why it’s a public key! Just remember that if you’re putting the contents of the key into the file, it should always be prefixed with key::. Otherwise, you can simply specify the path to the public key. From the command line, it looks like this:

1git config --global user.signingkey $HOME/gh-key.pub

To enable SSH signing, you need to configure commit.gpgsign and gpg.format in your .gitconfig. Alternatively, the command line for that:

1# Enable commit signing
2git config --global commit.gpgsign true
3
4# Configure the signing process to use SSH
5git config --global gpg.format ssh

Verification support

To be able to verify a public key, .gitconfig needs to have an entry for gpg.ssh.allowedSignersFile, pointing to a file that contains the public keys (allowed_signers). The file is normally stored in the $HOME/.ssh directory and contains one key per line, normally in the form:

1email-or-friendly-name namespaces="git" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD…

You can automatically add the registered keys for GitHub users (including yourself) by making an HTTP request to GitHub. The response will contain the public keys for the user. The following script will do that for you (assuming you have a public handle for the user):

1local -r signers="$HOME/.ssh/allowed_signers"
2while read pubkey; do
3    if [ $(grep -F "$pubkey" "$signers" 2> /dev/null | wc -l) -eq 0 ]; then
4      # Add the key, narrowly scoped for Git
5      echo "${handle} namespaces=\"git\" ${pubkey}" >> ${signers}
6    fi
7done < <(gh api users/${handle}/ssh_signing_keys --paginate --jq '.[] | .key | @text')

For cases where the gh CLI is not available, you can also use curl and jq to retrieve the keys:

1curl -sSL https://api.github.com/users/${handle}/ssh_signing_keys | jq '.[] | .key | @text' -r

With the approved signers properly configured and populated, you should be able to use git log --show-signature to see all of the commits and verify the signatures.

Back to the dotfiles

Remember that if an install.sh (or for Windows, install.cmd) exists in the dotfiles repo, it will be automatically executed by a dev container to configure the environment. For environments that are not dev containers, we will want to either create a new key or copy an existing key, then run these scripts. In Codespaces, we can skip these steps and use the native GitHub-provided GPG signing.

For dev containers in VS Code, the process is simpler. We just need to ensure the SSH agent is running on the host machine. VS Code’s dev container extension will automatically forward the local SSH agent into the container if it’s running. The only thing we have to do is make sure the container has the right configuration. If the pre-configured .gitconfig and allowed_signers are stored in the dotfiles repository, then it will automatically be setup and used.

Dynamic discovery for dev containers

Here’s a sample install.sh script for a dev container. It will dynamically discover the configured SSH signing key by using the email address from the Git configuration. Since a dev container is typically spun up in the context of a Git repo, this should work for the current user. A local key file is not available, so a key:: reference is used in the .gitconfig file.

 1#!/bin/bash
 2
 3# Reads the list of keys from the agent
 4# and returns the first signing key that 
 5# matches the user's email address
 6find_signing_key() { 
 7  echo "$(ssh-add -L | grep "$(git config --get user.email)" | head -n 1 )"
 8}
 9
10setup_signing() {
11  # Prevent this function from running in Codespaces
12  if [ "$CODESPACES" = "true" ]
13  then
14    return
15  fi
16
17  local key=$(find_signing_key)
18  local signers="$HOME/.ssh/allowed_signers"
19
20  # If a key is found, configure the environment
21  if [ -n "$key"  ]
22  then
23    echo "Setting up signing"
24
25    # Make sure SSH files and folders exist
26    [ -d "$HOME/.ssh" ] || mkdir $HOME/.ssh
27    [ -f $HOME/.ssh/config ] || touch $HOME/.ssh/config
28    [ -f $signers ] || touch $signers
29
30    # Configure Git to use the key for signing
31    git config --global gpg.format ssh
32    git config --global commit.gpgsign true
33    git config --global user.signingkey "key::$key"
34
35    # Configure Git to use the allowed signers file
36    git config --global gpg.ssh.allowedSignersFile "$signers"
37    
38    # If no lines are found that match the key, append it
39    # to the allowed signers file
40    if [ $(grep -F "$key" "$signers" 2> /dev/null | wc -l) -eq 0 ]
41    then
42      echo "$(git config --get user.email) namespaces=\"git\" $key" >> $signers
43    fi
44  else
45    echo "No signing key found"
46  fi  
47}
48
49# The main entry point for our script, organizing
50# the other function calls.
51main() {
52    setup_signing
53}
54
55# Execute the main function
56main