ssri 1.0.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 +7 -0
- data/lib/ssri/constants.rb +34 -0
- data/lib/ssri/errors.rb +28 -0
- data/lib/ssri/functions.rb +119 -0
- data/lib/ssri/hash.rb +62 -0
- data/lib/ssri/integrity.rb +82 -0
- data/lib/ssri.rb +7 -0
- metadata +49 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fc077f8e97a9f458076702ce2057aa7dd4403894f6dd2d38f3bf1a4e922ede72
|
|
4
|
+
data.tar.gz: 060dd31549dad3c51dd6f25d4a710e702dfcbd907258fe7bb1ac9587702ac269
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1f17f2f6f2ffd8a5c87eed3ab2ef9e07cbc4e79c885cc21a7ee452bcb5a02d9df387602f42a011430cffccc282d5942d888f387369230c178874b9e4fe1a8fca
|
|
7
|
+
data.tar.gz: dcc7876db22ec5d304136103c0d37d2c7c2d8ffc3fc5da2b624e2a2544fd16ee36df24c7d6066b8940e9eb28306bfb310f635020f80a7f545a6a03e0f4cc335d
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module SSRI
|
|
6
|
+
SPEC_ALGORITHMS = %w[sha512 sha384 sha256].freeze
|
|
7
|
+
DEFAULT_ALGORITHMS = %w[sha512].freeze
|
|
8
|
+
|
|
9
|
+
NODE_HASHES = OpenSSL::Digest.constants
|
|
10
|
+
.map { |c|
|
|
11
|
+
c.to_s.downcase.tr('_',
|
|
12
|
+
'')
|
|
13
|
+
}
|
|
14
|
+
.select { |c|
|
|
15
|
+
begin;
|
|
16
|
+
OpenSSL::Digest.new(c); true; rescue StandardError; false;
|
|
17
|
+
end
|
|
18
|
+
}
|
|
19
|
+
.uniq.freeze
|
|
20
|
+
|
|
21
|
+
BASE64_REGEX = /\A[a-z0-9+\/]+={0,2}\z/i
|
|
22
|
+
SRI_REGEX = /\A([a-z0-9]+)-([^?]+)(\?[?\S]*)?\z/
|
|
23
|
+
STRICT_SRI_REGEX = /\A([a-z0-9]+)-([A-Za-z0-9+\/=]{44,88})(\?[\x21-\x7E]*)?\z/
|
|
24
|
+
VCHAR_REGEX = /\A[\x21-\x7E]+\z/
|
|
25
|
+
|
|
26
|
+
DEFAULT_PRIORITY = %w[
|
|
27
|
+
md5 whirlpool sha1 sha224 sha256 sha384 sha512
|
|
28
|
+
sha3 sha3-256 sha3-384 sha3-512
|
|
29
|
+
].select { |algo| begin; OpenSSL::Digest.new(algo); true; rescue StandardError; false; end }.freeze
|
|
30
|
+
|
|
31
|
+
def self.get_opt_string(options)
|
|
32
|
+
options&.any? ? "?#{options.join('?')}" : ''
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/ssri/errors.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SSRI
|
|
4
|
+
class IntegrityError < StandardError
|
|
5
|
+
attr_reader :code, :found, :expected, :algorithm, :sri
|
|
6
|
+
|
|
7
|
+
def initialize(msg, found: nil, expected: nil, algorithm: nil, sri: nil)
|
|
8
|
+
super(msg)
|
|
9
|
+
@code = 'EINTEGRITY'
|
|
10
|
+
@found = found
|
|
11
|
+
@expected = expected
|
|
12
|
+
@algorithm = algorithm
|
|
13
|
+
@sri = sri
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class SizeMismatchError < StandardError
|
|
18
|
+
attr_reader :code, :found, :expected, :sri
|
|
19
|
+
|
|
20
|
+
def initialize(msg, found: nil, expected: nil, sri: nil)
|
|
21
|
+
super(msg)
|
|
22
|
+
@code = 'EBADSIZE'
|
|
23
|
+
@found = found
|
|
24
|
+
@expected = expected
|
|
25
|
+
@sri = sri
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require_relative 'constants'
|
|
6
|
+
|
|
7
|
+
module SSRI
|
|
8
|
+
def self.parse(sri, opts = {})
|
|
9
|
+
return nil if sri.nil?
|
|
10
|
+
|
|
11
|
+
if sri.is_a?(String)
|
|
12
|
+
_parse(sri, opts)
|
|
13
|
+
elsif sri.respond_to?(:algorithm) && sri.respond_to?(:digest)
|
|
14
|
+
full = Integrity.new
|
|
15
|
+
full[sri.algorithm] = [sri]
|
|
16
|
+
_parse(stringify(full, opts), opts)
|
|
17
|
+
else
|
|
18
|
+
_parse(stringify(sri, opts), opts)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self._parse(integrity, opts = {})
|
|
23
|
+
if opts[:single]
|
|
24
|
+
return Hash.new(integrity, opts)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result = integrity.strip.split(/\s+/).each_with_object(Integrity.new) do |str, acc|
|
|
28
|
+
h = Hash.new(str, opts)
|
|
29
|
+
next if h.algorithm.empty? || h.digest.empty?
|
|
30
|
+
|
|
31
|
+
acc[h.algorithm] ||= []
|
|
32
|
+
acc[h.algorithm] << h
|
|
33
|
+
end
|
|
34
|
+
result.empty? ? nil : result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.stringify(obj, opts = {})
|
|
38
|
+
if obj.respond_to?(:algorithm) && obj.respond_to?(:digest)
|
|
39
|
+
Hash.instance_method(:to_s).bind(obj).call(opts)
|
|
40
|
+
elsif obj.is_a?(String)
|
|
41
|
+
stringify(parse(obj, opts), opts)
|
|
42
|
+
else
|
|
43
|
+
Integrity.instance_method(:to_s).bind(obj).call(opts)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.from_hex(hex_digest, algorithm, opts = {})
|
|
48
|
+
opt_string = get_opt_string(opts[:options])
|
|
49
|
+
b64 = Base64.strict_encode64([hex_digest].pack('H*'))
|
|
50
|
+
parse("#{algorithm}-#{b64}#{opt_string}", opts)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from_data(data, opts = {})
|
|
54
|
+
algorithms = opts[:algorithms] || SSRI::DEFAULT_ALGORITHMS.dup
|
|
55
|
+
opt_string = SSRI.get_opt_string(opts[:options])
|
|
56
|
+
|
|
57
|
+
algorithms.each_with_object(Integrity.new) do |algo, acc|
|
|
58
|
+
digest = Base64.strict_encode64(OpenSSL::Digest.new(algo).digest(data))
|
|
59
|
+
h = Hash.new("#{algo}-#{digest}#{opt_string}", opts)
|
|
60
|
+
next if h.algorithm.empty? || h.digest.empty?
|
|
61
|
+
|
|
62
|
+
acc[h.algorithm] ||= []
|
|
63
|
+
acc[h.algorithm] << h
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.check_data(data, sri, opts = {})
|
|
68
|
+
sri = parse(sri, opts)
|
|
69
|
+
if sri.nil? || sri.empty?
|
|
70
|
+
raise IntegrityError.new('No valid integrity hashes to check against') if opts[:error]
|
|
71
|
+
|
|
72
|
+
return false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
algorithm = sri.pick_algorithm(opts)
|
|
76
|
+
digest = Base64.strict_encode64(OpenSSL::Digest.new(algorithm).digest(data))
|
|
77
|
+
new_sri = begin
|
|
78
|
+
tmp = Integrity.new
|
|
79
|
+
tmp[algorithm] = [Hash.new("#{algorithm}-#{digest}", opts)]
|
|
80
|
+
tmp
|
|
81
|
+
end
|
|
82
|
+
match = new_sri.match(sri, opts)
|
|
83
|
+
|
|
84
|
+
return match if match || !opts[:error]
|
|
85
|
+
|
|
86
|
+
if opts[:size].is_a?(Integer) && data.bytesize != opts[:size]
|
|
87
|
+
raise SizeMismatchError.new(
|
|
88
|
+
"data size mismatch when checking #{sri}.\n Wanted: #{opts[:size]}\n Found: #{data.bytesize}",
|
|
89
|
+
found: data.bytesize, expected: opts[:size], sri: sri
|
|
90
|
+
)
|
|
91
|
+
else
|
|
92
|
+
raise IntegrityError.new(
|
|
93
|
+
"Integrity checksum failed when using #{algorithm}: Wanted #{sri}, but got #{new_sri}.",
|
|
94
|
+
found: new_sri, expected: sri, algorithm: algorithm, sri: sri
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.create(opts = {})
|
|
100
|
+
algorithms = opts[:algorithms] || SSRI::DEFAULT_ALGORITHMS.dup
|
|
101
|
+
opt_string = SSRI.get_opt_string(opts[:options])
|
|
102
|
+
hashes = algorithms.map { |a| [a, OpenSSL::Digest.new(a)] }
|
|
103
|
+
|
|
104
|
+
obj = Object.new
|
|
105
|
+
obj.define_singleton_method(:update) do |chunk|
|
|
106
|
+
hashes.each { |_, h| h.update(chunk) }
|
|
107
|
+
obj
|
|
108
|
+
end
|
|
109
|
+
obj.define_singleton_method(:digest) do
|
|
110
|
+
hashes.each_with_object(Integrity.new) do |(algo, h), acc|
|
|
111
|
+
b64 = Base64.strict_encode64(h.digest)
|
|
112
|
+
hash = SSRI::Hash.new("#{algo}-#{b64}#{opt_string}", opts)
|
|
113
|
+
acc[hash.algorithm] ||= []
|
|
114
|
+
acc[hash.algorithm] << hash
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
obj
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/ssri/hash.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module SSRI
|
|
6
|
+
class Hash
|
|
7
|
+
attr_accessor :source, :digest, :algorithm, :options
|
|
8
|
+
|
|
9
|
+
def initialize(hash, opts = {})
|
|
10
|
+
@source = hash.strip
|
|
11
|
+
@digest = ''
|
|
12
|
+
@algorithm = ''
|
|
13
|
+
@options = []
|
|
14
|
+
|
|
15
|
+
strict = opts[:strict]
|
|
16
|
+
match = @source.match(strict ? STRICT_SRI_REGEX : SRI_REGEX)
|
|
17
|
+
return unless match
|
|
18
|
+
return if strict && !SPEC_ALGORITHMS.include?(match[1])
|
|
19
|
+
return unless NODE_HASHES.include?(match[1])
|
|
20
|
+
|
|
21
|
+
@algorithm = match[1]
|
|
22
|
+
@digest = match[2]
|
|
23
|
+
raw_opts = match[3]
|
|
24
|
+
@options = raw_opts ? raw_opts[1..].split('?') : []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def is_hash?; true; end
|
|
28
|
+
def is_integrity?; false; end
|
|
29
|
+
|
|
30
|
+
def hex_digest
|
|
31
|
+
return nil if @digest.empty?
|
|
32
|
+
|
|
33
|
+
Base64.strict_decode64(@digest).unpack1('H*')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_json(*); to_s; end
|
|
37
|
+
|
|
38
|
+
def match(integrity, opts = {})
|
|
39
|
+
other = SSRI.parse(integrity, opts)
|
|
40
|
+
return false unless other
|
|
41
|
+
|
|
42
|
+
if other.is_integrity?
|
|
43
|
+
algo = other.pick_algorithm(opts, [@algorithm])
|
|
44
|
+
return false unless algo
|
|
45
|
+
|
|
46
|
+
found = other[algo]&.find { |h| h.digest == @digest }
|
|
47
|
+
return found || false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
other.digest == @digest ? other : false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_s(opts = {})
|
|
54
|
+
if opts[:strict]
|
|
55
|
+
return '' unless SPEC_ALGORITHMS.include?(@algorithm) &&
|
|
56
|
+
@digest.match?(BASE64_REGEX) &&
|
|
57
|
+
@options.all? { |o| o.match?(VCHAR_REGEX) }
|
|
58
|
+
end
|
|
59
|
+
"#{@algorithm}-#{@digest}#{SSRI.get_opt_string(@options)}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SSRI
|
|
4
|
+
class Integrity
|
|
5
|
+
def initialize
|
|
6
|
+
@hashes = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def is_hash?; false; end
|
|
10
|
+
def is_integrity?; true; end
|
|
11
|
+
def empty?; @hashes.empty?; end
|
|
12
|
+
def [](algo); @hashes[algo]; end
|
|
13
|
+
def []=(algo, v); @hashes[algo] = v; end
|
|
14
|
+
def keys; @hashes.keys; end
|
|
15
|
+
def to_json(*); to_s; end
|
|
16
|
+
|
|
17
|
+
def to_s(opts = {})
|
|
18
|
+
sep = opts[:sep] || ' '
|
|
19
|
+
sep = sep.gsub(/\S+/, ' ') if opts[:strict]
|
|
20
|
+
parts = []
|
|
21
|
+
list = opts[:strict] ? SPEC_ALGORITHMS.select { |a| @hashes[a] } : @hashes.keys
|
|
22
|
+
list.each do |algo|
|
|
23
|
+
@hashes[algo]&.each do |h|
|
|
24
|
+
s = h.to_s(opts)
|
|
25
|
+
parts << s unless s.empty?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
parts.join(sep)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def concat(integrity, opts = {})
|
|
32
|
+
other = integrity.is_a?(String) ? integrity : SSRI.stringify(integrity, opts)
|
|
33
|
+
SSRI.parse("#{to_s(opts)} #{other}", opts)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def hex_digest
|
|
37
|
+
SSRI.parse(self, single: true).hex_digest
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def merge(integrity, opts = {})
|
|
41
|
+
other = SSRI.parse(integrity, opts)
|
|
42
|
+
other.keys.each do |algo|
|
|
43
|
+
if @hashes[algo]
|
|
44
|
+
unless @hashes[algo].any? { |h| other[algo].any? { |oh| h.digest == oh.digest } }
|
|
45
|
+
raise "hashes do not match, cannot update integrity"
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
@hashes[algo] = other[algo]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def match(integrity, opts = {})
|
|
54
|
+
other = SSRI.parse(integrity, opts)
|
|
55
|
+
return false unless other
|
|
56
|
+
|
|
57
|
+
algo = other.pick_algorithm(opts, @hashes.keys)
|
|
58
|
+
return false unless algo
|
|
59
|
+
|
|
60
|
+
@hashes[algo]&.find do |h|
|
|
61
|
+
other[algo]&.find { |oh| h.digest == oh.digest }
|
|
62
|
+
end || false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pick_algorithm(opts = {}, hashes = nil)
|
|
66
|
+
pick_fn = opts[:pick_algorithm] || method(:prioritized_hash)
|
|
67
|
+
keys = @hashes.keys
|
|
68
|
+
keys = keys.select { |k| hashes.include?(k) } if hashes&.any?
|
|
69
|
+
return nil if keys.empty?
|
|
70
|
+
|
|
71
|
+
keys.reduce { |acc, algo| pick_fn.call(acc, algo) || acc }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def prioritized_hash(algo1, algo2)
|
|
77
|
+
i1 = DEFAULT_PRIORITY.index(algo1.downcase) || -1
|
|
78
|
+
i2 = DEFAULT_PRIORITY.index(algo2.downcase) || -1
|
|
79
|
+
i1 >= i2 ? algo1 : algo2
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/ssri.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ssri
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ssanoop
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-02-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A Ruby port of the Node.js ssri library for parsing, generating and verifying
|
|
14
|
+
Subresource Integrity hashes.
|
|
15
|
+
email: samsanoop@outlook.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- lib/ssri.rb
|
|
21
|
+
- lib/ssri/constants.rb
|
|
22
|
+
- lib/ssri/errors.rb
|
|
23
|
+
- lib/ssri/functions.rb
|
|
24
|
+
- lib/ssri/hash.rb
|
|
25
|
+
- lib/ssri/integrity.rb
|
|
26
|
+
homepage:
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata: {}
|
|
30
|
+
post_install_message:
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 3.3.0
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 3.5.3
|
|
46
|
+
signing_key:
|
|
47
|
+
specification_version: 4
|
|
48
|
+
summary: Standard Subresource Integrity for Ruby
|
|
49
|
+
test_files: []
|