butler-http 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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +659 -0
  5. data/Rakefile +10 -0
  6. data/lib/butler/async.rb +130 -0
  7. data/lib/butler/body.rb +130 -0
  8. data/lib/butler/client.rb +206 -0
  9. data/lib/butler/configuration.rb +161 -0
  10. data/lib/butler/connection.rb +21 -0
  11. data/lib/butler/connection_pool.rb +64 -0
  12. data/lib/butler/errors.rb +78 -0
  13. data/lib/butler/headers.rb +106 -0
  14. data/lib/butler/pipeline/chain.rb +18 -0
  15. data/lib/butler/pipeline/context.rb +21 -0
  16. data/lib/butler/pipeline/middleware.rb +16 -0
  17. data/lib/butler/pipeline/middlewares/circuit_breaker_middleware.rb +21 -0
  18. data/lib/butler/pipeline/middlewares/retry_middleware.rb +67 -0
  19. data/lib/butler/pipeline/middlewares/security_middleware.rb +14 -0
  20. data/lib/butler/pipeline/middlewares/telemetry_middleware.rb +37 -0
  21. data/lib/butler/pipeline/middlewares/timeout_middleware.rb +17 -0
  22. data/lib/butler/quic/crypto/aead.rb +69 -0
  23. data/lib/butler/quic/crypto/header_protection.rb +51 -0
  24. data/lib/butler/quic/crypto/hkdf.rb +44 -0
  25. data/lib/butler/quic/crypto/key_schedule.rb +50 -0
  26. data/lib/butler/quic/packet.rb +113 -0
  27. data/lib/butler/quic/varint.rb +56 -0
  28. data/lib/butler/rails/notifications.rb +10 -0
  29. data/lib/butler/rails/railtie.rb +24 -0
  30. data/lib/butler/request.rb +92 -0
  31. data/lib/butler/resilience/backoff.rb +17 -0
  32. data/lib/butler/resilience/circuit_breaker.rb +120 -0
  33. data/lib/butler/resilience/deadline.rb +55 -0
  34. data/lib/butler/resilience/retry_policy.rb +76 -0
  35. data/lib/butler/resilience/timeout.rb +22 -0
  36. data/lib/butler/response.rb +81 -0
  37. data/lib/butler/security/host_policy.rb +29 -0
  38. data/lib/butler/security/limits.rb +39 -0
  39. data/lib/butler/security/redirect_policy.rb +28 -0
  40. data/lib/butler/security/tls.rb +51 -0
  41. data/lib/butler/stream.rb +44 -0
  42. data/lib/butler/telemetry/instrumentation.rb +59 -0
  43. data/lib/butler/telemetry/logger.rb +37 -0
  44. data/lib/butler/telemetry/open_telemetry_bridge.rb +46 -0
  45. data/lib/butler/testing/fake_transport.rb +29 -0
  46. data/lib/butler/testing/stub.rb +68 -0
  47. data/lib/butler/testing/stub_registry.rb +32 -0
  48. data/lib/butler/testing.rb +33 -0
  49. data/lib/butler/transport.rb +118 -0
  50. data/lib/butler/uri.rb +77 -0
  51. data/lib/butler/version.rb +3 -0
  52. data/lib/butler.rb +176 -0
  53. data/sig/butler/client.rbs +41 -0
  54. data/sig/butler/configuration.rbs +63 -0
  55. data/sig/butler/errors.rbs +67 -0
  56. data/sig/butler/headers.rbs +22 -0
  57. data/sig/butler/request.rbs +21 -0
  58. data/sig/butler/response.rbs +28 -0
  59. data/sig/butler.rbs +22 -0
  60. metadata +191 -0
