mayu-live 0.0.3 → 0.0.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/README.md +2 -2
- data/lib/mayu/encrypted_marshal.rb +119 -0
- data/lib/mayu/environment.rb +8 -5
- data/lib/mayu/resources/transformers/__test__/css/media_queries.out.css +3 -3
- data/lib/mayu/resources/types/svg.rb +3 -18
- data/lib/mayu/server/app.rb +2 -2
- data/lib/mayu/server/errors.rb +2 -2
- data/lib/mayu/session.rb +3 -6
- data/lib/mayu/version.rb +1 -1
- data/mayu-live.gemspec +4 -4
- metadata +28 -22
- data/lib/mayu/message_cipher.rb +0 -172
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3f819e4512b624c496e60ad22b2461db0ed9afbe13b2b5b528358266dd83cbe7
|
4
|
+
data.tar.gz: 4185727d1855c3eb019d27c0f2ab82d17d2941d3b8101043cb55d6fa9d31d350
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 314a09735d9870f64c4ce7635ff072281c87e11dc6414786957188158f047e70bef8757b5bb9bc038b0de7a4d298e5f4ea1a0fecd3ffeb485fc01c6b1f4ac54a
|
7
|
+
data.tar.gz: 3ec46f730741fcb7d6918b3a997704a298eef23572e3ffcf57903f97c2809bf82591bec9d70da4f33f0f9ce992bcc802a55df626dcc1ae1f7e83c57cba64466e
|
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
#
|
1
|
+
# 
|
2
2
|
|
3
3
|
[](https://github.com/mayu-live/framework/actions/workflows/test.yml)
|
4
4
|
[](https://github.com/mayu-live/framework/releases)
|
@@ -9,7 +9,7 @@
|
|
9
9
|
|
10
10
|
# Description
|
11
11
|
|
12
|
-
Mayu is a live
|
12
|
+
Mayu is a live updating server side component-based
|
13
13
|
VirtualDOM rendering framework written in Ruby.
|
14
14
|
|
15
15
|
Everything runs on the server, except for a tiny little runtime that
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "sorbet-runtime"
|
5
|
+
require "rbnacl"
|
6
|
+
require "brotli"
|
7
|
+
|
8
|
+
module Mayu
|
9
|
+
class EncryptedMarshal
|
10
|
+
class Error < StandardError
|
11
|
+
end
|
12
|
+
|
13
|
+
class IssuedInTheFutureError < Error
|
14
|
+
end
|
15
|
+
|
16
|
+
class ExpiredError < Error
|
17
|
+
end
|
18
|
+
|
19
|
+
class DecryptError < Error
|
20
|
+
end
|
21
|
+
|
22
|
+
AdditionalData =
|
23
|
+
Data.define(:issued_at, :ttl) do
|
24
|
+
def self.create(ttl:, now: Time.now) = new(now.to_f, ttl)
|
25
|
+
|
26
|
+
def self.unpack(data)
|
27
|
+
data.unpack("D S") => [issued_at, ttl]
|
28
|
+
new(issued_at, ttl)
|
29
|
+
end
|
30
|
+
|
31
|
+
def pack = [issued_at, ttl].pack("D S")
|
32
|
+
def expired?(now: Time.now) = now > expires_at
|
33
|
+
def expires_at = Time.at(issued_at + ttl)
|
34
|
+
end
|
35
|
+
|
36
|
+
Message =
|
37
|
+
Data.define(:nonce, :ad, :ciphertext) do
|
38
|
+
def self.unpack(data)
|
39
|
+
data.unpack("S a*") => [nonce_length, data]
|
40
|
+
data.unpack("a#{nonce_length} S a*") => [nonce, ad_length, data]
|
41
|
+
data.unpack("a#{ad_length} a*") => [ad, ciphertext]
|
42
|
+
new(nonce, AdditionalData.unpack(ad), ciphertext)
|
43
|
+
end
|
44
|
+
|
45
|
+
def pack
|
46
|
+
packed_ad = ad.pack
|
47
|
+
[
|
48
|
+
nonce.bytesize,
|
49
|
+
nonce,
|
50
|
+
packed_ad.bytesize,
|
51
|
+
packed_ad,
|
52
|
+
ciphertext
|
53
|
+
].pack("S a* S a* a*")
|
54
|
+
end
|
55
|
+
|
56
|
+
def expired?(now: Time.now) = ad.expired?(now:)
|
57
|
+
def expires_at = ad.expires_at
|
58
|
+
|
59
|
+
def verify_timestamps!(now: Time.now)
|
60
|
+
if ad.expired?(now:)
|
61
|
+
raise ExpiredError, "Message expired at #{ad.expires_at}"
|
62
|
+
end
|
63
|
+
|
64
|
+
if ad.issued_at > now.to_f
|
65
|
+
raise IssuedInTheFutureError,
|
66
|
+
"Message was issued in the future, #{Time.at(ad.issued_at)}"
|
67
|
+
end
|
68
|
+
|
69
|
+
self
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
extend T::Sig
|
74
|
+
|
75
|
+
Cipher = RbNaCl::AEAD::ChaCha20Poly1305IETF
|
76
|
+
|
77
|
+
DEFAULT_TTL_SECONDS = 10
|
78
|
+
|
79
|
+
sig { returns(String) }
|
80
|
+
def self.random_key = RbNaCl::Random.random_bytes(Cipher::KEYBYTES)
|
81
|
+
|
82
|
+
sig { params(base_key: String, default_ttl: Integer).void }
|
83
|
+
def initialize(base_key, default_ttl: DEFAULT_TTL_SECONDS)
|
84
|
+
@cipher = Cipher.new(RbNaCl::Hash.sha256(base_key))
|
85
|
+
@default_ttl = default_ttl
|
86
|
+
end
|
87
|
+
|
88
|
+
sig { params(object: T.untyped, ttl: Integer).returns(String) }
|
89
|
+
def dump(object, ttl: @default_ttl) =
|
90
|
+
object
|
91
|
+
.then { Marshal.dump(_1) }
|
92
|
+
.then { Brotli.deflate(_1) }
|
93
|
+
.then { encrypt(_1, ttl:) }
|
94
|
+
|
95
|
+
sig { params(encrypted: String).returns(T.untyped) }
|
96
|
+
def load(encrypted) =
|
97
|
+
encrypted
|
98
|
+
.then { Message.unpack(_1) }
|
99
|
+
.then { _1.verify_timestamps! }
|
100
|
+
.then { decrypt(_1) }
|
101
|
+
.then { Brotli.inflate(_1) }
|
102
|
+
.then { Marshal.load(_1) }
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def encrypt(message, ttl:)
|
107
|
+
nonce = RbNaCl::Random.random_bytes(@cipher.nonce_bytes)
|
108
|
+
ad = AdditionalData.create(ttl:)
|
109
|
+
ciphertext = @cipher.encrypt(nonce, message, ad.pack)
|
110
|
+
Message.new(nonce, ad, ciphertext).pack
|
111
|
+
end
|
112
|
+
|
113
|
+
def decrypt(message)
|
114
|
+
@cipher.decrypt(message.nonce, message.ciphertext, message.ad.pack)
|
115
|
+
rescue RbNaCl::CryptoError => e
|
116
|
+
raise DecryptError, e.message
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
data/lib/mayu/environment.rb
CHANGED
@@ -10,7 +10,7 @@ require_relative "metrics"
|
|
10
10
|
require_relative "app_metrics"
|
11
11
|
require_relative "resources/registry"
|
12
12
|
require_relative "fetch"
|
13
|
-
require_relative "
|
13
|
+
require_relative "encrypted_marshal"
|
14
14
|
require_relative "configuration"
|
15
15
|
|
16
16
|
module Mayu
|
@@ -34,8 +34,8 @@ module Mayu
|
|
34
34
|
attr_reader :resources
|
35
35
|
sig { returns(Fetch) }
|
36
36
|
attr_reader :fetch
|
37
|
-
sig { returns(
|
38
|
-
attr_reader :
|
37
|
+
sig { returns(EncryptedMarshal) }
|
38
|
+
attr_reader :encrypted_marshal
|
39
39
|
sig { returns(AppMetrics) }
|
40
40
|
attr_reader :metrics
|
41
41
|
|
@@ -44,8 +44,11 @@ module Mayu
|
|
44
44
|
@root = T.let(config.root, String)
|
45
45
|
@app_root = T.let(File.join(config.root, "app"), String)
|
46
46
|
@config = config
|
47
|
-
@
|
48
|
-
T.let(
|
47
|
+
@encrypted_marshal =
|
48
|
+
T.let(
|
49
|
+
EncryptedMarshal.new(config.secret_key, default_ttl: 30),
|
50
|
+
EncryptedMarshal
|
51
|
+
)
|
49
52
|
# TODO: Reload routes when things change in /pages...
|
50
53
|
# Should probably make routes into a resource type.
|
51
54
|
@routes =
|
@@ -1,5 +1,5 @@
|
|
1
|
-
@layer app\/components\/MyComponent\?
|
2
|
-
@media (
|
1
|
+
@layer app\/components\/MyComponent\?E59aOM9B {
|
2
|
+
@media (width >= 8em) and (width <= 32em) {
|
3
3
|
.app\/components\/MyComponent\.foo\?Ef7fDeuq {
|
4
4
|
color: #f0f;
|
5
5
|
}
|
@@ -9,4 +9,4 @@
|
|
9
9
|
color: #00f;
|
10
10
|
}
|
11
11
|
|
12
|
-
}
|
12
|
+
}
|
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
# typed: strict
|
3
3
|
|
4
|
-
require "svg_optimizer"
|
5
4
|
require_relative "base"
|
6
5
|
|
7
6
|
module Mayu
|
@@ -10,30 +9,16 @@ module Mayu
|
|
10
9
|
class SVG < Base
|
11
10
|
extend T::Sig
|
12
11
|
|
13
|
-
SVG_OPTIMIZER_PLUGINS =
|
14
|
-
T.let(
|
15
|
-
SvgOptimizer::DEFAULT_PLUGINS -
|
16
|
-
[
|
17
|
-
# The following plugin sets fill="none" in some cases
|
18
|
-
# which breaks the fontawesome icons... That's why
|
19
|
-
# it's disabled...
|
20
|
-
SvgOptimizer::Plugins::RemoveUselessStrokeAndFill
|
21
|
-
],
|
22
|
-
T::Array[T.class_of(SvgOptimizer::Plugins::Base)]
|
23
|
-
)
|
24
|
-
|
25
12
|
sig { params(resource: Resource).void }
|
26
13
|
def initialize(resource)
|
27
14
|
@resource = resource
|
28
15
|
|
29
|
-
|
30
|
-
optimized = SvgOptimizer.optimize(original, SVG_OPTIMIZER_PLUGINS)
|
16
|
+
source = resource.read(encoding: "utf-8")
|
31
17
|
|
32
|
-
content_hash =
|
33
|
-
Base64.urlsafe_encode64(Digest::SHA256.digest(optimized))
|
18
|
+
content_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(source))
|
34
19
|
|
35
20
|
@filename = T.let("#{content_hash}.svg", String)
|
36
|
-
@source = T.let(
|
21
|
+
@source = T.let(source, String)
|
37
22
|
end
|
38
23
|
|
39
24
|
sig { returns(T::Array[Asset]) }
|
data/lib/mayu/server/app.rb
CHANGED
@@ -433,7 +433,7 @@ module Mayu
|
|
433
433
|
)
|
434
434
|
end
|
435
435
|
|
436
|
-
@environment.
|
436
|
+
@environment.encrypted_marshal.load(body) => String => dumped
|
437
437
|
session = Session.restore(environment: @environment, dumped:)
|
438
438
|
@sessions.store(session.id, session)
|
439
439
|
end
|
@@ -454,7 +454,7 @@ module Mayu
|
|
454
454
|
EventStream::Message.new(
|
455
455
|
:"session.transfer",
|
456
456
|
EventStream::Blob.new(
|
457
|
-
@environment.
|
457
|
+
@environment.encrypted_marshal.dump(
|
458
458
|
Session::SerializedSession.dump_session(session)
|
459
459
|
)
|
460
460
|
)
|
data/lib/mayu/server/errors.rb
CHANGED
@@ -64,9 +64,9 @@ module Mayu
|
|
64
64
|
text_response(403, "session cookie not set")
|
65
65
|
rescue SessionNotFound => e
|
66
66
|
text_response(404, "session not found")
|
67
|
-
rescue Mayu::
|
67
|
+
rescue Mayu::EncryptedMarshal::DecryptError => e
|
68
68
|
text_response(403, "decrypt error")
|
69
|
-
rescue Mayu::
|
69
|
+
rescue Mayu::EncryptedMarshal::ExpiredError => e
|
70
70
|
text_response(403, "session expired")
|
71
71
|
rescue InvalidToken => e
|
72
72
|
text_response(403, "invalid token")
|
data/lib/mayu/session.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require "time"
|
4
4
|
require "nanoid"
|
5
|
+
require "rbnacl"
|
5
6
|
require_relative "environment"
|
6
7
|
require_relative "vdom/vtree"
|
7
8
|
require_relative "vdom/marshalling"
|
@@ -77,11 +78,7 @@ module Mayu
|
|
77
78
|
|
78
79
|
sig { params(token: String).returns(T::Boolean) }
|
79
80
|
def authorized?(token)
|
80
|
-
|
81
|
-
OpenSSL.fixed_length_secure_compare(self.token, token)
|
82
|
-
else
|
83
|
-
false
|
84
|
-
end
|
81
|
+
RbNaCl::Util.verify64(self.token, token)
|
85
82
|
end
|
86
83
|
|
87
84
|
Marshaled = T.type_alias { [String, String, String, String, String] }
|
@@ -166,7 +163,7 @@ module Mayu
|
|
166
163
|
# freeze
|
167
164
|
|
168
165
|
# encrypted_session =
|
169
|
-
# @environment.
|
166
|
+
# @environment.encrypted_marshal.dump(SerializedSession.dump_session(self))
|
170
167
|
|
171
168
|
links = [
|
172
169
|
%{<script async type="module" src="/__mayu/runtime/#{environment.init_js}##{id}" crossorigin="same-origin" fetchpriority="high"></script>},
|
data/lib/mayu/version.rb
CHANGED
data/mayu-live.gemspec
CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
|
|
11
11
|
spec.summary = "Server side VDOM framework"
|
12
12
|
|
13
13
|
spec.description = <<~EOF
|
14
|
-
Mayu Live is a live
|
14
|
+
Mayu Live is a live updating server side VirtualDOM framework for Ruby,
|
15
15
|
inspired by modern frontend tools that exist in the JavaScript ecosystem.
|
16
16
|
EOF
|
17
17
|
|
@@ -53,8 +53,9 @@ Gem::Specification.new do |spec|
|
|
53
53
|
spec.add_dependency "prometheus-client", "~> 4.0.0"
|
54
54
|
spec.add_dependency "protocol-http", "~> 0.23.12"
|
55
55
|
spec.add_dependency "pry", "~> 0.14.2"
|
56
|
-
spec.add_dependency "rack", "
|
56
|
+
spec.add_dependency "rack", ">= 3.0.4.1", "< 3.0.9.0"
|
57
57
|
spec.add_dependency "rake", "~> 13.0.6"
|
58
|
+
spec.add_dependency "rbnacl", "~> 7.1.1"
|
58
59
|
spec.add_dependency "sorbet-runtime", "~> 0.5.10634"
|
59
60
|
spec.add_dependency "terminal-table", "~> 3.0.2"
|
60
61
|
spec.add_dependency "toml-rb", "~> 2.2.0"
|
@@ -67,9 +68,8 @@ Gem::Specification.new do |spec|
|
|
67
68
|
spec.add_dependency "image_size", "~> 3.2.0"
|
68
69
|
spec.add_dependency "kramdown", "~> 2.4.0"
|
69
70
|
spec.add_dependency "rouge", "~> 4.0.0"
|
70
|
-
spec.add_dependency "mayu-css", "~> 0.0.
|
71
|
+
spec.add_dependency "mayu-css", "~> 0.0.3"
|
71
72
|
spec.add_dependency "source_map", "~> 3.0.1"
|
72
|
-
spec.add_dependency "svg_optimizer", "~> 0.2.6"
|
73
73
|
spec.add_dependency "syntax_tree", "~> 5.3.0"
|
74
74
|
spec.add_dependency "syntax_tree-haml", "~> 3.0.0"
|
75
75
|
spec.add_dependency "syntax_tree-xml", "~> 0.1.0"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mayu-live
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andreas Alin
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: async
|
@@ -154,16 +154,22 @@ dependencies:
|
|
154
154
|
name: rack
|
155
155
|
requirement: !ruby/object:Gem::Requirement
|
156
156
|
requirements:
|
157
|
-
- - "
|
157
|
+
- - ">="
|
158
158
|
- !ruby/object:Gem::Version
|
159
159
|
version: 3.0.4.1
|
160
|
+
- - "<"
|
161
|
+
- !ruby/object:Gem::Version
|
162
|
+
version: 3.0.9.0
|
160
163
|
type: :runtime
|
161
164
|
prerelease: false
|
162
165
|
version_requirements: !ruby/object:Gem::Requirement
|
163
166
|
requirements:
|
164
|
-
- - "
|
167
|
+
- - ">="
|
165
168
|
- !ruby/object:Gem::Version
|
166
169
|
version: 3.0.4.1
|
170
|
+
- - "<"
|
171
|
+
- !ruby/object:Gem::Version
|
172
|
+
version: 3.0.9.0
|
167
173
|
- !ruby/object:Gem::Dependency
|
168
174
|
name: rake
|
169
175
|
requirement: !ruby/object:Gem::Requirement
|
@@ -178,6 +184,20 @@ dependencies:
|
|
178
184
|
- - "~>"
|
179
185
|
- !ruby/object:Gem::Version
|
180
186
|
version: 13.0.6
|
187
|
+
- !ruby/object:Gem::Dependency
|
188
|
+
name: rbnacl
|
189
|
+
requirement: !ruby/object:Gem::Requirement
|
190
|
+
requirements:
|
191
|
+
- - "~>"
|
192
|
+
- !ruby/object:Gem::Version
|
193
|
+
version: 7.1.1
|
194
|
+
type: :runtime
|
195
|
+
prerelease: false
|
196
|
+
version_requirements: !ruby/object:Gem::Requirement
|
197
|
+
requirements:
|
198
|
+
- - "~>"
|
199
|
+
- !ruby/object:Gem::Version
|
200
|
+
version: 7.1.1
|
181
201
|
- !ruby/object:Gem::Dependency
|
182
202
|
name: sorbet-runtime
|
183
203
|
requirement: !ruby/object:Gem::Requirement
|
@@ -296,14 +316,14 @@ dependencies:
|
|
296
316
|
requirements:
|
297
317
|
- - "~>"
|
298
318
|
- !ruby/object:Gem::Version
|
299
|
-
version: 0.0.
|
319
|
+
version: 0.0.3
|
300
320
|
type: :runtime
|
301
321
|
prerelease: false
|
302
322
|
version_requirements: !ruby/object:Gem::Requirement
|
303
323
|
requirements:
|
304
324
|
- - "~>"
|
305
325
|
- !ruby/object:Gem::Version
|
306
|
-
version: 0.0.
|
326
|
+
version: 0.0.3
|
307
327
|
- !ruby/object:Gem::Dependency
|
308
328
|
name: source_map
|
309
329
|
requirement: !ruby/object:Gem::Requirement
|
@@ -318,20 +338,6 @@ dependencies:
|
|
318
338
|
- - "~>"
|
319
339
|
- !ruby/object:Gem::Version
|
320
340
|
version: 3.0.1
|
321
|
-
- !ruby/object:Gem::Dependency
|
322
|
-
name: svg_optimizer
|
323
|
-
requirement: !ruby/object:Gem::Requirement
|
324
|
-
requirements:
|
325
|
-
- - "~>"
|
326
|
-
- !ruby/object:Gem::Version
|
327
|
-
version: 0.2.6
|
328
|
-
type: :runtime
|
329
|
-
prerelease: false
|
330
|
-
version_requirements: !ruby/object:Gem::Requirement
|
331
|
-
requirements:
|
332
|
-
- - "~>"
|
333
|
-
- !ruby/object:Gem::Version
|
334
|
-
version: 0.2.6
|
335
341
|
- !ruby/object:Gem::Dependency
|
336
342
|
name: syntax_tree
|
337
343
|
requirement: !ruby/object:Gem::Requirement
|
@@ -375,7 +381,7 @@ dependencies:
|
|
375
381
|
- !ruby/object:Gem::Version
|
376
382
|
version: 0.1.0
|
377
383
|
description: |
|
378
|
-
Mayu Live is a live
|
384
|
+
Mayu Live is a live updating server side VirtualDOM framework for Ruby,
|
379
385
|
inspired by modern frontend tools that exist in the JavaScript ecosystem.
|
380
386
|
email:
|
381
387
|
- andreas.alin@gmail.com
|
@@ -450,12 +456,12 @@ files:
|
|
450
456
|
- lib/mayu/component/wrapper.rb
|
451
457
|
- lib/mayu/configuration.rb
|
452
458
|
- lib/mayu/disable_sorbet.rb
|
459
|
+
- lib/mayu/encrypted_marshal.rb
|
453
460
|
- lib/mayu/environment.rb
|
454
461
|
- lib/mayu/event_stream.rb
|
455
462
|
- lib/mayu/fetch.rb
|
456
463
|
- lib/mayu/html.rb
|
457
464
|
- lib/mayu/html.yaml
|
458
|
-
- lib/mayu/message_cipher.rb
|
459
465
|
- lib/mayu/metrics.rb
|
460
466
|
- lib/mayu/metrics/collector.rb
|
461
467
|
- lib/mayu/metrics/exporter.rb
|
data/lib/mayu/message_cipher.rb
DELETED
@@ -1,172 +0,0 @@
|
|
1
|
-
# typed: strict
|
2
|
-
# frozen_string_literal: true
|
3
|
-
|
4
|
-
require "sorbet-runtime"
|
5
|
-
require "time"
|
6
|
-
require "digest/sha2"
|
7
|
-
require "openssl"
|
8
|
-
require "securerandom"
|
9
|
-
require "brotli"
|
10
|
-
|
11
|
-
module Mayu
|
12
|
-
class MessageCipher
|
13
|
-
extend T::Sig
|
14
|
-
|
15
|
-
DEFAULT_TTL_SECONDS = T.let(10, Integer)
|
16
|
-
|
17
|
-
Message = T.type_alias { { iss: Float, exp: Float, payload: T.untyped } }
|
18
|
-
|
19
|
-
class Error < StandardError
|
20
|
-
end
|
21
|
-
class ExpiredError < Error
|
22
|
-
end
|
23
|
-
class IssuedInTheFutureError < Error
|
24
|
-
end
|
25
|
-
class EncryptError < Error
|
26
|
-
end
|
27
|
-
class DecryptError < Error
|
28
|
-
end
|
29
|
-
class InvalidHMACError < Error
|
30
|
-
end
|
31
|
-
|
32
|
-
sig { params(key: String, ttl: Integer).void }
|
33
|
-
def initialize(key:, ttl: DEFAULT_TTL_SECONDS)
|
34
|
-
raise ArgumentError, "ttl must be positive" unless ttl.positive?
|
35
|
-
@default_ttl_seconds = ttl
|
36
|
-
@key = T.let(Digest::SHA256.digest(key), String)
|
37
|
-
end
|
38
|
-
|
39
|
-
sig do
|
40
|
-
params(payload: T.untyped, auth_data: String, ttl: Integer).returns(
|
41
|
-
String
|
42
|
-
)
|
43
|
-
end
|
44
|
-
def dump(payload, auth_data: "", ttl: @default_ttl_seconds)
|
45
|
-
raise ArgumentError, "ttl must be positive" unless ttl.positive?
|
46
|
-
now = Time.now.to_f
|
47
|
-
message = { iss: now, exp: now + ttl, payload: Marshal.dump(payload) }
|
48
|
-
encode_message(message, auth_data:)
|
49
|
-
end
|
50
|
-
|
51
|
-
sig { params(data: String, auth_data: String).returns(T.untyped) }
|
52
|
-
def load(data, auth_data: "")
|
53
|
-
Marshal.load(decode_message(data, auth_data:))
|
54
|
-
end
|
55
|
-
|
56
|
-
private
|
57
|
-
|
58
|
-
sig { params(message: Message, auth_data: String).returns(String) }
|
59
|
-
def encode_message(message, auth_data: "")
|
60
|
-
message
|
61
|
-
.then { Marshal.dump(_1) }
|
62
|
-
.then { prepend_hmac(_1) }
|
63
|
-
.then { Brotli.deflate(_1) }
|
64
|
-
.then { encrypt(_1, auth_data:) }
|
65
|
-
end
|
66
|
-
|
67
|
-
sig { params(message: String, auth_data: String).returns(String) }
|
68
|
-
def decode_message(message, auth_data: "")
|
69
|
-
message
|
70
|
-
.then { decrypt(_1, auth_data:) }
|
71
|
-
.then { Brotli.inflate(_1) }
|
72
|
-
.then { validate_hmac(_1) }
|
73
|
-
.then { Marshal.load(_1) }
|
74
|
-
.tap { validate_times(_1) }
|
75
|
-
.fetch(:payload)
|
76
|
-
end
|
77
|
-
|
78
|
-
sig { params(input: String).returns(String) }
|
79
|
-
def prepend_hmac(input)
|
80
|
-
hmac = Digest::SHA256.digest(input)
|
81
|
-
input.prepend(hmac)
|
82
|
-
end
|
83
|
-
|
84
|
-
sig { params(input: String).returns(String) }
|
85
|
-
def validate_hmac(input)
|
86
|
-
hmac, message = input.unpack("a32 a*")
|
87
|
-
|
88
|
-
unless OpenSSL.fixed_length_secure_compare(
|
89
|
-
hmac,
|
90
|
-
Digest::SHA256.digest(message.to_s)
|
91
|
-
)
|
92
|
-
raise InvalidHMACError
|
93
|
-
end
|
94
|
-
|
95
|
-
message.to_s
|
96
|
-
end
|
97
|
-
|
98
|
-
sig { params(message: { iss: Float, exp: Float, payload: String }).void }
|
99
|
-
def validate_times(message)
|
100
|
-
message => { iss:, exp: }
|
101
|
-
now = Time.now.to_f
|
102
|
-
validate_iss(now, iss)
|
103
|
-
validate_exp(now, exp)
|
104
|
-
end
|
105
|
-
|
106
|
-
sig { params(now: Float, iss: Float).void }
|
107
|
-
def validate_iss(now, iss)
|
108
|
-
return if iss < now
|
109
|
-
|
110
|
-
raise IssuedInTheFutureError,
|
111
|
-
"The message was issued at #{Time.at(iss).iso8601}, which is in the future"
|
112
|
-
end
|
113
|
-
|
114
|
-
sig { params(now: Float, exp: Float).void }
|
115
|
-
def validate_exp(now, exp)
|
116
|
-
return if exp > now
|
117
|
-
|
118
|
-
raise ExpiredError,
|
119
|
-
"The message expired at #{Time.at(exp).iso8601}, which is in the past"
|
120
|
-
end
|
121
|
-
|
122
|
-
sig { params(message: String, auth_data: String).returns(String) }
|
123
|
-
def encrypt(message, auth_data: "")
|
124
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
125
|
-
cipher.encrypt
|
126
|
-
salt = SecureRandom.random_bytes(8)
|
127
|
-
cipher.key = generate_key(salt)
|
128
|
-
cipher.iv = iv = cipher.random_iv
|
129
|
-
cipher.auth_data = auth_data
|
130
|
-
cipher_text = cipher.update(message) + cipher.final
|
131
|
-
auth_tag = cipher.auth_tag
|
132
|
-
[auth_tag.bytesize, auth_tag, salt, iv, cipher_text].pack("C a* a* a* a*")
|
133
|
-
rescue OpenSSL::Cipher::CipherError
|
134
|
-
raise EncryptError
|
135
|
-
end
|
136
|
-
|
137
|
-
sig { params(data: String, auth_data: String).returns(String) }
|
138
|
-
def decrypt(data, auth_data: "")
|
139
|
-
data.unpack("C a*") => [Integer => auth_tag_len, String => data]
|
140
|
-
|
141
|
-
data.unpack("a#{auth_tag_len} a8 a12 a*") => [
|
142
|
-
auth_tag,
|
143
|
-
salt,
|
144
|
-
iv,
|
145
|
-
cipher_text
|
146
|
-
]
|
147
|
-
|
148
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
149
|
-
cipher.iv = iv
|
150
|
-
cipher.key = generate_key(salt)
|
151
|
-
cipher.auth_data = auth_data
|
152
|
-
cipher.auth_tag = auth_tag
|
153
|
-
cipher.update(cipher_text) + cipher.final
|
154
|
-
rescue NoMatchingPatternError
|
155
|
-
raise DecryptError
|
156
|
-
rescue OpenSSL::Cipher::CipherError
|
157
|
-
raise DecryptError
|
158
|
-
end
|
159
|
-
|
160
|
-
sig { params(salt: String).returns(String) }
|
161
|
-
def generate_key(salt)
|
162
|
-
OpenSSL::KDF.scrypt(
|
163
|
-
@key,
|
164
|
-
salt:, # Salt.
|
165
|
-
N: 2**14, # CPU/memory cost parameter. This must be a power of 2.
|
166
|
-
r: 8, # Block size parameter.
|
167
|
-
p: 1, # Parallelization parameter
|
168
|
-
length: 32 # Length in octets of the derived key
|
169
|
-
)
|
170
|
-
end
|
171
|
-
end
|
172
|
-
end
|