Add signature computation for entities support

This commit is contained in:
cmrd Senya 2015-11-11 22:02:28 +03:00
parent 1b929a6bbf
commit 91c3753019
18 changed files with 443 additions and 31 deletions

View file

@ -7,6 +7,7 @@ require "diaspora_federation/validators"
require "diaspora_federation/fetcher"
require "diaspora_federation/signing"
require "diaspora_federation/entities"
require "diaspora_federation/discovery"
@ -20,6 +21,11 @@ module DiasporaFederation
fetch_person_for_webfinger
fetch_person_for_hcard
save_person_after_webfinger
fetch_private_key_by_id
fetch_private_key_by_post_guid
fetch_public_key_by_id
fetch_public_key_by_post_guid
post_author_is_local?
)
class << self

View file

@ -4,10 +4,56 @@ module DiasporaFederation
def self.included(model)
model.class_eval do
property :parent_guid
property :parent_author_signature
property :author_signature
property :parent_author_signature, default: nil
property :author_signature, default: nil
end
end
# Generates XML and updates signatures
def to_xml
xml = entity_xml
hash = to_h
Relayable.update_signatures!(hash)
xml.at_xpath("author_signature").content = hash[:author_signature]
xml.at_xpath("parent_author_signature").content = hash[:parent_author_signature]
xml
end
class SignatureVerificationFailed < ArgumentError
end
def self.verify_signatures(data)
pkey = DiasporaFederation.callbacks.trigger(:fetch_public_key_by_id, data[:diaspora_id])
raise SignatureVerificationFailed, "failed to fetch public key for #{data[:diaspora_id]}" if pkey.nil?
raise SignatureVerificationFailed, "wrong author_signature" unless Signing.verify_signature(
data, data[:author_signature], pkey
)
unless DiasporaFederation.callbacks.trigger(:post_author_is_local?, data[:parent_guid])
# this happens only on downstream federation
pkey = DiasporaFederation.callbacks.trigger(:fetch_public_key_by_post_guid, data[:parent_guid])
raise SignatureVerificationFailed,
"failed to fetch public key for parent of #{data[:parent_guid]}" if pkey.nil?
raise SignatureVerificationFailed, "wrong parent_author_signature" unless Signing.verify_signature(
data, data[:parent_author_signature], pkey
)
end
end
def self.update_signatures!(data)
if data[:author_signature].nil?
pkey = DiasporaFederation.callbacks.trigger(:fetch_private_key_by_id, data[:diaspora_id])
data[:author_signature] = Signing.sign_with_key(data, pkey) unless pkey.nil?
end
if data[:parent_author_signature].nil?
pkey = DiasporaFederation.callbacks.trigger(:fetch_private_key_by_post_guid, data[:parent_guid])
data[:parent_author_signature] = Signing.sign_with_key(data, pkey) unless pkey.nil?
end
data
end
end
end
end

View file

@ -68,17 +68,29 @@ module DiasporaFederation
private
def determine_xml_name(name, type, opts={})
raise ArgumentError, "xml_name is not supported for nested entities" if type != String && opts.has_key?(:xml_name)
if type == String
if opts.has_key? :xml_name
raise InvalidName, "invalid xml_name" unless name_valid?(opts[:xml_name])
opts[:xml_name]
else
name
end
elsif type.instance_of?(Array)
type.first.entity_name.to_sym
elsif type.ancestors.include?(Entity)
type.entity_name.to_sym
else
raise ArgumentError, "unknown type #{type} supplied"
end
end
def define_property(name, type, opts={})
raise InvalidName unless name_valid?(name)
xml_name = name
if opts.has_key? :xml_name
raise ArgumentError, "xml_name is not supported for nested entities" unless type == String
xml_name = opts[:xml_name]
raise InvalidName, "invalid xml_name" unless name_valid?(xml_name)
end
class_props << {name: name, xml_name: xml_name, type: type}
class_props << {name: name, xml_name: determine_xml_name(name, type, opts), type: type}
default_props[name] = opts[:default] if opts.has_key? :default
instance_eval { attr_reader name }

View file

