├── .rspec ├── lib ├── macaroons │ ├── version.rb │ ├── errors.rb │ ├── serializers │ │ ├── base.rb │ │ ├── json.rb │ │ └── binary.rb │ ├── caveat.rb │ ├── macaroons.rb │ ├── utils.rb │ ├── raw_macaroon.rb │ └── verifier.rb └── macaroons.rb ├── Gemfile ├── Rakefile ├── spec ├── spec_helper.rb └── integration_spec.rb ├── .travis.yml ├── .gitignore ├── LICENSE ├── macaroons.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --format doc 4 | -b 5 | -------------------------------------------------------------------------------- /lib/macaroons/version.rb: -------------------------------------------------------------------------------- 1 | module Macaroons 2 | VERSION = '1.0.0' 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'coveralls', require: false 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /lib/macaroons/errors.rb: -------------------------------------------------------------------------------- 1 | class SignatureMismatchError < StandardError 2 | end 3 | 4 | class CaveatUnsatisfiedError < StandardError 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /lib/macaroons.rb: -------------------------------------------------------------------------------- 1 | require 'macaroons/macaroons' 2 | require 'macaroons/verifier' 3 | 4 | class Macaroon < Macaroons::Macaroon 5 | class Verifier < Macaroons::Verifier; end 6 | end 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | RSpec.configure do |config| 5 | config.expect_with :rspec do |c| 6 | c.syntax = :expect 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/macaroons/serializers/base.rb: -------------------------------------------------------------------------------- 1 | module Macaroons 2 | class BaseSerializer 3 | 4 | def serialize(macaroon) 5 | raise NotImplementedError 6 | end 7 | 8 | def deserialize(serialized) 9 | raise NotImplementedError 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: true 2 | language: ruby 3 | rvm: 4 | - 2.6.0 5 | - 2.5.3 6 | - 2.4.5 7 | - 2.3.8 8 | cache: bundler 9 | script: bundle exec rspec 10 | after_success: coveralls 11 | 12 | before_install: 13 | - sudo add-apt-repository -y ppa:chris-lea/libsodium 14 | - sudo apt-get update -q 15 | - sudo apt-get install libsodium-dev 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | Gemfile.lock 4 | /.config 5 | /coverage/ 6 | /InstalledFiles 7 | /pkg/ 8 | /spec/reports/ 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | ## Documentation cache and generated files: 14 | /.yardoc/ 15 | /_yardoc/ 16 | /doc/ 17 | /rdoc/ 18 | api.txt 19 | 20 | ## Environment normalisation: 21 | /.bundle/ 22 | /lib/bundler/man/ 23 | -------------------------------------------------------------------------------- /lib/macaroons/caveat.rb: -------------------------------------------------------------------------------- 1 | module Macaroons 2 | class Caveat 3 | def initialize(caveat_id, verification_id=nil, caveat_location=nil) 4 | @caveat_id = caveat_id 5 | @verification_id = verification_id 6 | @caveat_location = caveat_location 7 | end 8 | 9 | attr_accessor :caveat_id 10 | attr_accessor :verification_id 11 | attr_accessor :caveat_location 12 | 13 | def first_party? 14 | verification_id.nil? 15 | end 16 | 17 | def third_party? 18 | !first_party? 19 | end 20 | 21 | def to_h 22 | {'cid' => @caveat_id, 'vid' => @verification_id, 'cl' => @caveat_location} 23 | end 24 | 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 LocalMed, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/macaroons/macaroons.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | require 'macaroons/raw_macaroon' 4 | 5 | module Macaroons 6 | class Macaroon 7 | extend Forwardable 8 | 9 | def initialize(key: nil, identifier: nil, location: nil, raw_macaroon: nil) 10 | @raw_macaroon = raw_macaroon || RawMacaroon.new(key: key, identifier: identifier, location: location) 11 | end 12 | 13 | def_delegators :@raw_macaroon, :identifier, :location, :signature, :caveats, 14 | :serialize, :serialize_json, :add_first_party_caveat, :add_third_party_caveat, :prepare_for_request 15 | 16 | def self.from_binary(serialized) 17 | raw_macaroon = RawMacaroon.from_binary(serialized: serialized) 18 | macaroon = Macaroons::Macaroon.new(raw_macaroon: raw_macaroon) 19 | end 20 | 21 | def self.from_json(serialized) 22 | raw_macaroon = RawMacaroon.from_json(serialized: serialized) 23 | macaroon = Macaroons::Macaroon.new(raw_macaroon: raw_macaroon) 24 | end 25 | 26 | def first_party_caveats 27 | caveats.select(&:first_party?) 28 | end 29 | 30 | def third_party_caveats 31 | caveats.select(&:third_party?) 32 | end 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /macaroons.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'macaroons/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'macaroons' 8 | spec.version = Macaroons::VERSION 9 | spec.authors = ["Evan Cordell", "Peter Browne", "Joel James"] 10 | spec.email = ["ecordell@localmed.com", "pete@localmed.com", "joel.james@localmed.com"] 11 | spec.summary = "Macaroons library in Ruby" 12 | spec.description = "Macaroons library in Ruby" 13 | 14 | spec.files = `git ls-files`.split($/) 15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 17 | spec.require_paths = ["lib"] 18 | spec.required_ruby_version = ">= 2.2.6" 19 | spec.add_dependency "multi_json", "~> 1.10" 20 | spec.add_dependency "rbnacl", "~> 6.0" 21 | 22 | spec.add_development_dependency "bundler", "> 1.3" 23 | spec.add_development_dependency "rake" 24 | spec.add_development_dependency "rspec", "~> 3.8.0" 25 | spec.add_development_dependency "pry" 26 | spec.add_development_dependency "pry-stack_explorer" 27 | spec.add_development_dependency "rspec_junit_formatter" 28 | end 29 | -------------------------------------------------------------------------------- /lib/macaroons/serializers/json.rb: -------------------------------------------------------------------------------- 1 | require 'multi_json' 2 | 3 | module Macaroons 4 | class JsonSerializer 5 | 6 | def serialize(macaroon) 7 | caveats = macaroon.caveats.map! do |c| 8 | if c.first_party? 9 | c 10 | else 11 | Macaroons::Caveat.new( 12 | c.caveat_id, 13 | verification_id=Base64.strict_encode64(c.verification_id), 14 | caveat_location=c.caveat_location 15 | ) 16 | end 17 | end 18 | serialized = { 19 | location: macaroon.location, 20 | identifier: macaroon.identifier, 21 | caveats: caveats.map(&:to_h), 22 | signature: macaroon.signature 23 | } 24 | MultiJson.dump(serialized) 25 | end 26 | 27 | def deserialize(serialized) 28 | deserialized = MultiJson.load(serialized) 29 | macaroon = Macaroons::RawMacaroon.new(key: 'no_key', identifier: deserialized['identifier'], location: deserialized['location']) 30 | deserialized['caveats'].each do |c| 31 | if c['vid'] 32 | caveat = Macaroons::Caveat.new(c['cid'], Base64.strict_decode64(c['vid']), c['cl']) 33 | else 34 | caveat = Macaroons::Caveat.new(c['cid'], c['vid'], c['cl']) 35 | end 36 | macaroon.caveats << caveat 37 | end 38 | macaroon.signature = Utils.unhexlify(deserialized['signature']) 39 | macaroon 40 | end 41 | 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/macaroons/utils.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Macaroons 4 | module Utils 5 | 6 | def self.convert_to_bytes(string) 7 | string.encode('us-ascii') unless string.nil? 8 | end 9 | 10 | def self.hexlify(value) 11 | value.unpack('C*').map { |byte| '%02X' % byte }.join('') 12 | end 13 | 14 | def self.unhexlify(value) 15 | [value].pack('H*') 16 | end 17 | 18 | def self.truncate_or_pad(string, size=nil) 19 | size = size.nil? ? 32 : size 20 | if string.length > size 21 | string[0, size] 22 | elsif string.length < size 23 | string + "\0"*(size-string.length) 24 | else 25 | string 26 | end 27 | end 28 | 29 | def self.hmac(key, data, digest=nil) 30 | digest = OpenSSL::Digest.new('sha256') if digest.nil? 31 | OpenSSL::HMAC.digest(digest, key, data) 32 | end 33 | 34 | def self.sign_first_party_caveat(signature, predicate) 35 | Utils.hmac(signature, predicate) 36 | end 37 | 38 | def self.sign_third_party_caveat(signature, verification_id, caveat_id) 39 | verification_id_hash = Utils.hmac(signature, verification_id) 40 | caveat_id_hash = Utils.hmac(signature, caveat_id) 41 | combined = verification_id_hash + caveat_id_hash 42 | Utils.hmac(signature, combined) 43 | end 44 | 45 | def self.generate_derived_key(key) 46 | Utils.hmac('macaroons-key-generator', key) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/macaroons/raw_macaroon.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | 3 | require 'rbnacl' 4 | 5 | require 'macaroons/caveat' 6 | require 'macaroons/utils' 7 | require 'macaroons/serializers/binary' 8 | require 'macaroons/serializers/json' 9 | 10 | module Macaroons 11 | class RawMacaroon 12 | 13 | def initialize(key: nil, identifier: nil, location: nil) 14 | if key.nil? || identifier.nil? || location.nil? 15 | raise ArgumentError, 'Must provide all three: (key, identifier, location)' 16 | end 17 | 18 | @key = key 19 | @identifier = identifier 20 | @location = location 21 | @signature = create_initial_macaroon_signature(key, identifier) 22 | @caveats = [] 23 | end 24 | 25 | def self.from_binary(serialized: nil) 26 | Macaroons::BinarySerializer.new().deserialize(serialized) 27 | end 28 | 29 | def self.from_json(serialized: nil) 30 | Macaroons::JsonSerializer.new().deserialize(serialized) 31 | end 32 | 33 | attr_reader :identifier 34 | attr_reader :key 35 | attr_reader :location 36 | attr_accessor :caveats 37 | attr_accessor :signature 38 | 39 | def signature 40 | Utils.hexlify(@signature).downcase 41 | end 42 | 43 | def add_first_party_caveat(predicate) 44 | caveat = Caveat.new(predicate) 45 | @caveats << caveat 46 | @signature = Utils.sign_first_party_caveat(@signature, predicate) 47 | end 48 | 49 | def add_third_party_caveat(caveat_key, caveat_id, caveat_location) 50 | derived_caveat_key = Utils.truncate_or_pad(Utils.hmac('macaroons-key-generator', caveat_key)) 51 | truncated_or_padded_signature = Utils.truncate_or_pad(@signature) 52 | box = RbNaCl::SimpleBox.from_secret_key(truncated_or_padded_signature) 53 | ciphertext = box.encrypt(derived_caveat_key) 54 | verification_id = ciphertext 55 | caveat = Caveat.new(caveat_id, verification_id, caveat_location) 56 | @caveats << caveat 57 | @signature = Utils.sign_third_party_caveat(@signature, verification_id, caveat_id) 58 | end 59 | 60 | def serialize 61 | Macaroons::BinarySerializer.new().serialize(self) 62 | end 63 | 64 | def serialize_json 65 | Macaroons::JsonSerializer.new().serialize(self) 66 | end 67 | 68 | def prepare_for_request(macaroon) 69 | bound_macaroon = Marshal.load( Marshal.dump( macaroon ) ) 70 | raw = bound_macaroon.instance_variable_get(:@raw_macaroon) 71 | raw.signature = bind_signature(macaroon.signature) 72 | bound_macaroon 73 | end 74 | 75 | def bind_signature(signature) 76 | key = Utils.truncate_or_pad("\0") 77 | hash1 = Utils.hmac(key, Utils.unhexlify(self.signature)) 78 | hash2 = Utils.hmac(key, Utils.unhexlify(signature)) 79 | Utils.hmac(key, hash1 + hash2) 80 | end 81 | 82 | private 83 | 84 | def create_initial_macaroon_signature(key, identifier) 85 | derived_key = Utils.generate_derived_key(key) 86 | Utils.hmac(derived_key, identifier) 87 | end 88 | 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/macaroons/serializers/binary.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | 3 | require 'macaroons/serializers/base' 4 | 5 | module Macaroons 6 | class BinarySerializer < BaseSerializer 7 | PACKET_PREFIX_LENGTH = 4 8 | 9 | def serialize(macaroon) 10 | combined = packetize('location', macaroon.location) 11 | combined += packetize('identifier', macaroon.identifier) 12 | 13 | for caveat in macaroon.caveats 14 | combined += packetize('cid', caveat.caveat_id) 15 | 16 | if caveat.verification_id and caveat.caveat_location 17 | combined += packetize('vid', caveat.verification_id) 18 | combined += packetize('cl', caveat.caveat_location) 19 | end 20 | end 21 | 22 | combined += packetize( 23 | 'signature', 24 | Utils.unhexlify(macaroon.signature) 25 | ) 26 | base64_url_encode(combined) 27 | end 28 | 29 | def deserialize(serialized) 30 | caveats = [] 31 | decoded = base64_url_decode(serialized) 32 | 33 | index = 0 34 | 35 | while index < decoded.length 36 | packet_length = decoded[index..(index + PACKET_PREFIX_LENGTH - 1)].to_i(16) 37 | stripped_packet = decoded[index + PACKET_PREFIX_LENGTH..(index + packet_length - 2)] 38 | 39 | key, value = depacketize(stripped_packet) 40 | 41 | case key 42 | when 'location' 43 | location = value 44 | when 'identifier' 45 | identifier = value 46 | when 'cid' 47 | caveats << Caveat.new(value) 48 | when 'vid' 49 | caveats[-1].verification_id = value 50 | when 'cl' 51 | caveats[-1].caveat_location = value 52 | when 'signature' 53 | signature = value 54 | else 55 | raise KeyError, 'Invalid key in binary macaroon. Macaroon may be corrupted.' 56 | end 57 | 58 | index = index + packet_length 59 | end 60 | macaroon = Macaroons::RawMacaroon.new(key: 'no_key', identifier: identifier, location: location) 61 | macaroon.caveats = caveats 62 | macaroon.signature = signature 63 | macaroon 64 | end 65 | 66 | private 67 | 68 | def packetize(key, data) 69 | # The 2 covers the space and the newline 70 | packet_size = PACKET_PREFIX_LENGTH + 2 + key.length + data.length 71 | if packet_size > 65535 72 | # Due to packet structure, length of packet must be less than 0xFFFF 73 | raise ArgumentError, 'Data is too long for a binary packet.' 74 | end 75 | packet_size_hex = packet_size.to_s(16) 76 | header = packet_size_hex.to_s.rjust(4, '0') 77 | packet_content = "#{key} #{data}\n" 78 | packet = "#{header}#{packet_content}" 79 | packet 80 | end 81 | 82 | def depacketize(packet) 83 | key = packet.split(" ")[0] 84 | value = packet[key.length + 1..-1] 85 | [key, value] 86 | end 87 | 88 | def base64_url_decode(str) 89 | str = str.delete('=') 90 | str += '=' * (4 - str.length.modulo(4)).modulo(4) 91 | Base64.urlsafe_decode64(str) 92 | end 93 | 94 | def base64_url_encode(str) 95 | Base64.urlsafe_encode64(str).tr('=', '') 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/macaroons/verifier.rb: -------------------------------------------------------------------------------- 1 | require 'rbnacl' 2 | 3 | require 'macaroons/errors' 4 | 5 | module Macaroons 6 | class Verifier 7 | attr_accessor :predicates 8 | attr_accessor :callbacks 9 | 10 | def initialize 11 | @predicates = [] 12 | @callbacks = [] 13 | @calculated_signature = nil 14 | end 15 | 16 | def satisfy_exact(predicate) 17 | raise ArgumentError, 'Must provide predicate' unless predicate 18 | @predicates << predicate 19 | end 20 | 21 | def satisfy_general(callback = nil, &block) 22 | raise ArgumentError, 'Must provide callback or block' unless callback || block_given? 23 | callback = block if block_given? 24 | @callbacks << callback 25 | end 26 | 27 | def verify(macaroon: nil, key: nil, discharge_macaroons: nil) 28 | raise ArgumentError, 'Macaroon and Key required' if macaroon.nil? || key.nil? 29 | key = Utils.generate_derived_key(key) 30 | verify_discharge(root: macaroon, macaroon: macaroon, key: key, discharge_macaroons:discharge_macaroons) 31 | end 32 | 33 | def verify_discharge(root: nil, macaroon: nil, key: nil, discharge_macaroons: []) 34 | @calculated_signature = Utils.hmac(key, macaroon.identifier) 35 | 36 | verify_caveats(macaroon, discharge_macaroons) 37 | 38 | if root != macaroon 39 | raw = root.instance_variable_get(:@raw_macaroon) 40 | @calculated_signature = raw.bind_signature(Utils.hexlify(@calculated_signature).downcase) 41 | end 42 | 43 | raise SignatureMismatchError, 'Signatures do not match.' unless signatures_match(Utils.unhexlify(macaroon.signature), @calculated_signature) 44 | 45 | return true 46 | end 47 | 48 | private 49 | 50 | def verify_caveats(macaroon, discharge_macaroons) 51 | for caveat in macaroon.caveats 52 | if caveat.first_party? 53 | caveat_met = verify_first_party_caveat(caveat) 54 | else 55 | caveat_met = verify_third_party_caveat(caveat, macaroon, discharge_macaroons) 56 | end 57 | raise CaveatUnsatisfiedError, "Caveat not met. Unable to satisfy: #{caveat.caveat_id}" unless caveat_met 58 | end 59 | end 60 | 61 | def verify_first_party_caveat(caveat) 62 | caveat_met = false 63 | if @predicates.include? caveat.caveat_id 64 | caveat_met = true 65 | else 66 | @callbacks.each do |callback| 67 | caveat_met = true if callback.call(caveat.caveat_id) == true 68 | end 69 | end 70 | @calculated_signature = Utils.sign_first_party_caveat(@calculated_signature, caveat.caveat_id) if caveat_met 71 | return caveat_met 72 | end 73 | 74 | def verify_third_party_caveat(caveat, root_macaroon, discharge_macaroons) 75 | caveat_met = false 76 | 77 | caveat_macaroon = discharge_macaroons.find { |m| m.identifier == caveat.caveat_id } 78 | raise CaveatUnsatisfiedError, "Caveat not met. No discharge macaroon found for identifier: #{caveat.caveat_id}" unless caveat_macaroon 79 | 80 | caveat_key = extract_caveat_key(@calculated_signature, caveat) 81 | caveat_macaroon_verifier = Verifier.new() 82 | caveat_macaroon_verifier.predicates = @predicates 83 | caveat_macaroon_verifier.callbacks = @callbacks 84 | 85 | caveat_met = caveat_macaroon_verifier.verify_discharge( 86 | root: root_macaroon, 87 | macaroon: caveat_macaroon, 88 | key: caveat_key, 89 | discharge_macaroons: discharge_macaroons 90 | ) 91 | if caveat_met 92 | @calculated_signature = Utils.sign_third_party_caveat(@calculated_signature, caveat.verification_id, caveat.caveat_id) 93 | end 94 | return caveat_met 95 | end 96 | 97 | def extract_caveat_key(signature, caveat) 98 | key = Utils.truncate_or_pad(signature) 99 | box = RbNaCl::SimpleBox.from_secret_key(key) 100 | decoded_vid = caveat.verification_id 101 | box.decrypt(decoded_vid) 102 | end 103 | 104 | def signatures_match(a, b) 105 | # Constant time compare, taken from Rack 106 | return false unless a.bytesize == b.bytesize 107 | 108 | l = a.unpack("C*") 109 | 110 | r, i = 0, -1 111 | b.each_byte { |v| r |= v ^ l[i+=1] } 112 | r == 0 113 | end 114 | 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Macaroons 2 | [![Build Status](https://travis-ci.org/localmed/ruby-macaroons.svg?branch=master)](https://travis-ci.org/localmed/ruby-macaroons) 3 | [![Coverage Status](https://img.shields.io/coveralls/localmed/ruby-macaroons.svg)](https://coveralls.io/r/localmed/ruby-macaroons?branch=master) 4 | [![Gem Version](https://badge.fury.io/rb/macaroons.svg)](http://badge.fury.io/rb/macaroons) 5 | 6 | This is a Ruby implementation of macaroons. The implementation is stable but could still be subject to change, pending any standardization attempts around macaroons. 7 | 8 | ## What is a Macaroon? 9 | Macaroons, like cookies, are a form of bearer credential. Unlike opaque tokens, macaroons embed *caveats* that define specific authorization requirements for the *target service*, the service that issued the root macaroon and which is capable of verifying the integrity of macaroons it recieves. 10 | 11 | Macaroons allow for delegation and attenuation of authorization. They are simple and fast to verify, and decouple authorization policy from the enforcement of that policy. 12 | 13 | Simple examples are outlined below. For more in-depth examples check out the [functional tests](https://github.com/localmed/ruby-macaroons/blob/master/spec/integration_spec.rb) and [references](#references). 14 | 15 | ## Installing 16 | 17 | The macaroon implementation is pure Ruby, but relies on [rbnacl](https://github.com/crypto-rb/rbnacl) to provide strong cryptographic primitives. 18 | 19 | Install with: 20 | 21 | ``` 22 | gem install macaroons 23 | ``` 24 | 25 | And then import it into your Ruby program: 26 | 27 | ```ruby 28 | require 'macaroons' 29 | ``` 30 | 31 | ## Quickstart 32 | 33 | ```ruby 34 | key => Very secret key used to sign the macaroon 35 | identifier => An identifier, to remind you which key was used to sign the macaroon 36 | location => The location at which the macaroon is created 37 | 38 | # Construct a Macaroon. 39 | m = Macaroon.new(key: key, identifier: identifier, location: 'http://foo.com') 40 | 41 | # Add first party caveat 42 | m.add_first_party_caveat('caveat_1') 43 | 44 | # List all first party caveats 45 | m.first_party_caveats 46 | 47 | # Add third party caveat 48 | m.add_third_party_caveat('caveat_key', 'caveat_id', 'http://foo.com') 49 | 50 | # List all third party caveats 51 | m.third_party_caveats 52 | ``` 53 | 54 | ## Example with first- and third-party caveats 55 | 56 | ```ruby 57 | 58 | # Create macaroon. Sign with a key and identifier (a way to remember which key was used) 59 | m = Macaroon.new( 60 | location: 'http://mybank/', 61 | identifier: 'we used our other secret key', 62 | key: 'this is a different super-secret key; never use the same secret twice' 63 | ) 64 | 65 | # Add a first party caveat 66 | m.add_first_party_caveat('account = 3735928559') 67 | 68 | # Add a third party caveat 69 | caveat_key = '4; guaranteed random by a fair toss of the dice' 70 | identifier = 'this was how we remind auth of key/pred' 71 | m.add_third_party_caveat(caveat_key, identifier, 'http://auth.mybank/') 72 | 73 | # User collects a discharge macaroon (likely from a separate service), that proves the claims in the third-party caveat and which may add additional caveats of its own 74 | discharge = Macaroon.new( 75 | location: 'http://auth.mybank/', 76 | identifier: identifier, 77 | key: caveat_key 78 | ) 79 | discharge.add_first_party_caveat('time < 2015-01-01T00:00') 80 | 81 | # discharge macaroons are bound to the root macaroon so they cannot be reused 82 | protected_discharge = m.prepare_for_request(discharge) 83 | 84 | # The user sends their macaroon along with their discharge macaroons, and we verify them 85 | v = Macaroon::Verifier.new() 86 | v.satisfy_exact('account = 3735928559') 87 | v.satisfy_exact('time < 2015-01-01T00:00') 88 | verified = v.verify( 89 | macaroon: m, 90 | key: 'this is a different super-secret key; never use the same secret twice', 91 | discharge_macaroons: [protected_discharge] 92 | ) 93 | ``` 94 | 95 | ## More Macaroons 96 | 97 | [PyMacaroons](https://github.com/ecordell/pymacaroons) is available for Python. PyMacaroons and Ruby-Macaroons are completely compatible (they can be used interchangibly within the same target service). 98 | 99 | The [libmacaroons library](https://github.com/rescrv/libmacaroons) comes with Python and Go bindings. 100 | 101 | PyMacaroons, libmacaroons, and Ruby-Macaroons all use the same underlying cryptographic library (libsodium). 102 | 103 | ## References 104 | 105 | - [The Macaroon Paper](http://research.google.com/pubs/pub41892.html) 106 | - [Mozilla Macaroon Tech Talk](https://air.mozilla.org/macaroons-cookies-with-contextual-caveats-for-decentralized-authorization-in-the-cloud/) 107 | - [libmacaroons](https://github.com/rescrv/libmacaroons) 108 | - [PyMacaroons](https://github.com/ecordell/pymacaroons) 109 | - [rbnacl](https://github.com/crypto-rb/rbnacl) 110 | -------------------------------------------------------------------------------- /spec/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'macaroons' 3 | require 'macaroons/errors' 4 | 5 | describe 'Macaroon' do 6 | context 'without caveats' do 7 | it 'should have correct signature' do 8 | m = Macaroon.new( 9 | location: 'http://mybank/', 10 | identifier: 'we used our secret key', 11 | key: 'this is our super secret key; only we should know it' 12 | ) 13 | expect(m.signature).to eql('e3d9e02908526c4c0039ae15114115d97fdd68bf2ba379b342aaf0f617d0552f') 14 | end 15 | end 16 | 17 | context 'with first party caveat' do 18 | it 'should have correct signature' do 19 | m = Macaroon.new( 20 | location: 'http://mybank/', 21 | identifier: 'we used our secret key', 22 | key: 'this is our super secret key; only we should know it' 23 | ) 24 | m.add_first_party_caveat('test = caveat') 25 | expect(m.signature).to eql('197bac7a044af33332865b9266e26d493bdd668a660e44d88ce1a998c23dbd67') 26 | end 27 | end 28 | 29 | context 'when serilizing as binary' do 30 | it 'should serialize properly' do 31 | m = Macaroon.new( 32 | location: 'http://mybank/', 33 | identifier: 'we used our secret key', 34 | key: 'this is our super secret key; only we should know it' 35 | ) 36 | m.add_first_party_caveat('test = caveat') 37 | expect(m.serialize()).to eql('MDAxY2xvY2F0aW9uIGh0dHA6Ly9teWJhbmsvCjAwMjZpZGVudGlmaWVyIHdlIHVzZWQgb3VyIHNlY3JldCBrZXkKMDAxNmNpZCB0ZXN0ID0gY2F2ZWF0CjAwMmZzaWduYXR1cmUgGXusegRK8zMyhluSZuJtSTvdZopmDkTYjOGpmMI9vWcK') 38 | end 39 | end 40 | 41 | context 'when serilizing as binary with padding' do 42 | it 'should strip the padding' do 43 | m = Macaroon.new( 44 | location: 'http://mybank/', 45 | identifier: 'we used our secret key', 46 | key: 'this is our super secret key; only we should know it' 47 | ) 48 | m.add_first_party_caveat('test = a caveat') 49 | expect(m.serialize()).to eql('MDAxY2xvY2F0aW9uIGh0dHA6Ly9teWJhbmsvCjAwMjZpZGVudGlmaWVyIHdlIHVzZWQgb3VyIHNlY3JldCBrZXkKMDAxOGNpZCB0ZXN0ID0gYSBjYXZlYXQKMDAyZnNpZ25hdHVyZSAOX3fqTY3ESWO6a5DZltZZReCDkfjbcdwSQDTdBrhApwo') 50 | end 51 | end 52 | 53 | context 'when deserializing binary' do 54 | it 'should deserialize properly' do 55 | m = Macaroon.from_binary( 56 | 'MDAxY2xvY2F0aW9uIGh0dHA6Ly9teWJhbmsvCjAwMjZpZGVudGlmaWVyIHdlIHVzZWQgb3VyIHNlY3JldCBrZXkKMDAxNmNpZCB0ZXN0ID0gY2F2ZWF0CjAwMmZzaWduYXR1cmUgGXusegRK8zMyhluSZuJtSTvdZopmDkTYjOGpmMI9vWcK' 57 | ) 58 | expect(m.signature).to eql('197bac7a044af33332865b9266e26d493bdd668a660e44d88ce1a998c23dbd67') 59 | end 60 | end 61 | 62 | context 'when deserializing binary without padding' do 63 | it 'should add padding' do 64 | m = Macaroon.from_binary( 65 | 'MDAxY2xvY2F0aW9uIGh0dHA6Ly9teWJhbmsvCjAwMjZpZGVudGlmaWVyIHdlIHVzZWQgb3VyIHNlY3JldCBrZXkKMDAxOGNpZCB0ZXN0ID0gYSBjYXZlYXQKMDAyZnNpZ25hdHVyZSAOX3fqTY3ESWO6a5DZltZZReCDkfjbcdwSQDTdBrhApwo=' 66 | ) 67 | expect(m.signature).to eql('0e5f77ea4d8dc44963ba6b90d996d65945e08391f8db71dc124034dd06b840a7') 68 | end 69 | end 70 | 71 | context 'when serilizing as json' do 72 | it 'should serialize properly' do 73 | m = Macaroon.new( 74 | location: 'http://mybank/', 75 | identifier: 'we used our secret key', 76 | key: 'this is our super secret key; only we should know it' 77 | ) 78 | m.add_first_party_caveat('test = caveat') 79 | expect(m.serialize_json()).to eql('{"location":"http://mybank/","identifier":"we used our secret key","caveats":[{"cid":"test = caveat","vid":null,"cl":null}],"signature":"197bac7a044af33332865b9266e26d493bdd668a660e44d88ce1a998c23dbd67"}') 80 | end 81 | end 82 | 83 | context 'when deserializing json' do 84 | it 'should deserialize properly' do 85 | m = Macaroon.from_json( 86 | '{"location":"http://mybank/","identifier":"we used our secret key","caveats":[{"cid":"test = caveat","vid":null,"cl":null}],"signature":"197bac7a044af33332865b9266e26d493bdd668a660e44d88ce1a998c23dbd67"}' 87 | ) 88 | expect(m.signature).to eql('197bac7a044af33332865b9266e26d493bdd668a660e44d88ce1a998c23dbd67') 89 | end 90 | end 91 | 92 | context 'when serializing/deserializing binary with first and third caveats' do 93 | it 'should serialize/deserialize properly' do 94 | m = Macaroon.new( 95 | location: 'http://mybank/', 96 | identifier: 'we used our other secret key', 97 | key: 'this is a different super-secret key; never use the same secret twice' 98 | ) 99 | m.add_first_party_caveat('account = 3735928559') 100 | caveat_key = '4; guaranteed random by a fair toss of the dice' 101 | identifier = 'this was how we remind auth of key/pred' 102 | m.add_third_party_caveat(caveat_key, identifier, 'http://auth.mybank/') 103 | n = Macaroon.from_binary(m.serialize()) 104 | expect(m.signature).to eql(n.signature) 105 | end 106 | end 107 | 108 | context 'when serializing/deserializing json with first and third caveats' do 109 | it 'should serialize/deserialize properly' do 110 | m = Macaroon.new( 111 | location: 'http://mybank/', 112 | identifier: 'we used our other secret key', 113 | key: 'this is a different super-secret key; never use the same secret twice' 114 | ) 115 | m.add_first_party_caveat('account = 3735928559') 116 | caveat_key = '4; guaranteed random by a fair toss of the dice' 117 | identifier = 'this was how we remind auth of key/pred' 118 | m.add_third_party_caveat(caveat_key, identifier, 'http://auth.mybank/') 119 | n = Macaroon.from_json(m.serialize_json()) 120 | expect(m.signature).to eql(n.signature) 121 | end 122 | end 123 | 124 | context 'when preparing a macaroon for request' do 125 | it 'should bind the signature to the root' do 126 | m = Macaroon.new( 127 | location: 'http://mybank/', 128 | identifier: 'we used our other secret key', 129 | key: 'this is a different super-secret key; never use the same secret twice' 130 | ) 131 | m.add_first_party_caveat('account = 3735928559') 132 | caveat_key = '4; guaranteed random by a fair toss of the dice' 133 | identifier = 'this was how we remind auth of key/pred' 134 | m.add_third_party_caveat(caveat_key, identifier, 'http://auth.mybank/') 135 | 136 | discharge = Macaroon.new( 137 | location: 'http://auth.mybank/', 138 | identifier: identifier, 139 | key: caveat_key 140 | ) 141 | discharge.add_first_party_caveat('time < 2015-01-01T00:00') 142 | protected_discharge = m.prepare_for_request(discharge) 143 | 144 | expect(discharge.signature).not_to eql(protected_discharge.signature) 145 | end 146 | end 147 | end 148 | 149 | describe 'Verifier' do 150 | context 'verifying first party exact caveats' do 151 | before(:all) do 152 | @m = Macaroon.new( 153 | location: 'http://mybank/', 154 | identifier: 'we used our secret key', 155 | key: 'this is our super secret key; only we should know it' 156 | ) 157 | @m.add_first_party_caveat('test = caveat') 158 | end 159 | 160 | context 'all caveats met' do 161 | it 'should verify the macaroon' do 162 | v = Macaroon::Verifier.new() 163 | v.satisfy_exact('test = caveat') 164 | verified = v.verify( 165 | macaroon: @m, 166 | key: 'this is our super secret key; only we should know it' 167 | ) 168 | expect(verified).to be(true) 169 | end 170 | end 171 | context 'not all caveats met' do 172 | it 'should raise an error' do 173 | v = Macaroon::Verifier.new() 174 | expect { 175 | v.verify( 176 | macaroon: @m, 177 | key: 'this is our super secret key; only we should know it' 178 | ) 179 | }.to raise_error 180 | end 181 | end 182 | end 183 | 184 | context 'verifying first party general caveats' do 185 | before(:all) do 186 | @m = Macaroon.new( 187 | location: 'http://mybank/', 188 | identifier: 'we used our secret key', 189 | key: 'this is our super secret key; only we should know it' 190 | ) 191 | @m.add_first_party_caveat('general caveat') 192 | end 193 | 194 | context 'all caveats met' do 195 | it 'should verify the macaroon' do 196 | v = Macaroon::Verifier.new() 197 | v.satisfy_general { |predicate| predicate == 'general caveat' } 198 | verified = v.verify( 199 | macaroon: @m, 200 | key: 'this is our super secret key; only we should know it' 201 | ) 202 | expect(verified).to be(true) 203 | end 204 | end 205 | context 'not all caveats met' do 206 | it 'should raise an error' do 207 | v = Macaroon::Verifier.new() 208 | v.satisfy_general { |predicate| predicate == 'unmet' } 209 | expect { 210 | v.verify( 211 | macaroon: @m, 212 | key: 'this is our super secret key; only we should know it' 213 | ) 214 | }.to raise_error 215 | end 216 | end 217 | end 218 | 219 | context 'verifying third party caveats' do 220 | before(:all) do 221 | @m = Macaroon.new( 222 | location: 'http://mybank/', 223 | identifier: 'we used our other secret key', 224 | key: 'this is a different super-secret key; never use the same secret twice' 225 | ) 226 | @m.add_first_party_caveat('account = 3735928559') 227 | caveat_key = '4; guaranteed random by a fair toss of the dice' 228 | identifier = 'this was how we remind auth of key/pred' 229 | @m.add_third_party_caveat(caveat_key, identifier, 'http://auth.mybank/') 230 | 231 | discharge = Macaroon.new( 232 | location: 'http://auth.mybank/', 233 | identifier: identifier, 234 | key: caveat_key 235 | ) 236 | discharge.add_first_party_caveat('time < 2015-01-01T00:00') 237 | @protected_discharge = @m.prepare_for_request(discharge) 238 | end 239 | 240 | context 'all caveats met and discharges provided' do 241 | it 'should verify the macaroon' do 242 | v = Macaroon::Verifier.new() 243 | v.satisfy_exact('account = 3735928559') 244 | v.satisfy_exact('time < 2015-01-01T00:00') 245 | verified = v.verify( 246 | macaroon: @m, 247 | key: 'this is a different super-secret key; never use the same secret twice', 248 | discharge_macaroons: [@protected_discharge] 249 | ) 250 | end 251 | end 252 | 253 | context 'not all caveats met' do 254 | it 'should raise an error' do 255 | v = Macaroon::Verifier.new() 256 | v.satisfy_exact('account = 3735928559') 257 | expect { 258 | v.verify( 259 | macaroon: @m, 260 | key: 'this is a different super-secret key; never use the same secret twice', 261 | discharge_macaroons: [@protected_discharge] 262 | ) 263 | }.to raise_error 264 | end 265 | end 266 | 267 | context 'not all discharges provided' do 268 | it 'should raise an error' do 269 | v = Macaroon::Verifier.new() 270 | v.satisfy_exact('account = 3735928559') 271 | v.satisfy_exact('time < 2015-01-01T00:00') 272 | expect { 273 | v.verify( 274 | macaroon: @m, 275 | key: 'this is a different super-secret key; never use the same secret twice', 276 | discharge_macaroons: [] 277 | ) 278 | }.to raise_error 279 | end 280 | end 281 | end 282 | 283 | end 284 | --------------------------------------------------------------------------------