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 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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ssri/constants'
4
+ require_relative 'ssri/errors'
5
+ require_relative 'ssri/hash'
6
+ require_relative 'ssri/integrity'
7
+ require_relative 'ssri/functions'
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: []