tttls1.3 0.2.18 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +8 -5
- data/Gemfile +2 -0
- data/README.md +6 -3
- data/example/helper.rb +5 -2
- data/example/https_client_using_0rtt.rb +1 -1
- data/example/https_client_using_ech.rb +32 -0
- data/example/https_client_using_grease_ech.rb +26 -0
- data/example/https_client_using_grease_psk.rb +66 -0
- data/example/https_client_using_hrr_and_ech.rb +32 -0
- data/example/https_client_using_hrr_and_ticket.rb +1 -1
- data/example/https_client_using_ticket.rb +1 -1
- data/interop/client_spec.rb +3 -2
- data/interop/server_spec.rb +1 -3
- data/interop/{helper.rb → spec_helper.rb} +12 -5
- data/lib/tttls1.3/client.rb +553 -32
- data/lib/tttls1.3/connection.rb +9 -8
- data/lib/tttls1.3/cryptograph/aead.rb +1 -1
- data/lib/tttls1.3/error.rb +1 -1
- data/lib/tttls1.3/hpke.rb +91 -0
- data/lib/tttls1.3/key_schedule.rb +111 -8
- data/lib/tttls1.3/message/alert.rb +2 -1
- data/lib/tttls1.3/message/client_hello.rb +2 -1
- data/lib/tttls1.3/message/encrypted_extensions.rb +2 -1
- data/lib/tttls1.3/message/extension/alpn.rb +4 -5
- data/lib/tttls1.3/message/extension/compress_certificate.rb +1 -1
- data/lib/tttls1.3/message/extension/ech.rb +241 -0
- data/lib/tttls1.3/message/extension/key_share.rb +2 -4
- data/lib/tttls1.3/message/extension/server_name.rb +1 -1
- data/lib/tttls1.3/message/extensions.rb +20 -7
- data/lib/tttls1.3/message/record.rb +1 -1
- data/lib/tttls1.3/message/server_hello.rb +3 -5
- data/lib/tttls1.3/message.rb +3 -1
- data/lib/tttls1.3/named_group.rb +1 -1
- data/lib/tttls1.3/server.rb +2 -2
- data/lib/tttls1.3/utils.rb +8 -0
- data/lib/tttls1.3/version.rb +1 -1
- data/lib/tttls1.3.rb +4 -0
- data/spec/client_spec.rb +40 -0
- data/spec/connection_spec.rb +22 -7
- data/spec/ech_spec.rb +81 -0
- data/spec/extensions_spec.rb +1 -2
- data/spec/key_schedule_spec.rb +2 -2
- data/spec/server_spec.rb +22 -7
- data/spec/spec_helper.rb +41 -5
- data/tttls1.3.gemspec +2 -0
- metadata +39 -3
data/lib/tttls1.3/client.rb
CHANGED
@@ -58,7 +58,9 @@ module TTTLS13
|
|
58
58
|
alpn: nil,
|
59
59
|
process_new_session_ticket: nil,
|
60
60
|
ticket: nil,
|
61
|
+
# @deprecated Please use `resumption_secret` instead
|
61
62
|
resumption_master_secret: nil,
|
63
|
+
resumption_secret: nil,
|
62
64
|
psk_cipher_suite: nil,
|
63
65
|
ticket_nonce: nil,
|
64
66
|
ticket_age_add: nil,
|
@@ -67,14 +69,29 @@ module TTTLS13
|
|
67
69
|
check_certificate_status: false,
|
68
70
|
process_certificate_status: nil,
|
69
71
|
compress_certificate_algorithms: DEFALUT_CH_COMPRESS_CERTIFICATE_ALGORITHMS,
|
72
|
+
ech_config: nil,
|
73
|
+
ech_hpke_cipher_suites: nil,
|
70
74
|
compatibility_mode: true,
|
71
75
|
sslkeylogfile: nil,
|
72
76
|
loglevel: Logger::WARN
|
73
77
|
}.freeze
|
74
78
|
private_constant :DEFAULT_CLIENT_SETTINGS
|
75
79
|
|
80
|
+
STANDARD_CLIENT_ECH_HPKE_SYMMETRIC_CIPHER_SUITES = [
|
81
|
+
HpkeSymmetricCipherSuite.new(
|
82
|
+
HpkeSymmetricCipherSuite::HpkeKdfId.new(
|
83
|
+
Hpke::KdfId::HKDF_SHA256
|
84
|
+
),
|
85
|
+
HpkeSymmetricCipherSuite::HpkeAeadId.new(
|
86
|
+
Hpke::AeadId::AES_128_GCM
|
87
|
+
)
|
88
|
+
)
|
89
|
+
].freeze
|
76
90
|
# rubocop: disable Metrics/ClassLength
|
77
91
|
class Client < Connection
|
92
|
+
HpkeSymmetricCipherSuit = \
|
93
|
+
ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite
|
94
|
+
|
78
95
|
# @param socket [Socket]
|
79
96
|
# @param hostname [String]
|
80
97
|
# @param settings [Hash]
|
@@ -84,10 +101,21 @@ module TTTLS13
|
|
84
101
|
@endpoint = :client
|
85
102
|
@hostname = hostname
|
86
103
|
@settings = DEFAULT_CLIENT_SETTINGS.merge(settings)
|
104
|
+
# NOTE: backward compatibility
|
105
|
+
if @settings[:resumption_secret].nil? &&
|
106
|
+
!@settings[:resumption_master_secret].nil?
|
107
|
+
@settings[:resumption_secret] =
|
108
|
+
@settings.delete(:resumption_master_secret) \
|
109
|
+
end
|
110
|
+
raise Error::ConfigError if @settings[:resumption_secret] !=
|
111
|
+
@settings[:resumption_master_secret]
|
112
|
+
|
87
113
|
logger.level = @settings[:loglevel]
|
88
114
|
|
89
115
|
@early_data = ''
|
90
116
|
@succeed_early_data = false
|
117
|
+
@retry_configs = []
|
118
|
+
@rejected_ech = false
|
91
119
|
raise Error::ConfigError unless valid_settings?
|
92
120
|
end
|
93
121
|
|
@@ -123,7 +151,7 @@ module TTTLS13
|
|
123
151
|
# after here v
|
124
152
|
# CONNECTED
|
125
153
|
#
|
126
|
-
# https://
|
154
|
+
# https://datatracker.ietf.org/doc/html/rfc8446#appendix-A.1
|
127
155
|
#
|
128
156
|
# rubocop: disable Metrics/AbcSize
|
129
157
|
# rubocop: disable Metrics/BlockLength
|
@@ -137,7 +165,7 @@ module TTTLS13
|
|
137
165
|
priv_keys = {} # Hash of NamedGroup => OpenSSL::PKey::$Object
|
138
166
|
if use_psk?
|
139
167
|
psk = gen_psk_from_nst(
|
140
|
-
@settings[:
|
168
|
+
@settings[:resumption_secret],
|
141
169
|
@settings[:ticket_nonce],
|
142
170
|
CipherSuite.digest(@settings[:psk_cipher_suite])
|
143
171
|
)
|
@@ -153,6 +181,9 @@ module TTTLS13
|
|
153
181
|
hs_rcipher = nil # TTTLS13::Cryptograph::$Object
|
154
182
|
e_wcipher = nil # TTTLS13::Cryptograph::$Object
|
155
183
|
sslkeylogfile = nil # TTTLS13::SslKeyLogFile::Writer
|
184
|
+
ch1_outer = nil # TTTLS13::Message::ClientHello for rejected ECH
|
185
|
+
ch_outer = nil # TTTLS13::Message::ClientHello for rejected ECH
|
186
|
+
ech_state = nil # TTTLS13::Client::EchState for ECH with HRR
|
156
187
|
unless @settings[:sslkeylogfile].nil?
|
157
188
|
begin
|
158
189
|
sslkeylogfile = SslKeyLogFile::Writer.new(@settings[:sslkeylogfile])
|
@@ -170,7 +201,10 @@ module TTTLS13
|
|
170
201
|
|
171
202
|
extensions, priv_keys = gen_ch_extensions
|
172
203
|
binder_key = (use_psk? ? key_schedule.binder_key_res : nil)
|
173
|
-
ch = send_client_hello(extensions, binder_key)
|
204
|
+
ch, inner, ech_state = send_client_hello(extensions, binder_key)
|
205
|
+
ch_outer = ch
|
206
|
+
# use ClientHelloInner messages for the transcript hash
|
207
|
+
ch = inner.nil? ? ch : inner
|
174
208
|
transcript[CH] = [ch, ch.serialize]
|
175
209
|
send_ccs if @settings[:compatibility_mode]
|
176
210
|
if use_early_data?
|
@@ -235,6 +269,8 @@ module TTTLS13
|
|
235
269
|
|
236
270
|
ch1, = transcript[CH1] = transcript.delete(CH)
|
237
271
|
hrr, = transcript[HRR] = transcript.delete(SH)
|
272
|
+
ch1_outer = ch_outer
|
273
|
+
ch_outer = nil
|
238
274
|
|
239
275
|
# validate cookie
|
240
276
|
diff_sets = sh.extensions.keys - ch1.extensions.keys
|
@@ -242,7 +278,7 @@ module TTTLS13
|
|
242
278
|
unless (diff_sets - [Message::ExtensionType::COOKIE]).empty?
|
243
279
|
|
244
280
|
# validate key_share
|
245
|
-
# TODO: pre_shared_key
|
281
|
+
# TODO: validate pre_shared_key
|
246
282
|
ngl = ch1.extensions[Message::ExtensionType::SUPPORTED_GROUPS]
|
247
283
|
.named_group_list
|
248
284
|
kse = ch1.extensions[Message::ExtensionType::KEY_SHARE]
|
@@ -256,7 +292,16 @@ module TTTLS13
|
|
256
292
|
extensions, pk = gen_newch_extensions(ch1, hrr)
|
257
293
|
priv_keys = pk.merge(priv_keys)
|
258
294
|
binder_key = (use_psk? ? key_schedule.binder_key_res : nil)
|
259
|
-
ch = send_new_client_hello(
|
295
|
+
ch, inner = send_new_client_hello(
|
296
|
+
ch1,
|
297
|
+
hrr,
|
298
|
+
extensions,
|
299
|
+
binder_key,
|
300
|
+
ech_state
|
301
|
+
)
|
302
|
+
# use ClientHelloInner messages for the transcript hash
|
303
|
+
ch_outer = ch
|
304
|
+
ch = inner.nil? ? ch : inner
|
260
305
|
transcript[CH] = [ch, ch.serialize]
|
261
306
|
|
262
307
|
@state = ClientState::WAIT_SH
|
@@ -264,8 +309,11 @@ module TTTLS13
|
|
264
309
|
end
|
265
310
|
|
266
311
|
# generate shared secret
|
267
|
-
|
268
|
-
|
312
|
+
if sh.extensions.include?(Message::ExtensionType::PRE_SHARED_KEY)
|
313
|
+
# TODO: validate pre_shared_key
|
314
|
+
else
|
315
|
+
psk = nil
|
316
|
+
end
|
269
317
|
ch_ks = ch.extensions[Message::ExtensionType::KEY_SHARE]
|
270
318
|
.key_share_entry.map(&:group)
|
271
319
|
sh_ks = sh.extensions[Message::ExtensionType::KEY_SHARE]
|
@@ -285,6 +333,27 @@ module TTTLS13
|
|
285
333
|
cipher_suite: @cipher_suite,
|
286
334
|
transcript: transcript
|
287
335
|
)
|
336
|
+
|
337
|
+
# rejected ECH
|
338
|
+
# NOTE: It can compute (hrr_)accept_ech until client selects the
|
339
|
+
# cipher_suite.
|
340
|
+
if !sh.hrr? && use_ech?
|
341
|
+
if !transcript.include?(HRR) && !key_schedule.accept_ech?
|
342
|
+
# 1sh SH
|
343
|
+
transcript[CH] = [ch_outer, ch_outer.serialize]
|
344
|
+
@rejected_ech = true
|
345
|
+
elsif transcript.include?(HRR) &&
|
346
|
+
key_schedule.hrr_accept_ech? != key_schedule.accept_ech?
|
347
|
+
# 2nd SH
|
348
|
+
terminate(:illegal_parameter)
|
349
|
+
elsif transcript.include?(HRR) && !key_schedule.hrr_accept_ech?
|
350
|
+
# 2nd SH
|
351
|
+
transcript[CH1] = [ch1_outer, ch1_outer.serialize]
|
352
|
+
transcript[CH] = [ch_outer, ch_outer.serialize]
|
353
|
+
@rejected_ech = true
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
288
357
|
@alert_wcipher = hs_wcipher = gen_cipher(
|
289
358
|
@cipher_suite,
|
290
359
|
key_schedule.client_handshake_write_key,
|
@@ -321,6 +390,12 @@ module TTTLS13
|
|
321
390
|
@alpn = ee.extensions[
|
322
391
|
Message::ExtensionType::APPLICATION_LAYER_PROTOCOL_NEGOTIATION
|
323
392
|
]&.protocol_name_list&.first
|
393
|
+
@retry_configs = ee.extensions[
|
394
|
+
Message::ExtensionType::ENCRYPTED_CLIENT_HELLO
|
395
|
+
]&.retry_configs
|
396
|
+
terminate(:unsupported_extension) \
|
397
|
+
if !rejected_ech? && !@retry_configs.nil?
|
398
|
+
|
324
399
|
@state = ClientState::WAIT_CERT_CR
|
325
400
|
@state = ClientState::WAIT_FINISHED unless psk.nil?
|
326
401
|
when ClientState::WAIT_CERT_CR
|
@@ -423,12 +498,14 @@ module TTTLS13
|
|
423
498
|
transcript[CH].first.random,
|
424
499
|
key_schedule.server_application_traffic_secret
|
425
500
|
)
|
426
|
-
@
|
427
|
-
@
|
501
|
+
@exporter_secret = key_schedule.exporter_secret
|
502
|
+
@resumption_secret = key_schedule.resumption_secret
|
428
503
|
@state = ClientState::CONNECTED
|
429
504
|
when ClientState::CONNECTED
|
430
505
|
logger.debug('ClientState::CONNECTED')
|
431
506
|
|
507
|
+
send_alert(:ech_required) \
|
508
|
+
if use_ech? && (!@retry_configs.nil? && !@retry_configs.empty?)
|
432
509
|
break
|
433
510
|
end
|
434
511
|
end
|
@@ -440,6 +517,20 @@ module TTTLS13
|
|
440
517
|
# rubocop: enable Metrics/MethodLength
|
441
518
|
# rubocop: enable Metrics/PerceivedComplexity
|
442
519
|
|
520
|
+
# @param binary [String]
|
521
|
+
def write(binary)
|
522
|
+
# the client can regard ECH as securely disabled by the server, and it
|
523
|
+
# SHOULD retry the handshake with a new transport connection and ECH
|
524
|
+
# disabled.
|
525
|
+
if !@retry_configs.nil? && !@retry_configs.empty?
|
526
|
+
msg = 'SHOULD retry the handshake with a new transport connection'
|
527
|
+
logger.warn(msg)
|
528
|
+
return
|
529
|
+
end
|
530
|
+
|
531
|
+
super(binary)
|
532
|
+
end
|
533
|
+
|
443
534
|
# @param binary [String]
|
444
535
|
#
|
445
536
|
# @raise [TTTLS13::Error::ConfigError]
|
@@ -449,11 +540,23 @@ module TTTLS13
|
|
449
540
|
@early_data = binary
|
450
541
|
end
|
451
542
|
|
543
|
+
# @return [Array of ECHConfig]
|
544
|
+
def retry_configs
|
545
|
+
@retry_configs.filter do |c|
|
546
|
+
SUPPORTED_ECHCONFIG_VERSIONS.include?(c.version)
|
547
|
+
end
|
548
|
+
end
|
549
|
+
|
452
550
|
# @return [Boolean]
|
453
551
|
def succeed_early_data?
|
454
552
|
@succeed_early_data
|
455
553
|
end
|
456
554
|
|
555
|
+
# @return [Boolean]
|
556
|
+
def rejected_ech?
|
557
|
+
@rejected_ech
|
558
|
+
end
|
559
|
+
|
457
560
|
# @param res [OpenSSL::OCSP::Response]
|
458
561
|
# @param cert [OpenSSL::X509::Certificate]
|
459
562
|
# @param chain [Array of OpenSSL::X509::Certificate, nil]
|
@@ -535,6 +638,9 @@ module TTTLS13
|
|
535
638
|
return false if @settings[:check_certificate_status] &&
|
536
639
|
@settings[:process_certificate_status].nil?
|
537
640
|
|
641
|
+
ehcs = @settings[:ech_hpke_cipher_suites] || []
|
642
|
+
return false if !@settings[:ech_config].nil? && ehcs.empty?
|
643
|
+
|
538
644
|
true
|
539
645
|
end
|
540
646
|
# rubocop: enable Metrics/AbcSize
|
@@ -544,7 +650,7 @@ module TTTLS13
|
|
544
650
|
# @return [Boolean]
|
545
651
|
def use_psk?
|
546
652
|
!@settings[:ticket].nil? &&
|
547
|
-
!@settings[:
|
653
|
+
!@settings[:resumption_secret].nil? &&
|
548
654
|
!@settings[:psk_cipher_suite].nil? &&
|
549
655
|
!@settings[:ticket_nonce].nil? &&
|
550
656
|
!@settings[:ticket_age_add].nil? &&
|
@@ -556,6 +662,12 @@ module TTTLS13
|
|
556
662
|
!(@early_data.nil? || @early_data.empty?)
|
557
663
|
end
|
558
664
|
|
665
|
+
# @return [Boolean]
|
666
|
+
def use_ech?
|
667
|
+
!@settings[:ech_hpke_cipher_suites].nil? &&
|
668
|
+
!@settings[:ech_hpke_cipher_suites].empty?
|
669
|
+
end
|
670
|
+
|
559
671
|
# @param cipher [TTTLS13::Cryptograph::Aead]
|
560
672
|
def send_early_data(cipher)
|
561
673
|
ap = Message::ApplicationData.new(@early_data)
|
@@ -568,14 +680,14 @@ module TTTLS13
|
|
568
680
|
send_record(ap_record)
|
569
681
|
end
|
570
682
|
|
571
|
-
# @param
|
683
|
+
# @param resumption_secret [String]
|
572
684
|
# @param ticket_nonce [String]
|
573
685
|
# @param digest [String] name of digest algorithm
|
574
686
|
#
|
575
687
|
# @return [String]
|
576
|
-
def gen_psk_from_nst(
|
688
|
+
def gen_psk_from_nst(resumption_secret, ticket_nonce, digest)
|
577
689
|
hash_len = OpenSSL::Digest.new(digest).digest_length
|
578
|
-
KeySchedule.hkdf_expand_label(
|
690
|
+
KeySchedule.hkdf_expand_label(resumption_secret, 'resumption',
|
579
691
|
ticket_nonce, hash_len, digest)
|
580
692
|
end
|
581
693
|
|
@@ -654,36 +766,60 @@ module TTTLS13
|
|
654
766
|
# @param extensions [TTTLS13::Message::Extensions]
|
655
767
|
# @param binder_key [String, nil]
|
656
768
|
#
|
657
|
-
# @return [TTTLS13::Message::ClientHello]
|
769
|
+
# @return [TTTLS13::Message::ClientHello] outer
|
770
|
+
# @return [TTTLS13::Message::ClientHello] inner
|
771
|
+
# @return [TTTLS13::Client::EchState]
|
772
|
+
# rubocop: disable Metrics/MethodLength
|
658
773
|
def send_client_hello(extensions, binder_key = nil)
|
659
774
|
ch = Message::ClientHello.new(
|
660
775
|
cipher_suites: CipherSuites.new(@settings[:cipher_suites]),
|
661
776
|
extensions: extensions
|
662
777
|
)
|
663
778
|
|
779
|
+
# encrypted_client_hello
|
780
|
+
inner = nil # TTTLS13::Message::ClientHello
|
781
|
+
if use_ech?
|
782
|
+
inner = ch
|
783
|
+
inner_ech = Message::Extension::ECHClientHello.new_inner
|
784
|
+
inner.extensions[Message::ExtensionType::ENCRYPTED_CLIENT_HELLO] \
|
785
|
+
= inner_ech
|
786
|
+
ch, inner, ech_state = offer_ech(inner, @settings[:ech_config])
|
787
|
+
end
|
788
|
+
|
789
|
+
# psk_key_exchange_modes
|
790
|
+
# In order to use PSKs, clients MUST also send a
|
791
|
+
# "psk_key_exchange_modes" extension.
|
792
|
+
#
|
793
|
+
# https://datatracker.ietf.org/doc/html/rfc8446#section-4.2.9
|
664
794
|
if use_psk?
|
665
|
-
# pre_shared_key && psk_key_exchange_modes
|
666
|
-
#
|
667
|
-
# In order to use PSKs, clients MUST also send a
|
668
|
-
# "psk_key_exchange_modes" extension.
|
669
|
-
#
|
670
|
-
# https://tools.ietf.org/html/rfc8446#section-4.2.9
|
671
795
|
pkem = Message::Extension::PskKeyExchangeModes.new(
|
672
796
|
[Message::Extension::PskKeyExchangeMode::PSK_DHE_KE]
|
673
797
|
)
|
674
798
|
ch.extensions[Message::ExtensionType::PSK_KEY_EXCHANGE_MODES] = pkem
|
675
|
-
|
799
|
+
end
|
800
|
+
|
801
|
+
# pre_shared_key
|
802
|
+
# at the end, sign PSK binder
|
803
|
+
if use_psk?
|
676
804
|
sign_psk_binder(
|
677
805
|
ch: ch,
|
678
806
|
binder_key: binder_key
|
679
807
|
)
|
808
|
+
|
809
|
+
if use_ech?
|
810
|
+
sign_grease_psk_binder(
|
811
|
+
ch_outer: ch,
|
812
|
+
inner_pks: inner.extensions[Message::ExtensionType::PRE_SHARED_KEY]
|
813
|
+
)
|
814
|
+
end
|
680
815
|
end
|
681
816
|
|
682
817
|
send_handshakes(Message::ContentType::HANDSHAKE, [ch],
|
683
818
|
Cryptograph::Passer.new)
|
684
819
|
|
685
|
-
ch
|
820
|
+
[ch, inner, ech_state]
|
686
821
|
end
|
822
|
+
# rubocop: enable Metrics/MethodLength
|
687
823
|
|
688
824
|
# @param ch1 [TTTLS13::Message::ClientHello]
|
689
825
|
# @param hrr [TTTLS13::Message::ServerHello]
|
@@ -698,10 +834,10 @@ module TTTLS13
|
|
698
834
|
# partial ClientHello up to and including the
|
699
835
|
# PreSharedKeyExtension.identities field.
|
700
836
|
#
|
701
|
-
# https://
|
837
|
+
# https://datatracker.ietf.org/doc/html/rfc8446#section-4.2.11.2
|
702
838
|
digest = CipherSuite.digest(@settings[:psk_cipher_suite])
|
703
839
|
hash_len = OpenSSL::Digest.new(digest).digest_length
|
704
|
-
|
840
|
+
placeholder_binders = [hash_len.zeros]
|
705
841
|
psk = Message::Extension::PreSharedKey.new(
|
706
842
|
msg_type: Message::HandshakeType::CLIENT_HELLO,
|
707
843
|
offered_psks: Message::Extension::OfferedPsks.new(
|
@@ -709,7 +845,7 @@ module TTTLS13
|
|
709
845
|
identity: @settings[:ticket],
|
710
846
|
obfuscated_ticket_age: calc_obfuscated_ticket_age
|
711
847
|
)],
|
712
|
-
binders:
|
848
|
+
binders: placeholder_binders
|
713
849
|
)
|
714
850
|
)
|
715
851
|
ch.extensions[Message::ExtensionType::PRE_SHARED_KEY] = psk
|
@@ -723,6 +859,325 @@ module TTTLS13
|
|
723
859
|
)
|
724
860
|
end
|
725
861
|
|
862
|
+
# @param ch1 [TTTLS13::Message::ClientHello]
|
863
|
+
# @param hrr [TTTLS13::Message::ServerHello]
|
864
|
+
# @param ch_outer [TTTLS13::Message::ClientHello]
|
865
|
+
# @param inner_psk [Message::Extension::PreSharedKey]
|
866
|
+
# @param binder_key [String]
|
867
|
+
#
|
868
|
+
# @return [String]
|
869
|
+
def sign_grease_psk_binder(ch1: nil,
|
870
|
+
hrr: nil,
|
871
|
+
ch_outer:,
|
872
|
+
inner_psk:,
|
873
|
+
binder_key:)
|
874
|
+
digest = CipherSuite.digest(@settings[:psk_cipher_suite])
|
875
|
+
hash_len = OpenSSL::Digest.new(digest).digest_length
|
876
|
+
placeholder_binders = [hash_len.zeros]
|
877
|
+
# For each PSK identity advertised in the ClientHelloInner, the client
|
878
|
+
# generates a random PSK identity with the same length. It also generates
|
879
|
+
# a random, 32-bit, unsigned integer to use as the obfuscated_ticket_age.
|
880
|
+
# Likewise, for each inner PSK binder, the client generates a random
|
881
|
+
# string of the same length.
|
882
|
+
#
|
883
|
+
# https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.1.2-2
|
884
|
+
identity = inner_psk.offered_psks
|
885
|
+
.identities
|
886
|
+
.first
|
887
|
+
.identity
|
888
|
+
.length
|
889
|
+
.then { |len| OpenSSL::Random.random_bytes(len) }
|
890
|
+
ota = OpenSSL::Random.random_bytes(4)
|
891
|
+
psk = Message::Extension::PreSharedKey.new(
|
892
|
+
msg_type: Message::HandshakeType::CLIENT_HELLO,
|
893
|
+
offered_psks: Message::Extension::OfferedPsks.new(
|
894
|
+
identities: [Message::Extension::PskIdentity.new(
|
895
|
+
identity: identity,
|
896
|
+
obfuscated_ticket_age: ota
|
897
|
+
)],
|
898
|
+
binders: placeholder_binders
|
899
|
+
)
|
900
|
+
)
|
901
|
+
ch_outer.extensions[Message::ExtensionType::PRE_SHARED_KEY] = psk
|
902
|
+
|
903
|
+
psk.offered_psks.binders[0] = do_sign_psk_binder(
|
904
|
+
ch1: ch1,
|
905
|
+
hrr: hrr,
|
906
|
+
ch: ch_outer,
|
907
|
+
binder_key: binder_key,
|
908
|
+
digest: digest
|
909
|
+
)
|
910
|
+
end
|
911
|
+
|
912
|
+
# @param inner [TTTLS13::Message::ClientHello]
|
913
|
+
# @param ech_config [ECHConfig]
|
914
|
+
#
|
915
|
+
# @return [TTTLS13::Message::ClientHello]
|
916
|
+
# @return [TTTLS13::Message::ClientHello]
|
917
|
+
# @return [TTTLS13::Client::EchState]
|
918
|
+
# rubocop: disable Metrics/AbcSize
|
919
|
+
# rubocop: disable Metrics/MethodLength
|
920
|
+
def offer_ech(inner, ech_config)
|
921
|
+
return [new_greased_ch(inner, new_grease_ech), nil, nil] \
|
922
|
+
if ech_config.nil? ||
|
923
|
+
!SUPPORTED_ECHCONFIG_VERSIONS.include?(ech_config.version)
|
924
|
+
|
925
|
+
# Encrypted ClientHello Configuration
|
926
|
+
public_name = ech_config.echconfig_contents.public_name
|
927
|
+
key_config = ech_config.echconfig_contents.key_config
|
928
|
+
public_key = key_config.public_key.opaque
|
929
|
+
kem_id = key_config&.kem_id&.uint16
|
930
|
+
config_id = key_config.config_id
|
931
|
+
cipher_suite = select_ech_hpke_cipher_suite(key_config)
|
932
|
+
overhead_len = Hpke.aead_id2overhead_len(cipher_suite&.aead_id&.uint16)
|
933
|
+
aead_cipher = Hpke.aead_id2aead_cipher(cipher_suite&.aead_id&.uint16)
|
934
|
+
kdf_hash = Hpke.kdf_id2kdf_hash(cipher_suite&.kdf_id&.uint16)
|
935
|
+
return [new_greased_ch(inner, new_grease_ech), nil, nil] \
|
936
|
+
if [kem_id, overhead_len, aead_cipher, kdf_hash].any?(&:nil?)
|
937
|
+
|
938
|
+
kem_curve_name, kem_hash = Hpke.kem_id2dhkem(kem_id)
|
939
|
+
dhkem = Hpke.kem_curve_name2dhkem(kem_curve_name)
|
940
|
+
pkr = dhkem&.new(kem_hash)&.deserialize_public_key(public_key)
|
941
|
+
return [new_greased_ch(inner, new_grease_ech), nil, nil] if pkr.nil?
|
942
|
+
|
943
|
+
hpke = HPKE.new(kem_curve_name, kem_hash, kdf_hash, aead_cipher)
|
944
|
+
base_s = hpke.setup_base_s(pkr, "tls ech\x00" + ech_config.encode)
|
945
|
+
enc = base_s[:enc]
|
946
|
+
ctx = base_s[:context_s]
|
947
|
+
mnl = ech_config.echconfig_contents.maximum_name_length
|
948
|
+
encoded = encode_ch_inner(inner, mnl)
|
949
|
+
|
950
|
+
# Encoding the ClientHelloInner
|
951
|
+
aad = new_ch_outer_aad(
|
952
|
+
inner,
|
953
|
+
cipher_suite,
|
954
|
+
config_id,
|
955
|
+
enc,
|
956
|
+
encoded.length + overhead_len,
|
957
|
+
public_name
|
958
|
+
)
|
959
|
+
# Authenticating the ClientHelloOuter
|
960
|
+
# which does not include the Handshake structure's four byte header.
|
961
|
+
outer = new_ch_outer(
|
962
|
+
aad,
|
963
|
+
cipher_suite,
|
964
|
+
config_id,
|
965
|
+
enc,
|
966
|
+
ctx.seal(aad.serialize[4..], encoded)
|
967
|
+
)
|
968
|
+
|
969
|
+
ech_state = EchState.new(mnl, config_id, cipher_suite, public_name, ctx)
|
970
|
+
[outer, inner, ech_state]
|
971
|
+
end
|
972
|
+
# rubocop: enable Metrics/AbcSize
|
973
|
+
# rubocop: enable Metrics/MethodLength
|
974
|
+
|
975
|
+
# @param inner [TTTLS13::Message::ClientHello]
|
976
|
+
# @param ech_state [TTTLS13::Client::EchState]
|
977
|
+
#
|
978
|
+
# @return [TTTLS13::Message::ClientHello]
|
979
|
+
# @return [TTTLS13::Message::ClientHello]
|
980
|
+
def offer_new_ech(inner, ech_state)
|
981
|
+
encoded = encode_ch_inner(inner, ech_state.maximum_name_length)
|
982
|
+
overhead_len \
|
983
|
+
= Hpke.aead_id2overhead_len(ech_state.cipher_suite.aead_id.uint16)
|
984
|
+
|
985
|
+
# It encrypts EncodedClientHelloInner as described in Section 6.1.1, using
|
986
|
+
# the second partial ClientHelloOuterAAD, to obtain a second
|
987
|
+
# ClientHelloOuter. It reuses the original HPKE encryption context
|
988
|
+
# computed in Section 6.1 and uses the empty string for enc.
|
989
|
+
#
|
990
|
+
# https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.1.5-4.4.1
|
991
|
+
aad = new_ch_outer_aad(
|
992
|
+
inner,
|
993
|
+
ech_state.cipher_suite,
|
994
|
+
ech_state.config_id,
|
995
|
+
'',
|
996
|
+
encoded.length + overhead_len,
|
997
|
+
ech_state.public_name
|
998
|
+
)
|
999
|
+
# Authenticating the ClientHelloOuter
|
1000
|
+
# which does not include the Handshake structure's four byte header.
|
1001
|
+
outer = new_ch_outer(
|
1002
|
+
aad,
|
1003
|
+
ech_state.cipher_suite,
|
1004
|
+
ech_state.config_id,
|
1005
|
+
'',
|
1006
|
+
ech_state.ctx.seal(aad.serialize[4..], encoded)
|
1007
|
+
)
|
1008
|
+
|
1009
|
+
[outer, inner]
|
1010
|
+
end
|
1011
|
+
|
1012
|
+
# @param inner [TTTLS13::Message::ClientHello]
|
1013
|
+
# @param maximum_name_length [Integer]
|
1014
|
+
#
|
1015
|
+
# @return [String] EncodedClientHelloInner
|
1016
|
+
def encode_ch_inner(inner, maximum_name_length)
|
1017
|
+
# TODO: ech_outer_extensions
|
1018
|
+
encoded = Message::ClientHello.new(
|
1019
|
+
legacy_version: inner.legacy_version,
|
1020
|
+
random: inner.random,
|
1021
|
+
legacy_session_id: '',
|
1022
|
+
cipher_suites: inner.cipher_suites,
|
1023
|
+
legacy_compression_methods: inner.legacy_compression_methods,
|
1024
|
+
extensions: inner.extensions
|
1025
|
+
)
|
1026
|
+
server_name_length = \
|
1027
|
+
inner.extensions[Message::ExtensionType::SERVER_NAME].server_name.length
|
1028
|
+
|
1029
|
+
# which does not include the Handshake structure's four byte header.
|
1030
|
+
padding_encoded_ch_inner(
|
1031
|
+
encoded.serialize[4..],
|
1032
|
+
server_name_length,
|
1033
|
+
maximum_name_length
|
1034
|
+
)
|
1035
|
+
end
|
1036
|
+
|
1037
|
+
# @param s [String]
|
1038
|
+
# @param server_name_length [Integer]
|
1039
|
+
# @param maximum_name_length [Integer]
|
1040
|
+
#
|
1041
|
+
# @return [String]
|
1042
|
+
def padding_encoded_ch_inner(s, server_name_length, maximum_name_length)
|
1043
|
+
padding_len =
|
1044
|
+
if server_name_length.positive?
|
1045
|
+
[maximum_name_length - server_name_length, 0].max
|
1046
|
+
else
|
1047
|
+
9 + maximum_name_length
|
1048
|
+
end
|
1049
|
+
|
1050
|
+
padding_len = 31 - ((s.length + padding_len - 1) % 32)
|
1051
|
+
s + padding_len.zeros
|
1052
|
+
end
|
1053
|
+
|
1054
|
+
# @param inner [TTTLS13::Message::ClientHello]
|
1055
|
+
# @param cipher_suite [HpkeSymmetricCipherSuite]
|
1056
|
+
# @param config_id [Integer]
|
1057
|
+
# @param enc [String]
|
1058
|
+
# @param payload_len [Integer]
|
1059
|
+
# @param server_name [String]
|
1060
|
+
#
|
1061
|
+
# @return [TTTLS13::Message::ClientHello]
|
1062
|
+
# rubocop: disable Metrics/ParameterLists
|
1063
|
+
def new_ch_outer_aad(inner,
|
1064
|
+
cipher_suite,
|
1065
|
+
config_id,
|
1066
|
+
enc,
|
1067
|
+
payload_len,
|
1068
|
+
server_name)
|
1069
|
+
aad_ech = Message::Extension::ECHClientHello.new_outer(
|
1070
|
+
cipher_suite: cipher_suite,
|
1071
|
+
config_id: config_id,
|
1072
|
+
enc: enc,
|
1073
|
+
payload: payload_len.zeros
|
1074
|
+
)
|
1075
|
+
Message::ClientHello.new(
|
1076
|
+
legacy_version: inner.legacy_version,
|
1077
|
+
legacy_session_id: inner.legacy_session_id,
|
1078
|
+
cipher_suites: inner.cipher_suites,
|
1079
|
+
legacy_compression_methods: inner.legacy_compression_methods,
|
1080
|
+
extensions: inner.extensions.merge(
|
1081
|
+
Message::ExtensionType::ENCRYPTED_CLIENT_HELLO => aad_ech,
|
1082
|
+
Message::ExtensionType::SERVER_NAME => \
|
1083
|
+
Message::Extension::ServerName.new(server_name)
|
1084
|
+
)
|
1085
|
+
)
|
1086
|
+
end
|
1087
|
+
# rubocop: enable Metrics/ParameterLists
|
1088
|
+
|
1089
|
+
# @param aad [TTTLS13::Message::ClientHello]
|
1090
|
+
# @param cipher_suite [HpkeSymmetricCipherSuite]
|
1091
|
+
# @param config_id [Integer]
|
1092
|
+
# @param enc [String]
|
1093
|
+
# @param payload [String]
|
1094
|
+
#
|
1095
|
+
# @return [TTTLS13::Message::ClientHello]
|
1096
|
+
def new_ch_outer(aad, cipher_suite, config_id, enc, payload)
|
1097
|
+
outer_ech = Message::Extension::ECHClientHello.new_outer(
|
1098
|
+
cipher_suite: cipher_suite,
|
1099
|
+
config_id: config_id,
|
1100
|
+
enc: enc,
|
1101
|
+
payload: payload
|
1102
|
+
)
|
1103
|
+
Message::ClientHello.new(
|
1104
|
+
legacy_version: aad.legacy_version,
|
1105
|
+
random: aad.random,
|
1106
|
+
legacy_session_id: aad.legacy_session_id,
|
1107
|
+
cipher_suites: aad.cipher_suites,
|
1108
|
+
legacy_compression_methods: aad.legacy_compression_methods,
|
1109
|
+
extensions: aad.extensions.merge(
|
1110
|
+
Message::ExtensionType::ENCRYPTED_CLIENT_HELLO => outer_ech
|
1111
|
+
)
|
1112
|
+
)
|
1113
|
+
end
|
1114
|
+
|
1115
|
+
# @param conf [HpkeKeyConfig]
|
1116
|
+
#
|
1117
|
+
# @return [HpkeSymmetricCipherSuite, nil]
|
1118
|
+
def select_ech_hpke_cipher_suite(conf)
|
1119
|
+
@settings[:ech_hpke_cipher_suites].find do |cs|
|
1120
|
+
conf.cipher_suites.include?(cs)
|
1121
|
+
end
|
1122
|
+
end
|
1123
|
+
|
1124
|
+
# @return [Message::Extension::ECHClientHello]
|
1125
|
+
def new_grease_ech
|
1126
|
+
# https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#name-compliance-requirements
|
1127
|
+
cipher_suite = HpkeSymmetricCipherSuite.new(
|
1128
|
+
HpkeSymmetricCipherSuite::HpkeKdfId.new(
|
1129
|
+
TTTLS13::Hpke::KdfId::HKDF_SHA256
|
1130
|
+
),
|
1131
|
+
HpkeSymmetricCipherSuite::HpkeAeadId.new(
|
1132
|
+
TTTLS13::Hpke::AeadId::AES_128_GCM
|
1133
|
+
)
|
1134
|
+
)
|
1135
|
+
# Set the enc field to a randomly-generated valid encapsulated public key
|
1136
|
+
# output by the HPKE KEM.
|
1137
|
+
#
|
1138
|
+
# https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.2-2.3.1
|
1139
|
+
public_key = OpenSSL::PKey.read(
|
1140
|
+
OpenSSL::PKey.generate_key('X25519').public_to_pem
|
1141
|
+
)
|
1142
|
+
hpke = HPKE.new(:x25519, :sha256, :sha256, :aes_128_gcm)
|
1143
|
+
enc = hpke.setup_base_s(public_key, '')[:enc]
|
1144
|
+
# Set the payload field to a randomly-generated string of L+C bytes, where
|
1145
|
+
# C is the ciphertext expansion of the selected AEAD scheme and L is the
|
1146
|
+
# size of the EncodedClientHelloInner the client would compute when
|
1147
|
+
# offering ECH, padded according to Section 6.1.3.
|
1148
|
+
#
|
1149
|
+
# https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.2-2.4.1
|
1150
|
+
payload_len = placeholder_encoded_ch_inner_len \
|
1151
|
+
+ Hpke.aead_id2overhead_len(Hpke::AeadId::AES_128_GCM)
|
1152
|
+
|
1153
|
+
Message::Extension::ECHClientHello.new_outer(
|
1154
|
+
cipher_suite: cipher_suite,
|
1155
|
+
config_id: Convert.bin2i(OpenSSL::Random.random_bytes(1)),
|
1156
|
+
enc: enc,
|
1157
|
+
payload: OpenSSL::Random.random_bytes(payload_len)
|
1158
|
+
)
|
1159
|
+
end
|
1160
|
+
|
1161
|
+
# @return [Integer]
|
1162
|
+
def placeholder_encoded_ch_inner_len
|
1163
|
+
448
|
1164
|
+
end
|
1165
|
+
|
1166
|
+
# @param inner [TTTLS13::Message::ClientHello]
|
1167
|
+
# @param ech [Message::Extension::ECHClientHello]
|
1168
|
+
def new_greased_ch(inner, ech)
|
1169
|
+
Message::ClientHello.new(
|
1170
|
+
legacy_version: inner.legacy_version,
|
1171
|
+
random: inner.random,
|
1172
|
+
legacy_session_id: inner.legacy_session_id,
|
1173
|
+
cipher_suites: inner.cipher_suites,
|
1174
|
+
legacy_compression_methods: inner.legacy_compression_methods,
|
1175
|
+
extensions: inner.extensions.merge(
|
1176
|
+
Message::ExtensionType::ENCRYPTED_CLIENT_HELLO => ech
|
1177
|
+
)
|
1178
|
+
)
|
1179
|
+
end
|
1180
|
+
|
726
1181
|
# @return [Integer]
|
727
1182
|
def calc_obfuscated_ticket_age
|
728
1183
|
# the "ticket_lifetime" field in the NewSessionTicket message is
|
@@ -743,7 +1198,7 @@ module TTTLS13
|
|
743
1198
|
group = hrr.extensions[Message::ExtensionType::KEY_SHARE]
|
744
1199
|
.key_share_entry.first.group
|
745
1200
|
key_share, priv_keys \
|
746
|
-
|
1201
|
+
= Message::Extension::KeyShare.gen_ch_key_share([group])
|
747
1202
|
exs << key_share
|
748
1203
|
end
|
749
1204
|
|
@@ -754,7 +1209,7 @@ module TTTLS13
|
|
754
1209
|
# MUST copy the contents of the extension received in the
|
755
1210
|
# HelloRetryRequest into a "cookie" extension in the new ClientHello.
|
756
1211
|
#
|
757
|
-
# https://
|
1212
|
+
# https://datatracker.ietf.org/doc/html/rfc8446#section-4.2.2
|
758
1213
|
exs << hrr.extensions[Message::ExtensionType::COOKIE] \
|
759
1214
|
if hrr.extensions.include?(Message::ExtensionType::COOKIE)
|
760
1215
|
|
@@ -766,15 +1221,23 @@ module TTTLS13
|
|
766
1221
|
end
|
767
1222
|
|
768
1223
|
# NOTE:
|
769
|
-
# https://
|
1224
|
+
# https://datatracker.ietf.org/doc/html/rfc8446#section-4.1.2
|
770
1225
|
#
|
771
1226
|
# @param ch1 [TTTLS13::Message::ClientHello]
|
772
1227
|
# @param hrr [TTTLS13::Message::ServerHello]
|
773
1228
|
# @param extensions [TTTLS13::Message::Extensions]
|
774
1229
|
# @param binder_key [String, nil]
|
1230
|
+
# @param ech_state [TTTLS13::Client::EchState]
|
775
1231
|
#
|
776
|
-
# @return [TTTLS13::Message::ClientHello]
|
777
|
-
|
1232
|
+
# @return [TTTLS13::Message::ClientHello] outer
|
1233
|
+
# @return [TTTLS13::Message::ClientHello] inner
|
1234
|
+
# rubocop: disable Metrics/AbcSize
|
1235
|
+
# rubocop: disable Metrics/MethodLength
|
1236
|
+
def send_new_client_hello(ch1,
|
1237
|
+
hrr,
|
1238
|
+
extensions,
|
1239
|
+
binder_key = nil,
|
1240
|
+
ech_state = nil)
|
778
1241
|
ch = Message::ClientHello.new(
|
779
1242
|
legacy_version: ch1.legacy_version,
|
780
1243
|
random: ch1.random,
|
@@ -784,19 +1247,52 @@ module TTTLS13
|
|
784
1247
|
extensions: extensions
|
785
1248
|
)
|
786
1249
|
|
1250
|
+
# encrypted_client_hello
|
1251
|
+
if use_ech? && ech_state.nil?
|
1252
|
+
# If sending a second ClientHello in response to a HelloRetryRequest,
|
1253
|
+
# the client copies the entire "encrypted_client_hello" extension from
|
1254
|
+
# the first ClientHello.
|
1255
|
+
#
|
1256
|
+
# https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-17#section-6.2-3
|
1257
|
+
inner = ch.clone
|
1258
|
+
ch.extensions[Message::ExtensionType::ENCRYPTED_CLIENT_HELLO] \
|
1259
|
+
= ch1.extensions[Message::ExtensionType::ENCRYPTED_CLIENT_HELLO]
|
1260
|
+
elsif use_ech?
|
1261
|
+
ch, inner = offer_new_ech(ch, ech_state)
|
1262
|
+
end
|
1263
|
+
|
787
1264
|
# pre_shared_key
|
788
1265
|
#
|
789
1266
|
# Updating the "pre_shared_key" extension if present by recomputing
|
790
1267
|
# the "obfuscated_ticket_age" and binder values.
|
791
1268
|
if ch1.extensions.include?(Message::ExtensionType::PRE_SHARED_KEY)
|
792
1269
|
sign_psk_binder(ch1: ch1, hrr: hrr, ch: ch, binder_key: binder_key)
|
1270
|
+
|
1271
|
+
if use_ech?
|
1272
|
+
# it MUST also copy the "psk_key_exchange_modes" from the
|
1273
|
+
# ClientHelloInner into the ClientHelloOuter.
|
1274
|
+
ch.extensions[Message::ExtensionType::PSK_KEY_EXCHANGE_MODES] \
|
1275
|
+
= inner.extensions[Message::ExtensionType::PSK_KEY_EXCHANGE_MODES]
|
1276
|
+
# it MUST also include the "early_data" extension in ClientHelloOuter.
|
1277
|
+
ch.extensions[Message::ExtensionType::EARLY_DATA] \
|
1278
|
+
= inner.extensions[Message::ExtensionType::EARLY_DATA]
|
1279
|
+
sign_grease_psk_binder(
|
1280
|
+
ch1: ch1,
|
1281
|
+
hrr: hrr,
|
1282
|
+
ch_outer: ch,
|
1283
|
+
inner_psk: inner.extensions[Message::ExtensionType::PRE_SHARED_KEY],
|
1284
|
+
binder_key: binder_key
|
1285
|
+
)
|
1286
|
+
end
|
793
1287
|
end
|
794
1288
|
|
795
1289
|
send_handshakes(Message::ContentType::HANDSHAKE, [ch],
|
796
1290
|
Cryptograph::Passer.new)
|
797
1291
|
|
798
|
-
ch
|
1292
|
+
[ch, inner]
|
799
1293
|
end
|
1294
|
+
# rubocop: enable Metrics/AbcSize
|
1295
|
+
# rubocop: enable Metrics/MethodLength
|
800
1296
|
|
801
1297
|
# @raise [TTTLS13::Error::ErrorAlerts]
|
802
1298
|
#
|
@@ -949,10 +1445,35 @@ module TTTLS13
|
|
949
1445
|
def process_new_session_ticket(nst)
|
950
1446
|
super(nst)
|
951
1447
|
|
952
|
-
rms = @
|
1448
|
+
rms = @resumption_secret
|
953
1449
|
cs = @cipher_suite
|
954
1450
|
@settings[:process_new_session_ticket]&.call(nst, rms, cs)
|
955
1451
|
end
|
1452
|
+
|
1453
|
+
class EchState
|
1454
|
+
attr_accessor :maximum_name_length
|
1455
|
+
attr_accessor :config_id
|
1456
|
+
attr_accessor :cipher_suite
|
1457
|
+
attr_accessor :public_name
|
1458
|
+
attr_accessor :ctx
|
1459
|
+
|
1460
|
+
# @param maximum_name_length [Integer]
|
1461
|
+
# @param config_id [Integer]
|
1462
|
+
# @param cipher_suite [HpkeSymmetricCipherSuite]
|
1463
|
+
# @param public_name [String]
|
1464
|
+
# @param ctx [[HPKE::ContextS]
|
1465
|
+
def initialize(maximum_name_length,
|
1466
|
+
config_id,
|
1467
|
+
cipher_suite,
|
1468
|
+
public_name,
|
1469
|
+
ctx)
|
1470
|
+
@maximum_name_length = maximum_name_length
|
1471
|
+
@config_id = config_id
|
1472
|
+
@cipher_suite = cipher_suite
|
1473
|
+
@public_name = public_name
|
1474
|
+
@ctx = ctx
|
1475
|
+
end
|
1476
|
+
end
|
956
1477
|
end
|
957
1478
|
# rubocop: enable Metrics/ClassLength
|
958
1479
|
end
|