diff --git a/README.md b/README.md index b531455..f165ac7 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,59 @@ verified = v.verify( ) ``` +## Serializing + +Macaroons travel in one of four encodings. Ruby-Macaroons reads all four and writes either version. + +| | V1 | V2 | +|---|---|---| +| binary | `serialize(version: 1)` | `serialize` | +| JSON | `serialize_json(version: 1)` | `serialize_json` | + +```ruby +m = Macaroon.new(key: key, identifier: identifier, location: 'http://foo.com') + +m.serialize # base64url, unpadded, ready to put in a header +m.serialize_binary # the raw wire bytes +m.serialize_json # a JSON document +``` + +New macaroons default to **version 1**, so upgrading this gem never changes the bytes an +existing caller emits. Opt into version 2 per macaroon: + +```ruby +m = Macaroon.new(key: key, identifier: identifier, location: 'http://foo.com', version: 2) +``` + +Version 2 is the newest format and what libmacaroons (`MACAROON_LATEST`) and +[js-macaroon](https://github.com/go-macaroon/js-macaroon) produce by default. **You need it +to exchange binary macaroons with js-macaroon**, which can neither read nor write the V1 +binary format — V2 is the only binary encoding the two libraries share. + +### Deserializing + +`Macaroon.from_serialized` detects the encoding from the data itself, the way +`macaroon_deserialize` does in libmacaroons. It recognises V1 binary, V1 JSON, V2 binary +and V2 JSON, each either raw or base64-wrapped: + +```ruby +m = Macaroon.from_serialized(request.env['HTTP_AUTHORIZATION']) +m.version # => whichever version arrived +``` + +Re-serializing a deserialized macaroon reproduces the version it arrived in, so a service can +accept both and answer in kind — the `version:` default only applies to macaroons you mint. + +`Macaroon.from_binary` and `Macaroon.from_json` are aliases kept for backwards compatibility. +Neither is restricted to its namesake format any more, so there is no longer any reason to +call one and rescue into the other. + ## More Macaroons [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). +[js-macaroon](https://github.com/go-macaroon/js-macaroon) is available for JavaScript, and speaks the V2 binary and V2 JSON formats. + The [libmacaroons library](https://github.com/rescrv/libmacaroons) comes with Python and Go bindings. PyMacaroons, libmacaroons, and Ruby-Macaroons all use the same underlying cryptographic library (libsodium). @@ -106,4 +155,5 @@ PyMacaroons, libmacaroons, and Ruby-Macaroons all use the same underlying crypto - [Mozilla Macaroon Tech Talk](https://air.mozilla.org/macaroons-cookies-with-contextual-caveats-for-decentralized-authorization-in-the-cloud/) - [libmacaroons](https://github.com/rescrv/libmacaroons) - [PyMacaroons](https://github.com/ecordell/pymacaroons) +- [js-macaroon](https://github.com/go-macaroon/js-macaroon) - [rbnacl](https://github.com/crypto-rb/rbnacl) diff --git a/lib/macaroons/caveat.rb b/lib/macaroons/caveat.rb index f58ad01..9a42db6 100644 --- a/lib/macaroons/caveat.rb +++ b/lib/macaroons/caveat.rb @@ -1,25 +1,49 @@ +require 'macaroons/utils' + module Macaroons class Caveat def initialize(caveat_id, verification_id=nil, caveat_location=nil) - @caveat_id = caveat_id - @verification_id = verification_id - @caveat_location = caveat_location + self.caveat_id = caveat_id + self.verification_id = verification_id + self.caveat_location = caveat_location + end + + def caveat_id + Utils.readable(@caveat_id) end - attr_accessor :caveat_id - attr_accessor :verification_id - attr_accessor :caveat_location + def caveat_id=(value) + @caveat_id = Utils.binary(value) + end + + # Ciphertext, so it stays raw bytes on the way out. + attr_reader :verification_id + + def verification_id=(value) + @verification_id = Utils.binary(value) + end + + def caveat_location + Utils.readable(@caveat_location) + end + + def caveat_location=(value) + @caveat_location = Utils.binary(value) + end def first_party? - verification_id.nil? + verification_id.nil? || verification_id.empty? end def third_party? !first_party? end + # Always emits all three keys, nil included, as it always has. Readers treat + # a nil vid as a first party caveat — js-macaroon's importJSONV1 checks + # `if (jsonCaveat.vid)` — so the nulls are harmless on the wire. def to_h - {'cid' => @caveat_id, 'vid' => @verification_id, 'cl' => @caveat_location} + { 'cid' => caveat_id, 'vid' => verification_id, 'cl' => caveat_location } end end diff --git a/lib/macaroons/macaroons.rb b/lib/macaroons/macaroons.rb index 18a30ad..620e555 100644 --- a/lib/macaroons/macaroons.rb +++ b/lib/macaroons/macaroons.rb @@ -6,21 +6,26 @@ module Macaroons class Macaroon extend Forwardable - def initialize(key: nil, identifier: nil, location: nil, raw_macaroon: nil) - @raw_macaroon = raw_macaroon || RawMacaroon.new(key: key, identifier: identifier, location: location) + def initialize(key: nil, identifier: nil, location: nil, version: RawMacaroon::DEFAULT_VERSION, raw_macaroon: nil) + @raw_macaroon = raw_macaroon || + RawMacaroon.new(key: key, identifier: identifier, location: location, version: version) end - def_delegators :@raw_macaroon, :identifier, :location, :signature, :caveats, - :serialize, :serialize_json, :add_first_party_caveat, :add_third_party_caveat, :prepare_for_request + def_delegators :@raw_macaroon, :identifier, :location, :signature, :caveats, :version, + :serialize, :serialize_binary, :serialize_json, :add_first_party_caveat, + :add_third_party_caveat, :prepare_for_request - def self.from_binary(serialized) - raw_macaroon = RawMacaroon.from_binary(serialized: serialized) - macaroon = Macaroons::Macaroon.new(raw_macaroon: raw_macaroon) + # Detects the format of the serialized macaroon: V1 binary, V1 JSON, + # V2 binary or V2 JSON, either raw or base64-wrapped. + def self.from_serialized(serialized) + Macaroons::Macaroon.new(raw_macaroon: Macaroons::Serialization.deserialize(serialized)) end - def self.from_json(serialized) - raw_macaroon = RawMacaroon.from_json(serialized: serialized) - macaroon = Macaroons::Macaroon.new(raw_macaroon: raw_macaroon) + # Both kept for backwards compatibility; neither is limited to its namesake + # format any more. + class << self + alias_method :from_binary, :from_serialized + alias_method :from_json, :from_serialized end def first_party_caveats diff --git a/lib/macaroons/raw_macaroon.rb b/lib/macaroons/raw_macaroon.rb index b570976..de1fd37 100644 --- a/lib/macaroons/raw_macaroon.rb +++ b/lib/macaroons/raw_macaroon.rb @@ -5,48 +5,94 @@ require 'macaroons/caveat' require 'macaroons/utils' require 'macaroons/serializers/binary' +require 'macaroons/serializers/binary_v2' require 'macaroons/serializers/json' +require 'macaroons/serializers/json_v2' +require 'macaroons/serializers/dispatch' module Macaroons class RawMacaroon - def initialize(key: nil, identifier: nil, location: nil) + # 2 is the newest format, matching libmacaroons' MACAROON_LATEST and + # js-macaroon's newMacaroon default. + LATEST_VERSION = 2 + + # ...but new macaroons still default to 1, so that upgrading this gem never + # silently changes the bytes an existing caller emits. Opt in with + # `version: 2`, which is required to interoperate with js-macaroon over + # binary — it cannot read the V1 binary format at all. + DEFAULT_VERSION = 1 + + SUPPORTED_VERSIONS = [1, 2].freeze + + def initialize(key: nil, identifier: nil, location: nil, version: DEFAULT_VERSION) if key.nil? || identifier.nil? || location.nil? raise ArgumentError, 'Must provide all three: (key, identifier, location)' end @key = key - @identifier = identifier - @location = location - @signature = create_initial_macaroon_signature(key, identifier) + @identifier = Utils.binary(identifier) + @location = Utils.binary(location) + @version = check_version(version) + @signature = create_initial_macaroon_signature(key, @identifier) @caveats = [] end + # Rebuilds a macaroon from already-signed parts. Deserializers use this + # instead of the constructor so they neither invent a placeholder key nor + # derive a signature they are about to throw away. + def self.build(identifier: nil, location: nil, caveats: [], signature: nil, version: DEFAULT_VERSION) + macaroon = allocate + macaroon.send( + :initialize_from_parts, + identifier: identifier, + location: location, + caveats: caveats, + signature: signature, + version: version + ) + macaroon + end + + # Accepts any format any current macaroon implementation emits, raw or + # base64-wrapped. The two names are kept for backwards compatibility. def self.from_binary(serialized: nil) - Macaroons::BinarySerializer.new().deserialize(serialized) + Macaroons::Serialization.deserialize(serialized) end def self.from_json(serialized: nil) - Macaroons::JsonSerializer.new().deserialize(serialized) + Macaroons::Serialization.deserialize(serialized) end - attr_reader :identifier attr_reader :key - attr_reader :location + attr_reader :version attr_accessor :caveats - attr_accessor :signature + + def identifier + Utils.readable(@identifier) + end + + def location + Utils.readable(@location) + end def signature Utils.hexlify(@signature).downcase end + def signature=(value) + @signature = Utils.binary(value) + end + def add_first_party_caveat(predicate) + predicate = Utils.binary(predicate) caveat = Caveat.new(predicate) @caveats << caveat @signature = Utils.sign_first_party_caveat(@signature, predicate) end def add_third_party_caveat(caveat_key, caveat_id, caveat_location) + caveat_id = Utils.binary(caveat_id) derived_caveat_key = Utils.truncate_or_pad(Utils.hmac('macaroons-key-generator', caveat_key)) truncated_or_padded_signature = Utils.truncate_or_pad(@signature) box = RbNaCl::SimpleBox.from_secret_key(truncated_or_padded_signature) @@ -57,12 +103,18 @@ def add_third_party_caveat(caveat_key, caveat_id, caveat_location) @signature = Utils.sign_third_party_caveat(@signature, verification_id, caveat_id) end - def serialize - Macaroons::BinarySerializer.new().serialize(self) + # The transportable form: base64url, unpadded, as it has always been. + def serialize(version: @version) + Utils.base64_url_encode(serialize_binary(version: version)) end - def serialize_json - Macaroons::JsonSerializer.new().serialize(self) + # The raw wire bytes, for handing straight to libmacaroons or js-macaroon. + def serialize_binary(version: @version) + binary_serializer(check_version(version)).serialize_raw(self) + end + + def serialize_json(version: @version) + json_serializer(check_version(version)).serialize(self) end def prepare_for_request(macaroon) @@ -81,6 +133,33 @@ def bind_signature(signature) private + def initialize_from_parts(identifier: nil, location: nil, caveats: [], signature: nil, version: DEFAULT_VERSION) + raise ArgumentError, 'Must provide an identifier' if identifier.nil? + raise ArgumentError, 'Must provide a signature' if signature.nil? + + @key = nil + @identifier = Utils.binary(identifier) + @location = Utils.binary(location) + @caveats = caveats + @signature = Utils.binary(signature) + @version = check_version(version) + end + + def binary_serializer(version) + version == 1 ? BinarySerializer.new : BinaryV2Serializer.new + end + + def json_serializer(version) + version == 1 ? JsonSerializer.new : JsonV2Serializer.new + end + + def check_version(version) + unless SUPPORTED_VERSIONS.include?(version) + raise ArgumentError, "Unsupported macaroon version #{version.inspect}" + end + version + end + def create_initial_macaroon_signature(key, identifier) derived_key = Utils.generate_derived_key(key) Utils.hmac(derived_key, identifier) diff --git a/lib/macaroons/serializers/binary.rb b/lib/macaroons/serializers/binary.rb index 9cdbe30..fcae0ef 100644 --- a/lib/macaroons/serializers/binary.rb +++ b/lib/macaroons/serializers/binary.rb @@ -1,12 +1,26 @@ -require 'base64' - require 'macaroons/serializers/base' +require 'macaroons/utils' module Macaroons + # libmacaroons V1 binary format: a stream of `LLLLkey value\n` packets, where + # LLLL is the total packet length as four hex digits. See libmacaroons/v1.c. + # + # Note that js-macaroon can neither produce nor consume this format, so it is + # only useful for talking to libmacaroons and its bindings. class BinarySerializer < BaseSerializer PACKET_PREFIX_LENGTH = 4 + MAX_PACKET_LENGTH = 0xFFFF + HEX_HEADER = /\A[0-9a-fA-F]{4}\z/ + HEX_PREFIX = /\A[0-9a-fA-F]{4}/ + # Returns base64, as this method always has. RawMacaroon and the format + # dispatcher use #serialize_raw instead, because V2 binary is raw bytes on + # the wire and base64 is a transport choice made one layer up. def serialize(macaroon) + Utils.base64_url_encode(serialize_raw(macaroon)) + end + + def serialize_raw(macaroon) combined = packetize('location', macaroon.location) combined += packetize('identifier', macaroon.identifier) @@ -23,18 +37,42 @@ def serialize(macaroon) 'signature', Utils.unhexlify(macaroon.signature) ) - base64_url_encode(combined) + combined end + # Accepts either the raw packet stream or the base64 form this method used to + # require. The two are never confusable: raw packets open with a four hex + # digit length prefix, and base64 of those always opens "MDAx". def deserialize(serialized) + decoded = Utils.binary(serialized) + decoded = Utils.base64_url_decode(decoded) unless decoded =~ HEX_PREFIX + caveats = [] - decoded = base64_url_decode(serialized) + location = nil + identifier = nil + signature = nil index = 0 - while index < decoded.length - packet_length = decoded[index..(index + PACKET_PREFIX_LENGTH - 1)].to_i(16) - stripped_packet = decoded[index + PACKET_PREFIX_LENGTH..(index + packet_length - 2)] + while index < decoded.bytesize + header = decoded.byteslice(index, PACKET_PREFIX_LENGTH) + + unless header && header.bytesize == PACKET_PREFIX_LENGTH && header =~ HEX_HEADER + raise KeyError, 'Invalid packet header in binary macaroon. Macaroon may be corrupted.' + end + + packet_length = header.to_i(16) + + # A packet must at least carry its own header and the trailing newline. + # Without this guard a zero length spins the loop forever. + if packet_length <= PACKET_PREFIX_LENGTH || index + packet_length > decoded.bytesize + raise KeyError, 'Invalid packet length in binary macaroon. Macaroon may be corrupted.' + end + + stripped_packet = decoded.byteslice( + index + PACKET_PREFIX_LENGTH, + packet_length - PACKET_PREFIX_LENGTH - 1 + ) key, value = depacketize(stripped_packet) @@ -46,8 +84,10 @@ def deserialize(serialized) when 'cid' caveats << Caveat.new(value) when 'vid' + raise KeyError, 'Dangling vid in binary macaroon.' if caveats.empty? caveats[-1].verification_id = value when 'cl' + raise KeyError, 'Dangling cl in binary macaroon.' if caveats.empty? caveats[-1].caveat_location = value when 'signature' signature = value @@ -57,42 +97,40 @@ def deserialize(serialized) index = index + packet_length end - macaroon = Macaroons::RawMacaroon.new(key: 'no_key', identifier: identifier, location: location) - macaroon.caveats = caveats - macaroon.signature = signature - macaroon + + if identifier.nil? || signature.nil? + raise KeyError, 'Binary macaroon is missing an identifier or signature.' + end + + Macaroons::RawMacaroon.build( + identifier: identifier, + location: location, + caveats: caveats, + signature: signature, + version: 1 + ) end private def packetize(key, data) + data = Utils.binary(data) # The 2 covers the space and the newline - packet_size = PACKET_PREFIX_LENGTH + 2 + key.length + data.length - if packet_size > 65535 + packet_size = PACKET_PREFIX_LENGTH + 2 + key.bytesize + data.bytesize + if packet_size > MAX_PACKET_LENGTH # Due to packet structure, length of packet must be less than 0xFFFF raise ArgumentError, 'Data is too long for a binary packet.' end - packet_size_hex = packet_size.to_s(16) - header = packet_size_hex.to_s.rjust(4, '0') - packet_content = "#{key} #{data}\n" - packet = "#{header}#{packet_content}" - packet + header = packet_size.to_s(16).rjust(PACKET_PREFIX_LENGTH, '0') + Utils.binary("#{header}#{key} #{data}\n") end def depacketize(packet) - key = packet.split(" ")[0] - value = packet[key.length + 1..-1] + separator = packet.index(' ') + raise KeyError, 'Malformed packet in binary macaroon.' if separator.nil? + key = packet.byteslice(0, separator) + value = packet.byteslice(separator + 1, packet.bytesize - separator - 1) [key, value] end - - def base64_url_decode(str) - str = str.delete('=') - str += '=' * (4 - str.length.modulo(4)).modulo(4) - Base64.urlsafe_decode64(str) - end - - def base64_url_encode(str) - Base64.urlsafe_encode64(str).tr('=', '') - end end end diff --git a/lib/macaroons/serializers/binary_v2.rb b/lib/macaroons/serializers/binary_v2.rb new file mode 100644 index 0000000..4070caf --- /dev/null +++ b/lib/macaroons/serializers/binary_v2.rb @@ -0,0 +1,138 @@ +require 'macaroons/serializers/base' +require 'macaroons/utils' + +module Macaroons + # libmacaroons V2 binary format (libmacaroons/v2.c, js-macaroon's + # _exportBinaryV2). A version byte followed by typed, uvarint-length-prefixed + # fields, with EOS markers closing the header, each caveat, and the caveat list: + # + # 0x02 + # [LOCATION]? IDENTIFIER EOS + # ( [LOCATION]? IDENTIFIER [VID]? EOS )* + # EOS + # SIGNATURE + # + # This is the only binary format js-macaroon speaks. + class BinaryV2Serializer < BaseSerializer + VERSION = 2 + + FIELD_EOS = 0 + FIELD_LOCATION = 1 + FIELD_IDENTIFIER = 2 + FIELD_VID = 4 + FIELD_SIGNATURE = 6 + + # Same contract as BinarySerializer: #serialize returns base64, #serialize_raw + # returns the wire bytes. + def serialize(macaroon) + Utils.base64_url_encode(serialize_raw(macaroon)) + end + + def serialize_raw(macaroon) + eos = [FIELD_EOS].pack('C') + out = [VERSION].pack('C') + + out += optional_field(FIELD_LOCATION, macaroon.location) + out += required_field(FIELD_IDENTIFIER, macaroon.identifier) + out += eos + + macaroon.caveats.each do |caveat| + out += optional_field(FIELD_LOCATION, caveat.caveat_location) + out += required_field(FIELD_IDENTIFIER, caveat.caveat_id) + out += optional_field(FIELD_VID, caveat.verification_id) + out += eos + end + + out += eos + out += required_field(FIELD_SIGNATURE, Utils.unhexlify(macaroon.signature)) + out + end + + def deserialize(serialized) + data = Utils.binary(serialized) + raise KeyError, 'Empty macaroon data.' if data.bytesize == 0 + + version = data.getbyte(0) + unless version == VERSION + raise KeyError, "Only version 2 is supported, found version #{version}." + end + offset = 1 + + location, offset = read_optional_field(data, offset, FIELD_LOCATION) + identifier, offset = read_required_field(data, offset, FIELD_IDENTIFIER) + offset = read_eos(data, offset) + + caveats = [] + loop do + break if peek_type(data, offset) == FIELD_EOS + + caveat_location, offset = read_optional_field(data, offset, FIELD_LOCATION) + caveat_id, offset = read_required_field(data, offset, FIELD_IDENTIFIER) + verification_id, offset = read_optional_field(data, offset, FIELD_VID) + offset = read_eos(data, offset) + + caveats << Caveat.new(caveat_id, verification_id, caveat_location) + end + offset = read_eos(data, offset) + + signature, offset = read_required_field(data, offset, FIELD_SIGNATURE) + + if offset != data.bytesize + raise KeyError, 'Unexpected extra data at end of macaroon.' + end + + Macaroons::RawMacaroon.build( + identifier: identifier, + location: location, + caveats: caveats, + signature: signature, + version: VERSION + ) + end + + private + + def required_field(type, data) + data = Utils.binary(data) || '' + [type].pack('C') + Utils.encode_uvarint(data.bytesize) + data + end + + # An absent optional field is simply not emitted, which is why a zero-length + # location and a missing location are indistinguishable on the wire. + def optional_field(type, data) + return '' if data.nil? || data.empty? + required_field(type, data) + end + + def peek_type(data, offset) + raise KeyError, 'Truncated macaroon.' if offset >= data.bytesize + data.getbyte(offset) + end + + def read_required_field(data, offset, expected_type) + type = peek_type(data, offset) + unless type == expected_type + raise KeyError, "Unexpected field type, got #{type} want #{expected_type}." + end + offset += 1 + + length, offset = Utils.decode_uvarint(data, offset) + if offset + length > data.bytesize + raise KeyError, 'Truncated field in macaroon.' + end + + [data.byteslice(offset, length), offset + length] + end + + def read_optional_field(data, offset, expected_type) + return [nil, offset] if peek_type(data, offset) != expected_type + read_required_field(data, offset, expected_type) + end + + def read_eos(data, offset) + type = peek_type(data, offset) + raise KeyError, "Expected end-of-section marker, got #{type}." unless type == FIELD_EOS + offset + 1 + end + end +end diff --git a/lib/macaroons/serializers/dispatch.rb b/lib/macaroons/serializers/dispatch.rb new file mode 100644 index 0000000..3dcd2f5 --- /dev/null +++ b/lib/macaroons/serializers/dispatch.rb @@ -0,0 +1,90 @@ +require 'multi_json' + +require 'macaroons/utils' +require 'macaroons/serializers/binary' +require 'macaroons/serializers/binary_v2' +require 'macaroons/serializers/json' +require 'macaroons/serializers/json_v2' + +module Macaroons + # Picks a deserializer by looking at the data, the way libmacaroons' + # macaroon_deserialize (macaroons.c) and js-macaroon's importMacaroon do. + # + # One addition on top of those: macaroons arriving over HTTP are almost always + # base64-wrapped, so if the bytes are not a recognisable macaroon but do look + # like base64, they get decoded once and re-sniffed. That removes the need for + # callers to guess the format before calling. + # + # A module rather than a class because there is no state to hold — the same + # shape as Macaroons::Utils. Named Serialization, not Serializers, because the + # serializer classes are not namespaced under it; they sit directly under + # Macaroons, where they have always been. + module Serialization + module_function + + V2_BINARY_MARKER = 0x02 + JSON_MARKER = '{'.ord + HEX_HEADER = /\A[0-9a-fA-F]{4}/ + BASE64ISH = /\A[A-Za-z0-9+\/\-_=\s]+\z/ + + def deserialize(serialized) + data = Utils.binary(serialized) + raise KeyError, 'Empty macaroon data.' if data.nil? || data.empty? + dispatch(data, true) + end + + def dispatch(data, unwrap_base64) + serializer = serializer_for(data) + + if serializer + begin + return serializer.deserialize(data) + rescue StandardError + # Fall through to the base64 attempt below; a base64 payload can start + # with four hex characters and masquerade as a V1 binary macaroon. + raise unless unwrap_base64 && base64ish?(data) + end + end + + unless unwrap_base64 && base64ish?(data) + raise KeyError, 'Unable to determine the format of the serialized macaroon.' + end + + decoded = begin + Utils.base64_url_decode(data) + rescue ArgumentError + raise KeyError, 'Unable to determine the format of the serialized macaroon.' + end + + raise KeyError, 'Empty macaroon data.' if decoded.empty? + dispatch(decoded, false) + end + + def serializer_for(data) + case data.getbyte(0) + when V2_BINARY_MARKER + BinaryV2Serializer.new + when JSON_MARKER + json_serializer_for(data) + else + BinarySerializer.new if data =~ HEX_HEADER + end + end + + # V1 JSON is the one with a top-level "signature"; V2 uses "s"/"s64". + # Mirrors importJSON in js-macaroon. + def json_serializer_for(data) + parsed = begin + MultiJson.load(data) + rescue MultiJson::ParseError + return nil + end + return nil unless parsed.is_a?(Hash) + parsed.key?('signature') ? JsonSerializer.new : JsonV2Serializer.new + end + + def base64ish?(data) + data =~ BASE64ISH ? true : false + end + end +end diff --git a/lib/macaroons/serializers/json.rb b/lib/macaroons/serializers/json.rb index ea0f3a1..bc6014a 100644 --- a/lib/macaroons/serializers/json.rb +++ b/lib/macaroons/serializers/json.rb @@ -1,42 +1,60 @@ require 'multi_json' +require 'macaroons/serializers/base' +require 'macaroons/utils' + module Macaroons - class JsonSerializer + # V1 JSON. This is the only encoding that both js-macaroon and ruby-macaroons + # have ever spoken, so it is the compatibility floor. + class JsonSerializer < BaseSerializer + # Unchanged from previous releases: takes and returns a JSON string, and has + # never accepted base64. #serialize_raw is inherited and identical. def serialize(macaroon) - caveats = macaroon.caveats.map! do |c| + # `map`, not `map!` — the original mutated @caveats in place, so a second + # call base64-encoded an already-encoded vid. + caveats = macaroon.caveats.map do |c| if c.first_party? - c + c.to_h else + # Standard, padded base64: js-macaroon decodes it happily, and unlike + # url-safe it is also accepted by the stricter Go and Python readers. Macaroons::Caveat.new( c.caveat_id, - verification_id=Base64.strict_encode64(c.verification_id), - caveat_location=c.caveat_location - ) + Base64.strict_encode64(c.verification_id), + c.caveat_location + ).to_h end end + serialized = { location: macaroon.location, identifier: macaroon.identifier, - caveats: caveats.map(&:to_h), + caveats: caveats, signature: macaroon.signature } MultiJson.dump(serialized) end def deserialize(serialized) - deserialized = MultiJson.load(serialized) - macaroon = Macaroons::RawMacaroon.new(key: 'no_key', identifier: deserialized['identifier'], location: deserialized['location']) - deserialized['caveats'].each do |c| + deserialized = serialized.is_a?(Hash) ? serialized : MultiJson.load(serialized) + caveats = (deserialized['caveats'] || []).map do |c| if c['vid'] - caveat = Macaroons::Caveat.new(c['cid'], Base64.strict_decode64(c['vid']), c['cl']) + # js-macaroon writes vid url-safe and unpadded; earlier ruby-macaroons + # wrote it standard and padded. Accept either. + Macaroons::Caveat.new(c['cid'], Utils.base64_url_decode(c['vid']), c['cl']) else - caveat = Macaroons::Caveat.new(c['cid'], c['vid'], c['cl']) + Macaroons::Caveat.new(c['cid']) end - macaroon.caveats << caveat end - macaroon.signature = Utils.unhexlify(deserialized['signature']) - macaroon + + Macaroons::RawMacaroon.build( + identifier: deserialized['identifier'], + location: deserialized['location'], + caveats: caveats, + signature: Utils.unhexlify(deserialized['signature']), + version: 1 + ) end end diff --git a/lib/macaroons/serializers/json_v2.rb b/lib/macaroons/serializers/json_v2.rb new file mode 100644 index 0000000..a01b90a --- /dev/null +++ b/lib/macaroons/serializers/json_v2.rb @@ -0,0 +1,91 @@ +require 'multi_json' + +require 'macaroons/serializers/base' +require 'macaroons/utils' + +module Macaroons + # libmacaroons V2 JSON ("v2j"): the same field set as the V2 binary format, but + # each field is written either as a plain string under its short key, or, when + # the bytes are not valid UTF-8, as url-safe unpadded base64 under key + "64". + # See json_field_type_encoded in libmacaroons/v2.c and setJSONFieldV2 in + # js-macaroon. A 32-byte signature is almost never valid UTF-8, so in practice + # it lands in "s64". + class JsonV2Serializer < BaseSerializer + VERSION = 2 + + def serialize(macaroon) + obj = { 'v' => VERSION } + + set_field(obj, 'l', macaroon.location) + set_field(obj, 'i', macaroon.identifier) + + # Emitted even when empty, matching libmacaroons' v2j output. js-macaroon + # omits the key in that case; both readers accept either, but libmacaroons + # is the reference and its serialization_1 vector carries "c":[]. + obj['c'] = macaroon.caveats.map do |caveat| + caveat_obj = {} + set_field(caveat_obj, 'i', caveat.caveat_id) + unless caveat.first_party? + set_field(caveat_obj, 'v', caveat.verification_id) + set_field(caveat_obj, 'l', caveat.caveat_location) + end + caveat_obj + end + + set_field(obj, 's', Utils.unhexlify(macaroon.signature)) + + MultiJson.dump(obj) + end + + def deserialize(serialized) + obj = serialized.is_a?(Hash) ? serialized : MultiJson.load(serialized) + + # The Go library omits the version field entirely, so treat absent as 2. + # https://github.com/go-macaroon/macaroon/issues/35 + version = obj['v'] + unless version.nil? || version == VERSION + raise KeyError, "Unsupported macaroon version #{version}." + end + + caveats = (obj['c'] || []).map do |caveat_obj| + Caveat.new( + get_field(caveat_obj, 'i', true), + get_field(caveat_obj, 'v'), + get_field(caveat_obj, 'l') + ) + end + + Macaroons::RawMacaroon.build( + identifier: get_field(obj, 'i', true), + location: get_field(obj, 'l'), + caveats: caveats, + signature: get_field(obj, 's', true), + version: VERSION + ) + end + + private + + def set_field(obj, key, value) + return if value.nil? || value.empty? + + value = Utils.binary(value) + + if Utils.valid_utf8?(value) + obj[key] = value.dup.force_encoding(Encoding::UTF_8) + else + obj["#{key}64"] = Utils.base64_url_encode(value) + end + end + + def get_field(obj, key, required=false) + return Utils.binary(obj[key]) if obj.key?(key) + + key64 = "#{key}64" + return Utils.base64_url_decode(obj[key64]) if obj.key?(key64) + + raise KeyError, "Expected key: #{key}" if required + nil + end + end +end diff --git a/lib/macaroons/utils.rb b/lib/macaroons/utils.rb index ead01da..4c81680 100644 --- a/lib/macaroons/utils.rb +++ b/lib/macaroons/utils.rb @@ -1,10 +1,31 @@ +require 'base64' require 'openssl' module Macaroons module Utils - def self.convert_to_bytes(string) - string.encode('us-ascii') unless string.nil? + # Every value that reaches the wire is a byte string. Ruby will happily hand + # us UTF-8 Strings whose character count differs from their byte count, which + # corrupts any length-prefixed format, so wire values are funnelled through + # here on the way in. + def self.binary(value) + return nil if value.nil? + value.to_s.dup.force_encoding(Encoding::BINARY) + end + + # Drives the V2 JSON choice between a plain "key" and a base64 "key64". + def self.valid_utf8?(value) + return false if value.nil? + value.dup.force_encoding(Encoding::UTF_8).valid_encoding? + end + + # The inverse of binary, for values handed back to callers. Identifiers and + # predicates get compared against ordinary UTF-8 Strings (Verifier does + # exactly that with its predicate list), and a BINARY-encoded String never + # compares equal to a UTF-8 one once non-ASCII bytes are involved. + def self.readable(value) + return nil if value.nil? + valid_utf8?(value) ? value.dup.force_encoding(Encoding::UTF_8) : value end def self.hexlify(value) @@ -17,10 +38,10 @@ def self.unhexlify(value) def self.truncate_or_pad(string, size=nil) size = size.nil? ? 32 : size - if string.length > size - string[0, size] - elsif string.length < size - string + "\0"*(size-string.length) + if string.bytesize > size + string.byteslice(0, size) + elsif string.bytesize < size + string + "\0"*(size-string.bytesize) else string end @@ -45,5 +66,53 @@ def self.sign_third_party_caveat(signature, verification_id, caveat_id) def self.generate_derived_key(key) Utils.hmac('macaroons-key-generator', key) end + + # Base-128 varint, least significant group first, as used by the V2 binary + # format. Mirrors libmacaroons' varint.c and js-macaroon's appendUvarint. + MAX_UVARINT_BYTES = 10 + + def self.encode_uvarint(value) + raise ArgumentError, "varint #{value} out of range" if value < 0 + bytes = [] + while value >= 0x80 + bytes << ((value & 0x7f) | 0x80) + value >>= 7 + end + bytes << value + bytes.pack('C*') + end + + # Returns [value, offset_just_past_the_varint]. + def self.decode_uvarint(data, offset=0) + value = 0 + shift = 0 + read = 0 + + loop do + raise ArgumentError, 'Truncated varint.' if offset >= data.bytesize + byte = data.getbyte(offset) + offset += 1 + read += 1 + value |= (byte & 0x7f) << shift + break if byte < 0x80 + shift += 7 + raise ArgumentError, 'Varint is too long.' if read >= MAX_UVARINT_BYTES + end + + [value, offset] + end + + def self.base64_url_encode(value) + Base64.urlsafe_encode64(value).delete('=') + end + + # Deliberately lenient: implementations disagree on alphabet and padding. + # js-macaroon emits url-safe and unpadded, ruby-macaroons historically emitted + # standard and padded, and libmacaroons' b64_pton accepts either. + def self.base64_url_decode(value) + normalized = value.tr('-_', '+/').delete("=\n\r") + normalized += '=' * ((4 - normalized.bytesize % 4) % 4) + Base64.strict_decode64(normalized) + end end end diff --git a/lib/macaroons/version.rb b/lib/macaroons/version.rb index a122543..2a30046 100644 --- a/lib/macaroons/version.rb +++ b/lib/macaroons/version.rb @@ -1,3 +1,3 @@ module Macaroons - VERSION = '1.0.0' + VERSION = '1.1.0' end diff --git a/spec/serialization_spec.rb b/spec/serialization_spec.rb new file mode 100644 index 0000000..1fa0eee --- /dev/null +++ b/spec/serialization_spec.rb @@ -0,0 +1,540 @@ +require 'spec_helper' +require 'macaroons' +require 'macaroons/errors' + +# The V2 expectations below are taken verbatim from libmacaroons' own suite, +# test/unit/*.vtest. There is no macaroon specification for serialization — the +# paper defines the cryptographic construction and says nothing about wire +# formats — so libmacaroons/v1.c and v2.c are the de facto definition, and its +# vectors are the closest thing to a conformance suite that exists. Any +# implementation that reproduces them interoperates with every other one; +# js-macaroon, for instance, carries the same strings in its test/serialize-*.js. +# +# They are here as an oracle, not as a record of whatever this library happens +# to emit. If a vector and this implementation ever disagree, this +# implementation is the one that is wrong. +describe 'V2 serialization' do + let(:root_key) { 'this is the key' } + + def macaroon(*predicates) + m = Macaroon.new( + location: 'http://example.org/', + identifier: 'keyid', + key: root_key, + version: 2 + ) + predicates.each { |p| m.add_first_party_caveat(p) } + m + end + + it 'defaults new macaroons to version 1, so upgrading changes no bytes' do + expect(Macaroon.new(location: 'l', identifier: 'i', key: 'k').version).to eql(1) + expect(Macaroons::RawMacaroon::DEFAULT_VERSION).to eql(1) + end + + it 'still reports 2 as the latest available format' do + expect(Macaroons::RawMacaroon::LATEST_VERSION).to eql(2) + expect(macaroon.version).to eql(2) + end + + context 'binary' do + { + 'without caveats' => [ + [], + 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAAYgfN7nklEcW8b1KEhYBd_psk54XijiqZMB-dcRxgnjjvc' + ], + 'with one caveat' => [ + ['account = 3735928559'], + 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAhRhY2NvdW50ID0gMzczNTkyODU1OQAABiD1SAf23G7fiL8PcwazgiVio2JTPb9zObphdl2kvSWdhw' + ], + 'with two caveats' => [ + ['account = 3735928559', 'user = alice'], + 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAhRhY2NvdW50ID0gMzczNTkyODU1OQACDHVzZXIgPSBhbGljZQAABiBL6WfNHqDGsmuvakqU7psFsViG2guoXoxCqTyNDhJe_A' + ] + }.each do |description, (predicates, expected)| + it "serializes #{description} exactly as libmacaroons does" do + expect(macaroon(*predicates).serialize).to eql(expected) + end + + it "deserializes #{description}" do + m = Macaroon.from_binary(expected) + expect(m.version).to eql(2) + expect(m.location).to eql('http://example.org/') + expect(m.identifier).to eql('keyid') + expect(m.caveats.map(&:caveat_id)).to eql(predicates) + expect(m.signature).to eql(macaroon(*predicates).signature) + end + end + + it 'starts with the version byte when unwrapped' do + expect(macaroon.serialize_binary.getbyte(0)).to eql(2) + end + + it 'rejects trailing data after the signature' do + corrupt = macaroon.serialize_binary + 'junk' + expect { Macaroon.from_binary(corrupt) }.to raise_error(KeyError) + end + + it 'rejects an unknown version byte' do + expect { Macaroon.from_binary("\x03extra") }.to raise_error(KeyError) + end + end + + context 'json' do + { + 'without caveats' => [ + [], + '{"v":2,"l":"http://example.org/","i":"keyid","c":[],"s64":"fN7nklEcW8b1KEhYBd_psk54XijiqZMB-dcRxgnjjvc"}' + ], + 'with one caveat' => [ + ['account = 3735928559'], + '{"v":2,"l":"http://example.org/","i":"keyid","c":[{"i":"account = 3735928559"}],"s64":"9UgH9txu34i_D3MGs4IlYqNiUz2_czm6YXZdpL0lnYc"}' + ], + 'with two caveats' => [ + ['account = 3735928559', 'user = alice'], + '{"v":2,"l":"http://example.org/","i":"keyid","c":[{"i":"account = 3735928559"},{"i":"user = alice"}],"s64":"S-lnzR6gxrJrr2pKlO6bBbFYhtoLqF6MQqk8jQ4SXvw"}' + ] + }.each do |description, (predicates, expected)| + it "serializes #{description} exactly as libmacaroons does" do + expect(macaroon(*predicates).serialize_json).to eql(expected) + end + + it "deserializes #{description}" do + m = Macaroon.from_json(expected) + expect(m.version).to eql(2) + expect(m.caveats.map(&:caveat_id)).to eql(predicates) + expect(m.signature).to eql(macaroon(*predicates).signature) + end + end + + it 'treats a missing version field as 2, as the Go library emits it' do + without_v = '{"l":"http://example.org/","i":"keyid","s64":"fN7nklEcW8b1KEhYBd_psk54XijiqZMB-dcRxgnjjvc"}' + expect(Macaroon.from_json(without_v).signature).to eql(macaroon.signature) + end + + it 'rejects an unsupported version field' do + expect { Macaroon.from_json('{"v":3,"i":"keyid","s64":"AA"}') }.to raise_error(KeyError) + end + + it 'reads a base64 field written under its plain key' do + plain = '{"v":2,"l":"http://example.org/","i":"keyid","s":"not-really-a-signature"}' + expect(Macaroon.from_json(plain).signature).to eql(Macaroons::Utils.hexlify('not-really-a-signature').downcase) + end + end + + context 'round trips' do + it 'preserves the signature through binary with first and third party caveats' do + m = Macaroon.new( + location: 'http://mybank/', + identifier: 'we used our other secret key', + key: 'this is a different super-secret key; never use the same secret twice', + version: 2 + ) + m.add_first_party_caveat('account = 3735928559') + m.add_third_party_caveat('4; guaranteed random by a fair toss of the dice', + 'this was how we remind auth of key/pred', + 'http://auth.mybank/') + + n = Macaroon.from_binary(m.serialize) + expect(n.signature).to eql(m.signature) + expect(n.third_party_caveats.first.verification_id).to eql(m.third_party_caveats.first.verification_id) + expect(n.third_party_caveats.first.caveat_location).to eql('http://auth.mybank/') + end + + it 'preserves the signature through json with first and third party caveats' do + m = Macaroon.new( + location: 'http://mybank/', + identifier: 'we used our other secret key', + key: 'this is a different super-secret key; never use the same secret twice', + version: 2 + ) + m.add_first_party_caveat('account = 3735928559') + m.add_third_party_caveat('4; guaranteed random by a fair toss of the dice', + 'this was how we remind auth of key/pred', + 'http://auth.mybank/') + + n = Macaroon.from_json(m.serialize_json) + expect(n.signature).to eql(m.signature) + expect(n.third_party_caveats.first.verification_id).to eql(m.third_party_caveats.first.verification_id) + end + + it 're-serializes a deserialized macaroon in the version it arrived in' do + v1 = Macaroon.new(location: 'l', identifier: 'i', key: 'k', version: 1) + round_tripped = Macaroon.from_binary(v1.serialize) + expect(round_tripped.version).to eql(1) + expect(round_tripped.serialize).to eql(v1.serialize) + end + end +end + +describe 'Format detection' do + def build(version) + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret', version: version) + m.add_first_party_caveat('account = 3735928559') + m + end + + it 'detects raw V2 binary' do + m = build(2) + expect(Macaroon.from_binary(m.serialize_binary).signature).to eql(m.signature) + end + + it 'detects base64-wrapped V2 binary' do + m = build(2) + expect(Macaroon.from_binary(m.serialize).signature).to eql(m.signature) + end + + it 'detects raw V1 binary' do + m = build(1) + expect(Macaroon.from_binary(m.serialize_binary).signature).to eql(m.signature) + end + + it 'detects base64-wrapped V1 binary' do + m = build(1) + expect(Macaroon.from_binary(m.serialize).signature).to eql(m.signature) + end + + [1, 2].each do |version| + it "detects raw V#{version} JSON" do + m = build(version) + expect(Macaroon.from_binary(m.serialize_json).signature).to eql(m.signature) + end + + # This is the shape the js-macaroon clients send: btoa(JSON.stringify(...)). + it "detects base64-wrapped V#{version} JSON" do + m = build(version) + wrapped = Base64.strict_encode64(m.serialize_json) + expect(Macaroon.from_binary(wrapped).signature).to eql(m.signature) + end + end + + it 'accepts standard and url-safe base64 wrapping alike' do + m = build(2) + standard = Base64.strict_encode64(m.serialize_binary) + urlsafe = Base64.urlsafe_encode64(m.serialize_binary).delete('=') + expect(Macaroon.from_binary(standard).signature).to eql(m.signature) + expect(Macaroon.from_binary(urlsafe).signature).to eql(m.signature) + end + + it 'raises on data that is not a macaroon in any format' do + expect { Macaroon.from_binary('this is not a macaroon at all') }.to raise_error(KeyError) + end + + it 'raises on empty data' do + expect { Macaroon.from_binary('') }.to raise_error(KeyError) + end +end + +describe 'Byte handling' do + # Regression: the V1 serializer sized its packets with String#length, so a + # multibyte identifier produced a length prefix smaller than the bytes emitted. + [1, 2].each do |version| + it "round trips a multibyte identifier through V#{version} binary" do + m = Macaroon.new(location: 'http://mybank/', identifier: 'ünïcodé', key: 'secret', version: version) + m.add_first_party_caveat('nome = josé') + + n = Macaroon.from_binary(m.serialize) + expect(n.identifier).to eql('ünïcodé') + expect(n.caveats.first.caveat_id).to eql('nome = josé') + expect(n.signature).to eql(m.signature) + end + + it "round trips a multibyte identifier through V#{version} json" do + m = Macaroon.new(location: 'http://mybank/', identifier: 'ünïcodé', key: 'secret', version: version) + m.add_first_party_caveat('nome = josé') + + n = Macaroon.from_json(m.serialize_json) + expect(n.identifier).to eql('ünïcodé') + expect(n.signature).to eql(m.signature) + end + end + + it 'verifies a caveat whose predicate contains multibyte characters' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') + m.add_first_party_caveat('nome = josé') + + verifier = Macaroons::Verifier.new + verifier.satisfy_exact('nome = josé') + expect(verifier.verify(macaroon: Macaroon.from_binary(m.serialize), key: 'secret')).to be true + end + + # Regression: serialize_json used caveats.map!, which rewrote verification_id + # in place, so the second call base64-encoded the already-encoded value. + it 'does not mutate the macaroon when serializing json repeatedly' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret', version: 1) + m.add_third_party_caveat('caveat key', 'caveat id', 'http://auth.mybank/') + + first = m.serialize_json + expect(m.serialize_json).to eql(first) + expect(Macaroon.from_json(first).signature).to eql(m.signature) + end + + # Regression: js-macaroon writes V1 JSON vids url-safe and unpadded, which + # Base64.strict_decode64 rejects outright. + it 'reads a V1 JSON vid written in url-safe unpadded base64' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret', version: 1) + m.add_third_party_caveat('caveat key', 'caveat id', 'http://auth.mybank/') + + vid = m.third_party_caveats.first.verification_id + js_style = MultiJson.dump( + 'location' => 'http://mybank/', + 'identifier' => 'keyid', + 'caveats' => [{ + 'cid' => 'caveat id', + 'vid' => Base64.urlsafe_encode64(vid).delete('='), + 'cl' => 'http://auth.mybank/' + }], + 'signature' => m.signature + ) + + expect(Macaroon.from_json(js_style).third_party_caveats.first.verification_id).to eql(vid) + end + + it 'rejects a V1 packet that is too long to encode' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret', version: 1) + m.add_first_party_caveat('x' * 0xFFFF) + expect { m.serialize }.to raise_error(ArgumentError) + end + + # Regression: a zero-length packet header used to leave the read index in + # place, spinning forever instead of raising. + it 'rejects a V1 packet claiming zero length instead of looping forever' do + expect { Macaroon.from_binary('0000') }.to raise_error(KeyError) + end +end + +# The serializer classes are publicly reachable, so code outside this gem may +# call them directly. Their pre-V2 contract is pinned here: BinarySerializer +# takes and returns base64, JsonSerializer takes and returns a JSON string. +describe 'Serializer class backwards compatibility' do + let(:macaroon) do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret', version: 1) + m.add_first_party_caveat('account = 3735928559') + m + end + + context 'BinarySerializer' do + let(:serializer) { Macaroons::BinarySerializer.new } + + it 'still returns base64 from #serialize' do + expect(serializer.serialize(macaroon)).to eql(macaroon.serialize) + expect(serializer.serialize(macaroon)).to start_with('MDAx') + end + + it 'still accepts base64 in #deserialize' do + round_tripped = serializer.deserialize(serializer.serialize(macaroon)) + expect(round_tripped.signature).to eql(macaroon.signature) + end + + it 'also accepts the raw packet stream in #deserialize' do + round_tripped = serializer.deserialize(serializer.serialize_raw(macaroon)) + expect(round_tripped.signature).to eql(macaroon.signature) + end + + it 'exposes the raw packet bytes separately via #serialize_raw' do + expect(serializer.serialize_raw(macaroon)).to start_with('001c') + end + + # Raw and base64 are distinguishable because a packet stream opens with four + # hex digits and its base64 always opens "MDAx". + it 'never confuses the two forms' do + expect(serializer.serialize_raw(macaroon)).to match(/\A[0-9a-f]{4}/) + expect(serializer.serialize(macaroon)).not_to match(/\A[0-9a-fA-F]{4}/) + end + end + + context 'JsonSerializer' do + let(:serializer) { Macaroons::JsonSerializer.new } + + it 'still returns a JSON string from #serialize' do + expect(serializer.serialize(macaroon)).to eql(macaroon.serialize_json) + expect(serializer.serialize(macaroon)).to start_with('{') + end + + it 'still accepts a JSON string in #deserialize' do + round_tripped = serializer.deserialize(serializer.serialize(macaroon)) + expect(round_tripped.signature).to eql(macaroon.signature) + end + end +end + +describe 'Macaroons::Utils uvarint' do + # Boundaries where the encoded length grows by a byte. + { + 0 => "\x00", + 1 => "\x01", + 127 => "\x7f", + 128 => "\x80\x01", + 255 => "\xff\x01", + 16383 => "\xff\x7f", + 16384 => "\x80\x80\x01", + 4294967295 => "\xff\xff\xff\xff\x0f" + }.each do |value, encoded| + it "encodes and decodes #{value}" do + expect(Macaroons::Utils.encode_uvarint(value)).to eql(encoded.dup.force_encoding(Encoding::BINARY)) + expect(Macaroons::Utils.decode_uvarint(encoded.dup.force_encoding(Encoding::BINARY))).to eql([value, encoded.bytesize]) + end + end + + it 'rejects negative values' do + expect { Macaroons::Utils.encode_uvarint(-1) }.to raise_error(ArgumentError) + end + + it 'raises on a truncated varint' do + expect { Macaroons::Utils.decode_uvarint("\x80".dup.force_encoding(Encoding::BINARY)) }.to raise_error(ArgumentError) + end + + # Exercises multi-byte length prefixes end to end. + [127, 128, 16384].each do |size| + it "round trips a V2 caveat of #{size} bytes" do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret', version: 2) + m.add_first_party_caveat('y' * size) + expect(Macaroon.from_binary(m.serialize).caveats.first.caveat_id.bytesize).to eql(size) + end + end +end + +# libmacaroons ships its verifier vectors as plain text in test/unit/*.vtest: +# a root key, an expected outcome, zero or more exact-match predicates, and the +# base64 macaroon. Reproduced here as literals — no dependency on libmacaroons +# being present, and no dependency on any other language's implementation. +# +# Note that ruby-macaroons signals an unauthorized macaroon by raising rather +# than returning false, so the negative vectors assert the specific error. +describe 'libmacaroons V2 conformance vectors' do + no_caveats = 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAAYgfN7nklEcW8b1KEhYBd_psk54XijiqZMB-dcRxgnjjvc' + one_caveat = 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAhRhY2NvdW50ID0gMzczNTkyODU1OQAABiD1SAf23G7fiL8PcwazgiVio2JTPb9zObphdl2kvSWdhw' + two_caveats = 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAhRhY2NvdW50ID0gMzczNTkyODU1OQACDHVzZXIgPSBhbGljZQAABiBL6WfNHqDGsmuvakqU7psFsViG2guoXoxCqTyNDhJe_A' + + [ + { vtest: 'root_v2_1', macaroon: no_caveats, key: 'this is the key', + predicates: [], error: nil, + about: 'no caveats, correct key' }, + + { vtest: 'root_v2_2', macaroon: no_caveats, key: 'this is not the key', + predicates: [], error: SignatureMismatchError, + about: 'no caveats, wrong key' }, + + { vtest: 'caveat_v2_1', macaroon: one_caveat, key: 'this is the key', + predicates: ['account = 3735928559'], error: nil, + about: 'one caveat, predicate supplied' }, + + { vtest: 'caveat_v2_2', macaroon: one_caveat, key: 'this is the key', + predicates: ['account = 0000000000'], error: CaveatUnsatisfiedError, + about: 'one caveat, non-matching predicate' }, + + { vtest: 'caveat_v2_3', macaroon: one_caveat, key: 'this is the key', + predicates: [], error: CaveatUnsatisfiedError, + about: 'one caveat, no predicates at all' }, + + { vtest: 'caveat_v2_4', macaroon: two_caveats, key: 'this is the key', + predicates: ['account = 3735928559', 'user = alice'], error: nil, + about: 'two caveats, both predicates supplied' }, + + { vtest: 'caveat_v2_5', macaroon: two_caveats, key: 'this is the key', + predicates: ['account = 3735928559'], error: CaveatUnsatisfiedError, + about: 'two caveats, only the first predicate' }, + + { vtest: 'caveat_v2_6', macaroon: two_caveats, key: 'this is the key', + predicates: ['user = alice'], error: CaveatUnsatisfiedError, + about: 'two caveats, only the second predicate' } + ].each do |vector| + outcome = vector[:error] ? 'rejects' : 'verifies' + + it "#{vector[:vtest]}: #{outcome} #{vector[:about]}" do + macaroon = Macaroon.from_serialized(vector[:macaroon]) + verifier = Macaroon::Verifier.new + vector[:predicates].each { |p| verifier.satisfy_exact(p) } + + if vector[:error] + expect { verifier.verify(macaroon: macaroon, key: vector[:key]) } + .to raise_error(vector[:error]) + else + expect(verifier.verify(macaroon: macaroon, key: vector[:key])).to be true + end + end + end + + # Parsing is only half of it: re-serializing has to reproduce the vector byte + # for byte, or this library would be readable-but-not-writable by its peers. + { + 'root_v2_1' => no_caveats, + 'caveat_v2_1' => one_caveat, + 'caveat_v2_4' => two_caveats + }.each do |vtest, expected| + it "#{vtest}: re-serializes to the identical bytes" do + expect(Macaroon.from_serialized(expected).serialize).to eql(expected) + end + end + + it 'parses the vectors in their url-safe unpadded form, as the .vtest files store them' do + expect(two_caveats).to match(/[-_]/) + expect(Macaroon.from_serialized(two_caveats).caveats.map(&:caveat_id)) + .to eql(['account = 3735928559', 'user = alice']) + end +end + +# libmacaroons' test/unit/serialization_{1,2,3} give the same macaroon in all +# three encodings it supports, so they pin round-tripping across formats as well +# as the exact bytes of each. The v1 entry is doubly base64'd in the fixture +# because the V1 serialized form is itself base64; decoded once here to match +# what Macaroon#serialize returns. +describe 'libmacaroons cross-format serialization vectors' do + [ + { + name: 'serialization_1', + caveats: [], + v1: 'MDAyMWxvY2F0aW9uIGh0dHA6Ly9leGFtcGxlLm9yZy8KMDAxNWlkZW50aWZpZXIga2V5aWQKMDAyZnNpZ25hdHVyZSB83ueSURxbxvUoSFgF3-myTnheKOKpkwH51xHGCeOO9wo', + v2: 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAAYgfN7nklEcW8b1KEhYBd_psk54XijiqZMB-dcRxgnjjvc', + v2j: '{"v":2,"l":"http://example.org/","i":"keyid","c":[],"s64":"fN7nklEcW8b1KEhYBd_psk54XijiqZMB-dcRxgnjjvc"}' + }, + { + name: 'serialization_2', + caveats: ['account = 3735928559'], + v1: 'MDAyMWxvY2F0aW9uIGh0dHA6Ly9leGFtcGxlLm9yZy8KMDAxNWlkZW50aWZpZXIga2V5aWQKMDAxZGNpZCBhY2NvdW50ID0gMzczNTkyODU1OQowMDJmc2lnbmF0dXJlIPVIB_bcbt-Ivw9zBrOCJWKjYlM9v3M5umF2XaS9JZ2HCg', + v2: 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAhRhY2NvdW50ID0gMzczNTkyODU1OQAABiD1SAf23G7fiL8PcwazgiVio2JTPb9zObphdl2kvSWdhw', + v2j: '{"v":2,"l":"http://example.org/","i":"keyid","c":[{"i":"account = 3735928559"}],"s64":"9UgH9txu34i_D3MGs4IlYqNiUz2_czm6YXZdpL0lnYc"}' + }, + { + name: 'serialization_3', + caveats: ['account = 3735928559', 'user = alice'], + v1: 'MDAyMWxvY2F0aW9uIGh0dHA6Ly9leGFtcGxlLm9yZy8KMDAxNWlkZW50aWZpZXIga2V5aWQKMDAxZGNpZCBhY2NvdW50ID0gMzczNTkyODU1OQowMDE1Y2lkIHVzZXIgPSBhbGljZQowMDJmc2lnbmF0dXJlIEvpZ80eoMaya69qSpTumwWxWIbaC6hejEKpPI0OEl78Cg', + v2: 'AgETaHR0cDovL2V4YW1wbGUub3JnLwIFa2V5aWQAAhRhY2NvdW50ID0gMzczNTkyODU1OQACDHVzZXIgPSBhbGljZQAABiBL6WfNHqDGsmuvakqU7psFsViG2guoXoxCqTyNDhJe_A', + v2j: '{"v":2,"l":"http://example.org/","i":"keyid","c":[{"i":"account = 3735928559"},{"i":"user = alice"}],"s64":"S-lnzR6gxrJrr2pKlO6bBbFYhtoLqF6MQqk8jQ4SXvw"}' + } + ].each do |vector| + def build(caveats, version) + m = Macaroon.new( + location: 'http://example.org/', + identifier: 'keyid', + key: 'this is the key', + version: version + ) + caveats.each { |c| m.add_first_party_caveat(c) } + m + end + + context vector[:name] do + it 'emits the V1 binary bytes' do + expect(build(vector[:caveats], 1).serialize).to eql(vector[:v1]) + end + + it 'emits the V2 binary bytes' do + expect(build(vector[:caveats], 2).serialize).to eql(vector[:v2]) + end + + it 'emits the V2 JSON document' do + expect(build(vector[:caveats], 2).serialize_json).to eql(vector[:v2j]) + end + + # All three encode the same macaroon, so all three must land on one + # signature — that is what makes them interchangeable on the wire. + it 'parses all three encodings to the same signature' do + signature = build(vector[:caveats], 2).signature + [vector[:v1], vector[:v2], vector[:v2j]].each do |serialized| + expect(Macaroon.from_serialized(serialized).signature).to eql(signature) + end + end + end + end +end