@ -11,7 +11,7 @@ module DiasporaFederation
# </XML>
#
# (The +post+ element is there for historic reasons...)
class XmlPayload
module XmlPayload
# Encapsulates an Entity inside the wrapping xml structure
# and returns the XML Object.
#
@ -85,19 +85,31 @@ module DiasporaFederation
# @param [Nokogiri::XML::Element] node xml nodes
# @return [Entity] instance
def self.populate_entity(klass, node)
data = {}
klass.class_props.each do |prop_def|
name = prop_def[:name]
type = prop_def[:type]
# Build a hash of attributes basing on XML tree. If elements are known in "props" they respect the Entity logic.
# All other elemnts are respected and attached to resulted hash as string.
# It is intended to build a hash invariable of an Entity definition, in order to support receiving objects
# from the future versions of Diaspora, where new elements may have been added.
xml_names = klass.class_props.map {|prop_def| prop_def[:xml_name].to_s }
if type == String
data[name] = parse_string_from_node(prop_def[:xml_name], node)
elsif type.instance_of?(Array)
data[name] = parse_array_from_node(type, node)
elsif type.ancestors.include?(Entity)
data[name] = parse_entity_from_node(type, node)
data = node.element_children.map { |child|
xml_name = child.name
if xml_names.include?(xml_name)
prop = klass.class_props.find {|prop| prop[:xml_name].to_s == xml_name }
type = prop[:type]
if type == String
[prop[:name], parse_string_from_node(xml_name, node)]
elsif type.instance_of?(Array)
[prop[:name], parse_array_from_node(type, node)]
elsif type.ancestors.include?(Entity)
[prop[:name], parse_entity_from_node(type, node)]
end
else
[xml_name, child.text]
end
end
}.to_h
Entities::Relayable.verify_signatures(data) if klass.included_modules.include?(Entities::Relayable)
klass.new(data)
end

View file

@ -0,0 +1,48 @@
module DiasporaFederation
module Signing
extend Logging
# @param [OpenSSL::PKey::RSA] key An RSA key
# @return [String] A Base64 encoded signature of #signable_string with key
def self.sign_with_key(hash, key)
sig = Base64.strict_encode64(
key.sign(
OpenSSL::Digest::SHA256.new,
signable_string(hash)
)
)
logger.info "event=sign_with_key status=complete guid=#{hash[:guid]}"
sig
end
# Check that signature is a correct signature
#
# @param [String] signature The signature to be verified.
# @param [OpenSSL::PKey::RSA] key An RSA key
# @return [Boolean]
def self.verify_signature(hash, signature, key)
if key.nil?
logger.warn "event=verify_signature status=abort reason=no_key guid=#{hash[:guid]}"
return false
elsif signature.nil?
logger.warn "event=verify_signature status=abort reason=no_signature guid=#{hash[:guid]}"
return false
end
validity = key.verify(
OpenSSL::Digest::SHA256.new,
Base64.decode64(signature),
signable_string(hash)
)
logger.info "event=verify_signature status=complete guid=#{hash[:guid]} validity=#{validity}"
validity
end
private
def self.signable_string(hash)
hash.map { |name, value|
value.to_s unless name.match(/signature/)
}.compact.join(";")
end
end
end

View file

@ -4,6 +4,23 @@ def r_str
SecureRandom.hex(3)
end
#
# Sort hash according to an entity class's property sequence.
# This is used for rspec tests in order to generate correct input hash to
# compare results with.
#
def sort_hash(data, klass)
klass.class_props.map { |prop|
[prop[:name], data[prop[:name]]] unless data[prop[:name]].nil?
}.compact.to_h
end
def relayable_attributes_with_signatures(entity_type)
DiasporaFederation::Entities::Relayable.update_signatures!(
sort_hash(FactoryGirl.attributes_for(entity_type), FactoryGirl.factory_by_name(entity_type).build_class)
)
end
FactoryGirl.define do
initialize_with { new(attributes) }
sequence(:guid) { UUID.generate :compact }
@ -95,8 +112,6 @@ FactoryGirl.define do
factory :relayable_entity, class: DiasporaFederation::Entities::Relayable do
parent_guid { generate(:guid) }
parent_author_signature { generate(:signature) }
author_signature { generate(:signature) }
end
factory :participation_entity, class: DiasporaFederation::Entities::Participation, parent: :relayable_entity do

View file

