homura-runtime 0.3.2 → 0.3.4
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 +4 -4
- data/CHANGELOG.md +18 -0
- data/exe/compile-assets +2 -2
- data/exe/compile-erb +5 -7
- data/lib/homura/runtime/build_support.rb +19 -2
- data/lib/homura/runtime/version.rb +1 -1
- data/vendor/rack/auth/abstract/handler.rb +41 -0
- data/vendor/rack/auth/abstract/request.rb +51 -0
- data/vendor/rack/auth/basic.rb +58 -0
- data/vendor/rack/bad_request.rb +8 -0
- data/vendor/rack/body_proxy.rb +63 -0
- data/vendor/rack/builder.rb +315 -0
- data/vendor/rack/cascade.rb +67 -0
- data/vendor/rack/common_logger.rb +94 -0
- data/vendor/rack/conditional_get.rb +87 -0
- data/vendor/rack/config.rb +22 -0
- data/vendor/rack/constants.rb +68 -0
- data/vendor/rack/content_length.rb +34 -0
- data/vendor/rack/content_type.rb +33 -0
- data/vendor/rack/deflater.rb +159 -0
- data/vendor/rack/directory.rb +210 -0
- data/vendor/rack/etag.rb +71 -0
- data/vendor/rack/events.rb +172 -0
- data/vendor/rack/files.rb +224 -0
- data/vendor/rack/head.rb +25 -0
- data/vendor/rack/headers.rb +238 -0
- data/vendor/rack/lint.rb +1000 -0
- data/vendor/rack/lock.rb +29 -0
- data/vendor/rack/media_type.rb +42 -0
- data/vendor/rack/method_override.rb +56 -0
- data/vendor/rack/mime.rb +694 -0
- data/vendor/rack/mock.rb +3 -0
- data/vendor/rack/mock_request.rb +161 -0
- data/vendor/rack/mock_response.rb +147 -0
- data/vendor/rack/multipart/generator.rb +99 -0
- data/vendor/rack/multipart/parser.rb +586 -0
- data/vendor/rack/multipart/uploaded_file.rb +82 -0
- data/vendor/rack/multipart.rb +77 -0
- data/vendor/rack/null_logger.rb +48 -0
- data/vendor/rack/protection/authenticity_token.rb +256 -0
- data/vendor/rack/protection/base.rb +140 -0
- data/vendor/rack/protection/content_security_policy.rb +80 -0
- data/vendor/rack/protection/cookie_tossing.rb +77 -0
- data/vendor/rack/protection/escaped_params.rb +93 -0
- data/vendor/rack/protection/form_token.rb +25 -0
- data/vendor/rack/protection/frame_options.rb +39 -0
- data/vendor/rack/protection/http_origin.rb +43 -0
- data/vendor/rack/protection/ip_spoofing.rb +27 -0
- data/vendor/rack/protection/json_csrf.rb +60 -0
- data/vendor/rack/protection/path_traversal.rb +45 -0
- data/vendor/rack/protection/referrer_policy.rb +27 -0
- data/vendor/rack/protection/remote_referrer.rb +22 -0
- data/vendor/rack/protection/remote_token.rb +24 -0
- data/vendor/rack/protection/session_hijacking.rb +37 -0
- data/vendor/rack/protection/strict_transport.rb +41 -0
- data/vendor/rack/protection/version.rb +7 -0
- data/vendor/rack/protection/xss_header.rb +27 -0
- data/vendor/rack/protection.rb +58 -0
- data/vendor/rack/query_parser.rb +261 -0
- data/vendor/rack/recursive.rb +66 -0
- data/vendor/rack/reloader.rb +112 -0
- data/vendor/rack/request.rb +818 -0
- data/vendor/rack/response.rb +403 -0
- data/vendor/rack/rewindable_input.rb +116 -0
- data/vendor/rack/runtime.rb +35 -0
- data/vendor/rack/sendfile.rb +197 -0
- data/vendor/rack/session/abstract/id.rb +533 -0
- data/vendor/rack/session/constants.rb +13 -0
- data/vendor/rack/session/cookie.rb +292 -0
- data/vendor/rack/session/encryptor.rb +415 -0
- data/vendor/rack/session/pool.rb +76 -0
- data/vendor/rack/session/version.rb +10 -0
- data/vendor/rack/session.rb +12 -0
- data/vendor/rack/show_exceptions.rb +433 -0
- data/vendor/rack/show_status.rb +121 -0
- data/vendor/rack/static.rb +188 -0
- data/vendor/rack/tempfile_reaper.rb +44 -0
- data/vendor/rack/urlmap.rb +99 -0
- data/vendor/rack/utils.rb +631 -0
- data/vendor/rack/version.rb +17 -0
- data/vendor/rack.rb +66 -0
- metadata +76 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2022-2023, by Samuel Williams.
|
|
5
|
+
# Copyright, 2022, by Jeremy Evans.
|
|
6
|
+
# Copyright, 2022, by Jon Dufresne.
|
|
7
|
+
|
|
8
|
+
# require 'openssl'
|
|
9
|
+
# require 'zlib'
|
|
10
|
+
require 'json'
|
|
11
|
+
require 'base64'
|
|
12
|
+
require 'delegate'
|
|
13
|
+
|
|
14
|
+
require 'rack/constants'
|
|
15
|
+
require 'rack/utils'
|
|
16
|
+
|
|
17
|
+
require_relative 'abstract/id'
|
|
18
|
+
require_relative 'encryptor'
|
|
19
|
+
require_relative 'constants'
|
|
20
|
+
|
|
21
|
+
module Rack
|
|
22
|
+
|
|
23
|
+
module Session
|
|
24
|
+
|
|
25
|
+
# Rack::Session::Cookie provides simple cookie based session management.
|
|
26
|
+
# By default, the session is a Ruby Hash that is serialized and encoded as
|
|
27
|
+
# a cookie set to :key (default: rack.session).
|
|
28
|
+
#
|
|
29
|
+
# This middleware accepts a :secrets option which enables encryption of
|
|
30
|
+
# session cookies. This option should be one or more random "secret keys"
|
|
31
|
+
# that are each at least 64 bytes in length. Multiple secret keys can be
|
|
32
|
+
# supplied in an Array, which is useful when rotating secrets.
|
|
33
|
+
#
|
|
34
|
+
# Several options are also accepted that are passed to Rack::Session::Encryptor.
|
|
35
|
+
# These options include:
|
|
36
|
+
# * :serialize_json
|
|
37
|
+
# Use JSON for message serialization instead of Marshal. This can be
|
|
38
|
+
# viewed as a security enhancement.
|
|
39
|
+
# * :gzip_over
|
|
40
|
+
# For message data over this many bytes, compress it with the deflate
|
|
41
|
+
# algorithm.
|
|
42
|
+
#
|
|
43
|
+
# Refer to Rack::Session::Encryptor for more details on these options.
|
|
44
|
+
#
|
|
45
|
+
# Prior to version TODO, the session hash was stored as base64 encoded
|
|
46
|
+
# marshalled data. When a :secret option was supplied, the integrity of the
|
|
47
|
+
# encoded data was protected with HMAC-SHA1. This functionality is still
|
|
48
|
+
# supported using a set of a legacy options.
|
|
49
|
+
#
|
|
50
|
+
# Lastly, a :coder option is also accepted. When used, both encryption and
|
|
51
|
+
# the legacy HMAC will be skipped. This option could create security issues
|
|
52
|
+
# in your application!
|
|
53
|
+
#
|
|
54
|
+
# Example:
|
|
55
|
+
#
|
|
56
|
+
# use Rack::Session::Cookie, {
|
|
57
|
+
# key: 'rack.session',
|
|
58
|
+
# domain: 'foo.com',
|
|
59
|
+
# path: '/',
|
|
60
|
+
# expire_after: 2592000,
|
|
61
|
+
# secrets: 'a randomly generated, raw binary string 64 bytes in size',
|
|
62
|
+
# }
|
|
63
|
+
#
|
|
64
|
+
# Example using legacy HMAC options:
|
|
65
|
+
#
|
|
66
|
+
# Rack::Session:Cookie.new(application, {
|
|
67
|
+
# # The secret used for legacy HMAC cookies, this enables the functionality
|
|
68
|
+
# legacy_hmac_secret: 'legacy secret',
|
|
69
|
+
# # legacy_hmac_coder will default to Rack::Session::Cookie::Base64::Marshal
|
|
70
|
+
# legacy_hmac_coder: Rack::Session::Cookie::Identity.new,
|
|
71
|
+
# # legacy_hmac will default to OpenSSL::Digest::SHA1
|
|
72
|
+
# legacy_hmac: OpenSSL::Digest::SHA256
|
|
73
|
+
# })
|
|
74
|
+
#
|
|
75
|
+
# Example of a cookie with no encoding:
|
|
76
|
+
#
|
|
77
|
+
# Rack::Session::Cookie.new(application, {
|
|
78
|
+
# :coder => Rack::Session::Cookie::Identity.new
|
|
79
|
+
# })
|
|
80
|
+
#
|
|
81
|
+
# Example of a cookie with custom encoding:
|
|
82
|
+
#
|
|
83
|
+
# Rack::Session::Cookie.new(application, {
|
|
84
|
+
# :coder => Class.new {
|
|
85
|
+
# def encode(str); str.reverse; end
|
|
86
|
+
# def decode(str); str.reverse; end
|
|
87
|
+
# }.new
|
|
88
|
+
# })
|
|
89
|
+
#
|
|
90
|
+
|
|
91
|
+
class Cookie < Abstract::PersistedSecure
|
|
92
|
+
# Encode session cookies as Base64
|
|
93
|
+
class Base64
|
|
94
|
+
def encode(str)
|
|
95
|
+
::Base64.strict_encode64(str)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decode(str)
|
|
99
|
+
::Base64.decode64(str)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Encode session cookies as Marshaled Base64 data
|
|
103
|
+
class Marshal < Base64
|
|
104
|
+
def encode(str)
|
|
105
|
+
super(::Marshal.dump(str))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def decode(str)
|
|
109
|
+
return unless str
|
|
110
|
+
::Marshal.load(super(str)) rescue nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# N.B. Unlike other encoding methods, the contained objects must be a
|
|
115
|
+
# valid JSON composite type, either a Hash or an Array.
|
|
116
|
+
class JSON < Base64
|
|
117
|
+
def encode(obj)
|
|
118
|
+
super(::JSON.dump(obj))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def decode(str)
|
|
122
|
+
return unless str
|
|
123
|
+
::JSON.parse(super(str)) rescue nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class ZipJSON < Base64
|
|
128
|
+
def encode(obj)
|
|
129
|
+
super(Zlib::Deflate.deflate(::JSON.dump(obj)))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def decode(str)
|
|
133
|
+
return unless str
|
|
134
|
+
::JSON.parse(Zlib::Inflate.inflate(super(str)))
|
|
135
|
+
rescue
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Use no encoding for session cookies
|
|
142
|
+
class Identity
|
|
143
|
+
def encode(str); str; end
|
|
144
|
+
def decode(str); str; end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class Marshal
|
|
148
|
+
def encode(str)
|
|
149
|
+
::Marshal.dump(str)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def decode(str)
|
|
153
|
+
::Marshal.load(str) if str
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
attr_reader :coder, :encryptors
|
|
158
|
+
|
|
159
|
+
def initialize(app, options = {})
|
|
160
|
+
# homura patch: Opal/Workers has no OpenSSL, so
|
|
161
|
+
# Rack::Session::Encryptor (which uses OpenSSL::Cipher and
|
|
162
|
+
# mutating String#slice!) cannot be instantiated. We skip
|
|
163
|
+
# encryption entirely and fall through to the plain
|
|
164
|
+
# Base64::Marshal coder. This is the same mode CRuby's
|
|
165
|
+
# Rack::Session::Cookie uses when no secret is provided —
|
|
166
|
+
# session data lives in the cookie as Base64'd Marshal, with
|
|
167
|
+
# no server-side verification. For production use, the
|
|
168
|
+
# operator should layer Cloudflare's own signed-cookie or
|
|
169
|
+
# JWT-based session management on top.
|
|
170
|
+
@encryptors = []
|
|
171
|
+
@legacy_hmac = false
|
|
172
|
+
|
|
173
|
+
# homura patch: use JSON coder instead of Marshal — Opal doesn't
|
|
174
|
+
# ship a full Marshal implementation, but JSON is native on JS.
|
|
175
|
+
@coder = options[:coder] ||= Base64::JSON.new
|
|
176
|
+
|
|
177
|
+
super(app, options.merge!(cookie_only: true))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def find_session(req, sid)
|
|
183
|
+
data = unpacked_cookie_data(req)
|
|
184
|
+
data = persistent_session_id!(data)
|
|
185
|
+
[data["session_id"], data]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def extract_session_id(request)
|
|
189
|
+
unpacked_cookie_data(request)&.[]("session_id")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def unpacked_cookie_data(request)
|
|
193
|
+
request.fetch_header(RACK_SESSION_UNPACKED_COOKIE_DATA) do |k|
|
|
194
|
+
if cookie_data = request.cookies[@key]
|
|
195
|
+
session_data = nil
|
|
196
|
+
|
|
197
|
+
# Try to decrypt the session data with our encryptors
|
|
198
|
+
encryptors.each do |encryptor|
|
|
199
|
+
begin
|
|
200
|
+
session_data = encryptor.decrypt(cookie_data)
|
|
201
|
+
break
|
|
202
|
+
rescue Rack::Session::Encryptor::Error => error
|
|
203
|
+
request.env[Rack::RACK_ERRORS].puts "Session cookie encryptor error: #{error.message}"
|
|
204
|
+
|
|
205
|
+
next
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# If session decryption fails but there is @legacy_hmac_secret
|
|
210
|
+
# defined, attempt legacy HMAC verification
|
|
211
|
+
if !session_data && @legacy_hmac_secret
|
|
212
|
+
# Parse and verify legacy HMAC session cookie
|
|
213
|
+
session_data, _, digest = cookie_data.rpartition('--')
|
|
214
|
+
session_data = nil unless legacy_digest_match?(session_data, digest)
|
|
215
|
+
|
|
216
|
+
# Decode using legacy HMAC decoder
|
|
217
|
+
session_data = @legacy_hmac_coder.decode(session_data)
|
|
218
|
+
|
|
219
|
+
elsif !session_data && coder
|
|
220
|
+
# Use the coder option, which has the potential to be very unsafe
|
|
221
|
+
session_data = coder.decode(cookie_data)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
request.set_header(k, session_data || {})
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def persistent_session_id!(data, sid = nil)
|
|
230
|
+
data ||= {}
|
|
231
|
+
data["session_id"] ||= sid || generate_sid
|
|
232
|
+
data
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
class SessionId < DelegateClass(Session::SessionId)
|
|
236
|
+
attr_reader :cookie_value
|
|
237
|
+
|
|
238
|
+
def initialize(session_id, cookie_value)
|
|
239
|
+
super(session_id)
|
|
240
|
+
@cookie_value = cookie_value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def write_session(req, session_id, session, options)
|
|
245
|
+
session = session.merge("session_id" => session_id)
|
|
246
|
+
session_data = encode_session_data(session)
|
|
247
|
+
|
|
248
|
+
if session_data.size > (4096 - @key.size)
|
|
249
|
+
req.get_header(RACK_ERRORS).puts("Warning! Rack::Session::Cookie data size exceeds 4K.")
|
|
250
|
+
nil
|
|
251
|
+
else
|
|
252
|
+
SessionId.new(session_id, session_data)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def delete_session(req, session_id, options)
|
|
257
|
+
# Nothing to do here, data is in the client
|
|
258
|
+
generate_sid unless options[:drop]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def legacy_digest_match?(data, digest)
|
|
262
|
+
return false unless data && digest
|
|
263
|
+
|
|
264
|
+
Rack::Utils.secure_compare(digest, legacy_generate_hmac(data))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def legacy_generate_hmac(data)
|
|
268
|
+
OpenSSL::HMAC.hexdigest(@legacy_hmac, @legacy_hmac_secret, data)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def encode_session_data(session)
|
|
272
|
+
if encryptors.empty?
|
|
273
|
+
coder.encode(session)
|
|
274
|
+
else
|
|
275
|
+
encryptors.first.encrypt(session)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Were consider "secure" if:
|
|
280
|
+
# * Encrypted cookies are enabled and one or more encryptor is
|
|
281
|
+
# initialized
|
|
282
|
+
# * The legacy HMAC option is enabled
|
|
283
|
+
# * Customer :coder is used, with :let_coder_handle_secure_encoding
|
|
284
|
+
# set to true
|
|
285
|
+
def secure?(options)
|
|
286
|
+
!@encryptors.empty? ||
|
|
287
|
+
@legacy_hmac ||
|
|
288
|
+
(options[:coder] && options[:let_coder_handle_secure_encoding])
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2022-2023, by Samuel Williams.
|
|
5
|
+
# Copyright, 2022, by Philip Arndt.
|
|
6
|
+
|
|
7
|
+
require 'base64'
|
|
8
|
+
require 'json'
|
|
9
|
+
# require 'openssl'
|
|
10
|
+
require 'securerandom'
|
|
11
|
+
|
|
12
|
+
require 'rack/utils'
|
|
13
|
+
|
|
14
|
+
module Rack
|
|
15
|
+
module Session
|
|
16
|
+
class Encryptor
|
|
17
|
+
class Error < StandardError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class InvalidSignature < Error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class InvalidMessage < Error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module Serializable
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Returns a serialized payload of the message. If a :pad_size is supplied,
|
|
30
|
+
# the message will be padded. The first 2 bytes of the returned string will
|
|
31
|
+
# indicating the amount of padding.
|
|
32
|
+
def serialize_payload(message)
|
|
33
|
+
serialized_data = serializer.dump(message)
|
|
34
|
+
|
|
35
|
+
return "#{[0].pack('v')}#{serialized_data}" if @options[:pad_size].nil?
|
|
36
|
+
|
|
37
|
+
padding_bytes = @options[:pad_size] - (2 + serialized_data.size) % @options[:pad_size]
|
|
38
|
+
padding_data = SecureRandom.random_bytes(padding_bytes)
|
|
39
|
+
|
|
40
|
+
"#{[padding_bytes].pack('v')}#{padding_data}#{serialized_data}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Return the deserialized message. The first 2 bytes will be read as the
|
|
44
|
+
# amount of padding.
|
|
45
|
+
def deserialized_message(data)
|
|
46
|
+
# Read the first 2 bytes as the padding_bytes size
|
|
47
|
+
padding_bytes, = data.unpack('v')
|
|
48
|
+
|
|
49
|
+
# Slice out the serialized_data and deserialize it
|
|
50
|
+
serialized_data = data.slice(2 + padding_bytes, data.bytesize)
|
|
51
|
+
serializer.load serialized_data
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def serializer
|
|
55
|
+
@serializer ||= @options[:serialize_json] ? JSON : Marshal
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class V1
|
|
60
|
+
include Serializable
|
|
61
|
+
|
|
62
|
+
# The secret String must be at least 64 bytes in size. The first 32 bytes
|
|
63
|
+
# will be used for the encryption cipher key. The remainder will be used
|
|
64
|
+
# for an HMAC key.
|
|
65
|
+
#
|
|
66
|
+
# Options may include:
|
|
67
|
+
# * :serialize_json
|
|
68
|
+
# Use JSON for message serialization instead of Marshal. This can be
|
|
69
|
+
# viewed as a security enhancement.
|
|
70
|
+
# * :pad_size
|
|
71
|
+
# Pad encrypted message data, to a multiple of this many bytes
|
|
72
|
+
# (default: 32). This can be between 2-4096 bytes, or +nil+ to disable
|
|
73
|
+
# padding.
|
|
74
|
+
# * :purpose
|
|
75
|
+
# Limit messages to a specific purpose. This can be viewed as a
|
|
76
|
+
# security enhancement to prevent message reuse from different contexts
|
|
77
|
+
# if keys are reused.
|
|
78
|
+
#
|
|
79
|
+
# Cryptography and Output Format:
|
|
80
|
+
#
|
|
81
|
+
# urlsafe_encode64(version + random_data + IV + encrypted data + HMAC)
|
|
82
|
+
#
|
|
83
|
+
# Where:
|
|
84
|
+
# * version - 1 byte with value 0x01
|
|
85
|
+
# * random_data - 32 bytes used for generating the per-message secret
|
|
86
|
+
# * IV - 16 bytes random initialization vector
|
|
87
|
+
# * HMAC - 32 bytes HMAC-SHA-256 of all preceding data, plus the purpose
|
|
88
|
+
# value
|
|
89
|
+
def initialize(secret, opts = {})
|
|
90
|
+
raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)
|
|
91
|
+
raise ArgumentError, "invalid secret: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64
|
|
92
|
+
|
|
93
|
+
case opts[:pad_size]
|
|
94
|
+
when nil
|
|
95
|
+
# padding is disabled
|
|
96
|
+
when Integer
|
|
97
|
+
raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size]
|
|
98
|
+
else
|
|
99
|
+
raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@options = {
|
|
103
|
+
serialize_json: false, pad_size: 32, purpose: nil
|
|
104
|
+
}.update(opts)
|
|
105
|
+
|
|
106
|
+
@hmac_secret = secret.dup.force_encoding('BINARY')
|
|
107
|
+
@cipher_secret = @hmac_secret.slice!(0, 32)
|
|
108
|
+
|
|
109
|
+
@hmac_secret.freeze
|
|
110
|
+
@cipher_secret.freeze
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def decrypt(base64_data)
|
|
114
|
+
data = Base64.urlsafe_decode64(base64_data)
|
|
115
|
+
|
|
116
|
+
signature = data.slice!(-32..-1)
|
|
117
|
+
verify_authenticity!(data, signature)
|
|
118
|
+
|
|
119
|
+
version = data.slice!(0, 1)
|
|
120
|
+
raise InvalidMessage, 'wrong version' unless version == "\1"
|
|
121
|
+
|
|
122
|
+
message_secret = data.slice!(0, 32)
|
|
123
|
+
cipher_iv = data.slice!(0, 16)
|
|
124
|
+
|
|
125
|
+
cipher = new_cipher
|
|
126
|
+
cipher.decrypt
|
|
127
|
+
|
|
128
|
+
set_cipher_key(cipher, cipher_secret_from_message_secret(message_secret))
|
|
129
|
+
|
|
130
|
+
cipher.iv = cipher_iv
|
|
131
|
+
data = cipher.update(data) << cipher.final
|
|
132
|
+
|
|
133
|
+
deserialized_message data
|
|
134
|
+
rescue ArgumentError
|
|
135
|
+
raise InvalidSignature, 'Message invalid'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def encrypt(message)
|
|
139
|
+
version = "\1"
|
|
140
|
+
|
|
141
|
+
serialized_payload = serialize_payload(message)
|
|
142
|
+
message_secret, cipher_secret = new_message_and_cipher_secret
|
|
143
|
+
|
|
144
|
+
cipher = new_cipher
|
|
145
|
+
cipher.encrypt
|
|
146
|
+
|
|
147
|
+
set_cipher_key(cipher, cipher_secret)
|
|
148
|
+
|
|
149
|
+
cipher_iv = cipher.random_iv
|
|
150
|
+
|
|
151
|
+
encrypted_data = cipher.update(serialized_payload) << cipher.final
|
|
152
|
+
|
|
153
|
+
data = String.new
|
|
154
|
+
data << version
|
|
155
|
+
data << message_secret
|
|
156
|
+
data << cipher_iv
|
|
157
|
+
data << encrypted_data
|
|
158
|
+
data << compute_signature(data)
|
|
159
|
+
|
|
160
|
+
Base64.urlsafe_encode64(data)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def new_cipher
|
|
166
|
+
OpenSSL::Cipher.new('aes-256-ctr')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def new_message_and_cipher_secret
|
|
170
|
+
message_secret = SecureRandom.random_bytes(32)
|
|
171
|
+
|
|
172
|
+
[message_secret, cipher_secret_from_message_secret(message_secret)]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def cipher_secret_from_message_secret(message_secret)
|
|
176
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @cipher_secret, message_secret)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def set_cipher_key(cipher, key)
|
|
180
|
+
cipher.key = key
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def compute_signature(data)
|
|
184
|
+
signing_data = data
|
|
185
|
+
signing_data += @options[:purpose] if @options[:purpose]
|
|
186
|
+
|
|
187
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @hmac_secret, signing_data)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def verify_authenticity!(data, signature)
|
|
191
|
+
raise InvalidMessage, 'Message is invalid' if data.nil? || signature.nil?
|
|
192
|
+
|
|
193
|
+
unless Rack::Utils.secure_compare(signature, compute_signature(data))
|
|
194
|
+
raise InvalidSignature, 'HMAC is invalid'
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
class V2
|
|
200
|
+
include Serializable
|
|
201
|
+
|
|
202
|
+
# The secret String must be at least 32 bytes in size.
|
|
203
|
+
#
|
|
204
|
+
# Options may include:
|
|
205
|
+
# * :pad_size
|
|
206
|
+
# Pad encrypted message data, to a multiple of this many bytes
|
|
207
|
+
# (default: 32). This can be between 2-4096 bytes, or +nil+ to disable
|
|
208
|
+
# padding.
|
|
209
|
+
# * :purpose
|
|
210
|
+
# Limit messages to a specific purpose. This can be viewed as a
|
|
211
|
+
# security enhancement to prevent message reuse from different contexts
|
|
212
|
+
# if keys are reused.
|
|
213
|
+
#
|
|
214
|
+
# Cryptography and Output Format:
|
|
215
|
+
#
|
|
216
|
+
# strict_encode64(version + salt + IV + authentication tag + ciphertext)
|
|
217
|
+
#
|
|
218
|
+
# Where:
|
|
219
|
+
# * version - 1 byte with value 0x02
|
|
220
|
+
# * salt - 32 bytes used for generating the per-message secret
|
|
221
|
+
# * IV - 12 bytes random initialization vector
|
|
222
|
+
# * authentication tag - 16 bytes authentication tag generated by the GCM mode, covering version and salt
|
|
223
|
+
#
|
|
224
|
+
# Considerations about V2:
|
|
225
|
+
#
|
|
226
|
+
# 1) It serializes messages in JSON, period.
|
|
227
|
+
#
|
|
228
|
+
# 2) It uses non URL-safe Base64 encoding as it's faster than its
|
|
229
|
+
# URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is
|
|
230
|
+
# roughly equivalent to do Base64.strict_encode64(data).tr("-_",
|
|
231
|
+
# "+/") - and cookie values don't need to be URL-safe.
|
|
232
|
+
def initialize(secret, opts = {})
|
|
233
|
+
raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)
|
|
234
|
+
|
|
235
|
+
unless secret.bytesize >= 32
|
|
236
|
+
raise ArgumentError, "invalid secret: it's #{secret.bytesize}-byte long, must be >=32"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
case opts[:pad_size]
|
|
240
|
+
when nil
|
|
241
|
+
# padding is disabled
|
|
242
|
+
when Integer
|
|
243
|
+
raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size]
|
|
244
|
+
else
|
|
245
|
+
raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
@options = {
|
|
249
|
+
pad_size: 32, purpose: nil
|
|
250
|
+
}.update(opts)
|
|
251
|
+
@options[:serialize_json] = true # Enforce JSON serialization
|
|
252
|
+
|
|
253
|
+
@cipher_secret = secret.dup.force_encoding('BINARY').slice!(0, 32)
|
|
254
|
+
@cipher_secret.freeze
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def decrypt(base64_data)
|
|
258
|
+
data = Base64.strict_decode64(base64_data)
|
|
259
|
+
if data.bytesize <= 61 # version + salt + iv + auth_tag = 61 byte (and we also need some ciphertext :)
|
|
260
|
+
raise InvalidMessage, 'invalid message'
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
version = data[0]
|
|
264
|
+
raise InvalidMessage, 'invalid message' unless version == "\2"
|
|
265
|
+
|
|
266
|
+
ciphertext = data.slice!(61..-1)
|
|
267
|
+
auth_tag = data.slice!(45, 16)
|
|
268
|
+
cipher_iv = data.slice!(33, 12)
|
|
269
|
+
|
|
270
|
+
cipher = new_cipher
|
|
271
|
+
cipher.decrypt
|
|
272
|
+
salt = data.slice(1, 32)
|
|
273
|
+
set_cipher_key(cipher, message_secret_from_salt(salt))
|
|
274
|
+
cipher.iv = cipher_iv
|
|
275
|
+
cipher.auth_tag = auth_tag
|
|
276
|
+
cipher.auth_data = (purpose = @options[:purpose]) ? data + purpose : data
|
|
277
|
+
|
|
278
|
+
plaintext = cipher.update(ciphertext) << cipher.final
|
|
279
|
+
|
|
280
|
+
deserialized_message plaintext
|
|
281
|
+
rescue ArgumentError, OpenSSL::Cipher::CipherError
|
|
282
|
+
raise InvalidSignature, 'invalid message'
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def encrypt(message)
|
|
286
|
+
version = "\2"
|
|
287
|
+
|
|
288
|
+
serialized_payload = serialize_payload(message)
|
|
289
|
+
|
|
290
|
+
cipher = new_cipher
|
|
291
|
+
cipher.encrypt
|
|
292
|
+
salt, message_secret = new_salt_and_message_secret
|
|
293
|
+
set_cipher_key(cipher, message_secret)
|
|
294
|
+
cipher.iv_len = 12
|
|
295
|
+
cipher_iv = cipher.random_iv
|
|
296
|
+
|
|
297
|
+
data = String.new
|
|
298
|
+
data << version
|
|
299
|
+
data << salt
|
|
300
|
+
|
|
301
|
+
cipher.auth_data = (purpose = @options[:purpose]) ? data + purpose : data
|
|
302
|
+
encrypted_data = cipher.update(serialized_payload) << cipher.final
|
|
303
|
+
|
|
304
|
+
data << cipher_iv
|
|
305
|
+
data << auth_tag_from(cipher)
|
|
306
|
+
data << encrypted_data
|
|
307
|
+
|
|
308
|
+
Base64.strict_encode64(data)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private
|
|
312
|
+
|
|
313
|
+
def new_cipher
|
|
314
|
+
OpenSSL::Cipher.new('aes-256-gcm')
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def new_salt_and_message_secret
|
|
318
|
+
salt = SecureRandom.random_bytes(32)
|
|
319
|
+
|
|
320
|
+
[salt, message_secret_from_salt(salt)]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def message_secret_from_salt(salt)
|
|
324
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @cipher_secret, salt)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def set_cipher_key(cipher, key)
|
|
328
|
+
cipher.key = key
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if RUBY_ENGINE == 'jruby'
|
|
332
|
+
# JRuby's OpenSSL implementation doesn't currently support passing
|
|
333
|
+
# an argument to #auth_tag. Here we work around that.
|
|
334
|
+
def auth_tag_from(cipher)
|
|
335
|
+
tag = cipher.auth_tag
|
|
336
|
+
raise Error, 'the auth tag must be 16 bytes long' if tag.bytesize != 16
|
|
337
|
+
|
|
338
|
+
tag
|
|
339
|
+
end
|
|
340
|
+
else
|
|
341
|
+
def auth_tag_from(cipher)
|
|
342
|
+
cipher.auth_tag(16)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def initialize(secret, opts = {})
|
|
348
|
+
opts = opts.dup
|
|
349
|
+
|
|
350
|
+
@mode = opts.delete(:mode)&.to_sym || :guess_version
|
|
351
|
+
case @mode
|
|
352
|
+
when :v1
|
|
353
|
+
@v1 = V1.new(secret, opts)
|
|
354
|
+
when :v2
|
|
355
|
+
@v2 = V2.new(secret, opts)
|
|
356
|
+
else
|
|
357
|
+
@v1 = V1.new(secret, opts)
|
|
358
|
+
@v2 = V2.new(secret, opts)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def decrypt(base64_data)
|
|
363
|
+
decryptor =
|
|
364
|
+
case @mode
|
|
365
|
+
when :v2
|
|
366
|
+
v2
|
|
367
|
+
when :v1
|
|
368
|
+
v1
|
|
369
|
+
else
|
|
370
|
+
guess_decryptor(base64_data)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
decryptor.decrypt(base64_data)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def encrypt(message)
|
|
377
|
+
encryptor =
|
|
378
|
+
case @mode
|
|
379
|
+
when :v1
|
|
380
|
+
v1
|
|
381
|
+
else
|
|
382
|
+
v2
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
encryptor.encrypt(message)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private
|
|
389
|
+
|
|
390
|
+
attr_reader :v1, :v2
|
|
391
|
+
|
|
392
|
+
def guess_decryptor(base64_data)
|
|
393
|
+
raise InvalidMessage, 'invalid message' if base64_data.nil? || base64_data.bytesize < 4
|
|
394
|
+
|
|
395
|
+
first_encoded_4_bytes = base64_data.slice(0, 4)
|
|
396
|
+
# Transform the 4 bytes into non-URL-safe base64-encoded data. Nothing
|
|
397
|
+
# happens if the data is already non-URL-safe base64.
|
|
398
|
+
first_encoded_4_bytes.tr!('-_', '+/')
|
|
399
|
+
first_decoded_3_bytes = Base64.strict_decode64(first_encoded_4_bytes)
|
|
400
|
+
|
|
401
|
+
version = first_decoded_3_bytes[0]
|
|
402
|
+
case version
|
|
403
|
+
when "\2"
|
|
404
|
+
v2
|
|
405
|
+
when "\1"
|
|
406
|
+
v1
|
|
407
|
+
else
|
|
408
|
+
raise InvalidMessage, 'invalid message'
|
|
409
|
+
end
|
|
410
|
+
rescue ArgumentError
|
|
411
|
+
raise InvalidMessage, 'invalid message'
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|