From 5d8398c8b8b51ee7363e7d45acc560f489783e34 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 2 Jun 2020 19:24:53 +0200 Subject: Add E2EE API (#13820) --- .../activitypub/process_account_service.rb | 1 + app/services/backup_service.rb | 2 +- app/services/deliver_to_device_service.rb | 78 ++++++++++++++++++++++ app/services/keys/claim_service.rb | 77 +++++++++++++++++++++ app/services/keys/query_service.rb | 75 +++++++++++++++++++++ app/services/process_mentions_service.rb | 2 +- app/services/reblog_service.rb | 2 +- 7 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 app/services/deliver_to_device_service.rb create mode 100644 app/services/keys/claim_service.rb create mode 100644 app/services/keys/query_service.rb (limited to 'app/services') diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 7b4c53d50..f4276cece 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -76,6 +76,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' @account.followers_url = @json['followers'] || '' @account.featured_collection_url = @json['featured'] || '' + @account.devices_url = @json['devices'] || '' @account.url = url || @uri @account.uri = @uri @account.display_name = @json['name'] || '' diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 896699324..6a1575616 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -22,7 +22,7 @@ class BackupService < BaseService account.statuses.with_includes.reorder(nil).find_in_batches do |statuses| statuses.each do |status| - item = serialize_payload(status, ActivityPub::ActivitySerializer, signer: @account) + item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account) item.delete(:'@context') unless item[:type] == 'Announce' || item[:object][:attachment].blank? diff --git a/app/services/deliver_to_device_service.rb b/app/services/deliver_to_device_service.rb new file mode 100644 index 000000000..71711945c --- /dev/null +++ b/app/services/deliver_to_device_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class DeliverToDeviceService < BaseService + include Payloadable + + class EncryptedMessage < ActiveModelSerializers::Model + attributes :source_account, :target_account, :source_device, + :target_device_id, :type, :body, :digest, + :message_franking + end + + def call(source_account, source_device, options = {}) + @source_account = source_account + @source_device = source_device + @target_account = Account.find(options[:account_id]) + @target_device_id = options[:device_id] + @body = options[:body] + @type = options[:type] + @hmac = options[:hmac] + + set_message_franking! + + if @target_account.local? + deliver_to_local! + else + deliver_to_remote! + end + end + + private + + def set_message_franking! + @message_franking = message_franking.to_token + end + + def deliver_to_local! + target_device = @target_account.devices.find_by!(device_id: @target_device_id) + + target_device.encrypted_messages.create!( + from_account: @source_account, + from_device_id: @source_device.device_id, + type: @type, + body: @body, + digest: @hmac, + message_franking: @message_franking + ) + end + + def deliver_to_remote! + ActivityPub::DeliveryWorker.perform_async( + Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_encrypted_message(encrypted_message), ActivityPub::ActivitySerializer)), + @source_account.id, + @target_account.inbox_url + ) + end + + def message_franking + MessageFranking.new( + source_account_id: @source_account.id, + target_account_id: @target_account.id, + hmac: @hmac, + timestamp: Time.now.utc + ) + end + + def encrypted_message + EncryptedMessage.new( + source_account: @source_account, + target_account: @target_account, + source_device: @source_device, + target_device_id: @target_device_id, + type: @type, + body: @body, + digest: @hmac, + message_franking: @message_franking + ) + end +end diff --git a/app/services/keys/claim_service.rb b/app/services/keys/claim_service.rb new file mode 100644 index 000000000..672119130 --- /dev/null +++ b/app/services/keys/claim_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Keys::ClaimService < BaseService + HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze + + class Result < ActiveModelSerializers::Model + attributes :account, :device_id, :key_id, + :key, :signature + + def initialize(account, device_id, key_attributes = {}) + @account = account + @device_id = device_id + @key_id = key_attributes[:key_id] + @key = key_attributes[:key] + @signature = key_attributes[:signature] + end + end + + def call(source_account, target_account_id, device_id) + @source_account = source_account + @target_account = Account.find(target_account_id) + @device_id = device_id + + if @target_account.local? + claim_local_key! + else + claim_remote_key! + end + rescue ActiveRecord::RecordNotFound + nil + end + + private + + def claim_local_key! + device = @target_account.devices.find_by(device_id: @device_id) + key = nil + + ApplicationRecord.transaction do + key = device.one_time_keys.order(Arel.sql('random()')).first! + key.destroy! + end + + @result = Result.new(@target_account, @device_id, key) + end + + def claim_remote_key! + query_result = QueryService.new.call(@target_account) + device = query_result.find(@device_id) + + return unless device.present? && device.valid_claim_url? + + json = fetch_resource_with_post(device.claim_url) + + return unless json.present? && json['publicKeyBase64'].present? + + @result = Result.new(@target_account, @device_id, key_id: json['id'], key: json['publicKeyBase64'], signature: json.dig('signature', 'signatureValue')) + rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e + Rails.logger.debug "Claiming one-time key for #{@target_account.acct}:#{@device_id} failed: #{e}" + nil + end + + def fetch_resource_with_post(uri) + build_post_request(uri).perform do |response| + raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) + + body_to_json(response.body_with_limit) if response.code == 200 + end + end + + def build_post_request(uri) + Request.new(:post, uri).tap do |request| + request.on_behalf_of(@source_account, :uri) + request.add_headers(HEADERS) + end + end +end diff --git a/app/services/keys/query_service.rb b/app/services/keys/query_service.rb new file mode 100644 index 000000000..286fbd834 --- /dev/null +++ b/app/services/keys/query_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Keys::QueryService < BaseService + include JsonLdHelper + + class Result < ActiveModelSerializers::Model + attributes :account, :devices + + def initialize(account, devices) + @account = account + @devices = devices || [] + end + + def find(device_id) + @devices.find { |device| device.device_id == device_id } + end + end + + class Device < ActiveModelSerializers::Model + attributes :device_id, :name, :identity_key, :fingerprint_key + + def initialize(attributes = {}) + @device_id = attributes[:device_id] + @name = attributes[:name] + @identity_key = attributes[:identity_key] + @fingerprint_key = attributes[:fingerprint_key] + @claim_url = attributes[:claim_url] + end + + def valid_claim_url? + return false if @claim_url.blank? + + begin + parsed_url = Addressable::URI.parse(@claim_url).normalize + rescue Addressable::URI::InvalidURIError + return false + end + + %w(http https).include?(parsed_url.scheme) && parsed_url.host.present? + end + end + + def call(account) + @account = account + + if @account.local? + query_local_devices! + else + query_remote_devices! + end + + Result.new(@account, @devices) + end + + private + + def query_local_devices! + @devices = @account.devices.map { |device| Device.new(device) } + end + + def query_remote_devices! + return if @account.devices_url.blank? + + json = fetch_resource(@account.devices_url) + + return if json['items'].blank? + + @devices = json['items'].map do |device| + Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim']) + end + rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e + Rails.logger.debug "Querying devices for #{@account.acct} failed: #{e}" + nil + end +end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index b2d868165..3822b7dc5 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -65,7 +65,7 @@ class ProcessMentionsService < BaseService def activitypub_json return @activitypub_json if defined?(@activitypub_json) - @activitypub_json = Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account)) + @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) end def resolve_account_service diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 4b5ae9492..6866d2fac 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -60,6 +60,6 @@ class ReblogService < BaseService end def build_json(reblog) - Oj.dump(serialize_payload(reblog, ActivityPub::ActivitySerializer, signer: reblog.account)) + Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account)) end end -- cgit