@ -1,6 +1,6 @@
module DiasporaFederation
describe Entities::Comment do
let(:data) { FactoryGirl.attributes_for(:comment_entity) }
let(:data) { relayable_attributes_with_signatures(:comment_entity) }
let(:xml) {
<<-XML
@ -18,5 +18,7 @@ XML
it_behaves_like "an Entity subclass"
it_behaves_like "an XML Entity"
it_behaves_like "a relayable Entity"
end
end

View file

@ -1,7 +1,9 @@
module DiasporaFederation
describe Entities::Conversation do
let(:msg1) { FactoryGirl.build(:message_entity) }
let(:msg2) { FactoryGirl.build(:message_entity) }
let(:msg1_data) { relayable_attributes_with_signatures(:message_entity) }
let(:msg2_data) { relayable_attributes_with_signatures(:message_entity) }
let(:msg1) { FactoryGirl.build(:message_entity, msg1_data) }
let(:msg2) { FactoryGirl.build(:message_entity, msg2_data) }
let(:data) {
FactoryGirl.attributes_for(:conversation_entity).merge!(
messages: [msg1, msg2],

View file

@ -1,6 +1,6 @@
module DiasporaFederation
describe Entities::Like do
let(:data) { FactoryGirl.attributes_for(:like_entity) }
let(:data) { relayable_attributes_with_signatures(:like_entity) }
let(:xml) {
<<-XML
@ -19,5 +19,7 @@ XML
it_behaves_like "an Entity subclass"
it_behaves_like "an XML Entity"
it_behaves_like "a relayable Entity"
end
end

View file

@ -1,6 +1,6 @@
module DiasporaFederation
describe Entities::Message do
let(:data) { FactoryGirl.attributes_for(:message_entity) }
let(:data) { relayable_attributes_with_signatures(:message_entity) }
let(:xml) {
<<-XML
@ -20,5 +20,7 @@ XML
it_behaves_like "an Entity subclass"
it_behaves_like "an XML Entity"
it_behaves_like "a relayable Entity"
end
end

View file

@ -1,6 +1,6 @@
module DiasporaFederation
describe Entities::Participation do
let(:data) { FactoryGirl.attributes_for(:participation_entity) }
let(:data) { relayable_attributes_with_signatures(:participation_entity) }
let(:xml) {
<<-XML
@ -18,5 +18,7 @@ XML
it_behaves_like "an Entity subclass"
it_behaves_like "an XML Entity"
it_behaves_like "a relayable Entity"
end
end

View file

@ -1,6 +1,6 @@
module DiasporaFederation
describe Entities::PollParticipation do
let(:data) { FactoryGirl.attributes_for(:poll_participation_entity) }
let(:data) { relayable_attributes_with_signatures(:poll_participation_entity) }
let(:xml) {
<<-XML
@ -18,5 +18,7 @@ XML
it_behaves_like "an Entity subclass"
it_behaves_like "an XML Entity"
it_behaves_like "a relayable Entity"
end
end

View file

@ -0,0 +1,125 @@
module DiasporaFederation
describe Entities::Relayable do
let(:author_pkey) { OpenSSL::PKey::RSA.generate(1024) }
let(:parent_pkey) { OpenSSL::PKey::RSA.generate(1024) }
let(:hash) {
{
diaspora_id: FactoryGirl.generate(:diaspora_id),
parent_guid: FactoryGirl.generate(:guid),
some_other_data: "a_random_string"
}
}
describe ".verify_signatures" do
it "doesn't raise anything if correct data were passed" do
hash[:author_signature] = Signing.sign_with_key(hash, author_pkey)
hash[:parent_author_signature] = Signing.sign_with_key(hash, parent_pkey)
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_public_key_by_id, hash[:diaspora_id])
.and_return(author_pkey.public_key)
expect(DiasporaFederation.callbacks).to receive(:trigger)
.with(:fetch_public_key_by_post_guid, hash[:parent_guid])
.and_return(parent_pkey.public_key)
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:post_author_is_local?, hash[:parent_guid])
.and_return(false)
expect { Entities::Relayable.verify_signatures(hash) }.not_to raise_error
end
it "raises when no public key for author was fetched" do
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_public_key_by_id, anything)
.and_return(nil)
expect { Entities::Relayable.verify_signatures(hash) }.to raise_error(
Entities::Relayable::SignatureVerificationFailed
)
end
it "raises when bad author signature was passed" do
hash[:author_signature] = nil
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_public_key_by_id, hash[:diaspora_id])
.and_return(author_pkey.public_key)
expect { Entities::Relayable.verify_signatures(hash) }.to raise_error(
Entities::Relayable::SignatureVerificationFailed
)
end
it "raises when no public key for parent author was fetched" do
hash[:author_signature] = Signing.sign_with_key(hash, author_pkey)
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_public_key_by_id, hash[:diaspora_id])
.and_return(author_pkey.public_key)
expect(DiasporaFederation.callbacks).to receive(:trigger)
.with(:fetch_public_key_by_post_guid, hash[:parent_guid])
.and_return(nil)
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:post_author_is_local?, hash[:parent_guid])
.and_return(false)
expect { Entities::Relayable.verify_signatures(hash) }.to raise_error(
Entities::Relayable::SignatureVerificationFailed
)
end
it "raises when bad parent author signature was passed" do
hash[:author_signature] = Signing.sign_with_key(hash, author_pkey)
hash[:parent_author_signature] = nil
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_public_key_by_id, hash[:diaspora_id])
.and_return(author_pkey.public_key)
expect(DiasporaFederation.callbacks).to receive(:trigger)
.with(:fetch_public_key_by_post_guid, hash[:parent_guid])
.and_return(parent_pkey.public_key)
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:post_author_is_local?, hash[:parent_guid])
.and_return(false)
expect { Entities::Relayable.verify_signatures(hash) }.to raise_error(
Entities::Relayable::SignatureVerificationFailed
)
end
it "doesn't raise if parent_author_signature isn't set but we're on upstream federation" do
hash[:author_signature] = Signing.sign_with_key(hash, author_pkey)
hash[:parent_author_signature] = nil
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_public_key_by_id, hash[:diaspora_id])
.and_return(author_pkey.public_key)
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:post_author_is_local?, hash[:parent_guid])
.and_return(true)
expect { Entities::Relayable.verify_signatures(hash) }.not_to raise_error
end
end
describe ".update_singatures!" do
it "updates signatures when they were nil and keys were supplied" do
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_private_key_by_id, hash[:diaspora_id])
.and_return(author_pkey)
expect(DiasporaFederation.callbacks).to receive(:trigger)
.with(:fetch_private_key_by_post_guid, hash[:parent_guid])
.and_return(parent_pkey)
Entities::Relayable.update_signatures!(hash)
expect(Signing.verify_signature(hash, hash[:author_signature], author_pkey)).to be_truthy
expect(Signing.verify_signature(hash, hash[:parent_author_signature], parent_pkey)).to be_truthy
end
it "doesn't change signatures if they are already set" do
signatures = {author_signature: "aa", parent_author_signature: "bb"}
hash.merge!(signatures)
Entities::Relayable.update_signatures!(hash)
expect(hash[:author_signature]).to eq(signatures[:author_signature])
expect(hash[:parent_author_signature]).to eq(signatures[:parent_author_signature])
end
it "doesn't change signatures if keys weren't supplied" do
expect(DiasporaFederation.callbacks).to receive(:trigger).with(:fetch_private_key_by_id, hash[:diaspora_id])
.and_return(nil)
expect(DiasporaFederation.callbacks).to receive(:trigger)
.with(:fetch_private_key_by_post_guid, hash[:parent_guid])
.and_return(nil)
Entities::Relayable.update_signatures!(hash)
expect(hash[:author_signature]).to eq(nil)
expect(hash[:parent_author_signature]).to eq(nil)
end
end
end
end

