echspec 0.0.6 → 0.0.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dff1c822b0e5bef3e79cfbf5d1eb200f8312fb8fd0e0582e34d152dafdb8d810
4
- data.tar.gz: 8082a87b962ee5ea9b05589558e4704919c63d69fc69bf4f3e35379164429d80
3
+ metadata.gz: b4b19de1ec6feae0372b847a16805e3f2711283341d85b8b1e309ba5034c4d1b
4
+ data.tar.gz: edb1bc1ad4ca9489a2de82a6406d3f7e6dd57ecb785c2063a024ccfea5cc995a
5
5
  SHA512:
6
- metadata.gz: 37c0cfc5bc91f0be87099720490b2870921998f377d88b862152275586492670d4eb26c5ae6023a32345367b608830b4f9005b30a6df7b1fe7aea968769e0177
7
- data.tar.gz: c6cf6cbbe9a0607117a173f5d921201a38d18986e37217665d51c2ff1f004ab1784bf6f9b01ac9263f686bf2192e9099b8a9df947ebf6622f3201585c035b255
6
+ metadata.gz: 93d42d067d4839ff7ba052a81b3b587be0b7fa10f0acb372a97f6cfee4240a3dec35fd76899116164cb8a3276114b3bf5ba315cab1228af72a56b5a8cc841867
7
+ data.tar.gz: f5fa1e47ed16a24708583c6a2fa6419616c6df389c7d993bb0b6e52b5626581d6d9140b974e87dcd64d923c08ed2fb24cc8ef8cd412502c4fc921246578071a5
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.2
1
+ 4.0.7
data/README.md CHANGED
@@ -26,8 +26,11 @@ $ gem install echspec
26
26
  $ echspec --help
27
27
  Usage: echspec {SUBCOMMAND}
28
28
 
29
- Available subcommands: run, gen_configs, version, help.
29
+ Available subcommands: run, gen_configs, resolve, grease, version, help.
30
30
  ```
31
+
32
+ ### run
33
+
31
34
  ```sh-session
32
35
  $ echspec run --help
33
36
  Usage: echspec run [OPTIONS...] {HOSTNAME}
@@ -53,6 +56,7 @@ Example output:
53
56
  $ echspec run research.cloudflare.com
54
57
  TLS Encrypted Client Hello Server
55
58
  ✔ MUST implement the following HPKE cipher suite: KEM: DHKEM(X25519, HKDF-SHA256), KDF: HKDF-SHA256 and AEAD: AES-128-GCM. [9]
59
+ ✔ MUST include an "encrypted_client_hello" extension with an 8-byte confirmation payload in HelloRetryRequest, if ClientHello offers the inner variant. [5-11]
56
60
  ✔ MUST abort with an "illegal_parameter" alert, if EncodedClientHelloInner is padded with non-zero values. [5.1-9]
57
61
  ✔ MUST abort with an "illegal_parameter" alert, if any referenced extension is missing in ClientHelloOuter. [5.1-10]
58
62
  ✔ MUST abort with an "illegal_parameter" alert, if any extension is referenced in OuterExtensions more than once. [5.1-10]
@@ -65,6 +69,7 @@ TLS Encrypted Client Hello Server
65
69
  ✔ MUST abort with a "missing_extension" alert, if 2nd ClientHelloOuter does not contains the "encrypted_client_hello" extension. [7.1.1-2]
66
70
  ✔ MUST abort with an "illegal_parameter" alert, if 2nd ClientHelloOuter "encrypted_client_hello" enc is empty. [7.1.1-2]
67
71
  ✔ MUST abort with a "decrypt_error" alert, if fails to decrypt 2nd ClientHelloOuter. [7.1.1-5]
72
+ ✔ MUST confirm ECH acceptance in ServerHello, if it confirmed ECH acceptance in HelloRetryRequest. [7.2-1]
68
73
 
69
74
  Failures:
70
75
 
@@ -75,7 +80,7 @@ Failures:
75
80
  1 failure
76
81
  ```
77
82
 
78
- By default, `echspec` retrieves ECHConfigs via DNS HTTPS records. You can specify a local PEM file using the `-f, --file FILE` option. To test a server on localhost:
83
+ By default, `echspec` retrieves ECHConfigs via DNS HTTPS resource records. You can specify a local PEM file using the `-f, --file FILE` option. To test a server on localhost:
79
84
 
80
85
  ```sh-session
81
86
  $ echspec run -f fixtures/echconfigs.pem -p 4433 localhost
@@ -282,6 +287,8 @@ $ echspec run -s 7-5 -v research.cloudflare.com 2>&1 > /dev/null | jq .
282
287
 
283
288
  </details>
284
289
 
290
+ ### gen_configs
291
+
285
292
  You can generate an ECHConfig PEM file the following:
286
293
 
287
294
  ```sh-session
@@ -297,6 +304,104 @@ AQALZXhhbXBsZS5jb20AAA==
297
304
  -----END ECHCONFIG-----
298
305
  ```
299
306
 
