Commit 8d995986 authored by Nick Thomas's avatar Nick Thomas

Merge branch 'feat/ssh-sha256' into 'master'

add sha256 fingerprint to SSH key view and api to query user by ssh fingerprint

See merge request gitlab-org/gitlab!19860
parents 066e01de e712bbe8
# frozen_string_literal: true
class KeysFinder
InvalidFingerprint = Class.new(StandardError)
GitLabAccessDeniedError = Class.new(StandardError)
FINGERPRINT_ATTRIBUTES = {
'sha256' => 'fingerprint_sha256',
'md5' => 'fingerprint'
}.freeze
def initialize(current_user, params)
@current_user = current_user
@params = params
end
def execute
raise GitLabAccessDeniedError unless current_user.admin?
raise InvalidFingerprint unless valid_fingerprint_param?
Key.where(fingerprint_query).first # rubocop: disable CodeReuse/ActiveRecord
end
private
attr_reader :current_user, :params
def valid_fingerprint_param?
if fingerprint_type == "sha256"
Base64.decode64(fingerprint).length == 32
else
fingerprint =~ /^(\h{2}:){15}\h{2}/
end
end
def fingerprint_query
fingerprint_attribute = FINGERPRINT_ATTRIBUTES[fingerprint_type]
Key.arel_table[fingerprint_attribute].eq(fingerprint)
end
def fingerprint_type
if params[:fingerprint].start_with?(/sha256:|SHA256:/)
"sha256"
else
"md5"
end
end
def fingerprint
if fingerprint_type == "sha256"
params[:fingerprint].gsub(/sha256:|SHA256:/, "")
else
params[:fingerprint]
end
end
end
# frozen_string_literal: true
module Sha256Attribute
extend ActiveSupport::Concern
class_methods do
def sha256_attribute(name)
return if ENV['STATIC_VERIFICATION']
validate_binary_column_exists!(name) unless Rails.env.production?
attribute(name, Gitlab::Database::Sha256Attribute.new)
end
# This only gets executed in non-production environments as an additional check to ensure
# the column is the correct type. In production it should behave like any other attribute.
# See https://gitlab.com/gitlab-org/gitlab/merge_requests/5502 for more discussion
def validate_binary_column_exists!(name)
return unless database_exists?
unless table_exists?
warn "WARNING: sha256_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations"
return
end
column = columns.find { |c| c.name == name.to_s }
unless column
warn "WARNING: sha256_attribute #{name.inspect} is invalid since the column doesn't exist - you may need to run database migrations"
return
end
unless column.type == :binary
raise ArgumentError.new("sha256_attribute #{name.inspect} is invalid since the column type is not :binary")
end
rescue => error
Gitlab::AppLogger.error "Sha256Attribute initialization: #{error.message}"
raise
end
def database_exists?
ApplicationRecord.connection
true
rescue
false
end
end
end
......@@ -5,6 +5,9 @@ require 'digest/md5'
class Key < ApplicationRecord
include AfterCommitQueue
include Sortable
include Sha256Attribute
sha256_attribute :fingerprint_sha256
belongs_to :user
......@@ -34,6 +37,8 @@ class Key < ApplicationRecord
after_destroy :post_destroy_hook
after_destroy :refresh_user_cache
alias_attribute :fingerprint_md5, :fingerprint
def self.regular_keys
where(type: ['Key', nil])
end
......@@ -114,10 +119,12 @@ class Key < ApplicationRecord
def generate_fingerprint
self.fingerprint = nil
self.fingerprint_sha256 = nil
return unless public_key.valid?
self.fingerprint = public_key.fingerprint
self.fingerprint_md5 = public_key.fingerprint
self.fingerprint_sha256 = public_key.fingerprint("SHA256").gsub("SHA256:", "")
end
def key_meets_restrictions
......
......@@ -17,11 +17,21 @@
.col-md-8
= form_errors(@key, type: 'key') unless @key.valid?
%p
%span.light= _('Fingerprint:')
%code.key-fingerprint= @key.fingerprint
%pre.well-pre
= @key.key
.card
.card-header
= _('Fingerprints')
%ul.content-list
%li
%span.light= 'MD5:'
%code.key-fingerprint= @key.fingerprint
- if @key.fingerprint_sha256.present?
%li
%span.light= 'SHA256:'
%code.key-fingerprint= @key.fingerprint_sha256
.col-md-12
.float-right
- if @key.can_delete?
......
---
title: add sha256 fingerprint to keys model, view and extend users API to search user via fingerprint
merge_request: 19860
author: Roger Meier
type: added
# frozen_string_literal: true
class AddFingerprintSha256ToKey < ActiveRecord::Migration[5.0]
DOWNTIME = false
def up
add_column(:keys, :fingerprint_sha256, :binary)
end
def down
remove_column(:keys, :fingerprint_sha256) if column_exists?(:keys, :fingerprint_sha256)
end
end
# frozen_string_literal: true
class AddFingerprintSha256IndexToKey < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index(:keys, "fingerprint_sha256")
end
def down
remove_concurrent_index(:keys, "fingerprint_sha256")
end
end
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_12_06_122926) do
ActiveRecord::Schema.define(version: 2019_12_08_071112) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
......@@ -2206,7 +2206,9 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do
t.string "fingerprint"
t.boolean "public", default: false, null: false
t.datetime "last_used_at"
t.binary "fingerprint_sha256"
t.index ["fingerprint"], name: "index_keys_on_fingerprint", unique: true
t.index ["fingerprint_sha256"], name: "index_keys_on_fingerprint_sha256"
t.index ["id", "type"], name: "index_on_deploy_keys_id_and_type_and_public", unique: true, where: "(public = true)"
t.index ["user_id"], name: "index_keys_on_user_id"
end
......
......@@ -4,13 +4,19 @@
Get SSH key with user by ID of an SSH key. Note only administrators can lookup SSH key with user by ID of an SSH key.
```
```text
GET /keys/:id
```
Parameters:
| Attribute | Type | Required | Description |
|:----------|:--------|:---------|:---------------------|
| `id` | integer | yes | The ID of an SSH key |
Example request:
- `id` (required) - The ID of an SSH key
```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/keys/1
```
```json
{
......@@ -51,3 +57,74 @@ Parameters:
}
}
```
## Get user by fingerprint of SSH key
You can search for a user that owns a specific SSH key. Note only administrators can lookup SSH key with the fingerprint of an SSH key.
```text
GET /keys
```
| Attribute | Type | Required | Description |
|:--------------|:-------|:---------|:------------------------------|
| `fingerprint` | string | yes | The fingerprint of an SSH key |
Example request:
```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/keys?fingerprint=ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1'
```
If using sha256 fingerprint API calls, make sure that the fingerprint is URL-encoded.
For example, `/` is represented by `%2F` and `:` is represented by`%3A`:
```sh
curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/keys?fingerprint=SHA256%3AnUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo%2FlCg
```
Example response:
```json
{
"id": 1,
"title": "Sample key 1",
"key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
"created_at": "2019-11-14T15:11:13.222Z",
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "http://0.0.0.0:3000/root",
"created_at": "2019-11-14T15:09:34.831Z",
"bio": null,
"location": null,
"public_email": "",
"skype": "",
"linkedin": "",
"twitter": "",
"website_url": "",
"organization": null,
"last_sign_in_at": "2019-11-16T22:41:26.663Z",
"confirmed_at": "2019-11-14T15:09:34.575Z",
"last_activity_on": "2019-11-20",
"email": "admin@example.com",
"theme_id": 1,
"color_scheme_id": 1,
"projects_limit": 100000,
"current_sign_in_at": "2019-11-19T14:42:18.078Z",
"identities": [
],
"can_create_group": true,
"can_create_project": true,
"two_factor_enabled": false,
"external": false,
"private_profile": false,
"shared_runners_minutes_limit": null,
"extra_shared_runners_minutes_limit": null
}
}
```
......@@ -16,6 +16,23 @@ module API
present key, with: Entities::SSHKeyWithUser, current_user: current_user
end
desc 'Get SSH Key information' do
success Entities::UserWithAdmin
end
params do
requires :fingerprint, type: String, desc: 'Search for a SSH fingerprint'
end
get do
authenticated_with_full_private_access!
key = KeysFinder.new(current_user, params).execute
not_found!('Key') unless key
present key, with: Entities::SSHKeyWithUser, current_user: current_user
rescue KeysFinder::InvalidFingerprint
render_api_error!('Failed to return the key', 400)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Database
# Class for casting binary data to hexadecimal SHA256 hashes (and vice-versa).
#
# Using Sha256Attribute allows you to store SHA256 values as binary while still
# using them as if they were stored as string values. This gives you the
# ease of use of string values, but without the storage overhead.
class Sha256Attribute < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Bytea
# Casts binary data to a SHA256 and remove trailing = and newline from encode64
def deserialize(value)
value = super(value)
if value.present?
Base64.encode64(value).delete("=").chomp("\n")
else
nil
end
end
# Casts a SHA256 in a proper binary format. which is 32 bytes long
def serialize(value)
arg = if value.present?
Base64.decode64(value)
else
nil
end
super(arg)
end
end
end
end
......@@ -10,6 +10,7 @@ module Gitlab
#
class InsecureKeyFingerprint
attr_accessor :key
alias_attribute :fingerprint_md5, :fingerprint
#
# Gets the base64 encoded string representing a rsa or dsa key
......@@ -21,5 +22,9 @@ module Gitlab
def fingerprint
OpenSSL::Digest::MD5.hexdigest(Base64.decode64(@key)).scan(/../).join(':')
end
def fingerprint_sha256
Digest::SHA256.base64digest(Base64.decode64(@key)).scan(/../).join('').delete("=")
end
end
end
......@@ -7703,9 +7703,6 @@ msgstr ""
msgid "Fingerprint"
msgstr ""
msgid "Fingerprint:"
msgstr ""
msgid "Fingerprints"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe KeysFinder do
subject(:keys_finder) { described_class.new(user, params) }
let(:user) { create(:user) }
let(:fingerprint_type) { 'md5' }
let(:fingerprint) { 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' }
let(:params) do
{
type: fingerprint_type,
fingerprint: fingerprint
}
end
let!(:key) do
create(:key, user: user,
key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
fingerprint: 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1',
fingerprint_sha256: 'nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg'
)
end
context 'with a regular user' do
it 'raises GitLabAccessDeniedError' do
expect do
keys_finder.execute
end.to raise_error(KeysFinder::GitLabAccessDeniedError)
end
end
context 'with an admin user' do
let(:user) {create(:admin)}
context 'with invalid MD5 fingerprint' do
let(:fingerprint) { '11:11:11:11' }
it 'raises InvalidFingerprint' do
expect { keys_finder.execute }
.to raise_error(KeysFinder::InvalidFingerprint)
end
end
context 'with invalid SHA fingerprint' do
let(:fingerprint_type) { 'sha256' }
let(:fingerprint) { 'nUhzNyftwAAKs7HufskYTte2g' }
it 'raises InvalidFingerprint' do
expect { keys_finder.execute }
.to raise_error(KeysFinder::InvalidFingerprint)
end
end
context 'with valid MD5 params' do
it 'returns key if the fingerprint is found' do
result = keys_finder.execute
expect(result).to eq(key)
expect(key.user).to eq(user)
end
end
context 'with valid SHA256 params' do
let(:fingerprint) { 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' }
it 'returns key if the fingerprint is found' do
result = keys_finder.execute
expect(result).to eq(key)
expect(key.user).to eq(user)
end
end
end
end
......@@ -11,10 +11,17 @@ describe Gitlab::InsecureKeyFingerprint do
end
let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" }
let(:fingerprint_sha256) { "MQHWhS9nhzUezUdD42ytxubZoBKrZLbyBZzxCkmnxXc" }
describe "#fingerprint" do
it "generates the key's fingerprint" do
expect(described_class.new(key.split[1]).fingerprint).to eq(fingerprint)
expect(described_class.new(key.split[1]).fingerprint_md5).to eq(fingerprint)
end
end
describe "#fingerprint" do
it "generates the key's fingerprint" do
expect(described_class.new(key.split[1]).fingerprint_sha256).to eq(fingerprint_sha256)
end
end
end
......@@ -183,6 +183,34 @@ describe Gitlab::SSHPublicKey, lib: true do
end
end
describe '#fingerprint in SHA256 format' do
subject { public_key.fingerprint("SHA256").gsub("SHA256:", "") if public_key.fingerprint("SHA256") }
where(:factory, :fingerprint_sha256) do
[
[:rsa_key_2048, 'GdtgO0eHbwLB+mK47zblkoXujkqKRZjgMQrHH6Kks3E'],
[:rsa_key_4096, 'ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g'],
[:rsa_key_5120, 'PCCupLbFHScm4AbEufbGDvhBU27IM0MVAor715qKQK8'],
[:rsa_key_8192, 'CtHFQAS+9Hb8z4vrv4gVQPsHjNN0WIZhWODaB1mQLs4'],
[:dsa_key_2048, '+a3DQ7cU5GM+gaYOfmc0VWNnykHQSuth3VRcCpWuYNI'],
[:ecdsa_key_256, 'C+I5k3D+IGeM6k5iBR1ZsphqTKV+7uvL/XZ5hcrTr7g'],
[:ed25519_key_256, 'DCKAjzxWrdOTjaGKBBjtCW8qY5++GaiAJflrHPmp6W0']
]
end
with_them do
let(:key) { attributes_for(factory)[:key] }
it { is_expected.to eq(fingerprint_sha256) }
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' }
......
# frozen_string_literal: true
require 'spec_helper'
describe Sha256Attribute do
let(:model) { Class.new { include Sha256Attribute } }
before do
columns = [
double(:column, name: 'name', type: :text),
double(:column, name: 'sha256', type: :binary)
]
allow(model).to receive(:columns).and_return(columns)
end
describe '#sha_attribute' do
context 'when in non-production' do
before do
stub_rails_env('development')
end
context 'when the table exists' do
before do
allow(model).to receive(:table_exists?).and_return(true)
end
it 'defines a SHA attribute for a binary column' do
expect(model).to receive(:attribute)
.with(:sha256, an_instance_of(Gitlab::Database::Sha256Attribute))
model.sha256_attribute(:sha256)
end
it 'raises ArgumentError when the column type is not :binary' do
expect { model.sha256_attribute(:name) }.to raise_error(ArgumentError)
end
end
context 'when the table does not exist' do
it 'allows the attribute to be added and issues a warning' do
allow(model).to receive(:table_exists?).and_return(false)
expect(model).not_to receive(:columns)
expect(model).to receive(:attribute)
expect(model).to receive(:warn)
model.sha256_attribute(:name)
end
end
context 'when the column does not exist' do
it 'allows the attribute to be added and issues a warning' do
allow(model).to receive(:table_exists?).and_return(true)
expect(model).to receive(:columns)
expect(model).to receive(:attribute)
expect(model).to receive(:warn)
model.sha256_attribute(:no_name)
end
end
context 'when other execeptions are raised' do
it 'logs and re-rasises the error' do
allow(model).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError.new('does not exist'))
expect(model).not_to receive(:columns)
expect(model).not_to receive(:attribute)
expect(Gitlab::AppLogger).to receive(:error)
expect { model.sha256_attribute(:name) }.to raise_error(ActiveRecord::NoDatabaseError)
end
end
end
context 'when in production' do
before do
stub_rails_env('production')
end
it 'defines a SHA attribute' do
expect(model).not_to receive(:table_exists?)
expect(model).not_to receive(:columns)
expect(model).to receive(:attribute).with(:sha256, an_instance_of(Gitlab::Database::Sha256Attribute))
model.sha256_attribute(:sha256)
end
end
end
end
......@@ -92,6 +92,7 @@ describe Key, :mailer do
with_them do
let!(:key) { create(factory) }
let!(:original_fingerprint) { key.fingerprint }
let!(:original_fingerprint_sha256) { key.fingerprint_sha256 }
it 'accepts a key with blank space characters after stripping them' do
modified_key = key.key.insert(100, chars.first).insert(40, chars.last)
......@@ -104,6 +105,8 @@ describe Key, :mailer do
expect(content).not_to match(/\s/)
expect(original_fingerprint).to eq(key.fingerprint)
expect(original_fingerprint).to eq(key.fingerprint_md5)
expect(original_fingerprint_sha256).to eq(key.fingerprint_sha256)
end
end
end
......
......@@ -25,7 +25,6 @@ describe API::Keys do
it 'returns single ssh key with user information' do
user.keys << key
user.save
get api("/keys/#{key.id}", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq(key.title)
......@@ -40,4 +39,73 @@ describe API::Keys do
end
end
end
describe 'GET /keys?fingerprint=' do
it 'returns authentication error' do
get api("/keys?fingerprint=#{key.fingerprint}")
expect(response).to have_gitlab_http_status(401)
end
it 'returns authentication error when authenticated as user' do
get api("/keys?fingerprint=#{key.fingerprint}", user)
expect(response).to have_gitlab_http_status(403)
end
context 'when authenticated as admin' do
it 'returns 404 for non-existing SSH md5 fingerprint' do
get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin)
expect(response).to have_gitlab_http_status(404)
expect(json_response['message']).to eq('404 Key Not Found')
end
it 'returns 404 for non-existing SSH sha256 fingerprint' do
get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo1lCg")}", admin)
expect(response).to have_gitlab_http_status(404)
expect(json_response['message']).to eq('404 Key Not Found')
end
it 'returns user if SSH md5 fingerprint found' do
user.keys << key
get api("/keys?fingerprint=#{key.fingerprint}", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq(key.title)
expect(json_response['user']['id']).to eq(user.id)
expect(json_response['user']['username']).to eq(user.username)
end
it 'returns user if SSH sha256 fingerprint found' do
user.keys << key
get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + key.fingerprint_sha256)}", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq(key.title)
expect(json_response['user']['id']).to eq(user.id)
expect(json_response['user']['username']).to eq(user.username)
end
it 'returns user if SSH sha256 fingerprint found' do
user.keys << key
get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq(key.title)
expect(json_response['user']['id']).to eq(user.id)
expect(json_response['user']['username']).to eq(user.username)
end
it "does not include the user's `is_admin` flag" do
get api("/keys?fingerprint=#{key.fingerprint}", admin)
expect(json_response['user']['is_admin']).to be_nil
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment