* 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 settingmaster
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1 @@ | |||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#000"/></svg> |
@@ -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; | |||||
} | |||||
} |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -7,6 +7,9 @@ module AccountAssociations | |||||
# Local users | # Local users | ||||
has_one :user, inverse_of: :account, dependent: :destroy | has_one :user, inverse_of: :account, dependent: :destroy | ||||
# Identity proofs | |||||
has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account | |||||
# Timelines | # Timelines | ||||
has_many :stream_entries, inverse_of: :account, dependent: :destroy | has_many :stream_entries, inverse_of: :account, dependent: :destroy | ||||
has_many :statuses, inverse_of: :account, dependent: :destroy | has_many :statuses, inverse_of: :account, dependent: :destroy | ||||
@@ -1,7 +1,17 @@ | |||||
- proofs = account.identity_proofs.active | |||||
- fields = account.fields | |||||
.public-account-bio | .public-account-bio | ||||
- unless account.fields.empty? | |||||
- unless fields.empty? && proofs.empty? | |||||
.account__header__fields | .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 | %dl | ||||
%dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true) | %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true) | ||||
%dd{ title: field.value, class: custom_field_classes(field) } | %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)) } | %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) } | ||||
= fa_icon 'check' | = fa_icon 'check' | ||||
= Formatter.instance.format_field(account, field.value, custom_emojify: true) | = Formatter.instance.format_field(account, field.value, custom_emojify: true) | ||||
= account_badge(account) | = account_badge(account) | ||||
- if account.note.present? | - if account.note.present? | ||||
@@ -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 |
@@ -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 |
@@ -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' |
@@ -633,6 +633,21 @@ en: | |||||
validation_errors: | validation_errors: | ||||
one: Something isn't quite right yet! Please review the error below | one: Something isn't quite right yet! Please review the error below | ||||
other: Something isn't quite right yet! Please review %{count} errors 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: | imports: | ||||
modes: | modes: | ||||
merge: Merge | merge: Merge | ||||
@@ -835,6 +850,7 @@ en: | |||||
edit_profile: Edit profile | edit_profile: Edit profile | ||||
export: Data export | export: Data export | ||||
featured_tags: Featured hashtags | featured_tags: Featured hashtags | ||||
identity_proofs: Identity proofs | |||||
import: Import | import: Import | ||||
migrate: Account migration | migrate: Account migration | ||||
notifications: Notifications | notifications: Notifications | ||||
@@ -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 :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 :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 :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 | end | ||||
primary.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url | primary.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url | ||||
@@ -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/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/webfinger', to: 'well_known/webfinger#show', as: :webfinger | ||||
get '.well-known/change-password', to: redirect('/auth/edit') | 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 'manifest', to: 'manifests#show', defaults: { format: 'json' } | ||||
get 'intent', to: 'intents#show' | get 'intent', to: 'intents#show' | ||||
get 'custom.css', to: 'custom_css#show', as: :custom_css | get 'custom.css', to: 'custom_css#show', as: :custom_css | ||||
@@ -106,6 +108,8 @@ Rails.application.routes.draw do | |||||
resource :confirmation, only: [:new, :create] | resource :confirmation, only: [:new, :create] | ||||
end | end | ||||
resources :identity_proofs, only: [:index, :show, :new, :create, :update] | |||||
resources :applications, except: [:edit] do | resources :applications, except: [:edit] do | ||||
member do | member do | ||||
post :regenerate | post :regenerate | ||||
@@ -248,6 +252,9 @@ Rails.application.routes.draw do | |||||
# OEmbed | # OEmbed | ||||
get '/oembed', to: 'oembed#show', as: :oembed | get '/oembed', to: 'oembed#show', as: :oembed | ||||
# Identity proofs | |||||
get :proofs, to: 'proofs#index' | |||||
# JSON / REST API | # JSON / REST API | ||||
namespace :v1 do | namespace :v1 do | ||||
resources :statuses, only: [:create, :show, :destroy] do | resources :statuses, only: [:create, :show, :destroy] do | ||||
@@ -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 |
@@ -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 | t.index ["account_id", "domain"], name: "index_account_domain_blocks_on_account_id_and_domain", unique: true | ||||
end | 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| | create_table "account_moderation_notes", force: :cascade do |t| | ||||
t.text "content", null: false | t.text "content", null: false | ||||
t.bigint "account_id", 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", "accounts", on_delete: :cascade | ||||
add_foreign_key "account_conversations", "conversations", 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_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" | ||||
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" | add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" | ||||
add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade | add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade | ||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |