diff --git a/README.md b/README.md index ae7689e..2f6012a 100644 --- a/README.md +++ b/README.md @@ -54,19 +54,26 @@ When you want to notify a remote resource about an interaction (like a comment): your_rsa_keypair = OpenSSL::PKey::RSA.new 2048 - salmon = OStatus2::Salmon.new - envelope = salmon.pack(comment, your_rsa_keypair) + envelope = OStatus2::Salmon::MagicEnvelope.new(comment, your_rsa_keypair) - salmon.post('http://remote.salmon/endpoint', envelope) + OStatus2::Salmon::MagicEnvelope.post_xml('http://remote.salmon/endpoint', envelope.to_xml) When you receive a Salmon notification about a remote interaction: - salmon = OStatus2::Salmon.new - comment = salmon.unpack(envelope) + envelope = OStatus2::Salmon::MagicEnvelope(xml) - # Parse comment and determine who the remote author is pretending to be, - # fetch their public key via Webfinger or something like that, and finally + # Parse envelope.body and determine who the remote author is pretending to + # be, fetch their public key via Webfinger or something like that, and + # finally - if salmon.verify(envelope, remote_public_key) + if envelope.verify(remote_public_key) # You can be sure the salmon is genuine end + +## Generating documentation +This project uses YARD and its extension. Install and use it for generating +documentation. + + cd /path/to/ostatus2 + gem install yard + yard -e ostatus2_yard_handler.rb diff --git a/lib/ostatus2.rb b/lib/ostatus2.rb index feb4390..0711d73 100644 --- a/lib/ostatus2.rb +++ b/lib/ostatus2.rb @@ -4,16 +4,12 @@ require 'addressable' require 'nokogiri' -require 'ostatus2/version' -require 'ostatus2/publication' -require 'ostatus2/subscription' -require 'ostatus2/magic_key' -require 'ostatus2/salmon' - module OStatus2 class Error < StandardError end - - class BadSalmonError < Error - end end + +require 'ostatus2/version' +require 'ostatus2/publication' +require 'ostatus2/subscription' +require 'ostatus2/salmon' diff --git a/lib/ostatus2/magic_key.rb b/lib/ostatus2/magic_key.rb deleted file mode 100644 index 89ee260..0000000 --- a/lib/ostatus2/magic_key.rb +++ /dev/null @@ -1,35 +0,0 @@ -module OStatus2 - module MagicKey - if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.4.0') - def set_key(key, modulus, exponent) - key.n = modulus - key.e = exponent - end - else - def set_key(key, modulus, exponent) - key.set_key(modulus, exponent, nil) - end - end - - def magic_key_to_pem(magic_key) - _, modulus, exponent = magic_key.split('.') - modulus, exponent = [modulus, exponent].map { |n| decode_base64(n).bytes.inject(0) { |a, e| (a << 8) | e } } - - key = OpenSSL::PKey::RSA.new - set_key(key, modulus, exponent) - key.to_pem - end - - def decode_base64(string) - retries = 0 - - begin - return Base64::urlsafe_decode64(string) - rescue ArgumentError - retries += 1 - string = "#{string}=" - retry unless retries > 2 - end - end - end -end diff --git a/lib/ostatus2/salmon.rb b/lib/ostatus2/salmon.rb index d75268b..93ddc03 100644 --- a/lib/ostatus2/salmon.rb +++ b/lib/ostatus2/salmon.rb @@ -1,87 +1,33 @@ -module OStatus2 - class Salmon - include OStatus2::MagicKey - - XMLNS = 'http://salmon-protocol.org/ns/magic-env' - - # Create a magical envelope XML document around the original body - # and sign it with a private key - # @param [String] body - # @param [OpenSSL::PKey::RSA] key The private part of the key will be used - # @return [String] Magical envelope XML - def pack(body, key) - signed = plaintext_signature(body, 'application/atom+xml', 'base64url', 'RSA-SHA256') - signature = Base64.urlsafe_encode64(key.sign(digest, signed)) +require 'ostatus2/salmon/magic_envelope' +require 'ostatus2/salmon/magic_public_key' - Nokogiri::XML::Builder.new do |xml| - xml['me'].env({ 'xmlns:me' => XMLNS }) do - xml['me'].data({ type: 'application/atom+xml' }, Base64.urlsafe_encode64(body)) - xml['me'].encoding('base64url') - xml['me'].alg('RSA-SHA256') - xml['me'].sig({ key_id: Base64.urlsafe_encode64(key.public_key.to_s) }, signature) +module OStatus2 + # Provides features related to Salmon, conforming to draft-panzer-magicsig-00 + module Salmon + class << self + # Decode base64url, adding paddings if missing due to conformance with an obsolete specification + # @param [String] string String encoded with base64url, possibly missing paddings + # @return [String] Decoded string + def decode_base64url(string) + retries = 0 + + begin + return Base64::urlsafe_decode64(string) + rescue ArgumentError + retries += 1 + string = "#{string}=" + retry unless retries > 2 end - end.to_xml - end - - # Deliver the magical envelope to a Salmon endpoint - # @param [String] salmon_url Salmon endpoint URL - # @param [String] envelope Magical envelope - # @raise [HTTP::Error] Error raised upon delivery failure - # @raise [OpenSSL::SSL::SSLError] Error raised upon SSL-related failure during delivery - # @return [HTTP::Response] - def post(salmon_url, envelope) - http_client.headers(HTTP::Headers::CONTENT_TYPE => 'application/magic-envelope+xml').post(Addressable::URI.parse(salmon_url), body: envelope) - end - - # Unpack a magical envelope to get the content inside - # @param [String] raw_body Magical envelope - # @raise [OStatus2::BadSalmonError] Error raised if the envelope is malformed - # @return [String] Content inside the envelope - def unpack(raw_body) - body, _, _ = parse(raw_body) - body - end - - # Verify the magical envelope's integrity - # @param [String] raw_body Magical envelope - # @param [OpenSSL::PKey::RSA] key The public part of the key will be used - # @return [Boolean] - def verify(raw_body, key) - _, plaintext, signature = parse(raw_body) - key.public_key.verify(digest, signature, plaintext) - rescue OStatus2::BadSalmonError - false - end - - private - - def http_client - HTTP.timeout(:per_operation, write: 60, connect: 20, read: 60) - end - - def digest - OpenSSL::Digest::SHA256.new - end - - def parse(raw_body) - xml = Nokogiri::XML(raw_body) - - raise OStatus2::BadSalmonError if xml.at_xpath('//me:data', me: XMLNS).nil? || xml.at_xpath('//me:data', me: XMLNS).attribute('type').nil? || xml.at_xpath('//me:sig', me: XMLNS).nil? || xml.at_xpath('//me:encoding', me: XMLNS).nil? || xml.at_xpath('//me:alg', me: XMLNS).nil? - - data = xml.at_xpath('//me:data', me: XMLNS) - type = data.attribute('type').value - body = decode_base64(data.content.gsub(/\s+/, '')) - sig = xml.at_xpath('//me:sig', me: XMLNS) - signature = decode_base64(sig.content.gsub(/\s+/, '')) - encoding = xml.at_xpath('//me:encoding', me: XMLNS).content - alg = xml.at_xpath('//me:alg', me: XMLNS).content - plaintext = plaintext_signature(body, type, encoding, alg) - - [body, plaintext, signature] - end - - def plaintext_signature(data, type, encoding, alg) - [data, type, encoding, alg].map { |i| Base64.urlsafe_encode64(i) }.join('.') + end + + # Encode base64url (provided for symmetry with decode_base64url. + # see Base64.urlsafe_encode64.) + # @param (see Base64.urlsafe_encode64) + # @raise (see Base64.urlsafe_encode64) + # @return (see Base64.urlsafe_encode64) + def encode_base64url(*args) + Base64.urlsafe_encode64 *args + end end end end diff --git a/lib/ostatus2/salmon/magic_envelope.rb b/lib/ostatus2/salmon/magic_envelope.rb new file mode 100644 index 0000000..9b1eb79 --- /dev/null +++ b/lib/ostatus2/salmon/magic_envelope.rb @@ -0,0 +1,106 @@ +module OStatus2 + module Salmon + # Represents magic envelope + MagicEnvelope = Struct.new(:body, :sig, :key_id, :type, :encoding, :alg) do + # XML Namespace of magic envelope + XMLNS = 'http://salmon-protocol.org/ns/magic-env' + + # @!method initialize(body, sig, key_id, type='application/atom+xml', encoding='base64url', alg='RSA-SHA256') + # @param [String] body Content of data element or complete magic envelope XML serialization as the only argument + # @param [String, OpenSSL::PKey::RSA] sig A signature or a RSA key pair to sign + # @param [String] key_id key_id attribute of alg element + # (defaults to a PEM representation of a public key if provided) + # @param [String] type type attribute of data element + # @param [String] encoding Content of encoding element + # @param [String] alg Content of alg element + # @raise [OStatus2::Salmon::BadError] Error raised if the envelope is malformed + def initialize(*args) + if args.size == 1 + xml = Nokogiri::XML(args[0]) + + raise BadError if xml.at_xpath('//me:data', me: XMLNS).nil? || xml.at_xpath('//me:data', me: XMLNS).attribute('type').nil? || xml.at_xpath('//me:sig', me: XMLNS).nil? || xml.at_xpath('//me:encoding', me: XMLNS).nil? || xml.at_xpath('//me:alg', me: XMLNS).nil? + + data_element = xml.at_xpath('//me:data', me: XMLNS) + sig_element = xml.at_xpath('//me:sig', me: XMLNS) + encoding_element = xml.at_xpath('//me:encoding', me: XMLNS) + alg_element = xml.at_xpath('//me:alg', me: XMLNS) + + super OStatus2::Salmon::decode_base64url(data_element.content.gsub(/\s+/, '')), + OStatus2::Salmon::decode_base64url(sig_element.content.gsub(/\s+/, '')), + sig_element.attribute('key_id')&.value, + data_element.attribute('type').value, + encoding_element.content, + alg_element.content + else + args[3] ||= 'application/atom+xml' + args[4] ||= 'base64url' + args[5] ||= 'RSA-SHA256' + + if args[1].is_a?(OpenSSL::PKey::RSA) + key = args[1] + plaintext = plaintext_sig(args[0], args[3], args[4], args[5]) + args[1] = key.sign(digest, plaintext) + args[2] ||= OStatus2::Salmon.encode_base64url(key.public_key.to_s) + end + + super + end + end + + # Serialize a magic envelope into XML + # @return [String] Magic envelope XML serialization + def to_xml + Nokogiri::XML::Builder.new do |xml| + xml['me'].env({ 'xmlns:me' => XMLNS }) do + xml['me'].data({ type: type }, OStatus2::Salmon.encode_base64url(body)) + xml['me'].encoding(encoding) + xml['me'].alg(alg) + xml['me'].sig({ key_id: key_id }, OStatus2::Salmon.encode_base64url(sig)) + end + end.to_xml + end + + # Verify the magic envelope's integrity + # @param [OpenSSL::PKey::RSA] key The public part of the key will be used + # @return [Boolean] + def verify(key) + plaintext = plaintext_sig(body, type, encoding, alg) + key.public_key.verify(digest, sig, plaintext) + rescue BadError + false + end + + class << self + # Represents an error due to malformed magic envelope + class BadError < OStatus2::Error + end + + # Deliver the magic envelope XML serialization to a Salmon endpoint + # @param [String] salmon_url Salmon endpoint URL + # @param [String] envelope Magic envelope XML serialization + # @raise [HTTP::Error] Error raised upon delivery failure + # @raise [OpenSSL::SSL::SSLError] Error raised upon SSL-related failure during delivery + # @return [HTTP::Response] + def post_xml(salmon_url, envelope) + http_client.headers(HTTP::Headers::CONTENT_TYPE => 'application/magic-envelope+xml').post(Addressable::URI.parse(salmon_url), body: envelope) + end + + private + + def http_client + HTTP.timeout(:per_operation, write: 60, connect: 20, read: 60) + end + end + + private + + def digest + OpenSSL::Digest::SHA256.new + end + + def plaintext_sig(data, type, encoding, alg) + [data, type, encoding, alg].map { |i| OStatus2::Salmon.encode_base64url(i) }.join('.') + end + end + end +end diff --git a/lib/ostatus2/salmon/magic_public_key.rb b/lib/ostatus2/salmon/magic_public_key.rb new file mode 100644 index 0000000..da633e7 --- /dev/null +++ b/lib/ostatus2/salmon/magic_public_key.rb @@ -0,0 +1,37 @@ +module OStatus2 + module Salmon + # Represents public key + MagicPublicKey = Struct.new(:n, :e) do + # @!method initialize(n, e) + # @param [Numeric, String] n Modulus or a magic public key or + # public key in application/magic-key format as the only argument + # @param [Numeric] e Exponent + def initialize(*args) + if args.size == 1 + _, modulus, exponent = args[0].split('.') + decoded = [modulus, exponent].map { |n| OStatus2::Salmon.decode_base64url(n).bytes.inject(0) { |a, e| (a << 8) | e } } + super *decoded + else + super + end + end + + # Format itself into application/magic-key format + # @return [String] Public key in application/magic-key format + def format + encoded = [n, e].map do |component| + result = [] + + until component.zero? + result << [component % 256].pack('C') + component >>= 8 + end + + OStatus2::Salmon.encode_base64url(result.reverse.join) + end + + (['data:application/magic-public-key,RSA'] + encoded).join('.') + end + end + end +end diff --git a/ostatus2_yard_handler.rb b/ostatus2_yard_handler.rb new file mode 100644 index 0000000..5b21664 --- /dev/null +++ b/ostatus2_yard_handler.rb @@ -0,0 +1,14 @@ +# This is a backport of the following: +# Document block for Struct.new if present by akihikodaki · Pull Request #1099 · lsegal/yard +# https://github.com/lsegal/yard/pull/1099 +class OStatus2YARDHandler < YARD::Handlers::Ruby::Base + include YARD::Handlers::Ruby::StructHandlerMethods + handles :assign + + process do + if statement[1].call? && statement[1][0][0] == s(:const, 'Struct') && statement[1][2] == s(:ident, 'new') + parse_block(statement[1].block[1], + namespace: create_class(statement[0][0][0], P(:Struct))) + end + end +end diff --git a/spec/ostatus2/magic_key_spec.rb b/spec/ostatus2/magic_key_spec.rb deleted file mode 100644 index d3006f4..0000000 --- a/spec/ostatus2/magic_key_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'spec_helper' - -describe OStatus2::MagicKey do - subject { Class.new { extend OStatus2::MagicKey } } - - describe '#magic_key_to_pem' do - let(:magic_key) { 'data:application/magic-public-key,RSA.AKfeoEM7t8a5nBIudEnCZ37cXBw-QgijUmO3JGDFY0OJKrlwtMlUn9-7_dMpYQx_ehSIo1HrFfnVY4YLKQVfpwc.AQAB' } - - it 'returns a pem key' do - expect(subject.magic_key_to_pem(magic_key)).to be_a String - end - end - - describe '#decode_base64' do - it 'decodes padding-stripped base64' do - expect(subject.decode_base64('SGVsbG8gd29ybGQsIEkgYW0gZG9vbSwgYnJpbmdlciBvZiBiYWQgQmFzZTY0IGFuZCBiaWcgbnVtYmVycyBsaWtlIDk5OTI4ODg3MjM2NzY3ODI4Mg')).to eq 'Hello world, I am doom, bringer of bad Base64 and big numbers like 999288872367678282' - end - - it 'decodes normal urlsafe base64' do - expect(subject.decode_base64(Base64.urlsafe_encode64('Hello world'))).to eq 'Hello world' - end - end -end diff --git a/spec/ostatus2/salmon/magic_envelope_spec.rb b/spec/ostatus2/salmon/magic_envelope_spec.rb new file mode 100644 index 0000000..03854f3 --- /dev/null +++ b/spec/ostatus2/salmon/magic_envelope_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe OStatus2::Salmon::MagicEnvelope do + let(:url) { 'http://example.com/salmon' } + let(:body) { 'Lorem ipsum dolor sit amet' } + let(:key) { OpenSSL::PKey::RSA.new 2048 } + + subject { OStatus2::Salmon::MagicEnvelope.new(body, key) } + + describe '.new' do + it 'decodes the first argument if it is the only one' do + encoded = < + + TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQ= + base64url + RSA-SHA256 + 5ZwAIoos-jOB04CCVcs6zja5QUs3N32R7gxLiQJeZY9oZe4goVshI2QQne17BuoyZNsuN09pf0YA_J-1T3R92l0rWvfkYa0FVycehKCrYr67Zk-pwb8ww3J3pjM8dqz4pjk1jcyNEVcIQtwomMT3Z9wNI8DKAezNpEDdKOe-bM2ZXg_w1qTBQNa5ZFWCQ8Spc_g7uX_1GX9X0xexbJ4lvTPxRPtPU4I-u3gHP5YjHXxbRiqEFipjMklqZILsCRVlk7tFwteS3mmYW_9BkNu1YADkV0BfCrg2GShyeWrjOXR-krflLxkl4Bxn6T-NHlzD8vOnxcWXvNlXOChl7md_GQ== + +XML + + envelope = OStatus2::Salmon::MagicEnvelope.new(encoded) + + expect(envelope.body).to eq 'Lorem ipsum dolor sit amet' + end + + it 'decodes the first argument if it is the only one even if it lacks key_id' do + encoded = < + + TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQ= + base64url + RSA-SHA256 + 5ZwAIoos-jOB04CCVcs6zja5QUs3N32R7gxLiQJeZY9oZe4goVshI2QQne17BuoyZNsuN09pf0YA_J-1T3R92l0rWvfkYa0FVycehKCrYr67Zk-pwb8ww3J3pjM8dqz4pjk1jcyNEVcIQtwomMT3Z9wNI8DKAezNpEDdKOe-bM2ZXg_w1qTBQNa5ZFWCQ8Spc_g7uX_1GX9X0xexbJ4lvTPxRPtPU4I-u3gHP5YjHXxbRiqEFipjMklqZILsCRVlk7tFwteS3mmYW_9BkNu1YADkV0BfCrg2GShyeWrjOXR-krflLxkl4Bxn6T-NHlzD8vOnxcWXvNlXOChl7md_GQ== + +XML + + envelope = OStatus2::Salmon::MagicEnvelope.new(encoded) + + expect(envelope.key_id).to eq nil + end + + it 'sets default values for missing arguments if the number of given arguments is not 1' do + envelope = OStatus2::Salmon::MagicEnvelope.new + + expect(envelope.type).to eq 'application/atom+xml' + expect(envelope.encoding).to eq 'base64url' + expect(envelope.alg).to eq 'RSA-SHA256' + end + + it 'signs if the second argument is a RSA key pair' do + key = OpenSSL::PKey::RSA.new <