Git repository authentication and commit signing are two independent stages: the former determines whether code can be pulled and pushed, while the latter enables recipients to verify which key signed a commit. This article uses two separate pairs of FIDO2 SSH keys to manage the two uses independently.

Git supports signing commits and tags with SSH keys, so when working exclusively with Git, you can use YubiKey’s FIDO2 functionality directly without deploying GnuPG. For projects that require an OpenPGP signature-verification workflow, choose GPG signing according to the project’s requirements.

Approach and Scope

PurposeApproach
Repository SSH authenticationA dedicated ed25519-sk credential
Commit and tag signingA separate, independent ed25519-sk credential pair
Use across computersA resident credential (resident)
Verification during useverify-required, retaining the default touch requirement
Hardware redundancyGenerate an independent credential with a second YubiKey and register its public key in advance

This article targets macOS, Homebrew, and Gitea. The email addresses, identity names, domains, and repository URLs in the commands are examples. The authentication server must support OpenSSH FIDO key types; older SSH servers may not accept ed25519-sk.

FIDO2 credentials do not use PIV slots 9a, 9c, or 9d. This article does not include any YubiKey reset procedures either.

1. Confirming the Software and Hardware

First, check the actual versions and executable paths:

git --version
command -v git
brew --prefix openssh
"$(brew --prefix openssh)/bin/ssh" -V
ykman info
ykman fido info

Git’s SSH signing requires version 2.34 or later. It is recommended to use a recent OpenSSH build compiled with FIDO support; a recent version number alone does not prove that the build supports hardware keys. This article uses Homebrew OpenSSH and explicitly specifies executable paths to avoid mixing it with the macOS built-in version.

ed25519-sk requires hardware support for the corresponding algorithm; for YubiKey, this means firmware version 5.2.3 or later. If the device does not support it, consult its official algorithm list; support for FIDO2 alone does not imply support for Ed25519.

If the tools are missing, install them:

brew install openssh git ykman

Skip the installation if the tools are already installed and their versions meet the requirements. Older versions of macOS may fall outside the support range of current Homebrew binary packages, in which case installation may involve compiling from source or upgrading dependencies. This is a software installation issue and does not require regenerating the keys.

If the FIDO2 PIN has not yet been set, configure it interactively:

ykman fido access change-pin

Skip this step if a PIN is already set. The FIDO2 PIN, PIV PIN, and local file password are distinct concepts and should not be conflated.

2. Generating Separate Authentication and Signing Credentials

First, insert only the primary YubiKey. Set the variables in the same terminal, changing the email address to a verified email address on the hosting platform account:

CX_GIT_EMAIL='developer@example.com'
CX_KEY_ROLE=primary
CX_SSH_DIR="$(brew --prefix openssh)/bin"

mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

CX_AUTH_KEY="$HOME/.ssh/id_ed25519_sk_git_auth_${CX_KEY_ROLE}"
CX_SIGN_KEY="$HOME/.ssh/id_ed25519_sk_git_sign_${CX_KEY_ROLE}"

The following loop generates the authentication and signing keys separately. Existing files with the same names are skipped. If only some of the files were generated previously, first inspect the files and the state of the credentials on the hardware, and then decide how to proceed.

for CX_KEY_USE in auth sign; do
  CX_KEY_FILE="$HOME/.ssh/id_ed25519_sk_git_${CX_KEY_USE}_${CX_KEY_ROLE}"

  if [ -e "$CX_KEY_FILE" ] || [ -e "$CX_KEY_FILE.pub" ]; then
    printf '跳过已有文件:%s\n' "$CX_KEY_FILE"
    continue
  fi

  "$CX_SSH_DIR/ssh-keygen" \
    -t ed25519-sk \
    -O resident \
    -O verify-required \
    -O "application=ssh:git-${CX_KEY_USE}-${CX_KEY_ROLE}" \
    -O "user=developer-${CX_KEY_ROLE}" \
    -C "git-${CX_KEY_USE}-${CX_KEY_ROLE}" \
    -f "$CX_KEY_FILE" || break
done

If any generation operation fails, resolve the error before continuing with the configuration. If prompted to overwrite an existing credential on the hardware, choose n and first confirm the credential’s purpose.

The parameters have the following meanings:

ParameterPurpose
-t ed25519-skCreate an Ed25519 SSH key supported by the hardware
-O residentAlso store the credential reference on the hardware, making it easy to retrieve it on another computer
-O verify-requiredRequest user verification for every signature; a standard YubiKey normally uses a PIN
-O application=ssh:…Distinguish the credential’s application identifier; it must begin with ssh:
-O user=…Set the username associated with the resident credential
-C …Set the local SSH key comment

