From 7e879ea91ae59cabb040f662fcdeefe09ec6b1d8 Mon Sep 17 00:00:00 2001 From: Leonardo Mateo Date: Thu, 6 Aug 2026 18:01:42 -0300 Subject: [PATCH 1/3] Fix byte-vs-character and mutation bugs in the V1 serializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, each reachable from Macaroon.from_binary/from_json on caller-supplied input: 1. BinarySerializer sized its packets with String#length, so a multibyte identifier or caveat produced a length prefix smaller than the bytes actually emitted, corrupting the packet stream. deserialize had the matching problem, slicing the decoded stream by character. 2. A packet header claiming zero length left the read index unchanged, so deserialize spun forever instead of raising. Headers are now validated as four hex digits with a length that can at least cover the header and the trailing newline. 3. JsonSerializer#serialize used caveats.map!, mutating @caveats in place and replacing each third-party caveat's verification_id with its base64 form. Calling serialize_json twice therefore double-encoded the vid and produced a macaroon that could not be verified. Also widens vid decoding on the JSON side. js-macaroon writes V1 JSON vids as url-safe unpadded base64, which Base64.strict_decode64 rejects outright; decoding now accepts either alphabet, padded or not, matching js-macaroon's base64ToBytes and libmacaroons' b64_pton. Output is unchanged — still standard and padded, which the stricter Go and Python readers require. Wire-facing values are funnelled through Utils.binary on the way in and Utils.readable on the way out, so lengths are always measured in bytes while identifiers and predicates still compare equal to ordinary UTF-8 Strings — Verifier#satisfy_exact depends on the latter. RawMacaroon.build lets the deserializers rebuild a macaroon from signed parts without inventing a placeholder key and discarding a derived signature. Co-Authored-By: Claude Opus 5 --- lib/macaroons/caveat.rb | 40 ++++++++++--- lib/macaroons/raw_macaroon.rb | 49 +++++++++++++-- lib/macaroons/serializers/binary.rb | 85 ++++++++++++++++---------- lib/macaroons/serializers/json.rb | 47 ++++++++++----- lib/macaroons/utils.rb | 46 ++++++++++++-- spec/serialization_spec.rb | 93 +++++++++++++++++++++++++++++ 6 files changed, 295 insertions(+), 65 deletions(-) create mode 100644 spec/serialization_spec.rb 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/raw_macaroon.rb b/lib/macaroons/raw_macaroon.rb index b570976..cf4394c 100644 --- a/lib/macaroons/raw_macaroon.rb +++ b/lib/macaroons/raw_macaroon.rb @@ -16,12 +16,27 @@ def initialize(key: nil, identifier: nil, location: nil) end @key = key - @identifier = identifier - @location = location - @signature = create_initial_macaroon_signature(key, identifier) + @identifier = Utils.binary(identifier) + @location = Utils.binary(location) + @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) + macaroon = allocate + macaroon.send( + :initialize_from_parts, + identifier: identifier, + location: location, + caveats: caveats, + signature: signature + ) + macaroon + end + def self.from_binary(serialized: nil) Macaroons::BinarySerializer.new().deserialize(serialized) end @@ -30,23 +45,34 @@ def self.from_json(serialized: nil) Macaroons::JsonSerializer.new().deserialize(serialized) end - attr_reader :identifier attr_reader :key - attr_reader :location 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) @@ -81,6 +107,17 @@ def bind_signature(signature) private + def initialize_from_parts(identifier: nil, location: nil, caveats: [], signature: nil) + 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) + 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..a409f0c 100644 --- a/lib/macaroons/serializers/binary.rb +++ b/lib/macaroons/serializers/binary.rb @@ -1,10 +1,16 @@ -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/ def serialize(macaroon) combined = packetize('location', macaroon.location) @@ -23,18 +29,38 @@ def serialize(macaroon) 'signature', Utils.unhexlify(macaroon.signature) ) - base64_url_encode(combined) + Utils.base64_url_encode(combined) end def deserialize(serialized) + decoded = Utils.base64_url_decode(Utils.binary(serialized)) + 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 +72,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 +85,39 @@ 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 + ) 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/json.rb b/lib/macaroons/serializers/json.rb index ea0f3a1..881fd35 100644 --- a/lib/macaroons/serializers/json.rb +++ b/lib/macaroons/serializers/json.rb @@ -1,42 +1,59 @@ 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']) + ) end end diff --git a/lib/macaroons/utils.rb b/lib/macaroons/utils.rb index ead01da..e2ad11f 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,18 @@ def self.sign_third_party_caveat(signature, verification_id, caveat_id) def self.generate_derived_key(key) Utils.hmac('macaroons-key-generator', key) 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/spec/serialization_spec.rb b/spec/serialization_spec.rb new file mode 100644 index 0000000..a70fbdd --- /dev/null +++ b/spec/serialization_spec.rb @@ -0,0 +1,93 @@ +require 'spec_helper' +require 'macaroons' +require 'macaroons/errors' + +describe 'V1 serializer regressions' do + # The V1 serializer sized its packets with String#length, so a multibyte + # identifier produced a length prefix smaller than the bytes actually emitted. + it 'round trips a multibyte identifier through binary' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'ünïcodé', key: 'secret') + 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 json' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'ünïcodé', key: 'secret') + 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 + + 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 + + # 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') + 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 + + # js-macaroon writes V1 JSON vids url-safe and unpadded, which + # Base64.strict_decode64 rejects outright. + it 'reads a vid written in url-safe unpadded base64' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') + 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 'accepts a binary macaroon wrapped in standard-alphabet base64' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') + m.add_first_party_caveat('a = b') + + standard = Base64.strict_encode64(Base64.urlsafe_decode64(m.serialize + '=')) + expect(Macaroon.from_binary(standard).signature).to eql(m.signature) + end + + it 'rejects a packet that is too long to encode' do + m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') + m.add_first_party_caveat('x' * 0xFFFF) + expect { m.serialize }.to raise_error(ArgumentError) + end + + # A zero-length packet header used to leave the read index in place, spinning + # forever instead of raising. + it 'rejects a packet claiming zero length instead of looping forever' do + expect { Macaroon.from_binary(Base64.urlsafe_encode64('0000').delete('=')) } + .to raise_error(KeyError) + end + + it 'rejects a packet header that is not hexadecimal' do + expect { Macaroon.from_binary(Base64.urlsafe_encode64('zzzzlocation x\n').delete('=')) } + .to raise_error(KeyError) + end +end From 8b2af63e6e8faecf569b7b497a7f9c9deb420a74 Mon Sep 17 00:00:00 2001 From: Leonardo Mateo Date: Thu, 6 Aug 2026 18:03:10 -0300 Subject: [PATCH 2/3] Add V2 binary and JSON formats, with automatic format detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruby-macaroons has only ever spoken the V1 binary format (libmacaroons/v1.c) and a V1 JSON encoding. js-macaroon speaks neither: it refuses to export V1 binary outright, so the two libraries have had no binary encoding in common and only V1 JSON in common at all. This adds the two V2 encodings from libmacaroons/v2.c: BinaryV2Serializer a version byte followed by typed, uvarint-length prefixed fields, with EOS markers closing the header, each caveat and the caveat list. JsonV2Serializer the same field set, each written as a plain string under its short key, or as url-safe unpadded base64 under key + "64" when the bytes are not valid UTF-8. No cryptographic changes are involved. HMAC-SHA256, the macaroons-key-generator derived key, the keyedHash2 third-party construction and the zero-key request binding are identical across libmacaroons, js-macaroon and this library, and libmacaroons applies them regardless of format. Only the framing differs. Macaroon.from_serialized detects the encoding from the data, the way macaroon_deserialize does in libmacaroons and importMacaroon does in js-macaroon: V1 binary, V1 JSON, V2 binary or V2 JSON. It also unwraps a base64 layer if one is present, since macaroons arriving over HTTP almost always have one. from_binary and from_json remain as aliases, so callers that used to try one and rescue into the other no longer need to. New macaroons still default to version 1, so upgrading changes no bytes for any existing caller. Version 2 is opt-in per macaroon and is required to exchange binary macaroons with js-macaroon. A macaroon parsed from the wire re-serializes in the version it arrived in, so a service can accept both and answer in kind. The V2 expectations in the specs are taken verbatim from js-macaroon's test/serialize-binary.js and test/serialize-json.js, so they act as an interoperability oracle rather than a record of whatever this library happens to emit. Round trips through js-macaroon were verified in both directions, including third-party caveat vids, which cross the boundary intact because RbNaCl::SimpleBox and tweetnacl secretbox agree on nonce-then-ciphertext layout. BinarySerializer#serialize and #deserialize keep their existing contract: serialize still returns base64, deserialize still accepts it. serialize_raw exposes the wire bytes for callers that want to hand them to libmacaroons directly, and deserialize now additionally accepts the raw packet stream — the two forms are unambiguous, since packets open with four hex digits and their base64 always opens "MDAx". Co-Authored-By: Claude Opus 5 --- lib/macaroons/macaroons.rb | 25 +- lib/macaroons/raw_macaroon.rb | 62 ++- lib/macaroons/serializers/binary.rb | 19 +- lib/macaroons/serializers/binary_v2.rb | 138 +++++++ lib/macaroons/serializers/dispatch.rb | 90 +++++ lib/macaroons/serializers/json.rb | 3 +- lib/macaroons/serializers/json_v2.rb | 91 +++++ lib/macaroons/utils.rb | 35 ++ spec/serialization_spec.rb | 525 +++++++++++++++++++++++-- 9 files changed, 925 insertions(+), 63 deletions(-) create mode 100644 lib/macaroons/serializers/binary_v2.rb create mode 100644 lib/macaroons/serializers/dispatch.rb create mode 100644 lib/macaroons/serializers/json_v2.rb 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 cf4394c..de1fd37 100644 --- a/lib/macaroons/raw_macaroon.rb +++ b/lib/macaroons/raw_macaroon.rb @@ -5,12 +5,27 @@ 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 @@ -18,6 +33,7 @@ def initialize(key: nil, identifier: nil, location: nil) @key = key @identifier = Utils.binary(identifier) @location = Utils.binary(location) + @version = check_version(version) @signature = create_initial_macaroon_signature(key, @identifier) @caveats = [] end @@ -25,27 +41,31 @@ def initialize(key: nil, identifier: nil, location: nil) # 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) + 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 + 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 :key + attr_reader :version attr_accessor :caveats def identifier @@ -83,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) @@ -107,7 +133,7 @@ def bind_signature(signature) private - def initialize_from_parts(identifier: nil, location: nil, caveats: [], signature: nil) + 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? @@ -116,6 +142,22 @@ def initialize_from_parts(identifier: nil, location: nil, caveats: [], signature @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) diff --git a/lib/macaroons/serializers/binary.rb b/lib/macaroons/serializers/binary.rb index a409f0c..fcae0ef 100644 --- a/lib/macaroons/serializers/binary.rb +++ b/lib/macaroons/serializers/binary.rb @@ -11,8 +11,16 @@ 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) @@ -29,11 +37,15 @@ def serialize(macaroon) 'signature', Utils.unhexlify(macaroon.signature) ) - Utils.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.base64_url_decode(Utils.binary(serialized)) + decoded = Utils.binary(serialized) + decoded = Utils.base64_url_decode(decoded) unless decoded =~ HEX_PREFIX caveats = [] location = nil @@ -94,7 +106,8 @@ def deserialize(serialized) identifier: identifier, location: location, caveats: caveats, - signature: signature + signature: signature, + version: 1 ) 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 881fd35..bc6014a 100644 --- a/lib/macaroons/serializers/json.rb +++ b/lib/macaroons/serializers/json.rb @@ -52,7 +52,8 @@ def deserialize(serialized) identifier: deserialized['identifier'], location: deserialized['location'], caveats: caveats, - signature: Utils.unhexlify(deserialized['signature']) + signature: Utils.unhexlify(deserialized['signature']), + version: 1 ) 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 e2ad11f..4c81680 100644 --- a/lib/macaroons/utils.rb +++ b/lib/macaroons/utils.rb @@ -67,6 +67,41 @@ 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 diff --git a/spec/serialization_spec.rb b/spec/serialization_spec.rb index a70fbdd..1fa0eee 100644 --- a/spec/serialization_spec.rb +++ b/spec/serialization_spec.rb @@ -2,26 +2,251 @@ require 'macaroons' require 'macaroons/errors' -describe 'V1 serializer regressions' do - # The V1 serializer sized its packets with String#length, so a multibyte - # identifier produced a length prefix smaller than the bytes actually emitted. - it 'round trips a multibyte identifier through binary' do - m = Macaroon.new(location: 'http://mybank/', identifier: 'ünïcodé', key: 'secret') - m.add_first_party_caveat('nome = josé') +# 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' } - 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) + 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 'round trips a multibyte identifier through json' do - m = Macaroon.new(location: 'http://mybank/', identifier: 'ünïcodé', key: 'secret') - m.add_first_party_caveat('nome = josé') + 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 - n = Macaroon.from_json(m.serialize_json) - expect(n.identifier).to eql('ünïcodé') - expect(n.signature).to eql(m.signature) +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 @@ -33,10 +258,10 @@ expect(verifier.verify(macaroon: Macaroon.from_binary(m.serialize), key: 'secret')).to be true end - # serialize_json used caveats.map!, which rewrote verification_id in place, so - # the second call base64-encoded the already-encoded value. + # 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') + 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 @@ -44,10 +269,10 @@ expect(Macaroon.from_json(first).signature).to eql(m.signature) end - # js-macaroon writes V1 JSON vids url-safe and unpadded, which + # Regression: js-macaroon writes V1 JSON vids url-safe and unpadded, which # Base64.strict_decode64 rejects outright. - it 'reads a vid written in url-safe unpadded base64' do - m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') + 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 @@ -65,29 +290,251 @@ expect(Macaroon.from_json(js_style).third_party_caveats.first.verification_id).to eql(vid) end - it 'accepts a binary macaroon wrapped in standard-alphabet base64' do - m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') - m.add_first_party_caveat('a = b') + 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 - standard = Base64.strict_encode64(Base64.urlsafe_decode64(m.serialize + '=')) - expect(Macaroon.from_binary(standard).signature).to eql(m.signature) + # 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 - it 'rejects a packet that is too long to encode' do - m = Macaroon.new(location: 'http://mybank/', identifier: 'keyid', key: 'secret') - m.add_first_party_caveat('x' * 0xFFFF) - expect { m.serialize }.to raise_error(ArgumentError) +# 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 - # A zero-length packet header used to leave the read index in place, spinning - # forever instead of raising. - it 'rejects a packet claiming zero length instead of looping forever' do - expect { Macaroon.from_binary(Base64.urlsafe_encode64('0000').delete('=')) } - .to raise_error(KeyError) + # 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 - it 'rejects a packet header that is not hexadecimal' do - expect { Macaroon.from_binary(Base64.urlsafe_encode64('zzzzlocation x\n').delete('=')) } - .to raise_error(KeyError) + # 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 From 547ec21e9d5296e95d987698bde9a695c39878a1 Mon Sep 17 00:00:00 2001 From: Leonardo Mateo Date: Thu, 6 Aug 2026 18:23:57 -0300 Subject: [PATCH 3/3] Document the serialization formats and bump to 1.1.0 Adds a Serializing section covering the four encodings, the version: argument and its default, and the fact that Macaroon.from_serialized detects the format from the data. Calls out that js-macaroon cannot read or write V1 binary, since that is the detail which decides whether a caller needs version: 2. 1.1.0 rather than 2.0.0: the V2 formats are additive and the default version is unchanged, so no existing caller emits different bytes after upgrading. Co-Authored-By: Claude Opus 5 --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++ lib/macaroons/version.rb | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) 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/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