From b0f982fbdf69c292ab4530c0aaaf1ab42f4e7a01 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Mon, 21 Aug 2017 11:30:03 +0100 Subject: [PATCH] Add settings for minimum key strength and allowed key type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is an amalgamation of: * Cory Hinshaw: Initial implementation !5552 * Rémy Coutable: Updates !9350 * Nick Thomas: Resolve conflicts and add ED25519 support !13712 --- .../admin/application_settings_controller.rb | 3 + app/helpers/application_settings_helper.rb | 18 +++ app/models/application_setting.rb | 33 +++++ app/models/key.rb | 45 +++++- .../application_settings/_form.html.haml | 51 ++++++- ...-to-restrict-min-key-length-and-techno.yml | 5 + ...imum_key_length_to_application_settings.rb | 24 ++++ db/schema.rb | 5 + doc/api/settings.md | 19 ++- doc/security/README.md | 1 + .../img/ssh_keys_restrictions_settings.png | Bin 0 -> 41803 bytes doc/security/ssh_keys_restrictions.md | 18 +++ lib/api/entities.rb | 1 + lib/api/settings.rb | 6 + lib/gitlab/git_access.rb | 9 ++ lib/gitlab/key_fingerprint.rb | 48 ------- lib/gitlab/ssh_public_key.rb | 84 +++++++++++ spec/factories/keys.rb | 49 +++++++ spec/features/admin/admin_settings_spec.rb | 20 +++ spec/features/profiles/keys_spec.rb | 16 +++ spec/lib/gitlab/git_access_spec.rb | 42 ++++++ spec/lib/gitlab/key_fingerprint_spec.rb | 82 ----------- spec/lib/gitlab/ssh_public_key_spec.rb | 132 ++++++++++++++++++ spec/models/application_setting_spec.rb | 33 +++++ spec/models/key_spec.rb | 85 ++++++++++- spec/requests/api/settings_spec.rb | 17 ++- 26 files changed, 704 insertions(+), 142 deletions(-) create mode 100644 changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml create mode 100644 db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb create mode 100644 doc/security/img/ssh_keys_restrictions_settings.png create mode 100644 doc/security/ssh_keys_restrictions.md delete mode 100644 lib/gitlab/key_fingerprint.rb create mode 100644 lib/gitlab/ssh_public_key.rb delete mode 100644 spec/lib/gitlab/key_fingerprint_spec.rb create mode 100644 spec/lib/gitlab/ssh_public_key_spec.rb diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 8367c22d1ca..8cc7111033f 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -66,6 +66,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end end + params[:application_setting][:allowed_key_types]&.delete('') + enabled_oauth_sign_in_sources = params[:application_setting].delete(:enabled_oauth_sign_in_sources) params[:application_setting][:disabled_oauth_sign_in_sources] = @@ -83,6 +85,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def visible_application_setting_attributes ApplicationSettingsHelper.visible_attributes + [ :domain_blacklist_file, + allowed_key_types: [], disabled_oauth_sign_in_sources: [], import_sources: [], repository_storages: [], diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 3b76da238e0..75d090359d0 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -81,6 +81,20 @@ module ApplicationSettingsHelper end end + def allowed_key_types_checkboxes(help_block_id) + Gitlab::SSHPublicKey.technology_names.map do |type| + checked = current_application_settings.allowed_key_types.include?(type) + checkbox_id = "allowed_key_types-#{type}" + + label_tag(checkbox_id, class: checked ? 'active' : nil) do + check_box_tag('application_setting[allowed_key_types][]', type, checked, + autocomplete: 'off', + 'aria-describedby' => help_block_id, + id: checkbox_id) + type.upcase + end + end + end + def repository_storages_options_for_select options = Gitlab.config.repositories.storages.map do |name, storage| ["#{name} - #{storage['path']}", name] @@ -141,6 +155,10 @@ module ApplicationSettingsHelper :metrics_port, :metrics_sample_interval, :metrics_timeout, + :minimum_dsa_bits, + :minimum_ecdsa_bits, + :minimum_ed25519_bits, + :minimum_rsa_bits, :password_authentication_enabled, :performance_bar_allowed_group_id, :performance_bar_enabled, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 8e446ff6dd8..988ee4802b9 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -20,6 +20,7 @@ class ApplicationSetting < ActiveRecord::Base serialize :domain_blacklist, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize serialize :sidekiq_throttling_queues, Array # rubocop:disable Cop/ActiveRecordSerialize + serialize :allowed_key_types, Array # rubocop:disable Cop/ActiveRecordSerialize cache_markdown_field :sign_in_text cache_markdown_field :help_page_text @@ -146,6 +147,24 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :allowed_key_types, presence: true + + validates :minimum_rsa_bits, + presence: true, + inclusion: { in: Gitlab::SSHPublicKey.allowed_sizes('rsa') } + + validates :minimum_dsa_bits, + presence: true, + inclusion: { in: Gitlab::SSHPublicKey.allowed_sizes('dsa') } + + validates :minimum_ecdsa_bits, + presence: true, + inclusion: { in: Gitlab::SSHPublicKey.allowed_sizes('ecdsa') } + + validates :minimum_ed25519_bits, + presence: true, + inclusion: { in: Gitlab::SSHPublicKey.allowed_sizes('ed25519') } + validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| unless Gitlab::VisibilityLevel.options.value?(level) @@ -170,7 +189,16 @@ class ApplicationSetting < ActiveRecord::Base end end + validates_each :allowed_key_types do |record, attr, value| + value&.each do |type| + unless Gitlab::SSHPublicKey.allowed_type?(type) + record.errors.add(attr, "'#{type}' is not a valid SSH key type") + end + end + end + before_validation :ensure_uuid! + before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -212,6 +240,7 @@ class ApplicationSetting < ActiveRecord::Base { after_sign_up_text: nil, akismet_enabled: false, + allowed_key_types: Gitlab::SSHPublicKey.technology_names, container_registry_token_expire_delay: 5, default_artifacts_expire_in: '30 days', default_branch_protection: Settings.gitlab['default_branch_protection'], @@ -239,6 +268,10 @@ class ApplicationSetting < ActiveRecord::Base max_attachment_size: Settings.gitlab['max_attachment_size'], password_authentication_enabled: Settings.gitlab['password_authentication_enabled'], performance_bar_allowed_group_id: nil, + minimum_rsa_bits: 1024, + minimum_dsa_bits: 1024, + minimum_ecdsa_bits: 256, + minimum_ed25519_bits: 256, plantuml_enabled: false, plantuml_url: nil, project_export_enabled: true, diff --git a/app/models/key.rb b/app/models/key.rb index 49bc26122fa..27c91679ec9 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -1,6 +1,8 @@ require 'digest/md5' class Key < ActiveRecord::Base + include AfterCommitQueue + include Gitlab::CurrentSettings include Sortable LAST_USED_AT_REFRESH_TIME = 1.day.to_i @@ -12,14 +14,18 @@ class Key < ActiveRecord::Base validates :title, presence: true, length: { maximum: 255 } + validates :key, presence: true, length: { maximum: 5000 }, format: { with: /\A(ssh|ecdsa)-.*\Z/ } + validates :fingerprint, uniqueness: true, presence: { message: 'cannot be generated' } + validate :key_meets_minimum_bit_length, :key_type_is_allowed + delegate :name, :email, to: :user, prefix: true after_commit :add_to_shell, on: :create @@ -80,6 +86,10 @@ class Key < ActiveRecord::Base SystemHooksService.new.execute_hooks_for(self, :destroy) end + def public_key + @public_key ||= Gitlab::SSHPublicKey.new(key) + end + private def generate_fingerprint @@ -87,7 +97,40 @@ class Key < ActiveRecord::Base return unless self.key.present? - self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint + self.fingerprint = public_key.fingerprint + end + + def key_meets_minimum_bit_length + case public_key.type + when :rsa + if public_key.bits < current_application_settings.minimum_rsa_bits + errors.add(:key, "length must be at least #{current_application_settings.minimum_rsa_bits} bits") + end + when :dsa + if public_key.bits < current_application_settings.minimum_dsa_bits + errors.add(:key, "length must be at least #{current_application_settings.minimum_dsa_bits} bits") + end + when :ecdsa + if public_key.bits < current_application_settings.minimum_ecdsa_bits + errors.add(:key, "elliptic curve size must be at least #{current_application_settings.minimum_ecdsa_bits} bits") + end + when :ed25519 + if public_key.bits < current_application_settings.minimum_ed25519_bits + errors.add(:key, "length must be at least #{current_application_settings.minimum_ed25519_bits} bits") + end + end + end + + def key_type_is_allowed + unless current_application_settings.allowed_key_types.include?(public_key.type.to_s) + allowed_types = + current_application_settings + .allowed_key_types + .map(&:upcase) + .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + + errors.add(:key, "type is not allowed. Must be #{allowed_types}") + end end def notify_user diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 959af5c0d13..1cda98ffea8 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -42,12 +42,7 @@ = link_to "(?)", help_page_path("integration/bitbucket") and GitLab.com = link_to "(?)", help_page_path("integration/gitlab") - .form-group - %label.control-label.col-sm-2 Enabled Git access protocols - .col-sm-10 - = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') - %span.help-block#clone-protocol-help - Allow only the selected protocols to be used for Git access. + .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -55,6 +50,50 @@ = f.check_box :project_export_enabled Project export enabled + .form-group + %label.control-label.col-sm-2 Enabled Git access protocols + .col-sm-10 + = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') + %span.help-block#clone-protocol-help + Allow only the selected protocols to be used for Git access. + + .form-group + = f.label :allowed_key_types, 'Allowed SSH keys', class: 'control-label col-sm-2' + .col-sm-10 + = hidden_field_tag 'application_setting[allowed_key_types][]', nil, id: 'allowed_key_types-none' + - allowed_key_types_checkboxes('allowed-key-types-help').each do |key_type_checkbox| + .checkbox= key_type_checkbox + %span.help-block#allowed-key-types-help + Only SSH keys with allowed algorithms can be uploaded. + + .form-group + = f.label :minimum_rsa_bits, 'Minimum RSA key length', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :minimum_rsa_bits, Gitlab::SSHPublicKey.allowed_sizes('rsa'), {}, class: 'form-control' + .help-block + The minimum length for user RSA SSH keys (in bits) + + .form-group + = f.label :minimum_dsa_bits, 'Minimum DSA key length', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :minimum_dsa_bits, Gitlab::SSHPublicKey.allowed_sizes('dsa'), {}, class: 'form-control' + .help-block + The minimum length for user DSA SSH keys (in bits) + + .form-group + = f.label :minimum_ecdsa_bits, 'Minimum ECDSA key length', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :minimum_ecdsa_bits, Gitlab::SSHPublicKey.allowed_sizes('ecdsa'), {}, class: 'form-control' + .help-block + The minimum elliptic curve size for user ECDSA SSH keys (in bits) + + .form-group + = f.label :minimum_ed25519_bits, 'Minimum ED25519 key length', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :minimum_ed25519_bits, Gitlab::SSHPublicKey.allowed_sizes('ed25519'), {}, class: 'form-control' + .help-block + The minimum length for user ED25519 SSH keys (in bits) + %fieldset %legend Account and Limit Settings .form-group diff --git a/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml new file mode 100644 index 00000000000..2e0f509b17c --- /dev/null +++ b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml @@ -0,0 +1,5 @@ +--- +title: Add settings for minimum key strength and allowed key type +merge_request: 13712 +author: Cory Hinshaw +type: added diff --git a/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb new file mode 100644 index 00000000000..ce87d8a26b6 --- /dev/null +++ b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb @@ -0,0 +1,24 @@ +class AddMinimumKeyLengthToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, :minimum_rsa_bits, :integer, default: 1024 + add_column_with_default :application_settings, :minimum_dsa_bits, :integer, default: 1024 + add_column_with_default :application_settings, :minimum_ecdsa_bits, :integer, default: 256 + add_column_with_default :application_settings, :minimum_ed25519_bits, :integer, default: 256 + add_column_with_default :application_settings, :allowed_key_types, :string, default: %w[rsa dsa ecdsa ed25519].to_yaml + end + + def down + remove_column :application_settings, :minimum_rsa_bits + remove_column :application_settings, :minimum_dsa_bits + remove_column :application_settings, :minimum_ecdsa_bits + remove_column :application_settings, :minimum_ed25519_bits + remove_column :application_settings, :allowed_key_types + end +end diff --git a/db/schema.rb b/db/schema.rb index 0f4b0c0c3b3..49ae4b48627 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -129,6 +129,11 @@ ActiveRecord::Schema.define(version: 20170824162758) do t.boolean "password_authentication_enabled" t.boolean "project_export_enabled", default: true, null: false t.boolean "hashed_storage_enabled", default: false, null: false + t.integer "minimum_rsa_bits", default: 1024, null: false + t.integer "minimum_dsa_bits", default: 1024, null: false + t.integer "minimum_ecdsa_bits", default: 256, null: false + t.integer "minimum_ed25519_bits", default: 256, null: false + t.string "allowed_key_types", default: "---\n- rsa\n- dsa\n- ecdsa\n- ed25519\n", null: false end create_table "audit_events", force: :cascade do |t| diff --git a/doc/api/settings.md b/doc/api/settings.md index 94a9f8265fb..a43e13e6217 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -48,7 +48,12 @@ Example response: "plantuml_enabled": false, "plantuml_url": null, "terminal_max_session_time": 0, - "polling_interval_multiplier": 1.0 + "polling_interval_multiplier": 1.0, + "minimum_rsa_bits": 1024, + "minimum_dsa_bits": 1024, + "minimum_ecdsa_bits": 256, + "minimum_ed25519_bits": 256, + "allowed_key_types": ["rsa", "dsa", "ecdsa", "ed25519"] } ``` @@ -88,6 +93,11 @@ PUT /application/settings | `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. | | `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time. | | `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling. | +| `minimum_rsa_bits` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `1024`. +| `minimum_dsa_bits` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `1024`. +| `minimum_ecdsa_bits` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `256`. +| `minimum_ed25519_bits` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `256`. +| `allowed_key_types` | array of strings | no | Array of SSH key types accepted by the application. Allowed values are: `rsa`, `dsa`, `ecdsa`, and `ed25519`. Default is `["rsa", "dsa", "ecdsa", "ed25519"]`. ```bash curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/application/settings?signup_enabled=false&default_project_visibility=internal @@ -125,6 +135,11 @@ Example response: "plantuml_enabled": false, "plantuml_url": null, "terminal_max_session_time": 0, - "polling_interval_multiplier": 1.0 + "polling_interval_multiplier": 1.0, + "minimum_rsa_bits": 1024, + "minimum_dsa_bits": 1024, + "minimum_ecdsa_bits": 256, + "minimum_ed25519_bits": 256, + "allowed_key_types": ["rsa", "dsa", "ecdsa", "ed25519"] } ``` diff --git a/doc/security/README.md b/doc/security/README.md index 38706e48ec5..1f54948d113 100644 --- a/doc/security/README.md +++ b/doc/security/README.md @@ -1,6 +1,7 @@ # Security - [Password length limits](password_length_limits.md) +- [Restrict allowed SSH key technologies and minimum length](ssh_keys_restrictions.md) - [Rack attack](rack_attack.md) - [Webhooks and insecure internal web services](webhooks.md) - [Information exclusivity](information_exclusivity.md) diff --git a/doc/security/img/ssh_keys_restrictions_settings.png b/doc/security/img/ssh_keys_restrictions_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..b62bfc2f7e05312244d65d00b3c981439cce4852 GIT binary patch literal 41803 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

oXS}aTz4op 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 SSH Keys' do expect(page).to have_content("Title: #{attrs[:title]}") expect(page).to have_content(attrs[:key]) end + + context 'when only DSA and ECDSA keys are allowed' do + before do + stub_application_setting(allowed_key_types: %w[dsa ecdsa]) + end + + scenario 'shows a validation error' do + attrs = attributes_for(:key) + + fill_in('Key', with: attrs[:key]) + fill_in('Title', with: attrs[:title]) + click_button('Add key') + + expect(page).to have_content('Key type is not allowed. Must be DSA or ECDSA') + end + end end scenario 'User sees their keys' do diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 295a979da76..a67902c7209 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -155,6 +155,48 @@ describe Gitlab::GitAccess do end end + shared_examples '#check with a key that is not valid' do + before do + project.add_master(user) + end + + context 'key is too small' do + before do + stub_application_setting(minimum_rsa_bits: 4096) + end + + it 'does not allow keys which are too small' do + aggregate_failures do + expect(actor).not_to be_valid + expect { pull_access_check }.to raise_unauthorized('Your SSH key length must be at least 4096 bits.') + expect { push_access_check }.to raise_unauthorized('Your SSH key length must be at least 4096 bits.') + end + end + end + + context 'key type is not allowed' do + before do + stub_application_setting(allowed_key_types: ['ecdsa']) + end + + it 'does not allow keys which are too small' do + aggregate_failures do + expect(actor).not_to be_valid + expect { pull_access_check }.to raise_unauthorized('Your SSH key type is not allowed. Must be ECDSA.') + expect { push_access_check }.to raise_unauthorized('Your SSH key type is not allowed. Must be ECDSA.') + end + end + end + end + + it_behaves_like '#check with a key that is not valid' do + let(:actor) { build(:rsa_key_2048, user: user) } + end + + it_behaves_like '#check with a key that is not valid' do + let(:actor) { build(:rsa_deploy_key_2048, user: user) } + end + describe '#check_project_moved!' do before do project.add_master(user) diff --git a/spec/lib/gitlab/key_fingerprint_spec.rb b/spec/lib/gitlab/key_fingerprint_spec.rb deleted file mode 100644 index d643dc5342d..00000000000 --- a/spec/lib/gitlab/key_fingerprint_spec.rb +++ /dev/null @@ -1,82 +0,0 @@ -require 'spec_helper' - -describe Gitlab::KeyFingerprint, lib: true do - KEYS = { - rsa: - 'example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC5z65PwQ1GE6foJgwk' \ - '9rmQi/glaXbUeVa5uvQpnZ3Z5+forcI7aTngh3aZ/H2UDP2L70TGy7kKNyp0J3a8/OdG' \ - 'Z08y5yi3JlbjFARO1NyoFEjw2H1SJxeJ43L6zmvTlu+hlK1jSAlidl7enS0ufTlzEEj4' \ - 'iJcuTPKdVzKRgZuTRVm9woWNVKqIrdRC0rJiTinERnfSAp/vNYERMuaoN4oJt8p/NEek' \ - 'rmFoDsQOsyDW5RAnCnjWUU+jFBKDpfkJQ1U2n6BjJewC9dl6ODK639l3yN4WOLZEk4tN' \ - 'UysfbGeF3rmMeflaD6O1Jplpv3YhwVGFNKa7fMq6k3Z0tszTJPYh', - ecdsa: - 'example.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' \ - 'bmlzdHAyNTYAAABBBKTJy43NZzJSfNxpv/e2E6Zy3qoHoTQbmOsU5FEfpWfWa1MdTeXQ' \ - 'YvKOi+qz/1AaNx6BK421jGu74JCDJtiZWT8=', - ed25519: - '@revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjq' \ - 'uxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf', - dss: - 'example.com ssh-dss AAAAB3NzaC1kc3MAAACBAP1/U4EddRIpUt9KnC7s5Of2EbdS' \ - 'PO9EAMMeP4C2USZpRV1AIlH7WT2NWPq/xfW6MPbLm1Vs14E7gB00b/JmYLdrmVClpJ+f' \ - '6AR7ECLCT7up1/63xhv4O1fnxqimFQ8E+4P208UewwI1VBNaFpEy9nXzrith1yrv8iID' \ - 'GZ3RSAHHAAAAFQCXYFCPFSMLzLKSuYKi64QL8Fgc9QAAAIEA9+GghdabPd7LvKtcNrhX' \ - 'uXmUr7v6OuqC+VdMCz0HgmdRWVeOutRZT+ZxBxCBgLRJFnEj6EwoFhO3zwkyjMim4TwW' \ - 'eotUfI0o4KOuHiuzpnWRbqN/C/ohNWLx+2J6ASQ7zKTxvqhRkImog9/hWuWfBpKLZl6A' \ - 'e1UlZAFMO/7PSSoAAACBAJcQ4JODqhuGbXIEpqxetm7PWbdbCcr3y/GzIZ066pRovpL6' \ - 'qm3qCVIym4cyChxWwb8qlyCIi+YRUUWm1z/wiBYT2Vf3S4FXBnyymCkKEaV/EY7+jd4X' \ - '1bXI58OD2u+bLCB/sInM4fGB8CZUIWT9nJH0Ve9jJUge2ms348/QOJ1+' - }.freeze - - MD5_FINGERPRINTS = { - rsa: '06:b2:8a:92:df:0e:11:2c:ca:7b:8f:a4:ba:6e:4b:fd', - ecdsa: '45:ff:5b:98:9a:b6:8a:41:13:c1:30:8b:09:5e:7b:4e', - ed25519: '2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16', - dss: '57:98:86:02:5f:9c:f4:9b:ad:5a:1e:51:92:0e:fd:2b' - }.freeze - - BIT_COUNTS = { - rsa: 2048, - ecdsa: 256, - ed25519: 256, - dss: 1024 - }.freeze - - describe '#type' do - KEYS.each do |type, key| - it "calculates the type of #{type} keys" do - calculated_type = described_class.new(key).type - - expect(calculated_type).to eq(type.to_s.upcase) - end - end - end - - describe '#fingerprint' do - KEYS.each do |type, key| - it "calculates the MD5 fingerprint for #{type} keys" do - fp = described_class.new(key).fingerprint - - expect(fp).to eq(MD5_FINGERPRINTS[type]) - end - end - end - - describe '#bits' do - KEYS.each do |type, key| - it "calculates the number of bits in #{type} keys" do - bits = described_class.new(key).bits - - expect(bits).to eq(BIT_COUNTS[type]) - end - end - end - - describe '#key' do - it 'carries the unmodified key data' do - key = described_class.new(KEYS[:rsa]).key - - expect(key).to eq(KEYS[:rsa]) - end - end -end diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb new file mode 100644 index 00000000000..d3314552d31 --- /dev/null +++ b/spec/lib/gitlab/ssh_public_key_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' + +describe Gitlab::SSHPublicKey, lib: true do + let(:key) { attributes_for(:rsa_key_2048)[:key] } + let(:public_key) { described_class.new(key) } + + describe '.technology_names' do + it 'returns the available technology names' do + expect(described_class.technology_names).to eq(%w[rsa dsa ecdsa ed25519]) + end + end + + describe '.allowed_sizes(name)' do + where(:name, :sizes) do + [ + ['rsa', [1024, 2048, 3072, 4096]], + ['dsa', [1024, 2048, 3072]], + ['ecdsa', [256, 384, 521]], + ['ed25519', [256]] + ] + end + + subject { described_class.allowed_sizes(name) } + + with_them do + it { is_expected.to eq(sizes) } + end + end + + describe '.allowed_type?' do + it 'determines the key type' do + expect(described_class.allowed_type?('foo')).to be(false) + end + end + + describe '#valid?' do + subject { public_key } + + context 'with a valid SSH key' do + it { is_expected.to be_valid } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.not_to be_valid } + end + end + + describe '#type' do + subject { public_key.type } + + where(:factory, :type) do + [ + [:rsa_key_2048, :rsa], + [:dsa_key_2048, :dsa], + [:ecdsa_key_256, :ecdsa], + [:ed25519_key_256, :ed25519] + ] + end + + with_them do + let(:key) { attributes_for(factory)[:key] } + + it { is_expected.to eq(type) } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.to be_nil } + end + end + + describe '#bits' do + subject { public_key.bits } + + where(:factory, :bits) do + [ + [:rsa_key_2048, 2048], + [:dsa_key_2048, 2048], + [:ecdsa_key_256, 256], + [:ed25519_key_256, 256] + ] + end + + with_them do + let(:key) { attributes_for(factory)[:key] } + + it { is_expected.to eq(bits) } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.to be_nil } + end + end + + describe '#fingerprint' do + subject { public_key.fingerprint } + + where(:factory, :fingerprint) do + [ + [:rsa_key_2048, '2e:ca:dc:e0:37:29:ed:fc:f0:1d:bf:66:d4:cd:51:b1'], + [:dsa_key_2048, 'bc:c1:a4:be:7e:8c:84:56:b3:58:93:53:c6:80:78:8c'], + [:ecdsa_key_256, '67:a3:a9:7d:b8:e1:15:d4:80:40:21:34:bb:ed:97:38'], + [:ed25519_key_256, 'e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73'] + ] + end + + with_them do + let(:key) { attributes_for(factory)[:key] } + + it { is_expected.to eq(fingerprint) } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.to be_nil } + end + end + + describe '#key_text' do + let(:key) { 'this is not a key' } + + it 'carries the unmodified key data' do + expect(public_key.key_text).to eq(key) + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 359753b600e..44d473db07d 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -72,6 +72,27 @@ describe ApplicationSetting do .is_greater_than(0) end + it { is_expected.to validate_presence_of(:minimum_rsa_bits) } + it { is_expected.to allow_value(*Gitlab::SSHPublicKey.allowed_sizes('rsa')).for(:minimum_rsa_bits) } + it { is_expected.not_to allow_value(128).for(:minimum_rsa_bits) } + + it { is_expected.to validate_presence_of(:minimum_dsa_bits) } + it { is_expected.to allow_value(*Gitlab::SSHPublicKey.allowed_sizes('dsa')).for(:minimum_dsa_bits) } + it { is_expected.not_to allow_value(128).for(:minimum_dsa_bits) } + + it { is_expected.to validate_presence_of(:minimum_ecdsa_bits) } + it { is_expected.to allow_value(*Gitlab::SSHPublicKey.allowed_sizes('ecdsa')).for(:minimum_ecdsa_bits) } + it { is_expected.not_to allow_value(128).for(:minimum_ecdsa_bits) } + + it { is_expected.to validate_presence_of(:minimum_ed25519_bits) } + it { is_expected.to allow_value(*Gitlab::SSHPublicKey.allowed_sizes('ed25519')).for(:minimum_ed25519_bits) } + it { is_expected.not_to allow_value(128).for(:minimum_ed25519_bits) } + + describe 'allowed_key_types validations' do + it { is_expected.to allow_value(Gitlab::SSHPublicKey.technology_names).for(:allowed_key_types) } + it { is_expected.not_to allow_value(['foo']).for(:allowed_key_types) } + end + it_behaves_like 'an object with email-formated attributes', :admin_notification_email do subject { setting } end @@ -441,4 +462,16 @@ describe ApplicationSetting do end end end + + context 'allowed key types attribute' do + it 'set value with array of symbols' do + setting.allowed_key_types = [:rsa] + expect(setting.allowed_key_types).to contain_exactly(:rsa) + end + + it 'get value as array of symbols' do + setting.allowed_key_types = ['rsa'] + expect(setting.allowed_key_types).to eq(['rsa']) + end + end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 3508391c721..83b11baa371 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -1,6 +1,13 @@ require 'spec_helper' describe Key, :mailer do + include Gitlab::CurrentSettings + + describe 'modules' do + subject { described_class } + it { is_expected.to include_module(Gitlab::CurrentSettings) } + end + describe "Associations" do it { is_expected.to belong_to(:user) } end @@ -11,8 +18,10 @@ describe Key, :mailer do it { is_expected.to validate_presence_of(:key) } it { is_expected.to validate_length_of(:key).is_at_most(5000) } - it { is_expected.to allow_value('ssh-foo').for(:key) } - it { is_expected.to allow_value('ecdsa-foo').for(:key) } + it { is_expected.to allow_value(attributes_for(:rsa_key_2048)[:key]).for(:key) } + it { is_expected.to allow_value(attributes_for(:dsa_key_2048)[:key]).for(:key) } + it { is_expected.to allow_value(attributes_for(:ecdsa_key_256)[:key]).for(:key) } + it { is_expected.to allow_value(attributes_for(:ed25519_key_256)[:key]).for(:key) } it { is_expected.not_to allow_value('foo-bar').for(:key) } end @@ -95,6 +104,78 @@ describe Key, :mailer do end end + context 'validate it meets minimum bit length' do + where(:factory, :minimum, :result) do + [ + [:rsa_key_2048, 1024, true], + [:rsa_key_2048, 2048, true], + [:rsa_key_2048, 4096, false], + [:dsa_key_2048, 1024, true], + [:dsa_key_2048, 2048, true], + [:dsa_key_2048, 4096, false], + [:ecdsa_key_256, 256, true], + [:ecdsa_key_256, 384, false], + [:ed25519_key_256, 256, true], + [:ed25519_key_256, 384, false] + ] + end + + with_them do + subject(:key) { build(factory) } + + before do + stub_application_setting("minimum_#{key.public_key.type}_bits" => minimum) + end + + it { expect(key.valid?).to eq(result) } + end + end + + context 'validate the key type is allowed' do + it 'accepts RSA, DSA, ECDSA and ED25519 keys by default' do + expect(build(:rsa_key_2048)).to be_valid + expect(build(:dsa_key_2048)).to be_valid + expect(build(:ecdsa_key_256)).to be_valid + expect(build(:ed25519_key_256)).to be_valid + end + + it 'rejects RSA, ECDSA and ED25519 keys if DSA is the only allowed type' do + stub_application_setting(allowed_key_types: ['dsa']) + + expect(build(:rsa_key_2048)).not_to be_valid + expect(build(:dsa_key_2048)).to be_valid + expect(build(:ecdsa_key_256)).not_to be_valid + expect(build(:ed25519_key_256)).not_to be_valid + end + + it 'rejects RSA, DSA and ED25519 keys if ECDSA is the only allowed type' do + stub_application_setting(allowed_key_types: ['ecdsa']) + + expect(build(:rsa_key_2048)).not_to be_valid + expect(build(:dsa_key_2048)).not_to be_valid + expect(build(:ecdsa_key_256)).to be_valid + expect(build(:ed25519_key_256)).not_to be_valid + end + + it 'rejects DSA, ECDSA and ED25519 keys if RSA is the only allowed type' do + stub_application_setting(allowed_key_types: ['rsa']) + + expect(build(:rsa_key_2048)).to be_valid + expect(build(:dsa_key_2048)).not_to be_valid + expect(build(:ecdsa_key_256)).not_to be_valid + expect(build(:ed25519_key_256)).not_to be_valid + end + + it 'rejects RSA, DSA and ECDSA keys if ED25519 is the only allowed type' do + stub_application_setting(allowed_key_types: ['ed25519']) + + expect(build(:rsa_key_2048)).not_to be_valid + expect(build(:dsa_key_2048)).not_to be_valid + expect(build(:ecdsa_key_256)).not_to be_valid + expect(build(:ed25519_key_256)).to be_valid + end + end + context 'callbacks' do it 'adds new key to authorized_file' do key = build(:personal_key, id: 7) diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 737c028ad53..60e7c2d0da3 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -19,6 +19,11 @@ describe API::Settings, 'Settings' do expect(json_response['default_project_visibility']).to be_a String expect(json_response['default_snippet_visibility']).to be_a String expect(json_response['default_group_visibility']).to be_a String + expect(json_response['minimum_rsa_bits']).to eq(1024) + expect(json_response['minimum_dsa_bits']).to eq(1024) + expect(json_response['minimum_ecdsa_bits']).to eq(256) + expect(json_response['minimum_ed25519_bits']).to eq(256) + expect(json_response['allowed_key_types']).to contain_exactly('rsa', 'dsa', 'ecdsa', 'ed25519') end end @@ -44,7 +49,12 @@ describe API::Settings, 'Settings' do help_page_text: 'custom help text', help_page_hide_commercial_content: true, help_page_support_url: 'http://example.com/help', - project_export_enabled: false + project_export_enabled: false, + minimum_rsa_bits: 2048, + minimum_dsa_bits: 2048, + minimum_ecdsa_bits: 384, + minimum_ed25519_bits: 256, + allowed_key_types: ['rsa'] expect(response).to have_http_status(200) expect(json_response['default_projects_limit']).to eq(3) @@ -61,6 +71,11 @@ describe API::Settings, 'Settings' do expect(json_response['help_page_hide_commercial_content']).to be_truthy expect(json_response['help_page_support_url']).to eq('http://example.com/help') expect(json_response['project_export_enabled']).to be_falsey + expect(json_response['minimum_rsa_bits']).to eq(2048) + expect(json_response['minimum_dsa_bits']).to eq(2048) + expect(json_response['minimum_ecdsa_bits']).to eq(384) + expect(json_response['minimum_ed25519_bits']).to eq(256) + expect(json_response['allowed_key_types']).to eq(['rsa']) end end