View file

@ -121,6 +121,35 @@ XML
expect(entity.test).to eq("asdf")
expect(entity.qwer).to eq("qwer")
end
it "doesn't drop unknown properties" do
xml = <<-XML
<XML>
<post>
<test_entity>
<a_prop_from_newer_diaspora_version>some value</a_prop_from_newer_diaspora_version>
<test>asdf</test>
<some_random_property>another value</some_random_property>
</test_entity>
</post>
</XML>
XML
expect(Entities::TestEntity).to receive(:new).with(
"a_prop_from_newer_diaspora_version" => "some value",
:test => "asdf",
"some_random_property" => "another value"
)
Salmon::XmlPayload.unpack(Nokogiri::XML::Document.parse(xml).root)
end
end
context "relayable signature verification feature support" do
it "calls signatures verification on relayable unpack" do
entity = FactoryGirl.build(:comment_entity)
payload = Salmon::XmlPayload.pack(entity)
expect(Entities::Relayable).to receive(:verify_signatures).once
Salmon::XmlPayload.unpack(payload)
end
end
context "nested entities" do

View file

@ -0,0 +1,60 @@
module DiasporaFederation
describe Signing do
let(:pkey) {
OpenSSL::PKey::RSA.new <<-RSA
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQDT7vBTAl0Z55bPcBjM9dvSOTuVtBxsgfrw2W0hTAYpd1H5032C
cVW3mqd0l/9BHscgudVFAkvp+nf+wTQILn4qH4YAhOdWgrlSBA6Rbs3cmtmXzGNq
oQr4NOMbqs6sP+bBjDuDdB+cAFms/NDUH3cHBKPXi3e3csxiErmN1zyfWwIDAQAB
AoGAbpBC1CtxgqgtJz8l0ReafIvbJ/h0s68DyU7E/g/5TvyuyZSp77lMrKKEJfF9
+u0hmVMZjgzqqcA/haopiPMoYcJAwwhJLeXAgAWA+8j60Y524WLDcMPwMxQvVFd9
3FYXdOalojDoS34BWeBy6Gt+lLGyDvo/NnJBqIMPN0/KzYECQQDuslE4f1+RHhUq
wf2rL/7gCgrnkDOcH1SPjN2FrKG5ALmjThCq7Wr1Umj81uvmglfpIRY/ORgYgujA
kwNTB1ohAkEA40v0mHaYDegL//jucFmx/iK9Bs/722rJGIXI7bGIwLRC1hW101h3
DLMEMT0QaamVEEnrXFdqhjz+bfYfqUkh+wJAU3a+t8ayIAgo1p6mmKlbsfNRBM+D
fF/oLZnQC+HlWs9KGjQ918bU05tRYre0HRIOs1ICeXD5X/jGci/1xZ6YgQJAJony
Zwd0sKbvoe8rPpF2xIhPVKBfK8znW+kTMHoxnbryuinkMnmFdfnEdDTOW5wNUj22
Umnf/fLJkQtyQtnLkQJBANMoQPrP6aMRh45bhq+y6DbzHHHc2T5cuGBCtnhu+qrK
hWHXqQT4rArfq8YBpvDUa7qD13WwFGK3TPRpQSVGzNg=
-----END RSA PRIVATE KEY-----
RSA
}
let(:hash) {
{
param1: "1",
param2: "2",
signature: "SIGNATURE_VALUE==",
param3: "3",
parent_signature: "SIGNATURE2_VALUE==",
param4: "4"
}
}
let(:signature) {
"OesXlpesuLcA0t8gPyBjvznvkl0pz63p8z6+o2fxFNUaZkuR6YQv/sJOTSMPYBAFwcWr048Ol7yw4jSHq0gFCdBBeF7Mg287jktCie"\
"xa6G6mA24hBlOWnyRJLV2OyqcTU1P5pXWlUc1Mbwbr6bSIs6VK9djFMLLQ6wjjpusJ0XU="
}
describe ".signable_string" do
it "forms correct string for a hash" do
expect(Signing.signable_string(hash)).to eq("1;2;3;4")
end
end
describe ".sign_with_key" do
it "produces correct signature" do
expect(Signing.sign_with_key(hash, pkey)).to eq(signature)
end
end
describe ".verify_signature" do
it "verifies correct signature" do
expect(Signing.verify_signature(hash, signature, pkey)).to be_truthy
end
it "doesn't verify wrong signature" do
expect(Signing.verify_signature(hash, "false signature==", pkey)).to be_falsy
end
end
end
end

