kransekake 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0d88389afbc584e049d6d6e625861741bd28167c371ee950f506754c03517613
4
+ data.tar.gz: 856e4b4e1ec93f9a93a1bfb79ad53a2d8f7b3166595d0cf5751957a324adb8ac
5
+ SHA512:
6
+ metadata.gz: 13d63111914bcafc4de3f523a967c2ccc4143bd9f7ed03c101712d72f457b5fc82e74fae0a4b79d713f36d73c4d7159d6a00e200aa62cf4f6ea1ad8b5dfa8330
7
+ data.tar.gz: 3de3b29c89357829df4d148681b39a1a7930fe4a2a515ed7f9ff351a61f44b9d02e17b403a610ceb050db08487b51784209f20b2e606480c0b98de012980a719
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Shannon Skipper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # Kransekake
2
+
3
+ A pure Ruby [macaroons](https://research.google/pubs/macaroons-cookies-with-contextual-caveats-for-decentralized-authorization-in-the-cloud/) implementation. Interoperable with [go-macaroon](https://github.com/go-macaroon/macaroon) v2.
4
+
5
+ Named after the Norwegian ring cake, which is a type of macaroon.
6
+
7
+ Macaroons are bearer tokens that support attenuation (anyone can add restrictions, no one can remove them) and third-party delegation, making them well-suited for decentralized authorization.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ gem install kransekake
13
+ ```
14
+
15
+ Or add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "kransekake"
19
+ ```
20
+
21
+ For third-party caveats, also add [rbnacl](https://github.com/RubyCrypto/rbnacl) and install [libsodium](https://doc.libsodium.org/):
22
+
23
+ ```ruby
24
+ gem "rbnacl", "~> 7.0"
25
+ ```
26
+
27
+ First-party-only usage works without either.
28
+
29
+ ## How it works
30
+
31
+ Macaroons use HMAC-SHA256 chaining. The root signature is derived from a secret key and each caveat extends the chain — caveats can be added but never removed, and tampering breaks the signature.
32
+
33
+ Instances are immutable `Data` objects. `add_caveat` returns a new macaroon, leaving the original unchanged.
34
+
35
+ ```ruby
36
+ base = Kransekake.new(key: "secret", identifier: "we used our secret key")
37
+ restricted = base.add_caveat("account = 3735928559")
38
+
39
+ base.caveats.empty? # => true
40
+ restricted.caveats.size # => 1
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### Minting and attenuating
46
+
47
+ Mint a macaroon with a secret key. Anyone can add caveats but never remove them.
48
+
49
+ ```ruby
50
+ require "kransekake"
51
+
52
+ key = "this is our super secret key; only we should know it"
53
+
54
+ base = Kransekake.new(key:, identifier: "we used our secret key", location: "http://mybank/")
55
+
56
+ macaroon = base
57
+ .add_caveat("account = 3735928559")
58
+ .add_caveat("action = deposit")
59
+ .add_caveat("time < 2035-01-01T00:00")
60
+ ```
61
+
62
+ ### Verifying
63
+
64
+ Every caveat must be satisfied. Pass a string for exact match or a block for a general predicate.
65
+
66
+ ```ruby
67
+ verifier = Kransekake::Verifier.new
68
+ verifier
69
+ .satisfy("account = 3735928559")
70
+ .satisfy("action = deposit")
71
+ .satisfy { |caveat| caveat.start_with?("time < ") }
72
+
73
+ verifier.verify(macaroon:, key:) # => true
74
+ ```
75
+
76
+ A wrong key, unsatisfied caveat, missing discharge or tampered signature raises a `Kransekake::VerificationError` subclass.
77
+
78
+ ### Serialization
79
+
80
+ Binary (v2 format), base64 and JSON.
81
+
82
+ ```ruby
83
+ binary = macaroon.serialize
84
+ Kransekake.deserialize(binary)
85
+
86
+ base64 = macaroon.serialize(base64: true)
87
+ Kransekake.deserialize(base64, base64: true)
88
+
89
+ json = macaroon.to_json
90
+ Kransekake.from_json(json)
91
+ ```
92
+
93
+ ### Third-party caveats
94
+
95
+ Delegate verification to an external service by adding a caveat only the third party can discharge. Third-party caveats use NaCl secretbox (XSalsa20-Poly1305) to encrypt caveat keys. Requires `rbnacl` and libsodium.
96
+
97
+ ```ruby
98
+ root_key = "this is our super secret key; only we should know it"
99
+ caveat_key = "this is a different super-secret key; never use the same secret twice"
100
+
101
+ # The authorizing service adds a third-party caveat
102
+ base = Kransekake.new(key: root_key, identifier: "we used our secret key", location: "http://mybank/")
103
+
104
+ macaroon = base
105
+ .add_caveat("account = 3735928559")
106
+ .add_third_party_caveat(key: caveat_key, identifier: "auth-session-id", location: "http://auth.mybank/")
107
+
108
+ # The third-party service mints a discharge macaroon
109
+ discharge = Kransekake.new(key: caveat_key, identifier: "auth-session-id", location: "http://auth.mybank/")
110
+ .add_caveat("email = alice@example.org")
111
+
112
+ # Bind the discharge to the authorizing macaroon before sending
113
+ bound = macaroon.bind(discharge)
114
+
115
+ # Verify with both the macaroon and its discharge
116
+ verifier = Kransekake::Verifier.new
117
+ verifier.satisfy("account = 3735928559")
118
+ verifier.satisfy("email = alice@example.org")
119
+ verifier.verify(macaroon:, key: root_key, discharges: [bound]) # => true
120
+ ```
121
+
122
+ ### Bundles
123
+
124
+ Package a macaroon with its bound discharges for transport. Supports binary, base64 and JSON.
125
+
126
+ ```ruby
127
+ bundle = macaroon.bundle(discharges: [bound])
128
+ restored = Kransekake::Bundle.deserialize(bundle.serialize)
129
+
130
+ verifier.verify(macaroon: restored.macaroon, key: root_key, discharges: restored.discharges)
131
+ ```
132
+
133
+ ### Signature-only verification
134
+
135
+ Verify the HMAC chain without checking caveats. Returns first-party conditions.
136
+
137
+ ```ruby
138
+ macaroon = Kransekake.new(key: "secret", identifier: "we used our secret key")
139
+ .add_caveat("account = 3735928559")
140
+
141
+ verifier = Kransekake::Verifier.new
142
+ conditions = verifier.verify_signature(macaroon:, key: "secret")
143
+ # => ["account = 3735928559"]
144
+ ```
145
+
146
+ ### Trace
147
+
148
+ Step-by-step trace for debugging the HMAC chain.
149
+
150
+ ```ruby
151
+ macaroon = Kransekake.new(key: "secret", identifier: "we used our secret key")
152
+ .add_caveat("account = 3735928559")
153
+
154
+ verifier = Kransekake::Verifier.new
155
+ trace = verifier.trace(macaroon:, key: "secret")
156
+
157
+ trace.ok? # => true
158
+ trace.map(&:type) # => [:derive_key, :hmac, :hmac, :verify_ok]
159
+ ```
160
+
161
+ Each `Kransekake::Trace::Op` has `type`, `input` and `output` attributes.
162
+
163
+ ### Pattern matching
164
+
165
+ `Data` class, so deconstruction works:
166
+
167
+ ```ruby
168
+ macaroon = Kransekake.new(key: "secret", identifier: "user:alice@example.org")
169
+ .add_caveat("account = 3735928559")
170
+
171
+ case macaroon
172
+ in identifier: /^user:/, caveats: {list: [Kransekake::Caveat => caveat]}
173
+ puts caveat.identifier # => "account = 3735928559"
174
+ end
175
+ ```
176
+
177
+ ## Choosing secrets
178
+
179
+ The examples above use human-readable keys for clarity. Random bytes are recommended in practice:
180
+
181
+ ```ruby
182
+ key = SecureRandom.random_bytes(32)
183
+ ```
184
+
185
+ ## Interoperability
186
+
187
+ Interoperable with [go-macaroon](https://github.com/go-macaroon/macaroon) v2. Binary and JSON serialization, first-party verification, third-party caveat encryption (NaCl secretbox) and discharge binding all work bidirectionally. The test suite includes golden vectors from `gopkg.in/macaroon.v2`.
188
+
189
+ ## Requirements
190
+
191
+ - Ruby 4.0+
192
+ - [rbnacl](https://github.com/RubyCrypto/rbnacl) (~> 7.0) and [libsodium](https://doc.libsodium.org/) for third-party caveats (optional)
193
+ - macOS: `brew install libsodium`
194
+ - Debian: `apt install libsodium-dev`
195
+ - Arch: `pacman -S libsodium`
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+ require "standard/rake"
6
+
7
+ task default: %i[test standard]
8
+
9
+ Minitest::TestTask.create
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ Bundle = Data.define(:macaroon, :discharges) do
5
+ class << self
6
+ def deserialize(data, base64: false)
7
+ macaroons = Serializer.deserialize_all(base64 ? data.unpack1("m0") : data)
8
+ new(macaroon: macaroons.first, discharges: macaroons.drop(1))
9
+ end
10
+
11
+ def from_json(json)
12
+ macaroons = JSON.parse(json).map { |hash| Kransekake.from_json_hash(hash) }
13
+ new(macaroon: macaroons.first, discharges: macaroons.drop(1))
14
+ end
15
+ end
16
+
17
+ def initialize(macaroon:, discharges: []) = super(macaroon:, discharges: discharges.freeze)
18
+
19
+ def serialize(base64: false)
20
+ buf = macaroon.serialize
21
+ discharges.each { |d| buf << d.serialize }
22
+ base64 ? [buf].pack("m0") : buf
23
+ end
24
+
25
+ def to_json(*) = [macaroon, *discharges].map(&:to_json_hash).to_json(*)
26
+ end
27
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ Caveat = Data.define(:identifier, :vid, :location) do
5
+ include HexDisplay
6
+
7
+ def initialize(identifier:, vid: nil, location: nil) = super
8
+
9
+ def first_party? = vid.nil?
10
+ def third_party? = !first_party?
11
+
12
+ def to_json_hash
13
+ hash = {"i" => identifier}
14
+ hash["v64"] = [vid].pack("m0") if vid
15
+ hash["l"] = location if location
16
+ hash
17
+ end
18
+
19
+ def inspect
20
+ fields = ["identifier=#{identifier.inspect}"]
21
+ fields << "vid=#{truncate_hex(vid)}" if vid
22
+ fields << "location=#{location.inspect}" if location
23
+ "#<data #{self.class} #{fields.join(", ")}>"
24
+ end
25
+
26
+ def pretty_print(pp)
27
+ pp.group(1, "#<data #{self.class}", ">") do
28
+ pp.breakable
29
+ pp.text "identifier=#{identifier.inspect}"
30
+ if vid
31
+ pp.text ","
32
+ pp.breakable
33
+ pp.text "vid=#{vid.unpack1("H*")}"
34
+ end
35
+ if location
36
+ pp.text ","
37
+ pp.breakable
38
+ pp.text "location=#{location.inspect}"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ Caveats = Data.define(:list) do
5
+ include Enumerable
6
+
7
+ def initialize(list: []) = super(list: list.freeze)
8
+
9
+ def add(caveat) = with(list: [*list, caveat])
10
+ alias_method :<<, :add
11
+
12
+ def each(&) = list.each(&)
13
+ def size = list.size
14
+ def empty? = list.empty?
15
+ def [](index) = list[index]
16
+
17
+ def first_party = list.filter(&:first_party?)
18
+ def third_party = list.filter(&:third_party?)
19
+
20
+ def inspect = "#<data Kransekake::Caveats [#{list.map(&:inspect).join(", ")}]>"
21
+
22
+ def pretty_print(pp)
23
+ pp.group(2, "#<data Kransekake::Caveats [", "]>") do
24
+ pp.breakable ""
25
+ pp.seplist(list) { |caveat| pp.pp caveat }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ module Crypto
5
+ class DecryptionError < StandardError
6
+ def initialize(message = "decryption failed") = super
7
+ end
8
+
9
+ NONCE_SIZE = 24
10
+ ZERO_KEY = 0.chr * 32
11
+
12
+ module_function
13
+
14
+ def hmac(key, data) = OpenSSL::HMAC.digest("SHA256", key, data)
15
+ def hash2(key, d1, d2) = hmac(key, hmac(key, d1) + hmac(key, d2))
16
+ def derive_key(key) = hmac("macaroons-key-generator", key)
17
+
18
+ def encrypt(key, plaintext)
19
+ require_rbnacl
20
+ box = RbNaCl::SecretBox.new(key.b)
21
+ nonce = RbNaCl::Random.random_bytes(NONCE_SIZE)
22
+ nonce + box.encrypt(nonce, plaintext.b)
23
+ end
24
+
25
+ def decrypt(key, data)
26
+ require_rbnacl
27
+ data = data.b
28
+ box = RbNaCl::SecretBox.new(key.b)
29
+ box.decrypt(data.byteslice(0, NONCE_SIZE), data.byteslice(NONCE_SIZE..))
30
+ rescue ::RbNaCl::CryptoError, ::RbNaCl::LengthError => e
31
+ raise DecryptionError, e.message
32
+ end
33
+
34
+ def require_rbnacl
35
+ require "rbnacl"
36
+ rescue LoadError
37
+ raise LoadError, <<~MESSAGE.chomp
38
+ Third-party caveats require the rbnacl gem and libsodium.
39
+
40
+ Add to your Gemfile:
41
+ gem "rbnacl", "~> 7.0"
42
+
43
+ Install libsodium:
44
+ macOS: brew install libsodium
45
+ Debian: apt install libsodium-dev
46
+ Arch: pacman -S libsodium
47
+ MESSAGE
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ module HexDisplay
5
+ private
6
+
7
+ def truncate_hex(binary, max: 16)
8
+ hex = binary.unpack1("H*")
9
+ (hex.size > max) ? "#{hex[0, max]}..." : hex
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ module Serializer
5
+ class Reader
6
+ attr_reader :pos
7
+
8
+ def initialize(data)
9
+ @data = data.b
10
+ @pos = 0
11
+
12
+ raise ArgumentError, "empty macaroon data" if @data.empty?
13
+
14
+ version = read_byte
15
+ raise ParseError, "unsupported macaroon version: #{version} (expected #{VERSION})" unless version == VERSION
16
+ end
17
+
18
+ def section
19
+ location = nil
20
+ field_type, field_data = field
21
+
22
+ if field_type == LOCATION
23
+ location = field_data.force_encoding(Encoding::UTF_8)
24
+ field_type, field_data = field
25
+ end
26
+
27
+ raise ParseError, "expected field type `#{IDENTIFIER}` (identifier), got `#{field_type}`" unless field_type == IDENTIFIER
28
+
29
+ [location, field_data.force_encoding(Encoding::UTF_8)]
30
+ end
31
+
32
+ def caveat
33
+ location, identifier = section
34
+
35
+ vid = nil
36
+ field_type, field_data = field
37
+
38
+ if field_type == VID
39
+ vid = field_data
40
+ expect_eos
41
+ elsif field_type != EOS
42
+ raise ParseError, "unexpected field type `#{field_type}` in caveat"
43
+ end
44
+
45
+ Caveat.new(identifier:, vid:, location:)
46
+ end
47
+
48
+ def signature
49
+ field_type, field_data = field
50
+
51
+ raise ParseError, "expected field type `#{SIGNATURE}` (signature), got `#{field_type}`" unless field_type == SIGNATURE
52
+ raise ParseError, "signature must be 32 bytes, got #{field_data.bytesize}" unless field_data.bytesize == 32
53
+
54
+ field_data
55
+ end
56
+
57
+ def eos? = @data.getbyte(@pos) == EOS
58
+ def skip_eos = @pos += 1
59
+
60
+ def expect_eos
61
+ raise ParseError, "expected EOS" if @pos >= @data.bytesize || !eos?
62
+
63
+ skip_eos
64
+ end
65
+
66
+ private
67
+
68
+ def read_byte
69
+ raise ParseError, "unexpected end of data" if @pos >= @data.bytesize
70
+
71
+ byte = @data.getbyte(@pos)
72
+ @pos += 1
73
+ byte
74
+ end
75
+
76
+ def field
77
+ field_type = decode_varint
78
+ return [EOS, nil] if field_type == EOS
79
+
80
+ length = decode_varint
81
+ raise ParseError, "field extends past end of buffer" if @pos + length > @data.bytesize
82
+
83
+ field_data = @data.byteslice(@pos, length)
84
+ @pos += length
85
+ [field_type, field_data]
86
+ end
87
+
88
+ def decode_varint
89
+ value = 0
90
+ shift = 0
91
+
92
+ loop do
93
+ byte = read_byte
94
+ value |= (byte & 0x7f) << shift
95
+ return value if byte < 0x80
96
+
97
+ shift += 7
98
+ raise ParseError, "varint too large" if shift > 63
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ module Serializer
5
+ class Writer
6
+ def initialize
7
+ @buf = String.new(VERSION.chr, encoding: Encoding::BINARY, capacity: 256)
8
+ end
9
+
10
+ def field(type, data) = @buf << encode_varint(type) << encode_varint(data.bytesize) << data.b
11
+ def eos = @buf << "\x00"
12
+ def to_s = @buf
13
+
14
+ private
15
+
16
+ def encode_varint(value)
17
+ buf = String.new(encoding: Encoding::BINARY, capacity: 10)
18
+ while value > 0x7f
19
+ buf << ((value & 0x7f) | 0x80).chr
20
+ value >>= 7
21
+ end
22
+ buf << value.chr
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ module Serializer
5
+ VERSION = 2
6
+
7
+ EOS = 0
8
+ LOCATION = 1
9
+ IDENTIFIER = 2
10
+ VID = 4
11
+ SIGNATURE = 6
12
+
13
+ class << self
14
+ def serialize(macaroon)
15
+ writer = Writer.new
16
+
17
+ writer.field(LOCATION, macaroon.location) unless macaroon.location.empty?
18
+ writer.field(IDENTIFIER, macaroon.identifier)
19
+ writer.eos
20
+
21
+ macaroon.caveats.each do |caveat|
22
+ writer.field(LOCATION, caveat.location) if caveat.location
23
+ writer.field(IDENTIFIER, caveat.identifier)
24
+ writer.field(VID, caveat.vid) if caveat.vid
25
+ writer.eos
26
+ end
27
+
28
+ writer.eos
29
+ writer.field(SIGNATURE, macaroon.signature)
30
+
31
+ writer.to_s
32
+ end
33
+
34
+ def deserialize(data) = read_one(Reader.new(data))
35
+
36
+ def deserialize_all(data)
37
+ data = data.b
38
+ macaroons = []
39
+ offset = 0
40
+
41
+ while offset < data.bytesize
42
+ reader = Reader.new(data.byteslice(offset..))
43
+ macaroons << read_one(reader)
44
+ offset += reader.pos
45
+ end
46
+
47
+ macaroons
48
+ end
49
+
50
+ private
51
+
52
+ def read_one(reader)
53
+ location, identifier = reader.section
54
+ reader.expect_eos
55
+
56
+ caveats = Caveats.new
57
+ caveats <<= reader.caveat until reader.eos?
58
+ reader.skip_eos
59
+
60
+ Kransekake.new(location: location || "", identifier:, caveats:, signature: reader.signature)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ class Trace
5
+ Op = Data.define(:type, :input, :output) do
6
+ include HexDisplay
7
+
8
+ def inspect
9
+ "#<data #{self.class} type=#{type.inspect}, input=#{format_field(input)}, output=#{format_field(output)}>"
10
+ end
11
+
12
+ def pretty_print(pp)
13
+ pp.group(1, "#<data #{self.class}", ">") do
14
+ pp.breakable
15
+ pp.text "type=#{type.inspect},"
16
+ pp.breakable
17
+ pp.text "input=#{format_field(input, truncate: false)},"
18
+ pp.breakable
19
+ pp.text "output=#{format_field(output, truncate: false)}"
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def format_field(value, truncate: true)
26
+ if value.encoding == Encoding::BINARY
27
+ truncate ? truncate_hex(value) : value.unpack1("H*")
28
+ else
29
+ value.inspect
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ Trace = Data.define(:ops) do
5
+ include Enumerable
6
+
7
+ def each(&) = ops.each(&)
8
+ def size = ops.size
9
+ def ok? = ops.last&.type == :verify_ok
10
+ end
11
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ class Verifier
5
+ include Crypto
6
+
7
+ def initialize
8
+ @exact = Set.new
9
+ @general = []
10
+ end
11
+
12
+ def satisfy(predicate = nil, &)
13
+ if block_given?
14
+ @general << proc(&)
15
+ else
16
+ @exact << predicate
17
+ end
18
+ self
19
+ end
20
+
21
+ def verify(macaroon:, key:, discharges: [])
22
+ sig = hmac(derive_key(key), macaroon.identifier)
23
+
24
+ macaroon.caveats.each do |caveat|
25
+ sig = if caveat.third_party?
26
+ verify_third_party(caveat:, sig:, authorizing_signature: macaroon.signature, discharges:)
27
+ else
28
+ satisfy_caveat(predicate: caveat.identifier, sig:)
29
+ end
30
+ end
31
+
32
+ compare_signatures!(expected: sig, actual: macaroon.signature)
33
+ true
34
+ end
35
+
36
+ def verify_signature(macaroon:, key:, discharges: [])
37
+ conditions = []
38
+ sig = hmac(derive_key(key), macaroon.identifier)
39
+
40
+ macaroon.caveats.each do |caveat|
41
+ if caveat.third_party?
42
+ sig = verify_third_party_signature(caveat:, sig:, authorizing_signature: macaroon.signature,
43
+ discharges:, conditions:)
44
+ else
45
+ conditions << caveat.identifier
46
+ sig = hmac(sig, caveat.identifier)
47
+ end
48
+ end
49
+
50
+ compare_signatures!(expected: sig, actual: macaroon.signature)
51
+ conditions
52
+ end
53
+
54
+ def trace(macaroon:, key:, discharges: [])
55
+ ops = []
56
+ root_key = derive_key(key)
57
+ ops << Trace::Op.new(type: :derive_key, input: key, output: root_key)
58
+
59
+ sig = hmac(root_key, macaroon.identifier)
60
+ ops << Trace::Op.new(type: :hmac, input: macaroon.identifier, output: sig)
61
+
62
+ macaroon.caveats.each do |caveat|
63
+ if caveat.third_party?
64
+ caveat_key = decrypt(sig, caveat.vid)
65
+ ops << Trace::Op.new(type: :decrypt, input: caveat.identifier, output: caveat_key)
66
+ sig = hash2(sig, caveat.vid, caveat.identifier)
67
+ else
68
+ sig = hmac(sig, caveat.identifier)
69
+ end
70
+ ops << Trace::Op.new(type: :hmac, input: caveat.identifier, output: sig)
71
+ end
72
+
73
+ matched = OpenSSL.fixed_length_secure_compare(sig, macaroon.signature)
74
+ ops << Trace::Op.new(type: matched ? :verify_ok : :verify_fail, input: sig, output: macaroon.signature)
75
+
76
+ Trace.new(ops:)
77
+ end
78
+
79
+ private
80
+
81
+ def compare_signatures!(expected:, actual:)
82
+ raise SignatureMismatchError unless OpenSSL.fixed_length_secure_compare(expected, actual)
83
+ end
84
+
85
+ def satisfied?(predicate) = @exact.include?(predicate) || @general.any? { it.call(predicate) }
86
+
87
+ def satisfy_caveat(predicate:, sig:)
88
+ raise UnsatisfiedCaveatError.new(predicate) unless satisfied?(predicate)
89
+
90
+ hmac(sig, predicate)
91
+ end
92
+
93
+ def verify_third_party(caveat:, sig:, authorizing_signature:, discharges:)
94
+ discharge = discharges.find { it.identifier == caveat.identifier }
95
+ raise MissingDischargeError.new(caveat.identifier) unless discharge
96
+
97
+ caveat_key = decrypt(sig, caveat.vid)
98
+ verify_discharge(discharge:, key: caveat_key, authorizing_signature:, discharges:)
99
+
100
+ hash2(sig, caveat.vid, caveat.identifier)
101
+ end
102
+
103
+ def verify_discharge(discharge:, key:, authorizing_signature:, discharges:)
104
+ sig = hmac(key, discharge.identifier)
105
+
106
+ discharge.caveats.each do |caveat|
107
+ sig = if caveat.third_party?
108
+ verify_third_party(caveat:, sig:, authorizing_signature:, discharges:)
109
+ else
110
+ satisfy_caveat(predicate: caveat.identifier, sig:)
111
+ end
112
+ end
113
+
114
+ compare_signatures!(expected: hash2(ZERO_KEY, authorizing_signature, sig), actual: discharge.signature)
115
+ end
116
+
117
+ def verify_third_party_signature(caveat:, sig:, authorizing_signature:, discharges:, conditions:)
118
+ discharge = discharges.find { it.identifier == caveat.identifier }
119
+ raise MissingDischargeError.new(caveat.identifier) unless discharge
120
+
121
+ caveat_key = decrypt(sig, caveat.vid)
122
+ verify_discharge_signature(discharge:, key: caveat_key, authorizing_signature:, conditions:, discharges:)
123
+
124
+ hash2(sig, caveat.vid, caveat.identifier)
125
+ end
126
+
127
+ def verify_discharge_signature(discharge:, key:, authorizing_signature:, conditions:, discharges:)
128
+ sig = hmac(key, discharge.identifier)
129
+
130
+ discharge.caveats.each do |caveat|
131
+ if caveat.third_party?
132
+ sig = verify_third_party_signature(caveat:, sig:, authorizing_signature:, discharges:, conditions:)
133
+ else
134
+ conditions << caveat.identifier
135
+ sig = hmac(sig, caveat.identifier)
136
+ end
137
+ end
138
+
139
+ compare_signatures!(expected: hash2(ZERO_KEY, authorizing_signature, sig), actual: discharge.signature)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kransekake
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kransekake.rb ADDED
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ Kransekake = Data.define(:location, :identifier, :caveats, :signature)
7
+
8
+ require_relative "kransekake/hex_display"
9
+ require_relative "kransekake/caveat"
10
+ require_relative "kransekake/caveats"
11
+ require_relative "kransekake/crypto"
12
+ require_relative "kransekake/serializer"
13
+ require_relative "kransekake/serializer/writer"
14
+ require_relative "kransekake/serializer/reader"
15
+ require_relative "kransekake/bundle"
16
+ require_relative "kransekake/trace"
17
+ require_relative "kransekake/trace/op"
18
+ require_relative "kransekake/verifier"
19
+ require_relative "kransekake/version"
20
+
21
+ class Kransekake
22
+ class ParseError < StandardError
23
+ def initialize(message = "failed to parse macaroon") = super
24
+ end
25
+
26
+ class VerificationError < StandardError
27
+ def initialize(message = "verification failed") = super
28
+ end
29
+
30
+ class MissingDischargeError < VerificationError
31
+ attr_reader :identifier
32
+
33
+ def initialize(identifier)
34
+ @identifier = identifier
35
+ super("missing discharge for caveat: #{identifier}")
36
+ end
37
+ end
38
+
39
+ class SignatureMismatchError < VerificationError
40
+ def initialize(message = "signature mismatch") = super
41
+ end
42
+
43
+ class UnsatisfiedCaveatError < VerificationError
44
+ attr_reader :caveat
45
+
46
+ def initialize(caveat)
47
+ @caveat = caveat
48
+ super("unsatisfied caveat: #{caveat}")
49
+ end
50
+ end
51
+
52
+ include Crypto
53
+ include HexDisplay
54
+
55
+ class << self
56
+ def deserialize(data, base64: false) = Serializer.deserialize(base64 ? data.unpack1("m0") : data)
57
+ def from_json(json) = from_json_hash(JSON.parse(json))
58
+
59
+ def from_json_hash(hash)
60
+ location = hash.fetch("l", "")
61
+ identifier = hash["i"] || decode64(hash.fetch("i64"))
62
+
63
+ caveats = Caveats.new
64
+ hash.fetch("c", []).each do |c|
65
+ cav_id = c["i"] || decode64(c.fetch("i64"))
66
+ v64 = c["v64"]
67
+ cav_vid = decode64(v64) if v64
68
+ cav_loc = c["l"]
69
+ caveats <<= Caveat.new(identifier: cav_id, vid: cav_vid, location: cav_loc)
70
+ end
71
+
72
+ signature = decode64(hash["s64"])
73
+ raise ParseError, "signature must be 32 bytes, got #{signature.bytesize}" unless signature.bytesize == 32
74
+
75
+ new(location:, identifier:, caveats:, signature:)
76
+ end
77
+
78
+ private
79
+
80
+ def decode64(data) = data.tr("-_", "+/").ljust((data.size + 3) & ~3, "=").unpack1("m0")
81
+ end
82
+
83
+ def initialize(identifier:, key: nil, location: "", caveats: Caveats.new, **)
84
+ if key
85
+ super(identifier:, location:, caveats:, signature: hmac(derive_key(key), identifier), **)
86
+ else
87
+ super(identifier:, location:, caveats:, **)
88
+ end
89
+ end
90
+
91
+ def add_caveat(predicate) = with(caveats: caveats.add(Caveat.new(identifier: predicate)), signature: hmac(signature, predicate))
92
+
93
+ def add_third_party_caveat(key:, identifier:, location:)
94
+ vid = encrypt(signature, derive_key(key))
95
+ sig = hash2(signature, vid, identifier)
96
+
97
+ with(caveats: caveats.add(Caveat.new(identifier:, vid:, location:)), signature: sig)
98
+ end
99
+
100
+ def bind(discharge) = discharge.with(signature: hash2(ZERO_KEY, signature, discharge.signature))
101
+
102
+ def inspect
103
+ fields = []
104
+ fields << "location=#{location.inspect}" unless location.empty?
105
+ fields << "identifier=#{identifier.inspect}"
106
+ fields << "caveats=#{caveats.inspect}" unless caveats.empty?
107
+ fields << "signature=#{truncate_hex(signature)}"
108
+ "#<data #{self.class} #{fields.join(", ")}>"
109
+ end
110
+
111
+ def pretty_print(pp)
112
+ pp.group(1, "#<data #{self.class}", ">") do
113
+ pp.breakable
114
+ pp.text "location=#{location.inspect}," unless location.empty?
115
+ pp.breakable unless location.empty?
116
+ pp.text "identifier=#{identifier.inspect},"
117
+ unless caveats.empty?
118
+ pp.breakable
119
+ pp.text "caveats="
120
+ pp.pp caveats
121
+ pp.text ","
122
+ end
123
+ pp.breakable
124
+ pp.text "signature=#{signature.unpack1("H*")}"
125
+ end
126
+ end
127
+
128
+ def version = Serializer::VERSION
129
+ def bundle(discharges: []) = Bundle.new(macaroon: self, discharges:)
130
+
131
+ def serialize(base64: false)
132
+ data = Serializer.serialize(self)
133
+ base64 ? [data].pack("m0") : data
134
+ end
135
+
136
+ def to_json(*) = to_json_hash.to_json(*)
137
+
138
+ def to_json_hash
139
+ hash = {}
140
+ hash["l"] = location unless location.empty?
141
+ hash["i"] = identifier
142
+ hash["c"] = caveats.map(&:to_json_hash) unless caveats.empty?
143
+ hash["s64"] = [signature].pack("m0")
144
+ hash
145
+ end
146
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kransekake
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shannon Skipper
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A pure Ruby implementation of macaroons for flexible authorization credentials
13
+ email:
14
+ - shannonskipper@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE.txt
20
+ - README.md
21
+ - Rakefile
22
+ - lib/kransekake.rb
23
+ - lib/kransekake/bundle.rb
24
+ - lib/kransekake/caveat.rb
25
+ - lib/kransekake/caveats.rb
26
+ - lib/kransekake/crypto.rb
27
+ - lib/kransekake/hex_display.rb
28
+ - lib/kransekake/serializer.rb
29
+ - lib/kransekake/serializer/reader.rb
30
+ - lib/kransekake/serializer/writer.rb
31
+ - lib/kransekake/trace.rb
32
+ - lib/kransekake/trace/op.rb
33
+ - lib/kransekake/verifier.rb
34
+ - lib/kransekake/version.rb
35
+ homepage: https://github.com/havenwood/kransekake
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ source_code_uri: https://github.com/havenwood/kransekake
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '4.0'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 4.0.7
56
+ specification_version: 4
57
+ summary: A macaroon authorization credentials library
58
+ test_files: []