Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
40 changes: 32 additions & 8 deletions lib/macaroons/caveat.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 15 additions & 10 deletions lib/macaroons/macaroons.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 92 additions & 13 deletions lib/macaroons/raw_macaroon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading