Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · intermediate · ~60 min

Lab 7: Configure SSH signing for commits and tags

C · SimulationB · Nested virtualisation

Objectives

  • Generate an SSH key pair suitable for commit signing and document why the key type matters
  • Configure Git to sign commits and tags with the SSH key via `gpg.format = ssh` and the matching `user.signingkey`
  • Sign commits with `git commit -S` and tags with `git tag -s` and verify both with `git verify-commit` and `git verify-tag`
  • Show that a commit signed by a different key (or by no key) fails verification
  • Configure an `allowedSignersFile` so `git log --show-signature` can verify signatures against a known list of signing keys
  • Document the operational difference between "SSH signing" and "GPG signing" and when each is the right choice

Prerequisites

Objective

By the end of this lab you will have configured Git to sign commits and tags with an SSH key, signed several commits and an annotated tag, verified each signature locally, and shown that a commit signed by the wrong key fails verification. You will also have written an allowedSignersFile that maps an email address to a public key, so git log --show-signature can verify signatures without interactive prompts.

The point of this lab is to make signing concrete. A signature is a cryptographic proof that this key signed this commit. The operational question is not “did the commit get signed?” — Git can report that from the commit object — but “is the signature from a key the team trusts?” The allowedSignersFile is the answer to that question.

Architecture

A single repository with a dedicated SSH key, a config that points Git at the key, and a signed history. The verification chain runs locally against the allowed-signers file.

flowchart LR
    K["SSH key pair\nsigning-key / signing-key.pub"]
    K --> G["git config\ngpg.format=ssh\nuser.signingkey=signing-key.pub"]
    G --> C1["git commit -S"]
    G --> C2["git tag -s"]
    C1 --> H[commit objects with ssh-sig]
    C2 --> T[tag object with ssh-sig]
    H --> V["git verify-commit\nvs allowedSignersFile"]
    T --> V2["git verify-tag"]
    A["allowedSignersFile\nemail -> pubkey"] --> V
    A --> V2

The verification path is local. A signature is “good” if it was made by any SSH key that can produce a valid signature over the commit bytes; the allowedSignersFile is the additional check that narrows “any valid key” to “any key on the team’s allow-list”.

Requirements

  • Git 2.55.x on Linux or macOS.
  • OpenSSH 9.6p1 or later (the ssh-keygen tool). The lab targets the Ed25519 key type, which is the default in OpenSSH 9.x and is the recommended signing key type.
  • A clean working directory. Nothing outside $HOME/ssh-signing-lab is touched.
  • No network access. The ssh-keyscan reference is for verifying that the format matches what an upstream service would produce.

Scenario

A security review has flagged that the team has been merging commits “authored by ops@example.com” without any cryptographic proof that the author is actually who the team thinks they are. The mitigation is SSH signing: every commit is signed with a key whose public half is registered with the team, and git verify-commit rejects commits that were not signed by an allowed key.

The lab walks through the configuration that turns on SSH signing, the commands that produce signed commits and tags, and the verification chain that proves the signatures are valid.

Tasks

Task 1 — Generate the SSH key pair

LAB="$HOME/ssh-signing-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

# Ed25519 is the recommended key type for SSH signing. The lab
# generates a fresh key with no passphrase; production keys should
# have a passphrase or be stored on a hardware token.
ssh-keygen -t ed25519 \
  -C 'ops@example.com (lab key — disposable)' \
  -f "$LAB/signing-key" \
  -N ''

ls -l signing-key signing-key.pub

# Confirm the key fingerprint so it can be matched against any
# future verification output.
ssh-keygen -lf signing-key.pub

Ed25519 is the right choice because it produces short keys with strong security properties, has been the OpenSSH default since 8.5 (2021), and is the key type every forger service accepts for SSH signing. RSA keys work but are larger and slower; DSA keys are deprecated.

Task 2 — Configure Git to sign with the SSH key

cd "$HOME/ssh-signing-lab"

# Set the signing format to SSH and the signing key to the public
# half. Git uses the matching private key automatically.
git config --global gpg.format ssh
git config --global user.signingkey "$LAB/signing-key.pub"

# Confirm the configuration.
git config --global --get gpg.format
git config --global --get user.signingkey

gpg.format ssh is the switch that turns on SSH signing. Once it is set, git commit -S and git tag -s will produce SSH signatures (ssh-sig) instead of GPG signatures. The user.signingkey value is the path to the public key; Git pairs it with the matching private key in the same directory.

Task 3 — Initialise the repository and write the allowedSignersFile

cd "$HOME/ssh-signing-lab"

git init -b main
git config user.email 'ops@example.com'
git config user.name  'Ops'

# Build the allowedSignersFile from the public key.
EMAIL='ops@example.com'
PUBKEY="$(cat "$LAB/signing-key.pub")"

# Format is "<principal> <key-type> <key-body>"
echo "$EMAIL $PUBKEY" > allowed-signers

# Configure Git to use it.
git config gpg.ssh.allowedSignersFile "$LAB/allowed-signers"

cat allowed-signers

The allowedSignersFile is a single line per signing identity, in the format &lt;principal&gt; &lt;key-type&gt; &lt;key-body&gt;. The principal is typically the email address that matches the commit’s author/committer field. The key type is the SSH key type as it appears in the public key (ssh-ed25519, ssh-rsa, etc.); the key body is the base64 key material. The file can list multiple keys for multiple team members.

Task 4 — Sign commits and tags

cd "$HOME/ssh-signing-lab"

# C1: signed commit.
echo '# runbook infra' > README.md
git add README.md
git commit -S -m 'C1: initial repository'

# C2: signed commit with explicit GPG_SSH_KEY.
echo 'config = { region = "eu-west-1" }' > config.tf
git add config.tf
git commit -S -m 'C2: pin region'

# An annotated tag, also signed.
echo 'tags = { Owner = "platform" }' > tags.tf
git add tags.tf
git commit -m 'C3: add default tags'

git tag -s 'v1.0.0' -m 'release 1.0.0'

# Confirm the commit objects carry signatures.
git cat-file -p HEAD | tail -5
# expected: gpgsig (or sshsig) field with the signature.

# Confirm the tag object carries a signature.
git cat-file -p v1.0.0 | tail -5

git commit -S produces a commit whose payload ends with an gpgsig field containing the SSH signature. (The field is named gpgsig for historical reasons; Git 2.34+ stores both GPG and SSH signatures under the same field, distinguished by the signature header inside the value.) git tag -s produces an annotated tag object whose payload carries the signature in the same field.

Task 5 — Verify the signatures

cd "$HOME/ssh-signing-lab"

# Verify a single commit.
git verify-commit HEAD
# expected: "Good signature from ops@example.com" or similar.

# Verify the tag.
git verify-tag v1.0.0
# expected: "Good signature from ops@example.com" or similar.

# Verify the whole history.
git log --show-signature --oneline | tee signed-commits.txt

git verify-commit and git verify-tag exit 0 on a valid signature and non-zero on an invalid one. The --show-signature flag on git log produces a multi-line entry per commit, with the verification result on the lines preceding the commit message.

Task 6 — Show that an unsigned commit fails verification

cd "$HOME/ssh-signing-lab"

# An unsigned commit, made by skipping -S.
echo 'unsinged change' > unsigned.tf
git add unsigned.tf
git commit -m 'C4: unsigned change (deliberate)'

# Verify it.
git verify-commit HEAD
# expected: "no signature" or similar — exits non-zero with the
# "gpg.ssh.allow-untrusted-x509" flag not set, or with a warning.

# Show that the chain of signed commits is broken by this commit.
git log --show-signature --oneline

# Remove the unsigned commit so the history is clean again.
git reset --hard HEAD~1

rm -f unsigned.tf

A commit produced without -S has no signature to verify, and git verify-commit reports that. In a CI pipeline, the rule is “every commit on the protected branch must be signed”, and the pipeline runs git verify-commit against every commit reachable from the branch tip.

Task 7 — Show that a wrongly-signed commit fails verification

cd "$HOME/ssh-signing-lab"

# Generate a SECOND SSH key, with a different identity.
ssh-keygen -t ed25519 \
  -C 'attacker@example.com (lab key — disposable)' \
  -f "$HOME/ssh-signing-lab/rogue-key" \
  -N ''

# Sign a commit using the rogue key.
echo 'rogue change' > rogue.tf
git add rogue.tf

