From 9c4cbdbafb0324ae259e10865b90ed1ed0255bdd Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 18 Mar 2019 21:00:55 +0100 Subject: [PATCH] Add Keybase integration (#10297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create account_identity_proofs table * add endpoint for keybase to check local proofs * add async task to update validity and liveness of proofs from keybase * first pass keybase proof CRUD * second pass keybase proof creation * clean up proof list and add badges * add avatar url to keybase api * Always highlight the “Identity Proofs” navigation item when interacting with proofs. * Update translations. * Add profile URL. * Reorder proofs. * Add proofs to bio. * Update settings/identity_proofs front-end. * Use `link_to`. * Only encode query params if they exist. URLs without params had a trailing `?`. * Only show live proofs. * change valid to active in proof list and update liveness before displaying * minor fixes * add keybase config at well-known path * extremely naive feature flagging off the identity proof UI * fixes for rubocop * make identity proofs page resilient to potential keybase issues * normalize i18n * tweaks for brakeman * remove two unused translations * cleanup and add more localizations * make keybase_contacts an admin setting * fix ExternalProofService my_domain * use Addressable::URI in identity proofs * use active model serializer for keybase proof config * more cleanup of keybase proof config * rename proof is_valid and is_live to proof_valid and proof_live * cleanup * assorted tweaks for more robust communication with keybase * Clean up * Small fixes * Display verified identity identically to verified links * Clean up unused CSS * Add caching for Keybase avatar URLs * Remove keybase_contacts setting --- app/controllers/api/proofs_controller.rb | 30 +++++ .../settings/identity_proofs_controller.rb | 45 +++++++ .../keybase_proof_config_controller.rb | 9 ++ .../images/logo_transparent_black.svg | 1 + .../images/proof_providers/keybase.png | Bin 0 -> 12665 bytes app/javascript/styles/mastodon/forms.scss | 55 +++++++++ app/lib/proof_provider.rb | 12 ++ app/lib/proof_provider/keybase.rb | 59 +++++++++ app/lib/proof_provider/keybase/badge.rb | 48 ++++++++ .../keybase/config_serializer.rb | 70 +++++++++++ app/lib/proof_provider/keybase/serializer.rb | 25 ++++ app/lib/proof_provider/keybase/verifier.rb | 62 ++++++++++ app/lib/proof_provider/keybase/worker.rb | 33 ++++++ app/models/account_identity_proof.rb | 46 +++++++ app/models/concerns/account_associations.rb | 3 + app/views/accounts/_bio.html.haml | 15 ++- .../settings/identity_proofs/_proof.html.haml | 20 ++++ .../settings/identity_proofs/index.html.haml | 17 +++ .../settings/identity_proofs/new.html.haml | 31 +++++ config/locales/en.yml | 16 +++ config/navigation.rb | 1 + config/routes.rb | 7 ++ ...16190352_create_account_identity_proofs.rb | 16 +++ db/schema.rb | 14 +++ .../controllers/api/proofs_controller_spec.rb | 96 +++++++++++++++ .../identity_proofs_controller_spec.rb | 112 ++++++++++++++++++ .../keybase_proof_config_controller_spec.rb | 15 +++ .../account_identity_proof_fabricator.rb | 8 ++ .../proof_provider/keybase/verifier_spec.rb | 82 +++++++++++++ 29 files changed, 946 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/proofs_controller.rb create mode 100644 app/controllers/settings/identity_proofs_controller.rb create mode 100644 app/controllers/well_known/keybase_proof_config_controller.rb create mode 100644 app/javascript/images/logo_transparent_black.svg create mode 100644 app/javascript/images/proof_providers/keybase.png create mode 100644 app/lib/proof_provider.rb create mode 100644 app/lib/proof_provider/keybase.rb create mode 100644 app/lib/proof_provider/keybase/badge.rb create mode 100644 app/lib/proof_provider/keybase/config_serializer.rb create mode 100644 app/lib/proof_provider/keybase/serializer.rb create mode 100644 app/lib/proof_provider/keybase/verifier.rb create mode 100644 app/lib/proof_provider/keybase/worker.rb create mode 100644 app/models/account_identity_proof.rb create mode 100644 app/views/settings/identity_proofs/_proof.html.haml create mode 100644 app/views/settings/identity_proofs/index.html.haml create mode 100644 app/views/settings/identity_proofs/new.html.haml create mode 100644 db/migrate/20190316190352_create_account_identity_proofs.rb create mode 100644 spec/controllers/api/proofs_controller_spec.rb create mode 100644 spec/controllers/settings/identity_proofs_controller_spec.rb create mode 100644 spec/controllers/well_known/keybase_proof_config_controller_spec.rb create mode 100644 spec/fabricators/account_identity_proof_fabricator.rb create mode 100644 spec/lib/proof_provider/keybase/verifier_spec.rb diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb new file mode 100644 index 000000000..a84ad2014 --- /dev/null +++ b/app/controllers/api/proofs_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::ProofsController < Api::BaseController + before_action :set_account + before_action :set_provider + before_action :check_account_approval + before_action :check_account_suspension + + def index + render json: @account, serializer: @provider.serializer_class + end + + private + + def set_provider + @provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound) + end + + def set_account + @account = Account.find_local!(params[:username]) + end + + def check_account_approval + not_found if @account.user_pending? + end + + def check_account_suspension + gone if @account.suspended? + end +end diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb new file mode 100644 index 000000000..4a3b89a5e --- /dev/null +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Settings::IdentityProofsController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :check_required_params, only: :new + + def index + @proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc) + @proofs.each(&:refresh!) + end + + def new + @proof = current_account.identity_proofs.new( + token: params[:token], + provider: params[:provider], + provider_username: params[:provider_username] + ) + + render layout: 'auth' + end + + def create + @proof = current_account.identity_proofs.where(provider: resource_params[:provider], provider_username: resource_params[:provider_username]).first_or_initialize(resource_params) + @proof.token = resource_params[:token] + + if @proof.save + redirect_to @proof.on_success_path(params[:user_agent]) + else + flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize) + redirect_to settings_identity_proofs_path + end + end + + private + + def check_required_params + redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :token].all? { |k| params[k].present? } + end + + def resource_params + params.require(:account_identity_proof).permit(:provider, :provider_username, :token) + end +end diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb new file mode 100644 index 000000000..eb41e586f --- /dev/null +++ b/app/controllers/well_known/keybase_proof_config_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WellKnown + class KeybaseProofConfigController < ActionController::Base + def show + render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer + end + end +end diff --git a/app/javascript/images/logo_transparent_black.svg b/app/javascript/images/logo_transparent_black.svg new file mode 100644 index 000000000..e44bcf5e1 --- /dev/null +++ b/app/javascript/images/logo_transparent_black.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/images/proof_providers/keybase.png b/app/javascript/images/proof_providers/keybase.png new file mode 100644 index 0000000000000000000000000000000000000000..7e3ac657f419d3dab558f4454f85221b0932dcc5 GIT binary patch literal 12665 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 S`TmyzL`FhEyjs*S=>GvLj@fen literal 0 HcmV?d00001 diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 6051c1d00..9ef45e425 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -801,3 +801,58 @@ code { } } } + +.connection-prompt { + margin-bottom: 25px; + + .fa-link { + background-color: darken($ui-base-color, 4%); + border-radius: 100%; + font-size: 24px; + padding: 10px; + } + + &__column { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + flex-shrink: 1; + + &-sep { + flex-grow: 0; + overflow: visible; + position: relative; + z-index: 1; + } + } + + .account__avatar { + margin-bottom: 20px; + } + + &__connection { + background-color: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + padding: 25px 10px; + position: relative; + text-align: center; + + &::after { + background-color: darken($ui-base-color, 4%); + content: ''; + display: block; + height: 100%; + left: 50%; + position: absolute; + width: 1px; + } + } + + &__row { + align-items: center; + display: flex; + flex-direction: row; + } +} diff --git a/app/lib/proof_provider.rb b/app/lib/proof_provider.rb new file mode 100644 index 000000000..102c50f4f --- /dev/null +++ b/app/lib/proof_provider.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ProofProvider + SUPPORTED_PROVIDERS = %w(keybase).freeze + + def self.find(identifier, proof = nil) + case identifier + when 'keybase' + ProofProvider::Keybase.new(proof) + end + end +end diff --git a/app/lib/proof_provider/keybase.rb b/app/lib/proof_provider/keybase.rb new file mode 100644 index 000000000..96322a265 --- /dev/null +++ b/app/lib/proof_provider/keybase.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase + BASE_URL = 'https://keybase.io' + + class Error < StandardError; end + + class ExpectedProofLiveError < Error; end + + class UnexpectedResponseError < Error; end + + def initialize(proof = nil) + @proof = proof + end + + def serializer_class + ProofProvider::Keybase::Serializer + end + + def worker_class + ProofProvider::Keybase::Worker + end + + def validate! + unless @proof.token&.size == 66 + @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.invalid_token')) + return + end + + return if @proof.provider_username.blank? + + if verifier.valid? + @proof.verified = true + @proof.live = false + else + @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.verification_failed', kb_username: @proof.provider_username)) + end + end + + def refresh! + worker_class.new.perform(@proof) + rescue ProofProvider::Keybase::Error + nil + end + + def on_success_path(user_agent = nil) + verifier.on_success_path(user_agent) + end + + def badge + @badge ||= ProofProvider::Keybase::Badge.new(@proof.account.username, @proof.provider_username, @proof.token) + end + + private + + def verifier + @verifier ||= ProofProvider::Keybase::Verifier.new(@proof.account.username, @proof.provider_username, @proof.token) + end +end diff --git a/app/lib/proof_provider/keybase/badge.rb b/app/lib/proof_provider/keybase/badge.rb new file mode 100644 index 000000000..3aa067ecf --- /dev/null +++ b/app/lib/proof_provider/keybase/badge.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Badge + include RoutingHelper + + def initialize(local_username, provider_username, token) + @local_username = local_username + @provider_username = provider_username + @token = token + end + + def proof_url + "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/sigchain\##{@token}" + end + + def profile_url + "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}" + end + + def icon_url + "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/proof_badge/#{@token}?username=#{@local_username}&domain=#{domain}" + end + + def avatar_url + Rails.cache.fetch("proof_providers/keybase/#{@provider_username}/avatar_url", expires_in: 5.minutes) { remote_avatar_url } || default_avatar_url + end + + private + + def remote_avatar_url + request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/user/pic_url.json", params: { username: @provider_username }) + + request.perform do |res| + json = Oj.load(res.body_with_limit, mode: :strict) + json['pic_url'] if json.is_a?(Hash) + end + rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError + nil + end + + def default_avatar_url + asset_pack_path('media/images/proof_providers/keybase.png') + end + + def domain + Rails.configuration.x.local_domain + end +end diff --git a/app/lib/proof_provider/keybase/config_serializer.rb b/app/lib/proof_provider/keybase/config_serializer.rb new file mode 100644 index 000000000..474ea74e2 --- /dev/null +++ b/app/lib/proof_provider/keybase/config_serializer.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :version, :domain, :display_name, :username, + :brand_color, :logo, :description, :prefill_url, + :profile_url, :check_url, :check_path, :avatar_path, + :contact + + def version + 1 + end + + def domain + Rails.configuration.x.local_domain + end + + def display_name + Setting.site_title + end + + def logo + { svg_black: full_asset_url(asset_pack_path('media/images/logo_transparent_black.svg')), svg_full: full_asset_url(asset_pack_path('media/images/logo.svg')) } + end + + def brand_color + '#282c37' + end + + def description + Setting.site_short_description.presence || Setting.site_description.presence || I18n.t('about.about_mastodon_html') + end + + def username + { min: 1, max: 30, re: Account::USERNAME_RE.inspect } + end + + def prefill_url + params = { + provider: 'keybase', + token: '%{sig_hash}', + provider_username: '%{kb_username}', + username: '%{username}', + user_agent: '%{kb_ua}', + } + + CGI.unescape(new_settings_identity_proof_url(params)) + end + + def profile_url + CGI.unescape(short_account_url('%{username}')) # rubocop:disable Style/FormatStringToken + end + + def check_url + CGI.unescape(api_proofs_url(username: '%{username}', provider: 'keybase')) + end + + def check_path + ['signatures'] + end + + def avatar_path + ['avatar'] + end + + def contact + [Setting.site_contact_email.presence].compact + end +end diff --git a/app/lib/proof_provider/keybase/serializer.rb b/app/lib/proof_provider/keybase/serializer.rb new file mode 100644 index 000000000..d29283600 --- /dev/null +++ b/app/lib/proof_provider/keybase/serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Serializer < ActiveModel::Serializer + include RoutingHelper + + attribute :avatar + + has_many :identity_proofs, key: :signatures + + def avatar + full_asset_url(object.avatar_original_url) + end + + class AccountIdentityProofSerializer < ActiveModel::Serializer + attributes :sig_hash, :kb_username + + def sig_hash + object.token + end + + def kb_username + object.provider_username + end + end +end diff --git a/app/lib/proof_provider/keybase/verifier.rb b/app/lib/proof_provider/keybase/verifier.rb new file mode 100644 index 000000000..86f249dd7 --- /dev/null +++ b/app/lib/proof_provider/keybase/verifier.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Verifier + def initialize(local_username, provider_username, token) + @local_username = local_username + @provider_username = provider_username + @token = token + end + + def valid? + request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_valid.json", params: query_params) + + request.perform do |res| + json = Oj.load(res.body_with_limit, mode: :strict) + + if json.is_a?(Hash) + json.fetch('proof_valid', false) + else + false + end + end + rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError + false + end + + def on_success_path(user_agent = nil) + url = Addressable::URI.parse("#{ProofProvider::Keybase::BASE_URL}/_/proof_creation_success") + url.query_values = query_params.merge(kb_ua: user_agent || 'unknown') + url.to_s + end + + def status + request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_live.json", params: query_params) + + request.perform do |res| + raise ProofProvider::Keybase::UnexpectedResponseError unless res.code == 200 + + json = Oj.load(res.body_with_limit, mode: :strict) + + raise ProofProvider::Keybase::UnexpectedResponseError unless json.is_a?(Hash) && json.key?('proof_valid') && json.key?('proof_live') + + json + end + rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError + raise ProofProvider::Keybase::UnexpectedResponseError + end + + private + + def query_params + { + domain: domain, + kb_username: @provider_username, + username: @local_username, + sig_hash: @token, + } + end + + def domain + Rails.configuration.x.local_domain + end +end diff --git a/app/lib/proof_provider/keybase/worker.rb b/app/lib/proof_provider/keybase/worker.rb new file mode 100644 index 000000000..2872f59c1 --- /dev/null +++ b/app/lib/proof_provider/keybase/worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Worker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 20, unique: :until_executed + + sidekiq_retry_in do |count, exception| + # Retry aggressively when the proof is valid but not live in Keybase. + # This is likely because Keybase just hasn't noticed the proof being + # served from here yet. + + if exception.class == ProofProvider::Keybase::ExpectedProofLiveError + case count + when 0..2 then 0.seconds + when 2..6 then 1.second + end + end + end + + def perform(proof_id) + proof = proof_id.is_a?(AccountIdentityProof) ? proof_id : AccountIdentityProof.find(proof_id) + verifier = ProofProvider::Keybase::Verifier.new(proof.account.username, proof.provider_username, proof.token) + status = verifier.status + + # If Keybase thinks the proof is valid, and it exists here in Mastodon, + # then it should be live. Keybase just has to notice that it's here + # and then update its state. That might take a couple seconds. + raise ProofProvider::Keybase::ExpectedProofLiveError if status['proof_valid'] && !status['proof_live'] + + proof.update!(verified: status['proof_valid'], live: status['proof_live']) + end +end diff --git a/app/models/account_identity_proof.rb b/app/models/account_identity_proof.rb new file mode 100644 index 000000000..e7a3f97e5 --- /dev/null +++ b/app/models/account_identity_proof.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_identity_proofs +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# provider :string default(""), not null +# provider_username :string default(""), not null +# token :text default(""), not null +# verified :boolean default(FALSE), not null +# live :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class AccountIdentityProof < ApplicationRecord + belongs_to :account + + validates :provider, inclusion: { in: ProofProvider::SUPPORTED_PROVIDERS } + validates :provider_username, format: { with: /\A[a-z0-9_]+\z/i }, length: { minimum: 2, maximum: 15 } + validates :provider_username, uniqueness: { scope: [:account_id, :provider] } + validates :token, format: { with: /\A[a-f0-9]+\z/ }, length: { maximum: 66 } + + validate :validate_with_provider, if: :token_changed? + + scope :active, -> { where(verified: true, live: true) } + + after_create_commit :queue_worker + + delegate :refresh!, :on_success_path, :badge, to: :provider_instance + + private + + def provider_instance + @provider_instance ||= ProofProvider.find(provider, self) + end + + def queue_worker + provider_instance.worker_class.perform_async(id) + end + + def validate_with_provider + provider_instance.validate! + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index a8ba8fef1..70855e054 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -7,6 +7,9 @@ module AccountAssociations # Local users has_one :user, inverse_of: :account, dependent: :destroy + # Identity proofs + has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account + # Timelines has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :statuses, inverse_of: :account, dependent: :destroy diff --git a/app/views/accounts/_bio.html.haml b/app/views/accounts/_bio.html.haml index 2ea34a048..efc26d136 100644 --- a/app/views/accounts/_bio.html.haml +++ b/app/views/accounts/_bio.html.haml @@ -1,7 +1,17 @@ +- proofs = account.identity_proofs.active +- fields = account.fields + .public-account-bio - - unless account.fields.empty? + - unless fields.empty? && proofs.empty? .account__header__fields - - account.fields.each do |field| + - proofs.each do |proof| + %dl + %dt= proof.provider.capitalize + %dd.verified + = link_to fa_icon('check'), proof.badge.proof_url, class: 'verified__mark', title: t('accounts.link_verified_on', date: l(proof.updated_at)) + = link_to proof.provider_username, proof.badge.profile_url + + - fields.each do |field| %dl %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true) %dd{ title: field.value, class: custom_field_classes(field) } @@ -9,6 +19,7 @@ %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) } = fa_icon 'check' = Formatter.instance.format_field(account, field.value, custom_emojify: true) + = account_badge(account) - if account.note.present? diff --git a/app/views/settings/identity_proofs/_proof.html.haml b/app/views/settings/identity_proofs/_proof.html.haml new file mode 100644 index 000000000..524827ad7 --- /dev/null +++ b/app/views/settings/identity_proofs/_proof.html.haml @@ -0,0 +1,20 @@ +%tr + %td + = link_to proof.badge.profile_url, class: 'name-tag' do + = image_tag proof.badge.avatar_url, width: 15, height: 15, alt: '', class: 'avatar' + %span.username + = proof.provider_username + %span= "(#{proof.provider.capitalize})" + + %td + - if proof.live? + %span.positive-hint + = fa_icon 'check-circle fw' + = t('identity_proofs.active') + - else + %span.negative-hint + = fa_icon 'times-circle fw' + = t('identity_proofs.inactive') + + %td + = table_link_to 'external-link', t('identity_proofs.view_proof'), proof.badge.proof_url if proof.badge.proof_url diff --git a/app/views/settings/identity_proofs/index.html.haml b/app/views/settings/identity_proofs/index.html.haml new file mode 100644 index 000000000..d0ea03ecd --- /dev/null +++ b/app/views/settings/identity_proofs/index.html.haml @@ -0,0 +1,17 @@ +- content_for :page_title do + = t('settings.identity_proofs') + +%p= t('identity_proofs.explanation_html') + +- unless @proofs.empty? + %hr.spacer/ + + .table-wrapper + %table.table + %thead + %tr + %th= t('identity_proofs.identity') + %th= t('identity_proofs.status') + %th + %tbody + = render partial: 'settings/identity_proofs/proof', collection: @proofs, as: :proof diff --git a/app/views/settings/identity_proofs/new.html.haml b/app/views/settings/identity_proofs/new.html.haml new file mode 100644 index 000000000..8ce6e61c9 --- /dev/null +++ b/app/views/settings/identity_proofs/new.html.haml @@ -0,0 +1,31 @@ +- content_for :page_title do + = t('identity_proofs.authorize_connection_prompt') + +.form-container + .oauth-prompt + %h2= t('identity_proofs.authorize_connection_prompt') + + = simple_form_for @proof, url: settings_identity_proofs_url, html: { method: :post } do |f| + = f.input :provider, as: :hidden + = f.input :provider_username, as: :hidden + = f.input :token, as: :hidden + + = hidden_field_tag :user_agent, params[:user_agent] + + .connection-prompt + .connection-prompt__row.connection-prompt__connection + .connection-prompt__column + = image_tag current_account.avatar.url(:original), size: 96, class: 'account__avatar' + + %p= t('identity_proofs.i_am_html', username: content_tag(:strong,current_account.username), service: site_hostname) + + .connection-prompt__column.connection-prompt__column-sep + = fa_icon 'link' + + .connection-prompt__column + = image_tag @proof.badge.avatar_url, size: 96, class: 'account__avatar' + + %p= t('identity_proofs.i_am_html', username: content_tag(:strong, @proof.provider_username), service: @proof.provider.capitalize) + + = f.button :button, t('identity_proofs.authorize'), type: :submit + = link_to t('simple_form.no'), settings_identity_proofs_url, class: 'button negative' diff --git a/config/locales/en.yml b/config/locales/en.yml index 9f1081fb8..ba42e7ce1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -633,6 +633,21 @@ en: validation_errors: one: Something isn't quite right yet! Please review the error below other: Something isn't quite right yet! Please review %{count} errors below + identity_proofs: + active: Active + authorize: Yes, authorize + authorize_connection_prompt: Authorize this cryptographic connection? + errors: + failed: The cryptographic connection failed. Please try again from %{provider}. + keybase: + invalid_token: Keybase tokens are hashes of signatures and must be 66 hex characters + verification_failed: Keybase does not recognize this token as a signature of Keybase user %{kb_username}. Please retry from Keybase. + explanation_html: Here you can cryptographically connect your other identities, such as a Keybase profile. This lets other people send you encrypted messages and trust content you send them. + i_am_html: I am %{username} on %{service}. + identity: Identity + inactive: Inactive + status: Verification status + view_proof: View proof imports: modes: merge: Merge @@ -835,6 +850,7 @@ en: edit_profile: Edit profile export: Data export featured_tags: Featured hashtags + identity_proofs: Identity proofs import: Import migrate: Account migration notifications: Notifications diff --git a/config/navigation.rb b/config/navigation.rb index 77a300bbf..07aec4b9d 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -14,6 +14,7 @@ SimpleNavigation::Configuration.run do |navigation| settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url settings.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url + settings.item :identity_proofs, safe_join([fa_icon('key fw'), t('settings.identity_proofs')]), settings_identity_proofs_path, highlights_on: %r{/settings/identity_proofs*} end primary.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url diff --git a/config/routes.rb b/config/routes.rb index dc5633a68..194b4c09b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,8 @@ Rails.application.routes.draw do get '.well-known/host-meta', to: 'well_known/host_meta#show', as: :host_meta, defaults: { format: 'xml' } get '.well-known/webfinger', to: 'well_known/webfinger#show', as: :webfinger get '.well-known/change-password', to: redirect('/auth/edit') + get '.well-known/keybase-proof-config', to: 'well_known/keybase_proof_config#show' + get 'manifest', to: 'manifests#show', defaults: { format: 'json' } get 'intent', to: 'intents#show' get 'custom.css', to: 'custom_css#show', as: :custom_css @@ -106,6 +108,8 @@ Rails.application.routes.draw do resource :confirmation, only: [:new, :create] end + resources :identity_proofs, only: [:index, :show, :new, :create, :update] + resources :applications, except: [:edit] do member do post :regenerate @@ -248,6 +252,9 @@ Rails.application.routes.draw do # OEmbed get '/oembed', to: 'oembed#show', as: :oembed + # Identity proofs + get :proofs, to: 'proofs#index' + # JSON / REST API namespace :v1 do resources :statuses, only: [:create, :show, :destroy] do diff --git a/db/migrate/20190316190352_create_account_identity_proofs.rb b/db/migrate/20190316190352_create_account_identity_proofs.rb new file mode 100644 index 000000000..ddcbce3f3 --- /dev/null +++ b/db/migrate/20190316190352_create_account_identity_proofs.rb @@ -0,0 +1,16 @@ +class CreateAccountIdentityProofs < ActiveRecord::Migration[5.2] + def change + create_table :account_identity_proofs do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.string :provider, null: false, default: '' + t.string :provider_username, null: false, default: '' + t.text :token, null: false, default: '' + t.boolean :verified, null: false, default: false + t.boolean :live, null: false, default: false + + t.timestamps null: false + end + + add_index :account_identity_proofs, [:account_id, :provider, :provider_username], unique: true, name: :index_account_proofs_on_account_and_provider_and_username + end +end diff --git a/db/schema.rb b/db/schema.rb index 790e347c3..11535d867 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -36,6 +36,19 @@ ActiveRecord::Schema.define(version: 2019_03_17_135723) do t.index ["account_id", "domain"], name: "index_account_domain_blocks_on_account_id_and_domain", unique: true end + create_table "account_identity_proofs", force: :cascade do |t| + t.bigint "account_id" + t.string "provider", default: "", null: false + t.string "provider_username", default: "", null: false + t.text "token", default: "", null: false + t.boolean "verified", default: false, null: false + t.boolean "live", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "provider", "provider_username"], name: "index_account_proofs_on_account_and_provider_and_username", unique: true + t.index ["account_id"], name: "index_account_identity_proofs_on_account_id" + end + create_table "account_moderation_notes", force: :cascade do |t| t.text "content", null: false t.bigint "account_id", null: false @@ -732,6 +745,7 @@ ActiveRecord::Schema.define(version: 2019_03_17_135723) do add_foreign_key "account_conversations", "accounts", on_delete: :cascade add_foreign_key "account_conversations", "conversations", on_delete: :cascade add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade + add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade add_foreign_key "account_moderation_notes", "accounts" add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade diff --git a/spec/controllers/api/proofs_controller_spec.rb b/spec/controllers/api/proofs_controller_spec.rb new file mode 100644 index 000000000..dbde4927f --- /dev/null +++ b/spec/controllers/api/proofs_controller_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +describe Api::ProofsController do + let(:alice) { Fabricate(:account, username: 'alice') } + + before do + stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_valid.json?domain=cb6e6126.ngrok.io&kb_username=crypto_alice&sig_hash=111111111111111111111111111111111111111111111111111111111111111111&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":false}') + stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_live.json?domain=cb6e6126.ngrok.io&kb_username=crypto_alice&sig_hash=111111111111111111111111111111111111111111111111111111111111111111&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}') + stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_valid.json?domain=cb6e6126.ngrok.io&kb_username=hidden_alice&sig_hash=222222222222222222222222222222222222222222222222222222222222222222&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}') + stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_live.json?domain=cb6e6126.ngrok.io&kb_username=hidden_alice&sig_hash=222222222222222222222222222222222222222222222222222222222222222222&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}') + end + + describe 'GET #index' do + describe 'with a non-existent username' do + it '404s' do + get :index, params: { username: 'nonexistent', provider: 'keybase' } + + expect(response).to have_http_status(:not_found) + end + end + + describe 'with a user that has no proofs' do + it 'is an empty list of signatures' do + get :index, params: { username: alice.username, provider: 'keybase' } + + expect(body_as_json[:signatures]).to eq [] + end + end + + describe 'with a user that has a live, valid proof' do + let(:token1) { '111111111111111111111111111111111111111111111111111111111111111111' } + let(:kb_name1) { 'crypto_alice' } + + before do + Fabricate(:account_identity_proof, account: alice, verified: true, live: true, token: token1, provider_username: kb_name1) + end + + it 'is a list with that proof in it' do + get :index, params: { username: alice.username, provider: 'keybase' } + + expect(body_as_json[:signatures]).to eq [ + { kb_username: kb_name1, sig_hash: token1 }, + ] + end + + describe 'add one that is neither live nor valid' do + let(:token2) { '222222222222222222222222222222222222222222222222222222222222222222' } + let(:kb_name2) { 'hidden_alice' } + + before do + Fabricate(:account_identity_proof, account: alice, verified: false, live: false, token: token2, provider_username: kb_name2) + end + + it 'is a list with both proofs' do + get :index, params: { username: alice.username, provider: 'keybase' } + + expect(body_as_json[:signatures]).to eq [ + { kb_username: kb_name1, sig_hash: token1 }, + { kb_username: kb_name2, sig_hash: token2 }, + ] + end + end + end + + describe 'a user that has an avatar' do + let(:alice) { Fabricate(:account, username: 'alice', avatar: attachment_fixture('avatar.gif')) } + + context 'and a proof' do + let(:token1) { '111111111111111111111111111111111111111111111111111111111111111111' } + let(:kb_name1) { 'crypto_alice' } + + before do + Fabricate(:account_identity_proof, account: alice, verified: true, live: true, token: token1, provider_username: kb_name1) + get :index, params: { username: alice.username, provider: 'keybase' } + end + + it 'has two keys: signatures and avatar' do + expect(body_as_json.keys).to match_array [:signatures, :avatar] + end + + it 'has the correct signatures' do + expect(body_as_json[:signatures]).to eq [ + { kb_username: kb_name1, sig_hash: token1 }, + ] + end + + it 'has the correct avatar url' do + first_part = 'https://cb6e6126.ngrok.io/system/accounts/avatars/' + last_part = 'original/avatar.gif' + + expect(body_as_json[:avatar]).to match /#{Regexp.quote(first_part)}(?:\d{3,5}\/){3}#{Regexp.quote(last_part)}/ + end + end + end + end +end diff --git a/spec/controllers/settings/identity_proofs_controller_spec.rb b/spec/controllers/settings/identity_proofs_controller_spec.rb new file mode 100644 index 000000000..46af3ccf4 --- /dev/null +++ b/spec/controllers/settings/identity_proofs_controller_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +describe Settings::IdentityProofsController do + render_views + + let(:user) { Fabricate(:user) } + let(:valid_token) { '1'*66 } + let(:kbname) { 'kbuser' } + let(:provider) { 'keybase' } + let(:findable_id) { Faker::Number.number(5) } + let(:unfindable_id) { Faker::Number.number(5) } + let(:postable_params) do + { account_identity_proof: { provider: provider, provider_username: kbname, token: valid_token } } + end + + before do + allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:status) { { 'proof_valid' => true, 'proof_live' => true } } + sign_in user, scope: :user + end + + describe 'new proof creation' do + context 'GET #new with no existing proofs' do + it 'redirects to :index' do + get :new + expect(response).to redirect_to settings_identity_proofs_path + end + end + + context 'POST #create' do + context 'when saving works' do + before do + allow(ProofProvider::Keybase::Worker).to receive(:perform_async) + allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true } + allow_any_instance_of(AccountIdentityProof).to receive(:on_success_path) { root_url } + end + + it 'serializes a ProofProvider::Keybase::Worker' do + expect(ProofProvider::Keybase::Worker).to receive(:perform_async) + post :create, params: postable_params + end + + it 'delegates redirection to the proof provider' do + expect_any_instance_of(AccountIdentityProof).to receive(:on_success_path) + post :create, params: postable_params + expect(response).to redirect_to root_url + end + end + + context 'when saving fails' do + before do + allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { false } + end + + it 'redirects to :index' do + post :create, params: postable_params + expect(response).to redirect_to settings_identity_proofs_path + end + + it 'flashes a helpful message' do + post :create, params: postable_params + expect(flash[:alert]).to eq I18n.t('identity_proofs.errors.failed', provider: 'Keybase') + end + end + + context 'it can also do an update if the provider and username match an existing proof' do + before do + allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true } + allow(ProofProvider::Keybase::Worker).to receive(:perform_async) + Fabricate(:account_identity_proof, account: user.account, provider: provider, provider_username: kbname) + allow_any_instance_of(AccountIdentityProof).to receive(:on_success_path) { root_url } + end + + it 'calls update with the new token' do + expect_any_instance_of(AccountIdentityProof).to receive(:save) do |proof| + expect(proof.token).to eq valid_token + end + + post :create, params: postable_params + end + end + end + end + + describe 'GET #index' do + context 'with no existing proofs' do + it 'shows the helpful explanation' do + get :index + expect(response.body).to match I18n.t('identity_proofs.explanation_html') + end + end + + context 'with two proofs' do + before do + allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true } + @proof1 = Fabricate(:account_identity_proof, account: user.account) + @proof2 = Fabricate(:account_identity_proof, account: user.account) + allow_any_instance_of(AccountIdentityProof).to receive(:badge) { double(avatar_url: '', profile_url: '', proof_url: '') } + allow_any_instance_of(AccountIdentityProof).to receive(:refresh!) { } + end + + it 'has the first proof username on the page' do + get :index + expect(response.body).to match /#{Regexp.quote(@proof1.provider_username)}/ + end + + it 'has the second proof username on the page' do + get :index + expect(response.body).to match /#{Regexp.quote(@proof2.provider_username)}/ + end + end + end +end diff --git a/spec/controllers/well_known/keybase_proof_config_controller_spec.rb b/spec/controllers/well_known/keybase_proof_config_controller_spec.rb new file mode 100644 index 000000000..9067e676d --- /dev/null +++ b/spec/controllers/well_known/keybase_proof_config_controller_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe WellKnown::KeybaseProofConfigController, type: :controller do + render_views + + describe 'GET #show' do + it 'renders json' do + get :show + + expect(response).to have_http_status(200) + expect(response.content_type).to eq 'application/json' + expect { JSON.parse(response.body) }.not_to raise_exception + end + end +end diff --git a/spec/fabricators/account_identity_proof_fabricator.rb b/spec/fabricators/account_identity_proof_fabricator.rb new file mode 100644 index 000000000..94f40dfd6 --- /dev/null +++ b/spec/fabricators/account_identity_proof_fabricator.rb @@ -0,0 +1,8 @@ +Fabricator(:account_identity_proof) do + account + provider 'keybase' + provider_username { sequence(:provider_username) { |i| "#{Faker::Lorem.characters(15)}" } } + token { sequence(:token) { |i| "#{i}#{Faker::Crypto.sha1()*2}"[0..65] } } + verified false + live false +end diff --git a/spec/lib/proof_provider/keybase/verifier_spec.rb b/spec/lib/proof_provider/keybase/verifier_spec.rb new file mode 100644 index 000000000..4ce67da9c --- /dev/null +++ b/spec/lib/proof_provider/keybase/verifier_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +describe ProofProvider::Keybase::Verifier do + let(:my_domain) { Rails.configuration.x.local_domain } + + let(:keybase_proof) do + local_proof = AccountIdentityProof.new( + provider: 'Keybase', + provider_username: 'cryptoalice', + token: '11111111111111111111111111' + ) + + described_class.new('alice', 'cryptoalice', '11111111111111111111111111') + end + + let(:query_params) do + "domain=#{my_domain}&kb_username=cryptoalice&sig_hash=11111111111111111111111111&username=alice" + end + + describe '#valid?' do + let(:base_url) { 'https://keybase.io/_/api/1.0/sig/proof_valid.json' } + + context 'when valid' do + before do + json_response_body = '{"status":{"code":0,"name":"OK"},"proof_valid":true}' + stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) + end + + it 'calls out to keybase and returns true' do + expect(keybase_proof.valid?).to eq true + end + end + + context 'when invalid' do + before do + json_response_body = '{"status":{"code":0,"name":"OK"},"proof_valid":false}' + stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) + end + + it 'calls out to keybase and returns false' do + expect(keybase_proof.valid?).to eq false + end + end + + context 'with an unexpected api response' do + before do + json_response_body = '{"status":{"code":100,"desc":"wrong size hex_id","fields":{"sig_hash":"wrong size hex_id"},"name":"INPUT_ERROR"}}' + stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) + end + + it 'swallows the error and returns false' do + expect(keybase_proof.valid?).to eq false + end + end + end + + describe '#status' do + let(:base_url) { 'https://keybase.io/_/api/1.0/sig/proof_live.json' } + + context 'with a normal response' do + before do + json_response_body = '{"status":{"code":0,"name":"OK"},"proof_live":false,"proof_valid":true}' + stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) + end + + it 'calls out to keybase and returns the status fields as proof_valid and proof_live' do + expect(keybase_proof.status).to include({ 'proof_valid' => true, 'proof_live' => false }) + end + end + + context 'with an unexpected keybase response' do + before do + json_response_body = '{"status":{"code":100,"desc":"missing non-optional field sig_hash","fields":{"sig_hash":"missing non-optional field sig_hash"},"name":"INPUT_ERROR"}}' + stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) + end + + it 'raises a ProofProvider::Keybase::UnexpectedResponseError' do + expect { keybase_proof.status }.to raise_error ProofProvider::Keybase::UnexpectedResponseError + end + end + end +end