digest_fields 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +75 -7
- data/lib/digest_fields/algorithms.rb +27 -0
- data/lib/digest_fields/digestion.rb +3 -18
- data/lib/digest_fields/rack.rb +97 -0
- data/lib/digest_fields/version.rb +1 -1
- data/lib/digest_fields.rb +6 -3
- data.tar.gz.sig +0 -0
- metadata +3 -1
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 724411f2f670ec2fae3f43d2d84cbfbab3ff9fce8625e2acef870a9c7fc94ec5
|
|
4
|
+
data.tar.gz: ceb6a28e84a21e6014dfa64cda9d12736783a5ad6cc33b5c65fa009c9db4e8dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9c6c4624221fe9a21dc9d1d28463e3218c0ecfbb7b5c81d777858a29e28397097c02362300c705079d382a9f45c6a256e6db23f622c5dab536b97b09e659175
|
|
7
|
+
data.tar.gz: b84c7ecdc1888dca1ff176bc235b5edaa69145cf5fc029146e26f58b0e67de76aaae8fc260e185521670e58da54ce818b82bf75d19f5ee5bf4d80781866371de
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/README.md
CHANGED
|
@@ -4,19 +4,87 @@
|
|
|
4
4
|
|
|
5
5
|
Support for `Content-Digest` and `Repl-Digest` header digests that follows the [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) specification.
|
|
6
6
|
|
|
7
|
-
> [!WARNING]
|
|
8
|
-
> This library currently only offers low-level primitives, and does not interact with Rack, does not set headers or trailers, or decide what consistitues content to digest. This is currently an exercise left to the user.
|
|
9
|
-
|
|
10
7
|
## Usage
|
|
11
8
|
|
|
9
|
+
### Rack middleware
|
|
10
|
+
|
|
11
|
+
`Rack::DigestFields` automatically computes and injects `Unencoded-Digest` response headers.
|
|
12
|
+
|
|
13
|
+
#### Why `Unencoded-Digest` and not `Content-Digest` or `Repr-Digest`?
|
|
14
|
+
|
|
15
|
+
[RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) defines two similar headers:
|
|
16
|
+
|
|
17
|
+
- **`Content-Digest`** — digest of the bytes as transferred on the wire, after any content-encoding (e.g. gzip) is applied.
|
|
18
|
+
- **`Repr-Digest`** — digest of the full selected representation, which also varies with content-encoding.
|
|
19
|
+
|
|
20
|
+
Both require the middleware to know the final encoded bytes. In a typical deployment, content-encoding is applied *downstream* of the Ruby process — by nginx, a CDN, or `Rack::Deflater` positioned later in the stack. The app never sees those bytes, so it cannot correctly compute either header.
|
|
21
|
+
|
|
22
|
+
`Unencoded-Digest` is the digest of the body *before* any content-encoding. A Rack middleware always has these bytes, so it can produce the header reliably regardless of what happens downstream.
|
|
23
|
+
|
|
24
|
+
> [!NOTE]
|
|
25
|
+
> `Unencoded-Digest` is defined in an IETF draft ([draft-ietf-httpbis-unencoded-digest](https://datatracker.ietf.org/doc/draft-ietf-httpbis-unencoded-digest/)) that has not yet been standardised. Breaking changes to the header name, wire format, or semantics are unlikely — current feedback on the draft focuses on security considerations rather than the core design — but it is possible before the RFC is finalised.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config.ru
|
|
29
|
+
require "digest_fields/rack"
|
|
30
|
+
|
|
31
|
+
use Rack::DigestFields,
|
|
32
|
+
on_partial_content: :raise, # required: :raise | :warn | :skip
|
|
33
|
+
unencoded_digest: true # uses default algorithms (sha-256, sha-512)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
With algorithm override:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
use Rack::DigestFields,
|
|
40
|
+
on_partial_content: :skip,
|
|
41
|
+
unencoded_digest: {
|
|
42
|
+
algorithms: %w[sha-256],
|
|
43
|
+
on_partial_content: :warn # overrides global for this header
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
In Rails, insert before `Rack::Sendfile` to ensure the middleware sees the raw response body before any transformation:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
config.middleware.insert_before Rack::Sendfile, Rack::DigestFields,
|
|
51
|
+
on_partial_content: :raise,
|
|
52
|
+
unencoded_digest: true
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`on_partial_content` is required with no default — `206 Partial Content` responses cannot correctly produce `Unencoded-Digest` (it requires the full unencoded representation). The three behaviours are:
|
|
56
|
+
|
|
57
|
+
| Value | Behaviour |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `:raise` | Raise `Rack::DigestFields::PartialContentError` |
|
|
60
|
+
| `:warn` | Emit a warning and omit the header |
|
|
61
|
+
| `:skip` | Omit the header silently |
|
|
62
|
+
|
|
12
63
|
### Library
|
|
13
64
|
|
|
14
65
|
```ruby
|
|
15
|
-
|
|
16
|
-
|
|
66
|
+
DigestFields.digest(body)
|
|
67
|
+
# => "sha-256=:X48E9qOok...:, sha-512=:jas48SD...:"
|
|
68
|
+
|
|
69
|
+
DigestFields.digest(body, algorithms: "sha-256")
|
|
70
|
+
# => "sha-256=:X48E9qOok...:
|
|
71
|
+
|
|
72
|
+
DigestFields.digest(body, algorithms: %w[sha-256 sha-512])
|
|
73
|
+
# => "sha-256=:X48E9qOok...:, sha-512=:jas48SD...:"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Custom Algorithms
|
|
77
|
+
|
|
78
|
+
`sha-512` and `sha-256` are supported.
|
|
79
|
+
|
|
80
|
+
The spec's [deprecated hash algorithms](https://www.iana.org/assignments/http-digest-hash-alg/http-digest-hash-alg.xhtml) are intentionally not supported, but you can add your own support if you need to:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Register a custom algorithm
|
|
84
|
+
DigestFields.algorithms.add("md5", ->(body) { Digest::MD5.base64digest(body) })
|
|
17
85
|
|
|
18
|
-
|
|
19
|
-
|
|
86
|
+
DigestFields.digest(body, algorithms: %w[md5 sha-512])
|
|
87
|
+
# => "md5=:...:, sha-512=:...:, "
|
|
20
88
|
```
|
|
21
89
|
|
|
22
90
|
## Installation
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module DigestFields
|
|
2
|
+
class Algorithms
|
|
3
|
+
def initialize
|
|
4
|
+
reset!
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def add(key, callable)
|
|
8
|
+
@registry[key] = callable
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def reset!
|
|
12
|
+
@registry = {}
|
|
13
|
+
add("sha-256", ->(body) { Digest::SHA256.base64digest(body) })
|
|
14
|
+
add("sha-512", ->(body) { Digest::SHA512.base64digest(body) })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def keys
|
|
18
|
+
@registry.keys
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(key)
|
|
22
|
+
@registry.fetch(key) do
|
|
23
|
+
raise ArgumentError, "#{key.inspect} not available (try one of: #{@registry.keys.join(", ")})"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
module DigestFields::Digestion
|
|
2
|
-
ALGORITHMS = {
|
|
3
|
-
sha256: ->(body) { Digest::SHA256.base64digest(body) },
|
|
4
|
-
sha512: ->(body) { Digest::SHA512.base64digest(body) }
|
|
5
|
-
}
|
|
6
|
-
|
|
7
2
|
module_function
|
|
8
3
|
|
|
9
|
-
def compute(body, algorithms:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
algorithms.map do |algorithm|
|
|
13
|
-
key = algorithm.to_s.sub(/(\d+)$/, '-\1')
|
|
14
|
-
|
|
15
|
-
algorithm = ALGORITHMS.fetch(algorithm) do
|
|
16
|
-
raise ArgumentError, "#{algorithm.inspect} not available (try one of: #{ALGORITHMS.keys.join(", ")})"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
digest = algorithm.call(body)
|
|
20
|
-
|
|
21
|
-
"#{key}=:#{digest}:"
|
|
4
|
+
def compute(body, registry:, algorithms:)
|
|
5
|
+
[algorithms].flatten.map do |key|
|
|
6
|
+
"#{key}=:#{registry.fetch(key).call(body)}:"
|
|
22
7
|
end.join(", ")
|
|
23
8
|
end
|
|
24
9
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "rack"
|
|
2
|
+
require "digest_fields"
|
|
3
|
+
|
|
4
|
+
module Rack
|
|
5
|
+
class DigestFields
|
|
6
|
+
class PartialContentError < StandardError; end
|
|
7
|
+
|
|
8
|
+
VALID_ON_PARTIAL_CONTENT = %i[skip warn raise].freeze
|
|
9
|
+
RECOGNISED_HEADER_KEYS = %i[unencoded_digest].freeze
|
|
10
|
+
KNOWN_OPTION_KEYS = (RECOGNISED_HEADER_KEYS + [:on_partial_content]).freeze
|
|
11
|
+
|
|
12
|
+
def initialize(app, **options)
|
|
13
|
+
@app = app
|
|
14
|
+
validate_options!(options)
|
|
15
|
+
@on_partial_content = options.fetch(:on_partial_content)
|
|
16
|
+
@unencoded_digest = parse_unencoded_digest(options[:unencoded_digest])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
status, headers, body = @app.call(env)
|
|
21
|
+
|
|
22
|
+
if status == 206
|
|
23
|
+
mode = @unencoded_digest[:on_partial_content] || @on_partial_content
|
|
24
|
+
case mode
|
|
25
|
+
when :skip
|
|
26
|
+
return [status, headers, body]
|
|
27
|
+
when :warn
|
|
28
|
+
Kernel.warn("Rack::DigestFields: Unencoded-Digest cannot be computed for 206 Partial Content")
|
|
29
|
+
return [status, headers, body]
|
|
30
|
+
when :raise
|
|
31
|
+
raise PartialContentError, "Rack::DigestFields: Unencoded-Digest cannot be computed for 206 Partial Content"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
chunks = []
|
|
36
|
+
body.each { |chunk| chunks << chunk }
|
|
37
|
+
body.close if body.respond_to?(:close)
|
|
38
|
+
buffered = chunks.join
|
|
39
|
+
|
|
40
|
+
digest = ::DigestFields.digest(buffered, algorithms: @unencoded_digest[:algorithms])
|
|
41
|
+
headers = headers.merge("Unencoded-Digest" => digest)
|
|
42
|
+
|
|
43
|
+
[status, headers, [buffered]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_options!(options)
|
|
49
|
+
unknown = options.keys - KNOWN_OPTION_KEYS
|
|
50
|
+
raise ArgumentError, "Unrecognised options: #{unknown.map(&:inspect).join(", ")}" if unknown.any?
|
|
51
|
+
|
|
52
|
+
unless (options.keys & RECOGNISED_HEADER_KEYS).any?
|
|
53
|
+
raise ArgumentError, "No digest headers configured. Provide at least one of: #{RECOGNISED_HEADER_KEYS.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless options.key?(:on_partial_content)
|
|
57
|
+
raise ArgumentError, "on_partial_content is required (choose: :skip, :warn, or :raise)"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
validate_on_partial_content!(options[:on_partial_content], context: "global")
|
|
61
|
+
|
|
62
|
+
if options.key?(:unencoded_digest)
|
|
63
|
+
config = options[:unencoded_digest]
|
|
64
|
+
unless config == true || config.is_a?(Hash)
|
|
65
|
+
raise ArgumentError, "unencoded_digest must be true or a Hash, got #{config.inspect}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return unless options[:unencoded_digest].is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
if options[:unencoded_digest].key?(:on_partial_content)
|
|
72
|
+
validate_on_partial_content!(options[:unencoded_digest][:on_partial_content], context: "unencoded_digest")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if options[:unencoded_digest][:algorithms] == []
|
|
76
|
+
raise ArgumentError, "algorithms cannot be empty"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_on_partial_content!(value, context:)
|
|
81
|
+
return if VALID_ON_PARTIAL_CONTENT.include?(value)
|
|
82
|
+
raise ArgumentError, "Invalid on_partial_content #{value.inspect} for #{context} (choose: :skip, :warn, or :raise)"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_unencoded_digest(config)
|
|
86
|
+
# Algorithms are snapshotted from the registry at middleware init time.
|
|
87
|
+
# Changes to DigestFields.algorithms after initialization are not reflected.
|
|
88
|
+
base_algorithms = ::DigestFields.algorithms.keys
|
|
89
|
+
return {algorithms: base_algorithms} if config == true || config.nil? || config == {}
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
algorithms: config[:algorithms] || base_algorithms,
|
|
93
|
+
on_partial_content: config[:on_partial_content]
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/digest_fields.rb
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
|
|
3
3
|
module DigestFields
|
|
4
|
-
|
|
4
|
+
def self.algorithms
|
|
5
|
+
@algorithms ||= Algorithms.new
|
|
6
|
+
end
|
|
5
7
|
|
|
6
|
-
def digest(
|
|
7
|
-
Digestion.compute(
|
|
8
|
+
def self.digest(body, algorithms: self.algorithms.keys)
|
|
9
|
+
Digestion.compute(body, registry: self.algorithms, algorithms: algorithms)
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
require_relative "digest_fields/version"
|
|
14
|
+
require_relative "digest_fields/algorithms"
|
|
12
15
|
require_relative "digest_fields/digestion"
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: digest_fields
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pete Nicholls
|
|
@@ -49,7 +49,9 @@ files:
|
|
|
49
49
|
- Rakefile
|
|
50
50
|
- certs/aupajo.pem
|
|
51
51
|
- lib/digest_fields.rb
|
|
52
|
+
- lib/digest_fields/algorithms.rb
|
|
52
53
|
- lib/digest_fields/digestion.rb
|
|
54
|
+
- lib/digest_fields/rack.rb
|
|
53
55
|
- lib/digest_fields/version.rb
|
|
54
56
|
homepage: https://github.com/aupajo/digest_fields
|
|
55
57
|
licenses:
|
metadata.gz.sig
CHANGED
|
Binary file
|