From 817d9558febde484f23f623bd66d8c861ebc8236 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 14 Feb 2017 19:01:30 -0500 Subject: [PATCH 01/96] Prototype key verification --- Gemfile | 3 +++ Gemfile.lock | 3 +++ app/models/commit.rb | 12 ++++++++++++ app/views/projects/commit/_commit_box.html.haml | 1 + app/views/projects/commit/_signature.html.haml | 4 ++++ app/views/projects/commits/_commit.html.haml | 1 + lib/gitlab/git/commit.rb | 4 ++++ 7 files changed, 28 insertions(+) create mode 100644 app/views/projects/commit/_signature.html.haml diff --git a/Gemfile b/Gemfile index 43109de1b451..93934d03e421 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,9 @@ gem 'validates_hostname', '~> 1.0.6' # Browser detection gem 'browser', '~> 2.2' +# GPG +gem 'gpgme' + # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes # see https://github.com/intridea/omniauth-ldap/compare/master...gitlabhq:master diff --git a/Gemfile.lock b/Gemfile.lock index 6c2ac9368f20..77e87e2885fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -332,6 +332,8 @@ GEM multi_json (~> 1.11) os (~> 0.9) signet (~> 0.7) + gpgme (2.0.13) + mini_portile2 (~> 2.1) grape (0.19.2) activesupport builder @@ -983,6 +985,7 @@ DEPENDENCIES gollum-rugged_adapter (~> 0.4.4) gon (~> 6.1.0) google-api-client (~> 0.8.6) + gpgme grape (~> 0.19.2) grape-entity (~> 0.6.0) grape-route-helpers (~> 2.0.0) diff --git a/app/models/commit.rb b/app/models/commit.rb index 1e19f00106a1..b22aaa3c4619 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -234,6 +234,18 @@ def status(ref = nil) @statuses[ref] = pipelines.latest_status(ref) end + def signature + return @signature if defined?(@signature) + + sig, signed = @raw.extract_signature(project.repository.raw_repository) + if sig && signed + GPGME::Crypto.new.verify(sig, signed_text: signed) do |sign| + @signature = sign + end + end + @signature ||= nil + end + def revert_branch_name "revert-#{short_id}" end diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 45109f2c58b5..1a0c70ef8035 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -6,6 +6,7 @@ = clipboard_button(text: @commit.id, title: _("Copy commit SHA to clipboard")) %span.hidden-xs authored #{time_ago_with_tooltip(@commit.authored_date)} + = render partial: 'signature', object: @commit.signature %span= s_('ByAuthor|by') = author_avatar(@commit, size: 24) %strong diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml new file mode 100644 index 000000000000..7335b6b95978 --- /dev/null +++ b/app/views/projects/commit/_signature.html.haml @@ -0,0 +1,4 @@ +- if signature + %a.btn.disabled.btn-xs{ class: ('btn-success' if signature.valid?) } + %i.fa.fa-key{ class: ('fa-inverse' if signature.valid?) } + = signature.valid? ? 'Verified': 'Unverified' diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 1033bad0d49e..5f67727514a3 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -39,6 +39,7 @@ .commit-actions.flex-row.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) + = render partial: 'projects/commit/signature', object: commit.signature = link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha btn btn-transparent" = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard")) = link_to_browse_code(project, commit) diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 09511cc6504a..d19f55c423bd 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -319,6 +319,10 @@ def parents end end + def extract_signature(repo) + Rugged::Commit.extract_signature(repo.rugged, sha) + end + def stats Gitlab::Git::CommitStats.new(self) end -- GitLab From 28bb5e3d53a585b1fb958d1d91622da0a038bea8 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 16 Feb 2017 15:39:37 +0100 Subject: [PATCH 02/96] commit signature with spec --- app/models/commit.rb | 2 +- lib/gitlab/git/commit.rb | 7 +- spec/models/commit_spec.rb | 40 +++++++ spec/spec_helper.rb | 12 ++ spec/support/gpg_helpers.rb | 222 ++++++++++++++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 spec/support/gpg_helpers.rb diff --git a/app/models/commit.rb b/app/models/commit.rb index b22aaa3c4619..0d50a32d1385 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -237,7 +237,7 @@ def status(ref = nil) def signature return @signature if defined?(@signature) - sig, signed = @raw.extract_signature(project.repository.raw_repository) + sig, signed = @raw.signature(project.repository) if sig && signed GPGME::Crypto.new.verify(sig, signed_text: signed) do |sign| @signature = sign diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index d19f55c423bd..4dcaff5e0a08 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -319,7 +319,12 @@ def parents end end - def extract_signature(repo) + # Get the gpg signature of this commit. + # + # Ex. + # commit.signature(repo) + # + def signature(repo) Rugged::Commit.extract_signature(repo.rugged, sha) end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 528b211c9d62..0fc00ab4f18e 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -414,4 +414,44 @@ expect(described_class.valid_hash?('a' * 41)).to be false end end + + describe '#signature' do + it 'returns nil if the commit is not signed' do + expect(commit.signature).to be_nil + end + + context 'signed commit', :gpg do + it 'returns a valid signature if the public key is known' do + GPGME::Key.import(GpgHelpers.public_key) + + raw_commit = double(:raw_commit, signature: [ + GpgHelpers.signed_commit_signature, + GpgHelpers.signed_commit_base_data + ]) + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(commit.signature).to be_a GPGME::Signature + expect(commit.signature.valid?).to be_truthy + end + + it 'returns an invalid signature if the public commit is unknown', :gpg do + raw_commit = double(:raw_commit, signature: [ + GpgHelpers.signed_commit_signature, + GpgHelpers.signed_commit_base_data + ]) + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(commit.signature).to be_a GPGME::Signature + expect(commit.signature.valid?).to be_falsey + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e7329210896f..6b4ec608efb3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -141,6 +141,18 @@ config.around(:each, :postgresql) do |example| example.run if Gitlab::Database.postgresql? end + + config.around(:each, :gpg) do |example| + Dir.mktmpdir do |dir| + original_dir = GPGME::Engine.dirinfo('homedir') + + GPGME::Engine.home_dir = dir + + example.run + + GPGME::Engine.home_dir = original_dir + end + end end FactoryGirl::SyntaxRunner.class_eval do diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb new file mode 100644 index 000000000000..3b50d831b003 --- /dev/null +++ b/spec/support/gpg_helpers.rb @@ -0,0 +1,222 @@ +module GpgHelpers + extend self + + def signed_commit_signature + <<~SIGNATURE + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v1 + + iQEcBAABAgAGBQJYpIi9AAoJEMcorxCXLpfAZZIH/R/nhcC4s0j6nqAsi9Kbc4DX + TGZyfjed6puWzqnT90Vy+WyUC7FjWJpkuOKQz+NQD9JcBMRp/OC0GtkNz4djv1se + Nup29qWd+Fg2XGEBakTxAo2e9cg38a2rGEIL6V8i+tYAhDt5OyLdzD/XsF0vt02E + ZikSvV02c6ByrjPq37ZdOgnk1xJrS1NM0Sn4B7L3cAz6TYb1OvyG1Z4HnMWgTBHy + e/uKLPRYhx7a4D4TEt4/JWN3sb0VnaToG623EdJ1APF/MK9Es+H7YfgBsyu18nss + 705F+PZ2vx/1b9z5dLc/jQNf+k9vQH4uhmOFwUJnuQ/qB4/3H/UyLH/HfomK7Zk= + =fzCF + -----END PGP SIGNATURE----- + SIGNATURE + end + + def signed_commit_base_data + <<~SIGNEDDATA + tree ed60cfd202644fda1abaf684e7d965052db18c13 + parent 4ded8b5ce09d2b665e5893945b29d8d626691086 + author Alexis Reigel 1487177917 +0100 + committer Alexis Reigel 1487177917 +0100 + + signed commit, verified key/email + SIGNEDDATA + end + + def public_key + <<~PUBLICKEY + -----BEGIN PGP PUBLIC KEY BLOCK----- + Version: GnuPG v1 + + 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 + =1CGB + -----END PGP PUBLIC KEY BLOCK----- + PUBLICKEY + end +end -- GitLab From fbf1fd1a204a24aef2b80473ec64a520ed2a2dfc Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 22 Feb 2017 12:49:17 +0100 Subject: [PATCH 03/96] add gpg key model --- app/models/gpg_key.rb | 35 +++++++++++++++++ db/migrate/20170222111732_create_gpg_keys.rb | 13 +++++++ db/schema.rb | 11 ++++++ spec/models/gpg_key_spec.rb | 41 ++++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 app/models/gpg_key.rb create mode 100644 db/migrate/20170222111732_create_gpg_keys.rb create mode 100644 spec/models/gpg_key_spec.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb new file mode 100644 index 000000000000..16de0b542d7a --- /dev/null +++ b/app/models/gpg_key.rb @@ -0,0 +1,35 @@ +class GpgKey < ActiveRecord::Base + KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze + + belongs_to :user + + validates :fingerprint, + presence: true, + uniqueness: true + + validates :key, + presence: true, + uniqueness: true, + format: { + with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX}).)+\Z/m + } + + before_validation :extract_fingerprint + + def key=(value) + value.strip! unless value.blank? + write_attribute(:key, value) + end + + private + + def extract_fingerprint + import = GPGME::Key.import(key) + + return if import.considered == 0 + + # we can assume that the result only contains one item as the validation + # only allows one key + self.fingerprint = import.imports.first.fingerprint + end +end diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb new file mode 100644 index 000000000000..1b8b7a91fe14 --- /dev/null +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -0,0 +1,13 @@ +class CreateGpgKeys < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :gpg_keys do |t| + t.string :fingerprint + t.text :key + t.references :user, index: true, foreign_key: true + + t.timestamps_with_timezone null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1ec25c7d46fe..54f985592438 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -540,6 +540,16 @@ add_index "forked_project_links", ["forked_to_project_id"], name: "index_forked_project_links_on_forked_to_project_id", unique: true, using: :btree + create_table "gpg_keys", force: :cascade do |t| + t.string "fingerprint" + t.text "key" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "gpg_keys", ["user_id"], name: "index_gpg_keys_on_user_id", using: :btree + create_table "identities", force: :cascade do |t| t.string "extern_uid" t.string "provider" @@ -1602,6 +1612,7 @@ add_foreign_key "environments", "projects", name: "fk_d1c8c1da6a", on_delete: :cascade add_foreign_key "events", "projects", name: "fk_0434b48643", on_delete: :cascade add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade + add_foreign_key "gpg_keys", "users" add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb new file mode 100644 index 000000000000..02623c52fa60 --- /dev/null +++ b/spec/models/gpg_key_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +describe GpgKey do + describe "associations" do + it { is_expected.to belong_to(:user) } + end + + describe "validation" do + it { is_expected.to validate_presence_of(:fingerprint) } + + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_uniqueness_of(:key) } + it { is_expected.to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey").for(:key) } + it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey\n-----BEGIN PGP PUBLIC KEY BLOCK-----").for(:key) } + it { is_expected.not_to allow_value('BEGIN PGP').for(:key) } + end + + context 'callbacks' do + describe 'extract_fingerprint' do + it 'extracts the fingerprint from the gpg key', :gpg do + gpg_key = described_class.new(key: GpgHelpers.public_key) + gpg_key.valid? + expect(gpg_key.fingerprint).to eq '4F4840A503964251CF7D7F5DC728AF10972E97C0' + end + end + end + + describe '#key=' do + it 'strips white spaces' do + key = <<~KEY.strip + -----BEGIN PGP PUBLIC KEY BLOCK----- + Version: GnuPG v1 + + mQENBFMOSOgBCADFCYxmnXFbrDhfvlf03Q/bQuT+nZu46BFGbo7XkUjDowFXJQhP + -----END PGP PUBLIC KEY BLOCK----- + KEY + + expect(described_class.new(key: " #{key} ").key).to eq(key) + end + end +end -- GitLab From 7b7cd6f69d59092a55fc8b293edf09638fba20d9 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 22 Feb 2017 15:37:49 +0100 Subject: [PATCH 04/96] add emails method to GgpKey --- app/models/gpg_key.rb | 5 +++++ spec/factories/gpg_keys.rb | 7 +++++++ spec/models/gpg_key_spec.rb | 8 ++++++++ 3 files changed, 20 insertions(+) create mode 100644 spec/factories/gpg_keys.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 16de0b542d7a..d77051850f31 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -21,6 +21,11 @@ def key=(value) write_attribute(:key, value) end + def emails + raw_key = GPGME::Key.get(fingerprint) + raw_key.uids.map(&:email) + end + private def extract_fingerprint diff --git a/spec/factories/gpg_keys.rb b/spec/factories/gpg_keys.rb new file mode 100644 index 000000000000..e43a3c19672e --- /dev/null +++ b/spec/factories/gpg_keys.rb @@ -0,0 +1,7 @@ +require_relative '../support/gpg_helpers' + +FactoryGirl.define do + factory :gpg_key do + key GpgHelpers.public_key + end +end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 02623c52fa60..24ef291a0217 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -38,4 +38,12 @@ expect(described_class.new(key: " #{key} ").key).to eq(key) end end + + describe '#emails' do + it 'returns the emails from the gpg key' do + gpg_key = create :gpg_key + + expect(gpg_key.emails).to match_array %w(mail@koffeinfrei.org lex@panter.ch) + end + end end -- GitLab From ab4120de3165ea262de726aa3e102b74951d2bca Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 22 Feb 2017 16:22:39 +0100 Subject: [PATCH 05/96] only validate gpg_key#fingerprint "internally" --- app/models/gpg_key.rb | 14 +++++++++----- spec/models/gpg_key_spec.rb | 2 -- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index d77051850f31..b012db1428fe 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -3,17 +3,21 @@ class GpgKey < ActiveRecord::Base belongs_to :user - validates :fingerprint, - presence: true, - uniqueness: true - validates :key, presence: true, uniqueness: true, format: { - with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX}).)+\Z/m + with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX}).)+\Z/m, + message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}'" } + validates :fingerprint, + presence: true, + uniqueness: true, + # only validate when the `key` is valid, as we don't want the user to show + # the error about the fingerprint + unless: -> { errors.has_key?(:key) } + before_validation :extract_fingerprint def key=(value) diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 24ef291a0217..1c5dd95ba652 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -6,8 +6,6 @@ end describe "validation" do - it { is_expected.to validate_presence_of(:fingerprint) } - it { is_expected.to validate_presence_of(:key) } it { is_expected.to validate_uniqueness_of(:key) } it { is_expected.to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey").for(:key) } -- GitLab From 7b4d29f4b5b02b5aee3e3cbfc8282965a38c4622 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 22 Feb 2017 16:24:48 +0100 Subject: [PATCH 06/96] add profile gpg key page to manage gpg keys --- .../profiles/gpg_keys_controller.rb | 33 +++++++++++++++ app/models/user.rb | 1 + app/views/layouts/nav/_profile.html.haml | 4 ++ app/views/profiles/gpg_keys/_form.html.haml | 10 +++++ app/views/profiles/gpg_keys/_key.html.haml | 13 ++++++ .../profiles/gpg_keys/_key_table.html.haml | 11 +++++ app/views/profiles/gpg_keys/index.html.haml | 18 +++++++++ config/routes/profile.rb | 1 + spec/features/profiles/gpg_keys_spec.rb | 40 +++++++++++++++++++ 9 files changed, 131 insertions(+) create mode 100644 app/controllers/profiles/gpg_keys_controller.rb create mode 100644 app/views/profiles/gpg_keys/_form.html.haml create mode 100644 app/views/profiles/gpg_keys/_key.html.haml create mode 100644 app/views/profiles/gpg_keys/_key_table.html.haml create mode 100644 app/views/profiles/gpg_keys/index.html.haml create mode 100644 spec/features/profiles/gpg_keys_spec.rb diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb new file mode 100644 index 000000000000..b04c14a69936 --- /dev/null +++ b/app/controllers/profiles/gpg_keys_controller.rb @@ -0,0 +1,33 @@ +class Profiles::GpgKeysController < Profiles::ApplicationController + def index + @gpg_keys = current_user.gpg_keys + @gpg_key = GpgKey.new + end + + def create + @gpg_key = current_user.gpg_keys.new(gpg_key_params) + + if @gpg_key.save + redirect_to profile_gpg_keys_path + else + @gpg_keys = current_user.gpg_keys.select(&:persisted?) + render :index + end + end + + def destroy + @gpp_key = current_user.gpg_keys.find(params[:id]) + @gpp_key.destroy + + respond_to do |format| + format.html { redirect_to profile_gpg_keys_url, status: 302 } + format.js { head :ok } + end + end + + private + + def gpg_key_params + params.require(:gpg_key).permit(:key) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index c26be6d05a2c..5aebd36cf8ad 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,6 +76,7 @@ def update_tracked_fields!(request) where(type.not_eq('DeployKey').or(type.eq(nil))) end, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :gpg_keys, dependent: :destroy has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 424905ea8904..26d9640e98ab 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -43,6 +43,10 @@ = link_to profile_keys_path, title: 'SSH Keys' do %span SSH Keys + = nav_link(controller: :gpg_keys) do + = link_to profile_gpg_keys_path, title: 'GPG Keys' do + %span + GPG Keys = nav_link(controller: :preferences) do = link_to profile_preferences_path, title: 'Preferences' do %span diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml new file mode 100644 index 000000000000..3fcf563d970a --- /dev/null +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -0,0 +1,10 @@ +%div + = form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f| + = form_errors(@gpg_key) + + .form-group + = f.label :key, class: 'label-light' + = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'." + + .prepend-top-default + = f.submit 'Add key', class: "btn btn-create" diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml new file mode 100644 index 000000000000..fc167698ccde --- /dev/null +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -0,0 +1,13 @@ +%li.key-list-item + .pull-left.append-right-10 + = icon 'key', class: "settings-list-icon hidden-xs" + .key-list-item-info + = key.emails.join(' ') + .description + = key.fingerprint + .pull-right + %span.key-created-at + created #{time_ago_with_tooltip(key.created_at)} + = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-transparent prepend-left-10" do + %span.sr-only Remove + = icon('trash') diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml new file mode 100644 index 000000000000..cabb92c5a245 --- /dev/null +++ b/app/views/profiles/gpg_keys/_key_table.html.haml @@ -0,0 +1,11 @@ +- is_admin = local_assigns.fetch(:admin, false) + +- if @gpg_keys.any? + %ul.well-list + = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin } +- else + %p.settings-message.text-center + - if is_admin + There are no GPG keys associated with this account. + - else + There are no GPG keys with access to your account. diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml new file mode 100644 index 000000000000..30066522766d --- /dev/null +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -0,0 +1,18 @@ +- page_title "GPG Keys" += render 'profiles/head' + +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + GPG keys allow you to verify signed commits. + .col-lg-9 + %h5.prepend-top-0 + Add an GPG key + = render 'form' + %hr + %h5 + Your GPG keys (#{@gpg_keys.count}) + .append-bottom-default + = render 'key_table' diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 3dc890e5785e..00388b9c0cdb 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -23,6 +23,7 @@ end resource :preferences, only: [:show, :update] resources :keys, only: [:index, :show, :create, :destroy] + resources :gpg_keys, only: [:index, :create, :destroy] resources :emails, only: [:index, :create, :destroy] resources :chat_names, only: [:index, :new, :create, :destroy] do collection do diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb new file mode 100644 index 000000000000..223f2e818423 --- /dev/null +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +feature 'Profile > GPG Keys', :gpg do + let(:user) { create(:user) } + + before do + login_as(user) + end + + describe 'User adds a key' do + before do + visit profile_gpg_keys_path + end + + scenario 'saves the new key' do + fill_in('Key', with: attributes_for(:gpg_key)[:key]) + click_button('Add key') + + expect(page).to have_content('mail@koffeinfrei.org lex@panter.ch') + expect(page).to have_content('4F4840A503964251CF7D7F5DC728AF10972E97C0') + end + end + + scenario 'User sees their keys' do + create(:gpg_key, user: user) + visit profile_gpg_keys_path + + expect(page).to have_content('mail@koffeinfrei.org lex@panter.ch') + expect(page).to have_content('4F4840A503964251CF7D7F5DC728AF10972E97C0') + end + + scenario 'User removes a key via the key index' do + create(:gpg_key, user: user) + visit profile_gpg_keys_path + + click_link('Remove') + + expect(page).to have_content('Your GPG keys (0)') + end +end -- GitLab From e34cef0cd2fcf9a01d3f3b6dd215bbcc25d65d27 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 22 Feb 2017 17:20:42 +0100 Subject: [PATCH 07/96] extract gpg functionality to lib class --- app/models/gpg_key.rb | 6 +----- lib/gitlab/gpg.rb | 32 ++++++++++++++++++++++++++++++++ spec/lib/gitlab/gpg_spec.rb | 20 ++++++++++++++++++++ spec/spec_helper.rb | 8 +------- 4 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 lib/gitlab/gpg.rb create mode 100644 spec/lib/gitlab/gpg_spec.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index b012db1428fe..aa0e8883a473 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -33,12 +33,8 @@ def emails private def extract_fingerprint - import = GPGME::Key.import(key) - - return if import.considered == 0 - # we can assume that the result only contains one item as the validation # only allows one key - self.fingerprint = import.imports.first.fingerprint + self.fingerprint = Gitlab::Gpg.fingerprints_from_key(key).first end end diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb new file mode 100644 index 000000000000..373ef79ab85c --- /dev/null +++ b/lib/gitlab/gpg.rb @@ -0,0 +1,32 @@ +module Gitlab + module Gpg + extend self + + def fingerprints_from_key(key) + using_tmp_keychain do + import = GPGME::Key.import(key) + + return [] if import.imported == 0 + + import.imports.map(&:fingerprint) + end + end + + def using_tmp_keychain + Dir.mktmpdir do |dir| + @original_dirs ||= [GPGME::Engine.dirinfo('homedir')] + @original_dirs.push(dir) + + GPGME::Engine.home_dir = dir + + return_value = yield + + @original_dirs.pop + + GPGME::Engine.home_dir = @original_dirs[-1] + + return_value + end + end + end +end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb new file mode 100644 index 000000000000..a59302e67388 --- /dev/null +++ b/spec/lib/gitlab/gpg_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +describe Gitlab::Gpg do + describe '.fingerprints_from_key' do + it 'returns the fingerprint' do + expect( + described_class.fingerprints_from_key(GpgHelpers.public_key) + ).to eq ['4F4840A503964251CF7D7F5DC728AF10972E97C0'] + end + + it 'returns an empty array when the key is invalid' do + expect( + described_class.fingerprints_from_key('bogus') + ).to eq [] + end + end + + describe '.add_to_keychain' do + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6b4ec608efb3..a0df233507b1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -143,14 +143,8 @@ end config.around(:each, :gpg) do |example| - Dir.mktmpdir do |dir| - original_dir = GPGME::Engine.dirinfo('homedir') - - GPGME::Engine.home_dir = dir - + Gitlab::Gpg.using_tmp_keychain do example.run - - GPGME::Engine.home_dir = original_dir end end end -- GitLab From 87c0fd34557463528a552986a42f4ebb52d3bd56 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 22 Feb 2017 18:36:25 +0100 Subject: [PATCH 08/96] add / remove gpg keys to / from system keychain --- app/models/gpg_key.rb | 10 ++++++++++ lib/gitlab/gpg.rb | 8 ++++++++ spec/lib/gitlab/gpg_spec.rb | 20 +++++++++++++++++++- spec/models/gpg_key_spec.rb | 24 +++++++++++++++++++++--- spec/support/gpg_helpers.rb | 2 +- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index aa0e8883a473..a9f1400650c3 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -19,6 +19,8 @@ class GpgKey < ActiveRecord::Base unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint + after_create :add_to_keychain + after_destroy :remove_from_keychain def key=(value) value.strip! unless value.blank? @@ -37,4 +39,12 @@ def extract_fingerprint # only allows one key self.fingerprint = Gitlab::Gpg.fingerprints_from_key(key).first end + + def add_to_keychain + Gitlab::Gpg.add_to_keychain(key) + end + + def remove_from_keychain + Gitlab::Gpg.remove_from_keychain(fingerprint) + end end diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 373ef79ab85c..64f18d00e46a 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -12,6 +12,14 @@ def fingerprints_from_key(key) end end + def add_to_keychain(key) + GPGME::Key.import(key) + end + + def remove_from_keychain(fingerprint) + GPGME::Key.get(fingerprint).delete! + end + def using_tmp_keychain Dir.mktmpdir do |dir| @original_dirs ||= [GPGME::Engine.dirinfo('homedir')] diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index a59302e67388..2f779492c24f 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -15,6 +15,24 @@ end end - describe '.add_to_keychain' do + describe '.add_to_keychain', :gpg do + it 'stores the key in the keychain' do + expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).to eq [] + + Gitlab::Gpg.add_to_keychain(GpgHelpers.public_key) + + expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).not_to eq [] + end + end + + describe '.remove_from_keychain', :gpg do + it 'removes the key from the keychain' do + Gitlab::Gpg.add_to_keychain(GpgHelpers.public_key) + expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).not_to eq [] + + Gitlab::Gpg.remove_from_keychain('4F4840A503964251CF7D7F5DC728AF10972E97C0') + + expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).to eq [] + end end end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 1c5dd95ba652..facdf91550f5 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -13,14 +13,32 @@ it { is_expected.not_to allow_value('BEGIN PGP').for(:key) } end - context 'callbacks' do + context 'callbacks', :gpg do describe 'extract_fingerprint' do - it 'extracts the fingerprint from the gpg key', :gpg do + it 'extracts the fingerprint from the gpg key' do gpg_key = described_class.new(key: GpgHelpers.public_key) gpg_key.valid? expect(gpg_key.fingerprint).to eq '4F4840A503964251CF7D7F5DC728AF10972E97C0' end end + + describe 'add_to_keychain' do + it 'calls add_to_keychain after create' do + expect(Gitlab::Gpg).to receive(:add_to_keychain).with(GpgHelpers.public_key) + create :gpg_key + end + end + + describe 'remove_from_keychain' do + it 'calls remove_from_keychain after destroy' do + allow(Gitlab::Gpg).to receive :add_to_keychain + gpg_key = create :gpg_key + + expect(Gitlab::Gpg).to receive(:remove_from_keychain).with('4F4840A503964251CF7D7F5DC728AF10972E97C0') + + gpg_key.destroy! + end + end end describe '#key=' do @@ -37,7 +55,7 @@ end end - describe '#emails' do + describe '#emails', :gpg do it 'returns the emails from the gpg key' do gpg_key = create :gpg_key diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb index 3b50d831b003..2f4404885464 100644 --- a/spec/support/gpg_helpers.rb +++ b/spec/support/gpg_helpers.rb @@ -29,7 +29,7 @@ def signed_commit_base_data end def public_key - <<~PUBLICKEY + <<~PUBLICKEY.strip -----BEGIN PGP PUBLIC KEY BLOCK----- Version: GnuPG v1 -- GitLab From eb77e1068c09cf8ef45689720a2bf200542b8024 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 23 Feb 2017 12:02:36 +0100 Subject: [PATCH 09/96] add second gpg key for specs --- spec/factories/gpg_keys.rb | 2 +- spec/lib/gitlab/gpg_spec.rb | 6 +- spec/models/commit_spec.rb | 10 +- spec/models/gpg_key_spec.rb | 4 +- spec/support/gpg_helpers.rb | 510 +++++++++++++++++++++--------------- 5 files changed, 309 insertions(+), 223 deletions(-) diff --git a/spec/factories/gpg_keys.rb b/spec/factories/gpg_keys.rb index e43a3c19672e..70c2875b985d 100644 --- a/spec/factories/gpg_keys.rb +++ b/spec/factories/gpg_keys.rb @@ -2,6 +2,6 @@ FactoryGirl.define do factory :gpg_key do - key GpgHelpers.public_key + key GpgHelpers::User1.public_key end end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 2f779492c24f..04a434a993dd 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -4,7 +4,7 @@ describe '.fingerprints_from_key' do it 'returns the fingerprint' do expect( - described_class.fingerprints_from_key(GpgHelpers.public_key) + described_class.fingerprints_from_key(GpgHelpers::User1.public_key) ).to eq ['4F4840A503964251CF7D7F5DC728AF10972E97C0'] end @@ -19,7 +19,7 @@ it 'stores the key in the keychain' do expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).to eq [] - Gitlab::Gpg.add_to_keychain(GpgHelpers.public_key) + Gitlab::Gpg.add_to_keychain(GpgHelpers::User1.public_key) expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).not_to eq [] end @@ -27,7 +27,7 @@ describe '.remove_from_keychain', :gpg do it 'removes the key from the keychain' do - Gitlab::Gpg.add_to_keychain(GpgHelpers.public_key) + Gitlab::Gpg.add_to_keychain(GpgHelpers::User1.public_key) expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).not_to eq [] Gitlab::Gpg.remove_from_keychain('4F4840A503964251CF7D7F5DC728AF10972E97C0') diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 0fc00ab4f18e..3c6ce49b48d9 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -422,11 +422,11 @@ context 'signed commit', :gpg do it 'returns a valid signature if the public key is known' do - GPGME::Key.import(GpgHelpers.public_key) + GPGME::Key.import(GpgHelpers::User1.public_key) raw_commit = double(:raw_commit, signature: [ - GpgHelpers.signed_commit_signature, - GpgHelpers.signed_commit_base_data + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data ]) allow(raw_commit).to receive :save! @@ -440,8 +440,8 @@ it 'returns an invalid signature if the public commit is unknown', :gpg do raw_commit = double(:raw_commit, signature: [ - GpgHelpers.signed_commit_signature, - GpgHelpers.signed_commit_base_data + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data ]) allow(raw_commit).to receive :save! diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index facdf91550f5..6bfd0b0d4f67 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -16,7 +16,7 @@ context 'callbacks', :gpg do describe 'extract_fingerprint' do it 'extracts the fingerprint from the gpg key' do - gpg_key = described_class.new(key: GpgHelpers.public_key) + gpg_key = described_class.new(key: GpgHelpers::User1.public_key) gpg_key.valid? expect(gpg_key.fingerprint).to eq '4F4840A503964251CF7D7F5DC728AF10972E97C0' end @@ -24,7 +24,7 @@ describe 'add_to_keychain' do it 'calls add_to_keychain after create' do - expect(Gitlab::Gpg).to receive(:add_to_keychain).with(GpgHelpers.public_key) + expect(Gitlab::Gpg).to receive(:add_to_keychain).with(GpgHelpers::User1.public_key) create :gpg_key end end diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb index 2f4404885464..375f44158465 100644 --- a/spec/support/gpg_helpers.rb +++ b/spec/support/gpg_helpers.rb @@ -1,222 +1,308 @@ module GpgHelpers - extend self + module User1 + extend self - def signed_commit_signature - <<~SIGNATURE - -----BEGIN PGP SIGNATURE----- - Version: GnuPG v1 + def signed_commit_signature + <<~SIGNATURE + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v1 - iQEcBAABAgAGBQJYpIi9AAoJEMcorxCXLpfAZZIH/R/nhcC4s0j6nqAsi9Kbc4DX - TGZyfjed6puWzqnT90Vy+WyUC7FjWJpkuOKQz+NQD9JcBMRp/OC0GtkNz4djv1se - Nup29qWd+Fg2XGEBakTxAo2e9cg38a2rGEIL6V8i+tYAhDt5OyLdzD/XsF0vt02E - ZikSvV02c6ByrjPq37ZdOgnk1xJrS1NM0Sn4B7L3cAz6TYb1OvyG1Z4HnMWgTBHy - e/uKLPRYhx7a4D4TEt4/JWN3sb0VnaToG623EdJ1APF/MK9Es+H7YfgBsyu18nss - 705F+PZ2vx/1b9z5dLc/jQNf+k9vQH4uhmOFwUJnuQ/qB4/3H/UyLH/HfomK7Zk= - =fzCF - -----END PGP SIGNATURE----- - SIGNATURE - end + iQEcBAABAgAGBQJYpIi9AAoJEMcorxCXLpfAZZIH/R/nhcC4s0j6nqAsi9Kbc4DX + TGZyfjed6puWzqnT90Vy+WyUC7FjWJpkuOKQz+NQD9JcBMRp/OC0GtkNz4djv1se + Nup29qWd+Fg2XGEBakTxAo2e9cg38a2rGEIL6V8i+tYAhDt5OyLdzD/XsF0vt02E + ZikSvV02c6ByrjPq37ZdOgnk1xJrS1NM0Sn4B7L3cAz6TYb1OvyG1Z4HnMWgTBHy + e/uKLPRYhx7a4D4TEt4/JWN3sb0VnaToG623EdJ1APF/MK9Es+H7YfgBsyu18nss + 705F+PZ2vx/1b9z5dLc/jQNf+k9vQH4uhmOFwUJnuQ/qB4/3H/UyLH/HfomK7Zk= + =fzCF + -----END PGP SIGNATURE----- + SIGNATURE + end + + def signed_commit_base_data + <<~SIGNEDDATA + tree ed60cfd202644fda1abaf684e7d965052db18c13 + parent 4ded8b5ce09d2b665e5893945b29d8d626691086 + author Alexis Reigel 1487177917 +0100 + committer Alexis Reigel 1487177917 +0100 + + signed commit, verified key/email + SIGNEDDATA + end - def signed_commit_base_data - <<~SIGNEDDATA - tree ed60cfd202644fda1abaf684e7d965052db18c13 - parent 4ded8b5ce09d2b665e5893945b29d8d626691086 - author Alexis Reigel 1487177917 +0100 - committer Alexis Reigel 1487177917 +0100 + def public_key + <<~PUBLICKEY.strip + -----BEGIN PGP PUBLIC KEY BLOCK----- + Version: GnuPG v1 - signed commit, verified key/email - SIGNEDDATA + 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 + =1CGB + -----END PGP PUBLIC KEY BLOCK----- + PUBLICKEY + end + + def key_id + '972E97C0' + end end - def public_key - <<~PUBLICKEY.strip - -----BEGIN PGP PUBLIC KEY BLOCK----- - Version: GnuPG v1 + module User2 + extend self + + def private_key + <<~KEY.strip + -----BEGIN PGP PRIVATE KEY BLOCK----- + Version: GnuPG v1 + + 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 + =yEqv + -----END PGP PRIVATE KEY BLOCK----- + KEY + end + + def public_key + <<~KEY.strip + -----BEGIN PGP PUBLIC KEY BLOCK----- + Version: GnuPG v1 + + mI0EWK6qKgEEAODjqOSIBNYzL23WenEVYnvvGJs9d1SjfosBmBnq4a6eBLNwUvjn + Rt0TIwwablEmnoMJiRmXBAOUz/9gSrB/QKar8CJsLmb1sevuF/mwzGXjSywnh+N3 + NPqh2KE7S/EPYBFKu2LCQRLMXGMnnWaM590+8AZ/S5zHOB5F9SUKszeTABEBAAG0 + L0JldHRlIENhcnR3cmlnaHQgPGJldHRlLmNhcnR3cmlnaHRAZXhhbXBsZS5jb20+ + iLgEEwECACIFAliuqioCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEL+d + kl+RHv1lQhsD/0LDi008hCeoZdGMuwy2N5FMUusYg/X1tRQ6E0JcYYjvvE5oIZoX + bVN0yLLD99P0rt6TSlt05CC7RPVPCNpKxKbF+TZMjMJiOi13XmY5Yti77ZMdBZtD + 7HeBzjkWCVuR6k8eIwy83niHGI6p6G87Q6XxvDjQ7wdBCNBpwkzmTYIBuI0EWK6q + KgEEAN7mdR1U5xtmWfE6OXQoEBP4DlubIEtRPQGYs+yYDg+5cNK2Hta+Js8LzBwJ + 0JhWTQhExid5lSJar+jlziu2F8tHiODySu6+iZDgTh3iHIjpHQwvFdhndcy7rtW5 + JwWBstRHDV5FnXoA13c1zVW4VbuazS8IbSJ0HyJJkGhQtorxABEBAAGInwQYAQIA + CQUCWK6qKgIbDAAKCRC/nZJfkR79ZUIIBADVsEMK5U9gRS1lfBcfsJYN9fpnI5E6 + tC2lrt6LngJbqEpfd9gek6K7jIeuiaMaUg1OOMdyWwmmf+qaImLOQH3/GXshFZX5 + FWkOyFnebKY6V2kuIqAjn5GXqZm07hO0z0FjOIgQLbiH4iRosHKVljPiiB9vNcoX + wnG0c8xS7AlUMQ== + =Erp5 + -----END PGP PUBLIC KEY BLOCK----- + KEY + end + + def key_id + '911EFD65' + end - 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 - =1CGB - -----END PGP PUBLIC KEY BLOCK----- - PUBLICKEY + def signature + '6D494CA6FC90C0CAE0910E42BF9D925F911EFD65' + end end end -- GitLab From 597ae6e2208a4910544e5877db7fff300a058886 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 23 Feb 2017 12:03:18 +0100 Subject: [PATCH 10/96] feature spec for gpg signed commits --- spec/features/commits_spec.rb | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index fb1e47994ef4..c303f29a8327 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'fileutils' describe 'Commits' do include CiStatusHelper @@ -203,4 +204,35 @@ end end end + + describe 'GPG signed commits' do + let(:user) { create(:user) } + + before do + project.team << [user, :master] + login_with(user) + end + + it 'shows the signed status', :gpg do + GPGME::Key.import(GpgHelpers::User1.public_key) + + # FIXME: add this to the test repository directly + remote_path = project.repository.path_to_repo + Dir.mktmpdir do |dir| + FileUtils.cd dir do + `git clone --quiet #{remote_path} .` + `git commit --quiet -S#{GpgHelpers::User1.key_id} --allow-empty -m "signed commit, verified key/email"` + `git commit --quiet -S#{GpgHelpers::User2.key_id} --allow-empty -m "signed commit, unverified key/email"` + `git push --quiet` + end + end + + visit namespace_project_commits_path(project.namespace, project, :master) + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).to have_content 'Verified' + end + end + end end -- GitLab From 5ce61120b19f7f12e7aff714857851f57571ce0e Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 23 Feb 2017 14:07:51 +0100 Subject: [PATCH 11/96] use example gpg key instead of my own --- spec/features/profiles/gpg_keys_spec.rb | 8 +- spec/lib/gitlab/gpg_spec.rb | 12 +- spec/models/gpg_key_spec.rb | 8 +- spec/support/gpg_helpers.rb | 275 +++++++----------------- 4 files changed, 92 insertions(+), 211 deletions(-) diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb index 223f2e818423..42e1a6624b75 100644 --- a/spec/features/profiles/gpg_keys_spec.rb +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -16,8 +16,8 @@ fill_in('Key', with: attributes_for(:gpg_key)[:key]) click_button('Add key') - expect(page).to have_content('mail@koffeinfrei.org lex@panter.ch') - expect(page).to have_content('4F4840A503964251CF7D7F5DC728AF10972E97C0') + expect(page).to have_content(GpgHelpers::User1.email) + expect(page).to have_content(GpgHelpers::User1.fingerprint) end end @@ -25,8 +25,8 @@ create(:gpg_key, user: user) visit profile_gpg_keys_path - expect(page).to have_content('mail@koffeinfrei.org lex@panter.ch') - expect(page).to have_content('4F4840A503964251CF7D7F5DC728AF10972E97C0') + expect(page).to have_content(GpgHelpers::User1.email) + expect(page).to have_content(GpgHelpers::User1.fingerprint) end scenario 'User removes a key via the key index' do diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 04a434a993dd..6cfc634e2d91 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -5,7 +5,7 @@ it 'returns the fingerprint' do expect( described_class.fingerprints_from_key(GpgHelpers::User1.public_key) - ).to eq ['4F4840A503964251CF7D7F5DC728AF10972E97C0'] + ).to eq [GpgHelpers::User1.fingerprint] end it 'returns an empty array when the key is invalid' do @@ -17,22 +17,22 @@ describe '.add_to_keychain', :gpg do it 'stores the key in the keychain' do - expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).to eq [] + expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] Gitlab::Gpg.add_to_keychain(GpgHelpers::User1.public_key) - expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).not_to eq [] + expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).not_to eq [] end end describe '.remove_from_keychain', :gpg do it 'removes the key from the keychain' do Gitlab::Gpg.add_to_keychain(GpgHelpers::User1.public_key) - expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).not_to eq [] + expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).not_to eq [] - Gitlab::Gpg.remove_from_keychain('4F4840A503964251CF7D7F5DC728AF10972E97C0') + Gitlab::Gpg.remove_from_keychain(GpgHelpers::User1.fingerprint) - expect(GPGME::Key.find(:public, '4F4840A503964251CF7D7F5DC728AF10972E97C0')).to eq [] + expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] end end end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 6bfd0b0d4f67..917d420878a5 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -18,7 +18,7 @@ it 'extracts the fingerprint from the gpg key' do gpg_key = described_class.new(key: GpgHelpers::User1.public_key) gpg_key.valid? - expect(gpg_key.fingerprint).to eq '4F4840A503964251CF7D7F5DC728AF10972E97C0' + expect(gpg_key.fingerprint).to eq GpgHelpers::User1.fingerprint end end @@ -34,7 +34,7 @@ allow(Gitlab::Gpg).to receive :add_to_keychain gpg_key = create :gpg_key - expect(Gitlab::Gpg).to receive(:remove_from_keychain).with('4F4840A503964251CF7D7F5DC728AF10972E97C0') + expect(Gitlab::Gpg).to receive(:remove_from_keychain).with(GpgHelpers::User1.fingerprint) gpg_key.destroy! end @@ -57,9 +57,9 @@ describe '#emails', :gpg do it 'returns the emails from the gpg key' do - gpg_key = create :gpg_key + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key - expect(gpg_key.emails).to match_array %w(mail@koffeinfrei.org lex@panter.ch) + expect(gpg_key.emails).to eq [GpgHelpers::User1.email] end end end diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb index 375f44158465..8e005634c943 100644 --- a/spec/support/gpg_helpers.rb +++ b/spec/support/gpg_helpers.rb @@ -7,13 +7,11 @@ def signed_commit_signature -----BEGIN PGP SIGNATURE----- Version: GnuPG v1 - iQEcBAABAgAGBQJYpIi9AAoJEMcorxCXLpfAZZIH/R/nhcC4s0j6nqAsi9Kbc4DX - TGZyfjed6puWzqnT90Vy+WyUC7FjWJpkuOKQz+NQD9JcBMRp/OC0GtkNz4djv1se - Nup29qWd+Fg2XGEBakTxAo2e9cg38a2rGEIL6V8i+tYAhDt5OyLdzD/XsF0vt02E - ZikSvV02c6ByrjPq37ZdOgnk1xJrS1NM0Sn4B7L3cAz6TYb1OvyG1Z4HnMWgTBHy - e/uKLPRYhx7a4D4TEt4/JWN3sb0VnaToG623EdJ1APF/MK9Es+H7YfgBsyu18nss - 705F+PZ2vx/1b9z5dLc/jQNf+k9vQH4uhmOFwUJnuQ/qB4/3H/UyLH/HfomK7Zk= - =fzCF + iJwEAAECAAYFAliu264ACgkQzPvhnwCsix1VXgP9F6zwAMb3OXKZzqGxJ4MQIBoL + OdiUSJpL/4sIA9uhFeIv3GIA+uhsG1BHHsG627+sDy7b8W9VWEd7tbcoz4Mvhf3P + 8g0AIt9/KJuStQZDrXwP1uP6Rrl759nDcNpoOKdSQ5EZ1zlRzeDROlZeDp7Ckfvw + GLmN/74Gl3pk0wfgHFY= + =wSgS -----END PGP SIGNATURE----- SIGNATURE end @@ -21,208 +19,87 @@ def signed_commit_signature def signed_commit_base_data <<~SIGNEDDATA tree ed60cfd202644fda1abaf684e7d965052db18c13 - parent 4ded8b5ce09d2b665e5893945b29d8d626691086 - author Alexis Reigel 1487177917 +0100 - committer Alexis Reigel 1487177917 +0100 + parent caf6a0334a855e12f30205fff3d7333df1f65127 + author Nannie Bernhard 1487854510 +0100 + committer Nannie Bernhard 1487854510 +0100 signed commit, verified key/email SIGNEDDATA end + def secret_key + <<~KEY.strip + -----BEGIN PGP PRIVATE KEY BLOCK----- + Version: GnuPG v1 + + lQHYBFiu1ScBBADUhWsrlWHp5e7ASlI5iMcA0XN43fivhVlGYJJy4Ii3Hr2i4f5s + VffHS8QyhgxxzSnPwe2OKnZWWL9cHzUFbiG3fHalEBTjpB+7pG4HBgU8R/tiDOu8 + vkAR+tfJbkuRs9XeG3dGKBX/8WRhIfRucYnM+04l2Myyo5zIx7thJmxXjwARAQAB + AAP/XUtcqrtfSnDYCK4Xvo4e3msUSAEZxOPDNzP51lhfbBQgp7qSGDj9Fw5ZyNwz + 5llse3nksT5OyMUY7HX+rq2UOs12a/piLqvhtX1okp/oTAETmKXNYkZLenv6t94P + NqLi0o2AnXAvL9ueXa7WUY3l4DkvuLcjT4+9Ut2Y71zIjeECAN7q9ohNL7E8tNkf + Elsbx+8KfyHRQXiSUYaQLlvDRq2lYCKIS7sogTqjZMEgbZx2mRX1fakRlvcmqOwB + QoX34zcCAPQPd+yTteNUV12uvDaj8V9DICktPPhbHdYYaUoHjF8RrIHCTRUPzk9E + KzCL9dUP8eXPPBV/ty+zjUwl69IgCmkB/3pnNZ0D4EJsNgu24UgI0N+c8H/PE1D6 + K+bGQ/jK83uYPMXJUsiojssCHLGNp7eBGHFn1PpEqZphgVI50ZMrZQWhJbQtTmFu + bmllIEJlcm5oYXJkIDxuYW5uaWUuYmVybmhhcmRAZXhhbXBsZS5jb20+iLgEEwEC + ACIFAliu1ScCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEMz74Z8ArIsd + p5ID/32hRalvTY+V+QAtzHlGdxugweSBzNgRT3A4UiC9chF6zBOEIw689lqmK6L4 + i3Il9XeKMl87wi9tsVy9TuOMYDTvcFvu1vMAQ5AsDXqZaAEtCUZpFZscNbi7AXG+ + QkoDQbMSxp0Rd6eIRJpk9zis5co87f78xJBZLZua+8awFMS6nQHYBFiu1ScBBADI + XkITf+kKCkD+n8tMsdTLInefu8KrJ8p7YRYCCabEXnWRsDb5zxUAG2VXCVUhYl6Q + XQybkNiBaduS+uxilz7gtYZUMFJvQ09+fV7D2N9B7u/1bGdIYz+cDFJnEJitLY4w + /nju2Sno5CL5Ead8sZuslKetSXPYHR/kbW462EOw5wARAQABAAP+IoZfU1XUdVbr + +RPWp3ny5SekviDPu8co9BZ4ANTh5+8wyfA3oNbGUxTlYthoU07MZYqq+/k63R28 + 6HgVGC3gdvCiRMGmryIQ6roLLRXkfzjXrI7Lgnhx4OtVjo62pAKDqdl45wEa1Q+M + v08CQF6XNpb5R9Xszz4aBC4eV0KjtjkCANlGSQHZ1B81g+iltj1FAhRHkyUFlrc1 + cqLVhNgxtHZ96+R57Uk2A7dIJBsE00eIYaHOfk5X5GD/95s1QvPcQskCAOwUk5xj + NeQ6VV/1+cI91TrWU6VnT2Yj8632fM/JlKKfaS15pp8t5Ha6pNFr3xD4KgQutchq + fPsEOjaU7nwQ/i8B/1rDPTYfNXFpRNt33WAB1XtpgOIHlpmOfaYYqf6lneTlZWBc + TgyO+j+ZsHAvP18ugIRkU8D192NflzgAGwXLryijyYifBBgBAgAJBQJYrtUnAhsM + AAoJEMz74Z8ArIsdlkUEALTl6QUutJsqwVF4ZXKmmw0IEk8PkqW4G+tYRDHJMs6Z + O0nzDS89BG2DL4/UlOs5wRvERnlJYz01TMTxq/ciKaBTEjygFIv9CgIEZh97VacZ + TIqcF40k9SbpJNnh3JLf94xsNxNRJTEhbVC3uruaeILue/IR7pBMEyCs49Gcguwy + =b6UD + -----END PGP PRIVATE KEY BLOCK----- + KEY + end + def public_key - <<~PUBLICKEY.strip + <<~KEY.strip -----BEGIN PGP PUBLIC KEY BLOCK----- Version: GnuPG v1 - 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 - =1CGB + mI0EWK7VJwEEANSFayuVYenl7sBKUjmIxwDRc3jd+K+FWUZgknLgiLcevaLh/mxV + 98dLxDKGDHHNKc/B7Y4qdlZYv1wfNQVuIbd8dqUQFOOkH7ukbgcGBTxH+2IM67y+ + QBH618luS5Gz1d4bd0YoFf/xZGEh9G5xicz7TiXYzLKjnMjHu2EmbFePABEBAAG0 + LU5hbm5pZSBCZXJuaGFyZCA8bmFubmllLmJlcm5oYXJkQGV4YW1wbGUuY29tPoi4 + BBMBAgAiBQJYrtUnAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDM++Gf + AKyLHaeSA/99oUWpb02PlfkALcx5RncboMHkgczYEU9wOFIgvXIReswThCMOvPZa + piui+ItyJfV3ijJfO8IvbbFcvU7jjGA073Bb7tbzAEOQLA16mWgBLQlGaRWbHDW4 + uwFxvkJKA0GzEsadEXeniESaZPc4rOXKPO3+/MSQWS2bmvvGsBTEuriNBFiu1ScB + BADIXkITf+kKCkD+n8tMsdTLInefu8KrJ8p7YRYCCabEXnWRsDb5zxUAG2VXCVUh + Yl6QXQybkNiBaduS+uxilz7gtYZUMFJvQ09+fV7D2N9B7u/1bGdIYz+cDFJnEJit + LY4w/nju2Sno5CL5Ead8sZuslKetSXPYHR/kbW462EOw5wARAQABiJ8EGAECAAkF + Aliu1ScCGwwACgkQzPvhnwCsix2WRQQAtOXpBS60myrBUXhlcqabDQgSTw+Spbgb + 61hEMckyzpk7SfMNLz0EbYMvj9SU6znBG8RGeUljPTVMxPGr9yIpoFMSPKAUi/0K + AgRmH3tVpxlMipwXjST1Jukk2eHckt/3jGw3E1ElMSFtULe6u5p4gu578hHukEwT + IKzj0ZyC7DI= + =Ug0r -----END PGP PUBLIC KEY BLOCK----- - PUBLICKEY + KEY end def key_id - '972E97C0' + '00AC8B1D' + end + + def fingerprint + '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D' + end + + def email + 'nannie.bernhard@example.com' end end @@ -301,8 +178,12 @@ def key_id '911EFD65' end - def signature + def fingerprint '6D494CA6FC90C0CAE0910E42BF9D925F911EFD65' end + + def email + 'bette.cartwright@example.com' + end end end -- GitLab From 41c96c45f2696af54dde81271741a6342db5d55a Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 24 Feb 2017 20:17:08 +0100 Subject: [PATCH 12/96] test with a gpg key with multiple emails --- spec/features/profiles/gpg_keys_spec.rb | 4 ++-- spec/models/gpg_key_spec.rb | 2 +- spec/support/gpg_helpers.rb | 31 ++++++++++++++----------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb index 42e1a6624b75..e7939533272d 100644 --- a/spec/features/profiles/gpg_keys_spec.rb +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -16,7 +16,7 @@ fill_in('Key', with: attributes_for(:gpg_key)[:key]) click_button('Add key') - expect(page).to have_content(GpgHelpers::User1.email) + expect(page).to have_content(GpgHelpers::User1.emails.join) expect(page).to have_content(GpgHelpers::User1.fingerprint) end end @@ -25,7 +25,7 @@ create(:gpg_key, user: user) visit profile_gpg_keys_path - expect(page).to have_content(GpgHelpers::User1.email) + expect(page).to have_content(GpgHelpers::User1.emails.join) expect(page).to have_content(GpgHelpers::User1.fingerprint) end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 917d420878a5..889396c19c39 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -59,7 +59,7 @@ it 'returns the emails from the gpg key' do gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key - expect(gpg_key.emails).to eq [GpgHelpers::User1.email] + expect(gpg_key.emails).to eq GpgHelpers::User1.emails end end end diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb index 8e005634c943..52c478e19763 100644 --- a/spec/support/gpg_helpers.rb +++ b/spec/support/gpg_helpers.rb @@ -98,8 +98,8 @@ def fingerprint '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D' end - def email - 'nannie.bernhard@example.com' + def emails + ['nannie.bernhard@example.com'] end end @@ -161,15 +161,20 @@ def public_key iLgEEwECACIFAliuqioCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEL+d kl+RHv1lQhsD/0LDi008hCeoZdGMuwy2N5FMUusYg/X1tRQ6E0JcYYjvvE5oIZoX bVN0yLLD99P0rt6TSlt05CC7RPVPCNpKxKbF+TZMjMJiOi13XmY5Yti77ZMdBZtD - 7HeBzjkWCVuR6k8eIwy83niHGI6p6G87Q6XxvDjQ7wdBCNBpwkzmTYIBuI0EWK6q - KgEEAN7mdR1U5xtmWfE6OXQoEBP4DlubIEtRPQGYs+yYDg+5cNK2Hta+Js8LzBwJ - 0JhWTQhExid5lSJar+jlziu2F8tHiODySu6+iZDgTh3iHIjpHQwvFdhndcy7rtW5 - JwWBstRHDV5FnXoA13c1zVW4VbuazS8IbSJ0HyJJkGhQtorxABEBAAGInwQYAQIA - CQUCWK6qKgIbDAAKCRC/nZJfkR79ZUIIBADVsEMK5U9gRS1lfBcfsJYN9fpnI5E6 - tC2lrt6LngJbqEpfd9gek6K7jIeuiaMaUg1OOMdyWwmmf+qaImLOQH3/GXshFZX5 - FWkOyFnebKY6V2kuIqAjn5GXqZm07hO0z0FjOIgQLbiH4iRosHKVljPiiB9vNcoX - wnG0c8xS7AlUMQ== - =Erp5 + 7HeBzjkWCVuR6k8eIwy83niHGI6p6G87Q6XxvDjQ7wdBCNBpwkzmTYIBtC9CZXR0 + ZSBDYXJ0d3JpZ2h0IDxiZXR0ZS5jYXJ0d3JpZ2h0QGV4YW1wbGUubmV0Poi4BBMB + AgAiBQJYrwLFAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRC/nZJfkR79 + ZVzsBADYe3Hv71I7+lTJDvKOTmV7M4ShOfjEpmDvL/5JB3FzXHAucCOlReCv/6+N + afAspPTc1uys47dvtePwQalroAsd8Y1grn/PCh+Tx27kpn1WG8yZjUsuq3z5rrGR + uXj6g8aeZdqkMTHUVF9Gd+g/5KDODdJbOjwH+l63b/bOiATDFbiNBFiuqioBBADe + 5nUdVOcbZlnxOjl0KBAT+A5bmyBLUT0BmLPsmA4PuXDSth7WvibPC8wcCdCYVk0I + RMYneZUiWq/o5c4rthfLR4jg8kruvomQ4E4d4hyI6R0MLxXYZ3XMu67VuScFgbLU + Rw1eRZ16ANd3Nc1VuFW7ms0vCG0idB8iSZBoULaK8QARAQABiJ8EGAECAAkFAliu + qioCGwwACgkQv52SX5Ee/WVCCAQA1bBDCuVPYEUtZXwXH7CWDfX6ZyOROrQtpa7e + i54CW6hKX3fYHpOiu4yHromjGlINTjjHclsJpn/qmiJizkB9/xl7IRWV+RVpDshZ + 3mymOldpLiKgI5+Rl6mZtO4TtM9BYziIEC24h+IkaLBylZYz4ogfbzXKF8JxtHPM + UuwJVDE= + =0vYo -----END PGP PUBLIC KEY BLOCK----- KEY end @@ -182,8 +187,8 @@ def fingerprint '6D494CA6FC90C0CAE0910E42BF9D925F911EFD65' end - def email - 'bette.cartwright@example.com' + def emails + ['bette.cartwright@example.com', 'bette.cartwright@example.net'] end end end -- GitLab From 0e3d3d60bae48f3698f9e7b0e060edb67170b11e Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 24 Feb 2017 20:07:57 +0100 Subject: [PATCH 13/96] email handling for gpg keys --- app/models/gpg_key.rb | 3 +-- lib/gitlab/gpg.rb | 20 ++++++++++++++++++++ spec/lib/gitlab/gpg_spec.rb | 26 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index a9f1400650c3..146ca2f2705c 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -28,8 +28,7 @@ def key=(value) end def emails - raw_key = GPGME::Key.get(fingerprint) - raw_key.uids.map(&:email) + Gitlab::Gpg::CurrentKeyChain.emails(fingerprint) end private diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 64f18d00e46a..73a4b691cff0 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -2,6 +2,14 @@ module Gitlab module Gpg extend self + module CurrentKeyChain + extend self + + def emails(fingerprint) + GPGME::Key.find(:public, fingerprint).flat_map { |raw_key| raw_key.uids.map(&:email) } + end + end + def fingerprints_from_key(key) using_tmp_keychain do import = GPGME::Key.import(key) @@ -12,6 +20,18 @@ def fingerprints_from_key(key) end end + def emails_from_key(key) + using_tmp_keychain do + import = GPGME::Key.import(key) + + return [] if import.imported == 0 + + fingerprints = import.imports.map(&:fingerprint) + + GPGME::Key.find(:public, fingerprints).flat_map { |raw_key| raw_key.uids.map(&:email) } + end + end + def add_to_keychain(key) GPGME::Key.import(key) end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 6cfc634e2d91..2a55e7d89de6 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -15,6 +15,20 @@ end end + describe '.emails_from_key' do + it 'returns the emails' do + expect( + described_class.emails_from_key(GpgHelpers::User1.public_key) + ).to eq GpgHelpers::User1.emails + end + + it 'returns an empty array when the key is invalid' do + expect( + described_class.emails_from_key('bogus') + ).to eq [] + end + end + describe '.add_to_keychain', :gpg do it 'stores the key in the keychain' do expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] @@ -36,3 +50,15 @@ end end end + +describe Gitlab::Gpg::CurrentKeyChain, :gpg do + describe '.emails' do + it 'returns the emails' do + Gitlab::Gpg.add_to_keychain(GpgHelpers::User2.public_key) + + expect( + described_class.emails(GpgHelpers::User2.fingerprint) + ).to match_array GpgHelpers::User2.emails + end + end +end -- GitLab From 0668521b2b8ed32f4a3f192a8ad04c64f6c1c0cd Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 24 Feb 2017 20:16:04 +0100 Subject: [PATCH 14/96] move current keychain methods to namespace --- app/models/gpg_key.rb | 4 ++-- lib/gitlab/gpg.rb | 16 ++++++++-------- spec/lib/gitlab/gpg_spec.rb | 34 +++++++++++++++++----------------- spec/models/gpg_key_spec.rb | 8 +++++--- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 146ca2f2705c..1101dbae4a94 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -40,10 +40,10 @@ def extract_fingerprint end def add_to_keychain - Gitlab::Gpg.add_to_keychain(key) + Gitlab::Gpg::CurrentKeyChain.add(key) end def remove_from_keychain - Gitlab::Gpg.remove_from_keychain(fingerprint) + Gitlab::Gpg::CurrentKeyChain.remove(fingerprint) end end diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 73a4b691cff0..f478f1ae5d84 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -5,6 +5,14 @@ module Gpg module CurrentKeyChain extend self + def add(key) + GPGME::Key.import(key) + end + + def remove(fingerprint) + GPGME::Key.get(fingerprint).delete! + end + def emails(fingerprint) GPGME::Key.find(:public, fingerprint).flat_map { |raw_key| raw_key.uids.map(&:email) } end @@ -32,14 +40,6 @@ def emails_from_key(key) end end - def add_to_keychain(key) - GPGME::Key.import(key) - end - - def remove_from_keychain(fingerprint) - GPGME::Key.get(fingerprint).delete! - end - def using_tmp_keychain Dir.mktmpdir do |dir| @original_dirs ||= [GPGME::Engine.dirinfo('homedir')] diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 2a55e7d89de6..c0df719c0c20 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -28,37 +28,37 @@ ).to eq [] end end +end + +describe Gitlab::Gpg::CurrentKeyChain, :gpg do + describe '.emails' do + it 'returns the emails' do + Gitlab::Gpg::CurrentKeyChain.add(GpgHelpers::User2.public_key) + + expect( + described_class.emails(GpgHelpers::User2.fingerprint) + ).to match_array GpgHelpers::User2.emails + end + end - describe '.add_to_keychain', :gpg do + describe '.add', :gpg do it 'stores the key in the keychain' do expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] - Gitlab::Gpg.add_to_keychain(GpgHelpers::User1.public_key) + Gitlab::Gpg::CurrentKeyChain.add(GpgHelpers::User1.public_key) expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).not_to eq [] end end - describe '.remove_from_keychain', :gpg do + describe '.remove', :gpg do it 'removes the key from the keychain' do - Gitlab::Gpg.add_to_keychain(GpgHelpers::User1.public_key) + Gitlab::Gpg::CurrentKeyChain.add(GpgHelpers::User1.public_key) expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).not_to eq [] - Gitlab::Gpg.remove_from_keychain(GpgHelpers::User1.fingerprint) + Gitlab::Gpg::CurrentKeyChain.remove(GpgHelpers::User1.fingerprint) expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] end end end - -describe Gitlab::Gpg::CurrentKeyChain, :gpg do - describe '.emails' do - it 'returns the emails' do - Gitlab::Gpg.add_to_keychain(GpgHelpers::User2.public_key) - - expect( - described_class.emails(GpgHelpers::User2.fingerprint) - ).to match_array GpgHelpers::User2.emails - end - end -end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 889396c19c39..e8c412999379 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -24,17 +24,19 @@ describe 'add_to_keychain' do it 'calls add_to_keychain after create' do - expect(Gitlab::Gpg).to receive(:add_to_keychain).with(GpgHelpers::User1.public_key) + expect(Gitlab::Gpg::CurrentKeyChain).to receive(:add).with(GpgHelpers::User1.public_key) create :gpg_key end end describe 'remove_from_keychain' do it 'calls remove_from_keychain after destroy' do - allow(Gitlab::Gpg).to receive :add_to_keychain + allow(Gitlab::Gpg::CurrentKeyChain).to receive :add gpg_key = create :gpg_key - expect(Gitlab::Gpg).to receive(:remove_from_keychain).with(GpgHelpers::User1.fingerprint) + expect( + Gitlab::Gpg::CurrentKeyChain + ).to receive(:remove).with(GpgHelpers::User1.fingerprint) gpg_key.destroy! end -- GitLab From f0fe1b9d4397e6c1c6aa2da6e371e234db774fe2 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 24 Feb 2017 21:28:26 +0100 Subject: [PATCH 15/96] gpg email verification --- app/helpers/badges_helper.rb | 11 ++++++ app/models/gpg_key.rb | 16 ++++++++ app/views/profiles/gpg_keys/_key.html.haml | 2 +- spec/features/profiles/gpg_keys_spec.rb | 20 +++++----- spec/models/gpg_key_spec.rb | 45 ++++++++++++++++++++-- 5 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 app/helpers/badges_helper.rb diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb new file mode 100644 index 000000000000..e1c8927ab541 --- /dev/null +++ b/app/helpers/badges_helper.rb @@ -0,0 +1,11 @@ +module BadgesHelper + def verified_email_badge(email, verified) + css_classes = %w(btn btn-xs disabled) + + css_classes << 'btn-success' if verified + + content_tag 'span', class: css_classes do + "#{email} #{verified ? 'Verified' : 'Unverified'}" + end + end +end diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 1101dbae4a94..04c7ce2e79f5 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -31,6 +31,18 @@ def emails Gitlab::Gpg::CurrentKeyChain.emails(fingerprint) end + def emails_with_verified_status + emails_in_key_chain = emails + emails_from_key = Gitlab::Gpg.emails_from_key(key) + + emails_from_key.map do |email| + [ + email, + email == user.email && emails_in_key_chain.include?(email) + ] + end + end + private def extract_fingerprint @@ -40,6 +52,10 @@ def extract_fingerprint end def add_to_keychain + emails_from_key = Gitlab::Gpg.emails_from_key(key) + + return unless emails_from_key.include?(user.email) + Gitlab::Gpg::CurrentKeyChain.add(key) end diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index fc167698ccde..d7450a22f4c1 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -2,7 +2,7 @@ .pull-left.append-right-10 = icon 'key', class: "settings-list-icon hidden-xs" .key-list-item-info - = key.emails.join(' ') + = key.emails_with_verified_status.map { |email, verified| verified_email_badge(email, verified) }.join(' ').html_safe .description = key.fingerprint .pull-right diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb index e7939533272d..552cca4a84e7 100644 --- a/spec/features/profiles/gpg_keys_spec.rb +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' feature 'Profile > GPG Keys', :gpg do - let(:user) { create(:user) } + let(:user) { create(:user, email: GpgHelpers::User2.emails.first) } before do login_as(user) @@ -13,24 +13,26 @@ end scenario 'saves the new key' do - fill_in('Key', with: attributes_for(:gpg_key)[:key]) + fill_in('Key', with: GpgHelpers::User2.public_key) click_button('Add key') - expect(page).to have_content(GpgHelpers::User1.emails.join) - expect(page).to have_content(GpgHelpers::User1.fingerprint) + expect(page).to have_content('bette.cartwright@example.com Verified') + expect(page).to have_content('bette.cartwright@example.net Unverified') + expect(page).to have_content(GpgHelpers::User2.fingerprint) end end - scenario 'User sees their keys' do - create(:gpg_key, user: user) + scenario 'User sees their key' do + create(:gpg_key, user: user, key: GpgHelpers::User2.public_key) visit profile_gpg_keys_path - expect(page).to have_content(GpgHelpers::User1.emails.join) - expect(page).to have_content(GpgHelpers::User1.fingerprint) + expect(page).to have_content('bette.cartwright@example.com Verified') + expect(page).to have_content('bette.cartwright@example.net Unverified') + expect(page).to have_content(GpgHelpers::User2.fingerprint) end scenario 'User removes a key via the key index' do - create(:gpg_key, user: user) + create(:gpg_key, user: user, key: GpgHelpers::User2.public_key) visit profile_gpg_keys_path click_link('Remove') diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index e8c412999379..695a2f65c097 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -23,9 +23,20 @@ end describe 'add_to_keychain' do - it 'calls add_to_keychain after create' do - expect(Gitlab::Gpg::CurrentKeyChain).to receive(:add).with(GpgHelpers::User1.public_key) - create :gpg_key + context "user's email matches one of the key's emails" do + it 'calls .add after create' do + expect(Gitlab::Gpg::CurrentKeyChain).to receive(:add).with(GpgHelpers::User2.public_key) + user = create :user, email: GpgHelpers::User2.emails.first + create :gpg_key, user: user, key: GpgHelpers::User2.public_key + end + end + + context "user's email does not match one of the key's emails" do + it 'does not call .add after create' do + expect(Gitlab::Gpg::CurrentKeyChain).not_to receive(:add) + user = create :user + create :gpg_key, user: user, key: GpgHelpers::User2.public_key + end end end @@ -64,4 +75,32 @@ expect(gpg_key.emails).to eq GpgHelpers::User1.emails end end + + describe '#emails_with_verified_status', :gpg do + context 'key is in the keychain' do + it 'email is verified if the user has the matching email' do + user = create :user, email: 'bette.cartwright@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.emails_with_verified_status).to match_array [ + ['bette.cartwright@example.com', true], + ['bette.cartwright@example.net', false] + ] + end + end + + context 'key is in not the keychain' do + it 'emails are unverified' do + user = create :user, email: 'bette.cartwright@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + Gitlab::Gpg::CurrentKeyChain.remove(GpgHelpers::User2.fingerprint) + + expect(gpg_key.emails_with_verified_status).to match_array [ + ['bette.cartwright@example.com', false], + ['bette.cartwright@example.net', false] + ] + end + end + end end -- GitLab From c1281982bd7975b45bed5b8e2c5ef5e242ea18fd Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 28 Feb 2017 10:49:59 +0100 Subject: [PATCH 16/96] notification email on add new gpg key --- app/mailers/emails/profile.rb | 10 +++++++ app/models/gpg_key.rb | 7 +++++ app/services/notification_service.rb | 10 +++++++ app/views/notify/new_gpg_key_email.html.haml | 10 +++++++ app/views/notify/new_gpg_key_email.text.erb | 7 +++++ spec/factories/gpg_keys.rb | 1 + spec/mailers/emails/profile_spec.rb | 30 ++++++++++++++++++++ spec/models/gpg_key_spec.rb | 14 +++++++++ spec/services/notification_service_spec.rb | 12 ++++++++ 9 files changed, 101 insertions(+) create mode 100644 app/views/notify/new_gpg_key_email.html.haml create mode 100644 app/views/notify/new_gpg_key_email.text.erb diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 256cbcd73a1e..4580e1c83bd3 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -22,5 +22,15 @@ def new_ssh_key_email(key_id) @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) end + + def new_gpg_key_email(gpg_key_id) + @gpg_key = GpgKey.find_by_id(gpg_key_id) + + return unless @gpg_key + + @current_user = @user = @gpg_key.user + @target_url = user_url(@user) + mail(to: @user.notification_email, subject: subject("GPG key was added to your account")) + end end end diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 04c7ce2e79f5..83a303ae9539 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -1,4 +1,6 @@ class GpgKey < ActiveRecord::Base + include AfterCommitQueue + KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze belongs_to :user @@ -20,6 +22,7 @@ class GpgKey < ActiveRecord::Base before_validation :extract_fingerprint after_create :add_to_keychain + after_create :notify_user after_destroy :remove_from_keychain def key=(value) @@ -62,4 +65,8 @@ def add_to_keychain def remove_from_keychain Gitlab::Gpg::CurrentKeyChain.remove(fingerprint) end + + def notify_user + run_after_commit { NotificationService.new.new_gpg_key(self) } + end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 3a98a5f6b642..b94921d2a08b 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -17,6 +17,16 @@ def new_key(key) end end + # Always notify the user about gpg key added + # + # This is a security email so it will be sent even if the user user disabled + # notifications + def new_gpg_key(gpg_key) + if gpg_key.user + mailer.new_gpg_key_email(gpg_key.id).deliver_later + end + end + # Always notify user about email added to profile def new_email(email) if email.user diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml new file mode 100644 index 000000000000..4b9350c4e882 --- /dev/null +++ b/app/views/notify/new_gpg_key_email.html.haml @@ -0,0 +1,10 @@ +%p + Hi #{@user.name}! +%p + A new GPG key was added to your account: +%p + Fingerprint: + %code= @gpg_key.fingerprint +%p + If this key was added in error, you can remove it under + = link_to "GPG Keys", profile_gpg_keys_url diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb new file mode 100644 index 000000000000..80b5a1fd7ff8 --- /dev/null +++ b/app/views/notify/new_gpg_key_email.text.erb @@ -0,0 +1,7 @@ +Hi <%= @user.name %>! + +A new GPG key was added to your account: + +Fingerprint: <%= @gpg_key.fingerprint %> + +If this key was added in error, you can remove it at <%= profile_gpg_keys_url %> diff --git a/spec/factories/gpg_keys.rb b/spec/factories/gpg_keys.rb index 70c2875b985d..1258dce89404 100644 --- a/spec/factories/gpg_keys.rb +++ b/spec/factories/gpg_keys.rb @@ -3,5 +3,6 @@ FactoryGirl.define do factory :gpg_key do key GpgHelpers::User1.public_key + user end end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 8c1c9bf135fa..09e5094cf842 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -91,6 +91,36 @@ end end + describe 'user added gpg key' do + let(:gpg_key) { create(:gpg_key) } + + subject { Notify.new_gpg_key_email(gpg_key.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'is sent to the new user' do + is_expected.to deliver_to gpg_key.user.email + end + + it 'has the correct subject' do + is_expected.to have_subject /^GPG key was added to your account$/i + end + + it 'contains the new gpg key title' do + is_expected.to have_body_text /#{gpg_key.fingerprint}/ + end + + it 'includes a link to gpg keys page' do + is_expected.to have_body_text /#{profile_gpg_keys_path}/ + end + + context 'with GPG key that does not exist' do + it { expect { Notify.new_gpg_key_email('foo') }.not_to raise_error } + end + end + describe 'user added email' do let(:email) { create(:email) } diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 695a2f65c097..4292892da4f1 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -103,4 +103,18 @@ end end end + + describe 'notification' do + include EmailHelpers + + let(:user) { create(:user) } + + it 'sends a notification' do + perform_enqueued_jobs do + create(:gpg_key, user: user) + end + + should_email(user) + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 4fc5eb0a527e..0f07a89aad9f 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -93,6 +93,18 @@ def send_notifications(*new_mentions) end end + describe 'GpgKeys' do + describe '#new_gpg_key' do + let!(:key) { create(:gpg_key) } + + it { expect(notification.new_gpg_key(key)).to be_truthy } + + it 'sends email to key owner' do + expect{ notification.new_gpg_key(key) }.to change{ ActionMailer::Base.deliveries.size }.by(1) + end + end + end + describe 'Email' do describe '#new_email' do let!(:email) { create(:email) } -- GitLab From 8bd94a7304d392ad030295b5dfcd84c0100eddd1 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 28 Feb 2017 15:25:12 +0100 Subject: [PATCH 17/96] remove gpg from keychain when user's email changes --- app/models/gpg_key.rb | 31 ++++++++------ app/models/user.rb | 7 +++ lib/gitlab/gpg.rb | 4 +- spec/features/commits_spec.rb | 16 +++++-- spec/models/gpg_key_spec.rb | 80 ++++++++++++++++++++++++----------- spec/models/user_spec.rb | 20 +++++++++ 6 files changed, 118 insertions(+), 40 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 83a303ae9539..8332ba3ee6ea 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -21,9 +21,9 @@ class GpgKey < ActiveRecord::Base unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint - after_create :add_to_keychain + after_create :synchronize_keychain after_create :notify_user - after_destroy :remove_from_keychain + after_destroy :synchronize_keychain def key=(value) value.strip! unless value.blank? @@ -31,21 +31,32 @@ def key=(value) end def emails - Gitlab::Gpg::CurrentKeyChain.emails(fingerprint) + @emails ||= Gitlab::Gpg.emails_from_key(key) end - def emails_with_verified_status - emails_in_key_chain = emails - emails_from_key = Gitlab::Gpg.emails_from_key(key) + def emails_in_keychain + @emails_in_keychain ||= Gitlab::Gpg::CurrentKeyChain.emails(fingerprint) + end - emails_from_key.map do |email| + def emails_with_verified_status + emails.map do |email| [ email, - email == user.email && emails_in_key_chain.include?(email) + email == user.email && emails_in_keychain.include?(email) ] end end + def synchronize_keychain + if emails.include?(user.email) + add_to_keychain + else + remove_from_keychain + end + + @emails_in_keychain = nil + end + private def extract_fingerprint @@ -55,10 +66,6 @@ def extract_fingerprint end def add_to_keychain - emails_from_key = Gitlab::Gpg.emails_from_key(key) - - return unless emails_from_key.include?(user.email) - Gitlab::Gpg::CurrentKeyChain.add(key) end diff --git a/app/models/user.rb b/app/models/user.rb index 5aebd36cf8ad..42c83e3d2060 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -155,6 +155,7 @@ def update_tracked_fields!(request) before_validation :set_public_email, if: :public_email_changed? after_update :update_emails_with_primary_email, if: :email_changed? + after_update :synchronize_gpg_keys, if: :email_changed? before_save :ensure_authentication_token, :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: :external_changed? after_save :ensure_namespace_correct @@ -1157,4 +1158,10 @@ def self.create_unique_internal(scope, username, email_pattern, &creation_block) ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) end + + def synchronize_gpg_keys + gpg_keys.each do |gpg_key| + gpg_key.synchronize_keychain + end + end end diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index f478f1ae5d84..ee0467ae2640 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -10,7 +10,9 @@ def add(key) end def remove(fingerprint) - GPGME::Key.get(fingerprint).delete! + # `#get` raises an EOFError if the keychain is empty, which is why we + # use the friendlier `#find` + GPGME::Key.find(:public, fingerprint).each(&:delete!) end def emails(fingerprint) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index c303f29a8327..79952eda2ff4 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -206,7 +206,8 @@ end describe 'GPG signed commits' do - let(:user) { create(:user) } + let!(:user) { create :user, email: GpgHelpers::User1.emails.first } + let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key, user: user } before do project.team << [user, :master] @@ -214,8 +215,6 @@ end it 'shows the signed status', :gpg do - GPGME::Key.import(GpgHelpers::User1.public_key) - # FIXME: add this to the test repository directly remote_path = project.repository.path_to_repo Dir.mktmpdir do |dir| @@ -233,6 +232,17 @@ expect(page).to have_content 'Unverified' expect(page).to have_content 'Verified' end + + # user changes his email which makes the gpg key unverified + user.skip_reconfirmation! + user.update_attributes!(email: 'bette.cartwright@example.org') + + visit namespace_project_commits_path(project.namespace, project, :master) + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).not_to have_content 'Verified' + end end end end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 4292892da4f1..18746ad9d881 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -22,33 +22,16 @@ end end - describe 'add_to_keychain' do - context "user's email matches one of the key's emails" do - it 'calls .add after create' do - expect(Gitlab::Gpg::CurrentKeyChain).to receive(:add).with(GpgHelpers::User2.public_key) - user = create :user, email: GpgHelpers::User2.emails.first - create :gpg_key, user: user, key: GpgHelpers::User2.public_key - end + describe 'synchronize_keychain' do + it 'calls #synchronize_keychain after create' do + gpg_key = build :gpg_key + expect(gpg_key).to receive(:synchronize_keychain) + gpg_key.save! end - context "user's email does not match one of the key's emails" do - it 'does not call .add after create' do - expect(Gitlab::Gpg::CurrentKeyChain).not_to receive(:add) - user = create :user - create :gpg_key, user: user, key: GpgHelpers::User2.public_key - end - end - end - - describe 'remove_from_keychain' do - it 'calls remove_from_keychain after destroy' do - allow(Gitlab::Gpg::CurrentKeyChain).to receive :add + it 'calls #remove_from_keychain after destroy' do gpg_key = create :gpg_key - - expect( - Gitlab::Gpg::CurrentKeyChain - ).to receive(:remove).with(GpgHelpers::User1.fingerprint) - + expect(gpg_key).to receive(:synchronize_keychain) gpg_key.destroy! end end @@ -76,6 +59,15 @@ end end + describe '#emails_in_keychain', :gpg do + it 'returns the emails from the keychain' do + user = create :user, email: GpgHelpers::User1.emails.first + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user + + expect(gpg_key.emails_in_keychain).to eq GpgHelpers::User1.emails + end + end + describe '#emails_with_verified_status', :gpg do context 'key is in the keychain' do it 'email is verified if the user has the matching email' do @@ -104,6 +96,46 @@ end end + describe '#synchronize_keychain', :gpg do + context "user's email matches one of the key's emails" do + it 'adds the key to the keychain' do + user = create :user, email: GpgHelpers::User1.emails.first + gpg_key = create :gpg_key, user: user + + expect(gpg_key).to receive(:add_to_keychain) + + gpg_key.synchronize_keychain + end + end + + context "user's email does not match one of the key's emails" do + it 'does not add the key to the keychain' do + user = create :user, email: 'stepanie@cole.us' + gpg_key = create :gpg_key, user: user + + expect(gpg_key).to receive(:remove_from_keychain) + + gpg_key.synchronize_keychain + end + end + end + + describe '#add_to_keychain', :gpg do + it 'calls .add_to_keychain' do + expect(Gitlab::Gpg::CurrentKeyChain).to receive(:add).with(GpgHelpers::User2.public_key) + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key + gpg_key.send(:add_to_keychain) + end + end + + describe '#remove_from_keychain', :gpg do + it 'calls .remove_from_keychain' do + allow(Gitlab::Gpg::CurrentKeyChain).to receive(:remove).with(GpgHelpers::User2.fingerprint) + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key + gpg_key.send(:remove_from_keychain) + end + end + describe 'notification' do include EmailHelpers diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 20bdb7e37da4..60979fd6c06a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1956,4 +1956,24 @@ def add_user(access) expect(user.allow_password_authentication?).to be_falsey end end + + context 'callbacks' do + context '.synchronize_gpg_keys' do + let(:user) do + create(:user, email: 'tula.torphy@abshire.ca').tap do |user| + user.skip_reconfirmation! + end + end + + it 'does nothing when the name is updated' do + expect(user).not_to receive(:synchronize_gpg_keys) + user.update_attributes!(name: 'Bette') + end + + it 'synchronizes the gpg keys when the email is updated' do + expect(user).to receive(:synchronize_gpg_keys) + user.update_attributes!(email: 'shawnee.ritchie@denesik.com') + end + end + end end -- GitLab From d1101ec02ec718d0ce15e76217980f6fa21c9089 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 12 Jun 2017 14:26:13 +0200 Subject: [PATCH 18/96] use more descriptive variable names --- app/models/commit.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/models/commit.rb b/app/models/commit.rb index 0d50a32d1385..9c8edbb097da 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -237,13 +237,16 @@ def status(ref = nil) def signature return @signature if defined?(@signature) - sig, signed = @raw.signature(project.repository) - if sig && signed - GPGME::Crypto.new.verify(sig, signed_text: signed) do |sign| - @signature = sign + @signature = nil + + signature, signed_text = @raw.signature(project.repository) + if signature && signed_text + GPGME::Crypto.new.verify(signature, signed_text: signed_text) do |verified_signature| + @signature = verified_signature end end - @signature ||= nil + + @signature end def revert_branch_name -- GitLab From 7e13d96715750f74db399bf40ee4ec9679bbe806 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 12 Jun 2017 16:16:33 +0200 Subject: [PATCH 19/96] don't sync to keychain file --- app/models/gpg_key.rb | 26 +--------- app/models/user.rb | 7 --- lib/gitlab/gpg.rb | 18 ------- spec/lib/gitlab/gpg_spec.rb | 33 ------------- spec/models/gpg_key_spec.rb | 95 ++++--------------------------------- spec/models/user_spec.rb | 20 -------- 6 files changed, 9 insertions(+), 190 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 8332ba3ee6ea..d4570e36e062 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -21,9 +21,7 @@ class GpgKey < ActiveRecord::Base unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint - after_create :synchronize_keychain after_create :notify_user - after_destroy :synchronize_keychain def key=(value) value.strip! unless value.blank? @@ -34,29 +32,15 @@ def emails @emails ||= Gitlab::Gpg.emails_from_key(key) end - def emails_in_keychain - @emails_in_keychain ||= Gitlab::Gpg::CurrentKeyChain.emails(fingerprint) - end - def emails_with_verified_status emails.map do |email| [ email, - email == user.email && emails_in_keychain.include?(email) + email == user.email ] end end - def synchronize_keychain - if emails.include?(user.email) - add_to_keychain - else - remove_from_keychain - end - - @emails_in_keychain = nil - end - private def extract_fingerprint @@ -65,14 +49,6 @@ def extract_fingerprint self.fingerprint = Gitlab::Gpg.fingerprints_from_key(key).first end - def add_to_keychain - Gitlab::Gpg::CurrentKeyChain.add(key) - end - - def remove_from_keychain - Gitlab::Gpg::CurrentKeyChain.remove(fingerprint) - end - def notify_user run_after_commit { NotificationService.new.new_gpg_key(self) } end diff --git a/app/models/user.rb b/app/models/user.rb index 42c83e3d2060..5aebd36cf8ad 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -155,7 +155,6 @@ def update_tracked_fields!(request) before_validation :set_public_email, if: :public_email_changed? after_update :update_emails_with_primary_email, if: :email_changed? - after_update :synchronize_gpg_keys, if: :email_changed? before_save :ensure_authentication_token, :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: :external_changed? after_save :ensure_namespace_correct @@ -1158,10 +1157,4 @@ def self.create_unique_internal(scope, username, email_pattern, &creation_block) ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) end - - def synchronize_gpg_keys - gpg_keys.each do |gpg_key| - gpg_key.synchronize_keychain - end - end end diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index ee0467ae2640..384a9138fa10 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -2,24 +2,6 @@ module Gitlab module Gpg extend self - module CurrentKeyChain - extend self - - def add(key) - GPGME::Key.import(key) - end - - def remove(fingerprint) - # `#get` raises an EOFError if the keychain is empty, which is why we - # use the friendlier `#find` - GPGME::Key.find(:public, fingerprint).each(&:delete!) - end - - def emails(fingerprint) - GPGME::Key.find(:public, fingerprint).flat_map { |raw_key| raw_key.uids.map(&:email) } - end - end - def fingerprints_from_key(key) using_tmp_keychain do import = GPGME::Key.import(key) diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index c0df719c0c20..bdcf9ee0e651 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -29,36 +29,3 @@ end end end - -describe Gitlab::Gpg::CurrentKeyChain, :gpg do - describe '.emails' do - it 'returns the emails' do - Gitlab::Gpg::CurrentKeyChain.add(GpgHelpers::User2.public_key) - - expect( - described_class.emails(GpgHelpers::User2.fingerprint) - ).to match_array GpgHelpers::User2.emails - end - end - - describe '.add', :gpg do - it 'stores the key in the keychain' do - expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] - - Gitlab::Gpg::CurrentKeyChain.add(GpgHelpers::User1.public_key) - - expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).not_to eq [] - end - end - - describe '.remove', :gpg do - it 'removes the key from the keychain' do - Gitlab::Gpg::CurrentKeyChain.add(GpgHelpers::User1.public_key) - expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).not_to eq [] - - Gitlab::Gpg::CurrentKeyChain.remove(GpgHelpers::User1.fingerprint) - - expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] - end - end -end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 18746ad9d881..6ee436b6a6df 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -21,20 +21,6 @@ expect(gpg_key.fingerprint).to eq GpgHelpers::User1.fingerprint end end - - describe 'synchronize_keychain' do - it 'calls #synchronize_keychain after create' do - gpg_key = build :gpg_key - expect(gpg_key).to receive(:synchronize_keychain) - gpg_key.save! - end - - it 'calls #remove_from_keychain after destroy' do - gpg_key = create :gpg_key - expect(gpg_key).to receive(:synchronize_keychain) - gpg_key.destroy! - end - end end describe '#key=' do @@ -59,80 +45,15 @@ end end - describe '#emails_in_keychain', :gpg do - it 'returns the emails from the keychain' do - user = create :user, email: GpgHelpers::User1.emails.first - gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user - - expect(gpg_key.emails_in_keychain).to eq GpgHelpers::User1.emails - end - end - describe '#emails_with_verified_status', :gpg do - context 'key is in the keychain' do - it 'email is verified if the user has the matching email' do - user = create :user, email: 'bette.cartwright@example.com' - gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user - - expect(gpg_key.emails_with_verified_status).to match_array [ - ['bette.cartwright@example.com', true], - ['bette.cartwright@example.net', false] - ] - end - end - - context 'key is in not the keychain' do - it 'emails are unverified' do - user = create :user, email: 'bette.cartwright@example.com' - gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user - - Gitlab::Gpg::CurrentKeyChain.remove(GpgHelpers::User2.fingerprint) - - expect(gpg_key.emails_with_verified_status).to match_array [ - ['bette.cartwright@example.com', false], - ['bette.cartwright@example.net', false] - ] - end - end - end - - describe '#synchronize_keychain', :gpg do - context "user's email matches one of the key's emails" do - it 'adds the key to the keychain' do - user = create :user, email: GpgHelpers::User1.emails.first - gpg_key = create :gpg_key, user: user - - expect(gpg_key).to receive(:add_to_keychain) - - gpg_key.synchronize_keychain - end - end - - context "user's email does not match one of the key's emails" do - it 'does not add the key to the keychain' do - user = create :user, email: 'stepanie@cole.us' - gpg_key = create :gpg_key, user: user - - expect(gpg_key).to receive(:remove_from_keychain) - - gpg_key.synchronize_keychain - end - end - end - - describe '#add_to_keychain', :gpg do - it 'calls .add_to_keychain' do - expect(Gitlab::Gpg::CurrentKeyChain).to receive(:add).with(GpgHelpers::User2.public_key) - gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key - gpg_key.send(:add_to_keychain) - end - end - - describe '#remove_from_keychain', :gpg do - it 'calls .remove_from_keychain' do - allow(Gitlab::Gpg::CurrentKeyChain).to receive(:remove).with(GpgHelpers::User2.fingerprint) - gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key - gpg_key.send(:remove_from_keychain) + it 'email is verified if the user has the matching email' do + user = create :user, email: 'bette.cartwright@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.emails_with_verified_status).to match_array [ + ['bette.cartwright@example.com', true], + ['bette.cartwright@example.net', false] + ] end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 60979fd6c06a..20bdb7e37da4 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1956,24 +1956,4 @@ def add_user(access) expect(user.allow_password_authentication?).to be_falsey end end - - context 'callbacks' do - context '.synchronize_gpg_keys' do - let(:user) do - create(:user, email: 'tula.torphy@abshire.ca').tap do |user| - user.skip_reconfirmation! - end - end - - it 'does nothing when the name is updated' do - expect(user).not_to receive(:synchronize_gpg_keys) - user.update_attributes!(name: 'Bette') - end - - it 'synchronizes the gpg keys when the email is updated' do - expect(user).to receive(:synchronize_gpg_keys) - user.update_attributes!(email: 'shawnee.ritchie@denesik.com') - end - end - end end -- GitLab From 3c42d730986222d891c9b7985edf3942021afcef Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 13 Jun 2017 13:46:43 +0200 Subject: [PATCH 20/96] add primary keyid attribute to gpg keys --- app/models/gpg_key.rb | 15 ++++++++++++++- ...70613103429_add_primary_keyid_to_gpg_keys.rb | 17 +++++++++++++++++ db/schema.rb | 2 ++ lib/gitlab/gpg.rb | 12 ++++++++++++ spec/features/commits_spec.rb | 4 ++-- spec/lib/gitlab/gpg_spec.rb | 14 ++++++++++++++ spec/models/gpg_key_spec.rb | 8 ++++++++ spec/support/gpg_helpers.rb | 8 ++++---- 8 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index d4570e36e062..26f9a3975c93 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -20,7 +20,14 @@ class GpgKey < ActiveRecord::Base # the error about the fingerprint unless: -> { errors.has_key?(:key) } - before_validation :extract_fingerprint + validates :primary_keyid, + presence: true, + uniqueness: true, + # only validate when the `key` is valid, as we don't want the user to show + # the error about the fingerprint + unless: -> { errors.has_key?(:key) } + + before_validation :extract_fingerprint, :extract_primary_keyid after_create :notify_user def key=(value) @@ -49,6 +56,12 @@ def extract_fingerprint self.fingerprint = Gitlab::Gpg.fingerprints_from_key(key).first end + def extract_primary_keyid + # we can assume that the result only contains one item as the validation + # only allows one key + self.primary_keyid = Gitlab::Gpg.primary_keyids_from_key(key).first + end + def notify_user run_after_commit { NotificationService.new.new_gpg_key(self) } end diff --git a/db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb b/db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb new file mode 100644 index 000000000000..13f0500971bd --- /dev/null +++ b/db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb @@ -0,0 +1,17 @@ +class AddPrimaryKeyidToGpgKeys < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :gpg_keys, :primary_keyid, :string + add_concurrent_index :gpg_keys, :primary_keyid + end + + def down + remove_concurrent_index :gpg_keys, :primary_keyid if index_exists?(:gpg_keys, :primary_keyid) + remove_column :gpg_keys, :primary_keyid, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 54f985592438..fbf20f4eb667 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -546,8 +546,10 @@ t.integer "user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "primary_keyid" end + add_index "gpg_keys", ["primary_keyid"], name: "index_gpg_keys_on_primary_keyid", using: :btree add_index "gpg_keys", ["user_id"], name: "index_gpg_keys_on_user_id", using: :btree create_table "identities", force: :cascade do |t| diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 384a9138fa10..486e040adb64 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -12,6 +12,18 @@ def fingerprints_from_key(key) end end + def primary_keyids_from_key(key) + using_tmp_keychain do + import = GPGME::Key.import(key) + + return [] if import.imported == 0 + + fingerprints = import.imports.map(&:fingerprint) + + GPGME::Key.find(:public, fingerprints).map { |raw_key| raw_key.primary_subkey.keyid } + end + end + def emails_from_key(key) using_tmp_keychain do import = GPGME::Key.import(key) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 79952eda2ff4..1dbcf09d4a0a 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -220,8 +220,8 @@ Dir.mktmpdir do |dir| FileUtils.cd dir do `git clone --quiet #{remote_path} .` - `git commit --quiet -S#{GpgHelpers::User1.key_id} --allow-empty -m "signed commit, verified key/email"` - `git commit --quiet -S#{GpgHelpers::User2.key_id} --allow-empty -m "signed commit, unverified key/email"` + `git commit --quiet -S#{GpgHelpers::User1.primary_keyid} --allow-empty -m "signed commit, verified key/email"` + `git commit --quiet -S#{GpgHelpers::User2.primary_keyid} --allow-empty -m "signed commit, unverified key/email"` `git push --quiet` end end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index bdcf9ee0e651..55f34e0cf99a 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -15,6 +15,20 @@ end end + describe '.primary_keyids_from_key' do + it 'returns the keyid' do + expect( + described_class.primary_keyids_from_key(GpgHelpers::User1.public_key) + ).to eq [GpgHelpers::User1.primary_keyid] + end + + it 'returns an empty array when the key is invalid' do + expect( + described_class.primary_keyids_from_key('bogus') + ).to eq [] + end + end + describe '.emails_from_key' do it 'returns the emails' do expect( diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 6ee436b6a6df..ac446fca819d 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -21,6 +21,14 @@ expect(gpg_key.fingerprint).to eq GpgHelpers::User1.fingerprint end end + + describe 'extract_primary_keyid' do + it 'extracts the primary keyid from the gpg key' do + gpg_key = described_class.new(key: GpgHelpers::User1.public_key) + gpg_key.valid? + expect(gpg_key.primary_keyid).to eq GpgHelpers::User1.primary_keyid + end + end end describe '#key=' do diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb index 52c478e19763..f9128a629f26 100644 --- a/spec/support/gpg_helpers.rb +++ b/spec/support/gpg_helpers.rb @@ -90,8 +90,8 @@ def public_key KEY end - def key_id - '00AC8B1D' + def primary_keyid + fingerprint[-16..-1] end def fingerprint @@ -179,8 +179,8 @@ def public_key KEY end - def key_id - '911EFD65' + def primary_keyid + fingerprint[-16..-1] end def fingerprint -- GitLab From 2f956fae0399f6f2eb370ed186c7bb4a9486178b Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 13 Jun 2017 14:26:42 +0200 Subject: [PATCH 21/96] verify gpg commit using tmp keyring and db query --- app/models/commit.rb | 17 ++++++++++++++++- lib/gitlab/gpg.rb | 8 ++++++++ spec/lib/gitlab/gpg_spec.rb | 17 +++++++++++++++++ spec/models/commit_spec.rb | 4 ++-- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/models/commit.rb b/app/models/commit.rb index 9c8edbb097da..a6a11a2d3a56 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -240,7 +240,22 @@ def signature @signature = nil signature, signed_text = @raw.signature(project.repository) - if signature && signed_text + + return unless signature && signed_text + + Gitlab::Gpg.using_tmp_keychain do + # first we need to get the keyid from the signature... + GPGME::Crypto.new.verify(signature, signed_text: signed_text) do |verified_signature| + @signature = verified_signature + end + + # ... then we query the gpg key belonging to the keyid. + gpg_key = GpgKey.find_by(primary_keyid: @signature.fingerprint) + + return @signature unless gpg_key + + Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) + GPGME::Crypto.new.verify(signature, signed_text: signed_text) do |verified_signature| @signature = verified_signature end diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 486e040adb64..258901bb238d 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -2,6 +2,14 @@ module Gitlab module Gpg extend self + module CurrentKeyChain + extend self + + def add(key) + GPGME::Key.import(key) + end + end + def fingerprints_from_key(key) using_tmp_keychain do import = GPGME::Key.import(key) diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 55f34e0cf99a..edf7405d7f18 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -43,3 +43,20 @@ end end end + +describe Gitlab::Gpg::CurrentKeyChain, :gpg do + describe '.add', :gpg do + it 'stores the key in the keychain' do + expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] + + described_class.add(GpgHelpers::User1.public_key) + + keys = GPGME::Key.find(:public, GpgHelpers::User1.fingerprint) + expect(keys.count).to eq 1 + expect(keys.first).to have_attributes( + email: GpgHelpers::User1.emails.first, + fingerprint: GpgHelpers::User1.fingerprint + ) + end + end +end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 3c6ce49b48d9..96af675c3f49 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -422,7 +422,7 @@ context 'signed commit', :gpg do it 'returns a valid signature if the public key is known' do - GPGME::Key.import(GpgHelpers::User1.public_key) + create :gpg_key, key: GpgHelpers::User1.public_key raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, @@ -438,7 +438,7 @@ expect(commit.signature.valid?).to be_truthy end - it 'returns an invalid signature if the public commit is unknown', :gpg do + it 'returns an invalid signature if the public key is unknown', :gpg do raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data -- GitLab From 8236b12dff3df6d223888664c820ae54b4e0eaf7 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 14 Jun 2017 09:17:34 +0200 Subject: [PATCH 22/96] gpg signature model for gpg verification caching --- app/models/gpg_signature.rb | 7 +++++ .../20170613154149_create_gpg_signatures.rb | 29 +++++++++++++++++++ db/schema.rb | 17 +++++++++++ spec/models/gpg_signature_spec.rb | 14 +++++++++ 4 files changed, 67 insertions(+) create mode 100644 app/models/gpg_signature.rb create mode 100644 db/migrate/20170613154149_create_gpg_signatures.rb create mode 100644 spec/models/gpg_signature_spec.rb diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb new file mode 100644 index 000000000000..03294354d912 --- /dev/null +++ b/app/models/gpg_signature.rb @@ -0,0 +1,7 @@ +class GpgSignature < ActiveRecord::Base + belongs_to :project + belongs_to :gpg_key + + validates :commit_sha, presence: true + validates :project, presence: true +end diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb new file mode 100644 index 000000000000..72560cdb6d05 --- /dev/null +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -0,0 +1,29 @@ +class CreateGpgSignatures < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :gpg_signatures do |t| + t.string :commit_sha + t.references :project, index: true, foreign_key: true + t.references :gpg_key, index: true, foreign_key: true + t.string :gpg_key_primary_keyid + t.boolean :valid_signature + + t.timestamps_with_timezone null: false + end + + add_concurrent_index :gpg_signatures, :commit_sha + add_concurrent_index :gpg_signatures, :gpg_key_primary_keyid + end + + def down + remove_concurrent_index :gpg_signatures, :commit_sha if index_exists?(:gpg_signatures, :commit_sha) + remove_concurrent_index :gpg_signatures, :gpg_key_primary_keyid if index_exists?(:gpg_signatures, :gpg_key_primary_keyid) + + drop_table :gpg_signatures + end +end diff --git a/db/schema.rb b/db/schema.rb index fbf20f4eb667..53b1e83ddab5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -552,6 +552,21 @@ add_index "gpg_keys", ["primary_keyid"], name: "index_gpg_keys_on_primary_keyid", using: :btree add_index "gpg_keys", ["user_id"], name: "index_gpg_keys_on_user_id", using: :btree + create_table "gpg_signatures", force: :cascade do |t| + t.string "commit_sha" + t.integer "project_id" + t.integer "gpg_key_id" + t.string "gpg_key_primary_keyid" + t.boolean "valid_signature" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", using: :btree + add_index "gpg_signatures", ["gpg_key_id"], name: "index_gpg_signatures_on_gpg_key_id", using: :btree + add_index "gpg_signatures", ["gpg_key_primary_keyid"], name: "index_gpg_signatures_on_gpg_key_primary_keyid", using: :btree + add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree + create_table "identities", force: :cascade do |t| t.string "extern_uid" t.string "provider" @@ -1615,6 +1630,8 @@ add_foreign_key "events", "projects", name: "fk_0434b48643", on_delete: :cascade add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade add_foreign_key "gpg_keys", "users" + add_foreign_key "gpg_signatures", "gpg_keys" + add_foreign_key "gpg_signatures", "projects" add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb new file mode 100644 index 000000000000..d2720c416941 --- /dev/null +++ b/spec/models/gpg_signature_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe GpgSignature do + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:gpg_key) } + end + + describe 'validation' do + subject { described_class.new } + it { is_expected.to validate_presence_of(:commit_sha) } + it { is_expected.to validate_presence_of(:project) } + end +end -- GitLab From 69e511c4c2a0409fa69658cf95bf5c4072b2b2d0 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 14 Jun 2017 11:51:34 +0200 Subject: [PATCH 23/96] cache the gpg commit signature we store the result of the gpg commit verification in the db because the gpg verification is an expensive operation. --- app/models/commit.rb | 25 ++---- .../projects/commit/_signature.html.haml | 6 +- lib/gitlab/gpg/commit.rb | 51 +++++++++++ spec/lib/gitlab/gpg/commit_spec.rb | 53 +++++++++++ spec/models/commit_spec.rb | 88 ++++++++++++++----- 5 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 lib/gitlab/gpg/commit.rb create mode 100644 spec/lib/gitlab/gpg/commit_spec.rb diff --git a/app/models/commit.rb b/app/models/commit.rb index a6a11a2d3a56..6c5556902ec6 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -239,29 +239,14 @@ def signature @signature = nil - signature, signed_text = @raw.signature(project.repository) + cached_signature = GpgSignature.find_by(commit_sha: sha) + return cached_signature if cached_signature.present? - return unless signature && signed_text + gpg_commit = Gitlab::Gpg::Commit.new(self) - Gitlab::Gpg.using_tmp_keychain do - # first we need to get the keyid from the signature... - GPGME::Crypto.new.verify(signature, signed_text: signed_text) do |verified_signature| - @signature = verified_signature - end - - # ... then we query the gpg key belonging to the keyid. - gpg_key = GpgKey.find_by(primary_keyid: @signature.fingerprint) - - return @signature unless gpg_key - - Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) - - GPGME::Crypto.new.verify(signature, signed_text: signed_text) do |verified_signature| - @signature = verified_signature - end - end + return unless gpg_commit.has_signature? - @signature + @signature = gpg_commit.signature end def revert_branch_name diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 7335b6b95978..48665ede6eb1 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,4 +1,4 @@ - if signature - %a.btn.disabled.btn-xs{ class: ('btn-success' if signature.valid?) } - %i.fa.fa-key{ class: ('fa-inverse' if signature.valid?) } - = signature.valid? ? 'Verified': 'Unverified' + %a.btn.disabled.btn-xs{ class: ('btn-success' if signature.valid_signature?) } + %i.fa.fa-key{ class: ('fa-inverse' if signature.valid_signature?) } + = signature.valid_signature? ? 'Verified' : 'Unverified' diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb new file mode 100644 index 000000000000..f60e5125c139 --- /dev/null +++ b/lib/gitlab/gpg/commit.rb @@ -0,0 +1,51 @@ +module Gitlab + module Gpg + class Commit + attr_reader :commit + + def initialize(commit) + @commit = commit + + @signature_text, @signed_text = commit.raw.signature(commit.project.repository) + end + + def has_signature? + @signature_text && @signed_text + end + + def signature + Gitlab::Gpg.using_tmp_keychain do + # first we need to get the keyid from the signature to query the gpg + # key belonging to the keyid. + # This way we can add the key to the temporary keychain and extract + # the proper signature. + gpg_key = GpgKey.find_by(primary_keyid: verified_signature.fingerprint) + + if gpg_key + Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) + end + + create_cached_signature!(gpg_key) + end + end + + private + + def verified_signature + GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |verified_signature| + return verified_signature + end + end + + def create_cached_signature!(gpg_key) + GpgSignature.create!( + commit_sha: commit.sha, + project: commit.project, + gpg_key: gpg_key, + gpg_key_primary_keyid: gpg_key&.primary_keyid, + valid_signature: !!(gpg_key && verified_signature&.valid?) + ) + end + end + end +end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb new file mode 100644 index 000000000000..8b1747eebccc --- /dev/null +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe Gitlab::Gpg::Commit do + describe '#signature' do + let!(:project) { create :project, :repository, path: 'sample-project' } + + context 'known public key' do + it 'returns a valid signature' do + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key + + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: true + ) + end + end + + context 'unknown public key' do + it 'returns an invalid signature', :gpg do + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', + project: project, + gpg_key: nil, + gpg_key_primary_keyid: nil, + valid_signature: false + ) + end + end + end +end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 96af675c3f49..4370c78e6fd6 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -421,36 +421,78 @@ end context 'signed commit', :gpg do - it 'returns a valid signature if the public key is known' do - create :gpg_key, key: GpgHelpers::User1.public_key + context 'known public key' do + it 'returns a valid signature' do + create :gpg_key, key: GpgHelpers::User1.public_key - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ]) - allow(raw_commit).to receive :save! + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! - commit = create :commit, - git_commit: raw_commit, - project: project + commit = create :commit, + git_commit: raw_commit, + project: project - expect(commit.signature).to be_a GPGME::Signature - expect(commit.signature.valid?).to be_truthy + expect(commit.signature.valid_signature?).to be_truthy + end + + it 'returns the cached validation result on second call', :gpg do + create :gpg_key, key: GpgHelpers::User1.public_key + + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(Gitlab::Gpg::Commit).to receive(:new).and_call_original + expect(commit.signature.valid_signature?).to be_truthy + + # second call returns the cache + expect(Gitlab::Gpg::Commit).not_to receive(:new).and_call_original + expect(commit.signature.valid_signature?).to be_truthy + end end - it 'returns an invalid signature if the public key is unknown', :gpg do - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ]) - allow(raw_commit).to receive :save! + context 'unknown public key' do + it 'returns an invalid signature if the public key is unknown', :gpg do + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! - commit = create :commit, - git_commit: raw_commit, - project: project + commit = create :commit, + git_commit: raw_commit, + project: project - expect(commit.signature).to be_a GPGME::Signature - expect(commit.signature.valid?).to be_falsey + expect(commit.signature.valid_signature?).to be_falsey + end + + it 'returns the cached validation result on second call', :gpg do + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(Gitlab::Gpg::Commit).to receive(:new).and_call_original + expect(commit.signature.valid_signature?).to be_falsey + + # second call returns the cache + expect(Gitlab::Gpg::Commit).not_to receive(:new).and_call_original + expect(commit.signature.valid_signature?).to be_falsey + end end end end -- GitLab From 8c4b6a32fcc5786383904fa1d5cf8b317bec7a7f Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 09:16:50 +0200 Subject: [PATCH 24/96] bail if the commit has no signature --- app/models/commit.rb | 6 +----- lib/gitlab/gpg/commit.rb | 6 ++++-- spec/lib/gitlab/gpg/commit_spec.rb | 6 ++++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/models/commit.rb b/app/models/commit.rb index 6c5556902ec6..ed8b9a79a7ad 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -242,11 +242,7 @@ def signature cached_signature = GpgSignature.find_by(commit_sha: sha) return cached_signature if cached_signature.present? - gpg_commit = Gitlab::Gpg::Commit.new(self) - - return unless gpg_commit.has_signature? - - @signature = gpg_commit.signature + @signature = Gitlab::Gpg::Commit.new(self).signature end def revert_branch_name diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index f60e5125c139..f363652745f9 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -10,10 +10,12 @@ def initialize(commit) end def has_signature? - @signature_text && @signed_text + !!(@signature_text && @signed_text) end def signature + return unless has_signature? + Gitlab::Gpg.using_tmp_keychain do # first we need to get the keyid from the signature to query the gpg # key belonging to the keyid. @@ -43,7 +45,7 @@ def create_cached_signature!(gpg_key) project: commit.project, gpg_key: gpg_key, gpg_key_primary_keyid: gpg_key&.primary_keyid, - valid_signature: !!(gpg_key && verified_signature&.valid?) + valid_signature: !!(gpg_key && verified_signature.valid?) ) end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 8b1747eebccc..c4d92b8bbbf3 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -4,6 +4,12 @@ describe '#signature' do let!(:project) { create :project, :repository, path: 'sample-project' } + context 'unisgned commit' do + it 'returns nil' do + expect(described_class.new(project.commit).signature).to be_nil + end + end + context 'known public key' do it 'returns a valid signature' do gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key -- GitLab From 7b616d39efaa7cba933d17dfae010d393c18d057 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 09:57:50 +0200 Subject: [PATCH 25/96] gpg signature is only valid when key is verified --- app/models/gpg_key.rb | 4 ++++ lib/gitlab/gpg/commit.rb | 2 +- spec/lib/gitlab/gpg/commit_spec.rb | 28 ++++++++++++++++++++++++++-- spec/models/gpg_key_spec.rb | 16 ++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 26f9a3975c93..137abb60ddc3 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -48,6 +48,10 @@ def emails_with_verified_status end end + def verified? + emails_with_verified_status.any? { |_email, verified| verified } + end + private def extract_fingerprint diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index f363652745f9..d65a20f08f99 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -45,7 +45,7 @@ def create_cached_signature!(gpg_key) project: commit.project, gpg_key: gpg_key, gpg_key_primary_keyid: gpg_key&.primary_keyid, - valid_signature: !!(gpg_key && verified_signature.valid?) + valid_signature: !!(gpg_key && gpg_key.verified? && verified_signature.valid?) ) end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index c4d92b8bbbf3..2a583dc1bd52 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -10,9 +10,9 @@ end end - context 'known public key' do + context 'known and verified public key' do it 'returns a valid signature' do - gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first) raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, @@ -34,6 +34,30 @@ end end + context 'known but unverified public key' do + it 'returns an invalid signature' do + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key + + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + allow(raw_commit).to receive :save! + + commit = create :commit, + git_commit: raw_commit, + project: project + + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + ) + end + end + context 'unknown public key' do it 'returns an invalid signature', :gpg do raw_commit = double(:raw_commit, signature: [ diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index ac446fca819d..3cb1723cc12b 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -65,6 +65,22 @@ end end + describe '#verified?' do + it 'returns true one of the email addresses in the key belongs to the user' do + user = create :user, email: 'bette.cartwright@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.verified?).to be_truthy + end + + it 'returns false if one of the email addresses in the key does not belong to the user' do + user = create :user, email: 'someone.else@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.verified?).to be_falsey + end + end + describe 'notification' do include EmailHelpers -- GitLab From 34810acd6c3d4dd27f43f6f07e47b4e06bb95f82 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 10:28:28 +0200 Subject: [PATCH 26/96] move signature cache read to Gpg::Commit as we write the cache in the gpg commit class already the read should also happen there. This also removes all logic from the main commit class, which just proxies the call to the Gpg::Commit now. --- app/models/commit.rb | 5 -- lib/gitlab/gpg/commit.rb | 3 ++ spec/lib/gitlab/gpg/commit_spec.rb | 61 +++++++++++++++++----- spec/models/commit_spec.rb | 82 ------------------------------ 4 files changed, 52 insertions(+), 99 deletions(-) diff --git a/app/models/commit.rb b/app/models/commit.rb index ed8b9a79a7ad..35593d53cbce 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -237,11 +237,6 @@ def status(ref = nil) def signature return @signature if defined?(@signature) - @signature = nil - - cached_signature = GpgSignature.find_by(commit_sha: sha) - return cached_signature if cached_signature.present? - @signature = Gitlab::Gpg::Commit.new(self).signature end diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index d65a20f08f99..2b61caaebb5f 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -16,6 +16,9 @@ def has_signature? def signature return unless has_signature? + cached_signature = GpgSignature.find_by(commit_sha: commit.sha) + return cached_signature if cached_signature.present? + Gitlab::Gpg.using_tmp_keychain do # first we need to get the keyid from the signature to query the gpg # key belonging to the keyid. diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 2a583dc1bd52..539e6d4641f8 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -11,19 +11,21 @@ end context 'known and verified public key' do - it 'returns a valid signature' do - gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first) + let!(:gpg_key) do + create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first) + end + let!(:commit) do raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') allow(raw_commit).to receive :save! - commit = create :commit, - git_commit: raw_commit, - project: project + create :commit, git_commit: raw_commit, project: project + end + it 'returns a valid signature' do expect(described_class.new(commit).signature).to have_attributes( commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', project: project, @@ -32,22 +34,33 @@ valid_signature: true ) end + + it 'returns the cached signature on second call' do + gpg_commit = described_class.new(commit) + + expect(gpg_commit).to receive(:verified_signature).twice.and_call_original + gpg_commit.signature + + # consecutive call + expect(gpg_commit).not_to receive(:verified_signature).and_call_original + gpg_commit.signature + end end context 'known but unverified public key' do - it 'returns an invalid signature' do - gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key + let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key } + let!(:commit) do raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') allow(raw_commit).to receive :save! - commit = create :commit, - git_commit: raw_commit, - project: project + create :commit, git_commit: raw_commit, project: project + end + it 'returns an invalid signature' do expect(described_class.new(commit).signature).to have_attributes( commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', project: project, @@ -56,20 +69,33 @@ valid_signature: false ) end + + it 'returns the cached signature on second call' do + gpg_commit = described_class.new(commit) + + expect(gpg_commit).to receive(:verified_signature).and_call_original + gpg_commit.signature + + # consecutive call + expect(gpg_commit).not_to receive(:verified_signature).and_call_original + gpg_commit.signature + end end context 'unknown public key' do - it 'returns an invalid signature', :gpg do + let!(:commit) do raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') allow(raw_commit).to receive :save! - commit = create :commit, + create :commit, git_commit: raw_commit, project: project + end + it 'returns an invalid signature' do expect(described_class.new(commit).signature).to have_attributes( commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', project: project, @@ -78,6 +104,17 @@ valid_signature: false ) end + + it 'returns the cached signature on second call' do + gpg_commit = described_class.new(commit) + + expect(gpg_commit).to receive(:verified_signature).and_call_original + gpg_commit.signature + + # consecutive call + expect(gpg_commit).not_to receive(:verified_signature).and_call_original + gpg_commit.signature + end end end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 4370c78e6fd6..528b211c9d62 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -414,86 +414,4 @@ expect(described_class.valid_hash?('a' * 41)).to be false end end - - describe '#signature' do - it 'returns nil if the commit is not signed' do - expect(commit.signature).to be_nil - end - - context 'signed commit', :gpg do - context 'known public key' do - it 'returns a valid signature' do - create :gpg_key, key: GpgHelpers::User1.public_key - - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') - allow(raw_commit).to receive :save! - - commit = create :commit, - git_commit: raw_commit, - project: project - - expect(commit.signature.valid_signature?).to be_truthy - end - - it 'returns the cached validation result on second call', :gpg do - create :gpg_key, key: GpgHelpers::User1.public_key - - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') - allow(raw_commit).to receive :save! - - commit = create :commit, - git_commit: raw_commit, - project: project - - expect(Gitlab::Gpg::Commit).to receive(:new).and_call_original - expect(commit.signature.valid_signature?).to be_truthy - - # second call returns the cache - expect(Gitlab::Gpg::Commit).not_to receive(:new).and_call_original - expect(commit.signature.valid_signature?).to be_truthy - end - end - - context 'unknown public key' do - it 'returns an invalid signature if the public key is unknown', :gpg do - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') - allow(raw_commit).to receive :save! - - commit = create :commit, - git_commit: raw_commit, - project: project - - expect(commit.signature.valid_signature?).to be_falsey - end - - it 'returns the cached validation result on second call', :gpg do - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') - allow(raw_commit).to receive :save! - - commit = create :commit, - git_commit: raw_commit, - project: project - - expect(Gitlab::Gpg::Commit).to receive(:new).and_call_original - expect(commit.signature.valid_signature?).to be_falsey - - # second call returns the cache - expect(Gitlab::Gpg::Commit).not_to receive(:new).and_call_original - expect(commit.signature.valid_signature?).to be_falsey - end - end - end - end end -- GitLab From 5d5fd4babe4cb75c7f8f9f18cc86c63a0fa58d16 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 12:43:04 +0200 Subject: [PATCH 27/96] store gpg_key_primary_keyid for unknown gpg keys we need to store the keyid to be able to update the signature later in case the missing key is added later. --- app/models/gpg_signature.rb | 1 + lib/gitlab/gpg/commit.rb | 6 ++++-- spec/lib/gitlab/gpg/commit_spec.rb | 2 +- spec/models/gpg_signature_spec.rb | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 03294354d912..4fe9c3210ff6 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -4,4 +4,5 @@ class GpgSignature < ActiveRecord::Base validates :commit_sha, presence: true validates :project, presence: true + validates :gpg_key_primary_keyid, presence: true end diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 2b61caaebb5f..8d2e6269618e 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -43,12 +43,14 @@ def verified_signature end def create_cached_signature!(gpg_key) + verified_signature_result = verified_signature + GpgSignature.create!( commit_sha: commit.sha, project: commit.project, gpg_key: gpg_key, - gpg_key_primary_keyid: gpg_key&.primary_keyid, - valid_signature: !!(gpg_key && gpg_key.verified? && verified_signature.valid?) + gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature_result.fingerprint, + valid_signature: !!(gpg_key && gpg_key.verified? && verified_signature_result.valid?) ) end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 539e6d4641f8..448b16a656ea 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -100,7 +100,7 @@ commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', project: project, gpg_key: nil, - gpg_key_primary_keyid: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, valid_signature: false ) end diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb index d2720c416941..b3f842628742 100644 --- a/spec/models/gpg_signature_spec.rb +++ b/spec/models/gpg_signature_spec.rb @@ -10,5 +10,6 @@ subject { described_class.new } it { is_expected.to validate_presence_of(:commit_sha) } it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:gpg_key_primary_keyid) } end end -- GitLab From 502e31bec9af080bcb483b0d57c8b52aeb507f93 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 13:37:03 +0200 Subject: [PATCH 28/96] memoize verified_signature call --- lib/gitlab/gpg/commit.rb | 25 +++++++++++++++++-------- spec/lib/gitlab/gpg/commit_spec.rb | 12 ++++++------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 8d2e6269618e..8bc430db7159 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -19,6 +19,19 @@ def signature cached_signature = GpgSignature.find_by(commit_sha: commit.sha) return cached_signature if cached_signature.present? + using_keychain do |gpg_key| + if gpg_key + Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) + @verified_signature = nil + end + + create_cached_signature!(gpg_key) + end + end + + private + + def using_keychain Gitlab::Gpg.using_tmp_keychain do # first we need to get the keyid from the signature to query the gpg # key belonging to the keyid. @@ -30,27 +43,23 @@ def signature Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) end - create_cached_signature!(gpg_key) + yield gpg_key end end - private - def verified_signature - GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |verified_signature| + @verified_signature ||= GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |verified_signature| return verified_signature end end def create_cached_signature!(gpg_key) - verified_signature_result = verified_signature - GpgSignature.create!( commit_sha: commit.sha, project: commit.project, gpg_key: gpg_key, - gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature_result.fingerprint, - valid_signature: !!(gpg_key && gpg_key.verified? && verified_signature_result.valid?) + gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint, + valid_signature: !!(gpg_key && gpg_key.verified? && verified_signature.valid?) ) end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 448b16a656ea..387ce8f74b4e 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -38,11 +38,11 @@ it 'returns the cached signature on second call' do gpg_commit = described_class.new(commit) - expect(gpg_commit).to receive(:verified_signature).twice.and_call_original + expect(gpg_commit).to receive(:using_keychain).and_call_original gpg_commit.signature # consecutive call - expect(gpg_commit).not_to receive(:verified_signature).and_call_original + expect(gpg_commit).not_to receive(:using_keychain).and_call_original gpg_commit.signature end end @@ -73,11 +73,11 @@ it 'returns the cached signature on second call' do gpg_commit = described_class.new(commit) - expect(gpg_commit).to receive(:verified_signature).and_call_original + expect(gpg_commit).to receive(:using_keychain).and_call_original gpg_commit.signature # consecutive call - expect(gpg_commit).not_to receive(:verified_signature).and_call_original + expect(gpg_commit).not_to receive(:using_keychain).and_call_original gpg_commit.signature end end @@ -108,11 +108,11 @@ it 'returns the cached signature on second call' do gpg_commit = described_class.new(commit) - expect(gpg_commit).to receive(:verified_signature).and_call_original + expect(gpg_commit).to receive(:using_keychain).and_call_original gpg_commit.signature # consecutive call - expect(gpg_commit).not_to receive(:verified_signature).and_call_original + expect(gpg_commit).not_to receive(:using_keychain).and_call_original gpg_commit.signature end end -- GitLab From d48eb77a96d29260c214391c5b8979ee17250452 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 14:18:00 +0200 Subject: [PATCH 29/96] allow updating of gpg signature through gpg commit --- lib/gitlab/gpg/commit.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 8bc430db7159..6e3f7c28ebad 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -29,6 +29,14 @@ def signature end end + def update_signature!(cached_signature) + using_keychain do |gpg_key| + cached_signature.update_attributes!( + valid_signature: self.class.gpg_signature_valid_signature_value(gpg_key, verified_signature) + ) + end + end + private def using_keychain @@ -59,9 +67,13 @@ def create_cached_signature!(gpg_key) project: commit.project, gpg_key: gpg_key, gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint, - valid_signature: !!(gpg_key && gpg_key.verified? && verified_signature.valid?) + valid_signature: self.class.gpg_signature_valid_signature_value(gpg_key, verified_signature) ) end + + def self.gpg_signature_valid_signature_value(gpg_key, verified_signature_) + !!(gpg_key && gpg_key.verified? && verified_signature_.valid?) + end end end end -- GitLab From 24671cd601e93133787ff9746fcacc3cf5d3fbf4 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 14:22:37 +0200 Subject: [PATCH 30/96] update invalid gpg signatures when key is created --- app/models/gpg_key.rb | 5 ++ .../gpg/invalid_gpg_signature_updater.rb | 19 +++++++ spec/factories/gpg_signature.rb | 11 ++++ .../gpg/invalid_gpg_signature_updater_spec.rb | 50 +++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 lib/gitlab/gpg/invalid_gpg_signature_updater.rb create mode 100644 spec/factories/gpg_signature.rb create mode 100644 spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 137abb60ddc3..6ca108d6b870 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -28,6 +28,7 @@ class GpgKey < ActiveRecord::Base unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint, :extract_primary_keyid + after_create :update_invalid_gpg_signatures after_create :notify_user def key=(value) @@ -66,6 +67,10 @@ def extract_primary_keyid self.primary_keyid = Gitlab::Gpg.primary_keyids_from_key(key).first end + def update_invalid_gpg_signatures + run_after_commit { Gitlab::Gpg::InvalidGpgSignatureUpdater.new(self).run } + end + def notify_user run_after_commit { NotificationService.new.new_gpg_key(self) } end diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb new file mode 100644 index 000000000000..6511a8f8285f --- /dev/null +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -0,0 +1,19 @@ +module Gitlab + module Gpg + class InvalidGpgSignatureUpdater + def initialize(gpg_key) + @gpg_key = gpg_key + end + + def run + GpgSignature + .where(valid_signature: false) + .where(gpg_key_primary_keyid: @gpg_key.primary_keyid) + .find_each do |gpg_signature| + commit = Gitlab::Git::Commit.find(gpg_signature.project.repository, gpg_signature.commit_sha) + Gitlab::Gpg::Commit.new(commit).update_signature!(gpg_signature) + end + end + end + end +end diff --git a/spec/factories/gpg_signature.rb b/spec/factories/gpg_signature.rb new file mode 100644 index 000000000000..a5aeffbe12dc --- /dev/null +++ b/spec/factories/gpg_signature.rb @@ -0,0 +1,11 @@ +require_relative '../support/gpg_helpers' + +FactoryGirl.define do + factory :gpg_signature do + commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) } + project + gpg_key + gpg_key_primary_keyid { gpg_key.primary_keyid } + valid_signature true + end +end diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb new file mode 100644 index 000000000000..48f8fa285aac --- /dev/null +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do + describe '#run' do + context 'gpg signature did not have an associated gpg key' do + let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } + let!(:project) { create :project, :repository, path: 'sample-project' } + let!(:commit) do + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: commit_sha) + allow(raw_commit).to receive :save! + + create :commit, git_commit: raw_commit, project: project + end + + let!(:gpg_signature) do + create :gpg_signature, + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + end + + before do + allow(Gitlab::Git::Commit).to receive(:find).with(kind_of(Repository), commit_sha).and_return(commit) + end + + it 'updates the signature to being valid when the missing gpg key is added' do + # InvalidGpgSignatureUpdater is called by the after_create hook + create :gpg_key, + key: GpgHelpers::User1.public_key, + user: create(:user, email: GpgHelpers::User1.emails.first) + + expect(gpg_signature.reload.valid_signature).to be_truthy + end + + it 'keeps the signature at being invalid when an unrelated gpg key is added' do + # InvalidGpgSignatureUpdater is called by the after_create hook + create :gpg_key, + key: GpgHelpers::User2.public_key, + user: create(:user, email: GpgHelpers::User2.emails.first) + + expect(gpg_signature.reload.valid_signature).to be_falsey + end + end + end +end -- GitLab From e75ab064302bcec45a5953a636cc9f3295f2690c Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 15 Jun 2017 15:07:44 +0200 Subject: [PATCH 31/96] update invalid gpg signatures when email changes --- app/models/gpg_key.rb | 8 +- app/models/user.rb | 5 ++ .../gpg/invalid_gpg_signature_updater_spec.rb | 86 +++++++++++++------ spec/models/user_spec.rb | 20 +++++ 4 files changed, 90 insertions(+), 29 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 6ca108d6b870..ec30658e7eac 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -53,6 +53,10 @@ def verified? emails_with_verified_status.any? { |_email, verified| verified } end + def update_invalid_gpg_signatures + Gitlab::Gpg::InvalidGpgSignatureUpdater.new(self).run + end + private def extract_fingerprint @@ -67,10 +71,6 @@ def extract_primary_keyid self.primary_keyid = Gitlab::Gpg.primary_keyids_from_key(key).first end - def update_invalid_gpg_signatures - run_after_commit { Gitlab::Gpg::InvalidGpgSignatureUpdater.new(self).run } - end - def notify_user run_after_commit { NotificationService.new.new_gpg_key(self) } end diff --git a/app/models/user.rb b/app/models/user.rb index 5aebd36cf8ad..791d099605db 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -155,6 +155,7 @@ def update_tracked_fields!(request) before_validation :set_public_email, if: :public_email_changed? after_update :update_emails_with_primary_email, if: :email_changed? + after_update :update_invalid_gpg_signatures, if: :email_changed? before_save :ensure_authentication_token, :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: :external_changed? after_save :ensure_namespace_correct @@ -513,6 +514,10 @@ def update_emails_with_primary_email end end + def update_invalid_gpg_signatures + gpg_keys.each(&:update_invalid_gpg_signatures) + end + # Returns the groups a user has access to def authorized_groups union = Gitlab::SQL::Union diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index 48f8fa285aac..42348b3f2c1e 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -2,37 +2,39 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do describe '#run' do - context 'gpg signature did not have an associated gpg key' do - let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } - let!(:project) { create :project, :repository, path: 'sample-project' } - let!(:commit) do - raw_commit = double(:raw_commit, signature: [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ], sha: commit_sha) - allow(raw_commit).to receive :save! - - create :commit, git_commit: raw_commit, project: project - end + let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } + let!(:project) { create :project, :repository, path: 'sample-project' } + let!(:commit) do + raw_commit = double(:raw_commit, signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], sha: commit_sha) + allow(raw_commit).to receive :save! - let!(:gpg_signature) do - create :gpg_signature, - project: project, - commit_sha: commit_sha, - gpg_key: nil, - gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false - end + create :commit, git_commit: raw_commit, project: project + end - before do - allow(Gitlab::Git::Commit).to receive(:find).with(kind_of(Repository), commit_sha).and_return(commit) - end + let!(:gpg_signature) do + create :gpg_signature, + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + end + + before do + allow(Gitlab::Git::Commit).to receive(:find).with(kind_of(Repository), commit_sha).and_return(commit) + end + + context 'gpg signature did not have an associated gpg key' do + let!(:user) { create :user, email: GpgHelpers::User1.emails.first } it 'updates the signature to being valid when the missing gpg key is added' do # InvalidGpgSignatureUpdater is called by the after_create hook create :gpg_key, key: GpgHelpers::User1.public_key, - user: create(:user, email: GpgHelpers::User1.emails.first) + user: user expect(gpg_signature.reload.valid_signature).to be_truthy end @@ -41,7 +43,41 @@ # InvalidGpgSignatureUpdater is called by the after_create hook create :gpg_key, key: GpgHelpers::User2.public_key, - user: create(:user, email: GpgHelpers::User2.emails.first) + user: user + + expect(gpg_signature.reload.valid_signature).to be_falsey + end + end + + context 'gpg signature did have an associated unverified gpg key' do + let!(:user) do + create(:user, email: 'unrelated@example.com').tap do |user| + user.skip_reconfirmation! + end + end + + it 'updates the signature to being valid when the user updates the email address' do + create :gpg_key, + key: GpgHelpers::User1.public_key, + user: user + + expect(gpg_signature.reload.valid_signature).to be_falsey + + # InvalidGpgSignatureUpdater is called by the after_update hook + user.update_attributes!(email: GpgHelpers::User1.emails.first) + + expect(gpg_signature.reload.valid_signature).to be_truthy + end + + it 'keeps the signature at being invalid when the changed email address is still unrelated' do + create :gpg_key, + key: GpgHelpers::User1.public_key, + user: user + + expect(gpg_signature.reload.valid_signature).to be_falsey + + # InvalidGpgSignatureUpdater is called by the after_update hook + user.update_attributes!(email: 'still.unrelated@example.com') expect(gpg_signature.reload.valid_signature).to be_falsey end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 20bdb7e37da4..14b0440af9cd 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -350,6 +350,26 @@ end end + describe 'after update hook' do + describe '.update_invalid_gpg_signatures' do + let(:user) do + create(:user, email: 'tula.torphy@abshire.ca').tap do |user| + user.skip_reconfirmation! + end + end + + it 'does nothing when the name is updated' do + expect(user).not_to receive(:update_invalid_gpg_signatures) + user.update_attributes!(name: 'Bette') + end + + it 'synchronizes the gpg keys when the email is updated' do + expect(user).to receive(:update_invalid_gpg_signatures) + user.update_attributes!(email: 'shawnee.ritchie@denesik.com') + end + end + end + describe '#update_tracked_fields!', :clean_gitlab_redis_shared_state do let(:request) { OpenStruct.new(remote_ip: "127.0.0.1") } let(:user) { create(:user) } -- GitLab From d7f42643681ad1cc634a502ef25acab6b111c6e5 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 16 Jun 2017 14:37:28 +0200 Subject: [PATCH 32/96] no need for passing parameter we introduced memoizing, so it's safe to call the method multiple times. --- lib/gitlab/gpg/commit.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 6e3f7c28ebad..437f13ef3118 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -32,7 +32,7 @@ def signature def update_signature!(cached_signature) using_keychain do |gpg_key| cached_signature.update_attributes!( - valid_signature: self.class.gpg_signature_valid_signature_value(gpg_key, verified_signature) + valid_signature: gpg_signature_valid_signature_value(gpg_key) ) end end @@ -67,12 +67,12 @@ def create_cached_signature!(gpg_key) project: commit.project, gpg_key: gpg_key, gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint, - valid_signature: self.class.gpg_signature_valid_signature_value(gpg_key, verified_signature) + valid_signature: gpg_signature_valid_signature_value(gpg_key) ) end - def self.gpg_signature_valid_signature_value(gpg_key, verified_signature_) - !!(gpg_key && gpg_key.verified? && verified_signature_.valid?) + def gpg_signature_valid_signature_value(gpg_key) + !!(gpg_key && gpg_key.verified? && verified_signature.valid?) end end end -- GitLab From 028ecb081b7ed71d5123ded535d5b7f87db7cc67 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 16 Jun 2017 14:43:01 +0200 Subject: [PATCH 33/96] need to wrap the raw commit in a commit model --- lib/gitlab/gpg/invalid_gpg_signature_updater.rb | 3 ++- .../lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb index 6511a8f8285f..06e4823de327 100644 --- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -10,7 +10,8 @@ def run .where(valid_signature: false) .where(gpg_key_primary_keyid: @gpg_key.primary_keyid) .find_each do |gpg_signature| - commit = Gitlab::Git::Commit.find(gpg_signature.project.repository, gpg_signature.commit_sha) + raw_commit = Gitlab::Git::Commit.find(gpg_signature.project.repository, gpg_signature.commit_sha) + commit = ::Commit.new(raw_commit, gpg_signature.project) Gitlab::Gpg::Commit.new(commit).update_signature!(gpg_signature) end end diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index 42348b3f2c1e..8b60b36452b3 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -4,13 +4,18 @@ describe '#run' do let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } let!(:project) { create :project, :repository, path: 'sample-project' } - let!(:commit) do + let!(:raw_commit) do raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data ], sha: commit_sha) + allow(raw_commit).to receive :save! + raw_commit + end + + let!(:commit) do create :commit, git_commit: raw_commit, project: project end @@ -24,7 +29,7 @@ end before do - allow(Gitlab::Git::Commit).to receive(:find).with(kind_of(Repository), commit_sha).and_return(commit) + allow(Gitlab::Git::Commit).to receive(:find).with(kind_of(Repository), commit_sha).and_return(raw_commit) end context 'gpg signature did not have an associated gpg key' do -- GitLab From a01eabc19f0d5b73f6216edc10d19a7765c73b53 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 21 Jun 2017 21:54:16 +0200 Subject: [PATCH 34/96] update rugged the rugged versions up to 0.26.0b3 had a bug concerning the signature extraction. The extracted signature was not always the same, probably due to a buffer (overflow) issue in libgit. see https://github.com/libgit2/rugged/issues/608 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- lib/gitlab/git/commit.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 93934d03e421..d41d75b250a2 100644 --- a/Gemfile +++ b/Gemfile @@ -15,7 +15,7 @@ gem 'default_value_for', '~> 3.0.0' gem 'mysql2', '~> 0.4.5', group: :mysql gem 'pg', '~> 0.18.2', group: :postgres -gem 'rugged', '~> 0.25.1.1' +gem 'rugged', '~> 0.26.0' gem 'grape-route-helpers', '~> 2.0.0' gem 'faraday', '~> 0.12' diff --git a/Gemfile.lock b/Gemfile.lock index 77e87e2885fa..2483b0bf35c5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -751,7 +751,7 @@ GEM rubyzip (1.2.1) rufus-scheduler (3.4.0) et-orbi (~> 1.0) - rugged (0.25.1.1) + rugged (0.26.0) safe_yaml (1.0.4) sanitize (2.1.0) nokogiri (>= 1.4.4) @@ -1084,7 +1084,7 @@ DEPENDENCIES ruby-prof (~> 0.16.2) ruby_parser (~> 3.8) rufus-scheduler (~> 3.4) - rugged (~> 0.25.1.1) + rugged (~> 0.26.0) sanitize (~> 2.0) sass-rails (~> 5.0.6) scss_lint (~> 0.54.0) diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 4dcaff5e0a08..ca7e3a7c4bee 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -336,7 +336,7 @@ def to_patch(options = {}) begin raw_commit.to_mbox(options) rescue Rugged::InvalidError => ex - if ex.message =~ /Commit \w+ is a merge commit/ + if ex.message =~ /commit \w+ is a merge commit/i 'Patch format is not currently supported for merge commits.' end end -- GitLab From 9d30a80d24a583aad267a8a11f685058eab2c864 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 22 Jun 2017 08:47:11 +0200 Subject: [PATCH 35/96] update features specs for gpg commits --- spec/features/commits_spec.rb | 51 ++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 1dbcf09d4a0a..8f89b4651608 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -206,36 +206,51 @@ end describe 'GPG signed commits' do - let!(:user) { create :user, email: GpgHelpers::User1.emails.first } - let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key, user: user } - before do - project.team << [user, :master] - login_with(user) - end - - it 'shows the signed status', :gpg do # FIXME: add this to the test repository directly remote_path = project.repository.path_to_repo Dir.mktmpdir do |dir| FileUtils.cd dir do `git clone --quiet #{remote_path} .` - `git commit --quiet -S#{GpgHelpers::User1.primary_keyid} --allow-empty -m "signed commit, verified key/email"` - `git commit --quiet -S#{GpgHelpers::User2.primary_keyid} --allow-empty -m "signed commit, unverified key/email"` + `git commit --quiet -S#{GpgHelpers::User1.primary_keyid} --allow-empty -m "signed commit by nannie bernhard"` + `git commit --quiet -S#{GpgHelpers::User2.primary_keyid} --allow-empty -m "signed commit by bette cartwright"` `git push --quiet` end end + end + + it 'changes from unverified to verified when the user changes his email to match the gpg key' do + user = create :user, email: 'unrelated.user@example.org' + project.team << [user, :master] + + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + + login_with(user) visit namespace_project_commits_path(project.namespace, project, :master) within '#commits-list' do expect(page).to have_content 'Unverified' - expect(page).to have_content 'Verified' + expect(page).not_to have_content 'Verified' end - # user changes his email which makes the gpg key unverified + # user changes his email which makes the gpg key verified user.skip_reconfirmation! - user.update_attributes!(email: 'bette.cartwright@example.org') + user.update_attributes!(email: GpgHelpers::User1.emails.first) + + visit namespace_project_commits_path(project.namespace, project, :master) + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).to have_content 'Verified' + end + end + + it 'changes from unverified to verified when the user adds the missing gpg key' do + user = create :user, email: GpgHelpers::User1.emails.first + project.team << [user, :master] + + login_with(user) visit namespace_project_commits_path(project.namespace, project, :master) @@ -243,6 +258,16 @@ expect(page).to have_content 'Unverified' expect(page).not_to have_content 'Verified' end + + # user adds the gpg key which makes the signature valid + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + + visit namespace_project_commits_path(project.namespace, project, :master) + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).to have_content 'Verified' + end end end end -- GitLab From 9816856d055b33de9c47d9e3b73c4acb99c5b5e6 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 22 Jun 2017 14:18:01 +0200 Subject: [PATCH 36/96] perform signature update in sidekiq worker --- app/models/gpg_key.rb | 8 +++-- app/models/user.rb | 3 +- .../invalid_gpg_signature_update_worker.rb | 12 +++++++ config/sidekiq_queues.yml | 1 + spec/features/commits_spec.rb | 14 +++++--- ...nvalid_gpg_signature_update_worker_spec.rb | 36 +++++++++++++++++++ 6 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 app/workers/invalid_gpg_signature_update_worker.rb create mode 100644 spec/workers/invalid_gpg_signature_update_worker_spec.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index ec30658e7eac..a444792581a2 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -28,7 +28,7 @@ class GpgKey < ActiveRecord::Base unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint, :extract_primary_keyid - after_create :update_invalid_gpg_signatures + after_create :update_invalid_gpg_signatures_after_create after_create :notify_user def key=(value) @@ -54,7 +54,7 @@ def verified? end def update_invalid_gpg_signatures - Gitlab::Gpg::InvalidGpgSignatureUpdater.new(self).run + InvalidGpgSignatureUpdateWorker.perform_async(self.id) end private @@ -74,4 +74,8 @@ def extract_primary_keyid def notify_user run_after_commit { NotificationService.new.new_gpg_key(self) } end + + def update_invalid_gpg_signatures_after_create + run_after_commit { update_invalid_gpg_signatures } + end end diff --git a/app/models/user.rb b/app/models/user.rb index 791d099605db..931b760df34f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,6 +13,7 @@ class User < ActiveRecord::Base include IgnorableColumn include FeatureGate include CreatedAtFilterable + include AfterCommitQueue DEFAULT_NOTIFICATION_LEVEL = :participating @@ -515,7 +516,7 @@ def update_emails_with_primary_email end def update_invalid_gpg_signatures - gpg_keys.each(&:update_invalid_gpg_signatures) + run_after_commit { gpg_keys.each(&:update_invalid_gpg_signatures) } end # Returns the groups a user has access to diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb new file mode 100644 index 000000000000..277dd604aa89 --- /dev/null +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -0,0 +1,12 @@ +class InvalidGpgSignatureUpdateWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(gpg_key_id) + if gpg_key = GpgKey.find_by(id: gpg_key_id) + Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run + else + Rails.logger.error("InvalidGpgSignatureUpdateWorker: couldn't find gpg_key with ID=#{gpg_key_id}, skipping job") + end + end +end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 1d9e69a2408f..cf0f57196835 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -29,6 +29,7 @@ - [email_receiver, 2] - [emails_on_push, 2] - [mailers, 2] + - [invalid_gpg_signature_update, 2] - [upload_checksum, 1] - [use_key, 1] - [repository_fork, 1] diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 8f89b4651608..7635e87e8388 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -223,7 +223,9 @@ user = create :user, email: 'unrelated.user@example.org' project.team << [user, :master] - create :gpg_key, key: GpgHelpers::User1.public_key, user: user + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end login_with(user) @@ -235,8 +237,10 @@ end # user changes his email which makes the gpg key verified - user.skip_reconfirmation! - user.update_attributes!(email: GpgHelpers::User1.emails.first) + Sidekiq::Testing.inline! do + user.skip_reconfirmation! + user.update_attributes!(email: GpgHelpers::User1.emails.first) + end visit namespace_project_commits_path(project.namespace, project, :master) @@ -260,7 +264,9 @@ end # user adds the gpg key which makes the signature valid - create :gpg_key, key: GpgHelpers::User1.public_key, user: user + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end visit namespace_project_commits_path(project.namespace, project, :master) diff --git a/spec/workers/invalid_gpg_signature_update_worker_spec.rb b/spec/workers/invalid_gpg_signature_update_worker_spec.rb new file mode 100644 index 000000000000..8d568076e1ab --- /dev/null +++ b/spec/workers/invalid_gpg_signature_update_worker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe InvalidGpgSignatureUpdateWorker do + context 'when GpgKey is found' do + it 'calls NotificationService.new.run' do + gpg_key = create(:gpg_key) + invalid_signature_updater = double(:invalid_signature_updater) + + expect(Gitlab::Gpg::InvalidGpgSignatureUpdater).to receive(:new).with(gpg_key).and_return(invalid_signature_updater) + expect(invalid_signature_updater).to receive(:run) + + described_class.new.perform(gpg_key.id) + end + end + + context 'when GpgKey is not found' do + let(:nonexisting_gpg_key_id) { -1 } + + it 'logs InvalidGpgSignatureUpdateWorker process skipping' do + expect(Rails.logger).to receive(:error) + .with("InvalidGpgSignatureUpdateWorker: couldn't find gpg_key with ID=-1, skipping job") + + described_class.new.perform(nonexisting_gpg_key_id) + end + + it 'does not raise errors' do + expect { described_class.new.perform(nonexisting_gpg_key_id) }.not_to raise_error + end + + it 'does not call NotificationService.new.run' do + expect(Gitlab::Gpg::InvalidGpgSignatureUpdater).not_to receive(:new) + + described_class.new.perform(nonexisting_gpg_key_id) + end + end +end -- GitLab From 2ea951454a535ba16693c083c122218b8608329b Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 22 Jun 2017 17:21:03 +0200 Subject: [PATCH 37/96] allow removal of gpg key by nullifying signatures --- app/models/gpg_key.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index a444792581a2..bd5d833d68c8 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -4,6 +4,7 @@ class GpgKey < ActiveRecord::Base KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze belongs_to :user + has_many :gpg_signatures, dependent: :nullify validates :key, presence: true, -- GitLab From 78b5264511a76e481110236e9c14764d9c1b953a Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 23 Jun 2017 22:52:43 +0200 Subject: [PATCH 38/96] add gpg commit popover badges --- app/assets/javascripts/commons/bootstrap.js | 1 + app/assets/javascripts/main.js | 6 ++ app/assets/stylesheets/pages/commits.scss | 43 ++++++++++ app/helpers/commits_helper.rb | 78 +++++++++++++++++++ .../projects/commit/_signature.html.haml | 4 +- spec/features/commits_spec.rb | 25 ++++++ 6 files changed, 154 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index 36bfe457be90..510bedbf641a 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -8,6 +8,7 @@ import 'bootstrap-sass/assets/javascripts/bootstrap/modal'; import 'bootstrap-sass/assets/javascripts/bootstrap/tab'; import 'bootstrap-sass/assets/javascripts/bootstrap/transition'; import 'bootstrap-sass/assets/javascripts/bootstrap/tooltip'; +import 'bootstrap-sass/assets/javascripts/bootstrap/popover'; // custom jQuery functions $.fn.extend({ diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e96d51de8389..ecf7a677c993 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -159,6 +159,8 @@ document.addEventListener('beforeunload', function () { $(document).off('scroll'); // Close any open tooltips $('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy'); + // Close any open popover + $('[data-toggle="popover"]').popover('destroy'); }); window.addEventListener('hashchange', gl.utils.handleLocationHash); @@ -247,6 +249,10 @@ $(function () { return $(el).data('placement') || 'bottom'; } }); + // Initialize popovers + $body.popover({ + selector: '[data-toggle="popover"]' + }); $('.trigger-submit').on('change', function () { return $(this).parents('form').submit(); // Form submitter diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index fd0871ec0b89..54f6156ad8f3 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -283,3 +283,46 @@ color: $gl-text-color; } } + +.gpg-badge { + &.valid { + color: $brand-success; + } + + &.invalid { + color: $gray; + } +} + +.gpg-badge-popover-title { + font-weight: normal; +} + +.gpg-badge-popover-icon { + float: left; + font-size: 35px; + line-height: 35px; + width: 32px; + margin-right: $btn-side-margin; + + &.valid { + color: $brand-success; + } + + &.invalid { + color: $gray; + } +} + +.gpg-badge-popover-avatar { + float: left; + margin-bottom: $gl-padding; + + .avatar { + margin-left: 0; + } +} + +.gpg-badge-popover-username { + font-weight: bold; +} diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d08e346d605d..34ba56942886 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -212,4 +212,82 @@ def limited_commits(commits) [commits, 0] end end + + def commit_gpg_signature_badge(signature) + if signature.valid_signature? + commit_gpg_valid_signature_badge(signature) + else + commit_gpg_invalid_signature_badge(signature) + end + end + + def commit_gpg_valid_signature_badge(signature) + title = capture do + concat content_tag('i', '', class: 'fa fa-check-circle gpg-badge-popover-icon valid', 'aria-hidden' => 'true') + concat 'This commit was signed with a verified signature.' + end + + content = capture do + concat( + content_tag(:div, class: 'gpg-badge-popover-avatar') do + user_avatar(user: signature.gpg_key.user, size: 32) + end + ) + + concat( + content_tag(:div, class: 'gpg-badge-popover-username') do + signature.gpg_key.user.username + end + ) + + concat( + content_tag(:div) do + signature.gpg_key.user.name + end + ) + end + + commit_gpg_signature_badge_with(signature, label: 'Verified', title: title, content: content, css_classes: ['valid']) + end + + def commit_gpg_invalid_signature_badge(signature) + title = capture do + concat content_tag('i', '', class: 'fa fa-question-circle gpg-badge-popover-icon invalid', 'aria-hidden' => 'true') + concat 'This commit was signed with an unverified signature.' + end + commit_gpg_signature_badge_with(signature, label: 'Unverified', title: title, css_classes: ['invalid']) + end + + def commit_gpg_signature_badge_with(signature, label:, title: '', content: '', css_classes: []) + css_classes = %w(btn btn-xs gpg-badge) + css_classes + + content = capture do + concat( + content_tag(:div, class: 'clearfix') do + content + end + ) + + concat "GPG key ID: #{signature.gpg_key_primary_keyid}" + end + + title = capture do + content_tag 'span', class: 'gpg-badge-popover-title' do + title + end + end + + data = { + toggle: 'popover', + html: 'true', + placement: 'auto bottom', + trigger: 'focus', + title: title, + content: content + } + + content_tag :button, class: css_classes, data: data do + label + end + end end diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 48665ede6eb1..00120a665c5b 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,4 +1,2 @@ - if signature - %a.btn.disabled.btn-xs{ class: ('btn-success' if signature.valid_signature?) } - %i.fa.fa-key{ class: ('fa-inverse' if signature.valid_signature?) } - = signature.valid_signature? ? 'Verified' : 'Unverified' + = commit_gpg_signature_badge(signature) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 7635e87e8388..236e3089c6c5 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -275,5 +275,30 @@ expect(page).to have_content 'Verified' end end + + it 'shows popover badges', :js do + user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' + project.team << [user, :master] + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + login_with(user) + visit namespace_project_commits_path(project.namespace, project, :master) + + click_on 'Verified' + within '.popover' do + expect(page).to have_content 'This commit was signed with a verified signature.' + expect(page).to have_content 'nannie.bernhard' + expect(page).to have_content 'Nannie Bernhard' + expect(page).to have_content "GPG key ID: #{GpgHelpers::User1.primary_keyid}" + end + + click_on 'Unverified', match: :first + within '.popover' do + expect(page).to have_content 'This commit was signed with an unverified signature.' + expect(page).to have_content "GPG key ID: #{GpgHelpers::User2.primary_keyid}" + end + end end end -- GitLab From ee7468e786e434273211586df1408a3c6268e9ed Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 23 Jun 2017 22:53:41 +0200 Subject: [PATCH 39/96] we need to update the gpg_key as well --- lib/gitlab/gpg/commit.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 437f13ef3118..99d112a51a30 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -32,7 +32,8 @@ def signature def update_signature!(cached_signature) using_keychain do |gpg_key| cached_signature.update_attributes!( - valid_signature: gpg_signature_valid_signature_value(gpg_key) + valid_signature: gpg_signature_valid_signature_value(gpg_key), + gpg_key: gpg_key ) end end -- GitLab From 7fc69a7ed4acb6a583c473799b4169825dcd3777 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Sat, 24 Jun 2017 21:24:49 +0200 Subject: [PATCH 40/96] linkify the whole user badge part, not only avatar --- app/assets/stylesheets/pages/commits.scss | 5 ++++ app/helpers/commits_helper.rb | 36 ++++++++++++----------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 54f6156ad8f3..b1710eee1bf7 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -314,6 +314,11 @@ } } +.gpg-badge-popover-user-link { + text-decoration: none; + color: $gl-text-color; +} + .gpg-badge-popover-avatar { float: left; margin-bottom: $gl-padding; diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 34ba56942886..e31baa52e99e 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -228,23 +228,25 @@ def commit_gpg_valid_signature_badge(signature) end content = capture do - concat( - content_tag(:div, class: 'gpg-badge-popover-avatar') do - user_avatar(user: signature.gpg_key.user, size: 32) - end - ) - - concat( - content_tag(:div, class: 'gpg-badge-popover-username') do - signature.gpg_key.user.username - end - ) - - concat( - content_tag(:div) do - signature.gpg_key.user.name - end - ) + link_to user_path(signature.gpg_key.user), class: 'gpg-badge-popover-user-link' do + concat( + content_tag(:div, class: 'gpg-badge-popover-avatar') do + user_avatar_without_link(user: signature.gpg_key.user, size: 32) + end + ) + + concat( + content_tag(:div, class: 'gpg-badge-popover-username') do + signature.gpg_key.user.username + end + ) + + concat( + content_tag(:div) do + signature.gpg_key.user.name + end + ) + end end commit_gpg_signature_badge_with(signature, label: 'Verified', title: title, content: content, css_classes: ['valid']) -- GitLab From afd7582af6a20d72b1d941d9849f331aee8f994a Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 26 Jun 2017 09:13:36 +0200 Subject: [PATCH 41/96] extract variable --- spec/lib/gitlab/gpg/commit_spec.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 387ce8f74b4e..661956b7bb7b 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -3,6 +3,7 @@ RSpec.describe Gitlab::Gpg::Commit do describe '#signature' do let!(:project) { create :project, :repository, path: 'sample-project' } + let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } context 'unisgned commit' do it 'returns nil' do @@ -19,7 +20,7 @@ raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + ], sha: commit_sha) allow(raw_commit).to receive :save! create :commit, git_commit: raw_commit, project: project @@ -27,7 +28,7 @@ it 'returns a valid signature' do expect(described_class.new(commit).signature).to have_attributes( - commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', + commit_sha: commit_sha, project: project, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, @@ -54,7 +55,7 @@ raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + ], sha: commit_sha) allow(raw_commit).to receive :save! create :commit, git_commit: raw_commit, project: project @@ -62,7 +63,7 @@ it 'returns an invalid signature' do expect(described_class.new(commit).signature).to have_attributes( - commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', + commit_sha: commit_sha, project: project, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, @@ -87,7 +88,7 @@ raw_commit = double(:raw_commit, signature: [ GpgHelpers::User1.signed_commit_signature, GpgHelpers::User1.signed_commit_base_data - ], sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33') + ], sha: commit_sha) allow(raw_commit).to receive :save! create :commit, @@ -97,7 +98,7 @@ it 'returns an invalid signature' do expect(described_class.new(commit).signature).to have_attributes( - commit_sha: '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', + commit_sha: commit_sha, project: project, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, -- GitLab From 5013f3a8167bb7c665b19f44ae66e543fe0b2fce Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 26 Jun 2017 14:29:01 +0200 Subject: [PATCH 42/96] use updated gitlab-test repo for signed commits --- spec/features/commits_spec.rb | 24 +++++------------------- spec/support/test_env.rb | 1 + 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 236e3089c6c5..274247fc4d38 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'fileutils' describe 'Commits' do include CiStatusHelper @@ -206,19 +205,6 @@ end describe 'GPG signed commits' do - before do - # FIXME: add this to the test repository directly - remote_path = project.repository.path_to_repo - Dir.mktmpdir do |dir| - FileUtils.cd dir do - `git clone --quiet #{remote_path} .` - `git commit --quiet -S#{GpgHelpers::User1.primary_keyid} --allow-empty -m "signed commit by nannie bernhard"` - `git commit --quiet -S#{GpgHelpers::User2.primary_keyid} --allow-empty -m "signed commit by bette cartwright"` - `git push --quiet` - end - end - end - it 'changes from unverified to verified when the user changes his email to match the gpg key' do user = create :user, email: 'unrelated.user@example.org' project.team << [user, :master] @@ -229,7 +215,7 @@ login_with(user) - visit namespace_project_commits_path(project.namespace, project, :master) + visit namespace_project_commits_path(project.namespace, project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -242,7 +228,7 @@ user.update_attributes!(email: GpgHelpers::User1.emails.first) end - visit namespace_project_commits_path(project.namespace, project, :master) + visit namespace_project_commits_path(project.namespace, project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -256,7 +242,7 @@ login_with(user) - visit namespace_project_commits_path(project.namespace, project, :master) + visit namespace_project_commits_path(project.namespace, project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -268,7 +254,7 @@ create :gpg_key, key: GpgHelpers::User1.public_key, user: user end - visit namespace_project_commits_path(project.namespace, project, :master) + visit namespace_project_commits_path(project.namespace, project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -284,7 +270,7 @@ end login_with(user) - visit namespace_project_commits_path(project.namespace, project, :master) + visit namespace_project_commits_path(project.namespace, project, :'signed-commits') click_on 'Verified' within '.popover' do diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index c32c05b03e21..7682bdf8cd08 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -5,6 +5,7 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { + 'signed-commits' => '5d4a1cb', 'not-merged-branch' => 'b83d6e3', 'branch-merged' => '498214d', 'empty-branch' => '7efb185', -- GitLab From 4648d0016f67c9b780e3fff475e458fc28825daf Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 26 Jun 2017 14:41:17 +0200 Subject: [PATCH 43/96] popover trigger needs to be defined in js init According to https://github.com/twbs/bootstrap/issues/10547 it's not possible to have the trigger defined on the delegated element, i.e. not defined as a data attribute. --- app/assets/javascripts/main.js | 3 ++- app/helpers/commits_helper.rb | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ecf7a677c993..d039ca9e47c5 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -251,7 +251,8 @@ $(function () { }); // Initialize popovers $body.popover({ - selector: '[data-toggle="popover"]' + selector: '[data-toggle="popover"]', + trigger: 'focus' }); $('.trigger-submit').on('change', function () { return $(this).parents('form').submit(); diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index e31baa52e99e..42e9379d1f45 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -283,7 +283,6 @@ def commit_gpg_signature_badge_with(signature, label:, title: '', content: '', c toggle: 'popover', html: 'true', placement: 'auto bottom', - trigger: 'focus', title: title, content: content } -- GitLab From 9a759c620179c72821fa6217c138036b57ea3695 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 28 Jun 2017 12:51:18 +0200 Subject: [PATCH 44/96] add changelog --- changelogs/unreleased/feature-gpg-signed-commits.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/feature-gpg-signed-commits.yml diff --git a/changelogs/unreleased/feature-gpg-signed-commits.yml b/changelogs/unreleased/feature-gpg-signed-commits.yml new file mode 100644 index 000000000000..99bc5a309efb --- /dev/null +++ b/changelogs/unreleased/feature-gpg-signed-commits.yml @@ -0,0 +1,4 @@ +--- +title: GPG signed commits integration +merge_request: 9546 +author: Alexis Reigel -- GitLab From e9515dff845dfbbb51e556e4e6a4f9cf951cf239 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 28 Jun 2017 13:08:29 +0200 Subject: [PATCH 45/96] remove the :gpg rspec tag since everything (except the CurrentKeyChain method) operates on a tempoary keychain anyway we don't need this anymore. --- spec/features/profiles/gpg_keys_spec.rb | 2 +- spec/lib/gitlab/gpg_spec.rb | 10 ++++++++-- spec/models/gpg_key_spec.rb | 6 +++--- spec/spec_helper.rb | 6 ------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb index 552cca4a84e7..350126523b0f 100644 --- a/spec/features/profiles/gpg_keys_spec.rb +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Profile > GPG Keys', :gpg do +feature 'Profile > GPG Keys' do let(:user) { create(:user, email: GpgHelpers::User2.emails.first) } before do diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index edf7405d7f18..497fbeab5d5d 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -44,8 +44,14 @@ end end -describe Gitlab::Gpg::CurrentKeyChain, :gpg do - describe '.add', :gpg do +describe Gitlab::Gpg::CurrentKeyChain do + around do |example| + Gitlab::Gpg.using_tmp_keychain do + example.run + end + end + + describe '.add' do it 'stores the key in the keychain' do expect(GPGME::Key.find(:public, GpgHelpers::User1.fingerprint)).to eq [] diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 3cb1723cc12b..312e026a78e7 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -13,7 +13,7 @@ it { is_expected.not_to allow_value('BEGIN PGP').for(:key) } end - context 'callbacks', :gpg do + context 'callbacks' do describe 'extract_fingerprint' do it 'extracts the fingerprint from the gpg key' do gpg_key = described_class.new(key: GpgHelpers::User1.public_key) @@ -45,7 +45,7 @@ end end - describe '#emails', :gpg do + describe '#emails' do it 'returns the emails from the gpg key' do gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key @@ -53,7 +53,7 @@ end end - describe '#emails_with_verified_status', :gpg do + describe '#emails_with_verified_status' do it 'email is verified if the user has the matching email' do user = create :user, email: 'bette.cartwright@example.com' gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a0df233507b1..e7329210896f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -141,12 +141,6 @@ config.around(:each, :postgresql) do |example| example.run if Gitlab::Database.postgresql? end - - config.around(:each, :gpg) do |example| - Gitlab::Gpg.using_tmp_keychain do - example.run - end - end end FactoryGirl::SyntaxRunner.class_eval do -- GitLab From bd476c1b4cd3399e684cc833a350b1f34c20b115 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 28 Jun 2017 13:24:52 +0200 Subject: [PATCH 46/96] use sign_in instead of login_with --- spec/features/commits_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 274247fc4d38..709df6336fe4 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -213,7 +213,7 @@ create :gpg_key, key: GpgHelpers::User1.public_key, user: user end - login_with(user) + sign_in(user) visit namespace_project_commits_path(project.namespace, project, :'signed-commits') @@ -240,7 +240,7 @@ user = create :user, email: GpgHelpers::User1.emails.first project.team << [user, :master] - login_with(user) + sign_in(user) visit namespace_project_commits_path(project.namespace, project, :'signed-commits') @@ -269,7 +269,7 @@ create :gpg_key, key: GpgHelpers::User1.public_key, user: user end - login_with(user) + sign_in(user) visit namespace_project_commits_path(project.namespace, project, :'signed-commits') click_on 'Verified' -- GitLab From 28c75fc1a87f8190c89666f8b6e3436311d024ce Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 29 Jun 2017 14:31:55 +0200 Subject: [PATCH 47/96] documentation for gpg signed commits --- doc/README.md | 1 + .../img/profile_settings_gpg_keys.png | Bin 0 -> 32699 bytes .../profile_settings_gpg_keys_paste_pub.png | Bin 0 -> 24514 bytes .../profile_settings_gpg_keys_single_key.png | Bin 0 -> 10331 bytes .../project_signed_and_unsigned_commits.png | Bin 0 -> 112812 bytes ...ect_signed_commit_unverified_signature.png | Bin 0 -> 9542 bytes ...oject_signed_commit_verified_signature.png | Bin 0 -> 14029 bytes doc/workflow/gpg_signed_commits/index.md | 55 ++++++++++++++++++ 8 files changed, 56 insertions(+) create mode 100644 doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png create mode 100644 doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png create mode 100644 doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png create mode 100644 doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png create mode 100644 doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png create mode 100644 doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png create mode 100644 doc/workflow/gpg_signed_commits/index.md diff --git a/doc/README.md b/doc/README.md index ac7311a8c139..5537f54ab2b7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -89,6 +89,7 @@ Manage files and branches from the UI (user interface): - [Git](topics/git/index.md): Getting started with Git, branching strategies, Git LFS, advanced use. - [Git cheatsheet](https://gitlab.com/gitlab-com/marketing/raw/master/design/print/git-cheatsheet/print-pdf/git-cheatsheet.pdf): Download a PDF describing the most used Git operations. - [GitLab Flow](workflow/gitlab_flow.md): explore the best of Git with the GitLab Flow strategy. +- [Signing commits](workflow/gpg_signed_commits/index.md): use GPG to sign your commits. ### Migrate and import your projects from other platforms diff --git a/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png new file mode 100644 index 0000000000000000000000000000000000000000..e525083918bf6e034528314bff9ee25c03c7d4b4 GIT binary patch literal 32699 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 xYE;s90*%WuFyuNzX&8T$2JbY?#T5>Z{Lb=SYy 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 Cg<5<7 literal 0 HcmV?d00001 diff --git a/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png new file mode 100644 index 0000000000000000000000000000000000000000..f715c46adc339b1f16a6b76560f654186599250a GIT binary patch literal 10331 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 S>8zX$aHYeS-+ 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 z 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 Ze+I4?zDLt{y{`w!db;|#taD0e0sx_5Q)&PJ literal 0 HcmV?d00001 diff --git a/doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png b/doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png new file mode 100644 index 0000000000000000000000000000000000000000..22565cf7c7e402fd51cdf6d4b96f0d9cb0a49b8e GIT binary patch literal 9542 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 U#y;K(1_lNOPgg&ebxsLQ0DrFrGynhq literal 0 HcmV?d00001 diff --git a/doc/workflow/gpg_signed_commits/index.md b/doc/workflow/gpg_signed_commits/index.md new file mode 100644 index 000000000000..041c681ba633 --- /dev/null +++ b/doc/workflow/gpg_signed_commits/index.md @@ -0,0 +1,55 @@ +# Signing commits with GPG + +## Getting started + +- [Git Tools - Signing Your Work](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) + +## How GitLab handles GPG + +GitLab uses its own keyring to verify the GPG signature. It does not access any +public key server. + +In order to have a commit verified on GitLab the corresponding public key needs +to be uploaded to GitLab. + +For a signature to be verified two prerequisites need to be met: + +1. The public key needs to be added to GitLab +1. One of the emails in the GPG key matches your **primary** email + +## Add a GPG key + +1. On the upper right corner, click on your avatar and go to your **Settings**. + + ![Settings dropdown](../../gitlab-basics/img/profile_settings.png) + +1. Navigate to the **GPG keys** tab. + + ![GPG Keys](img/profile_settings_gpg_keys.png) + +1. Paste your **public** key in the 'Key' box. + + ![Paste GPG public key](img/profile_settings_gpg_keys_paste_pub.png) + +1. Finally, click on **Add key** to add it to GitLab. You will be able to see + its fingerprint, the corresponding email address and creation date. + + ![GPG key single page](img/profile_settings_gpg_keys_single_key.png) + +>**Note:** +Once you add a key, you cannot edit it, only remove it. In case the paste +didn't work, you will have to remove the offending key and re-add it. + +## Verifying commits + +1. Within a project navigate to the **Commits** tag. Signed commits will show a + badge containing either "Verified" or "Unverified", depending on the + verification status of the GPG signature. + + ![Signed and unsigned commits](img/project_signed_and_unsigned_commits.png) + +1. By clicking on the GPG badge details of the signature are displayed. + + ![Signed commit with verified signature](img/project_signed_commit_verified_signature.png) + + ![Signed commit with verified signature](img/project_signed_commit_unverified_signature.png) -- GitLab From 3b7ac360cff04a5c1be83ee13b1354f07021c80d Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Fri, 30 Jun 2017 12:48:08 +0200 Subject: [PATCH 48/96] position gpg badge first on commit line --- app/views/projects/commit/_commit_box.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 1a0c70ef8035..419fbe99af83 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,12 +1,12 @@ .page-content-header .header-main-content + = render partial: 'signature', object: @commit.signature %strong #{ s_('CommitBoxTitle|Commit') } %span.commit-sha= @commit.short_id = clipboard_button(text: @commit.id, title: _("Copy commit SHA to clipboard")) %span.hidden-xs authored #{time_ago_with_tooltip(@commit.authored_date)} - = render partial: 'signature', object: @commit.signature %span= s_('ByAuthor|by') = author_avatar(@commit, size: 24) %strong -- GitLab From ade54803a7f8b1470320fc6fa5871f2ec208eb0f Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 3 Jul 2017 09:41:32 +0200 Subject: [PATCH 49/96] add help links to gpg commits / gpg settings --- app/assets/stylesheets/pages/commits.scss | 5 +++++ app/helpers/commits_helper.rb | 7 +++++++ app/views/profiles/gpg_keys/index.html.haml | 5 ++++- doc/workflow/gpg_signed_commits/index.md | 2 ++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b1710eee1bf7..5de98cfc7afe 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -331,3 +331,8 @@ .gpg-badge-popover-username { font-weight: bold; } + +.commit .gpg-badge-popover-help-link { + display: block; + color: $link-color; +} diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 42e9379d1f45..60acc1e2f826 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -271,6 +271,13 @@ def commit_gpg_signature_badge_with(signature, label:, title: '', content: '', c ) concat "GPG key ID: #{signature.gpg_key_primary_keyid}" + concat( + link_to( + 'Learn about signing commits', + help_page_path('workflow/gpg_signed_commits/index.md'), + class: 'gpg-badge-popover-help-link' + ) + ) end title = capture do diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 30066522766d..8331daeeb750 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -9,7 +9,10 @@ GPG keys allow you to verify signed commits. .col-lg-9 %h5.prepend-top-0 - Add an GPG key + Add a GPG key + %p.profile-settings-content + Before you can add a GPG key you need to + = link_to 'generate it.', help_page_path('workflow/gpg_signed_commits/index.md') = render 'form' %hr %h5 diff --git a/doc/workflow/gpg_signed_commits/index.md b/doc/workflow/gpg_signed_commits/index.md index 041c681ba633..f7f5492c35ad 100644 --- a/doc/workflow/gpg_signed_commits/index.md +++ b/doc/workflow/gpg_signed_commits/index.md @@ -3,6 +3,8 @@ ## Getting started - [Git Tools - Signing Your Work](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) +- [Git Tools - Signing Your Work: GPG introduction](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work#_gpg_introduction) +- [Git Tools - Signing Your Work: Signing commits](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work#_signing_commits) ## How GitLab handles GPG -- GitLab From a06494bf71b589d2b9f5c80710b6c8e0749fc210 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 4 Jul 2017 13:46:33 +0200 Subject: [PATCH 50/96] no need for html_safe --- app/views/profiles/gpg_keys/_key.html.haml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index d7450a22f4c1..b4b9aa071901 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -2,7 +2,9 @@ .pull-left.append-right-10 = icon 'key', class: "settings-list-icon hidden-xs" .key-list-item-info - = key.emails_with_verified_status.map { |email, verified| verified_email_badge(email, verified) }.join(' ').html_safe + - key.emails_with_verified_status.map do |email, verified| + = verified_email_badge(email, verified) + .description = key.fingerprint .pull-right -- GitLab From 075dae65b1a5b57788e4823b09bdcb8fa6eeaf8a Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 5 Jul 2017 13:11:18 +0200 Subject: [PATCH 51/96] find_by_id -> find_by(:id, ...) --- app/mailers/emails/profile.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 4580e1c83bd3..c401030e34ac 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,7 @@ def new_email_email(email_id) end def new_ssh_key_email(key_id) - @key = Key.find_by_id(key_id) + @key = Key.find_by(id: key_id) return unless @key @@ -24,7 +24,7 @@ def new_ssh_key_email(key_id) end def new_gpg_key_email(gpg_key_id) - @gpg_key = GpgKey.find_by_id(gpg_key_id) + @gpg_key = GpgKey.find_by(id: gpg_key_id) return unless @gpg_key -- GitLab From d9fd3709abb7897785ac111c217b532663313abd Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 5 Jul 2017 13:16:50 +0200 Subject: [PATCH 52/96] use hash instead of 2d array --- app/models/gpg_key.rb | 2 +- spec/models/gpg_key_spec.rb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index bd5d833d68c8..8cfccef08541 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -47,7 +47,7 @@ def emails_with_verified_status email, email == user.email ] - end + end.to_h end def verified? diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 312e026a78e7..88b5eb79b59d 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -58,10 +58,10 @@ user = create :user, email: 'bette.cartwright@example.com' gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user - expect(gpg_key.emails_with_verified_status).to match_array [ - ['bette.cartwright@example.com', true], - ['bette.cartwright@example.net', false] - ] + expect(gpg_key.emails_with_verified_status).to eq( + 'bette.cartwright@example.com' => true, + 'bette.cartwright@example.net' => false + ) end end -- GitLab From e79e2ae1f4b671488b31428f7a6506a245a7bddc Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 5 Jul 2017 14:03:36 +0200 Subject: [PATCH 53/96] validate presence of user on gpg_key --- app/models/gpg_key.rb | 2 ++ spec/models/gpg_key_spec.rb | 1 + 2 files changed, 3 insertions(+) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 8cfccef08541..612d954b1c51 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -6,6 +6,8 @@ class GpgKey < ActiveRecord::Base belongs_to :user has_many :gpg_signatures, dependent: :nullify + validates :user, presence: true + validates :key, presence: true, uniqueness: true, diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 88b5eb79b59d..ffbf8760e866 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -6,6 +6,7 @@ end describe "validation" do + it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:key) } it { is_expected.to validate_uniqueness_of(:key) } it { is_expected.to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey").for(:key) } -- GitLab From 084cc718f759a37c8fc5535930daeee5e819c30f Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 5 Jul 2017 14:23:23 +0200 Subject: [PATCH 54/96] use after_commit instead of AfterCommitQueue --- app/models/gpg_key.rb | 12 +++--------- app/models/user.rb | 5 ++--- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 612d954b1c51..050245bd5026 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -1,6 +1,4 @@ class GpgKey < ActiveRecord::Base - include AfterCommitQueue - KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze belongs_to :user @@ -31,8 +29,8 @@ class GpgKey < ActiveRecord::Base unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint, :extract_primary_keyid - after_create :update_invalid_gpg_signatures_after_create - after_create :notify_user + after_commit :update_invalid_gpg_signatures, on: :create + after_commit :notify_user, on: :create def key=(value) value.strip! unless value.blank? @@ -75,10 +73,6 @@ def extract_primary_keyid end def notify_user - run_after_commit { NotificationService.new.new_gpg_key(self) } - end - - def update_invalid_gpg_signatures_after_create - run_after_commit { update_invalid_gpg_signatures } + NotificationService.new.new_gpg_key(self) end end diff --git a/app/models/user.rb b/app/models/user.rb index 931b760df34f..03a76f4fa234 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,7 +13,6 @@ class User < ActiveRecord::Base include IgnorableColumn include FeatureGate include CreatedAtFilterable - include AfterCommitQueue DEFAULT_NOTIFICATION_LEVEL = :participating @@ -156,10 +155,10 @@ def update_tracked_fields!(request) before_validation :set_public_email, if: :public_email_changed? after_update :update_emails_with_primary_email, if: :email_changed? - after_update :update_invalid_gpg_signatures, if: :email_changed? before_save :ensure_authentication_token, :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: :external_changed? after_save :ensure_namespace_correct + after_commit :update_invalid_gpg_signatures, on: :update, if: -> { previous_changes.key?('email') } after_initialize :set_projects_limit after_destroy :post_destroy_hook @@ -516,7 +515,7 @@ def update_emails_with_primary_email end def update_invalid_gpg_signatures - run_after_commit { gpg_keys.each(&:update_invalid_gpg_signatures) } + gpg_keys.each(&:update_invalid_gpg_signatures) end # Returns the groups a user has access to -- GitLab From 36c05b311c830aef25ecb7ad4416ac77a5c98651 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 5 Jul 2017 20:59:17 +0200 Subject: [PATCH 55/96] convert gpg badge helper methods to partials --- app/assets/stylesheets/pages/commits.scss | 1 + app/helpers/commits_helper.rb | 86 ------------------- .../commit/_invalid_signature_badge.html.haml | 7 ++ .../projects/commit/_signature.html.haml | 5 +- .../commit/_signature_badge.html.haml | 17 ++++ .../commit/_valid_signature_badge.html.haml | 19 ++++ 6 files changed, 48 insertions(+), 87 deletions(-) create mode 100644 app/views/projects/commit/_invalid_signature_badge.html.haml create mode 100644 app/views/projects/commit/_signature_badge.html.haml create mode 100644 app/views/projects/commit/_valid_signature_badge.html.haml diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 5de98cfc7afe..b6e9053fbce3 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -295,6 +295,7 @@ } .gpg-badge-popover-title { + display: inline; font-weight: normal; } diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 60acc1e2f826..d08e346d605d 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -212,90 +212,4 @@ def limited_commits(commits) [commits, 0] end end - - def commit_gpg_signature_badge(signature) - if signature.valid_signature? - commit_gpg_valid_signature_badge(signature) - else - commit_gpg_invalid_signature_badge(signature) - end - end - - def commit_gpg_valid_signature_badge(signature) - title = capture do - concat content_tag('i', '', class: 'fa fa-check-circle gpg-badge-popover-icon valid', 'aria-hidden' => 'true') - concat 'This commit was signed with a verified signature.' - end - - content = capture do - link_to user_path(signature.gpg_key.user), class: 'gpg-badge-popover-user-link' do - concat( - content_tag(:div, class: 'gpg-badge-popover-avatar') do - user_avatar_without_link(user: signature.gpg_key.user, size: 32) - end - ) - - concat( - content_tag(:div, class: 'gpg-badge-popover-username') do - signature.gpg_key.user.username - end - ) - - concat( - content_tag(:div) do - signature.gpg_key.user.name - end - ) - end - end - - commit_gpg_signature_badge_with(signature, label: 'Verified', title: title, content: content, css_classes: ['valid']) - end - - def commit_gpg_invalid_signature_badge(signature) - title = capture do - concat content_tag('i', '', class: 'fa fa-question-circle gpg-badge-popover-icon invalid', 'aria-hidden' => 'true') - concat 'This commit was signed with an unverified signature.' - end - commit_gpg_signature_badge_with(signature, label: 'Unverified', title: title, css_classes: ['invalid']) - end - - def commit_gpg_signature_badge_with(signature, label:, title: '', content: '', css_classes: []) - css_classes = %w(btn btn-xs gpg-badge) + css_classes - - content = capture do - concat( - content_tag(:div, class: 'clearfix') do - content - end - ) - - concat "GPG key ID: #{signature.gpg_key_primary_keyid}" - concat( - link_to( - 'Learn about signing commits', - help_page_path('workflow/gpg_signed_commits/index.md'), - class: 'gpg-badge-popover-help-link' - ) - ) - end - - title = capture do - content_tag 'span', class: 'gpg-badge-popover-title' do - title - end - end - - data = { - toggle: 'popover', - html: 'true', - placement: 'auto bottom', - title: title, - content: content - } - - content_tag :button, class: css_classes, data: data do - label - end - end end diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml new file mode 100644 index 000000000000..29c787bd3241 --- /dev/null +++ b/app/views/projects/commit/_invalid_signature_badge.html.haml @@ -0,0 +1,7 @@ +- title = capture do + %i{ class: 'fa fa-question-circle gpg-badge-popover-icon invalid', 'aria-hidden' => 'true' } + This commit was signed with an unverified signature. + +- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 00120a665c5b..60fa52557ef4 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,2 +1,5 @@ - if signature - = commit_gpg_signature_badge(signature) + - if signature.valid_signature? + = render partial: 'projects/commit/valid_signature_badge', locals: { signature: signature } + - else + = render partial: 'projects/commit/invalid_signature_badge', locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml new file mode 100644 index 000000000000..2e046c1f6848 --- /dev/null +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -0,0 +1,17 @@ +- css_classes = %w(btn btn-xs gpg-badge) + css_classes + +- title = capture do + .gpg-badge-popover-title + = title + +- content = capture do + .clearfix + = content + + GPG key ID: + = signature.gpg_key_primary_keyid + + = link_to('Learn about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-badge-popover-help-link') + +%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto bottom', title: title, content: content } } + = label diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml new file mode 100644 index 000000000000..47226466b851 --- /dev/null +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -0,0 +1,19 @@ +- title = capture do + %i{ class: 'fa fa-check-circle gpg-badge-popover-icon valid', 'aria-hidden' => 'true' } + This commit was signed with a verified signature. + +- content = capture do + - gpg_key = signature.gpg_key + + = link_to user_path(gpg_key.user), class: 'gpg-badge-popover-user-link' do + .gpg-badge-popover-avatar + = user_avatar_without_link(user: signature.gpg_key.user, size: 32) + + .gpg-badge-popover-username + = gpg_key.user.username + + %div= gpg_key.user.name + +- locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] } + += render partial: 'projects/commit/signature_badge', locals: locals -- GitLab From 4f7ba8f2861b39d3a7697eb99e3fbaaf39f32643 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 08:03:16 +0200 Subject: [PATCH 56/96] fix memoization --- lib/gitlab/gpg/commit.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 99d112a51a30..718e77ecadca 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -22,7 +22,6 @@ def signature using_keychain do |gpg_key| if gpg_key Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) - @verified_signature = nil end create_cached_signature!(gpg_key) @@ -50,6 +49,7 @@ def using_keychain if gpg_key Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) + @verified_signature = nil end yield gpg_key @@ -58,7 +58,7 @@ def using_keychain def verified_signature @verified_signature ||= GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |verified_signature| - return verified_signature + break verified_signature end end -- GitLab From a7d2ebe508b6dde3b3ae37c5a54fc78719b199b3 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 10:17:09 +0200 Subject: [PATCH 57/96] simplify fetching of commit --- app/models/gpg_signature.rb | 4 ++++ lib/gitlab/gpg/invalid_gpg_signature_updater.rb | 4 +--- .../gpg/invalid_gpg_signature_updater_spec.rb | 2 +- spec/models/gpg_signature_spec.rb | 13 +++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 4fe9c3210ff6..0ef335bf8a9e 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -5,4 +5,8 @@ class GpgSignature < ActiveRecord::Base validates :commit_sha, presence: true validates :project, presence: true validates :gpg_key_primary_keyid, presence: true + + def commit + project.commit(commit_sha) + end end diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb index 06e4823de327..860e1e1035c7 100644 --- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -10,9 +10,7 @@ def run .where(valid_signature: false) .where(gpg_key_primary_keyid: @gpg_key.primary_keyid) .find_each do |gpg_signature| - raw_commit = Gitlab::Git::Commit.find(gpg_signature.project.repository, gpg_signature.commit_sha) - commit = ::Commit.new(raw_commit, gpg_signature.project) - Gitlab::Gpg::Commit.new(commit).update_signature!(gpg_signature) + Gitlab::Gpg::Commit.new(gpg_signature.commit).update_signature!(gpg_signature) end end end diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index 8b60b36452b3..c16f15bf4bf1 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -29,7 +29,7 @@ end before do - allow(Gitlab::Git::Commit).to receive(:find).with(kind_of(Repository), commit_sha).and_return(raw_commit) + allow_any_instance_of(GpgSignature).to receive(:commit).and_return(commit) end context 'gpg signature did not have an associated gpg key' do diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb index b3f842628742..b6f256e61ee5 100644 --- a/spec/models/gpg_signature_spec.rb +++ b/spec/models/gpg_signature_spec.rb @@ -12,4 +12,17 @@ it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_presence_of(:gpg_key_primary_keyid) } end + + describe '#commit' do + it 'fetches the commit through the project' do + commit_sha = '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' + project = create :project + commit = create :commit, project: project + gpg_signature = create :gpg_signature, commit_sha: commit_sha + + expect_any_instance_of(Project).to receive(:commit).with(commit_sha).and_return(commit) + + gpg_signature.commit + end + end end -- GitLab From 7f03282f0ff45948d3d27efe007ba77e24e19fa5 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 10:26:31 +0200 Subject: [PATCH 58/96] remove duplicate statement --- lib/gitlab/gpg/commit.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 718e77ecadca..50e8d71bb136 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -20,10 +20,6 @@ def signature return cached_signature if cached_signature.present? using_keychain do |gpg_key| - if gpg_key - Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) - end - create_cached_signature!(gpg_key) end end -- GitLab From b66e3726dc377c2bb5c92983db4ec4c8d27237c4 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 11:15:31 +0200 Subject: [PATCH 59/96] also update gpg_signatures when gpg_key is null --- .../gpg/invalid_gpg_signature_updater.rb | 2 +- .../gpg/invalid_gpg_signature_updater_spec.rb | 70 +++++++++++++++---- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb index 860e1e1035c7..1782e20dcab6 100644 --- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -7,7 +7,7 @@ def initialize(gpg_key) def run GpgSignature - .where(valid_signature: false) + .where('gpg_key_id IS NULL OR valid_signature = ?', false) .where(gpg_key_primary_keyid: @gpg_key.primary_keyid) .find_each do |gpg_signature| Gitlab::Gpg::Commit.new(gpg_signature.commit).update_signature!(gpg_signature) diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index c16f15bf4bf1..5a81a86b93cf 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -19,29 +19,60 @@ create :commit, git_commit: raw_commit, project: project end - let!(:gpg_signature) do - create :gpg_signature, - project: project, - commit_sha: commit_sha, - gpg_key: nil, - gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false - end - before do allow_any_instance_of(GpgSignature).to receive(:commit).and_return(commit) end + context 'gpg signature did have an associated gpg key which was removed later' do + let!(:user) { create :user, email: GpgHelpers::User1.emails.first } + + let!(:valid_gpg_signature) do + create :gpg_signature, + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: true + end + + it 'assigns the gpg key to the signature when the missing gpg key is added' do + # InvalidGpgSignatureUpdater is called by the after_create hook + gpg_key = create :gpg_key, + key: GpgHelpers::User1.public_key, + user: user + + expect(valid_gpg_signature.reload.gpg_key).to eq gpg_key + end + + it 'does not assign the gpg key when an unrelated gpg key is added' do + # InvalidGpgSignatureUpdater is called by the after_create hook + create :gpg_key, + key: GpgHelpers::User2.public_key, + user: user + + expect(valid_gpg_signature.reload.gpg_key).to be_nil + end + end + context 'gpg signature did not have an associated gpg key' do let!(:user) { create :user, email: GpgHelpers::User1.emails.first } + let!(:invalid_gpg_signature) do + create :gpg_signature, + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + end + it 'updates the signature to being valid when the missing gpg key is added' do # InvalidGpgSignatureUpdater is called by the after_create hook create :gpg_key, key: GpgHelpers::User1.public_key, user: user - expect(gpg_signature.reload.valid_signature).to be_truthy + expect(invalid_gpg_signature.reload.valid_signature).to be_truthy end it 'keeps the signature at being invalid when an unrelated gpg key is added' do @@ -50,7 +81,7 @@ key: GpgHelpers::User2.public_key, user: user - expect(gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload.valid_signature).to be_falsey end end @@ -61,17 +92,26 @@ end end + let!(:invalid_gpg_signature) do + create :gpg_signature, + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + end + it 'updates the signature to being valid when the user updates the email address' do create :gpg_key, key: GpgHelpers::User1.public_key, user: user - expect(gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload.valid_signature).to be_falsey # InvalidGpgSignatureUpdater is called by the after_update hook user.update_attributes!(email: GpgHelpers::User1.emails.first) - expect(gpg_signature.reload.valid_signature).to be_truthy + expect(invalid_gpg_signature.reload.valid_signature).to be_truthy end it 'keeps the signature at being invalid when the changed email address is still unrelated' do @@ -79,12 +119,12 @@ key: GpgHelpers::User1.public_key, user: user - expect(gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload.valid_signature).to be_falsey # InvalidGpgSignatureUpdater is called by the after_update hook user.update_attributes!(email: 'still.unrelated@example.com') - expect(gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload.valid_signature).to be_falsey end end end -- GitLab From deb474b4137c8ab4ce16f4d46e011be593f0de60 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 11:55:56 +0200 Subject: [PATCH 60/96] extract common method --- lib/gitlab/gpg.rb | 22 +++++++++------------- spec/lib/gitlab/gpg_spec.rb | 29 +++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 258901bb238d..582347019e5a 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -8,10 +8,8 @@ module CurrentKeyChain def add(key) GPGME::Key.import(key) end - end - def fingerprints_from_key(key) - using_tmp_keychain do + def fingerprints_from_key(key) import = GPGME::Key.import(key) return [] if import.imported == 0 @@ -20,13 +18,15 @@ def fingerprints_from_key(key) end end - def primary_keyids_from_key(key) + def fingerprints_from_key(key) using_tmp_keychain do - import = GPGME::Key.import(key) - - return [] if import.imported == 0 + CurrentKeyChain.fingerprints_from_key(key) + end + end - fingerprints = import.imports.map(&:fingerprint) + def primary_keyids_from_key(key) + using_tmp_keychain do + fingerprints = CurrentKeyChain.fingerprints_from_key(key) GPGME::Key.find(:public, fingerprints).map { |raw_key| raw_key.primary_subkey.keyid } end @@ -34,11 +34,7 @@ def primary_keyids_from_key(key) def emails_from_key(key) using_tmp_keychain do - import = GPGME::Key.import(key) - - return [] if import.imported == 0 - - fingerprints = import.imports.map(&:fingerprint) + fingerprints = CurrentKeyChain.fingerprints_from_key(key) GPGME::Key.find(:public, fingerprints).flat_map { |raw_key| raw_key.uids.map(&:email) } end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 497fbeab5d5d..ebb7720eaea2 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -2,16 +2,15 @@ describe Gitlab::Gpg do describe '.fingerprints_from_key' do - it 'returns the fingerprint' do - expect( - described_class.fingerprints_from_key(GpgHelpers::User1.public_key) - ).to eq [GpgHelpers::User1.fingerprint] + before do + # make sure that each method is using the temporary keychain + expect(described_class).to receive(:using_tmp_keychain).and_call_original end - it 'returns an empty array when the key is invalid' do - expect( - described_class.fingerprints_from_key('bogus') - ).to eq [] + it 'returns CurrentKeyChain.fingerprints_from_key' do + expect(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(GpgHelpers::User1.public_key) + + described_class.fingerprints_from_key(GpgHelpers::User1.public_key) end end @@ -65,4 +64,18 @@ ) end end + + describe '.fingerprints_from_key' do + it 'returns the fingerprint' do + expect( + described_class.fingerprints_from_key(GpgHelpers::User1.public_key) + ).to eq [GpgHelpers::User1.fingerprint] + end + + it 'returns an empty array when the key is invalid' do + expect( + described_class.fingerprints_from_key('bogus') + ).to eq [] + end + end end -- GitLab From 3729c3a7c7cdfeed7a0fc363d18a67e9956bdf07 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 16:30:43 +0200 Subject: [PATCH 61/96] use the correct flex classes on the commits list --- app/views/projects/commits/_commit.html.haml | 4 ++-- app/views/projects/commits/_commits.html.haml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 5f67727514a3..b7f18d448386 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,7 +9,7 @@ - cache_key.push(commit.status(ref)) if commit.status(ref) = cache(cache_key, expires_in: 1.day) do - %li.commit.flex-list.js-toggle-container{ id: "commit-#{commit.short_id}" } + %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } .avatar-cell.hidden-xs = author_avatar(commit, size: 36) @@ -36,7 +36,7 @@ #{ commit_text.html_safe } - .commit-actions.flex-row.hidden-xs + .commit-actions.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) = render partial: 'projects/commit/signature', object: commit.signature diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index c764e35dd2a7..d14897428d0c 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -7,7 +7,7 @@ %span.commits-count= n_("%d commit", "%d commits", commits.count) % commits.count %li.commits-row{ data: { day: day } } - %ul.content-list.commit-list + %ul.content-list.commit-list.flex-list = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref } - if hidden > 0 -- GitLab From 8ccce9d54541de5cbc8e5ce4a33fcefd402bdda4 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 6 Jul 2017 16:36:57 +0200 Subject: [PATCH 62/96] use existing status-box css class for gpg badge --- app/assets/stylesheets/pages/commits.scss | 16 +++++++++------- .../commit/_invalid_signature_badge.html.haml | 2 +- .../projects/commit/_signature_badge.html.haml | 6 +++--- .../commit/_valid_signature_badge.html.haml | 8 ++++---- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b6e9053fbce3..16a8c399bc61 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -284,22 +284,24 @@ } } -.gpg-badge { +.gpg-status-box { &.valid { color: $brand-success; + border: 1px solid $brand-success; } &.invalid { color: $gray; + border: 1px solid $gray; } } -.gpg-badge-popover-title { +.gpg-popover-title { display: inline; font-weight: normal; } -.gpg-badge-popover-icon { +.gpg-popover-icon { float: left; font-size: 35px; line-height: 35px; @@ -315,12 +317,12 @@ } } -.gpg-badge-popover-user-link { +.gpg-popover-user-link { text-decoration: none; color: $gl-text-color; } -.gpg-badge-popover-avatar { +.gpg-popover-avatar { float: left; margin-bottom: $gl-padding; @@ -329,11 +331,11 @@ } } -.gpg-badge-popover-username { +.gpg-popover-username { font-weight: bold; } -.commit .gpg-badge-popover-help-link { +.commit .gpg-popover-help-link { display: block; color: $link-color; } diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml index 29c787bd3241..24b6040dcbb9 100644 --- a/app/views/projects/commit/_invalid_signature_badge.html.haml +++ b/app/views/projects/commit/_invalid_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - %i{ class: 'fa fa-question-circle gpg-badge-popover-icon invalid', 'aria-hidden' => 'true' } + %i{ class: 'fa fa-question-circle gpg-popover-icon invalid', 'aria-hidden' => 'true' } This commit was signed with an unverified signature. - locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 2e046c1f6848..88eb0505424d 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -1,7 +1,7 @@ -- css_classes = %w(btn btn-xs gpg-badge) + css_classes +- css_classes = %w(btn status-box gpg-status-box) + css_classes - title = capture do - .gpg-badge-popover-title + .gpg-popover-title = title - content = capture do @@ -11,7 +11,7 @@ GPG key ID: = signature.gpg_key_primary_keyid - = link_to('Learn about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-badge-popover-help-link') + = link_to('Learn about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') %button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto bottom', title: title, content: content } } = label diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml index 47226466b851..a94fe9ef9a15 100644 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -1,15 +1,15 @@ - title = capture do - %i{ class: 'fa fa-check-circle gpg-badge-popover-icon valid', 'aria-hidden' => 'true' } + %i{ class: 'fa fa-check-circle gpg-popover-icon valid', 'aria-hidden' => 'true' } This commit was signed with a verified signature. - content = capture do - gpg_key = signature.gpg_key - = link_to user_path(gpg_key.user), class: 'gpg-badge-popover-user-link' do - .gpg-badge-popover-avatar + = link_to user_path(gpg_key.user), class: 'gpg-popover-user-link' do + .gpg-popover-avatar = user_avatar_without_link(user: signature.gpg_key.user, size: 32) - .gpg-badge-popover-username + .gpg-popover-username = gpg_key.user.username %div= gpg_key.user.name -- GitLab From 4c5d4a69f0b5a813d2cb53e6f9af925cd3f9e8cb Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 10 Jul 2017 10:02:41 +0200 Subject: [PATCH 63/96] improve spacing / alignments in gpg popup --- app/assets/stylesheets/pages/commits.scss | 26 ++++++------------- .../commit/_invalid_signature_badge.html.haml | 3 ++- .../commit/_signature_badge.html.haml | 8 +++--- .../commit/_valid_signature_badge.html.haml | 12 ++++----- spec/features/commits_spec.rb | 14 +++++----- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 16a8c399bc61..62a1296f77eb 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -296,16 +296,16 @@ } } -.gpg-popover-title { - display: inline; +.gpg-popover-status { + display: flex; + align-items: center; font-weight: normal; + line-height: 1.5; } .gpg-popover-icon { - float: left; font-size: 35px; - line-height: 35px; - width: 32px; + // same margin as .s32.avatar margin-right: $btn-side-margin; &.valid { @@ -318,23 +318,13 @@ } .gpg-popover-user-link { + display: flex; + align-items: center; + margin-bottom: $gl-padding / 2; text-decoration: none; color: $gl-text-color; } -.gpg-popover-avatar { - float: left; - margin-bottom: $gl-padding; - - .avatar { - margin-left: 0; - } -} - -.gpg-popover-username { - font-weight: bold; -} - .commit .gpg-popover-help-link { display: block; color: $link-color; diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml index 24b6040dcbb9..0e94d0b2d59d 100644 --- a/app/views/projects/commit/_invalid_signature_badge.html.haml +++ b/app/views/projects/commit/_invalid_signature_badge.html.haml @@ -1,6 +1,7 @@ - title = capture do %i{ class: 'fa fa-question-circle gpg-popover-icon invalid', 'aria-hidden' => 'true' } - This commit was signed with an unverified signature. + %div + This commit was signed with an unverified signature. - locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 88eb0505424d..8e09a9333aa6 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -1,17 +1,17 @@ - css_classes = %w(btn status-box gpg-status-box) + css_classes - title = capture do - .gpg-popover-title + .gpg-popover-status = title - content = capture do .clearfix = content - GPG key ID: + GPG Key ID: = signature.gpg_key_primary_keyid - = link_to('Learn about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to('Learn more about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') -%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto bottom', title: title, content: content } } +%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } } = label diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml index a94fe9ef9a15..d51b99fe5911 100644 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -1,18 +1,18 @@ - title = capture do %i{ class: 'fa fa-check-circle gpg-popover-icon valid', 'aria-hidden' => 'true' } - This commit was signed with a verified signature. + %div + This commit was signed with a verified signature. - content = capture do - gpg_key = signature.gpg_key = link_to user_path(gpg_key.user), class: 'gpg-popover-user-link' do - .gpg-popover-avatar + %div = user_avatar_without_link(user: signature.gpg_key.user, size: 32) - .gpg-popover-username - = gpg_key.user.username - - %div= gpg_key.user.name + %div + %strong= gpg_key.user.username + %div= gpg_key.user.name - locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] } diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 709df6336fe4..74eaafc9000f 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -272,18 +272,18 @@ sign_in(user) visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + click_on 'Unverified', match: :first + within '.popover' do + expect(page).to have_content 'This commit was signed with an unverified signature.' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}" + end + click_on 'Verified' within '.popover' do expect(page).to have_content 'This commit was signed with a verified signature.' expect(page).to have_content 'nannie.bernhard' expect(page).to have_content 'Nannie Bernhard' - expect(page).to have_content "GPG key ID: #{GpgHelpers::User1.primary_keyid}" - end - - click_on 'Unverified', match: :first - within '.popover' do - expect(page).to have_content 'This commit was signed with an unverified signature.' - expect(page).to have_content "GPG key ID: #{GpgHelpers::User2.primary_keyid}" + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" end end end -- GitLab From e63b693f28bf752f617bd0aa2f375db701d1600a Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Mon, 10 Jul 2017 13:19:50 +0200 Subject: [PATCH 64/96] generate gpg signature on push --- app/services/git_push_service.rb | 8 +++ app/workers/create_gpg_signature_worker.rb | 20 ++++++ config/sidekiq_queues.yml | 1 + spec/services/git_push_service_spec.rb | 18 ++++++ .../create_gpg_signature_worker_spec.rb | 61 +++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 app/workers/create_gpg_signature_worker.rb create mode 100644 spec/workers/create_gpg_signature_worker_spec.rb diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 20d1fb29289e..bb7680c50543 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -56,6 +56,8 @@ def execute perform_housekeeping update_caches + + update_signatures end def update_gitattributes @@ -80,6 +82,12 @@ def update_caches ProjectCacheWorker.perform_async(@project.id, types, [:commit_count, :repository_size]) end + def update_signatures + @push_commits.each do |commit| + CreateGpgSignatureWorker.perform_async(commit.sha, @project.id) + end + end + # Schedules processing of commit messages. def process_commit_messages default = is_default_branch? diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb new file mode 100644 index 000000000000..6fbd6e1a3f37 --- /dev/null +++ b/app/workers/create_gpg_signature_worker.rb @@ -0,0 +1,20 @@ +class CreateGpgSignatureWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(commit_sha, project_id) + project = Project.find_by(id: project_id) + + unless project + return Rails.logger.error("CreateGpgSignatureWorker: couldn't find project with ID=#{project_id}, skipping job") + end + + commit = project.commit(commit_sha) + + unless commit + return Rails.logger.error("CreateGpgSignatureWorker: couldn't find commit with commit_sha=#{commit_sha}, skipping job") + end + + commit.signature + end +end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index cf0f57196835..7496bfa4fbbf 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -30,6 +30,7 @@ - [emails_on_push, 2] - [mailers, 2] - [invalid_gpg_signature_update, 2] + - [create_gpg_signature, 2] - [upload_checksum, 1] - [use_key, 1] - [repository_fork, 1] diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index f801506f1b61..34cd44460c6a 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -681,6 +681,24 @@ end end + describe '#update_signatures' do + let(:service) do + described_class.new( + project, + user, + oldrev: sample_commit.parent_id, + newrev: sample_commit.id, + ref: 'refs/heads/master' + ) + end + + it 'calls CreateGpgSignatureWorker.perform_async for each commit' do + expect(CreateGpgSignatureWorker).to receive(:perform_async).with(sample_commit.id, project.id) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + end + def execute_service(project, user, oldrev, newrev, ref) service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref ) service.execute diff --git a/spec/workers/create_gpg_signature_worker_spec.rb b/spec/workers/create_gpg_signature_worker_spec.rb new file mode 100644 index 000000000000..a23f0d6c34ab --- /dev/null +++ b/spec/workers/create_gpg_signature_worker_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe CreateGpgSignatureWorker do + context 'when GpgKey is found' do + it 'calls Commit#signature' do + commit_sha = '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' + project = create :project + commit = instance_double(Commit) + + allow(Project).to receive(:find_by).with(id: project.id).and_return(project) + allow(project).to receive(:commit).with(commit_sha).and_return(commit) + + expect(commit).to receive(:signature) + + described_class.new.perform(commit_sha, project.id) + end + end + + context 'when Commit is not found' do + let(:nonexisting_commit_sha) { 'bogus' } + let(:project) { create :project } + + it 'logs CreateGpgSignatureWorker process skipping' do + expect(Rails.logger).to receive(:error) + .with("CreateGpgSignatureWorker: couldn't find commit with commit_sha=bogus, skipping job") + + described_class.new.perform(nonexisting_commit_sha, project.id) + end + + it 'does not raise errors' do + expect { described_class.new.perform(nonexisting_commit_sha, project.id) }.not_to raise_error + end + + it 'does not call Commit#signature' do + expect_any_instance_of(Commit).not_to receive(:signature) + + described_class.new.perform(nonexisting_commit_sha, project.id) + end + end + + context 'when Project is not found' do + let(:nonexisting_project_id) { -1 } + + it 'logs CreateGpgSignatureWorker process skipping' do + expect(Rails.logger).to receive(:error) + .with("CreateGpgSignatureWorker: couldn't find project with ID=-1, skipping job") + + described_class.new.perform(anything, nonexisting_project_id) + end + + it 'does not raise errors' do + expect { described_class.new.perform(anything, nonexisting_project_id) }.not_to raise_error + end + + it 'does not call Commit#signature' do + expect_any_instance_of(Commit).not_to receive(:signature) + + described_class.new.perform(anything, nonexisting_project_id) + end + end +end -- GitLab From 71d884ad2d6eb4a877d2bfe163baeae0b7ddbac6 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 11 Jul 2017 13:35:57 +0200 Subject: [PATCH 65/96] don't use assignment in if condition --- app/workers/invalid_gpg_signature_update_worker.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index 277dd604aa89..c0bec3c96895 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -3,10 +3,12 @@ class InvalidGpgSignatureUpdateWorker include DedicatedSidekiqQueue def perform(gpg_key_id) - if gpg_key = GpgKey.find_by(id: gpg_key_id) - Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run - else - Rails.logger.error("InvalidGpgSignatureUpdateWorker: couldn't find gpg_key with ID=#{gpg_key_id}, skipping job") + gpg_key = GpgKey.find_by(id: gpg_key_id) + + unless gpg_key + return Rails.logger.error("InvalidGpgSignatureUpdateWorker: couldn't find gpg_key with ID=#{gpg_key_id}, skipping job") end + + Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run end end -- GitLab From e5c9c714bd932ebb8d458c2d52535582eb48b9ee Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 11 Jul 2017 13:47:31 +0200 Subject: [PATCH 66/96] add notfound icon (question mark) --- app/views/shared/icons/_icon_status_notfound_borderless.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/views/shared/icons/_icon_status_notfound_borderless.svg diff --git a/app/views/shared/icons/_icon_status_notfound_borderless.svg b/app/views/shared/icons/_icon_status_notfound_borderless.svg new file mode 100644 index 000000000000..e58bd264ef89 --- /dev/null +++ b/app/views/shared/icons/_icon_status_notfound_borderless.svg @@ -0,0 +1 @@ + -- GitLab From f1e6a9c8323ec0a918aa9a170903d1d6ac7af5e6 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 11 Jul 2017 14:12:06 +0200 Subject: [PATCH 67/96] use svg icons for gpg popovers --- app/assets/stylesheets/pages/commits.scss | 20 ++++++++++++++++--- .../commit/_invalid_signature_badge.html.haml | 3 ++- .../commit/_valid_signature_badge.html.haml | 3 ++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 62a1296f77eb..4c66515581a2 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -304,16 +304,30 @@ } .gpg-popover-icon { - font-size: 35px; // same margin as .s32.avatar margin-right: $btn-side-margin; &.valid { - color: $brand-success; + svg { + border: 1px solid $brand-success; + + fill: $brand-success; + } } &.invalid { - color: $gray; + svg { + border: 1px solid $gray; + + fill: $gray; + } + } + + svg { + width: 32px; + height: 32px; + border-radius: 50%; + vertical-align: middle; } } diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml index 0e94d0b2d59d..3a73aae9d95c 100644 --- a/app/views/projects/commit/_invalid_signature_badge.html.haml +++ b/app/views/projects/commit/_invalid_signature_badge.html.haml @@ -1,5 +1,6 @@ - title = capture do - %i{ class: 'fa fa-question-circle gpg-popover-icon invalid', 'aria-hidden' => 'true' } + .gpg-popover-icon.invalid + = render 'shared/icons/icon_status_notfound_borderless.svg' %div This commit was signed with an unverified signature. diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml index d51b99fe5911..4db62418d0d8 100644 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -1,5 +1,6 @@ - title = capture do - %i{ class: 'fa fa-check-circle gpg-popover-icon valid', 'aria-hidden' => 'true' } + .gpg-popover-icon.valid + = render 'shared/icons/icon_status_success_borderless.svg' %div This commit was signed with a verified signature. -- GitLab From 111edaa9f75f402cc18c2bec5cab9aa6615d9c4d Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 11 Jul 2017 14:25:16 +0200 Subject: [PATCH 68/96] use lighter gray for unverified gpg signature --- app/assets/stylesheets/pages/commits.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 4c66515581a2..41c44a8560db 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -292,7 +292,7 @@ &.invalid { color: $gray; - border: 1px solid $gray; + border: 1px solid $common-gray-light; } } @@ -317,9 +317,9 @@ &.invalid { svg { - border: 1px solid $gray; + border: 1px solid $common-gray-light; - fill: $gray; + fill: $common-gray-light; } } -- GitLab From 027309eb2ae54614a2ee1a0ca9e4cea76a86b94b Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 12 Jul 2017 07:59:28 +0200 Subject: [PATCH 69/96] user may now revoke a gpg key other than just removing a key, which doesn't affect the verified state of a commit, revoking a key unverifies all signed commits. --- .../profiles/gpg_keys_controller.rb | 10 +++++++ app/models/gpg_key.rb | 11 ++++++++ app/views/profiles/gpg_keys/_key.html.haml | 8 ++++-- config/routes/profile.rb | 6 ++++- doc/workflow/gpg_signed_commits/index.md | 27 +++++++++++++++++++ spec/features/profiles/gpg_keys_spec.rb | 16 +++++++++++ spec/models/gpg_key_spec.rb | 27 +++++++++++++++++++ 7 files changed, 102 insertions(+), 3 deletions(-) diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb index b04c14a69936..3e75247769d5 100644 --- a/app/controllers/profiles/gpg_keys_controller.rb +++ b/app/controllers/profiles/gpg_keys_controller.rb @@ -25,6 +25,16 @@ def destroy end end + def revoke + @gpp_key = current_user.gpg_keys.find(params[:id]) + @gpp_key.revoke + + respond_to do |format| + format.html { redirect_to profile_gpg_keys_url, status: 302 } + format.js { head :ok } + end + end + private def gpg_key_params diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 050245bd5026..1977023536e3 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -58,6 +58,17 @@ def update_invalid_gpg_signatures InvalidGpgSignatureUpdateWorker.perform_async(self.id) end + def revoke + GpgSignature.where(gpg_key: self, valid_signature: true).find_each do |gpg_signature| + gpg_signature.update_attributes!( + gpg_key: nil, + valid_signature: false + ) + end + + destroy + end + private def extract_fingerprint diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index b4b9aa071901..86e2510d22f3 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -3,13 +3,17 @@ = icon 'key', class: "settings-list-icon hidden-xs" .key-list-item-info - key.emails_with_verified_status.map do |email, verified| + = email = verified_email_badge(email, verified) .description - = key.fingerprint + %code= key.fingerprint .pull-right %span.key-created-at created #{time_ago_with_tooltip(key.created_at)} - = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-transparent prepend-left-10" do + = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure? Removing this GPG key does not affect already signed commits.' }, method: :delete, class: "btn btn-danger prepend-left-10" do %span.sr-only Remove = icon('trash') + = link_to revoke_profile_gpg_key_path(key), data: { confirm: 'Are you sure? All commits that were signed with this GPG key will be unverified.' }, method: :put, class: "btn btn-danger prepend-left-10" do + %span.sr-only Revoke + Revoke diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 00388b9c0cdb..3e4e6111ab86 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -23,7 +23,11 @@ end resource :preferences, only: [:show, :update] resources :keys, only: [:index, :show, :create, :destroy] - resources :gpg_keys, only: [:index, :create, :destroy] + resources :gpg_keys, only: [:index, :create, :destroy] do + member do + put :revoke + end + end resources :emails, only: [:index, :create, :destroy] resources :chat_names, only: [:index, :new, :create, :destroy] do collection do diff --git a/doc/workflow/gpg_signed_commits/index.md b/doc/workflow/gpg_signed_commits/index.md index f7f5492c35ad..7d5762d2b9d1 100644 --- a/doc/workflow/gpg_signed_commits/index.md +++ b/doc/workflow/gpg_signed_commits/index.md @@ -42,6 +42,33 @@ For a signature to be verified two prerequisites need to be met: Once you add a key, you cannot edit it, only remove it. In case the paste didn't work, you will have to remove the offending key and re-add it. +## Remove a GPG key + +1. On the upper right corner, click on your avatar and go to your **Settings**. + +1. Navigate to the **GPG keys** tab. + +1. Click on the trash icon besides the GPG key you want to delete. + +>**Note:** +Removing a key **does not unverify** already signed commits. Commits that were +verified by using this key will stay verified. Only unpushed commits will stay +unverified once you remove this key. + +## Revoke a GPG key + +1. On the upper right corner, click on your avatar and go to your **Settings**. + +1. Navigate to the **GPG keys** tab. + +1. Click on **Revoke** besides the GPG key you want to delete. + +>**Note:** +Revoking a key **unverifies** already signed commits. Commits that were +verified by using this key will change to an unverified state. Future commits +will also stay unverified once you revoke this key. This action should be used +in case your key has been compromised. + ## Verifying commits 1. Within a project navigate to the **Commits** tag. Signed commits will show a diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb index 350126523b0f..6edc482b47eb 100644 --- a/spec/features/profiles/gpg_keys_spec.rb +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -39,4 +39,20 @@ expect(page).to have_content('Your GPG keys (0)') end + + scenario 'User revokes a key via the key index' do + gpg_key = create :gpg_key, user: user, key: GpgHelpers::User2.public_key + gpg_signature = create :gpg_signature, gpg_key: gpg_key, valid_signature: true + + visit profile_gpg_keys_path + + click_link('Revoke') + + expect(page).to have_content('Your GPG keys (0)') + + expect(gpg_signature.reload).to have_attributes( + valid_signature: false, + gpg_key: nil + ) + end end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index ffbf8760e866..ddd0bbfb9ba0 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -95,4 +95,31 @@ should_email(user) end end + + describe '#revoke' do + it 'invalidates all associated gpg signatures and destroys the key' do + gpg_key = create :gpg_key + gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: gpg_key + + unrelated_gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key + unrelated_gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: unrelated_gpg_key + + gpg_key.revoke + + expect(gpg_signature.reload).to have_attributes( + valid_signature: false, + gpg_key: nil + ) + + expect(gpg_key.destroyed?).to be true + + # unrelated signature is left untouched + expect(unrelated_gpg_signature.reload).to have_attributes( + valid_signature: true, + gpg_key: unrelated_gpg_key + ) + + expect(unrelated_gpg_key.destroyed?).to be false + end + end end -- GitLab From 506836a695ae40ff200add21c639f3d13aaee9e9 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 13 Jul 2017 10:53:19 +0200 Subject: [PATCH 70/96] unify commit signature colors with pipeline status --- app/assets/stylesheets/framework/mixins.scss | 26 ++++++++++++++++++++ app/assets/stylesheets/pages/commits.scss | 8 +++--- app/assets/stylesheets/pages/status.scss | 21 +--------------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 3a98332e46c8..6f91d11b3692 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -118,3 +118,29 @@ @content; } } + +/* + * Mixin for status badges, as used for pipelines and commit signatures + */ +@mixin status-color($color-light, $color-main, $color-dark) { + color: $color-main; + border-color: $color-main; + + &:not(span):hover { + background-color: $color-light; + color: $color-dark; + border-color: $color-dark; + + svg { + fill: $color-dark; + } + } + + svg { + fill: $color-main; + } +} + +@mixin green-status-color { + @include status-color($green-50, $green-500, $green-700); +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 41c44a8560db..cd9f2d787c5c 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -284,15 +284,15 @@ } } + .gpg-status-box { &.valid { - color: $brand-success; - border: 1px solid $brand-success; + @include green-status-color; } &.invalid { - color: $gray; - border: 1px solid $common-gray-light; + @include status-color($gray-dark, $gray, $common-gray-dark); + border-color: $common-gray-light; } } diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 67ad1ae60afe..36f622db1367 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -1,22 +1,3 @@ -@mixin status-color($color-light, $color-main, $color-dark) { - color: $color-main; - border-color: $color-main; - - &:not(span):hover { - background-color: $color-light; - color: $color-dark; - border-color: $color-dark; - - svg { - fill: $color-dark; - } - } - - svg { - fill: $color-main; - } -} - .ci-status { padding: 2px 7px 4px; border: 1px solid $gray-darker; @@ -41,7 +22,7 @@ } &.ci-success { - @include status-color($green-50, $green-500, $green-700); + @include green-status-color; } &.ci-canceled, -- GitLab From cd01e82873b3cd471203dbf557c71571fd683d16 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 13 Jul 2017 15:22:15 +0200 Subject: [PATCH 71/96] store gpg user name and email on the signature --- app/models/gpg_key.rb | 16 ++++++++---- ...add_gpg_key_user_info_to_gpg_signatures.rb | 11 ++++++++ db/schema.rb | 2 ++ lib/gitlab/gpg.rb | 6 +++-- lib/gitlab/gpg/commit.rb | 21 ++++++++++----- spec/lib/gitlab/gpg/commit_spec.rb | 6 +++++ spec/lib/gitlab/gpg_spec.rb | 14 +++++----- spec/models/gpg_key_spec.rb | 26 ++++++++++++++++--- spec/support/gpg_helpers.rb | 8 ++++++ 9 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 1977023536e3..31a25f3e2f0a 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -37,15 +37,21 @@ def key=(value) write_attribute(:key, value) end - def emails - @emails ||= Gitlab::Gpg.emails_from_key(key) + def user_infos + @user_infos ||= Gitlab::Gpg.user_infos_from_key(key) + end + + def verified_user_infos + user_infos.select do |user_info| + user_info[:email] == user.email + end end def emails_with_verified_status - emails.map do |email| + user_infos.map do |user_info| [ - email, - email == user.email + user_info[:email], + user_info[:email] == user.email ] end.to_h end diff --git a/db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb b/db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb new file mode 100644 index 000000000000..0e51a86e64cb --- /dev/null +++ b/db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb @@ -0,0 +1,11 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddGpgKeyUserInfoToGpgSignatures < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :gpg_signatures, :gpg_key_user_name, :string + add_column :gpg_signatures, :gpg_key_user_email, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 53b1e83ddab5..b76a5efbbd73 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -560,6 +560,8 @@ t.boolean "valid_signature" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "gpg_key_user_name" + t.string "gpg_key_user_email" end add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", using: :btree diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 582347019e5a..e1d1724295a5 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -32,11 +32,13 @@ def primary_keyids_from_key(key) end end - def emails_from_key(key) + def user_infos_from_key(key) using_tmp_keychain do fingerprints = CurrentKeyChain.fingerprints_from_key(key) - GPGME::Key.find(:public, fingerprints).flat_map { |raw_key| raw_key.uids.map(&:email) } + GPGME::Key.find(:public, fingerprints).flat_map do |raw_key| + raw_key.uids.map { |uid| { name: uid.name, email: uid.email } } + end end end diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 50e8d71bb136..55428b852072 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -26,10 +26,7 @@ def signature def update_signature!(cached_signature) using_keychain do |gpg_key| - cached_signature.update_attributes!( - valid_signature: gpg_signature_valid_signature_value(gpg_key), - gpg_key: gpg_key - ) + cached_signature.update_attributes!(attributes(gpg_key)) end end @@ -59,18 +56,30 @@ def verified_signature end def create_cached_signature!(gpg_key) - GpgSignature.create!( + GpgSignature.create!(attributes(gpg_key)) + end + + def attributes(gpg_key) + user_infos = user_infos(gpg_key) + + { commit_sha: commit.sha, project: commit.project, gpg_key: gpg_key, gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint, + gpg_key_user_name: user_infos[:name], + gpg_key_user_email: user_infos[:email], valid_signature: gpg_signature_valid_signature_value(gpg_key) - ) + } end def gpg_signature_valid_signature_value(gpg_key) !!(gpg_key && gpg_key.verified? && verified_signature.valid?) end + + def user_infos(gpg_key) + gpg_key&.verified_user_infos&.first || gpg_key&.user_infos&.first || {} + end end end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 661956b7bb7b..ddb8dd9f0f4f 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -32,6 +32,8 @@ project: project, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, valid_signature: true ) end @@ -67,6 +69,8 @@ project: project, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, valid_signature: false ) end @@ -102,6 +106,8 @@ project: project, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: nil, + gpg_key_user_email: nil, valid_signature: false ) end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index ebb7720eaea2..8041518117de 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -28,16 +28,18 @@ end end - describe '.emails_from_key' do - it 'returns the emails' do - expect( - described_class.emails_from_key(GpgHelpers::User1.public_key) - ).to eq GpgHelpers::User1.emails + describe '.user_infos_from_key' do + it 'returns the names and emails' do + user_infos = described_class.user_infos_from_key(GpgHelpers::User1.public_key) + expect(user_infos).to eq([{ + name: GpgHelpers::User1.names.first, + email: GpgHelpers::User1.emails.first + }]) end it 'returns an empty array when the key is invalid' do expect( - described_class.emails_from_key('bogus') + described_class.user_infos_from_key('bogus') ).to eq [] end end diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index ddd0bbfb9ba0..06bdbb59a11f 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -46,11 +46,31 @@ end end - describe '#emails' do - it 'returns the emails from the gpg key' do + describe '#user_infos' do + it 'returns the user infos from the gpg key' do gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key + expect(Gitlab::Gpg).to receive(:user_infos_from_key).with(gpg_key.key) - expect(gpg_key.emails).to eq GpgHelpers::User1.emails + gpg_key.user_infos + end + end + + describe '#verified_user_infos' do + it 'returns the user infos if it is verified' do + user = create :user, email: GpgHelpers::User1.emails.first + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user + + expect(gpg_key.verified_user_infos).to eq([{ + name: GpgHelpers::User1.names.first, + email: GpgHelpers::User1.emails.first + }]) + end + + it 'returns an empty array if the user info is not verified' do + user = create :user, email: 'unrelated@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user + + expect(gpg_key.verified_user_infos).to eq([]) end end diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb index f9128a629f26..96ea6f28b303 100644 --- a/spec/support/gpg_helpers.rb +++ b/spec/support/gpg_helpers.rb @@ -98,6 +98,10 @@ def fingerprint '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D' end + def names + ['Nannie Bernhard'] + end + def emails ['nannie.bernhard@example.com'] end @@ -187,6 +191,10 @@ def fingerprint '6D494CA6FC90C0CAE0910E42BF9D925F911EFD65' end + def names + ['Bette Cartwright', 'Bette Cartwright'] + end + def emails ['bette.cartwright@example.com', 'bette.cartwright@example.net'] end -- GitLab From c52718332cb723cc4b3035c17eec9eeb9926c8cf Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 13 Jul 2017 16:04:19 +0200 Subject: [PATCH 72/96] show gpg key's user info when no profile exists --- .../commit/_valid_signature_badge.html.haml | 24 ++++++++++++++----- spec/features/commits_spec.rb | 23 +++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml index 4db62418d0d8..5a451cb4055f 100644 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -6,14 +6,26 @@ - content = capture do - gpg_key = signature.gpg_key + - user = gpg_key&.user + - user_name = signature.gpg_key_user_name + - user_email = signature.gpg_key_user_email - = link_to user_path(gpg_key.user), class: 'gpg-popover-user-link' do - %div - = user_avatar_without_link(user: signature.gpg_key.user, size: 32) + - if user + = link_to user_path(user), class: 'gpg-popover-user-link' do + %div + = user_avatar_without_link(user: user, size: 32) - %div - %strong= gpg_key.user.username - %div= gpg_key.user.name + %div + %strong= gpg_key.user.username + %div= gpg_key.user.name + - else + = mail_to user_email do + %div + = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) + + %div + %strong= user_name + %div= user_email - locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] } diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 74eaafc9000f..b6b0cc7e1d33 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -263,21 +263,25 @@ end it 'shows popover badges', :js do - user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' - project.team << [user, :master] + gpg_user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' Sidekiq::Testing.inline! do - create :gpg_key, key: GpgHelpers::User1.public_key, user: user + create :gpg_key, key: GpgHelpers::User1.public_key, user: gpg_user end + user = create :user + project.team << [user, :master] + sign_in(user) visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + # unverified signature click_on 'Unverified', match: :first within '.popover' do expect(page).to have_content 'This commit was signed with an unverified signature.' expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}" end + # verified and the gpg user has a gitlab profile click_on 'Verified' within '.popover' do expect(page).to have_content 'This commit was signed with a verified signature.' @@ -285,6 +289,19 @@ expect(page).to have_content 'Nannie Bernhard' expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" end + + # verified and the gpg user's profile doesn't exist anymore + gpg_user.destroy! + + visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + + click_on 'Verified' + within '.popover' do + expect(page).to have_content 'This commit was signed with a verified signature.' + expect(page).to have_content 'Nannie Bernhard' + expect(page).to have_content 'nannie.bernhard@example.com' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" + end end end end -- GitLab From ccf3ed4351ce45204035169c67ee7f3c01b05e81 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 13 Jul 2017 16:08:14 +0200 Subject: [PATCH 73/96] swap user's name and the user's username --- app/views/projects/commit/_valid_signature_badge.html.haml | 4 ++-- spec/features/commits_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml index 5a451cb4055f..db1a41bbf64b 100644 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -16,8 +16,8 @@ = user_avatar_without_link(user: user, size: 32) %div - %strong= gpg_key.user.username - %div= gpg_key.user.name + %strong= gpg_key.user.name + %div @#{gpg_key.user.username} - else = mail_to user_email do %div diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index b6b0cc7e1d33..9bd4b478cce9 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -285,8 +285,8 @@ click_on 'Verified' within '.popover' do expect(page).to have_content 'This commit was signed with a verified signature.' - expect(page).to have_content 'nannie.bernhard' expect(page).to have_content 'Nannie Bernhard' + expect(page).to have_content '@nannie.bernhard' expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" end -- GitLab From a03a6ff326300daafbd67fd32eaaa08a4b649395 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 13 Jul 2017 16:51:16 +0200 Subject: [PATCH 74/96] display gpg key in the popover with monospace font --- app/views/projects/commit/_signature_badge.html.haml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 8e09a9333aa6..d6ece085f18b 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -9,7 +9,8 @@ = content GPG Key ID: - = signature.gpg_key_primary_keyid + %span.monospace= signature.gpg_key_primary_keyid + = link_to('Learn more about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') -- GitLab From 312dc89a44642050a2224c1b780054828c819fd6 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 13 Jul 2017 16:52:34 +0200 Subject: [PATCH 75/96] nicer email badges on the profile gpg page --- app/assets/stylesheets/pages/profile.scss | 23 +++++++++++++++++++ app/helpers/badges_helper.rb | 11 --------- .../gpg_keys/_email_with_badge.html.haml | 8 +++++++ app/views/profiles/gpg_keys/_key.html.haml | 3 +-- 4 files changed, 32 insertions(+), 13 deletions(-) delete mode 100644 app/helpers/badges_helper.rb create mode 100644 app/views/profiles/gpg_keys/_email_with_badge.html.haml diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 22672614e0dc..14ad06b0ac21 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -391,3 +391,26 @@ table.u2f-registrations { margin-bottom: 0; } } + +.gpg-email-badge { + display: inline; + margin-right: $gl-padding / 2; + + .gpg-email-badge-email { + display: inline; + margin-right: $gl-padding / 4; + } + + .label-verification-status { + border-width: 1px; + border-style: solid; + + &.verified { + @include green-status-color; + } + + &.unverified { + @include status-color($gray-dark, $gray, $common-gray-dark); + } + } +} diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb deleted file mode 100644 index e1c8927ab541..000000000000 --- a/app/helpers/badges_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -module BadgesHelper - def verified_email_badge(email, verified) - css_classes = %w(btn btn-xs disabled) - - css_classes << 'btn-success' if verified - - content_tag 'span', class: css_classes do - "#{email} #{verified ? 'Verified' : 'Unverified'}" - end - end -end diff --git a/app/views/profiles/gpg_keys/_email_with_badge.html.haml b/app/views/profiles/gpg_keys/_email_with_badge.html.haml new file mode 100644 index 000000000000..5f7844584e16 --- /dev/null +++ b/app/views/profiles/gpg_keys/_email_with_badge.html.haml @@ -0,0 +1,8 @@ +- css_classes = %w(label label-verification-status) +- css_classes << (verified ? 'verified': 'unverified') +- text = verified ? 'Verified' : 'Unverified' + +.gpg-email-badge + .gpg-email-badge-email= email + %div{ class: css_classes } + = text diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index 86e2510d22f3..b04981f90e3a 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -3,8 +3,7 @@ = icon 'key', class: "settings-list-icon hidden-xs" .key-list-item-info - key.emails_with_verified_status.map do |email, verified| - = email - = verified_email_badge(email, verified) + = render partial: 'email_with_badge', locals: { email: email, verified: verified } .description %code= key.fingerprint -- GitLab From 8c8a9e6d3fcb529e95d76dc9a7d4e37542a2036f Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Sat, 15 Jul 2017 15:25:21 +0200 Subject: [PATCH 76/96] merge migrations to 1 single create per table also: * reorder table columns * no need for `add_concurrent_index` * no need for explicit index removal on `#down` --- db/migrate/20170222111732_create_gpg_keys.rb | 9 ++++-- ...613103429_add_primary_keyid_to_gpg_keys.rb | 17 ----------- .../20170613154149_create_gpg_signatures.rb | 29 +++++++------------ ...add_gpg_key_user_info_to_gpg_signatures.rb | 11 ------- db/schema.rb | 14 ++++----- 5 files changed, 25 insertions(+), 55 deletions(-) delete mode 100644 db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb delete mode 100644 db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb index 1b8b7a91fe14..55dc730e8843 100644 --- a/db/migrate/20170222111732_create_gpg_keys.rb +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -3,11 +3,16 @@ class CreateGpgKeys < ActiveRecord::Migration def change create_table :gpg_keys do |t| + t.timestamps_with_timezone null: false + + t.references :user, index: true, foreign_key: true + t.string :fingerprint + t.string :primary_keyid + t.text :key - t.references :user, index: true, foreign_key: true - t.timestamps_with_timezone null: false + t.index :primary_keyid end end end diff --git a/db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb b/db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb deleted file mode 100644 index 13f0500971bd..000000000000 --- a/db/migrate/20170613103429_add_primary_keyid_to_gpg_keys.rb +++ /dev/null @@ -1,17 +0,0 @@ -class AddPrimaryKeyidToGpgKeys < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - disable_ddl_transaction! - - def up - add_column :gpg_keys, :primary_keyid, :string - add_concurrent_index :gpg_keys, :primary_keyid - end - - def down - remove_concurrent_index :gpg_keys, :primary_keyid if index_exists?(:gpg_keys, :primary_keyid) - remove_column :gpg_keys, :primary_keyid, :string - end -end diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb index 72560cdb6d05..515c1413cf43 100644 --- a/db/migrate/20170613154149_create_gpg_signatures.rb +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -1,29 +1,22 @@ class CreateGpgSignatures < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - DOWNTIME = false - disable_ddl_transaction! - - def up + def change create_table :gpg_signatures do |t| - t.string :commit_sha + t.timestamps_with_timezone null: false + t.references :project, index: true, foreign_key: true t.references :gpg_key, index: true, foreign_key: true - t.string :gpg_key_primary_keyid - t.boolean :valid_signature - t.timestamps_with_timezone null: false - end - - add_concurrent_index :gpg_signatures, :commit_sha - add_concurrent_index :gpg_signatures, :gpg_key_primary_keyid - end + t.boolean :valid_signature - def down - remove_concurrent_index :gpg_signatures, :commit_sha if index_exists?(:gpg_signatures, :commit_sha) - remove_concurrent_index :gpg_signatures, :gpg_key_primary_keyid if index_exists?(:gpg_signatures, :gpg_key_primary_keyid) + t.string :commit_sha + t.string :gpg_key_primary_keyid + t.string :gpg_key_user_name + t.string :gpg_key_user_email - drop_table :gpg_signatures + t.index :commit_sha + t.index :gpg_key_primary_keyid + end end end diff --git a/db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb b/db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb deleted file mode 100644 index 0e51a86e64cb..000000000000 --- a/db/migrate/20170713104235_add_gpg_key_user_info_to_gpg_signatures.rb +++ /dev/null @@ -1,11 +0,0 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - -class AddGpgKeyUserInfoToGpgSignatures < ActiveRecord::Migration - DOWNTIME = false - - def change - add_column :gpg_signatures, :gpg_key_user_name, :string - add_column :gpg_signatures, :gpg_key_user_email, :string - end -end diff --git a/db/schema.rb b/db/schema.rb index b76a5efbbd73..f413aaa41cdd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -541,25 +541,25 @@ add_index "forked_project_links", ["forked_to_project_id"], name: "index_forked_project_links_on_forked_to_project_id", unique: true, using: :btree create_table "gpg_keys", force: :cascade do |t| - t.string "fingerprint" - t.text "key" - t.integer "user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "user_id" + t.string "fingerprint" t.string "primary_keyid" + t.text "key" end add_index "gpg_keys", ["primary_keyid"], name: "index_gpg_keys_on_primary_keyid", using: :btree add_index "gpg_keys", ["user_id"], name: "index_gpg_keys_on_user_id", using: :btree create_table "gpg_signatures", force: :cascade do |t| - t.string "commit_sha" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "project_id" t.integer "gpg_key_id" - t.string "gpg_key_primary_keyid" t.boolean "valid_signature" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "commit_sha" + t.string "gpg_key_primary_keyid" t.string "gpg_key_user_name" t.string "gpg_key_user_email" end -- GitLab From 8e0c33ed1337e3614fe87d9d0c1eb64af90cc61a Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 20 Jul 2017 15:44:15 +0200 Subject: [PATCH 77/96] use ShaAttribute for gpg table columns --- app/models/gpg_key.rb | 5 +++++ app/models/gpg_signature.rb | 5 +++++ app/views/profiles/gpg_keys/_key.html.haml | 2 +- app/views/projects/commit/_signature_badge.html.haml | 2 +- db/migrate/20170222111732_create_gpg_keys.rb | 4 ++-- db/migrate/20170613154149_create_gpg_signatures.rb | 5 +++-- db/migrate/limits_to_mysql.rb | 4 ++++ db/schema.rb | 8 ++++---- 8 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 31a25f3e2f0a..da2875a88517 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -1,6 +1,11 @@ class GpgKey < ActiveRecord::Base KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze + include ShaAttribute + + sha_attribute :primary_keyid + sha_attribute :fingerprint + belongs_to :user has_many :gpg_signatures, dependent: :nullify diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 0ef335bf8a9e..9ac89f0bbbf2 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -1,4 +1,9 @@ class GpgSignature < ActiveRecord::Base + include ShaAttribute + + sha_attribute :commit_sha + sha_attribute :gpg_key_primary_keyid + belongs_to :project belongs_to :gpg_key diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index b04981f90e3a..d625aaea4677 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -6,7 +6,7 @@ = render partial: 'email_with_badge', locals: { email: email, verified: verified } .description - %code= key.fingerprint + %code= key.fingerprint.upcase .pull-right %span.key-created-at created #{time_ago_with_tooltip(key.created_at)} diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index d6ece085f18b..e79360a36e50 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -9,7 +9,7 @@ = content GPG Key ID: - %span.monospace= signature.gpg_key_primary_keyid + %span.monospace= signature.gpg_key_primary_keyid.upcase = link_to('Learn more about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb index 55dc730e8843..7591238311f2 100644 --- a/db/migrate/20170222111732_create_gpg_keys.rb +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -7,8 +7,8 @@ def change t.references :user, index: true, foreign_key: true - t.string :fingerprint - t.string :primary_keyid + t.binary :primary_keyid, limit: Gitlab::Database.mysql? ? 20 : nil + t.binary :fingerprint, limit: Gitlab::Database.mysql? ? 20 : nil t.text :key diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb index 515c1413cf43..c5478551e11c 100644 --- a/db/migrate/20170613154149_create_gpg_signatures.rb +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -10,8 +10,9 @@ def change t.boolean :valid_signature - t.string :commit_sha - t.string :gpg_key_primary_keyid + t.binary :commit_sha, limit: Gitlab::Database.mysql? ? 20 : nil + t.binary :gpg_key_primary_keyid, limit: Gitlab::Database.mysql? ? 20 : nil + t.string :gpg_key_user_name t.string :gpg_key_user_email diff --git a/db/migrate/limits_to_mysql.rb b/db/migrate/limits_to_mysql.rb index be3501c4c2e2..de1288e64103 100644 --- a/db/migrate/limits_to_mysql.rb +++ b/db/migrate/limits_to_mysql.rb @@ -8,5 +8,9 @@ def up change_column :snippets, :content, :text, limit: 2147483647 change_column :notes, :st_diff, :text, limit: 2147483647 change_column :events, :data, :text, limit: 2147483647 + change_column :gpg_keys, :primary_keyid, :binary, limit: 20 + change_column :gpg_keys, :fingerprint, :binary, limit: 20 + change_column :gpg_signatures, :commit_sha, :binary, limit: 20 + change_column :gpg_signatures, :gpg_key_primary_keyid, :binary, limit: 20 end end diff --git a/db/schema.rb b/db/schema.rb index f413aaa41cdd..68b5963ec145 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -544,8 +544,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "user_id" - t.string "fingerprint" - t.string "primary_keyid" + t.binary "primary_keyid" + t.binary "fingerprint" t.text "key" end @@ -558,8 +558,8 @@ t.integer "project_id" t.integer "gpg_key_id" t.boolean "valid_signature" - t.string "commit_sha" - t.string "gpg_key_primary_keyid" + t.binary "commit_sha" + t.binary "gpg_key_primary_keyid" t.string "gpg_key_user_name" t.string "gpg_key_user_email" end -- GitLab From 895efdfbcfe6082709943767dc8b3ebf399e1283 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 20 Jul 2017 16:11:53 +0200 Subject: [PATCH 78/96] use text instead of string for db columns --- db/migrate/20170613154149_create_gpg_signatures.rb | 4 ++-- db/schema.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb index c5478551e11c..d1fe4b96a409 100644 --- a/db/migrate/20170613154149_create_gpg_signatures.rb +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -13,8 +13,8 @@ def change t.binary :commit_sha, limit: Gitlab::Database.mysql? ? 20 : nil t.binary :gpg_key_primary_keyid, limit: Gitlab::Database.mysql? ? 20 : nil - t.string :gpg_key_user_name - t.string :gpg_key_user_email + t.text :gpg_key_user_name + t.text :gpg_key_user_email t.index :commit_sha t.index :gpg_key_primary_keyid diff --git a/db/schema.rb b/db/schema.rb index 68b5963ec145..6fa8be791567 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -560,8 +560,8 @@ t.boolean "valid_signature" t.binary "commit_sha" t.binary "gpg_key_primary_keyid" - t.string "gpg_key_user_name" - t.string "gpg_key_user_email" + t.text "gpg_key_user_name" + t.text "gpg_key_user_email" end add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", using: :btree -- GitLab From 786b5a5991930bb838767a4ed6eed2a67e517e82 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 20 Jul 2017 16:18:02 +0200 Subject: [PATCH 79/96] use short project path helpers --- spec/features/commits_spec.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 9bd4b478cce9..729d83968d3a 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -215,7 +215,7 @@ sign_in(user) - visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + visit project_commits_path(project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -228,7 +228,7 @@ user.update_attributes!(email: GpgHelpers::User1.emails.first) end - visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + visit project_commits_path(project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -242,7 +242,7 @@ sign_in(user) - visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + visit project_commits_path(project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -254,7 +254,7 @@ create :gpg_key, key: GpgHelpers::User1.public_key, user: user end - visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + visit project_commits_path(project, :'signed-commits') within '#commits-list' do expect(page).to have_content 'Unverified' @@ -272,7 +272,7 @@ project.team << [user, :master] sign_in(user) - visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + visit project_commits_path(project, :'signed-commits') # unverified signature click_on 'Unverified', match: :first @@ -293,7 +293,7 @@ # verified and the gpg user's profile doesn't exist anymore gpg_user.destroy! - visit namespace_project_commits_path(project.namespace, project, :'signed-commits') + visit project_commits_path(project, :'signed-commits') click_on 'Verified' within '.popover' do -- GitLab From 57ccff8ea41aa2366f40b29187d3b8d1217264e0 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 20 Jul 2017 16:33:44 +0200 Subject: [PATCH 80/96] use db's on_delete instead of has_many :dependent --- app/models/gpg_key.rb | 2 +- app/models/user.rb | 2 +- db/migrate/20170222111732_create_gpg_keys.rb | 2 +- db/migrate/20170613154149_create_gpg_signatures.rb | 4 ++-- db/schema.rb | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index da2875a88517..47ebfc9d234f 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -7,7 +7,7 @@ class GpgKey < ActiveRecord::Base sha_attribute :fingerprint belongs_to :user - has_many :gpg_signatures, dependent: :nullify + has_many :gpg_signatures validates :user, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 03a76f4fa234..6e66c587a1f9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,7 +76,7 @@ def update_tracked_fields!(request) where(type.not_eq('DeployKey').or(type.eq(nil))) end, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :gpg_keys, dependent: :destroy + has_many :gpg_keys has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb index 7591238311f2..072c0819b2f4 100644 --- a/db/migrate/20170222111732_create_gpg_keys.rb +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -5,7 +5,7 @@ def change create_table :gpg_keys do |t| t.timestamps_with_timezone null: false - t.references :user, index: true, foreign_key: true + t.references :user, index: true, foreign_key: { on_delete: :cascade } t.binary :primary_keyid, limit: Gitlab::Database.mysql? ? 20 : nil t.binary :fingerprint, limit: Gitlab::Database.mysql? ? 20 : nil diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb index d1fe4b96a409..db86170776d6 100644 --- a/db/migrate/20170613154149_create_gpg_signatures.rb +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -5,8 +5,8 @@ def change create_table :gpg_signatures do |t| t.timestamps_with_timezone null: false - t.references :project, index: true, foreign_key: true - t.references :gpg_key, index: true, foreign_key: true + t.references :project, index: true, foreign_key: { on_delete: :cascade } + t.references :gpg_key, index: true, foreign_key: { on_delete: :nullify } t.boolean :valid_signature diff --git a/db/schema.rb b/db/schema.rb index 6fa8be791567..1a7eb2ded76c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1631,9 +1631,9 @@ add_foreign_key "environments", "projects", name: "fk_d1c8c1da6a", on_delete: :cascade add_foreign_key "events", "projects", name: "fk_0434b48643", on_delete: :cascade add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade - add_foreign_key "gpg_keys", "users" - add_foreign_key "gpg_signatures", "gpg_keys" - add_foreign_key "gpg_signatures", "projects" + add_foreign_key "gpg_keys", "users", on_delete: :cascade + add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify + add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade -- GitLab From c4c44c6a1bb892dc17989cef3cc9b6c23fecb2c8 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 20 Jul 2017 22:07:04 +0200 Subject: [PATCH 81/96] length constrain on the index, not on the column we actually don't need a limit on the column itself for MySQL to work. we need to set a length on the index. --- db/migrate/20170222111732_create_gpg_keys.rb | 6 +++--- .../20170613154149_create_gpg_signatures.rb | 8 ++++---- db/migrate/limits_to_mysql.rb | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb index 072c0819b2f4..ec619394f6ae 100644 --- a/db/migrate/20170222111732_create_gpg_keys.rb +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -7,12 +7,12 @@ def change t.references :user, index: true, foreign_key: { on_delete: :cascade } - t.binary :primary_keyid, limit: Gitlab::Database.mysql? ? 20 : nil - t.binary :fingerprint, limit: Gitlab::Database.mysql? ? 20 : nil + t.binary :primary_keyid + t.binary :fingerprint t.text :key - t.index :primary_keyid + t.index :primary_keyid, length: Gitlab::Database.mysql? ? 20 : nil end end end diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb index db86170776d6..775a9463914f 100644 --- a/db/migrate/20170613154149_create_gpg_signatures.rb +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -10,14 +10,14 @@ def change t.boolean :valid_signature - t.binary :commit_sha, limit: Gitlab::Database.mysql? ? 20 : nil - t.binary :gpg_key_primary_keyid, limit: Gitlab::Database.mysql? ? 20 : nil + t.binary :commit_sha + t.binary :gpg_key_primary_keyid t.text :gpg_key_user_name t.text :gpg_key_user_email - t.index :commit_sha - t.index :gpg_key_primary_keyid + t.index :commit_sha, length: Gitlab::Database.mysql? ? 20 : nil + t.index :gpg_key_primary_keyid, length: Gitlab::Database.mysql? ? 20 : nil end end end diff --git a/db/migrate/limits_to_mysql.rb b/db/migrate/limits_to_mysql.rb index de1288e64103..69ada782abeb 100644 --- a/db/migrate/limits_to_mysql.rb +++ b/db/migrate/limits_to_mysql.rb @@ -1,5 +1,9 @@ # rubocop:disable all +require Rails.root.join('lib/gitlab/database/migration_helpers.rb') + class LimitsToMysql < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + def up return unless ActiveRecord::Base.configurations[Rails.env]['adapter'] =~ /^mysql/ @@ -8,9 +12,14 @@ def up change_column :snippets, :content, :text, limit: 2147483647 change_column :notes, :st_diff, :text, limit: 2147483647 change_column :events, :data, :text, limit: 2147483647 - change_column :gpg_keys, :primary_keyid, :binary, limit: 20 - change_column :gpg_keys, :fingerprint, :binary, limit: 20 - change_column :gpg_signatures, :commit_sha, :binary, limit: 20 - change_column :gpg_signatures, :gpg_key_primary_keyid, :binary, limit: 20 + + [ + [:gpg_keys, :primary_keyid], + [:gpg_signatures, :commit_sha], + [:gpg_signatures, :gpg_key_primary_keyid] + ].each do |table_name, column_name| + remove_index table_name, column_name if index_exists?(table_name, column_name) + add_concurrent_index table_name, column_name, length: 20 + end end end -- GitLab From eda001565c5afbf6e2eb9b8b5cf4fa9d6525ed71 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 09:40:23 +0200 Subject: [PATCH 82/96] fetch gpg signature badges by ajax --- app/assets/javascripts/dispatcher.js | 4 ++ app/assets/javascripts/gpg_badges.js | 15 +++++++ .../projects/commits_controller.rb | 40 ++++++++++++++----- app/helpers/commits_helper.rb | 4 ++ app/models/commit.rb | 8 +++- .../projects/commit/_ajax_signature.html.haml | 3 ++ .../commit/_signature_badge.html.haml | 2 +- app/views/projects/commits/_commit.html.haml | 7 +++- app/views/projects/commits/show.html.haml | 2 +- config/routes/repository.rb | 2 + spec/features/commits_spec.rb | 4 +- 11 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 app/assets/javascripts/gpg_badges.js create mode 100644 app/views/projects/commit/_ajax_signature.html.haml diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 1dc6edacfed5..f2f814b9e18c 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -64,6 +64,7 @@ import initSettingsPanels from './settings_panels'; import initExperimentalFlags from './experimental_flags'; import OAuthRememberMe from './oauth_remember_me'; import PerformanceBar from './performance_bar'; +import GpgBadges from './gpg_badges'; (function() { var Dispatcher; @@ -300,6 +301,9 @@ import PerformanceBar from './performance_bar'; }).bindEvents(); break; case 'projects:commits:show': + shortcut_handler = new ShortcutsNavigation(); + GpgBadges.fetch(); + break; case 'projects:activity': shortcut_handler = new ShortcutsNavigation(); break; diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js new file mode 100644 index 000000000000..1c379e9bb67b --- /dev/null +++ b/app/assets/javascripts/gpg_badges.js @@ -0,0 +1,15 @@ +export default class GpgBadges { + static fetch() { + const form = $('.commits-search-form'); + + $.get({ + url: form.data('signatures-path'), + data: form.serialize(), + }).done((response) => { + const badges = $('.js-loading-gpg-badge'); + response.signatures.forEach((signature) => { + badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html); + }); + }); + } +} diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 37b5a6e9d481..2de9900d4495 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -6,18 +6,9 @@ class Projects::CommitsController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_download_code! + before_action :set_commits def show - @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i - search = params[:search] - - @commits = - if search.present? - @repository.find_commits_by_message(search, @ref, @path, @limit, @offset) - else - @repository.commits(@ref, path: @path, limit: @limit, offset: @offset) - end - @note_counts = project.notes.where(commit_id: @commits.map(&:id)) .group(:commit_id).count @@ -37,4 +28,33 @@ def show end end end + + def signatures + respond_to do |format| + format.json do + render json: { + signatures: @commits.select(&:has_signature?).map do |commit| + { + commit_sha: commit.sha, + html: view_to_html_string('projects/commit/_signature', signature: commit.signature) + } + end + } + end + end + end + + private + + def set_commits + @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i + search = params[:search] + + @commits = + if search.present? + @repository.find_commits_by_message(search, @ref, @path, @limit, @offset) + else + @repository.commits(@ref, path: @path, limit: @limit, offset: @offset) + end + end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d08e346d605d..69220a1c0f60 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -113,6 +113,10 @@ def cherry_pick_commit_link(commit, continue_to_path, btn_class: nil, has_toolti commit_action_link('cherry-pick', commit, continue_to_path, btn_class: btn_class, has_tooltip: has_tooltip) end + def commit_signature_badge_classes(additional_classes) + %w(btn status-box gpg-status-box) + Array(additional_classes) + end + protected # Private: Returns a link to a person. If the person has a matching user and diff --git a/app/models/commit.rb b/app/models/commit.rb index 35593d53cbce..7940733f557b 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -237,9 +237,11 @@ def status(ref = nil) def signature return @signature if defined?(@signature) - @signature = Gitlab::Gpg::Commit.new(self).signature + @signature = gpg_commit.signature end + delegate :has_signature?, to: :gpg_commit + def revert_branch_name "revert-#{short_id}" end @@ -388,4 +390,8 @@ def merged_merge_request?(user) def merged_merge_request_no_cache(user) MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit? end + + def gpg_commit + @gpg_commit ||= Gitlab::Gpg::Commit.new(self) + end end diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml new file mode 100644 index 000000000000..22674b671c9f --- /dev/null +++ b/app/views/projects/commit/_ajax_signature.html.haml @@ -0,0 +1,3 @@ +- if commit.has_signature? + %button{ class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } } + %i.fa.fa-spinner.fa-spin diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index e79360a36e50..51f04a117127 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -1,4 +1,4 @@ -- css_classes = %w(btn status-box gpg-status-box) + css_classes +- css_classes = commit_signature_badge_classes(css_classes) - title = capture do .gpg-popover-status diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index b7f18d448386..12b73ecdf137 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -39,7 +39,12 @@ .commit-actions.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) - = render partial: 'projects/commit/signature', object: commit.signature + + - if request.xhr? + = render partial: 'projects/commit/signature', object: commit.signature + - else + = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } + = link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha btn btn-transparent" = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard")) = link_to_browse_code(project, commit) diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 844ebb651484..bd2d900997e6 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -29,7 +29,7 @@ = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' .control - = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form') do + = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form', data: { 'signatures-path' => namespace_project_signatures_path }) do = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } .control = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do diff --git a/config/routes/repository.rb b/config/routes/repository.rb index 11911636fa77..edcf3ddf57b1 100644 --- a/config/routes/repository.rb +++ b/config/routes/repository.rb @@ -76,6 +76,8 @@ get '/tree/*id', to: 'tree#show', as: :tree get '/raw/*id', to: 'raw#show', as: :raw get '/blame/*id', to: 'blame#show', as: :blame + + get '/commits/*id/signatures', to: 'commits#signatures', as: :signatures get '/commits/*id', to: 'commits#show', as: :commits post '/create_dir/*id', to: 'tree#create_dir', as: :create_dir diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 729d83968d3a..87a0dc328a63 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -204,7 +204,7 @@ end end - describe 'GPG signed commits' do + describe 'GPG signed commits', :js do it 'changes from unverified to verified when the user changes his email to match the gpg key' do user = create :user, email: 'unrelated.user@example.org' project.team << [user, :master] @@ -262,7 +262,7 @@ end end - it 'shows popover badges', :js do + it 'shows popover badges' do gpg_user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' Sidekiq::Testing.inline! do create :gpg_key, key: GpgHelpers::User1.public_key, user: gpg_user -- GitLab From ce4e0837c4ce11ad31c7be487d08bf44d961ec6f Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 10:02:13 +0200 Subject: [PATCH 83/96] mysql hack: set length for binary indexes --- .../mysql_set_length_for_binary_indexes.rb | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 config/initializers/mysql_set_length_for_binary_indexes.rb diff --git a/config/initializers/mysql_set_length_for_binary_indexes.rb b/config/initializers/mysql_set_length_for_binary_indexes.rb new file mode 100644 index 000000000000..b5c6e39f6a88 --- /dev/null +++ b/config/initializers/mysql_set_length_for_binary_indexes.rb @@ -0,0 +1,25 @@ +# This patches ActiveRecord so indexes for binary columns created using the +# MySQL adapter apply a length of 20. Otherwise MySQL can't create an index on +# binary columns. + +if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) + module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter < AbstractMysqlAdapter + alias_method :__gitlab_add_index2, :add_index + + def add_index(table_name, column_names, options = {}) + Array(column_names).each do |column_name| + column = ActiveRecord::Base.connection.columns(table_name).find { |c| c.name == column_name } + + if column&.type == :binary + options[:length] = 20 + end + end + + __gitlab_add_index2(table_name, column_names, options) + end + end + end + end +end -- GitLab From f86580c075f50b78517283febca012afcc8b6211 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 16:02:30 +0200 Subject: [PATCH 84/96] no more more :length due to mysql set_index hack --- db/migrate/limits_to_mysql.rb | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/db/migrate/limits_to_mysql.rb b/db/migrate/limits_to_mysql.rb index 69ada782abeb..be3501c4c2e2 100644 --- a/db/migrate/limits_to_mysql.rb +++ b/db/migrate/limits_to_mysql.rb @@ -1,9 +1,5 @@ # rubocop:disable all -require Rails.root.join('lib/gitlab/database/migration_helpers.rb') - class LimitsToMysql < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - def up return unless ActiveRecord::Base.configurations[Rails.env]['adapter'] =~ /^mysql/ @@ -12,14 +8,5 @@ def up change_column :snippets, :content, :text, limit: 2147483647 change_column :notes, :st_diff, :text, limit: 2147483647 change_column :events, :data, :text, limit: 2147483647 - - [ - [:gpg_keys, :primary_keyid], - [:gpg_signatures, :commit_sha], - [:gpg_signatures, :gpg_key_primary_keyid] - ].each do |table_name, column_name| - remove_index table_name, column_name if index_exists?(table_name, column_name) - add_concurrent_index table_name, column_name, length: 20 - end end end -- GitLab From 98531fc2487f8d4d7de47fe9a1d60c10d1f1d9ba Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 16:23:52 +0200 Subject: [PATCH 85/96] upcase in the model instead of in the view --- app/models/gpg_key.rb | 8 ++++++++ app/models/gpg_signature.rb | 4 ++++ app/views/profiles/gpg_keys/_key.html.haml | 2 +- app/views/projects/commit/_signature_badge.html.haml | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 47ebfc9d234f..0d35baa7adea 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -37,6 +37,14 @@ class GpgKey < ActiveRecord::Base after_commit :update_invalid_gpg_signatures, on: :create after_commit :notify_user, on: :create + def primary_keyid + super&.upcase + end + + def fingerprint + super&.upcase + end + def key=(value) value.strip! unless value.blank? write_attribute(:key, value) diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 9ac89f0bbbf2..cb69106183df 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -11,6 +11,10 @@ class GpgSignature < ActiveRecord::Base validates :project, presence: true validates :gpg_key_primary_keyid, presence: true + def gpg_key_primary_keyid + super&.upcase + end + def commit project.commit(commit_sha) end diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index d625aaea4677..b04981f90e3a 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -6,7 +6,7 @@ = render partial: 'email_with_badge', locals: { email: email, verified: verified } .description - %code= key.fingerprint.upcase + %code= key.fingerprint .pull-right %span.key-created-at created #{time_ago_with_tooltip(key.created_at)} diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 51f04a117127..66f00eb5507b 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -9,7 +9,7 @@ = content GPG Key ID: - %span.monospace= signature.gpg_key_primary_keyid.upcase + %span.monospace= signature.gpg_key_primary_keyid = link_to('Learn more about signing commits', help_page_path('workflow/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') -- GitLab From ecbc11a839f7a48402e912f1176735770c091829 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 16:24:22 +0200 Subject: [PATCH 86/96] extract setter as before_action --- app/controllers/profiles/gpg_keys_controller.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb index 3e75247769d5..6779cc6ddac4 100644 --- a/app/controllers/profiles/gpg_keys_controller.rb +++ b/app/controllers/profiles/gpg_keys_controller.rb @@ -1,4 +1,6 @@ class Profiles::GpgKeysController < Profiles::ApplicationController + before_action :set_gpg_key, only: [:destroy, :revoke] + def index @gpg_keys = current_user.gpg_keys @gpg_key = GpgKey.new @@ -16,8 +18,7 @@ def create end def destroy - @gpp_key = current_user.gpg_keys.find(params[:id]) - @gpp_key.destroy + @gpg_key.destroy respond_to do |format| format.html { redirect_to profile_gpg_keys_url, status: 302 } @@ -26,8 +27,7 @@ def destroy end def revoke - @gpp_key = current_user.gpg_keys.find(params[:id]) - @gpp_key.revoke + @gpg_key.revoke respond_to do |format| format.html { redirect_to profile_gpg_keys_url, status: 302 } @@ -40,4 +40,8 @@ def revoke def gpg_key_params params.require(:gpg_key).permit(:key) end + + def set_gpg_key + @gpg_key = current_user.gpg_keys.find(params[:id]) + end end -- GitLab From 07dbd5649ad18e4473c10ef8a1a70ea863b88cc4 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 16:45:13 +0200 Subject: [PATCH 87/96] use Module#prepend instead of alias_method_chain --- .../mysql_set_length_for_binary_indexes.rb | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/config/initializers/mysql_set_length_for_binary_indexes.rb b/config/initializers/mysql_set_length_for_binary_indexes.rb index b5c6e39f6a88..de0bc5322aac 100644 --- a/config/initializers/mysql_set_length_for_binary_indexes.rb +++ b/config/initializers/mysql_set_length_for_binary_indexes.rb @@ -2,24 +2,20 @@ # MySQL adapter apply a length of 20. Otherwise MySQL can't create an index on # binary columns. -if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) - module ActiveRecord - module ConnectionAdapters - class Mysql2Adapter < AbstractMysqlAdapter - alias_method :__gitlab_add_index2, :add_index - - def add_index(table_name, column_names, options = {}) - Array(column_names).each do |column_name| - column = ActiveRecord::Base.connection.columns(table_name).find { |c| c.name == column_name } +module MysqlSetLengthForBinaryIndex + def add_index(table_name, column_names, options = {}) + Array(column_names).each do |column_name| + column = ActiveRecord::Base.connection.columns(table_name).find { |c| c.name == column_name } - if column&.type == :binary - options[:length] = 20 - end - end - - __gitlab_add_index2(table_name, column_names, options) - end + if column&.type == :binary + options[:length] = 20 end end + + super(table_name, column_names, options) end end + +if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) + ActiveRecord::ConnectionAdapters::Mysql2Adapter.send(:prepend, MysqlSetLengthForBinaryIndex) +end -- GitLab From 14551424c9fd3a9401559e6d2da34be8d1fdd45c Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 20:31:34 +0200 Subject: [PATCH 88/96] add unique indexes to gpg_keys --- db/migrate/20170222111732_create_gpg_keys.rb | 3 ++- db/schema.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb index ec619394f6ae..541228e87350 100644 --- a/db/migrate/20170222111732_create_gpg_keys.rb +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -12,7 +12,8 @@ def change t.text :key - t.index :primary_keyid, length: Gitlab::Database.mysql? ? 20 : nil + t.index :primary_keyid, unique: true, length: Gitlab::Database.mysql? ? 20 : nil + t.index :fingerprint, unique: true, length: Gitlab::Database.mysql? ? 20 : nil end end end diff --git a/db/schema.rb b/db/schema.rb index 1a7eb2ded76c..2cc8b1624c08 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -549,7 +549,8 @@ t.text "key" end - add_index "gpg_keys", ["primary_keyid"], name: "index_gpg_keys_on_primary_keyid", using: :btree + add_index "gpg_keys", ["fingerprint"], name: "index_gpg_keys_on_fingerprint", unique: true, using: :btree + add_index "gpg_keys", ["primary_keyid"], name: "index_gpg_keys_on_primary_keyid", unique: true, using: :btree add_index "gpg_keys", ["user_id"], name: "index_gpg_keys_on_user_id", using: :btree create_table "gpg_signatures", force: :cascade do |t| -- GitLab From 843b1de0dec3e101b323737e4d345c4e58b2a0c3 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 20:35:44 +0200 Subject: [PATCH 89/96] simplify nil handling --- app/models/gpg_key.rb | 3 +-- spec/models/gpg_key_spec.rb | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 0d35baa7adea..009a93ce1a80 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -46,8 +46,7 @@ def fingerprint end def key=(value) - value.strip! unless value.blank? - write_attribute(:key, value) + super(value&.strip) end def user_infos diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 06bdbb59a11f..1242f0b2e2aa 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -44,6 +44,10 @@ expect(described_class.new(key: " #{key} ").key).to eq(key) end + + it 'does not strip when the key is nil' do + expect(described_class.new(key: nil).key).to be_nil + end end describe '#user_infos' do -- GitLab From a5f04df8d76d7c3c4318820fc3053a9823143dba Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 21:14:14 +0200 Subject: [PATCH 90/96] update all records at once using `update_all` --- app/models/gpg_key.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 009a93ce1a80..535b40472b09 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -77,12 +77,11 @@ def update_invalid_gpg_signatures end def revoke - GpgSignature.where(gpg_key: self, valid_signature: true).find_each do |gpg_signature| - gpg_signature.update_attributes!( - gpg_key: nil, - valid_signature: false - ) - end + GpgSignature.where(gpg_key: self, valid_signature: true).update_all( + gpg_key_id: nil, + valid_signature: false, + updated_at: Time.zone.now + ) destroy end -- GitLab From fef030c23dff6f3b11b0e6bfd4c9443106375de1 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Tue, 25 Jul 2017 21:20:48 +0200 Subject: [PATCH 91/96] validate the foreign_key instead of the relation --- app/models/gpg_signature.rb | 2 +- spec/models/gpg_signature_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index cb69106183df..1ac0e123ff1b 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -8,7 +8,7 @@ class GpgSignature < ActiveRecord::Base belongs_to :gpg_key validates :commit_sha, presence: true - validates :project, presence: true + validates :project_id, presence: true validates :gpg_key_primary_keyid, presence: true def gpg_key_primary_keyid diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb index b6f256e61ee5..9a9b1900aa53 100644 --- a/spec/models/gpg_signature_spec.rb +++ b/spec/models/gpg_signature_spec.rb @@ -9,7 +9,7 @@ describe 'validation' do subject { described_class.new } it { is_expected.to validate_presence_of(:commit_sha) } - it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:project_id) } it { is_expected.to validate_presence_of(:gpg_key_primary_keyid) } end -- GitLab From 4e53131f7dceb001368446ef3e7eb3747cfcec02 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 26 Jul 2017 09:30:33 +0200 Subject: [PATCH 92/96] add unique index for gpg_signatures#commit_sha --- db/migrate/20170613154149_create_gpg_signatures.rb | 2 +- db/schema.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb index 775a9463914f..f6b5e7ebb7b8 100644 --- a/db/migrate/20170613154149_create_gpg_signatures.rb +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -16,7 +16,7 @@ def change t.text :gpg_key_user_name t.text :gpg_key_user_email - t.index :commit_sha, length: Gitlab::Database.mysql? ? 20 : nil + t.index :commit_sha, unique: true, length: Gitlab::Database.mysql? ? 20 : nil t.index :gpg_key_primary_keyid, length: Gitlab::Database.mysql? ? 20 : nil end end diff --git a/db/schema.rb b/db/schema.rb index 2cc8b1624c08..63030350c5dc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -565,7 +565,7 @@ t.text "gpg_key_user_email" end - add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", using: :btree + add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", unique: true, using: :btree add_index "gpg_signatures", ["gpg_key_id"], name: "index_gpg_signatures_on_gpg_key_id", using: :btree add_index "gpg_signatures", ["gpg_key_primary_keyid"], name: "index_gpg_signatures_on_gpg_key_primary_keyid", using: :btree add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree -- GitLab From 9488b7780edc57193cd1c51888478538ddc94e51 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 26 Jul 2017 10:24:46 +0200 Subject: [PATCH 93/96] optimize query, only select relevant db columns --- .../gpg/invalid_gpg_signature_updater.rb | 1 + .../gpg/invalid_gpg_signature_updater_spec.rb | 64 +++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb index 1782e20dcab6..3bb491120ba5 100644 --- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -7,6 +7,7 @@ def initialize(gpg_key) def run GpgSignature + .select(:id, :commit_sha, :project_id) .where('gpg_key_id IS NULL OR valid_signature = ?', false) .where(gpg_key_primary_keyid: @gpg_key.primary_keyid) .find_each do |gpg_signature| diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index 5a81a86b93cf..c4e04ee46a2a 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -20,7 +20,7 @@ end before do - allow_any_instance_of(GpgSignature).to receive(:commit).and_return(commit) + allow_any_instance_of(Project).to receive(:commit).and_return(commit) end context 'gpg signature did have an associated gpg key which was removed later' do @@ -41,7 +41,13 @@ key: GpgHelpers::User1.public_key, user: user - expect(valid_gpg_signature.reload.gpg_key).to eq gpg_key + expect(valid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: true + ) end it 'does not assign the gpg key when an unrelated gpg key is added' do @@ -50,7 +56,13 @@ key: GpgHelpers::User2.public_key, user: user - expect(valid_gpg_signature.reload.gpg_key).to be_nil + expect(valid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: true + ) end end @@ -68,11 +80,17 @@ it 'updates the signature to being valid when the missing gpg key is added' do # InvalidGpgSignatureUpdater is called by the after_create hook - create :gpg_key, + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user - expect(invalid_gpg_signature.reload.valid_signature).to be_truthy + expect(invalid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: true + ) end it 'keeps the signature at being invalid when an unrelated gpg key is added' do @@ -81,7 +99,13 @@ key: GpgHelpers::User2.public_key, user: user - expect(invalid_gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: nil, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + ) end end @@ -102,7 +126,7 @@ end it 'updates the signature to being valid when the user updates the email address' do - create :gpg_key, + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user @@ -111,20 +135,38 @@ # InvalidGpgSignatureUpdater is called by the after_update hook user.update_attributes!(email: GpgHelpers::User1.emails.first) - expect(invalid_gpg_signature.reload.valid_signature).to be_truthy + expect(invalid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: true + ) end it 'keeps the signature at being invalid when the changed email address is still unrelated' do - create :gpg_key, + gpg_key = create :gpg_key, key: GpgHelpers::User1.public_key, user: user - expect(invalid_gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + ) # InvalidGpgSignatureUpdater is called by the after_update hook user.update_attributes!(email: 'still.unrelated@example.com') - expect(invalid_gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload).to have_attributes( + project: project, + commit_sha: commit_sha, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + valid_signature: false + ) end end end -- GitLab From f1ccecc9979e3091e7cf54f98508f6bc7c01a7f5 Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 26 Jul 2017 15:47:00 +0200 Subject: [PATCH 94/96] improve gpg key validation when omitting the end part of the key ('-----END PGP PUBLIC KEY BLOCK-----') the error message was not about the key anymore, but about the missing fingerprint and primary_keyid, which was confusing for the user. the new validation checks that the end also matches the expected format. --- app/models/gpg_key.rb | 5 +++-- spec/models/gpg_key_spec.rb | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 535b40472b09..3df60ddc9501 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -1,5 +1,6 @@ class GpgKey < ActiveRecord::Base KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze + KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze include ShaAttribute @@ -15,8 +16,8 @@ class GpgKey < ActiveRecord::Base presence: true, uniqueness: true, format: { - with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX}).)+\Z/m, - message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}'" + with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/m, + message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}' and ends with '#{KEY_SUFFIX}'" } validates :fingerprint, diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index 1242f0b2e2aa..59c074199dbe 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -7,10 +7,18 @@ describe "validation" do it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:key) } it { is_expected.to validate_uniqueness_of(:key) } - it { is_expected.to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey").for(:key) } + + it { is_expected.to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey\n-----END PGP PUBLIC KEY BLOCK-----").for(:key) } + + it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey").for(:key) } it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey\n-----BEGIN PGP PUBLIC KEY BLOCK-----").for(:key) } + it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK----------END PGP PUBLIC KEY BLOCK-----").for(:key) } + it { is_expected.not_to allow_value("-----BEGIN PGP PUBLIC KEY BLOCK-----").for(:key) } + it { is_expected.not_to allow_value("-----END PGP PUBLIC KEY BLOCK-----").for(:key) } + it { is_expected.not_to allow_value("key\n-----END PGP PUBLIC KEY BLOCK-----").for(:key) } it { is_expected.not_to allow_value('BEGIN PGP').for(:key) } end -- GitLab From 7f7e93a34471f673ac3888549c67bce4e763300e Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Wed, 26 Jul 2017 16:01:24 +0200 Subject: [PATCH 95/96] remove log statements from workers --- app/workers/create_gpg_signature_worker.rb | 8 ++------ app/workers/invalid_gpg_signature_update_worker.rb | 4 +--- spec/workers/create_gpg_signature_worker_spec.rb | 14 -------------- .../invalid_gpg_signature_update_worker_spec.rb | 7 ------- 4 files changed, 3 insertions(+), 30 deletions(-) diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb index 6fbd6e1a3f37..4f47717ff69f 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_gpg_signature_worker.rb @@ -5,15 +5,11 @@ class CreateGpgSignatureWorker def perform(commit_sha, project_id) project = Project.find_by(id: project_id) - unless project - return Rails.logger.error("CreateGpgSignatureWorker: couldn't find project with ID=#{project_id}, skipping job") - end + return unless project commit = project.commit(commit_sha) - unless commit - return Rails.logger.error("CreateGpgSignatureWorker: couldn't find commit with commit_sha=#{commit_sha}, skipping job") - end + return unless commit commit.signature end diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index c0bec3c96895..db6b1ea8e8de 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -5,9 +5,7 @@ class InvalidGpgSignatureUpdateWorker def perform(gpg_key_id) gpg_key = GpgKey.find_by(id: gpg_key_id) - unless gpg_key - return Rails.logger.error("InvalidGpgSignatureUpdateWorker: couldn't find gpg_key with ID=#{gpg_key_id}, skipping job") - end + return unless gpg_key Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run end diff --git a/spec/workers/create_gpg_signature_worker_spec.rb b/spec/workers/create_gpg_signature_worker_spec.rb index a23f0d6c34ab..c6a17d77d73c 100644 --- a/spec/workers/create_gpg_signature_worker_spec.rb +++ b/spec/workers/create_gpg_signature_worker_spec.rb @@ -20,13 +20,6 @@ let(:nonexisting_commit_sha) { 'bogus' } let(:project) { create :project } - it 'logs CreateGpgSignatureWorker process skipping' do - expect(Rails.logger).to receive(:error) - .with("CreateGpgSignatureWorker: couldn't find commit with commit_sha=bogus, skipping job") - - described_class.new.perform(nonexisting_commit_sha, project.id) - end - it 'does not raise errors' do expect { described_class.new.perform(nonexisting_commit_sha, project.id) }.not_to raise_error end @@ -41,13 +34,6 @@ context 'when Project is not found' do let(:nonexisting_project_id) { -1 } - it 'logs CreateGpgSignatureWorker process skipping' do - expect(Rails.logger).to receive(:error) - .with("CreateGpgSignatureWorker: couldn't find project with ID=-1, skipping job") - - described_class.new.perform(anything, nonexisting_project_id) - end - it 'does not raise errors' do expect { described_class.new.perform(anything, nonexisting_project_id) }.not_to raise_error end diff --git a/spec/workers/invalid_gpg_signature_update_worker_spec.rb b/spec/workers/invalid_gpg_signature_update_worker_spec.rb index 8d568076e1ab..5972696515be 100644 --- a/spec/workers/invalid_gpg_signature_update_worker_spec.rb +++ b/spec/workers/invalid_gpg_signature_update_worker_spec.rb @@ -16,13 +16,6 @@ context 'when GpgKey is not found' do let(:nonexisting_gpg_key_id) { -1 } - it 'logs InvalidGpgSignatureUpdateWorker process skipping' do - expect(Rails.logger).to receive(:error) - .with("InvalidGpgSignatureUpdateWorker: couldn't find gpg_key with ID=-1, skipping job") - - described_class.new.perform(nonexisting_gpg_key_id) - end - it 'does not raise errors' do expect { described_class.new.perform(nonexisting_gpg_key_id) }.not_to raise_error end -- GitLab From 5ebccab1eb74f7bf9f7f9d4f2d9a56fb81754cbe Mon Sep 17 00:00:00 2001 From: Alexis Reigel Date: Thu, 27 Jul 2017 16:01:24 +0200 Subject: [PATCH 96/96] add "GPG Keys" to new navigation --- app/views/layouts/nav/_new_profile_sidebar.html.haml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/views/layouts/nav/_new_profile_sidebar.html.haml b/app/views/layouts/nav/_new_profile_sidebar.html.haml index 239e6b949e2f..6bbd569583eb 100644 --- a/app/views/layouts/nav/_new_profile_sidebar.html.haml +++ b/app/views/layouts/nav/_new_profile_sidebar.html.haml @@ -47,6 +47,10 @@ = link_to profile_keys_path, title: 'SSH Keys' do %span SSH Keys + = nav_link(controller: :gpg_keys) do + = link_to profile_gpg_keys_path, title: 'GPG Keys' do + %span + GPG Keys = nav_link(controller: :preferences) do = link_to profile_preferences_path, title: 'Preferences' do %span -- GitLab