Update an existing GPG key when the same key is added again
What does this MR do and why?
A GPG key cannot be changed after it is added: a new version of the same key (a later expiration, a new subkey or user ID, a revocation) fails with "Fingerprint has already been taken", so users remove the key and add it again, which unlinks every signature and checks it again.
Now, when the key added in User settings > GPG keys has the fingerprint of one of the user's keys, Repositories::GpgKeys::UpdateService merges the new version into it, as gpg --import does.
- Nothing is removed, and an older version cannot undo a revocation or an expiration change.
- Refused with a message: another key, several keys, a secret key, a key revoked or still expired after the merge, a merged key over 4 MiB. Another user's fingerprint still answers "Fingerprint has already been taken".
- The same text again answers "This GPG key is already up to date." and writes nothing.
- When the merge adds something, new subkeys get their rows and
InvalidGpgSignatureUpdateWorkerruns with its existing rule, as after adding a key. - Docs: a new "Update a GPG key" section.
Follow-ups, as stacked MRs on this one, in this order:
- The
InvalidGpgSignatureUpdaterrule, so a verified subkey signature keeps its status. - The owner's email, behind the
updated_gpg_key_emailflag (rollout issue #636223). - The
update_gpg_keyaudit event.
The REST API comes after them.
Database
No migration. The update runs GpgKey#stored_duplicate (a SELECT on gpg_keys by user_id and fingerprint, after a create failed on uniqueness), lock! (SELECT ... FOR UPDATE by id), save! (UPDATE gpg_keys ... WHERE id), and one INSERT INTO gpg_key_subkeys per new subkey, as the create does.
How I tested it
Ruby 3.3.11 and PostgreSQL 17, without the GDK:
bundle exec rspec spec/lib/gitlab/gpg_spec.rb spec/models/gpg_key_spec.rb spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb spec/requests/user_settings/gpg_keys_controller_spec.rb spec/features/profiles/gpg_keys_spec.rb spec/services/repositories/gpg_keys/update_service_spec.rb spec/services/gpg_keys/: 162 examples, 1 failure, the one below.- The service, request and model specs with
FOSS_ONLY=1: 87 examples, 0 failures. - RuboCop,
gettext_extractor, markdownlint-cli2 and Vale on the changed files: clean.
I saw the new specs fail on master's code first. invalid_gpg_signature_updater_spec.rb:70 fails on master too (a let_it_be_with_reload record keeps the other example's commit double), and this MR does not touch it.
The new fixtures in spec/support/helpers/gpg_helpers.rb come from the secret keys already there, with GnuPG 2.2.40: --gen-revoke, --quick-set-expire under --faked-system-time, --quick-add-uid, and revkey in --edit-key.
References
Closes #296977
- The previous change to this code: !255300 (merged).
- Milestone: 19.6, as in the docs history. I cannot set it myself.
- I found this while working on gitlab-mcp-server, which keeps a record of its upstream work in upstream-bugs.md.
Screenshots or screen recordings
Only the notice changes:
| Before | After |
|---|---|
| A new version of your key: "Fingerprint has already been taken" | "GPG key updated." |
| The same key again: "Key has already been taken" | "This GPG key is already up to date." |
How to set up and validate locally
-
Make a throwaway key with an email address of your user, and add the output in User settings > GPG keys:
export GNUPGHOME=$(mktemp -d) g() { gpg --batch --pinentry-mode loopback --passphrase '' "$@"; } g --quick-gen-key 'Test User <you@example.com>' ed25519 sign 1y FPR=$(gpg --list-keys --with-colons | awk -F: '/^fpr/ { print $10; exit }') gpg --armor --export "$FPR" -
Run
g --quick-add-key "$FPR" ed25519 sign 1y, then add the newgpg --armor --export "$FPR". GitLab shows GPG key updated. and lists the new subkey. -
Add the same export again. GitLab shows This GPG key is already up to date.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.