Skip to content
This repository has been archived by the owner on Jan 14, 2022. It is now read-only.

Commit

Permalink
Alter the API of Salmon related functions
Browse files Browse the repository at this point in the history
This commit has the following significant changes for the API
* Rename symbols with the name the specification uses.
* Introduce MagicEnvelope object in favor of reuse of parsed information
  and simplicity.
* Add a method to decode magic public key.
  • Loading branch information
akihikodaki committed Jun 23, 2017
1 parent eec4c51 commit ab9c018
Show file tree
Hide file tree
Showing 11 changed files with 364 additions and 201 deletions.
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 5 additions & 9 deletions lib/ostatus2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
35 changes: 0 additions & 35 deletions lib/ostatus2/magic_key.rb

This file was deleted.

110 changes: 28 additions & 82 deletions lib/ostatus2/salmon.rb
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions lib/ostatus2/salmon/magic_envelope.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions lib/ostatus2/salmon/magic_public_key.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions ostatus2_yard_handler.rb
Original file line number Diff line number Diff line change
@@ -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
23 changes: 0 additions & 23 deletions spec/ostatus2/magic_key_spec.rb

This file was deleted.

Loading

0 comments on commit ab9c018

Please sign in to comment.