diff --git a/lib/diaspora_federation/salmon.rb b/lib/diaspora_federation/salmon.rb index ec46941..c2cb7a5 100644 --- a/lib/diaspora_federation/salmon.rb +++ b/lib/diaspora_federation/salmon.rb @@ -2,8 +2,14 @@ module DiasporaFederation # This module contains a Diaspora*-specific implementation of parts of the # {http://www.salmon-protocol.org/ Salmon Protocol}. module Salmon + # XML namespace url + XMLNS = "https://joindiaspora.com/protocol" end end +require "diaspora_federation/salmon/aes" require "diaspora_federation/salmon/exceptions" require "diaspora_federation/salmon/xml_payload" +require "diaspora_federation/salmon/magic_envelope" +require "diaspora_federation/salmon/slap" +require "diaspora_federation/salmon/encrypted_slap" diff --git a/lib/diaspora_federation/salmon/aes.rb b/lib/diaspora_federation/salmon/aes.rb new file mode 100644 index 0000000..3a4929d --- /dev/null +++ b/lib/diaspora_federation/salmon/aes.rb @@ -0,0 +1,43 @@ +module DiasporaFederation + module Salmon + class AES + # OpenSSL aes cipher definition + CIPHER = "AES-256-CBC" + + # encrypts the given data with a new, random AES cipher and returns the + # resulting ciphertext, the key and iv in a hash (each of the entries + # base64 strict_encoded). + # @param [String] data plain input + # @return [Hash] { key: "...", iv: "...", ciphertext: "..." } + def self.encrypt(data) + cipher = OpenSSL::Cipher.new(CIPHER) + cipher.encrypt + key = cipher.random_key + iv = cipher.random_iv + ciphertext = cipher.update(data) + cipher.final + + enc = [key, iv, ciphertext].map {|i| Base64.strict_encode64(i) } + + {key: enc[0], iv: enc[1], ciphertext: enc[2]} + end + + # decrypts the given ciphertext with an AES cipher defined by the given key + # and iv. parameters are expected to be base64 encoded + # @param [String] ciphertext input data + # @param [String] key AES key + # @param [String] iv AES initialization vector + # @return [String] decrypted plain message + def self.decrypt(ciphertext, key, iv) + dec = [ciphertext, key, iv].map {|i| Base64.decode64(i) } + + decipher = OpenSSL::Cipher.new(CIPHER) + decipher.decrypt + decipher.key = dec[1] + decipher.iv = dec[2] + + plain = decipher.update(dec[0]) + decipher.final + plain + end + end + end +end diff --git a/lib/diaspora_federation/salmon/encrypted_slap.rb b/lib/diaspora_federation/salmon/encrypted_slap.rb new file mode 100644 index 0000000..7e76247 --- /dev/null +++ b/lib/diaspora_federation/salmon/encrypted_slap.rb @@ -0,0 +1,202 @@ +module DiasporaFederation + module Salmon + # +EncryptedSlap+ provides class methods for generating and parsing encrypted + # Slaps. (In principle the same as {Slap}, but with encryption.) + # + # The basic encryption mechanism used here is based on the knowledge that + # asymmetrical encryption is slow and symmetrical encryption is fast. Keeping in + # mind that a message we want to de-/encrypt may greatly vary in length, + # performance considerations must play a part of this scheme. + # + # A Diaspora*-flavored encrypted magic-enveloped XML message looks like the following: + # + # + # + # {encrypted_header} + # {magic_envelope with encrypted data} + # + # + # The encrypted header is encoded in JSON like this (when in plain text): + # + # { + # "aes_key" => "...", + # "ciphertext" => "..." + # } + # + # +aes_key+ is encrypted using the recipients public key, and contains the AES + # +key+ and +iv+ used to encrypt the +ciphertext+ also encoded as JSON. + # + # { + # "key" => "...", + # "iv" => "..." + # } + # + # +ciphertext+, once decrypted, contains the +author_id+, +aes_key+ and +iv+ + # relevant to the decryption of the data in the magic_envelope and the + # verification of its signature. + # + # The decrypted cyphertext has this XML structure: + # + # + # {iv} + # {aes_key} + # {author_id} + # + # + # Finally, before decrypting the magic envelope payload, the signature should + # first be verified. + # + # @example Generating an encrypted Salmon Slap + # author_id = "author@pod.example.tld" + # author_privkey = however_you_retrieve_the_authors_private_key(author_id) + # recipient_pubkey = however_you_retrieve_the_recipients_public_key() + # entity = YourEntity.new(attr: "val") + # + # slap_xml = EncryptedSlap.generate_xml(author_id, author_privkey, entity, recipient_pubkey) + # + # @example Parsing a Salmon Slap + # recipient_privkey = however_you_retrieve_the_recipients_private_key() + # slap = EncryptedSlap.from_xml(slap_xml, recipient_privkey) + # author_pubkey = however_you_retrieve_the_authors_public_key(slap.author_id) + # + # entity = slap.entity(author_pubkey) + # + class EncryptedSlap + # Creates a Slap instance from the data within the given XML string + # containing an encrypted payload. + # + # @param [String] slap_xml encrypted Salmon xml + # @param [OpenSSL::PKey::RSA] pkey recipient private_key for decryption + # + # @return [Slap] new Slap instance + # + # @raise [ArgumentError] if any of the arguments is of the wrong type + # @raise [MissingHeader] if the +encrypted_header+ element is missing in the XML + # @raise [MissingMagicEnvelope] if the +me:env+ element is missing in the XML + def self.from_xml(slap_xml, pkey) + raise ArgumentError unless slap_xml.instance_of?(String) && pkey.instance_of?(OpenSSL::PKey::RSA) + doc = Nokogiri::XML::Document.parse(slap_xml) + ns = {d: Salmon::XMLNS, me: MagicEnvelope::XMLNS} + header_xpath = "d:diaspora/d:encrypted_header" + magicenv_xpath = "d:diaspora/me:env" + + if doc.namespaces.empty? + ns = nil + header_xpath = "diaspora/encrypted_header" + magicenv_xpath = "diaspora/env" + end + + slap = Slap.new + + header_elem = doc.at_xpath(header_xpath, ns) + raise MissingHeader if header_elem.nil? + header = header_data(header_elem.content, pkey) + slap.author_id = header[:author_id] + slap.cipher_params = {key: header[:aes_key], iv: header[:iv]} + + magic_env_elem = doc.at_xpath(magicenv_xpath, ns) + raise MissingMagicEnvelope if magic_env_elem.nil? + slap.magic_envelope = magic_env_elem + + slap + end + + # Creates an encrypted Salmon Slap and returns the XML string. + # + # @param [String] author_id Diaspora* handle of the author + # @param [OpenSSL::PKey::RSA] pkey sender private key for signing the magic envelope + # @param [Entity] entity payload + # @param [OpenSSL::PKey::RSA] pubkey recipient public key for encrypting the AES key + # @return [String] Salmon XML string + # @raise [ArgumentError] if any of the arguments is of the wrong type + def self.generate_xml(author_id, pkey, entity, pubkey) + raise ArgumentError unless author_id.instance_of?(String) && + pkey.instance_of?(OpenSSL::PKey::RSA) && + entity.is_a?(Entity) && + pubkey.instance_of?(OpenSSL::PKey::RSA) + + doc = Nokogiri::XML::Document.new + doc.encoding = "UTF-8" + + root = Nokogiri::XML::Element.new("diaspora", doc) + root.default_namespace = Salmon::XMLNS + root.add_namespace("me", MagicEnvelope::XMLNS) + doc.root = root + + magic_envelope = MagicEnvelope.new(pkey, entity, root) + envelope_key = magic_envelope.encrypt! + + encrypted_header(author_id, envelope_key, pubkey, root) + magic_envelope.envelop + + doc.to_xml + end + + # decrypts and reads the data from the encrypted XML header + # @param [String] base64 encoded, encrypted header data + # @param [OpenSSL::PKey::RSA] private_key for decryption + # @return [Hash] { iv: "...", aes_key: "...", author_id: "..." } + def self.header_data(data, pkey) + header_elem = decrypt_header(data, pkey) + raise InvalidHeader unless header_elem.name == "decrypted_header" + + iv = header_elem.at_xpath("iv").content + key = header_elem.at_xpath("aes_key").content + author_id = header_elem.at_xpath("author_id").content + + {iv: iv, aes_key: key, author_id: author_id} + end + private_class_method :header_data + + # decrypts the xml header + # @param [String] base64 encoded, encrypted header data + # @param [OpenSSL::PKey::RSA] private_key for decryption + # @return [Nokogiri::XML::Element] header xml document + def self.decrypt_header(data, pkey) + cipher_header = JSON.parse(Base64.decode64(data)) + header_key = JSON.parse(pkey.private_decrypt(Base64.decode64(cipher_header["aes_key"]))) + + xml = AES.decrypt(cipher_header["ciphertext"], header_key["key"], header_key["iv"]) + Nokogiri::XML::Document.parse(xml).root + end + private_class_method :decrypt_header + + # encrypt the header xml with an AES cipher and encrypt the cipher params + # with the recipients public_key + # @param [String] diaspora_handle + # @param [Hash] envelope cipher params + # @param [OpenSSL::PKey::RSA] recipient public_key + # @param parent_node [Nokogiri::XML::Element] parent element for insering in XML document + def self.encrypted_header(author_id, envelope_key, pubkey, parent_node) + data = header_xml(author_id, envelope_key) + encryption_data = AES.encrypt(data) + + json_key = JSON.generate(key: encryption_data[:key], iv: encryption_data[:iv]) + encrypted_key = Base64.strict_encode64(pubkey.public_encrypt(json_key)) + + json_header = JSON.generate(aes_key: encrypted_key, ciphertext: encryption_data[:ciphertext]) + + header = Nokogiri::XML::Element.new("encrypted_header", parent_node.document) + header.content = Base64.strict_encode64(json_header) + parent_node << header + end + private_class_method :encrypted_header + + # generate the header xml string, including the author, aes_key and iv + # @param [String] diaspora_handle of the author + # @param [Hash] { key: "...", iv: "..." } (values in base64) + # @return [String] header XML string + def self.header_xml(author_id, envelope_key) + builder = Nokogiri::XML::Builder.new do |xml| + xml.decrypted_header { + xml.iv(envelope_key[:iv]) + xml.aes_key(envelope_key[:key]) + xml.author_id(author_id) + } + end + builder.to_xml.strip + end + private_class_method :header_xml + end + end +end diff --git a/lib/diaspora_federation/salmon/magic_envelope.rb b/lib/diaspora_federation/salmon/magic_envelope.rb new file mode 100644 index 0000000..4cc56f4 --- /dev/null +++ b/lib/diaspora_federation/salmon/magic_envelope.rb @@ -0,0 +1,182 @@ +module DiasporaFederation + module Salmon + # Represents a Magic Envelope for Diaspora* federation messages. + # + # When generating a Magic Envelope, an instance of this class is created and + # the contents are specified on initialization. Optionally, the payload can be + # encrypted ({MagicEnvelope#encrypt!}), before the XML is returned + # ({MagicEnvelope#envelop}). + # + # The generated XML appears like so: + # + # + # {data} + # base64url + # RSA-SHA256 + # {signature} + # + # + # When parsing the XML of an incoming Magic Envelope {MagicEnvelope.unenvelop} + # is used. + # + # @see http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-magicsig-01.html + class MagicEnvelope + # returns the payload (only used for testing purposes) + attr_reader :payload + + # encoding used for the payload data + ENCODING = "base64url" + + # algorithm used for signing the payload data + ALGORITHM = "RSA-SHA256" + + # mime type describing the payload data + DATA_TYPE = "application/xml" + + # digest instance used for signing + DIGEST = OpenSSL::Digest::SHA256.new + + # XML namespace url + XMLNS = "http://salmon-protocol.org/ns/magic-env" + + # Creates a new instance of MagicEnvelope. + # + # @param rsa_pkey [OpenSSL::PKey::RSA] private key used for signing + # @param payload [Entity] Entity instance + # @param parent_node [Nokogiri::XML::Element] parent element for insering in XML document + # @raise [ArgumentError] if either argument is not of the right type + def initialize(rsa_pkey, payload, parent_node=nil) + raise ArgumentError unless rsa_pkey.instance_of?(OpenSSL::PKey::RSA) && + payload.is_a?(Entity) + + if parent_node.nil? + doc = Nokogiri::XML::Document.new + parent_node = Nokogiri::XML::Element.new("root", doc) + parent_node.add_namespace("me", XMLNS) + doc.root = parent_node + end + + @parent_node = parent_node + @rsa_pkey = rsa_pkey + @payload = XmlPayload.pack(payload).to_xml.strip + end + + # Builds the XML structure for the magic envelope, inserts the {ENCODING} + # encoded data and signs the envelope using {DIGEST}. + # + # @return [Nokogiri::XML::Element] XML root node + def envelop + builder = Nokogiri::XML::Builder.with(@parent_node) do |xml| + xml["me"].env { + xml["me"].data(Base64.urlsafe_encode64(@payload), type: DATA_TYPE) + xml["me"].encoding(ENCODING) + xml["me"].alg(ALGORITHM) + xml["me"].sig(Base64.urlsafe_encode64(signature)) + } + end + + builder.doc.at_xpath("//me:env") + end + + # Encrypts the payload with a new, random AES cipher and returns the cipher + # params that were used. + # + # This must happen after the MagicEnvelope instance was created and before + # {MagicEnvelope#envelop} is called. + # + # @see Salmon.aes_encrypt + # + # @return [Hash] AES key and iv. E.g.: { key: "...", iv: "..." } + def encrypt! + encryption_data = AES.encrypt(@payload) + @payload = encryption_data[:ciphertext] + + {key: encryption_data[:key], iv: encryption_data[:iv]} + end + + # Extracts the entity encoded in the magic envelope data, if the signature + # is valid. If +cipher_params+ is given, also attempts to decrypt the payload first. + # + # Does some sanity checking to avoid bad surprises... + # + # @see XmlPayload.unpack + # @see Salmon.aes_decrypt + # + # @param [Nokogiri::XML::Element] magic_env XML root node of a magic envelope + # @param [OpenSSL::PKey::RSA] rsa_pubkey public key to verify the signature + # @param [Hash] cipher_params hash containing the key and iv for + # AES-decrypting previously encrypted data. E.g.: { iv: "...", key: "..." } + # + # @return [Entity] reconstructed entity instance + # + # @raise [ArgumentError] if any of the arguments is of invalid type + # @raise [InvalidEnvelope] if the envelope XML structure is malformed + # @raise [InvalidSignature] if the signature can't be verified + # @raise [InvalidEncoding] if the data is wrongly encoded + # @raise [InvalidAlgorithm] if the algorithm used doesn't match + def self.unenvelop(magic_env, rsa_pubkey, cipher_params=nil) + raise ArgumentError unless rsa_pubkey.instance_of?(OpenSSL::PKey::RSA) && + magic_env.instance_of?(Nokogiri::XML::Element) + + raise InvalidEnvelope unless envelope_valid?(magic_env) + raise InvalidSignature unless signature_valid?(magic_env, rsa_pubkey) + + enc = magic_env.at_xpath("me:encoding").content + alg = magic_env.at_xpath("me:alg").content + + raise InvalidEncoding unless enc == ENCODING + raise InvalidAlgorithm unless alg == ALGORITHM + + data = Base64.urlsafe_decode64(magic_env.at_xpath("me:data").content) + unless cipher_params.nil? + data = AES.decrypt(data, cipher_params[:key], cipher_params[:iv]) + end + + XmlPayload.unpack(Nokogiri::XML::Document.parse(data).root) + end + + private + + # create the signature for all fields according to specification + def signature + subject = self.class.sig_subject([@payload, + DATA_TYPE, + ENCODING, + ALGORITHM]) + @rsa_pkey.sign(DIGEST, subject) + end + + # @param [Nokogiri::XML::Element] + def self.envelope_valid?(env) + (env.instance_of?(Nokogiri::XML::Element) && + env.name == "env" && + !env.at_xpath("me:data").content.empty? && + !env.at_xpath("me:encoding").content.empty? && + !env.at_xpath("me:alg").content.empty? && + !env.at_xpath("me:sig").content.empty?) + end + private_class_method :envelope_valid? + + # @param [Nokogiri::XML::Element] + # @param [OpenSSL::PKey::RSA] public_key + def self.signature_valid?(env, pkey) + subject = sig_subject([Base64.urlsafe_decode64(env.at_xpath("me:data").content), + env.at_xpath("me:data")["type"], + env.at_xpath("me:encoding").content, + env.at_xpath("me:alg").content]) + + sig = Base64.urlsafe_decode64(env.at_xpath("me:sig").content) + pkey.verify(DIGEST, sig, subject) + end + private_class_method :signature_valid? + + # constructs the signature subject. + # the given array should consist of the data, data_type (mimetype), encoding + # and the algorithm + # @param [Array] + def self.sig_subject(data_arr) + data_arr.map {|i| Base64.urlsafe_encode64(i) }.join(".") + end + end + end +end diff --git a/lib/diaspora_federation/salmon/slap.rb b/lib/diaspora_federation/salmon/slap.rb new file mode 100644 index 0000000..22c2bce --- /dev/null +++ b/lib/diaspora_federation/salmon/slap.rb @@ -0,0 +1,119 @@ +module DiasporaFederation + module Salmon + # +Slap+ provides class methods to create unencrypted Slap XML from payload + # data and parse incoming XML into a Slap instance. + # + # A Diaspora*-flavored magic-enveloped XML message looks like the following: + # + # + # + #
+ # {author} + #
+ # {magic_envelope} + #
+ # + # @example Generating a Salmon Slap + # author_id = "author@pod.example.tld" + # author_privkey = however_you_retrieve_the_authors_private_key(author_id) + # entity = YourEntity.new(attr: "val") + # + # slap_xml = Slap.generate_xml(author_id, author_privkey, entity) + # + # @example Parsing a Salmon Slap + # slap = Slap.from_xml(slap_xml) + # author_pubkey = however_you_retrieve_the_authors_public_key(slap.author_id) + # + # entity = slap.entity(author_pubkey) + # + class Slap + attr_accessor :author_id, :magic_envelope, :cipher_params + + # Returns new instance of the Entity that is contained within the XML of + # this Slap. + # + # The first time this is called, a public key has to be specified to verify + # the Magic Envelope signature. On repeated calls, the key may be omitted. + # + # @see MagicEnvelope.unenvelop + # + # @param [OpenSSL::PKey::RSA] pubkey public key for validating the signature + # @return [Entity] entity instance from the XML + # @raise [ArgumentError] if the public key is of the wrong type + def entity(pubkey=nil) + return @entity unless @entity.nil? + + raise ArgumentError unless pubkey.instance_of?(OpenSSL::PKey::RSA) + @entity = MagicEnvelope.unenvelop(magic_envelope, pubkey, @cipher_params) + @entity + end + + # Parses an unencrypted Salmon XML string and returns a new instance of + # {Slap} populated with the XML data. + # + # @param [String] slap_xml Salmon XML + # @return [Slap] new Slap instance + # @raise [ArgumentError] if the argument is not a String + # @raise [MissingAuthor] if the +author_id+ element is missing from the XML + # @raise [MissingMagicEnvelope] if the +me:env+ element is missing from the XML + def self.from_xml(slap_xml) + raise ArgumentError unless slap_xml.instance_of?(String) + doc = Nokogiri::XML::Document.parse(slap_xml) + ns = {d: Salmon::XMLNS, me: MagicEnvelope::XMLNS} + author_xpath = "d:diaspora/d:header/d:author_id" + magicenv_xpath = "d:diaspora/me:env" + + if doc.namespaces.empty? + ns = nil + author_xpath = "diaspora/header/author_id" + magicenv_xpath = "diaspora/env" + end + + slap = Slap.new + + author_elem = doc.at_xpath(author_xpath, ns) + raise MissingAuthor if author_elem.nil? || author_elem.content.empty? + slap.author_id = author_elem.content + + magic_env_elem = doc.at_xpath(magicenv_xpath, ns) + raise MissingMagicEnvelope if magic_env_elem.nil? + slap.magic_envelope = magic_env_elem + + slap + end + + # Creates an unencrypted Salmon Slap and returns the XML string. + # + # @param [String] author_id Diaspora* handle of the author + # @param [OpenSSL::PKey::RSA] pkey sender private_key for signing the magic envelope + # @param [Entity] entity payload + # @return [String] Salmon XML string + # @raise [ArgumentError] if any of the arguments is not the correct type + def self.generate_xml(author_id, pkey, entity) + raise ArgumentError unless author_id.instance_of?(String) && + pkey.instance_of?(OpenSSL::PKey::RSA) && + entity.is_a?(Entity) + + doc = Nokogiri::XML::Document.new + doc.encoding = "UTF-8" + + root = Nokogiri::XML::Element.new("diaspora", doc) + root.default_namespace = Salmon::XMLNS + root.add_namespace("me", MagicEnvelope::XMLNS) + doc.root = root + + header = Nokogiri::XML::Element.new("header", doc) + root << header + + author = Nokogiri::XML::Element.new("author_id", doc) + author.content = author_id + header << author + + magic_envelope = MagicEnvelope.new(pkey, entity, root) + magic_envelope.envelop + + doc.to_xml + end + end + end +end diff --git a/spec/lib/diaspora_federation/salmon/encrypted_slap_spec.rb b/spec/lib/diaspora_federation/salmon/encrypted_slap_spec.rb new file mode 100644 index 0000000..c9b1198 --- /dev/null +++ b/spec/lib/diaspora_federation/salmon/encrypted_slap_spec.rb @@ -0,0 +1,117 @@ +module DiasporaFederation + describe Salmon::EncryptedSlap do + let(:author_id) { "user_test@diaspora.example.tld" } + let(:pkey) { OpenSSL::PKey::RSA.generate(512) } # use small key for speedy specs + let(:okey) { OpenSSL::PKey::RSA.generate(1024) } # use small key for speedy specs + let(:entity) { Entities::TestEntity.new(test: "qwertzuiop") } + let(:slap_xml) { Salmon::EncryptedSlap.generate_xml(author_id, pkey, entity, okey.public_key) } + let(:ns) { {d: Salmon::XMLNS, me: Salmon::MagicEnvelope::XMLNS} } + + context ".generate_xml" do + context "sanity" do + it "accepts correct params" do + expect { + Salmon::EncryptedSlap.generate_xml(author_id, pkey, entity, okey.public_key) + }.not_to raise_error + end + + it "raises an error when the params are the wrong type" do + ["asdf", 1234, true, :symbol, entity, pkey].each do |val| + expect { + Salmon::EncryptedSlap.generate_xml(val, val, val, val) + }.to raise_error ArgumentError + end + end + end + + it "generates valid xml" do + doc = Nokogiri::XML::Document.parse(slap_xml) + expect(doc.root.name).to eq("diaspora") + expect(doc.at_xpath("d:diaspora/d:encrypted_header", ns).content).to_not be_empty + expect(doc.xpath("d:diaspora/me:env", ns)).to have(1).item + end + + context "header" do + subject { + doc = Nokogiri::XML::Document.parse(slap_xml) + doc.at_xpath("d:diaspora/d:encrypted_header", ns).content + } + let(:cipher_header) { JSON.parse(Base64.decode64(subject)) } + let(:header_key) { + JSON.parse(okey.private_decrypt(Base64.decode64(cipher_header["aes_key"]))) + } + + it "encoded the header correctly" do + json_header = {} + expect { + json_header = JSON.parse(Base64.decode64(subject)) + }.not_to raise_error + expect(json_header).to include("aes_key", "ciphertext") + end + + it "encrypted the public_key encrypted header correctly" do + key = {} + expect { + key = JSON.parse(okey.private_decrypt(Base64.decode64(cipher_header["aes_key"]))) + }.not_to raise_error + expect(key).to include("key", "iv") + end + + it "encrypted the aes encrypted header correctly" do + header = "" + expect { + header = Salmon::AES.decrypt(cipher_header["ciphertext"], + header_key["key"], + header_key["iv"]) + }.not_to raise_error + header_doc = Nokogiri::XML::Document.parse(header) + expect(header_doc.root.name).to eq("decrypted_header") + expect(header_doc.xpath("//iv")).to have(1).item + expect(header_doc.xpath("//aes_key")).to have(1).item + expect(header_doc.xpath("//author_id")).to have(1).item + expect(header_doc.at_xpath("//author_id").content).to eq(author_id) + end + end + end + + context ".from_xml" do + context "sanity" do + it "accepts correct params" do + expect { + Salmon::EncryptedSlap.from_xml(slap_xml, okey) + }.not_to raise_error + end + + it "raises an error when the params have a wrong type" do + [1234, false, :symbol, entity, pkey].each do |val| + expect { + Salmon::EncryptedSlap.from_xml(val, val) + }.to raise_error ArgumentError + end + end + + it "verifies the existence of 'encrypted_header'" do + faulty_xml = < + +XML + expect { + Salmon::EncryptedSlap.from_xml(faulty_xml, okey) + }.to raise_error Salmon::MissingHeader + end + + it "verifies the existence of a magic envelope" do + faulty_xml = < + + +XML + expect(Salmon::EncryptedSlap).to receive(:header_data).and_return(aes_key: "", iv: "", author_id: "") + expect { + Salmon::EncryptedSlap.from_xml(faulty_xml, okey) + }.to raise_error Salmon::MissingMagicEnvelope + end + end + end + end +end diff --git a/spec/lib/diaspora_federation/salmon/magic_envelope_spec.rb b/spec/lib/diaspora_federation/salmon/magic_envelope_spec.rb new file mode 100644 index 0000000..523c17e --- /dev/null +++ b/spec/lib/diaspora_federation/salmon/magic_envelope_spec.rb @@ -0,0 +1,162 @@ +module DiasporaFederation + describe Salmon::MagicEnvelope do + let(:payload) { Entities::TestEntity.new(test: "asdf") } + let(:pkey) { OpenSSL::PKey::RSA.generate(512) } # use small key for speedy specs + let(:envelope) { Salmon::MagicEnvelope.new(pkey, payload).envelop } + + def sig_subj(env) + data = Base64.urlsafe_decode64(env.at_xpath("me:data").content) + type = env.at_xpath("me:data")["type"] + enc = env.at_xpath("me:encoding").content + alg = env.at_xpath("me:alg").content + + [data, type, enc, alg].map {|i| Base64.urlsafe_encode64(i) }.join(".") + end + + def re_sign(env, key) + new_sig = Base64.urlsafe_encode64(key.sign(OpenSSL::Digest::SHA256.new, sig_subj(env))) + env.at_xpath("me:sig").content = new_sig + end + + context "sanity" do + it "constructs an instance" do + expect { + Salmon::MagicEnvelope.new(pkey, payload) + }.not_to raise_error + end + + it "raises an error if the param types are wrong" do + ["asdf", 1234, :test, false].each do |val| + expect { + Salmon::MagicEnvelope.new(val, val) + }.to raise_error ArgumentError + end + end + end + + context "#envelop" do + subject { Salmon::MagicEnvelope.new(pkey, payload) } + + it "should be an instance of Nokogiri::XML::Element" do + expect(subject.envelop).to be_an_instance_of Nokogiri::XML::Element + end + + it "returns a magic envelope of correct structure" do + env = subject.envelop + expect(env.name).to eq("env") + + control = %w(data encoding alg sig) + env.children.each do |node| + expect(control).to include(node.name) + control.reject! {|i| i == node.name } + end + + expect(control).to be_empty + end + + it "signs the payload correctly" do + env = subject.envelop + + subj = sig_subj(env) + sig = Base64.urlsafe_decode64(env.at_xpath("me:sig").content) + + expect(pkey.public_key.verify(OpenSSL::Digest::SHA256.new, sig, subj)).to be_truthy + end + end + + context "#encrypt!" do + subject { Salmon::MagicEnvelope.new(pkey, payload) } + + it "encrypts the payload, returning cipher params" do + params = {} + expect { + params = subject.encrypt! + }.not_to raise_error + expect(params).to include(:key, :iv) + end + + it "actually encrypts the payload" do + plain_payload = subject.payload + params = subject.encrypt! + encrypted_payload = subject.payload + + cipher = OpenSSL::Cipher.new(Salmon::AES::CIPHER) + cipher.encrypt + cipher.iv = Base64.decode64(params[:iv]) + cipher.key = Base64.decode64(params[:key]) + + ciphertext = cipher.update(plain_payload) + cipher.final + + expect(Base64.strict_encode64(ciphertext)).to eq(encrypted_payload) + end + end + + context ".unenvelop" do + context "sanity" do + it "works with sane input" do + expect { + Salmon::MagicEnvelope.unenvelop(envelope, pkey.public_key) + }.not_to raise_error + end + + it "raises an error if the param types are wrong" do + ["asdf", 1234, :test, false].each do |val| + expect { + Salmon::MagicEnvelope.unenvelop(val, val) + }.to raise_error ArgumentError + end + end + + it "verifies the envelope structure" do + expect { + Salmon::MagicEnvelope.unenvelop(Nokogiri::XML::Document.parse("").root, pkey.public_key) + }.to raise_error Salmon::InvalidEnvelope + end + + it "verifies the signature" do + other_key = OpenSSL::PKey::RSA.generate(512) + expect { + Salmon::MagicEnvelope.unenvelop(envelope, other_key.public_key) + }.to raise_error Salmon::InvalidSignature + end + + it "verifies the encoding" do + bad_env = Salmon::MagicEnvelope.new(pkey, payload).envelop + elem = bad_env.at_xpath("me:encoding") + elem.content = "invalid_enc" + re_sign(bad_env, pkey) + expect { + Salmon::MagicEnvelope.unenvelop(bad_env, pkey.public_key) + }.to raise_error Salmon::InvalidEncoding + end + + it "verifies the algorithm" do + bad_env = Salmon::MagicEnvelope.new(pkey, payload).envelop + elem = bad_env.at_xpath("me:alg") + elem.content = "invalid_alg" + re_sign(bad_env, pkey) + expect { + Salmon::MagicEnvelope.unenvelop(bad_env, pkey.public_key) + }.to raise_error Salmon::InvalidAlgorithm + end + end + + it "returns the original entity" do + entity = Salmon::MagicEnvelope.unenvelop(envelope, pkey.public_key) + expect(entity).to be_an_instance_of Entities::TestEntity + expect(entity.test).to eq("asdf") + end + + it "decrypts on the fly, when cipher params are present" do + env = Salmon::MagicEnvelope.new(pkey, payload) + params = env.encrypt! + + envelope = env.envelop + + entity = Salmon::MagicEnvelope.unenvelop(envelope, pkey.public_key, params) + expect(entity).to be_an_instance_of Entities::TestEntity + expect(entity.test).to eq("asdf") + end + end + end +end diff --git a/spec/lib/diaspora_federation/salmon/slap_spec.rb b/spec/lib/diaspora_federation/salmon/slap_spec.rb new file mode 100644 index 0000000..dc1de9a --- /dev/null +++ b/spec/lib/diaspora_federation/salmon/slap_spec.rb @@ -0,0 +1,76 @@ +module DiasporaFederation + describe Salmon::Slap do + let(:author_id) { "test_user@pod.somedomain.tld" } + let(:pkey) { OpenSSL::PKey::RSA.generate(512) } # use small key for speedy specs + let(:entity) { Entities::TestEntity.new(test: "qwertzuiop") } + let(:slap) { Salmon::Slap.generate_xml(author_id, pkey, entity) } + + context ".generate_xml" do + context "sanity" do + it "accepts correct params" do + expect { + Salmon::Slap.generate_xml(author_id, pkey, entity) + }.not_to raise_error + end + + it "raises an error when the params are the wrong type" do + ["asdf", 1234, true, :symbol, entity, pkey].each do |val| + expect { + Salmon::Slap.generate_xml(val, val, val) + }.to raise_error ArgumentError + end + end + end + + it "generates valid xml" do + ns = {d: Salmon::XMLNS, me: Salmon::MagicEnvelope::XMLNS} + doc = Nokogiri::XML::Document.parse(slap) + expect(doc.root.name).to eq("diaspora") + expect(doc.at_xpath("d:diaspora/d:header/d:author_id", ns).content).to eq(author_id) + expect(doc.xpath("d:diaspora/me:env", ns)).to have(1).item + end + end + + context ".from_xml" do + context "sanity" do + it "accepts salmon xml as param" do + expect { + Salmon::Slap.from_xml(slap) + }.not_to raise_error + end + + it "raises an error when the param has a wrong type" do + [1234, false, :symbol, entity, pkey].each do |val| + expect { + Salmon::Slap.from_xml(val) + }.to raise_error ArgumentError + end + end + + it "verifies the existence of an author_id" do + faulty_xml = < +
+ +XML + expect { + Salmon::Slap.from_xml(faulty_xml) + }.to raise_error Salmon::MissingAuthor + end + + it "verifies the existence of a magic envelope" do + faulty_xml = <<-XML + +
+ #{author_id} +
+
+XML + expect { + Salmon::Slap.from_xml(faulty_xml) + }.to raise_error Salmon::MissingMagicEnvelope + end + end + end + end +end