diff --git a/Gemfile b/Gemfile index 43109de1b45..d41d75b250a 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' @@ -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 6c2ac9368f2..2483b0bf35c 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 @@ -749,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) @@ -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) @@ -1081,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/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index 36bfe457be9..510bedbf641 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/dispatcher.js b/app/assets/javascripts/dispatcher.js index 1dc6edacfed..f2f814b9e18 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 00000000000..1c379e9bb67 --- /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/assets/javascripts/main.js b/app/assets/javascripts/main.js index e96d51de838..d039ca9e47c 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,11 @@ $(function () { return $(el).data('placement') || 'bottom'; } }); + // Initialize popovers + $body.popover({ + selector: '[data-toggle="popover"]', + trigger: 'focus' + }); $('.trigger-submit').on('change', function () { return $(this).parents('form').submit(); // Form submitter diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 3a98332e46c..6f91d11b369 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 fd0871ec0b8..cd9f2d787c5 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -283,3 +283,63 @@ color: $gl-text-color; } } + + +.gpg-status-box { + &.valid { + @include green-status-color; + } + + &.invalid { + @include status-color($gray-dark, $gray, $common-gray-dark); + border-color: $common-gray-light; + } +} + +.gpg-popover-status { + display: flex; + align-items: center; + font-weight: normal; + line-height: 1.5; +} + +.gpg-popover-icon { + // same margin as .s32.avatar + margin-right: $btn-side-margin; + + &.valid { + svg { + border: 1px solid $brand-success; + + fill: $brand-success; + } + } + + &.invalid { + svg { + border: 1px solid $common-gray-light; + + fill: $common-gray-light; + } + } + + svg { + width: 32px; + height: 32px; + border-radius: 50%; + vertical-align: middle; + } +} + +.gpg-popover-user-link { + display: flex; + align-items: center; + margin-bottom: $gl-padding / 2; + text-decoration: none; + color: $gl-text-color; +} + +.commit .gpg-popover-help-link { + display: block; + color: $link-color; +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 22672614e0d..14ad06b0ac2 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/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 67ad1ae60af..36f622db136 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, diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb new file mode 100644 index 00000000000..6779cc6ddac --- /dev/null +++ b/app/controllers/profiles/gpg_keys_controller.rb @@ -0,0 +1,47 @@ +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 + 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 + @gpg_key.destroy + + respond_to do |format| + format.html { redirect_to profile_gpg_keys_url, status: 302 } + format.js { head :ok } + end + end + + def revoke + @gpg_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 + params.require(:gpg_key).permit(:key) + end + + def set_gpg_key + @gpg_key = current_user.gpg_keys.find(params[:id]) + end +end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 37b5a6e9d48..2de9900d449 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 @@ class Projects::CommitsController < Projects::ApplicationController 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 d08e346d605..69220a1c0f6 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -113,6 +113,10 @@ module CommitsHelper 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/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 256cbcd73a1..c401030e34a 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,7 @@ module Emails 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 @@ -22,5 +22,15 @@ module Emails @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/commit.rb b/app/models/commit.rb index 1e19f00106a..7940733f557 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -234,6 +234,14 @@ class Commit @statuses[ref] = pipelines.latest_status(ref) end + def signature + return @signature if defined?(@signature) + + @signature = gpg_commit.signature + end + + delegate :has_signature?, to: :gpg_commit + def revert_branch_name "revert-#{short_id}" end @@ -382,4 +390,8 @@ class Commit 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/models/gpg_key.rb b/app/models/gpg_key.rb new file mode 100644 index 00000000000..3df60ddc950 --- /dev/null +++ b/app/models/gpg_key.rb @@ -0,0 +1,107 @@ +class GpgKey < ActiveRecord::Base + KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze + KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze + + include ShaAttribute + + sha_attribute :primary_keyid + sha_attribute :fingerprint + + belongs_to :user + has_many :gpg_signatures + + validates :user, presence: true + + validates :key, + presence: true, + uniqueness: true, + format: { + 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, + 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) } + + 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_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) + super(value&.strip) + end + + 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 + user_infos.map do |user_info| + [ + user_info[:email], + user_info[:email] == user.email + ] + end.to_h + end + + def verified? + emails_with_verified_status.any? { |_email, verified| verified } + end + + def update_invalid_gpg_signatures + InvalidGpgSignatureUpdateWorker.perform_async(self.id) + end + + def revoke + GpgSignature.where(gpg_key: self, valid_signature: true).update_all( + gpg_key_id: nil, + valid_signature: false, + updated_at: Time.zone.now + ) + + destroy + end + + private + + def extract_fingerprint + # we can assume that the result only contains one item as the validation + # only allows one key + 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 + NotificationService.new.new_gpg_key(self) + end +end diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb new file mode 100644 index 00000000000..1ac0e123ff1 --- /dev/null +++ b/app/models/gpg_signature.rb @@ -0,0 +1,21 @@ +class GpgSignature < ActiveRecord::Base + include ShaAttribute + + sha_attribute :commit_sha + sha_attribute :gpg_key_primary_keyid + + belongs_to :project + belongs_to :gpg_key + + validates :commit_sha, presence: true + validates :project_id, presence: true + validates :gpg_key_primary_keyid, presence: true + + def gpg_key_primary_keyid + super&.upcase + end + + def commit + project.commit(commit_sha) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index c26be6d05a2..6e66c587a1f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,6 +76,7 @@ class User < ActiveRecord::Base 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 has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -157,6 +158,7 @@ class User < ActiveRecord::Base 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 @@ -512,6 +514,10 @@ class User < ActiveRecord::Base 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/app/services/git_push_service.rb b/app/services/git_push_service.rb index 20d1fb29289..bb7680c5054 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -56,6 +56,8 @@ class GitPushService < BaseService perform_housekeeping update_caches + + update_signatures end def update_gitattributes @@ -80,6 +82,12 @@ class GitPushService < BaseService 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/services/notification_service.rb b/app/services/notification_service.rb index 3a98a5f6b64..b94921d2a08 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -17,6 +17,16 @@ class NotificationService 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/layouts/nav/_new_profile_sidebar.html.haml b/app/views/layouts/nav/_new_profile_sidebar.html.haml index 239e6b949e2..6bbd569583e 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 diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 424905ea890..26d9640e98a 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/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml new file mode 100644 index 00000000000..4b9350c4e88 --- /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 00000000000..80b5a1fd7ff --- /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/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 00000000000..5f7844584e1 --- /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/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml new file mode 100644 index 00000000000..3fcf563d970 --- /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 00000000000..b04981f90e3 --- /dev/null +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -0,0 +1,18 @@ +%li.key-list-item + .pull-left.append-right-10 + = icon 'key', class: "settings-list-icon hidden-xs" + .key-list-item-info + - key.emails_with_verified_status.map do |email, verified| + = render partial: 'email_with_badge', locals: { email: email, verified: verified } + + .description + %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? 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/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml new file mode 100644 index 00000000000..cabb92c5a24 --- /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 00000000000..8331daeeb75 --- /dev/null +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -0,0 +1,21 @@ +- 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 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 + Your GPG keys (#{@gpg_keys.count}) + .append-bottom-default + = render 'key_table' 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 00000000000..22674b671c9 --- /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/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 45109f2c58b..419fbe99af8 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,5 +1,6 @@ .page-content-header .header-main-content + = render partial: 'signature', object: @commit.signature %strong #{ s_('CommitBoxTitle|Commit') } %span.commit-sha= @commit.short_id 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 00000000000..3a73aae9d95 --- /dev/null +++ b/app/views/projects/commit/_invalid_signature_badge.html.haml @@ -0,0 +1,9 @@ +- title = capture do + .gpg-popover-icon.invalid + = render 'shared/icons/icon_status_notfound_borderless.svg' + %div + 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 new file mode 100644 index 00000000000..60fa52557ef --- /dev/null +++ b/app/views/projects/commit/_signature.html.haml @@ -0,0 +1,5 @@ +- if 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 00000000000..66f00eb5507 --- /dev/null +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -0,0 +1,18 @@ +- css_classes = commit_signature_badge_classes(css_classes) + +- title = capture do + .gpg-popover-status + = title + +- content = capture do + .clearfix + = content + + GPG Key ID: + %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') + +%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 new file mode 100644 index 00000000000..db1a41bbf64 --- /dev/null +++ b/app/views/projects/commit/_valid_signature_badge.html.haml @@ -0,0 +1,32 @@ +- title = capture do + .gpg-popover-icon.valid + = render 'shared/icons/icon_status_success_borderless.svg' + %div + This commit was signed with a verified signature. + +- 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 + + - 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.name + %div @#{gpg_key.user.username} + - 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'] } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 1033bad0d49..12b73ecdf13 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,9 +36,15 @@ #{ commit_text.html_safe } - .commit-actions.flex-row.hidden-xs + .commit-actions.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) + + - 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/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index c764e35dd2a..d14897428d0 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 diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 844ebb65148..bd2d900997e 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/app/views/shared/icons/_icon_status_notfound_borderless.svg b/app/views/shared/icons/_icon_status_notfound_borderless.svg new file mode 100644 index 00000000000..e58bd264ef8 --- /dev/null +++ b/app/views/shared/icons/_icon_status_notfound_borderless.svg @@ -0,0 +1 @@ + diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb new file mode 100644 index 00000000000..4f47717ff69 --- /dev/null +++ b/app/workers/create_gpg_signature_worker.rb @@ -0,0 +1,16 @@ +class CreateGpgSignatureWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(commit_sha, project_id) + project = Project.find_by(id: project_id) + + return unless project + + commit = project.commit(commit_sha) + + return unless commit + + commit.signature + end +end 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 00000000000..db6b1ea8e8d --- /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) + gpg_key = GpgKey.find_by(id: gpg_key_id) + + return unless gpg_key + + Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run + end +end diff --git a/changelogs/unreleased/feature-gpg-signed-commits.yml b/changelogs/unreleased/feature-gpg-signed-commits.yml new file mode 100644 index 00000000000..99bc5a309ef --- /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 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 00000000000..de0bc5322aa --- /dev/null +++ b/config/initializers/mysql_set_length_for_binary_indexes.rb @@ -0,0 +1,21 @@ +# 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. + +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 + + super(table_name, column_names, options) + end +end + +if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) + ActiveRecord::ConnectionAdapters::Mysql2Adapter.send(:prepend, MysqlSetLengthForBinaryIndex) +end diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 3dc890e5785..3e4e6111ab8 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -23,6 +23,11 @@ resource :profile, only: [:show, :update] do end resource :preferences, only: [:show, :update] resources :keys, only: [:index, :show, :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/config/routes/repository.rb b/config/routes/repository.rb index 11911636fa7..edcf3ddf57b 100644 --- a/config/routes/repository.rb +++ b/config/routes/repository.rb @@ -76,6 +76,8 @@ scope format: false do 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/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 1d9e69a2408..7496bfa4fbb 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -29,6 +29,8 @@ - [email_receiver, 2] - [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/db/migrate/20170222111732_create_gpg_keys.rb b/db/migrate/20170222111732_create_gpg_keys.rb new file mode 100644 index 00000000000..541228e8735 --- /dev/null +++ b/db/migrate/20170222111732_create_gpg_keys.rb @@ -0,0 +1,19 @@ +class CreateGpgKeys < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :gpg_keys do |t| + t.timestamps_with_timezone null: false + + t.references :user, index: true, foreign_key: { on_delete: :cascade } + + t.binary :primary_keyid + t.binary :fingerprint + + t.text :key + + 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/migrate/20170613154149_create_gpg_signatures.rb b/db/migrate/20170613154149_create_gpg_signatures.rb new file mode 100644 index 00000000000..f6b5e7ebb7b --- /dev/null +++ b/db/migrate/20170613154149_create_gpg_signatures.rb @@ -0,0 +1,23 @@ +class CreateGpgSignatures < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :gpg_signatures do |t| + t.timestamps_with_timezone null: false + + 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 + + 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, unique: true, 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/schema.rb b/db/schema.rb index 1ec25c7d46f..63030350c5d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -540,6 +540,36 @@ ActiveRecord::Schema.define(version: 20170725145659) do 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "user_id" + t.binary "primary_keyid" + t.binary "fingerprint" + t.text "key" + end + + 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| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "project_id" + t.integer "gpg_key_id" + t.boolean "valid_signature" + t.binary "commit_sha" + t.binary "gpg_key_primary_keyid" + 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", 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 + create_table "identities", force: :cascade do |t| t.string "extern_uid" t.string "provider" @@ -1602,6 +1632,9 @@ ActiveRecord::Schema.define(version: 20170725145659) do 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", 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 diff --git a/doc/README.md b/doc/README.md index ac7311a8c13..5537f54ab2b 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 00000000000..e525083918b Binary files /dev/null and b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys.png differ diff --git a/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png new file mode 100644 index 00000000000..8e26d98f1b0 Binary files /dev/null and b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_paste_pub.png differ 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 00000000000..f715c46adc3 Binary files /dev/null and b/doc/workflow/gpg_signed_commits/img/profile_settings_gpg_keys_single_key.png differ diff --git a/doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png b/doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png new file mode 100644 index 00000000000..16ec2d031ae Binary files /dev/null and b/doc/workflow/gpg_signed_commits/img/project_signed_and_unsigned_commits.png differ 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 00000000000..22565cf7c7e Binary files /dev/null and b/doc/workflow/gpg_signed_commits/img/project_signed_commit_unverified_signature.png differ diff --git a/doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png b/doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png new file mode 100644 index 00000000000..1778b2ddf2b Binary files /dev/null and b/doc/workflow/gpg_signed_commits/img/project_signed_commit_verified_signature.png differ diff --git a/doc/workflow/gpg_signed_commits/index.md b/doc/workflow/gpg_signed_commits/index.md new file mode 100644 index 00000000000..7d5762d2b9d --- /dev/null +++ b/doc/workflow/gpg_signed_commits/index.md @@ -0,0 +1,84 @@ +# Signing commits with GPG + +## 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 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. + +## 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 + 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) diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 09511cc6504..ca7e3a7c4be 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -319,6 +319,15 @@ module Gitlab end end + # Get the gpg signature of this commit. + # + # Ex. + # commit.signature(repo) + # + def signature(repo) + Rugged::Commit.extract_signature(repo.rugged, sha) + end + def stats Gitlab::Git::CommitStats.new(self) end @@ -327,7 +336,7 @@ module Gitlab 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 diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb new file mode 100644 index 00000000000..e1d1724295a --- /dev/null +++ b/lib/gitlab/gpg.rb @@ -0,0 +1,62 @@ +module Gitlab + module Gpg + extend self + + module CurrentKeyChain + extend self + + def add(key) + GPGME::Key.import(key) + end + + def fingerprints_from_key(key) + import = GPGME::Key.import(key) + + return [] if import.imported == 0 + + import.imports.map(&:fingerprint) + end + end + + def fingerprints_from_key(key) + using_tmp_keychain do + CurrentKeyChain.fingerprints_from_key(key) + end + end + + 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 + end + + def user_infos_from_key(key) + using_tmp_keychain do + fingerprints = CurrentKeyChain.fingerprints_from_key(key) + + GPGME::Key.find(:public, fingerprints).flat_map do |raw_key| + raw_key.uids.map { |uid| { name: uid.name, email: uid.email } } + end + 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/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb new file mode 100644 index 00000000000..55428b85207 --- /dev/null +++ b/lib/gitlab/gpg/commit.rb @@ -0,0 +1,85 @@ +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 + return unless has_signature? + + cached_signature = GpgSignature.find_by(commit_sha: commit.sha) + return cached_signature if cached_signature.present? + + using_keychain do |gpg_key| + create_cached_signature!(gpg_key) + end + end + + def update_signature!(cached_signature) + using_keychain do |gpg_key| + cached_signature.update_attributes!(attributes(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. + # 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) + @verified_signature = nil + end + + yield gpg_key + end + end + + def verified_signature + @verified_signature ||= GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |verified_signature| + break verified_signature + end + end + + def create_cached_signature!(gpg_key) + 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/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb new file mode 100644 index 00000000000..3bb491120ba --- /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 + .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| + Gitlab::Gpg::Commit.new(gpg_signature.commit).update_signature!(gpg_signature) + end + end + end + end +end diff --git a/spec/factories/gpg_keys.rb b/spec/factories/gpg_keys.rb new file mode 100644 index 00000000000..1258dce8940 --- /dev/null +++ b/spec/factories/gpg_keys.rb @@ -0,0 +1,8 @@ +require_relative '../support/gpg_helpers' + +FactoryGirl.define do + factory :gpg_key do + key GpgHelpers::User1.public_key + user + end +end diff --git a/spec/factories/gpg_signature.rb b/spec/factories/gpg_signature.rb new file mode 100644 index 00000000000..a5aeffbe12d --- /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/features/commits_spec.rb b/spec/features/commits_spec.rb index 479fb713297..15ec6f20763 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -203,4 +203,105 @@ describe 'Commits' do end end end + + 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] + + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + sign_in(user) + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).not_to have_content 'Verified' + end + + # user changes his email which makes the gpg key verified + Sidekiq::Testing.inline! do + user.skip_reconfirmation! + user.update_attributes!(email: GpgHelpers::User1.emails.first) + end + + visit project_commits_path(project, :'signed-commits') + + 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] + + sign_in(user) + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).not_to have_content 'Verified' + end + + # user adds the gpg key which makes the signature valid + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).to have_content 'Verified' + end + end + + 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 + end + + user = create :user + project.team << [user, :master] + + sign_in(user) + visit project_commits_path(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.' + 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 + + # verified and the gpg user's profile doesn't exist anymore + gpg_user.destroy! + + visit project_commits_path(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 diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb new file mode 100644 index 00000000000..6edc482b47e --- /dev/null +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +feature 'Profile > GPG Keys' do + let(:user) { create(:user, email: GpgHelpers::User2.emails.first) } + + 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: GpgHelpers::User2.public_key) + click_button('Add key') + + 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 key' do + create(:gpg_key, user: user, key: GpgHelpers::User2.public_key) + visit profile_gpg_keys_path + + 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, key: GpgHelpers::User2.public_key) + visit profile_gpg_keys_path + + click_link('Remove') + + 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/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb new file mode 100644 index 00000000000..ddb8dd9f0f4 --- /dev/null +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +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 + expect(described_class.new(project.commit).signature).to be_nil + end + end + + context 'known and verified public key' do + 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: commit_sha) + allow(raw_commit).to receive :save! + + 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: commit_sha, + 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 + + it 'returns the cached signature on second call' do + gpg_commit = described_class.new(commit) + + expect(gpg_commit).to receive(:using_keychain).and_call_original + gpg_commit.signature + + # consecutive call + expect(gpg_commit).not_to receive(:using_keychain).and_call_original + gpg_commit.signature + end + end + + context 'known but unverified public key' do + 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: commit_sha) + allow(raw_commit).to receive :save! + + 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: commit_sha, + 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 + + it 'returns the cached signature on second call' do + gpg_commit = described_class.new(commit) + + expect(gpg_commit).to receive(:using_keychain).and_call_original + gpg_commit.signature + + # consecutive call + expect(gpg_commit).not_to receive(:using_keychain).and_call_original + gpg_commit.signature + end + end + + context 'unknown public key' do + 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 + + it 'returns an invalid signature' do + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: commit_sha, + 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 + + it 'returns the cached signature on second call' do + gpg_commit = described_class.new(commit) + + expect(gpg_commit).to receive(:using_keychain).and_call_original + gpg_commit.signature + + # consecutive call + expect(gpg_commit).not_to receive(:using_keychain).and_call_original + gpg_commit.signature + end + 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 new file mode 100644 index 00000000000..c4e04ee46a2 --- /dev/null +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -0,0 +1,173 @@ +require 'rails_helper' + +RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do + describe '#run' do + let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } + let!(:project) { create :project, :repository, path: 'sample-project' } + 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 + + before do + 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 + 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).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 + # InvalidGpgSignatureUpdater is called by the after_create hook + create :gpg_key, + key: GpgHelpers::User2.public_key, + user: user + + 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 + + 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 + gpg_key = create :gpg_key, + key: GpgHelpers::User1.public_key, + user: user + + 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 + # InvalidGpgSignatureUpdater is called by the after_create hook + create :gpg_key, + key: GpgHelpers::User2.public_key, + user: user + + 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 + + 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 + + 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 + gpg_key = create :gpg_key, + key: GpgHelpers::User1.public_key, + user: user + + 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(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 + gpg_key = create :gpg_key, + key: GpgHelpers::User1.public_key, + user: user + + 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).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 +end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb new file mode 100644 index 00000000000..8041518117d --- /dev/null +++ b/spec/lib/gitlab/gpg_spec.rb @@ -0,0 +1,83 @@ +require 'rails_helper' + +describe Gitlab::Gpg do + describe '.fingerprints_from_key' do + 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 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 + + 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 '.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.user_infos_from_key('bogus') + ).to eq [] + end + end +end + +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 [] + + 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 + + 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 diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 8c1c9bf135f..09e5094cf84 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -91,6 +91,36 @@ describe Emails::Profile do 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 new file mode 100644 index 00000000000..59c074199db --- /dev/null +++ b/spec/models/gpg_key_spec.rb @@ -0,0 +1,157 @@ +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(: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\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 + + 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) + gpg_key.valid? + 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 + 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 + + 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 + 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) + + 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 + + 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 + + expect(gpg_key.emails_with_verified_status).to eq( + 'bette.cartwright@example.com' => true, + 'bette.cartwright@example.net' => false + ) + 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 + + let(:user) { create(:user) } + + it 'sends a notification' do + perform_enqueued_jobs do + create(:gpg_key, user: user) + end + + 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 diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb new file mode 100644 index 00000000000..9a9b1900aa5 --- /dev/null +++ b/spec/models/gpg_signature_spec.rb @@ -0,0 +1,28 @@ +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_id) } + 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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 71aadbb4186..ec98a3f3498 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -350,6 +350,26 @@ describe User do 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) } diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 8e6e5864c9a..75329e9dda2 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -681,6 +681,24 @@ describe GitPushService do 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/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index c98eb87b94e..49d6fc7853f 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -93,6 +93,18 @@ describe NotificationService do 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) } diff --git a/spec/support/gpg_helpers.rb b/spec/support/gpg_helpers.rb new file mode 100644 index 00000000000..96ea6f28b30 --- /dev/null +++ b/spec/support/gpg_helpers.rb @@ -0,0 +1,202 @@ +module GpgHelpers + module User1 + extend self + + def signed_commit_signature + <<~SIGNATURE + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v1 + + iJwEAAECAAYFAliu264ACgkQzPvhnwCsix1VXgP9F6zwAMb3OXKZzqGxJ4MQIBoL + OdiUSJpL/4sIA9uhFeIv3GIA+uhsG1BHHsG627+sDy7b8W9VWEd7tbcoz4Mvhf3P + 8g0AIt9/KJuStQZDrXwP1uP6Rrl759nDcNpoOKdSQ5EZ1zlRzeDROlZeDp7Ckfvw + GLmN/74Gl3pk0wfgHFY= + =wSgS + -----END PGP SIGNATURE----- + SIGNATURE + end + + def signed_commit_base_data + <<~SIGNEDDATA + tree ed60cfd202644fda1abaf684e7d965052db18c13 + 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 + <<~KEY.strip + -----BEGIN PGP PUBLIC KEY BLOCK----- + Version: GnuPG v1 + + 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----- + KEY + end + + def primary_keyid + fingerprint[-16..-1] + end + + def fingerprint + '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D' + end + + def names + ['Nannie Bernhard'] + end + + def emails + ['nannie.bernhard@example.com'] + end + end + + module User2 + extend self + + def private_key + <<~KEY.strip + -----BEGIN PGP PRIVATE KEY BLOCK----- + Version: GnuPG v1 + + lQHYBFiuqioBBADg46jkiATWMy9t1npxFWJ77xibPXdUo36LAZgZ6uGungSzcFL4 + 50bdEyMMGm5RJp6DCYkZlwQDlM//YEqwf0Cmq/AibC5m9bHr7hf5sMxl40ssJ4fj + dzT6odihO0vxD2ARSrtiwkESzFxjJ51mjOfdPvAGf0ucxzgeRfUlCrM3kwARAQAB + AAP8CJlDFnbywR9dWfqBxi19sFMOk/smCObNQanuTcx6CDcu4zHi0Yxx6BoNCQES + cDRCLX5HevnpZngzQB3qa7dga+yqxKzwO8v0P0hliL81B1ZVXUk9TWhBj3NS3m3v + +kf2XeTxuZFb9fj44/4HpfbQ2yazTs/Xa+/ZeMqFPCYSNEECAOtjIbwHdfjkpVWR + uiwphRkNimv5hdObufs63m9uqhpKPdPKmr2IXgahPZg5PooxqE0k9IXaX2pBsJUF + DyuL1dsCAPSVL+YAOviP8ecM1jvdKpkFDd67kR5C+7jEvOGl+c2aX3qLvKt62HPR + +DxvYE0Oy0xfoHT14zNSfqthmlhIPqkB/i4WyJaafQVvkkoA9+A5aXbyihOR+RTx + p+CMNYvaAplFAyey7nv8l5+la/N+Sv86utjaenLZmCf34nDQEZy7rFWny7QvQmV0 + dGUgQ2FydHdyaWdodCA8YmV0dGUuY2FydHdyaWdodEBleGFtcGxlLmNvbT6IuAQT + AQIAIgUCWK6qKgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQv52SX5Ee + /WVCGwP/QsOLTTyEJ6hl0Yy7DLY3kUxS6xiD9fW1FDoTQlxhiO+8TmghmhdtU3TI + ssP30/Su3pNKW3TkILtE9U8I2krEpsX5NkyMwmI6LXdeZjli2Lvtkx0Fm0Psd4HO + ORYJW5HqTx4jDLzeeIcYjqnobztDpfG8ONDvB0EI0GnCTOZNggG0L0JldHRlIENh + cnR3cmlnaHQgPGJldHRlLmNhcnR3cmlnaHRAZXhhbXBsZS5uZXQ+iLgEEwECACIF + AlivAsUCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEL+dkl+RHv1lXOwE + ANh7ce/vUjv6VMkO8o5OZXszhKE5+MSmYO8v/kkHcXNccC5wI6VF4K//r41p8Cyk + 9NzW7Kzjt2+14/BBqWugCx3xjWCuf88KH5PHbuSmfVYbzJmNSy6rfPmusZG5ePqD + xp5l2qQxMdRUX0Z36D/koM4N0ls6PAf6Xrdv9s6IBMMVnQHYBFiuqioBBADe5nUd + VOcbZlnxOjl0KBAT+A5bmyBLUT0BmLPsmA4PuXDSth7WvibPC8wcCdCYVk0IRMYn + eZUiWq/o5c4rthfLR4jg8kruvomQ4E4d4hyI6R0MLxXYZ3XMu67VuScFgbLURw1e + RZ16ANd3Nc1VuFW7ms0vCG0idB8iSZBoULaK8QARAQABAAP5AdCfUT/y2kmi75iF + ZX1ahSkax9LraEWW8TOCuolR6v2b7jFKrr2xX/P1A2DulID2Y1v4/5MJPHR/1G4D + l95Fkw+iGsTvKB5rPG5xye0vOYbbujRa6B9LL6s4Taf486shEegOrdjN9FIweM6f + vuVaDYzIk8Qwv5/sStEBxx8rxIkCAOBftFi56AY0gLniyEMAvVRjyVeOZPPJbS8i + v6L9asJB5wdsGJxJVyUZ/ylar5aCS7sroOcYTN2b1tOPoWuGqIkCAP5RlDRgm3Zg + xL6hXejqZp3G1/DXhKBSI/yUTR/D89H5/qNQe3W7dZqns9mSAJNtqOu+UMZ5UreY + Ond0/dmL5SkCAOO5r6gXM8ZDcNjydlQexCLnH70yVkCL6hG9Va1gOuFyUztRnCd+ + E35YRCEwZREZDr87BRr2Aak5t+lb1EFVqV+nvYifBBgBAgAJBQJYrqoqAhsMAAoJ + EL+dkl+RHv1lQggEANWwQwrlT2BFLWV8Fx+wlg31+mcjkTq0LaWu3oueAluoSl93 + 2B6ToruMh66JoxpSDU44x3JbCaZ/6poiYs5Aff8ZeyEVlfkVaQ7IWd5spjpXaS4i + oCOfkZepmbTuE7TPQWM4iBAtuIfiJGiwcpWWM+KIH281yhfCcbRzzFLsCVQx + =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 + 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 + + def primary_keyid + fingerprint[-16..-1] + end + + def fingerprint + '6D494CA6FC90C0CAE0910E42BF9D925F911EFD65' + end + + def names + ['Bette Cartwright', 'Bette Cartwright'] + end + + def emails + ['bette.cartwright@example.com', 'bette.cartwright@example.net'] + end + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index c32c05b03e2..7682bdf8cd0 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', 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 00000000000..c6a17d77d73 --- /dev/null +++ b/spec/workers/create_gpg_signature_worker_spec.rb @@ -0,0 +1,47 @@ +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 '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 '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 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 00000000000..5972696515b --- /dev/null +++ b/spec/workers/invalid_gpg_signature_update_worker_spec.rb @@ -0,0 +1,29 @@ +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 '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