307
+ The generated file contains the following two PEM blocks:
308
+
309
+ - `PRIVATE KEY`
310
+ - X25519
311
+ - `ECHCONFIG`
312
+ - version
313
+ - 0xfe0d
314
+ - config_id
315
+ - 123
316
+ - KEM
317
+ - DHKEM(X25519, HKDF-SHA256)
318
+ - KDF
319
+ - HKDF-SHA256
320
+ - AEAD
321
+ - AES-128-GCM
322
+ - maximum\_name\_length
323
+ - 32
324
+ - public_name
325
+ - "localhost"
326
+ - extensions
327
+ - []
328
+
329
+ ### resolve
330
+
331
+ You can resolve ECHConfigs for a hostname and print fields using the `resolve` subcommand.
332
+ With `-w`, ECHConfigs are resolved from the well-known URI (`https://{HOSTNAME}/.well-known/origin-svcb`) defined in [draft-ietf-tls-wkech](https://datatracker.ietf.org/doc/html/draft-ietf-tls-wkech-12), instead of DNS HTTPS resource records:
333
+
334
+ ```sh-session
335
+ $ echspec resolve --help
336
+ Usage: echspec resolve [OPTIONS...] [{HOSTNAME}]
337
+
338
+ Resolve ECHConfigs for a hostname and print fields.
339
+ {HOSTNAME} is required unless -f is specified.
340
+
341
+ Examples:
342
+
343
+ $ echspec resolve localhost
344
+ $ echspec resolve -w localhost
345
+ $ echspec resolve -f echconfigs.pem
346
+
347
+ Options:
348
+ -f, --file FILE path to ECHConfigs PEM file (default resolve ECHConfigs via DNS)
349
+ -w, --well-known resolve ECHConfigs via well-known URI, instead of DNS
350
+ ```
351
+ ```sh-session
352
+ $ echspec resolve research.cloudflare.com
353
+ ECHConfig: │
354
+ version(hex): │ fe 0d
355
+ length(integer): │ 65
356
+ contents: │
357
+ key_config: │
358
+ config_id(integer): │ 104
359
+ kem_id(hex): │ 00 20
360
+ public_key(hex): │ e8 49 25 b3 6c e3 fb 9c 72 c7 71 e2 7a b2 8c 65 84 ba c2 2a 90 c2 cb 59 23 b2 27 d8 c1 85 84 17
361
+ cipher_suites: │
362
+ kdf_id(hex): │ 00 01
363
+ aead_id(hex): │ 00 01
364
+ maximum_name_length(integer): │ 0
365
+ public_name(string): │ cloudflare-ech.com
366
+ extensions(hex): │
367
+ ```
368
+
369
+ ### grease
370
+
371
+ You can send a GREASE ECH to a server to trigger `retry_configs` and print the retry ECHConfigs fields using the `grease` subcommand:
372
+
373
+ ```sh-session
374
+ $ echspec grease --help
375
+ Usage: echspec grease [OPTIONS...] {HOSTNAME}
376
+
377
+ Send GREASE ECH to a server and display retry_configs.
378
+
379
+ Examples:
380
+
381
+ $ echspec grease localhost
382
+ $ echspec grease -p 4433 localhost
383
+
384
+ Options:
385
+ -p, --port VALUE server port number (default 443)
386
+ ```
387
+ ```sh-session
388
+ $ echspec grease research.cloudflare.com
389
+ ECHConfig: │
390
+ version(hex): │ fe 0d
391
+ length(integer): │ 65
392
+ contents: │
393
+ key_config: │
394
+ config_id(integer): │ 72
395
+ kem_id(hex): │ 00 20
396
+ public_key(hex): │ 0a 89 2a 9f 05 0a d5 21 da cc 42 65 df cf 70 38 22 7e 85 44 93 1b 68 fa a2 b5 37 ad d8 2c 89 39
397
+ cipher_suites: │
398
+ kdf_id(hex): │ 00 01
399
+ aead_id(hex): │ 00 01
400
+ maximum_name_length(integer): │ 0
401
+ public_name(string): │ cloudflare-ech.com
402
+ extensions(hex): │
403
+ ```
404
+
300
405
 
301
406
  ## Note
302
407
 
@@ -0,0 +1,55 @@
1
+ module EchSpec
2
+ class CLI
3
+ module EchConfigPrinter
4
+ def print_ech_configs(ech_configs)
5
+ all_pairs = ech_configs.map { |c| field_pairs(c) }
6
+ label_width, value_width = evaluate_widths(all_pairs)
7
+
8
+ all_pairs.each_with_index do |pairs, i|
9
+ puts "#{'─' * label_width}┼#{'─' * value_width}" if i.positive?
10
+ pairs.each { |label, value| print_field(label, value, label_width) }
11
+ end
12
+ end
13
+
14
+ def evaluate_widths(all_pairs)
15
+ label_width = all_pairs.flat_map { |pairs| pairs.map { |label, _| label.length } }.max + 1
16
+ value_width = all_pairs.flat_map { |pairs| pairs.map { |_, value| value.to_s.length } }.max + 1
17
+ [label_width, value_width]
18
+ end
19
+
20
+ def print_field(label, value, label_width)
21
+ puts "#{label.ljust(label_width)}│ #{value}"
22
+ end
23
+
24
+ # rubocop: disable Metrics/AbcSize
25
+ def field_pairs(c)
26
+ ec = c.echconfig_contents
27
+ kc = ec.key_config
28
+ ext = ec.extensions.octet.unpack1('H*').scan(/.{2}/).join(' ')
29
+ cipher_suite_pairs = kc.cipher_suites.flat_map do |cs|
30
+ [
31
+ [' kdf_id(hex):', cs.kdf_id.encode.unpack1('H4').scan(/.{2}/).join(' ')],
32
+ [' aead_id(hex):', cs.aead_id.encode.unpack1('H4').scan(/.{2}/).join(' ')]
33
+ ]
34
+ end
35
+
36
+ [
37
+ ['ECHConfig:', ''],
38
+ [' version(hex):', c.version.unpack1('H4').scan(/.{2}/).join(' ')],
39
+ [' length(integer):', ec.encode.length],
40
+ [' contents:', ''],
41
+ [' key_config:', ''],
42
+ [' config_id(integer):', kc.config_id],
43
+ [' kem_id(hex):', kc.kem_id.encode.unpack1('H4').scan(/.{2}/).join(' ')],
44
+ [' public_key(hex):', kc.public_key.opaque.unpack1('H*').scan(/.{2}/).join(' ')],
45
+ [' cipher_suites:', ''],
46
+ *cipher_suite_pairs,
47
+ [' maximum_name_length(integer):', ec.maximum_name_length],
48
+ [' public_name(string):', ec.public_name],
49
+ [' extensions(hex):', ext]
50
+ ]
51
+ end
52
+ # rubocop: enable Metrics/AbcSize
53
+ end
54
+ end
55
+ end
@@ -36,32 +36,8 @@ module EchSpec
36
36
  end
37
37
 
38
38
  def write(fpath)
39
- hostname = 'localhost'
40
-
41
39
  key = OpenSSL::PKey.generate_key('X25519')
42
- echconfigs = ECHConfigList.new(
43
- [
44
- ECHConfig.new(
45
- "\xfe\x0d".b,
46
- ECHConfig::ECHConfigContents.new(
47
- ECHConfig::ECHConfigContents::HpkeKeyConfig.new(
48
- 123,
49
- ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeKemId.new(HPKE::DHKEM_X25519_HKDF_SHA256),
50
- ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkePublicKey.new(key.raw_public_key),
51
- [
52
- ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite.new(
53
- ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite::HpkeKdfId.new(HPKE::HKDF_SHA256),
54
- ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite::HpkeAeadId.new(HPKE::AES_128_GCM)
55
- )
56
- ]
57
- ),
58
- 32,
59
- hostname.b,
60
- ECHConfig::ECHConfigContents::Extensions.new('')
61
- )
62
- )
63
- ]
64
- )
40
+ echconfigs = ECHConfigList.new([EchConfig.gen_ech_config(key.raw_public_key)])
65
41
  File.write(fpath, key.private_to_pem + echconfigs.to_pem)
66
42
  end
67
43
  end
@@ -0,0 +1,73 @@
1
+ module EchSpec
2
+ class CLI
3
+ class Grease
4
+ include EchConfigPrinter
5
+
6
+ def execute(argv)
7
+ port, hostname = parse_options(argv)
8
+
9
+ TTTLS13::Logging.logger.level = Logger::WARN
10
+ socket = TCPSocket.new(hostname, port)
11
+ recv = TLS13Client.send_ch_with_greased_ech(socket, hostname)
12
+ ex = recv.extensions[TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO]
13
+
14
+ if ex.nil? || ex.retry_configs.nil? || ex.retry_configs.empty?
15
+ warn "** #{hostname}:#{port} did not send retry_configs"
16
+ exit 1
17
+ end
18
+
19
+ print_ech_configs(ex.retry_configs)
20
+ rescue Timeout::Error
21
+ warn "** #{hostname}:#{port} connection timeout"
22
+ exit 1
23
+ rescue Errno::ECONNREFUSED
24
+ warn "** #{hostname}:#{port} connection refused"
25
+ exit 1
26
+ ensure
27
+ socket&.close
28
+ end
29
+
30
+ def parse_options(argv)
31
+ op = OptionParser.new
32
+ port = 443
33
+
34
+ op.on(
35
+ '-p',
36
+ '--port VALUE',
37
+ "server port number (default #{port})"
38
+ ) do |v|
39
+ port = v
40
+ end
41
+
42
+ op.banner = <<~USAGE
43
+ Usage: echspec grease [OPTIONS...] {HOSTNAME}
44
+
45
+ Send GREASE ECH to a server and display retry_configs.
46
+
47
+ Examples:
48
+
49
+ $ echspec grease localhost
50
+ $ echspec grease -p 4433 localhost
51
+
52
+ Options:
53
+ USAGE
54
+
55
+ begin
56
+ args = op.parse(argv)
57
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
58
+ warn op
59
+ warn "** #{e.message}"
60
+ exit 1
61
+ end
62
+
63
+ if args.length != 1
64
+ warn op
65
+ warn '** {HOSTNAME} argument is not specified'
66
+ exit 1
67
+ end
68
+
69
+ [port, args[0]]
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,86 @@
1
+ module EchSpec
2
+ class CLI
3
+ class Resolve
4
+ include EchConfigPrinter
5
+ def execute(argv)
6
+ fpath, well_known, hostname = parse_options(argv)
7
+
8
+ result = if !fpath.nil?
9
+ Spec::Spec9.parse_pem(File.read(fpath))
10
+ elsif well_known
11
+ Spec::Spec9.well_known_origin_svcb(hostname)
12
+ else
13
+ Spec::Spec9.resolve_ech_configs(hostname)
14
+ end
15
+
16
+ case result
17
+ in Ok(ech_configs)
18
+ print_ech_configs(ech_configs)
19
+ in Err(details, _)
20
+ warn "** #{details}"
21
+ exit 1
22
+ end
23
+ end
24
+
25
+ # rubocop: disable Metrics/MethodLength
26
+ def parse_options(argv)
27
+ op = OptionParser.new
28
+ fpath = nil
29
+ well_known = false
30
+
31
+ op.on(
32
+ '-f',
33
+ '--file FILE',
34
+ 'path to ECHConfigs PEM file (default resolve ECHConfigs via DNS)'
35
+ ) do |v|
36
+ fpath = v
37
+ end
38
+
39
+ op.on(
40
+ '-w',
41
+ '--well-known',
42
+ 'resolve ECHConfigs via well-known URI, instead of DNS'
43
+ ) do
44
+ well_known = true
45
+ end
46
+
47
+ op.banner = <<~USAGE
48
+ Usage: echspec resolve [OPTIONS...] [{HOSTNAME}]
49
+
50
+ Resolve ECHConfigs for a hostname and print fields.
51
+ {HOSTNAME} is required unless -f is specified.
52
+
53
+ Examples:
54
+
55
+ $ echspec resolve localhost
56
+ $ echspec resolve -w localhost
57
+ $ echspec resolve -f echconfigs.pem
58
+
59
+ Options:
60
+ USAGE
61
+
62
+ begin
63
+ args = op.parse(argv)
64
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
65
+ warn op
66
+ warn "** #{e.message}"
67
+ exit 1
68
+ end
69
+
70
+ if !fpath.nil? && !File.exist?(fpath)
71
+ warn '** {FILE} is not found'
72
+ exit 1
73
+ end
74
+
75
+ if fpath.nil? && args.length != 1
76
+ warn op
77
+ warn '** {HOSTNAME} argument is not specified'
78
+ exit 1
79
+ end
80
+
81
+ [fpath, well_known, args[0]]
82
+ end
83
+ # rubocop: enable Metrics/MethodLength
84
+ end
85
+ end
86
+ end
data/lib/echspec/cli.rb CHANGED
@@ -1,4 +1,7 @@
1
+ require_relative 'cli/ech_config_printer'
1
2
  require_relative 'cli/gen_configs'
3
+ require_relative 'cli/grease'
4
+ require_relative 'cli/resolve'
2
5
  require_relative 'cli/run'
3
6
 
4
7
  module EchSpec
@@ -6,7 +9,7 @@ module EchSpec
6
9
  using Refinements
7
10
 
8
11
  def execute(argv = ARGV)
9
- subcommands = %i[run gen_configs]
12
+ subcommands = %i[run gen_configs resolve grease]
10
13
 
11
14
  op = OptionParser.new
12
15
 
@@ -0,0 +1,36 @@
1
+ module EchSpec
2
+ module EchConfig
3
+ # @param public_key [String]
4
+ # @param kem_id [Integer]
5
+ # @param kdf_id [Integer]
6
+ # @param aead_id [Integer]
7
+ #
8
+ # @return [ECHConfig]
9
+ def self.gen_ech_config(public_key, kem_id: HPKE::DHKEM_X25519_HKDF_SHA256, kdf_id: HPKE::HKDF_SHA256, aead_id: HPKE::AES_128_GCM)
10
+ version = "\xfe\x0d".b
11
+ config_id = 123
12
+ maximum_name_length = 32
13
+ hostname = 'localhost'
14
+
15
+ ECHConfig.new(
16
+ version,
17
+ ECHConfig::ECHConfigContents.new(
18
+ ECHConfig::ECHConfigContents::HpkeKeyConfig.new(
19
+ config_id,
20
+ ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeKemId.new(kem_id),
21
+ ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkePublicKey.new(public_key),
22
+ [
23
+ ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite.new(
24
+ ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite::HpkeKdfId.new(kdf_id),
25
+ ECHConfig::ECHConfigContents::HpkeKeyConfig::HpkeSymmetricCipherSuite::HpkeAeadId.new(aead_id)
26
+ )
27
+ ]
28
+ ),
29
+ maximum_name_length,
30
+ hostname.b,
31
+ ECHConfig::ECHConfigContents::Extensions.new('')
32
+ )
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,70 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec5_11 < WithSocket
4
+ # Finally, when the client offers the "encrypted_client_hello", if the
5
+ # payload is the inner variant and the server responds with
6
+ # HelloRetryRequest, it MUST include an "encrypted_client_hello"
7
+ # extension with the following payload:
8
+ #
9
+ # struct {
10
+ # opaque confirmation[8];
11
+ # } ECHHelloRetryRequest;
12
+ #
13
+ # The value of ECHHelloRetryRequest.confirmation is set to
14
+ # hrr_accept_confirmation as described in Section 7.2.1.
15
+ #
16
+ # https://datatracker.ietf.org/doc/html/rfc9849#section-5-11
17
+
18
+ # @return [EchSpec::SpecGroup]
19
+ def self.spec_group
20
+ SpecGroup.new(
21
+ '5-11',
22
+ [
23
+ SpecCase.new(
24
+ 'MUST include an "encrypted_client_hello" extension with an 8-byte confirmation payload in HelloRetryRequest, if ClientHello offers the inner variant.',
25
+ method(:validate_hrr_ech_length)
26
+ )
27
+ ]
28
+ )
29
+ end
30
+
31
+ # @param hostname [String]
32
+ # @param port [Integer]
33
+ # @param ech_config [ECHConfig]
34
+ #
35
+ # @return [EchSpec::Ok | Err]
36
+ def self.validate_hrr_ech_length(hostname, port, ech_config)
37
+ Spec5_11.new.do_validate_hrr_ech_length(hostname, port, ech_config)
38
+ end
39
+
40
+ # @param hostname [String]
41
+ # @param port [Integer]
42
+ # @param ech_config [ECHConfig]
43
+ #
44
+ # @return [EchSpec::Ok | Err]
45
+ def do_validate_hrr_ech_length(hostname, port, ech_config)
46
+ with_socket(hostname, port) do |socket|
47
+ _conn, _inner, _ch, hrr, = TLS13Client.recv_hrr(socket, hostname, ech_config, @stack)
48
+ validate_hrr_ech(hrr)
49
+ end
50
+ end
51
+
52
+ # @param hrr [TTTLS13::Message::ServerHello]
53
+ #
54
+ # @return [EchSpec::Ok | Err]
55
+ def validate_hrr_ech(hrr)
56
+ # Extensions#[] returns nil for UnknownExtension, so use super_fetch.
57
+ ex = hrr.extensions.super_fetch(TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO, nil)
58
+ case ex
59
+ in TTTLS13::Message::Extension::ECHHelloRetryRequest
60
+ Ok.new(nil)
61
+ in nil
62
+ Err.new('HelloRetryRequest did not include "encrypted_client_hello" extension', message_stack)
63
+ in TTTLS13::Message::Extension::UnknownExtension
64
+ # tttls1.3 deserializes the extension whose length is not 8 as UnknownExtension
65
+ Err.new("HelloRetryRequest \"encrypted_client_hello\" extension has #{ex.extension_data.length}-byte payload, expected 8 bytes", message_stack)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -92,14 +92,9 @@ module EchSpec
92
92
  def self.padding_encoded_ch_inner(s,
93
93
  server_name_length,
94
94
  maximum_name_length)
95
- padding_len =
96
- if server_name_length.positive?
97
- [maximum_name_length - server_name_length, 0].max
98
- else
99
- 9 + maximum_name_length
100
- end
101
-
102
- padding_len = 31 - ((s.length + padding_len - 1) % 32)
95
+ padding_len = super.length - s.length
96
+ # at least one non-zero byte is needed for this test case
97
+ padding_len = 32 if padding_len.zero?
103
98
  s + NON_ZERO * padding_len # padding with non-zero value
104
99
  end
105
100
  end
@@ -43,86 +43,32 @@ module EchSpec
43
43
  # @return [EchSpec::Ok | Err]
44
44
  def do_validate_ee_retry_configs(hostname, port)
45
45
  with_socket(hostname, port) do |socket|
46
- recv = send_ch_with_greased_ech(socket, hostname)
47
- ex = recv.extensions[TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO]
48
- return Err.new('did not send expected alert: encrypted_client_hello', message_stack) \
49
- unless ex.is_a?(TTTLS13::Message::Extension::ECHEncryptedExtensions)
50
- return Err.new('ECHConfigs did not have "retry_configs"', message_stack) \
51
- if ex.retry_configs.nil? || ex.retry_configs.empty?
52
-
53
- Ok.new(nil)
46
+ recv = TLS13Client.send_ch_with_greased_ech(socket, hostname, @stack)
47
+ validate_ee_ech(recv)
48
+ rescue ECHConfig::DecodeError
49
+ Err.new('EncryptedExtensions "encrypted_client_hello" extension could not be decoded', message_stack)
54
50
  end
55
51
  end
56
52
 
57
- # rubocop: disable Metrics/AbcSize
58
- # rubocop: disable Metrics/MethodLength
59
- def send_ch_with_greased_ech(socket, hostname)
60
- # send ClientHello
61
- conn = TLS13Client::Connection.new(socket, :client)
62
- inner_ech = TTTLS13::Message::Extension::ECHClientHello.new_inner
63
- exs, shared_secret = TLS13Client.gen_ch_extensions(hostname)
64
- inner = TTTLS13::Message::ClientHello.new(
65
- cipher_suites: TTTLS13::CipherSuites.new(
66
- [
67
- TTTLS13::CipherSuite::TLS_AES_256_GCM_SHA384,
68
- TTTLS13::CipherSuite::TLS_CHACHA20_POLY1305_SHA256,
69
- TTTLS13::CipherSuite::TLS_AES_128_GCM_SHA256
70
- ]
71
- ),
72
- extensions: exs.merge(
73
- TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO => inner_ech
74
- )
75
- )
76
- @stack << inner
77
-
78
- ch = TTTLS13::Ech.new_greased_ch(inner, TTTLS13::Ech.new_grease_ech)
79
- conn.send_record(
80
- TTTLS13::Message::Record.new(
81
- type: TTTLS13::Message::ContentType::HANDSHAKE,
82
- messages: [ch],
83
- cipher: TTTLS13::Cryptograph::Passer.new
84
- )
85
- )
86
- @stack << ch
87
-
88
- # receive ServerHello
89
- recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
90
- @stack << recv
91
- raise Error::BeforeTargetSituationError, 'not received ServerHello' \
92
- unless recv.is_a?(TTTLS13::Message::ServerHello) && !recv.hrr?
53
+ # @param ee [TTTLS13::Message::EncryptedExtensions]
54
+ #
55
+ # @return [EchSpec::Ok | Err]
56
+ def validate_ee_ech(ee)
57
+ # Extensions#[] returns nil for UnknownExtension, so use super_fetch.
58
+ ex = ee.extensions.super_fetch(TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO, nil)
59
+ case ex
60
+ in TTTLS13::Message::Extension::ECHEncryptedExtensions
61
+ return Err.new('EncryptedExtensions "encrypted_client_hello" extension did not have "retry_configs"', message_stack) \
62
+ if ex.retry_configs.nil? || ex.retry_configs.empty?
93
63
 
94
- # receive EncryptedExtensions
95
- transcript = TTTLS13::Transcript.new
96
- transcript[TTTLS13::CH] = [ch, ch.serialize]
97
- sh = recv
98
- transcript[TTTLS13::SH] = [sh, sh.serialize]
99
- kse = sh.extensions[TTTLS13::Message::ExtensionType::KEY_SHARE]
100
- .key_share_entry.first
101
- key_schedule = TTTLS13::KeySchedule.new(
102
- psk: nil,
103
- shared_secret: shared_secret.build(kse.group, kse.key_exchange),
104
- cipher_suite: sh.cipher_suite,
105
- transcript:
106
- )
107
- hs_rcipher = TTTLS13::Endpoint.gen_cipher(
108
- sh.cipher_suite,
109
- key_schedule.server_handshake_write_key,
110
- key_schedule.server_handshake_write_iv
111
- )
112
- recv, = conn.recv_message(hs_rcipher)
113
- @stack << recv
114
- if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
115
- recv, = conn.recv_message(hs_rcipher)
116
- @stack << recv
64
+ Ok.new(nil)
65
+ in nil
66
+ Err.new('EncryptedExtensions did not include "encrypted_client_hello" extension', message_stack)
67
+ in TTTLS13::Message::Extension::UnknownExtension
68
+ # tttls1.3 deserializes the extension whose length field mismatches as UnknownExtension
69
+ Err.new('EncryptedExtensions "encrypted_client_hello" extension could not be decoded', message_stack)
117
70
  end
118
-
119
- raise Error::BeforeTargetSituationError, 'not received EncryptedExtensions' \
120
- unless recv.is_a?(TTTLS13::Message::EncryptedExtensions)
121
-
122
- recv
123
71
  end
124
- # rubocop: enable Metrics/AbcSize
125
- # rubocop: enable Metrics/MethodLength
126
72
  end
127
73
  end
128
74
  end
@@ -0,0 +1,112 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec7_2_1 < WithSocket
4
+ # Upon receipt of an "encrypted_client_hello" extension of type inner
5
+ # in a ClientHello, if the backend server negotiates TLS 1.3 or higher,
6
+ # then it MUST confirm ECH acceptance to the client by computing its
7
+ # ServerHello as described here.
8
+ #
9
+ # https://datatracker.ietf.org/doc/html/rfc9849#section-7.2-1
10
+
11
+ # @return [EchSpec::SpecGroup]
12
+ def self.spec_group
13
+ SpecGroup.new(
14
+ '7.2-1',
15
+ [
16
+ SpecCase.new(
17
+ 'MUST confirm ECH acceptance in ServerHello, if it confirmed ECH acceptance in HelloRetryRequest.',
18
+ method(:validate_sh_confirmation_after_hrr)
19
+ )
20
+ ]
21
+ )
22
+ end
23
+
24
+ # @param hostname [String]
25
+ # @param port [Integer]
26
+ # @param ech_config [ECHConfig]
27
+ #
28
+ # @return [EchSpec::Ok | Err]
29
+ def self.validate_sh_confirmation_after_hrr(hostname, port, ech_config)
30
+ Spec7_2_1.new.do_validate_sh_confirmation_after_hrr(hostname, port, ech_config)
31
+ end
32
+
33
+ # @param hostname [String]
34
+ # @param port [Integer]
35
+ # @param ech_config [ECHConfig]
36
+ #
37
+ # @return [EchSpec::Ok | Err]
38
+ def do_validate_sh_confirmation_after_hrr(hostname, port, ech_config)
39
+ with_socket(hostname, port) do |socket|
40
+ recv, confirmation = send_2nd_ch_after_hrr(socket, hostname, ech_config)
41
+ return Err.new('did not send expected handshake message: ServerHello', message_stack) \
42
+ unless recv.is_a?(TTTLS13::Message::ServerHello) && !recv.hrr?
43
+
44
+ validate_sh_confirmation(confirmation, recv)
45
+ end
46
+ end
47
+
48
+ # @param expected [String] accept_confirmation
49
+ # @param sh [TTTLS13::Message::ServerHello]
50
+ #
51
+ # @return [EchSpec::Ok | Err]
52
+ def validate_sh_confirmation(expected, sh)
53
+ return Ok.new(nil) if sh.random[-8..] == expected
54
+
55
+ Err.new('the last 8 bytes of ServerHello.random did not match accept_confirmation, although HelloRetryRequest confirmed ECH acceptance', message_stack)
56
+ end
57
+
58
+ # rubocop: disable Metrics/AbcSize
59
+ # rubocop: disable Metrics/MethodLength
60
+ def send_2nd_ch_after_hrr(socket, hostname, ech_config)
61
+ conn, inner1, _ch1, hrr, ech_state = TLS13Client.recv_hrr(socket, hostname, ech_config, @stack)
62
+ transcript = TTTLS13::Transcript.new
63
+ transcript[TTTLS13::CH1] = [inner1, inner1.serialize]
64
+ transcript[TTTLS13::HRR] = [hrr, hrr.serialize]
65
+ # shared_secret is not used to compute (hrr_)accept_confirmation
66
+ key_schedule = TTTLS13::KeySchedule.new(
67
+ shared_secret: nil,
68
+ cipher_suite: hrr.cipher_suite,
69
+ transcript:
70
+ )
71
+ ex = hrr.extensions[TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO]
72
+ raise Error::BeforeTargetSituationError, 'HelloRetryRequest did not confirm ECH acceptance' \
73
+ if ex.nil? || ex.confirmation != key_schedule.hrr_accept_confirmation
74
+
75
+ # send 2nd ClientHello; ClientHelloInner2 is based on ClientHelloInner1,
76
+ # and its "encrypted_client_hello" extension of type inner is left
77
+ # unmodified.
78
+ inner2 = TTTLS13::Message::ClientHello.new(
79
+ legacy_version: inner1.legacy_version,
80
+ random: inner1.random,
81
+ legacy_session_id: inner1.legacy_session_id,
82
+ cipher_suites: inner1.cipher_suites,
83
+ legacy_compression_methods: inner1.legacy_compression_methods,
84
+ extensions: TLS13Client.gen_newch_extensions(inner1, hrr)
85
+ )
86
+ ch, inner2 = TTTLS13::Ech.offer_new_ech(inner2, ech_state)
87
+ conn.send_record(
88
+ TTTLS13::Message::Record.new(
89
+ type: TTTLS13::Message::ContentType::HANDSHAKE,
90
+ messages: [ch],
91
+ cipher: TTTLS13::Cryptograph::Passer.new
92
+ )
93
+ )
94
+ @stack << inner2
95
+ @stack << ch
96
+
97
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
98
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new) \
99
+ if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
100
+ @stack << recv
101
+ return [recv, nil] unless recv.is_a?(TTTLS13::Message::ServerHello) && !recv.hrr?
102
+
103
+ transcript[TTTLS13::CH] = [inner2, inner2.serialize]
104
+ sh = recv
105
+ transcript[TTTLS13::SH] = [sh, sh.serialize]
106
+ [recv, key_schedule.accept_confirmation]
107
+ end
108
+ # rubocop: enable Metrics/AbcSize
109
+ # rubocop: enable Metrics/MethodLength
110
+ end
111
+ end
112
+ end
@@ -77,12 +77,87 @@ module EchSpec
77
77
  return Err.new("HTTPS resource record for #{hostname} does NOT have ech SvcParams.", nil) if rr.params[ech].nil?
78
78
 
79
79
  octet = rr.params[ech].value
80
- Err.new('Failed to parse ECHConfig on HTTPS resource record.', nil) \
81
- unless octet.length == octet.slice(0, 2).unpack1('n') + 2
80
+ return Err.new('Failed to parse ECHConfig on HTTPS resource record.', nil) \
81
+ unless octet.length >= 2 && octet.length == octet.slice(0, 2).unpack1('n') + 2
82
82
 
83
83
  Ok.new(ECHConfig.decode_vectors(octet.slice(2..)))
84
84
  end
85
85
 
86
+ # @param hostname [String]
87
+ #
88
+ # @return [EchSpec::Ok<Array<ECHConfig>> | Err]
89
+ def well_known_origin_svcb(hostname)
90
+ res = fetch_well_known(hostname)
91
+ body = case res
92
+ in Ok(obj)
93
+ obj
94
+ in Err
95
+ return res
96
+ end
97
+
98
+ parse_origin_svcb(body)
99
+ end
100
+
101
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-wkech-12
102
+ WK_ECH_PATH = '/.well-known/origin-svcb'.freeze
103
+ private_constant :WK_ECH_PATH
104
+
105
+ # @param hostname [String]
106
+ #
107
+ # @return [EchSpec::Ok | Err]
108
+ def fetch_well_known(hostname)
109
+ uri = URI("https://#{hostname}#{WK_ECH_PATH}")
110
+ res = Net::HTTP.get_response(uri)
111
+ return Err.new("GET #{uri} returned #{res.code}.", nil) unless res.is_a?(Net::HTTPSuccess)
112
+
113
+ Ok.new(res.body)
114
+ rescue StandardError => e
115
+ Err.new(e.message, nil)
116
+ end
117
+
118
+ # {
119
+ # "regeninterval": 3600,
120
+ # "endpoints": [
121
+ # {
122
+ # "priority": 1,
123
+ # "params": {
124
+ # "ipv4hint": [ "192.0.2.1", "192.0.2.254" ],
125
+ # "ech": "AD7+DQA6NAAgACCn3zNTeX/WOD...AAA==",
126
+ # "ipv6hint": [ "2001:DB::ec4" ],
127
+ # "alpn": [ "h2", "http/1.1" ]
128
+ # }
129
+ # }
130
+ # ]
131
+ # }
132
+ #
133
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-wkech-12#section-5
134
+ #
135
+ # @param body [String]
136
+ #
137
+ # @return [EchSpec::Ok | Err]
138
+ # rubocop: disable Metrics/CyclomaticComplexity
139
+ # rubocop: disable Metrics/PerceivedComplexity
140
+ def parse_origin_svcb(body)
141
+ h = JSON.parse(body)
142
+ echs = h['endpoints']&.filter_map { |e| e.dig('params', 'ech') }
143
+ return Err.new('The origin-svcb well-known resource does NOT have ech SvcParams.', nil) if echs.nil? || echs.empty?
144
+
145
+ # if parsing any one of the ech values fails, return Err
146
+ ech_configs = echs.flat_map do |ech|
147
+ octet = Base64.decode64(ech)
148
+ return Err.new('Failed to parse ECHConfig on the origin-svcb well-known resource.', nil) \
149
+ unless octet.length >= 2 && octet.length == octet.slice(0, 2).unpack1('n') + 2
150
+
151
+ ECHConfig.decode_vectors(octet.slice(2..))
152
+ end
153
+
154
+ Ok.new(ech_configs)
155
+ rescue StandardError => e
156
+ Err.new(e.message, nil)
157
+ end
158
+ # rubocop: enable Metrics/CyclomaticComplexity
159
+ # rubocop: enable Metrics/PerceivedComplexity
160
+
86
161
  # @param pem [String]
87
162
  #
88
163
  # @return [EchSpec::Ok<Array<ECHConfig>> | Err]
@@ -92,6 +167,9 @@ module EchSpec
92
167
  .first
93
168
  .gsub("\n", '')
94
169
  b = Base64.decode64(s)
170
+ return Err.new('Failed to parse ECHConfig on the PEM file.', nil) \
171
+ unless b.length >= 2 && b.length == b.slice(0, 2).unpack1('n') + 2
172
+
95
173
  ech_configs = ECHConfig.decode_vectors(b.slice(2..))
96
174
  Ok.new(ech_configs)
97
175
  rescue StandardError
data/lib/echspec/spec.rb CHANGED
@@ -176,10 +176,10 @@ module EchSpec
176
176
 
177
177
  def spec_groups
178
178
  # https://datatracker.ietf.org/doc/html/rfc9849#section-5
179
- groups = [Spec5_1_9, Spec5_1_10]
179
+ groups = [Spec5_11, Spec5_1_9, Spec5_1_10]
180
180
 
181
181
  # https://datatracker.ietf.org/doc/html/rfc9849#section-7
182
- groups += [Spec7_5, Spec7_1_11, Spec7_1_14_2_1, Spec7_1_1_2, Spec7_1_1_5]
182
+ groups += [Spec7_5, Spec7_1_11, Spec7_1_14_2_1, Spec7_1_1_2, Spec7_1_1_5, Spec7_2_1]
183
183
 
184
184
  groups.map(&:spec_group)
185
185
  end
@@ -1,4 +1,5 @@
1
1
  module EchSpec
2
+ # rubocop: disable Metrics/ModuleLength
2
3
  module TLS13Client
3
4
  class Connection < TTTLS13::Connection
4
5
  # @param socket [Socket]
@@ -95,6 +96,84 @@ module EchSpec
95
96
  ch1.extensions.merge(exs)
96
97
  end
97
98
 
99
+ # @param socket [TCPSocket]
100
+ # @param hostname [String]
101
+ # @param stack [EchSpec::Log::MessageStack | nil]
102
+ #
103
+ # @raise [EchSpec::Error::BeforeTargetSituationError]
104
+ #
105
+ # @return [TTTLS13::Message::EncryptedExtensions]
106
+ # rubocop: disable Metrics/AbcSize
107
+ # rubocop: disable Metrics/CyclomaticComplexity
108
+ # rubocop: disable Metrics/MethodLength
109
+ # rubocop: disable Metrics/PerceivedComplexity
110
+ def send_ch_with_greased_ech(socket, hostname, stack = nil)
111
+ conn = TLS13Client::Connection.new(socket, :client)
112
+ inner_ech = TTTLS13::Message::Extension::ECHClientHello.new_inner
113
+ exs, shared_secret = TLS13Client.gen_ch_extensions(hostname)
114
+ inner = TTTLS13::Message::ClientHello.new(
115
+ cipher_suites: TTTLS13::CipherSuites.new(
116
+ [
117
+ TTTLS13::CipherSuite::TLS_AES_256_GCM_SHA384,
118
+ TTTLS13::CipherSuite::TLS_CHACHA20_POLY1305_SHA256,
119
+ TTTLS13::CipherSuite::TLS_AES_128_GCM_SHA256
120
+ ]
121
+ ),
122
+ extensions: exs.merge(
123
+ TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO => inner_ech
124
+ )
125
+ )
126
+ stack << inner if stack
127
+
128
+ ch = TTTLS13::Ech.new_greased_ch(inner, TTTLS13::Ech.new_grease_ech)
129
+ conn.send_record(
130
+ TTTLS13::Message::Record.new(
131
+ type: TTTLS13::Message::ContentType::HANDSHAKE,
132
+ messages: [ch],
133
+ cipher: TTTLS13::Cryptograph::Passer.new
134
+ )
135
+ )
136
+ stack << ch if stack
137
+
138
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
139
+ stack << recv if stack
140
+ raise Error::BeforeTargetSituationError, 'not received ServerHello' \
141
+ unless recv.is_a?(TTTLS13::Message::ServerHello) && !recv.hrr?
142
+
143
+ transcript = TTTLS13::Transcript.new
144
+ transcript[TTTLS13::CH] = [ch, ch.serialize]
145
+ sh = recv
146
+ transcript[TTTLS13::SH] = [sh, sh.serialize]
147
+ kse = sh.extensions[TTTLS13::Message::ExtensionType::KEY_SHARE]
148
+ .key_share_entry.first
149
+ key_schedule = TTTLS13::KeySchedule.new(
150
+ psk: nil,
151
+ shared_secret: shared_secret.build(kse.group, kse.key_exchange),
152
+ cipher_suite: sh.cipher_suite,
153
+ transcript:
154
+ )
155
+ hs_rcipher = TTTLS13::Endpoint.gen_cipher(
156
+ sh.cipher_suite,
157
+ key_schedule.server_handshake_write_key,
158
+ key_schedule.server_handshake_write_iv
159
+ )
160
+ recv, = conn.recv_message(hs_rcipher)
161
+ stack << recv if stack
162
+ if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
163
+ recv, = conn.recv_message(hs_rcipher)
164
+ stack << recv if stack
165
+ end
166
+
167
+ raise Error::BeforeTargetSituationError, 'not received EncryptedExtensions' \
168
+ unless recv.is_a?(TTTLS13::Message::EncryptedExtensions)
169
+
170
+ recv
171
+ end
172
+ # rubocop: enable Metrics/AbcSize
173
+ # rubocop: enable Metrics/CyclomaticComplexity
174
+ # rubocop: enable Metrics/MethodLength
175
+ # rubocop: enable Metrics/PerceivedComplexity
176
+
98
177
  # @param conf [ECHConfig::ECHConfigContents::HpkeKeyConfig]
99
178
  #
100
179
  # @return [Boolean]
@@ -164,4 +243,5 @@ module EchSpec
164
243
  # rubocop: enable Metrics/MethodLength
165
244
  end
166
245
  end
246
+ # rubocop: enable Metrics/ModuleLength
167
247
  end
@@ -1,3 +1,3 @@
1
1
  module EchSpec
2
- VERSION = '0.0.6'.freeze
2
+ VERSION = '0.0.8'.freeze
3
3
  end
data/lib/echspec.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require 'base64'
2
+ require 'json'
3
+ require 'net/http'
2
4
  require 'optparse'
3
5
  require 'pp'
4
6
  require 'resolv'
@@ -10,6 +12,7 @@ require_relative 'echspec/utils'
10
12
  require_relative 'echspec/log'
11
13
  require_relative 'echspec/error'
12
14
  require_relative 'echspec/result'
15
+ require_relative 'echspec/ech_config'
13
16
  require_relative 'echspec/tls13_client'
14
17
  require_relative 'echspec/spec_case'
15
18
  require_relative 'echspec/spec_group'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: echspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - thekuwayama
@@ -101,12 +101,17 @@ files:
101
101
  - fixtures/server.key
102
102
  - lib/echspec.rb
103
103
  - lib/echspec/cli.rb
104
+ - lib/echspec/cli/ech_config_printer.rb
104
105
  - lib/echspec/cli/gen_configs.rb
106
+ - lib/echspec/cli/grease.rb
107
+ - lib/echspec/cli/resolve.rb
105
108
  - lib/echspec/cli/run.rb
109
+ - lib/echspec/ech_config.rb
106
110
  - lib/echspec/error.rb
107
111
  - lib/echspec/log.rb
108
112
  - lib/echspec/result.rb
109
113
  - lib/echspec/spec.rb
114
+ - lib/echspec/spec/5-11.rb
110
115
  - lib/echspec/spec/5.1-10.rb
111
116
  - lib/echspec/spec/5.1-9.rb
112
117
  - lib/echspec/spec/7-5.rb
@@ -114,6 +119,7 @@ files:
114
119
  - lib/echspec/spec/7.1-14.2.1.rb
115
120
  - lib/echspec/spec/7.1.1-2.rb
116
121
  - lib/echspec/spec/7.1.1-5.rb
122
+ - lib/echspec/spec/7.2-1.rb
117
123
  - lib/echspec/spec/9.rb
118
124
  - lib/echspec/spec_case.rb
119
125
  - lib/echspec/spec_group.rb
@@ -138,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
144
  - !ruby/object:Gem::Version
139
145
  version: '0'
140
146
  requirements: []
141
- rubygems_version: 4.0.6
147
+ rubygems_version: 4.0.20
142
148
  specification_version: 4
143
149
  summary: A conformance testing tool for ECH implementation
144
150
  test_files: []