GIT_AUTHOR_NAME='Attacker' GIT_AUTHOR_EMAIL='attacker@example.com' \
GIT_COMMITTER_NAME='Attacker' GIT_COMMITTER_EMAIL='attacker@example.com' \
git commit -S -m 'C5: rogue commit signed by an untrusted key'

# Verify it. The signature is cryptographically valid, but the key
# is not in the allowedSignersFile.
git verify-commit HEAD
# expected: "bad signature" or "untrusted signature" — exits
# non-zero.

# Cleanup the rogue commit.
git reset --hard HEAD~1
rm -f rogue.tf

This is the failure mode the allowedSignersFile exists to catch: a signature that is mathematically valid but was produced by a key the team does not recognise. git verify-commit with the file configured checks both the signature and the allow-list.

Task 8 — Capture the deliverables

cd "$HOME/ssh-signing-lab"

# Deliverable 1: the public half of the signing key.
cp signing-key.pub signing-key.pub.deliverable
mv signing-key.pub.deliverable "$HOME/signing-key.pub"

# Deliverable 2: the allowedSignersFile.
cp allowed-signers "$HOME/allowed-signers"

# Deliverable 3: the signed history.
git log --show-signature --oneline > "$HOME/signed-commits.txt"

# Deliverable 4: the explicit verification report.
{
  echo "--- git verify-commit HEAD"
  git verify-commit HEAD
  echo
  echo "--- git verify-tag v1.0.0"
  git verify-tag v1.0.0
  echo
  echo "--- unsigned commit failure mode"
  echo 'unsigned' > unsigned.tf
  git add unsigned.tf
  git commit -m 'unsigned (deliberate)' >/dev/null
  if git verify-commit HEAD; then
    echo "UNEXPECTED: unsigned commit reported as good"
  else
    echo "EXPECTED: unsigned commit reported as bad"
  fi
  git reset --hard HEAD~1
  rm -f unsigned.tf

  echo
  echo "--- wrongly-signed commit failure mode"
  echo 'rogue' > rogue.tf
  git add rogue.tf
  GIT_AUTHOR_NAME='Attacker' GIT_AUTHOR_EMAIL='attacker@example.com' \
  GIT_COMMITTER_NAME='Attacker' GIT_COMMITTER_EMAIL='attacker@example.com' \
  git commit -S -m 'rogue signed (deliberate)' >/dev/null
  if git verify-commit HEAD; then
    echo "UNEXPECTED: rogue commit reported as good"
  else
    echo "EXPECTED: rogue commit reported as bad"
  fi
  git reset --hard HEAD~1
  rm -f rogue.tf
} > "$HOME/verification-report.txt"

ls -l "$HOME/signing-key.pub" "$HOME/allowed-signers" \
      "$HOME/signed-commits.txt" "$HOME/verification-report.txt"

The deliverables are four files in $HOME. The verification report captures both success and failure modes, so a reviewer can confirm the configuration works end-to-end without re-running the lab.

Task 9 — Capture the verification report output

The verification report from Task 8 already includes the explicit output of git verify-commit and git verify-tag, plus the two failure modes. Confirm it is non-empty:

cat "$HOME/verification-report.txt"

The deliverable is self-contained. Reviewers can read it without access to the repository.

Validation

  • ssh-keygen -lf signing-key.pub shows an Ed25519 key with a fingerprint that matches the key used by the lab.
  • git config --get gpg.format returns ssh.
  • git config --get user.signingkey returns the absolute path to the public key.
  • git config --get gpg.ssh.allowedSignersFile returns the absolute path to allowed-signers.
  • git verify-commit HEAD exits 0 with “Good signature from ops@example.com” or similar.
  • git verify-tag v1.0.0 exits 0 with “Good signature from ops@example.com” or similar.
  • git verify-commit HEAD on the rogue commit in Task 7 exits non-zero.
  • git log --show-signature --oneline shows every commit’s verification status.
  • The deliverables signing-key.pub, allowed-signers, signed-commits.txt, and verification-report.txt exist and are non-empty.

Expected Outcome

A repository where every commit and every tag carries an SSH signature that can be verified against a known allow-list, and a verification report that captures both the success and failure modes.

$HOME/ssh-signing-lab/
├── .git/
│   ├── config                     # gpg.format, signingkey, allowedSignersFile
│   ├── objects/
│   └── refs/heads/main
├── allowed-signers                # the allow-list, copied to $HOME
├── config.tf
├── README.md
├── rogue-key                      # the rogue key, deleted in cleanup
├── rogue-key.pub
├── signing-key                    # the private key, deleted in cleanup
├── signing-key.pub                # the public key, copied to $HOME
└── tags.tf