Touch interaction is user-presence verification, whereas a PIN is user identity authentication; the two serve different purposes. resident primarily facilitates use across computers; it does not increase the algorithm’s strength.

The Enter passphrase prompt shown during generation is the encryption password for the local reference file. You can set it as needed or leave it blank. Even if the local reference file has no password, hardware signing still requires the corresponding YubiKey and compliance with the credential’s verification policy.

The local file without the .pub extension references the hardware credential and cannot sign independently of the hardware like an ordinary software private key. The .pub file is used to distribute the public key.

3. Configuring Git Authentication and Signing

The following uses global default settings and affects the current user’s Git repositories. Local configuration in an existing repository may override the global settings. If different identities are needed for each project, enter the repository and use git config --local.

git config --global user.name 'Developer'
git config --global user.email "$CX_GIT_EMAIL"

git config --global core.sshCommand \
  "\"$CX_SSH_DIR/ssh\" -i \"$CX_AUTH_KEY\" -o IdentitiesOnly=yes"

git config --global gpg.format ssh
git config --global gpg.ssh.program "$CX_SSH_DIR/ssh-keygen"
git config --global user.signingkey "$CX_SIGN_KEY"

git config --global commit.gpgsign true
git config --global tag.gpgsign true

There are three easily confused points here:

  • core.sshCommand is used for Git’s SSH transport. Neither the email address nor commit signing is configured through it. Globally specifying an authentication file means Git uses the same authentication key by default with every SSH hosting platform. When platform-specific keys are required, use repository-local configuration or SSH’s Host configuration instead.
  • gpg.format = ssh specifies the signing format. Although the setting name contains gpg, the actual signing program is ssh-keygen.
  • user.signingkey points to the local key reference file without the .pub extension, allowing the signing process to use it directly. When configured with .pub, the corresponding key must already be loaded into a usable ssh-agent.

The main procedure in this article explicitly specifies the signing key, does not rely on automatic selection, and does not require running ssh-add first.

4. Configuring Local Signature Verification

A valid signature and a trusted signer are two different matters. Git uses allowedSignersFile to associate trusted SSH public keys with identities.

Use the existing global trust list; when it has not been configured, use ~/.ssh/allowed_signers:

CX_ALLOWED_SIGNERS="$(git config --global --path --get gpg.ssh.allowedSignersFile)"
CX_ALLOWED_SIGNERS="${CX_ALLOWED_SIGNERS:-$HOME/.ssh/allowed_signers}"

mkdir -p "$(dirname "$CX_ALLOWED_SIGNERS")"

printf '%s namespaces="git" %s\n' \
  "$CX_GIT_EMAIL" \
  "$(cat "$CX_SIGN_KEY.pub")" >> "$CX_ALLOWED_SIGNERS"

git config --global gpg.ssh.allowedSignersFile "$CX_ALLOWED_SIGNERS"

This operation appends to the file and does not clear existing entries. Running it repeatedly creates duplicate lines, which can be removed manually. The public-key portion is read directly from the .pub file, preserving its actual algorithm identifier. The public key type corresponding to ed25519-sk is usually sk-ssh-ed25519@openssh.com; do not manually change it to ssh-ed25519.

namespaces="git" restricts this trust entry to Git signing use. This file only controls local signature verification on the current computer. It is not automatically synchronized to Gitea and does not imply that the public key has already been uploaded.

5. Verifying in a Temporary Repository

CX_TEST_REPO="$(mktemp -d "${TMPDIR:-/tmp}/git-sign-test.XXXXXX")"

git init "$CX_TEST_REPO"

git -C "$CX_TEST_REPO" commit --allow-empty -m "Test hardware SSH signing"
git -C "$CX_TEST_REPO" verify-commit HEAD

git -C "$CX_TEST_REPO" tag -m "Test signed tag" yubikey-test
git -C "$CX_TEST_REPO" verify-tag yubikey-test

Follow the prompts to enter the PIN and touch the hardware when signing. When both verifications succeed, you will see output similar to the following, and the command’s exit status should be 0:

Good "git" signature for developer@example.com ...

You can also view the signature of the most recent commit:

git -C "$CX_TEST_REPO" log -1 --show-signature

This is a local test and does not push anything to the remote. If an askpass error appears at this point, first troubleshoot the interactive environment as described below, and then rerun whichever commit- or tag-creation command failed.

6. Registering and Verifying Public Keys in Gitea

Go to the SSH/GPG key management page in your personal settings. Both the authentication public key and the signing public key are SSH public keys and should be added separately to the SSH keys section.

First, copy the authentication public key, paste it, and save it as git-auth-primary:

pbcopy < "$CX_AUTH_KEY.pub"

After saving it, copy the signing public key and add it as git-sign-primary:

pbcopy < "$CX_SIGN_KEY.pub"

Generating the authentication and signing keys separately makes independent rotation easier, but do not assume that the hosting platform provides strict purpose isolation between these two public keys. An account SSH public key in Gitea usually also grants repository access; the exact behavior depends on the installed version.

Perform the ownership verification offered on the page for the signing public key. Use the token value generated for the current instance of the page, and use the namespace specified by the command on the page. If the page uses -n gitea:

printf '%s' '替换为页面当前显示的token' |
  "$CX_SSH_DIR/ssh-keygen" -Y sign -n gitea -f "$CX_SIGN_KEY"

Copy the complete signature block in the output, from -----BEGIN SSH SIGNATURE----- to -----END SSH SIGNATURE-----, back into the page. Do not include the command prompt, PIN prompt, or touch prompt.

Gitea’s token verification and the local allowed_signers configuration are separate matters. The email address in a commit should also correspond to an activated email address on the account. Successful local verification does not mean that the platform will necessarily display it as verified.

To test repository access, copy the complete SSH clone URL from the repository page:

git ls-remote 'ssh://git@git.example.com:2222/developer/project.git'

Replace the domain, SSH port, and repository path in the example. Use the SSH port specified by the clone URL; do not infer it from the port shown on the HTTPS page. For an existing repository, you can also inspect git remote -v to confirm that it uses an SSH address.

7. Configuring a Backup YubiKey

Remove the primary key, insert only the backup key, and confirm that its FIDO2 PIN has been set. In the original terminal, execute:

CX_KEY_ROLE=backup

Then, starting from the CX_AUTH_KEY=… and CX_SIGN_KEY=… lines in Section 2, repeat the generation, configuration, local testing, and platform registration procedures. Do not set CX_KEY_ROLE back to primary. The new files will use the backup suffix, and the corresponding public keys in Gitea should also use different names and complete signing public-key verification.

The backup key holds an independent key. The primary key’s FIDO2 credential cannot be cloned by copying its local reference file. Both public keys need to be registered in advance. Only after both signing public keys have been added to the trust list can the local machine recognize both signatures.

After testing the backup key, reinsert the primary key. To switch between keys later, execute the following commands, changing only the role on the first line:

CX_KEY_ROLE=primary
CX_SSH_DIR="$(brew --prefix openssh)/bin"
CX_AUTH_KEY="$HOME/.ssh/id_ed25519_sk_git_auth_${CX_KEY_ROLE}"
CX_SIGN_KEY="$HOME/.ssh/id_ed25519_sk_git_sign_${CX_KEY_ROLE}"

git config --global core.sshCommand \
  "\"$CX_SSH_DIR/ssh\" -i \"$CX_AUTH_KEY\" -o IdentitiesOnly=yes"

git config --global user.signingkey "$CX_SIGN_KEY"

Switching changes only the key used for subsequent authentication and signing. It does not modify historical commits.

8. Restoring References on a New Computer

On the new computer, install OpenSSH with FIDO support, and then enter a newly created empty directory:

CX_SSH_DIR="$(brew --prefix openssh)/bin"
CX_RESTORE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ssh-resident-restore.XXXXXX")"
cd "$CX_RESTORE_DIR"
"$CX_SSH_DIR/ssh-keygen" -K

Enter the PIN and operate the hardware when prompted. ssh-keygen -K writes the references and public keys for the resident credentials into the current directory; it does not export the actual private key from the YubiKey.

Identify the purpose by comparing the fingerprint with the registered public key, and then move the corresponding file to ~/.ssh and give it the desired name:

"$CX_SSH_DIR/ssh-keygen" -lf '实际恢复出的公钥文件.pub'

The restored local filename, comment, and encrypted wrapper content may differ from those on the old computer. Confirm the identity using the public key and fingerprint, rather than checking whether the reference files are byte-for-byte identical. After restoration, you must also reconfigure Git and the local trust list.

9. Common Issues

1. ssh-askpass does not exist, or the PIN cannot be entered

For example:

ssh_askpass: exec(/usr/X11R6/bin/ssh-askpass): No such file or directory
Couldn't sign message: incorrect passphrase supplied to decrypt private key

First, check the signing program, runtime environment, and complete error output. The missing askpass already indicates a problem with the interactive input path; the generic error that follows cannot, by itself, prove that the PIN was entered incorrectly. The hardware-signing PIN request from ssh-keygen may be handled through askpass. IDE, pipeline, and agent environments can also affect how prompts are presented.

If no usable askpass is available, you can use the following minimal helper on macOS. It reads the password through a system dialog, outputs it to the calling process, and does not write the password to a file. It is a helper implementation provided by this article, not a program included with OpenSSH.

