raioquic 0.1.0

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.
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