Add E2EE API (#13820)
parent
9b7e3b4774
commit
5d8398c8b8
@ -0,0 +1,21 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class ActivityPub::ClaimsController < ActivityPub::BaseController |
||||||
|
include SignatureVerification |
||||||
|
include AccountOwnedConcern |
||||||
|
|
||||||
|
skip_before_action :authenticate_user! |
||||||
|
|
||||||
|
before_action :require_signature! |
||||||
|
before_action :set_claim_result |
||||||
|
|
||||||
|
def create |
||||||
|
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_claim_result |
||||||
|
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id]) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,30 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Api::V1::Crypto::DeliveriesController < Api::BaseController |
||||||
|
before_action -> { doorkeeper_authorize! :crypto } |
||||||
|
before_action :require_user! |
||||||
|
before_action :set_current_device |
||||||
|
|
||||||
|
def create |
||||||
|
devices.each do |device_params| |
||||||
|
DeliverToDeviceService.new.call(current_account, @current_device, device_params) |
||||||
|
end |
||||||
|
|
||||||
|
render_empty |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_current_device |
||||||
|
@current_device = Device.find_by!(access_token: doorkeeper_token) |
||||||
|
end |
||||||
|
|
||||||
|
def resource_params |
||||||
|
params.require(:device) |
||||||
|
params.permit(device: [:account_id, :device_id, :type, :body, :hmac]) |
||||||
|
end |
||||||
|
|
||||||
|
def devices |
||||||
|
Array(resource_params[:device]) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,59 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController |
||||||
|
LIMIT = 80 |
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto } |
||||||
|
before_action :require_user! |
||||||
|
before_action :set_current_device |
||||||
|
|
||||||
|
before_action :set_encrypted_messages, only: :index |
||||||
|
after_action :insert_pagination_headers, only: :index |
||||||
|
|
||||||
|
def index |
||||||
|
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer |
||||||
|
end |
||||||
|
|
||||||
|
def clear |
||||||
|
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all |
||||||
|
render_empty |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_current_device |
||||||
|
@current_device = Device.find_by!(access_token: doorkeeper_token) |
||||||
|
end |
||||||
|
|
||||||
|
def set_encrypted_messages |
||||||
|
@encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) |
||||||
|
end |
||||||
|
|
||||||
|
def insert_pagination_headers |
||||||
|
set_pagination_headers(next_path, prev_path) |
||||||
|
end |
||||||
|
|
||||||
|
def next_path |
||||||
|
api_v1_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? |
||||||
|
end |
||||||
|
|
||||||
|
def prev_path |
||||||
|
api_v1_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? |
||||||
|
end |
||||||
|
|
||||||
|
def pagination_max_id |
||||||
|
@encrypted_messages.last.id |
||||||
|
end |
||||||
|
|
||||||
|
def pagination_since_id |
||||||
|
@encrypted_messages.first.id |
||||||
|
end |
||||||
|
|
||||||
|
def records_continue? |
||||||
|
@encrypted_messages.size == limit_param(LIMIT) |
||||||
|
end |
||||||
|
|
||||||
|
def pagination_params(core_params) |
||||||
|
params.slice(:limit).permit(:limit).merge(core_params) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,25 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController |
||||||
|
before_action -> { doorkeeper_authorize! :crypto } |
||||||
|
before_action :require_user! |
||||||
|
before_action :set_claim_results |
||||||
|
|
||||||
|
def create |
||||||
|
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_claim_results |
||||||
|
@claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact |
||||||
|
end |
||||||
|
|
||||||
|
def resource_params |
||||||
|
params.permit(device: [:account_id, :device_id]) |
||||||
|
end |
||||||
|
|
||||||
|
def devices |
||||||
|
Array(resource_params[:device]) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,17 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::CountsController < Api::BaseController |
||||||
|
before_action -> { doorkeeper_authorize! :crypto } |
||||||
|
before_action :require_user! |
||||||
|
before_action :set_current_device |
||||||
|
|
||||||
|
def show |
||||||
|
render json: { one_time_keys: @current_device.one_time_keys.count } |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_current_device |
||||||
|
@current_device = Device.find_by!(access_token: doorkeeper_token) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,26 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::QueriesController < Api::BaseController |
||||||
|
before_action -> { doorkeeper_authorize! :crypto } |
||||||
|
before_action :require_user! |
||||||
|
before_action :set_accounts |
||||||
|
before_action :set_query_results |
||||||
|
|
||||||
|
def create |
||||||
|
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_accounts |
||||||
|
@accounts = Account.where(id: account_ids).includes(:devices) |
||||||
|
end |
||||||
|
|
||||||
|
def set_query_results |
||||||
|
@query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact |
||||||
|
end |
||||||
|
|
||||||
|
def account_ids |
||||||
|
Array(params[:id]).map(&:to_i) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,29 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::UploadsController < Api::BaseController |
||||||
|
before_action -> { doorkeeper_authorize! :crypto } |
||||||
|
before_action :require_user! |
||||||
|
|
||||||
|
def create |
||||||
|
device = Device.find_or_initialize_by(access_token: doorkeeper_token) |
||||||
|
|
||||||
|
device.transaction do |
||||||
|
device.account = current_account |
||||||
|
device.update!(resource_params[:device]) |
||||||
|
|
||||||
|
if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable) |
||||||
|
resource_params[:one_time_keys].each do |one_time_key_params| |
||||||
|
device.one_time_keys.create!(one_time_key_params) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
render json: device, serializer: REST::Keys::DeviceSerializer |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def resource_params |
||||||
|
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature]) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,35 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
# == Schema Information |
||||||
|
# |
||||||
|
# Table name: devices |
||||||
|
# |
||||||
|
# id :bigint(8) not null, primary key |
||||||
|
# access_token_id :bigint(8) |
||||||
|
# account_id :bigint(8) |
||||||
|
# device_id :string default(""), not null |
||||||
|
# name :string default(""), not null |
||||||
|
# fingerprint_key :text default(""), not null |
||||||
|
# identity_key :text default(""), not null |
||||||
|
# created_at :datetime not null |
||||||
|
# updated_at :datetime not null |
||||||
|
# |
||||||
|
|
||||||
|
class Device < ApplicationRecord |
||||||
|
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken' |
||||||
|
belongs_to :account |
||||||
|
|
||||||
|
has_many :one_time_keys, dependent: :destroy, inverse_of: :device |
||||||
|
has_many :encrypted_messages, dependent: :destroy, inverse_of: :device |
||||||
|
|
||||||
|
validates :name, :fingerprint_key, :identity_key, presence: true |
||||||
|
validates :fingerprint_key, :identity_key, ed25519_key: true |
||||||
|
|
||||||
|
before_save :invalidate_associations, if: -> { device_id_changed? || fingerprint_key_changed? || identity_key_changed? } |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def invalidate_associations |
||||||
|
one_time_keys.destroy_all |
||||||
|
encrypted_messages.destroy_all |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,50 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
# == Schema Information |
||||||
|
# |
||||||
|
# Table name: encrypted_messages |
||||||
|
# |
||||||
|
# id :bigint(8) not null, primary key |
||||||
|
# device_id :bigint(8) |
||||||
|
# from_account_id :bigint(8) |
||||||
|
# from_device_id :string default(""), not null |
||||||
|
# type :integer default(0), not null |
||||||
|
# body :text default(""), not null |
||||||
|
# digest :text default(""), not null |
||||||
|
# message_franking :text default(""), not null |
||||||
|
# created_at :datetime not null |
||||||
|
# updated_at :datetime not null |
||||||
|
# |
||||||
|
|
||||||
|
class EncryptedMessage < ApplicationRecord |
||||||
|
self.inheritance_column = nil |
||||||
|
|
||||||
|
include Paginable |
||||||
|
|
||||||
|
scope :up_to, ->(id) { where(arel_table[:id].lteq(id)) } |
||||||
|
|
||||||
|
belongs_to :device |
||||||
|
belongs_to :from_account, class_name: 'Account' |
||||||
|
|
||||||
|
around_create Mastodon::Snowflake::Callbacks |
||||||
|
|
||||||
|
after_commit :push_to_streaming_api |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def push_to_streaming_api |
||||||
|
Rails.logger.info(streaming_channel) |
||||||
|
Rails.logger.info(subscribed_to_timeline?) |
||||||
|
|
||||||
|
return if destroyed? || !subscribed_to_timeline? |
||||||
|
|
||||||
|
PushEncryptedMessageWorker.perform_async(id) |
||||||
|
end |
||||||
|
|
||||||
|
def subscribed_to_timeline? |
||||||
|
Redis.current.exists("subscribed:#{streaming_channel}") |
||||||
|
end |
||||||
|
|
||||||
|
def streaming_channel |
||||||
|
"timeline:#{device.account_id}:#{device.device_id}" |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,19 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class MessageFranking |
||||||
|
attr_reader :hmac, :source_account_id, :target_account_id, |
||||||
|
:timestamp, :original_franking |
||||||
|
|
||||||
|
def initialize(attributes = {}) |
||||||
|
@hmac = attributes[:hmac] |
||||||
|
@source_account_id = attributes[:source_account_id] |
||||||
|
@target_account_id = attributes[:target_account_id] |
||||||
|
@timestamp = attributes[:timestamp] |
||||||
|
@original_franking = attributes[:original_franking] |
||||||
|
end |
||||||
|
|
||||||
|
def to_token |
||||||
|
crypt = ActiveSupport::MessageEncryptor.new(SystemKey.current_key, serializer: Oj) |
||||||
|
crypt.encrypt_and_sign(self) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,21 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
# == Schema Information |
||||||
|
# |
||||||
|
# Table name: one_time_keys |
||||||
|
# |
||||||
|
# id :bigint(8) not null, primary key |
||||||
|
# device_id :bigint(8) |
||||||
|
# key_id :string default(""), not null |
||||||
|
# key :text default(""), not null |
||||||
|
# signature :text default(""), not null |
||||||
|
# created_at :datetime not null |
||||||
|
# updated_at :datetime not null |
||||||
|
# |
||||||
|
|
||||||
|
class OneTimeKey < ApplicationRecord |
||||||
|
belongs_to :device |
||||||
|
|
||||||
|
validates :key_id, :key, :signature, presence: true |
||||||
|
validates :key, ed25519_key: true |
||||||
|
validates :signature, ed25519_signature: { message: :key, verify_key: ->(one_time_key) { one_time_key.device.fingerprint_key } } |
||||||
|
end |
@ -0,0 +1,41 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
# == Schema Information |
||||||
|
# |
||||||
|
# Table name: system_keys |
||||||
|
# |
||||||
|
# id :bigint(8) not null, primary key |
||||||
|
# key :binary |
||||||
|
# created_at :datetime not null |
||||||
|
# updated_at :datetime not null |
||||||
|
# |
||||||
|
class SystemKey < ApplicationRecord |
||||||
|
ROTATION_PERIOD = 1.week.freeze |
||||||
|
|
||||||
|
before_validation :set_key |
||||||
|
|
||||||
|
scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - ROTATION_PERIOD * 3)) } |
||||||
|
|
||||||
|
class << self |
||||||
|
def current_key |
||||||
|
previous_key = order(id: :asc).last |
||||||
|
|
||||||
|
if previous_key && previous_key.created_at >= ROTATION_PERIOD.ago |
||||||
|
previous_key.key |
||||||
|
else |
||||||
|
create.key |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def set_key |
||||||
|
return if key.present? |
||||||
|
|
||||||
|
cipher = OpenSSL::Cipher.new('AES-256-GCM') |
||||||
|
cipher.encrypt |
||||||
|
|
||||||
|
self.key = cipher.random_key |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,41 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model |
||||||
|
attributes :id, :type, :actor, :published, :to, :cc, :virtual_object |
||||||
|
|
||||||
|
class << self |
||||||
|
def from_status(status) |
||||||
|
new.tap do |presenter| |
||||||
|
presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status) |
||||||
|
presenter.type = status.reblog? ? 'Announce' : 'Create' |
||||||
|
presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account) |
||||||
|
presenter.published = status.created_at |
||||||
|
presenter.to = ActivityPub::TagManager.instance.to(status) |
||||||
|
presenter.cc = ActivityPub::TagManager.instance.cc(status) |
||||||
|
|
||||||
|
presenter.virtual_object = begin |
||||||
|
if status.reblog? |
||||||
|
if status.account == status.proper.account && status.proper.private_visibility? && status.local? |
||||||
|
status.proper |
||||||
|
else |
||||||
|
ActivityPub::TagManager.instance.uri_for(status.proper) |
||||||
|
end |
||||||
|
else |
||||||
|
status.proper |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def from_encrypted_message(encrypted_message) |
||||||
|
new.tap do |presenter| |
||||||
|
presenter.id = ActivityPub::TagManager.instance.generate_uri_for(nil) |
||||||
|
presenter.type = 'Create' |
||||||
|
presenter.actor = ActivityPub::TagManager.instance.uri_for(encrypted_message.source_account) |
||||||
|
presenter.published = Time.now.utc |
||||||
|
presenter.to = ActivityPub::TagManager.instance.uri_for(encrypted_message.target_account) |
||||||
|
presenter.virtual_object = encrypted_message |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -1,52 +1,22 @@ |
|||||||
# frozen_string_literal: true |
# frozen_string_literal: true |
||||||
|
|
||||||
class ActivityPub::ActivitySerializer < ActivityPub::Serializer |
class ActivityPub::ActivitySerializer < ActivityPub::Serializer |
||||||
attributes :id, :type, :actor, :published, :to, :cc |
def self.serializer_for(model, options) |
||||||
|
case model.class.name |
||||||
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, if: :serialize_object? |
when 'Status' |
||||||
|
ActivityPub::NoteSerializer |
||||||
attribute :proper_uri, key: :object, unless: :serialize_object? |
when 'DeliverToDeviceService::EncryptedMessage' |
||||||
attribute :atom_uri, if: :announce? |
ActivityPub::EncryptedMessageSerializer |
||||||
|
else |
||||||
def id |
super |
||||||
ActivityPub::TagManager.instance.activity_uri_for(object) |
end |
||||||
end |
end |
||||||
|
|
||||||
def type |
attributes :id, :type, :actor, :published, :to, :cc |
||||||
announce? ? 'Announce' : 'Create' |
|
||||||
end |
|
||||||
|
|
||||||
def actor |
has_one :virtual_object, key: :object |
||||||
ActivityPub::TagManager.instance.uri_for(object.account) |
|
||||||
end |
|
||||||
|
|
||||||
def published |
def published |
||||||
object.created_at.iso8601 |
object.published.iso8601 |
||||||
end |
|
||||||
|
|
||||||
def to |
|
||||||
ActivityPub::TagManager.instance.to(object) |
|
||||||
end |
|
||||||
|
|
||||||
def cc |
|
||||||
ActivityPub::TagManager.instance.cc(object) |
|
||||||
end |
|
||||||
|
|
||||||
def proper_uri |
|
||||||
ActivityPub::TagManager.instance.uri_for(object.proper) |
|
||||||
end |
|
||||||
|
|
||||||
def atom_uri |
|
||||||
OStatus::TagManager.instance.uri_for(object) |
|
||||||
end |
|
||||||
|
|
||||||
def announce? |
|
||||||
object.reblog? |
|
||||||
end |
|
||||||
|
|
||||||
def serialize_object? |
|
||||||
return true unless announce? |
|
||||||
# Serialize private self-boosts of local toots |
|
||||||
object.account == object.proper.account && object.proper.private_visibility? && object.local? |
|
||||||
end |
end |
||||||
end |
end |
||||||
|
@ -0,0 +1,52 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class ActivityPub::DeviceSerializer < ActivityPub::Serializer |
||||||
|
context_extensions :olm |
||||||
|
|
||||||
|
include RoutingHelper |
||||||
|
|
||||||
|
class FingerprintKeySerializer < ActivityPub::Serializer |
||||||
|
attributes :type, :public_key_base64 |
||||||
|
|
||||||
|
def type |
||||||
|
'Ed25519Key' |
||||||
|
end |
||||||
|
|
||||||
|
def public_key_base64 |
||||||
|
object.fingerprint_key |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
class IdentityKeySerializer < ActivityPub::Serializer |
||||||
|
attributes :type, :public_key_base64 |
||||||
|
|
||||||
|
def type |
||||||
|
'Curve25519Key' |
||||||
|
end |
||||||
|
|
||||||
|
def public_key_base64 |
||||||
|
object.identity_key |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
attributes :device_id, :type, :name, :claim |
||||||
|
|
||||||
|
has_one :fingerprint_key, serializer: FingerprintKeySerializer |
||||||
|
has_one :identity_key, serializer: IdentityKeySerializer |
||||||
|
|
||||||
|
def type |
||||||
|
'Device' |
||||||
|
end |
||||||
|
|
||||||
|
def claim |
||||||
|
account_claim_url(object.account, id: object.device_id) |
||||||
|
end |
||||||
|
|
||||||
|
def fingerprint_key |
||||||
|
object |
||||||
|
end |
||||||
|
|
||||||
|
def identity_key |
||||||
|
object |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,61 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class ActivityPub::EncryptedMessageSerializer < ActivityPub::Serializer |
||||||
|
context :security |
||||||
|
|
||||||
|
context_extensions :olm |
||||||
|
|
||||||
|
class DeviceSerializer < ActivityPub::Serializer |
||||||
|
attributes :type, :device_id |
||||||
|
|
||||||
|
def type |
||||||
|
'Device' |
||||||
|
end |
||||||
|
|
||||||
|
def device_id |
||||||
|
object |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
class DigestSerializer < ActivityPub::Serializer |
||||||
|
attributes :type, :digest_algorithm, :digest_value |
||||||
|
|
||||||
|
def type |
||||||
|
'Digest' |
||||||
|
end |
||||||
|
|
||||||
|
def digest_algorithm |
||||||
|
'http://www.w3.org/2000/09/xmldsig#hmac-sha256' |
||||||
|
end |
||||||
|
|
||||||
|
def digest_value |
||||||
|
object |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
attributes :type, :message_type, :cipher_text, :message_franking |
||||||
|
|
||||||
|
has_one :attributed_to, serializer: DeviceSerializer |
||||||
|
has_one :to, serializer: DeviceSerializer |
||||||
|
has_one :digest, serializer: DigestSerializer |
||||||
|
|
||||||
|
def type |
||||||
|
'EncryptedMessage' |
||||||
|
end |
||||||
|
|
||||||
|
def attributed_to |
||||||
|
object.source_device.device_id |
||||||
|
end |
||||||
|
|
||||||
|
def to |
||||||
|
object.target_device_id |
||||||
|
end |
||||||
|
|
||||||
|
def message_type |
||||||
|
object.type |
||||||
|
end |
||||||
|
|
||||||
|
def cipher_text |
||||||
|
object.body |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,35 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class ActivityPub::OneTimeKeySerializer < ActivityPub::Serializer |
||||||
|
context :security |
||||||
|
|
||||||
|
context_extensions :olm |
||||||
|
|
||||||
|
class SignatureSerializer < ActivityPub::Serializer |
||||||
|
attributes :type, :signature_value |
||||||
|
|
||||||
|
def type |
||||||
|
'Ed25519Signature' |
||||||
|
end |
||||||
|
|
||||||
|
def signature_value |
||||||
|
object.signature |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
attributes :key_id, :type, :public_key_base64 |
||||||
|
|
||||||
|
has_one :signature, serializer: SignatureSerializer |
||||||
|
|
||||||
|
def type |
||||||
|
'Curve25519Key' |
||||||
|
end |
||||||
|
|
||||||
|
def public_key_base64 |
||||||
|
object.key |
||||||
|
end |
||||||
|
|
||||||
|
def signature |
||||||
|
object |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,18 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class REST::EncryptedMessageSerializer < ActiveModel::Serializer |
||||||
|
attributes :id, :account_id, :device_id, |
||||||
|
:type, :body, :digest, :message_franking |
||||||
|
|
||||||
|
def id |
||||||
|
object.id.to_s |
||||||
|
end |
||||||
|
|
||||||
|
def account_id |
||||||
|
object.from_account_id.to_s |
||||||
|
end |
||||||
|
|
||||||
|
def device_id |
||||||
|
object.from_device_id |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,9 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class REST::Keys::ClaimResultSerializer < ActiveModel::Serializer |
||||||
|
attributes :account_id, :device_id, :key_id, :key, :signature |
||||||
|
|
||||||
|
def account_id |
||||||
|
object.account.id.to_s |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,6 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class REST::Keys::DeviceSerializer < ActiveModel::Serializer |
||||||
|
attributes :device_id, :name, :identity_key, |
||||||
|
:fingerprint_key |
||||||
|
end |
@ -0,0 +1,11 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class REST::Keys::QueryResultSerializer < ActiveModel::Serializer |
||||||
|
attributes :account_id |
||||||
|
|
||||||
|
has_many :devices, serializer: REST::Keys::DeviceSerializer |
||||||
|
|
||||||
|
def account_id |
||||||
|
object.account.id.to_s |
||||||
|
end |
||||||
|
end |
@ -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 |
@ -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 |
@ -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 |
@ -0,0 +1,19 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Ed25519KeyValidator < ActiveModel::EachValidator |
||||||
|
def validate_each(record, attribute, value) |
||||||
|
return if value.blank? |
||||||
|
|
||||||
|
key = Base64.decode64(value) |
||||||
|
|
||||||
|
record.errors[attribute] << I18n.t('crypto.errors.invalid_key') unless verified?(key) |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def verified?(key) |
||||||
|
Ed25519.validate_key_bytes(key) |
||||||
|
rescue ArgumentError |
||||||
|
false |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,29 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Ed25519SignatureValidator < ActiveModel::EachValidator |
||||||
|
def validate_each(record, attribute, value) |
||||||
|
return if value.blank? |
||||||
|
|
||||||
|
verify_key = Ed25519::VerifyKey.new(Base64.decode64(option_to_value(record, :verify_key))) |
||||||
|
signature = Base64.decode64(value) |
||||||
|
message = option_to_value(record, :message) |
||||||
|
|
||||||
|
record.errors[attribute] << I18n.t('crypto.errors.invalid_signature') unless verified?(verify_key, signature, message) |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def verified?(verify_key, signature, message) |
||||||
|
verify_key.verify(signature, message) |
||||||
|
rescue Ed25519::VerifyError, ArgumentError |
||||||
|
false |
||||||
|
end |
||||||
|
|
||||||
|
def option_to_value(record, key) |
||||||
|
if options[key].is_a?(Proc) |
||||||
|
options[key].call(record) |
||||||
|
else |
||||||
|
record.public_send(options[key]) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,16 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class PushEncryptedMessageWorker |
||||||
|
include Sidekiq::Worker |
||||||
|
include Redisable |
||||||
|
|
||||||
|
def perform(encrypted_message_id) |
||||||
|
encrypted_message = EncryptedMessage.find(encrypted_message_id) |
||||||
|
message = InlineRenderer.render(encrypted_message, nil, :encrypted_message) |
||||||
|
timeline_id = "timeline:#{encrypted_message.device.account_id}:#{encrypted_message.device.device_id}" |
||||||
|
|
||||||
|
redis.publish(timeline_id, Oj.dump(event: :encrypted_message, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) |
||||||
|
rescue ActiveRecord::RecordNotFound |
||||||
|
true |
||||||
|
end |
||||||
|
end |
@ -1,13 +0,0 @@ |
|||||||
class CreateDevices < ActiveRecord::Migration[5.0] |
|
||||||
def change |
|
||||||
create_table :devices do |t| |
|
||||||
t.integer :account_id, null: false |
|
||||||
t.string :registration_id, null: false, default: '' |
|
||||||
|
|
||||||
t.timestamps |
|
||||||
end |
|
||||||
|
|
||||||
add_index :devices, :registration_id |
|
||||||
add_index :devices, :account_id |
|
||||||
end |
|
||||||
end |
|
@ -1,5 +1,5 @@ |
|||||||
class RemoveDevices < ActiveRecord::Migration[5.0] |
class RemoveDevices < ActiveRecord::Migration[5.0] |
||||||
def change |
def change |
||||||
drop_table :devices |
drop_table :devices if table_exists?(:devices) |
||||||
end |
end |
||||||
end |
end |
||||||
|
@ -0,0 +1,14 @@ |
|||||||
|
class CreateDevices < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
create_table :devices do |t| |
||||||
|
t.references :access_token, foreign_key: { to_table: :oauth_access_tokens, on_delete: :cascade, index: :unique } |
||||||
|
t.references :account, foreign_key: { on_delete: :cascade } |
||||||
|
t.string :device_id, default: '', null: false |
||||||
|
t.string :name, default: '', null: false |
||||||
|
t.text :fingerprint_key, default: '', null: false |
||||||
|
t.text :identity_key, default: '', null: false |
||||||
|
|
||||||
|
t.timestamps |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,12 @@ |
|||||||
|
class CreateOneTimeKeys < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
create_table :one_time_keys do |t| |
||||||
|
t.references :device, foreign_key: { on_delete: :cascade } |
||||||
|
t.string :key_id, default: '', null: false, index: :unique |
||||||
|
t.text :key, default: '', null: false |
||||||
|
t.text :signature, default: '', null: false |
||||||
|
|
||||||
|
t.timestamps |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,15 @@ |
|||||||
|
class CreateEncryptedMessages < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
create_table :encrypted_messages do |t| |
||||||
|
t.references :device, foreign_key: { on_delete: :cascade } |
||||||
|
t.references :from_account, foreign_key: { to_table: :accounts, on_delete: :cascade } |
||||||
|
t.string :from_device_id, default: '', null: false |
||||||
|
t.integer :type, default: 0, null: false |
||||||
|
t.text :body, default: '', null: false |
||||||
|
t.text :digest, default: '', null: false |
||||||
|
t.text :message_franking, default: '', null: false |
||||||
|
|
||||||
|
t.timestamps |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
class EncryptedMessageIdsToTimestampIds < ActiveRecord::Migration[5.2] |
||||||
|
def up |
||||||
|
safety_assured do |
||||||
|
execute("ALTER TABLE encrypted_messages ALTER COLUMN id SET DEFAULT timestamp_id('encrypted_messages')") |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def down |
||||||
|
execute("LOCK encrypted_messages") |
||||||
|
execute("SELECT setval('encrypted_messages_id_seq', (SELECT MAX(id) FROM encrypted_messages))") |
||||||
|
execute("ALTER TABLE encrypted_messages ALTER COLUMN id SET DEFAULT nextval('encrypted_messages_id_seq')") |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
class AddDevicesUrlToAccounts < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
add_column :accounts, :devices_url, :string |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,9 @@ |
|||||||
|
class CreateSystemKeys < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
create_table :system_keys do |t| |
||||||
|
t.binary :key |
||||||
|
|
||||||
|
t.timestamps |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,8 @@ |
|||||||
|
Fabricator(:device) do |
||||||
|
access_token |
||||||
|
account |
||||||
|
device_id { Faker::Number.number(digits: 5) } |
||||||
|
name { Faker::App.name } |
||||||
|
fingerprint_key { Base64.strict_encode64(Ed25519::SigningKey.generate.verify_key.to_bytes) } |
||||||
|
identity_key { Base64.strict_encode64(Ed25519::SigningKey.generate.verify_key.to_bytes) } |
||||||
|
end |
@ -0,0 +1,8 @@ |
|||||||
|
Fabricator(:encrypted_message) do |
||||||
|
device |
||||||
|
from_account |
||||||
|
from_device_id { Faker::Number.number(digits: 5) } |
||||||
|
type 0 |
||||||
|
body "" |
||||||
|
message_franking "" |
||||||
|
end |
@ -0,0 +1,11 @@ |
|||||||
|
Fabricator(:one_time_key) do |
||||||
|
device |
||||||
|
key_id { Faker::Alphanumeric.alphanumeric(number: 10) } |
||||||
|
key { Base64.strict_encode64(Ed25519::SigningKey.generate.verify_key.to_bytes) } |
||||||
|
|
||||||
|
signature do |attrs| |
||||||
|
signing_key = Ed25519::SigningKey.generate |
||||||
|
attrs[:device].update(fingerprint_key: Base64.strict_encode64(signing_key.verify_key.to_bytes)) |
||||||
|
Base64.strict_encode64(signing_key.sign(attrs[:key])) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,3 @@ |
|||||||
|
Fabricator(:system_key) do |
||||||
|
|
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe Device, type: :model do |
||||||
|
|
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe EncryptedMessage, type: :model do |
||||||
|
|
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe OneTimeKey, type: :model do |
||||||
|
|
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe SystemKey, type: :model do |
||||||
|
|
||||||
|
end |
Loading…
Reference in new issue