mkdir -p "$HOME/.local/bin"

cat > "$HOME/.local/bin/ssh-askpass-macos" <<'SH'
#!/bin/sh
case "${SSH_ASKPASS_PROMPT:-}" in
  none) exit 0 ;;
  confirm)
    exec /usr/bin/osascript - "$@" <<'APPLESCRIPT'
on run argv
    display dialog (item 1 of argv) buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with title "OpenSSH"
    return ""
end run
APPLESCRIPT
    ;;
esac
exec /usr/bin/osascript - "$@" <<'APPLESCRIPT'
on run argv
    set resultDialog to display dialog (item 1 of argv) default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with title "OpenSSH"
    return text returned of resultDialog
end run
APPLESCRIPT
SH

chmod 700 "$HOME/.local/bin/ssh-askpass-macos"
export SSH_ASKPASS="$HOME/.local/bin/ssh-askpass-macos"
export SSH_ASKPASS_REQUIRE=force

Rerun the failed signing operation. force makes the relevant password input use askpass first, so there is no need to set DISPLAY arbitrarily just to display the dialog. Canceling the dialog causes the current operation to fail, which is expected.

Once it has been confirmed to work, you can put the last two export lines in your shell startup file. An IDE launched from the graphical interface may not read the shell startup file, so make sure that the actual process running Git can inherit both variables. You can verify this by launching an IDE that supports command-line launching from a terminal with the environment configured. If signing is performed by an agent, the agent’s own environment must also be configured correctly; changing only the client variables does not update an already running agent.

2. ssh-add -L does not show the key after it is inserted

Inserting the hardware key does not automatically add all resident credentials to the current agent. First, confirm the agent and executable paths in use:

printf '%s\n' "$SSH_AUTH_SOCK"
"$CX_SSH_DIR/ssh-add" -L

If the agent is genuinely needed, add the specific local reference file:

"$CX_SSH_DIR/ssh-add" "$CX_SIGN_KEY"
"$CX_SSH_DIR/ssh-add" -L

You can also load resident credentials from the hardware:

"$CX_SSH_DIR/ssh-add" -K

You must use a FIDO-capable OpenSSH ssh-add here. Do not confuse it with the historical same-named option in the macOS built-in ssh-add. If the current agent does not support this type, first resolve the agent compatibility and environment issues. Adding a reference does not export the actual hardware private key, nor does it remove subsequent PIN or touch requirements.

The main procedure in this article uses a local reference file and does not depend on the signing key appearing in the ssh-add -L output.

3. defaultKeyCommand returns no keys

For example:

warning: gpg.ssh.defaultKeyCommand succeeded but returned no keys
error: user.signingKey needs to be set for ssh signing

gpg.ssh.defaultKeyCommand is the mechanism for querying candidate keys when user.signingkey has not been set. It does not automatically discover inserted hardware. If the query command succeeds but produces no public key, signing still cannot be completed.

With the fixed-key approach used in this article, you can inspect the effective configuration:

git config --show-origin --get user.signingkey
git config --show-origin --get gpg.ssh.defaultKeyCommand
git config --show-origin --get gpg.ssh.program

Then explicitly specify the local reference file:

git config --global user.signingkey "$CX_SIGN_KEY"

Repository-local configuration overrides global configuration, so adjustments should be made based on the source indicated by --show-origin. If you continue to rely on automatic selection by the agent, first confirm that the agent actually holds the key, and then make the query script output only the target signing public key. Do not arbitrarily select the first entry from ssh-add -L, because it may contain authentication keys, keys for other accounts, or references to hardware keys that have already been removed.

4. It works locally, but the platform still does not verify it

Check, in order, whether the signing public key has been registered, whether ownership verification has been completed, whether the commit email address belongs to the corresponding account, and whether the server version, verification configuration, and on-page prompts are correct. Always use the actual .pub contents; do not manually change the algorithm name.

For an existing repository that is not signing as expected, inspect the configuration sources:

git config --show-origin --get gpg.format
git config --show-origin --get user.signingkey
git config --show-origin --get commit.gpgsign
git config --show-origin --get gpg.ssh.allowedSignersFile

5. Rebase or bulk re-signing requires repeated operations

Signing is bound to the commit object. Operations that modify commit objects, such as Rebase and Amend, require the new objects to be signed again, which may result in multiple PIN or touch interactions. Hardware protection restricts the use and export of private keys, but it cannot replace code review or prove that the signed content is not malicious.

Unattended CI should use a separate bot identity with restricted credentials. It should not depend on a personal, daily-use YubiKey that requires human interaction.

References