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"