echspec 0.0.1

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.
@@ -0,0 +1,93 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec7_1_11 < WithSocket
4
+ # Upon determining the ClientHelloInner, the client-facing server
5
+ # checks that the message includes a well-formed
6
+ # "encrypted_client_hello" extension of type inner and that it does not
7
+ # offer TLS 1.2 or below. If either of these checks fails, the client-
8
+ # facing server MUST abort with an "illegal_parameter" alert.
9
+ #
10
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-7.1-11
11
+
12
+ # @return [SpecGroup]
13
+ def self.spec_group
14
+ SpecGroup.new(
15
+ '7.1-11',
16
+ [
17
+ SpecCase.new(
18
+ 'MUST abort with an "illegal_parameter" alert, if ClientHelloInner offers TLS 1.2 or below.',
19
+ method(:validate_ech_with_tls12)
20
+ )
21
+ ]
22
+ )
23
+ end
24
+
25
+ # @param hostname [String]
26
+ # @param port [Integer]
27
+ # @param ech_config [ECHConfig]
28
+ #
29
+ # @return [EchSpec::Ok | Err]
30
+ def self.validate_ech_with_tls12(hostname, port, ech_config)
31
+ Spec7_1_11.new.do_validate_ech_with_tls12(hostname, port, ech_config)
32
+ end
33
+
34
+ # @param hostname [String]
35
+ # @param port [Integer]
36
+ # @param ech_config [ECHConfig]
37
+ #
38
+ # @return [EchSpec::Ok | Err]
39
+ def do_validate_ech_with_tls12(hostname, port, ech_config)
40
+ with_socket(hostname, port) do |socket|
41
+ recv = send_ch_ech_with_tls12(socket, hostname, ech_config)
42
+ return Err.new('did not send expected alert: illegal_parameter', message_stack) \
43
+ unless Spec.expect_alert(recv, :illegal_parameter)
44
+
45
+ Ok.new(nil)
46
+ end
47
+ end
48
+
49
+ # rubocop: disable Metrics/MethodLength
50
+ def send_ch_ech_with_tls12(socket, hostname, ech_config)
51
+ conn = TLS13Client::Connection.new(socket, :client)
52
+ inner_ech = TTTLS13::Message::Extension::ECHClientHello.new_inner
53
+ exs, = TLS13Client.gen_ch_extensions(hostname)
54
+ # supported_versions: only TLS 1.2
55
+ versions = TTTLS13::Message::Extension::SupportedVersions.new(
56
+ msg_type: TTTLS13::Message::HandshakeType::CLIENT_HELLO,
57
+ versions: [TTTLS13::Message::ProtocolVersion::TLS_1_2]
58
+ )
59
+ exs[TTTLS13::Message::ExtensionType::SUPPORTED_VERSIONS] = versions
60
+ inner = TTTLS13::Message::ClientHello.new(
61
+ cipher_suites: TTTLS13::CipherSuites.new(
62
+ [
63
+ TTTLS13::CipherSuite::TLS_AES_256_GCM_SHA384,
64
+ TTTLS13::CipherSuite::TLS_CHACHA20_POLY1305_SHA256,
65
+ TTTLS13::CipherSuite::TLS_AES_128_GCM_SHA256
66
+ ]
67
+ ),
68
+ extensions: exs.merge(
69
+ TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO => inner_ech
70
+ )
71
+ )
72
+
73
+ selector = proc { |x| TLS13Client.select_ech_hpke_cipher_suite(x) }
74
+ ch, inner, = TTTLS13::Ech.offer_ech(inner, ech_config, selector)
75
+ conn.send_record(
76
+ TTTLS13::Message::Record.new(
77
+ type: TTTLS13::Message::ContentType::HANDSHAKE,
78
+ messages: [ch],
79
+ cipher: TTTLS13::Cryptograph::Passer.new
80
+ )
81
+ )
82
+ @stack << inner
83
+ @stack << ch
84
+
85
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
86
+ @stack << recv
87
+
88
+ recv
89
+ end
90
+ # rubocop: enable Metrics/MethodLength
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,133 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec7_1_14_2_1 < WithSocket
4
+ # Otherwise, if all candidate ECHConfig values fail to decrypt the
5
+ # extension, the client-facing server MUST ignore the extension and
6
+ # proceed with the connection using ClientHelloOuter, with the
7
+ # following modifications:
8
+ #
9
+ # * If the server is configured with any ECHConfigs, it MUST include
10
+ # the "encrypted_client_hello" extension in its EncryptedExtensions
11
+ # with the "retry_configs" field set to one or more ECHConfig
12
+ # structures with up-to-date keys. Servers MAY supply multiple
13
+ # ECHConfig values of different versions. This allows a server to
14
+ # support multiple versions at once.
15
+ #
16
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-7.1-14.2.1
17
+
18
+ # @return [EchSpec::SpecGroup]
19
+ def self.spec_group
20
+ SpecGroup.new(
21
+ '7.1-14.2.1',
22
+ [
23
+ SpecCase.new(
24
+ 'MUST include the "encrypted_client_hello" extension in its EncryptedExtensions with the "retry_configs" field set to one or more ECHConfig.',
25
+ method(:validate_ee_retry_configs)
26
+ )
27
+ ]
28
+ )
29
+ end
30
+
31
+ # @param hostname [String]
32
+ # @param port [Integer]
33
+ # @param _ [ECHConfig]
34
+ #
35
+ # @return [EchSpec::Ok | Err]
36
+ def self.validate_ee_retry_configs(hostname, port, _)
37
+ Spec7_1_14_2_1.new.do_validate_ee_retry_configs(hostname, port)
38
+ end
39
+
40
+ # @param hostname [String]
41
+ # @param port [Integer]
42
+ #
43
+ # @return [EchSpec::Ok | Err]
44
+ def do_validate_ee_retry_configs(hostname, port)
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)
54
+ end
55
+ end
56
+
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, priv_keys = 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?
93
+
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
+ shared_secret = TTTLS13::Endpoint.gen_shared_secret(
102
+ kse.key_exchange,
103
+ priv_keys[kse.group],
104
+ kse.group
105
+ )
106
+ key_schedule = TTTLS13::KeySchedule.new(
107
+ psk: nil,
108
+ shared_secret:,
109
+ cipher_suite: sh.cipher_suite,
110
+ transcript:
111
+ )
112
+ hs_rcipher = TTTLS13::Endpoint.gen_cipher(
113
+ sh.cipher_suite,
114
+ key_schedule.server_handshake_write_key,
115
+ key_schedule.server_handshake_write_iv
116
+ )
117
+ recv, = conn.recv_message(hs_rcipher)
118
+ @stack << recv
119
+ if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
120
+ recv, = conn.recv_message(hs_rcipher)
121
+ @stack << recv
122
+ end
123
+
124
+ raise Error::BeforeTargetSituationError, 'not received EncryptedExtensions' \
125
+ unless recv.is_a?(TTTLS13::Message::EncryptedExtensions)
126
+
127
+ recv
128
+ end
129
+ # rubocop: enable Metrics/AbcSize
130
+ # rubocop: enable Metrics/MethodLength
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,142 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec7_1_1_2 < WithSocket
4
+ # If the client-facing server accepted ECH, it checks the second
5
+ # ClientHelloOuter also contains the "encrypted_client_hello"
6
+ # extension. If not, it MUST abort the handshake with a
7
+ # "missing_extension" alert. Otherwise, it checks that
8
+ # ECHClientHello.cipher_suite and ECHClientHello.config_id are
9
+ # unchanged, and that ECHClientHello.enc is empty. If not, it MUST
10
+ # abort the handshake with an "illegal_parameter" alert.
11
+ #
12
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-7.1.1-2
13
+
14
+ # @return [EchSpec::SpecGroup]
15
+ def self.spec_group
16
+ SpecGroup.new(
17
+ '7.1.1-2',
18
+ [
19
+ SpecCase.new(
20
+ 'MUST abort with a "missing_extension" alert, if 2nd ClientHelloOuter does not contains the "encrypted_client_hello" extension.',
21
+ method(:validate_2nd_ch_missing_ech)
22
+ ),
23
+ SpecCase.new(
24
+ 'MUST abort with an "illegal_parameter" alert, if 2nd ClientHelloOuter "encrypted_client_hello" enc is empty.',
25
+ method(:validate_2nd_ch_unchanged_ech)
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_2nd_ch_missing_ech(hostname, port, ech_config)
37
+ Spec7_1_1_2.new.do_validate_2nd_ch_missing_ech(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_2nd_ch_missing_ech(hostname, port, ech_config)
46
+ with_socket(hostname, port) do |socket|
47
+ recv = send_2nd_ch_missing_ech(socket, hostname, ech_config)
48
+ return Err.new('did not send expected alert: missing_extension', message_stack) \
49
+ unless Spec.expect_alert(recv, :missing_extension)
50
+
51
+ Ok.new(nil)
52
+ end
53
+ end
54
+
55
+ # @param hostname [String]
56
+ # @param port [Integer]
57
+ # @param ech_config [ECHConfig]
58
+ #
59
+ # @return [EchSpec::Ok | Err]
60
+ def self.validate_2nd_ch_unchanged_ech(hostname, port, ech_config)
61
+ Spec7_1_1_2.new.do_validate_2nd_ch_unchanged_ech(hostname, port, ech_config)
62
+ end
63
+
64
+ # @param hostname [String]
65
+ # @param port [Integer]
66
+ # @param ech_config [ECHConfig]
67
+ #
68
+ # @return [EchSpec::Ok | Err]
69
+ def do_validate_2nd_ch_unchanged_ech(hostname, port, ech_config)
70
+ with_socket(hostname, port) do |socket|
71
+ recv = send_2nd_ch_unchanged_ech(socket, hostname, ech_config)
72
+ return Err.new('did not send expected alert: illegal_parameter', message_stack) \
73
+ unless Spec.expect_alert(recv, :illegal_parameter)
74
+
75
+ Ok.new(nil)
76
+ end
77
+ end
78
+
79
+ def send_2nd_ch_missing_ech(socket, hostname, ech_config)
80
+ conn, _inner1, ch1, hrr, = TLS13Client.recv_hrr(socket, hostname, ech_config, @stack)
81
+ # send 2nd ClientHello without ech
82
+ new_exs = TLS13Client.gen_newch_extensions(ch1, hrr)
83
+ new_exs.delete(TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO)
84
+ ch = TTTLS13::Message::ClientHello.new(
85
+ legacy_version: ch1.legacy_version,
86
+ random: ch1.random,
87
+ legacy_session_id: ch1.legacy_session_id,
88
+ cipher_suites: ch1.cipher_suites,
89
+ legacy_compression_methods: ch1.legacy_compression_methods,
90
+ extensions: new_exs
91
+ )
92
+ conn.send_record(
93
+ TTTLS13::Message::Record.new(
94
+ type: TTTLS13::Message::ContentType::HANDSHAKE,
95
+ messages: [ch],
96
+ cipher: TTTLS13::Cryptograph::Passer.new
97
+ )
98
+ )
99
+ @stack << ch
100
+
101
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
102
+ @stack << recv
103
+
104
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new) \
105
+ if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
106
+ recv
107
+ end
108
+
109
+ def send_2nd_ch_unchanged_ech(socket, hostname, ech_config)
110
+ conn, inner1, ch1, hrr, = TLS13Client.recv_hrr(socket, hostname, ech_config, @stack)
111
+ # send 2nd ClientHello with unchanged ech
112
+ new_exs = TLS13Client.gen_newch_extensions(ch1, hrr)
113
+ new_exs[TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO] =
114
+ ch1.extensions[TTTLS13::Message::ExtensionType::ENCRYPTED_CLIENT_HELLO]
115
+ ch = TTTLS13::Message::ClientHello.new(
116
+ legacy_version: ch1.legacy_version,
117
+ random: ch1.random,
118
+ legacy_session_id: ch1.legacy_session_id,
119
+ cipher_suites: ch1.cipher_suites,
120
+ legacy_compression_methods: ch1.legacy_compression_methods,
121
+ extensions: new_exs
122
+ )
123
+ conn.send_record(
124
+ TTTLS13::Message::Record.new(
125
+ type: TTTLS13::Message::ContentType::HANDSHAKE,
126
+ messages: [ch],
127
+ cipher: TTTLS13::Cryptograph::Passer.new
128
+ )
129
+ )
130
+ @stack << inner1
131
+ @stack << ch
132
+
133
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
134
+ @stack << recv
135
+
136
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new) \
137
+ if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
138
+ recv
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,83 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec7_1_1_5 < WithSocket
4
+ # ClientHelloOuterAAD is computed as described in Section 5.2, but
5
+ # using the second ClientHelloOuter. If decryption fails, the client-
6
+ # facing server MUST abort the handshake with a "decrypt_error" alert.
7
+ # Otherwise, it reconstructs the second ClientHelloInner from the new
8
+ # EncodedClientHelloInner as described in Section 5.1, using the
9
+ # second ClientHelloOuter for any referenced extensions.
10
+ #
11
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-7.1.1-5
12
+
13
+ # @return [EchSpec::SpecGroup]
14
+ def self.spec_group
15
+ SpecGroup.new(
16
+ '7.1.1-5',
17
+ [
18
+ SpecCase.new(
19
+ 'MUST abort with a "decrypt_error" alert, if fails to decrypt 2nd ClientHelloOuter.',
20
+ method(:validate_undecryptable_2nd_ch_outer)
21
+ )
22
+ ]
23
+ )
24
+ end
25
+
26
+ # @param hostname [String]
27
+ # @param port [Integer]
28
+ # @param ech_config [ECHConfig]
29
+ #
30
+ # @return [EchSpec::Ok | Err]
31
+ def self.validate_undecryptable_2nd_ch_outer(hostname, port, ech_config)
32
+ Spec7_1_1_5.new.do_validate_undecryptable_2nd_ch_outer(hostname, port, ech_config)
33
+ end
34
+
35
+ # @param hostname [String]
36
+ # @param port [Integer]
37
+ # @param ech_config [ECHConfig]
38
+ #
39
+ # @return [EchSpec::Ok | Err]
40
+ def do_validate_undecryptable_2nd_ch_outer(hostname, port, ech_config)
41
+ with_socket(hostname, port) do |socket|
42
+ recv = send_2nd_ch_with_undecryptable_ech(socket, hostname, ech_config)
43
+ return Err.new('did not send expected alert: decrypt_error', message_stack) \
44
+ unless Spec.expect_alert(recv, :decrypt_error)
45
+
46
+ Ok.new(nil)
47
+ end
48
+ end
49
+
50
+ def send_2nd_ch_with_undecryptable_ech(socket, hostname, ech_config)
51
+ conn, _inner1, ch1, hrr, ech_state = TLS13Client.recv_hrr(socket, hostname, ech_config, @stack)
52
+ # send 2nd ClientHello with undecryptable ech
53
+ new_exs = TLS13Client.gen_newch_extensions(ch1, hrr)
54
+ inner = TTTLS13::Message::ClientHello.new(
55
+ legacy_version: ch1.legacy_version,
56
+ random: ch1.random,
57
+ legacy_session_id: ch1.legacy_session_id,
58
+ cipher_suites: ch1.cipher_suites,
59
+ legacy_compression_methods: ch1.legacy_compression_methods,
60
+ extensions: new_exs
61
+ )
62
+ ech_state.ctx.increment_seq # invalidly increment of the sequence number
63
+ ch, inner = TTTLS13::Ech.offer_new_ech(inner, ech_state)
64
+ conn.send_record(
65
+ TTTLS13::Message::Record.new(
66
+ type: TTTLS13::Message::ContentType::HANDSHAKE,
67
+ messages: [ch],
68
+ cipher: TTTLS13::Cryptograph::Passer.new
69
+ )
70
+ )
71
+ @stack << inner
72
+ @stack << ch
73
+
74
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new)
75
+ @stack << recv
76
+
77
+ recv, = conn.recv_message(TTTLS13::Cryptograph::Passer.new) \
78
+ if recv.is_a?(TTTLS13::Message::ChangeCipherSpec)
79
+ recv
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,113 @@
1
+ module EchSpec
2
+ module Spec
3
+ class Spec9
4
+ # In the absence of an application profile standard specifying
5
+ # otherwise, a compliant ECH application MUST implement the following
6
+ # HPKE cipher suite:
7
+ #
8
+ # * KEM: DHKEM(X25519, HKDF-SHA256) (see Section 7.1 of [HPKE])
9
+ # * KDF: HKDF-SHA256 (see Section 7.2 of [HPKE])
10
+ # * AEAD: AES-128-GCM (see Section 7.3 of [HPKE])
11
+ #
12
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-22#section-9
13
+ @section = '9'
14
+ @description = 'MUST implement the following HPKE cipher suite: KEM: DHKEM(X25519, HKDF-SHA256), KDF: HKDF-SHA256 and AEAD: AES-128-GCM.'
15
+ class << self
16
+ attr_reader :description, :section
17
+
18
+ # @param fpath [String | NilClass]
19
+ # @param hostname [String]
20
+ # @param force_compliant [Boolean]
21
+ #
22
+ # @return [EchSpec::Ok<ECHConfig> | Err]
23
+ def try_get_ech_config(fpath, hostname, force_compliant)
24
+ result = if fpath.nil?
25
+ resolve_ech_configs(hostname)
26
+ else
27
+ parse_pem(File.open(fpath).read)
28
+ end
29
+
30
+ ech_configs = case result
31
+ in Ok(obj)
32
+ obj
33
+ in Err
34
+ return result
35
+ end
36
+
37
+ if force_compliant
38
+ validate_compliant_ech_configs(ech_configs)
39
+ else
40
+ Ok.new(ech_configs.first)
41
+ end
42
+ end
43
+
44
+ # @param ech_configs [Array of ECHConfig]
45
+ #
46
+ # @return [EchSpec::Ok<ECHConfig> | Err]
47
+ def validate_compliant_ech_configs(ech_configs)
48
+ ech_config = ech_configs.find do |c|
49
+ kconfig = c.echconfig_contents.key_config
50
+ valid_kem_id = kconfig.kem_id.uint16 == 0x0020
51
+ valid_cipher_suite = kconfig.cipher_suites.any? do |cs|
52
+ cs.kdf_id.uint16 == 0x0001 && cs.aead_id.uint16 == 0x0001
53
+ end
54
+
55
+ valid_kem_id && valid_cipher_suite
56
+ end
57
+ return Ok.new(ech_config) unless ech_config.nil?
58
+
59
+ Err.new('ECHConfigs does NOT include HPKE cipher suite: KEM: DHKEM(X25519, HKDF-SHA256), KDF: HKDF-SHA256 and AEAD: AES-128-GCM.', nil)
60
+ end
61
+
62
+ # @param hostname [String]
63
+ #
64
+ # @return [EchSpec::Ok<Array of ECHConfig> | Err]
65
+ def resolve_ech_configs(hostname)
66
+ begin
67
+ rr = Resolv::DNS.new.getresource(
68
+ hostname,
69
+ Resolv::DNS::Resource::IN::HTTPS
70
+ )
71
+ rescue Resolv::ResolvError => e
72
+ return Err.new(e.message, nil)
73
+ end
74
+
75
+ # https://datatracker.ietf.org/doc/html/draft-ietf-tls-svcb-ech-01#section-6
76
+ ech = 5
77
+ return Err.new("HTTPS resource record for #{hostname} does NOT have ech SvcParams.", nil) if rr.params[ech].nil?
78
+
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
82
+
83
+ Ok.new(ECHConfig.decode_vectors(octet.slice(2..)))
84
+ end
85
+
86
+ # @param pem [String]
87
+ #
88
+ # @return [EchSpec::Ok<Array of ECHConfig> | Err]
89
+ def parse_pem(pem)
90
+ s = pem.scan(/-----BEGIN ECHCONFIG-----(.*)-----END ECHCONFIG-----/m)
91
+ .first
92
+ .first
93
+ .gsub("\n", '')
94
+ b = Base64.decode64(s)
95
+ ech_configs = ECHConfig.decode_vectors(b.slice(2..))
96
+ Ok.new(ech_configs)
97
+ rescue StandardError
98
+ # https://datatracker.ietf.org/doc/html/draft-farrell-tls-pemesni-08#section-3
99
+ example = <<~PEM
100
+ -----BEGIN PRIVATE KEY-----
101
+ MC4CAQAwBQYDK2VuBCIEICjd4yGRdsoP9gU7YT7My8DHx1Tjme8GYDXrOMCi8v1V
102
+ -----END PRIVATE KEY-----
103
+ -----BEGIN ECHCONFIG-----
104
+ AD7+DQA65wAgACA8wVN2BtscOl3vQheUzHeIkVmKIiydUhDCliA4iyQRCwAEAAEA
105
+ AQALZXhhbXBsZS5jb20AAA==
106
+ -----END ECHCONFIG-----
107
+ PEM
108
+ Err.new("Failed to parse ECHConfig PEM file, expected ECHConfig PEM like following: \n\n#{example}", nil)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end