raioquic 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.containerignore +4 -0
  3. data/.rubocop.yml +93 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/Containerfile +6 -0
  7. data/Gemfile +24 -0
  8. data/Gemfile.lock +113 -0
  9. data/LICENSE +28 -0
  10. data/README.md +48 -0
  11. data/Rakefile +16 -0
  12. data/Steepfile +8 -0
  13. data/example/curlcatcher.rb +18 -0
  14. data/example/interoperability/README.md +9 -0
  15. data/example/interoperability/aioquic/aioquic_client.py +47 -0
  16. data/example/interoperability/aioquic/aioquic_server.py +34 -0
  17. data/example/interoperability/key.pem +28 -0
  18. data/example/interoperability/localhost-unasuke-dev.crt +21 -0
  19. data/example/interoperability/quic-go/sample_server.go +61 -0
  20. data/example/interoperability/raioquic_client.rb +42 -0
  21. data/example/interoperability/raioquic_server.rb +43 -0
  22. data/example/parse_curl_example.rb +108 -0
  23. data/lib/raioquic/buffer.rb +202 -0
  24. data/lib/raioquic/core_ext.rb +54 -0
  25. data/lib/raioquic/crypto/README.md +5 -0
  26. data/lib/raioquic/crypto/aesgcm.rb +52 -0
  27. data/lib/raioquic/crypto/backend/aead.rb +52 -0
  28. data/lib/raioquic/crypto/backend.rb +12 -0
  29. data/lib/raioquic/crypto.rb +10 -0
  30. data/lib/raioquic/quic/configuration.rb +81 -0
  31. data/lib/raioquic/quic/connection.rb +2776 -0
  32. data/lib/raioquic/quic/crypto.rb +317 -0
  33. data/lib/raioquic/quic/event.rb +69 -0
  34. data/lib/raioquic/quic/logger.rb +272 -0
  35. data/lib/raioquic/quic/packet.rb +471 -0
  36. data/lib/raioquic/quic/packet_builder.rb +301 -0
  37. data/lib/raioquic/quic/rangeset.rb +113 -0
  38. data/lib/raioquic/quic/recovery.rb +528 -0
  39. data/lib/raioquic/quic/stream.rb +343 -0
  40. data/lib/raioquic/quic.rb +20 -0
  41. data/lib/raioquic/tls.rb +1659 -0
  42. data/lib/raioquic/version.rb +5 -0
  43. data/lib/raioquic.rb +12 -0
  44. data/misc/export_x25519.py +43 -0
  45. data/misc/gen_rfc8448_keypair.rb +90 -0
  46. data/raioquic.gemspec +37 -0
  47. data/sig/raioquic/buffer.rbs +37 -0
  48. data/sig/raioquic/core_ext.rbs +7 -0
  49. data/sig/raioquic/crypto/aesgcm.rbs +20 -0
  50. data/sig/raioquic/crypto/backend/aead.rbs +11 -0
  51. data/sig/raioquic/quic/configuration.rbs +34 -0
  52. data/sig/raioquic/quic/connection.rbs +277 -0
  53. data/sig/raioquic/quic/crypto.rbs +88 -0
  54. data/sig/raioquic/quic/event.rbs +51 -0
  55. data/sig/raioquic/quic/logger.rbs +57 -0
  56. data/sig/raioquic/quic/packet.rbs +157 -0
  57. data/sig/raioquic/quic/packet_builder.rbs +76 -0
  58. data/sig/raioquic/quic/rangeset.rbs +17 -0
  59. data/sig/raioquic/quic/recovery.rbs +142 -0
  60. data/sig/raioquic/quic/stream.rbs +87 -0
  61. data/sig/raioquic/tls.rbs +444 -0
  62. data/sig/raioquic.rbs +9 -0
  63. metadata +121 -0
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../crypto"
4
+ require_relative "packet"
5
+ require "tttls1.3"
6
+ require "openssl"
7
+
8
+ module Raioquic
9
+ module Quic
10
+ # Raioquic::Quic::Crypto
11
+ # Migrated from these files
12
+ # - aioquic/src/aioquic/quic/crypto.py
13
+ # - aioquic/src/aioquic/_crypto.c
14
+ module Crypto
15
+ INITIAL_CIPHER_SUITE = nil
16
+ AEAD_KEY_LENGTH_MAX = 32
17
+ AEAD_NONCE_LENGTH = 12
18
+ AEAD_TAG_LENGTH = 16
19
+ PACKET_LENGTH_MAX = 1500
20
+ PACKET_NUMBER_LENGTH_MAX = 4
21
+ SAMPLE_LENGTH = 16
22
+ INITIAL_SALT_VERSION_1 = ["38762cf7f55934b34d179ae6a4c80cadccbb7f0a"].pack("H*")
23
+
24
+ # class CryptoError < ::StandardError; end
25
+ CryptoError = Class.new(StandardError)
26
+
27
+ def derive_key_iv_hp(_cipher_suite, secret)
28
+ # TODO: implement on TLS module
29
+ [
30
+ TTTLS13::KeySchedule.hkdf_expand_label(secret, "quic key", "", 16, "SHA256"),
31
+ TTTLS13::KeySchedule.hkdf_expand_label(secret, "quic iv", "", 12, "SHA256"),
32
+ TTTLS13::KeySchedule.hkdf_expand_label(secret, "quic hp", "", 16, "SHA256"),
33
+ ]
34
+ end
35
+ module_function :derive_key_iv_hp
36
+
37
+ def xor_str(a, b) # rubocop:disable Naming/MethodParameterName
38
+ a.unpack("C*").zip(b.unpack("C*")).map { |x, y| x ^ y }.pack("C*")
39
+ end
40
+ module_function :xor_str
41
+
42
+ class NoCallback
43
+ def call(_); end
44
+ end
45
+
46
+ # CryptoContext
47
+ # represent sender or receiver crypto context
48
+ class CryptoContext
49
+ attr_reader :aead
50
+ attr_reader :key_phase
51
+ attr_reader :secret
52
+
53
+ def initialize(key_phase: 0, setup_cb: NoCallback.new, teardown_cb: NoCallback.new)
54
+ @aead = nil
55
+ @cipher_suite = nil
56
+ @hp = nil
57
+ @key_phase = key_phase
58
+ @secret = nil
59
+ @version = nil
60
+ @setup_cb = setup_cb
61
+ @teardown_cb = teardown_cb
62
+ end
63
+
64
+ def decrypt_packet(packet:, encrypted_offset:, expected_packet_number:)
65
+ raise ArgumentError unless @aead # TODO: KeyUnavailableError
66
+
67
+ # header protection
68
+ plain_header, packet_number, = @hp.remove(packet: packet, encrypted_offset: encrypted_offset)
69
+ first_byte = plain_header[0].unpack1("C*")
70
+
71
+ # packet number
72
+ pn_length = (first_byte & 0x03) + 1
73
+ packet_number = Packet.decode_packet_number(truncated: packet_number, num_bits: pn_length * 8, expected: expected_packet_number)
74
+
75
+ # detect key phase change
76
+ crypto = self
77
+ unless Packet.is_long_header(first_byte)
78
+ key_phase = (first_byte & 4) >> 2
79
+ crypto = next_key_phase if key_phase != @key_phase
80
+ end
81
+ payload = crypto.aead.decrypt(data: packet[plain_header.length..], associated_data: plain_header, packet_number: packet_number)
82
+
83
+ return [plain_header, payload, packet_number, crypto != self]
84
+ end
85
+
86
+ def encrypt_packet(plain_header:, plain_payload:, packet_number:)
87
+ raise RuntimeError unless is_valid
88
+
89
+ protected_payload = @aead.encrypt(data: plain_payload, associated_data: plain_header, packet_number: packet_number)
90
+ return @hp.apply(plain_header: plain_header, protected_payload: protected_payload)
91
+ end
92
+
93
+ def is_valid
94
+ !!@aead
95
+ end
96
+
97
+ def setup(cipher_suite:, secret:, version:)
98
+ hp_cipher_name = "aes-128-ecb" # TODO: hardcode
99
+ _aead_cipher_name = OpenSSL::Digest.new("SHA256") # TODO: hardcode
100
+
101
+ key, iv, hp = ::Raioquic::Quic::Crypto.derive_key_iv_hp(cipher_suite, secret)
102
+ # binding.b
103
+ @aead = AEAD.new(cipher_name: "aes-128-gcm", key: key, iv: iv) # TODO: hardcode
104
+ @cipher_suite = cipher_suite
105
+ @hp = HeaderProtection.new(cipher_name: hp_cipher_name, key: hp)
106
+ @secret = secret
107
+ @version = version
108
+
109
+ # trigger callback
110
+ @setup_cb&.call("tls")
111
+ end
112
+
113
+ def teardown
114
+ @aead = nil
115
+ @cipher_suite = nil
116
+ @hp = nil
117
+
118
+ # trigger callback
119
+ @teardown_cb&.call("tls")
120
+ end
121
+
122
+ def apply_key_phase(crypto, _trigger)
123
+ @aead = crypto.aead
124
+ @key_phase = crypto.key_phase
125
+ @secret = crypto.secret
126
+
127
+ # trigger callback
128
+ @setup_cb&.call("tls")
129
+ end
130
+
131
+ def next_key_phase
132
+ crypto = self.class.new(key_phase: (@key_phase.zero? ? 1 : 0))
133
+ crypto.setup(
134
+ cipher_suite: "aes-128-gcm", # TODO: hardcode
135
+ secret: TTTLS13::KeySchedule.hkdf_expand_label(@secret, "quic ku", "", 32, "SHA256"),
136
+ version: Packet::QuicProtocolVersion::VERSION_1,
137
+ )
138
+ return crypto
139
+ end
140
+ end
141
+
142
+ # CryptoPair
143
+ # store sender and receiver crypto context object
144
+ class CryptoPair
145
+ attr_reader :recv
146
+ attr_reader :send
147
+ attr_reader :aead_tag_size
148
+
149
+ def initialize(recv_setup_cb: NoCallback.new, recv_teardown_cb: NoCallback.new, send_setup_cb: NoCallback.new, send_teardown_cb: NoCallback.new) # rubocop:disable Layout/LineLength
150
+ @aead_tag_size = 16
151
+ @recv = CryptoContext.new(setup_cb: recv_setup_cb, teardown_cb: recv_teardown_cb)
152
+ @send = CryptoContext.new(setup_cb: send_setup_cb, teardown_cb: send_teardown_cb)
153
+ @update_key_requested = false
154
+ end
155
+
156
+ def decrypt_packet(packet:, encrypted_offset:, expected_packet_number:)
157
+ plain_header, payload, packe_number, need_update_key = @recv.decrypt_packet(
158
+ packet: packet, encrypted_offset: encrypted_offset, expected_packet_number: expected_packet_number,
159
+ )
160
+ _update_key("remote_update") if need_update_key
161
+
162
+ return [plain_header, payload, packe_number]
163
+ end
164
+
165
+ def encrypt_packet(plain_header:, plain_payload:, packet_number:)
166
+ _update_key("local_update") if @update_key_requested
167
+
168
+ return @send.encrypt_packet(plain_header: plain_header, plain_payload: plain_payload, packet_number: packet_number)
169
+ end
170
+
171
+ def setup_initial(cid:, is_client:, version:)
172
+ if is_client
173
+ recv_label = "server in"
174
+ send_label = "client in"
175
+ else
176
+ recv_label = "client in"
177
+ send_label = "server in"
178
+
179
+ end
180
+ initial_salt = INITIAL_SALT_VERSION_1
181
+ # algorithm = TLS.cipher_suite_hash(INITIAL_CIPHER_SUITE) TODO: impleement on tls module
182
+ algorithm = OpenSSL::Digest.new("SHA256")
183
+ # initial_secret = hkdf_extract(algorithm, initial_salt, cid) TODO: implement on tls module
184
+ initial_secret = OpenSSL::HMAC.digest(algorithm, initial_salt, cid)
185
+ @recv.setup(
186
+ cipher_suite: INITIAL_CIPHER_SUITE,
187
+ secret: TTTLS13::KeySchedule.hkdf_expand_label(initial_secret, recv_label, "", 32, "SHA256"),
188
+ version: version,
189
+ )
190
+ @send.setup(
191
+ cipher_suite: INITIAL_CIPHER_SUITE,
192
+ secret: TTTLS13::KeySchedule.hkdf_expand_label(initial_secret, send_label, "", 32, "SHA256"),
193
+ version: version,
194
+ )
195
+ end
196
+
197
+ def teardown
198
+ @recv.teardown
199
+ @send.teardown
200
+ end
201
+
202
+ def key_phase
203
+ if @update_key_requested
204
+ @recv.key_phase.zero? ? 1 : 0
205
+ else
206
+ @recv.key_phase
207
+ end
208
+ end
209
+
210
+ def update_key
211
+ @update_key_requested = true
212
+ end
213
+
214
+ def _update_key(trigger)
215
+ # binding.irb
216
+ @recv.apply_key_phase(@recv.next_key_phase, trigger)
217
+ @send.apply_key_phase(@send.next_key_phase, trigger)
218
+ @update_key_requested = false
219
+ end
220
+ end
221
+
222
+ # HeaderProtection
223
+ # remove/apply QUIC header protection
224
+ class HeaderProtection
225
+ def initialize(cipher_name:, key:)
226
+ @cipher = OpenSSL::Cipher.new(cipher_name)
227
+ @cipher.encrypt
228
+ @cipher.key = key
229
+ @key = key
230
+ @mask = "\x00" * 31
231
+ @zero = "\x00" * 5
232
+ end
233
+
234
+ def apply(plain_header:, protected_payload:)
235
+ pn_length = (plain_header[0].unpack1("C*") & 0x03) + 1
236
+ pn_offset = plain_header.length - pn_length
237
+ mask(protected_payload.slice((PACKET_NUMBER_LENGTH_MAX - pn_length)..-1)[0, SAMPLE_LENGTH])
238
+ buffer = plain_header + protected_payload
239
+ if buffer[0].unpack1("C*") & 0x80 != 0 # rubocop:disable Style/NegatedIfElseCondition, Style/ConditionalAssignment
240
+ buffer[0] = Crypto.xor_str(buffer[0], [@mask[0].unpack1("C*") & 0x0f].pack("C*"))
241
+ else
242
+ buffer[0] = Crypto.xor_str(buffer[0], [@mask[0].unpack1("C*") & 0x1f].pack("C*"))
243
+ end
244
+ pn_length.times do |i|
245
+ buffer[pn_offset + i] = Crypto.xor_str(buffer[pn_offset + i], @mask[1 + i])
246
+ end
247
+ return buffer
248
+ end
249
+
250
+ def remove(packet:, encrypted_offset:)
251
+ mask(packet.slice(encrypted_offset + PACKET_NUMBER_LENGTH_MAX, SAMPLE_LENGTH))
252
+ buffer = packet.dup.slice(0, encrypted_offset + PACKET_NUMBER_LENGTH_MAX)
253
+ if buffer[0].unpack1("C*") & 0x80 != 0 # rubocop:disable Style/NegatedIfElseCondition, Style/ConditionalAssignment
254
+ buffer[0] = Crypto.xor_str(buffer[0], [@mask[0].unpack1("C*") & 0x0f].pack("C*"))
255
+ else
256
+ buffer[0] = Crypto.xor_str(buffer[0], [@mask[0].unpack1("C*") & 0x1f].pack("C*"))
257
+ end
258
+ pn_length = (buffer[0].unpack1("C*") & 0x03) + 1
259
+ pn_truncated = 0
260
+ pn_length.times do |i|
261
+ buffer[encrypted_offset + i] = Crypto.xor_str(buffer[encrypted_offset + i], @mask[1 + i])
262
+ pn_truncated = buffer[encrypted_offset + i].unpack1("C*") | (pn_truncated << 8)
263
+ end
264
+ [buffer.slice(0, encrypted_offset + pn_length), pn_truncated]
265
+ end
266
+
267
+ private def mask(sample)
268
+ # if chacha20 TODO: chacha20
269
+ @mask = @cipher.update(sample) + @cipher.final
270
+ end
271
+ end
272
+
273
+ # AEAD
274
+ # encrypt/dectypt AEAD
275
+ class AEAD
276
+ def initialize(cipher_name:, key:, iv:) # rubocop:disable Naming/MethodParameterName
277
+ @cipher = OpenSSL::Cipher.new(cipher_name)
278
+ @cipher_name = cipher_name
279
+ @key = key
280
+ @iv = iv
281
+ end
282
+
283
+ def decrypt(data:, associated_data:, packet_number:)
284
+ raise CryptoError, "Invalid payload length" if data.length < AEAD_TAG_LENGTH || data.length > PACKET_LENGTH_MAX
285
+
286
+ nonce = @iv.dup
287
+ 8.times do |i|
288
+ nonce[AEAD_NONCE_LENGTH - 1 - i] = Crypto.xor_str(nonce[AEAD_NONCE_LENGTH - 1 - i], [packet_number >> (8 * i)].pack("C*"))
289
+ end
290
+ @cipher.decrypt
291
+ @cipher.key = @key
292
+ @cipher.iv = nonce
293
+ @cipher.auth_tag = data.slice(data.length - AEAD_TAG_LENGTH, AEAD_TAG_LENGTH)
294
+ @cipher.auth_data = associated_data
295
+ decrypted = @cipher.update(data.slice(0...(data.length - AEAD_TAG_LENGTH))) + @cipher.final
296
+ return decrypted
297
+ end
298
+
299
+ def encrypt(data:, associated_data:, packet_number:)
300
+ raise CryptoError, "Invalid payload length" if data.length > PACKET_LENGTH_MAX
301
+
302
+ nonce = @iv.dup
303
+ 8.times do |i|
304
+ nonce[AEAD_NONCE_LENGTH - 1 - i] = Crypto.xor_str(nonce[AEAD_NONCE_LENGTH - 1 - i], [packet_number >> (8 * i)].pack("C*"))
305
+ end
306
+
307
+ @cipher.encrypt
308
+ @cipher.key = @key
309
+ @cipher.iv = nonce
310
+ @cipher.auth_data = associated_data
311
+ encrypted = @cipher.update(data) + @cipher.final
312
+ return encrypted + @cipher.auth_tag
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raioquic
4
+ module Quic
5
+ module Event
6
+ # Base class for QUIC events.
7
+ # Should be implement by Struct?
8
+ class QuicEvent
9
+ def ==(other)
10
+ return false if self.class != other.class
11
+
12
+ return false if instance_variables != other.instance_variables
13
+
14
+ return instance_variables.all? { |iver| instance_variable_get(iver) == other.instance_variable_get(iver) }
15
+ end
16
+ end
17
+
18
+ class ConnectionIdIssued < QuicEvent
19
+ attr_accessor :connection_id
20
+ end
21
+
22
+ class ConnectionIdRetired < QuicEvent
23
+ attr_accessor :connection_id
24
+ end
25
+
26
+ # The ConnectionTerminated event is fired when the QUIC connection is terminated.
27
+ class ConnectionTerminated < QuicEvent
28
+ attr_accessor :error_code # The error code which was specified when closing the connection.
29
+ attr_accessor :frame_type # The frame type which caused the connection to be closed, or `None`.
30
+ attr_accessor :reason_phrase # The human-readable reason for which the connection was closed.
31
+ end
32
+
33
+ # The DatagramFrameReceived event is fired when a DATAGRAM frame is received.
34
+ class DatagramFrameReceived < QuicEvent
35
+ attr_accessor :data # The data which was received.
36
+ end
37
+
38
+ # The HandshakeCompleted event is fired when the TLS handshake completes.
39
+ class HandshakeCompleted < QuicEvent
40
+ attr_accessor :alpn_protocol # The protocol which was negotiated using ALPN, or `nil`.
41
+ attr_accessor :early_data_accepted # WHether early (0-RTT) data was accepted by the remote peer.
42
+ attr_accessor :session_resumed # Whether a TLS session was resumed.
43
+ end
44
+
45
+ # The PingAcknowledged event is fired when a PING frame is acknowledged.
46
+ class PingAcknowledged < QuicEvent
47
+ attr_accessor :uid # The unique ID of the PING.
48
+ end
49
+
50
+ # The ProtocolNegotiated event is fired when when ALPN negotiation completes.
51
+ class ProtocolNegotiated < QuicEvent
52
+ attr_accessor :alpn_protocol # The protocol which was negotiated using ALPN, or `nil`.
53
+ end
54
+
55
+ # The StreamDataReceived event is fired whenever data is received on a stream.
56
+ class StreamDataReceived < QuicEvent
57
+ attr_accessor :data # The data which was received.
58
+ attr_accessor :end_stream # Whether the STREAM frame has the FIN bit set.
59
+ attr_accessor :stream_id # The ID of the stream the data was received for.
60
+ end
61
+
62
+ # The StreamReset event is fired when the remote peer resets a stream.
63
+ class StreamReset < QuicEvent
64
+ attr_accessor :error_code # The error code that triggered the reset.
65
+ attr_accessor :stream_id # The ID of the stream that was reset.
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Raioquic
6
+ module Quic
7
+ module Logger
8
+ QLOG_VERSION = "0.3"
9
+
10
+ # A QUIC event trace.
11
+ #
12
+ # Events are logged in the format defined by qlog.
13
+ #
14
+ # See:
15
+ # - https://datatracker.ietf.org/doc/html/draft-ietf-quic-qlog-main-schema-02
16
+ # - https://datatracker.ietf.org/doc/html/draft-marx-quic-qlog-quic-events
17
+ # - https://datatracker.ietf.org/doc/html/draft-marx-quic-qlog-h3-events
18
+ class QuicLoggerTrace
19
+ PACKET_TYPE_NAMES = {
20
+ Quic::Packet::PACKET_TYPE_INITIAL => "initial",
21
+ Quic::Packet::PACKET_TYPE_HANDSHAKE => "handshake",
22
+ Quic::Packet::PACKET_TYPE_ZERO_RTT => "0RTT",
23
+ Quic::Packet::PACKET_TYPE_ONE_RTT => "1RTT",
24
+ Quic::Packet::PACKET_TYPE_RETRY => "retry",
25
+ }
26
+
27
+ attr_reader :is_client
28
+
29
+ def initialize(is_client:, odcid:)
30
+ @odcid = odcid
31
+ @events = []
32
+ @is_client = is_client
33
+ @vantage_point = {
34
+ name: "raioquic(aioquic porting)",
35
+ type: (is_client ? "client" : "server"),
36
+ }
37
+ end
38
+
39
+ def encode_ack_frame(ranges:, delay:)
40
+ {
41
+ ack_delay: encode_time(delay),
42
+ acked_ranges: ranges.list.map { |x| [x.first, x.last - 1] },
43
+ frame_type: "ack",
44
+ }
45
+ end
46
+
47
+ def encode_connection_close_frame(error_code:, frame_type: nil, reason_phrase:)
48
+ attrs = {
49
+ error_code: error_code,
50
+ error_space: (frame_type ? "transport" : "application"),
51
+ frame_type: "connection_close",
52
+ raw_error_code: error_code,
53
+ reason: reason_phrase,
54
+ }
55
+ attrs[:trigger_frame_type] = frame_type if frame_type
56
+ attrs
57
+ end
58
+
59
+ def encode_connection_limit_frame(frame_type:, maximum:)
60
+ if frame_type == Quic::Packet::QuicFrameType::MAX_DATA
61
+ { frame_type: "max_data", maximum: maximum }
62
+ else
63
+ {
64
+ frame_type: "max_streams",
65
+ maximum: maximum,
66
+ stream_type: (frame_type == Quic::Packet::QuicFrameType::MAX_STREAMS_UNI ? "unidirectional" : "bidirectional"),
67
+ }
68
+ end
69
+ end
70
+
71
+ def encode_crypto_frame(frame:)
72
+ {
73
+ frame_type: "crypto",
74
+ length: frame.data.bytesize,
75
+ offset: frame.offset,
76
+ }
77
+ end
78
+
79
+ def encode_data_blocked_frame(limit:)
80
+ { frame_type: "data_blocked", limit: limit }
81
+ end
82
+
83
+ def encode_datagram_frame(length:)
84
+ { frame_type: "datagram", length: length }
85
+ end
86
+
87
+ def encode_handshake_done_frame
88
+ { frame_type: "handshake_done" }
89
+ end
90
+
91
+ def encode_max_stream_data_frame(maximum:, stream_id:)
92
+ {
93
+ frame_type: "max_stream_data",
94
+ maximum: maximum,
95
+ stream_id: stream_id,
96
+ }
97
+ end
98
+
99
+ def encode_new_connection_id_frame(connection_id:, retire_prior_to:, sequence_number:, stateless_reset_token:)
100
+ {
101
+ connection_id: connection_id.unpack1("H*"),
102
+ frame_type: "new_connection_id",
103
+ length: connection_id.bytesize,
104
+ reset_token: stateless_reset_token.unpack1("H*"),
105
+ retire_prior_to: retire_prior_to,
106
+ sequence_number: sequence_number,
107
+ }
108
+ end
109
+
110
+ def encode_new_token_frame(token:)
111
+ {
112
+ frame_type: "new_token",
113
+ length: token.bytesize,
114
+ token: token.unpack1("H*"),
115
+ }
116
+ end
117
+
118
+ def encode_padding_frame
119
+ { frame_type: "padding" }
120
+ end
121
+
122
+ def encode_path_challenge_frame(data:)
123
+ { frame_type: "path_challenge", data: data.unpack1("H*") }
124
+ end
125
+
126
+ def encode_path_response_frame(data:)
127
+ { frame_type: "path_response", data: data.unpack1("H*") }
128
+ end
129
+
130
+ def encode_ping_frame
131
+ { frame_type: "ping" }
132
+ end
133
+
134
+ def encode_reset_stream_frame(error_code:, final_size:, stream_id:)
135
+ {
136
+ error_code: error_code,
137
+ final_size: final_size,
138
+ frame_type: "reset_stream",
139
+ stream_id: stream_id,
140
+ }
141
+ end
142
+
143
+ def encode_retire_connection_id_frame(sequence_number:)
144
+ {
145
+ frame_type: "retire_connection_id",
146
+ sequence_number: sequence_number,
147
+ }
148
+ end
149
+
150
+ def encode_stream_data_blocked_frame(limit:, stream_id:)
151
+ {
152
+ frame_type: "stream_data_blocked",
153
+ limit: limit,
154
+ stream_id: stream_id,
155
+ }
156
+ end
157
+
158
+ def encode_stop_sending_frame(error_code:, stream_id:)
159
+ {
160
+ frame_type: "stop_sending",
161
+ error_code: error_code,
162
+ stream_id: stream_id,
163
+ }
164
+ end
165
+
166
+ def encode_stream_frame(frame:, stream_id:)
167
+ {
168
+ fin: frame.fin,
169
+ frame_type: "stream",
170
+ length: frame.data.bytesize,
171
+ offset: frame.offset,
172
+ stream_id: stream_id,
173
+ }
174
+ end
175
+
176
+ def encode_streams_blocked_frame(is_unidirectional:, limit:)
177
+ {
178
+ frame_type: "streams_blocked",
179
+ limit: limit,
180
+ stream_type: (is_unidirectional ? "unidirectional" : "bidirectional"),
181
+ }
182
+ end
183
+
184
+ # Convert a time to milliseconds.
185
+ def encode_time(seconds)
186
+ seconds * 1000
187
+ end
188
+
189
+ def encode_transport_parameters(owner:, parameters:)
190
+ data = { owner: owner }
191
+ parameters.each_pair do |key, value|
192
+ if value.is_a?(TrueClass) || value.is_a?(FalseClass) || value.is_a?(Integer)
193
+ data[key] = value
194
+ elsif value.is_a?(String)
195
+ data[key] = value.unpack1("H*")
196
+ end
197
+ end
198
+ data
199
+ end
200
+
201
+ def packet_type(packet_type)
202
+ PACKET_TYPE_NAMES[packet_type & Quic::Packet::PACKET_TYPE_MASK] || "1RTT"
203
+ end
204
+
205
+ def log_event(category:, event:, data:)
206
+ @events << {
207
+ data: data,
208
+ name: "#{category}:#{event}",
209
+ time: encode_time(Time.now.to_f),
210
+ }
211
+ end
212
+
213
+ # Return the trace as a dictionary which can be written as JSON.
214
+ def to_dict
215
+ {
216
+ common_fields: {
217
+ ODCID: @odcid.unpack1("H*"),
218
+ },
219
+ events: @events,
220
+ vantage_point: @vantage_point,
221
+ }
222
+ end
223
+ end
224
+
225
+ class QuicLogger
226
+ def initialize
227
+ @traces = []
228
+ end
229
+
230
+ def start_trace(is_client:, odcid:)
231
+ @trace = QuicLoggerTrace.new(is_client: is_client, odcid: odcid)
232
+ @traces << @trace
233
+ @trace
234
+ end
235
+
236
+ def to_dict
237
+ {
238
+ qlog_format: "JSON",
239
+ qlog_version: QLOG_VERSION,
240
+ traces: @traces.map(&:to_dict),
241
+ }
242
+ end
243
+ end
244
+
245
+ # A QUIC event logger which writes one trace per file.
246
+ class QuicFileLogger < QuicLogger
247
+ def initialize(path:)
248
+ raise ValueError, "QUIC log output directory #{path} does not exist" unless File.directory?(path)
249
+
250
+ @path = path # TODO: path check
251
+ super()
252
+ end
253
+
254
+ def end_trace(trace)
255
+ return unless trace
256
+ trace_dict = trace.to_dict
257
+ trace_type = trace.is_client ? "client" : "server"
258
+ trace_path = File.join(@path, trace_dict[:common_fields][:ODCID] + "_#{trace_type}.qlog")
259
+ File.write(
260
+ trace_path,
261
+ JSON.generate({
262
+ qlog_format: "JSON",
263
+ qlog_version: QLOG_VERSION,
264
+ traces: [trace_dict],
265
+ }))
266
+ idx = @traces.find_index(trace)
267
+ @traces.delete_at(idx) if idx
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end