@@ -0,0 +1,64 @@
1
+ # Unlike the Net::HTTP-based pool this replaces, Butler::ConnectionPool is
2
+ # not a socket-level checkout/checkin pool — Async::HTTP::Client already
3
+ # does per-origin pooling and HTTP/2 multiplexing internally (see
4
+ # butler/transport.rb). This is a thin, bounded registry of one memoized
5
+ # Butler::Connection per origin, so repeated requests to the same host
6
+ # reuse the same underlying Async::HTTP::Client instead of rebuilding one
7
+ # (and paying DNS/TCP/TLS setup again) on every call.
8
+ class Butler::ConnectionPool
9
+ Entry = Struct.new(:connection, :last_used_at)
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ @entries = {} # fingerprint => Entry, insertion-ordered (cheap LRU)
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def acquire(uri, config)
18
+ key = fingerprint(uri, config)
19
+ now = monotonic_now
20
+
21
+ @mutex.synchronize do
22
+ if (entry = @entries.delete(key))
23
+ if now - entry.last_used_at <= config.pool.idle_timeout
24
+ entry.last_used_at = now
25
+ @entries[key] = entry # touch: move to the end
26
+ return entry.connection
27
+ else
28
+ entry.connection.close # sat idle past pool.idle_timeout — rebuild fresh below rather than reuse
29
+ end
30
+ end
31
+
32
+ connection = Butler::Transport.current.build_connection(uri, config)
33
+ @entries[key] = Entry.new(connection, now)
34
+ evict_if_needed!
35
+ connection
36
+ end
37
+ end
38
+
39
+ def reset!
40
+ @mutex.synchronize do
41
+ @entries.each_value { |entry| entry.connection.close }
42
+ @entries.clear
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def fingerprint(uri, config)
49
+ transport_name = Butler::Transport.current.equal?(Butler::Testing::FakeTransport) ? "fake" : "real"
50
+ "#{uri.origin_key}|proxy=#{config.proxy}|http_version=#{config.http_version}|verify_tls=#{config.security.verify_tls}|transport=#{transport_name}"
51
+ end
52
+
53
+ def evict_if_needed!
54
+ max = @config.pool.max_connections
55
+ while @entries.size > max
56
+ _key, entry = @entries.shift
57
+ entry&.connection&.close
58
+ end
59
+ end
60
+
61
+ def monotonic_now
62
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
63
+ end
64
+ end
@@ -0,0 +1,78 @@
1
+ # Butler's error hierarchy. Every error Butler itself raises is a
2
+ # Butler::Errors::Error, so `rescue Butler::Errors::Error` is always a safe
3
+ # top-level catch-all for "something about this HTTP call failed."
4
+ #
5
+ # The shape mirrors the PRD's proposed hierarchy closely, with two additions
6
+ # not in that list: LimitExceeded (raised by Security::Limits, since response
7
+ # and header size caps need somewhere to live) and TooManyRedirectsError
8
+ # (carried over from the previous implementation).
9
+ module Butler::Errors
10
+ class Error < StandardError; end
11
+
12
+ # The request was misconfigured (bad option, invalid combination) before
13
+ # anything was sent over the wire.
14
+ class ConfigurationError < Error; end
15
+
16
+ # The request itself couldn't be built/sent as specified.
17
+ class RequestError < Error; end
18
+
19
+ class TooManyRedirectsError < RequestError; end
20
+
21
+ # Something went wrong at or below the transport layer, i.e. the request
22
+ # never received a well-formed HTTP response.
23
+ class TransportError < Error; end
24
+ class ConnectionError < TransportError; end
25
+ class TimeoutError < TransportError; end
26
+ class TLSError < TransportError; end
27
+
28
+ # A TLSError specifically caused by certificate verification failing
29
+ # (self-signed, expired, hostname mismatch, untrusted root — anything
30
+ # OpenSSL itself reports as "certificate verify failed"). Split out from
31
+ # the general TLSError deliberately: unlike a transient TLS handshake
32
+ # failure, a bad certificate won't become valid on the very next retry a
33
+ # few hundred milliseconds later, so RetryPolicy excludes this subclass
34
+ # specifically while still retrying TLSError generally. Still a TLSError
35
+ # (rescue TLSError still catches it) — only its retry eligibility differs.
36
+ class CertificateVerificationError < TLSError; end
37
+
38
+ class DNSFailure < TransportError; end
39
+ class ProtocolError < TransportError; end
40
+
41
+ # A well-formed HTTP response was received. Only raised when a call is
42
+ # made with raise_on_error: true; carries the Butler::Response so rescuers
43
+ # can still inspect status/body/headers.
44
+ class HTTPError < Error
45
+ attr_reader :response
46
+
47
+ def initialize(response)
48
+ @response = response
49
+ super("#{response.status} response for #{response.uri}")
50
+ end
51
+ end
52
+
53
+ class ClientError < HTTPError; end
54
+ class ServerError < HTTPError; end
55
+
56
+ # Resilience-layer errors.
57
+ class RetryExhausted < Error
58
+ attr_reader :cause_response, :attempts
59
+
60
+ def initialize(message, cause_response: nil, attempts: nil)
61
+ @cause_response = cause_response
62
+ @attempts = attempts
63
+ super(message)
64
+ end
65
+ end
66
+
67
+ class CircuitOpen < Error; end
68
+ class Cancelled < Error; end
69
+
70
+ # Security::Limits (max_response_size / max_header_size). Not in the PRD's
71
+ # enumerated list, but there's nowhere else for this to live.
72
+ class LimitExceeded < Error; end
73
+
74
+ # HostPolicy allow/block list rejections. Kept as RequestError rather than
75
+ # a new subclass since "this request isn't allowed to be sent" is exactly
76
+ # what RequestError already means.
77
+ class HostNotAllowed < RequestError; end
78
+ end
@@ -0,0 +1,106 @@
1
+ # A case-insensitive, order-preserving, multi-value header map. Header
2
+ # names are stored lower-cased internally (the shape both HTTP/2 and
3
+ # Protocol::HTTP expect on the wire) but multiple values for the same name
4
+ # (e.g. repeated Set-Cookie lines) are kept as separate pairs rather than
5
+ # collapsed, since #[]= collapsing them would silently lose data on
6
+ # responses.
7
+ class Butler::Headers
8
+ include Enumerable
9
+
10
+ def initialize(pairs = nil)
11
+ @pairs = []
12
+ pairs&.each { |k, v| add(k, v) }
13
+ end
14
+
15
+ def self.from_a(pairs)
16
+ new(pairs)
17
+ end
18
+
19
+ # Accepts a Butler::Headers as-is, or builds one from a Hash/Array of pairs.
20
+ def self.coerce(headers)
21
+ return headers if headers.is_a?(Butler::Headers)
22
+
23
+ new(headers)
24
+ end
25
+
26
+ # First value for +key+ (the common case — most headers only ever appear
27
+ # once). Use #values_at to read every value of a repeated header.
28
+ def [](key)
29
+ dk = downcase(key)
30
+ pair = @pairs.find { |k, _| k == dk }
31
+ pair && pair[1]
32
+ end
33
+
34
+ # Replaces every existing value for +key+ with a single new one.
35
+ def []=(key, value)
36
+ delete(key)
37
+ add(key, value)
38
+ end
39
+
40
+ # Appends an additional value for +key+ without removing existing ones.
41
+ def add(key, value)
42
+ @pairs << [downcase(key), value.to_s]
43
+ value
44
+ end
45
+
46
+ def key?(key)
47
+ dk = downcase(key)
48
+ @pairs.any? { |k, _| k == dk }
49
+ end
50
+
51
+ def delete(key)
52
+ dk = downcase(key)
53
+ removed = @pairs.select { |k, _| k == dk }.map { |_, v| v }
54
+ @pairs.reject! { |k, _| k == dk }
55
+ removed.first
56
+ end
57
+
58
+ def values_at(key)
59
+ dk = downcase(key)
60
+ @pairs.select { |k, _| k == dk }.map { |_, v| v }
61
+ end
62
+
63
+ def each
64
+ return enum_for(:each) unless block_given?
65
+
66
+ @pairs.each { |k, v| yield k, v }
67
+ end
68
+
69
+ # Array of [lower-cased-name, value] pairs — exactly the shape
70
+ # Protocol::HTTP::Methods#get/post/... expects for its headers argument.
71
+ def to_a
72
+ @pairs.map(&:dup)
73
+ end
74
+
75
+ def to_h
76
+ @pairs.each_with_object({}) { |(k, v), h| h[k] = v }
77
+ end
78
+
79
+ # +other+'s keys replace this Headers' values for the same key (so
80
+ # per-request headers override client-level defaults, rather than
81
+ # duplicating them).
82
+ def merge(other)
83
+ merged = Butler::Headers.new(@pairs)
84
+ Butler::Headers.coerce(other).each { |k, v| merged[k] = v }
85
+ merged
86
+ end
87
+
88
+ def empty?
89
+ @pairs.empty?
90
+ end
91
+
92
+ def size
93
+ @pairs.size
94
+ end
95
+
96
+ def initialize_copy(source)
97
+ super
98
+ @pairs = @pairs.map(&:dup)
99
+ end
100
+
101
+ private
102
+
103
+ def downcase(key)
104
+ key.to_s.downcase
105
+ end
106
+ end
@@ -0,0 +1,18 @@
1
+ class Butler
2
+ module Pipeline
3
+ # Builds the nested-proc chain out of an ordered list of middlewares
4
+ # plus a terminal step (ConnectionPool#acquire + Transport#call). Each
5
+ # middleware wraps the next, exactly like Rack.
6
+ class Chain
7
+ def initialize(middlewares, terminal:)
8
+ @app = middlewares.reverse.reduce(terminal) do |next_middleware, middleware|
9
+ ->(context) { middleware.call(context, next_middleware) }
10
+ end
11
+ end
12
+
13
+ def call(context)
14
+ @app.call(context)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ class Butler
2
+ module Pipeline
3
+ # Carries everything one request attempt through the middleware chain
4
+ # needs. A fresh Context is built for every redirect hop (a new
5
+ # Butler::Request against the new URI), but shares the same Deadline
6
+ # across every hop and every retry attempt within a hop.
7
+ class Context
8
+ attr_accessor :client, :request, :response, :config, :deadline, :attempt, :metadata
9
+
10
+ def initialize(client:, request:, config:, deadline:)
11
+ @client = client
12
+ @request = request
13
+ @config = config
14
+ @deadline = deadline
15
+ @response = nil
16
+ @attempt = 0
17
+ @metadata = {}
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ class Butler
2
+ module Pipeline
3
+ # Documents the interface a pipeline middleware implements — not
4
+ # enforced through inheritance, just the shape every built-in
5
+ # middleware (and anything passed to Client#use) follows:
6
+ #
7
+ # def call(context, next_middleware)
8
+ # # ...inspect/modify context.request...
9
+ # response = next_middleware.call(context)
10
+ # # ...inspect/modify response, or handle an exception...
11
+ # response
12
+ # end
13
+ module Middleware
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ class Butler
2
+ module Pipeline
3
+ module Middlewares
4
+ class CircuitBreakerMiddleware
5
+ def initialize(circuit_breaker)
6
+ @circuit_breaker = circuit_breaker
7
+ end
8
+
9
+ def call(context, next_middleware)
10
+ return next_middleware.call(context) unless context.config.circuit_breaker.enabled
11
+
12
+ scope_key = @circuit_breaker.scope_key_for(context.request, context.config)
13
+ @circuit_breaker.call(
14
+ scope_key, context.config.circuit_breaker,
15
+ failure: ->(response) { response.server_error? }
16
+ ) { next_middleware.call(context) }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,67 @@
1
+ class Butler
2
+ module Pipeline
3
+ module Middlewares
4
+ # The only middleware with a loop. Two independent retry paths — a
5
+ # raised transport-level exception, or a response whose status is
6
+ # retryable — each consulting Resilience::RetryPolicy for whether to
7
+ # retry and how long to wait first (see that class for the exact
8
+ # method/status/idempotency/deadline rules).
9
+ class RetryMiddleware
10
+ def initialize(retry_policy)
11
+ @retry_policy = retry_policy
12
+ end
13
+
14
+ def call(context, next_middleware)
15
+ attempt = 0
16
+
17
+ loop do
18
+ attempt += 1
19
+ context.attempt = attempt
20
+
21
+ begin
22
+ response = next_middleware.call(context)
23
+ rescue Butler::Errors::TransportError => e
24
+ retry_options = context.config.retry
25
+ if @retry_policy.retry_on_exception?(exception: e, attempt: attempt, deadline: context.deadline, retry_options: retry_options)
26
+ wait_before_retry(@retry_policy.delay_for(attempt: attempt, retry_options: retry_options, deadline: context.deadline))
27
+ next
28
+ end
29
+ raise wrap_exhausted(e, attempt, context.deadline)
30
+ end
31
+
32
+ retry_options = context.config.retry
33
+ if @retry_policy.retry_on_response?(request: context.request, response: response, attempt: attempt, deadline: context.deadline, retry_options: retry_options)
34
+ wait_before_retry(@retry_policy.delay_for(response: response, attempt: attempt, retry_options: retry_options, deadline: context.deadline))
35
+ next
36
+ end
37
+
38
+ return response
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def wrap_exhausted(error, attempt, deadline)
45
+ return error if attempt <= 1
46
+
47
+ if deadline.expired?
48
+ Butler::Errors::TimeoutError.new("deadline exceeded after #{attempt} attempt(s): #{error.message}")
49
+ else
50
+ Butler::Errors::RetryExhausted.new("giving up after #{attempt} attempt(s): #{error.message}", attempts: attempt)
51
+ end
52
+ end
53
+
54
+ # Plain Kernel#sleep, not Async::Task#sleep (deprecated as of async
55
+ # 2.x): the `async` gem registers itself as a Fiber scheduler, so
56
+ # Kernel#sleep already yields to sibling fibers/tasks rather than
57
+ # blocking the thread whenever one is active — no need to reach for
58
+ # the task-specific API.
59
+ def wait_before_retry(seconds)
60
+ return if seconds <= 0
61
+
62
+ sleep(seconds)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,14 @@
1
+ class Butler
2
+ module Pipeline
3
+ module Middlewares
4
+ # Runs first, before any socket is touched: rejects the request
5
+ # outright if its host isn't allowed (see Butler::Security::HostPolicy).
6
+ class SecurityMiddleware
7
+ def call(context, next_middleware)
8
+ Butler::Security::HostPolicy.check!(context.request.uri, context.config)
9
+ next_middleware.call(context)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,37 @@
1
+ class Butler
2
+ module Pipeline
3
+ module Middlewares
4
+ # Wraps the rest of the chain in Butler::Telemetry::Instrumentation
5
+ # (standalone pub/sub + optional ActiveSupport::Notifications bridge)
6
+ # and, when OpenTelemetry is loaded, a real tracing span with correct
7
+ # timing around the actual request execution. Its payload is built
8
+ # from an explicit allow-list of fields — it structurally never reads
9
+ # request/response bodies or Authorization/Cookie/Set-Cookie headers.
10
+ class TelemetryMiddleware
11
+ def call(context, next_middleware)
12
+ return next_middleware.call(context) unless context.config.telemetry.enabled
13
+
14
+ request = context.request
15
+ payload = { method: request.method, host: request.uri.host, port: request.uri.port, path: request.uri.uri.path }
16
+
17
+ Butler::Telemetry::Instrumentation.instrument(:request, payload) do
18
+ in_span(context.config, payload) do
19
+ response = next_middleware.call(context)
20
+ payload[:status] = response.status
21
+ payload[:attempt] = context.attempt
22
+ response
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def in_span(config, payload, &block)
30
+ return block.call unless Butler::Telemetry::OpenTelemetryBridge.available?(config)
31
+
32
+ Butler::Telemetry::OpenTelemetryBridge.in_span(payload, &block)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ class Butler
2
+ module Pipeline
3
+ module Middlewares
4
+ # The total-budget checkpoint: raises before even attempting the next
5
+ # step once context.deadline has expired. Per-attempt connect/read/
6
+ # write timeouts are separate — those are handed to Async::HTTP as
7
+ # raw seconds and enforced by Butler::Resilience::Timeout inside
8
+ # Transport::Async.
9
+ class TimeoutMiddleware
10
+ def call(context, next_middleware)
11
+ context.deadline.exceeded!
12
+ next_middleware.call(context)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,69 @@
1
+ class Butler
2
+ module Quic
3
+ module Crypto
4
+ # QUIC packet protection (RFC 9001 section 5.3): AEAD-encrypts a
5
+ # packet's payload with the unprotected header as associated data.
6
+ # The AEAD itself is OpenSSL's (AES-GCM / ChaCha20-Poly1305); this
7
+ # module only builds the nonce the spec describes and wires OpenSSL's
8
+ # streaming cipher API to it.
9
+ module AEAD
10
+ module_function
11
+
12
+ CIPHERS = {
13
+ aes_128_gcm: "aes-128-gcm",
14
+ aes_256_gcm: "aes-256-gcm",
15
+ chacha20_poly1305: "chacha20-poly1305",
16
+ }.freeze
17
+
18
+ TAG_LENGTH = 16
19
+
20
+ def seal(cipher_suite, key, nonce, plaintext, aad)
21
+ cipher = ::OpenSSL::Cipher.new(CIPHERS.fetch(cipher_suite))
22
+ cipher.encrypt
23
+ cipher.key = key
24
+ cipher.iv = nonce
25
+ cipher.auth_data = aad
26
+ ciphertext = cipher.update(plaintext) + cipher.final
27
+ ciphertext + cipher.auth_tag
28
+ end
29
+
30
+ # Raises Butler::Errors::TLSError (rather than letting the OpenSSL
31
+ # error leak) if the packet fails to authenticate — a forged or
32
+ # corrupted packet, or protection keys that don't actually match
33
+ # this packet. Also raised (not a raw ArgumentError/TypeError) if
34
+ # +ciphertext_and_tag+ is too short to even contain a tag — this is
35
+ # a public entry point that untrusted-length network data can reach
36
+ # directly, not just through Packet.unprotect's own length check.
37
+ def open(cipher_suite, key, nonce, ciphertext_and_tag, aad)
38
+ if ciphertext_and_tag.bytesize < TAG_LENGTH
39
+ raise Butler::Errors::TLSError, "QUIC packet too short (#{ciphertext_and_tag.bytesize} bytes) to contain an AEAD tag"
40
+ end
41
+
42
+ tag = ciphertext_and_tag.byteslice(-TAG_LENGTH, TAG_LENGTH)
43
+ ciphertext = ciphertext_and_tag.byteslice(0, ciphertext_and_tag.bytesize - TAG_LENGTH)
44
+
45
+ cipher = ::OpenSSL::Cipher.new(CIPHERS.fetch(cipher_suite))
46
+ cipher.decrypt
47
+ cipher.key = key
48
+ cipher.iv = nonce
49
+ cipher.auth_tag = tag
50
+ cipher.auth_data = aad
51
+ cipher.update(ciphertext) + cipher.final
52
+ rescue ::OpenSSL::Cipher::CipherError => e
53
+ raise Butler::Errors::TLSError, "QUIC packet failed to authenticate: #{e.message}"
54
+ end
55
+
56
+ # RFC 9001 section 5.3: the packet-protection nonce is the packet
57
+ # number, left-padded with zeros to the IV's length, XORed with the
58
+ # IV — built byte-by-byte rather than via Array#pack("Q>") since a
59
+ # QUIC packet number can in principle need up to 8 bytes and this
60
+ # keeps the padding/truncation behavior explicit either way.
61
+ def nonce_for(iv, packet_number)
62
+ pn_bytes = Array.new(iv.bytesize, 0)
63
+ iv.bytesize.times { |i| pn_bytes[iv.bytesize - 1 - i] = (packet_number >> (8 * i)) & 0xff }
64
+ iv.bytes.zip(pn_bytes).map { |a, b| a ^ b }.pack("C*")
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,51 @@
1
+ class Butler
2
+ module Quic
3
+ module Crypto
4
+ # QUIC header protection (RFC 9001 section 5.4): a 5-byte mask,
5
+ # derived from a 16-byte sample of the packet's own ciphertext, that
6
+ # XORs out the packet number's length bits and the packet number
7
+ # itself so on-path observers can't trivially correlate packet
8
+ # numbers across a connection. This is deliberately *not* an AEAD —
9
+ # it has no authentication of its own, which is why it's only ever
10
+ # applied on top of (never instead of) AEAD packet protection.
11
+ module HeaderProtection
12
+ module_function
13
+
14
+ SAMPLE_LENGTH = 16
15
+ MASK_LENGTH = 5
16
+
17
+ # RFC 9001 5.4.3 (AES-based suites) / 5.4.4 (ChaCha20-based suites).
18
+ def mask_for(cipher_suite, hp_key, sample)
19
+ if cipher_suite == :chacha20_poly1305
20
+ chacha20_mask(hp_key, sample)
21
+ else
22
+ aes_ecb_mask(hp_key, sample)
23
+ end
24
+ end
25
+
26
+ # RFC 9001 5.4.3: the mask is the sample AES-ECB-encrypted under the
27
+ # header-protection key — no IV, since ECB is only ever used here
28
+ # for this one fixed-size block, not as a general-purpose cipher.
29
+ def aes_ecb_mask(hp_key, sample)
30
+ cipher = ::OpenSSL::Cipher.new(hp_key.bytesize == 32 ? "aes-256-ecb" : "aes-128-ecb")
31
+ cipher.encrypt
32
+ cipher.key = hp_key
33
+ cipher.padding = 0
34
+ (cipher.update(sample) + cipher.final).byteslice(0, MASK_LENGTH)
35
+ end
36
+
37
+ # RFC 9001 5.4.4: the sample itself doubles as ChaCha20's 16-byte
38
+ # IV (a 4-byte little-endian counter followed by a 12-byte nonce —
39
+ # exactly the layout OpenSSL's raw "chacha20" cipher expects), used
40
+ # to encrypt 5 zero bytes.
41
+ def chacha20_mask(hp_key, sample)
42
+ cipher = ::OpenSSL::Cipher.new("chacha20")
43
+ cipher.encrypt
44
+ cipher.key = hp_key
45
+ cipher.iv = sample
46
+ cipher.update("\x00".b * MASK_LENGTH) + cipher.final
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,44 @@
1
+ class Butler
2
+ module Quic
3
+ module Crypto
4
+ # RFC 5869 HKDF, plus TLS 1.3's HKDF-Expand-Label construction (RFC
5
+ # 8446 section 7.1) that QUIC-TLS layers all of its key derivation on
6
+ # top of (RFC 9001 section 5). All actual cryptographic work (HMAC) is
7
+ # OpenSSL's — this module only assembles it into the shape the specs
8
+ # describe; it never implements a hash or cipher primitive itself.
9
+ module HKDF
10
+ module_function
11
+
12
+ def extract(salt, ikm, hash: "SHA256")
13
+ ::OpenSSL::HMAC.digest(hash, salt, ikm)
14
+ end
15
+
16
+ def expand(prk, info, length, hash: "SHA256")
17
+ hash_len = ::OpenSSL::Digest.new(hash).digest_length
18
+ blocks = (length.to_f / hash_len).ceil
19
+ raise ArgumentError, "length too large for HKDF-Expand" if blocks > 255
20
+
21
+ t = +""
22
+ okm = +""
23
+ (1..blocks).each do |i|
24
+ t = ::OpenSSL::HMAC.digest(hash, prk, t + info + [i].pack("C"))
25
+ okm << t
26
+ end
27
+ okm.byteslice(0, length)
28
+ end
29
+
30
+ # RFC 8446 section 7.1: HKDF-Expand-Label(Secret, Label, Context,
31
+ # Length). QUIC-TLS always uses the "tls13 " label prefix (RFC 9001
32
+ # section 5.1) and, for every secret Butler derives, an empty
33
+ # Context.
34
+ def expand_label(secret, label, length, context: "", hash: "SHA256")
35
+ full_label = "tls13 #{label}"
36
+ hkdf_label = [length].pack("n") <<
37
+ [full_label.bytesize].pack("C") << full_label.b <<
38
+ [context.bytesize].pack("C") << context.b
39
+ expand(secret, hkdf_label, length, hash: hash)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ class Butler
2
+ module Quic
3
+ module Crypto
4
+ # QUIC-TLS key derivation for the Initial packet number space (RFC
5
+ # 9001 section 5.2). Initial secrets are derived from a fixed,
6
+ # version-specific public salt (not a real negotiated secret), so
7
+ # every QUIC v1 connection's Initial keys are public and the same
8
+ # derivation applies to every connection — which is exactly what lets
9
+ # RFC 9001 Appendix A publish literal test vectors for them.
10
+ #
11
+ # Handshake/1-RTT secrets (derived from the real TLS 1.3 handshake
12
+ # instead) are Stage 4+ work, not this module's.
13
+ module KeySchedule
14
+ module_function
15
+
16
+ # RFC 9001 section 5.2's QUIC v1 Initial salt.
17
+ INITIAL_SALT_V1 = ["38762cf7f55934b34d179ae6a4c80cadccbb7f0a"].pack("H*").freeze
18
+
19
+ KEY_LENGTHS = {
20
+ aes_128_gcm: 16,
21
+ aes_256_gcm: 32,
22
+ chacha20_poly1305: 32,
23
+ }.freeze
24
+
25
+ # Returns { client: client_initial_secret, server: server_initial_secret }
26
+ # for the given Destination Connection ID (the DCID the client chose
27
+ # for its first Initial packet).
28
+ def initial_secrets(destination_connection_id)
29
+ initial_secret = HKDF.extract(INITIAL_SALT_V1, destination_connection_id)
30
+ {
31
+ client: HKDF.expand_label(initial_secret, "client in", 32),
32
+ server: HKDF.expand_label(initial_secret, "server in", 32),
33
+ }
34
+ end
35
+
36
+ # RFC 9001 section 5.1: derives the AEAD key/iv and the
37
+ # header-protection key from a traffic secret, sized for
38
+ # +cipher_suite+.
39
+ def packet_protection_keys(secret, cipher_suite)
40
+ key_length = KEY_LENGTHS.fetch(cipher_suite)
41
+ {
42
+ key: HKDF.expand_label(secret, "quic key", key_length),
43
+ iv: HKDF.expand_label(secret, "quic iv", 12),
44
+ hp: HKDF.expand_label(secret, "quic hp", key_length),
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end