diff --git a/docs/_entities/account_migration.md b/docs/_entities/account_migration.md new file mode 100644 index 0000000..072355a --- /dev/null +++ b/docs/_entities/account_migration.md @@ -0,0 +1,59 @@ +--- +title: AccountMigration +--- + +This entity is sent when a person changes their diaspora* ID (e.g. when a user migration from one to another pod happens). + +## Properties + +| Property | Type | Description | +| ----------- | ---------------------------- | ------------------------------------------------------------------------------------ | +| `author` | [diaspora\* ID][diaspora-id] | The diaspora\* ID of the closed account. | +| `person` | [Profile][profile] | New profile of a person | +| `signature` | [Signature][signature] | Signature that validates original and target diaspora* IDs with the new key of person | + +### Signature + +The signature base string is produced by concatenating the following substrings together, separated by semicolon (`:`): + +1) The entity name specifier: `AccountMigration`. + +2) diaspora\* ID of the closed account (old diaspora\* ID). + +3) diaspora\* ID of the replacement account (new diaspora\* ID). + +Example of a string: + +~~~ +AccountMigration:old-diaspora-id@example.org:new-diaspora-id@example.com +~~~ + +## Example + +~~~xml + + alice@example.org + + alice@newpod.example.net + my name + + /assets/user/default.png + /assets/user/default.png + /assets/user/default.png + 1988-07-15 + Female + some text about me + github + true + false + #i #love #tags + + + 07b1OIY6sTUQwV5pbpgFK0uz6W4cu+oQnlg410Q4uISUOdNOlBdYqhZJm62VFhgvzt4TZXfiJgoupFkRjP0BsaVaZuP2zKMNvO3ngWOeJRf2oRK4Ub5cEA/g7yijkRc+7y8r1iLJ31MFb1czyeCsLxw9Ol8SvAJddogGiLHDhjE= + + +~~~ + +[diaspora-id]: {{ site.baseurl }}/federation/types.html#diaspora-id +[profile]: {{ site.baseurl }}/entities/profile.html +[signature]: {{ site.baseurl }}/federation/types.html#signature diff --git a/lib/diaspora_federation/entities.rb b/lib/diaspora_federation/entities.rb index 6760c56..5ae794b 100644 --- a/lib/diaspora_federation/entities.rb +++ b/lib/diaspora_federation/entities.rb @@ -12,6 +12,7 @@ require "diaspora_federation/entities/related_entity" # abstract types require "diaspora_federation/entities/post" +require "diaspora_federation/entities/signable" require "diaspora_federation/entities/relayable" # types @@ -19,6 +20,7 @@ require "diaspora_federation/entities/profile" require "diaspora_federation/entities/person" require "diaspora_federation/entities/contact" require "diaspora_federation/entities/account_deletion" +require "diaspora_federation/entities/account_migration" require "diaspora_federation/entities/participation" require "diaspora_federation/entities/like" diff --git a/lib/diaspora_federation/entities/account_migration.rb b/lib/diaspora_federation/entities/account_migration.rb new file mode 100644 index 0000000..8085bc4 --- /dev/null +++ b/lib/diaspora_federation/entities/account_migration.rb @@ -0,0 +1,74 @@ +module DiasporaFederation + module Entities + # This entity is sent when a person changes their diaspora* ID (e.g. when a user migration + # from one to another pod happens). + # + # @see Validators::AccountMigrationValidator + class AccountMigration < Entity + include Signable + + # @!attribute [r] author + # The old diaspora* ID of the person who changes their ID + # @see Person#author + # @return [String] author diaspora* ID + property :author, :string + + # @!attribute [r] profile + # Holds new updated profile of a person, including diaspora* ID + # @return [Person] person new data + entity :profile, Entities::Profile + + # @!attribute [r] signature + # Signature that validates original and target diaspora* IDs with the new key of person + # @return [String] signature + property :signature, :string, default: nil + + # @return [String] string representation of this object + def to_s + "AccountMigration:#{author}:#{profile.author}" + end + + # Shortcut for calling super method with sensible arguments + # + # @see DiasporaFederation::Entities::Signable#verify_signature + def verify_signature + super(profile.author, :signature) + end + + # Calls super and additionally does signature verification for the instantiated entity. + # + # @see DiasporaFederation::Entity.from_hash + def self.from_hash(*args) + super.tap(&:verify_signature) + end + + private + + # @see DiasporaFederation::Entities::Signable#signature_data + def signature_data + to_s + end + + def enriched_properties + super.tap do |hash| + hash[:signature] = signature || sign_with_new_key + end + end + + # Sign with new user's key + # @raise [NewPrivateKeyNotFound] if the new user's private key is not found + # @return [String] A Base64 encoded signature of #signature_data with key + def sign_with_new_key + privkey = DiasporaFederation.callbacks.trigger(:fetch_private_key, profile.author) + raise NewPrivateKeyNotFound, "author=#{profile.author} obj=#{self}" if privkey.nil? + sign_with_key(privkey).tap do + logger.info "event=sign status=complete signature=signature author=#{profile.author} obj=#{self}" + end + end + + # Raised, if creating the signature fails, because the new private key of a user was not found + class NewPrivateKeyNotFound < RuntimeError + end + end + end +end diff --git a/lib/diaspora_federation/entities/relayable.rb b/lib/diaspora_federation/entities/relayable.rb index 4ed432f..ce30e69 100644 --- a/lib/diaspora_federation/entities/relayable.rb +++ b/lib/diaspora_federation/entities/relayable.rb @@ -5,10 +5,7 @@ module DiasporaFederation # has a parent, identified by guid. Relayables are also signed and signing/verification # logic is embedded into Salmon XML processing code. module Relayable - include Logging - - # Digest instance used for signing - DIGEST = OpenSSL::Digest::SHA256.new + include Signable # Order from the parsed xml for signature # @return [Array] order from xml @@ -79,7 +76,8 @@ module DiasporaFederation end # Verifies the signatures (+author_signature+ and +parent_author_signature+ if needed). - # @raise [SignatureVerificationFailed] if the signature is not valid or no public key is found + # @raise [SignatureVerificationFailed] if the signature is not valid + # @raise [PublicKeyNotFound] if no public key is found def verify_signatures verify_signature(author, :author_signature) @@ -107,24 +105,6 @@ module DiasporaFederation private - # Check that signature is a correct signature - # - # @param [String] author The author of the signature - # @param [String] signature_key The signature to be verified - # @return [Boolean] signature valid - def verify_signature(author, signature_key) - pubkey = DiasporaFederation.callbacks.trigger(:fetch_public_key, author) - raise PublicKeyNotFound, "signature=#{signature_key} person=#{author} obj=#{self}" if pubkey.nil? - - signature = public_send(signature_key) - raise SignatureVerificationFailed, "no #{signature_key} for #{self}" if signature.nil? - - valid = pubkey.verify(DIGEST, Base64.decode64(signature), signature_data) - raise SignatureVerificationFailed, "wrong #{signature_key} for #{self}" unless valid - - logger.info "event=verify_signature signature=#{signature_key} status=valid obj=#{self}" - end - # Sign with author key # @raise [AuthorPrivateKeyNotFound] if the author private key is not found # @return [String] A Base64 encoded signature of #signature_data with key @@ -147,14 +127,6 @@ module DiasporaFederation end end - # Sign the data with the key - # - # @param [OpenSSL::PKey::RSA] privkey An RSA key - # @return [String] A Base64 encoded signature of #signature_data with key - def sign_with_key(privkey) - Base64.strict_encode64(privkey.sign(DIGEST, signature_data)) - end - # Update the signatures with the keys of the author and the parent # if the signatures are not there yet and if the keys are available. # @@ -245,14 +217,6 @@ module DiasporaFederation # Raised, if creating the author_signature fails, because the private key was not found class AuthorPrivateKeyNotFound < RuntimeError end - - # Raised, if verify_signatures fails to verify signatures (no public key found) - class PublicKeyNotFound < RuntimeError - end - - # Raised, if verify_signatures fails to verify signatures (signatures are wrong) - class SignatureVerificationFailed < RuntimeError - end end end end diff --git a/lib/diaspora_federation/entities/signable.rb b/lib/diaspora_federation/entities/signable.rb new file mode 100644 index 0000000..d78d4a5 --- /dev/null +++ b/lib/diaspora_federation/entities/signable.rb @@ -0,0 +1,54 @@ +module DiasporaFederation + module Entities + # Signable is a module that encapsulates basic signature generation/verification flow for entities. + module Signable + include Logging + + # Digest instance used for signing + DIGEST = OpenSSL::Digest::SHA256.new + + # Sign the data with the key + # + # @param [OpenSSL::PKey::RSA] privkey An RSA key + # @return [String] A Base64 encoded signature of #signature_data with key + def sign_with_key(privkey) + Base64.strict_encode64(privkey.sign(DIGEST, signature_data)) + end + + # Check that signature is a correct signature + # + # @param [String] author The author of the signature + # @param [String] signature_key The signature to be verified + # @raise [SignatureVerificationFailed] if the signature is not valid + # @raise [PublicKeyNotFound] if no public key is found + def verify_signature(author, signature_key) + pubkey = DiasporaFederation.callbacks.trigger(:fetch_public_key, author) + raise PublicKeyNotFound, "signature=#{signature_key} person=#{author} obj=#{self}" if pubkey.nil? + + signature = public_send(signature_key) + raise SignatureVerificationFailed, "no #{signature_key} for #{self}" if signature.nil? + + valid = pubkey.verify(DIGEST, Base64.decode64(signature), signature_data) + raise SignatureVerificationFailed, "wrong #{signature_key} for #{self}" unless valid + + logger.info "event=verify_signature signature=#{signature_key} status=valid obj=#{self}" + end + + # This method defines what data is used for a signature creation/verification + # + # @abstract + # @return [String] a string to sign + def signature_data + raise NotImplementedError.new("you must override this method to define signature base string") + end + + # Raised, if verify_signatures fails to verify signatures (no public key found) + class PublicKeyNotFound < RuntimeError + end + + # Raised, if verify_signatures fails to verify signatures (signatures are wrong) + class SignatureVerificationFailed < RuntimeError + end + end + end +end diff --git a/lib/diaspora_federation/test/factories.rb b/lib/diaspora_federation/test/factories.rb index e27e2f2..673492e 100644 --- a/lib/diaspora_federation/test/factories.rb +++ b/lib/diaspora_federation/test/factories.rb @@ -41,6 +41,13 @@ module DiasporaFederation searchable true end + factory :account_migration_entity, class: DiasporaFederation::Entities::AccountMigration do + author { generate(:diaspora_id) } + profile { + FactoryGirl.build(:profile_entity) + } + end + factory :person_entity, class: DiasporaFederation::Entities::Person do guid author { generate(:diaspora_id) } diff --git a/lib/diaspora_federation/validators.rb b/lib/diaspora_federation/validators.rb index 6fea7e4..5b0d3ed 100644 --- a/lib/diaspora_federation/validators.rb +++ b/lib/diaspora_federation/validators.rb @@ -41,6 +41,7 @@ require "diaspora_federation/validators/relayable_validator" # types require "diaspora_federation/validators/account_deletion_validator" +require "diaspora_federation/validators/account_migration_validator" require "diaspora_federation/validators/comment_validator" require "diaspora_federation/validators/contact_validator" require "diaspora_federation/validators/conversation_validator" diff --git a/lib/diaspora_federation/validators/account_migration_validator.rb b/lib/diaspora_federation/validators/account_migration_validator.rb new file mode 100644 index 0000000..7570fd1 --- /dev/null +++ b/lib/diaspora_federation/validators/account_migration_validator.rb @@ -0,0 +1,12 @@ +module DiasporaFederation + module Validators + # This validates a {Entities::AccountMigration}. + class AccountMigrationValidator < Validation::Validator + include Validation + + rule :author, %i(not_empty diaspora_id) + + rule :profile, :not_nil + end + end +end diff --git a/spec/lib/diaspora_federation/entities/account_migration_spec.rb b/spec/lib/diaspora_federation/entities/account_migration_spec.rb new file mode 100644 index 0000000..560260e --- /dev/null +++ b/spec/lib/diaspora_federation/entities/account_migration_spec.rb @@ -0,0 +1,119 @@ +module DiasporaFederation + describe Entities::AccountMigration do + let(:new_diaspora_id) { alice.diaspora_id } + let(:new_author_pkey) { alice.private_key } + let(:hash) { + FactoryGirl.attributes_for( + :account_migration_entity, + profile: FactoryGirl.build(:profile_entity, author: new_diaspora_id) + ) + } + let(:data) { + hash.tap {|hash| + properties = described_class.new(hash).send(:enriched_properties) + hash[:signature] = properties[:signature] + } + } + let(:signature_data) { "AccountMigration:#{hash[:author]}:#{new_diaspora_id}" } + + let(:xml) { <<-XML } + + #{data[:author]} + + #{data[:profile].author} + #{data[:profile].first_name} + + #{data[:profile].image_url} + #{data[:profile].image_url} + #{data[:profile].image_url} + #{data[:profile].birthday} + #{data[:profile].gender} + #{data[:profile].bio} + #{data[:profile].location} + #{data[:profile].searchable} + #{data[:profile].nsfw} + #{data[:profile].tag_string} + + #{data[:signature]} + +XML + + let(:string) { "AccountMigration:#{data[:author]}:#{data[:profile].author}" } + + it_behaves_like "an Entity subclass" + + it_behaves_like "an XML Entity" + + describe "#to_xml" do + it "computes signature when no signature was provided" do + expect_callback(:fetch_private_key, new_diaspora_id).and_return(new_author_pkey) + + entity = Entities::AccountMigration.new(hash) + xml = entity.to_xml + + signature = xml.at_xpath("signature").text + expect(verify_signature(new_author_pkey, signature, entity.to_s)).to be_truthy + end + + it "doesn't change signature if it is already set" do + hash[:signature] = "aa" + + xml = Entities::AccountMigration.new(hash).to_xml + + expect(xml.at_xpath("signature").text).to eq("aa") + end + + it "raises when signature isn't set and key isn't supplied" do + expect_callback(:fetch_private_key, new_diaspora_id).and_return(nil) + + expect { + Entities::AccountMigration.new(hash).to_xml + }.to raise_error Entities::AccountMigration::NewPrivateKeyNotFound + end + end + + describe "#verify_signature" do + it "doesn't raise anything if correct signatures were passed" do + hash[:signature] = sign_with_key(new_author_pkey, signature_data) + expect_callback(:fetch_public_key, new_diaspora_id).and_return(new_author_pkey) + expect { Entities::AccountMigration.new(hash).verify_signature }.not_to raise_error + end + + it "raises when no public key for author was fetched" do + expect_callback(:fetch_public_key, anything).and_return(nil) + + expect { + Entities::AccountMigration.new(hash).verify_signature + }.to raise_error Entities::AccountMigration::PublicKeyNotFound + end + + it "raises when bad author signature was passed" do + hash[:signature] = "abcdef" + + expect_callback(:fetch_public_key, new_diaspora_id).and_return(new_author_pkey.public_key) + + expect { + Entities::AccountMigration.new(hash).verify_signature + }.to raise_error Entities::AccountMigration::SignatureVerificationFailed + end + end + + describe ".from_hash" do + it "calls #verify_signature" do + expect_any_instance_of(Entities::AccountMigration).to receive(:freeze) + expect_any_instance_of(Entities::AccountMigration).to receive(:verify_signature) + Entities::AccountMigration.from_hash(hash) + end + + it "raises when bad author signature was passed" do + hash[:signature] = "abcdef" + + expect_callback(:fetch_public_key, new_diaspora_id).and_return(new_author_pkey.public_key) + + expect { + Entities::AccountMigration.from_hash(hash) + }.to raise_error Entities::AccountMigration::SignatureVerificationFailed + end + end + end +end diff --git a/spec/lib/diaspora_federation/entities/relayable_spec.rb b/spec/lib/diaspora_federation/entities/relayable_spec.rb index 9161fc7..004d25e 100644 --- a/spec/lib/diaspora_federation/entities/relayable_spec.rb +++ b/spec/lib/diaspora_federation/entities/relayable_spec.rb @@ -15,14 +15,6 @@ module DiasporaFederation let(:legacy_signature_data) { "#{guid};#{author};#{property};#{parent_guid}" } - def sign_with_key(privkey, signature_data) - Base64.strict_encode64(privkey.sign(OpenSSL::Digest::SHA256.new, signature_data)) - end - - def verify_signature(pubkey, signature, signed_string) - pubkey.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), signed_string) - end - describe "#initialize" do it "filters signatures from order" do xml_order = [:author, :guid, :parent_guid, :property, "new_property", :author_signature] diff --git a/spec/lib/diaspora_federation/entities/signable_spec.rb b/spec/lib/diaspora_federation/entities/signable_spec.rb new file mode 100644 index 0000000..bcb31c3 --- /dev/null +++ b/spec/lib/diaspora_federation/entities/signable_spec.rb @@ -0,0 +1,77 @@ +module DiasporaFederation + describe Entities::Signable do + TEST_STRING_VALUE = "abc123".freeze + let(:private_key) { OpenSSL::PKey::RSA.generate(1024) } + let(:test_string) { TEST_STRING_VALUE } + let(:test_signature) { sign_with_key(private_key, test_string) } + + class TestSignableEntity < Entity + include Entities::Signable + + property :my_signature, :string, default: nil + + def signature_data + TEST_STRING_VALUE + end + end + + describe "#signature_data" do + it "raises NotImplementedError when not overridden" do + class TestEntity < Entity + include Entities::Signable + end + + expect { + TestEntity.new({}).signature_data + }.to raise_error(NotImplementedError) + end + end + + describe "#sign_with_key" do + it "produces a correct signature" do + signature = TestSignableEntity.new({}).sign_with_key(private_key) + expect(verify_signature(private_key.public_key, signature, test_string)).to be_truthy + end + end + + describe "#verify_signature" do + it "doesn't raise if signature is correct" do + expect_callback(:fetch_public_key, "id@example.tld").and_return(private_key.public_key) + + expect { + TestSignableEntity + .new(my_signature: test_signature) + .verify_signature("id@example.tld", :my_signature) + }.not_to raise_error + end + + it "raises PublicKeyNotFound when key isn't provided" do + expect_callback(:fetch_public_key, "id@example.tld").and_return(nil) + + expect { + TestSignableEntity + .new(my_signature: test_signature) + .verify_signature("id@example.tld", :my_signature) + }.to raise_error(Entities::Signable::PublicKeyNotFound) + end + + it "raises SignatureVerificationFailed when signature isn't provided" do + expect_callback(:fetch_public_key, "id@example.tld").and_return(private_key.public_key) + + expect { + TestSignableEntity.new({}).verify_signature("id@example.tld", :my_signature) + }.to raise_error(Entities::Signable::SignatureVerificationFailed) + end + + it "raises SignatureVerificationFailed when signature is wrong" do + expect_callback(:fetch_public_key, "id@example.tld").and_return(private_key.public_key) + + expect { + TestSignableEntity + .new(my_signature: "faked signature") + .verify_signature("id@example.tld", :my_signature) + }.to raise_error(Entities::Signable::SignatureVerificationFailed) + end + end + end +end diff --git a/spec/lib/diaspora_federation/validators/account_migration_validator_spec.rb b/spec/lib/diaspora_federation/validators/account_migration_validator_spec.rb new file mode 100644 index 0000000..eca0e5d --- /dev/null +++ b/spec/lib/diaspora_federation/validators/account_migration_validator_spec.rb @@ -0,0 +1,20 @@ +module DiasporaFederation + describe Validators::AccountMigrationValidator do + let(:entity) { :account_migration_entity } + + it_behaves_like "a common validator" + + it_behaves_like "a diaspora* ID validator" do + let(:property) { :author } + let(:mandatory) { true } + end + + describe "#person" do + it_behaves_like "a property with a value validation/restriction" do + let(:property) { :profile } + let(:wrong_values) { [nil] } + let(:correct_values) { [] } + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9f07f48..c5f7454 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -45,6 +45,14 @@ def add_signatures(hash, klass=described_class) hash[:parent_author_signature] = properties[:parent_author_signature] end +def sign_with_key(privkey, signature_data) + Base64.strict_encode64(privkey.sign(OpenSSL::Digest::SHA256.new, signature_data)) +end + +def verify_signature(pubkey, signature, signed_string) + pubkey.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), signed_string) +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"