mayu-live 0.0.3 → 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
# ![Mayu Live](https://raw.githubusercontent.com/mayu-live/logo/main/logo-with-text.svg)
|
2
2
|
|
3
3
|
[![Tests](https://img.shields.io/github/actions/workflow/status/mayu-live/framework/.github/workflows/test.yml?branch=main&label=Tests&style=flat-square)](https://github.com/mayu-live/framework/actions/workflows/test.yml)
|
4
4
|
[![Release](https://img.shields.io/github/v/release/mayu-live/framework?sort=semver&style=flat-square)](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
|