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-keygentool). 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-labis touched. - No network access. The
ssh-keyscanreference 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 <principal> <key-type> <key-body>. 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.pubshows an Ed25519 key with a fingerprint that matches the key used by the lab.git config --get gpg.formatreturnsssh.git config --get user.signingkeyreturns the absolute path to the public key.git config --get gpg.ssh.allowedSignersFilereturns the absolute path toallowed-signers.git verify-commit HEADexits 0 with “Good signature from ops@example.com” or similar.git verify-tag v1.0.0exits 0 with “Good signature from ops@example.com” or similar.git verify-commit HEADon the rogue commit in Task 7 exits non-zero.git log --show-signature --onelineshows every commit’s verification status.- The deliverables
signing-key.pub,allowed-signers,signed-commits.txt, andverification-report.txtexist 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 <name> 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 sshanduser.signingkeysettings are global by default, which means a single setup covers every repository on the workstation. - The signature header is named
gpgsigregardless 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. allowedSignersFileis the trust anchor. Without it,git verify-commitaccepts 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-signatureis 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.