$HOME/
├── allowed-signers                # deliverable
├── signed-commits.txt             # deliverable
├── signing-key.pub                # deliverable
└── verification-report.txt        # deliverable

You can answer the operational question “is this commit signed by a key we trust?” with one command — git verify-commit — and the answer is reproducible from the deliverables alone.

Troubleshooting

git commit -S errors with “failed to sign the data”. The SSH agent does not have the private key loaded, or the path in user.signingkey does not match a file on disk. Re-confirm with git config --get user.signingkey and ls -l "$(git config --get user.signingkey)". If the key has a passphrase, load it into the agent: ssh-add signing-key.

git verify-commit reports “No principal matched”. The allowedSignersFile does not contain the email address used as the commit author. Re-confirm with cat allowed-signers and the commit’s author field. The principal in the allowed-signers file must match the commit author email exactly.

git verify-commit reports “bad signature”. Either the commit was tampered with after signing, or the signature was made with a different key than the one in user.signingkey. Re-check by re-running the commit and re-verifying — if the new commit also fails, the keypair is mismatched and the lab should be restarted from Task 1.

git tag -s errors with “cannot open signed tag”. The tag name already exists with a different content. Delete it with git tag -d &lt;name&gt; and re-run.

The signature header shows -----BEGIN SSH SIGNATURE----- but git verify-commit says “ssh format not supported”. The Git version is older than 2.34, which is when SSH signature support was added. Update Git to 2.55.x or later.

Cleanup

The lab generates two SSH keypairs. Both have no passphrase and no production value; both must be deleted.

LAB="$HOME/ssh-signing-lab"

# Keep the deliverables.
mv "$LAB"/allowed-signers "$LAB"/signing-key.pub \
   "$HOME"/ 2>/dev/null || true

# Remove the private keys, the rogue keypair, and the lab directory.
shred -u "$LAB"/signing-key "$LAB"/rogue-key \
      "$LAB"/rogue-key.pub 2>/dev/null \
  || rm -f "$LAB"/signing-key "$LAB"/rogue-key \
         "$LAB"/rogue-key.pub

rm -rf "$LAB"

find "$HOME" -maxdepth 1 -name 'ssh-signing-lab' -print
# expected: (no output)

# Confirm the global Git config still has the SSH signing settings
# (these are intentional and persist outside the lab).
git config --global --get gpg.format
git config --global --get user.signingkey

If you want to undo the global Git configuration:

git config --global --unset gpg.format
git config --global --unset user.signingkey

What You Learned

  • SSH signing is configured per-user, not per-repo. The gpg.format ssh and user.signingkey settings are global by default, which means a single setup covers every repository on the workstation.
  • The signature header is named gpgsig regardless of format. Git 2.34+ uses the same field for both GPG and SSH signatures; the format is distinguished by the signature body, not the header.
  • allowedSignersFile is the trust anchor. Without it, git verify-commit accepts any mathematically valid signature. With it, signatures are checked against the team’s allow-list.
  • A valid signature is not a trusted signature. This is the single most important operational distinction in commit signing. Production verification always combines signature validity with a trusted-principal check.
  • Ed25519 is the recommended key type. It produces short keys with strong security, has been the OpenSSH default since 8.5, and is accepted by every forger service. RSA is the fallback; DSA is deprecated.
  • Hardware tokens and SSH agents are the production answer to passphrase prompts. A key without a passphrase is convenient for a lab and dangerous for production; a key on a YubiKey or in an unlocked SSH agent is the right balance of safety and ergonomics.
  • git log --show-signature is the CI-friendly verification command. A single command verifies the whole history, exits non-zero on any failure, and produces output that is reviewable in a pull request.

Deliverables

  • · signing-key.pub — the public half of the SSH key, formatted for `allowedSignersFile`
  • · allowed-signers — the allowedSignersFile content, with the principal and key
  • · signed-commits.txt — the output of `git log --show-signature` for the signed history
  • · verification-report.txt — the explicit `git verify-commit` and `git verify-tag` outputs, plus the failure mode for an unsigned or wrongly-signed commit

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.