View file

@ -33,6 +33,10 @@ def alice
@alice ||= Person.find_by(diaspora_id: "alice@localhost:3000")
end
def test_pkey
DiasporaFederation.callbacks.trigger(:fetch_private_key_by_id)
end
# Requires supporting files with custom matchers and macros, etc,
# in ./support/ and its subdirectories.
fixture_builder_file = "#{File.dirname(__FILE__)}/support/fixture_builder.rb"

View file

@ -70,3 +70,22 @@ shared_examples "an XML Entity" do
end
end
end
shared_examples "a relayable Entity" do
let(:instance) { described_class.new(data.merge(author_signature: nil, parent_author_signature: nil)) }
context "signatures generation" do
it "computes correct signatures for the entity" do
hash = instance.to_h
xml = DiasporaFederation::Salmon::XmlPayload.pack(instance)
author_signature = xml.at_xpath("post/*[1]/author_signature").text
parent_author_signature = xml.at_xpath("post/*[1]/parent_author_signature").text
expect(DiasporaFederation::Signing.verify_signature(hash, author_signature, test_pkey))
.to be_truthy
expect(DiasporaFederation::Signing.verify_signature(hash, parent_author_signature, test_pkey))
.to be_truthy
end
end
end

View file

@ -58,5 +58,29 @@ DiasporaFederation.configure do |config|
serialized_public_key: person.exported_key, url: person.url).save!
end
end
def pkey
@test_pkey ||= OpenSSL::PKey::RSA.generate(1024)
end
on :fetch_private_key_by_id do
pkey
end
on :fetch_private_key_by_post_guid do
pkey
end
on :fetch_public_key_by_id do
pkey.public_key
end
on :fetch_public_key_by_post_guid do
pkey.public_key
end
on :post_author_is_local? do
false
end
end
end